@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
package/src/layout.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ANSI-aware layout helpers for the TUI screens.
3
+ *
4
+ * `padEnd`/`padStart` count ANSI escape bytes, which drifts every padded
5
+ * column whenever text carries color/bold codes. These helpers measure on the
6
+ * VISIBLE width so columns align, and clip long lines so they never wrap past
7
+ * the terminal width and scramble the frame.
8
+ */
9
+ import { ANSI, stripAnsi } from './tui.js';
10
+
11
+ export { stripAnsi };
12
+
13
+ /** Visible length of a string (ANSI escapes excluded). */
14
+ export function visibleWidth(s: string): number {
15
+ return stripAnsi(s).length;
16
+ }
17
+
18
+ /** Pad `text` to `width` visible columns (left or right aligned). */
19
+ export function padVisible(text: string, width: number, align: 'left' | 'right' = 'left', fill = ' '): string {
20
+ const v = visibleWidth(text);
21
+ if (v >= width) return text;
22
+ const gap = fill.repeat(width - v);
23
+ return align === 'right' ? gap + text : text + gap;
24
+ }
25
+
26
+ /** Center `text` within `width` visible columns. */
27
+ export function centerVisible(text: string, width: number): string {
28
+ const v = visibleWidth(text);
29
+ if (v >= width) return text;
30
+ const left = Math.floor((width - v) / 2);
31
+ const right = width - v - left;
32
+ return ' '.repeat(left) + text + ' '.repeat(right);
33
+ }
34
+
35
+ /**
36
+ * Truncate `text` to `width` visible columns, preserving any leading ANSI
37
+ * color/style. A trailing reset is appended when color was stripped mid-span.
38
+ */
39
+ export function clipVisible(text: string, width: number): string {
40
+ if (visibleWidth(text) <= width) return text;
41
+ let out = '';
42
+ let used = 0;
43
+ let i = 0;
44
+ let sawColor = false;
45
+ while (i < text.length && used < width) {
46
+ const ch = text[i]!;
47
+ if (ch === '\x1b') {
48
+ let j = i + 1;
49
+ if (text.charCodeAt(j) === 0x5b) {
50
+ // CSI: ESC [ params/intermediates final
51
+ j++;
52
+ while (j < text.length) {
53
+ const c = text.charCodeAt(j);
54
+ if (c >= 0x20 && c <= 0x3f) { j++; continue; } // parameter + intermediate bytes
55
+ break;
56
+ }
57
+ if (j < text.length) j++; // final byte (0x40–0x7e)
58
+ } else if (j < text.length) {
59
+ j++; // non-CSI escape: consume one more byte
60
+ }
61
+ out += text.slice(i, j);
62
+ sawColor = true;
63
+ i = j;
64
+ } else {
65
+ out += ch;
66
+ used++;
67
+ i++;
68
+ }
69
+ }
70
+ if (sawColor && !out.endsWith(ANSI.reset)) out += ANSI.reset;
71
+ return out;
72
+ }
73
+
74
+ /** Fit `text` into exactly `width` visible columns: clip then pad. */
75
+ export function fitCell(text: string, width: number, align: 'left' | 'right' = 'left'): string {
76
+ return padVisible(clipVisible(text, width), width, align);
77
+ }
78
+
79
+ export const BORDER = {
80
+ tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│',
81
+ };
82
+
83
+ /**
84
+ * Wrap `inner` lines in a single-line border box of inner width `innerWidth`.
85
+ * Returns the full bordered lines (top border, padded rows, bottom border).
86
+ */
87
+ export function boxAround(inner: string[], innerWidth: number): string[] {
88
+ const top = `${ANSI.dim}${BORDER.tl}${BORDER.h.repeat(innerWidth + 2)}${BORDER.tr}${ANSI.reset}`;
89
+ const bottom = `${ANSI.dim}${BORDER.bl}${BORDER.h.repeat(innerWidth + 2)}${BORDER.br}${ANSI.reset}`;
90
+ const rows = [top];
91
+ for (const line of inner) {
92
+ rows.push(`${ANSI.dim}${BORDER.v}${ANSI.reset} ${fitCell(line, innerWidth)} ${ANSI.dim}${BORDER.v}${ANSI.reset}`);
93
+ }
94
+ rows.push(bottom);
95
+ return rows;
96
+ }
97
+
98
+ /** Repeat the horizontal border char for `n` columns, dimmed. */
99
+ export function divider(n: number): string {
100
+ return `${ANSI.dim}${BORDER.h.repeat(Math.max(n, 0))}${ANSI.reset}`;
101
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Recursive template-tree rendering shared by init + feature scaffolding.
3
+ *
4
+ * Walks a source template directory and copies every file into `targetDir`,
5
+ * rendering EJS-style files (`<%= %>`, `<%- %>`, `<% %>`) with the given vars.
6
+ * Supports an `exclude` predicate for skipping whole subtrees (used to leave
7
+ * apps out of a scaffold when their feature toggle is off).
8
+ */
9
+ import { mkdir, writeFile, readFile, readdir } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
11
+ import { join, dirname } from 'node:path';
12
+ import { renderString, isTemplateName, stripTemplateSuffix, type TemplateVars } from './render.js';
13
+
14
+ export interface RenderTreeOptions {
15
+ /** Return true to skip a path (relative, using `/` separators). */
16
+ exclude?: (relPath: string) => boolean;
17
+ }
18
+
19
+ export async function renderTemplateTree(
20
+ srcDir: string,
21
+ targetDir: string,
22
+ vars: TemplateVars,
23
+ opts: RenderTreeOptions = {},
24
+ ): Promise<void> {
25
+ if (!existsSync(srcDir)) {
26
+ console.warn(`[render] missing template dir: ${srcDir}`);
27
+ return;
28
+ }
29
+ await renderDir(srcDir, targetDir, vars, opts, '');
30
+ }
31
+
32
+ async function renderDir(
33
+ srcDir: string,
34
+ targetDir: string,
35
+ vars: TemplateVars,
36
+ opts: RenderTreeOptions,
37
+ rel: string,
38
+ ): Promise<void> {
39
+ const entries = await readdir(srcDir, { withFileTypes: true });
40
+ for (const e of entries) {
41
+ const src = join(srcDir, e.name);
42
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
43
+ if (opts.exclude?.(childRel)) continue;
44
+
45
+ const renderedName = isTemplateName(e.name) ? stripTemplateSuffix(e.name) : e.name;
46
+ const fileName = renderedName === 'gitignore' ? '.gitignore'
47
+ : renderedName === 'dockerignore' ? '.dockerignore'
48
+ : renderedName;
49
+ const dest = join(targetDir, fileName);
50
+
51
+ if (e.isDirectory()) {
52
+ await mkdir(dest, { recursive: true });
53
+ await renderDir(src, dest, vars, opts, childRel);
54
+ } else if (e.isFile()) {
55
+ try {
56
+ const raw = await readFile(src, 'utf-8');
57
+ const isTemplate = isTemplateName(e.name) || raw.includes('<%');
58
+ const content = isTemplate ? renderString(raw, vars) : raw;
59
+ await mkdir(dirname(dest), { recursive: true });
60
+ await writeFile(dest, content, 'utf-8');
61
+ } catch (err) {
62
+ console.warn(`[render] failed to render ${src}:`, err);
63
+ }
64
+ }
65
+ }
66
+ }
package/src/tui.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Shared full-screen TUI helpers for the Nexus CLI.
3
+ *
4
+ * Zero-dependency ANSI + raw-mode keypress foundation used by the welcome
5
+ * launcher, the init wizard and the dev console panel. Owns raw input, the
6
+ * alternate screen buffer and suspend/resume for spawning interactive
7
+ * subcommands.
8
+ */
9
+ import { stdin as input, stdout as output } from 'node:process';
10
+ import { spawn } from 'node:child_process';
11
+ import { existsSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { dirname, join } from 'node:path';
14
+ import * as readline from 'node:readline';
15
+
16
+ export const ANSI = {
17
+ reset: '\x1b[0m',
18
+ bold: '\x1b[1m',
19
+ dim: '\x1b[2m',
20
+ clear: '\x1b[2J\x1b[H',
21
+ altOn: '\x1b[?1049h',
22
+ altOff: '\x1b[?1049l',
23
+ hideCursor: '\x1b[?25l',
24
+ showCursor: '\x1b[?25h',
25
+ move: (r: number, c: number) => `\x1b[${r};${c}H`,
26
+ black: '\x1b[30m',
27
+ white: '\x1b[37m',
28
+ cyan: '\x1b[36m',
29
+ green: '\x1b[32m',
30
+ yellow: '\x1b[33m',
31
+ red: '\x1b[31m',
32
+ blue: '\x1b[34m',
33
+ magenta: '\x1b[35m',
34
+ dimGray: '\x1b[90m',
35
+ bgReset: '\x1b[49m',
36
+ bgCyan: '\x1b[46m',
37
+ bgYellow: '\x1b[43m',
38
+ bgMagenta: '\x1b[45m',
39
+ bgGreen: '\x1b[42m',
40
+ bgBlue: '\x1b[44m',
41
+ bgRed: '\x1b[41m',
42
+ };
43
+
44
+ export interface KeyInfo {
45
+ name: string;
46
+ ctrl: boolean;
47
+ meta: boolean;
48
+ shift: boolean;
49
+ sequence: string;
50
+ }
51
+
52
+ export function isTty(): boolean {
53
+ return Boolean(input.isTTY && output.isTTY);
54
+ }
55
+
56
+ /** Fit a list of text lines into the current terminal height. */
57
+ export function fitLines(lines: string[]): string[] {
58
+ const H = (output.rows || 24) - 1;
59
+ return lines.slice(0, Math.max(H, 1));
60
+ }
61
+
62
+ /**
63
+ * Tui — owns the alternate screen buffer + raw keypress stream.
64
+ *
65
+ * Callers provide an `onKey` handler and call `draw()` to paint a frame.
66
+ * `wait()` resolves once the `quit` flag is set (set by the caller, or by
67
+ * Ctrl+C via the internal signal handler).
68
+ */
69
+ export class Tui {
70
+ private _quit = false;
71
+ private handler: (str: string, key: KeyInfo) => void;
72
+ private keypress = (str: string, key: KeyInfo) => this.handler(str, key);
73
+ private onSignal = () => { this._quit = true; };
74
+
75
+ constructor(handler: (str: string, key: KeyInfo) => void) {
76
+ this.handler = handler;
77
+ }
78
+
79
+ get quit(): boolean {
80
+ return this._quit;
81
+ }
82
+
83
+ set quit(v: boolean) {
84
+ this._quit = v;
85
+ }
86
+
87
+ enter(): void {
88
+ if (input.isTTY) {
89
+ input.setRawMode?.(true);
90
+ input.resume?.();
91
+ readline.emitKeypressEvents(input);
92
+ input.on('keypress', this.keypress);
93
+ }
94
+ output.write(ANSI.altOn + ANSI.hideCursor);
95
+ process.on('SIGINT', this.onSignal);
96
+ process.on('SIGTERM', this.onSignal);
97
+ }
98
+
99
+ /** Leave the screen so a child process can own the TTY. */
100
+ suspend(): void {
101
+ input.setRawMode?.(false);
102
+ output.write(ANSI.altOff + ANSI.showCursor);
103
+ }
104
+
105
+ resume(): void {
106
+ input.setRawMode?.(true);
107
+ input.resume?.();
108
+ output.write(ANSI.altOn + ANSI.hideCursor);
109
+ }
110
+
111
+ exit(): void {
112
+ input.removeListener('keypress', this.keypress);
113
+ input.setRawMode?.(false);
114
+ output.write(ANSI.altOff + ANSI.showCursor);
115
+ process.removeListener('SIGINT', this.onSignal);
116
+ process.removeListener('SIGTERM', this.onSignal);
117
+ }
118
+
119
+ /** Paint a frame. `rows` are the body; `footer` is the final status line. */
120
+ draw(rows: string[], footer = ''): void {
121
+ const H = output.rows || 24;
122
+ const body = fitLines(rows);
123
+ let out = ANSI.clear;
124
+ out += body.join('\n');
125
+ out += ANSI.move(H, 1);
126
+ out += footer;
127
+ output.write(out);
128
+ }
129
+
130
+ /** Resolve once `quit` becomes true. */
131
+ async wait(): Promise<void> {
132
+ while (!this._quit) {
133
+ await new Promise((r) => setTimeout(r, 100));
134
+ }
135
+ }
136
+ }
137
+
138
+ /** Strip ANSI color codes from a string. */
139
+ export function stripAnsi(text: string): string {
140
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
141
+ }
142
+
143
+ /** Spawn `node <bin> <args>` with inherited stdio (used for interactive CLI commands). */
144
+ export function spawnNode(bin: string, args: string[]): Promise<void> {
145
+ return new Promise((resolve) => {
146
+ const child = spawn(process.execPath, [bin, ...args], { stdio: 'inherit', cwd: process.cwd() });
147
+ child.on('exit', () => resolve());
148
+ child.on('error', () => resolve());
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Resolve the CLI bin for suspend-mode commands.
154
+ *
155
+ * Handles the monorepo layout (packages/nexus-cli/src → repo bin/nexus.js),
156
+ * the bundled layout (node_modules/bhooai-nexus/packages/nexus-cli/src →
157
+ * node_modules/bhooai-nexus/bin/nexus.js) and a CLI package with its own bin.
158
+ */
159
+ export function resolveCliBin(): string {
160
+ const here = dirname(fileURLToPath(import.meta.url));
161
+ const candidates = [
162
+ join(here, '..', '..', 'bin', 'nexus.js'),
163
+ join(here, '..', '..', '..', 'bin', 'nexus.js'),
164
+ join(here, '..', '..', '..', '..', 'bin', 'nexus.js'),
165
+ ];
166
+ for (const p of candidates) {
167
+ if (existsSync(p)) return p;
168
+ }
169
+ throw new Error(`Cannot resolve nexus bin (tried from ${here})`);
170
+ }