Compare commits
2 Commits
9fbe45244d
...
62f06813a4
| Author | SHA1 | Date |
|---|---|---|
|
|
62f06813a4 | |
|
|
b324d67a31 |
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -114,3 +114,23 @@ python -m cmdforge.web.app
|
||||||
# Production (example)
|
# Production (example)
|
||||||
CMDFORGE_REGISTRY_DB=/path/to/db PORT=5050 python -m cmdforge.web.app
|
CMDFORGE_REGISTRY_DB=/path/to/db PORT=5050 python -m cmdforge.web.app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Infrastructure Documentation
|
||||||
|
|
||||||
|
For deployment, server details, and operations, see the `docs/` folder:
|
||||||
|
|
||||||
|
- **docs/servers.md** - Server IPs (192.168.0.162), SSH access, paths, service commands
|
||||||
|
- **docs/deployment.md** - Architecture diagram, deploy process, systemd service config
|
||||||
|
- **docs/maintenance.md** - Backups, updates, troubleshooting
|
||||||
|
- **docs/architecture.md** - Module structure, data flow diagrams
|
||||||
|
|
||||||
|
### Production Server Quick Reference
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Server | 192.168.0.162 (OpenMediaVault) |
|
||||||
|
| SSH | `ssh rob@192.168.0.162` |
|
||||||
|
| App Path | `/srv/mergerfs/data_pool/home/rob/cmdforge-registry/` |
|
||||||
|
| Service | `systemctl --user status cmdforge-web` |
|
||||||
|
| Public URL | https://cmdforge.brrd.tech |
|
||||||
|
| Port | 5050 (via Cloudflare tunnel) |
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ WORKDIR /app
|
||||||
COPY pyproject.toml README.md ./
|
COPY pyproject.toml README.md ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY examples/ ./examples/
|
COPY examples/ ./examples/
|
||||||
COPY docs/ ./docs/
|
|
||||||
COPY tests/ ./tests/
|
COPY tests/ ./tests/
|
||||||
|
|
||||||
# Install CmdForge and all dependencies (CLI, TUI, registry)
|
# Install CmdForge and all dependencies (CLI, TUI, registry)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Gunicorn configuration for CmdForge web service."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Server socket
|
||||||
|
bind = "0.0.0.0:" + os.environ.get("PORT", "5050")
|
||||||
|
backlog = 2048
|
||||||
|
|
||||||
|
# Worker processes
|
||||||
|
workers = 2 # Conservative for shared server
|
||||||
|
worker_class = "sync"
|
||||||
|
worker_connections = 1000
|
||||||
|
timeout = 120
|
||||||
|
keepalive = 2
|
||||||
|
|
||||||
|
# Process naming
|
||||||
|
proc_name = "cmdforge-web"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
accesslog = "-" # stdout
|
||||||
|
errorlog = "-" # stderr
|
||||||
|
loglevel = "info"
|
||||||
|
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
|
||||||
|
|
||||||
|
# Server mechanics
|
||||||
|
daemon = False
|
||||||
|
pidfile = None
|
||||||
|
umask = 0
|
||||||
|
user = None
|
||||||
|
group = None
|
||||||
|
tmp_upload_dir = None
|
||||||
|
|
||||||
|
# SSL (handled by Cloudflare, not needed here)
|
||||||
|
keyfile = None
|
||||||
|
certfile = None
|
||||||
|
|
@ -47,12 +47,14 @@ registry = [
|
||||||
"Flask>=2.3",
|
"Flask>=2.3",
|
||||||
"argon2-cffi>=21.0",
|
"argon2-cffi>=21.0",
|
||||||
"sentry-sdk[flask]>=1.0",
|
"sentry-sdk[flask]>=1.0",
|
||||||
|
"gunicorn>=21.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"urwid>=2.1.0",
|
"urwid>=2.1.0",
|
||||||
"Flask>=2.3",
|
"Flask>=2.3",
|
||||||
"argon2-cffi>=21.0",
|
"argon2-cffi>=21.0",
|
||||||
"sentry-sdk[flask]>=1.0",
|
"sentry-sdk[flask]>=1.0",
|
||||||
|
"gunicorn>=21.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
|
|
@ -574,6 +574,21 @@ def create_app() -> Flask:
|
||||||
if not row:
|
if not row:
|
||||||
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404)
|
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404)
|
||||||
|
|
||||||
|
# Parse source attribution
|
||||||
|
source_obj = None
|
||||||
|
if row["source_json"]:
|
||||||
|
try:
|
||||||
|
source_obj = json.loads(row["source_json"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
# Fall back to legacy fields if no source_json
|
||||||
|
if not source_obj and (row["source"] or row["source_url"]):
|
||||||
|
source_obj = {
|
||||||
|
"type": "imported",
|
||||||
|
"original_tool": row["source"],
|
||||||
|
"url": row["source_url"],
|
||||||
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"owner": row["owner"],
|
"owner": row["owner"],
|
||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
|
|
@ -588,6 +603,7 @@ def create_app() -> Flask:
|
||||||
"replacement": row["replacement"],
|
"replacement": row["replacement"],
|
||||||
"config": row["config_yaml"],
|
"config": row["config_yaml"],
|
||||||
"readme": row["readme"],
|
"readme": row["readme"],
|
||||||
|
"source": source_obj,
|
||||||
}
|
}
|
||||||
response = jsonify({"data": payload})
|
response = jsonify({"data": payload})
|
||||||
response.headers["Cache-Control"] = "max-age=60"
|
response.headers["Cache-Control"] = "max-age=60"
|
||||||
|
|
@ -1100,9 +1116,25 @@ def create_app() -> Flask:
|
||||||
description = (data.get("description") or "").strip()
|
description = (data.get("description") or "").strip()
|
||||||
category = (data.get("category") or "").strip() or None
|
category = (data.get("category") or "").strip() or None
|
||||||
tags = data.get("tags") or []
|
tags = data.get("tags") or []
|
||||||
source = (data.get("source") or "").strip() or None
|
|
||||||
|
# Handle source attribution - can be a dict (full ToolSource) or string (legacy)
|
||||||
|
source_data = data.get("source")
|
||||||
|
source_json = None
|
||||||
|
source = None
|
||||||
source_url = (data.get("source_url") or "").strip() or None
|
source_url = (data.get("source_url") or "").strip() or None
|
||||||
|
|
||||||
|
if isinstance(source_data, dict):
|
||||||
|
# Full source object from tool YAML
|
||||||
|
source_json = json.dumps(source_data)
|
||||||
|
# Keep legacy fields for backward compat
|
||||||
|
source = source_data.get("original_tool") or source_data.get("author")
|
||||||
|
source_url = source_data.get("url") or source_url
|
||||||
|
elif isinstance(source_data, str) and source_data.strip():
|
||||||
|
# Legacy string format
|
||||||
|
source = source_data.strip()
|
||||||
|
# Create a minimal source_json for consistency
|
||||||
|
source_json = json.dumps({"type": "imported", "original_tool": source, "url": source_url})
|
||||||
|
|
||||||
if not name or not TOOL_NAME_RE.match(name) or len(name) > MAX_TOOL_NAME_LEN:
|
if not name or not TOOL_NAME_RE.match(name) or len(name) > MAX_TOOL_NAME_LEN:
|
||||||
return error_response("VALIDATION_ERROR", "Invalid tool name")
|
return error_response("VALIDATION_ERROR", "Invalid tool name")
|
||||||
if not version or Semver.parse(version) is None:
|
if not version or Semver.parse(version) is None:
|
||||||
|
|
@ -1218,8 +1250,8 @@ def create_app() -> Flask:
|
||||||
INSERT INTO tools (
|
INSERT INTO tools (
|
||||||
owner, name, version, description, category, tags, config_yaml, readme,
|
owner, name, version, description, category, tags, config_yaml, readme,
|
||||||
publisher_id, deprecated, deprecated_message, replacement, downloads,
|
publisher_id, deprecated, deprecated_message, replacement, downloads,
|
||||||
scrutiny_status, scrutiny_report, source, source_url, published_at
|
scrutiny_status, scrutiny_report, source, source_url, source_json, published_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
[
|
[
|
||||||
owner,
|
owner,
|
||||||
|
|
@ -1239,6 +1271,7 @@ def create_app() -> Flask:
|
||||||
scrutiny_json,
|
scrutiny_json,
|
||||||
source,
|
source,
|
||||||
source_url,
|
source_url,
|
||||||
|
source_json,
|
||||||
datetime.utcnow().isoformat(),
|
datetime.utcnow().isoformat(),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS tools (
|
||||||
scrutiny_report TEXT,
|
scrutiny_report TEXT,
|
||||||
source TEXT,
|
source TEXT,
|
||||||
source_url TEXT,
|
source_url TEXT,
|
||||||
|
source_json TEXT,
|
||||||
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(owner, name, version)
|
UNIQUE(owner, name, version)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -68,19 +68,34 @@
|
||||||
|
|
||||||
{% if tool.source %}
|
{% if tool.source %}
|
||||||
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
<div class="flex items-center">
|
<div class="flex items-start">
|
||||||
<svg class="w-5 h-5 text-amber-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-amber-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm text-amber-800">
|
<div class="text-sm text-amber-800">
|
||||||
Based on
|
{% if tool.source.type == 'forked' %}
|
||||||
{% if tool.source_url %}
|
<span class="font-medium">Forked from</span>
|
||||||
<a href="{{ tool.source_url }}" target="_blank" rel="noopener" class="font-medium underline hover:text-amber-900">{{ tool.source }}</a>
|
{% elif tool.source.type == 'imported' %}
|
||||||
|
<span class="font-medium">Based on</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="font-medium">{{ tool.source }}</span>
|
<span class="font-medium">Source:</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
pattern
|
{% if tool.source.original_tool %}
|
||||||
</span>
|
{% if tool.source.url %}
|
||||||
|
<a href="{{ tool.source.url }}" target="_blank" rel="noopener" class="font-medium underline hover:text-amber-900">{{ tool.source.original_tool }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="font-medium">{{ tool.source.original_tool }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif tool.source.url %}
|
||||||
|
<a href="{{ tool.source.url }}" target="_blank" rel="noopener" class="font-medium underline hover:text-amber-900">{{ tool.source.url }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if tool.source.author %}
|
||||||
|
<span class="block mt-1">Author: <span class="font-medium">{{ tool.source.author }}</span></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if tool.source.license %}
|
||||||
|
<span class="block mt-1">License: <span class="font-medium">{{ tool.source.license }}</span></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue