Add full ToolSource support to registry

- Add source_json column to store complete source attribution
- Update publish API to accept source object or legacy string
- Update get_tool API to return parsed source object
- Update web UI to display type, author, license, url, original_tool
- Add gunicorn config and dependency for production server

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-13 02:43:09 -04:00
parent b324d67a31
commit 62f06813a4
5 changed files with 98 additions and 12 deletions

35
gunicorn.conf.py Normal file
View File

@ -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

View File

@ -47,12 +47,14 @@ registry = [
"Flask>=2.3",
"argon2-cffi>=21.0",
"sentry-sdk[flask]>=1.0",
"gunicorn>=21.0",
]
all = [
"urwid>=2.1.0",
"Flask>=2.3",
"argon2-cffi>=21.0",
"sentry-sdk[flask]>=1.0",
"gunicorn>=21.0",
]
[project.scripts]

View File

@ -574,6 +574,21 @@ def create_app() -> Flask:
if not row:
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 = {
"owner": row["owner"],
"name": row["name"],
@ -588,6 +603,7 @@ def create_app() -> Flask:
"replacement": row["replacement"],
"config": row["config_yaml"],
"readme": row["readme"],
"source": source_obj,
}
response = jsonify({"data": payload})
response.headers["Cache-Control"] = "max-age=60"
@ -1100,9 +1116,25 @@ def create_app() -> Flask:
description = (data.get("description") or "").strip()
category = (data.get("category") or "").strip() or None
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
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:
return error_response("VALIDATION_ERROR", "Invalid tool name")
if not version or Semver.parse(version) is None:
@ -1218,8 +1250,8 @@ def create_app() -> Flask:
INSERT INTO tools (
owner, name, version, description, category, tags, config_yaml, readme,
publisher_id, deprecated, deprecated_message, replacement, downloads,
scrutiny_status, scrutiny_report, source, source_url, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
scrutiny_status, scrutiny_report, source, source_url, source_json, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
owner,
@ -1239,6 +1271,7 @@ def create_app() -> Flask:
scrutiny_json,
source,
source_url,
source_json,
datetime.utcnow().isoformat(),
],
)

View File

@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS tools (
scrutiny_report TEXT,
source TEXT,
source_url TEXT,
source_json TEXT,
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, name, version)
);

View File

@ -68,19 +68,34 @@
{% if tool.source %}
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-amber-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="flex items-start">
<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"/>
</svg>
<span class="text-sm text-amber-800">
Based on
{% if tool.source_url %}
<a href="{{ tool.source_url }}" target="_blank" rel="noopener" class="font-medium underline hover:text-amber-900">{{ tool.source }}</a>
<div class="text-sm text-amber-800">
{% if tool.source.type == 'forked' %}
<span class="font-medium">Forked from</span>
{% elif tool.source.type == 'imported' %}
<span class="font-medium">Based on</span>
{% else %}
<span class="font-medium">{{ tool.source }}</span>
<span class="font-medium">Source:</span>
{% endif %}
pattern
</span>
{% if tool.source.original_tool %}
{% 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>
{% endif %}