@bhooai/nexus-cli 0.1.0 → 0.1.4
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/package.json +4 -2
- package/src/commands/dev.ts +80 -13
- package/src/commands/init.ts +67 -18
- package/src/commands/node.ts +7 -2
- package/src/commands/pysetup.ts +7 -1
- package/src/supervisor.ts +17 -7
- package/src/util.ts +10 -1
- package/templates/Dockerfile +5 -5
- package/templates/README.md +1 -1
- package/templates/apps/admin/package.json +2 -4
- package/templates/apps/admin/postcss.config.js +3 -6
- package/templates/apps/admin/vite.config.ts +1 -1
- package/templates/apps/backend/package.json +2 -1
- package/templates/apps/backend/src/main.ts +1 -1
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +3 -3
- package/templates/apps/frontend/package.json +1 -0
- package/templates/apps/frontend/postcss.config.js +3 -0
- package/templates/apps/frontend/src/index.css +44 -0
- package/templates/apps/frontend/src/main.tsx +1 -0
- package/templates/apps/frontend/tailwind.config.js +9 -0
- package/templates/apps/frontend/vite.config.ts +10 -1
- package/templates/bin/serve-all.mjs +2 -2
- package/templates/nexus.config.ts +1 -1
- package/tests/cli.test.ts +7 -0
package/package.json
CHANGED
package/src/commands/dev.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { ServiceSpec, Supervisor } from '../supervisor.js';
|
|
2
2
|
import { loadConfigAuto } from '../../../nexus-core/src/index.js';
|
|
3
3
|
import { configChangedSinceSync, syncConfig } from '../config-sync.js';
|
|
4
|
+
import { isPortFree } from '../util.js';
|
|
4
5
|
|
|
5
6
|
const COLORS = {
|
|
6
7
|
backend: '\x1b[32m',
|
|
7
8
|
frontend: '\x1b[36m',
|
|
8
9
|
'ai-server': '\x1b[33m',
|
|
9
10
|
admin: '\x1b[35m',
|
|
11
|
+
'node-agent': '\x1b[34m',
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
const BOLD = '\x1b[1m';
|
|
@@ -20,9 +22,20 @@ export async function dev(args: string[] = []): Promise<number> { const cfg = a
|
|
|
20
22
|
const cpIdx = args.indexOf('--control-port');
|
|
21
23
|
const controlPort = cpIdx >= 0 && Number.isFinite(Number(args[cpIdx + 1])) ? Number(args[cpIdx + 1]) : 7474;
|
|
22
24
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
25
|
+
// Runtime port auto-allocation: a second project (master + slave) can run on
|
|
26
|
+
// the same machine side-by-side. Any configured port that is already taken is
|
|
27
|
+
// bumped to the next free one and threaded through NEXUS_* env, which both
|
|
28
|
+
// the backend loader and the Vite dev servers honour (config < env).
|
|
29
|
+
const serverPort = await nextFreePort(cfg.server.host, cfg.server.port);
|
|
30
|
+
const frontendPort = await nextFreePort('127.0.0.1', cfg.frontend.port);
|
|
31
|
+
const adminPort = await nextFreePort('127.0.0.1', cfg.admin.port);
|
|
32
|
+
const aiServerPort = await nextFreePort('127.0.0.1', Number(aiPort(cfg.ai.serverUrl)));
|
|
33
|
+
const lbPort = await nextFreePort('127.0.0.1', cfg.cluster.lbPort);
|
|
34
|
+
const agentPort = await nextFreePort('127.0.0.1', cfg.cluster.nodeAgentPort);
|
|
35
|
+
|
|
36
|
+
// If the config changed since the last sync, rewrite every derived artifact
|
|
37
|
+
// (Dockerfile, docker.*, serve-all.mjs, admin pkg, project DB) before booting
|
|
38
|
+
// so the whole stack runs on the new values.
|
|
26
39
|
if (!args.includes('--no-sync')) {
|
|
27
40
|
if (configChangedSinceSync(process.cwd(), cfg)) {
|
|
28
41
|
process.stdout.write(`\n ${BOLD}Config changed - re-syncing derived artifacts...${RESET}\n`);
|
|
@@ -34,19 +47,33 @@ export async function dev(args: string[] = []): Promise<number> { const cfg = a
|
|
|
34
47
|
}
|
|
35
48
|
}
|
|
36
49
|
|
|
50
|
+
// Env override for a port that was bumped away from the configured value.
|
|
51
|
+
// Only set when different, so the configured port is used verbatim otherwise.
|
|
52
|
+
const portEnv = (actual: number, configured: number, key: string): Record<string, string> =>
|
|
53
|
+
actual !== configured ? { [key]: String(actual) } : {};
|
|
54
|
+
|
|
37
55
|
const all: ServiceSpec[] = [
|
|
38
56
|
{
|
|
39
57
|
name: 'backend',
|
|
40
58
|
command: ['tsx', 'watch', 'apps/backend/src/main.ts'],
|
|
41
59
|
cwd: '',
|
|
42
60
|
color: COLORS.backend,
|
|
61
|
+
env: {
|
|
62
|
+
...portEnv(serverPort, cfg.server.port, 'NEXUS_SERVER_PORT'),
|
|
63
|
+
...portEnv(lbPort, cfg.cluster.lbPort, 'NEXUS_CLUSTER_LBPORT'),
|
|
64
|
+
...portEnv(agentPort, cfg.cluster.nodeAgentPort, 'NEXUS_CLUSTER_NODEAGENTPORT'),
|
|
65
|
+
},
|
|
43
66
|
},
|
|
44
67
|
{
|
|
45
68
|
name: 'frontend',
|
|
46
|
-
command: ['vite', '--port', String(
|
|
69
|
+
command: ['vite', '--port', String(frontendPort), '--host', cfg.frontend.host],
|
|
47
70
|
cwd: 'apps/frontend',
|
|
48
71
|
color: COLORS.frontend,
|
|
49
72
|
optional: true,
|
|
73
|
+
env: {
|
|
74
|
+
...portEnv(serverPort, cfg.server.port, 'NEXUS_SERVER_PORT'),
|
|
75
|
+
...portEnv(frontendPort, cfg.frontend.port, 'NEXUS_FRONTEND_PORT'),
|
|
76
|
+
},
|
|
50
77
|
},
|
|
51
78
|
{
|
|
52
79
|
name: 'ai-server',
|
|
@@ -54,17 +81,39 @@ export async function dev(args: string[] = []): Promise<number> { const cfg = a
|
|
|
54
81
|
cwd: 'apps/ai-server',
|
|
55
82
|
color: COLORS['ai-server'],
|
|
56
83
|
optional: true,
|
|
57
|
-
env: { AI_PORT:
|
|
84
|
+
env: { AI_PORT: String(aiServerPort) },
|
|
58
85
|
},
|
|
59
86
|
{
|
|
60
87
|
name: 'admin',
|
|
61
|
-
command: ['vite', '--port', String(
|
|
88
|
+
command: ['vite', '--port', String(adminPort), '--host', cfg.admin.host],
|
|
62
89
|
cwd: 'apps/admin',
|
|
63
90
|
color: COLORS.admin,
|
|
64
91
|
optional: true,
|
|
92
|
+
env: {
|
|
93
|
+
...portEnv(serverPort, cfg.server.port, 'NEXUS_SERVER_PORT'),
|
|
94
|
+
...portEnv(adminPort, cfg.admin.port, 'NEXUS_ADMIN_PORT'),
|
|
95
|
+
},
|
|
65
96
|
},
|
|
66
97
|
];
|
|
67
98
|
|
|
99
|
+
// Slave/node mode: auto-start a node agent (agent-only — it advertises the dev
|
|
100
|
+
// supervisor's backend as its role service instead of spawning a second one).
|
|
101
|
+
// The master can then link this node via its agent URL right after `npm run dev`.
|
|
102
|
+
// Invoked through `node node_modules/bhooai-nexus/bin/nexus.js` so it resolves
|
|
103
|
+
// even when the `nexus` bin shim isn't linked into the project's .bin.
|
|
104
|
+
if (cfg.cluster.role) {
|
|
105
|
+
all.push({
|
|
106
|
+
name: 'node-agent',
|
|
107
|
+
command: ['node', 'node_modules/bhooai-nexus/bin/nexus.js', 'node', 'serve', `--role=${cfg.cluster.role}`, `--port=${agentPort}`, '--no-service'],
|
|
108
|
+
cwd: '',
|
|
109
|
+
color: COLORS['node-agent'],
|
|
110
|
+
env: {
|
|
111
|
+
...portEnv(serverPort, cfg.server.port, 'NEXUS_SERVER_PORT'),
|
|
112
|
+
...portEnv(agentPort, cfg.cluster.nodeAgentPort, 'NEXUS_CLUSTER_NODEAGENTPORT'),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
68
117
|
// Drop services the config disables (unless an explicit --only pins them).
|
|
69
118
|
const enabled = all.filter((s) => {
|
|
70
119
|
if (only) return only.includes(s.name);
|
|
@@ -85,19 +134,23 @@ export async function dev(args: string[] = []): Promise<number> { const cfg = a
|
|
|
85
134
|
switch (s.name) {
|
|
86
135
|
case 'backend':
|
|
87
136
|
host = cfg.server.host;
|
|
88
|
-
port = String(
|
|
137
|
+
port = String(serverPort);
|
|
89
138
|
break;
|
|
90
139
|
case 'frontend':
|
|
91
140
|
host = cfg.frontend.host;
|
|
92
|
-
port = String(
|
|
141
|
+
port = String(frontendPort);
|
|
93
142
|
break;
|
|
94
143
|
case 'admin':
|
|
95
144
|
host = cfg.admin.host;
|
|
96
|
-
port = String(
|
|
145
|
+
port = String(adminPort);
|
|
97
146
|
break;
|
|
98
147
|
case 'ai-server':
|
|
99
148
|
host = '0.0.0.0';
|
|
100
|
-
port =
|
|
149
|
+
port = String(aiServerPort);
|
|
150
|
+
break;
|
|
151
|
+
case 'node-agent':
|
|
152
|
+
host = cfg.cluster.nodeAgentHost;
|
|
153
|
+
port = String(agentPort);
|
|
101
154
|
break;
|
|
102
155
|
}
|
|
103
156
|
const padName = s.name.padEnd(15);
|
|
@@ -112,9 +165,13 @@ export async function dev(args: string[] = []): Promise<number> { const cfg = a
|
|
|
112
165
|
// After services spawn, print quick-start URLs.
|
|
113
166
|
setTimeout(() => {
|
|
114
167
|
process.stdout.write(`\n ${BOLD}Visit:${RESET}\n`);
|
|
115
|
-
process.stdout.write(` http://localhost:${
|
|
116
|
-
process.stdout.write(` http://localhost:${
|
|
117
|
-
process.stdout.write(` http://localhost:${
|
|
168
|
+
process.stdout.write(` http://localhost:${frontendPort} (frontend)\n`);
|
|
169
|
+
process.stdout.write(` http://localhost:${adminPort} (admin)\n`);
|
|
170
|
+
process.stdout.write(` http://localhost:${serverPort}/health (backend)\n`);
|
|
171
|
+
if (cfg.cluster.role) {
|
|
172
|
+
process.stdout.write(`\n ${BOLD}Node agent${RESET} — link this from the master:\n`);
|
|
173
|
+
process.stdout.write(` http://${cfg.cluster.nodeAgentHost}:${agentPort} (agent) · role: ${cfg.cluster.role}\n`);
|
|
174
|
+
}
|
|
118
175
|
process.stdout.write(`\n`);
|
|
119
176
|
}, 2000);
|
|
120
177
|
|
|
@@ -122,6 +179,16 @@ export async function dev(args: string[] = []): Promise<number> { const cfg = a
|
|
|
122
179
|
return 0;
|
|
123
180
|
}
|
|
124
181
|
|
|
182
|
+
/** First free port at or above `port` on `host` (fails fast, caps at +100). */
|
|
183
|
+
async function nextFreePort(host: string, port: number): Promise<number> {
|
|
184
|
+
const base = Number(port);
|
|
185
|
+
if (await isPortFree(host, base)) return base;
|
|
186
|
+
for (let i = 1; i <= 100; i++) {
|
|
187
|
+
if (await isPortFree(host, base + i)) return base + i;
|
|
188
|
+
}
|
|
189
|
+
return base;
|
|
190
|
+
}
|
|
191
|
+
|
|
125
192
|
/** Port parsed from the configured AI server URL (defaults 8000). */
|
|
126
193
|
function aiPort(serverUrl: string): string {
|
|
127
194
|
try {
|
package/src/commands/init.ts
CHANGED
|
@@ -234,7 +234,7 @@ export async function init(opts: InitOptions = {}, args: string[] = []): Promise
|
|
|
234
234
|
ai: { serverUrl: 'http://localhost:8000' },
|
|
235
235
|
server: { host: '0.0.0.0', port: 4000 },
|
|
236
236
|
frontend: { port: 3000 },
|
|
237
|
-
admin: { port:
|
|
237
|
+
admin: { port: 3300 },
|
|
238
238
|
});
|
|
239
239
|
const mongoDown = services.find((s) => s.name === 'mongodb' && !s.ok);
|
|
240
240
|
const redisDown = services.find((s) => s.name === 'redis' && !s.ok);
|
|
@@ -261,7 +261,7 @@ export async function init(opts: InitOptions = {}, args: string[] = []): Promise
|
|
|
261
261
|
ai: { serverUrl: 'http://localhost:8000' },
|
|
262
262
|
server: { host: '0.0.0.0', port: 4000 },
|
|
263
263
|
frontend: { port: 3000 },
|
|
264
|
-
admin: { port:
|
|
264
|
+
admin: { port: 3300 },
|
|
265
265
|
});
|
|
266
266
|
const md = services.find((s) => s.name === 'mongodb' && !s.ok);
|
|
267
267
|
const rd = services.find((s) => s.name === 'redis' && !s.ok);
|
|
@@ -430,7 +430,7 @@ export async function init(opts: InitOptions = {}, args: string[] = []): Promise
|
|
|
430
430
|
// (detected from a sibling root, prompted, or passed via --cluster-token)
|
|
431
431
|
// so the central can authenticate this node's agent.
|
|
432
432
|
const token = choices.clusterToken || randomBytes(16).toString('hex');
|
|
433
|
-
applyClusterConfig(target, choices.kind, token);
|
|
433
|
+
applyClusterConfig(target, choices.kind, choices.role, token, choices.agentPort);
|
|
434
434
|
patchConfig(target, {
|
|
435
435
|
mongoUri: choices.mongoUri,
|
|
436
436
|
redisUrl: choices.redisUrl,
|
|
@@ -660,7 +660,7 @@ async function registerProject(target: string, choices: WizardChoices, clusterTo
|
|
|
660
660
|
payments: { currency: 'INR', providersConfigured: [] },
|
|
661
661
|
cluster: { enabled: choices.kind === 'root', token: clusterToken },
|
|
662
662
|
paths: { uploads: 'uploads', plugins: 'plugins', certs: 'certs', logs: 'logs' },
|
|
663
|
-
ports: { backend: 4000, frontend: 3000, admin:
|
|
663
|
+
ports: { backend: 4000, frontend: 3000, admin: 3300, ai: 8000 },
|
|
664
664
|
};
|
|
665
665
|
const record: ProjectInfo = {
|
|
666
666
|
...base,
|
|
@@ -706,8 +706,9 @@ async function verifyProject(target: string): Promise<void> {
|
|
|
706
706
|
/** Write the `cluster` section into the scaffolded nexus.config.ts.
|
|
707
707
|
* The template ships with `cluster: { enabled: false, token: '' }`, so this
|
|
708
708
|
* always patches the existing line - setting `enabled` (true for root, false
|
|
709
|
-
* for node)
|
|
710
|
-
|
|
709
|
+
* for node), stamping a fresh pairing `token`, and (for nodes) the `role` so
|
|
710
|
+
* `nexus dev` knows to auto-start a node agent. Idempotent on re-runs. */
|
|
711
|
+
function applyClusterConfig(target: string, kind: 'root' | 'node', role: string, token: string, agentPort: number): void {
|
|
711
712
|
const configPath = join(target, 'nexus.config.ts');
|
|
712
713
|
if (!existsSync(configPath)) return;
|
|
713
714
|
let src = readFileSync(configPath, 'utf8');
|
|
@@ -716,7 +717,8 @@ function applyClusterConfig(target: string, kind: 'root' | 'node', token: string
|
|
|
716
717
|
|
|
717
718
|
if (!/cluster\s*:/.test(src)) {
|
|
718
719
|
// No cluster block at all - insert one after the opening brace.
|
|
719
|
-
const
|
|
720
|
+
const roleLine = kind === 'node' ? `, role: '${role}'` : '';
|
|
721
|
+
const clusterLine = ` cluster: { enabled: ${enabled}, token: '${desiredToken}'${roleLine}, nodeAgentPort: ${agentPort} },`;
|
|
720
722
|
writeFileSync(configPath, src.replace(
|
|
721
723
|
/(const config: Partial<NexusConfig> = \{\r?\n)/,
|
|
722
724
|
`$1${clusterLine}\n`,
|
|
@@ -725,7 +727,7 @@ function applyClusterConfig(target: string, kind: 'root' | 'node', token: string
|
|
|
725
727
|
return;
|
|
726
728
|
}
|
|
727
729
|
|
|
728
|
-
// Patch the existing cluster line's `enabled` + `token` in place.
|
|
730
|
+
// Patch the existing cluster line's `enabled` + `token` (+ `role`/`nodeAgentPort`) in place.
|
|
729
731
|
let changed = false;
|
|
730
732
|
|
|
731
733
|
// enabled: <bool>
|
|
@@ -745,9 +747,44 @@ function applyClusterConfig(target: string, kind: 'root' | 'node', token: string
|
|
|
745
747
|
changed = true;
|
|
746
748
|
}
|
|
747
749
|
|
|
750
|
+
// role: '<role>' — set for nodes, remove for root. Only patch if present and different.
|
|
751
|
+
if (kind === 'node') {
|
|
752
|
+
const roleRe = /(cluster\s*:\s*\{[^}]*\brole\s*:\s*')([^']*)(')/;
|
|
753
|
+
const roleMatch = roleRe.exec(src);
|
|
754
|
+
if (roleMatch) {
|
|
755
|
+
if (roleMatch[2] !== role) {
|
|
756
|
+
src = src.replace(roleRe, `$1${role}$3`);
|
|
757
|
+
changed = true;
|
|
758
|
+
}
|
|
759
|
+
} else {
|
|
760
|
+
// No role key yet — insert it right after `enabled: <bool>,`.
|
|
761
|
+
src = src.replace(
|
|
762
|
+
/(cluster\s*:\s*\{[^}]*\benabled\s*:\s*(?:false|true)\s*,)/,
|
|
763
|
+
`$1 role: '${role}',`,
|
|
764
|
+
);
|
|
765
|
+
changed = true;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// nodeAgentPort: <number> — persist the port chosen at init so the admin
|
|
770
|
+
// node-agent panel + `nexus dev` reflect it instead of the 7575 default.
|
|
771
|
+
const portRe = /(cluster\s*:\s*\{[^}]*\bnodeAgentPort\s*:\s*)(\d+)/;
|
|
772
|
+
const portMatch = portRe.exec(src);
|
|
773
|
+
if (portMatch && portMatch[2] !== String(agentPort)) {
|
|
774
|
+
src = src.replace(portRe, `$1${agentPort}`);
|
|
775
|
+
changed = true;
|
|
776
|
+
} else if (!portMatch) {
|
|
777
|
+
// No nodeAgentPort key yet — insert it after `token: '...',`.
|
|
778
|
+
src = src.replace(
|
|
779
|
+
/(cluster\s*:\s*\{[^}]*\btoken\s*:\s*'[^']*'\s*,)/,
|
|
780
|
+
`$1 nodeAgentPort: ${agentPort},`,
|
|
781
|
+
);
|
|
782
|
+
changed = true;
|
|
783
|
+
}
|
|
784
|
+
|
|
748
785
|
if (changed) {
|
|
749
786
|
writeFileSync(configPath, src);
|
|
750
|
-
console.log(` ${GREEN}update${RESET} nexus.config.ts (cluster: ${kind}, enabled: ${enabled})`);
|
|
787
|
+
console.log(` ${GREEN}update${RESET} nexus.config.ts (cluster: ${kind}, enabled: ${enabled}${kind === 'node' ? `, role: ${role}` : ''}, nodeAgentPort: ${agentPort})`);
|
|
751
788
|
} else {
|
|
752
789
|
console.log(` ${DIM}keep${RESET} nexus.config.ts (cluster: ${kind} already set)`);
|
|
753
790
|
}
|
|
@@ -812,7 +849,7 @@ function wireFrameworkDependency(target: string): void {
|
|
|
812
849
|
|
|
813
850
|
/** Auto-allocate free ports for a freshly scaffolded project and patch its
|
|
814
851
|
* nexus.config.ts. All projects ship with the same defaults (server 4000,
|
|
815
|
-
* frontend 3000, admin
|
|
852
|
+
* frontend 3000, admin 3300, AI 8000, cluster LB 8080, agent 7575), so a
|
|
816
853
|
* second project running on the same machine collides - its admin Vite fails
|
|
817
854
|
* to bind and the browser hits another project's admin (wrong project name). */
|
|
818
855
|
async function allocateProjectPorts(target: string): Promise<void> {
|
|
@@ -823,7 +860,7 @@ async function allocateProjectPorts(target: string): Promise<void> {
|
|
|
823
860
|
const defaults: Array<{ key: string; port: number }> = [
|
|
824
861
|
{ key: 'server', port: 4000 },
|
|
825
862
|
{ key: 'frontend', port: 3000 },
|
|
826
|
-
{ key: 'admin', port:
|
|
863
|
+
{ key: 'admin', port: 3300 },
|
|
827
864
|
{ key: 'ai', port: 8000 },
|
|
828
865
|
{ key: 'lb', port: 8080 },
|
|
829
866
|
{ key: 'agent', port: 7575 },
|
|
@@ -869,13 +906,14 @@ async function allocateProjectPorts(target: string): Promise<void> {
|
|
|
869
906
|
|
|
870
907
|
function installDependencies(target: string): number {
|
|
871
908
|
console.log('\n Installing project dependencies (React, Vite, admin, and framework packages)...');
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
909
|
+
// Windows npm is a .cmd shim and requires shell execution. Passing the full
|
|
910
|
+
// command line (no separate args array) avoids Node's DEP0190 warning, which
|
|
911
|
+
// fires when args are combined with shell: true.
|
|
912
|
+
const win = process.platform === 'win32';
|
|
913
|
+
const npmArgs = ['install', '--no-audit', '--no-fund'];
|
|
914
|
+
const result = win
|
|
915
|
+
? spawnSync(`npm.cmd ${npmArgs.join(' ')}`, { cwd: target, stdio: 'inherit', shell: true })
|
|
916
|
+
: spawnSync('npm', npmArgs, { cwd: target, stdio: 'inherit' });
|
|
879
917
|
if (result.error) {
|
|
880
918
|
console.error(`init: npm install failed: ${result.error.message}`);
|
|
881
919
|
return 1;
|
|
@@ -884,6 +922,17 @@ function installDependencies(target: string): number {
|
|
|
884
922
|
console.error(`init: npm install exited with code ${result.status ?? 'unknown'}`);
|
|
885
923
|
return result.status ?? 1;
|
|
886
924
|
}
|
|
925
|
+
// npm 11.7+ gates dependency install scripts behind an `allowScripts` policy
|
|
926
|
+
// and prints an advisory warning for unreviewed scripts (e.g. esbuild's
|
|
927
|
+
// postinstall). Record approvals in the scaffolded project so its own future
|
|
928
|
+
// installs stay quiet. Older npm has no such command - the failure is
|
|
929
|
+
// harmless, and `--no-allow-scripts-pin` keeps entries version-agnostic.
|
|
930
|
+
const approve = win
|
|
931
|
+
? spawnSync('npm.cmd approve-scripts --all --no-allow-scripts-pin', { cwd: target, stdio: 'ignore', shell: true })
|
|
932
|
+
: spawnSync('npm', ['approve-scripts', '--all', '--no-allow-scripts-pin'], { cwd: target, stdio: 'ignore' });
|
|
933
|
+
if (approve.error || (approve.status !== null && approve.status !== 0)) {
|
|
934
|
+
// Old npm - the advisory warning is cosmetic.
|
|
935
|
+
}
|
|
887
936
|
console.log(' Dependencies installed.');
|
|
888
937
|
return 0;
|
|
889
938
|
}
|
package/src/commands/node.ts
CHANGED
|
@@ -36,6 +36,7 @@ export async function node(args: string[]): Promise<number> {
|
|
|
36
36
|
if (sub === 'serve') {
|
|
37
37
|
const description = roleDescription(role);
|
|
38
38
|
const command = serviceCommand(role, cfg);
|
|
39
|
+
const noService = args.includes('--no-service');
|
|
39
40
|
const agent = new NodeAgent({
|
|
40
41
|
projectRoot: process.cwd(),
|
|
41
42
|
role: role as never,
|
|
@@ -44,7 +45,8 @@ export async function node(args: string[]): Promise<number> {
|
|
|
44
45
|
token,
|
|
45
46
|
version: '0.1.0',
|
|
46
47
|
services: serviceUrls(role, cfg),
|
|
47
|
-
serviceCommand: command,
|
|
48
|
+
serviceCommand: noService ? [] : command,
|
|
49
|
+
serviceEnv: { NEXUS_SERVER_PORT: String(cfg.server.port) },
|
|
48
50
|
advertisedUrl: process.env.NEXUS_NODE_ADVERTISED_URL,
|
|
49
51
|
});
|
|
50
52
|
await agent.listen(agentHost);
|
|
@@ -52,7 +54,10 @@ export async function node(args: string[]): Promise<number> {
|
|
|
52
54
|
console.log(` ${GREEN}agent url: http://${agentHost}:${effectivePort}${RESET}`);
|
|
53
55
|
console.log(` ${GREEN}token: ${token ? `${token}` : '<empty - set cluster.token>'}`)
|
|
54
56
|
console.log(` ${DIM}link it from the central: nexus cluster link http://${agentHost}:${effectivePort}${RESET}`);
|
|
55
|
-
if (
|
|
57
|
+
if (noService) {
|
|
58
|
+
console.log(` ${DIM}agent-only mode (--no-service): not spawning a role service;${RESET}`);
|
|
59
|
+
console.log(` ${DIM}advertising ${serviceUrls(role, cfg)[role as 'backend'] ?? '—'} as the role service.${RESET}`);
|
|
60
|
+
} else if (command.length) {
|
|
56
61
|
agent.startService();
|
|
57
62
|
console.log(` started role service: ${command.join(' ')}`);
|
|
58
63
|
}
|
package/src/commands/pysetup.ts
CHANGED
|
@@ -126,7 +126,13 @@ async function resolvePython(explicit: string): Promise<string> {
|
|
|
126
126
|
|
|
127
127
|
function run(cmd: string, args: string[]): Promise<number> {
|
|
128
128
|
return new Promise((resolveRun) => {
|
|
129
|
-
|
|
129
|
+
// Windows: python may be a .cmd shim and needs a shell. Pass the full command
|
|
130
|
+
// line to avoid Node's DEP0190 warning (args + shell: true are concatenated
|
|
131
|
+
// unescaped anyway).
|
|
132
|
+
const win = process.platform === 'win32';
|
|
133
|
+
const child = win
|
|
134
|
+
? spawn([cmd, ...args].map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '), { stdio: 'inherit', shell: true })
|
|
135
|
+
: spawn(cmd, args, { stdio: 'inherit' });
|
|
130
136
|
child.on('error', (err) => {
|
|
131
137
|
console.error(`\x1b[31m[pysetup]\x1b[0m failed to run ${cmd}: ${err.message}`);
|
|
132
138
|
resolveRun(1);
|
package/src/supervisor.ts
CHANGED
|
@@ -121,12 +121,22 @@ export class Supervisor {
|
|
|
121
121
|
for (const k of ['PATH', 'Path', 'path']) delete childEnv[k];
|
|
122
122
|
childEnv.PATH = [...binDirs, origPath].filter(Boolean).join(PATH_SEP);
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
// Windows: service commands are often .cmd shims (tsx/vite/npm) that need a
|
|
125
|
+
// shell. Pass the full command line instead of a separate args array to avoid
|
|
126
|
+
// Node's DEP0190 warning (args + shell: true are only concatenated anyway).
|
|
127
|
+
const win = process.platform === 'win32';
|
|
128
|
+
const proc = win
|
|
129
|
+
? spawn([cmd, ...args].map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '), {
|
|
130
|
+
cwd: cwdAbs,
|
|
131
|
+
env: childEnv,
|
|
132
|
+
shell: true,
|
|
133
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
134
|
+
})
|
|
135
|
+
: spawn(cmd, args, {
|
|
136
|
+
cwd: cwdAbs,
|
|
137
|
+
env: childEnv,
|
|
138
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
139
|
+
});
|
|
130
140
|
this.procs.set(spec.name, proc);
|
|
131
141
|
state.status = 'running';
|
|
132
142
|
state.pid = proc.pid;
|
|
@@ -304,7 +314,7 @@ export class Supervisor {
|
|
|
304
314
|
|
|
305
315
|
private handleControl(req: IncomingMessage, res: ServerResponse): void {
|
|
306
316
|
const url = new URL(req.url ?? '/', `http://127.0.0.1:${this.controlPort}`);
|
|
307
|
-
// Permissive CORS so the browser-based admin (localhost:
|
|
317
|
+
// Permissive CORS so the browser-based admin (localhost:3300) can call us.
|
|
308
318
|
res.setHeader('access-control-allow-origin', '*');
|
|
309
319
|
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
|
|
310
320
|
res.setHeader('access-control-allow-headers', 'content-type, authorization');
|
package/src/util.ts
CHANGED
|
@@ -45,7 +45,16 @@ export function parseHostPort(url: string, defaultPort: number): { host: string;
|
|
|
45
45
|
|
|
46
46
|
export async function versionOf(cmd: string, args: string[] = ['--version']): Promise<string> {
|
|
47
47
|
try {
|
|
48
|
-
|
|
48
|
+
// Windows: runtimes like `npm` are .cmd shims requiring a shell. Passing the
|
|
49
|
+
// full command line (no separate args array) avoids Node's DEP0190 warning.
|
|
50
|
+
const win = process.platform === 'win32';
|
|
51
|
+
const { stdout } = win
|
|
52
|
+
? await execFileAsync(
|
|
53
|
+
[cmd, ...args].map((t) => (/\s/.test(t) ? `"${t}"` : t)).join(' '),
|
|
54
|
+
[],
|
|
55
|
+
{ shell: true },
|
|
56
|
+
)
|
|
57
|
+
: await execFileAsync(cmd, args, {});
|
|
49
58
|
return stdout.trim();
|
|
50
59
|
} catch {
|
|
51
60
|
return '';
|
package/templates/Dockerfile
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
# Build:
|
|
4
4
|
# docker build -t node-1 .
|
|
5
5
|
# Run (pass project secrets; real env vars always win over .env):
|
|
6
|
-
# docker run --rm -p 4000:4000 -p 3000:3000 -p
|
|
6
|
+
# docker run --rm -p 4000:4000 -p 3000:3000 -p 3300:3300 --env-file .env node-1
|
|
7
7
|
# # or override the server port / cluster node agent port:
|
|
8
|
-
# docker run --rm -p 4000:4000 -p 3000:3000 -p
|
|
8
|
+
# docker run --rm -p 4000:4000 -p 3000:3000 -p 3300:3300 -e NEXUS_SERVER_PORT=4000 -e NEXUS_NODE_PORT=7575 node-1
|
|
9
9
|
#
|
|
10
10
|
# Access:
|
|
11
11
|
# frontend http://localhost:3000 (SPA; /health /graphql /auth /ai /ws proxied to the backend)
|
|
12
|
-
# admin http://localhost:
|
|
12
|
+
# admin http://localhost:3300 (SPA; /admin /auth proxied to the backend)
|
|
13
13
|
# backend http://localhost:4000 (API + /health)
|
|
14
14
|
# node http://localhost:7575 (cluster node agent — link it from a root: `nexus cluster link http://<host>:7575`)
|
|
15
15
|
#
|
|
@@ -42,7 +42,7 @@ RUN mkdir -p uploads && chown -R node:node /app
|
|
|
42
42
|
|
|
43
43
|
# Default ports are informational; the real values come from nexus.config.* /
|
|
44
44
|
# NEXUS_* env overrides at runtime.
|
|
45
|
-
EXPOSE 3000
|
|
45
|
+
EXPOSE 3000 3300 4000 7575
|
|
46
46
|
|
|
47
47
|
ENV NODE_ENV=production
|
|
48
48
|
|
|
@@ -53,7 +53,7 @@ USER node
|
|
|
53
53
|
|
|
54
54
|
# Runs the stack: cluster node agent (:7575) + backend role service via
|
|
55
55
|
# `nexus node serve --role=backend --port=7575`, plus frontend (:3000) and
|
|
56
|
-
# admin (:
|
|
56
|
+
# admin (:3300) previews.
|
|
57
57
|
COPY --chown=node:node bin/serve-all.mjs bin/serve-all.mjs
|
|
58
58
|
COPY --chown=node:node package.json package.json
|
|
59
59
|
|
package/templates/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"dev": "vite --port
|
|
6
|
+
"dev": "vite --port 3300",
|
|
7
7
|
"build": "tsc -b && vite build",
|
|
8
8
|
"preview": "vite preview"
|
|
9
9
|
},
|
|
@@ -12,12 +12,10 @@
|
|
|
12
12
|
"react-dom": "^18.3.0"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
+
"@bhooai/nexus-postcss": "*",
|
|
15
16
|
"@types/react": "^18.3.0",
|
|
16
17
|
"@types/react-dom": "^18.3.0",
|
|
17
18
|
"@vitejs/plugin-react": "^4.3.0",
|
|
18
|
-
"autoprefixer": "^10.4.20",
|
|
19
|
-
"postcss": "^8.4.47",
|
|
20
|
-
"tailwindcss": "^3.4.13",
|
|
21
19
|
"typescript": "^5.6.0",
|
|
22
20
|
"vite": "^5.4.0"
|
|
23
21
|
}
|
|
@@ -31,7 +31,7 @@ async function loadConfig() {
|
|
|
31
31
|
},
|
|
32
32
|
admin: {
|
|
33
33
|
host: str(process.env.NEXUS_ADMIN_HOST, user.admin?.host ?? 'localhost'),
|
|
34
|
-
port: num(process.env.NEXUS_ADMIN_PORT, user.admin?.port ??
|
|
34
|
+
port: num(process.env.NEXUS_ADMIN_PORT, user.admin?.port ?? 3300),
|
|
35
35
|
enabled: user.admin?.enabled ?? true,
|
|
36
36
|
},
|
|
37
37
|
frontend: {
|
|
@@ -312,7 +312,7 @@ async function main(): Promise<void> {
|
|
|
312
312
|
});
|
|
313
313
|
|
|
314
314
|
// Cluster manager — auto-starts the LB + autoscaler when config says enabled.
|
|
315
|
-
const cluster = new ClusterManager({ config: config.cluster, root: PROJECT_ROOT, aiServerUrl: config.ai.serverUrl });
|
|
315
|
+
const cluster = new ClusterManager({ config: config.cluster, root: PROJECT_ROOT, aiServerUrl: config.ai.serverUrl, self: { id: 'master', baseUrl: `http://127.0.0.1:${config.server.port}` } });
|
|
316
316
|
if (config.cluster.enabled) {
|
|
317
317
|
try {
|
|
318
318
|
await cluster.listenLb(config.cluster.lbHost);
|
|
@@ -38,7 +38,7 @@ export function registerClusterRoutes(
|
|
|
38
38
|
deps: { root: string; config: import('@bhooai/nexus-core').NexusConfig; manager?: ClusterManager },
|
|
39
39
|
): void {
|
|
40
40
|
const manager = deps.manager ?? (() => {
|
|
41
|
-
const m = new ClusterManager({ config: deps.config.cluster, root: deps.root, aiServerUrl: deps.config.ai.serverUrl });
|
|
41
|
+
const m = new ClusterManager({ config: deps.config.cluster, root: deps.root, aiServerUrl: deps.config.ai.serverUrl, self: { id: 'master', baseUrl: `http://127.0.0.1:${deps.config.server.port}` } });
|
|
42
42
|
deps.manager = m;
|
|
43
43
|
return m;
|
|
44
44
|
})();
|
|
@@ -214,10 +214,10 @@ export function registerClusterRoutes(
|
|
|
214
214
|
}, guard);
|
|
215
215
|
|
|
216
216
|
router.post('/admin/cluster/link', async (ctx) => {
|
|
217
|
-
const { nodeUrl } = (ctx.body ?? {}) as { nodeUrl?: string };
|
|
217
|
+
const { nodeUrl, token } = (ctx.body ?? {}) as { nodeUrl?: string; token?: string };
|
|
218
218
|
if (!nodeUrl) { ctx.json({ error: 'nodeUrl is required' }, 400); return; }
|
|
219
219
|
try {
|
|
220
|
-
const node = await manager.link(nodeUrl);
|
|
220
|
+
const node = await manager.link(nodeUrl, token);
|
|
221
221
|
upsertClusterNode(toClusterNodeRecord({
|
|
222
222
|
id: node.identity.id, role: node.identity.role, tier: node.identity.tier, version: node.identity.version,
|
|
223
223
|
baseUrl: node.identity.baseUrl, services: node.identity.services, status: node.status, enabled: node.enabled,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
@import '@bhooai/nexus-postcss/theme.css';
|
|
2
|
+
|
|
3
|
+
@tailwind base;
|
|
4
|
+
@tailwind components;
|
|
5
|
+
@tailwind utilities;
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Example: using Nexus design tokens + Tailwind utilities together.
|
|
9
|
+
*
|
|
10
|
+
* The theme.css @import above defines CSS custom properties on :root:
|
|
11
|
+
* --nexus-bg, --nexus-ink, --nexus-muted, --nexus-surface,
|
|
12
|
+
* --nexus-border, --nexus-accent, --nexus-radius, --nexus-mesh, …
|
|
13
|
+
*
|
|
14
|
+
* Override any token in your own :root to retheme the whole app:
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
:root {
|
|
18
|
+
/* --nexus-accent: #ff6b6b; /* uncomment to retheme the accent */
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Example: a glass-surface card built from Nexus tokens. */
|
|
22
|
+
.nexus-card {
|
|
23
|
+
background: var(--nexus-surface);
|
|
24
|
+
border: 1px solid var(--nexus-border);
|
|
25
|
+
border-radius: var(--nexus-radius);
|
|
26
|
+
color: var(--nexus-ink);
|
|
27
|
+
padding: 1.5rem;
|
|
28
|
+
backdrop-filter: blur(20px);
|
|
29
|
+
-webkit-backdrop-filter: blur(20px);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Example: ambient mesh background (gradient field behind your app). */
|
|
33
|
+
.nexus-mesh-bg {
|
|
34
|
+
background: var(--nexus-mesh), var(--nexus-bg);
|
|
35
|
+
background-attachment: fixed;
|
|
36
|
+
min-height: 100vh;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Example: accent-colored link / button using a token. */
|
|
40
|
+
.nexus-link {
|
|
41
|
+
color: var(--nexus-accent);
|
|
42
|
+
text-decoration: none;
|
|
43
|
+
}
|
|
44
|
+
.nexus-link:hover { text-decoration: underline; }
|
|
@@ -34,6 +34,10 @@ async function loadConfig() {
|
|
|
34
34
|
port: num(process.env.NEXUS_FRONTEND_PORT, user.frontend?.port ?? 3000),
|
|
35
35
|
enabled: user.frontend?.enabled ?? true,
|
|
36
36
|
},
|
|
37
|
+
cluster: {
|
|
38
|
+
enabled: user.cluster?.enabled ?? false,
|
|
39
|
+
lbPort: num(process.env.NEXUS_CLUSTER_LBPORT, user.cluster?.lbPort ?? 8080),
|
|
40
|
+
},
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -42,6 +46,11 @@ export default defineConfig(async () => {
|
|
|
42
46
|
const backendHost = cfg.server.host === '0.0.0.0' || cfg.server.host === '::' ? '127.0.0.1' : cfg.server.host;
|
|
43
47
|
const backend = `http://${backendHost}:${cfg.server.port}`;
|
|
44
48
|
const backendWs = `ws://${backendHost}:${cfg.server.port}`;
|
|
49
|
+
// When the cluster is enabled, route /uploads through the load balancer so
|
|
50
|
+
// path-pin routing (/uploads/images -> slave-1, /uploads/files -> slave-2)
|
|
51
|
+
// actually applies. Otherwise fall back to the local backend directly.
|
|
52
|
+
const lbTarget = `http://${backendHost}:${cfg.cluster.lbPort}`;
|
|
53
|
+
const uploadsTarget = cfg.cluster.enabled ? lbTarget : backend;
|
|
45
54
|
|
|
46
55
|
return {
|
|
47
56
|
plugins: [react()],
|
|
@@ -55,7 +64,7 @@ export default defineConfig(async () => {
|
|
|
55
64
|
'/graphql': { target: backend, changeOrigin: true, ws: true },
|
|
56
65
|
'/ai': { target: backend, changeOrigin: true },
|
|
57
66
|
'/payments': { target: backend, changeOrigin: true },
|
|
58
|
-
'/uploads': { target:
|
|
67
|
+
'/uploads': { target: uploadsTarget, changeOrigin: true },
|
|
59
68
|
'/ws': { target: backendWs, ws: true, changeOrigin: true },
|
|
60
69
|
},
|
|
61
70
|
},
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// backend -> nexus node serve --role=backend --port=<NEXUS_NODE_PORT|7575>
|
|
3
3
|
// (cluster node agent on :7575 + backend role service)
|
|
4
4
|
// frontend -> vite preview on :3000 (built SPA, proxies API)
|
|
5
|
-
// admin -> vite preview on :
|
|
5
|
+
// admin -> vite preview on :3300 (built admin SPA, proxies API)
|
|
6
6
|
// Forwards signals / reaps children so `docker stop` shuts down cleanly.
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { join, dirname } from 'node:path';
|
|
@@ -42,4 +42,4 @@ process.on('SIGTERM', () => shutdown(0));
|
|
|
42
42
|
|
|
43
43
|
run('backend', root, node, ['bin/nexus.js', 'node', 'serve', '--role=backend', `--port=${nodePort}`]);
|
|
44
44
|
run('frontend', join(root, 'apps', 'frontend'), npm, ['run', 'preview', '--', '--host', '0.0.0.0', '--port', '3000']);
|
|
45
|
-
run('admin', join(root, 'apps', 'admin'), npm, ['run', 'preview', '--', '--host', '0.0.0.0', '--port', '
|
|
45
|
+
run('admin', join(root, 'apps', 'admin'), npm, ['run', 'preview', '--', '--host', '0.0.0.0', '--port', '3300']);
|
|
@@ -22,7 +22,7 @@ const config: Partial<NexusConfig> = {
|
|
|
22
22
|
// Python AI server (first /ai/* proxy hops to this).
|
|
23
23
|
ai: { serverUrl: 'http://localhost:8000', timeoutMs: 60_000, defaultProvider: 'auto', schemaModel: 'llama3:latest' },
|
|
24
24
|
// Vite dev server for the admin app.
|
|
25
|
-
admin: { port:
|
|
25
|
+
admin: { port: 3300, host: 'localhost', enabled: true },
|
|
26
26
|
// Node mesh: set kind via —as=root|node at init (this file stays minimal).
|
|
27
27
|
cluster: { enabled: false, failOpenSingleNode: true, lbHost: '0.0.0.0', lbPort: 8080, nodeAgentHost: '0.0.0.0', nodeAgentPort: 7575, registryFile: 'cluster.runtime.json', token: '' },
|
|
28
28
|
|
package/tests/cli.test.ts
CHANGED
|
@@ -31,6 +31,13 @@ describe('CLI init', () => {
|
|
|
31
31
|
expect(existsSync(join(dir, 'nexus.config.ts'))).toBe(true);
|
|
32
32
|
expect(existsSync(join(dir, 'apps/backend/src/main.ts'))).toBe(true);
|
|
33
33
|
expect(existsSync(join(dir, 'apps/frontend/src/main.tsx'))).toBe(true);
|
|
34
|
+
expect(existsSync(join(dir, 'apps/frontend/tailwind.config.js'))).toBe(true);
|
|
35
|
+
expect(existsSync(join(dir, 'apps/frontend/postcss.config.js'))).toBe(true);
|
|
36
|
+
expect(existsSync(join(dir, 'apps/frontend/src/index.css'))).toBe(true);
|
|
37
|
+
const fePkg = JSON.parse(readFileSync(join(dir, 'apps/frontend/package.json'), 'utf8')) as Record<string, any>;
|
|
38
|
+
expect(fePkg.devDependencies?.['@bhooai/nexus-postcss']).toBeDefined();
|
|
39
|
+
const fePostcss = readFileSync(join(dir, 'apps/frontend/postcss.config.js'), 'utf8');
|
|
40
|
+
expect(fePostcss).toContain('createPreset');
|
|
34
41
|
expect(existsSync(join(dir, 'apps/ai-server/main.py'))).toBe(true);
|
|
35
42
|
expect(existsSync(join(dir, 'apps/admin/src/main.tsx'))).toBe(true);
|
|
36
43
|
});
|