@bhooai/nexus-cli 2.0.2 → 2.0.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.
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 +74 -125
  4. package/src/commands/init.ts +87 -136
  5. package/src/devPanel.ts +696 -0
  6. package/src/devServiceManager.ts +229 -0
  7. package/src/dispatcher.ts +22 -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.2",
3
+ "version": "2.0.4",
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
  },
@@ -1,26 +1,18 @@
1
1
  /**
2
- * nexus dev — start the dev services:
3
- * every apps/backend-* (tsx watch), frontend(s) (vite), admin (vite), ai-server (python).
2
+ * nexus dev — start the dev services.
4
3
  *
5
- * Sub-process supervision with log prefixing, auto-restart, and clean shutdown
6
- * on Ctrl+C.
4
+ * In an interactive TTY this opens the full-screen Nexus Console control panel
5
+ * (services start/stop/restart + a palette that runs every other CLI command).
6
+ * When stdout isn't a TTY (CI, piped) it falls back to plain prefixed log
7
+ * streaming. Pass `--no-panel` to force plain mode in a TTY.
7
8
  */
8
- import { spawn, type ChildProcess } from 'node:child_process';
9
9
  import { existsSync } from 'node:fs';
10
10
  import { readdir } from 'node:fs/promises';
11
- import { resolve, join } from 'node:path';
11
+ import { join } from 'node:path';
12
12
  import type { CommandContext } from '../dispatcher.js';
13
13
  import { ensurePort } from '../ports.js';
14
-
15
- interface Service {
16
- name: string;
17
- cmd: string;
18
- args: string[];
19
- cwd: string;
20
- color: string;
21
- enabled: boolean;
22
- port: number;
23
- }
14
+ import { ServiceManager, type ServiceSpec } from '../devServiceManager.js';
15
+ import { startDevPanel } from '../devPanel.js';
24
16
 
25
17
  const COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m', '\x1b[34m', '\x1b[31m', '\x1b[37m', '\x1b[90m'];
26
18
  const RESET = '\x1b[0m';
@@ -28,8 +20,59 @@ const RESET = '\x1b[0m';
28
20
  export async function run(ctx: CommandContext): Promise<void> {
29
21
  const only = (ctx.args.flags.only as string)?.split(',').map((s) => s.trim()) ?? null;
30
22
  const projectRoot = process.cwd();
23
+ const noPanel = !!ctx.args.flags['no-panel'];
24
+ const isTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
25
+
26
+ const specs = await discoverServices(projectRoot, only);
27
+ if (specs.length === 0) {
28
+ console.log('No services to start. Check that apps/* exist.');
29
+ return;
30
+ }
31
+
32
+ const manager = new ServiceManager(specs, projectRoot);
33
+
34
+ if (isTty && !noPanel) {
35
+ // Full-screen Nexus Console.
36
+ console.log(`nexus dev — ${specs.length} service(s). Opening console...\n`);
37
+ await startDevPanel({ services: manager, onQuit: () => manager.killAll() });
38
+ return;
39
+ }
40
+
41
+ // Plain mode (piped / CI / --no-panel): start all + stream prefixed logs.
42
+ console.log(`nexus dev — starting ${specs.length} service(s)\n`);
43
+ let shuttingDown = false;
44
+ const shutdown = () => {
45
+ if (shuttingDown) return;
46
+ shuttingDown = true;
47
+ manager.killAll();
48
+ setTimeout(() => process.exit(0), 500);
49
+ };
50
+ process.on('SIGINT', shutdown);
51
+ process.on('SIGTERM', shutdown);
52
+
53
+ // Wire a plain log printer (prints only NEW lines per service).
54
+ const colors = new Map<string, string>();
55
+ specs.forEach((s, i) => colors.set(s.name, COLORS[i % COLORS.length]!));
56
+ const printed = new Map<string, number>();
57
+ manager.onLog = (name) => {
58
+ const svc = manager.get(name);
59
+ if (!svc) return;
60
+ const prefix = `${colors.get(name) ?? ''}[${name}]${RESET}`;
61
+ const from = printed.get(name) ?? 0;
62
+ const newLines = svc.logBuffer.slice(from);
63
+ printed.set(name, svc.logBuffer.length);
64
+ for (const line of newLines) {
65
+ process.stdout.write(`${prefix} ${line}\n`);
66
+ }
67
+ };
68
+
69
+ for (const spec of specs) manager.start(spec.name);
31
70
 
32
- // Discover backends, frontends, admin, ai-server under apps/
71
+ // Keep the process alive.
72
+ await new Promise(() => {});
73
+ }
74
+
75
+ async function discoverServices(projectRoot: string, only: string[] | null): Promise<ServiceSpec[]> {
33
76
  const appsDir = join(projectRoot, 'apps');
34
77
  const appEntries = existsSync(appsDir) ? await readdir(appsDir, { withFileTypes: true }) : [];
35
78
  const backends = appEntries.filter((e) => e.isDirectory() && e.name.startsWith('backend')).map((e) => e.name);
@@ -37,127 +80,33 @@ export async function run(ctx: CommandContext): Promise<void> {
37
80
  const admins = appEntries.filter((e) => e.isDirectory() && e.name.startsWith('admin')).map((e) => e.name);
38
81
  const aiServers = appEntries.filter((e) => e.isDirectory() && e.name === 'ai-server').map((e) => e.name);
39
82
 
40
- const services: Service[] = [];
41
- let colorIdx = 0;
42
-
83
+ const specs: ServiceSpec[] = [];
43
84
  for (const b of backends) {
44
- const mainPath = join(appsDir, b, 'src', 'main.ts');
45
- if (!existsSync(mainPath)) continue;
85
+ if (!existsSync(join(appsDir, b, 'src', 'main.ts'))) continue;
46
86
  const port = await ensurePort(projectRoot, b);
47
- services.push({
48
- name: b,
49
- cmd: 'npx',
50
- args: ['tsx', 'watch', 'src/main.ts'],
51
- cwd: join(appsDir, b),
52
- color: COLORS[colorIdx++ % COLORS.length]!,
53
- enabled: !only || only.includes(b),
54
- port: port,
55
- });
87
+ if (only && !only.includes(b)) continue;
88
+ specs.push({ name: b, cmd: 'npx', args: ['tsx', 'watch', 'src/main.ts'], cwd: join(appsDir, b), port });
56
89
  }
57
90
  for (const f of frontends) {
58
91
  if (!existsSync(join(appsDir, f, 'package.json'))) continue;
59
92
  const port = await ensurePort(projectRoot, f);
60
- services.push({
61
- name: f,
62
- cmd: 'npx',
63
- args: ['vite', '--port', String(port), '--strictPort'],
64
- cwd: join(appsDir, f),
65
- color: COLORS[colorIdx++ % COLORS.length]!,
66
- enabled: !only || only.includes(f),
67
- port,
68
- });
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, portArgIndex: 2 });
69
95
  }
70
96
  for (const a of admins) {
71
97
  if (!existsSync(join(appsDir, a, 'package.json'))) continue;
72
98
  const port = await ensurePort(projectRoot, a);
73
- services.push({
74
- name: a,
75
- cmd: 'npx',
76
- args: ['vite', '--port', String(port), '--strictPort'],
77
- cwd: join(appsDir, a),
78
- color: COLORS[colorIdx++ % COLORS.length]!,
79
- enabled: !only || only.includes(a),
80
- port,
81
- });
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, portArgIndex: 2 });
82
101
  }
83
102
  for (const ai of aiServers) {
84
- const mainPy = join(appsDir, ai, 'main.py');
85
- if (!existsSync(mainPy)) continue;
103
+ if (!existsSync(join(appsDir, ai, 'main.py'))) continue;
86
104
  const port = await ensurePort(projectRoot, ai);
87
- services.push({
88
- name: ai,
89
- cmd: 'python',
90
- args: ['main.py'],
91
- cwd: join(appsDir, ai),
92
- color: COLORS[colorIdx++ % COLORS.length]!,
93
- enabled: !only || only.includes(ai),
94
- port,
95
- });
96
- }
97
-
98
- const active = services.filter((s) => s.enabled);
99
- if (active.length === 0) {
100
- console.log('No services to start. Check that apps/* exist.');
101
- return;
102
- }
103
-
104
- console.log(`nexus dev — starting ${active.length} service(s)\n`);
105
- for (const s of active) {
106
- console.log(` ${s.color}[${s.name}]${RESET} → :${s.port}`);
107
- }
108
- console.log('');
109
-
110
- const procs: ChildProcess[] = [];
111
- let shuttingDown = false;
112
-
113
- const shutdown = () => {
114
- if (shuttingDown) return;
115
- shuttingDown = true;
116
- console.log('\nShutting down...');
117
- for (const p of procs) {
118
- try {
119
- p.kill('SIGTERM');
120
- } catch { /* ignore */ }
121
- }
122
- setTimeout(() => process.exit(0), 500);
123
- };
124
-
125
- process.on('SIGINT', shutdown);
126
- process.on('SIGTERM', shutdown);
127
-
128
- for (const svc of active) {
129
- const child = spawn(svc.cmd, svc.args, {
130
- cwd: svc.cwd,
131
- stdio: ['inherit', 'pipe', 'pipe'],
132
- env: { ...process.env, FORCE_COLOR: '1', NEXUS_PORT: String(svc.port) },
133
- shell: true,
134
- });
135
-
136
- const prefix = `${svc.color}[${svc.name}]${RESET}`;
137
- child.stdout?.on('data', (chunk: Buffer) => {
138
- const lines = chunk.toString().split('\n');
139
- for (const line of lines) {
140
- if (line.trim()) process.stdout.write(`${prefix} ${line}\n`);
141
- }
142
- });
143
- child.stderr?.on('data', (chunk: Buffer) => {
144
- const lines = chunk.toString().split('\n');
145
- for (const line of lines) {
146
- if (line.trim()) process.stderr.write(`${prefix} ${line}\n`);
147
- }
148
- });
149
- child.on('exit', (code, signal) => {
150
- if (shuttingDown) return;
151
- console.log(`${prefix} exited (${code ?? signal}). Restarting in 1s...`);
152
- setTimeout(() => {
153
- if (!shuttingDown) {
154
- procs.push(spawn(svc.cmd, svc.args, { cwd: svc.cwd, stdio: 'inherit', env: process.env, shell: true }));
155
- }
156
- }, 1000);
157
- });
158
- procs.push(child);
105
+ if (only && !only.includes(ai)) continue;
106
+ specs.push({ name: ai, cmd: 'python', args: ['main.py'], cwd: join(appsDir, ai), port });
159
107
  }
108
+ return specs;
160
109
  }
161
110
 
162
- export const description = 'Start dev services (all apps under ./apps)';
163
- export const usage = 'nexus dev [--only backend,frontend]';
111
+ export const description = 'Start dev services (all apps under ./apps) — interactive console in a TTY';
112
+ export const usage = 'nexus dev [--only a,b] [--no-panel]';
@@ -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]';