@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
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
+ }