@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.
- package/PLAN.md +141 -0
- package/README.md +34 -0
- package/package.json +25 -0
- package/src/commands/cluster.ts +133 -0
- package/src/commands/dev.ts +133 -0
- package/src/commands/doctor.ts +199 -0
- package/src/commands/init.ts +960 -0
- package/src/commands/node.ts +101 -0
- package/src/commands/pysetup.ts +136 -0
- package/src/commands/sync.ts +116 -0
- package/src/commands/uninstall.ts +287 -0
- package/src/config-sync.ts +384 -0
- package/src/dotenv.ts +39 -0
- package/src/index.ts +94 -0
- package/src/supervisor.ts +384 -0
- package/src/util.ts +123 -0
- package/src/wizard.ts +149 -0
- package/templates/Dockerfile +60 -0
- package/templates/README.md +69 -0
- package/templates/apps/admin/index.html +12 -0
- package/templates/apps/admin/package.json +24 -0
- package/templates/apps/admin/postcss.config.js +6 -0
- package/templates/apps/admin/src/main.tsx +10 -0
- package/templates/apps/admin/src/vite-env.d.ts +18 -0
- package/templates/apps/admin/tailwind.config.js +9 -0
- package/templates/apps/admin/tsconfig.json +17 -0
- package/templates/apps/admin/vite.config.ts +64 -0
- package/templates/apps/ai-server/main.py +43 -0
- package/templates/apps/ai-server/providers/__init__.py +3 -0
- package/templates/apps/ai-server/providers/base.py +111 -0
- package/templates/apps/ai-server/requirements.txt +3 -0
- package/templates/apps/ai-server/routers/__init__.py +3 -0
- package/templates/apps/ai-server/routers/chat.py +47 -0
- package/templates/apps/ai-server/routers/embeddings.py +30 -0
- package/templates/apps/ai-server/routers/lint.py +167 -0
- package/templates/apps/ai-server/routers/models.py +23 -0
- package/templates/apps/ai-server/routers/preflight.py +169 -0
- package/templates/apps/ai-server/settings.py +48 -0
- package/templates/apps/backend/package.json +33 -0
- package/templates/apps/backend/src/main.ts +375 -0
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
- package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
- package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
- package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
- package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
- package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
- package/templates/apps/backend/tsconfig.json +14 -0
- package/templates/apps/frontend/index.html +12 -0
- package/templates/apps/frontend/package.json +19 -0
- package/templates/apps/frontend/src/main.tsx +64 -0
- package/templates/apps/frontend/vite.config.ts +63 -0
- package/templates/bin/nexus.js +35 -0
- package/templates/bin/serve-all.mjs +45 -0
- package/templates/dockerignore +15 -0
- package/templates/gitignore +12 -0
- package/templates/nexus.config.ts +69 -0
- package/templates/package.json +47 -0
- package/templates/tsconfig.json +17 -0
- package/templates/uploads/.gitkeep +0 -0
- package/tests/cli.test.ts +45 -0
- package/tests/config-sync.test.ts +201 -0
- package/tests/dotenv.test.ts +51 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
package/PLAN.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Plan: `nexus init` — all-in-one setup wizard
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
Turn `nexus init` into a single interactive command that, when it finishes, leaves a
|
|
5
|
+
fully bootable stack: Node backend, frontend, admin, Python AI server, Mongo + Redis
|
|
6
|
+
wired, JWT secret, AI provider keys, and the cluster token. After running it once,
|
|
7
|
+
`npm run dev` starts everything.
|
|
8
|
+
|
|
9
|
+
## Current state (source of truth)
|
|
10
|
+
|
|
11
|
+
`nexus init` (`packages/nexus-cli/src/commands/init.ts`) today:
|
|
12
|
+
1. Scaffolds templates (`listFiles(TEMPLATES)` -> write each)
|
|
13
|
+
2. `wireFrameworkDependency()` — workspace paths + `file:` dep + Vite/Tailwind patching
|
|
14
|
+
3. `allocateProjectPorts()` — auto-pick free ports for server/frontend/admin/ai/lb/agent
|
|
15
|
+
4. `ensureJwtSecret()` — generate `NEXUS_AUTH_JWT_SECRET`, add empty AI/payment env keys
|
|
16
|
+
5. `chooseKind()` — interactive root/node prompt (the only wizard step today)
|
|
17
|
+
6. `applyClusterConfig()` — write cluster section into `nexus.config.ts`
|
|
18
|
+
7. `installDependencies()` — `npm install`
|
|
19
|
+
8. `printSetup()` — onboarding text
|
|
20
|
+
|
|
21
|
+
Gaps that prevent "everything at once":
|
|
22
|
+
- Python deps never installed (separate `nexus pysetup` command, forgotten by users)
|
|
23
|
+
- No Mongo/Redis reachability check before scaffolding -> silent failures at `npm run dev`
|
|
24
|
+
- No way to customize DB URI / Redis URL / AI provider keys during init (empty placeholders only)
|
|
25
|
+
- No final verification that the scaffolded project actually loads
|
|
26
|
+
- `.env` doesn't get `MONGODB_URI`/`REDIS_URL` (Python AI server can't connect)
|
|
27
|
+
|
|
28
|
+
`pysetup.ts` is non-interactive (one-shot pip install). `doctor.ts` has reusable check
|
|
29
|
+
logic (`tcpReachable`, `versionOf`, `parseHostPort`) but it's a monolithic `doctor()`
|
|
30
|
+
function, not exported helpers.
|
|
31
|
+
|
|
32
|
+
## Wizard flow (interactive when stdin is a TTY; flag-driven otherwise)
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
nexus init [target] [--as=root|node] [--no-interactive] [--no-venv] [--no-install] [--skip-mongo-check]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| # | Step | Interactive prompt | Non-interactive fallback |
|
|
39
|
+
|---|------|--------------------|--------------------------|
|
|
40
|
+
| 0 | Banner — "BhooAI Nexus — project setup wizard" + what it'll do | shown | shown |
|
|
41
|
+
| 1 | Prerequisite scan — detect node, npm, python, git, Mongo TCP, Redis TCP. Blocks on missing node/python (critical); warns on missing Mongo/Redis (asks to continue) | shown, "Continue anyway?" if Mongo/Redis down | `--skip-mongo-check` skips; missing node/python -> exit 1 |
|
|
42
|
+
| 2 | Project name -> `package.json` name + Mongo db name | prompt, default = dir basename | `--name <x>` or dir basename |
|
|
43
|
+
| 3 | Server kind root/node (existing `chooseKind`) | prompt | `--as=root\|node` |
|
|
44
|
+
| 4 | Cluster role + port (if node) | prompt role, default `backend` | `--role` `--port` |
|
|
45
|
+
| 5 | Ports — auto-allocate (existing) OR custom | "Auto-allocate free ports? Y/n" | auto (existing) |
|
|
46
|
+
| 6 | Mongo URI — confirm or edit, DB name from step 2 | prompt, default `mongodb://localhost:27017/<name>` | `--mongo-uri` |
|
|
47
|
+
| 7 | Redis URL — confirm or edit | prompt, default `redis://localhost:6379` | `--redis-url` |
|
|
48
|
+
| 8 | AI providers — multi-select (openai/ollama/anthropic/google/groq/...), then enter API key for each selected (written to `.env`). Ollama/lmstudio need no key. | multi-select + per-key prompt | `--ai-providers openai,ollama` (keys via env or `--ai-key <id>=<val>`) |
|
|
49
|
+
| 9 | Python venv — "Create a virtualenv for the AI server? Y/n" | prompt | `--no-venv` skips; `--venv` forces |
|
|
50
|
+
| 10 | Review summary — table of all chosen values; "Proceed? Y/n" | shown | proceeds |
|
|
51
|
+
| 11 | Scaffold templates (existing) | — | — |
|
|
52
|
+
| 12 | Wire framework (existing) | — | — |
|
|
53
|
+
| 13 | Allocate ports (existing) | — | — |
|
|
54
|
+
| 14 | Write config — patch `nexus.config.ts` with chosen Mongo URI / Redis URL / AI serverUrl + `applyClusterConfig` (existing) | — | — |
|
|
55
|
+
| 15 | Generate `.env` — JWT secret (existing) + `MONGODB_URI` + `REDIS_URL` + AI provider keys from step 8 + cluster token + payment placeholders (existing) | — | — |
|
|
56
|
+
| 16 | npm install (existing) | — | — |
|
|
57
|
+
| 17 | Python setup — invoke `pysetup(['--venv'\|--no-venv])` -> creates `apps/ai-server/.venv` + `pip install -r requirements.txt` | — | — |
|
|
58
|
+
| 18 | Verify — run an in-process mini-doctor on the new project: load `nexus.config.ts`, TCP-check Mongo/Redis, check backend/frontend/admin/ai ports free, sign+verify a JWT. Print pass/fail table. | — | — |
|
|
59
|
+
| 19 | Next steps — print URLs (backend `:port/health`, frontend, admin, ai `127.0.0.1:port`), pairing token, and `npm run dev` | shown | shown |
|
|
60
|
+
|
|
61
|
+
## Files to change (8)
|
|
62
|
+
|
|
63
|
+
### Framework — CLI
|
|
64
|
+
|
|
65
|
+
1. **`packages/nexus-cli/src/wizard.ts`** (NEW) — shared wizard primitives used by `init`
|
|
66
|
+
and `pysetup`:
|
|
67
|
+
- `isInteractive()` — `process.stdin.isTTY && !process.env.CI`
|
|
68
|
+
- `prompt(q, default?)`, `confirm(q, default?)`, `select(q, options)`, `multiSelect(q, options)`
|
|
69
|
+
- `banner(title, lines)`, `summaryTable(rows)`, `statusIcon(ok)`
|
|
70
|
+
- Built on `node:readline/promises` (already used by `chooseKind`)
|
|
71
|
+
- No new deps
|
|
72
|
+
|
|
73
|
+
2. **`packages/nexus-cli/src/commands/init.ts`** (rewrite) — orchestrate the 20-step flow
|
|
74
|
+
above. Keep existing helpers (`wireFrameworkDependency`, `allocateProjectPorts`,
|
|
75
|
+
`ensureJwtSecret`, `applyClusterConfig`, `installDependencies`, `printSetup`); insert
|
|
76
|
+
prerequisite scan, prompts (steps 2/6/7/8/9), review, pysetup call, verify. Extract
|
|
77
|
+
`ensureJwtSecret`'s env-key writing into a reusable `writeEnv(target, entries)` so the
|
|
78
|
+
new `.env` step is one call. Add flags: `--name`, `--mongo-uri`, `--redis-url`,
|
|
79
|
+
`--ai-providers`, `--ai-key`, `--venv`/`--no-venv`, `--no-interactive`,
|
|
80
|
+
`--skip-mongo-check`.
|
|
81
|
+
|
|
82
|
+
3. **`packages/nexus-cli/src/commands/pysetup.ts`** (extend) — accept an `--interactive`
|
|
83
|
+
flag that, when set and TTY, prompts for venv y/n + python path before installing.
|
|
84
|
+
Expose `pysetup(args)` so `init` can call it programmatically (already exported). No
|
|
85
|
+
behavior change for direct `nexus pysetup` invocation unless `--interactive` passed.
|
|
86
|
+
|
|
87
|
+
4. **`packages/nexus-cli/src/commands/doctor.ts`** (refactor) — extract the reusable
|
|
88
|
+
prerequisite scan into exported helpers in `util.ts` so `init` step 1 reuses them:
|
|
89
|
+
`scanRuntimes()` (node/npm/python/git versions), `scanServices(cfg)` (Mongo/Redis/AI
|
|
90
|
+
TCP + port-free checks). `doctor()` becomes a thin renderer over these. No output change.
|
|
91
|
+
|
|
92
|
+
5. **`packages/nexus-cli/src/util.ts`** — add `scanRuntimes()`, `scanServices(cfg)`,
|
|
93
|
+
`tcpReachable` (exists), `versionOf` (exists). Move the runtime + service check logic
|
|
94
|
+
out of `doctor.ts` so both `doctor` and `init` share one implementation.
|
|
95
|
+
|
|
96
|
+
6. **`packages/nexus-cli/src/index.ts`** — update `init` help line to list new flags; no
|
|
97
|
+
dispatch change (init already wired at `index.ts:20`).
|
|
98
|
+
|
|
99
|
+
### Framework — templates
|
|
100
|
+
|
|
101
|
+
7. **`packages/nexus-cli/templates/package.json`** — add `"pysetup": "nexus pysetup"`
|
|
102
|
+
script so users can re-run it easily. (Optional; the wizard runs it automatically.)
|
|
103
|
+
|
|
104
|
+
8. **`packages/nexus-cli/templates/nexus.config.ts`** — no structural change, but the
|
|
105
|
+
wizard patches it at step 14 with the chosen Mongo URI / Redis URL / AI serverUrl.
|
|
106
|
+
(Patch logic lives in `init.ts`, not the template.)
|
|
107
|
+
|
|
108
|
+
## Security / correctness invariants
|
|
109
|
+
|
|
110
|
+
- **Python AI server binds `127.0.0.1`** — wizard writes `AI_HOST=127.0.0.1` into `.env`
|
|
111
|
+
and the template `settings.py` reads it (pairs with the `/py-server` plan; if that plan
|
|
112
|
+
hasn't run yet, the wizard still sets the env so a future `settings.py` picks it up).
|
|
113
|
+
- **`.env` is the single secrets sink** — JWT secret, Mongo URI, Redis URL, AI keys,
|
|
114
|
+
cluster token all land there. `nexus.config.ts` stays clean (no secrets), matching the
|
|
115
|
+
existing precedence comment.
|
|
116
|
+
- **No external exposure of Python** — only Node binds `0.0.0.0`; AI server stays
|
|
117
|
+
loopback. Wizard's verify step asserts `ai.serverUrl` host is `127.0.0.1` or `localhost`.
|
|
118
|
+
- **Idempotent** — re-running `nexus init .` in an existing project updates config +
|
|
119
|
+
`.env` without clobbering secrets (existing `ensureJwtSecret` pattern preserved).
|
|
120
|
+
|
|
121
|
+
## Verification (after execution)
|
|
122
|
+
|
|
123
|
+
1. `nexus init test-app` (interactive) -> walk through prompts -> all 20 steps complete.
|
|
124
|
+
2. `cd test-app && npm run doctor` -> all green.
|
|
125
|
+
3. `cd test-app && npm run dev` -> backend, frontend, admin, AI server all start;
|
|
126
|
+
`http://localhost:<port>/health` returns ok.
|
|
127
|
+
4. `nexus init test-app --no-interactive --name ci-app --mongo-uri mongodb://localhost:27017/ci-app --ai-providers ollama --venv`
|
|
128
|
+
-> fully non-interactive, exit 0.
|
|
129
|
+
5. Re-run `nexus init .` inside `test-app` -> secrets preserved, config updated.
|
|
130
|
+
6. Existing `cli.test.ts` (`run('init', [dir, '--skip-install'])`) still exits 0 and
|
|
131
|
+
scaffolds the four-terminal tree (non-interactive mode, no pysetup).
|
|
132
|
+
|
|
133
|
+
## Decisions (confirmed)
|
|
134
|
+
|
|
135
|
+
- Wizard interactivity auto-detected via TTY + `!CI`; `--no-interactive` forces flags-only.
|
|
136
|
+
- Mongo/Redis down at init -> warn + "Continue anyway?" (default yes). Hard requirement
|
|
137
|
+
only for node/python.
|
|
138
|
+
- AI provider keys typed inline during the wizard (hidden echo). Left blank = fill later.
|
|
139
|
+
- Python venv default **yes** (deps don't pollute global site-packages).
|
|
140
|
+
- `--skip-install` skips BOTH npm install and pysetup (so tests/CI don't hang on pip).
|
|
141
|
+
- Plan saved here at `packages/nexus-cli/PLAN.md` (matches repo `PLAN.md` convention).
|
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @bhooai/nexus-cli
|
|
2
|
+
|
|
3
|
+
The `nexus` CLI: `init`, `dev`, `doctor`, and a multi-process supervisor.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
| command | what it does |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `init [target] [--force]` | scaffold a new Nexus project from `templates/` |
|
|
10
|
+
| `dev [--only a,b]` | start backend + frontend + AI + admin under the supervisor |
|
|
11
|
+
| `doctor` | verify node/python versions and mongo/redis reachability (advisory) |
|
|
12
|
+
| `build` / `test` / `plugin` / `add` | reserved/forwarded to the workspace tools |
|
|
13
|
+
|
|
14
|
+
## Templates
|
|
15
|
+
|
|
16
|
+
`templates/` holds the real project files copied by `init` — a runnable backend
|
|
17
|
+
(boots `NexusServer` with the security stack + `/health`, `/csrf-token`, `/echo`),
|
|
18
|
+
a React+Vite frontend, a Python FastAPI AI server, an admin shell, and the CLI
|
|
19
|
+
`bin/nexus.js` shim. `init` is idempotent (skips existing files unless `--force`).
|
|
20
|
+
|
|
21
|
+
## Supervisor
|
|
22
|
+
|
|
23
|
+
`Supervisor` owns the four child processes, streams prefixed logs, handles
|
|
24
|
+
graceful Ctrl-C, and exposes a localhost HTTP control API (start/stop/restart/
|
|
25
|
+
status/logs-tail) that the admin app consumes — decoupling admin from
|
|
26
|
+
OS-specific process management.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
node bin/nexus.js init ./my-app
|
|
32
|
+
node bin/nexus.js doctor
|
|
33
|
+
node bin/nexus.js dev
|
|
34
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"nexus": "./bin/nexus.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"test": "vitest run"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@bhooai/nexus-cluster": "^0.1.0",
|
|
17
|
+
"@bhooai/nexus-core": "^0.1.0",
|
|
18
|
+
"@bhooai/nexus-data": "^0.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.5.0",
|
|
22
|
+
"typescript": "^5.6.2",
|
|
23
|
+
"vitest": "^2.1.1"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { loadConfigAuto } from '../../../nexus-core/src/index.js';
|
|
4
|
+
import { ClusterManager } from '@bhooai/nexus-cluster';
|
|
5
|
+
import type { DeepPartial, NexusConfig } from '@bhooai/nexus-core';
|
|
6
|
+
|
|
7
|
+
const GREEN = '\x1b[32m';
|
|
8
|
+
const RED = '\x1b[31m';
|
|
9
|
+
const YELLOW = '\x1b[33m';
|
|
10
|
+
const CYAN = '\x1b[36m';
|
|
11
|
+
const DIM = '\x1b[2m';
|
|
12
|
+
const RESET = '\x1b[0m';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `nexus cluster <subcommand>` - central-side mesh orchestration.
|
|
16
|
+
*
|
|
17
|
+
* cluster status list linked nodes + LB RPS
|
|
18
|
+
* cluster link <url> handshake + register a node by its agent URL
|
|
19
|
+
* cluster unlink <id> forget a node
|
|
20
|
+
* cluster start|stop|restart|kill <id>
|
|
21
|
+
* cluster scale <n> target exactly n backend nodes
|
|
22
|
+
* cluster serve hold process: LB + autoscaler (foreground)
|
|
23
|
+
* cluster check poll every node once (health + metrics)
|
|
24
|
+
*/
|
|
25
|
+
export async function cluster(args: string[]): Promise<number> {
|
|
26
|
+
const cfg = await loadConfigAuto({ root: process.cwd() });
|
|
27
|
+
const [sub, a, b] = args;
|
|
28
|
+
|
|
29
|
+
// `serve` is the explicit "start the cluster" command - if a prior admin
|
|
30
|
+
// stop wrote {cluster:{enabled:false}} into nexus.runtime.json, that
|
|
31
|
+
// override wins over nexus.config.ts and would silently keep the cluster
|
|
32
|
+
// off. Re-enable it in runtime.json so the LB actually starts.
|
|
33
|
+
if (sub === 'serve' && !cfg.cluster.enabled) {
|
|
34
|
+
enableClusterInRuntime(process.cwd());
|
|
35
|
+
console.log(`${YELLOW}cluster was disabled in nexus.runtime.json - re-enabling${RESET}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// For non-serve commands, warn (don't block) when the cluster is off.
|
|
39
|
+
if (sub !== 'serve' && !cfg.cluster.enabled) {
|
|
40
|
+
console.warn(`${RED}cluster.enabled is false${RESET} - run ${CYAN}nexus cluster serve${RESET} to start it, or set cluster: { enabled: true } in nexus.config.ts`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const manager = new ClusterManager({ config: cfg.cluster, root: process.cwd(), aiServerUrl: cfg.ai.serverUrl });
|
|
44
|
+
|
|
45
|
+
switch (sub ?? 'help') {
|
|
46
|
+
case 'status': {
|
|
47
|
+
const nodes = manager.list();
|
|
48
|
+
const rps = manager.rps();
|
|
49
|
+
console.log(`${CYAN}cluster${RESET} - ${nodes.length} linked node(s)`);
|
|
50
|
+
for (const n of nodes) {
|
|
51
|
+
const color = n.status === 'ready' ? GREEN : n.status === 'unreachable' ? RED : CYAN;
|
|
52
|
+
console.log(` ${color}${n.status}${RESET} ${DIM}${n.identity.id}${RESET} role=${n.identity.role} tier=${n.identity.tier} enabled=${n.enabled} rps=${rps[n.identity.id] ?? 0}${n.lastMetrics ? ` cpu=${n.lastMetrics.cpu}% mem=${n.lastMetrics.memoryMb}MiB` : ''}`);
|
|
53
|
+
}
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
case 'link': {
|
|
57
|
+
if (!a) { console.error('cluster link <agent-url>'); return 1; }
|
|
58
|
+
try {
|
|
59
|
+
const node = await manager.link(a);
|
|
60
|
+
const color = node.status === 'ready' ? GREEN : CYAN;
|
|
61
|
+
console.log(`${color}linked${RESET} ${node.identity.id} role=${node.identity.role} base=${node.identity.baseUrl}`);
|
|
62
|
+
return 0;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error(`${RED}link failed${RESET}: ${(err as Error).message}`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
case 'unlink': {
|
|
69
|
+
if (!a) { console.error('cluster unlink <id>'); return 1; }
|
|
70
|
+
return manager.unlink(a) ? (console.log(`removed ${a}`), 0) : (console.error(`no such node: ${a}`), 1);
|
|
71
|
+
}
|
|
72
|
+
case 'start':
|
|
73
|
+
case 'stop':
|
|
74
|
+
case 'restart':
|
|
75
|
+
case 'kill': {
|
|
76
|
+
if (!a) { console.error(`cluster ${sub} <id>`); return 1; }
|
|
77
|
+
await manager.exec(a, sub === 'kill' ? 'stop' : sub);
|
|
78
|
+
console.log(`${sub} ${a}`);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
case 'scale': {
|
|
82
|
+
if (!a || !Number.isFinite(Number(a))) { console.error('cluster scale <n>'); return 1; }
|
|
83
|
+
try {
|
|
84
|
+
await manager.scaleTo(Number(a));
|
|
85
|
+
console.log(`scaled to ${a} backend node(s)`);
|
|
86
|
+
return 0;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error(`${RED}${(err as Error).message}${RESET}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
case 'check': {
|
|
93
|
+
await manager.pollAll();
|
|
94
|
+
console.log(`checked ${manager.list().length} node(s)`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
case 'serve': {
|
|
98
|
+
console.log(`${CYAN}cluster${RESET} LB on ${cfg.cluster.lbHost}:${cfg.cluster.lbPort}${RESET}`);
|
|
99
|
+
await manager.listenLb(cfg.cluster.lbHost);
|
|
100
|
+
manager.autoscaler.start(10_000);
|
|
101
|
+
setInterval(() => void manager.pollAll().catch(() => {}), 15_000);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
case 'help':
|
|
105
|
+
default:
|
|
106
|
+
console.log(`Usage: nexus cluster <status|link|unlink|start|stop|restart|kill|scale|check|serve>`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Patch `nexus.runtime.json` to set `cluster.enabled = true`. The admin
|
|
113
|
+
* "stop cluster" endpoint writes `{cluster:{enabled:false}}` there, and that
|
|
114
|
+
* override beats `nexus.config.ts` in the loader precedence - so `nexus
|
|
115
|
+
* cluster serve` would read `enabled:false` and the LB would never bind.
|
|
116
|
+
* This removes the stale override so `serve` actually starts the cluster.
|
|
117
|
+
*/
|
|
118
|
+
function enableClusterInRuntime(root: string): void {
|
|
119
|
+
const runtimePath = resolve(root, 'nexus.runtime.json');
|
|
120
|
+
let runtime: Record<string, unknown> = {};
|
|
121
|
+
if (existsSync(runtimePath)) {
|
|
122
|
+
try {
|
|
123
|
+
runtime = JSON.parse(readFileSync(runtimePath, 'utf8')) as Record<string, unknown>;
|
|
124
|
+
} catch {
|
|
125
|
+
// corrupt runtime file - start fresh
|
|
126
|
+
runtime = {};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const cluster = (runtime['cluster'] ?? {}) as Record<string, unknown>;
|
|
130
|
+
cluster['enabled'] = true;
|
|
131
|
+
runtime['cluster'] = cluster;
|
|
132
|
+
writeFileSync(runtimePath, `${JSON.stringify(runtime, null, 2)}\n`);
|
|
133
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { ServiceSpec, Supervisor } from '../supervisor.js';
|
|
2
|
+
import { loadConfigAuto } from '../../../nexus-core/src/index.js';
|
|
3
|
+
import { configChangedSinceSync, syncConfig } from '../config-sync.js';
|
|
4
|
+
|
|
5
|
+
const COLORS = {
|
|
6
|
+
backend: '\x1b[32m',
|
|
7
|
+
frontend: '\x1b[36m',
|
|
8
|
+
'ai-server': '\x1b[33m',
|
|
9
|
+
admin: '\x1b[35m',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const BOLD = '\x1b[1m';
|
|
13
|
+
const DIM = '\x1b[2m';
|
|
14
|
+
const RESET = '\x1b[0m';
|
|
15
|
+
|
|
16
|
+
/** `nexus dev` - start the four terminals under the supervisor. */
|
|
17
|
+
export async function dev(args: string[] = []): Promise<number> { const cfg = await loadConfigAuto({ root: process.cwd() });
|
|
18
|
+
const onlyIdx = args.indexOf('--only');
|
|
19
|
+
const only = onlyIdx >= 0 ? args[onlyIdx + 1]?.split(',') : undefined;
|
|
20
|
+
const cpIdx = args.indexOf('--control-port');
|
|
21
|
+
const controlPort = cpIdx >= 0 && Number.isFinite(Number(args[cpIdx + 1])) ? Number(args[cpIdx + 1]) : 7474;
|
|
22
|
+
|
|
23
|
+
// If nexus.config.* changed since the last sync, rewrite every derived
|
|
24
|
+
// artifact (Dockerfile, docker.*, serve-all.mjs, admin pkg, project DB)
|
|
25
|
+
// before booting so the whole stack runs on the new values.
|
|
26
|
+
if (!args.includes('--no-sync')) {
|
|
27
|
+
if (configChangedSinceSync(process.cwd(), cfg)) {
|
|
28
|
+
process.stdout.write(`\n ${BOLD}Config changed - re-syncing derived artifacts...${RESET}\n`);
|
|
29
|
+
const report = await syncConfig(process.cwd(), cfg);
|
|
30
|
+
const changed = report.files.filter((f) => f.status === 'updated').map((f) => f.file);
|
|
31
|
+
process.stdout.write(
|
|
32
|
+
` ${DIM}updated: ${changed.length > 0 ? changed.join(', ') : 'none'} | db: ${report.db}${RESET}\n`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const all: ServiceSpec[] = [
|
|
38
|
+
{
|
|
39
|
+
name: 'backend',
|
|
40
|
+
command: ['tsx', 'watch', 'apps/backend/src/main.ts'],
|
|
41
|
+
cwd: '',
|
|
42
|
+
color: COLORS.backend,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'frontend',
|
|
46
|
+
command: ['vite', '--port', String(cfg.frontend.port), '--host', cfg.frontend.host],
|
|
47
|
+
cwd: 'apps/frontend',
|
|
48
|
+
color: COLORS.frontend,
|
|
49
|
+
optional: true,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'ai-server',
|
|
53
|
+
command: ['python', 'main.py'],
|
|
54
|
+
cwd: 'apps/ai-server',
|
|
55
|
+
color: COLORS['ai-server'],
|
|
56
|
+
optional: true,
|
|
57
|
+
env: { AI_PORT: aiPort(cfg.ai.serverUrl) },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'admin',
|
|
61
|
+
command: ['vite', '--port', String(cfg.admin.port), '--host', cfg.admin.host],
|
|
62
|
+
cwd: 'apps/admin',
|
|
63
|
+
color: COLORS.admin,
|
|
64
|
+
optional: true,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Drop services the config disables (unless an explicit --only pins them).
|
|
69
|
+
const enabled = all.filter((s) => {
|
|
70
|
+
if (only) return only.includes(s.name);
|
|
71
|
+
if (s.name === 'frontend') return cfg.frontend.enabled;
|
|
72
|
+
if (s.name === 'admin') return cfg.admin.enabled;
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
const services = enabled;
|
|
76
|
+
|
|
77
|
+
// Startup banner - show host:port for every service about to start.
|
|
78
|
+
process.stdout.write(`\n${BOLD} * BhooAI Nexus${RESET} - ${services.map((s) => s.name).join(', ')}\n\n`);
|
|
79
|
+
process.stdout.write(` ${DIM}Service Host Port${RESET}\n`);
|
|
80
|
+
process.stdout.write(` ${DIM}----------- --------- ----${RESET}\n`);
|
|
81
|
+
|
|
82
|
+
for (const s of services) {
|
|
83
|
+
let host = '';
|
|
84
|
+
let port = '';
|
|
85
|
+
switch (s.name) {
|
|
86
|
+
case 'backend':
|
|
87
|
+
host = cfg.server.host;
|
|
88
|
+
port = String(cfg.server.port);
|
|
89
|
+
break;
|
|
90
|
+
case 'frontend':
|
|
91
|
+
host = cfg.frontend.host;
|
|
92
|
+
port = String(cfg.frontend.port);
|
|
93
|
+
break;
|
|
94
|
+
case 'admin':
|
|
95
|
+
host = cfg.admin.host;
|
|
96
|
+
port = String(cfg.admin.port);
|
|
97
|
+
break;
|
|
98
|
+
case 'ai-server':
|
|
99
|
+
host = '0.0.0.0';
|
|
100
|
+
port = aiPort(cfg.ai.serverUrl);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
const padName = s.name.padEnd(15);
|
|
104
|
+
const padHost = host.padEnd(16);
|
|
105
|
+
process.stdout.write(` ${s.color}${padName}${RESET}${padHost}${port}\n`);
|
|
106
|
+
}
|
|
107
|
+
process.stdout.write(`\n`);
|
|
108
|
+
|
|
109
|
+
const supervisor = new Supervisor(services, process.cwd(), controlPort);
|
|
110
|
+
await supervisor.start();
|
|
111
|
+
|
|
112
|
+
// After services spawn, print quick-start URLs.
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
process.stdout.write(`\n ${BOLD}Visit:${RESET}\n`);
|
|
115
|
+
process.stdout.write(` http://localhost:${cfg.frontend.port} (frontend)\n`);
|
|
116
|
+
process.stdout.write(` http://localhost:${cfg.admin.port} (admin)\n`);
|
|
117
|
+
process.stdout.write(` http://localhost:${cfg.server.port}/health (backend)\n`);
|
|
118
|
+
process.stdout.write(`\n`);
|
|
119
|
+
}, 2000);
|
|
120
|
+
|
|
121
|
+
// Keep the process alive; supervisor handles SIGINT/SIGTERM.
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Port parsed from the configured AI server URL (defaults 8000). */
|
|
126
|
+
function aiPort(serverUrl: string): string {
|
|
127
|
+
try {
|
|
128
|
+
const port = new URL(serverUrl).port;
|
|
129
|
+
return port || '80';
|
|
130
|
+
} catch {
|
|
131
|
+
return '8000';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { loadConfigAuto } from '../../../nexus-core/src/index.js';
|
|
5
|
+
import { CheckResult, scanRuntimes, scanServices } from '../util.js';
|
|
6
|
+
|
|
7
|
+
const GREEN = '\x1b[32m';
|
|
8
|
+
const RED = '\x1b[31m';
|
|
9
|
+
const YELLOW = '\x1b[33m';
|
|
10
|
+
const CYAN = '\x1b[36m';
|
|
11
|
+
const DIM = '\x1b[2m';
|
|
12
|
+
const BOLD = '\x1b[1m';
|
|
13
|
+
const RESET = '\x1b[0m';
|
|
14
|
+
|
|
15
|
+
/** Critical dependency names that must be present. */
|
|
16
|
+
const CRITICAL = new Set(['node', 'config', 'jwt-secret', 'jwt token', 'package.json', 'nexus.config']);
|
|
17
|
+
|
|
18
|
+
/** Compute total directory size in bytes (shallow - does not recurse). */
|
|
19
|
+
function dirSize(dir: string): number {
|
|
20
|
+
try {
|
|
21
|
+
let size = 0;
|
|
22
|
+
for (const entry of readdirSync(dir)) {
|
|
23
|
+
try { size += statSync(join(dir, entry)).size; } catch { /* skip */ }
|
|
24
|
+
}
|
|
25
|
+
return size;
|
|
26
|
+
} catch {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Check whether a directory exists and contains at least one entry. */
|
|
32
|
+
function dirPopulated(root: string, rel: string): { ok: boolean; detail: string } {
|
|
33
|
+
const abs = join(root, rel);
|
|
34
|
+
if (!existsSync(abs)) return { ok: false, detail: `${rel}/ missing` };
|
|
35
|
+
try {
|
|
36
|
+
const count = readdirSync(abs).filter((e) => e !== '.gitkeep').length;
|
|
37
|
+
return { ok: count > 0, detail: count > 0 ? `${count} file(s)` : 'empty' };
|
|
38
|
+
} catch {
|
|
39
|
+
return { ok: false, detail: `${rel}/ unreadable` };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Check whether node_modules exists and has at least 10 packages. */
|
|
44
|
+
function depsInstalled(root: string, rel: string): { ok: boolean; detail: string } {
|
|
45
|
+
const mod = join(root, rel, 'node_modules');
|
|
46
|
+
if (!existsSync(mod)) return { ok: false, detail: `${rel}/node_modules missing` };
|
|
47
|
+
try {
|
|
48
|
+
const count = readdirSync(mod).filter((e) => !e.startsWith('.')).length;
|
|
49
|
+
return { ok: count >= 10, detail: count >= 10 ? `${count} pkgs` : `only ${count} pkgs` };
|
|
50
|
+
} catch {
|
|
51
|
+
return { ok: false, detail: `${rel}/node_modules unreadable` };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Verify the environment comprehensively. */
|
|
56
|
+
export async function doctor(): Promise<number> {
|
|
57
|
+
const root = process.cwd();
|
|
58
|
+
const results: CheckResult[] = [];
|
|
59
|
+
|
|
60
|
+
// -- runtime versions (shared with `nexus init`) ----------------
|
|
61
|
+
const runtimes = await scanRuntimes();
|
|
62
|
+
results.push(...runtimes.map((r) => ({ name: r.name, ok: r.ok, detail: r.detail })));
|
|
63
|
+
|
|
64
|
+
// -- project files ------------------------------------------------
|
|
65
|
+
const pkgJson = existsSync(join(root, 'package.json'));
|
|
66
|
+
results.push({ name: 'package.json', ok: pkgJson, detail: pkgJson ? 'exists' : 'missing - not a nexus project' });
|
|
67
|
+
|
|
68
|
+
const configExists = ['ts', 'js', 'mjs', 'cjs'].some((ext) => existsSync(join(root, `nexus.config.${ext}`)));
|
|
69
|
+
const configExt = ['ts', 'js', 'mjs', 'cjs'].find((ext) => existsSync(join(root, `nexus.config.${ext}`)));
|
|
70
|
+
results.push({ name: 'nexus.config', ok: configExists, detail: configExists ? `nexus.config.${configExt}` : 'missing' });
|
|
71
|
+
|
|
72
|
+
const envExists = existsSync(join(root, '.env'));
|
|
73
|
+
results.push({ name: '.env', ok: envExists, detail: envExists ? 'exists' : 'missing - secrets not loaded' });
|
|
74
|
+
|
|
75
|
+
// -- project structure -------------------------------------------
|
|
76
|
+
const structDirs: Array<{ rel: string; label: string }> = [
|
|
77
|
+
{ rel: 'apps/backend', label: 'apps/backend' },
|
|
78
|
+
{ rel: 'apps/frontend', label: 'apps/frontend' },
|
|
79
|
+
{ rel: 'apps/admin', label: 'apps/admin' },
|
|
80
|
+
{ rel: 'apps/ai-server', label: 'ai-server' },
|
|
81
|
+
{ rel: 'packages', label: 'packages' },
|
|
82
|
+
{ rel: 'bin', label: 'bin' },
|
|
83
|
+
{ rel: 'uploads', label: 'uploads' },
|
|
84
|
+
{ rel: 'plugins', label: 'plugins' },
|
|
85
|
+
{ rel: 'certs', label: 'certs' },
|
|
86
|
+
{ rel: 'logs', label: 'logs' },
|
|
87
|
+
];
|
|
88
|
+
for (const d of structDirs) {
|
|
89
|
+
const r = dirPopulated(root, d.rel);
|
|
90
|
+
results.push({ name: d.label, ok: r.ok, detail: r.detail });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -- dependencies ------------------------------------------------
|
|
94
|
+
const depTargets = [
|
|
95
|
+
{ rel: '', label: 'root deps' },
|
|
96
|
+
{ rel: 'apps/backend', label: 'backend deps' },
|
|
97
|
+
{ rel: 'apps/frontend', label: 'frontend deps' },
|
|
98
|
+
{ rel: 'apps/admin', label: 'admin deps' },
|
|
99
|
+
];
|
|
100
|
+
for (const d of depTargets) {
|
|
101
|
+
const r = depsInstalled(root, d.rel);
|
|
102
|
+
results.push({ name: d.label, ok: r.ok, detail: r.detail });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// -- disk space ---------------------------------------------------
|
|
106
|
+
const uploadsDir = join(root, 'uploads');
|
|
107
|
+
const uploadsSize = dirSize(uploadsDir);
|
|
108
|
+
results.push({ name: 'uploads size', ok: true, detail: existsSync(uploadsDir) ? `${(uploadsSize / 1024 / 1024).toFixed(1)} MB` : 'no dir' });
|
|
109
|
+
|
|
110
|
+
const logsDir = join(root, 'logs');
|
|
111
|
+
const logsSize = dirSize(logsDir);
|
|
112
|
+
results.push({ name: 'logs size', ok: true, detail: existsSync(logsDir) ? `${(logsSize / 1024 / 1024).toFixed(1)} MB` : 'no dir' });
|
|
113
|
+
|
|
114
|
+
// -- config + services --------------------------------------------
|
|
115
|
+
try {
|
|
116
|
+
const cfg = await loadConfigAuto({ root });
|
|
117
|
+
|
|
118
|
+
// config validation
|
|
119
|
+
results.push({ name: 'config load', ok: true, detail: `env=${cfg.env} server=${cfg.server.host}:${cfg.server.port}` });
|
|
120
|
+
|
|
121
|
+
// -- services (shared with `nexus init`) -----------------------
|
|
122
|
+
const services = await scanServices(cfg);
|
|
123
|
+
results.push(...services.map((r) => ({ name: r.name, ok: r.ok, detail: r.detail })));
|
|
124
|
+
|
|
125
|
+
// jwt secret + token round-trip
|
|
126
|
+
if (cfg.auth.jwt.secret === 'change-me-please') {
|
|
127
|
+
results.push({ name: 'jwt-secret', ok: false, detail: 'still the default - set NEXUS_AUTH_JWT_SECRET in .env' });
|
|
128
|
+
results.push({ name: 'jwt token', ok: false, detail: 'skipped - secret not configured' });
|
|
129
|
+
} else {
|
|
130
|
+
results.push({ name: 'jwt-secret', ok: true, detail: 'configured' });
|
|
131
|
+
// Sign + verify a test access token to validate the JWT pipeline end-to-end.
|
|
132
|
+
try {
|
|
133
|
+
const { SignJWT, jwtVerify } = await import('jose');
|
|
134
|
+
const testToken = await new SignJWT({ sub: 'doctor-test', roles: ['admin'], iat: Math.floor(Date.now() / 1000) })
|
|
135
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
136
|
+
.setIssuer(cfg.auth.jwt.issuer ?? 'bhooai-nexus')
|
|
137
|
+
.setAudience(cfg.auth.jwt.audience ?? 'bhooai-nexus-client')
|
|
138
|
+
.setExpirationTime('15m')
|
|
139
|
+
.sign(new TextEncoder().encode(cfg.auth.jwt.secret));
|
|
140
|
+
const { payload } = await jwtVerify(testToken, new TextEncoder().encode(cfg.auth.jwt.secret));
|
|
141
|
+
const ok = payload.sub === 'doctor-test' && payload.roles?.[0] === 'admin';
|
|
142
|
+
results.push({ name: 'jwt token', ok, detail: ok ? `HS256 sign+verify OK (sub=${payload.sub})` : 'token payload mismatch' });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
results.push({ name: 'jwt token', ok: false, detail: `sign/verify failed - ${(err as Error).message}` });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// graphql + ws paths
|
|
149
|
+
results.push({ name: 'graphql', ok: true, detail: `path=${cfg.graphql.path} federation=${cfg.graphql.federation}` });
|
|
150
|
+
results.push({ name: 'websocket', ok: true, detail: `path=${cfg.ws.path} csrf=${cfg.ws.requireCsrf}` });
|
|
151
|
+
|
|
152
|
+
// frontend/admin enabled
|
|
153
|
+
results.push({ name: 'frontend', ok: cfg.frontend.enabled, detail: cfg.frontend.enabled ? `enabled :${cfg.frontend.port}` : 'disabled' });
|
|
154
|
+
results.push({ name: 'admin', ok: cfg.admin.enabled, detail: cfg.admin.enabled ? `enabled :${cfg.admin.port}` : 'disabled' });
|
|
155
|
+
|
|
156
|
+
// cluster
|
|
157
|
+
results.push({ name: 'cluster', ok: true, detail: cfg.cluster.enabled ? `enabled (LB :${cfg.cluster.lbPort})` : 'disabled' });
|
|
158
|
+
|
|
159
|
+
// payments
|
|
160
|
+
results.push({ name: 'payments', ok: true, detail: `currency=${cfg.payments.currency}` });
|
|
161
|
+
|
|
162
|
+
// email
|
|
163
|
+
results.push({ name: 'email', ok: true, detail: `provider=${cfg.email.provider}` });
|
|
164
|
+
} catch (err) {
|
|
165
|
+
results.push({ name: 'config', ok: false, detail: String((err as Error).message) });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// -- render ------------------------------------------------------
|
|
169
|
+
console.log(`\n${BOLD} * BhooAI Nexus Doctor${RESET} ${DIM}${new Date().toLocaleString()}${RESET}\n`);
|
|
170
|
+
|
|
171
|
+
let criticalOk = true;
|
|
172
|
+
let warnings = 0;
|
|
173
|
+
let passed = 0;
|
|
174
|
+
let failed = 0;
|
|
175
|
+
|
|
176
|
+
for (const r of results) {
|
|
177
|
+
const icon = r.ok ? `${GREEN}[OK]${RESET}` : `${RED}[X]${RESET}`;
|
|
178
|
+
const label = r.name.padEnd(16);
|
|
179
|
+
if (r.ok) {
|
|
180
|
+
passed++;
|
|
181
|
+
console.log(` ${icon} ${CYAN}${label}${RESET} ${DIM}${r.detail}${RESET}`);
|
|
182
|
+
} else {
|
|
183
|
+
failed++;
|
|
184
|
+
const warn = CRITICAL.has(r.name) ? RED : YELLOW;
|
|
185
|
+
if (!CRITICAL.has(r.name)) warnings++;
|
|
186
|
+
console.log(` ${icon} ${warn}${label}${RESET} ${r.detail}`);
|
|
187
|
+
if (CRITICAL.has(r.name)) criticalOk = false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log(`\n ${BOLD}${passed} passed${RESET}, ${YELLOW}${warnings} warnings${RESET}, ${RED}${failed - warnings} errors${RESET}\n`);
|
|
192
|
+
|
|
193
|
+
if (criticalOk) {
|
|
194
|
+
console.log(`${GREEN} Nexus environment looks ready.${RESET}\n`);
|
|
195
|
+
} else {
|
|
196
|
+
console.log(`${RED} Critical issues found - cannot start.${RESET}\n`);
|
|
197
|
+
}
|
|
198
|
+
return 0; // doctor never hard-fails
|
|
199
|
+
}
|