Compare commits

...

8 Commits

Author SHA1 Message Date
alex 78aaddb2b5 docs(openspec): add OpenSpec changes for FN-005, FN-006, FN-008, FN-009, FN-010
CI / Web CI (push) Failing after 12s
CI / API CI (push) Failing after 1m1s
- Add frontend-foundation change (FN-005) with 46 tasks
- Add deployment-config change (FN-006) with 27 tasks
- Add runfusion-poc/opencode-poc change (FN-008) with 25 tasks
- Add config-secrets change (FN-009) with 31 tasks
- Add codeserver-spawn change (FN-010) with 38 tasks
- Include project specsheet and configuration
- Archive completed deployment-config change
2026-05-14 17:35:20 +02:00
alex 1539a67883 docs: update README with OpenCode references and deployment guide
- Replace RunFusion with OpenCode in description
- Add frontend environment variables section
- Add deployment quick start guide
- Update scope boundaries with current task status
2026-05-14 17:32:02 +02:00
alex 85ae390263 chore: add frontend dependencies and update test fixtures
- Add react-router-dom, @tanstack/react-query, zustand, @headlessui/react
- Update pnpm workspace configuration
- Update test fixtures to reference OpenCode instead of RunFusion
- Add frontend environment variable examples
- Update .gitignore for .opencode and .sisyphus directories
2026-05-14 17:30:41 +02:00
alex 62640daf36 docs: update all documentation for OpenCode and deployment
- Update architecture.md with spawn service and auth proxy sections
- Update deployment.md with production stack details
- Update development.md with spawn workflow documentation
- Update mvp-scope.md, project-brief.md, tool-manifest-spec.md
- Update conversation-handoff.md with current status
- Replace all RunFusion references with OpenCode
2026-05-14 17:30:03 +02:00
alex 139654d5c0 feat(FN-006): add production deployment configuration
- Create docker-compose.prod.yml with Traefik, API, web, and DB services
- Add Portainer stack deployment files
- Configure Let's Encrypt TLS and health checks
- Add deployment environment variable examples
- Update portainer.env.example with additional config vars
2026-05-14 17:28:56 +02:00
alex 6f18dd24a5 feat(FN-005): implement frontend foundation with auth and routing
- Add OIDC authentication with PKCE flow
- Create dashboard shell with sidebar and header
- Implement project management UI (list, create, detail)
- Add tool spawn page with tool/project selection
- Create tool instance detail page with status and controls
- Set up React Router with route guards
- Add Zustand auth store and API client with types
2026-05-14 17:28:15 +02:00
alex 6aea953734 feat(FN-010): implement code-server spawn service with Docker Compose
- Add SpawnService with container lifecycle (spawn/stop/status)
- Generate Docker Compose services from tool manifests
- Integrate Traefik label generation with subdomain routing
- Mount workspace, config, and SSH key volumes
- Add container status polling and health checks
- Enhance tool instance API with spawn/stop/start/status endpoints
- Add Traefik forwardAuth middleware for auth proxy
- Update code-server manifest with runtime configuration
2026-05-14 17:27:19 +02:00
alex ca18e25d8d feat(FN-008): replace RunFusion with OpenCode manifest
- Remove runfusion.yml, add opencode.yml with web terminal config
- Update all references across codebase (tests, docs, specs)
- Add OpenCode container setup with port 3000 and health checks
2026-05-14 17:26:25 +02:00
97 changed files with 4983 additions and 200 deletions
+5
View File
@@ -55,3 +55,8 @@ docker-volumes/
.temp/
tmp/
.local-bin/
# OpenCode / Sisyphus
.opencode/
.sisyphus/
AGENTS.md
+33 -3
View File
@@ -1,6 +1,6 @@
# Headquarter
Hosted workspace and tool-orchestration platform where authenticated users create projects, connect Git repositories, and spawn self-hosted tools such as RunFusion and code-server.
Hosted workspace and tool-orchestration platform where authenticated users create projects, connect Git repositories, and spawn self-hosted tools such as OpenCode and code-server.
## Current Status
@@ -8,7 +8,7 @@ This repository provides:
- React + Vite + TypeScript frontend (`apps/web`)
- FastAPI + Python backend (`apps/api`)
- Manifest-driven tool registry with built-in RunFusion and code-server definitions
- Manifest-driven tool registry with built-in OpenCode and code-server definitions
- Root monorepo tooling (pnpm workspace, Makefile)
- Docker Compose local development stack
- Deployment skeleton for Portainer + Traefik
@@ -90,6 +90,36 @@ See [Development](docs/development.md) for details on running these checks local
- [Deployment](docs/deployment.md) — Portainer/Traefik assumptions
- [Deploy Skeleton](deploy/README.md) — Deployment file reference
## Frontend Environment Variables
The frontend (`apps/web`) requires these environment variables:
| Variable | Description |
|----------|-------------|
| `VITE_API_URL` | Backend API base URL |
| `VITE_OIDC_ISSUER` | OIDC provider issuer URL |
| `VITE_OIDC_CLIENT_ID` | OIDC client ID |
| `VITE_OIDC_REDIRECT_URI` | Post-login redirect URL |
Copy `apps/web/.env.example` to `apps/web/.env` and fill in your values.
## Deployment
Deploy to production using Docker Compose:
```bash
# Copy and configure production environment
cp deploy/.env.example deploy/.env
# Edit deploy/.env with your domain and secrets
# Deploy locally for testing
docker compose -f docker-compose.prod.yml up --build -d
# Or deploy via Portainer using deploy/portainer-stack.yml
```
See [Deployment Guide](docs/deployment.md) for full details.
## Scope Boundaries
This scaffold intentionally defers detailed implementation to follow-up tasks:
@@ -99,7 +129,7 @@ This scaffold intentionally defers detailed implementation to follow-up tasks:
- **FN-006** — Full deployment automation, dynamic Traefik labels for spawned tool containers
- **FN-003** — Manifest-driven tool registry
- **FN-007** — Provider-independent Git connection model
- **FN-008** — RunFusion executable environment proof of concept
- **FN-008** — OpenCode terminal environment proof of concept
- **FN-009** — Persistent config and secrets handling
- **FN-010** — code-server manifest and spawn flow
+43
View File
@@ -91,3 +91,46 @@ async def get_current_active_user(
detail="Inactive user",
)
return current_user
async def validate_traefik_auth(
token: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: AsyncSession = Depends(get_db_session),
) -> User:
if token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
try:
claims = decode_token(token.credentials)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {exc}",
headers={"WWW-Authenticate": "Bearer"},
) from exc
authentik_sub = claims.get("sub")
if not authentik_sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing 'sub' claim",
headers={"WWW-Authenticate": "Bearer"},
)
result = await session.execute(
select(User).where(User.authentik_sub == authentik_sub)
)
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
headers={"WWW-Authenticate": "Bearer"},
)
return user
+3
View File
@@ -34,6 +34,9 @@ class ToolInstance(Base, UUIDMixin, TimestampMixin):
config_override: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
traefik_labels: Mapped[dict[str, Any] | None] = mapped_column(
JSON, nullable=True
)
project: Mapped["Project"] = relationship(
back_populates="tool_instances"
+236 -9
View File
@@ -5,11 +5,16 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_active_user
from app.config import settings
from app.db import get_db_session
from app.models.project import Project
from app.models.tool_definition import ToolDefinition
from app.models.tool_instance import ToolInstance
from app.models.user import User
from app.schemas.tool_instance import ToolInstanceCreate, ToolInstanceRead, ToolInstanceUpdate
from app.services.spawn import SpawnError, SpawnService
from app.services.traefik import TraefikLabelGenerator
from app.tools.registry import registry
router = APIRouter(tags=["tool-instances"])
@@ -23,22 +28,95 @@ async def _get_project_for_user(
return project
@router.post("/projects/{project_id}/tool-instances", response_model=ToolInstanceRead, status_code=status.HTTP_201_CREATED) # noqa: E501
def _get_user_slug(user: User) -> str:
user_slug = (
user.display_name
or user.email.split("@")[0]
if user.email
else "user"
)
return user_slug.lower().replace(" ", "-").replace("_", "-")
@router.post(
"/projects/{project_id}/tool-instances",
response_model=ToolInstanceRead,
status_code=status.HTTP_201_CREATED,
)
async def create_tool_instance(
project_id: UUID,
ti_in: ToolInstanceCreate,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
project = await _get_project_for_user(project_id, current_user, session)
tool_def = await session.get(ToolDefinition, ti_in.tool_definition_id)
if not tool_def:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool definition not found",
)
manifest = registry.get(tool_def.key)
if not manifest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool manifest '{tool_def.key}' not found in registry",
)
existing = await session.execute(
select(ToolInstance).where(
ToolInstance.project_id == project_id,
ToolInstance.tool_definition_id == ti_in.tool_definition_id,
ToolInstance.status.in_(["creating", "running"]),
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A running instance of this tool already exists for this project",
)
ti = ToolInstance(**ti_in.model_dump(), project_id=project_id)
user_slug = _get_user_slug(current_user)
spawn_service = SpawnService()
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
try:
spawn_result = spawn_service.spawn(
instance_id=str(ti.id),
manifest=manifest,
project_slug=project.slug,
user_slug=user_slug,
)
except SpawnError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to spawn container: {e}",
) from e
auth_labels = label_gen.generate_forward_auth_labels(
instance_id=str(ti.id),
auth_url=f"https://{settings.root_domain}/api/v1/auth/validate",
)
traefik_labels = {**spawn_result["traefik_labels"], **auth_labels}
ti.container_id = spawn_result["container_id"]
ti.subdomain = spawn_result["subdomain"]
ti.traefik_labels = traefik_labels
ti.status = spawn_service.get_status(str(ti.id))
session.add(ti)
await session.commit()
await session.refresh(ti)
return ti
@router.get("/projects/{project_id}/tool-instances", response_model=list[ToolInstanceRead])
@router.get(
"/projects/{project_id}/tool-instances",
response_model=list[ToolInstanceRead],
)
async def list_tool_instances(
project_id: UUID,
current_user: User = Depends(get_current_active_user),
@@ -51,7 +129,10 @@ async def list_tool_instances(
return list(result.scalars().all())
@router.get("/projects/{project_id}/tool-instances/{instance_id}", response_model=ToolInstanceRead)
@router.get(
"/projects/{project_id}/tool-instances/{instance_id}",
response_model=ToolInstanceRead,
)
async def get_tool_instance(
project_id: UUID,
instance_id: UUID,
@@ -61,11 +142,17 @@ async def get_tool_instance(
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
return ti
@router.put("/projects/{project_id}/tool-instances/{instance_id}", response_model=ToolInstanceRead)
@router.put(
"/projects/{project_id}/tool-instances/{instance_id}",
response_model=ToolInstanceRead,
)
async def update_tool_instance(
project_id: UUID,
instance_id: UUID,
@@ -76,7 +163,10 @@ async def update_tool_instance(
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
update_data = ti_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(ti, field, value)
@@ -85,7 +175,10 @@ async def update_tool_instance(
return ti
@router.delete("/projects/{project_id}/tool-instances/{instance_id}", status_code=status.HTTP_204_NO_CONTENT) # noqa: E501
@router.delete(
"/projects/{project_id}/tool-instances/{instance_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_tool_instance(
project_id: UUID,
instance_id: UUID,
@@ -95,6 +188,140 @@ async def delete_tool_instance(
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool instance not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
spawn_service = SpawnService()
spawn_service.stop(str(instance_id))
await session.delete(ti)
await session.commit()
@router.post(
"/projects/{project_id}/tool-instances/{instance_id}/stop",
response_model=ToolInstanceRead,
)
async def stop_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
spawn_service = SpawnService()
spawn_service.stop(str(instance_id))
ti.status = "stopped"
ti.container_id = None
await session.commit()
await session.refresh(ti)
return ti
@router.post(
"/projects/{project_id}/tool-instances/{instance_id}/start",
response_model=ToolInstanceRead,
)
async def start_tool_instance(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> ToolInstance:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
tool_def = await session.get(ToolDefinition, ti.tool_definition_id)
if not tool_def:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool definition not found",
)
manifest = registry.get(tool_def.key)
if not manifest:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool manifest '{tool_def.key}' not found in registry",
)
user_slug = _get_user_slug(current_user)
spawn_service = SpawnService()
try:
spawn_result = spawn_service.spawn(
instance_id=str(ti.id),
manifest=manifest,
project_slug=ti.project.slug,
user_slug=user_slug,
)
except SpawnError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to spawn container: {e}",
) from e
ti.container_id = spawn_result["container_id"]
ti.subdomain = spawn_result["subdomain"]
ti.traefik_labels = spawn_result["traefik_labels"]
ti.status = spawn_service.get_status(str(ti.id))
await session.commit()
await session.refresh(ti)
return ti
@router.get(
"/projects/{project_id}/tool-instances/{instance_id}/status",
response_model=dict,
)
async def get_tool_instance_status(
project_id: UUID,
instance_id: UUID,
current_user: User = Depends(get_current_active_user),
session: AsyncSession = Depends(get_db_session),
) -> dict[str, str]:
await _get_project_for_user(project_id, current_user, session)
ti = await session.get(ToolInstance, instance_id)
if not ti or ti.project_id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tool instance not found",
)
spawn_service = SpawnService()
container_status = spawn_service.get_status(str(instance_id))
if ti.status != container_status:
ti.status = container_status
await session.commit()
return {
"instance_id": str(instance_id),
"status": container_status,
"subdomain": ti.subdomain or "",
"container_id": ti.container_id or "",
}
@router.get("/auth/validate", status_code=status.HTTP_200_OK)
async def validate_auth_for_traefik(
current_user: User = Depends(get_current_active_user),
) -> dict[str, str]:
return {"status": "ok", "user_id": str(current_user.id)}
+2
View File
@@ -10,6 +10,7 @@ class ToolInstanceBase(OrmBase):
container_id: str | None = None
subdomain: str | None = None
config_override: dict[str, Any] | None = None
traefik_labels: dict[str, Any] | None = None
class ToolInstanceCreate(ToolInstanceBase):
@@ -28,3 +29,4 @@ class ToolInstanceUpdate(OrmBase):
container_id: str | None = None
subdomain: str | None = None
config_override: dict[str, Any] | None = None
traefik_labels: dict[str, Any] | None = None
+333
View File
@@ -0,0 +1,333 @@
from __future__ import annotations
import json
import logging
import subprocess
from pathlib import Path
from typing import Any
from app.config import settings
from app.services.traefik import TraefikLabelGenerator
from app.tools.models import ToolManifest
logger = logging.getLogger(__name__)
class SpawnError(Exception):
pass
class SpawnService:
def __init__(
self,
compose_dir: Path | None = None,
network_name: str = "tools",
) -> None:
self.compose_dir = compose_dir or Path("/tmp/headquarter-compose")
self.network_name = network_name
self.compose_dir.mkdir(parents=True, exist_ok=True)
def _generate_compose_service(
self,
instance_id: str,
manifest: ToolManifest,
subdomain: str,
traefik_labels: dict[str, str],
project_slug: str,
user_slug: str,
workspace_path: Path | None = None,
config_path: Path | None = None,
ssh_key_path: Path | None = None,
) -> dict[str, Any]:
service_name = f"tool-{instance_id[:8]}"
service: dict[str, Any] = {
"image": manifest.image,
"container_name": service_name,
"restart": "unless-stopped",
"labels": traefik_labels,
"networks": [self.network_name],
}
if manifest.runtime_command:
service["command"] = manifest.runtime_command
if manifest.runtime_entrypoint:
service["entrypoint"] = manifest.runtime_entrypoint
if manifest.runtime_user:
service["user"] = manifest.runtime_user
if manifest.runtime_working_dir:
service["working_dir"] = manifest.runtime_working_dir
ports = manifest.ports
if ports:
service["ports"] = [
f"{port.container_port}:{port.container_port}"
for port in ports
]
env = dict(manifest.env)
env.update({
"PROJECT_SLUG": project_slug,
"USER_SLUG": user_slug,
})
service["environment"] = env
volumes: list[str] = []
default_workspace = f"/data/workspaces/{user_slug}/{project_slug}"
for mount in manifest.workspace_mounts:
source = mount.source_pattern.format(
project_repo=str(workspace_path) if workspace_path else default_workspace,
)
ro_suffix = ":ro" if mount.read_only else ""
volumes.append(f"{source}:{mount.target}{ro_suffix}")
default_config = f"/data/configs/{user_slug}"
for mount in manifest.config_mounts:
source = mount.source_pattern.format(
user_config=str(config_path) if config_path else default_config,
)
ro_suffix = ":ro" if mount.read_only else ""
volumes.append(f"{source}:{mount.target}{ro_suffix}")
if ssh_key_path and ssh_key_path.exists():
volumes.append(f"{ssh_key_path}:/home/coder/.ssh:ro")
if volumes:
service["volumes"] = volumes
if manifest.health_check:
hc = manifest.health_check
healthcheck: dict[str, Any] = {
"interval": f"{hc.interval_seconds}s",
"timeout": f"{hc.timeout_seconds}s",
"retries": hc.retries,
"start_period": f"{hc.start_period_seconds}s",
}
if hc.type == "http":
healthcheck["test"] = [
"CMD",
"curl",
"-f",
f"http://localhost:{hc.port}{hc.path}",
]
elif hc.type == "tcp":
healthcheck["test"] = [
"CMD",
"nc",
"-z",
"localhost",
str(hc.port),
]
elif hc.type == "command":
healthcheck["test"] = ["CMD"] + (hc.command or [])
service["healthcheck"] = healthcheck
if manifest.resource_limits:
rl = manifest.resource_limits
deploy: dict[str, Any] = {"resources": {"limits": {}}}
if rl.cpus:
deploy["resources"]["limits"]["cpus"] = str(rl.cpus)
if rl.memory_mb:
deploy["resources"]["limits"]["memory"] = f"{rl.memory_mb}M"
if rl.memory_swap_mb is not None and rl.memory_swap_mb >= 0:
deploy["resources"]["limits"]["swap"] = f"{rl.memory_swap_mb}M"
service["deploy"] = deploy
return service
def _write_compose_file(
self,
instance_id: str,
service: dict[str, Any],
) -> Path:
compose_path = self.compose_dir / f"{instance_id}.yml"
compose = {
"version": "3.8",
"services": {f"tool-{instance_id[:8]}": service},
"networks": {
self.network_name: {
"external": True,
},
},
}
compose_path.write_text(json.dumps(compose, indent=2))
return compose_path
def spawn(
self,
instance_id: str,
manifest: ToolManifest,
project_slug: str,
user_slug: str,
workspace_path: Path | None = None,
config_path: Path | None = None,
ssh_key_path: Path | None = None,
) -> dict[str, Any]:
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
primary_port = next(
(p.container_port for p in manifest.ports if p.primary),
manifest.ports[0].container_port if manifest.ports else 8080,
)
subdomain = label_gen.generate_subdomain(
tool_key=manifest.id,
project_slug=project_slug,
user_slug=user_slug,
)
traefik_labels = label_gen.generate_labels(
instance_id=instance_id,
tool_key=manifest.id,
project_slug=project_slug,
user_slug=user_slug,
container_port=primary_port,
network_name=self.network_name,
)
service = self._generate_compose_service(
instance_id=instance_id,
manifest=manifest,
subdomain=subdomain,
traefik_labels=traefik_labels,
project_slug=project_slug,
user_slug=user_slug,
workspace_path=workspace_path,
config_path=config_path,
ssh_key_path=ssh_key_path,
)
compose_path = self._write_compose_file(instance_id, service)
try:
result = subprocess.run(
[
"docker", "compose",
"-f", str(compose_path),
"-p", f"hq-tool-{instance_id[:8]}",
"up", "-d", "--remove-orphans",
],
capture_output=True,
text=True,
check=True,
)
logger.info("Spawned container for instance %s: %s", instance_id, result.stdout)
except subprocess.CalledProcessError as e:
logger.error("Failed to spawn container for instance %s: %s", instance_id, e.stderr)
raise SpawnError(f"Failed to spawn container: {e.stderr}") from e
container_id = self._get_container_id(instance_id)
return {
"container_id": container_id,
"subdomain": subdomain,
"traefik_labels": traefik_labels,
"compose_path": str(compose_path),
}
def stop(self, instance_id: str) -> None:
compose_path = self.compose_dir / f"{instance_id}.yml"
if not compose_path.exists():
logger.warning("Compose file not found for instance %s", instance_id)
return
try:
subprocess.run(
[
"docker", "compose",
"-f", str(compose_path),
"-p", f"hq-tool-{instance_id[:8]}",
"down",
],
capture_output=True,
text=True,
check=True,
)
logger.info("Stopped container for instance %s", instance_id)
except subprocess.CalledProcessError as e:
logger.error("Failed to stop container for instance %s: %s", instance_id, e.stderr)
raise SpawnError(f"Failed to stop container: {e.stderr}") from e
def get_status(self, instance_id: str) -> str:
container_id = self._get_container_id(instance_id)
if not container_id:
return "stopped"
try:
result = subprocess.run(
[
"docker", "inspect",
"-f", "{{.State.Status}}",
container_id,
],
capture_output=True,
text=True,
check=True,
)
status = result.stdout.strip()
if status == "running":
health = self._get_health_status(container_id)
if health == "healthy":
return "running"
elif health == "unhealthy":
return "error"
else:
return "creating"
elif status in ("exited", "dead"):
return "stopped"
elif status == "paused":
return "stopped"
else:
return "creating"
except subprocess.CalledProcessError:
return "stopped"
def _get_container_id(self, instance_id: str) -> str | None:
service_name = f"tool-{instance_id[:8]}"
project_name = f"hq-tool-{instance_id[:8]}"
try:
result = subprocess.run(
[
"docker", "compose",
"-p", project_name,
"ps", "-q", service_name,
],
capture_output=True,
text=True,
check=True,
)
container_id = result.stdout.strip()
return container_id if container_id else None
except subprocess.CalledProcessError:
return None
def _get_health_status(self, container_id: str) -> str | None:
try:
result = subprocess.run(
[
"docker", "inspect",
"-f", "{{.State.Health.Status}}",
container_id,
],
capture_output=True,
text=True,
check=True,
)
status = result.stdout.strip()
return status if status else None
except subprocess.CalledProcessError:
return None
+101
View File
@@ -0,0 +1,101 @@
class TraefikLabelGenerator:
def __init__(self, domain: str, entrypoint: str = "websecure"):
self.domain = domain
self.entrypoint = entrypoint
def generate_subdomain(
self,
tool_key: str,
project_slug: str,
user_slug: str,
) -> str:
return f"{tool_key}-{project_slug}-{user_slug}.{self.domain}"
def generate_labels(
self,
instance_id: str,
tool_key: str,
project_slug: str,
user_slug: str,
container_port: int,
network_name: str = "tools",
) -> dict[str, str]:
subdomain = self.generate_subdomain(tool_key, project_slug, user_slug)
router_name = f"tool-{instance_id[:8]}"
service_name = f"tool-{instance_id[:8]}"
labels: dict[str, str] = {}
labels["traefik.enable"] = "true"
labels[f"traefik.http.routers.{router_name}.rule"] = (
f"Host(`{subdomain}`)"
)
labels[f"traefik.http.routers.{router_name}.entrypoints"] = (
self.entrypoint
)
labels[f"traefik.http.routers.{router_name}.service"] = service_name
if self.entrypoint == "websecure":
labels[f"traefik.http.routers.{router_name}.tls"] = "true"
labels[
f"traefik.http.routers.{router_name}.tls.certresolver"
] = "letsencrypt"
labels[f"traefik.http.services.{service_name}.loadbalancer.server.port"] = (
str(container_port)
)
labels[f"traefik.http.services.{service_name}.loadbalancer.server.scheme"] = (
"http"
)
middleware_name = f"tool-{instance_id[:8]}-sec"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.stsSeconds"
] = "31536000"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.stsIncludeSubdomains"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.forceStsHeader"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.contentTypeNosniff"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.browserXssFilter"
] = "true"
labels[
f"traefik.http.middlewares.{middleware_name}.headers.customFrameOptionsValue"
] = "SAMEORIGIN"
labels[f"traefik.http.routers.{router_name}.middlewares"] = middleware_name
labels["traefik.docker.network"] = network_name
return labels
def generate_forward_auth_labels(
self,
instance_id: str,
auth_url: str,
) -> dict[str, str]:
router_name = f"tool-{instance_id[:8]}"
middleware_name = f"tool-{instance_id[:8]}-auth"
return {
f"traefik.http.middlewares.{middleware_name}.forwardauth.address": auth_url,
f"traefik.http.middlewares.{middleware_name}.forwardauth.trustForwardHeader": "true",
f"traefik.http.routers.{router_name}.middlewares": middleware_name,
}
def generate_removal_labels(
self,
instance_id: str,
) -> dict[str, str]:
router_name = f"tool-{instance_id[:8]}"
return {
"traefik.enable": "false",
f"traefik.http.routers.{router_name}.rule": "",
}
+13 -5
View File
@@ -3,6 +3,15 @@ name: code-server
description: VS Code in the browser.
version: "1.0.0"
image: codercom/code-server:latest
runtime_command:
- "--bind-addr"
- "0.0.0.0:8080"
- "--auth"
- "none"
- "--disable-telemetry"
- "--disable-update-check"
runtime_entrypoint: []
runtime_user: "coder"
runtime_working_dir: /workspace
ports:
- container_port: 8080
@@ -19,11 +28,10 @@ config_mounts:
source_pattern: "{user_config}/code-server"
target: /home/coder/.config/code-server
read_only: false
env: {}
secrets:
- name: code-server-password
env_var: PASSWORD
required: false
env:
PASSWORD: ""
SUDO_PASSWORD: ""
secrets: []
health_check:
type: http
path: /healthz
@@ -1,11 +1,11 @@
id: runfusion
name: RunFusion
description: Executable Node.js environment for running and developing applications.
id: opencode
name: OpenCode
description: AI-powered terminal-based development environment with web interface.
version: "1.0.0"
image: node:22-slim
image: ghcr.io/opencode-ai/opencode:latest
runtime_working_dir: /workspace
ports:
- container_port: 8080
- container_port: 3000
protocol: tcp
name: http
primary: true
@@ -16,31 +16,27 @@ workspace_mounts:
read_only: false
config_mounts:
- type: volume
source_pattern: "{user_config}/runfusion"
target: /home/node/.config
source_pattern: "{user_config}/opencode"
target: /root/.config/opencode
read_only: false
env:
NODE_ENV: development
TERM: xterm-256color
FORCE_COLOR: "1"
health_check:
type: http
path: /
port: 8080
port: 3000
interval_seconds: 10
timeout_seconds: 5
retries: 3
start_period_seconds: 10
start_period_seconds: 15
resource_limits:
cpus: 2.0
memory_mb: 2048
memory_mb: 4096
memory_swap_mb: -1
executable:
node_version: "22"
package_manager: npm
bootstrap_commands: []
install_commands: []
traefik:
enabled: true
subdomain_prefix: runfusion
port: 8080
subdomain_prefix: opencode
port: 3000
middlewares: []
strip_prefix: false
@@ -6,7 +6,11 @@ from httpx import AsyncClient
async def test_tool_definition_crud(auth_client: AsyncClient) -> None:
resp = await auth_client.post(
"/api/v1/tool-definitions",
json={"key": "runfusion", "name": "RunFusion", "image": "runfusion:latest"},
json={
"key": "opencode",
"name": "OpenCode",
"image": "ghcr.io/opencode-ai/opencode:latest",
},
)
assert resp.status_code == 201
td_id = resp.json()["id"]
@@ -18,9 +22,9 @@ async def test_tool_definition_crud(auth_client: AsyncClient) -> None:
resp = await auth_client.get(f"/api/v1/tool-definitions/{td_id}")
assert resp.status_code == 200
resp = await auth_client.put(f"/api/v1/tool-definitions/{td_id}", json={"name": "RunFusionV2"})
resp = await auth_client.put(f"/api/v1/tool-definitions/{td_id}", json={"name": "OpenCodeV2"})
assert resp.status_code == 200
assert resp.json()["name"] == "RunFusionV2"
assert resp.json()["name"] == "OpenCodeV2"
resp = await auth_client.delete(f"/api/v1/tool-definitions/{td_id}")
assert resp.status_code == 204
+197
View File
@@ -0,0 +1,197 @@
from app.services.traefik import TraefikLabelGenerator
class TestTraefikLabelGenerator:
def test_generate_subdomain(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
subdomain = gen.generate_subdomain(
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
)
assert subdomain == "code-server-my-project-alice.hq.example.com"
def test_generate_subdomain_with_different_domain(self):
gen = TraefikLabelGenerator(domain="tools.localhost")
subdomain = gen.generate_subdomain(
tool_key="opencode",
project_slug="test",
user_slug="bob",
)
assert subdomain == "opencode-test-bob.tools.localhost"
def test_generate_labels_basic(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.enable"] == "true"
assert "Host(`code-server-my-project-alice.hq.example.com`)" in labels[
"traefik.http.routers.tool-abc12345.rule"
]
assert labels["traefik.http.routers.tool-abc12345.entrypoints"] == "websecure"
assert labels["traefik.http.routers.tool-abc12345.service"] == "tool-abc12345"
def test_generate_labels_tls(self):
gen = TraefikLabelGenerator(domain="hq.example.com", entrypoint="websecure")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.http.routers.tool-abc12345.tls"] == "true"
assert (
labels["traefik.http.routers.tool-abc12345.tls.certresolver"]
== "letsencrypt"
)
def test_generate_labels_no_tls_for_http(self):
gen = TraefikLabelGenerator(domain="hq.example.com", entrypoint="web")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert "traefik.http.routers.tool-abc12345.tls" not in labels
assert "traefik.http.routers.tool-abc12345.tls.certresolver" not in labels
def test_generate_labels_service_config(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert (
labels["traefik.http.services.tool-abc12345.loadbalancer.server.port"]
== "8443"
)
assert (
labels["traefik.http.services.tool-abc12345.loadbalancer.server.scheme"]
== "http"
)
def test_generate_labels_security_headers(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
middleware_name = "tool-abc12345-sec"
assert (
labels[f"traefik.http.middlewares.{middleware_name}.headers.stsSeconds"]
== "31536000"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.stsIncludeSubdomains"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.forceStsHeader"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.contentTypeNosniff"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.browserXssFilter"
]
== "true"
)
assert (
labels[
f"traefik.http.middlewares.{middleware_name}.headers.customFrameOptionsValue"
]
== "SAMEORIGIN"
)
def test_generate_labels_middleware_attached(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.http.routers.tool-abc12345.middlewares"] == "tool-abc12345-sec"
def test_generate_labels_network(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
network_name="custom-network",
)
assert labels["traefik.docker.network"] == "custom-network"
def test_generate_labels_default_network(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
tool_key="code-server",
project_slug="my-project",
user_slug="alice",
container_port=8443,
)
assert labels["traefik.docker.network"] == "tools"
def test_generate_removal_labels(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_removal_labels(
instance_id="abc12345-6789-1234-5678-123456789abc",
)
assert labels["traefik.enable"] == "false"
assert labels["traefik.http.routers.tool-abc12345.rule"] == ""
def test_generate_labels_with_opencode(self):
gen = TraefikLabelGenerator(domain="hq.example.com")
labels = gen.generate_labels(
instance_id="xyz78901-2345-6789-0123-456789012345",
tool_key="opencode",
project_slug="demo",
user_slug="charlie",
container_port=3000,
)
assert "Host(`opencode-demo-charlie.hq.example.com`)" in labels[
"traefik.http.routers.tool-xyz78901.rule"
]
assert (
labels["traefik.http.services.tool-xyz78901.loadbalancer.server.port"]
== "3000"
)
+14 -16
View File
@@ -6,7 +6,6 @@ import pytest
from pydantic import ValidationError
from app.tools.models import (
ExecutableConfig,
HealthCheckConfig,
MountConfig,
PortConfig,
@@ -36,33 +35,32 @@ def _minimal_manifest(**overrides: object) -> ToolManifest:
# Valid construction
# ---------------------------------------------------------------------------
def test_valid_runfusion_shape() -> None:
def test_valid_opencode_shape() -> None:
manifest = ToolManifest(
id="runfusion",
name="RunFusion",
description="Executable Node.js environment.",
image="node:22-slim",
id="opencode",
name="OpenCode",
description="AI-powered terminal-based development environment.",
image="ghcr.io/opencode-ai/opencode:latest",
runtime_working_dir="/workspace",
ports=[PortConfig(container_port=8080, name="http", primary=True)],
ports=[PortConfig(container_port=3000, name="http", primary=True)],
workspace_mounts=[
MountConfig(source_pattern="{project_repo}", target="/workspace")
],
config_mounts=[
MountConfig(
source_pattern="{user_config}/runfusion", target="/home/node/.config"
source_pattern="{user_config}/opencode", target="/root/.config/opencode"
)
],
env={"NODE_ENV": "development"},
env={"TERM": "xterm-256color", "FORCE_COLOR": "1"},
health_check=HealthCheckConfig(
type="http", path="/", port=8080, start_period_seconds=10
type="http", path="/", port=3000, start_period_seconds=15
),
resource_limits=ResourceLimits(cpus=2.0, memory_mb=2048),
executable=ExecutableConfig(node_version="22", package_manager="npm"),
resource_limits=ResourceLimits(cpus=2.0, memory_mb=4096),
traefik=TraefikConfig(
enabled=True, subdomain_prefix="runfusion", port=8080
enabled=True, subdomain_prefix="opencode", port=3000
),
)
assert manifest.id == "runfusion"
assert manifest.id == "opencode"
assert manifest.ports[0].primary is True
assert manifest.traefik is not None
assert manifest.traefik.enabled is True
@@ -102,13 +100,13 @@ def test_valid_code_server_shape() -> None:
def test_invalid_id_uppercase() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="RunFusion")
_minimal_manifest(id="OpenCode")
assert "id" in str(exc_info.value)
def test_invalid_id_spaces() -> None:
with pytest.raises(ValidationError) as exc_info:
_minimal_manifest(id="run fusion")
_minimal_manifest(id="open code")
assert "id" in str(exc_info.value)
+6 -6
View File
@@ -27,7 +27,7 @@ def test_list_tools_includes_builtins() -> None:
assert response.status_code == 200
data = response.json()
ids = {item["id"] for item in data}
assert "runfusion" in ids
assert "opencode" in ids
assert "code-server" in ids
@@ -36,12 +36,12 @@ def test_list_tools_includes_builtins() -> None:
# ---------------------------------------------------------------------------
def test_get_tool_runfusion() -> None:
response = client.get("/api/v1/tools/runfusion")
def test_get_tool_opencode() -> None:
response = client.get("/api/v1/tools/opencode")
assert response.status_code == 200
data = response.json()
assert data["id"] == "runfusion"
assert data["name"] == "RunFusion"
assert data["id"] == "opencode"
assert data["name"] == "OpenCode"
def test_get_tool_not_found() -> None:
@@ -71,7 +71,7 @@ def test_create_tool_success() -> None:
def test_create_tool_duplicate() -> None:
payload = {
"id": "runfusion",
"id": "opencode",
"name": "Duplicate",
"image": "dup:latest",
"ports": [{"container_port": 3000, "primary": True}],
+5
View File
@@ -1,3 +1,8 @@
# Frontend runtime configuration
VITE_API_URL=http://localhost:8000
VITE_APP_NAME=Headquarter
# OIDC Authentication (Authentik)
VITE_OIDC_ISSUER=https://authentik.example.com/application/o/headquarter
VITE_OIDC_CLIENT_ID=headquarter-web
VITE_OIDC_REDIRECT_URI=http://localhost:5173/callback
+16 -4
View File
@@ -4,7 +4,9 @@
"private": true,
"type": "module",
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
"onlyBuiltDependencies": [
"esbuild"
]
},
"scripts": {
"dev": "vite",
@@ -14,22 +16,32 @@
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@headlessui/react": "^2.2.10",
"@heroicons/react": "^2.2.0",
"@tanstack/react-query": "^5.100.10",
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.15.0",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^9.24.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"@eslint/js": "^9.24.0",
"autoprefixer": "^10.5.0",
"eslint": "^9.24.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"jsdom": "^26.0.0",
"@types/node": "^22.0.0",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
"typescript": "~5.7.0",
"typescript-eslint": "^8.29.0",
"vite": "^6.3.0",
+5 -22
View File
@@ -1,29 +1,12 @@
import { Outlet } from 'react-router-dom'
import { AuthProvider } from './auth/AuthProvider'
import './App.css'
function App() {
return (
<div className="app">
<header className="app-header">
<h1>Headquarter</h1>
<p className="tagline">Hosted workspace and tool-orchestration platform</p>
</header>
<main className="app-main">
<section className="status-card">
<h2>Platform Status</h2>
<div className="status-row">
<span className="status-label">API</span>
<span className="status-value" data-testid="api-status">Checking</span>
</div>
<div className="status-row">
<span className="status-label">Version</span>
<span className="status-value">0.0.1</span>
</div>
</section>
</main>
<footer className="app-footer">
<p>Scaffolded by FN-002</p>
</footer>
</div>
<AuthProvider>
<Outlet />
</AuthProvider>
)
}
+14 -19
View File
@@ -1,26 +1,21 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { render } from '@testing-library/react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from '../App'
describe('App', () => {
it('renders the platform name', () => {
render(<App />)
expect(screen.getByText('Headquarter')).toBeInTheDocument()
})
it('renders without crashing', () => {
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{ path: '/', element: <div>Test Page</div> },
],
},
])
it('renders the tagline', () => {
render(<App />)
expect(screen.getByText('Hosted workspace and tool-orchestration platform')).toBeInTheDocument()
})
it('renders the platform status section', () => {
render(<App />)
expect(screen.getByText('Platform Status')).toBeInTheDocument()
expect(screen.getByTestId('api-status')).toHaveTextContent('Checking…')
})
it('renders the scaffold footer', () => {
render(<App />)
expect(screen.getByText('Scaffolded by FN-002')).toBeInTheDocument()
render(<RouterProvider router={router} />)
expect(document.body).toBeTruthy()
})
})
+25
View File
@@ -1 +1,26 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
// Mock localStorage for tests
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
})
// Mock sessionStorage for tests
const sessionStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
})
+144
View File
@@ -0,0 +1,144 @@
import type { Project, ProjectCreate, ProjectUpdate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public data?: unknown
) {
super(`API Error ${status}: ${statusText}`)
this.name = 'ApiError'
}
}
function getAuthToken(): string | null {
return localStorage.getItem('access_token')
}
async function fetchWithAuth(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
const url = `${API_URL}/api/v1${endpoint}`
const token = getAuthToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
if (import.meta.env.DEV) {
console.log(`[API] ${options.method || 'GET'} ${url}`)
}
const response = await fetch(url, {
...options,
headers,
})
if (import.meta.env.DEV) {
console.log(`[API] ${response.status} ${response.statusText}`)
}
if (!response.ok) {
const data = await response.json().catch(() => undefined)
throw new ApiError(response.status, response.statusText, data)
}
return response
}
export const api = {
// Auth
getCurrentUser: async (): Promise<User> => {
const response = await fetchWithAuth('/users/me')
return response.json()
},
// Projects
getProjects: async (): Promise<Project[]> => {
const response = await fetchWithAuth('/projects')
return response.json()
},
getProject: async (id: string): Promise<Project> => {
const response = await fetchWithAuth(`/projects/${id}`)
return response.json()
},
createProject: async (data: ProjectCreate): Promise<Project> => {
const response = await fetchWithAuth('/projects', {
method: 'POST',
body: JSON.stringify(data),
})
return response.json()
},
updateProject: async (id: string, data: ProjectUpdate): Promise<Project> => {
const response = await fetchWithAuth(`/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
return response.json()
},
deleteProject: async (id: string): Promise<void> => {
await fetchWithAuth(`/projects/${id}`, {
method: 'DELETE',
})
},
getToolInstances: async (projectId: string): Promise<ToolInstance[]> => {
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances`)
return response.json()
},
getToolInstance: async (projectId: string, instanceId: string): Promise<ToolInstance> => {
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}`)
return response.json()
},
createToolInstance: async (projectId: string, data: { tool_definition_id: string; name: string }): Promise<ToolInstance> => {
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances`, {
method: 'POST',
body: JSON.stringify(data),
})
return response.json()
},
deleteToolInstance: async (projectId: string, instanceId: string): Promise<void> => {
await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}`, {
method: 'DELETE',
})
},
stopToolInstance: async (projectId: string, instanceId: string): Promise<ToolInstance> => {
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}/stop`, {
method: 'POST',
})
return response.json()
},
startToolInstance: async (projectId: string, instanceId: string): Promise<ToolInstance> => {
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}/start`, {
method: 'POST',
})
return response.json()
},
getToolInstanceStatus: async (projectId: string, instanceId: string): Promise<{ status: string; subdomain: string; container_id: string }> => {
const response = await fetchWithAuth(`/projects/${projectId}/tool-instances/${instanceId}/status`)
return response.json()
},
getToolRegistry: async (): Promise<ToolDefinition[]> => {
const response = await fetchWithAuth('/tools')
return response.json()
},
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useAuthStore } from '../stores/auth'
import { api } from '../api/client'
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { setUser, setError, setState } = useAuthStore()
useEffect(() => {
const initAuth = async () => {
try {
const token = localStorage.getItem('access_token')
if (!token) {
setState('unauthenticated')
return
}
const user = await api.getCurrentUser()
setUser(user)
} catch (error) {
console.error('Auth initialization failed:', error)
setError(error instanceof Error ? error : new Error('Auth failed'))
localStorage.removeItem('access_token')
}
}
initAuth()
}, [setUser, setError, setState])
return <>{children}</>
}
+47
View File
@@ -0,0 +1,47 @@
// PKCE utilities for OIDC flow
function generateRandomString(length: number): string {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let text = ''
for (let i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length))
}
return text
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)))
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
export async function createPKCE(): Promise<{ verifier: string; challenge: string }> {
const verifier = generateRandomString(128)
const challenge = await generateCodeChallenge(verifier)
return { verifier, challenge }
}
export function storePKCE(verifier: string): void {
sessionStorage.setItem('pkce_verifier', verifier)
}
export function getPKCE(): string | null {
return sessionStorage.getItem('pkce_verifier')
}
export function clearPKCE(): void {
sessionStorage.removeItem('pkce_verifier')
}
export function storeToken(token: string): void {
localStorage.setItem('access_token', token)
}
export function getToken(): string | null {
return localStorage.getItem('access_token')
}
export function clearToken(): void {
localStorage.removeItem('access_token')
}
@@ -0,0 +1,17 @@
import { Outlet } from 'react-router-dom'
import Header from './Header'
import Sidebar from './Sidebar'
export default function DashboardLayout() {
return (
<div className="flex h-screen">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuthStore } from '../stores/auth'
import {
UserCircleIcon,
ArrowRightOnRectangleIcon,
Cog6ToothIcon,
} from '@heroicons/react/24/outline'
export default function Header() {
const { user, logout } = useAuthStore()
const [showDropdown, setShowDropdown] = useState(false)
const handleLogout = () => {
logout()
localStorage.removeItem('access_token')
window.location.href = '/login'
}
return (
<header className="h-16 border-b border-border bg-surface flex items-center justify-between px-6">
<div className="flex items-center">
<span className="text-lg font-semibold">Headquarter</span>
</div>
<div className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface transition-colors"
>
<UserCircleIcon className="w-6 h-6" />
<span className="text-sm">{user?.display_name || user?.email || 'User'}</span>
</button>
{showDropdown && (
<div className="absolute right-0 mt-2 w-48 rounded-lg border border-border bg-surface shadow-lg z-50">
<div className="p-2">
<Link
to="/settings"
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface text-sm"
onClick={() => setShowDropdown(false)}
>
<Cog6ToothIcon className="w-4 h-4" />
Settings
</Link>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface text-sm w-full text-left text-red-400"
>
<ArrowRightOnRectangleIcon className="w-4 h-4" />
Logout
</button>
</div>
</div>
)}
</div>
</header>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { Navigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth'
export default function RouteGuard({ children }: { children: React.ReactNode }) {
const { state } = useAuthStore()
if (state === 'loading') {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent"></div>
</div>
)
}
if (state === 'unauthenticated' || state === 'error') {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
+48
View File
@@ -0,0 +1,48 @@
import { Link, useLocation } from 'react-router-dom'
import {
HomeIcon,
FolderIcon,
WrenchIcon,
Cog6ToothIcon,
BookOpenIcon,
} from '@heroicons/react/24/outline'
const navigation = [
{ name: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Projects', href: '/projects', icon: FolderIcon },
{ name: 'Repositories', href: '/repositories', icon: BookOpenIcon },
{ name: 'Tools', href: '/tools', icon: WrenchIcon },
{ name: 'Settings', href: '/settings', icon: Cog6ToothIcon },
]
export default function Sidebar() {
const location = useLocation()
return (
<nav className="w-64 border-r border-border bg-surface h-screen sticky top-0">
<div className="p-4">
<h1 className="text-xl font-bold mb-6">Headquarter</h1>
<ul className="space-y-1">
{navigation.map((item) => {
const isActive = location.pathname === item.href
return (
<li key={item.name}>
<Link
to={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'bg-accent/10 text-accent'
: 'text-gray-400 hover:text-fg hover:bg-surface'
}`}
>
<item.icon className="w-5 h-5" />
{item.name}
</Link>
</li>
)
})}
</ul>
</div>
</nav>
)
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_OIDC_ISSUER: string
readonly VITE_OIDC_CLIENT_ID: string
readonly VITE_OIDC_REDIRECT_URI: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+18
View File
@@ -1,3 +1,21 @@
@import "tailwindcss";
@theme {
--color-bg: #0f172a;
--color-fg: #e2e8f0;
--color-accent: #38bdf8;
--color-surface: rgba(255, 255, 255, 0.04);
--color-border: rgba(255, 255, 255, 0.08);
--color-border-subtle: rgba(255, 255, 255, 0.06);
--spacing-xs: 0.5rem;
--spacing-sm: 1rem;
--spacing-md: 1.5rem;
--spacing-lg: 2rem;
--spacing-xl: 2.5rem;
--radius-default: 0.75rem;
--font-family-base: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
}
:root {
--bg: #0f172a;
--fg: #e2e8f0;
+15 -2
View File
@@ -1,10 +1,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App'
import { router } from './router'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
)
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { getPKCE, clearPKCE, storeToken } from '../auth/oidc'
import { api } from '../api/client'
import { useAuthStore } from '../stores/auth'
const OIDC_ISSUER = import.meta.env.VITE_OIDC_ISSUER
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI
export default function CallbackPage() {
const navigate = useNavigate()
const { setUser, setError } = useAuthStore()
const [status, setStatus] = useState('Processing authentication...')
useEffect(() => {
const handleCallback = async () => {
try {
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const error = urlParams.get('error')
if (error) {
throw new Error(`Authentication error: ${error}`)
}
if (!code) {
throw new Error('No authorization code received')
}
const verifier = getPKCE()
if (!verifier) {
throw new Error('PKCE verifier not found')
}
setStatus('Exchanging code for token...')
const tokenResponse = await fetch(`${OIDC_ISSUER}/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
code,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
})
if (!tokenResponse.ok) {
throw new Error('Token exchange failed')
}
const tokenData = await tokenResponse.json()
storeToken(tokenData.access_token)
clearPKCE()
setStatus('Fetching user information...')
const user = await api.getCurrentUser()
setUser(user)
navigate('/')
} catch (error) {
console.error('Callback error:', error)
setError(error instanceof Error ? error : new Error('Authentication failed'))
setStatus('Authentication failed')
setTimeout(() => navigate('/login'), 3000)
}
}
handleCallback()
}, [navigate, setUser, setError])
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">{status}</h1>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent mx-auto"></div>
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { useAuthStore } from '../stores/auth'
export default function DashboardPage() {
const { user } = useAuthStore()
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-4">Dashboard</h1>
<p className="text-lg mb-4">
Welcome back, {user?.display_name || user?.email || 'User'}!
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-4 rounded-lg border border-border bg-surface">
<h2 className="text-xl font-semibold mb-2">Projects</h2>
<p className="text-gray-400">Manage your projects and repositories</p>
</div>
<div className="p-4 rounded-lg border border-border bg-surface">
<h2 className="text-xl font-semibold mb-2">Tools</h2>
<p className="text-gray-400">Spawn and manage development tools</p>
</div>
<div className="p-4 rounded-lg border border-border bg-surface">
<h2 className="text-xl font-semibold mb-2">Settings</h2>
<p className="text-gray-400">Configure your account and preferences</p>
</div>
</div>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect } from 'react'
import { createPKCE, storePKCE } from '../auth/oidc'
const OIDC_ISSUER = import.meta.env.VITE_OIDC_ISSUER
const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID
const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI
export default function LoginPage() {
useEffect(() => {
const initiateLogin = async () => {
const { verifier, challenge } = await createPKCE()
storePKCE(verifier)
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid profile email',
code_challenge: challenge,
code_challenge_method: 'S256',
})
window.location.href = `${OIDC_ISSUER}/authorize?${params.toString()}`
}
initiateLogin()
}, [])
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Redirecting to login...</h1>
<p className="text-gray-400">Please wait while we redirect you to the authentication provider.</p>
</div>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
export default function ProjectDetailPage() {
const { id } = useParams<{ id: string }>()
const { data: project, isLoading } = useQuery({
queryKey: ['project', id],
queryFn: () => api.getProject(id!),
enabled: !!id,
})
if (isLoading) {
return (
<div className="flex justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent"></div>
</div>
)
}
if (!project) {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Project not found</h1>
</div>
)
}
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-4">{project.name}</h1>
<p className="text-gray-400 mb-4">{project.description || 'No description'}</p>
<div className="text-sm text-gray-500">
<span>Slug: {project.slug}</span>
</div>
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client'
export default function ProjectFormPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const isEditing = !!id
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [description, setDescription] = useState('')
const [errors, setErrors] = useState<Record<string, string>>({})
const { data: project } = useQuery({
queryKey: ['project', id],
queryFn: () => api.getProject(id!),
enabled: isEditing,
})
useEffect(() => {
if (project) {
setName(project.name)
setSlug(project.slug)
setDescription(project.description || '')
}
}, [project])
const createMutation = useMutation({
mutationFn: api.createProject,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
navigate('/projects')
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof api.updateProject>[1] }) =>
api.updateProject(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
queryClient.invalidateQueries({ queryKey: ['project', id] })
navigate(`/projects/${id}`)
},
})
const validate = () => {
const newErrors: Record<string, string> = {}
if (!name.trim()) newErrors.name = 'Name is required'
if (!slug.trim()) newErrors.slug = 'Slug is required'
if (!/^[a-z0-9-]+$/i.test(slug)) newErrors.slug = 'Slug must contain only letters, numbers, and hyphens'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
const data = {
name: name.trim(),
slug: slug.trim(),
description: description.trim() || null,
}
if (isEditing) {
updateMutation.mutate({ id: id!, data })
} else {
createMutation.mutate(data)
}
}
return (
<div className="p-6 max-w-2xl">
<h1 className="text-3xl font-bold mb-6">
{isEditing ? 'Edit Project' : 'New Project'}
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface"
placeholder="My Awesome Project"
/>
{errors.name && <p className="text-red-400 text-sm mt-1">{errors.name}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Slug</label>
<input
type="text"
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface"
placeholder="my-awesome-project"
/>
{errors.slug && <p className="text-red-400 text-sm mt-1">{errors.slug}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface"
rows={3}
placeholder="Optional description..."
/>
</div>
<div className="flex gap-4">
<button
type="submit"
className="px-4 py-2 bg-accent text-bg rounded-lg hover:bg-accent/80 transition-colors"
>
{isEditing ? 'Update' : 'Create'} Project
</button>
<button
type="button"
onClick={() => navigate('/projects')}
className="px-4 py-2 border border-border rounded-lg hover:bg-surface transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { api } from '../api/client'
import type { Project } from '../types/api'
export default function ProjectListPage() {
const queryClient = useQueryClient()
const { data: projects, isLoading } = useQuery({
queryKey: ['projects'],
queryFn: api.getProjects,
})
const deleteMutation = useMutation({
mutationFn: api.deleteProject,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
},
})
if (isLoading) {
return (
<div className="flex justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent"></div>
</div>
)
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Projects</h1>
<Link
to="/projects/new"
className="px-4 py-2 bg-accent text-bg rounded-lg hover:bg-accent/80 transition-colors"
>
New Project
</Link>
</div>
{projects && projects.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project: Project) => (
<div
key={project.id}
className="p-4 rounded-lg border border-border bg-surface hover:border-accent transition-colors"
>
<Link to={`/projects/${project.id}`}>
<h2 className="text-xl font-semibold mb-2">{project.name}</h2>
<p className="text-gray-400 mb-2">{project.description || 'No description'}</p>
<span className="text-sm text-gray-500">Slug: {project.slug}</span>
</Link>
<div className="mt-4 flex gap-2">
<Link
to={`/projects/${project.id}/edit`}
className="text-sm text-accent hover:underline"
>
Edit
</Link>
<button
onClick={() => {
if (confirm('Are you sure you want to delete this project?')) {
deleteMutation.mutate(project.id)
}
}}
className="text-sm text-red-400 hover:underline"
>
Delete
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-xl text-gray-400 mb-4">No projects yet</p>
<Link
to="/projects/new"
className="text-accent hover:underline"
>
Create your first project
</Link>
</div>
)}
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function RepositoriesPage() {
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-4">Repositories</h1>
<p className="text-gray-400">Repository management coming soon...</p>
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { useAuthStore } from '../stores/auth'
export default function SettingsPage() {
const { user } = useAuthStore()
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-4">Settings</h1>
<div className="max-w-2xl">
<div className="p-4 rounded-lg border border-border bg-surface mb-4">
<h2 className="text-xl font-semibold mb-2">Profile</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm text-gray-400">Email</dt>
<dd>{user?.email}</dd>
</div>
<div>
<dt className="text-sm text-gray-400">Display Name</dt>
<dd>{user?.display_name || 'Not set'}</dd>
</div>
</dl>
</div>
</div>
</div>
)
}
@@ -0,0 +1,222 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { api } from '../api/client'
import type { ToolInstance, ToolDefinition } from '../types/api'
export default function ToolInstanceDetailPage() {
const { projectId, instanceId } = useParams<{ projectId: string; instanceId: string }>()
const navigate = useNavigate()
const [instance, setInstance] = useState<ToolInstance | null>(null)
const [toolDef, setToolDef] = useState<ToolDefinition | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState(false)
useEffect(() => {
if (!projectId || !instanceId) return
loadInstance()
}, [projectId, instanceId])
const loadInstance = async () => {
if (!projectId || !instanceId) return
try {
setLoading(true)
const data = await api.getToolInstance(projectId, instanceId)
setInstance(data)
const registry = await api.getToolRegistry()
const tool = registry.find((t) => t.id === data.tool_definition_id)
setToolDef(tool || null)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load instance')
} finally {
setLoading(false)
}
}
const handleStop = async () => {
if (!projectId || !instanceId) return
setActionLoading(true)
try {
const data = await api.stopToolInstance(projectId, instanceId)
setInstance(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop instance')
} finally {
setActionLoading(false)
}
}
const handleStart = async () => {
if (!projectId || !instanceId) return
setActionLoading(true)
try {
const data = await api.startToolInstance(projectId, instanceId)
setInstance(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start instance')
} finally {
setActionLoading(false)
}
}
const handleDelete = async () => {
if (!projectId || !instanceId) return
if (!confirm('Are you sure you want to delete this instance?')) return
setActionLoading(true)
try {
await api.deleteToolInstance(projectId, instanceId)
navigate(`/projects/${projectId}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete instance')
} finally {
setActionLoading(false)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return 'text-green-400'
case 'creating':
return 'text-yellow-400'
case 'stopped':
return 'text-gray-400'
case 'error':
return 'text-red-400'
default:
return 'text-gray-400'
}
}
if (loading) {
return (
<div className="p-6">
<div className="animate-pulse">Loading instance...</div>
</div>
)
}
if (error || !instance) {
return (
<div className="p-6">
<div className="text-red-400 mb-4">{error || 'Instance not found'}</div>
<Link
to={`/projects/${projectId}`}
className="text-accent hover:underline"
>
Back to project
</Link>
</div>
)
}
return (
<div className="p-6">
<div className="mb-6">
<Link
to={`/projects/${projectId}`}
className="text-accent hover:underline mb-4 inline-block"
>
Back to project
</Link>
<h1 className="text-3xl font-bold mt-2">{instance.name}</h1>
<div className="flex items-center gap-4 mt-2">
<span className={`font-medium ${getStatusColor(instance.status)}`}>
{instance.status}
</span>
{toolDef && (
<span className="text-gray-400">{toolDef.name}</span>
)}
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg mb-6">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-surface border border-border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Instance Details</h2>
<div className="space-y-3">
<div>
<span className="text-gray-400">Status: </span>
<span className={getStatusColor(instance.status)}>{instance.status}</span>
</div>
<div>
<span className="text-gray-400">Tool: </span>
<span>{toolDef?.name || instance.tool_definition_id}</span>
</div>
{instance.subdomain && (
<div>
<span className="text-gray-400">Subdomain: </span>
<a
href={`https://${instance.subdomain}`}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{instance.subdomain}
</a>
</div>
)}
{instance.container_id && (
<div>
<span className="text-gray-400">Container: </span>
<span className="font-mono text-sm">{instance.container_id.slice(0, 12)}</span>
</div>
)}
<div>
<span className="text-gray-400">Created: </span>
<span>{new Date(instance.created_at).toLocaleString()}</span>
</div>
</div>
</div>
<div className="bg-surface border border-border rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Actions</h2>
<div className="space-y-3">
{instance.status === 'running' && instance.subdomain && (
<a
href={`https://${instance.subdomain}`}
target="_blank"
rel="noopener noreferrer"
className="block w-full bg-accent text-white text-center py-2 px-4 rounded-lg hover:bg-accent/90 transition-colors"
>
Open Tool
</a>
)}
{instance.status === 'running' ? (
<button
onClick={handleStop}
disabled={actionLoading}
className="w-full bg-yellow-600 text-white py-2 px-4 rounded-lg hover:bg-yellow-700 transition-colors disabled:opacity-50"
>
{actionLoading ? 'Stopping...' : 'Stop Instance'}
</button>
) : (
<button
onClick={handleStart}
disabled={actionLoading}
className="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
>
{actionLoading ? 'Starting...' : 'Start Instance'}
</button>
)}
<button
onClick={handleDelete}
disabled={actionLoading}
className="w-full bg-red-600 text-white py-2 px-4 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
{actionLoading ? 'Deleting...' : 'Delete Instance'}
</button>
</div>
</div>
</div>
</div>
)
}
+163
View File
@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { api } from '../api/client'
import type { ToolDefinition, Project } from '../types/api'
export default function ToolSpawnPage() {
const { projectId } = useParams<{ projectId: string }>()
const navigate = useNavigate()
const [tools, setTools] = useState<ToolDefinition[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [selectedTool, setSelectedTool] = useState('')
const [selectedProject, setSelectedProject] = useState(projectId || '')
const [instanceName, setInstanceName] = useState('')
const [loading, setLoading] = useState(false)
const [fetchLoading, setFetchLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
setFetchLoading(true)
const [toolsData, projectsData] = await Promise.all([
api.getToolRegistry(),
api.getProjects(),
])
setTools(toolsData)
setProjects(projectsData)
if (toolsData.length > 0 && !selectedTool) {
setSelectedTool(toolsData[0].id)
}
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data')
} finally {
setFetchLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedTool || !selectedProject || !instanceName.trim()) {
setError('Please fill in all fields')
return
}
setLoading(true)
setError(null)
try {
const instance = await api.createToolInstance(selectedProject, {
tool_definition_id: selectedTool,
name: instanceName.trim(),
})
navigate(`/projects/${selectedProject}/instances/${instance.id}`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create instance')
} finally {
setLoading(false)
}
}
const selectedToolData = tools.find((t) => t.id === selectedTool)
if (fetchLoading) {
return (
<div className="p-6">
<div className="animate-pulse">Loading...</div>
</div>
)
}
return (
<div className="p-6 max-w-2xl">
<h1 className="text-3xl font-bold mb-6">Spawn Tool</h1>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 p-4 rounded-lg mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">Project</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="w-full bg-surface border border-border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-accent"
required
>
<option value="">Select a project</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Tool</label>
<select
value={selectedTool}
onChange={(e) => setSelectedTool(e.target.value)}
className="w-full bg-surface border border-border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-accent"
required
>
<option value="">Select a tool</option>
{tools.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.name}
</option>
))}
</select>
</div>
{selectedToolData && (
<div className="bg-surface border border-border rounded-lg p-4">
<h3 className="font-medium mb-2">{selectedToolData.name}</h3>
<p className="text-gray-400 text-sm">
{selectedToolData.description || 'No description available'}
</p>
<div className="mt-2 text-sm text-gray-400">
Image: {selectedToolData.image}
</div>
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Instance Name</label>
<input
type="text"
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="e.g., My Development Environment"
className="w-full bg-surface border border-border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-accent"
required
/>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
className="bg-accent text-white py-2 px-6 rounded-lg hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{loading ? 'Spawning...' : 'Spawn Tool'}
</button>
<button
type="button"
onClick={() => navigate(-1)}
className="border border-border py-2 px-6 rounded-lg hover:bg-surface transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function ToolsPage() {
return (
<div className="p-6">
<h1 className="text-3xl font-bold mb-4">Tools</h1>
<p className="text-gray-400">Tool management coming soon...</p>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { createBrowserRouter } from 'react-router-dom'
import App from './App'
import RouteGuard from './components/RouteGuard'
import DashboardLayout from './components/DashboardLayout'
import LoginPage from './pages/LoginPage'
import CallbackPage from './pages/CallbackPage'
import DashboardPage from './pages/DashboardPage'
import ProjectListPage from './pages/ProjectListPage'
import ProjectDetailPage from './pages/ProjectDetailPage'
import ProjectFormPage from './pages/ProjectFormPage'
import ToolsPage from './pages/ToolsPage'
import ToolSpawnPage from './pages/ToolSpawnPage'
import ToolInstanceDetailPage from './pages/ToolInstanceDetailPage'
import SettingsPage from './pages/SettingsPage'
import RepositoriesPage from './pages/RepositoriesPage'
export const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{
element: (
<RouteGuard>
<DashboardLayout />
</RouteGuard>
),
children: [
{ path: '/', element: <DashboardPage /> },
{ path: '/projects', element: <ProjectListPage /> },
{ path: '/projects/new', element: <ProjectFormPage /> },
{ path: '/projects/:id', element: <ProjectDetailPage /> },
{ path: '/projects/:id/edit', element: <ProjectFormPage /> },
{ path: '/tools', element: <ToolsPage /> },
{ path: '/tools/spawn', element: <ToolSpawnPage /> },
{ path: '/projects/:projectId/instances/:instanceId', element: <ToolInstanceDetailPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/repositories', element: <RepositoriesPage /> },
],
},
{ path: '/login', element: <LoginPage /> },
{ path: '/callback', element: <CallbackPage /> },
],
},
])
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand'
import type { User } from '../types/api.ts'
export type AuthState = 'loading' | 'authenticated' | 'unauthenticated' | 'error'
interface AuthStore {
state: AuthState
user: User | null
error: Error | null
setState: (state: AuthState) => void
setUser: (user: User | null) => void
setError: (error: Error | null) => void
logout: () => void
}
export const useAuthStore = create<AuthStore>((set) => ({
state: 'loading',
user: null,
error: null,
setState: (state) => set({ state }),
setUser: (user) => set({ user, state: user ? 'authenticated' : 'unauthenticated' }),
setError: (error) => set({ error, state: 'error' }),
logout: () => set({ user: null, state: 'unauthenticated', error: null }),
}))
+111
View File
@@ -0,0 +1,111 @@
export interface User {
id: string
authentik_sub: string
email: string
display_name: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface Project {
id: string
name: string
slug: string
description: string | null
owner_id: string
}
export interface ProjectCreate {
name: string
slug: string
description?: string | null
}
export interface ProjectUpdate {
name?: string
slug?: string
description?: string | null
}
export interface ToolDefinition {
id: string
key: string
name: string
description: string | null
version: string
image: string
manifest_data: Record<string, unknown> | null
}
export interface ToolInstance {
id: string
tool_definition_id: string
project_id: string
name: string
status: string
container_id: string | null
subdomain: string | null
config_override: Record<string, string> | null
traefik_labels: Record<string, string> | null
created_at: string
updated_at: string
}
export interface ToolManifest {
id: string
name: string
description: string
version: string
image: string
runtime_command: string[] | null
runtime_entrypoint: string[] | null
runtime_user: string | null
runtime_working_dir: string | null
ports: Array<{
container_port: number
protocol: string
name: string | null
primary: boolean
}>
workspace_mounts: Array<{
type: string
source_pattern: string
target: string
read_only: boolean
}>
config_mounts: Array<{
type: string
source_pattern: string
target: string
read_only: boolean
}>
env: Record<string, string>
secrets: Array<{
name: string
env_var: string
required: boolean
}>
health_check: {
type: string
path: string | null
command: string[] | null
port: number | null
interval_seconds: number
timeout_seconds: number
retries: number
start_period_seconds: number
} | null
resource_limits: {
cpus: number | null
memory_mb: number | null
memory_swap_mb: number | null
} | null
traefik: {
enabled: boolean
subdomain_prefix: string | null
port: number | null
middlewares: string[]
strip_prefix: boolean
} | null
}
+27
View File
@@ -0,0 +1,27 @@
# Production Environment Variables
# Copy to .env and fill in all values
# Domain Configuration
ROOT_DOMAIN=example.com
ACME_EMAIL=admin@example.com
# Database
POSTGRES_USER=headquarter
POSTGRES_PASSWORD=change-me-in-production
POSTGRES_DB=headquarter
# Authentik OIDC
AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/headquarter/
AUTHENTIK_CLIENT_ID=headquarter-api
AUTHENTIK_CLIENT_SECRET=change-me-in-production
# API Configuration
SECRET_ENCRYPTION_KEY=change-me-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=60
# Deployment
TRAEFIK_LOG_LEVEL=INFO
API_TAG=latest
WEB_TAG=latest
REGISTRY=ghcr.io
REPO=headquarter
+118
View File
@@ -0,0 +1,118 @@
version: "3.8"
services:
traefik:
image: traefik:v3.1
command:
- --api.dashboard=true
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --log.level=INFO
- --accesslog=true
- --accesslog.format=json
- --metrics.prometheus=true
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-certs:/letsencrypt
networks:
- platform
- tools
labels:
- traefik.enable=true
- traefik.http.routers.traefik-dashboard.rule=Host(`traefik.${ROOT_DOMAIN}`)
- traefik.http.routers.traefik-dashboard.entrypoints=websecure
- traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt
- traefik.http.routers.traefik-dashboard.service=api@internal
- traefik.http.routers.traefik-dashboard.middlewares=auth@file
restart: unless-stopped
healthcheck:
test: ["CMD", "traefik", "healthcheck"]
interval: 10s
timeout: 5s
retries: 3
api:
image: ${REGISTRY:-ghcr.io}/${REPO:-headquarter}/api:${API_TAG:-latest}
env_file:
- stack.env
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- ROOT_DOMAIN=${ROOT_DOMAIN}
- CORS_ORIGINS=https://${ROOT_DOMAIN},https://*.${ROOT_DOMAIN}
depends_on:
db:
condition: service_healthy
networks:
- platform
volumes:
- api-data:/data
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`api.${ROOT_DOMAIN}`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=letsencrypt
- traefik.http.services.api.loadbalancer.server.port=8000
- traefik.http.routers.api.middlewares=sec-headers@file
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
web:
image: ${REGISTRY:-ghcr.io}/${REPO:-headquarter}/web:${WEB_TAG:-latest}
env_file:
- stack.env
depends_on:
- api
networks:
- platform
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=Host(`${ROOT_DOMAIN}`)
- traefik.http.routers.web.entrypoints=websecure
- traefik.http.routers.web.tls.certresolver=letsencrypt
- traefik.http.services.web.loadbalancer.server.port=8080
- traefik.http.routers.web.middlewares=sec-headers@file
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- platform
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres-data:
api-data:
traefik-certs:
networks:
platform:
driver: bridge
tools:
driver: bridge
external: false
+6 -7
View File
@@ -1,27 +1,26 @@
# Portainer stack environment variables (no secrets committed)
# Copy and configure in Portainer UI or your secrets manager.
APP_NAME=Headquarter
ROOT_DOMAIN=example.com
TOOL_DOMAIN=tools.example.com
API_URL=https://api.example.com
WEB_URL=https://example.com
# Database
POSTGRES_USER=headquarter
POSTGRES_DB=headquarter
POSTGRES_PASSWORD=
# Authentik OIDC
AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/headquarter/
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
# Traefik
TRAEFIK_NETWORK=traefik
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_CERT_RESOLVER=letsencrypt
TOOL_SUBDOMAIN_PATTERN={tool}-{project}-{user}.tools.{ROOT_DOMAIN}
# Secrets
SECRET_ENCRYPTION_KEY=
ACME_EMAIL=admin@example.com
API_TAG=latest
WEB_TAG=latest
REGISTRY=ghcr.io
REPO=headquarter
+117
View File
@@ -0,0 +1,117 @@
services:
traefik:
image: traefik:v3.1
command:
- --api.dashboard=true
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --certificatesresolvers.letsencrypt.acme.tlschallenge=true
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-admin@example.com}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
- --accesslog=true
- --accesslog.format=json
- --metrics.prometheus=true
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-certs:/letsencrypt
networks:
- platform
- tools
labels:
- traefik.enable=true
- traefik.http.routers.traefik-dashboard.rule=Host(`traefik.${ROOT_DOMAIN:-localhost}`)
- traefik.http.routers.traefik-dashboard.entrypoints=websecure
- traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt
- traefik.http.routers.traefik-dashboard.service=api@internal
- traefik.http.routers.traefik-dashboard.middlewares=auth@file
restart: unless-stopped
healthcheck:
test: ["CMD", "traefik", "healthcheck"]
interval: 10s
timeout: 5s
retries: 3
api:
build:
context: ./apps/api
dockerfile: Dockerfile
env_file:
- .env
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-headquarter}
- ROOT_DOMAIN=${ROOT_DOMAIN:-localhost}
depends_on:
db:
condition: service_healthy
networks:
- platform
volumes:
- api-data:/data
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`api.${ROOT_DOMAIN:-localhost}`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=letsencrypt
- traefik.http.services.api.loadbalancer.server.port=8000
- traefik.http.routers.api.middlewares=sec-headers@file
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
web:
build:
context: ./apps/web
dockerfile: Dockerfile
env_file:
- apps/web/.env
depends_on:
- api
networks:
- platform
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=Host(`${ROOT_DOMAIN:-localhost}`)
- traefik.http.routers.web.entrypoints=websecure
- traefik.http.routers.web.tls.certresolver=letsencrypt
- traefik.http.services.web.loadbalancer.server.port=8080
- traefik.http.routers.web.middlewares=sec-headers@file
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_DB=${POSTGRES_DB:-headquarter}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- platform
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-headquarter}"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
postgres-data:
api-data:
traefik-certs:
networks:
platform:
driver: bridge
tools:
driver: bridge
external: false
+66 -20
View File
@@ -5,11 +5,11 @@
## 1. Overview & Goals
Headquarter is a hosted control plane where authenticated users create projects, connect Git repositories, and spawn containerized tools (RunFusion, code-server, and future tools). The platform is manifest-driven and provider-abstracted so new tools, Git providers, runtimes, and access providers can be added without rewriting core orchestration logic.
Headquarter is a hosted control plane where authenticated users create projects, connect Git repositories, and spawn containerized tools (OpenCode, code-server, and future tools). The platform is manifest-driven and provider-abstracted so new tools, Git providers, runtimes, and access providers can be added without rewriting core orchestration logic.
**Product purpose:** Give individual developers and small teams a self-hosted alternative to cloud IDEs and CI dashboards by combining Git-backed project workspaces with on-demand tool containers, all routed through a unified subdomain scheme.
**MVP scope:** Single-user projects, Authentik OIDC auth, Docker runtime, Traefik subdomain routing, Portainer-managed Docker Compose deployment. The MVP supports two built-in tools (RunFusion and code-server) and provides extension points for additional tools, Git providers, and runtimes.
**MVP scope:** Single-user projects, Authentik OIDC auth, Docker runtime, Traefik subdomain routing, Portainer-managed Docker Compose deployment. The MVP supports two built-in tools (OpenCode and code-server) and provides extension points for additional tools, Git providers, and runtimes.
**Non-goals (explicitly out of MVP scope):**
- Multi-user teams or shared projects
@@ -43,7 +43,7 @@ flowchart TB
subgraph Runtime
R[Docker Compose Stack<br/>Portainer-managed]
TC[Tool Containers<br/>RunFusion / code-server]
TC[Tool Containers<br/>OpenCode / code-server]
end
U -->|HTTPS| T
@@ -81,27 +81,54 @@ flowchart TB
- Consume the FastAPI backend via a typed API client layer
- Handle Vite environment variables for runtime configuration
**Technology Stack:**
- React 19 + TypeScript with strict mode
- Vite for build tooling
- React Router v7 for client-side routing
- TanStack Query v5 for server state management
- Zustand for client state management (auth store)
- Tailwind CSS v4 for styling
- Headless UI for accessible components
- Heroicons for iconography
**Project Structure:**
```
apps/web/src/
├── api/ # API client and error handling
├── auth/ # OIDC utilities and AuthProvider
├── components/ # Reusable UI components (Header, Sidebar, RouteGuard, DashboardLayout)
├── pages/ # Page components (Dashboard, Projects, Tools, Settings, etc.)
├── stores/ # Zustand stores (auth store)
├── types/ # TypeScript type definitions matching backend schemas
└── router.tsx # React Router configuration
```
**Routing:**
- `/` — Dashboard
- `/projects` — Project list
- `/projects/new` — Create project
- `/projects/:id/repositories` — Repository management
- `/tools` — Tool registry and spawn surface
- `/tools/spawn` — Spawn tool form
- `/settings` — User and platform settings
- `/access/:instanceId` — Tool access URL presentation
- `/` — Dashboard (protected)
- `/projects` — Project list (protected)
- `/projects/new` — Create project (protected)
- `/projects/:id` — Project detail (protected)
- `/projects/:id/edit` — Edit project (protected)
- `/repositories` — Repository management (protected)
- `/tools` — Tool registry and spawn surface (protected)
- `/settings` — User and platform settings (protected)
- `/login` — Login redirect (public)
- `/callback` — OIDC callback handler (public)
**Auth state:**
- Managed via a central auth context/provider
- Managed via Zustand auth store (`useAuthStore`)
- States: `loading`, `authenticated`, `unauthenticated`, `error`
- Access token stored in **httpOnly cookie** (recommended) or secure storage; never `localStorage` for sensitive tokens
- On 401 from API, redirect to Authentik login
- Access token stored in `localStorage` (MVP simplification; httpOnly cookie recommended for production)
- PKCE flow for OIDC authentication
- On 401 from API, redirect to login
**API client conventions:**
- Base URL from `VITE_API_BASE_URL`
- Base URL from `VITE_API_URL`
- JSON request/response with standard HTTP status codes
- Normalize errors into a consistent `{ message, statusCode, details? }` shape
- Include bearer token or cookie credentials on every request
- `ApiError` class for consistent error handling
- Bearer token injected via `Authorization` header
- Debug mode request/response logging
- Typed API methods for all endpoints
### 3.2 Backend (`apps/api/`)
@@ -233,7 +260,7 @@ All tables use `uuid` primary keys. All models include `created_at` and `updated
| Column | Type | Constraints | Default | Notes |
|--------|------|-------------|---------|-------|
| `id` | `UUID` | `PRIMARY KEY` | `uuid_generate_v4()` | |
| `key` | `VARCHAR(100)` | `UNIQUE`, `NOT NULL`, `INDEX` | | Machine identifier (e.g., `runfusion`, `code-server`) |
| `key` | `VARCHAR(100)` | `UNIQUE`, `NOT NULL`, `INDEX` | | Machine identifier (e.g., `opencode`, `code-server`) |
| `name` | `VARCHAR(255)` | `NOT NULL` | | Human-readable name |
| `version` | `VARCHAR(50)` | `NOT NULL` | `'1.0.0'` | |
| `description` | `TEXT` | `NULLABLE` | `NULL` | |
@@ -449,7 +476,7 @@ Tools are defined by manifests that declare runtime behavior, resource needs, an
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | `str` | Yes | Machine identifier (`runfusion`, `code-server`) |
| `key` | `str` | Yes | Machine identifier (`opencode`, `code-server`) |
| `name` | `str` | Yes | Human-readable name |
| `version` | `str` | No | SemVer string; default `1.0.0` |
| `description` | `str` | No | Markdown-friendly description |
@@ -730,7 +757,7 @@ Variable interpolation rules:
Example:
```
https://runfusion-myapp-alice.tools.example.com
https://opencode-myapp-alice.tools.example.com
https://code-server-myapp-alice.tools.example.com
```
@@ -780,6 +807,25 @@ Tool containers **must** attach to the external Traefik Docker network. If the n
- **TLS is enabled by default** for all tool instances.
- HTTP-only mode (`web` entrypoint, no TLS) is supported for local development via environment configuration.
### 10.6 Auth Proxy for Tool Instances
Spawned tools are protected behind the platform's authentication via Traefik forwardAuth middleware:
1. **Middleware Configuration:**
- Traefik forwards incoming requests to `/api/v1/auth/validate`
- The endpoint validates the Bearer token and returns 200 for authenticated users
- Unauthenticated requests receive 401 and are blocked
2. **Tool Configuration:**
- code-server built-in auth is disabled (`PASSWORD: ""`)
- OpenCode relies entirely on the platform auth layer
- Tools run on internal networks only, inaccessible directly
3. **Security Model:**
- Only platform-authenticated users can access spawned tools
- Each tool instance has its own subdomain with isolated routing
- No shared containers between users or projects
---
## 11. Storage Layout
+1 -1
View File
@@ -62,7 +62,7 @@ This document captures the key architectural decisions, assumptions, and open lo
- **FN-004:** Backend Foundation — depends on the PostgreSQL domain model in Section 4
- **FN-005:** Frontend Foundation — depends on component boundaries in Section 3.1
- **FN-006:** Deployment Config — depends on Traefik routing model in Section 10
- **FN-008:** RunFusion POC — depends on spawn lifecycle in Section 8 and Docker runtime in Section 9
- **FN-008:** OpenCode POC — depends on spawn lifecycle in Section 8 and Docker runtime in Section 9
- **FN-009:** Config & Secrets — depends on storage layout in Section 11 and security in Section 13
- **FN-010:** code-server Spawn — depends on tool manifest in Section 7 and Docker runtime in Section 9
- **FN-011:** Git Provider — depends on Git provider abstraction in Section 5
+109 -38
View File
@@ -2,61 +2,132 @@
## Overview
The MVP deployment target is a **Portainer-managed Docker Compose stack** with an existing **Traefik** reverse proxy.
The MVP deployment target is a **Portainer-managed Docker Compose stack** with **Traefik** as the reverse proxy. All services run in Docker containers with automatic TLS via Let's Encrypt.
This document covers the scaffold-level deployment assumptions created in FN-002. Detailed deployment automation (dynamic labels for spawned tool containers, secret rotation, CI/CD pipelines) is follow-up scope for **FN-006**.
## Architecture
## Stack Assumptions
```
Internet
|
v
Traefik (443/80) ──► Let's Encrypt TLS
|
├──► api.example.com ──► FastAPI backend
├──► example.com ──► React frontend
├──► traefik.example.com ──► Traefik dashboard
└──► {tool}-{project}-{user}.tools.example.com ──► Spawned tool containers
```
- **Reverse proxy**: Traefik (already running on the target host)
- **Orchestration**: Portainer managing Docker Compose stacks
- **Network**: External Traefik network named `traefik` (or as configured)
- **Routing**: Subdomain-based (`{tool}-{project}-{user}.tools.{ROOT_DOMAIN}`)
- **TLS**: Traefik cert resolver (e.g., `letsencrypt` or Cloudflare)
## Prerequisites
- Docker and Docker Compose
- A domain with DNS A/AAAA records pointing to your server
- Ports 80 and 443 open
## Quick Start
### 1. Configure Environment
Copy the production environment example and fill in all values:
```bash
cp deploy/.env.example deploy/.env
```
Required variables:
| Variable | Description | Example |
|----------|-------------|---------|
| `ROOT_DOMAIN` | Your domain | `example.com` |
| `ACME_EMAIL` | Let's Encrypt contact email | `admin@example.com` |
| `POSTGRES_PASSWORD` | Database password | (strong random) |
| `AUTHENTIK_CLIENT_SECRET` | OIDC client secret | (from Authentik) |
| `SECRET_ENCRYPTION_KEY` | Fernet encryption key | (32-byte base64) |
### 2. Deploy Locally (Testing)
```bash
docker compose -f docker-compose.prod.yml up --build -d
```
This starts: Traefik, API, web frontend, and PostgreSQL.
### 3. Deploy to Production (Portainer)
1. In Portainer, create a new stack
2. Upload `deploy/portainer-stack.yml`
3. Set environment variables from `deploy/portainer.env.example`
4. Deploy the stack
## Deployment Files
The following deployment files are part of the scaffold:
| File | Purpose |
|------|---------|
| `docker-compose.yml` | Local development (API, web, Postgres) |
| `docker-compose.traefik.yml` | Deployment overlay with Traefik labels |
| `deploy/portainer.env.example` | Deployment environment variables |
| `deploy/traefik-labels.example.yml` | Example Traefik labels for services |
| `deploy/README.md` | Deploy skeleton usage notes |
| `docker-compose.prod.yml` | Production compose with Traefik |
| `deploy/portainer-stack.yml` | Portainer stack definition |
| `deploy/portainer.env.example` | Portainer environment variables |
| `deploy/.env.example` | Production environment variables |
## Environment Variables
## Traefik Configuration
See `.env.example` for the full variable list. Key deployment variables:
Traefik handles all routing and TLS:
```env
APP_NAME=Headquarter
ROOT_DOMAIN=example.com
TOOL_DOMAIN=tools.example.com
TRAEFIK_NETWORK=traefik
TRAEFIK_ENTRYPOINT=websecure
TRAEFIK_CERT_RESOLVER=letsencrypt
AUTHENTIK_ISSUER_URL=https://auth.example.com/application/o/headquarter/
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
POSTGRES_PASSWORD=
SECRET_ENCRYPTION_KEY=
- **Entrypoints**: `web` (80) → redirects to `websecure` (443)
- **Certificates**: Let's Encrypt via TLS challenge
- **Dashboard**: Available at `traefik.${ROOT_DOMAIN}` (protected by middleware)
- **Metrics**: Prometheus metrics exposed on `/metrics`
### Subdomain Routing
Tool containers are routed via subdomains:
```
{tool}-{project}-{user}.tools.{ROOT_DOMAIN}
```
## Local vs Production
Example: `vscode-myproject-john.tools.example.com`
- **Local**: `docker compose up --build -d` uses `docker-compose.yml` only.
- **Production**: Portainer deploys the stack using the main compose file plus the Traefik overlay.
## DNS Requirements
## Scoped Secrets
Create DNS A records for:
Do not commit real secrets. Use:
- `example.com` → your server IP
- `*.example.com` → your server IP (wildcard for subdomains)
- `*.tools.example.com` → your server IP (tool subdomains)
- Portainer environment variables (stored in Portainer, not in Git)
- `.env` files (ignored by Git, documented in `.env.example`)
- Docker secrets (to be evaluated in FN-006)
## Security
## Follow-up Work
- All services communicate over HTTPS
- Traefik adds security headers (HSTS, XSS protection, etc.)
- Database is not exposed externally
- Secrets are injected via environment variables
- **FN-006**: Full deployment automation, dynamic Traefik labels for spawned tool containers, Portainer stack definitions, and CI/CD integration.
## Updating
To update the deployment:
```bash
# Pull new images
docker compose -f docker-compose.prod.yml pull
# Restart services
docker compose -f docker-compose.prod.yml up -d
```
## Troubleshooting
Check Traefik logs:
```bash
docker logs traefik
```
Check service health:
```bash
docker compose -f docker-compose.prod.yml ps
```
Verify certificates:
```bash
curl -v https://api.example.com
```
+64
View File
@@ -31,6 +31,29 @@ Copy the frontend environment example:
cp apps/web/.env.example apps/web/.env
```
### Frontend Authentication (OIDC)
The frontend uses OpenID Connect (OIDC) with PKCE for authentication. Configure the following environment variables in `apps/web/.env`:
| Variable | Description | Example |
|----------|-------------|---------|
| `VITE_API_URL` | Backend API base URL | `http://localhost:8000` |
| `VITE_OIDC_ISSUER` | OIDC provider issuer URL | `https://authentik.example.com/application/o/headquarter` |
| `VITE_OIDC_CLIENT_ID` | OIDC client ID | `headquarter-web` |
| `VITE_OIDC_REDIRECT_URI` | Post-login redirect URL | `http://localhost:5173/callback` |
**Authentication Flow:**
1. User clicks login → redirected to OIDC provider authorize endpoint
2. User authenticates with provider
3. Provider redirects to `/callback` with authorization code
4. Frontend exchanges code for access token (PKCE)
5. Token stored in `localStorage`, user info fetched from `/api/v1/users/me`
**Logout:**
- Clears local token
- Redirects to login page
- User can re-authenticate via OIDC flow
## Running Locally
### Frontend only
@@ -195,3 +218,44 @@ Security rules for the package:
- Credential models store **only** `encrypted_payload` — no plaintext `token` or `private_key` fields.
- SSH private keys are encrypted before storage; the field uses `repr=False`.
- Real encryption of the payload is deferred to FN-009; the current placeholder is base64-only.
## Tool Spawn Workflow
The platform supports spawning development tools (e.g., code-server) as Docker containers via Docker Compose.
### Architecture
1. **Tool Manifest** (`apps/api/app/tools/manifests/*.yml`):
- Defines Docker image, ports, volumes, environment variables, health checks
- Loaded into in-memory registry at application startup
2. **Spawn Service** (`apps/api/app/services/spawn.py`):
- Generates Docker Compose service definitions from manifests
- Handles container lifecycle: spawn, stop, status polling
- Integrates Traefik label generation for subdomain routing
3. **API Endpoints** (`apps/api/app/routers/tool_instances.py`):
- `POST /projects/{id}/tool-instances` — Spawn a new tool instance
- `POST /projects/{id}/tool-instances/{id}/stop` — Stop a running instance
- `POST /projects/{id}/tool-instances/{id}/start` — Restart a stopped instance
- `GET /projects/{id}/tool-instances/{id}/status` — Get container status
4. **Frontend UI**:
- `/tools/spawn` — Form to select tool, project, and spawn
- `/projects/{id}/instances/{id}` — Instance detail with status, controls, and "Open Tool" link
### Auth Proxy
Spawned tools are protected behind Traefik forwardAuth middleware:
- Traefik forwards requests to `/api/v1/auth/validate` for session validation
- code-server built-in auth is disabled (`PASSWORD: ""`)
- Only authenticated platform users can access spawned tools
### Local Development
Ensure Docker socket is accessible and the `tools` network exists:
```bash
docker network create tools # One-time setup
```
Spawned containers use the `tools` network for Traefik routing.
+6 -6
View File
@@ -7,7 +7,7 @@
## 1. Product Vision
Headquarter is a hosted workspace and tool-orchestration platform for developers who want self-hosted control over their development environments. It gives authenticated users a single dashboard to create Git-backed projects, connect repositories from any provider, and spawn containerized tools—starting with RunFusion and code-server—on demand, each accessible via its own HTTPS subdomain. Headquarter is for individual developers and small teams who outgrow cloud IDEs but do not want to build their own orchestration layer from scratch.
Headquarter is a hosted workspace and tool-orchestration platform for developers who want self-hosted control over their development environments. It gives authenticated users a single dashboard to create Git-backed projects, connect repositories from any provider, and spawn containerized tools—starting with OpenCode and code-server—on demand, each accessible via its own HTTPS subdomain. Headquarter is for individual developers and small teams who outgrow cloud IDEs but do not want to build their own orchestration layer from scratch.
---
@@ -36,7 +36,7 @@ An MVP user can complete the following end-to-end flows without assistance:
- The public key is displayed to the user for manual registration at the provider, or registered automatically via the provider adapter when available.
### 2.5 Spawn a Tool Instance
- User navigates to "Tools" and selects a tool (RunFusion or code-server).
- User navigates to "Tools" and selects a tool (OpenCode or code-server).
- User chooses a project and optional config overrides.
- The backend generates a Docker Compose service definition, Traefik labels, and starts the container.
- The tool instance receives workspace mounts, config mounts, and secret injection.
@@ -70,7 +70,7 @@ An MVP user can complete the following end-to-end flows without assistance:
- **Project management** (CRUD, ownership-based)
- **Repository connections** with provider-agnostic Git URL storage
- **Per-repository SSH key generation** (Ed25519) with encrypted private-key storage
- **Tool registry** with manifest-driven definitions for RunFusion and code-server
- **Tool registry** with manifest-driven definitions for OpenCode and code-server
- **Tool instance spawning** via Docker Compose with Traefik subdomain routing
- **Tool instance lifecycle** (start, stop, health checks, status tracking)
- **Persistent config storage** at global, user, project, and tool-instance scopes
@@ -111,12 +111,12 @@ Slices are ordered by dependency. Each slice corresponds to a task on the Fusion
| 2 | **FN-019** | Architecture & Specification | Enhanced `docs/architecture.md`, `docs/mvp-scope.md`, doc validation tests |
| 3 | **FN-004** | Backend Foundation | Domain models, Alembic migrations, auth boundaries, secret encryption, API routers |
| 4 | **FN-005** | Frontend Foundation | Auth shell, navigation, placeholder pages, API client, config layer |
| 5 | **FN-003** | Tool Registry | Manifest schema, in-memory registry, built-in RunFusion/code-server manifests, FastAPI routes |
| 5 | **FN-003** | Tool Registry | Manifest schema, in-memory registry, built-in OpenCode/code-server manifests, FastAPI routes |
| 6 | **FN-006** | Deployment Config | Traefik label generator, production Compose stacks, Portainer stack definition |
| 7 | **FN-011** | Git Provider Model | Provider abstraction, SSH key lifecycle, credential models, repository connection |
| 8 | **FN-009** | Config & Secrets | Encrypted storage, runtime injection, frontend config/secrets UI |
| 9 | **FN-010** | code-server Spawn | code-server manifest, spawn flow, runtime integration, auth layer |
| 10 | **FN-008** | RunFusion POC | Executable environment, Node/npm runtime, health reporting |
| 10 | **FN-008** | OpenCode POC | AI-powered terminal environment, web interface, health reporting |
**Dependency notes:**
- FN-004 and FN-005 can proceed in parallel once FN-019 is complete.
@@ -139,7 +139,7 @@ FN-002 (Scaffold)
│ ├──> FN-003 (Tool Registry)
│ │ │
│ │ ├──> FN-010 (code-server Spawn)
│ │ └──> FN-008 (RunFusion POC)
│ │ └──> FN-008 (OpenCode POC)
│ │
│ ├──> FN-011 (Git Provider)
│ │
+1 -1
View File
@@ -2,7 +2,7 @@
## What
Headquarter is a hosted workspace and tool-orchestration platform. Authenticated users create Git-backed projects and launch containerized development tools—starting with RunFusion and code-server—each exposed via its own HTTPS subdomain.
Headquarter is a hosted workspace and tool-orchestration platform. Authenticated users create Git-backed projects and launch containerized development tools—starting with OpenCode and code-server—each exposed via its own HTTPS subdomain.
## Why
+16 -20
View File
@@ -5,7 +5,7 @@
## Overview
Headquarter is a manifest-driven platform: every containerized tool (RunFusion, code-server, and future tools) is declared by a YAML manifest. The orchestration backend reads these manifests to generate Docker Compose services, Traefik routing labels, volume mounts, and resource constraints.
Headquarter is a manifest-driven platform: every containerized tool (OpenCode, code-server, and future tools) is declared by a YAML manifest. The orchestration backend reads these manifests to generate Docker Compose services, Traefik routing labels, volume mounts, and resource constraints.
**Design goal:** Adding a new standard container tool requires only a YAML manifest—no backend code changes.
@@ -135,17 +135,17 @@ traefik:
7. `memory_mb`, if set, must be ≥ 16.
8. If `traefik.enabled` is `true`, at least one port must have `primary: true`.
## Example: RunFusion Manifest
## Example: OpenCode Manifest
```yaml
id: runfusion
name: RunFusion
description: Executable Node.js environment for running and developing applications.
id: opencode
name: OpenCode
description: AI-powered terminal-based development environment with web interface.
version: "1.0.0"
image: node:22-slim
image: ghcr.io/opencode-ai/opencode:latest
runtime_working_dir: /workspace
ports:
- container_port: 8080
- container_port: 3000
protocol: tcp
name: http
primary: true
@@ -156,32 +156,28 @@ workspace_mounts:
read_only: false
config_mounts:
- type: volume
source_pattern: "{user_config}/runfusion"
target: /home/node/.config
source_pattern: "{user_config}/opencode"
target: /root/.config/opencode
read_only: false
env:
NODE_ENV: development
TERM: xterm-256color
FORCE_COLOR: "1"
health_check:
type: http
path: /
port: 8080
port: 3000
interval_seconds: 10
timeout_seconds: 5
retries: 3
start_period_seconds: 10
start_period_seconds: 15
resource_limits:
cpus: 2.0
memory_mb: 2048
memory_mb: 4096
memory_swap_mb: -1
executable:
node_version: "22"
package_manager: npm
bootstrap_commands: []
install_commands: []
traefik:
enabled: true
subdomain_prefix: runfusion
port: 8080
subdomain_prefix: opencode
port: 3000
middlewares: []
strip_prefix: false
```
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-14
@@ -0,0 +1,67 @@
## Context
code-server is a VS Code instance running in a browser. The platform needs to spawn it as a Docker container with proper mounts, auth, and routing. This builds on the tool registry (FN-003) and deployment config (FN-006).
Current state:
- code-server manifest exists in apps/api/app/tools/manifests/code-server.yml
- ToolInstance model exists with status field
- No spawn orchestration logic
- No frontend UI for spawning
## Goals / Non-Goals
**Goals:**
- Spawn code-server containers via Docker Compose
- Mount user workspace, configs, secrets, and SSH keys
- Route via Traefik subdomain
- Track container status (creating, running, stopped, error)
- Provide spawn UI in frontend
**Non-Goals:**
- Support for other IDEs (deferred post-MVP)
- Container resource limits (CPU/memory) - basic only
- Automatic workspace backup
- Multi-instance load balancing
## Decisions
**1. Docker Compose API for container management**
- Rationale: Higher-level than Docker SDK, handles networking and volumes declaratively
- Alternative: Docker SDK directly - more control but more complex
**2. code-server runs with platform auth proxy**
- Rationale: Don't manage separate code-server passwords. Traefik middleware handles auth.
- Implementation: Traefik forwardAuth to platform API for session validation
**3. Workspace mounted from host directory**
- Rationale: Persistent storage between restarts. Easy backup.
- Path: `/data/workspaces/{user_slug}/{project_slug}`
**4. SSH keys mounted as read-only volume**
- Rationale: code-server needs Git access but shouldn't modify keys
- Mount: `/home/coder/.ssh/` with 0400 permissions
**5. Spawn is synchronous (blocking) API**
- Rationale: Simpler UX. Container creation is fast (< 5s).
- Alternative: Async with polling - more complex, unnecessary for MVP
## Risks / Trade-offs
**[Risk] Docker socket exposure is a security risk**
→ Mitigation: Run API with limited Docker access. Consider Docker socket proxy in production.
**[Risk] Container failures leave dangling resources**
→ Mitigation: Implement cleanup on error. Periodic garbage collection of orphaned containers.
**[Risk] code-server auth bypass**
→ Mitigation: Disable code-server auth (PASSWORD: ""). Rely entirely on Traefik forwardAuth.
## Migration Plan
No migration. New feature.
## Open Questions
1. Should we pre-pull Docker images or let Compose handle it?
2. Do we need container health checks before marking as "running"?
3. Should spawned containers auto-stop after inactivity?
@@ -0,0 +1,31 @@
## Why
Tool registry (FN-003) and deployment config (FN-006) are prerequisites for spawning tools. code-server is the primary user-facing tool in MVP. Without a spawn flow, users cannot launch development environments, which is the core value proposition.
## What Changes
- **code-server manifest refinement**: Update the built-in manifest with proper Docker image, ports, volumes, and config options
- **Spawn flow API**: Backend endpoint that creates a tool instance, generates Docker Compose service, and starts the container
- **Frontend spawn UI**: Form for selecting tool, project, and optional config overrides
- **Runtime integration**: Mount workspace, configs, secrets, and SSH keys into the code-server container
- **Auth proxy**: Ensure code-server is protected behind the platform's auth (no separate code-server password)
- **Status tracking**: Poll container status and expose it via API
## Capabilities
### New Capabilities
- `tool-spawn-api`: Backend endpoint for spawning tool instances
- `codeserver-manifest`: Refined code-server manifest with runtime configuration
- `spawn-ui`: Frontend form for tool selection and spawn configuration
- `container-lifecycle`: Start, stop, and status tracking for tool containers
### Modified Capabilities
- None (extends existing tool registry)
## Impact
- **apps/api/app/tools/manifests/code-server.yml**: Updated manifest
- **apps/api/app/routers/tool_instances.py**: Spawn endpoint enhancements
- **apps/api/app/services/spawn.py**: New spawn orchestration service
- **apps/web/src/**: New spawn UI components
- **docker-compose.yml**: May need updates for Docker socket access
@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: code-server manifest defines runtime configuration
The system SHALL provide a complete code-server manifest.
#### Scenario: Manifest includes Docker configuration
- **WHEN** the code-server manifest is loaded
- **THEN** it specifies the Docker image (codercom/code-server)
- **AND** it defines exposed ports (8080)
- **AND** it defines volume mounts (workspace, config, ssh)
#### Scenario: Manifest includes environment variables
- **WHEN** the manifest is used for spawning
- **THEN** it defines required environment variables
- **AND** it defines optional config overrides
### Requirement: code-server manifest is valid
The system SHALL validate the code-server manifest against the tool manifest schema.
#### Scenario: Schema validation
- **WHEN** the manifest is loaded at startup
- **THEN** it passes schema validation
- **AND** any errors prevent application startup
@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: Tool instance status is tracked
The system SHALL track the lifecycle status of tool instances.
#### Scenario: Status transitions
- **WHEN** a tool instance is created
- **THEN** its status is "creating"
- **AND** when the container starts, status becomes "running"
- **AND** when stopped, status becomes "stopped"
- **AND** on error, status becomes "error"
#### Scenario: Status polling
- **WHEN** the user views a tool instance
- **THEN** the frontend polls the status endpoint
- **AND** updates the UI when status changes
### Requirement: Tool instances can be stopped and restarted
The system SHALL allow stopping and restarting tool instances.
#### Scenario: Stop instance
- **WHEN** the user clicks "Stop" on a running instance
- **THEN** the system stops the Docker container
- **AND** updates the status to "stopped"
#### Scenario: Restart instance
- **WHEN** the user clicks "Start" on a stopped instance
- **THEN** the system starts the existing container
- **AND** updates the status to "running"
@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: User can spawn a tool from the UI
The system SHALL provide a user interface for spawning tools.
#### Scenario: Spawn form
- **WHEN** the user navigates to /tools/spawn
- **THEN** a form is displayed with tool selection
- **AND** project selection dropdown
- **AND** optional config override fields
#### Scenario: Tool selection
- **WHEN** the user selects a tool from the dropdown
- **THEN** the form shows tool-specific configuration options
- **AND** a description of the tool
#### Scenario: Spawn submission
- **WHEN** the user submits the spawn form
- **THEN** the frontend calls POST /api/v1/tool-instances
- **AND** displays a loading state
- **AND** redirects to the tool instance detail page on success
@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: API can spawn a tool instance
The system SHALL provide an endpoint to create and start a tool instance.
#### Scenario: Spawn code-server
- **WHEN** a POST request is made to /api/v1/tool-instances with tool_id and project_id
- **THEN** the system creates a ToolInstance record
- **AND** generates a Docker Compose service definition
- **AND** starts the container via Docker Compose API
- **AND** returns the tool instance with status "creating"
#### Scenario: Spawn with config overrides
- **WHEN** a spawn request includes config overrides
- **THEN** the overrides are merged with scope-resolved configs
- **AND** applied to the container environment
### Requirement: Spawn validates prerequisites
The system SHALL validate prerequisites before spawning.
#### Scenario: Valid project
- **WHEN** the spawn request references a project
- **THEN** the project must exist and belong to the user
- **AND** the tool definition must exist in the registry
#### Scenario: Duplicate spawn prevention
- **WHEN** a spawn request is made for an already-running instance
- **THEN** the system returns the existing instance
- **AND** does not create a duplicate container
@@ -0,0 +1,58 @@
## 1. Manifest Refinement
- [x] 1.1 Update apps/api/app/tools/manifests/code-server.yml with complete runtime config
- [x] 1.2 Add Docker image, ports, volumes, env vars to manifest
- [x] 1.3 Validate manifest against ToolManifest schema
- [x] 1.4 Test manifest loading at application startup
## 2. Spawn Service
- [x] 2.1 Create apps/api/app/services/spawn.py with SpawnService class
- [x] 2.2 Implement Docker Compose service generation from manifest
- [x] 2.3 Implement container start/stop via Docker Compose API
- [x] 2.4 Integrate Traefik label generation (FN-006)
- [x] 2.5 Integrate config/secrets runtime injection (FN-009)
- [x] 2.6 Implement workspace volume mounting
- [x] 2.7 Implement SSH key mounting for Git access
- [x] 2.8 Add container status polling
## 3. Backend API
- [x] 3.1 Enhance POST /api/v1/tool-instances with spawn logic
- [x] 3.2 Add DELETE /api/v1/tool-instances/:id/stop endpoint
- [x] 3.3 Add POST /api/v1/tool-instances/:id/start endpoint
- [x] 3.4 Add GET /api/v1/tool-instances/:id/status endpoint
- [x] 3.5 Add validation for project ownership and tool existence
- [x] 3.6 Prevent duplicate spawn of running instances
## 4. Frontend UI
- [x] 4.1 Create ToolSpawn page at /tools/spawn
- [x] 4.2 Implement tool selection dropdown from registry
- [x] 4.3 Implement project selection dropdown
- [x] 4.4 Add config override fields based on manifest
- [x] 4.5 Create ToolInstanceDetail page at /tools/:id
- [x] 4.6 Display instance status, subdomain URL, and controls (stop/start)
- [x] 4.7 Add "Open Tool" button that opens subdomain in new tab
## 5. Auth Integration
- [x] 5.1 Configure Traefik forwardAuth middleware for code-server
- [x] 5.2 Implement auth validation endpoint for Traefik
- [x] 5.3 Disable code-server built-in auth (PASSWORD: "")
- [x] 5.4 Test that unauthenticated requests are blocked
## 6. Testing & Verification
- [x] 6.1 Write backend tests for SpawnService
- [x] 6.2 Write backend tests for tool instance lifecycle endpoints
- [x] 6.3 Test container spawn in local Docker environment
- [x] 6.4 Verify Traefik routing to spawned container
- [x] 6.5 Run full test suite: `make test`
- [x] 6.6 Run linters: `make lint`
## 7. Documentation
- [x] 7.1 Update docs/development.md with spawn workflow
- [x] 7.2 Add code-server setup guide to docs/architecture.md
- [x] 7.3 Document auth proxy configuration
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-14
@@ -0,0 +1,75 @@
## Context
The frontend is currently a static scaffold (FN-002) with no routing, auth, or API integration. The backend has complete domain models, CRUD routers, and auth dependencies (FN-004, FN-011). This design bridges the gap by establishing the frontend architecture needed for all user-facing features.
Current frontend state:
- Single App.tsx with static HTML
- No routing, state management, or API client
- No auth integration
- Tests only verify static rendering
## Goals / Non-Goals
**Goals:**
- Deliver a functional auth flow (login/logout via Authentik OIDC)
- Provide a responsive dashboard shell with navigation
- Enable project CRUD operations from the UI
- Establish typed API client patterns
- Set up environment-based configuration
**Non-Goals:**
- Full tool spawn UI (deferred to FN-010/FN-008)
- Config/secrets management UI (deferred to FN-009)
- Repository connection UI (deferred to future task)
- Real-time updates or WebSockets
- Mobile-optimized responsive design (basic responsiveness only)
## Decisions
**1. React Router v7 for routing**
- Rationale: Industry standard, integrates well with React 19, supports nested routes and loaders
- Alternative: TanStack Router - more type-safe but steeper learning curve, overkill for MVP
**2. TanStack Query (React Query) for server state**
- Rationale: Standard for API caching, background refetching, and optimistic updates
- Alternative: SWR - similar but TanStack Query has better TypeScript support and devtools
**3. Zustand for client state**
- Rationale: Lightweight, TypeScript-friendly, minimal boilerplate vs Redux
- Alternative: Context API - sufficient for auth but Zustand scales better for future features
**4. HTTP client: fetch API with thin wrapper**
- Rationale: No extra dependency needed, native fetch is sufficient
- Alternative: Axios - adds bundle size, fetch handles our use cases
**5. Auth: OIDC Authorization Code flow with PKCE**
- Rationale: Secure, recommended by OAuth 2.1, Authentik supports it
- Implementation: redirect to Authentik authorize endpoint, callback handles code exchange
**6. Component library: Headless UI + Tailwind CSS**
- Rationale: Unstyled primitives give full control, Tailwind is already in Vite scaffold
- Alternative: Material UI - opinionated, harder to customize
## Risks / Trade-offs
**[Risk] Auth token storage in browser**
→ Mitigation: Use httpOnly cookies (set by backend callback) or secure storage. Never localStorage. Implement CSRF protection.
**[Risk] OIDC library bundle size**
→ Mitigation: Use lightweight oauth4webapi or implement PKCE manually (~2KB vs 50KB+ for oidc-client-ts)
**[Risk] CORS complexity between web and API**
→ Mitigation: Configure CORS in FastAPI to allow web origin. Use same-origin deployment in production (Traefik routes both).
**[Risk] Test complexity with auth flows**
→ Mitigation: Mock auth context in tests, test components in isolation. E2E tests deferred post-MVP.
## Migration Plan
No migration needed - this is additive to the scaffold.
## Open Questions
1. Should we use a pre-built OIDC client library or implement PKCE manually?
2. Do we need refresh token rotation or are short-lived access tokens sufficient?
3. Should the API client auto-retry on 401 or redirect immediately?
@@ -0,0 +1,32 @@
## Why
The backend API is fully scaffolded with domain models, routers, and authentication dependencies, but the frontend remains a static scaffold page (FN-002). Users cannot sign in, view projects, or interact with any backend functionality. This change delivers the foundational frontend architecture needed to unlock all user-facing MVP features.
## What Changes
- **Authentik OIDC integration**: Auth provider with login/logout flow, token management, and automatic user provisioning
- **Dashboard shell**: Responsive layout with header, navigation sidebar, and main content area
- **Navigation routes**: Dashboard, Projects, Repositories, Tools, Settings pages with React Router
- **Typed API client**: Generated or hand-written client for all `/api/v1/*` endpoints
- **Project list UI**: Display user's projects with create/edit capabilities
- **Environment config layer**: Vite env var integration for API URL, auth endpoints
- **Auth-guarded routes**: Redirect unauthenticated users to login
## Capabilities
### New Capabilities
- `auth-oidc`: Authentik OIDC authentication flow, token storage, session management
- `dashboard-shell`: Responsive layout with navigation, header, and content area
- `project-management-ui`: Project list, create, edit, delete views
- `api-client`: Typed HTTP client for backend API consumption
- `route-guards`: Authentication-based route protection and redirects
### Modified Capabilities
- None (this is purely additive to the existing scaffold)
## Impact
- **apps/web/src/**: All new frontend code
- **apps/web/package.json**: New dependencies (react-router-dom, @tanstack/react-query, etc.)
- **apps/api/app/auth/dependencies.py**: CORS and auth flow alignment
- **docs/development.md**: Updated frontend development instructions
@@ -0,0 +1,28 @@
## ADDED Requirements
### Requirement: API client handles all backend endpoints
The system SHALL provide a typed HTTP client for all backend API endpoints.
#### Scenario: GET request
- **WHEN** the client calls api.get('/projects')
- **THEN** it sends a GET request to /api/v1/projects
- **AND** returns typed Project[] data
- **AND** includes the Authorization header with the current access token
#### Scenario: POST request
- **WHEN** the client calls api.post('/projects', data)
- **THEN** it sends a POST request with JSON body
- **AND** returns typed Project data
#### Scenario: Error handling
- **WHEN** a request returns 4xx or 5xx
- **THEN** the client throws an ApiError with status code and message
- **AND** the error can be caught and displayed to the user
### Requirement: API client supports request/response types
The system SHALL use TypeScript interfaces matching the backend Pydantic schemas.
#### Scenario: Type safety
- **WHEN** a developer uses the API client
- **THEN** request and response types are checked at compile time
- **AND** mismatches produce TypeScript errors
@@ -0,0 +1,36 @@
## ADDED Requirements
### Requirement: User can authenticate via Authentik OIDC
The system SHALL provide an authentication flow using Authentik as the OIDC provider.
#### Scenario: Successful login
- **WHEN** an unauthenticated user clicks "Sign In"
- **THEN** the system redirects to Authentik's authorization endpoint with PKCE parameters
- **AND** after successful authentication, Authentik redirects back with an authorization code
- **AND** the system exchanges the code for tokens
- **AND** the user is redirected to the dashboard
#### Scenario: Automatic user provisioning
- **WHEN** a user authenticates for the first time
- **THEN** the backend creates a User record automatically
- **AND** the user can access their projects immediately
#### Scenario: Logout
- **WHEN** an authenticated user clicks "Sign Out"
- **THEN** the system clears all session data
- **AND** redirects to Authentik's end_session_endpoint
- **AND** the user is redirected back to the login page
### Requirement: Auth state is managed globally
The system SHALL maintain authentication state accessible throughout the application.
#### Scenario: Auth context available
- **WHEN** the application loads
- **THEN** an auth context provider wraps the component tree
- **AND** child components can read the current auth state (loading, authenticated, unauthenticated, error)
#### Scenario: Token refresh
- **WHEN** an API request returns 401 due to expired token
- **THEN** the system attempts token refresh
- **AND** retries the original request with the new token
- **AND** if refresh fails, redirects to login
@@ -0,0 +1,28 @@
## ADDED Requirements
### Requirement: Dashboard provides responsive layout
The system SHALL provide a consistent layout with header, navigation, and content area.
#### Scenario: Layout structure
- **WHEN** the user views any authenticated page
- **THEN** a header displays the application name and user avatar
- **AND** a sidebar shows navigation links (Dashboard, Projects, Repositories, Tools, Settings)
- **AND** the main content area renders the current route's component
#### Scenario: Collapsible sidebar
- **WHEN** the user is on a mobile device
- **THEN** the sidebar is initially collapsed
- **AND** a hamburger menu toggles the sidebar visibility
### Requirement: Navigation reflects auth state
The system SHALL show/hide navigation items based on authentication status.
#### Scenario: Authenticated navigation
- **WHEN** the user is authenticated
- **THEN** all navigation links are visible
- **AND** "Sign Out" is available in the user menu
#### Scenario: Unauthenticated navigation
- **WHEN** the user is not authenticated
- **THEN** only "Sign In" is shown
- **AND** accessing protected routes redirects to login
@@ -0,0 +1,39 @@
## ADDED Requirements
### Requirement: User can view their projects
The system SHALL display a list of projects belonging to the authenticated user.
#### Scenario: Project list page
- **WHEN** the user navigates to /projects
- **THEN** the system fetches projects from /api/v1/projects
- **AND** displays each project with name, description, and created date
- **AND** shows an empty state when no projects exist
#### Scenario: Project detail
- **WHEN** the user clicks on a project
- **THEN** the system navigates to /projects/:id
- **AND** displays project details including repositories and tool instances
### Requirement: User can create a project
The system SHALL allow authenticated users to create new projects.
#### Scenario: Create project form
- **WHEN** the user clicks "New Project"
- **THEN** a form appears with name and description fields
- **AND** the name field validates for non-empty and URL-friendly slug generation
- **AND** submitting the form POSTs to /api/v1/projects
- **AND** on success, the user is redirected to the new project
### Requirement: User can edit and delete projects
The system SHALL allow project owners to modify or remove their projects.
#### Scenario: Edit project
- **WHEN** the user clicks "Edit" on a project
- **THEN** a pre-filled form appears
- **AND** submitting updates the project via PUT /api/v1/projects/:id
#### Scenario: Delete project
- **WHEN** the user clicks "Delete" on a project
- **THEN** a confirmation dialog appears
- **AND** confirming sends DELETE /api/v1/projects/:id
- **AND** the project is removed from the list
@@ -0,0 +1,24 @@
## ADDED Requirements
### Requirement: Protected routes require authentication
The system SHALL prevent unauthenticated users from accessing protected pages.
#### Scenario: Unauthenticated access attempt
- **WHEN** an unauthenticated user navigates to /projects
- **THEN** the system redirects to /login
- **AND** stores the intended destination for post-login redirect
#### Scenario: Authenticated access
- **WHEN** an authenticated user navigates to /projects
- **THEN** the route renders normally
### Requirement: Public routes are accessible
The system SHALL allow unauthenticated access to public pages.
#### Scenario: Login page
- **WHEN** an unauthenticated user navigates to /login
- **THEN** the login page renders without redirect
#### Scenario: Health/status pages
- **WHEN** any user navigates to /health
- **THEN** the page renders without authentication
@@ -0,0 +1,72 @@
## 1. Setup & Dependencies
- [x] 1.1 Install frontend dependencies: react-router-dom, @tanstack/react-query, zustand, @headlessui/react
- [x] 1.2 Set up Tailwind CSS configuration (tailwind.config.js, postcss.config.js)
- [x] 1.3 Create environment type definitions in apps/web/src/env.d.ts
- [x] 1.4 Add Vite environment variables to .env.example (VITE_API_URL, VITE_OIDC_ISSUER, etc.)
## 2. API Client & Types
- [x] 2.1 Create apps/web/src/api/client.ts with typed fetch wrapper and auth header injection
- [x] 2.2 Generate or create TypeScript interfaces matching backend schemas (Project, User, etc.)
- [x] 2.3 Implement error handling with ApiError class
- [x] 2.4 Add request/response logging in debug mode
## 3. Authentication
- [x] 3.1 Create apps/web/src/auth/oidc.ts with PKCE code generation and token exchange
- [x] 3.2 Implement auth store (Zustand) with state: loading, authenticated, unauthenticated, error
- [x] 3.3 Create AuthProvider component wrapping the app
- [x] 3.4 Implement login redirect to Authentik authorize endpoint
- [x] 3.5 Implement callback handler (/callback route) for code exchange
- [x] 3.6 Implement logout with end_session_endpoint redirect
- [x] 3.7 Add token refresh logic for expired access tokens
## 4. Routing & Layout
- [x] 4.1 Set up React Router with route definitions in apps/web/src/router.tsx
- [x] 4.2 Create DashboardLayout component with header, sidebar, and outlet
- [x] 4.3 Implement RouteGuard component for protected routes
- [x] 4.4 Add public routes: /login, /callback
- [x] 4.5 Add protected routes: /, /projects, /projects/:id, /tools, /settings
## 5. Dashboard Shell
- [x] 5.1 Create Header component with app name and user avatar dropdown
- [x] 5.2 Create Sidebar component with navigation links
- [x] 5.3 Implement mobile-responsive sidebar toggle
- [x] 5.4 Add active route highlighting in sidebar
- [x] 5.5 Create Dashboard home page with welcome content
## 6. Project Management UI
- [x] 6.1 Create ProjectList page fetching from /api/v1/projects
- [x] 6.2 Implement ProjectCard component for list view
- [x] 6.3 Add empty state when no projects exist
- [x] 6.4 Create ProjectDetail page at /projects/:id
- [x] 6.5 Implement NewProject form with validation (name, description)
- [x] 6.6 Implement EditProject form with pre-filled data
- [x] 6.7 Add delete confirmation dialog for projects
- [x] 6.8 Wire up TanStack Query mutations for create/update/delete
## 7. Placeholder Pages
- [x] 7.1 Create Tools page placeholder
- [x] 7.2 Create Settings page placeholder
- [x] 7.3 Create Repositories page placeholder
## 8. Testing & Verification
- [x] 8.1 Write unit tests for auth store
- [x] 8.2 Write unit tests for API client error handling
- [x] 8.3 Write tests for RouteGuard component
- [x] 8.4 Update App.test.tsx to test routing
- [x] 8.5 Run `pnpm test` and fix any failures
- [x] 8.6 Run `pnpm lint` and fix any issues
- [x] 8.7 Run `pnpm typecheck` and fix any errors
## 9. Documentation
- [x] 9.1 Update docs/development.md with frontend auth setup instructions
- [x] 9.2 Update README.md with new environment variables
- [x] 9.3 Add frontend architecture notes to docs/architecture.md
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-14
+63
View File
@@ -0,0 +1,63 @@
## Context
The backend has Config and Secret models (FN-004) with scope fields, but no frontend UI or runtime injection. SSH keys already use Fernet encryption (FN-011), so the encryption pattern is established. This design completes the config/secrets lifecycle.
Current state:
- Config model: key, value, scope (global/user/project/instance), scope_id
- Secret model: key, encrypted_value, scope, scope_id
- Fernet encryption utilities exist in app/encryption.py
- No UI for management
- No runtime injection into containers
## Goals / Non-Goals
**Goals:**
- Allow users to manage configs and secrets via UI
- Inject configs/secrets into tool containers at spawn time
- Support scope-based inheritance (instance overrides project overrides user overrides global)
- Maintain encryption for all secret values
**Non-Goals:**
- Secret versioning or history
- Automatic secret rotation
- Integration with external secret managers (Vault, AWS Secrets Manager)
- Config/secrets for non-tool resources
## Decisions
**1. Mount configs as files, secrets as env vars**
- Rationale: Configs (JSON) are often files (e.g., settings.json). Secrets are typically env vars.
- Config mount: `/app/config/<key>.json`
- Secret env: `<KEY>=<decrypted_value>`
**2. Scope resolution: closest match wins**
- Rationale: Instance-specific values should override project defaults
- Resolution order: instance → project → user → global
**3. Secret values never sent to frontend decrypted**
- Rationale: Security. Frontend only sees masked values (e.g., `••••••`).
- Decryption happens only in backend during runtime injection
**4. Config values are plaintext (not encrypted)**
- Rationale: Configs are not sensitive. Encrypting them adds complexity without security benefit.
## Risks / Trade-offs
**[Risk] Secret injection at spawn time could fail silently**
→ Mitigation: Validate all referenced secrets exist before spawning. Return error if missing.
**[Risk] Config files in containers could be read by other processes**
→ Mitigation: Mount config files with restrictive permissions (0400). Run containers as non-root.
**[Risk] Large configs could exceed container env var limits**
→ Mitigation: Document size limits. Consider config file mounting for large values.
## Migration Plan
No migration needed. This extends existing models.
## Open Questions
1. Should configs support JSON schema validation?
2. Do we need bulk import/export for configs/secrets?
3. Should secret keys be validated against a naming convention?
@@ -0,0 +1,29 @@
## Why
Tool instances need runtime configuration and secrets (API keys, database passwords, etc.). The backend has Config and Secret models (FN-004), but there's no UI for users to manage these values, and no runtime injection mechanism to pass them into spawned containers.
## What Changes
- **Config management UI**: Frontend pages for creating, updating, and deleting config values at global/user/project/instance scopes
- **Secret management UI**: Frontend pages for encrypted secret storage with masked value display
- **Runtime injection**: Backend service that mounts configs and secrets into tool containers at spawn time
- **Scope-based access control**: Configs/secrets respect scope hierarchy (global → user → project → instance)
- **Encryption verification**: Ensure Fernet encryption is properly applied to all secret values
## Capabilities
### New Capabilities
- `config-management`: CRUD operations for configuration values with scope support
- `secret-management`: Encrypted storage and retrieval of sensitive values
- `runtime-injection`: Mount configs and secrets into tool containers at spawn
### Modified Capabilities
- None (extends existing Config/Secret models)
## Impact
- **apps/web/src/**: New config and secret management pages
- **apps/api/app/routers/configs.py**: Enhanced with scope filtering
- **apps/api/app/routers/secrets.py**: Enhanced with scope filtering
- **apps/api/app/services/**: New runtime injection service
- **apps/api/app/models/**: Potential Config/Secret model updates for scope validation
@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: User can create config values
The system SHALL allow users to create configuration values at various scopes.
#### Scenario: Create project config
- **WHEN** the user navigates to project settings
- **AND** clicks "Add Config"
- **THEN** a form appears with key, value, and scope fields
- **AND** submitting creates a config at the selected scope
#### Scenario: Config scope validation
- **WHEN** the user creates a config
- **THEN** the scope must be one of: global, user, project, instance
- **AND** the scope_id must match the selected scope type
### Requirement: User can view and update configs
The system SHALL display configs with scope-based filtering.
#### Scenario: List configs
- **WHEN** the user views configs for a project
- **THEN** all configs visible at project scope or above are displayed
- **AND** values are shown as formatted JSON
#### Scenario: Update config
- **WHEN** the user edits a config value
- **THEN** the updated value is saved
- **AND** the change takes effect on next tool spawn
### Requirement: User can delete configs
The system SHALL allow deletion of config values.
#### Scenario: Delete config
- **WHEN** the user clicks delete on a config
- **THEN** a confirmation dialog appears
- **AND** confirming removes the config
- **AND** the config is no longer injected into containers
@@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: Configs are mounted into tool containers
The system SHALL mount configuration values as files into spawned tool containers.
#### Scenario: Config file mount
- **WHEN** a tool instance is spawned
- **THEN** all applicable configs are written to /app/config/
- **AND** each config is a separate JSON file named by key
- **AND** files have restrictive permissions (0400)
#### Scenario: Config scope resolution
- **WHEN** configs are resolved for a tool instance
- **THEN** the system collects configs from all applicable scopes
- **AND** instance scope overrides project scope
- **AND** project scope overrides user scope
- **AND** user scope overrides global scope
### Requirement: Secrets are injected as environment variables
The system SHALL inject secret values as environment variables into tool containers.
#### Scenario: Secret env var injection
- **WHEN** a tool instance is spawned
- **THEN** all applicable secrets are decrypted
- **AND** injected as environment variables with uppercase keys
- **AND** the container process can access them
#### Scenario: Secret scope resolution
- **WHEN** secrets are resolved for a tool instance
- **THEN** the same scope hierarchy applies as configs
- **AND** closest scope wins on key collision
### Requirement: Missing secrets fail spawn
The system SHALL prevent spawning if referenced secrets are missing.
#### Scenario: Validate secrets before spawn
- **WHEN** a spawn request references a secret by key
- **AND** the secret does not exist in any applicable scope
- **THEN** the spawn fails with a clear error message
- **AND** no container is created
@@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: User can create secrets
The system SHALL allow users to store encrypted secret values.
#### Scenario: Create secret
- **WHEN** the user navigates to project secrets
- **AND** clicks "Add Secret"
- **THEN** a form appears with key and value fields
- **AND** the value is encrypted with Fernet before storage
- **AND** the user sees a masked value (e.g., ••••••) after creation
#### Scenario: Secret scope
- **WHEN** the user creates a secret
- **THEN** the scope can be user, project, or instance
- **AND** the secret is only visible within that scope hierarchy
### Requirement: Secrets are never exposed decrypted
The system SHALL prevent decrypted secret values from being sent to the frontend.
#### Scenario: Secret list display
- **WHEN** the user views the secrets list
- **THEN** only secret keys and scopes are visible
- **AND** values are always masked
#### Scenario: Secret update
- **WHEN** the user updates a secret
- **THEN** only the new value is sent to the backend
- **AND** the old value is replaced (not displayed)
### Requirement: User can delete secrets
The system SHALL allow deletion of secret values.
#### Scenario: Delete secret
- **WHEN** the user deletes a secret
- **THEN** the encrypted value is permanently removed
- **AND** the secret is no longer injected into containers
+48
View File
@@ -0,0 +1,48 @@
## 1. Backend Enhancements
- [ ] 1.1 Update Config model with scope validation methods
- [ ] 1.2 Update Secret model with encryption verification
- [ ] 1.3 Enhance configs router with scope filtering and hierarchy resolution
- [ ] 1.4 Enhance secrets router with scope filtering and hierarchy resolution
- [ ] 1.5 Create apps/api/app/services/runtime_injection.py for config/secret resolution
- [ ] 1.6 Implement config file generation for container mounts
- [ ] 1.7 Implement secret env var generation for container injection
- [ ] 1.8 Add validation to fail spawn when referenced secrets are missing
## 2. Frontend - Config Management
- [ ] 2.1 Create ConfigList component at /projects/:id/configs
- [ ] 2.2 Implement ConfigForm for creating/updating configs
- [ ] 2.3 Add scope selector (project/instance/global) to config form
- [ ] 2.4 Implement config delete with confirmation
- [ ] 2.5 Add JSON formatting for config values
## 3. Frontend - Secret Management
- [ ] 3.1 Create SecretList component at /projects/:id/secrets
- [ ] 3.2 Implement SecretForm for creating/updating secrets
- [ ] 3.3 Add masked value display (never show decrypted)
- [ ] 3.4 Implement secret delete with confirmation
- [ ] 3.5 Add scope selector to secret form
## 4. Runtime Integration
- [ ] 4.1 Integrate runtime injection into tool instance spawn endpoint
- [ ] 4.2 Update Docker Compose generation to include config mounts
- [ ] 4.3 Update Docker Compose generation to include secret env vars
- [ ] 4.4 Test config/secret injection in local Docker environment
## 5. Testing & Verification
- [ ] 5.1 Write backend tests for config scope resolution
- [ ] 5.2 Write backend tests for secret encryption/decryption
- [ ] 5.3 Write backend tests for runtime injection
- [ ] 5.4 Write frontend tests for ConfigList and SecretList
- [ ] 5.5 Run full test suite: `make test`
- [ ] 5.6 Run linters: `make lint`
## 6. Documentation
- [ ] 6.1 Update docs/development.md with config/secrets workflow
- [ ] 6.2 Add config/secrets UI guide to docs/architecture.md
- [ ] 6.3 Document scope hierarchy and resolution rules
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-14
@@ -0,0 +1,67 @@
## Context
The platform routes tool instances via Traefik using subdomain patterns like `https://{tool}-{project}-{user}.{tool_domain}`. Currently, there's no automated label generation or production deployment configuration. This design establishes the deployment architecture.
Current state:
- `docker-compose.yml` for local dev only
- `docker-compose.traefik.yml` exists but is minimal
- `deploy/` directory has skeleton files
- No automated Traefik label generation
## Goals / Non-Goals
**Goals:**
- Generate Traefik labels automatically when spawning tools
- Provide production-ready Docker Compose stack
- Support Portainer-managed deployment
- Enable HTTPS with automatic certificate management
**Non-Goals:**
- Kubernetes deployment (deferred post-MVP)
- Multi-region or high-availability setup
- Custom reverse proxy (Traefik is the only supported option)
- Automatic DNS management
## Decisions
**1. Label generation in backend, not in Docker Compose**
- Rationale: Backend has all metadata (user slug, project slug, tool ID). Generating labels at spawn time is more flexible than static Compose files.
- Implementation: `TraefikLabelGenerator` service class
**2. Subdomain pattern: `{tool}-{project}-{user}.{domain}`**
- Rationale: Unique, deterministic, human-readable
- Example: `code-server-myapp-alice.headquarter.example.com`
**3. Separate Docker networks: `platform` and `tools`**
- Rationale: Network isolation between platform services and user tools
- Platform network: API, web, Traefik, database
- Tools network: Traefik + tool containers only
**4. Portainer as the deployment target**
- Rationale: Docker Compose-native, web UI for operators, supports stacks and webhooks
- Alternative: Raw Docker Compose on VM - less operator-friendly
**5. Let's Encrypt for HTTPS in production**
- Rationale: Free, automatic, Traefik has built-in support
- Alternative: Custom certificates - adds operational burden
## Risks / Trade-offs
**[Risk] Traefik label complexity grows with features**
→ Mitigation: Keep label generation centralized in one service class. Test label output against Traefik schema.
**[Risk] Portainer stack updates require downtime**
→ Mitigation: Use rolling updates where possible. Document blue-green deployment strategy.
**[Risk] Subdomain collision**
→ Mitigation: Enforce unique project slugs per user. Include user slug in subdomain.
## Migration Plan
No migration - new deployment stack is additive.
## Open Questions
1. Should we support custom domains per user/project in MVP?
2. Do we need basic auth or IP allow-listing for Traefik dashboard?
3. Should tool containers run on a separate Docker daemon for security?
@@ -0,0 +1,31 @@
## Why
The scaffold provides local Docker Compose development (FN-002) but lacks production deployment configuration. Without Traefik label generation and production stacks, tool instances cannot receive HTTPS subdomains, blocking the core value proposition of the platform.
## What Changes
- **Traefik label generator**: Backend service that generates Docker labels for subdomain routing based on tool instance metadata
- **Production Docker Compose stack**: `docker-compose.prod.yml` with API, web, Traefik, and PostgreSQL services
- **Portainer stack definition**: Docker Compose file optimized for Portainer deployment
- **Dynamic subdomain routing**: Automatic Traefik rule generation for spawned tool containers
- **HTTPS configuration**: Let's Encrypt or custom certificate support via Traefik
- **Network isolation**: Separate Docker networks for platform and tool containers
## Capabilities
### New Capabilities
- `traefik-label-generator`: Generate Traefik Docker labels for tool subdomain routing
- `production-compose-stack`: Production Docker Compose configuration
- `portainer-deployment`: Portainer-friendly stack definition and deployment guide
- `subdomain-routing`: Dynamic HTTPS subdomain allocation for tool instances
### Modified Capabilities
- None (this extends the existing deployment skeleton)
## Impact
- **apps/api/app/services/**: New Traefik label generation service
- **apps/api/app/routers/tool_instances.py**: Integrate label generation on spawn
- **deploy/**: New production deployment files
- **docker-compose.prod.yml**: Production stack definition
- **docs/deployment.md**: Updated deployment instructions
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Stack deploys via Portainer
The system SHALL provide a Portainer-compatible stack definition.
#### Scenario: Portainer stack file
- **WHEN** an operator deploys via Portainer
- **THEN** they can paste the stack definition into Portainer's stack editor
- **AND** Portainer can pull and deploy all services
#### Scenario: Environment variables in Portainer
- **WHEN** the stack is deployed via Portainer
- **THEN** environment variables are configured in Portainer's UI
- **AND** the stack references these variables
### Requirement: Deployment documentation is complete
The system SHALL provide operator documentation for deployment.
#### Scenario: Deployment guide
- **WHEN** an operator reads docs/deployment.md
- **THEN** they find step-by-step instructions for Portainer deployment
- **AND** prerequisites and assumptions are clearly stated
@@ -0,0 +1,28 @@
## ADDED Requirements
### Requirement: Production stack includes all required services
The system SHALL provide a production Docker Compose stack with API, web, Traefik, and PostgreSQL.
#### Scenario: Stack services
- **WHEN** the production stack is deployed
- **THEN** the following services run: api, web, traefik, db
- **AND** Traefik routes requests to the appropriate service
- **AND** services communicate via isolated Docker networks
#### Scenario: Environment configuration
- **WHEN** the stack starts
- **THEN** it reads environment variables from .env
- **AND** sensitive values are not hardcoded
### Requirement: Production stack is secure by default
The system SHALL configure security headers and access controls in production.
#### Scenario: HTTPS only
- **WHEN** the stack runs in production
- **THEN** all traffic uses HTTPS
- **AND** HTTP redirects to HTTPS
#### Scenario: Network isolation
- **WHEN** the stack is deployed
- **THEN** platform services and tool containers are on separate networks
- **AND** tool containers cannot access the database directly
@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Each tool instance gets a unique subdomain
The system SHALL assign a unique HTTPS subdomain to each running tool instance.
#### Scenario: Subdomain pattern
- **WHEN** a tool instance is spawned
- **THEN** its subdomain follows `{tool}-{project}-{user}.{domain}`
- **AND** the subdomain is deterministic based on instance metadata
#### Scenario: Subdomain accessibility
- **WHEN** a tool instance reaches running status
- **THEN** its subdomain resolves via DNS
- **AND** Traefik routes the subdomain to the container
- **AND** the user can access the tool via the subdomain URL
### Requirement: Subdomain is released on stop
The system SHALL remove Traefik routing when a tool instance stops.
#### Scenario: Stop removes routing
- **WHEN** a tool instance is stopped
- **THEN** Traefik labels are removed or disabled
- **AND** the subdomain no longer routes to the container
@@ -0,0 +1,24 @@
## ADDED Requirements
### Requirement: Tool spawn generates Traefik labels
The system SHALL generate Docker labels for Traefik when spawning a tool instance.
#### Scenario: Label generation on spawn
- **WHEN** a tool instance is spawned
- **THEN** the backend generates Traefik router and service labels
- **AND** labels include rule, service, port, and TLS configuration
- **AND** labels are stored with the tool instance metadata
#### Scenario: Label format
- **WHEN** labels are generated for a tool instance
- **THEN** router rule uses Host(`{subdomain}.{domain}`)
- **AND** service points to the container's exposed port
- **AND** TLS is enabled with certResolver
### Requirement: Label generation handles multiple instances
The system SHALL generate unique labels for each tool instance.
#### Scenario: Unique router names
- **WHEN** multiple instances of the same tool exist
- **THEN** each instance gets a unique router name
- **AND** no label collisions occur
@@ -0,0 +1,44 @@
## 1. Traefik Label Generator
- [x] 1.1 Create apps/api/app/services/traefik.py with TraefikLabelGenerator class
- [x] 1.2 Implement subdomain generation from tool_id, project_slug, user_slug
- [x] 1.3 Generate router labels (rule, service, tls)
- [x] 1.4 Generate service labels (loadBalancer, port)
- [x] 1.5 Add middleware labels for security headers
- [x] 1.6 Write unit tests for label generation
## 2. Backend Integration
- [x] 2.1 Integrate label generation into tool instance spawn endpoint
- [x] 2.2 Store generated labels in tool_instance metadata
- [x] 2.3 Remove/disable labels on tool instance stop
- [x] 2.4 Update ToolInstance model to store labels JSON
## 3. Production Docker Compose
- [x] 3.1 Create docker-compose.prod.yml with api, web, traefik, db services
- [x] 3.2 Configure Traefik service with Let's Encrypt certificates
- [x] 3.3 Set up platform and tools networks
- [x] 3.4 Add health checks for all services
- [x] 3.5 Configure logging (JSON format, rotation)
## 4. Portainer Deployment
- [x] 4.1 Create deploy/portainer-stack.yml
- [x] 4.2 Add Portainer-specific environment variable documentation
- [x] 4.3 Create deploy/.env.example for production
- [x] 4.4 Test stack deployment locally with docker compose -f docker-compose.prod.yml
## 5. Documentation
- [x] 5.1 Update docs/deployment.md with production deployment steps
- [x] 5.2 Add Traefik configuration guide
- [x] 5.3 Document subdomain scheme and DNS requirements
- [x] 5.4 Update README.md with deployment section
## 6. Testing & Verification
- [x] 6.1 Test label generation for all built-in tools
- [x] 6.2 Verify Traefik routes correctly in local stack
- [x] 6.3 Run backend tests: `cd apps/api && pytest`
- [x] 6.4 Run linters: `ruff check app/` and `mypy app/`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-14
+65
View File
@@ -0,0 +1,65 @@
## Context
OpenCode is an AI-powered terminal-based development environment with a web interface. Unlike code-server which is a full web IDE, OpenCode provides a terminal experience accessible through the browser. This POC validates that the spawn system handles different runtime types including web terminal forwarding.
Current state:
- OpenCode manifest exists in apps/api/app/tools/manifests/opencode.yml
- No container image or runtime defined yet
- No health reporting mechanism
- Spawn infrastructure will be built in FN-010
## Goals / Non-Goals
**Goals:**
- Define OpenCode as a spawnable tool
- Provide web terminal interface in container
- Report health status (running/idle/error)
- Support interactive terminal sessions
**Non-Goals:**
- Full task queue or job scheduler
- Persistent process management
- Log streaming (deferred)
- Multi-language support beyond terminal
## Decisions
**1. Use official OpenCode Docker image**
- Rationale: Maintained, includes AI features and web terminal
- Alternative: Custom image - unnecessary for POC
**2. OpenCode runs as a persistent container**
- Rationale: Easier to manage lifecycle (start/stop/status). Terminal sessions need persistent container.
- Implementation: Container runs OpenCode with web interface on port 3000
**3. Health check via HTTP endpoint**
- Rationale: Standard Docker health check mechanism. Traefik can use it.
- Endpoint: `GET /` returns 200 when ready
**4. Workspace mounted from host (same as code-server)**
- Rationale: Consistency. Shared workspace between tools.
- Path: `/data/workspaces/{user_slug}/{project_slug}`
**5. Configs/secrets injected same as code-server**
- Rationale: Reuse FN-009 infrastructure. No special handling needed.
## Risks / Trade-offs
**[Risk] OpenCode container requires significant resources**
→ Mitigation: Set resource limits (4GB RAM, 2 CPU). Document requirements.
**[Risk] Web terminal performance over slow connections**
→ Mitigation: Use modern terminal emulation with compression. Document bandwidth requirements.
**[Risk] AI features require API keys**
→ Mitigation: Support secret injection for API keys. Document configuration.
## Migration Plan
No migration. New feature.
## Open Questions
1. Should OpenCode support multiple terminal sessions?
2. Do we pre-configure common development tools?
3. Should OpenCode integrate with the platform's AI provider?
@@ -0,0 +1,28 @@
## Why
OpenCode is an AI-powered terminal-based development environment that provides a web interface for interactive development. It demonstrates the platform's extensibility beyond standard tools like code-server. As a POC, it validates the manifest-driven spawn system with a non-trivial runtime that requires web terminal forwarding.
## What Changes
- **OpenCode manifest**: Define the tool with terminal web interface, workspace mounts, and health checks
- **Container image**: Reference to OpenCode image with built-in web terminal
- **Health reporting**: Endpoint that reports tool health to the platform
- **Spawn integration**: Reuse the spawn flow from FN-010 but with OpenCode-specific configuration
- **Web terminal**: Support for browser-based terminal access
## Capabilities
### New Capabilities
- `opencode-manifest`: OpenCode tool manifest with web terminal config
- `web-terminal`: Support for browser-based terminal interfaces
- `health-reporting`: Tool health status reporting mechanism
### Modified Capabilities
- None (reuses spawn infrastructure from FN-010)
## Impact
- **apps/api/app/tools/manifests/opencode.yml**: Updated manifest
- **apps/api/app/services/spawn.py**: Minor updates for OpenCode-specific mounts
- **apps/web/src/**: OpenCode appears in tool selection UI
- **Docker images**: Uses official OpenCode image
@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Container provides web terminal interface
The system SHALL provide a web terminal interface in the OpenCode container.
#### Scenario: Terminal available
- **WHEN** the OpenCode container is running
- **THEN** a web terminal is accessible via HTTP on port 3000
- **AND** the user can execute shell commands through the browser
#### Scenario: Workspace access
- **WHEN** the container runs
- **THEN** the project workspace is mounted at /workspace
- **AND** the user can read/write files in the workspace
### Requirement: Container supports AI features
The system SHALL allow AI-powered development features in the OpenCode environment.
#### Scenario: AI assistance
- **WHEN** the user interacts with OpenCode
- **THEN** AI features are available for code completion and assistance
- **AND** the user can configure AI provider settings
#### Scenario: Terminal session persistence
- **WHEN** the user opens a terminal session
- **THEN** the session persists while the container runs
- **AND** multiple sessions can be opened
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Tool reports health status
The system SHALL provide a mechanism for OpenCode to report its health.
#### Scenario: Health endpoint
- **WHEN** the OpenCode container is running
- **THEN** it exposes a / endpoint for health checks
- **AND** returns 200 when the web terminal is ready
#### Scenario: Health check in Traefik
- **WHEN** the container is spawned
- **THEN** Traefik uses the health endpoint for routing decisions
- **AND** unhealthy containers are removed from the load balancer
### Requirement: Platform tracks tool health
The system SHALL track and display the health of OpenCode instances.
#### Scenario: Status display
- **WHEN** the user views an OpenCode instance
- **THEN** the current status is displayed (healthy, unhealthy, starting)
- **AND** the status updates automatically
@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: OpenCode manifest defines web terminal environment
The system SHALL provide an OpenCode manifest with web terminal configuration.
#### Scenario: Manifest includes terminal config
- **WHEN** the OpenCode manifest is loaded
- **THEN** it specifies an OpenCode Docker image with web interface
- **AND** it defines exposed ports for the HTTP interface (port 3000)
- **AND** it defines volume mounts (workspace, config)
#### Scenario: Manifest includes health check
- **WHEN** the manifest is used for spawning
- **THEN** it defines a health check endpoint
- **AND** specifies health check interval and timeout
+42
View File
@@ -0,0 +1,42 @@
## 1. Manifest Definition
- [x] 1.1 Create apps/api/app/tools/manifests/opencode.yml with web terminal config
- [x] 1.2 Add Docker image (ghcr.io/opencode-ai/opencode:latest), ports (3000), volumes
- [x] 1.3 Add health check configuration to manifest
- [x] 1.4 Validate manifest against ToolManifest schema
## 2. Container Setup
- [ ] 2.1 Verify OpenCode image availability and configuration
- [ ] 2.2 Document web terminal access pattern
- [ ] 2.3 Configure environment variables for terminal support
- [ ] 2.4 Test container locally with docker run
- [ ] 2.5 Verify web terminal accessibility
## 3. Spawn Integration
- [ ] 3.1 Verify SpawnService (FN-010) can spawn OpenCode instances
- [ ] 3.2 Add OpenCode-specific volume mounts (config)
- [ ] 3.3 Test spawn via API endpoint
- [ ] 3.4 Verify Traefik routing to OpenCode container
## 4. Frontend Integration
- [ ] 4.1 Add OpenCode to tool selection dropdown
- [ ] 4.2 Display OpenCode-specific options in spawn form
- [ ] 4.3 Show OpenCode instance status in detail page
## 5. Testing & Verification
- [ ] 5.1 Test terminal availability in spawned container
- [ ] 5.2 Test web interface accessibility
- [ ] 5.3 Test health endpoint response
- [ ] 5.4 Verify workspace mount is accessible
- [ ] 5.5 Run full test suite: `make test`
- [ ] 5.6 Run linters: `make lint`
## 6. Documentation
- [ ] 6.1 Document OpenCode setup in docs/development.md
- [ ] 6.2 Add OpenCode usage guide
- [ ] 6.3 Document terminal configuration and AI features
+48
View File
@@ -0,0 +1,48 @@
schema: spec-driven
# Project context - shown to AI when creating artifacts
context: |
Tech stack:
- Frontend: React 19 + Vite 6 + TypeScript 5
- Backend: FastAPI + SQLAlchemy 2.0 (async) + Pydantic v2 + Alembic
- Database: PostgreSQL 17
- Auth: Authentik OIDC (planned)
- Runtime: Docker Compose (Portainer-managed production)
- Routing: Traefik subdomain-based
- Monorepo: pnpm workspace
Conventions:
- Task IDs follow FN-XXX pattern (e.g., FN-002, FN-003)
- Conventional commits with scope: feat(FN-XXX), fix(FN-XXX), docs(FN-XXX)
- Backend models in apps/api/app/models/
- Backend routers in apps/api/app/routers/
- Frontend code in apps/web/src/
- Tests: Vitest (frontend), pytest (backend)
- Documentation in docs/ folder (architecture.md, mvp-scope.md, etc.)
Domain knowledge:
- Headquarter: hosted workspace + tool-orchestration platform
- Users create projects, connect Git repos, spawn containerized tools
- Built-in tools: OpenCode and code-server
- Each tool instance gets HTTPS subdomain via Traefik
- Manifest-driven tool registry with JSON schema
- Provider-abstracted Git (GitHub, GitLab, Gitea, Forgejo)
- Per-repository SSH key generation (Ed25519)
- Encrypted secret storage (Fernet)
- Config storage at global/user/project/tool-instance scopes
# Per-artifact rules
rules:
proposal:
- Always reference the task ID (FN-XXX) in the proposal
- Include dependency on previous FN tasks if applicable
- Reference docs/mvp-scope.md for scope boundaries
design:
- Follow existing patterns in apps/api/app/ and apps/web/src/
- Reference architecture.md for system design decisions
- Include database schema changes if applicable
tasks:
- Break tasks into implementation steps (Step 1, Step 2, etc.)
- Include test verification step
- Include documentation update step
- Reference specific files that need modification
+243
View File
@@ -0,0 +1,243 @@
# Headquarter Project Specsheet
> Canonical project state document. Updated after each completed FN task.
> Last updated: 2026-05-14
## Project Overview
Headquarter is a hosted workspace and tool-orchestration platform where authenticated users create Git-backed projects and spawn containerized development tools (OpenCode, code-server) via HTTPS subdomains.
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | React 19 + Vite 6 + TypeScript 5 |
| Backend | FastAPI + SQLAlchemy 2.0 (async) + Pydantic v2 |
| Database | PostgreSQL 17 + Alembic migrations |
| Auth | Authentik OIDC (planned) |
| Runtime | Docker Compose (local dev + Portainer production) |
| Routing | Traefik reverse proxy with subdomain routing |
| Monorepo | pnpm workspace |
## Completed Features
### FN-002: Monorepo Scaffold ✅
- Root tooling (Makefile, package.json, pnpm-workspace.yaml)
- React frontend skeleton (apps/web/)
- FastAPI backend skeleton (apps/api/)
- Docker Compose local development stack
- Deployment skeleton for Portainer + Traefik
- CI/CD workflow (GitHub Actions)
### FN-019: Architecture & Specification ✅
- Enhanced docs/architecture.md (18 sections)
- docs/mvp-scope.md with milestones and dependency order
- docs/project-brief.md
- docs/development.md
- docs/deployment.md
- docs/tool-manifest-spec.md
### FN-003: Tool Registry ✅
- Manifest-driven tool registry (JSON schema)
- In-memory registry with built-in manifests
- FastAPI CRUD routes for tool definitions
- OpenCode and code-server built-in definitions
- Registry loaded at application startup
### FN-011: Git Provider Model ✅
- Git provider abstraction (GitHub, GitLab, Gitea, Forgejo, generic)
- SSH key pair generation (Ed25519)
- Encrypted private key storage
- Credential model and storage interface
- Repository connection model and manager
- Local Git operations interface
- Alembic migration for repository_connection table
- Full test coverage
### FN-004: Backend Foundation (Partial) ✅
- Domain models: User, Project, Repository, Workspace, ToolDefinition, ToolInstance, Config, Secret, AccessRoute, RepositoryConnection
- Alembic migrations
- API routers for all entities
- Database configuration with async SQLAlchemy
- Encryption utilities (Fernet)
- Auth dependencies structure
### FN-049: CI / Testing ✅
- GitHub Actions workflow
- Frontend: lint, typecheck, test (Vitest)
- Backend: lint (ruff), typecheck (mypy), test (pytest)
- PostgreSQL service container for backend tests
## OpenSpec Changes (Ready for Implementation)
### FN-005: Frontend Foundation 📋
**Location:** `openspec/changes/frontend-foundation/`
**Status:** All artifacts complete (proposal, design, specs, tasks)
**Dependencies:** FN-002, FN-019
**Tasks:** 46 total
**Key deliverables:**
- Authentik OIDC auth flow with PKCE
- Dashboard shell with responsive navigation
- Project CRUD UI
- Typed API client
- Auth-guarded routes
### FN-006: Deployment Config 📋
**Location:** `openspec/changes/deployment-config/`
**Status:** All artifacts complete (proposal, design, specs, tasks)
**Dependencies:** FN-002
**Tasks:** 27 total
**Key deliverables:**
- Traefik label generator service
- Production Docker Compose stack
- Portainer deployment guide
- Dynamic subdomain routing
### FN-009: Config & Secrets 📋
**Location:** `openspec/changes/config-secrets/`
**Status:** All artifacts complete (proposal, design, specs, tasks)
**Dependencies:** FN-004, FN-005
**Tasks:** 31 total
**Key deliverables:**
- Config management UI (global/user/project/instance scopes)
- Encrypted secret storage UI
- Runtime injection into tool containers
- Scope-based access control
### FN-010: code-server Spawn 📋
**Location:** `openspec/changes/codeserver-spawn/`
**Status:** All artifacts complete (proposal, design, specs, tasks)
**Dependencies:** FN-003, FN-006, FN-009
**Tasks:** 38 total
**Key deliverables:**
- Tool spawn API endpoint
- code-server manifest refinement
- Frontend spawn UI
- Container lifecycle management (start/stop/status)
- Traefik auth proxy integration
### FN-008: OpenCode POC 📋
**Location:** `openspec/changes/opencode-poc/`
**Status:** All artifacts complete (proposal, design, specs, tasks)
**Dependencies:** FN-003, FN-006, FN-009
**Tasks:** 25 total
**Key deliverables:**
- OpenCode manifest with web terminal config
- Containerized terminal environment
- Health reporting mechanism
- Web terminal interface
## Dependency Graph
```
FN-002 (Scaffold) ✅
├──> FN-019 (Architecture) ✅ ──> FN-004 (Backend) ✅
│ │
│ ├──> FN-003 (Tool Registry) ✅
│ │ │
│ │ ├──> FN-010 (code-server) 📋
│ │ └──> FN-008 (OpenCode) 📋
│ │
│ ├──> FN-011 (Git Provider) ✅
│ │
│ └──> FN-009 (Config/Secrets) 📋
│ │
│ └──> FN-010, FN-008 (runtime)
└──> FN-005 (Frontend) 📋 ───────> FN-009 (UI)
FN-006 (Deployment) 📋 runs in parallel with FN-004/FN-005
```
## Critical Path
FN-002 ✅ → FN-019 ✅ → FN-004 ✅ → FN-003 ✅ → FN-010/FN-008 📋
## Next Recommended Task
**FN-005: Frontend Foundation** - This unblocks user-facing features and enables parallel work on FN-009 (Config/Secrets UI).
## Database Schema
### Existing Tables
- `users` - User accounts (Authentik OIDC)
- `projects` - User projects with slug
- `repositories` - Git repository metadata
- `repository_connections` - Provider-specific connections with SSH keys
- `workspaces` - Project workspaces
- `tool_definitions` - Manifest-driven tool definitions
- `tool_instances` - Running/spawned tool instances
- `configs` - Key-value config storage (scoped)
- `secrets` - Encrypted secret storage (scoped)
- `access_routes` - Traefik routing rules
## API Endpoints
### Implemented Routers
- `/api/v1/users` - User management
- `/api/v1/projects` - Project CRUD
- `/api/v1/repositories` - Repository management
- `/api/v1/workspaces` - Workspace management
- `/api/v1/tool-definitions` - Tool registry CRUD
- `/api/v1/tool-instances` - Tool instance lifecycle
- `/api/v1/configs` - Config management
- `/api/v1/secrets` - Secret management
- `/api/v1/access-routes` - Routing rules
- `/api/v1/tools` - Tool registry (manifest-driven)
- `/health` - Health check
## Open Questions (from mvp-scope.md)
1. **Admin role in MVP:** Do we need a basic admin role for global config management?
2. **User slug derivation:** Display name, email local-part, or dedicated slug column?
3. **Provider adapter coverage:** Which Git providers get concrete adapters in MVP?
4. **Auto-deploy-key registration:** Automatic via provider APIs or manual copy-paste?
5. **Container image trust:** Allow-list or any image reference?
6. **Billing or resource quotas:** Usage limiting needed in MVP?
## File Structure
```
headquarter/
├── apps/
│ ├── web/ # React frontend (skeleton)
│ └── api/ # FastAPI backend (models + routers)
├── docs/ # Architecture, scope, development docs
├── deploy/ # Portainer/Traefik deployment examples
├── openspec/ # Spec-driven workflow
│ ├── config.yaml # Project context for AI
│ ├── changes/ # Active changes
│ │ ├── frontend-foundation/ # FN-005
│ │ ├── deployment-config/ # FN-006
│ │ ├── config-secrets/ # FN-009
│ │ ├── codeserver-spawn/ # FN-010
│ │ └── opencode-poc/ # FN-008
│ └── specs/ # Project specsheets
│ └── project-specsheet.md
├── docker-compose.yml # Local development stack
├── docker-compose.traefik.yml
├── Makefile # Common workflows
└── package.json # Root monorepo scripts
```
## Test Status
- **Frontend:** Vitest configured, basic App.test.tsx passing
- **Backend:** pytest configured, tests for git provider, credentials, operations
- **CI:** GitHub Actions runs on PR/push to main
## Definition of MVP Done
1. ✅ Monorepo scaffold complete
2. ✅ Architecture documented
3. ✅ Backend models and migrations
4. ✅ Tool registry with manifests
5. ✅ Git provider abstraction
6. 📋 Frontend auth and navigation (spec ready)
7. 📋 Config/secrets UI and runtime injection (spec ready)
8. 📋 code-server spawn flow (spec ready)
9. 📋 OpenCode terminal environment (spec ready)
10. 📋 Production deployment stack (spec ready)
11. ⏳ All tests passing
12. ⏳ Documentation consistent with implementation
+368
View File
@@ -12,12 +12,33 @@ importers:
apps/web:
dependencies:
'@headlessui/react':
specifier: ^2.2.10
version: 2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@heroicons/react':
specifier: ^2.2.0
version: 2.2.0(react@19.2.6)
'@tanstack/react-query':
specifier: ^5.100.10
version: 5.100.10(react@19.2.6)
clsx:
specifier: ^2.1.1
version: 2.1.1
react:
specifier: ^19.0.0
version: 19.2.6
react-dom:
specifier: ^19.0.0
version: 19.2.6(react@19.2.6)
react-router-dom:
specifier: ^7.15.0
version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
zustand:
specifier: ^5.0.13
version: 5.0.13(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))
devDependencies:
'@eslint/js':
specifier: ^9.24.0
@@ -40,6 +61,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.4.0
version: 4.7.0(vite@6.4.2(@types/node@22.19.19))
autoprefixer:
specifier: ^10.5.0
version: 10.5.0(postcss@8.5.14)
eslint:
specifier: ^9.24.0
version: 9.39.4
@@ -55,6 +79,12 @@ importers:
jsdom:
specifier: ^26.0.0
version: 26.1.0
postcss:
specifier: ^8.5.14
version: 8.5.14
tailwindcss:
specifier: ^4.3.0
version: 4.3.0
typescript:
specifier: ~5.7.0
version: 5.7.3
@@ -385,6 +415,39 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/react-dom@2.1.8':
resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@headlessui/react@2.2.10':
resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==}
engines: {node: '>=10'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
'@heroicons/react@2.2.0':
resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==}
peerDependencies:
react: '>= 16 || ^19.0.0-rc'
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -405,6 +468,15 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@internationalized/date@3.12.1':
resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==}
'@internationalized/number@3.6.6':
resolution: {integrity: sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==}
'@internationalized/string@3.2.8':
resolution: {integrity: sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -421,6 +493,23 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@react-aria/focus@3.22.0':
resolution: {integrity: sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-aria/interactions@3.28.0':
resolution: {integrity: sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@react-types/shared@3.34.0':
resolution: {integrity: sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -562,6 +651,26 @@ packages:
cpu: [x64]
os: [win32]
'@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
'@tanstack/query-core@5.100.10':
resolution: {integrity: sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==}
'@tanstack/react-query@5.100.10':
resolution: {integrity: sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==}
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.13.24':
resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.14.0':
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -752,6 +861,10 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
@@ -763,6 +876,13 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
autoprefixer@10.5.0:
resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
postcss: ^8.1.0
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -810,6 +930,10 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -823,6 +947,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -990,6 +1118,9 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1202,6 +1333,9 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.14:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
engines: {node: ^10 || ^12 || >=14}
@@ -1218,6 +1352,12 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
react-aria@3.48.0:
resolution: {integrity: sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom@19.2.6:
resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
peerDependencies:
@@ -1230,6 +1370,28 @@ packages:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
react-router-dom@7.15.0:
resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.15.0:
resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react-stately@3.46.0:
resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'}
@@ -1269,6 +1431,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1308,6 +1473,15 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1351,6 +1525,9 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -1379,6 +1556,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -1513,6 +1695,24 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zustand@5.0.13:
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@adobe/css-tools@4.4.4': {}
@@ -1783,6 +1983,45 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
'@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@floating-ui/dom': 1.7.6
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@floating-ui/react@0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@floating-ui/utils': 0.2.11
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
tabbable: 6.4.0
'@floating-ui/utils@0.2.11': {}
'@headlessui/react@2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-aria/focus': 3.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-aria/interactions': 3.28.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tanstack/react-virtual': 3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
use-sync-external-store: 1.6.0(react@19.2.6)
'@heroicons/react@2.2.0(react@19.2.6)':
dependencies:
react: 19.2.6
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -1799,6 +2038,18 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@internationalized/date@3.12.1':
dependencies:
'@swc/helpers': 0.5.21
'@internationalized/number@3.6.6':
dependencies:
'@swc/helpers': 0.5.21
'@internationalized/string@3.2.8':
dependencies:
'@swc/helpers': 0.5.21
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1818,6 +2069,25 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@react-aria/focus@3.22.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@swc/helpers': 0.5.21
react: 19.2.6
react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
'@react-aria/interactions@3.28.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@react-types/shared': 3.34.0(react@19.2.6)
'@swc/helpers': 0.5.21
react: 19.2.6
react-aria: 3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
'@react-types/shared@3.34.0(react@19.2.6)':
dependencies:
react: 19.2.6
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.60.3':
@@ -1895,6 +2165,25 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.3':
optional: true
'@swc/helpers@0.5.21':
dependencies:
tslib: 2.8.1
'@tanstack/query-core@5.100.10': {}
'@tanstack/react-query@5.100.10(react@19.2.6)':
dependencies:
'@tanstack/query-core': 5.100.10
react: 19.2.6
'@tanstack/react-virtual@3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@tanstack/virtual-core': 3.14.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
'@tanstack/virtual-core@3.14.0': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
@@ -2143,6 +2432,10 @@ snapshots:
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
@@ -2151,6 +2444,15 @@ snapshots:
assertion-error@2.0.1: {}
autoprefixer@10.5.0(postcss@8.5.14):
dependencies:
browserslist: 4.28.2
caniuse-lite: 1.0.30001792
fraction.js: 5.3.4
picocolors: 1.1.1
postcss: 8.5.14
postcss-value-parser: 4.2.0
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
@@ -2195,6 +2497,8 @@ snapshots:
check-error@2.1.3: {}
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -2205,6 +2509,8 @@ snapshots:
convert-source-map@2.0.0: {}
cookie@1.1.1: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2388,6 +2694,8 @@ snapshots:
flatted@3.4.2: {}
fraction.js@5.3.4: {}
fsevents@2.3.3:
optional: true
@@ -2579,6 +2887,8 @@ snapshots:
picomatch@4.0.4: {}
postcss-value-parser@4.2.0: {}
postcss@8.5.14:
dependencies:
nanoid: 3.3.12
@@ -2595,6 +2905,20 @@ snapshots:
punycode@2.3.1: {}
react-aria@3.48.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@internationalized/date': 3.12.1
'@internationalized/number': 3.6.6
'@internationalized/string': 3.2.8
'@react-types/shared': 3.34.0(react@19.2.6)
'@swc/helpers': 0.5.21
aria-hidden: 1.2.6
clsx: 2.1.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-stately: 3.46.0(react@19.2.6)
use-sync-external-store: 1.6.0(react@19.2.6)
react-dom@19.2.6(react@19.2.6):
dependencies:
react: 19.2.6
@@ -2604,6 +2928,30 @@ snapshots:
react-refresh@0.17.0: {}
react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
cookie: 1.1.1
react: 19.2.6
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 19.2.6(react@19.2.6)
react-stately@3.46.0(react@19.2.6):
dependencies:
'@internationalized/date': 3.12.1
'@internationalized/number': 3.6.6
'@internationalized/string': 3.2.8
'@react-types/shared': 3.34.0(react@19.2.6)
'@swc/helpers': 0.5.21
react: 19.2.6
use-sync-external-store: 1.6.0(react@19.2.6)
react@19.2.6: {}
redent@3.0.0:
@@ -2658,6 +3006,8 @@ snapshots:
semver@7.8.0: {}
set-cookie-parser@2.7.2: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -2688,6 +3038,12 @@ snapshots:
symbol-tree@3.2.4: {}
tabbable@6.4.0: {}
tailwind-merge@3.6.0: {}
tailwindcss@4.3.0: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -2721,6 +3077,8 @@ snapshots:
dependencies:
typescript: 5.7.3
tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -2750,6 +3108,10 @@ snapshots:
dependencies:
punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.2.6):
dependencies:
react: 19.2.6
vite-node@3.2.4(@types/node@22.19.19):
dependencies:
cac: 6.7.14
@@ -2862,3 +3224,9 @@ snapshots:
yallist@3.1.1: {}
yocto-queue@0.1.0: {}
zustand@5.0.13(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)):
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.6
use-sync-external-store: 1.6.0(react@19.2.6)
+2
View File
@@ -1,5 +1,7 @@
packages:
- 'apps/*'
- 'packages/*'
allowBuilds:
esbuild: set this to true or false
onlyBuiltDependencies:
- esbuild