@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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { createConnection } from 'node:net';
|
|
2
|
+
import type { Router, Middleware, NexusConfig } from '@bhooai/nexus-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Preflight diagnostics proxy.
|
|
6
|
+
*
|
|
7
|
+
* Node performs the connectivity/latency checks itself (fetch + net), so the
|
|
8
|
+
* report no longer depends on the Python engine. The AI-server /preflight
|
|
9
|
+
* endpoint still exists for external tooling; the admin console uses this
|
|
10
|
+
* native path.
|
|
11
|
+
*
|
|
12
|
+
* POST /admin/preflight -> { passed, warnings, failed, checks[] }
|
|
13
|
+
*
|
|
14
|
+
* Target addresses are derived from config but always normalized to a
|
|
15
|
+
* connectable loopback address — binding on 0.0.0.0 is legal for a listener
|
|
16
|
+
* but invalid as a connect target, so backend/GraphQL probes used to fail with
|
|
17
|
+
* "timed out — the service may be filtering traffic".
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface PreflightTarget {
|
|
21
|
+
name: string;
|
|
22
|
+
kind: 'http' | 'tcp';
|
|
23
|
+
url?: string;
|
|
24
|
+
host?: string;
|
|
25
|
+
port?: number;
|
|
26
|
+
timeout?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PreflightCheck {
|
|
30
|
+
name: string;
|
|
31
|
+
kind: string;
|
|
32
|
+
ok: boolean;
|
|
33
|
+
latencyMs?: number;
|
|
34
|
+
status?: number | null;
|
|
35
|
+
host?: string;
|
|
36
|
+
port?: number;
|
|
37
|
+
url?: string;
|
|
38
|
+
error?: string;
|
|
39
|
+
errorCategory?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PreflightReport {
|
|
43
|
+
ranAt: string;
|
|
44
|
+
durationMs: number;
|
|
45
|
+
engineOk?: boolean;
|
|
46
|
+
passed: number;
|
|
47
|
+
warnings: number;
|
|
48
|
+
failed: number;
|
|
49
|
+
checks: PreflightCheck[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Map a bind/alias host to a connectable address. `0.0.0.0`/`::` are bind-only. */
|
|
53
|
+
function normalizeProbeHost(host: string | undefined, fallback = '127.0.0.1'): string {
|
|
54
|
+
if (!host) return fallback;
|
|
55
|
+
const h = host.trim().replace(/^\[|\]$/g, '');
|
|
56
|
+
if (h === '0.0.0.0' || h === '::' || h === 'localhost' || h === 'localhost.localdomain') return fallback;
|
|
57
|
+
return h;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parse a mongodb:// or redis:// URL into a host/port pair with defaults applied. */
|
|
61
|
+
function endpointFromUrl(uri: string, defaultPort: number, defaultHost = '127.0.0.1'): { host: string; port: number } {
|
|
62
|
+
let host = defaultHost;
|
|
63
|
+
let port = defaultPort;
|
|
64
|
+
try {
|
|
65
|
+
const u = new URL(uri);
|
|
66
|
+
if (u.hostname) host = u.hostname;
|
|
67
|
+
if (u.port) port = Number(u.port) || defaultPort;
|
|
68
|
+
} catch {
|
|
69
|
+
/* fall back to defaults */
|
|
70
|
+
}
|
|
71
|
+
return { host: normalizeProbeHost(host), port };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Category = 'refused' | 'timeout' | 'dns' | 'http' | 'other';
|
|
75
|
+
|
|
76
|
+
function classifyTransient(err: unknown): { category: Category; message: string } {
|
|
77
|
+
const code = (err as { code?: string })?.code;
|
|
78
|
+
const why = (err as { cause?: unknown })?.cause;
|
|
79
|
+
const causeCode = (why as { code?: string })?.code;
|
|
80
|
+
if (code === 'ECONNREFUSED' || causeCode === 'ECONNREFUSED') {
|
|
81
|
+
return { category: 'refused', message: 'connection refused — is the service running?' };
|
|
82
|
+
}
|
|
83
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN' || causeCode === 'ENOTFOUND' || causeCode === 'EAI_AGAIN') {
|
|
84
|
+
return { category: 'dns', message: 'host not found' };
|
|
85
|
+
}
|
|
86
|
+
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'ETIMEDOUT' || code === 'ABORT_ERR') {
|
|
87
|
+
return { category: 'timeout', message: 'timed out — no response within the probe window' };
|
|
88
|
+
}
|
|
89
|
+
return { category: 'other', message: (err as Error).message || String(err) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function httpProbe(url: string, timeoutMs: number): Promise<PreflightCheck> {
|
|
93
|
+
const start = Date.now();
|
|
94
|
+
return fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' })
|
|
95
|
+
.then((res) => {
|
|
96
|
+
const ok = res.status < 400;
|
|
97
|
+
return {
|
|
98
|
+
name: '',
|
|
99
|
+
kind: 'http',
|
|
100
|
+
url,
|
|
101
|
+
ok,
|
|
102
|
+
status: res.status,
|
|
103
|
+
latencyMs: Date.now() - start,
|
|
104
|
+
errorCategory: ok ? null : 'http',
|
|
105
|
+
error: ok ? null : `HTTP ${res.status}`,
|
|
106
|
+
};
|
|
107
|
+
})
|
|
108
|
+
.catch((err: unknown) => {
|
|
109
|
+
const { category, message } = classifyTransient(err);
|
|
110
|
+
return {
|
|
111
|
+
name: '',
|
|
112
|
+
kind: 'http',
|
|
113
|
+
url,
|
|
114
|
+
ok: false,
|
|
115
|
+
status: null,
|
|
116
|
+
latencyMs: Date.now() - start,
|
|
117
|
+
errorCategory: category,
|
|
118
|
+
error: sanitize(message),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function tcpProbe(host: string, port: number, timeoutMs: number): Promise<PreflightCheck> {
|
|
124
|
+
const start = Date.now();
|
|
125
|
+
return new Promise<PreflightCheck>((resolve) => {
|
|
126
|
+
const socket = createConnection({ host, port });
|
|
127
|
+
const settled = (check: PreflightCheck) => {
|
|
128
|
+
socket.destroy();
|
|
129
|
+
resolve(check);
|
|
130
|
+
};
|
|
131
|
+
let connected = false;
|
|
132
|
+
socket.setTimeout(timeoutMs);
|
|
133
|
+
socket.once('connect', () => {
|
|
134
|
+
connected = true;
|
|
135
|
+
settled({
|
|
136
|
+
name: '',
|
|
137
|
+
kind: 'tcp',
|
|
138
|
+
host,
|
|
139
|
+
port,
|
|
140
|
+
ok: true,
|
|
141
|
+
latencyMs: Date.now() - start,
|
|
142
|
+
errorCategory: null,
|
|
143
|
+
error: null,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
socket.once('error', (err: NodeJS.ErrnoException) => {
|
|
147
|
+
if (connected) return;
|
|
148
|
+
const { category, message } = classifyTransient(err);
|
|
149
|
+
settled({
|
|
150
|
+
name: '',
|
|
151
|
+
kind: 'tcp',
|
|
152
|
+
host,
|
|
153
|
+
port,
|
|
154
|
+
ok: false,
|
|
155
|
+
latencyMs: Date.now() - start,
|
|
156
|
+
errorCategory: category,
|
|
157
|
+
error: sanitize(message),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
socket.once('timeout', () => {
|
|
161
|
+
if (connected) return;
|
|
162
|
+
settled({
|
|
163
|
+
name: '',
|
|
164
|
+
kind: 'tcp',
|
|
165
|
+
host,
|
|
166
|
+
port,
|
|
167
|
+
ok: false,
|
|
168
|
+
latencyMs: Date.now() - start,
|
|
169
|
+
errorCategory: 'timeout',
|
|
170
|
+
error: 'timed out — no response within the probe window',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Raw error strings that carry no diagnostic value. */
|
|
177
|
+
const NOISE = /address(?:not valid| already in use)|WinError/i;
|
|
178
|
+
|
|
179
|
+
function sanitize(message: string): string {
|
|
180
|
+
return NOISE.test(message) ? 'unreachable from this host' : message;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function registerPreflightRoutes(router: Router, config: NexusConfig, guard: Middleware[]): void {
|
|
184
|
+
router.post('/admin/preflight', async (ctx) => {
|
|
185
|
+
const started = Date.now();
|
|
186
|
+
const serverUrlRaw = (config.ai?.serverUrl as string) ?? 'http://localhost:8000';
|
|
187
|
+
const serverUrl = serverUrlRaw.replace(/\/+$/, '');
|
|
188
|
+
const host = normalizeProbeHost(config.server.host);
|
|
189
|
+
const port = config.server.port ?? 8080;
|
|
190
|
+
|
|
191
|
+
const targets: PreflightTarget[] = [];
|
|
192
|
+
|
|
193
|
+
const backendUrl = `http://${host}:${port}/health`;
|
|
194
|
+
targets.push({ name: 'Backend API', kind: 'http', url: backendUrl, timeout: 3000 });
|
|
195
|
+
|
|
196
|
+
const aiUrl = `${serverUrl.replace(/localhost/i, host)}/health`;
|
|
197
|
+
targets.push({ name: 'AI server', kind: 'http', url: aiUrl, timeout: 3000 });
|
|
198
|
+
|
|
199
|
+
if (config.graphql?.path) {
|
|
200
|
+
// A bare GET would 400 (no query) and POST is CSRF-blocked — probe with a
|
|
201
|
+
// minimal read-query so the endpoint answers 200 instead.
|
|
202
|
+
const graphqlUrl = `http://${host}:${port}${config.graphql.path}?query=${encodeURIComponent('{ __typename }')}`;
|
|
203
|
+
targets.push({ name: 'GraphQL', kind: 'http', url: graphqlUrl, timeout: 3000 });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Mongo + Redis as TCP reachability probes (authenticated ping is out of scope here).
|
|
207
|
+
const dbUri = (config.db?.uri as string) ?? 'mongodb://127.0.0.1:27017';
|
|
208
|
+
const mongo = endpointFromUrl(dbUri, 27017);
|
|
209
|
+
targets.push({ name: 'MongoDB', kind: 'tcp', host: mongo.host, port: mongo.port, timeout: 2000 });
|
|
210
|
+
|
|
211
|
+
const redisUrl = (config.redis?.url as string) ?? '';
|
|
212
|
+
if (redisUrl) {
|
|
213
|
+
const redis = endpointFromUrl(redisUrl, 6379);
|
|
214
|
+
targets.push({ name: 'Redis', kind: 'tcp', host: redis.host, port: redis.port, timeout: 2000 });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const runner = async (t: PreflightTarget): Promise<PreflightCheck> => {
|
|
218
|
+
if (t.kind === 'http') {
|
|
219
|
+
return httpProbe(t.url ?? '', t.timeout ?? 3000).then((c) => ({ ...c, name: t.name }));
|
|
220
|
+
}
|
|
221
|
+
return tcpProbe(t.host ?? '127.0.0.1', t.port ?? 0, t.timeout ?? 2000).then((c) => ({ ...c, name: t.name }));
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const checks = await Promise.all(targets.map(runner));
|
|
225
|
+
const durationMs = Date.now() - started;
|
|
226
|
+
|
|
227
|
+
const failed = checks.filter((c) => !c.ok);
|
|
228
|
+
const passed = checks.filter((c) => c.ok);
|
|
229
|
+
const slow = passed.filter((c) => typeof c.latencyMs === 'number' && (c.latencyMs as number) > 800);
|
|
230
|
+
const rest = passed.filter((c) => !slow.includes(c));
|
|
231
|
+
|
|
232
|
+
ctx.json({
|
|
233
|
+
ranAt: new Date().toISOString(),
|
|
234
|
+
durationMs,
|
|
235
|
+
engineOk: true,
|
|
236
|
+
passed: passed.length,
|
|
237
|
+
warnings: slow.length,
|
|
238
|
+
failed: failed.length,
|
|
239
|
+
checks: [...failed, ...slow, ...rest],
|
|
240
|
+
});
|
|
241
|
+
}, guard);
|
|
242
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role catalog for the admin console.
|
|
3
|
+
*
|
|
4
|
+
* Describes every assignable role: what the holder CAN do (grants) and what
|
|
5
|
+
* they are RESTRICTED from (restricts), plus an icon, accent color and short
|
|
6
|
+
* blurb. The catalog is returned to the admin UI (`GET /admin/roles`) so role
|
|
7
|
+
* editors show accurate, useful boxes. Enforcement stays role-name based via
|
|
8
|
+
* the auth `requireRole` middleware (only `admin` can reach /admin/*).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface RolePermission {
|
|
12
|
+
label: string;
|
|
13
|
+
detail: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RoleDefinition {
|
|
17
|
+
/** Role value stored on the user document. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Human-readable role name. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Unicode glyph shown in the chip / box. */
|
|
22
|
+
icon: string;
|
|
23
|
+
/** Accent color used for the role chip / box accent. */
|
|
24
|
+
accent: string;
|
|
25
|
+
/** One-line description. */
|
|
26
|
+
description: string;
|
|
27
|
+
/** Things the role CAN do. */
|
|
28
|
+
grants: RolePermission[];
|
|
29
|
+
/** Things the role is RESTRICTED from doing. */
|
|
30
|
+
restricts: RolePermission[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const ROLE_CATALOG: RoleDefinition[] = [
|
|
34
|
+
{
|
|
35
|
+
id: 'admin',
|
|
36
|
+
label: 'Administrator',
|
|
37
|
+
icon: '◈',
|
|
38
|
+
accent: '#63bdff',
|
|
39
|
+
description: 'Full operational control of this Nexus project — services, configuration, data, payments and team access.',
|
|
40
|
+
grants: [
|
|
41
|
+
{ label: 'Manage users & roles', detail: 'View every account and assign or revoke roles for other users.' },
|
|
42
|
+
{ label: 'Restart services', detail: 'Stop, start and restart backend, frontend and AI services.' },
|
|
43
|
+
{ label: 'Edit runtime configuration', detail: 'Change runtime.json overrides and the human-edited config file.' },
|
|
44
|
+
{ label: 'Manage environment variables', detail: 'Read masked env values and update keys in the project .env.' },
|
|
45
|
+
{ label: 'Administer plugins', detail: 'See and configure plugin extensions and their admin surfaces.' },
|
|
46
|
+
{ label: 'Manage databases', detail: 'Create, rename and drop databases and collections; preview documents.' },
|
|
47
|
+
{ label: 'Operate payments', detail: 'Review orders, transactions and provider status; create test orders.' },
|
|
48
|
+
{ label: 'Monitor the build', detail: 'View live metrics, uptime, PID and process health.' },
|
|
49
|
+
{ label: 'Generate AI schemas', detail: 'Produce MongoDB schemas and models from natural language.' },
|
|
50
|
+
],
|
|
51
|
+
restricts: [
|
|
52
|
+
{ label: 'Read stored password hashes', detail: 'Hashes are excluded from every user query; they are never returned to any client.' },
|
|
53
|
+
{ label: 'Read secret values', detail: 'Env and payment secrets are masked — real values stay on disk and in memory only.' },
|
|
54
|
+
{ label: 'Escape the project root', detail: 'Uploads, config and database paths are confined to the project directory.' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'user',
|
|
59
|
+
label: 'Member',
|
|
60
|
+
icon: '◎',
|
|
61
|
+
accent: '#8ba0bd',
|
|
62
|
+
description: 'A standard end-user account. Can use the application and manage their own profile.',
|
|
63
|
+
grants: [
|
|
64
|
+
{ label: 'Own account', detail: 'Register, sign in, and manage their own profile and sessions.' },
|
|
65
|
+
{ label: 'Application data', detail: 'Use the app and its data, scoped to their own account.' },
|
|
66
|
+
],
|
|
67
|
+
restricts: [
|
|
68
|
+
{ label: 'Admin console', detail: 'No /admin/* endpoints are accessible — the console requires the admin role.' },
|
|
69
|
+
{ label: 'Service & config control', detail: 'Runtime, processes, plugins, databases and payments are read-only or hidden.' },
|
|
70
|
+
{ label: 'Other accounts & secrets', detail: 'Only their own account is returned; roles and other users are internal.' },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Look up a role's definition by id, or `null` if it is not assignable. */
|
|
76
|
+
export function findRole(id: string): RoleDefinition | undefined {
|
|
77
|
+
return ROLE_CATALOG.find((r) => r.id === id);
|
|
78
|
+
}
|