@bhooai/nexus-cli 0.1.4 → 2.0.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 (112) hide show
  1. package/package.json +8 -12
  2. package/src/commands/add.ts +274 -0
  3. package/src/commands/build.ts +91 -0
  4. package/src/commands/db.ts +241 -0
  5. package/src/commands/dev.ts +149 -186
  6. package/src/commands/doctor.ts +102 -184
  7. package/src/commands/init.ts +204 -958
  8. package/src/commands/maintenance-commands.ts +40 -0
  9. package/src/commands/plugin.ts +233 -0
  10. package/src/commands/queue.ts +145 -0
  11. package/src/commands/test.ts +34 -0
  12. package/src/dispatcher.ts +98 -0
  13. package/src/generators.ts +102 -0
  14. package/src/index.ts +32 -90
  15. package/src/make/generators.ts +431 -0
  16. package/src/ports.ts +85 -0
  17. package/src/prompts.ts +68 -0
  18. package/src/templating/render.ts +109 -0
  19. package/src/util.ts +74 -112
  20. package/templates/base/Dockerfile.ejs +68 -0
  21. package/templates/base/README.md.ejs +62 -0
  22. package/templates/base/apps/admin/index.html.ejs +12 -0
  23. package/templates/base/apps/admin/nginx.conf.ejs +16 -0
  24. package/templates/base/apps/admin/package.json.ejs +15 -0
  25. package/templates/base/apps/admin/src/main.tsx.ejs +7 -0
  26. package/templates/base/apps/admin/vite.config.ts.ejs +22 -0
  27. package/templates/base/apps/ai-server/main.py.ejs +30 -0
  28. package/templates/base/apps/ai-server/requirements.txt +4 -0
  29. package/templates/base/apps/backend/src/main.ts +14 -0
  30. package/templates/base/apps/backend/src/routes/index.ts.ejs +11 -0
  31. package/templates/base/apps/frontend/index.html.ejs +12 -0
  32. package/templates/base/apps/frontend/nginx.conf.ejs +33 -0
  33. package/templates/base/apps/frontend/package.json.ejs +11 -0
  34. package/templates/base/apps/frontend/src/main.tsx.ejs +16 -0
  35. package/templates/base/apps/frontend/vite.config.ts.ejs +22 -0
  36. package/templates/base/docker-compose.yml.ejs +93 -0
  37. package/templates/base/dockerignore +21 -0
  38. package/templates/base/gitignore +43 -0
  39. package/templates/base/nexus.config.ts.ejs +97 -0
  40. package/templates/base/package.json.ejs +40 -0
  41. package/templates/{tsconfig.json → base/tsconfig.json} +2 -3
  42. package/tsconfig.json +5 -4
  43. package/PLAN.md +0 -141
  44. package/README.md +0 -34
  45. package/src/commands/cluster.ts +0 -133
  46. package/src/commands/node.ts +0 -106
  47. package/src/commands/pysetup.ts +0 -142
  48. package/src/commands/sync.ts +0 -116
  49. package/src/commands/uninstall.ts +0 -287
  50. package/src/config-sync.ts +0 -384
  51. package/src/dotenv.ts +0 -39
  52. package/src/supervisor.ts +0 -394
  53. package/src/wizard.ts +0 -149
  54. package/templates/Dockerfile +0 -60
  55. package/templates/README.md +0 -69
  56. package/templates/apps/admin/index.html +0 -12
  57. package/templates/apps/admin/package.json +0 -22
  58. package/templates/apps/admin/postcss.config.js +0 -3
  59. package/templates/apps/admin/src/main.tsx +0 -10
  60. package/templates/apps/admin/src/vite-env.d.ts +0 -18
  61. package/templates/apps/admin/tailwind.config.js +0 -9
  62. package/templates/apps/admin/tsconfig.json +0 -17
  63. package/templates/apps/admin/vite.config.ts +0 -64
  64. package/templates/apps/ai-server/main.py +0 -43
  65. package/templates/apps/ai-server/providers/__init__.py +0 -3
  66. package/templates/apps/ai-server/providers/base.py +0 -111
  67. package/templates/apps/ai-server/requirements.txt +0 -3
  68. package/templates/apps/ai-server/routers/__init__.py +0 -3
  69. package/templates/apps/ai-server/routers/chat.py +0 -47
  70. package/templates/apps/ai-server/routers/embeddings.py +0 -30
  71. package/templates/apps/ai-server/routers/lint.py +0 -167
  72. package/templates/apps/ai-server/routers/models.py +0 -23
  73. package/templates/apps/ai-server/routers/preflight.py +0 -169
  74. package/templates/apps/ai-server/settings.py +0 -48
  75. package/templates/apps/backend/package.json +0 -34
  76. package/templates/apps/backend/src/main.ts +0 -375
  77. package/templates/apps/backend/src/modules/admin/adminRoutes.ts +0 -732
  78. package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +0 -391
  79. package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +0 -161
  80. package/templates/apps/backend/src/modules/admin/lintProxy.ts +0 -89
  81. package/templates/apps/backend/src/modules/admin/preflightProxy.ts +0 -242
  82. package/templates/apps/backend/src/modules/admin/roleCatalog.ts +0 -78
  83. package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +0 -449
  84. package/templates/apps/backend/src/modules/ai/aiProxy.ts +0 -265
  85. package/templates/apps/backend/src/modules/auth/authRoutes.ts +0 -220
  86. package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +0 -100
  87. package/templates/apps/backend/src/modules/payments/paymentStore.ts +0 -172
  88. package/templates/apps/backend/src/modules/requests/requestLog.ts +0 -175
  89. package/templates/apps/backend/src/modules/users/userGraph.ts +0 -83
  90. package/templates/apps/backend/src/modules/users/userModel.ts +0 -88
  91. package/templates/apps/backend/src/plugins/CronScheduler.ts +0 -69
  92. package/templates/apps/backend/src/plugins/loadPlugins.ts +0 -107
  93. package/templates/apps/backend/tsconfig.json +0 -14
  94. package/templates/apps/frontend/index.html +0 -12
  95. package/templates/apps/frontend/package.json +0 -20
  96. package/templates/apps/frontend/postcss.config.js +0 -3
  97. package/templates/apps/frontend/src/index.css +0 -44
  98. package/templates/apps/frontend/src/main.tsx +0 -65
  99. package/templates/apps/frontend/tailwind.config.js +0 -9
  100. package/templates/apps/frontend/vite.config.ts +0 -72
  101. package/templates/bin/nexus.js +0 -35
  102. package/templates/bin/serve-all.mjs +0 -45
  103. package/templates/dockerignore +0 -15
  104. package/templates/gitignore +0 -12
  105. package/templates/nexus.config.ts +0 -69
  106. package/templates/package.json +0 -47
  107. package/tests/cli.test.ts +0 -52
  108. package/tests/config-sync.test.ts +0 -201
  109. package/tests/dotenv.test.ts +0 -51
  110. package/vitest.config.ts +0 -9
  111. package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +0 -13
  112. /package/templates/{uploads → base/apps/backend/storage}/.gitkeep +0 -0
@@ -1,169 +0,0 @@
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
- }
@@ -1,48 +0,0 @@
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
@@ -1,34 +0,0 @@
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
- "@bhooai/nexus-cluster": "*"
27
- },
28
- "devDependencies": {
29
- "@types/node": "^22.5.0",
30
- "tsx": "^4.19.0",
31
- "typescript": "^5.6.2",
32
- "vitest": "^2.1.1"
33
- }
34
- }
@@ -1,375 +0,0 @@
1
- import { dirname, join, resolve } from 'node:path';
2
- import { readFile } from 'node:fs/promises';
3
- import { fileURLToPath } from 'node:url';
4
- import { loadConfig, loadConfigAuto, Router, NexusServer, bodyParser, serveStatic, registerUploadRoutes } from '@bhooai/nexus-core';
5
- import { cors, csrf, securityHeaders, rateLimit, issueCsrfToken, AuthService, MemorySessionStore } from '@bhooai/nexus-auth';
6
- import { Logger, MetricsRegistry } from '@bhooai/nexus-telemetry';
7
- import {
8
- connect,
9
- getConnection,
10
- resolveProjectInfo,
11
- connectProjectInfo,
12
- getProjectInfo,
13
- listProjectInfo,
14
- upsertProjectInfo,
15
- closeProjectInfo,
16
- type ProjectInfo,
17
- } from '@bhooai/nexus-data';
18
- import { RealtimeServer } from '@bhooai/nexus-realtime';
19
- import { createGateway, graphqlHttpHandler, SubscriptionServer, PubSub, type GraphQLContext } from '@bhooai/nexus-graphql';
20
- import { Cache } from '@bhooai/nexus-cache';
21
- import { createEmail, TemplateEngine } from '@bhooai/nexus-email';
22
- import { createPayments } from '@bhooai/nexus-payments';
23
- import { generateKeyPair, createSelfSignedCertificate, createCsr } from '@bhooai/nexus-crypto';
24
- import { ClusterManager } from '@bhooai/nexus-cluster';
25
- import { registerAuthRoutes } from './modules/auth/authRoutes.js';
26
- import { buildUsersSubgraph } from './modules/users/userGraph.js';
27
- import { loadPlugins } from './plugins/loadPlugins.js';
28
- import { registerAiRoutes } from './modules/ai/aiProxy.js';
29
- import { registerAdminRoutes } from './modules/admin/adminRoutes.js';
30
- import { registerPaymentRoutes } from './modules/payments/paymentRoutes.js';
31
- import { recordTransaction } from './modules/payments/paymentStore.js';
32
- import { appendRequestLog, closeRequestLog } from './modules/requests/requestLog.js';
33
-
34
- // Keep project-relative config and runtime directories tied to this app tree,
35
- // not to the directory from which the backend command was launched.
36
- const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
37
-
38
- /**
39
- * BhooAI Nexus backend bootstrap (Phase 5 checkpoint).
40
- *
41
- * Boots an inbuilt HTTP server with the security stack applied:
42
- * security headers → CORS → body parsing → CSRF → rate limiting.
43
- * Connects to MongoDB, registers the User model, mounts /auth/* routes,
44
- * and serves a single-subgraph GraphQL gateway over HTTP (/graphql) and
45
- * WebSocket subscriptions (/graphql/ws). Realtime (/ws) runs alongside.
46
- */
47
- async function main(): Promise<void> {
48
- const config = await loadConfigAuto({ root: PROJECT_ROOT });
49
- const log = new Logger({
50
- level: config.logging.level,
51
- format: config.logging.format,
52
- console: config.logging.console,
53
- redact: ['password', 'secret', 'authorization'],
54
- });
55
-
56
- // Each project owns its own database (named from its package.json). The
57
- // default connection targets that database; a second connection targets the
58
- // shared project-info store (nexus_projects) for name + settings.
59
- const project: ProjectInfo = await resolveProjectInfo(PROJECT_ROOT);
60
- const dbName = config.db.name ?? project.dbName;
61
-
62
- connect(config.db.uri, { name: dbName, autoIndex: config.db.autoIndex });
63
- await getConnection().db;
64
- connectProjectInfo(config.db.uri, { autoIndex: config.db.autoIndex });
65
- const allProjects = await listProjectInfo();
66
- // Prefer the record matched by path so a project renamed via the admin panel
67
- // keeps its identity (and settings) across restarts.
68
- const storedProject = allProjects.find((p) => p.path === PROJECT_ROOT) ?? (await getProjectInfo(project.name).catch(() => null));
69
- const configuredAiProviders = (config.ai.providers ?? []).map((provider) => ({
70
- id: provider.id,
71
- label: provider.label,
72
- baseUrl: provider.baseUrl,
73
- enabled: provider.enabled,
74
- ...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}),
75
- }));
76
- await upsertProjectInfo({
77
- ...project,
78
- dbName,
79
- status: 'running',
80
- startedAt: new Date().toISOString(),
81
- version: storedProject?.version ?? await readPackageVersion(PROJECT_ROOT),
82
- settings: {
83
- ...(storedProject?.settings ?? {}),
84
- env: config.env,
85
- host: config.server.host,
86
- port: config.server.port,
87
- graphqlPath: config.graphql.path,
88
- websocketPath: config.ws.path,
89
- database: dbName,
90
- ...(!storedProject?.settings?.aiProviders && configuredAiProviders.length > 0
91
- ? { aiProviders: configuredAiProviders }
92
- : {}),
93
- },
94
- });
95
- log.info('connected to MongoDB', { project: project.name, db: dbName });
96
-
97
- // One AuthService shared by realtime + GraphQL context (token verification
98
- // is stateless; the session store is only used for login/refresh flows).
99
- const authService = new AuthService(
100
- { secret: config.auth.jwt.secret, algorithm: 'HS256', issuer: config.auth.jwt.issuer, audience: config.auth.jwt.audience, accessTtl: config.auth.jwt.accessTtl, refreshTtl: config.auth.jwt.refreshTtl },
101
- new MemorySessionStore(),
102
- );
103
-
104
- const router = new Router();
105
- const uploadsDir = join(PROJECT_ROOT, config.uploads.dir);
106
-
107
- // Multipart uploads are persisted beneath the project-local /uploads folder.
108
- // Filenames are generated by the framework; the public path is read-only.
109
- registerUploadRoutes(router, {
110
- directory: uploadsDir,
111
- path: config.uploads.path,
112
- publicPath: config.uploads.path,
113
- maxFileSize: config.uploads.maxFileSize,
114
- maxFiles: config.uploads.maxFiles,
115
- allowedTypes: config.uploads.allowedTypes,
116
- });
117
-
118
- // Health check (no CSRF, no rate limit — always available).
119
- router.get('/health', (ctx) => {
120
- ctx.json({ status: 'ok', env: config.env, time: Date.now() });
121
- });
122
-
123
- // CSRF token endpoint — mints a double-submit token cookie + returns it.
124
- router.get('/csrf-token', (ctx) => {
125
- const token = issueCsrfToken(ctx, { trustedOrigins: trustedOrigins(config) });
126
- ctx.json({ token });
127
- });
128
-
129
- // A sample unsafe route to demonstrate CSRF enforcement.
130
- router.post('/echo', (ctx) => {
131
- ctx.json({ received: ctx.body });
132
- });
133
-
134
- // Auth routes (register/login/refresh/logout/me + OAuth). Requires DB.
135
- registerAuthRoutes(router, config, () => pubsub.publish('USER_COUNT', 1));
136
-
137
- // Create a mutable copy of the AI providers array so the admin provider
138
- // management endpoints can mutate API keys / enabled state in-place, and
139
- // the AI proxy sees the updates without restarting. The config is frozen
140
- // (Object.freeze) so we must clone before passing to both routes.
141
- const aiProviders = (config.ai.providers ?? []).map((p) => ({ ...p }));
142
-
143
- // AI proxy — mounts /ai/* OpenAI-compatible routes that re-emit SSE from the
144
- // Python AI server. The browser never calls Python directly.
145
- registerAiRoutes(router, {
146
- serverUrl: config.ai.serverUrl,
147
- timeoutMs: config.ai.timeoutMs,
148
- // Pass the mutable array — same reference the admin endpoints mutate.
149
- providers: aiProviders,
150
- });
151
-
152
- // GraphQL: single-subgraph gateway over the users subgraph. The context
153
- // builder verifies the optional Bearer access token so `me` can resolve.
154
- const usersSubgraph = buildUsersSubgraph();
155
- const gateway = createGateway({ subgraph: usersSubgraph });
156
- // PubSub feeds the GraphQL `userCount` subscription (published on register).
157
- const pubsub = new PubSub();
158
- const graphContext = async (ctx: import('@bhooai/nexus-core').RequestContext): Promise<GraphQLContext> => {
159
- const auth = ctx.headers['authorization'];
160
- const header = Array.isArray(auth) ? auth[0] : auth;
161
- const token = header && header.startsWith('Bearer ') ? header.slice(7) : undefined;
162
- if (token) {
163
- try {
164
- const claims = await authService.verifyAccessToken(token);
165
- return { request: ctx, user: { sub: claims.sub, roles: claims.roles ?? [], sid: claims.sid }, pubsub };
166
- } catch { /* invalid token → anonymous */ }
167
- }
168
- return { request: ctx, pubsub };
169
- };
170
- router.post(config.graphql.path, graphqlHttpHandler({ gateway, introspection: config.graphql.introspection, context: graphContext }));
171
- router.get(config.graphql.path, graphqlHttpHandler({ gateway, introspection: config.graphql.introspection, context: graphContext }));
172
-
173
- // Phase 7 services — cache (lazy Redis), email (log provider in dev), payments
174
- // webhook router (no providers enabled unless keys are configured), and a
175
- // cert/CSR generation endpoint backed by nexus-crypto (hand-rolled DER for CSRs).
176
- const cache = new Cache({ url: config.redis.url, keyPrefix: config.redis.keyPrefix });
177
-
178
- const templates = new TemplateEngine();
179
- templates.register('welcome', '<h1>Welcome, {{name}}!</h1><p>Verify your email to get started.</p>');
180
- const email = createEmail(config.email, { templates });
181
-
182
- const payments = createPayments(config.payments);
183
- const webhookRouter = payments.webhookRouter((ev) => {
184
- log.info('payment webhook event', { provider: ev.provider, event: ev.event, verified: ev.verified });
185
- // Persist every verified webhook event as a transaction (admin dashboard).
186
- if (ev.provider && ev.event) {
187
- recordTransaction(ev.provider, ev).catch((err) => log.warn('failed to record payment transaction', { msg: err.message }));
188
- }
189
- });
190
- if (config.payments.webhookPath) {
191
- router.post(config.payments.webhookPath, webhookRouter.handler);
192
- }
193
- // Phase 11 — browser-facing checkout routes (auth-guarded). Webhooks stay separate.
194
- registerPaymentRoutes(router, config, payments, authService);
195
-
196
- // Generate a self-signed cert + keypair (and optionally a CSR) on demand.
197
- router.post('/certs/self-signed', (ctx) => {
198
- const body = (ctx.body ?? {}) as { commonName?: string; organization?: string; country?: string; csr?: boolean };
199
- const kp = generateKeyPair(config.certs.keyType, {
200
- modulusLength: config.certs.rsaModulus,
201
- namedCurve: config.certs.ecCurve,
202
- });
203
- const cert = createSelfSignedCertificate({
204
- keyPair: { publicKey: kp.publicKey, privateKey: kp.privateKey },
205
- commonName: body.commonName ?? 'localhost',
206
- organization: body.organization,
207
- country: body.country,
208
- });
209
- const out: Record<string, string> = { cert: cert.pem, privateKey: kp.pem.private, publicKey: kp.pem.public };
210
- if (body.csr) {
211
- const csr = createCsr({
212
- keyPair: { publicKey: kp.publicKey, privateKey: kp.privateKey },
213
- commonName: body.commonName ?? 'localhost',
214
- organization: body.organization,
215
- country: body.country,
216
- });
217
- out.csr = csr.pem;
218
- }
219
- ctx.json(out);
220
- });
221
-
222
- const docsDir = join(PROJECT_ROOT, 'docs');
223
-
224
- const server = new NexusServer({
225
- router,
226
- bodyLimit: config.server.bodyLimit,
227
- middleware: [
228
- securityHeaders(),
229
- cors({ origin: true, credentials: true }),
230
- serveStatic(uploadsDir, { prefix: config.uploads.path }),
231
- serveStatic(docsDir, { prefix: '/docs' }),
232
- bodyParser(config.server.bodyLimit),
233
- csrf({ trustedOrigins: trustedOrigins(config) }),
234
- rateLimit({ windowMs: 60_000, max: 300 }),
235
- ],
236
- onError: (err, ctx) => {
237
- log.warn('request error', { code: err.code, path: ctx.path, method: ctx.method, msg: err.message });
238
- return { error: { code: err.code, message: err.message } };
239
- },
240
- });
241
-
242
- // Realtime (WebSocket) server: auth via access token on upgrade, optional
243
- // CSRF origin/double-submit check. Reuses the shared authService.
244
- const realtime = new RealtimeServer({
245
- httpServer: server.httpServer,
246
- path: config.ws.path,
247
- authService,
248
- csrfOptions: config.ws.requireCsrf ? { trustedOrigins: trustedOrigins(config) } : undefined,
249
- });
250
-
251
- // GraphQL subscriptions over WebSocket (graphql-transport-ws), on a path
252
- // under the GraphQL route. Auth via the shared authService.
253
- const subscriptions = new SubscriptionServer({
254
- httpServer: server.httpServer,
255
- gateway,
256
- path: `${config.graphql.path}/ws`,
257
- authService,
258
- context: (_init, user) => ({ user, pubsub }),
259
- });
260
-
261
- // Phase 8 — plugins: build real HostBindings from the running services,
262
- // discover plugins from the configured dir, and run their lifecycle in
263
- // dependency order before the server accepts traffic. Exposes the admin
264
- // extensions (pages/slots contributed by plugins) at /plugins/extensions.
265
- const pluginsDir = join(PROJECT_ROOT, config.plugins.dir);
266
- const configByPlugin: Record<string, unknown> = {};
267
- for (const entry of config.plugins.entries) {
268
- if (entry.enabled) configByPlugin[entry.path] = entry.config ?? {};
269
- }
270
- const plugins = await loadPlugins({
271
- router,
272
- log,
273
- realtime,
274
- pluginsDir,
275
- configByPlugin,
276
- });
277
- router.get('/plugins/extensions', (ctx) => {
278
- ctx.json(plugins.adminExtensions.toJSON());
279
- });
280
-
281
- // Admin endpoints — bearer-token auth + 'admin' role (first registered user
282
- // is bootstrapped admin). Edits write to nexus.runtime.json (gitignored).
283
- const metrics = new MetricsRegistry();
284
- metrics.counter('http_requests_total', 'Total HTTP requests');
285
- const requestLogDir = join(PROJECT_ROOT, config.logging.dir);
286
- // Count every HTTP request so the admin overview "HTTP REQUESTS" stat reflects
287
- // live traffic, and append each completed request (with IP, url, referer etc.)
288
- // to a datewise JSON log file under the project's logging dir.
289
- server.use((ctx, next) => {
290
- metrics.inc('http_requests_total', 1, { method: ctx.method });
291
- const started = Date.now();
292
- ctx.res.on('finish', () => {
293
- const headers = ctx.headers as Record<string, string | string[] | undefined>;
294
- const first = (v: string | string[] | undefined): string | undefined => (Array.isArray(v) ? v[0] : v);
295
- const forwarded = first(headers['x-forwarded-for'])?.split(',')[0]?.trim();
296
- appendRequestLog(requestLogDir, {
297
- time: started,
298
- method: ctx.method,
299
- path: ctx.path,
300
- url: ctx.req.url,
301
- status: ctx.res.statusCode,
302
- durationMs: Date.now() - started,
303
- ip: forwarded || ctx.req.socket.remoteAddress,
304
- referer: first(headers['referer']),
305
- userAgent: first(headers['user-agent']),
306
- origin: first(headers['origin']),
307
- requestId: ctx.requestId,
308
- route: ctx.routePattern,
309
- });
310
- });
311
- return next();
312
- });
313
-
314
- // Cluster manager — auto-starts the LB + autoscaler when config says enabled.
315
- const cluster = new ClusterManager({ config: config.cluster, root: PROJECT_ROOT, aiServerUrl: config.ai.serverUrl, self: { id: 'master', baseUrl: `http://127.0.0.1:${config.server.port}` } });
316
- if (config.cluster.enabled) {
317
- try {
318
- await cluster.listenLb(config.cluster.lbHost);
319
- cluster.autoscaler.start(10_000);
320
- log.info(`cluster LB listening on ${config.cluster.lbHost}:${config.cluster.lbPort} (auto-started)`);
321
- } catch (err) {
322
- log.warn(`cluster failed to auto-start: ${(err as Error).message}`);
323
- }
324
- }
325
-
326
- registerAdminRoutes(router, config, { root: PROJECT_ROOT, project, adminExtensions: plugins.adminExtensions, metrics: () => metrics.toJSON(), payments, cluster, aiProviders });
327
-
328
- await server.listen(config.server.port, config.server.host);
329
- log.info(`Nexus backend listening on http://${config.server.host}:${config.server.port}`, {
330
- graphql: config.graphql.path,
331
- ws: config.ws.path,
332
- });
333
-
334
- const shutdown = async (signal: string) => {
335
- log.info(`received ${signal}, shutting down`);
336
- await plugins.host.stop();
337
- plugins.scheduler.close();
338
- await subscriptions.close();
339
- await realtime.close();
340
- await server.close();
341
- await cluster.close();
342
- await closeProjectInfo();
343
- await getConnection().close();
344
- await cache.close();
345
- closeRequestLog();
346
- process.exit(0);
347
- };
348
- process.on('SIGINT', () => void shutdown('SIGINT'));
349
- process.on('SIGTERM', () => void shutdown('SIGTERM'));
350
- }
351
-
352
- function trustedOrigins(config: Awaited<ReturnType<typeof loadConfig>>): string[] {
353
- const loopback = ['localhost', '127.0.0.1'];
354
- const origins: string[] = [];
355
- for (const host of loopback) {
356
- for (const port of [config.server.port, config.frontend.port, config.admin.port]) {
357
- origins.push(`http://${host}:${port}`);
358
- }
359
- }
360
- return origins;
361
- }
362
-
363
- async function readPackageVersion(root: string): Promise<string | undefined> {
364
- try {
365
- const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version?: string };
366
- return typeof pkg.version === 'string' ? pkg.version : undefined;
367
- } catch {
368
- return undefined;
369
- }
370
- }
371
-
372
- main().catch((err) => {
373
- console.error(err);
374
- process.exit(1);
375
- });