@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 CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-cli",
3
- "version": "0.1.0",
4
- "publishConfig": { "access": "public" },
3
+ "version": "0.1.4",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
5
7
  "type": "module",
6
8
  "main": "./src/index.ts",
7
9
  "types": "./src/index.ts",
@@ -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
- // 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.
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(cfg.frontend.port), '--host', cfg.frontend.host],
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: aiPort(cfg.ai.serverUrl) },
84
+ env: { AI_PORT: String(aiServerPort) },
58
85
  },
59
86
  {
60
87
  name: 'admin',
61
- command: ['vite', '--port', String(cfg.admin.port), '--host', cfg.admin.host],
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(cfg.server.port);
137
+ port = String(serverPort);
89
138
  break;
90
139
  case 'frontend':
91
140
  host = cfg.frontend.host;
92
- port = String(cfg.frontend.port);
141
+ port = String(frontendPort);
93
142
  break;
94
143
  case 'admin':
95
144
  host = cfg.admin.host;
96
- port = String(cfg.admin.port);
145
+ port = String(adminPort);
97
146
  break;
98
147
  case 'ai-server':
99
148
  host = '0.0.0.0';
100
- port = aiPort(cfg.ai.serverUrl);
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:${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`);
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 {
@@ -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: 3001 },
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: 3001 },
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: 3001, ai: 8000 },
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) and stamping a fresh pairing `token`. Idempotent on re-runs. */
710
- function applyClusterConfig(target: string, kind: 'root' | 'node', token: string): void {
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 clusterLine = ` cluster: { enabled: ${enabled}, token: '${desiredToken}' },`;
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 3001, AI 8000, cluster LB 8080, agent 7575), so a
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: 3001 },
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
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
873
- const result = spawnSync(npm, ['install', '--no-audit', '--no-fund'], {
874
- cwd: target,
875
- stdio: 'inherit',
876
- // Windows npm is a .cmd shim and requires shell execution.
877
- shell: process.platform === 'win32',
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
  }
@@ -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 (command.length) {
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
  }
@@ -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
- const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32' });
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
- const proc = spawn(cmd, args, {
125
- cwd: cwdAbs,
126
- env: childEnv,
127
- shell: process.platform === 'win32',
128
- stdio: ['ignore', 'pipe', 'pipe'],
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:3001) can call us.
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
- const { stdout } = await execFileAsync(cmd, args, { shell: process.platform === 'win32' });
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 '';
@@ -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 3001:3001 --env-file .env node-1
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 3001:3001 -e NEXUS_SERVER_PORT=4000 -e NEXUS_NODE_PORT=7575 node-1
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:3001 (SPA; /admin /auth proxied to the backend)
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 3001 4000 7575
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 (:3001) previews.
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
 
@@ -18,7 +18,7 @@ Then visit:
18
18
 
19
19
  - Backend: http://localhost:4000/health
20
20
  - Frontend: http://localhost:3000
21
- - Admin: http://localhost:3001
21
+ - Admin: http://localhost:3300
22
22
  - AI server: http://localhost:8000
23
23
 
24
24
  ## What you got
@@ -3,7 +3,7 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "scripts": {
6
- "dev": "vite --port 3001",
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
  }
@@ -1,6 +1,3 @@
1
- export default {
2
- plugins: {
3
- tailwindcss: {},
4
- autoprefixer: {},
5
- },
6
- };
1
+ import { createPreset } from '@bhooai/nexus-postcss';
2
+
3
+ export default createPreset();
@@ -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 ?? 3001),
34
+ port: num(process.env.NEXUS_ADMIN_PORT, user.admin?.port ?? 3300),
35
35
  enabled: user.admin?.enabled ?? true,
36
36
  },
37
37
  frontend: {
@@ -22,7 +22,8 @@
22
22
  "@bhooai/nexus-crypto": "*",
23
23
  "@bhooai/nexus-ads": "*",
24
24
  "@bhooai/nexus-plugins": "*",
25
- "@bhooai/nexus-ai-client": "*"
25
+ "@bhooai/nexus-ai-client": "*",
26
+ "@bhooai/nexus-cluster": "*"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@types/node": "^22.5.0",
@@ -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,
@@ -12,6 +12,7 @@
12
12
  "react-dom": "^18.3.0"
13
13
  },
14
14
  "devDependencies": {
15
+ "@bhooai/nexus-postcss": "*",
15
16
  "@vitejs/plugin-react": "^4.3.0",
16
17
  "typescript": "^5.6.0",
17
18
  "vite": "^5.4.0"
@@ -0,0 +1,3 @@
1
+ import { createPreset } from '@bhooai/nexus-postcss';
2
+
3
+ export default createPreset();
@@ -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; }
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
+ import './index.css';
3
4
 
4
5
  // Generated placeholder frontend. The framework's own apps/frontend has the full
5
6
  // auth / GraphQL / streaming / checkout UI — copy what you need from there.
@@ -0,0 +1,9 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ './index.html',
5
+ './src/**/*.{ts,tsx}',
6
+ ],
7
+ theme: { extend: {} },
8
+ plugins: [],
9
+ };
@@ -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: backend, changeOrigin: true },
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 :3001 (built admin SPA, proxies API)
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', '3001']);
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: 3001, host: 'localhost', enabled: true },
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
  });