From 6deebbd0523660b59c90b6be55ce5136808109ce Mon Sep 17 00:00:00 2001 From: haikdc Date: Mon, 30 Mar 2026 08:53:52 -0700 Subject: [PATCH] [Haik]: ckpt, nine router now runs on different ports for dev vs prod. Also made a custom linter to enforce my standards --- .vscode/settings.json | 13 +++ .vscode/tasks.json | 55 +++++++++++ backend/apps/nine_router.py | 26 +++-- backend/ports.py | 16 ++- backend/requirements.txt | 4 +- linter/README.md | 81 ++++++++++++++++ linter/pyrightconfig.json | 14 +++ linter/structlint.json | 26 +++++ linter/structlint.py | 187 ++++++++++++++++++++++++++++++++++++ ports.config.json | 5 +- 10 files changed, 418 insertions(+), 9 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 linter/README.md create mode 100644 linter/pyrightconfig.json create mode 100644 linter/structlint.json create mode 100644 linter/structlint.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b95cebde --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "python.analysis.typeCheckingMode": "strict", + "python.analysis.include": ["backend"], + "python.analysis.exclude": [ + "backend/.venv", + "backend/uv-bin", + "backend/data", + "backend/tests" + ], + "python.analysis.diagnosticSeverityOverrides": { + "reportMissingTypeStubs": "none" + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..d7d7dfc3 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,55 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "structlint:watch", + "type": "shell", + "command": "python3", + "args": ["${workspaceFolder}/linter/structlint.py", "--watch", "--root", "${workspaceFolder}"], + "isBackground": true, + "runOptions": { + "runOn": "folderOpen" + }, + "presentation": { + "reveal": "never", + "panel": "dedicated", + "close": true + }, + "problemMatcher": { + "owner": "structlint", + "fileLocation": ["relative", "${workspaceFolder}"], + "pattern": { + "regexp": "^(.+):(\\d+):(\\d+):\\s+(error|warning):\\s+(.+)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + }, + "background": { + "activeOnStart": true, + "beginsPattern": "^structlint: checking\\.\\.\\.$", + "endsPattern": "^structlint: done\\." + } + } + }, + { + "label": "structlint:check", + "type": "shell", + "command": "python3", + "args": ["${workspaceFolder}/linter/structlint.py", "--root", "${workspaceFolder}"], + "problemMatcher": { + "owner": "structlint", + "fileLocation": ["relative", "${workspaceFolder}"], + "pattern": { + "regexp": "^(.+):(\\d+):(\\d+):\\s+(error|warning):\\s+(.+)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + } + } + ] +} diff --git a/backend/apps/nine_router.py b/backend/apps/nine_router.py index 7a28396f..8bba0ce4 100644 --- a/backend/apps/nine_router.py +++ b/backend/apps/nine_router.py @@ -3,8 +3,9 @@ 9Router is a free AI subscription proxy that lets users connect their Claude/ChatGPT/Gemini subscriptions to OpenSwarm without API keys. -It runs silently in the background on port 20128 and exposes an -OpenAI-compatible API at localhost:20128/v1. +It runs silently in the background and exposes an OpenAI-compatible API +at localhost:/v1. The port is read from ports.config.json (dev +vs prod) so the packaged app and the dev server never collide. """ import asyncio @@ -115,7 +116,12 @@ async def ensure_running(): print(f"9Router: starting (production) on port {NINE_ROUTER_PORT}...", flush=True) cmd = [node, standalone_server] cwd = os.path.dirname(standalone_server) - env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"} + env = { + **os.environ, + "PORT": str(NINE_ROUTER_PORT), + "NEXT_PUBLIC_BASE_URL": NINE_ROUTER_URL, + "NODE_ENV": "production", + } if node == os.environ.get("OPENSWARM_ELECTRON_PATH"): env["ELECTRON_RUN_AS_NODE"] = "1" @@ -135,7 +141,11 @@ async def ensure_running(): print(f"9Router: starting (dev) on port {NINE_ROUTER_PORT}...", flush=True) cmd = [npx, "next", "dev", "--webpack", "-p", str(NINE_ROUTER_PORT)] cwd = _9router_dir - env = {**os.environ, "PORT": str(NINE_ROUTER_PORT)} + env = { + **os.environ, + "PORT": str(NINE_ROUTER_PORT), + "NEXT_PUBLIC_BASE_URL": NINE_ROUTER_URL, + } else: npx = shutil.which("npx") @@ -143,9 +153,13 @@ async def ensure_running(): print("9Router: npx not found and no bundled 9router directory", flush=True) return print(f"9Router: starting (npx) on port {NINE_ROUTER_PORT}...", flush=True) - cmd = [npx, "9router"] + cmd = [npx, "9router", "--port", str(NINE_ROUTER_PORT)] cwd = None - env = {**os.environ, "PORT": str(NINE_ROUTER_PORT)} + env = { + **os.environ, + "PORT": str(NINE_ROUTER_PORT), + "NEXT_PUBLIC_BASE_URL": NINE_ROUTER_URL, + } try: _process = subprocess.Popen( diff --git a/backend/ports.py b/backend/ports.py index 5fecd5e9..41e2796a 100644 --- a/backend/ports.py +++ b/backend/ports.py @@ -15,9 +15,23 @@ BACKEND_DEV_PORT: int = _cfg["backend"]["dev"] BACKEND_PROD_PORT_START: int = _cfg["backend"]["prod"]["start"] BACKEND_PROD_PORT_END: int = _cfg["backend"]["prod"]["end"] FRONTEND_DEV_PORT: int = _cfg["frontend"]["dev"] -NINE_ROUTER_PORT: int = _cfg["nineRouter"] +NINE_ROUTER_DEV_PORT: int = _cfg["nineRouter"]["dev"] +NINE_ROUTER_PROD_PORT: int = _cfg["nineRouter"]["prod"] + + +def _is_packaged() -> bool: + return os.environ.get("OPENSWARM_PACKAGED") == "1" def get_backend_port() -> int: """Return the active backend port (env override or dev default).""" return int(os.environ.get("OPENSWARM_PORT", str(BACKEND_DEV_PORT))) + + +def get_nine_router_port() -> int: + """Return the 9Router port for the current environment.""" + return NINE_ROUTER_PROD_PORT if _is_packaged() else NINE_ROUTER_DEV_PORT + + +# Convenience alias — most callers just need the current port. +NINE_ROUTER_PORT: int = get_nine_router_port() diff --git a/backend/requirements.txt b/backend/requirements.txt index 54900937..eb5c7d2e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,4 +8,6 @@ pytest-asyncio==0.25.2 typeguard==4.4.2 Pillow posthog -httpx>=0.27.0 \ No newline at end of file +httpx>=0.27.0 +watchfiles +pyright \ No newline at end of file diff --git a/linter/README.md b/linter/README.md new file mode 100644 index 00000000..75d168f8 --- /dev/null +++ b/linter/README.md @@ -0,0 +1,81 @@ +# Structural Linter + +A linter is a tool that automatically checks your code for problems. This one doesn't check for bugs — it enforces **structural rules** that keep the codebase organized and easy to navigate. + +## What it checks + +**1. File length** — Every source file must be under 250 lines. + +Big files are hard to read, hard to review, and hard to maintain. If a file is getting long, it's a sign it should be split into smaller, more focused pieces. + +**2. Folder size** — Every folder must contain fewer than 6 items (files or subfolders). + +When a folder has dozens of files it becomes a junk drawer. Keeping folders small forces you to organize code into logical groups. + +These rules apply to `.py`, `.ts`, `.tsx`, `.js`, and `.jsx` files. Non-code files (images, JSON data, configs, lock files, etc.) are ignored. + +## How it runs + +You don't need to do anything — it runs automatically. + +When you open the project in Cursor/VS Code, a background task starts watching for file changes. Every time you save, it re-checks the codebase. Violations show up as errors in the **Problems panel** (`Cmd+Shift+M`) and as red badges on files in the sidebar, just like any other linter. + +If you want to run it manually from the terminal: + +```bash +# one-shot check (exits with code 1 if violations exist) +python3 linter/structlint.py --root . + +# continuous watch mode +python3 linter/structlint.py --watch --root . +``` + +## Configuration + +All config lives in `structlint.json` (this folder). Here's what each field does: + +```json +{ + "rules": { + "max-file-lines": 250, // files with >= this many lines trigger an error + "max-folder-items": 6 // folders with >= this many items trigger an error + }, + "include_extensions": [".py", ".ts", ".tsx", ".js", ".jsx"], // only these file types are line-counted + "exclude": ["node_modules", ".venv", "..."], // directories to skip entirely + "exceptions": { + "max-file-lines": [], // glob patterns for files exempt from the line limit + "max-folder-items": [] // glob patterns for folders exempt from the item limit + } +} +``` + +## Adding exceptions + +If a file or folder legitimately needs to exceed a limit, add a glob pattern to the `exceptions` list in `structlint.json`. For example: + +```json +{ + "exceptions": { + "max-file-lines": [ + "backend/tests/test_analytics.py" + ], + "max-folder-items": [ + "backend/apps/agents" + ] + } +} +``` + +You can use wildcards: `"backend/tests/*"` exempts all files in the tests folder. + +## Type checking + +This folder also contains `pyrightconfig.json`, which configures strict type checking for the Python backend. This works through the **Pylance** extension in Cursor/VS Code — it shows type errors in real-time as you type, the same way TypeScript checks the frontend. No setup needed beyond having Pylance installed. + +## Files in this folder + +| File | Purpose | +|---|---| +| `structlint.py` | The linter script | +| `structlint.json` | Rules, exclusions, and exceptions | +| `pyrightconfig.json` | Python type checking config (for CLI `pyright` usage) | diff --git a/linter/pyrightconfig.json b/linter/pyrightconfig.json new file mode 100644 index 00000000..4533e7fd --- /dev/null +++ b/linter/pyrightconfig.json @@ -0,0 +1,14 @@ +{ + "include": ["../backend"], + "exclude": [ + "../backend/.venv", + "../backend/uv-bin", + "../backend/data", + "../backend/tests" + ], + "typeCheckingMode": "strict", + "pythonVersion": "3.11", + "venvPath": "../backend", + "venv": ".venv", + "reportMissingTypeStubs": false +} diff --git a/linter/structlint.json b/linter/structlint.json new file mode 100644 index 00000000..12f8bfb1 --- /dev/null +++ b/linter/structlint.json @@ -0,0 +1,26 @@ +{ + "rules": { + "max-file-lines": 250, + "max-folder-items": 6 + }, + "include_extensions": [".py", ".ts", ".tsx", ".js", ".jsx"], + "exclude": [ + "node_modules", + ".venv", + "dist", + "build", + "__pycache__", + ".git", + ".cursor", + ".vscode", + "uv-bin", + "data", + "public", + "readme_assets", + "ASSISTANT_UI_MIGRATION" + ], + "exceptions": { + "max-file-lines": [], + "max-folder-items": [] + } +} diff --git a/linter/structlint.py b/linter/structlint.py new file mode 100644 index 00000000..f866ed57 --- /dev/null +++ b/linter/structlint.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Structural linter: enforces file line limits and folder item limits.""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import os +import sys +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +CONFIG_FILE = SCRIPT_DIR / "structlint.json" + + +def load_config() -> dict[str, Any]: + with open(CONFIG_FILE) as f: + return json.load(f) + + +def _matches_any(text: str, patterns: list[str]) -> bool: + return any(fnmatch.fnmatch(text, p) for p in patterns) + + +def is_excluded(path: Path, root: Path, excludes: list[str]) -> bool: + rel = path.relative_to(root) + for part in rel.parts: + if _matches_any(part, excludes): + return True + return _matches_any(str(rel), excludes) + + +def is_excepted(rel_path: str, rule: str, exceptions: dict[str, list[str]]) -> bool: + return _matches_any(rel_path, exceptions.get(rule, [])) + + +def check_file_lines( + filepath: Path, root: Path, max_lines: int, +) -> tuple[str, int] | None: + try: + count = len(filepath.read_text(errors="ignore").splitlines()) + except OSError: + return None + if count >= max_lines: + rel = filepath.relative_to(root) + msg = ( + f"{rel}:1:1: error: File has {count} lines " + f"(limit {max_lines}) [max-file-lines]" + ) + return (msg, count) + return None + + +ANCHOR_FILES = ("__init__.py", "index.ts", "index.tsx", "index.js") + + +def _find_anchor_file(dirpath: Path, root: Path) -> str: + """Find a real file inside the folder to attach the diagnostic to. + + Prefers common entry-point files (__init__.py, index.ts, etc.) so the + error shows up inline when you open that file. Falls back to the first + file alphabetically, then the directory path itself. + """ + for name in ANCHOR_FILES: + candidate = dirpath / name + if candidate.exists(): + return str(candidate.relative_to(root)) + try: + first = sorted( + f for f in dirpath.iterdir() + if f.is_file() and not f.name.startswith(".") + ) + if first: + return str(first[0].relative_to(root)) + except OSError: + pass + return str(dirpath.relative_to(root)) + + +def check_folder_items( + dirpath: Path, root: Path, max_items: int, excludes: list[str], +) -> tuple[str, int] | None: + try: + items = [ + i for i in dirpath.iterdir() + if not i.name.startswith(".") and not _matches_any(i.name, excludes) + ] + except OSError: + return None + count = len(items) + if count >= max_items: + anchor = _find_anchor_file(dirpath, root) + rel = dirpath.relative_to(root) + msg = ( + f"{anchor}:1:1: error: Folder '{rel}' has {count} items " + f"(limit {max_items}) [max-folder-items]" + ) + return (msg, count) + return None + + +def run_checks(root: Path) -> list[str]: + config = load_config() + rules: dict[str, int] = config["rules"] + excludes: list[str] = config["exclude"] + exceptions: dict[str, list[str]] = config["exceptions"] + extensions: list[str] = config["include_extensions"] + + max_lines: int = rules["max-file-lines"] + max_items: int = rules["max-folder-items"] + errors: list[str] = [] + + for dirpath_str, dirnames, filenames in os.walk(root): + dp = Path(dirpath_str) + + if is_excluded(dp, root, excludes): + dirnames.clear() + continue + + rel_dir = str(dp.relative_to(root)) + if rel_dir != "." and not is_excepted(rel_dir, "max-folder-items", exceptions): + result = check_folder_items(dp, root, max_items, excludes) + if result: + errors.append(result[0]) + + for fname in filenames: + fp = dp / fname + if fp.suffix not in extensions: + continue + if is_excluded(fp, root, excludes): + continue + rel_file = str(fp.relative_to(root)) + if not is_excepted(rel_file, "max-file-lines", exceptions): + result = check_file_lines(fp, root, max_lines) + if result: + errors.append(result[0]) + + return sorted(errors) + + +def print_results(errors: list[str]) -> None: + print("structlint: checking...", flush=True) + for err in errors: + print(err, flush=True) + count = len(errors) + print(f"structlint: done. {count} error(s) found.", flush=True) + + +def watch_loop(root: Path) -> None: + from watchfiles import watch, DefaultFilter + + print_results(run_checks(root)) + + class SourceFilter(DefaultFilter): + allowed_extensions = (".py", ".ts", ".tsx", ".js", ".jsx") + + def __call__(self, change: Any, path: str) -> bool: + if not super().__call__(change, path): + return False + if Path(path).suffix in self.allowed_extensions: + return True + return Path(path).is_dir() + + for _changes in watch(root, watch_filter=SourceFilter()): + print_results(run_checks(root)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Structural linter") + parser.add_argument("--watch", action="store_true", help="Watch for changes") + parser.add_argument("--root", type=str, default=".", help="Root directory") + args = parser.parse_args() + + root = Path(args.root).resolve() + + if args.watch: + watch_loop(root) + else: + errors = run_checks(root) + print_results(errors) + sys.exit(1 if errors else 0) + + +if __name__ == "__main__": + main() diff --git a/ports.config.json b/ports.config.json index 79880e61..46081a84 100644 --- a/ports.config.json +++ b/ports.config.json @@ -6,5 +6,8 @@ "frontend": { "dev": 3000 }, - "nineRouter": 20128 + "nineRouter": { + "dev": 20129, + "prod": 20128 + } }