@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
@@ -0,0 +1,229 @@
1
+ /**
2
+ * devServiceManager — supervises the app services started by `nexus dev`.
3
+ *
4
+ * Owns each service's process + state and provides start/stop/restart with a
5
+ * crash-loop guard. Log output is captured into a per-service ring buffer so
6
+ * the control panel can render live logs.
7
+ */
8
+ import { spawn, type ChildProcess } from 'node:child_process';
9
+ import type { Readable } from 'node:stream';
10
+ import { nextFreePort } from './util.js';
11
+ import { readRegistry, writeRegistry, type PortRegistry } from './ports.js';
12
+
13
+ export type ServiceStatus = 'starting' | 'running' | 'stopped' | 'crashed';
14
+
15
+ export interface ServiceSpec {
16
+ name: string;
17
+ cmd: string;
18
+ args: string[];
19
+ cwd: string;
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;
23
+ }
24
+
25
+ export interface ManagedService extends ServiceSpec {
26
+ status: ServiceStatus;
27
+ pid: number | null;
28
+ restarts: number;
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[];
32
+ logBuffer: string[];
33
+ child: ChildProcess | null;
34
+ }
35
+
36
+ const MAX_LOG_LINES = 200;
37
+ const CRASH_THRESHOLD = 5;
38
+ const CRASH_WINDOW_MS = 30_000;
39
+
40
+ export class ServiceManager {
41
+ readonly services: ManagedService[] = [];
42
+ private crashTimes = new Map<string, number[]>();
43
+ private projectRoot: string;
44
+
45
+ constructor(specs: ServiceSpec[], projectRoot: string) {
46
+ this.projectRoot = projectRoot;
47
+ for (const spec of specs) {
48
+ this.services.push({ ...spec, status: 'stopped', pid: null, restarts: 0, lastExit: null, lastCrashLog: [], logBuffer: [], child: null });
49
+ }
50
+ }
51
+
52
+ /** Get a service by name. */
53
+ get(name: string): ManagedService | null {
54
+ return this.services.find((s) => s.name === name) ?? null;
55
+ }
56
+
57
+ /** Start a service (idempotent). */
58
+ start(name: string): void {
59
+ const svc = this.get(name);
60
+ if (!svc || svc.child) return;
61
+ this.spawnChild(svc);
62
+ }
63
+
64
+ /** Stop a service (SIGTERM, then SIGKILL after a grace period). */
65
+ stop(name: string): void {
66
+ const svc = this.get(name);
67
+ if (!svc || !svc.child) return;
68
+ this.killChild(svc, 'SIGTERM');
69
+ }
70
+
71
+ /** Restart a service. */
72
+ restart(name: string): void {
73
+ const svc = this.get(name);
74
+ if (!svc) return;
75
+ if (svc.child) this.killChild(svc, 'SIGTERM');
76
+ // Restart after a short delay so the port frees up.
77
+ setTimeout(() => this.start(name), 250);
78
+ }
79
+
80
+ /** Kill all services (used on panel quit). */
81
+ killAll(): void {
82
+ for (const svc of this.services) {
83
+ if (svc.child) this.killChild(svc, 'SIGTERM');
84
+ }
85
+ }
86
+
87
+ private spawnChild(svc: ManagedService): void {
88
+ svc.status = 'starting';
89
+ svc.restarts++;
90
+ this.recordCrashWindow(svc.name);
91
+
92
+ // On Windows with shell:true, a cmd path containing spaces must be quoted
93
+ // or the shell splits it ('C:\Program' is not recognized).
94
+ const cmd = process.platform === 'win32' && svc.cmd.includes(' ')
95
+ ? `"${svc.cmd}"`
96
+ : svc.cmd;
97
+
98
+ const child = spawn(cmd, svc.args, {
99
+ cwd: svc.cwd,
100
+ stdio: ['ignore', 'pipe', 'pipe'],
101
+ env: { ...process.env, FORCE_COLOR: '1', NEXUS_PORT: String(svc.port) },
102
+ shell: process.platform === 'win32',
103
+ });
104
+ svc.child = child;
105
+ svc.pid = child.pid ?? null;
106
+
107
+ this.pipe(child.stdout, svc);
108
+ this.pipe(child.stderr, svc);
109
+
110
+ child.once('spawn', () => {
111
+ svc.status = 'running';
112
+ });
113
+
114
+ child.on('exit', async (code, signal) => {
115
+ const exitInfo = { code, signal };
116
+ svc.child = null;
117
+ svc.pid = null;
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 ---
133
+ const wasCrashed = this.isCrashLooping(svc.name);
134
+ if (wasCrashed) {
135
+ svc.status = 'crashed';
136
+ svc.lastCrashLog = svc.logBuffer.slice(-8);
137
+ this.push(svc, `✗ ${svc.name} crashed repeatedly — press s to start again`);
138
+ return;
139
+ }
140
+ svc.status = 'stopped';
141
+ if (svc.restarts > 0 && !this.explicitlyStopping) {
142
+ this.push(svc, `↻ ${svc.name} exited (${code ?? signal}) — restarting in 1s`);
143
+ setTimeout(() => this.start(svc.name), 1000);
144
+ }
145
+ });
146
+ }
147
+
148
+ private explicitlyStopping = false;
149
+
150
+ private killChild(svc: ManagedService, signal: NodeJS.Signals): void {
151
+ this.explicitlyStopping = true;
152
+ const child = svc.child;
153
+ if (!child) return;
154
+ try {
155
+ child.kill(signal);
156
+ } catch { /* ignore */ }
157
+ // Force-kill if it lingers.
158
+ setTimeout(() => {
159
+ if (svc.child && svc.pid) {
160
+ try { process.kill(svc.pid, 'SIGKILL'); } catch { /* ignore */ }
161
+ }
162
+ }, 3000);
163
+ setTimeout(() => {
164
+ this.explicitlyStopping = false;
165
+ }, 3500);
166
+ }
167
+
168
+ private pipe(stream: Readable | null, svc: ManagedService): void {
169
+ if (!stream) return;
170
+ stream.on('data', (chunk: Buffer) => {
171
+ const text = chunk.toString();
172
+ this.push(svc, text);
173
+ });
174
+ }
175
+
176
+ private push(svc: ManagedService, text: string): void {
177
+ const lines = text.split('\n');
178
+ for (const line of lines) {
179
+ if (!line.trim()) continue;
180
+ svc.logBuffer.push(line);
181
+ if (svc.logBuffer.length > MAX_LOG_LINES) svc.logBuffer.shift();
182
+ }
183
+ this.onLog?.(svc.name);
184
+ }
185
+
186
+ /** Called whenever a service's log buffer changes (for panel redraw). */
187
+ onLog: ((serviceName: string) => void) | null = null;
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
+
211
+ private recordCrashWindow(name: string): void {
212
+ const now = Date.now();
213
+ const list = (this.crashTimes.get(name) ?? []).filter((t) => now - t < CRASH_WINDOW_MS);
214
+ list.push(now);
215
+ this.crashTimes.set(name, list);
216
+ }
217
+
218
+ private isCrashLooping(name: string): boolean {
219
+ const list = this.crashTimes.get(name) ?? [];
220
+ return list.length >= CRASH_THRESHOLD;
221
+ }
222
+
223
+ /** Latest log lines for a service. */
224
+ tail(name: string, lines = 50): string[] {
225
+ const svc = this.get(name);
226
+ if (!svc) return [];
227
+ return svc.logBuffer.slice(-lines);
228
+ }
229
+ }
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[];
@@ -25,8 +26,28 @@ export function registerCommand(cmd: Command): void {
25
26
  COMMANDS.push(cmd);
26
27
  }
27
28
 
29
+ /** All registered commands (for the panel's command palette). */
30
+ export function getCommands(): Command[] {
31
+ return [...COMMANDS];
32
+ }
33
+
34
+ /** Invoke a registered command by name with an argv list (skips help/version). */
35
+ export async function runCommand(name: string, argv: string[]): Promise<void> {
36
+ const found = COMMANDS.find((c) => c.name === name);
37
+ if (!found) {
38
+ console.error(`Unknown command: ${name}`);
39
+ return;
40
+ }
41
+ await found.run({ argv, args: parseArgs(argv) });
42
+ }
43
+
28
44
  export async function run(cmd: string, argv: string[]): Promise<void> {
29
45
  try {
46
+ // Bare `nexus` → full-screen welcome launcher.
47
+ if (!cmd) {
48
+ await launchWelcome();
49
+ return;
50
+ }
30
51
  if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
31
52
  printHelp();
32
53
  return;
@@ -54,7 +75,7 @@ function printHelp(): void {
54
75
  Usage: nexus <command> [options]
55
76
 
56
77
  Project lifecycle:
57
- init [dir] [--example <name>] [--interactive] Scaffold a new project
78
+ init [name] [--no-install] [--force] Scaffold a new project (guided wizard)
58
79
  dev [--only a,b] Start dev services
59
80
  build [--target <app>|all] Build for production
60
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 };