@bhooai/nexus-cli 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/commands/add.ts +1 -1
  3. package/src/commands/dev.ts +3 -3
  4. package/src/commands/init.ts +87 -136
  5. package/src/devPanel.ts +602 -346
  6. package/src/devServiceManager.ts +50 -4
  7. package/src/dispatcher.ts +7 -1
  8. package/src/examples.ts +90 -0
  9. package/src/features.ts +261 -0
  10. package/src/launcher.ts +164 -0
  11. package/src/layout.ts +101 -0
  12. package/src/templating/tree.ts +66 -0
  13. package/src/tui.ts +170 -0
  14. package/src/wizard.ts +691 -0
  15. package/templates/base/Dockerfile.ejs +1 -0
  16. package/templates/base/apps/admin/nginx.conf.ejs +30 -1
  17. package/templates/base/apps/admin/package.json.ejs +7 -2
  18. package/templates/base/apps/admin/postcss.config.js +5 -0
  19. package/templates/base/apps/admin/src/App.tsx +4127 -0
  20. package/templates/base/apps/admin/src/alertCenter.tsx +150 -0
  21. package/templates/base/apps/admin/src/api.ts +474 -0
  22. package/templates/base/apps/admin/src/assets/bhooai-nexus-logo.svg +25 -0
  23. package/templates/base/apps/admin/src/index.css +3481 -0
  24. package/templates/base/apps/admin/src/main.tsx.ejs +3 -3
  25. package/templates/base/apps/admin/src/vite-env.d.ts +19 -0
  26. package/templates/base/apps/admin/tailwind.config.js +9 -0
  27. package/templates/base/apps/admin/vite.config.ts.ejs +21 -2
  28. package/templates/base/apps/ai-server/main.py.ejs +94 -6
  29. package/templates/base/apps/backend/package.json.ejs +27 -0
  30. package/templates/base/apps/frontend/package.json.ejs +7 -0
  31. package/templates/base/apps/frontend/vite.config.ts.ejs +0 -1
  32. package/templates/base/docker-compose.yml.ejs +6 -1
  33. package/templates/base/nexus.config.ts.ejs +4 -4
  34. package/templates/features/auth/apps/backend/src/models/User.ts +21 -0
  35. package/templates/features/auth/apps/backend/src/routes/auth.ts +95 -0
  36. package/templates/features/email/apps/backend/src/mail/mailables/WelcomeMail.ts +25 -0
  37. package/templates/features/email/apps/backend/src/mail/templates/welcome.ejs.ejs +10 -0
  38. package/templates/features/graphql/apps/backend/src/graphql/post.graph.ts +61 -0
  39. package/templates/features/graphql/apps/backend/src/models/Post.ts +15 -0
  40. package/templates/features/payments/apps/backend/src/routes/payments.ts +45 -0
  41. package/templates/features/queue/apps/backend/src/events/JobQueued.ts +14 -0
  42. package/templates/features/queue/apps/backend/src/jobs/ExampleJob.ts +18 -0
  43. package/templates/features/queue/apps/backend/src/listeners/OnJobQueued.ts +12 -0
  44. package/templates/features/realtime/apps/backend/src/models/Message.ts +14 -0
  45. package/templates/features/realtime/apps/backend/src/ws/chat.room.ts +56 -0
  46. package/templates/features/storage/apps/backend/src/routes/uploads.ts +91 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-cli",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "BhooAI Nexus v2 CLI — init, dev, build, test, scaffolding, plugins, queue, db.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -178,7 +178,7 @@ export default defineConfig({
178
178
  server: {
179
179
  port: ${port},
180
180
  proxy: {
181
- '/api': { target: 'http://localhost:${backendPort}', changeOrigin: true, rewrite: (p: string) => p.replace(/^\\/api/, '') },
181
+ '/api': { target: 'http://localhost:${backendPort}', changeOrigin: true },
182
182
  '/uploads': { target: 'http://localhost:${backendPort}', changeOrigin: true },
183
183
  '/ws': { target: 'ws://localhost:${backendPort}', ws: true },
184
184
  },
@@ -29,7 +29,7 @@ export async function run(ctx: CommandContext): Promise<void> {
29
29
  return;
30
30
  }
31
31
 
32
- const manager = new ServiceManager(specs);
32
+ const manager = new ServiceManager(specs, projectRoot);
33
33
 
34
34
  if (isTty && !noPanel) {
35
35
  // Full-screen Nexus Console.
@@ -91,13 +91,13 @@ async function discoverServices(projectRoot: string, only: string[] | null): Pro
91
91
  if (!existsSync(join(appsDir, f, 'package.json'))) continue;
92
92
  const port = await ensurePort(projectRoot, f);
93
93
  if (only && !only.includes(f)) continue;
94
- specs.push({ name: f, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, f), port });
94
+ specs.push({ name: f, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, f), port, portArgIndex: 2 });
95
95
  }
96
96
  for (const a of admins) {
97
97
  if (!existsSync(join(appsDir, a, 'package.json'))) continue;
98
98
  const port = await ensurePort(projectRoot, a);
99
99
  if (only && !only.includes(a)) continue;
100
- specs.push({ name: a, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, a), port });
100
+ specs.push({ name: a, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, a), port, portArgIndex: 2 });
101
101
  }
102
102
  for (const ai of aiServers) {
103
103
  if (!existsSync(join(appsDir, ai, 'main.py'))) continue;
@@ -1,26 +1,29 @@
1
1
  /**
2
- * nexus init — scaffold a new Nexus project.
2
+ * nexus init — scaffold a new Nexus project via the guided wizard.
3
3
  *
4
- * Modes:
5
- * nexus init my-app quick, no prompts, sensible defaults
6
- * nexus init --interactive my-app — full wizard with example picker
7
- * nexus init my-app --example chat — quick + named example
4
+ * nexus init open the full-screen setup wizard
5
+ * nexus init my-app open the wizard with the name pre-filled
6
+ *
7
+ * Always interactive (the wizard). Requires a TTY; in a non-interactive
8
+ * context it prints a message and exits non-zero. `--no-install` skips the
9
+ * dependency install, `--force` overwrites an existing target directory.
8
10
  */
9
- import { mkdir, writeFile, cp, readFile, readdir } from 'node:fs/promises';
11
+ import { mkdir, writeFile } from 'node:fs/promises';
10
12
  import { existsSync } from 'node:fs';
11
13
  import { spawn } from 'node:child_process';
12
- import { join, resolve, dirname, basename } from 'node:path';
14
+ import { join, resolve, dirname } from 'node:path';
13
15
  import { fileURLToPath } from 'node:url';
14
16
  import type { CommandContext } from '../dispatcher.js';
15
- import { prompt, confirm, select, isInteractive } from '../prompts.js';
16
- import { randomSecret, slugify } from '../util.js';
17
- import { ensurePort } from '../ports.js';
18
- import { renderString, isTemplateName, stripTemplateSuffix } from '../templating/render.js';
17
+ import { confirm } from '../prompts.js';
18
+ import { randomSecret } from '../util.js';
19
+ import { writeRegistry, type PortRegistry } from '../ports.js';
20
+ import { renderTemplateTree } from '../templating/tree.js';
21
+ import { runWizard } from '../wizard.js';
22
+ import { getFeature, installFeature } from '../features.js';
23
+ import { isTty } from '../tui.js';
19
24
 
20
25
  const HERE = dirname(fileURLToPath(import.meta.url));
21
26
  const TEMPLATES_DIR = resolve(HERE, '..', '..', 'templates');
22
- // Examples ship as @bhooai/nexus-examples workspace package.
23
- const EXAMPLES_DIR = resolve(HERE, '..', '..', '..', 'nexus-examples', 'examples');
24
27
 
25
28
  interface InitVars {
26
29
  name: string;
@@ -38,75 +41,47 @@ interface InitVars {
38
41
 
39
42
  export async function run(ctx: CommandContext): Promise<void> {
40
43
  const args = ctx.args;
41
- const targetDirArg = args._[0] ?? '.';
42
- const interactive = !!args.flags.interactive;
43
- let example = (args.flags.example as string) ?? 'empty';
44
- let name = (args.flags.name as string) ?? '';
45
-
46
- // Resolve target directory
47
- const targetDir = resolve(process.cwd(), targetDirArg);
48
- if (!name) {
49
- name = targetDirArg === '.' ? basename(process.cwd()) : basename(targetDir);
50
- }
51
- name = slugify(name);
52
- if (!name) {
53
- console.error('Project name cannot be empty after slugification.');
44
+ const prefillName = (args._[0] as string) ?? '';
45
+
46
+ // `nexus init` is always the guided wizard — it needs a terminal.
47
+ if (!isTty()) {
48
+ console.error('nexus init needs an interactive terminal for the guided wizard.');
49
+ console.error('Run it from a terminal, or use `nexus` to open the welcome screen.');
54
50
  process.exitCode = 1;
55
51
  return;
56
52
  }
57
53
 
58
- // Interactive mode: ask for everything
59
- let mongoUri = (args.flags['mongo-uri'] as string) ?? `mongodb://localhost:27017/${name}`;
60
- let redisUrl = (args.flags['redis-url'] as string) ?? 'redis://localhost:6379';
61
- let aiProviders: string[] = [];
62
- let useVenv = true;
63
-
64
- if (interactive && isInteractive()) {
65
- console.log(`\nCreating Nexus project: ${name}\n`);
66
-
67
- // Example picker
68
- const examples = await listExamples();
69
- example = await select('Start with:', ['empty', ...examples] as const) as string;
70
-
71
- mongoUri = await prompt('MongoDB URI', mongoUri);
72
- redisUrl = await prompt('Redis URL', redisUrl);
73
-
74
- const providerNames = await prompt(
75
- 'AI providers (comma-separated, empty for none) — e.g. ollama,openai',
76
- '',
77
- );
78
- aiProviders = providerNames
79
- .split(',')
80
- .map((s) => s.trim())
81
- .filter(Boolean);
82
-
83
- useVenv = await confirm('Create Python virtualenv for AI server?', true);
54
+ console.log('Launching Nexus project setup…');
55
+ const result = await runWizard(prefillName);
56
+ if (!result) {
57
+ console.log('Setup cancelled.');
58
+ return;
84
59
  }
85
60
 
86
- // Allocate ports via registry (writes .nexus-ports.json)
87
- // We allocate AFTER targetDir is created (see below), so use a temp registry for now.
88
- // Simpler: pre-pick ports via nextFreePort, then commit them once the dir exists.
89
- const backendPort = 4000;
90
- const frontendPort = 3000;
91
- const adminPort = 3300;
92
- const aiPort = 8000;
61
+ const name = result.nameSlug;
62
+ const targetDir = resolve(process.cwd(), name);
63
+
64
+ const includeAdmin = result.includeAdmin;
65
+ const includeFrontend = result.features.includes('frontend');
66
+ const includeAi = result.features.includes('ai-server');
67
+ const backendFeatureIds = result.features.filter((id) => !!getFeature(id)?.templateDir);
93
68
 
94
69
  const vars: InitVars = {
95
70
  name,
96
71
  nameSlug: name,
97
72
  jwtSecret: randomSecret(32),
98
- mongoUri,
99
- redisUrl,
100
- example,
101
- backendPort,
102
- frontendPort,
103
- adminPort,
104
- aiPort,
105
- aiProviders,
73
+ mongoUri: result.mongoUri,
74
+ redisUrl: result.redisUrl,
75
+ example: result.example,
76
+ backendPort: result.ports.backend,
77
+ frontendPort: result.ports.frontend,
78
+ adminPort: result.ports.admin,
79
+ aiPort: result.ports.ai,
80
+ aiProviders: result.aiProviders,
106
81
  };
107
82
 
108
- if (existsSync(targetDir) && targetDirArg !== '.' && !args.flags.force) {
109
- const ok = isInteractive() ? await confirm(`Directory ${targetDir} exists — proceed?`, false) : false;
83
+ if (existsSync(targetDir) && !args.flags.force) {
84
+ const ok = await confirm(`Directory ${targetDir} exists — proceed?`, false);
110
85
  if (!ok) {
111
86
  console.error('Aborted.');
112
87
  process.exitCode = 1;
@@ -114,41 +89,51 @@ export async function run(ctx: CommandContext): Promise<void> {
114
89
  }
115
90
  }
116
91
 
117
- // 1. Create directory
92
+ // 1. Create directory + commit the chosen ports to the registry.
118
93
  await mkdir(targetDir, { recursive: true });
94
+ const registry: PortRegistry = { backend: vars.backendPort };
95
+ if (includeFrontend) registry.frontend = vars.frontendPort;
96
+ if (includeAdmin) registry.admin = vars.adminPort;
97
+ if (includeAi) registry['ai-server'] = vars.aiPort;
98
+ await writeRegistry(targetDir, registry);
119
99
 
120
- // Commit allocated ports into the project's registry so `add` / `dev` see them.
121
- await ensurePort(targetDir, 'backend');
122
- await ensurePort(targetDir, 'frontend');
123
- await ensurePort(targetDir, 'admin');
124
- await ensurePort(targetDir, 'ai-server');
100
+ // 2. Render base templates, excluding apps the user turned off.
101
+ const exclude = (rel: string): boolean =>
102
+ (!includeAdmin && (rel === 'apps/admin' || rel.startsWith('apps/admin/'))) ||
103
+ (!includeFrontend && (rel === 'apps/frontend' || rel.startsWith('apps/frontend/'))) ||
104
+ (!includeAi && (rel === 'apps/ai-server' || rel.startsWith('apps/ai-server/')));
125
105
 
126
- // 2. Render base templates (recursive)
127
- await renderTemplateTree(join(TEMPLATES_DIR, 'base'), targetDir, vars);
106
+ await renderTemplateTree(join(TEMPLATES_DIR, 'base'), targetDir, vars as unknown as Record<string, unknown>, { exclude });
128
107
 
129
108
  // 2b. Create the full Laravel-style folder tree for the default backend.
130
- // Mirrors what `add backend` does so subsequent `make:*` calls find their homes.
131
109
  await ensureBackendFolderTree(join(targetDir, 'apps', 'backend'));
132
110
 
133
- // 3. Apply example overlay (if not 'empty')
134
- if (example && example !== 'empty') {
135
- const exampleDir = join(EXAMPLES_DIR, example);
136
- if (existsSync(exampleDir)) {
137
- await renderTemplateTree(exampleDir, targetDir, vars);
138
- console.log(`✓ Applied example: ${example}`);
111
+ // 3. Apply example overlay (if not 'empty').
112
+ if (vars.example && vars.example !== 'empty') {
113
+ const exampleDir = join(result.examplesDir, vars.example);
114
+ if (result.examplesDir && existsSync(exampleDir)) {
115
+ await renderTemplateTree(exampleDir, targetDir, vars as unknown as Record<string, unknown>);
116
+ console.log(`✓ Applied example: ${vars.example}`);
139
117
  } else {
140
- console.warn(`! Example "${example}" not found — continuing with empty scaffold.`);
118
+ console.warn(`! Example "${vars.example}" not found — continuing with empty scaffold.`);
141
119
  }
142
120
  }
143
121
 
122
+ // 3b. Install selected backend feature starter code.
123
+ for (const id of backendFeatureIds) {
124
+ const feature = getFeature(id);
125
+ if (!feature) continue;
126
+ const res = await installFeature(targetDir, id);
127
+ for (const msg of res.messages) console.log(msg);
128
+ }
129
+
144
130
  // 4. .env
145
131
  const envPath = join(targetDir, '.env');
146
132
  if (!existsSync(envPath)) {
147
- const envContent = buildEnvFile(vars);
148
- await writeFile(envPath, envContent, 'utf-8');
133
+ await writeFile(envPath, buildEnvFile(vars), 'utf-8');
149
134
  }
150
135
 
151
- // 5. Install dependencies (unless --no-install)
136
+ // 5. Install dependencies (unless --no-install).
152
137
  const skipInstall = !!args.flags['no-install'];
153
138
  if (skipInstall) {
154
139
  console.log(`\n( --no-install given — run \`npm install\` in ${targetDir} manually. )`);
@@ -168,15 +153,17 @@ export async function run(ctx: CommandContext): Promise<void> {
168
153
  ✓ Project scaffolded at ${targetDir}
169
154
 
170
155
  Next steps:
171
- cd ${targetDirArg === '.' ? '.' : targetDirArg}
156
+ cd ${name}
172
157
  npm run dev
173
158
 
174
159
  Ports allocated:
175
- Backend : ${backendPort}
176
- Frontend : ${frontendPort}
177
- Admin : ${adminPort}
178
- AI server : ${aiPort}
160
+ Backend : ${vars.backendPort}
161
+ Frontend : ${includeFrontend ? vars.frontendPort : '(not included)'}
162
+ Admin : ${includeAdmin ? vars.adminPort : '(not included)'}
163
+ AI server : ${includeAi ? vars.aiPort : '(not included)'}
179
164
  `);
165
+
166
+
180
167
  }
181
168
 
182
169
  /** Run `npm install` in a directory; returns the exit code. */
@@ -195,13 +182,6 @@ function runNpmInstall(dir: string): Promise<number> {
195
182
  });
196
183
  }
197
184
 
198
- async function listExamples(): Promise<string[]> {
199
- const dir = EXAMPLES_DIR;
200
- if (!existsSync(dir)) return [];
201
- const entries = await readdir(dir, { withFileTypes: true });
202
- return entries.filter((e) => e.isDirectory()).map((e) => e.name);
203
- }
204
-
205
185
  /** The canonical 26-folder Laravel-style tree under a backend's src/. Exported for `add.ts`. */
206
186
  export const BACKEND_FOLDERS = [
207
187
  'src/routes', 'src/graphql', 'src/ws',
@@ -222,38 +202,6 @@ async function ensureBackendFolderTree(backendDir: string): Promise<void> {
222
202
  }
223
203
  }
224
204
 
225
- async function renderTemplateTree(srcDir: string, targetDir: string, vars: InitVars): Promise<void> {
226
- if (!existsSync(srcDir)) {
227
- console.warn(`[init] missing template dir: ${srcDir}`);
228
- return;
229
- }
230
- const entries = await readdir(srcDir, { withFileTypes: true });
231
- for (const e of entries) {
232
- const src = join(srcDir, e.name);
233
- const renderedName = isTemplateName(e.name) ? stripTemplateSuffix(e.name) : e.name;
234
- // Handle special filenames
235
- const fileName = renderedName === 'gitignore' ? '.gitignore'
236
- : renderedName === 'dockerignore' ? '.dockerignore'
237
- : renderedName;
238
- const dest = join(targetDir, fileName);
239
-
240
- if (e.isDirectory()) {
241
- await mkdir(dest, { recursive: true });
242
- await renderTemplateTree(src, dest, vars);
243
- } else if (e.isFile()) {
244
- try {
245
- const raw = await readFile(src, 'utf-8');
246
- const isTemplate = isTemplateName(e.name) || raw.includes('<%');
247
- const content = isTemplate ? renderString(raw, vars as unknown as Record<string, unknown>) : raw;
248
- await mkdir(dirname(dest), { recursive: true });
249
- await writeFile(dest, content, 'utf-8');
250
- } catch (err) {
251
- console.warn(`[init] failed to render ${src}:`, err);
252
- }
253
- }
254
- }
255
- }
256
-
257
205
  function buildEnvFile(vars: InitVars): string {
258
206
  return `# BhooAI Nexus — generated .env
259
207
  # Secrets live here. Do not commit.
@@ -271,8 +219,11 @@ NEXUS_REDIS_URL=${vars.redisUrl}
271
219
  # Auth
272
220
  NEXUS_AUTH_JWT_SECRET=${vars.jwtSecret}
273
221
 
274
- # AI providers (uncomment as needed)
275
- ${vars.aiProviders.map((p) => `NEXUS_AI_${p.toUpperCase()}_API_KEY=`).join('\n') || '# NEXUS_AI_<PROVIDER>_API_KEY='}
222
+ # AI (ollama is local by default; uncomment others as needed)
223
+ NEXUS_AI_PROVIDER=ollama
224
+ NEXUS_AI_MODEL=llama3.1:8b
225
+ OLLAMA_MODEL=llama3.1:8b
226
+ ${vars.aiProviders.filter((p) => p !== 'ollama').map((p) => `# NEXUS_AI_${p.toUpperCase()}_API_KEY=`).join('\n')}
276
227
 
277
228
  # Storage (uncomment for S3)
278
229
  # NEXUS_STORAGE_S3_BUCKET=
@@ -282,5 +233,5 @@ ${vars.aiProviders.map((p) => `NEXUS_AI_${p.toUpperCase()}_API_KEY=`).join('\n')
282
233
  `;
283
234
  }
284
235
 
285
- export const description = 'Scaffold a new Nexus project (quick or interactive)';
286
- export const usage = 'nexus init [dir] [--example <name>] [--interactive] [--name <name>] [--no-install]';
236
+ export const description = 'Scaffold a new Nexus project (guided wizard)';
237
+ export const usage = 'nexus init [name] [--no-install] [--force]';