@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,384 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import type { NexusConfig } from '../../nexus-core/src/index.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Config sync - propagate the single `nexus.config.ts` source of truth into
|
|
9
|
+
* every derived artifact that still hardcodes a host/port/URL:
|
|
10
|
+
*
|
|
11
|
+
* - Dockerfile (EXPOSE, healthcheck, docker-run comments)
|
|
12
|
+
* - docker.ps1 / docker.sh / docker.bat (port vars, -p mappings, URLs)
|
|
13
|
+
* - bin/serve-all.mjs (cluster node port + frontend/admin preview ports)
|
|
14
|
+
* - apps/admin/package.json (the `vite --port NNNN` dev script)
|
|
15
|
+
* - shared project-info database (upsertProjectInfo so the DB record shows
|
|
16
|
+
* the same value the admin dashboard reads)
|
|
17
|
+
*
|
|
18
|
+
* A `.nexus-sync.json` fingerprint at the project root lets `nexus dev` detect
|
|
19
|
+
* "config changed since last sync" and re-sync every artifact before booting.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface SyncValues {
|
|
23
|
+
/** Docker image / container name (package.json `name`). */
|
|
24
|
+
image: string;
|
|
25
|
+
serverPort: string;
|
|
26
|
+
frontendPort: string;
|
|
27
|
+
adminPort: string;
|
|
28
|
+
nodePort: string;
|
|
29
|
+
lbPort: string;
|
|
30
|
+
aiPort: string;
|
|
31
|
+
/** Host-service URLs as the docker helpers should reference them. */
|
|
32
|
+
dbUri: string;
|
|
33
|
+
redisUri: string;
|
|
34
|
+
aiUrl: string;
|
|
35
|
+
/** Mongo database name parsed from `db.uri`. */
|
|
36
|
+
dbName: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FileSyncReport {
|
|
40
|
+
file: string;
|
|
41
|
+
status: 'updated' | 'in-sync' | 'no-match' | 'missing';
|
|
42
|
+
changes: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SyncReport {
|
|
46
|
+
values: SyncValues;
|
|
47
|
+
fingerprint: string;
|
|
48
|
+
files: FileSyncReport[];
|
|
49
|
+
db: 'updated' | 'skipped' | 'failed';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SyncOptions {
|
|
53
|
+
/** Write files (default true). False = dry-run / diff-only audit. */
|
|
54
|
+
write?: boolean;
|
|
55
|
+
/** Upsert the project-info MongoDB record (default true). */
|
|
56
|
+
db?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* -- helpers -------------------------------------------------------------- */
|
|
60
|
+
|
|
61
|
+
/** Stable hash of the values that drive every derived artifact. */
|
|
62
|
+
export function fingerprintOf(values: SyncValues): string {
|
|
63
|
+
return createHash('sha256')
|
|
64
|
+
.update(
|
|
65
|
+
[
|
|
66
|
+
values.image,
|
|
67
|
+
values.serverPort,
|
|
68
|
+
values.frontendPort,
|
|
69
|
+
values.adminPort,
|
|
70
|
+
values.nodePort,
|
|
71
|
+
values.lbPort,
|
|
72
|
+
values.aiPort,
|
|
73
|
+
values.dbUri,
|
|
74
|
+
values.redisUri,
|
|
75
|
+
values.aiUrl,
|
|
76
|
+
values.dbName,
|
|
77
|
+
].join('|'),
|
|
78
|
+
)
|
|
79
|
+
.digest('hex')
|
|
80
|
+
.slice(0, 16);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Path to the `.nexus-sync.json` fingerprint file. */
|
|
84
|
+
export function syncFingerprintPath(root: string): string {
|
|
85
|
+
return join(root, '.nexus-sync.json');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read the last-synced record, or undefined. */
|
|
89
|
+
export function readFingerprint(root: string): { fingerprint: string; values: SyncValues; syncedAt?: string } | undefined {
|
|
90
|
+
const file = syncFingerprintPath(root);
|
|
91
|
+
if (!existsSync(file)) return undefined;
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(readFileSync(file, 'utf8')) as { fingerprint: string; values: SyncValues; syncedAt?: string };
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** MongoDB database name parsed out of a connection string. */
|
|
100
|
+
export function dbNameFromUrl(uri: string): string {
|
|
101
|
+
try {
|
|
102
|
+
const url = new URL(uri);
|
|
103
|
+
const name = decodeURIComponent(url.pathname.replace(/^\//, ''));
|
|
104
|
+
return name || 'platform';
|
|
105
|
+
} catch {
|
|
106
|
+
return 'platform';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Rewrite a host-service URL so a container reaches it via the host gateway. */
|
|
111
|
+
function dockerHost(url: string): string {
|
|
112
|
+
return url.replace(/\/\/(localhost|127\.0\.0\.1|\[::1\])([:/])/g, (m, _h, sep) => `//host.docker.internal${sep}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Port parsed from a URL (falls back on a sensible default). */
|
|
116
|
+
function portFrom(url: string, fallback: string): string {
|
|
117
|
+
try {
|
|
118
|
+
return new URL(url).port || fallback;
|
|
119
|
+
} catch {
|
|
120
|
+
return fallback;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Package.json `name`, else the project folder name. */
|
|
125
|
+
function projectImageName(root: string): string {
|
|
126
|
+
try {
|
|
127
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: string };
|
|
128
|
+
if (pkg.name) return pkg.name;
|
|
129
|
+
} catch {
|
|
130
|
+
/* no valid package.json - fall through */
|
|
131
|
+
}
|
|
132
|
+
return basename(root);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Role order used when scanning a contiguous `-p <port>:<port>` run. */
|
|
136
|
+
const PORT_ROLES = ['server', 'frontend', 'admin', 'node'] as const;
|
|
137
|
+
|
|
138
|
+
/** Value for a role before a `-p` token maps to. */
|
|
139
|
+
function forRole(role: string, v: SyncValues): string {
|
|
140
|
+
switch (role) {
|
|
141
|
+
case 'server':
|
|
142
|
+
return v.serverPort;
|
|
143
|
+
case 'frontend':
|
|
144
|
+
return v.frontendPort;
|
|
145
|
+
case 'admin':
|
|
146
|
+
return v.adminPort;
|
|
147
|
+
case 'node':
|
|
148
|
+
return v.nodePort;
|
|
149
|
+
default:
|
|
150
|
+
return role;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Rewrite every group of contiguous `-p <port>:<port>` mapping tokens into the
|
|
156
|
+
* configured ports. Roles are assigned positionally (server, frontend, admin,
|
|
157
|
+
* node) so the rewrite is idempotent even after an earlier run wrote a custom
|
|
158
|
+
* port (e.g. 3120) that role-splitting cannot recognize by number alone.
|
|
159
|
+
*
|
|
160
|
+
* The tokens in one `docker run` may be separated by whitespace, line-continuation
|
|
161
|
+
* carets (`^` in .bat / `\` in .sh), and newlines, but never by another flag.
|
|
162
|
+
*/
|
|
163
|
+
function syncPortMap(content: string, v: SyncValues): string {
|
|
164
|
+
return content.replace(/-p\s+\d+:\d+(?:[ \t\r\n^\\]*-p\s+\d+:\d+)*/g, (run) => {
|
|
165
|
+
let idx = 0;
|
|
166
|
+
return run.replace(/-p\s+\d+:\d+/g, (token) => {
|
|
167
|
+
const p = forRole(PORT_ROLES[idx % PORT_ROLES.length], v);
|
|
168
|
+
idx++;
|
|
169
|
+
return token.replace(/\d+:\d+/, `${p}:${p}`);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Run a value-aware pipeline over file content. */
|
|
175
|
+
function render(content: string, v: SyncValues, steps: Array<(c: string) => string>): { out: string; changed: boolean } {
|
|
176
|
+
let out = content;
|
|
177
|
+
for (const step of steps) out = step(out);
|
|
178
|
+
return { out, changed: out !== content };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Show the human-readable diff between two file versions. */
|
|
182
|
+
function diffLines(before: string, after: string): string[] {
|
|
183
|
+
const changes: string[] = [];
|
|
184
|
+
const b = before.split('\n');
|
|
185
|
+
const a = after.split('\n');
|
|
186
|
+
const max = Math.max(b.length, a.length);
|
|
187
|
+
for (let i = 0; i < max; i++) {
|
|
188
|
+
if (b[i] !== a[i]) {
|
|
189
|
+
changes.push(`line ${i + 1}: ${(b[i] ?? '').trim()} -> ${(a[i] ?? '').trim()}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return changes;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* -- per-file renderers --------------------------------------------------- */
|
|
196
|
+
|
|
197
|
+
function renderDockerfile(content: string, v: SyncValues): string {
|
|
198
|
+
return render(content, v, [
|
|
199
|
+
(c) => c.replace(/^EXPOSE .*$/m, `EXPOSE ${v.frontendPort} ${v.adminPort} ${v.serverPort} ${v.nodePort}`),
|
|
200
|
+
(c) => c.replace(/NEXUS_SERVER_PORT:-?\d+/g, `NEXUS_SERVER_PORT:-${v.serverPort}`),
|
|
201
|
+
(c) => syncPortMap(c, v),
|
|
202
|
+
(c) => c.replace(/-e NEXUS_SERVER_PORT=\d+/g, `-e NEXUS_SERVER_PORT=${v.serverPort}`),
|
|
203
|
+
(c) => c.replace(/-e NEXUS_NODE_PORT=\d+/g, `-e NEXUS_NODE_PORT=${v.nodePort}`),
|
|
204
|
+
(c) => c.replace(/(frontend\s+http:\/\/localhost:)\d+/g, `$1${v.frontendPort}`),
|
|
205
|
+
(c) => c.replace(/(admin\s+http:\/\/localhost:)\d+/g, `$1${v.adminPort}`),
|
|
206
|
+
(c) => c.replace(/(backend\s+http:\/\/localhost:)\d+/g, `$1${v.serverPort}`),
|
|
207
|
+
(c) => c.replace(/(node\s+http:\/\/localhost:)\d+/g, `$1${v.nodePort}`),
|
|
208
|
+
]).out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderServeAll(content: string, v: SyncValues): string {
|
|
212
|
+
return render(content, v, [
|
|
213
|
+
(c) => c.replace(/process\.env\.NEXUS_NODE_PORT\s*\?\?\s*'[^']+'/, `process.env.NEXUS_NODE_PORT ?? '${v.nodePort}'`),
|
|
214
|
+
// Each service's preview port: match inside the `run('frontend'/'admin'` line.
|
|
215
|
+
(c) => c.replace(/(run\('frontend',[^\n]*?'--port',\s*')[0-9]+(')/, `$1${v.frontendPort}$2`),
|
|
216
|
+
(c) => c.replace(/(run\('admin',[^\n]*?'--port',\s*')[0-9]+(')/, `$1${v.adminPort}$2`),
|
|
217
|
+
(c) => c.replace(/(frontend.*?on\s+:)[0-9]+/g, `$1${v.frontendPort}`),
|
|
218
|
+
(c) => c.replace(/(admin.*?on\s+:)[0-9]+/g, `$1${v.adminPort}`),
|
|
219
|
+
]).out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderAdminPackage(content: string, v: SyncValues): string {
|
|
223
|
+
return render(content, v, [
|
|
224
|
+
(c) => c.replace(/"dev":\s*"vite[^"]*"/, `"dev": "vite --port ${v.adminPort}"`),
|
|
225
|
+
]).out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderDockerPs1(content: string, v: SyncValues): string {
|
|
229
|
+
const set = (c: string, key: string, value: string) =>
|
|
230
|
+
c.replace(new RegExp(`(\\$${key}\\s*=\\s*')[0-9]+(')`, 'g'), `$1${value}$2`);
|
|
231
|
+
return render(content, v, [
|
|
232
|
+
(c) => set(c, 'serverPort', v.serverPort),
|
|
233
|
+
(c) => set(c, 'frontendPort', v.frontendPort),
|
|
234
|
+
(c) => set(c, 'adminPort', v.adminPort),
|
|
235
|
+
(c) => set(c, 'nodePort', v.nodePort),
|
|
236
|
+
(c) => c.replace(/(\$dbUri\s*=\s*')([^']*)(')/g, `$1${v.dbUri}$3`),
|
|
237
|
+
(c) => c.replace(/(\$redisUrl\s*=\s*')([^']*)(')/g, `$1${v.redisUri}$3`),
|
|
238
|
+
(c) => c.replace(/(\$aiUrl\s*=\s*')([^']*)(')/g, `$1${v.aiUrl}$3`),
|
|
239
|
+
(c) => syncPortMap(c, v),
|
|
240
|
+
]).out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function renderDockerSh(content: string, v: SyncValues): string {
|
|
244
|
+
return render(content, v, [
|
|
245
|
+
(c) => c.replace(/(DB_URI=")([^"]*)(")/g, `$1${v.dbUri}$3`),
|
|
246
|
+
(c) => c.replace(/(REDIS_URL=")([^"]*)(")/g, `$1${v.redisUri}$3`),
|
|
247
|
+
(c) => c.replace(/(AI_URL=")([^"]*)(")/g, `$1${v.aiUrl}$3`),
|
|
248
|
+
(c) => c.replace(/(NODE_PORT=")([^"]*)(")/g, `$1${v.nodePort}$3`),
|
|
249
|
+
(c) => syncPortMap(c, v),
|
|
250
|
+
(c) => c.replace(/(frontend http:\/\/localhost:)\d+/g, `$1${v.frontendPort}`),
|
|
251
|
+
(c) => c.replace(/(admin\s+http:\/\/localhost:)\d+/g, `$1${v.adminPort}`),
|
|
252
|
+
(c) => c.replace(/(backend\s+http:\/\/localhost:)\d+/g, `$1${v.serverPort}`),
|
|
253
|
+
]).out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function renderDockerBat(content: string, v: SyncValues): string {
|
|
257
|
+
return render(content, v, [
|
|
258
|
+
(c) => syncPortMap(c, v),
|
|
259
|
+
(c) => c.replace(/NEXUS_DB_URI=mongodb:\/\/[^\s^&]+/g, `NEXUS_DB_URI=${v.dbUri}`),
|
|
260
|
+
(c) => c.replace(/NEXUS_REDIS_URL=redis:\/\/[^\s^&]+/g, `NEXUS_REDIS_URL=${v.redisUri}`),
|
|
261
|
+
(c) => c.replace(/NEXUS_AI_SERVER_URL=http:\/\/[^\s^&]+/g, `NEXUS_AI_SERVER_URL=${v.aiUrl}`),
|
|
262
|
+
(c) => c.replace(/NEXUS_NODE_PORT=\d+/g, `NEXUS_NODE_PORT=${v.nodePort}`),
|
|
263
|
+
(c) => c.replace(/(frontend http:\/\/localhost:)\d+/g, `$1${v.frontendPort}`),
|
|
264
|
+
(c) => c.replace(/(admin\s+http:\/\/localhost:)\d+/g, `$1${v.adminPort}`),
|
|
265
|
+
(c) => c.replace(/(backend\s+http:\/\/localhost:)\d+/g, `$1${v.serverPort}`),
|
|
266
|
+
]).out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* -- targets -------------------------------------------------------------- */
|
|
270
|
+
|
|
271
|
+
type FileRenderer = (content: string, v: SyncValues) => string;
|
|
272
|
+
|
|
273
|
+
const TARGETS: Array<{ rel: string; render: FileRenderer }> = [
|
|
274
|
+
{ rel: 'Dockerfile', render: renderDockerfile },
|
|
275
|
+
{ rel: 'bin/serve-all.mjs', render: renderServeAll },
|
|
276
|
+
{ rel: 'apps/admin/package.json', render: renderAdminPackage },
|
|
277
|
+
{ rel: 'docker.ps1', render: renderDockerPs1 },
|
|
278
|
+
{ rel: 'docker.sh', render: renderDockerSh },
|
|
279
|
+
{ rel: 'docker.bat', render: renderDockerBat },
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
/* -- public API ----------------------------------------------------------- */
|
|
283
|
+
|
|
284
|
+
/** Derive the full set of printable / embeddable values from the effective config. */
|
|
285
|
+
export function deriveValues(cfg: NexusConfig, image: string): SyncValues {
|
|
286
|
+
return {
|
|
287
|
+
image,
|
|
288
|
+
serverPort: String(cfg.server.port),
|
|
289
|
+
frontendPort: String(cfg.frontend.port),
|
|
290
|
+
adminPort: String(cfg.admin.port),
|
|
291
|
+
nodePort: String(cfg.cluster.nodeAgentPort ?? 7575),
|
|
292
|
+
lbPort: String(cfg.cluster.lbPort ?? 8080),
|
|
293
|
+
aiPort: portFrom(cfg.ai.serverUrl, '8000'),
|
|
294
|
+
dbUri: dockerHost(cfg.db.uri),
|
|
295
|
+
redisUri: dockerHost(cfg.redis.url),
|
|
296
|
+
aiUrl: dockerHost(cfg.ai.serverUrl),
|
|
297
|
+
dbName: dbNameFromUrl(cfg.db.uri),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Sync the effective config into every derived file + the project-info DB.
|
|
303
|
+
* Returns a field-level report; with `opts.write === false` it only audits.
|
|
304
|
+
*/
|
|
305
|
+
export async function syncConfig(root: string, cfg: NexusConfig, opts: SyncOptions = {}): Promise<SyncReport> {
|
|
306
|
+
const write = opts.write !== false;
|
|
307
|
+
const image = projectImageName(root);
|
|
308
|
+
const values = deriveValues(cfg, image);
|
|
309
|
+
const fingerprint = fingerprintOf(values);
|
|
310
|
+
|
|
311
|
+
const files: FileSyncReport[] = [];
|
|
312
|
+
for (const target of TARGETS) {
|
|
313
|
+
const abs = join(root, target.rel);
|
|
314
|
+
if (!existsSync(abs)) {
|
|
315
|
+
files.push({ file: target.rel, status: 'missing', changes: [] });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const current = await readFile(abs, 'utf8');
|
|
320
|
+
const next = target.render(current, values);
|
|
321
|
+
if (next === current) {
|
|
322
|
+
files.push({ file: target.rel, status: 'in-sync', changes: [] });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (write) await writeFile(abs, next, 'utf8');
|
|
326
|
+
files.push({ file: target.rel, status: 'updated', changes: diffLines(current, next) });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
files.push({ file: target.rel, status: 'no-match', changes: [String((err as Error).message)] });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (write) {
|
|
333
|
+
const record = { fingerprint, values, syncedAt: new Date().toISOString() };
|
|
334
|
+
writeFileSync(syncFingerprintPath(root), JSON.stringify(record, null, 2), 'utf8');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const db = opts.db === false ? 'skipped' : await syncDatabase(root, cfg, values, write);
|
|
338
|
+
|
|
339
|
+
return { values, fingerprint, files, db };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Upsert the shared `nexus_projects` record so the DB mirrors the config. */
|
|
343
|
+
async function syncDatabase(root: string, cfg: NexusConfig, values: SyncValues, write: boolean): Promise<SyncReport['db']> {
|
|
344
|
+
if (!write) return 'skipped';
|
|
345
|
+
try {
|
|
346
|
+
const { resolveProjectInfo, connectProjectInfo, upsertProjectInfo, closeProjectInfo } = await import(
|
|
347
|
+
'../../../nexus-data/src/index.js'
|
|
348
|
+
);
|
|
349
|
+
const project = await resolveProjectInfo(root);
|
|
350
|
+
connectProjectInfo(cfg.db.uri, { autoIndex: false });
|
|
351
|
+
await upsertProjectInfo({
|
|
352
|
+
...project,
|
|
353
|
+
status: 'stopped',
|
|
354
|
+
settings: {
|
|
355
|
+
env: cfg.env,
|
|
356
|
+
host: cfg.server.host,
|
|
357
|
+
serverPort: cfg.server.port,
|
|
358
|
+
frontendPort: cfg.frontend.port,
|
|
359
|
+
adminPort: cfg.admin.port,
|
|
360
|
+
lbPort: cfg.cluster?.lbPort,
|
|
361
|
+
nodePort: cfg.cluster?.nodeAgentPort,
|
|
362
|
+
aiPort: Number(values.aiPort),
|
|
363
|
+
database: values.dbName,
|
|
364
|
+
dbUri: cfg.db.uri,
|
|
365
|
+
redisUrl: cfg.redis.url,
|
|
366
|
+
aiUrl: cfg.ai.serverUrl,
|
|
367
|
+
graphqlPath: cfg.graphql.path,
|
|
368
|
+
wsPath: cfg.ws.path,
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
await closeProjectInfo();
|
|
372
|
+
return 'updated';
|
|
373
|
+
} catch {
|
|
374
|
+
return 'failed';
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** True when the stored fingerprint differs from the current config. */
|
|
379
|
+
export function configChangedSinceSync(root: string, cfg: NexusConfig): boolean {
|
|
380
|
+
const image = projectImageName(root);
|
|
381
|
+
const current = fingerprintOf(deriveValues(cfg, image));
|
|
382
|
+
const saved = readFingerprint(root);
|
|
383
|
+
return !saved || saved.fingerprint !== current;
|
|
384
|
+
}
|
package/src/dotenv.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal dependency-free `.env` loader for the CLI and supervised services.
|
|
6
|
+
*
|
|
7
|
+
* The framework's config maps every `NEXUS_*` variable onto the config tree
|
|
8
|
+
* (`env.ts`), but nothing ever loaded a `.env` file - so keys like
|
|
9
|
+
* `NEXUS_PAYMENTS_RAZORPAY_KEY_ID` in the project's `.env` were silently
|
|
10
|
+
* ignored. We parse `<root>/.env` once at CLI startup and merge it into
|
|
11
|
+
* `process.env` WITHOUT overriding variables that are already set (a real
|
|
12
|
+
* environment value always wins).
|
|
13
|
+
*
|
|
14
|
+
* Grammar: `KEY=VALUE` (optional `export ` prefix), `#` comments, blank lines,
|
|
15
|
+
* and optional single/double quotes. Placeholder policies are left to the app.
|
|
16
|
+
*/
|
|
17
|
+
export function loadDotEnv(root: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
18
|
+
const file = resolve(root, '.env');
|
|
19
|
+
if (!existsSync(file)) return;
|
|
20
|
+
let content: string;
|
|
21
|
+
try {
|
|
22
|
+
content = readFileSync(file, 'utf8');
|
|
23
|
+
} catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
27
|
+
const line = rawLine.trim();
|
|
28
|
+
if (!line || line.startsWith('#')) continue;
|
|
29
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
30
|
+
if (!m) continue;
|
|
31
|
+
const key = m[1]!;
|
|
32
|
+
if (env[key] !== undefined) continue; // real env always wins
|
|
33
|
+
let value = m[2]!.trim();
|
|
34
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
35
|
+
value = value.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
env[key] = value;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { doctor } from './commands/doctor.js';
|
|
2
|
+
import { init } from './commands/init.js';
|
|
3
|
+
import { dev } from './commands/dev.js';
|
|
4
|
+
import { pysetup } from './commands/pysetup.js';
|
|
5
|
+
import { cluster } from './commands/cluster.js';
|
|
6
|
+
import { node } from './commands/node.js';
|
|
7
|
+
import { syncCommand } from './commands/sync.js';
|
|
8
|
+
import { uninstall } from './commands/uninstall.js';
|
|
9
|
+
import { loadDotEnv } from './dotenv.js';
|
|
10
|
+
|
|
11
|
+
/** Dispatch a CLI command. Returns the exit code. */
|
|
12
|
+
export async function run(cmd: string, args: string[]): Promise<number> {
|
|
13
|
+
// Load the project .env once so NEXUS_* keys reach the config loader AND the
|
|
14
|
+
// supervised child processes (they inherit process.env).
|
|
15
|
+
loadDotEnv(process.cwd());
|
|
16
|
+
|
|
17
|
+
switch (cmd) {
|
|
18
|
+
case 'doctor':
|
|
19
|
+
return doctor();
|
|
20
|
+
case 'init':
|
|
21
|
+
return init({}, args);
|
|
22
|
+
case 'dev':
|
|
23
|
+
return dev(args);
|
|
24
|
+
case 'build':
|
|
25
|
+
console.log('nexus build - implemented in a later phase (tsc across workspaces).');
|
|
26
|
+
return 0;
|
|
27
|
+
case 'test':
|
|
28
|
+
console.log('nexus test - run `npm test` (vitest + pytest) for now.');
|
|
29
|
+
return 0;
|
|
30
|
+
case 'plugin':
|
|
31
|
+
console.log('nexus plugin <new|install> - implemented in Phase 8.');
|
|
32
|
+
return 0;
|
|
33
|
+
case 'add':
|
|
34
|
+
console.log('nexus add <subgraph|module> - implemented in Phase 5/6.');
|
|
35
|
+
return 0;
|
|
36
|
+
case 'pysetup':
|
|
37
|
+
return pysetup(args);
|
|
38
|
+
case 'uninstall':
|
|
39
|
+
return uninstall(args);
|
|
40
|
+
case 'cluster':
|
|
41
|
+
return cluster(args);
|
|
42
|
+
case 'node':
|
|
43
|
+
return node(args);
|
|
44
|
+
case 'sync':
|
|
45
|
+
return syncCommand(args);
|
|
46
|
+
case 'help':
|
|
47
|
+
case '--help':
|
|
48
|
+
case '-h':
|
|
49
|
+
printHelp();
|
|
50
|
+
return 0;
|
|
51
|
+
default:
|
|
52
|
+
console.error(`Unknown command: ${cmd}`);
|
|
53
|
+
printHelp();
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printHelp(): void {
|
|
59
|
+
console.log(`
|
|
60
|
+
BhooAI Nexus CLI
|
|
61
|
+
|
|
62
|
+
Usage: nexus <command> [args]
|
|
63
|
+
|
|
64
|
+
Commands:
|
|
65
|
+
init [target] [--as=root|node] [--role=R] [--name=N] [--mongo-uri=URI]
|
|
66
|
+
[--redis-url=URL] [--ai-providers=a,b] [--ai-key id=val]
|
|
67
|
+
[--cluster-token=T] [--venv|--no-venv] [--no-interactive] [--no-install] [--skip-mongo-check]
|
|
68
|
+
Scaffold a project; one-command wizard sets up
|
|
69
|
+
Node + frontend + admin + Python AI + Mongo/Redis
|
|
70
|
+
dev [--only a,b] Start the four terminals under the supervisor
|
|
71
|
+
build Build all TypeScript workspaces
|
|
72
|
+
test Run all test suites
|
|
73
|
+
doctor Verify the environment (node, python, mongo, redis)
|
|
74
|
+
plugin <new|install> Manage plugins
|
|
75
|
+
add <subgraph|module> Scaffold a new subgraph or module
|
|
76
|
+
pysetup [pkgs...] [--venv] [--interactive]
|
|
77
|
+
Install the AI-server Python deps (requirements.txt) + extras
|
|
78
|
+
uninstall [--target <path>] [--purge] [--force] [--dry-run]
|
|
79
|
+
Drop the project DB, delete its nexus_projects record,
|
|
80
|
+
optionally remove the project directory (--purge)
|
|
81
|
+
node <id|serve> Run this machine as a cluster node (agent + role service)
|
|
82
|
+
cluster <sub> Root-side mesh: status, link, scale, serve, etc.
|
|
83
|
+
sync [--check] [--no-db] Rewrite Dockerfiles, docker.*, serve-all.mjs & DB from nexus.config.ts
|
|
84
|
+
|
|
85
|
+
Options:
|
|
86
|
+
-h, --help Show this help
|
|
87
|
+
`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Re-exports for programmatic use.
|
|
91
|
+
export { doctor, init, dev, pysetup, cluster, node, syncCommand };
|
|
92
|
+
export { syncConfig, configChangedSinceSync, deriveValues, fingerprintOf } from './config-sync.js';
|
|
93
|
+
export { Supervisor } from './supervisor.js';
|
|
94
|
+
export type { LogEntry } from './supervisor.js';
|