Compare commits

..

16 Commits

Author SHA1 Message Date
alex 391dd5ee80 docs: refresh README 2026-07-27 15:24:01 +02:00
alex d8d44f0c6a merge: suppress competing terminal context menus 2026-07-22 14:56:04 +02:00
alex bf561186fb fix(terminal): suppress competing context menus
Stop contextmenu propagation after recognizing selected xterm output so the native browser overlay cannot block Copy.
2026-07-22 14:56:03 +02:00
alex 0afce741eb merge: prioritize terminal selected copy menu 2026-07-22 14:25:21 +02:00
alex 7603b739cb fix(terminal): prioritize selected copy menu
Handle selected-output context menus during capture so xterm and the browser cannot open a competing menu.
2026-07-22 14:25:20 +02:00
alex ff8efdd887 merge: add terminal right click copy action 2026-07-22 14:15:47 +02:00
alex b289edf5a9 fix(terminal): add right click copy action
Replace the conflicting keyboard copy shortcut with an xterm-aware Copy action shown for selected output.
2026-07-22 14:15:46 +02:00
alex 75b67f2a6f merge: use ctrl shift c for terminal output copy 2026-07-22 13:01:32 +02:00
alex bcc7486b59 fix(terminal): use ctrl shift c for output copy
Capture Ctrl+Shift+C before xterm input handling and copy selected output through a browser-compatible fallback. Preserve Ctrl+C as terminal input.
2026-07-22 13:01:31 +02:00
alex 29e765b2be merge: support terminal browser clipboard shortcuts 2026-07-22 12:52:20 +02:00
alex d14cdc1151 fix(terminal): support browser clipboard shortcuts
Copy selected terminal output with Ctrl/Cmd+C without sending an interrupt, and route browser paste consistently through the terminal transport.
2026-07-22 12:52:19 +02:00
alex c80dbf9737 merge: preserve mounted profile file updates 2026-07-22 11:52:43 +02:00
alex 5331a0f110 fix(config-profiles): preserve mounted file inodes
Update files within profile directory mounts in place so editor saves reach running containers.
2026-07-22 11:52:42 +02:00
alex b995521e22 merge: preserve profile file bind mount updates 2026-07-22 11:37:54 +02:00
alex 5610017f50 fix(config-profiles): preserve bound file inodes
Overwrite individually bind-mounted profile files in place so editor saves remain visible to running containers.
2026-07-22 11:37:53 +02:00
alex 61e7d68d71 merge: synchronize shared profile mount working copies 2026-07-22 11:23:34 +02:00
7 changed files with 257 additions and 152 deletions
+84 -146
View File
@@ -1,150 +1,61 @@
# Headquarter
A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication.
Headquarter is a self-hosted workspace for managing projects, Git repositories, development-tool instances, SSH keys, and user preferences. It has a FastAPI API, a React frontend, PostgreSQL and Redis, and Authentik OAuth2/session authentication. Built-in tool definitions include code-server and Jupyter.
## Overview
## Prerequisites
Headquarter provides a centralized workspace for development teams to:
- Manage projects and their associated git repositories
- Browse repository files and view git history
- Spawn development tools (VS Code Server, Jupyter Notebook, etc.)
- Manage SSH keys and user preferences
- Docker and Docker Compose for the provided local and production Compose stacks.
- Git for repository workflows.
- Python 3.11 or newer for manual API development.
- Node.js and npm for manual frontend development.
- An Authentik configuration for the authenticated deployment.
## Features
Production additionally requires an existing Traefik network and host Docker access for tool-instance management.
### Project Management
- Create and manage projects
- View all projects in a dashboard
- Click any project to open its workspace
## Local Compose setup
### Git Repository Management
- Initialize bare repositories
- Clone repositories (including mirror clones)
- Smart URL parsing (converts browser URLs to git URLs)
- View repository history and commit details
1. Create a local environment file from the template and replace placeholder credentials before using a shared or production-like environment:
### Repository Workspace
- Browse files and directories
- View file contents with syntax highlighting
- Switch between branches
- Quick file editing with automatic commits
### Git History Visualization
- View commit history with branch graph
- See commit details, statistics, and diffs
- Filter by branch
### Authentication
- OAuth2 via Authentik
- Session-based authentication
- User profile management
### Tool Management
- Built-in tool types (code-server, jupyter-notebook)
- Create custom tool types with Docker Compose templates
- Template validation
### User Settings
- Theme selection (system/light/dark)
- Git identity configuration
- Default editor preference
### SSH Key Management
- Generate Ed25519 key pairs
- Copy public keys to clipboard
- Delete keys
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Git
### Local Development
1. **Clone the repository:**
```bash
git clone <repository-url>
cd headquarter
```
2. **Set up environment:**
```bash
cp .env.example .env
# Edit .env with your settings
```
3. **Start services:**
2. Start the local stack:
```bash
docker compose up -d
```
4. **Access the application:**
- Frontend: http://localhost:5173
- API: http://localhost:8000
- API Docs: http://localhost:8000/docs
The local Compose stack starts PostgreSQL, Redis, the API, and the web frontend. It publishes the web frontend at <http://localhost:3000>, the API at <http://localhost:8000>, and API documentation at <http://localhost:8000/docs>. PostgreSQL and Redis are also published on ports 5432 and 6379 respectively.
### Production Deployment
> **Authentication limitation:** this command does not configure the `AUTHENTIK_*`, `API_DOMAIN`, or `WEB_DOMAIN` values required for a verified authenticated flow. Treat authenticated local use as unsupported until those values are supplied through a documented local configuration.
See [Deployment Guide](docs/deployment/) for production setup with Traefik and Authentik.
Useful operational commands:
## Tech Stack
### Backend
- **FastAPI** - Python web framework
- **SQLAlchemy** - ORM with async PostgreSQL support
- **Pydantic** - Data validation
- **Alembic** - Database migrations
- **python-jose** - JWT handling
### Frontend
- **React** - UI library
- **TypeScript** - Type safety
- **Vite** - Build tool
- **React Router** - Client-side routing
### Infrastructure
- **Docker** - Containerization
- **PostgreSQL** - Database
- **Traefik** - Reverse proxy (production)
- **Authentik** - Identity provider
## Documentation
- [User Guide](docs/features/) - Feature documentation
- [API Reference](docs/api/) - API endpoints
- [Architecture](docs/architecture/) - System design
- [Deployment](docs/deployment/) - Setup guides
- [Development](docs/development/) - Contributing
## Project Structure
```
.
├── apps/
│ ├── api/ # FastAPI backend
│ │ ├── src/
│ │ │ ├── api/ # API routes
│ │ │ ├── auth/ # Authentication
│ │ │ ├── models/ # Database models
│ │ │ └── utils/ # Utilities
│ │ ├── tests/ # Test suite
│ │ └── Dockerfile
│ └── web/ # React frontend
│ ├── src/
│ │ ├── api/ # API clients
│ │ ├── components/# UI components
│ │ └── pages/ # Page components
│ └── Dockerfile
├── docs/ # Documentation
├── docker-compose.yml # Development setup
├── docker-compose.traefik.yml # Production setup
└── Makefile # Common commands
```bash
make up # Start services
make down # Stop services
make logs # Follow Compose logs
make migrate # Apply database migrations in the API container
make health # Show Compose service status
```
## Development
## Production deployment
The production Compose file is [`docker-compose.traefik.yml`](docker-compose.traefik.yml). It expects an existing external Traefik network (named `traefik` by default), configured domains, an Authentik client secret, and host paths for repositories, working copies, and tool instances.
After preparing `.env` with production values, deploy with:
```bash
docker compose -f docker-compose.traefik.yml up -d
```
The production API container mounts `/var/run/docker.sock` so it can manage tool instances. Treat this as privileged host access and restrict it appropriately. The deployment guide and supporting material are under [`docs/deployment/`](docs/deployment/).
## Manual development
### API
### Backend Development
```bash
cd apps/api
python -m venv .venv
@@ -153,43 +64,70 @@ pip install -e ".[dev]"
uvicorn src.main:app --reload
```
### Frontend Development
### Web
```bash
cd apps/web
npm install
npm run dev
```
### Running Tests
## Tests and quality checks
The Make targets run commands in the Compose services:
```bash
# Backend tests
make test
# Frontend tests
make test-web
# All quality gates
make test-unit
make test-integration
make test-system
make test-e2e
make lint
make typecheck
make build
```
The frontend test command is run directly from its package directory:
```bash
cd apps/web
npm run test
```
`make lint` runs API Ruff and mypy checks plus frontend linting; `make typecheck` runs API mypy and frontend typechecking. Neither target runs frontend tests.
## Configuration
Key environment variables:
Copy [`.env.example`](.env.example) to `.env` and replace its placeholder values rather than committing credentials. Key settings include:
| Variable | Description | Default |
|----------|-------------|---------|
| `API_DOMAIN` | API domain | `localhost` |
| `WEB_DOMAIN` | Web domain | `localhost` |
| `AUTHENTIK_DOMAIN` | Authentik domain | - |
| `AUTHENTIK_CLIENT_ID` | OAuth client ID | - |
| `AUTHENTIK_CLIENT_SECRET` | OAuth client secret | - |
| `DATABASE_URL` | PostgreSQL URL | - |
| `JWT_SECRET` | JWT signing secret | - |
| `REPO_BASE_PATH` | Repository storage path | `/data/repos` |
| Setting | Purpose |
| --- | --- |
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | PostgreSQL connection settings |
| `REDIS_URL` | Redis connection URL |
| `SESSION_SECRET`, `SESSION_TTL_HOURS` | Session signing and lifetime |
| `API_DOMAIN`, `WEB_DOMAIN` | Public API and web domains |
| `AUTHENTIK_DOMAIN`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth configuration |
| `AUTHENTIK_APPLICATION_SLUG` | Authentik application path component |
| `VITE_API_BASE_URL`, `VITE_APP_URL` | Frontend build-time public URLs |
| `REPO_BASE_PATH` | Repository storage path |
| `TRAEFIK_NETWORK` | Existing Traefik network for the production Compose stack |
See [Environment Variables](docs/deployment/environment.md) for complete list.
The API reads `.env` settings and has development defaults, but defaults such as local credentials and session secrets are not suitable for production.
## Repository layout
```text
.
├── apps/
│ ├── api/ # FastAPI API, migrations, and tests
│ └── web/ # React/Vite frontend
├── docs/ # Architecture, API, feature, deployment, and development docs
├── e2e/ # Playwright end-to-end tests
├── docker-compose.yml # Local Compose stack
├── docker-compose.traefik.yml # Traefik deployment stack
└── Makefile # Compose, test, quality, and build commands
```
## License
[License information]
No license file or declared license was found in this checkout.
@@ -515,7 +515,13 @@ def apply_resolved_profile(
files_dir = profile_dir / "files"
mounts_dir = profile_dir / "mounts"
def write_canonical_file(root: Path, relative_path: str, content: str) -> Path | None:
def write_canonical_file(
root: Path,
relative_path: str,
content: str,
*,
preserve_inode: bool = False,
) -> Path | None:
path = root / relative_path
try:
path.resolve().relative_to(root.resolve())
@@ -523,6 +529,12 @@ def apply_resolved_profile(
logger.warning("Profile file path escapes canonical storage: %s", relative_path)
return None
path.parent.mkdir(parents=True, exist_ok=True)
if preserve_inode and path.is_file():
# A file bind mount follows its inode, not its directory entry.
# Replacing this path would leave a running container attached to
# the old inode, so overwrite the existing file in place.
path.write_text(content, encoding="utf-8")
return path
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=path.parent, delete=False
) as temporary_file:
@@ -534,7 +546,9 @@ def apply_resolved_profile(
# Top-level profile files are individual bind mounts under the working
# directory. They therefore cannot mask the workspace directory itself.
for file_path, content in resolved.files.items():
canonical_file = write_canonical_file(files_dir, file_path, content)
canonical_file = write_canonical_file(
files_dir, file_path, content, preserve_inode=True
)
if canonical_file is None:
continue
volume_mounts.append(
@@ -555,7 +569,7 @@ def apply_resolved_profile(
expanded_target = os.path.normpath(expand_container_path(mount.target, home_dir))
mount_dir = mounts_dir / expanded_target.lstrip("/").replace("/", "_")
for file_path, content in mount.files.items():
write_canonical_file(mount_dir, file_path, content)
write_canonical_file(mount_dir, file_path, content, preserve_inode=True)
volume_mounts.append(
{
@@ -36,7 +36,7 @@ class TestMergeFunctions:
def test_merge_env_vars_tracks_overrides(self) -> None:
"""Test that env var overrides are tracked."""
overrides = {}
overrides: dict[str, str] = {}
_merge_env_vars(
{"A": "1"},
{"A": "2"},
@@ -93,7 +93,7 @@ class TestMergeFunctions:
"""Test that mount mode conflicts are resolved (later wins)."""
from src.services.config.config_profile_resolver import ResolvedMount
overrides = {}
overrides: dict[str, str] = {}
result = _merge_mounts(
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
[{"target": "/app", "mode": "ro", "files": {}}],
@@ -562,6 +562,59 @@ class TestApplyResolvedProfile:
]
assert canonical_file.read_text() == "setting = true"
def test_top_level_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
"""An individually bind-mounted file must update in place."""
profile_id = uuid.uuid4()
instance_root = tmp_path / "instances"
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
files={"settings.toml": "value = 1"},
)
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
canonical_file = (
instance_root / "config-profiles" / str(profile_id) / "files" / "settings.toml"
)
original_inode = canonical_file.stat().st_ino
resolved.files["settings.toml"] = "value = 2"
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
assert canonical_file.stat().st_ino == original_inode
assert canonical_file.read_text() == "value = 2"
def test_mounted_file_update_preserves_bind_mount_inode(self, tmp_path) -> None:
"""A file inside a profile directory mount must update in place."""
profile_id = uuid.uuid4()
instance_root = tmp_path / "instances"
resolved = ResolvedProfile(
profile_id=profile_id,
profile_name="test",
mounts={
"/etc/tool": ResolvedMount(
target="/etc/tool", mode="rw", files={"settings.toml": "value = 1"}
)
},
)
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
canonical_file = (
instance_root
/ "config-profiles"
/ str(profile_id)
/ "mounts"
/ "etc_tool"
/ "settings.toml"
)
original_inode = canonical_file.stat().st_ino
resolved.mounts["/etc/tool"].files["settings.toml"] = "value = 2"
apply_resolved_profile(str(instance_root / "instance-a"), resolved)
assert canonical_file.stat().st_ino == original_inode
assert canonical_file.read_text() == "value = 2"
def test_instances_share_profile_scoped_mount_sources(self, tmp_path) -> None:
"""Different instance paths resolve a profile to one canonical source."""
profile_id = uuid.uuid4()
@@ -4,6 +4,7 @@ import {
getTerminalScrollbackLimit,
isCurrentWebSocket,
shouldRetryWebSocketClose,
shouldShowTerminalCopyMenu,
} from "./terminal.tsx";
describe("getTerminalScrollbackLimit", () => {
@@ -30,3 +31,13 @@ describe("getTerminalScrollbackLimit", () => {
);
});
});
describe("shouldShowTerminalCopyMenu", () => {
it("shows Copy for selected terminal output", () => {
expect(shouldShowTerminalCopyMenu("selected output")).toBe(true);
});
it("keeps the native context menu when no output is selected", () => {
expect(shouldShowTerminalCopyMenu("")).toBe(false);
});
});
@@ -68,6 +68,22 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
return code !== 1000 && !(code === 4000 && reason === "New connection established");
}
export function shouldShowTerminalCopyMenu(selection: string): boolean {
return selection.length > 0;
}
function copyTextWithFallback(text: string): void {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
function matchesByteSequence(
data: Uint8Array,
start: number,
@@ -107,6 +123,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
>("connecting");
const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [copyMenu, setCopyMenu] = useState<{
x: number;
y: number;
text: string;
} | null>(null);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
@@ -462,8 +483,27 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.paste(text);
};
const handleBrowserCopy = (event: ClipboardEvent) => {
const selection = term.getSelection();
if (!selection) return;
event.preventDefault();
event.clipboardData?.setData("text/plain", selection);
};
const handleTerminalContextMenu = (event: MouseEvent) => {
const selection = term.getSelection();
if (!shouldShowTerminalCopyMenu(selection)) {
setCopyMenu(null);
return;
}
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
};
const handleBrowserPaste = (event: ClipboardEvent) => {
if (!bracketedPasteEnabledRef.current) return;
const text = event.clipboardData?.getData("text/plain");
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
return;
@@ -473,6 +513,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
event.stopImmediatePropagation();
pasteTextRef.current(text);
};
container.addEventListener("copy", handleBrowserCopy, true);
container.addEventListener("contextmenu", handleTerminalContextMenu, true);
container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll.
@@ -720,6 +762,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange,
);
if (touchCleanup) touchCleanup();
container.removeEventListener("copy", handleBrowserCopy, true);
container.removeEventListener(
"contextmenu",
handleTerminalContextMenu,
true,
);
container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false;
@@ -854,6 +902,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => {
setCopyMenu(null);
if (isMobile && termRef.current) {
termRef.current.focus();
}
@@ -968,6 +1017,26 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
</div>
</div>
)}
{copyMenu && (
<button
aria-label="Copy selected terminal text"
className="terminal-context-copy"
onMouseDown={(event) => {
event.preventDefault();
copyTextWithFallback(copyMenu.text);
setCopyMenu(null);
}}
style={{
left: copyMenu.x,
position: "fixed",
top: copyMenu.y,
zIndex: 1000,
}}
type="button"
>
Copy
</button>
)}
{error && (
<div className="terminal-error">
{error}
@@ -0,0 +1,14 @@
# Fix Web Terminal Clipboard
## Problem
Users cannot reliably copy terminal output from the browser terminal. Browser copy shortcuts may be forwarded to the terminal as input instead of copying the xterm selection, and paste behavior differs by bracketed-paste mode.
## Required behavior
- Right-clicking a selected terminal region presents an xterm-aware Copy action that writes the selection to the system clipboard.
- Browser native context menus remain available when no terminal output is selected.
- Terminal keyboard input, including `Ctrl+C`, remains unchanged.
- Copy requests expose the xterm selection as plain text.
- Pasting plain text is handled once and follows bracketed-paste mode when enabled.
- Normal terminal interrupts still work when there is no active selection.
@@ -0,0 +1,6 @@
# Web Terminal Clipboard Tasks
- [x] Add selected-text copy interception without suppressing unselected terminal interrupts.
- [x] Normalize browser paste handling through the existing paste transport.
- [x] Add focused frontend coverage for copy shortcut decisions.
- [x] Run frontend tests, typecheck/build, lint, and diagnostics.