@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/src/wizard.ts ADDED
@@ -0,0 +1,691 @@
1
+ /**
2
+ * Full-screen init wizard (TUI) — the single `nexus init` experience.
3
+ *
4
+ * Steps: project name → starter (empty vs examples) → include admin? →
5
+ * feature checkboxes → port check/auto-assign → infra (mongo/redis/AI/venv)
6
+ * → review & create.
7
+ *
8
+ * Boxed-card layout: a title bar with a step indicator, then a bordered card
9
+ * around the active step's content. All columns align on VISIBLE width and
10
+ * long lines are clipped so the frame never wraps.
11
+ *
12
+ * Returns a WizardResult consumed by `nexus init`, or null when the user
13
+ * cancels / there's no TTY.
14
+ */
15
+ import { stdout as output } from 'node:process';
16
+ import { ANSI, Tui, isTty, type KeyInfo } from './tui.js';
17
+ import { padVisible, clipVisible, fitCell, boxAround, visibleWidth } from './layout.js';
18
+ import { FEATURES } from './features.js';
19
+ import { ensureExamples, listExamplesFrom, resolveExamplesDir } from './examples.js';
20
+ import { nextFreePort, isPortFree, slugify } from './util.js';
21
+
22
+ export interface PortAssignment {
23
+ key: 'backend' | 'frontend' | 'admin' | 'ai';
24
+ label: string;
25
+ default: number;
26
+ port: number;
27
+ }
28
+
29
+ export interface WizardResult {
30
+ name: string;
31
+ nameSlug: string;
32
+ example: string;
33
+ /** Resolved examples directory (bundled or on-demand cache). */
34
+ examplesDir: string;
35
+ /** Selected feature ids from the catalog (incl. frontend/admin/ai-server). */
36
+ features: string[];
37
+ /** Include the admin app. */
38
+ includeAdmin: boolean;
39
+ /** Port assignments keyed by backend/frontend/admin/ai. */
40
+ ports: Record<'backend' | 'frontend' | 'admin' | 'ai', number>;
41
+ mongoUri: string;
42
+ redisUrl: string;
43
+ aiProviders: string[];
44
+ useVenv: boolean;
45
+ /** Whether to launch the dev console after scaffolding. */
46
+ launchDev: boolean;
47
+ }
48
+
49
+ type Page = 'name' | 'starter' | 'examples' | 'admin' | 'features' | 'ports' | 'infra' | 'confirm';
50
+
51
+ interface InfraField {
52
+ key: 'mongo' | 'redis' | 'ai' | 'venv';
53
+ label: string;
54
+ value: string;
55
+ kind: 'text' | 'bool';
56
+ }
57
+
58
+ const STEPS: Array<{ id: Page; label: string }> = [
59
+ { id: 'name', label: 'Project name' },
60
+ { id: 'starter', label: 'Starter' },
61
+ { id: 'admin', label: 'Admin panel' },
62
+ { id: 'features', label: 'Features' },
63
+ { id: 'ports', label: 'Ports' },
64
+ { id: 'infra', label: 'Infrastructure' },
65
+ { id: 'confirm', label: 'Review' },
66
+ ];
67
+
68
+ function stepIndex(page: Page): number {
69
+ // `examples` is a drill-down sub-page of `starter` — share its step index.
70
+ const p: Page = page === 'examples' ? 'starter' : page;
71
+ return Math.max(0, STEPS.findIndex((s) => s.id === p));
72
+ }
73
+
74
+ const PORT_DEFAULTS: Array<Pick<PortAssignment, 'key' | 'label' | 'default'>> = [
75
+ { key: 'backend', label: 'Backend API', default: 4000 },
76
+ { key: 'frontend', label: 'Frontend', default: 3000 },
77
+ { key: 'admin', label: 'Admin panel', default: 3300 },
78
+ { key: 'ai', label: 'AI server', default: 8000 },
79
+ ];
80
+
81
+ interface WizardState {
82
+ page: Page;
83
+ name: string;
84
+ examples: string[];
85
+ examplesDir: string;
86
+ examplesLoading: boolean;
87
+ examplesError: string;
88
+ examplesIndex: number;
89
+ starterIndex: number;
90
+ example: string;
91
+ features: Set<string>;
92
+ featureIndex: number;
93
+ ports: PortAssignment[];
94
+ portIndex: number;
95
+ portEditing: boolean;
96
+ portBuffer: string;
97
+ fields: InfraField[];
98
+ fieldIndex: number;
99
+ fieldEditing: boolean;
100
+ fieldBuffer: string;
101
+ result: WizardResult | null;
102
+ cancelled: boolean;
103
+ error: string;
104
+ }
105
+
106
+ export async function runWizard(prefillName = ''): Promise<WizardResult | null> {
107
+ if (!isTty()) return null;
108
+
109
+ // Probe the canonical ports first (auto-specify free alternatives).
110
+ const ports: PortAssignment[] = [];
111
+ for (const d of PORT_DEFAULTS) {
112
+ ports.push({ key: d.key, label: d.label, default: d.default, port: await nextFreePort(d.default) });
113
+ }
114
+
115
+ // Pre-fill the examples list only if the bundled dir is already available
116
+ // (no install). The Examples drill-down will resolve/install on demand.
117
+ const bundledDir = resolveExamplesDir();
118
+ const preloaded = bundledDir ? await listExamplesFrom(bundledDir) : [];
119
+
120
+ const initialName = prefillName ? slugify(prefillName) : '';
121
+ const state: WizardState = {
122
+ page: 'name',
123
+ name: initialName,
124
+ examples: preloaded,
125
+ examplesDir: bundledDir ?? '',
126
+ examplesLoading: false,
127
+ examplesError: '',
128
+ examplesIndex: 0,
129
+ starterIndex: 0,
130
+ example: 'empty',
131
+ features: new Set(['admin', 'frontend', 'ai-server']),
132
+ featureIndex: 0,
133
+ ports,
134
+ portIndex: 0,
135
+ portEditing: false,
136
+ portBuffer: '',
137
+ fields: [
138
+ { key: 'mongo', label: 'MongoDB URI', value: initialName ? `mongodb://localhost:27017/${initialName}` : '', kind: 'text' },
139
+ { key: 'redis', label: 'Redis URL', value: 'redis://localhost:6379', kind: 'text' },
140
+ { key: 'ai', label: 'AI providers (comma-separated, empty = none)', value: '', kind: 'text' },
141
+ { key: 'venv', label: 'Create Python virtualenv', value: 'yes', kind: 'bool' },
142
+ ],
143
+ fieldIndex: 0,
144
+ fieldEditing: false,
145
+ fieldBuffer: '',
146
+ result: null,
147
+ cancelled: false,
148
+ error: '',
149
+ };
150
+
151
+ const tui = new Tui((str, key) => handleKey(state, str, key, tui));
152
+ tui.enter();
153
+ render(state, tui);
154
+ await tui.wait();
155
+ tui.exit();
156
+
157
+ if (state.cancelled) return null;
158
+ return state.result;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Key handling (unchanged behavior)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ function handleKey(state: WizardState, str: string, key: KeyInfo, tui: Tui): void {
166
+ if (key.name === 'c' && key.ctrl) {
167
+ state.cancelled = true;
168
+ tui.quit = true;
169
+ return;
170
+ }
171
+ if (key.name === 'escape') {
172
+ if (state.portEditing || state.fieldEditing) {
173
+ state.portEditing = false;
174
+ state.fieldEditing = false;
175
+ } else if (state.page === 'examples') {
176
+ state.examplesLoading = false;
177
+ state.page = 'starter';
178
+ } else {
179
+ const idx = stepIndex(state.page);
180
+ if (idx > 0) state.page = STEPS[idx - 1]!.id;
181
+ }
182
+ render(state, tui);
183
+ return;
184
+ }
185
+ if (key.name === 'q') {
186
+ state.cancelled = true;
187
+ tui.quit = true;
188
+ return;
189
+ }
190
+
191
+ if (state.portEditing) {
192
+ handlePortEditing(state, str, key);
193
+ render(state, tui);
194
+ return;
195
+ }
196
+ if (state.fieldEditing) {
197
+ handleFieldEditing(state, str, key);
198
+ render(state, tui);
199
+ return;
200
+ }
201
+
202
+ switch (state.page) {
203
+ case 'name': pageName(state, str, key); break;
204
+ case 'starter': pageStarter(state, key, tui); break;
205
+ case 'examples': pageExamples(state, key, tui); break;
206
+ case 'admin': pageAdmin(state, str, key); break;
207
+ case 'features': pageFeatures(state, key); break;
208
+ case 'ports': pagePorts(state, key, tui); break;
209
+ case 'infra': pageInfra(state, key); break;
210
+ case 'confirm': pageConfirm(state, key, tui); break;
211
+ }
212
+ render(state, tui);
213
+ }
214
+
215
+ function pageName(state: WizardState, str: string, key: KeyInfo): void {
216
+ if (key.name === 'return' || key.name === 'enter') {
217
+ const slug = slugify(state.name);
218
+ if (!slug) {
219
+ state.error = 'Project name cannot be empty.';
220
+ return;
221
+ }
222
+ state.name = slug;
223
+ const mongo = state.fields.find((f) => f.key === 'mongo');
224
+ if (mongo) mongo.value = `mongodb://localhost:27017/${slug}`;
225
+ state.page = 'starter';
226
+ state.error = '';
227
+ return;
228
+ }
229
+ if (key.name === 'backspace') {
230
+ state.name = state.name.slice(0, -1);
231
+ state.error = '';
232
+ } else if (str && !key.ctrl && !key.meta) {
233
+ state.name += str;
234
+ state.error = '';
235
+ }
236
+ }
237
+
238
+ function pageStarter(state: WizardState, key: KeyInfo, tui: Tui): void {
239
+ if (key.name === 'up') {
240
+ state.starterIndex = 0;
241
+ } else if (key.name === 'down') {
242
+ state.starterIndex = 1;
243
+ } else if (key.name === 'return' || key.name === 'enter') {
244
+ if (state.starterIndex === 0) {
245
+ state.example = 'empty';
246
+ state.page = 'admin';
247
+ } else {
248
+ // Drill into the examples list; resolve/install on demand.
249
+ state.examplesError = '';
250
+ if (state.examples.length > 0) {
251
+ state.examplesIndex = 0;
252
+ state.page = 'examples';
253
+ } else {
254
+ state.examplesLoading = true;
255
+ state.page = 'examples';
256
+ void loadExamples(state, tui);
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ async function loadExamples(state: WizardState, tui: Tui): Promise<void> {
263
+ const res = await ensureExamples();
264
+ state.examplesLoading = false;
265
+ if ('error' in res) {
266
+ state.examplesError = res.error;
267
+ } else {
268
+ state.examples = res.list;
269
+ state.examplesDir = res.dir;
270
+ state.examplesIndex = 0;
271
+ }
272
+ render(state, tui);
273
+ }
274
+
275
+ function pageExamples(state: WizardState, key: KeyInfo, tui: Tui): void {
276
+ if (state.examplesLoading) return; // ignore keys while installing
277
+ if (state.examplesError) {
278
+ if (key.name === 'escape' || key.name === 'return' || key.name === 'enter') {
279
+ state.page = 'starter';
280
+ }
281
+ return;
282
+ }
283
+ if (key.name === 'escape') {
284
+ state.page = 'starter';
285
+ return;
286
+ }
287
+ const list = state.examples;
288
+ if (key.name === 'up') {
289
+ state.examplesIndex = Math.max(0, state.examplesIndex - 1);
290
+ } else if (key.name === 'down') {
291
+ state.examplesIndex = Math.min(list.length - 1, state.examplesIndex + 1);
292
+ } else if (key.name === 'return' || key.name === 'enter') {
293
+ const chosen = list[state.examplesIndex];
294
+ if (chosen) {
295
+ state.example = chosen;
296
+ state.page = 'admin';
297
+ }
298
+ }
299
+ void tui;
300
+ }
301
+
302
+ function pageAdmin(state: WizardState, str: string, key: KeyInfo): void {
303
+ const isYes = state.features.has('admin');
304
+ if (key.name === 'left' || key.name === 'right' || key.name === 'up' || key.name === 'down') {
305
+ state.features.add('admin');
306
+ if (isYes) state.features.delete('admin');
307
+ } else if (key.name === 'return' || key.name === 'enter') {
308
+ state.page = 'features';
309
+ } else if (str && /y/i.test(str)) {
310
+ state.features.add('admin');
311
+ } else if (str && /n/i.test(str)) {
312
+ state.features.delete('admin');
313
+ }
314
+ }
315
+
316
+ function pageFeatures(state: WizardState, key: KeyInfo): void {
317
+ if (key.name === 'up') {
318
+ state.featureIndex = Math.max(0, state.featureIndex - 1);
319
+ } else if (key.name === 'down') {
320
+ state.featureIndex = Math.min(FEATURES.length - 1, state.featureIndex + 1);
321
+ } else if (key.name === ' ') {
322
+ const id = FEATURES[state.featureIndex]?.id;
323
+ if (id) {
324
+ if (state.features.has(id)) state.features.delete(id);
325
+ else state.features.add(id);
326
+ }
327
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'tab') {
328
+ state.page = 'ports';
329
+ }
330
+ }
331
+
332
+ function pagePorts(state: WizardState, key: KeyInfo, tui: Tui): void {
333
+ if (key.name === 'up') {
334
+ state.portIndex = Math.max(0, state.portIndex - 1);
335
+ } else if (key.name === 'down') {
336
+ state.portIndex = Math.min(state.ports.length - 1, state.portIndex + 1);
337
+ } else if (key.name === 'e') {
338
+ state.portEditing = true;
339
+ state.portBuffer = String(state.ports[state.portIndex]?.port ?? '');
340
+ } else if (key.name === 'r') {
341
+ state.error = '';
342
+ void reprobePorts(state, tui);
343
+ } else if (key.name === 'return' || key.name === 'enter') {
344
+ state.page = 'infra';
345
+ }
346
+ }
347
+
348
+ function handlePortEditing(state: WizardState, str: string, key: KeyInfo): void {
349
+ if (key.name === 'return' || key.name === 'enter') {
350
+ const n = parseInt(state.portBuffer, 10);
351
+ const row = state.ports[state.portIndex];
352
+ if (row && Number.isFinite(n) && n > 0 && n < 65536) {
353
+ row.port = n;
354
+ state.error = '';
355
+ } else {
356
+ state.error = 'Port must be a number between 1 and 65535.';
357
+ }
358
+ state.portEditing = false;
359
+ state.portBuffer = '';
360
+ } else if (key.name === 'backspace') {
361
+ state.portBuffer = state.portBuffer.slice(0, -1);
362
+ } else if (str && /\d/.test(str)) {
363
+ state.portBuffer += str;
364
+ }
365
+ }
366
+
367
+ async function reprobePorts(state: WizardState, tui: Tui): Promise<void> {
368
+ const used = new Set<number>();
369
+ for (const row of state.ports) {
370
+ let candidate = row.default;
371
+ while (used.has(candidate) || !(await isPortFree(candidate))) candidate++;
372
+ row.port = candidate;
373
+ used.add(candidate);
374
+ }
375
+ render(state, tui);
376
+ }
377
+
378
+ function pageInfra(state: WizardState, key: KeyInfo): void {
379
+ if (key.name === 'up') {
380
+ state.fieldIndex = Math.max(0, state.fieldIndex - 1);
381
+ } else if (key.name === 'down') {
382
+ state.fieldIndex = Math.min(state.fields.length, state.fieldIndex + 1);
383
+ } else if (key.name === 'return' || key.name === 'enter') {
384
+ const field = state.fields[state.fieldIndex];
385
+ if (!field) {
386
+ state.page = 'confirm';
387
+ return;
388
+ }
389
+ if (field.key === 'venv') {
390
+ field.value = field.value === 'yes' ? 'no' : 'yes';
391
+ } else {
392
+ state.fieldEditing = true;
393
+ state.fieldBuffer = field.value;
394
+ }
395
+ } else if (key.name === 'tab') {
396
+ state.page = 'confirm';
397
+ }
398
+ }
399
+
400
+ function handleFieldEditing(state: WizardState, str: string, key: KeyInfo): void {
401
+ if (key.name === 'return' || key.name === 'enter') {
402
+ const field = state.fields[state.fieldIndex];
403
+ if (field) field.value = state.fieldBuffer;
404
+ state.fieldEditing = false;
405
+ state.fieldBuffer = '';
406
+ } else if (key.name === 'backspace') {
407
+ state.fieldBuffer = state.fieldBuffer.slice(0, -1);
408
+ } else if (str && !key.ctrl && !key.meta) {
409
+ state.fieldBuffer += str;
410
+ }
411
+ }
412
+
413
+ function pageConfirm(state: WizardState, key: KeyInfo, tui: Tui): void {
414
+ if (key.name === 'return' || key.name === 'enter') {
415
+ commitResult(state, tui);
416
+ }
417
+ }
418
+
419
+ function commitResult(state: WizardState, tui: Tui): void {
420
+ const mongo = state.fields.find((f) => f.key === 'mongo');
421
+ const redis = state.fields.find((f) => f.key === 'redis');
422
+ const ai = state.fields.find((f) => f.key === 'ai');
423
+ const venv = state.fields.find((f) => f.key === 'venv');
424
+
425
+ const ports = {
426
+ backend: state.ports.find((p) => p.key === 'backend')?.port ?? 4000,
427
+ frontend: state.ports.find((p) => p.key === 'frontend')?.port ?? 3000,
428
+ admin: state.ports.find((p) => p.key === 'admin')?.port ?? 3300,
429
+ ai: state.ports.find((p) => p.key === 'ai')?.port ?? 8000,
430
+ };
431
+
432
+ state.result = {
433
+ name: state.name,
434
+ nameSlug: state.name,
435
+ example: state.example,
436
+ examplesDir: state.examplesDir,
437
+ features: [...state.features],
438
+ includeAdmin: state.features.has('admin'),
439
+ ports,
440
+ mongoUri: mongo?.value ?? `mongodb://localhost:27017/${state.name}`,
441
+ redisUrl: redis?.value ?? 'redis://localhost:6379',
442
+ aiProviders: (ai?.value ?? '').split(',').map((s) => s.trim()).filter(Boolean),
443
+ useVenv: (venv?.value ?? 'yes') === 'yes',
444
+ launchDev: false,
445
+ };
446
+ tui.quit = true;
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Rendering — boxed cards
451
+ // ---------------------------------------------------------------------------
452
+
453
+ const EXAMPLE_DESC: Record<string, string> = {
454
+ empty: 'Bare scaffold — no demo code',
455
+ chat: 'Realtime rooms, presence, typing',
456
+ 'video-call': 'WebRTC signaling, 1:1 calls',
457
+ checkout: 'Payments + mailables + webhooks',
458
+ livestream: 'Broadcaster/viewer streaming',
459
+ 'ai-chat': 'Streaming LLM via SSE',
460
+ 'saas-starter': 'Auth + teams + projects + policies',
461
+ 'blog-crud': 'ODM models, GraphQL subgraph',
462
+ dashboard: 'Admin metrics demo',
463
+ 'file-storage': 'Uploads, signed URLs, S3',
464
+ 'multi-app': 'Two backends, two frontends',
465
+ };
466
+
467
+ function render(state: WizardState, tui: Tui): void {
468
+ const W = output.columns || 80;
469
+ const innerW = Math.min(Math.max(W - 6, 40), 72);
470
+
471
+ const inner: string[] = [];
472
+ inner.push(progressDots(state));
473
+ inner.push('');
474
+ inner.push(`${ANSI.bold}${ANSI.cyan}${stepTitle(state)}${ANSI.reset}`);
475
+ inner.push('');
476
+
477
+ switch (state.page) {
478
+ case 'name': inner.push(...renderName(state, innerW)); break;
479
+ case 'starter': inner.push(...renderStarter(state, innerW)); break;
480
+ case 'examples': inner.push(...renderExamplesList(state, innerW)); break;
481
+ case 'admin': inner.push(...renderAdmin(state, innerW)); break;
482
+ case 'features': inner.push(...renderFeatures(state, innerW)); break;
483
+ case 'ports': inner.push(...renderPorts(state, innerW)); break;
484
+ case 'infra': inner.push(...renderInfra(state, innerW)); break;
485
+ case 'confirm': inner.push(...renderConfirm(state, innerW)); break;
486
+ }
487
+
488
+ if (state.error) {
489
+ inner.push('');
490
+ inner.push(`${ANSI.red}${state.error}${ANSI.reset}`);
491
+ }
492
+
493
+ const rows: string[] = [titleBar(state, W), ''];
494
+ rows.push(...boxAround(inner, innerW));
495
+
496
+ tui.draw(rows, `${ANSI.dim}${footerFor(state)}${ANSI.reset}`);
497
+ }
498
+
499
+ function titleBar(state: WizardState, W: number): string {
500
+ const left = `${ANSI.bold}${ANSI.cyan}BhooAI Nexus${ANSI.reset} ${ANSI.dim}project setup${ANSI.reset}`;
501
+ const n = stepIndex(state.page) + 1;
502
+ const right = `${ANSI.dim}Step ${n} of ${STEPS.length}${ANSI.reset}`;
503
+ const gap = Math.max(1, W - visibleWidth(left) - visibleWidth(right));
504
+ return `${left}${' '.repeat(gap)}${right}`;
505
+ }
506
+
507
+ function progressDots(state: WizardState): string {
508
+ const idx = stepIndex(state.page);
509
+ return STEPS.map((s, i) => {
510
+ if (i < idx) return `${ANSI.green}●${ANSI.reset}`;
511
+ if (i === idx) return `${ANSI.cyan}◐${ANSI.reset}`;
512
+ return `${ANSI.dimGray}○${ANSI.reset}`;
513
+ }).join(' ');
514
+ }
515
+
516
+ function stepTitle(state: WizardState): string {
517
+ const idx = stepIndex(state.page);
518
+ return `${idx + 1}. ${STEPS[idx]!.label}`;
519
+ }
520
+
521
+ function renderName(state: WizardState, innerW: number): string[] {
522
+ void innerW;
523
+ return [
524
+ ` ${ANSI.cyan}${state.name}█${ANSI.reset}`,
525
+ '',
526
+ `${ANSI.dim} Type a project name. It will be scaffolded into ./${state.name || '<name>'}${ANSI.reset}`,
527
+ `${ANSI.dim} (letters, numbers and dashes).${ANSI.reset}`,
528
+ ];
529
+ }
530
+
531
+ function renderStarter(state: WizardState, innerW: number): string[] {
532
+ const nameW = 16;
533
+ const descW = Math.max(innerW - nameW - 5, 10);
534
+ const out: string[] = [];
535
+ const choices: Array<{ label: string; desc: string }> = [
536
+ { label: 'Empty', desc: 'Bare scaffold — no demo code' },
537
+ { label: 'Examples', desc: 'Start from a working example (10 demos)' },
538
+ ];
539
+ choices.forEach((c, i) => {
540
+ const sel = i === state.starterIndex;
541
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
542
+ const label = sel ? `${ANSI.bold}${c.label}${ANSI.reset}` : c.label;
543
+ out.push(` ${marker} ${fitCell(label, nameW)} ${clipVisible(`${ANSI.dim}${c.desc}${ANSI.reset}`, descW)}`);
544
+ });
545
+ return out;
546
+ }
547
+
548
+ function renderExamplesList(state: WizardState, innerW: number): string[] {
549
+ const nameW = 16;
550
+ const descW = Math.max(innerW - nameW - 5, 10);
551
+ const out: string[] = [];
552
+
553
+ if (state.examplesLoading) {
554
+ out.push(` ${ANSI.dim}Installing @bhooai/nexus-examples…${ANSI.reset}`);
555
+ out.push('');
556
+ out.push(` ${ANSI.dim}(this only happens once, when examples aren't bundled)${ANSI.reset}`);
557
+ return out;
558
+ }
559
+
560
+ if (state.examplesError) {
561
+ out.push(` ${ANSI.red}${state.examplesError}${ANSI.reset}`);
562
+ out.push('');
563
+ out.push(` ${ANSI.dim}Press Esc to go back and choose Empty.${ANSI.reset}`);
564
+ return out;
565
+ }
566
+
567
+ if (state.examples.length === 0) {
568
+ out.push(` ${ANSI.dim}No examples available.${ANSI.reset}`);
569
+ out.push('');
570
+ out.push(` ${ANSI.dim}Press Esc to go back.${ANSI.reset}`);
571
+ return out;
572
+ }
573
+
574
+ state.examples.forEach((ex, i) => {
575
+ const sel = i === state.examplesIndex;
576
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
577
+ const label = sel ? `${ANSI.bold}${ex}${ANSI.reset}` : ex;
578
+ out.push(` ${marker} ${fitCell(label, nameW)} ${clipVisible(`${ANSI.dim}${EXAMPLE_DESC[ex] ?? ''}${ANSI.reset}`, descW)}`);
579
+ });
580
+ out.push('');
581
+ out.push(`${ANSI.dim} ↑/↓ pick · Enter choose · Esc back${ANSI.reset}`);
582
+ return out;
583
+ }
584
+
585
+ function renderAdmin(state: WizardState, innerW: number): string[] {
586
+ void innerW;
587
+ const yes = state.features.has('admin');
588
+ const row = (on: boolean, label: string, desc: string) => {
589
+ const marker = on ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
590
+ const text = on ? `${ANSI.bold}${label}${ANSI.reset}` : label;
591
+ return ` ${marker} ${text} ${ANSI.dim}${desc}${ANSI.reset}`;
592
+ };
593
+ return [
594
+ row(yes, 'Yes', 'apps/admin — users, logs, registry, config'),
595
+ row(!yes, 'No', 'skip the admin app and its port'),
596
+ ];
597
+ }
598
+
599
+ function renderFeatures(state: WizardState, innerW: number): string[] {
600
+ const nameW = 18;
601
+ const descW = Math.max(innerW - nameW - 6, 10);
602
+ const out: string[] = [];
603
+ FEATURES.forEach((f, i) => {
604
+ const on = state.features.has(f.id);
605
+ const sel = i === state.featureIndex;
606
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
607
+ const box = on ? `${ANSI.green}■${ANSI.reset}` : `${ANSI.dimGray}□${ANSI.reset}`;
608
+ const label = sel ? `${ANSI.bold}${f.name}${ANSI.reset}` : f.name;
609
+ const desc = clipVisible(`${ANSI.dim}${f.desc}${ANSI.reset}`, descW);
610
+ out.push(` ${marker} ${box} ${fitCell(label, nameW)} ${desc}`);
611
+ });
612
+ return out;
613
+ }
614
+
615
+ function renderPorts(state: WizardState, innerW: number): string[] {
616
+ const labelW = 16;
617
+ const out: string[] = [];
618
+ state.ports.forEach((p, i) => {
619
+ const sel = i === state.portIndex;
620
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
621
+ const label = sel ? `${ANSI.bold}${p.label}${ANSI.reset}` : p.label;
622
+ const value = state.portEditing && sel ? `${ANSI.cyan}${state.portBuffer}█${ANSI.reset}` : String(p.port);
623
+ const tail = `${ANSI.dim} (default ${p.default})${ANSI.reset}`;
624
+ out.push(` ${marker} ${fitCell(label, labelW)} :${fitCell(value, 6)}${tail}`);
625
+ });
626
+ out.push('');
627
+ out.push(`${ANSI.dim} e edit selected · r re-probe free ports${ANSI.reset}`);
628
+ void innerW;
629
+ return out;
630
+ }
631
+
632
+ function renderInfra(state: WizardState, innerW: number): string[] {
633
+ const labelW = Math.min(40, Math.max(20, innerW - 18));
634
+ const out: string[] = [];
635
+ state.fields.forEach((f, i) => {
636
+ const sel = i === state.fieldIndex;
637
+ const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
638
+ const label = sel ? `${ANSI.bold}${f.label}${ANSI.reset}` : f.label;
639
+ let value: string;
640
+ if (f.kind === 'bool') {
641
+ value = f.value === 'yes' ? `${ANSI.green}yes${ANSI.reset}` : `${ANSI.red}no${ANSI.reset}`;
642
+ } else {
643
+ value = state.fieldEditing && sel ? `${ANSI.cyan}${state.fieldBuffer}█${ANSI.reset}` : (f.value || `${ANSI.dim}—${ANSI.reset}`);
644
+ }
645
+ out.push(` ${marker} ${fitCell(label, labelW)} ${clipVisible(value, innerW - labelW - 3)}`);
646
+ });
647
+ out.push('');
648
+ const onContinue = state.fieldIndex === state.fields.length;
649
+ out.push(` ${onContinue ? `${ANSI.cyan}▶${ANSI.reset}` : ' '} ${ANSI.bold}→ Review & create${ANSI.reset}`);
650
+ return out;
651
+ }
652
+
653
+ function renderConfirm(state: WizardState, innerW: number): string[] {
654
+ const ports = {
655
+ backend: state.ports.find((p) => p.key === 'backend')?.port ?? 4000,
656
+ frontend: state.ports.find((p) => p.key === 'frontend')?.port ?? 3000,
657
+ admin: state.ports.find((p) => p.key === 'admin')?.port ?? 3300,
658
+ ai: state.ports.find((p) => p.key === 'ai')?.port ?? 8000,
659
+ };
660
+ const mongo = state.fields.find((f) => f.key === 'mongo');
661
+ const redis = state.fields.find((f) => f.key === 'redis');
662
+ const features = [...state.features];
663
+ const labelW = 12;
664
+ const row = (k: string, v: string) => ` ${fitCell(k, labelW)} ${clipVisible(v, innerW - labelW - 3)}`;
665
+ return [
666
+ row('Project', state.name),
667
+ row('Starter', state.example),
668
+ row('Features', features.join(', ') || '(none)'),
669
+ row('Ports', `backend :${ports.backend} · frontend :${ports.frontend} · admin :${ports.admin} · ai :${ports.ai}`),
670
+ row('MongoDB', mongo?.value ?? ''),
671
+ row('Redis', redis?.value ?? ''),
672
+ '',
673
+ `${ANSI.dim} Enter to scaffold the project (deps install automatically).${ANSI.reset}`,
674
+ ];
675
+ }
676
+
677
+ function footerFor(state: WizardState): string {
678
+ switch (state.page) {
679
+ case 'name': return 'Type a project name · Enter continue';
680
+ case 'starter': return '↑/↓ choose · Enter continue';
681
+ case 'admin': return '←/→ or y/n toggle · Enter continue';
682
+ case 'features': return '↑/↓ move · Space toggle · Enter continue';
683
+ case 'ports': return '↑/↓ select · e edit · r re-probe · Enter continue';
684
+ case 'infra': return '↑/↓ select · Enter edit · Enter on Review to continue';
685
+ case 'confirm': return 'Enter create project · Esc back';
686
+ default: return '';
687
+ }
688
+ }
689
+
690
+ // `padVisible` re-exported for callers that want the aligned helpers.
691
+ export { padVisible };
@@ -15,6 +15,7 @@ COPY apps/backend/package.json apps/backend/package.json
15
15
  RUN npm install --omit=dev --workspace=apps/backend --include-workspace-root=false
16
16
  COPY apps/backend apps/backend
17
17
  COPY tsconfig.json ./
18
+ COPY nexus.config.ts ./
18
19
 
19
20
  EXPOSE <%= backendPort %>
20
21
  ENV NODE_ENV=production