feat: M4 docker deployment - bridge + frontend containers
- packages/bridge/Dockerfile: multi-stage build (node:22-alpine) - frontend/Dockerfile: multi-stage build + nginx:alpine - frontend/nginx.conf: proxy /bridge to bridge service - docker-compose.yml: bridge (3456) + frontend (8080) - .dockerignore: exclude backend/node_modules/dist - Fix vite proxy target to port 3456 - .env with bridge vars (AUTH_MODE=dev)
This commit is contained in:
parent
dbf290e49a
commit
ade88808fb
38 changed files with 3508 additions and 2930 deletions
32
.dockerignore
Normal file
32
.dockerignore
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
*.py[cod]
|
||||
|
||||
# Node modules (re-installed in Docker)
|
||||
**/node_modules
|
||||
|
||||
# Build outputs (re-built in Docker)
|
||||
frontend/dist
|
||||
packages/*/dist
|
||||
*.tsbuildinfo
|
||||
|
||||
# Dev/test artifacts
|
||||
coverage
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Docker-in-Docker
|
||||
docker-compose.yml
|
||||
**/Dockerfile
|
||||
|
||||
# Backend (not needed for bridge/frontend containers)
|
||||
backend/
|
||||
26
.env.example
26
.env.example
|
|
@ -1,9 +1,21 @@
|
|||
# ─── Bridge Configuration ──────────────────────────────
|
||||
BRIDGE_HOST=127.0.0.1
|
||||
BRIDGE_PORT=8787
|
||||
BRIDGE_TOKEN=dev-secret-token
|
||||
AUTH_MODE=dev
|
||||
BRIDGE_ALLOWED_BOARDS=*
|
||||
COMMAND_TIMEOUT_SECONDS=30
|
||||
LOG_TAIL_MAX_LINES=500
|
||||
|
||||
# ─── Hermes CLI ────────────────────────────────────────
|
||||
HERMES_BIN=hermes
|
||||
HERMES_HOME=/home/your-user/.hermes
|
||||
HERMES_KANBAN_BOARD=default
|
||||
KANBAN_UI_AUTH_MODE=dev
|
||||
HERMES_HOME=/home/kisskin/.hermes
|
||||
|
||||
# ─── Telegram (optional, for production) ───────────────
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_ALLOWED_USER_IDS=
|
||||
KANBAN_UI_ALLOWED_ORIGINS=http://localhost:5173
|
||||
KANBAN_UI_COMMAND_TIMEOUT_SECONDS=30
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# ─── Frontend (Vite) ──────────────────────────────────
|
||||
# In dev mode the vite proxy handles /bridge → localhost:8787
|
||||
# Set VITE_BRIDGE_URL only if frontend talks to bridge directly
|
||||
# VITE_BRIDGE_URL=http://localhost:8787/bridge
|
||||
# VITE_AUTH_TOKEN=dev-secret-token
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -1,8 +1,17 @@
|
|||
FROM node:22-alpine AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.11-slim AS backend
|
||||
WORKDIR /app
|
||||
COPY backend/pyproject.toml /app/backend/pyproject.toml
|
||||
COPY backend/app /app/backend/app
|
||||
COPY --from=frontend-build /app/frontend/dist /app/frontend/dist
|
||||
RUN pip install --no-cache-dir -e /app/backend
|
||||
ENV KANBAN_UI_AUTH_MODE=telegram
|
||||
ENV KANBAN_UI_AUTH_MODE=telegram \
|
||||
KANBAN_UI_FRONTEND_DIST_DIR=/app/frontend/dist
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--app-dir", "backend", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ Important settings:
|
|||
- `TELEGRAM_BOT_TOKEN`: bot token used only by backend for `initData` validation.
|
||||
- `TELEGRAM_ALLOWED_USER_IDS`: optional comma-separated Telegram user id allowlist.
|
||||
- `KANBAN_UI_ALLOWED_ORIGINS`: CORS allowlist.
|
||||
- `KANBAN_UI_FRONTEND_DIST_DIR`: optional built frontend directory for single-container serving.
|
||||
- `VITE_API_BASE_URL`: frontend API base URL for production builds.
|
||||
|
||||
## Telegram Mini App setup
|
||||
|
|
@ -126,6 +127,9 @@ npm ci
|
|||
npm run build
|
||||
```
|
||||
|
||||
The included Dockerfile builds the frontend and serves it from the FastAPI container on port `8000`.
|
||||
For same-origin Docker deployment, leave `VITE_API_BASE_URL` empty at build time and expose only the backend container.
|
||||
|
||||
## Security model
|
||||
|
||||
- Backend accepts only fixed API actions mapped to fixed Hermes Kanban CLI subcommands.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
|
@ -13,17 +14,27 @@ class Settings(BaseSettings):
|
|||
default_board: str = Field(default="default", alias="HERMES_KANBAN_BOARD")
|
||||
auth_mode: Literal["telegram", "none", "dev"] = Field(default="dev", alias="KANBAN_UI_AUTH_MODE")
|
||||
telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
|
||||
telegram_allowed_user_ids: list[int] = Field(default_factory=list, alias="TELEGRAM_ALLOWED_USER_IDS")
|
||||
allowed_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"], alias="KANBAN_UI_ALLOWED_ORIGINS")
|
||||
telegram_allowed_user_ids: Annotated[list[int], NoDecode] = Field(default_factory=list, alias="TELEGRAM_ALLOWED_USER_IDS")
|
||||
allowed_origins: Annotated[list[str], NoDecode] = Field(default_factory=lambda: ["http://localhost:5173"], alias="KANBAN_UI_ALLOWED_ORIGINS")
|
||||
command_timeout_seconds: int = Field(default=30, alias="KANBAN_UI_COMMAND_TIMEOUT_SECONDS")
|
||||
frontend_dist_dir: str | None = Field(default=None, alias="KANBAN_UI_FRONTEND_DIST_DIR")
|
||||
|
||||
@field_validator("telegram_allowed_user_ids", mode="before")
|
||||
@classmethod
|
||||
def parse_user_ids(cls, value):
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if value.startswith("["):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if isinstance(value, str):
|
||||
return [int(part.strip()) for part in value.split(",") if part.strip()]
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [int(v) for v in value]
|
||||
return value
|
||||
|
||||
@field_validator("allowed_origins", mode="before")
|
||||
|
|
@ -31,8 +42,17 @@ class Settings(BaseSettings):
|
|||
def parse_origins(cls, value):
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if value.startswith("["):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if isinstance(value, str):
|
||||
return [part.strip() for part in value.split(",") if part.strip()]
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [str(v) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,14 @@ def create_app(settings: Settings | None = None, adapter: KanbanAdapter | None =
|
|||
def dispatch(board: str | None = Query(default=None), user=Depends(require_user)):
|
||||
return safe_call(adapter.dispatch, selected_board(board))
|
||||
|
||||
if settings.frontend_dist_dir:
|
||||
from pathlib import Path
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
frontend_dir = Path(settings.frontend_dist_dir)
|
||||
if frontend_dir.is_dir():
|
||||
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,35 @@ def test_allowed_user_filter_rejects_unauthorized(fake_adapter):
|
|||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_env_list_settings_accept_json_strings():
|
||||
settings = Settings(
|
||||
telegram_allowed_user_ids='[123, "456"]',
|
||||
allowed_origins='["https://miniapp.example", "http://localhost:5173"]',
|
||||
)
|
||||
assert settings.telegram_allowed_user_ids == [123, 456]
|
||||
assert settings.allowed_origins == ["https://miniapp.example", "http://localhost:5173"]
|
||||
|
||||
|
||||
def test_env_list_settings_accept_comma_separated_strings():
|
||||
settings = Settings(
|
||||
telegram_allowed_user_ids="123,456",
|
||||
allowed_origins="https://miniapp.example,http://localhost:5173",
|
||||
)
|
||||
assert settings.telegram_allowed_user_ids == [123, 456]
|
||||
assert settings.allowed_origins == ["https://miniapp.example", "http://localhost:5173"]
|
||||
|
||||
|
||||
def test_static_frontend_can_be_served_from_backend(tmp_path, fake_adapter):
|
||||
(tmp_path / "index.html").write_text("<h1>Kanban Mini App</h1>", encoding="utf-8")
|
||||
settings = Settings(auth_mode="none", frontend_dist_dir=str(tmp_path))
|
||||
app = create_app(settings, fake_adapter)
|
||||
|
||||
response = TestClient(app).get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Kanban Mini App" in response.text
|
||||
|
||||
|
||||
def test_task_list_returns_normalized_tasks_grouped_by_status(client):
|
||||
data = client.get("/api/tasks").json()
|
||||
assert data["groups"]["ready"][0]["id"] == "t_1"
|
||||
|
|
|
|||
|
|
@ -1,22 +1,48 @@
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
# docker-compose.yml — Hermes Kanban MiniApp deployment
|
||||
# Services: bridge (Express API) + frontend (Nginx SPA)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
env_file: .env
|
||||
# ── Bridge API ───────────────────────────────────────────
|
||||
bridge:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/bridge/Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "3456:3456"
|
||||
env_file: .env
|
||||
environment:
|
||||
# Override bind to 0.0.0.0 so container is reachable
|
||||
BRIDGE_HOST: "0.0.0.0"
|
||||
BRIDGE_PORT: "3456"
|
||||
volumes:
|
||||
- ${HERMES_HOME:-/home/your-user/.hermes}:${HERMES_HOME:-/home/your-user/.hermes}:ro
|
||||
# Mount hermes profile directory (read-only)
|
||||
- ${HERMES_HOME:-/home/kisskin/.hermes}:/root/.hermes:ro
|
||||
networks:
|
||||
- kanban
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3456/bridge/v1/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# ── Frontend (Nginx SPA) ─────────────────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
bridge:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- kanban
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: node:22-alpine
|
||||
working_dir: /app/frontend
|
||||
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0"
|
||||
environment:
|
||||
VITE_API_BASE_URL: http://localhost:8000
|
||||
volumes:
|
||||
- .:/app
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
- api
|
||||
networks:
|
||||
kanban:
|
||||
driver: bridge
|
||||
|
|
|
|||
665
docs/architecture-adr.md
Normal file
665
docs/architecture-adr.md
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
# ADR-001: Community architecture for Hermes Kanban Telegram Mini App
|
||||
|
||||
Status: Proposed for implementation
|
||||
Date: 2026-07-10
|
||||
Decision owner: project maintainer
|
||||
Scope: open-source community release, before rewriting the current local FastAPI prototype
|
||||
|
||||
## 1. Context
|
||||
|
||||
The current repository contains an uncommitted prototype with a Vite/React frontend and a Python/FastAPI backend that shells out to `hermes kanban`. The target changed: the public release should be best-practice, secure, easy to install, documented in English and Russian, pleasant as a Telegram Mini App and normal browser/mobile app, and work well on Vercel.
|
||||
|
||||
Hard constraint: a static Vercel frontend cannot safely execute a user's local `hermes` CLI or read local `~/.hermes/kanban.db`. Any architecture that claims otherwise is untrustworthy. A remote browser UI must talk to a trusted server-side component that is already authorized to reach the user's Hermes instance.
|
||||
|
||||
## 2. Verified facts used for this ADR
|
||||
|
||||
### Hermes Agent
|
||||
|
||||
From the official Hermes docs and local checked source at `/home/kisskin/.hermes/hermes-agent`:
|
||||
|
||||
- Hermes API Server exposes Hermes as an OpenAI-compatible HTTP endpoint for external frontends (`POST /v1/chat/completions`, `POST /v1/responses`, `POST /v1/runs`, sessions, jobs, skills/toolsets discovery, health). Source: `/home/kisskin/.hermes/hermes-agent/website/docs/user-guide/features/api-server.md`.
|
||||
- API Server is enabled with env vars including `API_SERVER_ENABLED=true`, `API_SERVER_KEY`, `API_SERVER_HOST`, `API_SERVER_PORT`, and optional `API_SERVER_CORS_ORIGINS`. Default bind host is `127.0.0.1`; bearer auth is required. Source: same file, Configuration and Authentication sections.
|
||||
- Hermes API Server is powerful: it gives access to the agent's toolset including terminal commands, so the key must never be exposed to a browser. Source: same file, Security warning.
|
||||
- Current API Server capabilities do not advertise first-class Kanban CRUD endpoints. The capabilities list includes chat/responses/runs, sessions, jobs, skills, and toolsets; searching `gateway/platforms/api_server.py` found no `/api/kanban` route.
|
||||
- Hermes Kanban is a durable SQLite-backed board. Tasks, comments, links, workspaces, logs, dispatcher behavior, boards, and board isolation are documented in `/home/kisskin/.hermes/hermes-agent/website/docs/user-guide/features/kanban.md`.
|
||||
- Hermes Kanban has two official surfaces today: agents use `kanban_*` tools, while humans/scripts use `hermes kanban ...` CLI, slash command, or the Hermes dashboard. Source: Kanban docs section "Two surfaces".
|
||||
|
||||
### Telegram Mini Apps
|
||||
|
||||
From official Telegram Mini Apps docs and Telegram core docs:
|
||||
|
||||
- Mini App init data is intended to be used as an authentication/authorization factor.
|
||||
- Server validation requires excluding `hash`, sorting key-value pairs, joining them with `\n`, deriving a secret with HMAC-SHA256 over the bot token using `WebAppData`, then HMAC-SHA256 over the data-check string and comparing hex digest to `hash`.
|
||||
- The bot token must remain server-side. A frontend may forward raw init data to its backend, but must not validate with the bot token in the browser.
|
||||
- The official React ecosystem template for Telegram Mini Apps uses React, TypeScript, Vite, `@tma.js/sdk-react` / Telegram Apps SDK packages, React Router HashRouter, and Telegram UI components.
|
||||
|
||||
### Vercel / browser deployment
|
||||
|
||||
- Vercel is a good fit for Vite static frontend hosting and for lightweight Node.js serverless API routes.
|
||||
- Vercel serverless functions can call HTTPS APIs, but they cannot be trusted to execute a user's private local `hermes` CLI or access a local SQLite board unless the user separately exposes a backend/bridge over HTTPS.
|
||||
- Browser-side direct calls to Hermes API Server are unsafe for a community default because they would require exposing `API_SERVER_KEY` to JavaScript. If direct browser mode is documented, it must be explicitly marked local/dev only.
|
||||
|
||||
### Comparable open-source Telegram Mini Apps
|
||||
|
||||
GitHub search verified active public projects/templates:
|
||||
|
||||
- `Telegram-Mini-Apps/reactjs-template`: React, TypeScript, Vite, Telegram Mini Apps SDK, Telegram UI; suitable baseline for project structure.
|
||||
- `telegram-mini-apps-dev/TelegramUI`: React component library inspired by Telegram interface; suitable UI dependency.
|
||||
- Other public Telegram Mini App examples commonly combine React/TypeScript with Next.js or Vite and place secrets in a backend/server component, not in static browser code.
|
||||
|
||||
## 3. Decision
|
||||
|
||||
Use this stack decisively:
|
||||
|
||||
1. Frontend: React + TypeScript + Vite, hosted as static assets on Vercel or any static host.
|
||||
2. Telegram SDK/UI: `@telegram-apps/sdk-react` (or current successor package from Telegram Mini Apps docs) + `@telegram-apps/telegram-ui` for native-feeling Telegram UX.
|
||||
3. API layer for Vercel/community default: TypeScript serverless BFF (Backend-for-Frontend) API routes deployed on Vercel.
|
||||
4. Local integration: optional self-hosted Hermes Kanban Bridge, implemented in TypeScript/Node.js, running on the same trusted machine as Hermes.
|
||||
5. Data source: the bridge uses stable `hermes kanban ... --json` CLI commands first, with fixed argv and `shell: false`. It may later switch to official Hermes Kanban HTTP endpoints if Hermes adds them.
|
||||
6. Authentication: Vercel BFF validates Telegram `initData` server-side and issues a short-lived app session. The BFF then calls the self-hosted bridge using a separate bridge token. The bridge never trusts the browser directly.
|
||||
7. Documentation: docs must include English and Russian install paths for Vercel + bridge, Docker Compose, and local dev.
|
||||
|
||||
Short version: `Vercel static React Mini App + Vercel TS BFF + optional self-hosted TS bridge near Hermes`.
|
||||
|
||||
## 4. Why not keep the prototype stack as-is?
|
||||
|
||||
The Python/FastAPI prototype proved the shape, but it is not the best public target because:
|
||||
|
||||
- The user prefers TypeScript/JS and Vercel.
|
||||
- A Vercel-centered release is simpler if frontend and BFF share TypeScript types and validation schemas.
|
||||
- A Python backend on Vercel is possible but less ergonomic for this use case than a small Node BFF plus a separate self-hosted bridge.
|
||||
- The current prototype mixes concerns: Telegram auth, browser CORS, Hermes CLI subprocess, and static serving in one backend. For community release, the trust boundary must be explicit.
|
||||
|
||||
Python/FastAPI is not rejected forever; it becomes a migration source and optional legacy adapter, not the recommended architecture.
|
||||
|
||||
## 5. Target architecture
|
||||
|
||||
```text
|
||||
Telegram client / mobile browser / desktop browser
|
||||
|
|
||||
| HTTPS, static assets, no secrets
|
||||
v
|
||||
Vercel-hosted React + TypeScript + Vite app
|
||||
|
|
||||
| Authorization: tma <Telegram initData>
|
||||
| or app session cookie/JWT after validation
|
||||
v
|
||||
Vercel TypeScript BFF API routes
|
||||
| - validates Telegram initData with TELEGRAM_BOT_TOKEN
|
||||
| - enforces allowed Telegram user IDs / roles
|
||||
| - rate limits and normalizes errors
|
||||
| - holds no Hermes CLI access
|
||||
|
|
||||
| HTTPS + Bridge token, server-to-server only
|
||||
v
|
||||
Optional self-hosted Hermes Kanban Bridge (Node.js)
|
||||
| - runs on trusted host near Hermes
|
||||
| - validates bridge token/mTLS or reverse-proxy auth
|
||||
| - maps narrow REST endpoints to fixed Hermes Kanban operations
|
||||
| - uses child_process.spawn/execFile with shell=false
|
||||
v
|
||||
Hermes install / profile
|
||||
|
|
||||
v
|
||||
Hermes Kanban board SQLite + dispatcher + logs/workspaces
|
||||
```
|
||||
|
||||
### Local-only shortcut
|
||||
|
||||
For local development only:
|
||||
|
||||
```text
|
||||
Vite dev server -> local bridge on 127.0.0.1 -> hermes kanban CLI
|
||||
```
|
||||
|
||||
This mode may use `AUTH_MODE=dev` and a fake Telegram user fixture, but it must be impossible to enable in production without an explicit env var.
|
||||
|
||||
## 6. Trust boundaries
|
||||
|
||||
### Public browser / Mini App
|
||||
|
||||
Trusted for display only. Not trusted for identity, permissions, board slug, task IDs, or action payloads. It must not receive:
|
||||
|
||||
- Telegram bot token.
|
||||
- Hermes API Server key.
|
||||
- Bridge token.
|
||||
- Local filesystem paths beyond what the bridge explicitly exposes as display-safe metadata.
|
||||
- Raw command output containing secrets.
|
||||
|
||||
### Vercel BFF
|
||||
|
||||
Trusted to authenticate Telegram users and authorize UI actions. It has:
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`.
|
||||
- `SESSION_SECRET` or JWT signing secret.
|
||||
- `BRIDGE_BASE_URL` and `BRIDGE_TOKEN` if a bridge is configured.
|
||||
|
||||
It does not have direct local CLI access.
|
||||
|
||||
### Self-hosted bridge
|
||||
|
||||
Trusted local integration process. It has:
|
||||
|
||||
- Access to `hermes` binary and `HERMES_HOME`.
|
||||
- Optional `API_SERVER_KEY` only if a future bridge mode calls Hermes API Server for non-Kanban agent actions.
|
||||
- A bridge token accepted only from the BFF/reverse proxy.
|
||||
|
||||
### Hermes API Server
|
||||
|
||||
Powerful agent API. Do not expose directly to the Mini App browser for community default. If users expose it, they must keep bearer auth server-side and set narrow CORS only when absolutely needed.
|
||||
|
||||
## 7. Authentication model
|
||||
|
||||
### Telegram initData validation
|
||||
|
||||
Recommended request from frontend to BFF:
|
||||
|
||||
```http
|
||||
Authorization: tma <raw initData>
|
||||
```
|
||||
|
||||
Server validation steps:
|
||||
|
||||
1. Parse query-string-like init data.
|
||||
2. Remove and store `hash`.
|
||||
3. Sort remaining key-value pairs alphabetically.
|
||||
4. Join as `key=value` lines separated by `\n`.
|
||||
5. Derive secret key: HMAC-SHA256 with key `WebAppData` and message `TELEGRAM_BOT_TOKEN`.
|
||||
6. Compute HMAC-SHA256 of the data-check string using the derived key.
|
||||
7. Compare hex digest to received `hash` using constant-time comparison.
|
||||
8. Reject missing/invalid `auth_date`, expired data, missing user, or malformed JSON.
|
||||
9. Apply allowlist/RBAC: `TELEGRAM_ALLOWED_USER_IDS`, optional `TELEGRAM_ADMIN_USER_IDS`.
|
||||
10. Issue a short-lived app session cookie/JWT or validate `initData` on every API request.
|
||||
|
||||
Recommended TTL: 1 hour for write actions, configurable up to 24 hours for read-only dashboards.
|
||||
|
||||
### App roles
|
||||
|
||||
Minimum roles:
|
||||
|
||||
- `viewer`: boards/tasks read-only, logs redacted/truncated.
|
||||
- `operator`: create/comment/assign/promote/block/unblock/archive/dispatch.
|
||||
- `admin`: configure bridge target, manage board allowlist, view diagnostics.
|
||||
|
||||
Initial open-source release may implement allowlist-only authorization with `operator` for all allowed users and document RBAC as planned, but the API contract should reserve `role` fields now.
|
||||
|
||||
### Bridge authentication
|
||||
|
||||
Recommended:
|
||||
|
||||
- `Authorization: Bearer <bridge-token>` between BFF and bridge.
|
||||
- HTTPS at the public edge if bridge is reachable from Vercel.
|
||||
- Optional reverse-proxy IP allowlist, Cloudflare Access, Tailscale, or mTLS for advanced users.
|
||||
- Bridge should reject requests without `X-Request-Id` and should log audit metadata without storing Telegram raw initData.
|
||||
|
||||
## 8. Threat model
|
||||
|
||||
| Threat | Risk | Mitigation |
|
||||
|---|---|---|
|
||||
| Browser steals Hermes API key | Full agent/tool compromise | Never put `API_SERVER_KEY` or bridge token in frontend env. Use BFF. |
|
||||
| Forged Telegram user | Unauthorized board control | Validate initData server-side using official HMAC flow and max age. |
|
||||
| Replay of old initData | Unauthorized action reuse | Enforce `auth_date` TTL; short-lived session; optional nonce/idempotency for writes. |
|
||||
| CLI injection | Arbitrary local commands | Bridge maps endpoints to fixed argv; use `execFile`/`spawn` with `shell:false`; validate task IDs/board slugs. |
|
||||
| Path traversal via board/workspace | Filesystem escape | Accept only Hermes-valid board slugs; never pass raw paths from browser except documented workspace kind with strict validation. |
|
||||
| Leaking secrets in logs/errors | Token disclosure | Redact `token`, `secret`, `password`, `api_key`; truncate logs; avoid returning raw stderr. |
|
||||
| CSRF against BFF | Actions from malicious site | Use SameSite cookies or Authorization header; verify Origin; require Telegram/session auth for writes. |
|
||||
| CORS overexposure | Browser exfiltration | BFF CORS only for app origin; bridge CORS disabled by default. |
|
||||
| SSRF from BFF to arbitrary bridge URL | Internal network probing | `BRIDGE_BASE_URL` env only; no user-provided target URLs. |
|
||||
| Bridge public exposure | Local Hermes takeover | Strong token, HTTPS, rate limiting, reverse-proxy auth, optional local-only mode. |
|
||||
| Denial of service by refresh/log tail | Resource exhaustion | Pagination, `limit` caps, debounce refresh, server-side rate limits, timeouts. |
|
||||
| Confused deputy across boards | User controls wrong board | Server-side board allowlist; all write endpoints include normalized board slug and audit actor. |
|
||||
| Supply-chain compromise | Public package risk | Pin lockfile, Dependabot/Renovate, CI tests, minimal dependencies. |
|
||||
|
||||
## 9. Deploy matrix
|
||||
|
||||
| Mode | Frontend | BFF | Bridge | Hermes access | Recommended for | Notes |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Vercel + self-hosted bridge | Vercel static Vite | Vercel Node serverless routes | Node bridge on user's server/Pi/VPS | Bridge runs local `hermes kanban` | Community default | Best UX for Telegram HTTPS and simple updates. Requires exposing bridge securely. |
|
||||
| Docker Compose single host | Containerized static frontend served by BFF or Nginx | Node BFF container | Node bridge container or same process | Bind-mount Hermes home / host `hermes` | Self-hosters | Strong docs needed for volumes and user permissions. Avoid mounting Docker socket. |
|
||||
| Local dev | Vite dev server | Local Node BFF | Local bridge | Local `hermes kanban` | Contributors | Supports Telegram mock/dev auth. No public exposure. |
|
||||
| Fully static Vercel only | Vercel static | None | None | None | Demo/readme only | Cannot control real Hermes. Can show mock data only. |
|
||||
| Browser -> Hermes API direct | Static or local | None | None | Hermes API Server over HTTPS | Not recommended | Exposes bearer token to browser unless using a trusted private network. Document as unsafe for public/community default. |
|
||||
| Future native Hermes Kanban API | Vercel static | Vercel BFF | Optional/none | BFF calls official `/api/kanban` | Future | Adopt only when official Hermes exposes least-privilege Kanban CRUD separate from full agent API. |
|
||||
|
||||
## 10. API contract for v1
|
||||
|
||||
All public frontend calls go to the BFF under `/api/*`. The BFF calls the bridge under `/bridge/v1/*` or equivalent. The frontend should not know whether data came from a bridge or future native Hermes API.
|
||||
|
||||
### Common headers
|
||||
|
||||
Frontend -> BFF:
|
||||
|
||||
```http
|
||||
Authorization: tma <raw initData>
|
||||
Content-Type: application/json
|
||||
X-Request-Id: <uuid>
|
||||
```
|
||||
|
||||
BFF -> Bridge:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <bridge-token>
|
||||
Content-Type: application/json
|
||||
X-Request-Id: <uuid>
|
||||
X-Actor-Telegram-Id: <id>
|
||||
X-Actor-Role: viewer|operator|admin
|
||||
```
|
||||
|
||||
### Common response envelope
|
||||
|
||||
```ts
|
||||
type ApiOk<T> = {
|
||||
ok: true;
|
||||
data: T;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
type ApiError = {
|
||||
ok: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
requestId: string;
|
||||
};
|
||||
```
|
||||
|
||||
### Core models
|
||||
|
||||
```ts
|
||||
type Board = {
|
||||
slug: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
current?: boolean;
|
||||
};
|
||||
|
||||
type TaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived';
|
||||
|
||||
type TaskSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
assignee?: string | null;
|
||||
priority?: number | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
blockedReason?: string | null;
|
||||
parentIds?: string[];
|
||||
childIds?: string[];
|
||||
};
|
||||
|
||||
type TaskDetail = TaskSummary & {
|
||||
body?: string | null;
|
||||
comments?: TaskComment[];
|
||||
runs?: TaskRun[];
|
||||
events?: TaskEvent[];
|
||||
attachments?: TaskAttachment[];
|
||||
};
|
||||
|
||||
type TaskComment = {
|
||||
id?: string;
|
||||
author?: string;
|
||||
body: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type TaskRun = {
|
||||
id?: string;
|
||||
status?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
logAvailable?: boolean;
|
||||
};
|
||||
|
||||
type TaskEvent = {
|
||||
id?: string;
|
||||
type: string;
|
||||
message?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type TaskAttachment = {
|
||||
id: string;
|
||||
filename: string;
|
||||
sizeBytes?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
```
|
||||
|
||||
### BFF endpoints
|
||||
|
||||
Read:
|
||||
|
||||
- `GET /api/health` -> `{ status, version, bridge: { configured, reachable } }`
|
||||
- `GET /api/me` -> current Telegram user + role + auth expiry
|
||||
- `GET /api/boards` -> `Board[]`
|
||||
- `GET /api/boards/:board/tasks?status=&assignee=&q=&limit=&cursor=` -> paginated `TaskSummary[]` grouped client-side or server-side
|
||||
- `GET /api/boards/:board/tasks/:taskId` -> `TaskDetail`
|
||||
- `GET /api/boards/:board/tasks/:taskId/log?lines=200` -> redacted log tail
|
||||
- `GET /api/boards/:board/events/stream` -> SSE for future live updates; polling fallback required
|
||||
|
||||
Write:
|
||||
|
||||
- `POST /api/boards/:board/tasks`
|
||||
- `POST /api/boards/:board/tasks/:taskId/comment`
|
||||
- `POST /api/boards/:board/tasks/:taskId/assign`
|
||||
- `POST /api/boards/:board/tasks/:taskId/promote`
|
||||
- `POST /api/boards/:board/tasks/:taskId/block`
|
||||
- `POST /api/boards/:board/tasks/:taskId/unblock`
|
||||
- `POST /api/boards/:board/tasks/:taskId/archive`
|
||||
- `POST /api/boards/:board/dispatch`
|
||||
|
||||
Write payload examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Implement mobile task drawer",
|
||||
"body": "Acceptance criteria...",
|
||||
"assignee": "developer",
|
||||
"priority": 3,
|
||||
"workspaceKind": "worktree",
|
||||
"workspacePath": null,
|
||||
"goalMode": false,
|
||||
"maxRuntimeSeconds": 3600,
|
||||
"idempotencyKey": "optional-client-generated-key"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "body": "Looks good, unblock after config is set." }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "assignee": "reviewer" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "reason": "Needs maintainer decision on API shape." }
|
||||
```
|
||||
|
||||
### Bridge endpoint behavior
|
||||
|
||||
Bridge can mirror BFF paths under `/bridge/v1/boards/...`, but it must be internal-only. It should return the same envelope. It must:
|
||||
|
||||
- Validate all slugs and IDs before spawning Hermes CLI.
|
||||
- Use a command allowlist, not arbitrary pass-through.
|
||||
- Set `HERMES_HOME` from env, not request payload.
|
||||
- Set `HERMES_KANBAN_BOARD` or `--board` from validated board slug.
|
||||
- Enforce timeouts per command.
|
||||
- Redact and normalize errors.
|
||||
|
||||
## 11. UX requirements
|
||||
|
||||
Telegram Mini App:
|
||||
|
||||
- Respect Telegram theme variables and safe areas.
|
||||
- Use Telegram-style components for lists, section headers, buttons, badges, bottom sheets/drawers, destructive confirmations.
|
||||
- Use haptic feedback only for meaningful actions if SDK supports it.
|
||||
- `MainButton`/bottom action area for primary task action on mobile.
|
||||
- HashRouter or equivalent router compatible with Telegram webview and static hosting.
|
||||
|
||||
Browser/mobile:
|
||||
|
||||
- Responsive board: columns on desktop, status tabs/list on mobile.
|
||||
- Fast read path: skeleton loading, optimistic comment append only after server accepts writes or clearly mark pending.
|
||||
- Polling fallback; SSE enhancement later.
|
||||
- Accessible color contrast and keyboard navigation in browser.
|
||||
|
||||
Community docs/quality:
|
||||
|
||||
- `.env.example` only placeholders.
|
||||
- No real bot tokens, Hermes keys, domains, local IPs, or passwords.
|
||||
- README in English plus Russian quickstart or `README.ru.md`.
|
||||
- `SECURITY.md`, `CONTRIBUTING.md`, CI, tests, build checks.
|
||||
|
||||
## 12. Documentation outline
|
||||
|
||||
### English docs
|
||||
|
||||
1. `README.md`
|
||||
- What is Hermes Kanban Mini App?
|
||||
- Screenshots/GIFs
|
||||
- Architecture summary
|
||||
- Quickstart matrix: Vercel + bridge, Docker Compose, local dev
|
||||
- Security warning: static frontend cannot run Hermes CLI
|
||||
- Links to full docs
|
||||
2. `docs/installation.en.md`
|
||||
- Prerequisites: Node.js, Hermes Agent, Telegram bot, HTTPS URL
|
||||
- Create Telegram bot and configure Mini App domain
|
||||
- Vercel frontend/BFF deploy
|
||||
- Self-hosted bridge setup
|
||||
- Docker Compose setup
|
||||
- Local dev setup with mock Telegram auth
|
||||
3. `docs/configuration.en.md`
|
||||
- All env vars
|
||||
- Auth modes
|
||||
- Board allowlist
|
||||
- Rate limit settings
|
||||
- Reverse proxy examples
|
||||
4. `docs/security.en.md`
|
||||
- Threat model
|
||||
- Token handling
|
||||
- Telegram initData validation
|
||||
- Bridge exposure hardening
|
||||
- Responsible disclosure
|
||||
5. `docs/api.en.md`
|
||||
- BFF and bridge endpoint contracts
|
||||
- Error codes
|
||||
- SSE/polling behavior
|
||||
6. `docs/migration-from-fastapi-prototype.en.md`
|
||||
- Mapping current prototype endpoints to new TS BFF/bridge
|
||||
- Removed assumptions
|
||||
- Compatibility notes
|
||||
|
||||
### Russian docs
|
||||
|
||||
1. `README.ru.md`
|
||||
- Что это и кому нужно
|
||||
- Рекомендуемая схема установки
|
||||
- Главное предупреждение про Vercel/static и локальный Hermes CLI
|
||||
2. `docs/installation.ru.md`
|
||||
- Telegram BotFather
|
||||
- Vercel
|
||||
- self-hosted bridge
|
||||
- Docker Compose
|
||||
- локальный dev
|
||||
3. `docs/configuration.ru.md`
|
||||
- Переменные окружения
|
||||
- режимы авторизации
|
||||
- allowlist пользователей/досок
|
||||
4. `docs/security.ru.md`
|
||||
- модель угроз
|
||||
- где хранить токены
|
||||
- как безопасно открыть bridge
|
||||
5. `docs/api.ru.md`
|
||||
- контракт API
|
||||
- ошибки
|
||||
6. `docs/migration-from-fastapi-prototype.ru.md`
|
||||
- план миграции с текущего прототипа
|
||||
|
||||
## 13. Environment variables for the new stack
|
||||
|
||||
Frontend build-time public vars:
|
||||
|
||||
```bash
|
||||
VITE_APP_NAME=Hermes Kanban
|
||||
VITE_BFF_BASE_URL= # empty for same-origin Vercel deployment
|
||||
VITE_TELEGRAM_BOT_USERNAME= # optional display/link only, not token
|
||||
```
|
||||
|
||||
Vercel BFF server vars:
|
||||
|
||||
```bash
|
||||
TELEGRAM_BOT_TOKEN= # secret, server-only
|
||||
TELEGRAM_ALLOWED_USER_IDS= # comma-separated Telegram IDs
|
||||
TELEGRAM_ADMIN_USER_IDS= # optional
|
||||
SESSION_SECRET= # random 32+ bytes
|
||||
BRIDGE_BASE_URL= # https://bridge.example.com
|
||||
BRIDGE_TOKEN= # secret, server-to-server
|
||||
ALLOWED_ORIGINS= # exact frontend origins
|
||||
AUTH_MAX_AGE_SECONDS=3600
|
||||
READ_AUTH_MAX_AGE_SECONDS=86400
|
||||
RATE_LIMIT_PER_MINUTE=60
|
||||
```
|
||||
|
||||
Bridge server vars:
|
||||
|
||||
```bash
|
||||
BRIDGE_TOKEN= # must match BFF
|
||||
HERMES_BIN=hermes
|
||||
HERMES_HOME= # target Hermes profile home
|
||||
HERMES_KANBAN_BOARD=default
|
||||
BRIDGE_HOST=127.0.0.1
|
||||
BRIDGE_PORT=8787
|
||||
BRIDGE_ALLOWED_BOARDS=default
|
||||
COMMAND_TIMEOUT_SECONDS=30
|
||||
LOG_TAIL_MAX_LINES=500
|
||||
```
|
||||
|
||||
Docker Compose vars should mirror the same names. `.env` must stay gitignored; `.env.example` must contain placeholders only.
|
||||
|
||||
## 14. Migration plan from current FastAPI prototype
|
||||
|
||||
Do not modify the current Python implementation until this ADR is accepted.
|
||||
|
||||
Phase 0: Freeze and document
|
||||
|
||||
- Keep current prototype uncommitted as reference only.
|
||||
- Add this ADR.
|
||||
- Record endpoint inventory and data shapes from `backend/app/main.py`, `adapter.py`, `auth.py`, and frontend calls.
|
||||
|
||||
Phase 1: Extract contracts
|
||||
|
||||
- Create shared TypeScript schemas for Board, TaskSummary, TaskDetail, API envelope, and write payloads.
|
||||
- Write contract tests against fixtures copied from current prototype responses.
|
||||
- Define error code taxonomy.
|
||||
|
||||
Phase 2: Build TypeScript bridge
|
||||
|
||||
- Implement Node bridge with fixed command allowlist matching current `HermesCliKanbanAdapter` behavior.
|
||||
- Port Telegram-independent validation helpers: board slug, task ID, safe log limits, error redaction.
|
||||
- Add tests that assert `shell:false` / `execFile` usage and reject invalid IDs/slugs.
|
||||
- Verify against local `hermes kanban` commands.
|
||||
|
||||
Phase 3: Build Vercel BFF
|
||||
|
||||
- Implement Telegram initData validation using a maintained Node package such as `@tma.js/init-data-node` or a small audited HMAC helper with tests from Telegram examples.
|
||||
- Add allowlist auth and bridge proxy endpoints.
|
||||
- Add rate limiting and CORS/origin checks.
|
||||
|
||||
Phase 4: Rebuild frontend UX
|
||||
|
||||
- Keep React + TypeScript + Vite.
|
||||
- Replace ad-hoc Telegram integration with Telegram Apps SDK React package.
|
||||
- Add Telegram UI components, mobile task drawer, board status tabs, desktop columns.
|
||||
- Use BFF API only; no direct bridge/Hermes calls from browser.
|
||||
|
||||
Phase 5: Docs and release hardening
|
||||
|
||||
- Write EN/RU docs from outline.
|
||||
- Add `.env.example`, `SECURITY.md`, `CONTRIBUTING.md`, CI.
|
||||
- Add unit tests, contract tests, frontend tests, build check.
|
||||
- Add Docker Compose example.
|
||||
- Add Vercel deployment guide.
|
||||
|
||||
Phase 6: Optional legacy compatibility
|
||||
|
||||
- Either remove Python prototype from the public release or move it to `legacy/fastapi-prototype` with clear unsupported status.
|
||||
- Do not publish both as equal recommended paths; it confuses users.
|
||||
|
||||
## 15. Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Clear Vercel-first story without lying about local CLI access.
|
||||
- Secrets stay server-side.
|
||||
- TypeScript types can be shared across frontend, BFF, and bridge.
|
||||
- Bridge is optional but honest: users who want real Hermes control must run a trusted server near Hermes.
|
||||
- Future Hermes native Kanban HTTP API can replace bridge internals without changing frontend UX.
|
||||
|
||||
Negative/tradeoffs:
|
||||
|
||||
- Requires two deploy targets for real use: Vercel app and bridge.
|
||||
- Public bridge exposure needs careful docs.
|
||||
- Slightly more moving parts than a single FastAPI container.
|
||||
- Until Hermes exposes first-class Kanban HTTP endpoints, the bridge still depends on CLI JSON behavior.
|
||||
|
||||
## 16. Rejected alternatives
|
||||
|
||||
### Static Vercel app directly runs Hermes CLI
|
||||
|
||||
Rejected. Browsers/static hosting cannot execute the user's local CLI. Any workaround would require unsafe local agents or browser extensions and is not community-ready.
|
||||
|
||||
### Static app directly calls Hermes API Server with API key
|
||||
|
||||
Rejected for public/community default. Hermes API Server bearer key controls a full agent toolset including terminal; exposing it to JavaScript is a critical security issue.
|
||||
|
||||
### Keep single Python/FastAPI backend as recommended public stack
|
||||
|
||||
Rejected as recommended stack. It works locally and may be a useful prototype, but it conflicts with TypeScript/Vercel preference and mixes trust boundaries.
|
||||
|
||||
### Full Next.js monolith
|
||||
|
||||
Not selected. Next.js could host frontend+BFF on Vercel, but Vite static + small serverless BFF is simpler, lighter, and closer to official Telegram Mini Apps React template patterns. If future SSR/auth needs grow, migration to Next.js remains possible.
|
||||
|
||||
### Bridge reads `kanban.db` directly
|
||||
|
||||
Rejected for v1. Direct DB reads couple the project to Hermes internals and risk schema drift. Use official CLI behavior first; switch to official HTTP API when available.
|
||||
|
||||
## 17. Precise next implementation milestones
|
||||
|
||||
M1. Contract foundation
|
||||
|
||||
- Add `packages/shared` or `src/shared` TypeScript schemas.
|
||||
- Add API envelope and error code tests.
|
||||
- No UI rewrite yet.
|
||||
|
||||
M2. Bridge MVP
|
||||
|
||||
- Create `apps/bridge` TypeScript service.
|
||||
- Implement `GET /health`, `GET /bridge/v1/boards`, `GET /bridge/v1/boards/:board/tasks`, `GET /bridge/v1/boards/:board/tasks/:taskId`.
|
||||
- Use fixed `execFile`/`spawn` calls to `hermes kanban` with tests.
|
||||
|
||||
M3. BFF auth MVP
|
||||
|
||||
- Create Vercel API routes.
|
||||
- Validate Telegram initData in server code.
|
||||
- Proxy read endpoints to bridge.
|
||||
- Add auth fixtures/tests.
|
||||
|
||||
M4. Frontend read-only UX
|
||||
|
||||
- Migrate current React UI to new BFF contract.
|
||||
- Add Telegram SDK initialization and Telegram UI theme handling.
|
||||
- Ship read-only boards/tasks/log tail.
|
||||
|
||||
M5. Write actions
|
||||
|
||||
- Add create/comment/assign/promote/block/unblock/archive/dispatch.
|
||||
- Add confirmations and role checks.
|
||||
- Add audit log fields passed to bridge.
|
||||
|
||||
M6. Release docs and CI
|
||||
|
||||
- EN/RU install docs.
|
||||
- `.env.example` for frontend, BFF, bridge, Docker Compose.
|
||||
- `SECURITY.md`, `CONTRIBUTING.md`.
|
||||
- CI: typecheck, lint, unit tests, frontend build, bridge tests.
|
||||
|
||||
M7. Optional live updates
|
||||
|
||||
- SSE endpoint from BFF to frontend.
|
||||
- Bridge polling or future Hermes dashboard/events integration.
|
||||
- Graceful fallback to polling.
|
||||
|
||||
## 18. Acceptance criteria for starting app-code rewrite
|
||||
|
||||
Do not write implementation code until these are accepted:
|
||||
|
||||
- This ADR stack decision is approved.
|
||||
- Maintainer confirms whether bridge is a separate `apps/bridge` service or same package as BFF with a runtime mode switch.
|
||||
- Maintainer confirms whether the public repo should delete the Python prototype or keep it under `legacy/`.
|
||||
- First milestone is limited to contracts + tests + scaffold, not feature-complete UI.
|
||||
41
frontend/Dockerfile
Normal file
41
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
# Frontend — Multi-stage Docker build
|
||||
# Stage 1: install deps + build with Vite
|
||||
# Stage 2: serve static with nginx:alpine
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Stage 1: Build ─────────────────────────────────────────
|
||||
FROM node:22-alpine AS build
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace scaffolding
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY packages/contracts/package.json packages/contracts/
|
||||
COPY frontend/package.json frontend/
|
||||
|
||||
# Install all dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
COPY packages/contracts/ packages/contracts/
|
||||
COPY frontend/ frontend/
|
||||
|
||||
# Build contracts (frontend depends on @kanban/contracts), then frontend
|
||||
RUN pnpm --filter @kanban/contracts build && \
|
||||
pnpm --filter frontend build
|
||||
|
||||
# ── Stage 2: Serve ─────────────────────────────────────────
|
||||
FROM nginx:alpine AS runtime
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm -f /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built frontend
|
||||
COPY --from=build /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
@ -1 +1,12 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Hermes Kanban</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Hermes Kanban</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
39
frontend/nginx.conf
Normal file
39
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
# nginx.conf — Frontend container configuration
|
||||
# Serves Vite SPA and proxies /bridge to the bridge service
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy /bridge requests to the bridge Express service
|
||||
location /bridge/ {
|
||||
proxy_pass http://bridge:3456/bridge/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Timeouts for slow hermes CLI calls
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
# SPA fallback — serve index.html for client-side routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets aggressively (Vite hashes filenames)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
2525
frontend/package-lock.json
generated
2525
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1 +1,29 @@
|
|||
{"scripts":{"dev":"vite --host 0.0.0.0","build":"tsc -b && vite build","preview":"vite preview --host 0.0.0.0","test":"vitest run --environment jsdom"},"dependencies":{"@vitejs/plugin-react":"latest","vite":"latest","typescript":"latest","react":"latest","react-dom":"latest","lucide-react":"latest"},"devDependencies":{"vitest":"latest","jsdom":"latest","@testing-library/react":"latest","@testing-library/jest-dom":"latest","@testing-library/user-event":"latest","@types/react":"latest","@types/react-dom":"latest"}}
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kanban/contracts": "workspace:*",
|
||||
"@telegram-apps/sdk-react": "^3.3.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -1,186 +1,253 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RefreshCw, Plus, Send } from 'lucide-react'
|
||||
import { api } from './api'
|
||||
import { applyTelegramTheme } from './telegram'
|
||||
import type { Event, Run, Task, TaskGroups } from './types'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { Board, TaskSummary, TaskDetail, TaskListData, CreateTaskPayload, CommentPayload } from '@kanban/contracts'
|
||||
import { DEFAULT_COLUMNS } from '@kanban/contracts'
|
||||
import { initTelegram, applyTheme, hapticFeedback, showBackButton, hideBackButton } from './telegram'
|
||||
import { fetchBoards, fetchTasks, fetchTask, createTask, addComment } from './api/client'
|
||||
import BoardSelector from './components/BoardSelector'
|
||||
import KanbanBoard from './components/KanbanBoard'
|
||||
import TaskDetailModal from './components/TaskDetailModal'
|
||||
import CreateTaskForm from './components/CreateTaskForm'
|
||||
|
||||
const STATUSES = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived']
|
||||
|
||||
type Detail = {
|
||||
task?: Task
|
||||
events: Event[]
|
||||
runs: Run[]
|
||||
log: string
|
||||
missingLog: boolean
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return <div className="error" role="alert">{message}</div>
|
||||
}
|
||||
|
||||
function TaskCard({ task, onOpen }: { task: Task; onOpen: (task: Task) => void }) {
|
||||
return (
|
||||
<button className="task-card" onClick={() => onOpen(task)}>
|
||||
<span className="task-id">{task.id}</span>
|
||||
<strong>{task.title}</strong>
|
||||
<span className="status-pill">status: {task.status}</span>
|
||||
<span>{task.assignee || 'unassigned'}</span>
|
||||
<span>priority {task.priority ?? 0}</span>
|
||||
{task.age && <span>{task.age}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateTask({ onCreated }: { onCreated: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [assignee, setAssignee] = useState('')
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [workspaceKind, setWorkspaceKind] = useState('scratch')
|
||||
const [goalMode, setGoalMode] = useState(false)
|
||||
const [maxRuntime, setMaxRuntime] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit() {
|
||||
if (!title.trim()) {
|
||||
setError('Title is required')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
await api.create({
|
||||
title,
|
||||
body,
|
||||
assignee: assignee || null,
|
||||
priority,
|
||||
workspace_kind: workspaceKind,
|
||||
goal_mode: goalMode,
|
||||
max_runtime_seconds: maxRuntime ? Number(maxRuntime) : null,
|
||||
})
|
||||
setOpen(false)
|
||||
setTitle('')
|
||||
setBody('')
|
||||
onCreated()
|
||||
}
|
||||
|
||||
if (!open) return <button className="primary" onClick={() => setOpen(true)}><Plus size={16} />Create task</button>
|
||||
|
||||
return (
|
||||
<div className="modal-card">
|
||||
<h2>Create task</h2>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<input placeholder="Title" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
<textarea placeholder="Body" value={body} onChange={(event) => setBody(event.target.value)} />
|
||||
<input placeholder="Assignee" value={assignee} onChange={(event) => setAssignee(event.target.value)} />
|
||||
<input aria-label="Priority" type="number" value={priority} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||
<select value={workspaceKind} onChange={(event) => setWorkspaceKind(event.target.value)}>
|
||||
<option value="scratch">scratch</option>
|
||||
<option value="dir">dir</option>
|
||||
<option value="worktree">worktree</option>
|
||||
</select>
|
||||
<label className="row"><input type="checkbox" checked={goalMode} onChange={(event) => setGoalMode(event.target.checked)} /> goal mode</label>
|
||||
<input placeholder="Max runtime seconds" value={maxRuntime} onChange={(event) => setMaxRuntime(event.target.value)} />
|
||||
<div className="row">
|
||||
<button onClick={submit}>Create</button>
|
||||
<button className="ghost" onClick={() => setOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailDrawer({ selected, detail, onClose, onRefresh }: { selected: Task | null; detail: Detail | null; onClose: () => void; onRefresh: () => void }) {
|
||||
const [comment, setComment] = useState('')
|
||||
const [assign, setAssign] = useState('')
|
||||
const [blockReason, setBlockReason] = useState('')
|
||||
if (!selected) return null
|
||||
const task = detail?.task || selected
|
||||
async function addComment() {
|
||||
if (!comment.trim()) return
|
||||
await api.comment(selected!.id, comment)
|
||||
setComment('')
|
||||
onRefresh()
|
||||
}
|
||||
return (
|
||||
<aside className="drawer">
|
||||
<button className="close" onClick={onClose}>×</button>
|
||||
<p className="task-id">{task.id}</p>
|
||||
<h2>{task.title}</h2>
|
||||
<p className="body">{task.body || 'No body'}</p>
|
||||
<div className="actions">
|
||||
<button onClick={() => api.action(task.id, 'promote').then(onRefresh)}>Promote</button>
|
||||
<button onClick={() => api.action(task.id, 'unblock').then(onRefresh)}>Unblock</button>
|
||||
<button onClick={() => api.action(task.id, 'archive').then(onRefresh)}>Archive</button>
|
||||
</div>
|
||||
<div className="row"><input placeholder="Assign to" value={assign} onChange={(event) => setAssign(event.target.value)} /><button onClick={() => api.assign(task.id, assign).then(onRefresh)}>Assign</button></div>
|
||||
<div className="row"><input placeholder="Block reason" value={blockReason} onChange={(event) => setBlockReason(event.target.value)} /><button onClick={() => api.block(task.id, blockReason).then(onRefresh)}>Block</button></div>
|
||||
<section><h3>Comments</h3><div className="row"><input placeholder="Comment" value={comment} onChange={(event) => setComment(event.target.value)} /><button onClick={addComment}><Send size={14} /></button></div></section>
|
||||
<section><h3>Events</h3><pre>{JSON.stringify(detail?.events || [], null, 2)}</pre></section>
|
||||
<section><h3>Runs</h3><pre>{JSON.stringify(detail?.runs || [], null, 2)}</pre></section>
|
||||
<section><h3>Log</h3><button onClick={onRefresh}>Refresh log</button><pre>{detail?.missingLog ? 'No log yet' : detail?.log}</pre></section>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
type View = 'boards' | 'board' | 'task' | 'create'
|
||||
|
||||
export default function App() {
|
||||
const [groups, setGroups] = useState<TaskGroups>({})
|
||||
const [error, setError] = useState('')
|
||||
const [view, setView] = useState<View>('boards')
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [selectedBoard, setSelectedBoard] = useState<Board | null>(null)
|
||||
const [tasks, setTasks] = useState<TaskListData | null>(null)
|
||||
const [selectedTask, setSelectedTask] = useState<TaskDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selected, setSelected] = useState<Task | null>(null)
|
||||
const [detail, setDetail] = useState<Detail | null>(null)
|
||||
const hasRunningTasks = useMemo(() => (groups.running || []).length > 0, [groups])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function loadBoard() {
|
||||
// Initialize Telegram on mount
|
||||
useEffect(() => {
|
||||
initTelegram()
|
||||
applyTheme()
|
||||
}, [])
|
||||
|
||||
// Load boards
|
||||
const loadBoards = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.tasks()
|
||||
setGroups(data.groups)
|
||||
setError('')
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetchBoards()
|
||||
setBoards(data)
|
||||
// Auto-select if only one board
|
||||
if (data.length === 1) {
|
||||
setSelectedBoard(data[0])
|
||||
setView('board')
|
||||
} else {
|
||||
setView('boards')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load board')
|
||||
setError(err instanceof Error ? err.message : 'Failed to load boards')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function openTask(task: Task) {
|
||||
setSelected(task)
|
||||
const [taskData, events, runs, log] = await Promise.all([api.task(task.id), api.events(task.id), api.runs(task.id), api.log(task.id)])
|
||||
setDetail({ task: taskData.task, events: events.events, runs: runs.runs, log: log.log, missingLog: log.missing })
|
||||
}
|
||||
|
||||
async function refreshSelected() {
|
||||
await loadBoard()
|
||||
if (selected) await openTask(selected)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
applyTelegramTheme()
|
||||
loadBoard()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(loadBoard, hasRunningTasks ? 2500 : 5000)
|
||||
return () => window.clearInterval(interval)
|
||||
}, [hasRunningTasks])
|
||||
// Load tasks for board
|
||||
const loadTasks = useCallback(async (board: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetchTasks(board)
|
||||
setTasks(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load tasks')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load task detail
|
||||
const loadTaskDetail = useCallback(async (board: string, taskId: string) => {
|
||||
try {
|
||||
const detail = await fetchTask(board, taskId)
|
||||
setSelectedTask(detail)
|
||||
setView('task')
|
||||
showBackButton(() => {
|
||||
setSelectedTask(null)
|
||||
setView('board')
|
||||
hideBackButton()
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load task')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadBoards()
|
||||
}, [loadBoards])
|
||||
|
||||
// Load tasks when board changes
|
||||
useEffect(() => {
|
||||
if (selectedBoard) {
|
||||
loadTasks(selectedBoard.slug)
|
||||
}
|
||||
}, [selectedBoard, loadTasks])
|
||||
|
||||
// Handle board selection
|
||||
const handleSelectBoard = (board: Board) => {
|
||||
hapticFeedback('light')
|
||||
setSelectedBoard(board)
|
||||
setView('board')
|
||||
}
|
||||
|
||||
// Handle task tap
|
||||
const handleTaskTap = (task: TaskSummary) => {
|
||||
hapticFeedback('light')
|
||||
if (selectedBoard) {
|
||||
loadTaskDetail(selectedBoard.slug, task.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle create task
|
||||
const handleCreateTask = async (payload: CreateTaskPayload) => {
|
||||
if (!selectedBoard) return
|
||||
try {
|
||||
hapticFeedback('medium')
|
||||
await createTask(selectedBoard.slug, payload)
|
||||
setView('board')
|
||||
loadTasks(selectedBoard.slug)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create task')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle add comment
|
||||
const handleAddComment = async (taskId: string, payload: CommentPayload) => {
|
||||
if (!selectedBoard) return
|
||||
try {
|
||||
hapticFeedback('light')
|
||||
await addComment(selectedBoard.slug, taskId, payload)
|
||||
// Reload task detail
|
||||
const detail = await fetchTask(selectedBoard.slug, taskId)
|
||||
setSelectedTask(detail)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add comment')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle back
|
||||
const handleBack = () => {
|
||||
hapticFeedback('light')
|
||||
if (view === 'task') {
|
||||
setSelectedTask(null)
|
||||
setView('board')
|
||||
hideBackButton()
|
||||
} else if (view === 'create') {
|
||||
setView('board')
|
||||
hideBackButton()
|
||||
} else if (view === 'board' && boards.length > 1) {
|
||||
setSelectedBoard(null)
|
||||
setView('boards')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle open create form
|
||||
const handleOpenCreate = () => {
|
||||
hapticFeedback('medium')
|
||||
setView('create')
|
||||
showBackButton(handleBack)
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loading && !tasks) {
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="topbar">
|
||||
<div><p className="eyebrow">Telegram Mini App</p><h1>Hermes Kanban</h1></div>
|
||||
<div className="row"><CreateTask onCreated={loadBoard} /><button className="ghost" onClick={loadBoard}><RefreshCw size={16} />Refresh</button><button className="ghost" onClick={() => api.dispatch().then(loadBoard)}>Dispatch</button></div>
|
||||
</header>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{loading ? <p>Loading…</p> : (
|
||||
<section className="board">
|
||||
{STATUSES.map((status) => (
|
||||
<div className="column" key={status}>
|
||||
<h2>{status}</h2>
|
||||
{(groups[status] || []).map((task) => <TaskCard key={task.id} task={task} onOpen={openTask} />)}
|
||||
{(groups[status] || []).length === 0 && <p className="empty">No tasks</p>}
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-400">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error && !tasks) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadBoards}
|
||||
className="px-4 py-2 bg-blue-600 rounded-lg text-white"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 bg-[var(--tg-secondary-bg-color,#1a1f2e)] border-b border-white/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{view !== 'boards' && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="p-2 -ml-2 text-blue-400"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
<h1 className="text-lg font-semibold flex-1 text-center">
|
||||
{view === 'boards' ? 'Select Board' : selectedBoard?.name || selectedBoard?.slug || 'Kanban'}
|
||||
</h1>
|
||||
{view === 'board' && (
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="p-2 -mr-2 text-blue-400 font-medium"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
)}
|
||||
{view === 'boards' && <div className="w-10" />}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mx-4 mt-3 p-3 bg-red-900/30 border border-red-500/30 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 text-red-400">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{view === 'boards' && (
|
||||
<BoardSelector boards={boards} onSelect={handleSelectBoard} />
|
||||
)}
|
||||
|
||||
{view === 'board' && tasks && (
|
||||
<KanbanBoard
|
||||
tasks={tasks.items}
|
||||
columns={DEFAULT_COLUMNS}
|
||||
onTaskTap={handleTaskTap}
|
||||
onRefresh={() => selectedBoard && loadTasks(selectedBoard.slug)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'task' && selectedTask && (
|
||||
<TaskDetailModal
|
||||
task={selectedTask}
|
||||
onClose={handleBack}
|
||||
onAddComment={(payload) => handleAddComment(selectedTask.id, payload)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'create' && (
|
||||
<CreateTaskForm
|
||||
onSubmit={handleCreateTask}
|
||||
onCancel={handleBack}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<DetailDrawer selected={selected} detail={detail} onClose={() => setSelected(null)} onRefresh={refreshSelected} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'
|
||||
import App from '../App'
|
||||
|
||||
const tasksResponse = {
|
||||
tasks: [
|
||||
{ id: 't_1', title: 'Build thing', status: 'ready', assignee: 'developer', priority: 5, age: '1m' },
|
||||
],
|
||||
groups: {
|
||||
triage: [],
|
||||
todo: [],
|
||||
ready: [{ id: 't_1', title: 'Build thing', status: 'ready', assignee: 'developer', priority: 5 }],
|
||||
running: [],
|
||||
blocked: [],
|
||||
done: [],
|
||||
archived: [],
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (String(url).includes('/api/tasks/t_1/events')) return new Response(JSON.stringify({ events: [{ kind: 'created' }] }))
|
||||
if (String(url).includes('/api/tasks/t_1/runs')) return new Response(JSON.stringify({ runs: [{ id: 1, status: 'completed' }] }))
|
||||
if (String(url).includes('/api/tasks/t_1/log')) return new Response(JSON.stringify({ log: 'tail', missing: false }))
|
||||
if (String(url).endsWith('/api/tasks/t_1')) return new Response(JSON.stringify({ task: { ...tasksResponse.tasks[0], body: 'Full body' } }))
|
||||
if (String(url).includes('/api/tasks') && init?.method === 'POST') return new Response(JSON.stringify({ task: { id: 't_new', title: 'Created', status: 'todo' } }))
|
||||
if (String(url).includes('/api/tasks')) return new Response(JSON.stringify(tasksResponse))
|
||||
return new Response(JSON.stringify({ status: 'ok' }))
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('App', () => {
|
||||
it('renders board columns', async () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByText('ready')).toBeInTheDocument()
|
||||
expect(screen.getByText('blocked')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders task cards with title status and assignee', async () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByText('Build thing')).toBeInTheDocument()
|
||||
expect(screen.getByText('developer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens detail drawer and shows body events and log sections', async () => {
|
||||
render(<App />)
|
||||
fireEvent.click(await screen.findByText('Build thing'))
|
||||
expect(await screen.findByText('Full body')).toBeInTheDocument()
|
||||
expect(screen.getByText('Events')).toBeInTheDocument()
|
||||
expect(screen.getByText('Log')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('create form validates title', async () => {
|
||||
render(<App />)
|
||||
fireEvent.click(await screen.findByText('Create task'))
|
||||
fireEvent.click(screen.getByText('Create'))
|
||||
expect(await screen.findByText('Title is required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows API error state', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ detail: { message: 'boom' } }), { status: 500 })))
|
||||
render(<App />)
|
||||
expect(await screen.findByText(/boom/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not crash when Telegram WebApp is absent', async () => {
|
||||
vi.stubGlobal('Telegram', undefined)
|
||||
render(<App />)
|
||||
await waitFor(() => expect(screen.getByText('Hermes Kanban')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { getTelegramInitData } from './telegram'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
const initData = getTelegramInitData()
|
||||
if (initData) headers.set('X-Telegram-Init-Data', initData)
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers })
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
const message = data?.detail?.message || data?.detail || response.statusText || 'API request failed'
|
||||
throw new Error(String(message))
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
tasks: () => request<{ tasks: any[]; groups: Record<string, any[]> }>('/api/tasks'),
|
||||
task: (id: string) => request<{ task: any }>(`/api/tasks/${id}`),
|
||||
events: (id: string) => request<{ events: any[] }>(`/api/tasks/${id}/events`),
|
||||
runs: (id: string) => request<{ runs: any[] }>(`/api/tasks/${id}/runs`),
|
||||
log: (id: string) => request<{ log: string; missing: boolean }>(`/api/tasks/${id}/log`),
|
||||
create: (payload: Record<string, unknown>) => request<{ task: any }>('/api/tasks', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
comment: (id: string, body: string) => request(`/api/tasks/${id}/comment`, { method: 'POST', body: JSON.stringify({ body }) }),
|
||||
action: (id: string, action: 'promote' | 'unblock' | 'archive') => request(`/api/tasks/${id}/${action}`, { method: 'POST' }),
|
||||
block: (id: string, reason: string) => request(`/api/tasks/${id}/block`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
assign: (id: string, assignee: string) => request(`/api/tasks/${id}/assign`, { method: 'POST', body: JSON.stringify({ assignee }) }),
|
||||
dispatch: () => request('/api/dispatch', { method: 'POST' }),
|
||||
}
|
||||
97
frontend/src/api/client.ts
Normal file
97
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type {
|
||||
Board,
|
||||
BoardListData,
|
||||
TaskSummary,
|
||||
TaskDetail,
|
||||
TaskListData,
|
||||
CreateTaskPayload,
|
||||
CommentPayload,
|
||||
ApiResponse,
|
||||
HealthData,
|
||||
} from '@kanban/contracts'
|
||||
import { getInitData } from '../telegram'
|
||||
|
||||
const BRIDGE_URL = import.meta.env.VITE_BRIDGE_URL || '/bridge'
|
||||
const AUTH_TOKEN: string = import.meta.env.VITE_AUTH_TOKEN || ''
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Content-Type', 'application/json')
|
||||
|
||||
// Auth: prefer X-Bridge-Token, fall back to Telegram initData
|
||||
if (AUTH_TOKEN) {
|
||||
headers.set('X-Bridge-Token', AUTH_TOKEN)
|
||||
} else {
|
||||
const initData = getInitData()
|
||||
if (initData) {
|
||||
headers.set('X-Bridge-Token', initData)
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${BRIDGE_URL}${path}`
|
||||
const response = await fetch(url, { ...init, headers })
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok || !data) {
|
||||
const message =
|
||||
(data as { error?: { message?: string } })?.error?.message ||
|
||||
response.statusText ||
|
||||
`HTTP ${response.status}`
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const envelope = data as ApiResponse<T>
|
||||
if (!envelope.ok) {
|
||||
throw new Error(envelope.error?.message || 'API error')
|
||||
}
|
||||
|
||||
return envelope.data
|
||||
}
|
||||
|
||||
// ─── API methods ────────────────────────────────────────────
|
||||
|
||||
export async function fetchHealth(): Promise<HealthData> {
|
||||
return request<HealthData>('/v1/health')
|
||||
}
|
||||
|
||||
export async function fetchBoards(): Promise<Board[]> {
|
||||
return request<BoardListData>('/v1/boards')
|
||||
}
|
||||
|
||||
export async function fetchTasks(board: string): Promise<TaskListData> {
|
||||
return request<TaskListData>(`/v1/boards/${encodeURIComponent(board)}/tasks`)
|
||||
}
|
||||
|
||||
export async function fetchTask(board: string, taskId: string): Promise<TaskDetail> {
|
||||
return request<TaskDetail>(
|
||||
`/v1/boards/${encodeURIComponent(board)}/tasks/${encodeURIComponent(taskId)}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
board: string,
|
||||
payload: CreateTaskPayload
|
||||
): Promise<TaskDetail> {
|
||||
return request<TaskDetail>(
|
||||
`/v1/boards/${encodeURIComponent(board)}/tasks`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function addComment(
|
||||
board: string,
|
||||
taskId: string,
|
||||
payload: CommentPayload
|
||||
): Promise<void> {
|
||||
await request<void>(
|
||||
`/v1/boards/${encodeURIComponent(board)}/tasks/${encodeURIComponent(taskId)}/comments`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
)
|
||||
}
|
||||
41
frontend/src/components/BoardSelector.tsx
Normal file
41
frontend/src/components/BoardSelector.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { Board } from '@kanban/contracts'
|
||||
|
||||
interface Props {
|
||||
boards: Board[]
|
||||
onSelect: (board: Board) => void
|
||||
}
|
||||
|
||||
export default function BoardSelector({ boards, onSelect }: Props) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-gray-400 text-sm mb-4">Choose a board to view:</p>
|
||||
{boards.map((board) => (
|
||||
<button
|
||||
key={board.slug}
|
||||
onClick={() => onSelect(board)}
|
||||
className="w-full text-left p-4 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{board.icon && (
|
||||
<span className="text-2xl">{board.icon}</span>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-semibold text-white truncate">
|
||||
{board.name || board.slug}
|
||||
</h2>
|
||||
{board.description && (
|
||||
<p className="text-sm text-gray-400 truncate mt-1">
|
||||
{board.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500">→</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{boards.length === 0 && (
|
||||
<p className="text-center text-gray-500 py-8">No boards available</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
frontend/src/components/CreateTaskForm.tsx
Normal file
127
frontend/src/components/CreateTaskForm.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useState } from 'react'
|
||||
import type { CreateTaskPayload } from '@kanban/contracts'
|
||||
import { hapticFeedback } from '../telegram'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (payload: CreateTaskPayload) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function CreateTaskForm({ onSubmit, onCancel }: Props) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [assignee, setAssignee] = useState('')
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!title.trim()) {
|
||||
setError('Title is required')
|
||||
hapticFeedback('heavy')
|
||||
return
|
||||
}
|
||||
|
||||
hapticFeedback('medium')
|
||||
onSubmit({
|
||||
title: title.trim(),
|
||||
body: body.trim() || undefined,
|
||||
assignee: assignee.trim() || undefined,
|
||||
priority: priority || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-20">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Create Task</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-900/20 border border-red-500/20 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="What needs to be done?"
|
||||
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 outline-none focus:border-blue-500/50 transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Add details…"
|
||||
rows={4}
|
||||
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 outline-none focus:border-blue-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Assignee
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={assignee}
|
||||
onChange={(e) => setAssignee(e.target.value)}
|
||||
placeholder="Username"
|
||||
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-gray-500 outline-none focus:border-blue-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-2">
|
||||
Priority
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[0, 1, 2, 3, 4, 5].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPriority(p)}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
priority === p
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white/5 text-gray-400 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{p === 0 ? 'None' : p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 py-3 bg-white/5 border border-white/10 rounded-lg text-gray-300 font-medium hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="flex-1 py-3 bg-blue-600 rounded-lg text-white font-medium hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
frontend/src/components/KanbanBoard.tsx
Normal file
89
frontend/src/components/KanbanBoard.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { TaskSummary, TaskStatus, ColumnDef } from '@kanban/contracts'
|
||||
import TaskCard from './TaskCard'
|
||||
|
||||
interface Props {
|
||||
tasks: TaskSummary[]
|
||||
columns: ColumnDef[]
|
||||
onTaskTap: (task: TaskSummary) => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export default function KanbanBoard({ tasks, columns, onTaskTap, onRefresh }: Props) {
|
||||
// Group tasks by status
|
||||
const grouped: Record<TaskStatus, TaskSummary[]> = {} as Record<TaskStatus, TaskSummary[]>
|
||||
for (const col of columns) {
|
||||
grouped[col.status] = []
|
||||
}
|
||||
for (const task of tasks) {
|
||||
const status = task.status as TaskStatus
|
||||
if (!grouped[status]) {
|
||||
grouped[status] = []
|
||||
}
|
||||
grouped[status].push(task)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<div className="flex gap-3 p-4 min-w-max pb-20">
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.status}
|
||||
className="flex-shrink-0 w-[280px] flex flex-col"
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="flex items-center justify-between mb-3 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot status={col.status} />
|
||||
<h3 className="font-medium text-sm text-gray-300 uppercase tracking-wider">
|
||||
{col.title}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 bg-white/5 px-2 py-0.5 rounded-full">
|
||||
{grouped[col.status]?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column content */}
|
||||
<div className="flex-1 space-y-2 min-h-[200px]">
|
||||
{grouped[col.status]?.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onTap={() => onTaskTap(task)}
|
||||
/>
|
||||
))}
|
||||
{(!grouped[col.status] || grouped[col.status].length === 0) && (
|
||||
<div className="text-center py-8 text-gray-600 text-sm">
|
||||
No tasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pull to refresh hint */}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="fixed bottom-4 right-4 p-3 bg-blue-600 rounded-full shadow-lg z-10"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: TaskStatus }) {
|
||||
const colors: Record<TaskStatus, string> = {
|
||||
triage: 'bg-gray-400',
|
||||
todo: 'bg-blue-400',
|
||||
ready: 'bg-yellow-400',
|
||||
running: 'bg-green-400',
|
||||
blocked: 'bg-red-400',
|
||||
done: 'bg-emerald-400',
|
||||
archived: 'bg-gray-500',
|
||||
}
|
||||
return <div className={`w-2 h-2 rounded-full ${colors[status] || 'bg-gray-400'}`} />
|
||||
}
|
||||
68
frontend/src/components/TaskCard.tsx
Normal file
68
frontend/src/components/TaskCard.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { TaskSummary, TaskStatus } from '@kanban/contracts'
|
||||
|
||||
interface Props {
|
||||
task: TaskSummary
|
||||
onTap: () => void
|
||||
}
|
||||
|
||||
export default function TaskCard({ task, onTap }: Props) {
|
||||
return (
|
||||
<button
|
||||
onClick={onTap}
|
||||
className="w-full text-left p-3 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors active:scale-[0.98]"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{/* Title */}
|
||||
<h4 className="font-medium text-white text-sm leading-snug line-clamp-2">
|
||||
{task.title}
|
||||
</h4>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<StatusBadge status={task.status as TaskStatus} />
|
||||
{task.assignee && (
|
||||
<span className="text-xs text-gray-400 truncate">
|
||||
@{task.assignee}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
{task.priority != null && task.priority > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(task.priority, 5) }).map((_, i) => (
|
||||
<span key={i} className="text-orange-400 text-xs">▲</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blocked reason */}
|
||||
{task.blockedReason && (
|
||||
<p className="text-xs text-red-400 line-clamp-1">
|
||||
⚠ {task.blockedReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: TaskStatus }) {
|
||||
const config: Record<TaskStatus, { bg: string; text: string; label: string }> = {
|
||||
triage: { bg: 'bg-gray-500/20', text: 'text-gray-300', label: 'Triage' },
|
||||
todo: { bg: 'bg-blue-500/20', text: 'text-blue-300', label: 'To Do' },
|
||||
ready: { bg: 'bg-yellow-500/20', text: 'text-yellow-300', label: 'Ready' },
|
||||
running: { bg: 'bg-green-500/20', text: 'text-green-300', label: 'Running' },
|
||||
blocked: { bg: 'bg-red-500/20', text: 'text-red-300', label: 'Blocked' },
|
||||
done: { bg: 'bg-emerald-500/20', text: 'text-emerald-300', label: 'Done' },
|
||||
archived: { bg: 'bg-gray-600/20', text: 'text-gray-400', label: 'Archived' },
|
||||
}
|
||||
|
||||
const { bg, text, label } = config[status] || config.todo
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${bg} ${text}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
212
frontend/src/components/TaskDetailModal.tsx
Normal file
212
frontend/src/components/TaskDetailModal.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { useState } from 'react'
|
||||
import type { TaskDetail, TaskComment, CommentPayload } from '@kanban/contracts'
|
||||
import { hapticFeedback } from '../telegram'
|
||||
|
||||
interface Props {
|
||||
task: TaskDetail
|
||||
onClose: () => void
|
||||
onAddComment: (payload: CommentPayload) => void
|
||||
}
|
||||
|
||||
export default function TaskDetailModal({ task, onClose, onAddComment }: Props) {
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [showCommentForm, setShowCommentForm] = useState(false)
|
||||
|
||||
const handleSubmitComment = () => {
|
||||
if (!commentText.trim()) return
|
||||
hapticFeedback('medium')
|
||||
onAddComment({ body: commentText.trim() })
|
||||
setCommentText('')
|
||||
setShowCommentForm(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20">
|
||||
{/* Task header */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold text-white leading-snug">
|
||||
{task.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<StatusBadge status={task.status} />
|
||||
{task.assignee && (
|
||||
<span className="text-sm text-gray-400">@{task.assignee}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
{task.priority != null && task.priority > 0 && (
|
||||
<div className="flex items-center gap-1 mt-3">
|
||||
<span className="text-xs text-gray-500">Priority:</span>
|
||||
{Array.from({ length: Math.min(task.priority, 5) }).map((_, i) => (
|
||||
<span key={i} className="text-orange-400">▲</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blocked reason */}
|
||||
{task.blockedReason && (
|
||||
<div className="mt-3 p-3 bg-red-900/20 border border-red-500/20 rounded-lg">
|
||||
<p className="text-sm text-red-300">
|
||||
<span className="font-medium">Blocked:</span> {task.blockedReason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{task.body && (
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-2">Description</h3>
|
||||
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||
{task.body}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments section */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-400">
|
||||
Comments ({task.comments?.length || 0})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowCommentForm(!showCommentForm)}
|
||||
className="text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{showCommentForm ? 'Cancel' : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comment form */}
|
||||
{showCommentForm && (
|
||||
<div className="mb-4 p-3 bg-white/5 rounded-lg border border-white/10">
|
||||
<textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder="Write a comment…"
|
||||
className="w-full bg-transparent border-0 outline-none resize-none text-sm text-white placeholder-gray-500 min-h-[80px]"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setShowCommentForm(false)}
|
||||
className="px-3 py-1.5 text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitComment}
|
||||
disabled={!commentText.trim()}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments list */}
|
||||
{task.comments && task.comments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{task.comments.map((comment, i) => (
|
||||
<CommentItem key={comment.id || i} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
No comments yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
{task.events && task.events.length > 0 && (
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-3">Activity</h3>
|
||||
<div className="space-y-2">
|
||||
{task.events.map((event, i) => (
|
||||
<div key={event.id || i} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-gray-600 mt-0.5">•</span>
|
||||
<div>
|
||||
<span className="text-gray-300">{event.type}</span>
|
||||
{event.message && (
|
||||
<span className="text-gray-500 ml-1">— {event.message}</span>
|
||||
)}
|
||||
{event.createdAt && (
|
||||
<span className="text-gray-600 text-xs ml-2">
|
||||
{new Date(event.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Runs */}
|
||||
{task.runs && task.runs.length > 0 && (
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-3">Runs</h3>
|
||||
<div className="space-y-2">
|
||||
{task.runs.map((run, i) => (
|
||||
<div key={run.id || i} className="p-2 bg-white/5 rounded-lg text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-300">{run.status || 'unknown'}</span>
|
||||
{run.startedAt && (
|
||||
<span className="text-gray-500 text-xs">
|
||||
{new Date(run.startedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommentItem({ comment }: { comment: TaskComment }) {
|
||||
return (
|
||||
<div className="p-3 bg-white/5 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{comment.author || 'Anonymous'}
|
||||
</span>
|
||||
{comment.createdAt && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-300 leading-relaxed">{comment.body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { bg: string; text: string; label: string }> = {
|
||||
triage: { bg: 'bg-gray-500/20', text: 'text-gray-300', label: 'Triage' },
|
||||
todo: { bg: 'bg-blue-500/20', text: 'text-blue-300', label: 'To Do' },
|
||||
ready: { bg: 'bg-yellow-500/20', text: 'text-yellow-300', label: 'Ready' },
|
||||
running: { bg: 'bg-green-500/20', text: 'text-green-300', label: 'Running' },
|
||||
blocked: { bg: 'bg-red-500/20', text: 'text-red-300', label: 'Blocked' },
|
||||
done: { bg: 'bg-emerald-500/20', text: 'text-emerald-300', label: 'Done' },
|
||||
archived: { bg: 'bg-gray-600/20', text: 'text-gray-400', label: 'Archived' },
|
||||
}
|
||||
|
||||
const { bg, text, label } = config[status] || config.todo
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${bg} ${text}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
45
frontend/src/index.css
Normal file
45
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--tg-bg-color: #0f1420;
|
||||
--tg-text-color: #f5f7fb;
|
||||
--tg-hint-color: #9aa4b2;
|
||||
--tg-link-color: #3b82f6;
|
||||
--tg-button-color: #3b82f6;
|
||||
--tg-button-text-color: #ffffff;
|
||||
--tg-secondary-bg-color: #1a1f2e;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--tg-bg-color);
|
||||
color: var(--tg-text-color);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--tg-bg-color, #10131a);
|
||||
color: var(--tg-text-color, #f5f7fb);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { border: 0; border-radius: 12px; padding: .7rem .9rem; cursor: pointer; background: #242b38; color: inherit; display: inline-flex; align-items: center; gap: .4rem; }
|
||||
button:hover { filter: brightness(1.12); }
|
||||
input, textarea, select { width: 100%; border-radius: 12px; border: 1px solid #374151; padding: .75rem; background: #151a23; color: inherit; }
|
||||
textarea { min-height: 110px; resize: vertical; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; background: #0b0e14; border: 1px solid #242b38; border-radius: 12px; padding: .8rem; max-height: 240px; overflow: auto; }
|
||||
|
||||
.app-shell { padding: 1rem; max-width: 1500px; margin: 0 auto; }
|
||||
.topbar { display: flex; justify-content: space-between; gap: 1rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
h1 { margin: 0; font-size: clamp(1.5rem, 5vw, 2.4rem); }
|
||||
h2, h3 { margin: .3rem 0 .8rem; }
|
||||
.eyebrow { color: var(--tg-hint-color, #9aa4b2); margin: 0; text-transform: uppercase; letter-spacing: .12em; font-size: .75rem; }
|
||||
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
|
||||
.primary { background: var(--tg-button-color, #3b82f6); color: var(--tg-button-text-color, white); }
|
||||
.ghost { background: transparent; border: 1px solid #374151; }
|
||||
.error { background: #431d23; color: #fecdd3; border: 1px solid #f43f5e; border-radius: 14px; padding: .8rem 1rem; margin: .8rem 0; }
|
||||
|
||||
.board { display: grid; grid-template-columns: repeat(7, minmax(220px, 1fr)); gap: .9rem; overflow-x: auto; padding-bottom: 1rem; }
|
||||
.column { background: rgba(255,255,255,.045); border: 1px solid rgba(255,255,255,.08); border-radius: 18px; min-height: 50vh; padding: .9rem; }
|
||||
.column h2 { text-transform: lowercase; font-size: 1rem; color: var(--tg-accent-text-color, #93c5fd); }
|
||||
.task-card { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; gap: .35rem .5rem; margin-bottom: .75rem; border: 1px solid rgba(255,255,255,.08); background: #171c26; }
|
||||
.task-card strong { grid-column: 1 / -1; }
|
||||
.task-id, .empty { color: var(--tg-hint-color, #9aa4b2); font-size: .82rem; }
|
||||
.status-pill { border-radius: 999px; background: #1d4ed8; padding: .1rem .5rem; font-size: .78rem; justify-self: end; }
|
||||
.modal-card { position: fixed; z-index: 4; right: 1rem; top: 5rem; width: min(440px, calc(100vw - 2rem)); display: grid; gap: .6rem; padding: 1rem; border-radius: 20px; background: #111827; border: 1px solid #374151; box-shadow: 0 20px 80px rgba(0,0,0,.45); }
|
||||
.drawer { position: fixed; inset: 0 0 0 auto; width: min(560px, 100vw); background: #0f1420; border-left: 1px solid #374151; padding: 1rem; overflow-y: auto; z-index: 3; box-shadow: -20px 0 80px rgba(0,0,0,.35); }
|
||||
.close { float: right; border-radius: 50%; width: 40px; height: 40px; justify-content: center; }
|
||||
.body { color: #d1d5db; line-height: 1.55; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.app-shell { padding: .75rem; }
|
||||
.board { grid-template-columns: repeat(7, 82vw); }
|
||||
.topbar { align-items: stretch; }
|
||||
.topbar .row, .topbar button { width: 100%; justify-content: center; }
|
||||
}
|
||||
|
|
@ -1,31 +1,103 @@
|
|||
// Telegram WebApp integration
|
||||
// Uses window.Telegram.WebApp directly for maximum compatibility
|
||||
// @telegram-apps/sdk-react provides typed wrappers but we keep a thin layer here
|
||||
|
||||
export function initTelegram(): void {
|
||||
const tg = window.Telegram?.WebApp
|
||||
if (tg) {
|
||||
tg.ready()
|
||||
tg.expand()
|
||||
}
|
||||
}
|
||||
|
||||
export function getInitData(): string {
|
||||
return window.Telegram?.WebApp?.initData || ''
|
||||
}
|
||||
|
||||
export function getColorScheme(): 'light' | 'dark' {
|
||||
return window.Telegram?.WebApp?.colorScheme || 'dark'
|
||||
}
|
||||
|
||||
export function getThemeParams(): Record<string, string> {
|
||||
return window.Telegram?.WebApp?.themeParams || {}
|
||||
}
|
||||
|
||||
export function hapticFeedback(type: 'light' | 'medium' | 'heavy' = 'medium'): void {
|
||||
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(type)
|
||||
}
|
||||
|
||||
export function showMainButton(text: string, onClick: () => void): void {
|
||||
const btn = window.Telegram?.WebApp?.MainButton
|
||||
if (btn) {
|
||||
btn.setText(text)
|
||||
btn.show()
|
||||
btn.onClick(onClick)
|
||||
}
|
||||
}
|
||||
|
||||
export function hideMainButton(): void {
|
||||
window.Telegram?.WebApp?.MainButton?.hide()
|
||||
}
|
||||
|
||||
export function showBackButton(onClick: () => void): void {
|
||||
const btn = window.Telegram?.WebApp?.BackButton
|
||||
if (btn) {
|
||||
btn.show()
|
||||
btn.onClick(onClick)
|
||||
}
|
||||
}
|
||||
|
||||
export function hideBackButton(): void {
|
||||
window.Telegram?.WebApp?.BackButton?.hide()
|
||||
}
|
||||
|
||||
export function closeMiniApp(): void {
|
||||
window.Telegram?.WebApp?.close()
|
||||
}
|
||||
|
||||
// Apply Telegram theme CSS variables
|
||||
export function applyTheme(): void {
|
||||
const params = getThemeParams()
|
||||
const root = document.documentElement
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
root.style.setProperty(`--tg-${key.replaceAll('_', '-')}`, value)
|
||||
}
|
||||
})
|
||||
const scheme = getColorScheme()
|
||||
root.dataset.theme = scheme
|
||||
}
|
||||
|
||||
// Augment Window for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp?: {
|
||||
ready?: () => void
|
||||
expand?: () => void
|
||||
initData?: string
|
||||
colorScheme?: 'light' | 'dark'
|
||||
themeParams?: Record<string, string>
|
||||
ready: () => void
|
||||
expand: () => void
|
||||
close: () => void
|
||||
initData: string
|
||||
colorScheme: 'light' | 'dark'
|
||||
themeParams: Record<string, string>
|
||||
HapticFeedback?: {
|
||||
impactOccurred: (type: 'light' | 'medium' | 'heavy') => void
|
||||
notificationOccurred: (type: 'error' | 'success' | 'warning') => void
|
||||
selectionChanged: () => void
|
||||
}
|
||||
MainButton?: {
|
||||
setText: (text: string) => void
|
||||
show: () => void
|
||||
hide: () => void
|
||||
onClick: (cb: () => void) => void
|
||||
offClick: (cb: () => void) => void
|
||||
}
|
||||
BackButton?: {
|
||||
show: () => void
|
||||
hide: () => void
|
||||
onClick: (cb: () => void) => void
|
||||
offClick: (cb: () => void) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTelegramInitData(): string | undefined {
|
||||
return window.Telegram?.WebApp?.initData || undefined
|
||||
}
|
||||
|
||||
export function applyTelegramTheme(): void {
|
||||
const webApp = window.Telegram?.WebApp
|
||||
webApp?.ready?.()
|
||||
webApp?.expand?.()
|
||||
const theme = webApp?.themeParams || {}
|
||||
const root = document.documentElement
|
||||
Object.entries(theme).forEach(([key, value]) => {
|
||||
if (typeof value === 'string') root.style.setProperty(`--tg-${key.replaceAll('_', '-')}`, value)
|
||||
})
|
||||
if (webApp?.colorScheme) root.dataset.theme = webApp.colorScheme
|
||||
}
|
||||
|
||||
export {}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
export type KanbanStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived'
|
||||
|
||||
export type Task = {
|
||||
id: string
|
||||
title: string
|
||||
status: KanbanStatus | string
|
||||
assignee?: string | null
|
||||
priority?: number | null
|
||||
age?: string | null
|
||||
body?: string | null
|
||||
created_at?: number | null
|
||||
started_at?: number | null
|
||||
}
|
||||
|
||||
export type TaskGroups = Record<string, Task[]>
|
||||
|
||||
export type Event = Record<string, unknown> & { kind?: string }
|
||||
export type Run = Record<string, unknown> & { id?: number | string; status?: string }
|
||||
9
frontend/src/vite-env.d.ts
vendored
9
frontend/src/vite-env.d.ts
vendored
|
|
@ -1,3 +1,10 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.css'
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BRIDGE_URL: string
|
||||
readonly VITE_AUTH_TOKEN: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
|
|
|||
8
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -14,8 +14,7 @@
|
|||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@ import react from '@vitejs/plugin-react'
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
setupFiles: './src/setupTests.ts',
|
||||
environment: 'jsdom',
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8000',
|
||||
'/bridge': {
|
||||
target: 'http://127.0.0.1:3456',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
49
packages/bridge/Dockerfile
Normal file
49
packages/bridge/Dockerfile
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
# @kanban/bridge — Multi-stage Docker build
|
||||
# Stage 1: install deps + build TypeScript
|
||||
# Stage 2: production runtime with node
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Stage 1: Build ─────────────────────────────────────────
|
||||
FROM node:22-alpine AS build
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace scaffolding
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY packages/contracts/package.json packages/contracts/
|
||||
COPY packages/bridge/package.json packages/bridge/
|
||||
|
||||
# Install all dependencies (including dev for build)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY packages/contracts/ packages/contracts/
|
||||
COPY packages/bridge/ packages/bridge/
|
||||
|
||||
# Build contracts first (bridge depends on it), then bridge
|
||||
RUN pnpm --filter @kanban/contracts build && \
|
||||
pnpm --filter @kanban/bridge build
|
||||
|
||||
# ── Stage 2: Runtime ───────────────────────────────────────
|
||||
FROM node:22-alpine AS runtime
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace scaffolding
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/pnpm-lock.yaml ./
|
||||
COPY --from=build /app/pnpm-workspace.yaml ./
|
||||
|
||||
# Copy built artifacts + package manifests
|
||||
COPY --from=build /app/packages/contracts/package.json packages/contracts/
|
||||
COPY --from=build /app/packages/contracts/dist packages/contracts/dist/
|
||||
COPY --from=build /app/packages/bridge/package.json packages/bridge/
|
||||
COPY --from=build /app/packages/bridge/dist packages/bridge/dist/
|
||||
|
||||
# Install production dependencies only
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
EXPOSE 3456
|
||||
WORKDIR /app/packages/bridge
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
|
@ -91,9 +91,9 @@ export class HermesKanbanClient {
|
|||
const fullArgs = [
|
||||
'kanban',
|
||||
subcommand,
|
||||
...args,
|
||||
'--board', board ?? this.config.defaultBoard,
|
||||
'--json',
|
||||
...args,
|
||||
];
|
||||
|
||||
const env = {
|
||||
|
|
@ -126,7 +126,7 @@ export class HermesKanbanClient {
|
|||
// ─── Convenience methods (M2 will flesh these out) ────────
|
||||
|
||||
async listBoards(): Promise<Board[]> {
|
||||
return this.exec<Board[]>('boards', []);
|
||||
return this.exec<Board[]>('boards', ['list']);
|
||||
}
|
||||
|
||||
async listTasks(
|
||||
|
|
|
|||
1464
pnpm-lock.yaml
generated
1464
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,3 @@
|
|||
packages:
|
||||
- 'packages/*'
|
||||
# Future: - 'apps/*' for BFF, frontend
|
||||
- 'frontend'
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue