From adefe909c2a8f84374e9c36c9be568a22ade0ae2 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 16 Jan 2026 23:42:32 -0400 Subject: [PATCH] Add fork display, version selector, and admin cleanup features Registry Features: - Fork tracking with forked_from/forked_version metadata - Forked tools show "Forked from" notice on detail page - Original tools display "Forks" section listing all forks - Fork count in tool stats - API: GET /api/v1/tools///forks GUI Improvements: - Version selector dropdown for registry installs - Fetch available versions via GET /api/v1/tools///versions - "Latest" option plus all available versions listed Admin Features: - POST /api/v1/admin/cleanup/rejected endpoint - Maintenance section in admin dashboard - "Dry Run" and "Run Cleanup" buttons for rejected version cleanup - Configurable grace period (default 7 days) Documentation: - Updated CHANGELOG.md with all recent changes Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 54 ++++++- src/cmdforge/gui/pages/registry_page.py | 101 +++++++++++-- src/cmdforge/registry/app.py | 142 ++++++++++++++++++ src/cmdforge/web/routes.py | 15 ++ src/cmdforge/web/templates/admin/index.html | 130 ++++++++++++++++ .../web/templates/pages/tool_detail.html | 63 +++++++- 6 files changed, 494 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e1498c..956478d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,53 @@ -# Auto-deploy test Tue 13 Jan 2026 03:09:44 AM AST +# Changelog + +All notable changes to CmdForge will be documented in this file. + +## [Unreleased] + +### Added + +#### Registry Features +- **Fork tracking and display**: Tools now track their fork origin with `forked_from` and `forked_version` metadata + - Forked tools show a "Forked from" notice on the tool detail page + - Original tools display a "Forks" section listing all forks + - Fork count displayed in tool stats + - API endpoint: `GET /api/v1/tools///forks` + +- **Version selector for installs**: Users can select specific versions when installing tools from the registry + - Version dropdown in registry page populated via `GET /api/v1/tools///versions` + - "Latest" option plus all available versions listed + - Selected version passed to install worker + +- **Auto-cleanup rejected versions**: Admin maintenance feature to purge rejected tool submissions + - API endpoint: `POST /api/v1/admin/cleanup/rejected` + - Parameters: + - `days` (default: 7) - grace period before deletion + - `dry_run` (default: false) - preview mode without actual deletion + - Admin dashboard UI with "Dry Run" and "Run Cleanup" buttons + - Shows count of rejected versions pending cleanup + +#### GUI Improvements +- Version display and bump buttons in publish dialog +- Auto-fetch registry version when opening publish dialog +- Fork detection during publish workflow +- Always refresh tools page after publish dialog closes + +#### Admin Features +- Maintenance section in admin dashboard +- Rejected version count display +- Cleanup result modal with detailed output + +### Fixed +- VERSION_EXISTS error showing after successful publish (made endpoint idempotent by checking config_hash) +- My Tools page listing every version separately (now consolidated by tool name) +- GUI not refreshing after publish dialog closes + +### Changed +- Publish endpoint now returns success if same config_hash already exists (idempotent) +- My Tools page groups versions by tool name, showing version count and list + +--- + +## Previous Changes + +See git history for changes prior to this changelog. diff --git a/src/cmdforge/gui/pages/registry_page.py b/src/cmdforge/gui/pages/registry_page.py index 5aff3c2..1b4dd6f 100644 --- a/src/cmdforge/gui/pages/registry_page.py +++ b/src/cmdforge/gui/pages/registry_page.py @@ -57,9 +57,9 @@ class SearchWorker(QThread): self.error.emit(str(e)) -class InstallWorker(QThread): - """Background worker for tool installation.""" - finished = Signal(str) +class VersionsWorker(QThread): + """Background worker to fetch available versions.""" + finished = Signal(list) # List of version strings error = Signal(str) def __init__(self, owner: str, name: str): @@ -70,8 +70,29 @@ class InstallWorker(QThread): def run(self): try: client = RegistryClient() - client.install_tool(self.owner, self.name) - self.finished.emit(f"{self.owner}/{self.name}") + versions = client.get_tool_versions(self.owner, self.name) + self.finished.emit(versions) + except Exception as e: + self.error.emit(str(e)) + + +class InstallWorker(QThread): + """Background worker for tool installation.""" + finished = Signal(str) + error = Signal(str) + + def __init__(self, owner: str, name: str, version: str = None): + super().__init__() + self.owner = owner + self.name = name + self.version = version + + def run(self): + try: + client = RegistryClient() + client.install_tool(self.owner, self.name, version=self.version) + version_str = f"@{self.version}" if self.version else "" + self.finished.emit(f"{self.owner}/{self.name}{version_str}") except Exception as e: self.error.emit(str(e)) @@ -84,7 +105,9 @@ class RegistryPage(QWidget): self.main_window = main_window self._search_worker = None self._install_worker = None + self._versions_worker = None self._selected_tool = None + self._available_versions = [] self._current_page = 1 self._total_pages = 1 self._current_tags = [] @@ -228,6 +251,23 @@ class RegistryPage(QWidget): right_layout.addWidget(details_box, 1) + # Version selector row + version_row = QHBoxLayout() + version_row.setSpacing(8) + + version_label = QLabel("Version:") + version_label.setStyleSheet("color: #4a5568;") + version_row.addWidget(version_label) + + self.version_combo = QComboBox() + self.version_combo.setMinimumWidth(120) + self.version_combo.addItem("Latest", None) + self.version_combo.setEnabled(False) + version_row.addWidget(self.version_combo) + + version_row.addStretch() + right_layout.addLayout(version_row) + # Action buttons actions = QHBoxLayout() @@ -405,6 +445,9 @@ class RegistryPage(QWidget): self.details_text.clear() self.btn_install.setEnabled(False) self.btn_update.hide() + self.version_combo.clear() + self.version_combo.addItem("Latest", None) + self.version_combo.setEnabled(False) return row = items[0].row() @@ -413,11 +456,47 @@ class RegistryPage(QWidget): self._selected_tool = tool self._show_tool_details(tool) + # Reset and fetch versions + self.version_combo.clear() + self.version_combo.addItem("Loading...", None) + self.version_combo.setEnabled(False) + self._fetch_versions(tool.get("owner", ""), tool.get("name", "")) + # Check if installed / update available - tool_name = tool.get("name", "") + self._update_install_buttons() + + def _fetch_versions(self, owner: str, name: str): + """Fetch available versions for a tool.""" + self._versions_worker = VersionsWorker(owner, name) + self._versions_worker.finished.connect(self._on_versions_fetched) + self._versions_worker.error.connect(self._on_versions_error) + self._versions_worker.start() + + def _on_versions_fetched(self, versions: list): + """Handle versions fetch completion.""" + self._available_versions = versions + self.version_combo.clear() + self.version_combo.addItem("Latest", None) + for version in versions: + self.version_combo.addItem(f"v{version}", version) + self.version_combo.setEnabled(len(versions) > 1) + + def _on_versions_error(self, error: str): + """Handle versions fetch error.""" + self._available_versions = [] + self.version_combo.clear() + self.version_combo.addItem("Latest", None) + self.version_combo.setEnabled(False) + + def _update_install_buttons(self): + """Update install/update buttons based on selection.""" + if not self._selected_tool: + return + + tool_name = self._selected_tool.get("name", "") if tool_name in self._installed_tools: installed_version = self._installed_tools[tool_name] - registry_version = tool.get("version", "1.0.0") + registry_version = self._selected_tool.get("version", "1.0.0") if installed_version != registry_version: self.btn_install.hide() self.btn_update.show() @@ -546,12 +625,16 @@ class RegistryPage(QWidget): owner = self._selected_tool.get("owner", "") name = self._selected_tool.get("name", "") + version = self.version_combo.currentData() # None for "Latest" self.btn_install.setEnabled(False) self.btn_update.setEnabled(False) - self.status_label.setText(f"Installing {owner}/{name}...") + self.version_combo.setEnabled(False) - self._install_worker = InstallWorker(owner, name) + version_str = f"@{version}" if version else "" + self.status_label.setText(f"Installing {owner}/{name}{version_str}...") + + self._install_worker = InstallWorker(owner, name, version) self._install_worker.finished.connect(self._on_install_complete) self._install_worker.error.connect(self._on_install_error) self._install_worker.start() diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index 4458f5d..a42fffd 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -972,6 +972,17 @@ def create_app() -> Flask: "url": row["source_url"], } + # Count forks of this tool + fork_count = 0 + fork_pattern = f"{row['owner']}/{row['name']}" + fork_row = query_one( + g.db, + "SELECT COUNT(DISTINCT owner || '/' || name) as cnt FROM tools WHERE forked_from = ? AND moderation_status = 'approved'", + [fork_pattern] + ) + if fork_row: + fork_count = fork_row["cnt"] + payload = { "owner": row["owner"], "name": row["name"], @@ -987,6 +998,9 @@ def create_app() -> Flask: "config": row["config_yaml"], "readme": row["readme"], "source": source_obj, + "forked_from": row.get("forked_from"), + "forked_version": row.get("forked_version"), + "fork_count": fork_count, } response = jsonify({"data": payload}) response.headers["Cache-Control"] = "max-age=60" @@ -1015,6 +1029,55 @@ def create_app() -> Flask: versions = [row["version"] for row in rows] return jsonify({"data": {"versions": versions}}) + @app.route("/api/v1/tools///forks", methods=["GET"]) + def list_tool_forks(owner: str, name: str) -> Response: + """List all forks of a tool.""" + if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name): + return error_response("VALIDATION_ERROR", "Invalid owner or tool name") + + # Check if the original tool exists + original = query_one( + g.db, + "SELECT id FROM tools WHERE owner = ? AND name = ? LIMIT 1", + [owner, name] + ) + if not original: + return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' does not exist", 404) + + # Find all tools that were forked from this one + fork_pattern = f"{owner}/{name}" + rows = query_all( + g.db, + """ + SELECT DISTINCT owner, name, description, forked_version, + (SELECT MAX(version) FROM tools t2 WHERE t2.owner = t.owner AND t2.name = t.name) as latest_version, + (SELECT SUM(downloads) FROM tools t2 WHERE t2.owner = t.owner AND t2.name = t.name) as total_downloads + FROM tools t + WHERE forked_from = ? AND moderation_status = 'approved' + ORDER BY total_downloads DESC + """, + [fork_pattern] + ) + + forks = [ + { + "owner": row["owner"], + "name": row["name"], + "description": row["description"], + "forked_version": row["forked_version"], + "latest_version": row["latest_version"], + "downloads": row["total_downloads"] or 0, + } + for row in rows + ] + + return jsonify({ + "data": { + "forks": forks, + "fork_count": len(forks), + } + }) + @app.route("/api/v1/tools///download", methods=["GET"]) def download_tool(owner: str, name: str) -> Response: if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name): @@ -2849,6 +2912,85 @@ def create_app() -> Flask: return jsonify({"data": {"status": "removed", "tool_id": tool_id}}) + @app.route("/api/v1/admin/cleanup/rejected", methods=["POST"]) + @require_admin + def admin_cleanup_rejected() -> Response: + """ + Delete rejected tool versions older than N days. + + This endpoint permanently deletes tools that were rejected during + moderation after a grace period, allowing users time to see the + rejection reason and fix issues. + + Query params: + days: Number of days to retain rejected versions (default: 7) + dry_run: If "true", only report what would be deleted without deleting + """ + days = request.args.get("days", 7, type=int) + dry_run = request.args.get("dry_run", "false").lower() == "true" + + if days < 0: + return error_response("VALIDATION_ERROR", "Days must be non-negative", 400) + + # Calculate cutoff date + cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat() + + # Find rejected tools older than cutoff + rejected_tools = query_all( + g.db, + """ + SELECT id, owner, name, version, moderated_at + FROM tools + WHERE moderation_status = 'rejected' + AND moderated_at < ? + """, + [cutoff], + ) + + if not rejected_tools: + return jsonify({ + "data": { + "deleted_count": 0, + "dry_run": dry_run, + "cutoff_date": cutoff, + "deleted_tools": [], + } + }) + + deleted_tools = [] + for tool in rejected_tools: + deleted_tools.append({ + "id": tool["id"], + "name": f"{tool['owner']}/{tool['name']}", + "version": tool["version"], + "moderated_at": tool["moderated_at"], + }) + + if not dry_run: + tool_id = tool["id"] + # Delete associated records + g.db.execute("DELETE FROM download_stats WHERE tool_id = ?", [tool_id]) + g.db.execute("DELETE FROM reports WHERE tool_id = ?", [tool_id]) + g.db.execute("DELETE FROM featured_tools WHERE tool_id = ?", [tool_id]) + g.db.execute("DELETE FROM tools WHERE id = ?", [tool_id]) + + if not dry_run: + g.db.commit() + log_audit("cleanup_rejected", "system", "cleanup", { + "days": days, + "deleted_count": len(deleted_tools), + "tool_ids": [t["id"] for t in deleted_tools], + }) + + return jsonify({ + "data": { + "deleted_count": len(deleted_tools), + "dry_run": dry_run, + "cutoff_date": cutoff, + "deleted_tools": deleted_tools, + } + }) + @app.route("/api/v1/admin/tools/", methods=["DELETE"]) @require_admin def admin_delete_tool(tool_id: int) -> Response: diff --git a/src/cmdforge/web/routes.py b/src/cmdforge/web/routes.py index e754c72..0a2e947 100644 --- a/src/cmdforge/web/routes.py +++ b/src/cmdforge/web/routes.py @@ -418,6 +418,10 @@ def tool_detail(owner: str, name: str): issues = issues_payload.get("data", []) issues_meta = issues_payload.get("meta", {}) + # Load forks list + _, forks_payload = _api_get(f"/api/v1/tools/{owner}/{name}/forks") + forks = forks_payload.get("data", {}).get("forks", []) + return render_template( "pages/tool_detail.html", tool=tool, @@ -428,6 +432,7 @@ def tool_detail(owner: str, name: str): reviews_total=reviews_meta.get("total", 0), issues=issues, issues_total=issues_meta.get("total", 0), + forks=forks, ) @@ -460,6 +465,10 @@ def tool_version(owner: str, name: str, version: str): issues = issues_payload.get("data", []) issues_meta = issues_payload.get("meta", {}) + # Load forks list + _, forks_payload = _api_get(f"/api/v1/tools/{owner}/{name}/forks") + forks = forks_payload.get("data", {}).get("forks", []) + return render_template( "pages/tool_detail.html", tool=tool, @@ -470,6 +479,7 @@ def tool_version(owner: str, name: str, version: str): reviews_total=reviews_meta.get("total", 0), issues=issues, issues_total=issues_meta.get("total", 0), + forks=forks, ) @@ -1114,6 +1124,10 @@ def admin_dashboard(): status, payload = _api_get("/api/v1/admin/publishers?per_page=1", token=token) publishers_count = payload.get("meta", {}).get("total", 0) if status == 200 else 0 + # Get rejected tools count for cleanup + status, payload = _api_get("/api/v1/admin/scrutiny?moderation_status=rejected&per_page=1", token=token) + rejected_count = payload.get("meta", {}).get("total", 0) if status == 200 else 0 + return render_template( "admin/index.html", user=user, @@ -1121,6 +1135,7 @@ def admin_dashboard(): pending_count=pending_count, reports_count=reports_count, publishers_count=publishers_count, + rejected_count=rejected_count, ) diff --git a/src/cmdforge/web/templates/admin/index.html b/src/cmdforge/web/templates/admin/index.html index 623431d..df977d7 100644 --- a/src/cmdforge/web/templates/admin/index.html +++ b/src/cmdforge/web/templates/admin/index.html @@ -106,5 +106,135 @@ + + + {% if user.role == 'admin' %} +
+

Maintenance

+
+ +
+
+
+ + + +
+
+

Cleanup Rejected Versions

+

Delete rejected tool versions older than 7 days

+

+ {{ rejected_count }} rejected version(s) in registry +

+
+
+
+ + +
+
+
+
+ {% endif %} + + + + + {% endblock %} diff --git a/src/cmdforge/web/templates/pages/tool_detail.html b/src/cmdforge/web/templates/pages/tool_detail.html index 44ef200..2efe7e5 100644 --- a/src/cmdforge/web/templates/pages/tool_detail.html +++ b/src/cmdforge/web/templates/pages/tool_detail.html @@ -66,7 +66,24 @@ {% endif %} - {% if tool.source %} + {% if tool.forked_from %} +
+
+ + + +
+ Forked from + {% set fork_parts = tool.forked_from.split('/') %} + {{ tool.forked_from }} + {% if tool.forked_version %} + v{{ tool.forked_version }} + {% endif %} +
+
+
+ {% elif tool.source %}
@@ -330,6 +347,44 @@
+ + {% if forks %} +
+

+ + + + + Forks + {{ forks|length }} + +

+ + {% if forks|length > 5 %} +

+ + {{ forks|length - 5 }} more fork{{ 's' if forks|length - 5 != 1 else '' }} +

+ {% endif %} +
+ {% endif %} +

Stats

@@ -359,6 +414,12 @@
{{ rating.unique_users|format_number }}
{% endif %} + {% if tool.fork_count %} +
+
Forks
+
{{ tool.fork_count }}
+
+ {% endif %}