Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
642e5f2
build: add fastapi/uvicorn/sse-starlette, drop direct watchfiles
thalida Jun 3, 2026
4d7afb2
refactor: move scan/cache/clone/media under api/services (no behavior…
thalida Jun 3, 2026
abe0b8e
refactor: env.py -> config.py (live env reads, size constants)
thalida Jun 3, 2026
c848cc2
feat: extract allowed_roots trust model into api/security.py
thalida Jun 3, 2026
41ca34d
feat: Pydantic manifest models (port of types.py)
thalida Jun 3, 2026
667928f
feat: Pydantic response + SSE event models
thalida Jun 3, 2026
d455a33
feat: FastAPI app factory + health/config router + SPA static + Scala…
thalida Jun 3, 2026
edcb623
feat: /api/file router (trust-gated, 413 oversize, text coercion)
thalida Jun 3, 2026
73c970b
feat: /api/commit router (sha validation + multi-root lookup)
thalida Jun 3, 2026
ccd7c0b
feat: source resolution + /api/manifest/signature + cache-delete routes
thalida Jun 3, 2026
e8ff324
feat: SSE /api/manifest stream (errors as events, cancel via is_disco…
thalida Jun 3, 2026
c25282a
test: real-socket SSE disconnect does not hang server
thalida Jun 3, 2026
5433488
refactor: retire hand-rolled server/types/reload; uvicorn entrypoint
thalida Jun 3, 2026
1d60366
build: document uvicorn entrypoint; fix stale server.py static-dir ref
thalida Jun 3, 2026
98f0a13
feat: generate manifest TS types from OpenAPI + compile-time drift guard
thalida Jun 3, 2026
9f3c9f0
feat: frontend manifest stream NDJSON -> EventSource (same ScanStream…
thalida Jun 3, 2026
ff0ea51
docs: describe FastAPI/SSE/Scalar backend; log migration follow-ups
thalida Jun 3, 2026
ccaedd0
fix: make scan.py + cache.py pyright-strict clean (whole project now …
thalida Jun 3, 2026
b9d6033
build: copy app/.npmrc into the web-builder stage before npm ci
thalida Jun 3, 2026
9ccae48
refactor: address code-review feedback on the FastAPI layer
thalida Jun 3, 2026
ba69174
fix: address code-review findings on the FastAPI SSE layer
thalida Jun 10, 2026
ad4a729
refactor: rename SSE events to describe content, not sequence position
thalida Jun 10, 2026
44581bf
refactor: move source resolution into api/services/source.py
thalida Jun 10, 2026
224f15b
refactor: single-source the cache root in config; clone gets CLONES_ROOT
thalida Jun 10, 2026
cc36a94
fix: exclude the SPA static catch-all from the OpenAPI schema/docs
thalida Jun 10, 2026
3f13aa5
test: pin commit author via --author so test_commit_lookup_ok is cont…
thalida Jun 10, 2026
752ad4c
style: prettier-format loadingReactions.ts after the ScanPhase rename
thalida Jun 10, 2026
676a529
build: add ruff (the prettier of Python) + gate it in hook/CI
thalida Jun 10, 2026
edb6697
style: apply ruff format across api/ + scripts/
thalida Jun 10, 2026
4aed7a2
fix: make manifest wire-model optionality match the true wire
thalida Jun 10, 2026
fd69381
feat: stream-gzip the SSE manifest stream (per-event flush)
thalida Jun 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ jobs:
docker compose -f docker-compose.test.yml run --rm pytest \
--cov-report=xml:/srv/api/coverage.xml

- name: Python format check (ruff)
run: docker compose -f docker-compose.test.yml run --rm ruff

- name: Run vitest with coverage
# Override the default compose command (which runs `npm test`) to
# run `npm run coverage` instead. Same apt-get / npm bootstrap as
Expand Down
11 changes: 8 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ FROM node:24-bookworm-slim AS web-builder
ARG NPM_VERSION=11.6.2
RUN npm install -g npm@${NPM_VERSION}
WORKDIR /build
COPY app/package.json app/package-lock.json ./
# .npmrc carries legacy-peer-deps=true (openapi-typescript's stale peer range
# vs TS 6) — it MUST be copied before `npm ci` or resolution fails with ERESOLVE.
COPY app/package.json app/package-lock.json app/.npmrc ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund
COPY app/ ./
Expand Down Expand Up @@ -49,8 +51,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# Python source
COPY api/ ./api/

# Built frontend → /srv/api/static (matches api/server.py default STATIC_DIR
# resolution: Path(__file__).parent / "static"). No env var needed.
# Built frontend → /srv/api/static (matches api/app.py DEFAULT_STATIC_DIR
# resolution: Path(__file__).resolve().parent / "static"). No env var needed.
COPY --from=web-builder /build/dist /srv/api/static

# pyproject.toml uses hatch-vcs (`source = "vcs"`) for dynamic versioning,
Expand Down Expand Up @@ -81,6 +83,9 @@ HEALTHCHECK --interval=10s --timeout=2s --start-period=3s --retries=3 \
# re-sync that re-downloads dev deps and tries to reinstall the console
# script into /srv/.venv/bin (read-only for the non-root runtime user).
# Zombie reaping + signal propagation are handled by Docker's --init.
# `python -m api` launches a single uvicorn process (api.app:app) — single
# process by design, see api/security.py (the allowed_roots trust set is
# in-memory; multi-worker would split it).
ENTRYPOINT ["/srv/.venv/bin/python", "-m", "api"]
CMD ["--port", "8080"]

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ just run # run the local image like an end user

Each worktree gets its own `<slug>.localhost` URL so source-picker recents stay isolated per project in localStorage.

The pre-push hook runs pytest, vitest, eslint, prettier, and typecheck before pushing to origin. Bypass with `git push --no-verify` if needed. Docker must be running.
The pre-push hook runs pytest, ruff (Python format), vitest, eslint, prettier, and typecheck before pushing to origin. Bypass with `git push --no-verify` if needed. Docker must be running. Apply Python formatting with `just fmt`.

The backend is a [FastAPI](https://fastapi.tiangolo.com/) app on uvicorn — a single process by design, since the in-memory scan-root trust set (`api/security.py`) can't be split across workers. Scan progress streams over Server-Sent Events (`GET /api/manifest`). Interactive API docs render at `/api/docs` ([Scalar](https://github.com/scalar/scalar)), with the raw schema at `/api/openapi.json` — the source of truth for the generated frontend wire types (`just gen-types`, guarded against drift by `app/src/types/manifest.contract.ts`).

## Release

Expand Down
88 changes: 20 additions & 68 deletions api/__main__.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
"""api CLI entrypoint.

Surface:
python -m api Serve on :8080.
python -m api --port 8000 Override port.
python -m api --reload Auto-reload on .py changes (dev only).
python -m api --version Print version.
python -m api Serve on :8080 (single uvicorn process).
python -m api --port 8000 Override port.
python -m api --reload Auto-reload on source changes (dev only).
python -m api --version Print version.

The container ENTRYPOINT runs `python -m api`, so this is the only entrypoint
in production. Dev mode uses --reload via docker-compose.dev.yml.

Port + browser-opening logic that lived in the old cli.py is now external:
Docker handles port mapping (-p HOST:CONTAINER), and end users open the URL
themselves.
SINGLE PROCESS by design — see api/security.py (the allowed_roots trust
set is in-memory; multi-worker would split it). No --workers flag.
"""

from __future__ import annotations

import argparse
import os
import signal
import sys
import threading
from typing import Optional

import uvicorn

from api import __version__


Expand All @@ -32,65 +26,23 @@ def _build_parser() -> argparse.ArgumentParser:
description="Visualize a codebase as an isometric 3D city.",
)
p.add_argument("--version", action="version", version=f"codecity {__version__}")
p.add_argument(
"--port",
type=int,
default=8080,
help="HTTP port to listen on (default: 8080).",
)
p.add_argument(
"--reload",
action="store_true",
help="Watch api/**/*.py and re-exec on change (dev only).",
)
p.add_argument("--port", type=int, default=8080, help="HTTP port (default 8080).")
p.add_argument("--host", default="0.0.0.0", help="Bind host (default 0.0.0.0).")
p.add_argument("--reload", action="store_true", help="Auto-reload (dev only).")
return p


def main(argv: Optional[list[str]] = None) -> int:
if argv is None:
argv = sys.argv[1:]
args = _build_parser().parse_args(argv)

if args.reload:
# Defer the import — keeps watchfiles off the cold-start import graph
# for `codecity --version` / `--help` / non-reload runs.
try:
from api._reload import run_with_reload
except ImportError:
print(
"error: --reload is not yet wired up. "
"Use docker compose -f docker-compose.dev.yml up for dev mode.",
file=sys.stderr,
)
return 2
return run_with_reload(port=args.port)

return _serve(port=args.port)


def _serve(port: int) -> int:
from api.server import start_server

_, bound, shutdown = start_server(port=port, host="0.0.0.0")
print(
f"[codecity] listening on http://0.0.0.0:{bound}/",
file=sys.stderr,
flush=True,
args = _build_parser().parse_args(sys.argv[1:] if argv is None else argv)
uvicorn.run(
"api.app:app",
host=args.host,
port=args.port,
reload=args.reload,
reload_dirs=["api"] if args.reload else None,
workers=1,
log_level="info",
)
print("[codecity] Ctrl-C to stop", file=sys.stderr, flush=True)

stop_event = threading.Event()

def _handle_signal(signum: int, _frame: object) -> None:
stop_event.set()

signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)

try:
stop_event.wait()
finally:
shutdown()
return 0


Expand Down
93 changes: 0 additions & 93 deletions api/_reload.py

This file was deleted.

75 changes: 75 additions & 0 deletions api/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""FastAPI app factory.

Order matters: API routers register first, the SPA static catch-all last
(it owns every non-/api path). Swagger + default ReDoc are disabled;
Scalar is mounted at /api/docs and OpenAPI JSON relocated to
/api/openapi.json (the source for the generated TS types)."""

from __future__ import annotations

from pathlib import Path

from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse, JSONResponse

from api.config import GZIP_MIN_BYTES
from api.models.responses import ErrorResponse
from api.routers import commit, file, manifest, meta
from api.sse_compression import SSEGZipMiddleware
from api.static import make_static_router

DEFAULT_STATIC_DIR = Path(__file__).resolve().parent / "static"

_SCALAR_HTML = """<!doctype html><html><head><title>CodeCity API</title>
<meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
</head><body><script id="api-reference" data-url="/api/openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body></html>"""


def _scalar_docs() -> HTMLResponse:
"""Serve the Scalar API-reference UI at /api/docs."""
return HTMLResponse(_SCALAR_HTML)


async def _api_error_handler(_request: Request, exc: Exception) -> JSONResponse:
"""Render HTTPExceptions as a uniform ErrorResponse JSON body (so an
unknown /api/* path is a 404 JSON, not HTML)."""
status = exc.status_code if isinstance(exc, HTTPException) else 500
detail = exc.detail if isinstance(exc, HTTPException) else "internal server error"
return JSONResponse(
status_code=status, content=ErrorResponse(error=detail).model_dump()
)


def create_app(static_dir: Path | None = None) -> FastAPI:
# NB: the process-global TRUST set is intentionally NOT reset here — the
# factory must be side-effect-free on session auth state. A fresh process
# starts with an empty TRUST; tests isolate it via an autouse fixture.
app = FastAPI(
title="CodeCity API",
docs_url=None, # disable Swagger UI
redoc_url=None, # disable default ReDoc
openapi_url="/api/openapi.json",
)
# GZip compresses ordinary responses (it skips text/event-stream); the SSE
# middleware stream-gzips the manifest event stream with per-event flush.
app.add_middleware(GZipMiddleware, minimum_size=GZIP_MIN_BYTES)
app.add_middleware(SSEGZipMiddleware)

# Registered by reference (not as decorated nested functions) so they are
# plain module-level handlers — no pyright reportUnusedFunction ignore.
app.add_api_route("/api/docs", _scalar_docs, include_in_schema=False)
app.add_exception_handler(HTTPException, _api_error_handler)

app.include_router(meta.router)
app.include_router(file.router)
app.include_router(commit.router)
app.include_router(manifest.router)
app.include_router(make_static_router(static_dir or DEFAULT_STATIC_DIR))
return app


# Module-level instance for `uvicorn api.app:app` (prod + --reload).
app = create_app()
47 changes: 47 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Process configuration: env-driven flags and size limits.

Replaces the old api/env.py. Functions that read env are intentionally
LIVE (re-read per call) so tests can monkeypatch os.environ without a
restart — notably CODECITY_ALLOW_LOCAL_REPOS, which gates local scans.
"""

from __future__ import annotations

import os
from pathlib import Path

# Cap individual /api/file responses (stray symlink to a giant blob).
MAX_FILE_BYTES = 100 * 1024 * 1024
# Bodies under this skip gzip — framing overhead exceeds the savings.
GZIP_MIN_BYTES = 256

# Root for every on-disk cache — the single source of truth for where codecity
# stores things. cache.py hangs its manifest/file-stat/git-history subdirs off
# this; clone.py its `clones/` dir. Read once at import (a fixed location, not a
# live flag); override with CODECITY_CACHE_ROOT (e.g. an XDG dir or a writable
# mount in containers). Tests monkeypatch the per-module copies.
CACHE_ROOT = Path(
os.environ.get("CODECITY_CACHE_ROOT") or Path.home() / ".cache" / "codecity"
)

# Permissive truthy set (case-insensitive, trimmed) — matches the prior
# api/env.py semantics so e.g. `-e CODECITY_FOO=yes` keeps working.
_TRUTHY = frozenset({"1", "true", "yes", "on"})


def env_bool(name: str, default: bool = False) -> bool:
"""True if env var `name` is a truthy string (1/true/yes/on, any case)."""
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in _TRUTHY


def local_repos_allowed() -> bool:
"""Live read of CODECITY_ALLOW_LOCAL_REPOS (re-read per call)."""
return env_bool("CODECITY_ALLOW_LOCAL_REPOS")


def quiet() -> bool:
"""Live read of CODECITY_QUIET — silences disconnect/scan logs."""
return env_bool("CODECITY_QUIET")
Loading