@bhooai/nexus-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/PLAN.md +141 -0
  2. package/README.md +34 -0
  3. package/package.json +25 -0
  4. package/src/commands/cluster.ts +133 -0
  5. package/src/commands/dev.ts +133 -0
  6. package/src/commands/doctor.ts +199 -0
  7. package/src/commands/init.ts +960 -0
  8. package/src/commands/node.ts +101 -0
  9. package/src/commands/pysetup.ts +136 -0
  10. package/src/commands/sync.ts +116 -0
  11. package/src/commands/uninstall.ts +287 -0
  12. package/src/config-sync.ts +384 -0
  13. package/src/dotenv.ts +39 -0
  14. package/src/index.ts +94 -0
  15. package/src/supervisor.ts +384 -0
  16. package/src/util.ts +123 -0
  17. package/src/wizard.ts +149 -0
  18. package/templates/Dockerfile +60 -0
  19. package/templates/README.md +69 -0
  20. package/templates/apps/admin/index.html +12 -0
  21. package/templates/apps/admin/package.json +24 -0
  22. package/templates/apps/admin/postcss.config.js +6 -0
  23. package/templates/apps/admin/src/main.tsx +10 -0
  24. package/templates/apps/admin/src/vite-env.d.ts +18 -0
  25. package/templates/apps/admin/tailwind.config.js +9 -0
  26. package/templates/apps/admin/tsconfig.json +17 -0
  27. package/templates/apps/admin/vite.config.ts +64 -0
  28. package/templates/apps/ai-server/main.py +43 -0
  29. package/templates/apps/ai-server/providers/__init__.py +3 -0
  30. package/templates/apps/ai-server/providers/base.py +111 -0
  31. package/templates/apps/ai-server/requirements.txt +3 -0
  32. package/templates/apps/ai-server/routers/__init__.py +3 -0
  33. package/templates/apps/ai-server/routers/chat.py +47 -0
  34. package/templates/apps/ai-server/routers/embeddings.py +30 -0
  35. package/templates/apps/ai-server/routers/lint.py +167 -0
  36. package/templates/apps/ai-server/routers/models.py +23 -0
  37. package/templates/apps/ai-server/routers/preflight.py +169 -0
  38. package/templates/apps/ai-server/settings.py +48 -0
  39. package/templates/apps/backend/package.json +33 -0
  40. package/templates/apps/backend/src/main.ts +375 -0
  41. package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
  42. package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
  43. package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
  44. package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
  45. package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
  46. package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
  47. package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
  48. package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
  49. package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
  50. package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
  51. package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
  52. package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
  53. package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
  54. package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
  55. package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
  56. package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
  57. package/templates/apps/backend/tsconfig.json +14 -0
  58. package/templates/apps/frontend/index.html +12 -0
  59. package/templates/apps/frontend/package.json +19 -0
  60. package/templates/apps/frontend/src/main.tsx +64 -0
  61. package/templates/apps/frontend/vite.config.ts +63 -0
  62. package/templates/bin/nexus.js +35 -0
  63. package/templates/bin/serve-all.mjs +45 -0
  64. package/templates/dockerignore +15 -0
  65. package/templates/gitignore +12 -0
  66. package/templates/nexus.config.ts +69 -0
  67. package/templates/package.json +47 -0
  68. package/templates/tsconfig.json +17 -0
  69. package/templates/uploads/.gitkeep +0 -0
  70. package/tests/cli.test.ts +45 -0
  71. package/tests/config-sync.test.ts +201 -0
  72. package/tests/dotenv.test.ts +51 -0
  73. package/tsconfig.json +9 -0
  74. package/vitest.config.ts +9 -0
  75. package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
@@ -0,0 +1,167 @@
1
+ """Env + config linter router.
2
+
3
+ Node sends the raw `.env` text (and the raw `nexus.runtime.json` text) and
4
+ Python computes a structured report. Python never echoes secret *values* — only
5
+ keys + issues. This keeps the lint logic in Python while Node owns auth + file
6
+ access. There is no `.env.example` anymore: secrets live directly in `.env`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import time
13
+ from typing import Any
14
+
15
+ from fastapi import APIRouter, HTTPException
16
+ from pydantic import BaseModel
17
+
18
+ router = APIRouter()
19
+
20
+ ALLOWED_LOG_LEVELS = {"silent", "fatal", "error", "warn", "info", "debug", "trace"}
21
+
22
+
23
+ class LintEnvRequest(BaseModel):
24
+ envText: str = ""
25
+
26
+
27
+ class LintConfigRequest(BaseModel):
28
+ content: str = ""
29
+
30
+
31
+ # ---- env parsing helpers ----
32
+
33
+ def _parse_env(text: str) -> tuple[list[dict[str, Any]], list[str]]:
34
+ """Parse dotenv-ish text. Returns (exports, malformed_lines)."""
35
+ exports: list[dict[str, Any]] = []
36
+ malformed: list[str] = []
37
+ for lineno, raw in enumerate(text.splitlines(), 1):
38
+ line = raw.strip()
39
+ if not line or line.startswith("#"):
40
+ continue
41
+ if "=" not in line:
42
+ malformed.append(f"line {lineno}: no '=' found")
43
+ continue
44
+ key, _, value = line.partition("=")
45
+ key = key.strip()
46
+ value = value.strip()
47
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
48
+ value = value[1:-1]
49
+ exports.append({"key": key, "value": value})
50
+ return exports, malformed
51
+
52
+
53
+ _PLACEHOLDER_PATTERNS = [
54
+ r"^(change|changeme|change-me|your|example|exampl|replace|dummy|sample)",
55
+ r"^xxxx+$",
56
+ r"^xxxx+",
57
+ ]
58
+
59
+
60
+ def _is_placeholder(value: str) -> bool:
61
+ if len(value.strip()) < 4:
62
+ return True
63
+ lowered = value.strip().lower()
64
+ return any(re.search(p, lowered) for p in _PLACEHOLDER_PATTERNS)
65
+
66
+
67
+ # ---- env checks ----
68
+
69
+ def _check_env(exports: list[dict[str, Any]], malformed: list[str]) -> list[dict[str, Any]]:
70
+ checks: list[dict[str, Any]] = []
71
+ seen: dict[str, int] = {}
72
+ for e in exports:
73
+ k = e["key"]
74
+ count = seen.get(k, 0) + 1
75
+ seen[k] = count
76
+ if count > 1:
77
+ checks.append({"key": k, "severity": "error", "kind": "duplicate", "message": "duplicate key"})
78
+ if e["value"] == "":
79
+ checks.append({"key": k, "severity": "warning", "kind": "empty", "message": "has an empty value"})
80
+ elif _is_placeholder(e["value"]):
81
+ checks.append({"key": k, "severity": "warning", "kind": "placeholder", "message": "value still looks like a placeholder"})
82
+ for line in malformed:
83
+ checks.append({"key": "(syntax)", "severity": "error", "kind": "malformed", "message": line})
84
+ env = {f["key"] for f in exports}
85
+ if "NEXUS_AUTH_JWT_SECRET" not in env:
86
+ checks.append({"key": "NEXUS_AUTH_JWT_SECRET", "severity": "warning", "kind": "missing", "message": "set this in .env — there is no default JWT secret"})
87
+ return checks
88
+
89
+
90
+ # ---- config checks ----
91
+
92
+ def _config_checks(data: dict[str, Any]) -> list[dict[str, Any]]:
93
+ checks: list[dict[str, Any]] = []
94
+ port = _get(data, "server.port")
95
+ if port is not None and not (isinstance(port, (int, float)) and 1 <= int(port) <= 65535):
96
+ checks.append({"key": "server.port", "severity": "error", "kind": "type", "message": "must be an integer port 1–65535"})
97
+ body = _get(data, "server.bodyLimit")
98
+ if body is not None and not isinstance(body, (int, float)):
99
+ checks.append({"key": "server.bodyLimit", "severity": "warning", "kind": "type", "message": "must be a number (bytes)"})
100
+ lvl = _get(data, "logging.level")
101
+ if lvl is not None and lvl not in ALLOWED_LOG_LEVELS:
102
+ checks.append({"key": "logging.level", "severity": "warning", "kind": "value", "message": f"unexpected level '{lvl}' (expected one of {sorted(ALLOWED_LOG_LEVELS)})"})
103
+ uri = _get(data, "db.uri")
104
+ if uri is not None and not (isinstance(uri, str) and uri.startswith(("mongodb://", "mongodb+srv://"))):
105
+ checks.append({"key": "db.uri", "severity": "error", "kind": "url", "message": "must be a mongodb:// or mongodb+srv:// URL"})
106
+ auto = _get(data, "db.autoIndex")
107
+ if auto is not None and not isinstance(auto, bool):
108
+ checks.append({"key": "db.autoIndex", "severity": "warning", "kind": "type", "message": "must be a boolean"})
109
+ intro = _get(data, "graphql.introspection")
110
+ if intro is not None and not isinstance(intro, bool):
111
+ checks.append({"key": "graphql.introspection", "severity": "warning", "kind": "type", "message": "must be a boolean"})
112
+ return checks
113
+
114
+
115
+ def _get(doc: dict[str, Any], path: str) -> Any:
116
+ node: Any = doc
117
+ for part in path.split("."):
118
+ if isinstance(node, dict) and part in node:
119
+ node = node[part]
120
+ else:
121
+ return None
122
+ return node
123
+
124
+
125
+ # ---- report ----
126
+
127
+ def _report(checks: list[dict[str, Any]]) -> dict[str, Any]:
128
+ summary = {"error": 0, "warning": 0, "info": 0, "ok": 0}
129
+ for c in checks:
130
+ sev = c["severity"] if c["severity"] in summary else "info"
131
+ summary[sev] += 1
132
+ if not checks:
133
+ summary["ok"] = 1
134
+ order = {"error": 0, "warning": 1, "info": 2, "ok": 9}
135
+ checks.sort(key=lambda c: (order.get(c["severity"], 9), c["key"]))
136
+ return {"ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "summary": summary, "checks": checks}
137
+
138
+
139
+ # ---- routes ----
140
+
141
+ @router.post("/lint/env")
142
+ async def lint_env(req: LintEnvRequest):
143
+ exports, malformed = _parse_env(req.envText)
144
+ checks = _check_env(exports, malformed)
145
+ return _report(checks)
146
+
147
+
148
+ @router.post("/lint/config")
149
+ async def lint_config(req: LintConfigRequest):
150
+ try:
151
+ doc = json.loads(req.content)
152
+ except json.JSONDecodeError as e:
153
+ return {
154
+ "ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
155
+ "summary": {"error": 1, "warning": 0, "info": 0, "ok": 0},
156
+ "checks": [
157
+ {"key": "(syntax)", "severity": "error", "kind": "malformed",
158
+ "message": f"not valid JSON: {e} at line {e.lineno} col {e.colno}"}
159
+ ],
160
+ }
161
+ if not isinstance(doc, dict):
162
+ return {
163
+ "ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
164
+ "summary": {"error": 1, "warning": 0, "info": 0, "ok": 0},
165
+ "checks": [{"key": "(root)", "severity": "error", "kind": "type", "message": "config root must be a JSON object"}],
166
+ }
167
+ return _report(_config_checks(doc))
@@ -0,0 +1,23 @@
1
+ """Models router — lists available upstream models."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, HTTPException
5
+ from fastapi.params import Query
6
+
7
+ from providers import get_provider
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.get("/models")
13
+ async def list_models(provider: str | None = Query(default=None)):
14
+ try:
15
+ p = get_provider(provider)
16
+ except ValueError as e:
17
+ raise HTTPException(status_code=400, detail=str(e))
18
+ try:
19
+ return await p.list_models()
20
+ except HTTPException:
21
+ raise
22
+ except Exception as e:
23
+ raise HTTPException(status_code=502, detail=f"upstream error: {e}")
@@ -0,0 +1,169 @@
1
+ """Preflight diagnostics router — connectivity + latency checks.
2
+
3
+ The Node backend composes the targets it wants checked (backend health, AI
4
+ server health, Mongo/Redis TCP reachability, service ports) and posts them here.
5
+ Python performs the checks concurrently and returns a normalized report. Only
6
+ stdlib (`socket`) + httpx are used, so this router adds no new dependencies.
7
+
8
+ Failed checks carry an `errorCategory` (refused/timeout/dns/ssl/http/other) plus
9
+ a short human-readable `error` message so the admin console never has to surface
10
+ raw transport exceptions like httpx's "All connection attempts failed...".
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import socket
16
+ import ssl
17
+ import time
18
+ from typing import Any, Literal
19
+ from urllib.parse import urlparse
20
+
21
+ import httpx
22
+ from fastapi import APIRouter, HTTPException
23
+ from pydantic import BaseModel
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ class Target(BaseModel):
29
+ name: str
30
+ kind: Literal["http", "tcp"] = "tcp"
31
+ url: str | None = None
32
+ host: str | None = None
33
+ port: int | None = None
34
+ timeout: float | None = None
35
+
36
+
37
+ class PreflightRequest(BaseModel):
38
+ targets: list[Target] = []
39
+ timeout: float = 2.0
40
+
41
+
42
+ def _classify_error(e: Exception, host: str = "", port: int | None = None) -> tuple[str, str]:
43
+ """Map an exception to a (category, friendly message) pair.
44
+
45
+ httpx wraps low-level socket errors, so when the top-level exception is the
46
+ generic "All connection attempts failed..." the underlying cause is unwrapped.
47
+ """
48
+ endpoint = f"{host}:{port}" if host else "target"
49
+
50
+ if isinstance(e, (httpx.ConnectTimeout, httpx.ConnectError, asyncio.TimeoutError, socket.timeout)):
51
+ try:
52
+ cause = e.__cause__
53
+ except AttributeError:
54
+ cause = None
55
+ if isinstance(cause, socket.gaierror):
56
+ return "dns", f"host not found: {host or 'unknown'}"
57
+ if isinstance(cause, (ConnectionRefusedError, OSError)) and getattr(cause, "errno", None) == getattr(socket, "ECONNREFUSED", 111):
58
+ return "refused", f"connection refused on {endpoint} — is the service running?"
59
+ return "timeout", "timed out — the service may be filtering traffic or is down"
60
+
61
+ if isinstance(e, socket.gaierror):
62
+ return "dns", f"host not found: {host or 'unknown'}"
63
+ if isinstance(e, (ConnectionRefusedError, OSError)) and getattr(e, "errno", None) == getattr(socket, "ECONNREFUSED", 111):
64
+ return "refused", f"connection refused on {endpoint} — is the service running?"
65
+ if isinstance(e, ssl.SSLError):
66
+ return "ssl", "TLS handshake failed"
67
+ return "other", str(e) or type(e).__name__
68
+
69
+
70
+ async def _check_http(target: Target, timeout: float) -> dict[str, Any]:
71
+ url = target.url or ""
72
+ start = time.perf_counter()
73
+ try:
74
+ async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=1.0, read=timeout, write=timeout, pool=timeout), follow_redirects=True) as client:
75
+ resp = await client.get(url)
76
+ latency_ms = round((time.perf_counter() - start) * 1000, 1)
77
+ ok = resp.status_code < 400
78
+ category: str | None = None
79
+ error: str | None = None
80
+ if not ok:
81
+ category = "http"
82
+ error = f"HTTP {resp.status_code}"
83
+ return {
84
+ "name": target.name,
85
+ "kind": "http",
86
+ "url": url,
87
+ "ok": ok,
88
+ "status": resp.status_code,
89
+ "latencyMs": latency_ms,
90
+ "errorCategory": category,
91
+ "error": error,
92
+ }
93
+ except Exception as e: # noqa: BLE001 - classify connectivity errors
94
+ latency_ms = round((time.perf_counter() - start) * 1000, 1)
95
+ category, message = _classify_error(e, urlparse(url).hostname or "", urlparse(url).port)
96
+ return {
97
+ "name": target.name,
98
+ "kind": "http",
99
+ "url": url,
100
+ "ok": False,
101
+ "status": None,
102
+ "latencyMs": latency_ms,
103
+ "errorCategory": category,
104
+ "error": message,
105
+ }
106
+
107
+
108
+ async def _check_tcp(target: Target, timeout: float) -> dict[str, Any]:
109
+ host = target.host or ""
110
+ port = target.port or 0
111
+ start = time.perf_counter()
112
+ try:
113
+ reader, writer = await asyncio.wait_for(
114
+ asyncio.open_connection(host, port, limit=256),
115
+ timeout,
116
+ )
117
+ latency_ms = round((time.perf_counter() - start) * 1000, 1)
118
+ writer.close()
119
+ try:
120
+ await writer.wait_closed()
121
+ except Exception: # noqa: BLE001
122
+ pass
123
+ return {
124
+ "name": target.name,
125
+ "kind": "tcp",
126
+ "host": host,
127
+ "port": port,
128
+ "ok": True,
129
+ "latencyMs": latency_ms,
130
+ "errorCategory": None,
131
+ "error": None,
132
+ }
133
+ except Exception as e: # noqa: BLE001 - classify connectivity errors
134
+ latency_ms = round((time.perf_counter() - start) * 1000, 1)
135
+ category, message = _classify_error(e, host, port)
136
+ return {
137
+ "name": target.name,
138
+ "kind": "tcp",
139
+ "host": host,
140
+ "port": port,
141
+ "ok": False,
142
+ "latencyMs": latency_ms,
143
+ "errorCategory": category,
144
+ "error": message,
145
+ }
146
+
147
+
148
+ @router.post("/preflight")
149
+ async def preflight(req: PreflightRequest):
150
+ if not req.targets:
151
+ raise HTTPException(status_code=400, detail="at least one target is required")
152
+ started = time.perf_counter()
153
+ results = await asyncio.gather(*(_check_http(t, t.timeout or req.timeout) if t.kind == "http" else _check_tcp(t, t.timeout or req.timeout) for t in req.targets))
154
+ duration_ms = round((time.perf_counter() - started) * 1000, 1)
155
+
156
+ failed = [c for c in results if not c["ok"]]
157
+ passed = [c for c in results if c["ok"]]
158
+ # failed then warnings (slow) then the rest, preserving stable order within groups.
159
+ slow = [c for c in passed if isinstance(c.get("latencyMs"), (int, float)) and c["latencyMs"] > 800]
160
+ ok = [c for c in passed if c not in slow]
161
+
162
+ return {
163
+ "ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
164
+ "durationMs": duration_ms,
165
+ "passed": len(passed),
166
+ "warnings": len(slow),
167
+ "failed": len(failed),
168
+ "checks": failed + slow + ok,
169
+ }
@@ -0,0 +1,48 @@
1
+ """Configuration for the AI server (env-driven)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ def _env(key: str, default: str) -> str:
9
+ return os.environ.get(key, default)
10
+
11
+
12
+ @dataclass
13
+ class Settings:
14
+ # OpenAI-compatible upstreams. Both OpenAI and Ollama speak the same shape;
15
+ # only the base URL + auth header differ.
16
+ openai_base_url: str = field(default_factory=lambda: _env("AI_OPENAI_BASE_URL", "https://api.openai.com/v1"))
17
+ # Fall back to the generic OPENAI_API_KEY / OLLAMA_HOST names so project
18
+ # .env keys work with the AI server as well as the AI_* names.
19
+ openai_api_key: str = field(
20
+ default_factory=lambda: _env("AI_OPENAI_API_KEY", _env("OPENAI_API_KEY", ""))
21
+ )
22
+ ollama_base_url: str = field(
23
+ default_factory=lambda: _env(
24
+ "AI_OLLAMA_BASE_URL",
25
+ (_env("OLLAMA_HOST", "http://localhost:11434") + "/v1"),
26
+ )
27
+ )
28
+ ollama_api_key: str = field(default_factory=lambda: _env("AI_OLLAMA_API_KEY", ""))
29
+
30
+ default_provider: str = field(default_factory=lambda: _env("AI_DEFAULT_PROVIDER", "auto"))
31
+ request_timeout_s: float = field(default_factory=lambda: float(_env("AI_TIMEOUT_S", "120")))
32
+ server_port: int = field(default_factory=lambda: int(_env("AI_PORT", "8000")))
33
+
34
+ # CORS: the Node backend proxies, but allow dev tools + the admin to call directly.
35
+ cors_origins: list[str] = field(default_factory=lambda: _env("AI_CORS_ORIGINS", "*").split(","))
36
+
37
+
38
+ settings = Settings()
39
+
40
+
41
+ def resolve_provider(requested: str | None) -> str:
42
+ """Resolve 'auto' to a concrete provider. auto → openai if a key is set, else ollama."""
43
+ provider = (requested or settings.default_provider or "auto").lower()
44
+ if provider == "auto":
45
+ provider = "openai" if settings.openai_api_key else "ollama"
46
+ if provider not in ("openai", "ollama"):
47
+ raise ValueError(f"unknown provider: {provider}")
48
+ return provider
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@bhooai/app-backend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "./src/main.ts",
7
+ "scripts": {
8
+ "dev": "tsx watch src/main.ts",
9
+ "start": "tsx src/main.ts",
10
+ "test": "vitest run"
11
+ },
12
+ "dependencies": {
13
+ "@bhooai/nexus-core": "*",
14
+ "@bhooai/nexus-auth": "*",
15
+ "@bhooai/nexus-telemetry": "*",
16
+ "@bhooai/nexus-data": "*",
17
+ "@bhooai/nexus-realtime": "*",
18
+ "@bhooai/nexus-graphql": "*",
19
+ "@bhooai/nexus-cache": "*",
20
+ "@bhooai/nexus-email": "*",
21
+ "@bhooai/nexus-payments": "*",
22
+ "@bhooai/nexus-crypto": "*",
23
+ "@bhooai/nexus-ads": "*",
24
+ "@bhooai/nexus-plugins": "*",
25
+ "@bhooai/nexus-ai-client": "*"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.5.0",
29
+ "tsx": "^4.19.0",
30
+ "typescript": "^5.6.2",
31
+ "vitest": "^2.1.1"
32
+ }
33
+ }