@bhooai/nexus-cli 2.0.3 → 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 +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
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { spawn, type ChildProcess } from 'node:child_process';
9
9
  import type { Readable } from 'node:stream';
10
+ import { nextFreePort } from './util.js';
11
+ import { readRegistry, writeRegistry, type PortRegistry } from './ports.js';
10
12
 
11
13
  export type ServiceStatus = 'starting' | 'running' | 'stopped' | 'crashed';
12
14
 
@@ -16,6 +18,8 @@ export interface ServiceSpec {
16
18
  args: string[];
17
19
  cwd: string;
18
20
  port: number;
21
+ /** Index into args[] that holds the port value (for vite --port). Undefined = no editable port arg (backend uses env). */
22
+ portArgIndex?: number;
19
23
  }
20
24
 
21
25
  export interface ManagedService extends ServiceSpec {
@@ -23,6 +27,8 @@ export interface ManagedService extends ServiceSpec {
23
27
  pid: number | null;
24
28
  restarts: number;
25
29
  lastExit: { code: number | null; signal: NodeJS.Signals | null } | null;
30
+ /** Last log lines captured before crash (for inline display in the panel). */
31
+ lastCrashLog: string[];
26
32
  logBuffer: string[];
27
33
  child: ChildProcess | null;
28
34
  }
@@ -34,10 +40,12 @@ const CRASH_WINDOW_MS = 30_000;
34
40
  export class ServiceManager {
35
41
  readonly services: ManagedService[] = [];
36
42
  private crashTimes = new Map<string, number[]>();
43
+ private projectRoot: string;
37
44
 
38
- constructor(specs: ServiceSpec[]) {
45
+ constructor(specs: ServiceSpec[], projectRoot: string) {
46
+ this.projectRoot = projectRoot;
39
47
  for (const spec of specs) {
40
- this.services.push({ ...spec, status: 'stopped', pid: null, restarts: 0, lastExit: null, logBuffer: [], child: null });
48
+ this.services.push({ ...spec, status: 'stopped', pid: null, restarts: 0, lastExit: null, lastCrashLog: [], logBuffer: [], child: null });
41
49
  }
42
50
  }
43
51
 
@@ -103,13 +111,29 @@ export class ServiceManager {
103
111
  svc.status = 'running';
104
112
  });
105
113
 
106
- child.on('exit', (code, signal) => {
114
+ child.on('exit', async (code, signal) => {
115
+ const exitInfo = { code, signal };
107
116
  svc.child = null;
108
117
  svc.pid = null;
109
- svc.lastExit = { code, signal };
118
+ svc.lastExit = exitInfo;
119
+
120
+ // --- EADDRINUSE: auto-bump port and restart ---
121
+ const recentLogs = svc.logBuffer.slice(-20).join('\n');
122
+ if (/\bEADDRINUSE\b/i.test(recentLogs)) {
123
+ const bump = await this.bumpPort(svc);
124
+ if (bump) {
125
+ this.push(svc, `⚡ Port ${bump.oldPort} busy → bumped to ${bump.newPort}, restarting ${svc.name}`);
126
+ svc.status = 'stopped';
127
+ setTimeout(() => this.start(svc.name), 300);
128
+ return;
129
+ }
130
+ }
131
+
132
+ // --- Crash-loop guard ---
110
133
  const wasCrashed = this.isCrashLooping(svc.name);
111
134
  if (wasCrashed) {
112
135
  svc.status = 'crashed';
136
+ svc.lastCrashLog = svc.logBuffer.slice(-8);
113
137
  this.push(svc, `✗ ${svc.name} crashed repeatedly — press s to start again`);
114
138
  return;
115
139
  }
@@ -162,6 +186,28 @@ export class ServiceManager {
162
186
  /** Called whenever a service's log buffer changes (for panel redraw). */
163
187
  onLog: ((serviceName: string) => void) | null = null;
164
188
 
189
+ /** Bump a service's port on EADDRINUSE and persist the new port to the registry. */
190
+ private async bumpPort(svc: ManagedService): Promise<{ oldPort: number; newPort: number } | null> {
191
+ const oldPort = svc.port;
192
+ const newPort = await nextFreePort(oldPort + 1);
193
+
194
+ // Update the args array if the service has an editable port arg (vite).
195
+ if (svc.portArgIndex !== undefined && svc.portArgIndex < svc.args.length) {
196
+ svc.args[svc.portArgIndex] = String(newPort);
197
+ }
198
+ // Update the port field (used by env var and registry).
199
+ svc.port = newPort;
200
+
201
+ // Persist to registry so the next run keeps the bumped port.
202
+ try {
203
+ const reg: PortRegistry = await readRegistry(this.projectRoot);
204
+ reg[svc.name] = newPort;
205
+ await writeRegistry(this.projectRoot, reg);
206
+ } catch { /* non-fatal */ }
207
+
208
+ return { oldPort, newPort };
209
+ }
210
+
165
211
  private recordCrashWindow(name: string): void {
166
212
  const now = Date.now();
167
213
  const list = (this.crashTimes.get(name) ?? []).filter((t) => now - t < CRASH_WINDOW_MS);
package/src/dispatcher.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { parseArgs } from './util.js';
8
8
  import { closePrompts } from './prompts.js';
9
+ import { launchWelcome } from './launcher.js';
9
10
 
10
11
  export interface CommandContext {
11
12
  argv: string[];
@@ -42,6 +43,11 @@ export async function runCommand(name: string, argv: string[]): Promise<void> {
42
43
 
43
44
  export async function run(cmd: string, argv: string[]): Promise<void> {
44
45
  try {
46
+ // Bare `nexus` → full-screen welcome launcher.
47
+ if (!cmd) {
48
+ await launchWelcome();
49
+ return;
50
+ }
45
51
  if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
46
52
  printHelp();
47
53
  return;
@@ -69,7 +75,7 @@ function printHelp(): void {
69
75
  Usage: nexus <command> [options]
70
76
 
71
77
  Project lifecycle:
72
- init [dir] [--example <name>] [--interactive] Scaffold a new project
78
+ init [name] [--no-install] [--force] Scaffold a new project (guided wizard)
73
79
  dev [--only a,b] Start dev services
74
80
  build [--target <app>|all] Build for production
75
81
  test [--watch|--e2e] Run tests
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Examples resolution + on-demand install.
3
+ *
4
+ * Examples ship bundled inside `bhooai-nexus` at
5
+ * `packages/nexus-examples/examples`. In a normal install that dir is present
6
+ * and the wizard reads it directly. If it's missing (a stripped/custom
7
+ * install), `ensureExamples()` falls back to installing
8
+ * `@bhooai/nexus-examples` into a persistent temp cache and reads from there.
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { readdir } from 'node:fs/promises';
12
+ import { spawn } from 'node:child_process';
13
+ import { resolve, dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { tmpdir } from 'node:os';
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url));
18
+
19
+ /** Bundled examples dir: packages/nexus-examples/examples (2-up from src/). */
20
+ export const BUNDLED_EXAMPLES_DIR = resolve(HERE, '..', '..', 'nexus-examples', 'examples');
21
+
22
+ /** Persistent cache dir for the on-demand install fallback. */
23
+ const CACHE_DIR = join(tmpdir(), 'nexus-examples-cache');
24
+ const CACHE_EXAMPLES_DIR = join(CACHE_DIR, 'node_modules', '@bhooai', 'nexus-examples', 'examples');
25
+
26
+ /** List example names (subdirectories) inside a resolved examples dir. */
27
+ export async function listExamplesFrom(dir: string): Promise<string[]> {
28
+ if (!existsSync(dir)) return [];
29
+ try {
30
+ const entries = await readdir(dir, { withFileTypes: true });
31
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ /** Synchronously return the bundled examples dir if it has examples, else null. */
38
+ export function resolveExamplesDir(): string | null {
39
+ if (existsSync(BUNDLED_EXAMPLES_DIR)) return BUNDLED_EXAMPLES_DIR;
40
+ if (existsSync(CACHE_EXAMPLES_DIR)) return CACHE_EXAMPLES_DIR;
41
+ return null;
42
+ }
43
+
44
+ export interface EnsureExamplesResult {
45
+ dir: string;
46
+ list: string[];
47
+ installed: boolean;
48
+ }
49
+
50
+ /**
51
+ * Ensure the examples are available, installing @bhooai/nexus-examples on
52
+ * demand when the bundled dir is missing. Returns the resolved dir + list.
53
+ */
54
+ export async function ensureExamples(): Promise<EnsureExamplesResult | { error: string }> {
55
+ // Bundled first.
56
+ const bundled = await listExamplesFrom(BUNDLED_EXAMPLES_DIR);
57
+ if (bundled.length > 0) {
58
+ return { dir: BUNDLED_EXAMPLES_DIR, list: bundled, installed: false };
59
+ }
60
+
61
+ // Cached install already present?
62
+ const cached = await listExamplesFrom(CACHE_EXAMPLES_DIR);
63
+ if (cached.length > 0) {
64
+ return { dir: CACHE_EXAMPLES_DIR, list: cached, installed: false };
65
+ }
66
+
67
+ // On-demand install into the cache.
68
+ const code = await runNpmInstall(CACHE_DIR, '@bhooai/nexus-examples');
69
+ if (code !== 0) {
70
+ return { error: 'Failed to install @bhooai/nexus-examples (network or npm error).' };
71
+ }
72
+ const list = await listExamplesFrom(CACHE_EXAMPLES_DIR);
73
+ if (list.length === 0) {
74
+ return { error: '@bhooai/nexus-examples installed but no examples were found inside it.' };
75
+ }
76
+ return { dir: CACHE_EXAMPLES_DIR, list, installed: true };
77
+ }
78
+
79
+ /** Run `npm install <pkg>` in a directory; returns the exit code. */
80
+ function runNpmInstall(dir: string, pkg: string): Promise<number> {
81
+ return new Promise((res) => {
82
+ const child = spawn('npm', ['install', '--no-audit', '--no-fund', '--prefix', dir, pkg], {
83
+ cwd: dir,
84
+ stdio: 'ignore',
85
+ shell: process.platform === 'win32',
86
+ });
87
+ child.on('error', () => res(1));
88
+ child.on('exit', (code) => res(code ?? 1));
89
+ });
90
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Feature catalog + installer.
3
+ *
4
+ * A single source of truth shared by the init wizard (feature picker) and the
5
+ * dev console's Features tab. Each feature either:
6
+ * - provides a whole app folder (frontend / admin / ai-server) rendered from
7
+ * the base template tree, or
8
+ * - overlays starter code (routes / models / ws rooms / graphql / jobs / …)
9
+ * from templates/features/<id> into an existing project.
10
+ *
11
+ * Env keys are appended to `.env` (as placeholders) when a feature is installed.
12
+ */
13
+ import { existsSync } from 'node:fs';
14
+ import { readFile, readdir, writeFile, appendFile } from 'node:fs/promises';
15
+ import { resolve, join, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { renderTemplateTree } from './templating/tree.js';
18
+ import { ensurePort, readRegistry } from './ports.js';
19
+ import { slugify } from './util.js';
20
+
21
+ const HERE = dirname(fileURLToPath(import.meta.url));
22
+ const TEMPLATES_DIR = resolve(HERE, '..', 'templates');
23
+ const FEATURES_TEMPLATES = join(TEMPLATES_DIR, 'features');
24
+ const BASE_TEMPLATES = join(TEMPLATES_DIR, 'base');
25
+
26
+ export interface Feature {
27
+ id: string;
28
+ name: string;
29
+ desc: string;
30
+ category: 'frontend' | 'backend' | 'infra';
31
+ /** App folder this feature provides (rendered from templates/base/apps/<app>). */
32
+ app?: 'frontend' | 'admin' | 'ai-server';
33
+ /** Overlay template dir under templates/features/<id>. */
34
+ templateDir?: string;
35
+ envKeys: string[];
36
+ }
37
+
38
+ export const FEATURES: Feature[] = [
39
+ {
40
+ id: 'frontend',
41
+ name: 'React Frontend',
42
+ desc: 'Vite + React SPA that proxies /api, /uploads and /ws to the backend.',
43
+ category: 'frontend',
44
+ app: 'frontend',
45
+ envKeys: [],
46
+ },
47
+ {
48
+ id: 'admin',
49
+ name: 'Admin Panel',
50
+ desc: 'Nexus admin console (apps/admin) with live service registry + users.',
51
+ category: 'frontend',
52
+ app: 'admin',
53
+ envKeys: [],
54
+ },
55
+ {
56
+ id: 'ai-server',
57
+ name: 'AI Server',
58
+ desc: 'Python FastAPI server behind the /ai proxy, plus provider API keys.',
59
+ category: 'infra',
60
+ app: 'ai-server',
61
+ envKeys: ['NEXUS_AI_OPENAI_API_KEY', 'NEXUS_AI_OLLAMA_API_KEY', 'NEXUS_AI_ANTHROPIC_API_KEY'],
62
+ },
63
+ {
64
+ id: 'auth',
65
+ name: 'Auth Starter',
66
+ desc: 'register / login / me routes, a User model and JWT sessions.',
67
+ category: 'backend',
68
+ templateDir: 'auth',
69
+ envKeys: [],
70
+ },
71
+ {
72
+ id: 'realtime',
73
+ name: 'Realtime',
74
+ desc: 'A WebSocket chat room (presence, typing) on the /ws endpoint.',
75
+ category: 'backend',
76
+ templateDir: 'realtime',
77
+ envKeys: [],
78
+ },
79
+ {
80
+ id: 'graphql',
81
+ name: 'GraphQL',
82
+ desc: 'Federation subgraph starter (posts + authors) under /graphql.',
83
+ category: 'backend',
84
+ templateDir: 'graphql',
85
+ envKeys: [],
86
+ },
87
+ {
88
+ id: 'payments',
89
+ name: 'Payments',
90
+ desc: 'Checkout + webhook routes wired for Razorpay and PayPal.',
91
+ category: 'backend',
92
+ templateDir: 'payments',
93
+ envKeys: [
94
+ 'NEXUS_PAYMENTS_RAZORPAY_KEY_ID',
95
+ 'NEXUS_PAYMENTS_RAZORPAY_KEY_SECRET',
96
+ 'NEXUS_PAYMENTS_PAYPAL_CLIENT_ID',
97
+ 'NEXUS_PAYMENTS_PAYPAL_CLIENT_SECRET',
98
+ ],
99
+ },
100
+ {
101
+ id: 'email',
102
+ name: 'Email',
103
+ desc: 'A Welcome Mailable + EJS template, rendered via the log provider.',
104
+ category: 'backend',
105
+ templateDir: 'email',
106
+ envKeys: [],
107
+ },
108
+ {
109
+ id: 'storage',
110
+ name: 'File Storage',
111
+ desc: 'Upload route + signed private URLs + local/S3 disk config.',
112
+ category: 'backend',
113
+ templateDir: 'storage',
114
+ envKeys: [
115
+ 'NEXUS_STORAGE_S3_BUCKET',
116
+ 'NEXUS_STORAGE_S3_REGION',
117
+ 'NEXUS_STORAGE_S3_ENDPOINT',
118
+ 'NEXUS_STORAGE_S3_CDN',
119
+ ],
120
+ },
121
+ {
122
+ id: 'queue',
123
+ name: 'Queue & Jobs',
124
+ desc: 'Example job + listener, dispatched to the Redis queue.',
125
+ category: 'backend',
126
+ templateDir: 'queue',
127
+ envKeys: [],
128
+ },
129
+ ];
130
+
131
+ export function getFeature(id: string): Feature | undefined {
132
+ return FEATURES.find((f) => f.id === id);
133
+ }
134
+
135
+ /** Features that ship as a whole app folder under templates/base/apps. */
136
+ export function appFeatures(): Feature[] {
137
+ return FEATURES.filter((f): f is Feature & { app: NonNullable<Feature['app']> } => !!f.app);
138
+ }
139
+
140
+ /** Features that overlay starter code under apps/backend/src. */
141
+ export function backendFeatures(): Feature[] {
142
+ return FEATURES.filter((f) => !!f.templateDir);
143
+ }
144
+
145
+ /** Vars needed to render base app templates against an existing project. */
146
+ export interface ProjectVars {
147
+ name: string;
148
+ nameSlug: string;
149
+ backendPort: number;
150
+ frontendPort: number;
151
+ adminPort: number;
152
+ aiPort: number;
153
+ }
154
+
155
+ /** Derive render vars for a feature install from the project's package.json + port registry. */
156
+ export async function projectVars(projectRoot: string): Promise<ProjectVars> {
157
+ let name = 'app';
158
+ try {
159
+ const raw = await readFile(join(projectRoot, 'package.json'), 'utf-8');
160
+ const pkg = JSON.parse(raw) as { name?: string };
161
+ name = pkg.name ?? 'app';
162
+ } catch { /* leave default */ }
163
+ const reg = await readRegistry(projectRoot);
164
+ return {
165
+ name,
166
+ nameSlug: slugify(name),
167
+ backendPort: reg.backend ?? 4000,
168
+ frontendPort: reg.frontend ?? 3000,
169
+ adminPort: reg.admin ?? 3300,
170
+ aiPort: reg['ai-server'] ?? 8000,
171
+ };
172
+ }
173
+
174
+ /** Detect which catalog features are already installed in a project. */
175
+ export async function detectFeatures(projectRoot: string): Promise<Set<string>> {
176
+ const installed = new Set<string>();
177
+ const apps = join(projectRoot, 'apps');
178
+ const backendSrc = join(apps, 'backend', 'src');
179
+
180
+ const has = (p: string) => existsSync(join(projectRoot, p));
181
+ const hasInDir = async (dir: string, predicate: (name: string) => boolean): Promise<boolean> => {
182
+ if (!existsSync(dir)) return false;
183
+ try {
184
+ const entries = await readdir(dir, { withFileTypes: true });
185
+ return entries.some((e) => predicate(e.name));
186
+ } catch {
187
+ return false;
188
+ }
189
+ };
190
+
191
+ if (has('apps/frontend/package.json')) installed.add('frontend');
192
+ if (has('apps/admin/package.json')) installed.add('admin');
193
+ if (has('apps/ai-server/main.py')) installed.add('ai-server');
194
+
195
+ if (has('apps/backend/src/routes/auth.ts')) installed.add('auth');
196
+ if (await hasInDir(join(backendSrc, 'ws'), (n) => n.endsWith('.room.ts'))) installed.add('realtime');
197
+ if (await hasInDir(join(backendSrc, 'graphql'), (n) => n.endsWith('.graph.ts'))) installed.add('graphql');
198
+ if (has('apps/backend/src/routes/payments.ts')) installed.add('payments');
199
+ if (await hasInDir(join(backendSrc, 'mail', 'mailables'), (n) => n.endsWith('.ts'))) installed.add('email');
200
+ if (has('apps/backend/src/routes/uploads.ts')) installed.add('storage');
201
+ if (await hasInDir(join(backendSrc, 'jobs'), (n) => n.endsWith('Job.ts'))) installed.add('queue');
202
+
203
+ return installed;
204
+ }
205
+
206
+ export interface InstallResult {
207
+ messages: string[];
208
+ addedApps: string[];
209
+ }
210
+
211
+ /**
212
+ * Install a feature into an existing project. Creates app folders / overlays
213
+ * starter code, ensures ports are registered and appends env-key placeholders.
214
+ */
215
+ export async function installFeature(projectRoot: string, id: string): Promise<InstallResult> {
216
+ const feature = getFeature(id);
217
+ const messages: string[] = [];
218
+ const addedApps: string[] = [];
219
+ if (!feature) {
220
+ messages.push(`✗ Unknown feature: ${id}`);
221
+ return { messages, addedApps };
222
+ }
223
+ const vars = (await projectVars(projectRoot)) as unknown as Record<string, unknown>;
224
+
225
+ if (feature.app) {
226
+ const appDir = join(projectRoot, 'apps', feature.app);
227
+ if (existsSync(appDir)) {
228
+ messages.push(`• ${feature.name} — apps/${feature.app} already exists`);
229
+ } else {
230
+ await renderTemplateTree(join(BASE_TEMPLATES, 'apps', feature.app), appDir, vars);
231
+ await ensurePort(projectRoot, feature.app);
232
+ addedApps.push(feature.app);
233
+ messages.push(`✓ ${feature.name} — created apps/${feature.app}`);
234
+ }
235
+ }
236
+
237
+ if (feature.templateDir) {
238
+ await renderTemplateTree(join(FEATURES_TEMPLATES, feature.templateDir), projectRoot, vars);
239
+ messages.push(`✓ ${feature.name} — starter code added under apps/backend/src`);
240
+ }
241
+
242
+ await appendEnvKeys(projectRoot, feature.envKeys);
243
+ return { messages, addedApps };
244
+ }
245
+
246
+ /** Append commented env-key placeholders to .env when they are not present. */
247
+ async function appendEnvKeys(projectRoot: string, keys: string[]): Promise<void> {
248
+ if (keys.length === 0) return;
249
+ const envPath = join(projectRoot, '.env');
250
+ if (!existsSync(envPath)) await writeFile(envPath, '# BhooAI Nexus — generated .env\n', 'utf-8');
251
+ const current = existsSync(envPath) ? await readFile(envPath, 'utf-8') : '';
252
+ const lines: string[] = [];
253
+ for (const key of keys) {
254
+ if (current.includes(key)) continue;
255
+ lines.push(`# ${key}=`);
256
+ }
257
+ if (lines.length > 0) await appendFile(envPath, '\n' + lines.join('\n') + '\n', 'utf-8');
258
+ }
259
+
260
+ /** Read the current port registry (exported for the Settings tab). */
261
+ export { readRegistry };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Welcome launcher — full-screen menu shown when `nexus` runs with no
3
+ * command. Offers project init (runs the guided wizard), opening an existing
4
+ * project, an environment check, docs, and quit.
5
+ *
6
+ * Exits its screen before running any action so the sub-command owns the TTY.
7
+ */
8
+ import { existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { stdout as output } from 'node:process';
11
+ import { ANSI, Tui, isTty, type KeyInfo } from './tui.js';
12
+ import { boxAround, fitCell } from './layout.js';
13
+ import { runCommand } from './dispatcher.js';
14
+
15
+ type MenuAction = 'init' | 'open' | 'doctor' | 'docs' | 'quit';
16
+
17
+ interface MenuItem {
18
+ id: MenuAction;
19
+ label: string;
20
+ desc: string;
21
+ enabled: boolean;
22
+ }
23
+
24
+ const BANNER = `${ANSI.cyan}${ANSI.bold}BhooAI Nexus${ANSI.reset}`;
25
+
26
+ interface MenuState {
27
+ index: number;
28
+ result: MenuAction | null;
29
+ }
30
+
31
+ export async function launchWelcome(): Promise<void> {
32
+ for (;;) {
33
+ const choice = await showMenu();
34
+ switch (choice) {
35
+ case 'init':
36
+ await runCommand('init', []);
37
+ return; // init scaffolds + can open dev; back to the shell.
38
+ case 'open':
39
+ await runCommand('dev', []);
40
+ break;
41
+ case 'doctor':
42
+ await runCommand('doctor', []);
43
+ await pause();
44
+ break;
45
+ case 'docs':
46
+ printDocs();
47
+ await pause();
48
+ break;
49
+ case 'quit':
50
+ case null:
51
+ return;
52
+ }
53
+ }
54
+ }
55
+
56
+ async function showMenu(): Promise<MenuAction | null> {
57
+ if (!isTty()) {
58
+ console.log(BANNER);
59
+ console.log(' Welcome to BhooAI Nexus! (full-screen launcher needs a TTY)\n');
60
+ console.log(' nexus init [name] Guided project setup');
61
+ console.log(' nexus dev Run the dev console');
62
+ console.log(' nexus doctor Environment check');
63
+ console.log(' nexus help CLI reference\n');
64
+ return null;
65
+ }
66
+
67
+ const hasProject = existsSync(join(process.cwd(), 'apps'));
68
+ const items: MenuItem[] = [
69
+ { id: 'init', label: 'Init project', desc: 'Scaffold a new Nexus project (guided wizard)', enabled: true },
70
+ { id: 'open', label: 'Open project', desc: 'Run the dev console for this directory', enabled: hasProject },
71
+ { id: 'doctor', label: 'Environment check', desc: 'Verify Node, npm, MongoDB and Redis', enabled: true },
72
+ { id: 'docs', label: 'Docs', desc: 'Show the CLI overview', enabled: true },
73
+ { id: 'quit', label: 'Quit', desc: 'Exit', enabled: true },
74
+ ];
75
+
76
+ const state: MenuState = { index: 0, result: null };
77
+ const tui = new Tui((str, key) => handleMenuKey(state, items, tui, str, key));
78
+ tui.enter();
79
+ renderMenu(state, items, tui);
80
+ await tui.wait();
81
+ tui.exit();
82
+ return state.result;
83
+ }
84
+
85
+ function handleMenuKey(state: MenuState, items: MenuItem[], tui: Tui, _str: string, key: KeyInfo): void {
86
+ void _str;
87
+ if (key.name === 'c' && key.ctrl) {
88
+ state.result = 'quit';
89
+ tui.quit = true;
90
+ return;
91
+ }
92
+ if (key.name === 'q' || key.name === 'escape') {
93
+ state.result = 'quit';
94
+ tui.quit = true;
95
+ return;
96
+ }
97
+ if (key.name === 'up') {
98
+ state.index = Math.max(0, state.index - 1);
99
+ renderMenu(state, items, tui);
100
+ } else if (key.name === 'down') {
101
+ state.index = Math.min(items.length - 1, state.index + 1);
102
+ renderMenu(state, items, tui);
103
+ } else if (key.name === 'return' || key.name === 'enter') {
104
+ const item = items[state.index];
105
+ if (!item?.enabled) return;
106
+ state.result = item.id;
107
+ tui.quit = true;
108
+ }
109
+ }
110
+
111
+ function renderMenu(state: MenuState, items: MenuItem[], tui: Tui): void {
112
+ const W = output.columns || 80;
113
+ const innerW = Math.min(Math.max(W - 6, 40), 64);
114
+
115
+ const inner: string[] = [
116
+ `${ANSI.bold}${ANSI.cyan}BhooAI Nexus${ANSI.reset}`,
117
+ `${ANSI.dim}Full-stack framework — one config, one CLI, multi-app monorepo${ANSI.reset}`,
118
+ '',
119
+ `${ANSI.bold}${ANSI.green}Welcome!${ANSI.reset} ${ANSI.dim}What do you want to do?${ANSI.reset}`,
120
+ '',
121
+ ];
122
+
123
+ const labelW = 18;
124
+ items.forEach((item, i) => {
125
+ const sel = i === state.index;
126
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
127
+ const label = sel ? `${ANSI.bold}${item.label}${ANSI.reset}` : item.label;
128
+ const color = item.enabled ? '' : ANSI.dimGray;
129
+ const desc = item.enabled ? item.desc : '— not available here —';
130
+ inner.push(`${color} ${marker} ${fitCell(label, labelW)} ${ANSI.dim}${desc}${ANSI.reset}`);
131
+ });
132
+
133
+ const rows = ['', ...boxAround(inner, innerW), ''];
134
+ rows.push(`${ANSI.dim} ↑/↓ move · Enter select · q quit${ANSI.reset}`);
135
+ tui.draw(rows);
136
+ }
137
+
138
+ function printDocs(): void {
139
+ console.log(`
140
+ nexus — BhooAI Nexus CLI (v2)
141
+
142
+ Project lifecycle:
143
+ init [name] Scaffold a new project (guided wizard)
144
+ dev Start dev services (console)
145
+ build [--target <app>|all] Build for production
146
+ test [--watch|--e2e] Run tests
147
+ doctor Environment health check
148
+
149
+ Multi-app scaffolding:
150
+ add backend <name> · add frontend <name> · add route <name> --app <backend>
151
+
152
+ Generators (make:*): route, controller, model, service, repository, middleware,
153
+ validator, job, event, listener, policy, resource, request, mail, room,
154
+ subgraph, seeder, migration, provider, plugin
155
+
156
+ Data / queue: db:seed · db:migrate · db:rollback · queue:work · queue:retry
157
+
158
+ Run \`nexus <command> --help\` for flags (where implemented).
159
+ `);
160
+ }
161
+
162
+ async function pause(): Promise<void> {
163
+ await new Promise((r) => setTimeout(r, 600));
164
+ }