@bhooai/nexus-cli 2.0.3 → 2.0.5
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.
- package/package.json +1 -1
- package/src/commands/add.ts +1 -1
- package/src/commands/dev.ts +3 -3
- package/src/commands/init.ts +87 -136
- package/src/devPanel.ts +602 -346
- package/src/devServiceManager.ts +50 -4
- package/src/dispatcher.ts +7 -1
- package/src/examples.ts +90 -0
- package/src/features.ts +261 -0
- package/src/launcher.ts +164 -0
- package/src/layout.ts +101 -0
- package/src/templating/tree.ts +66 -0
- package/src/tui.ts +170 -0
- package/src/wizard.ts +691 -0
- package/templates/base/Dockerfile.ejs +1 -0
- package/templates/base/apps/admin/nginx.conf.ejs +30 -1
- package/templates/base/apps/admin/package.json.ejs +7 -2
- package/templates/base/apps/admin/postcss.config.js +5 -0
- package/templates/base/apps/admin/src/App.tsx +4127 -0
- package/templates/base/apps/admin/src/alertCenter.tsx +150 -0
- package/templates/base/apps/admin/src/api.ts +474 -0
- package/templates/base/apps/admin/src/assets/bhooai-nexus-logo.svg +25 -0
- package/templates/base/apps/admin/src/index.css +3481 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +3 -3
- package/templates/base/apps/admin/src/vite-env.d.ts +19 -0
- package/templates/base/apps/admin/tailwind.config.js +9 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +21 -2
- package/templates/base/apps/ai-server/main.py.ejs +94 -6
- package/templates/base/apps/backend/package.json.ejs +27 -0
- package/templates/base/apps/frontend/package.json.ejs +7 -0
- package/templates/base/apps/frontend/vite.config.ts.ejs +0 -1
- package/templates/base/docker-compose.yml.ejs +6 -1
- package/templates/base/nexus.config.ts.ejs +4 -4
- package/templates/features/auth/apps/backend/src/models/User.ts +21 -0
- package/templates/features/auth/apps/backend/src/routes/auth.ts +95 -0
- package/templates/features/email/apps/backend/src/mail/mailables/WelcomeMail.ts +25 -0
- package/templates/features/email/apps/backend/src/mail/templates/welcome.ejs.ejs +10 -0
- package/templates/features/graphql/apps/backend/src/graphql/post.graph.ts +61 -0
- package/templates/features/graphql/apps/backend/src/models/Post.ts +15 -0
- package/templates/features/payments/apps/backend/src/routes/payments.ts +45 -0
- package/templates/features/queue/apps/backend/src/events/JobQueued.ts +14 -0
- package/templates/features/queue/apps/backend/src/jobs/ExampleJob.ts +18 -0
- package/templates/features/queue/apps/backend/src/listeners/OnJobQueued.ts +12 -0
- package/templates/features/realtime/apps/backend/src/models/Message.ts +14 -0
- package/templates/features/realtime/apps/backend/src/ws/chat.room.ts +56 -0
- package/templates/features/storage/apps/backend/src/routes/uploads.ts +91 -0
package/package.json
CHANGED
package/src/commands/add.ts
CHANGED
|
@@ -178,7 +178,7 @@ export default defineConfig({
|
|
|
178
178
|
server: {
|
|
179
179
|
port: ${port},
|
|
180
180
|
proxy: {
|
|
181
|
-
'/api': { target: 'http://localhost:${backendPort}', changeOrigin: true
|
|
181
|
+
'/api': { target: 'http://localhost:${backendPort}', changeOrigin: true },
|
|
182
182
|
'/uploads': { target: 'http://localhost:${backendPort}', changeOrigin: true },
|
|
183
183
|
'/ws': { target: 'ws://localhost:${backendPort}', ws: true },
|
|
184
184
|
},
|
package/src/commands/dev.ts
CHANGED
|
@@ -29,7 +29,7 @@ export async function run(ctx: CommandContext): Promise<void> {
|
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const manager = new ServiceManager(specs);
|
|
32
|
+
const manager = new ServiceManager(specs, projectRoot);
|
|
33
33
|
|
|
34
34
|
if (isTty && !noPanel) {
|
|
35
35
|
// Full-screen Nexus Console.
|
|
@@ -91,13 +91,13 @@ async function discoverServices(projectRoot: string, only: string[] | null): Pro
|
|
|
91
91
|
if (!existsSync(join(appsDir, f, 'package.json'))) continue;
|
|
92
92
|
const port = await ensurePort(projectRoot, f);
|
|
93
93
|
if (only && !only.includes(f)) continue;
|
|
94
|
-
specs.push({ name: f, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, f), port });
|
|
94
|
+
specs.push({ name: f, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, f), port, portArgIndex: 2 });
|
|
95
95
|
}
|
|
96
96
|
for (const a of admins) {
|
|
97
97
|
if (!existsSync(join(appsDir, a, 'package.json'))) continue;
|
|
98
98
|
const port = await ensurePort(projectRoot, a);
|
|
99
99
|
if (only && !only.includes(a)) continue;
|
|
100
|
-
specs.push({ name: a, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, a), port });
|
|
100
|
+
specs.push({ name: a, cmd: 'npx', args: ['vite', '--port', String(port), '--strictPort'], cwd: join(appsDir, a), port, portArgIndex: 2 });
|
|
101
101
|
}
|
|
102
102
|
for (const ai of aiServers) {
|
|
103
103
|
if (!existsSync(join(appsDir, ai, 'main.py'))) continue;
|
package/src/commands/init.ts
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* nexus init — scaffold a new Nexus project.
|
|
2
|
+
* nexus init — scaffold a new Nexus project via the guided wizard.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* nexus init my-app
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* nexus init open the full-screen setup wizard
|
|
5
|
+
* nexus init my-app open the wizard with the name pre-filled
|
|
6
|
+
*
|
|
7
|
+
* Always interactive (the wizard). Requires a TTY; in a non-interactive
|
|
8
|
+
* context it prints a message and exits non-zero. `--no-install` skips the
|
|
9
|
+
* dependency install, `--force` overwrites an existing target directory.
|
|
8
10
|
*/
|
|
9
|
-
import { mkdir, writeFile
|
|
11
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
10
12
|
import { existsSync } from 'node:fs';
|
|
11
13
|
import { spawn } from 'node:child_process';
|
|
12
|
-
import { join, resolve, dirname
|
|
14
|
+
import { join, resolve, dirname } from 'node:path';
|
|
13
15
|
import { fileURLToPath } from 'node:url';
|
|
14
16
|
import type { CommandContext } from '../dispatcher.js';
|
|
15
|
-
import {
|
|
16
|
-
import { randomSecret
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { confirm } from '../prompts.js';
|
|
18
|
+
import { randomSecret } from '../util.js';
|
|
19
|
+
import { writeRegistry, type PortRegistry } from '../ports.js';
|
|
20
|
+
import { renderTemplateTree } from '../templating/tree.js';
|
|
21
|
+
import { runWizard } from '../wizard.js';
|
|
22
|
+
import { getFeature, installFeature } from '../features.js';
|
|
23
|
+
import { isTty } from '../tui.js';
|
|
19
24
|
|
|
20
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
26
|
const TEMPLATES_DIR = resolve(HERE, '..', '..', 'templates');
|
|
22
|
-
// Examples ship as @bhooai/nexus-examples workspace package.
|
|
23
|
-
const EXAMPLES_DIR = resolve(HERE, '..', '..', '..', 'nexus-examples', 'examples');
|
|
24
27
|
|
|
25
28
|
interface InitVars {
|
|
26
29
|
name: string;
|
|
@@ -38,75 +41,47 @@ interface InitVars {
|
|
|
38
41
|
|
|
39
42
|
export async function run(ctx: CommandContext): Promise<void> {
|
|
40
43
|
const args = ctx.args;
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const targetDir = resolve(process.cwd(), targetDirArg);
|
|
48
|
-
if (!name) {
|
|
49
|
-
name = targetDirArg === '.' ? basename(process.cwd()) : basename(targetDir);
|
|
50
|
-
}
|
|
51
|
-
name = slugify(name);
|
|
52
|
-
if (!name) {
|
|
53
|
-
console.error('Project name cannot be empty after slugification.');
|
|
44
|
+
const prefillName = (args._[0] as string) ?? '';
|
|
45
|
+
|
|
46
|
+
// `nexus init` is always the guided wizard — it needs a terminal.
|
|
47
|
+
if (!isTty()) {
|
|
48
|
+
console.error('nexus init needs an interactive terminal for the guided wizard.');
|
|
49
|
+
console.error('Run it from a terminal, or use `nexus` to open the welcome screen.');
|
|
54
50
|
process.exitCode = 1;
|
|
55
51
|
return;
|
|
56
52
|
}
|
|
57
53
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (interactive && isInteractive()) {
|
|
65
|
-
console.log(`\nCreating Nexus project: ${name}\n`);
|
|
66
|
-
|
|
67
|
-
// Example picker
|
|
68
|
-
const examples = await listExamples();
|
|
69
|
-
example = await select('Start with:', ['empty', ...examples] as const) as string;
|
|
70
|
-
|
|
71
|
-
mongoUri = await prompt('MongoDB URI', mongoUri);
|
|
72
|
-
redisUrl = await prompt('Redis URL', redisUrl);
|
|
73
|
-
|
|
74
|
-
const providerNames = await prompt(
|
|
75
|
-
'AI providers (comma-separated, empty for none) — e.g. ollama,openai',
|
|
76
|
-
'',
|
|
77
|
-
);
|
|
78
|
-
aiProviders = providerNames
|
|
79
|
-
.split(',')
|
|
80
|
-
.map((s) => s.trim())
|
|
81
|
-
.filter(Boolean);
|
|
82
|
-
|
|
83
|
-
useVenv = await confirm('Create Python virtualenv for AI server?', true);
|
|
54
|
+
console.log('Launching Nexus project setup…');
|
|
55
|
+
const result = await runWizard(prefillName);
|
|
56
|
+
if (!result) {
|
|
57
|
+
console.log('Setup cancelled.');
|
|
58
|
+
return;
|
|
84
59
|
}
|
|
85
60
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
61
|
+
const name = result.nameSlug;
|
|
62
|
+
const targetDir = resolve(process.cwd(), name);
|
|
63
|
+
|
|
64
|
+
const includeAdmin = result.includeAdmin;
|
|
65
|
+
const includeFrontend = result.features.includes('frontend');
|
|
66
|
+
const includeAi = result.features.includes('ai-server');
|
|
67
|
+
const backendFeatureIds = result.features.filter((id) => !!getFeature(id)?.templateDir);
|
|
93
68
|
|
|
94
69
|
const vars: InitVars = {
|
|
95
70
|
name,
|
|
96
71
|
nameSlug: name,
|
|
97
72
|
jwtSecret: randomSecret(32),
|
|
98
|
-
mongoUri,
|
|
99
|
-
redisUrl,
|
|
100
|
-
example,
|
|
101
|
-
backendPort,
|
|
102
|
-
frontendPort,
|
|
103
|
-
adminPort,
|
|
104
|
-
aiPort,
|
|
105
|
-
aiProviders,
|
|
73
|
+
mongoUri: result.mongoUri,
|
|
74
|
+
redisUrl: result.redisUrl,
|
|
75
|
+
example: result.example,
|
|
76
|
+
backendPort: result.ports.backend,
|
|
77
|
+
frontendPort: result.ports.frontend,
|
|
78
|
+
adminPort: result.ports.admin,
|
|
79
|
+
aiPort: result.ports.ai,
|
|
80
|
+
aiProviders: result.aiProviders,
|
|
106
81
|
};
|
|
107
82
|
|
|
108
|
-
if (existsSync(targetDir) &&
|
|
109
|
-
const ok =
|
|
83
|
+
if (existsSync(targetDir) && !args.flags.force) {
|
|
84
|
+
const ok = await confirm(`Directory ${targetDir} exists — proceed?`, false);
|
|
110
85
|
if (!ok) {
|
|
111
86
|
console.error('Aborted.');
|
|
112
87
|
process.exitCode = 1;
|
|
@@ -114,41 +89,51 @@ export async function run(ctx: CommandContext): Promise<void> {
|
|
|
114
89
|
}
|
|
115
90
|
}
|
|
116
91
|
|
|
117
|
-
// 1. Create directory
|
|
92
|
+
// 1. Create directory + commit the chosen ports to the registry.
|
|
118
93
|
await mkdir(targetDir, { recursive: true });
|
|
94
|
+
const registry: PortRegistry = { backend: vars.backendPort };
|
|
95
|
+
if (includeFrontend) registry.frontend = vars.frontendPort;
|
|
96
|
+
if (includeAdmin) registry.admin = vars.adminPort;
|
|
97
|
+
if (includeAi) registry['ai-server'] = vars.aiPort;
|
|
98
|
+
await writeRegistry(targetDir, registry);
|
|
119
99
|
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
100
|
+
// 2. Render base templates, excluding apps the user turned off.
|
|
101
|
+
const exclude = (rel: string): boolean =>
|
|
102
|
+
(!includeAdmin && (rel === 'apps/admin' || rel.startsWith('apps/admin/'))) ||
|
|
103
|
+
(!includeFrontend && (rel === 'apps/frontend' || rel.startsWith('apps/frontend/'))) ||
|
|
104
|
+
(!includeAi && (rel === 'apps/ai-server' || rel.startsWith('apps/ai-server/')));
|
|
125
105
|
|
|
126
|
-
|
|
127
|
-
await renderTemplateTree(join(TEMPLATES_DIR, 'base'), targetDir, vars);
|
|
106
|
+
await renderTemplateTree(join(TEMPLATES_DIR, 'base'), targetDir, vars as unknown as Record<string, unknown>, { exclude });
|
|
128
107
|
|
|
129
108
|
// 2b. Create the full Laravel-style folder tree for the default backend.
|
|
130
|
-
// Mirrors what `add backend` does so subsequent `make:*` calls find their homes.
|
|
131
109
|
await ensureBackendFolderTree(join(targetDir, 'apps', 'backend'));
|
|
132
110
|
|
|
133
|
-
// 3. Apply example overlay (if not 'empty')
|
|
134
|
-
if (example && example !== 'empty') {
|
|
135
|
-
const exampleDir = join(
|
|
136
|
-
if (existsSync(exampleDir)) {
|
|
137
|
-
await renderTemplateTree(exampleDir, targetDir, vars);
|
|
138
|
-
console.log(`✓ Applied example: ${example}`);
|
|
111
|
+
// 3. Apply example overlay (if not 'empty').
|
|
112
|
+
if (vars.example && vars.example !== 'empty') {
|
|
113
|
+
const exampleDir = join(result.examplesDir, vars.example);
|
|
114
|
+
if (result.examplesDir && existsSync(exampleDir)) {
|
|
115
|
+
await renderTemplateTree(exampleDir, targetDir, vars as unknown as Record<string, unknown>);
|
|
116
|
+
console.log(`✓ Applied example: ${vars.example}`);
|
|
139
117
|
} else {
|
|
140
|
-
console.warn(`! Example "${example}" not found — continuing with empty scaffold.`);
|
|
118
|
+
console.warn(`! Example "${vars.example}" not found — continuing with empty scaffold.`);
|
|
141
119
|
}
|
|
142
120
|
}
|
|
143
121
|
|
|
122
|
+
// 3b. Install selected backend feature starter code.
|
|
123
|
+
for (const id of backendFeatureIds) {
|
|
124
|
+
const feature = getFeature(id);
|
|
125
|
+
if (!feature) continue;
|
|
126
|
+
const res = await installFeature(targetDir, id);
|
|
127
|
+
for (const msg of res.messages) console.log(msg);
|
|
128
|
+
}
|
|
129
|
+
|
|
144
130
|
// 4. .env
|
|
145
131
|
const envPath = join(targetDir, '.env');
|
|
146
132
|
if (!existsSync(envPath)) {
|
|
147
|
-
|
|
148
|
-
await writeFile(envPath, envContent, 'utf-8');
|
|
133
|
+
await writeFile(envPath, buildEnvFile(vars), 'utf-8');
|
|
149
134
|
}
|
|
150
135
|
|
|
151
|
-
// 5. Install dependencies (unless --no-install)
|
|
136
|
+
// 5. Install dependencies (unless --no-install).
|
|
152
137
|
const skipInstall = !!args.flags['no-install'];
|
|
153
138
|
if (skipInstall) {
|
|
154
139
|
console.log(`\n( --no-install given — run \`npm install\` in ${targetDir} manually. )`);
|
|
@@ -168,15 +153,17 @@ export async function run(ctx: CommandContext): Promise<void> {
|
|
|
168
153
|
✓ Project scaffolded at ${targetDir}
|
|
169
154
|
|
|
170
155
|
Next steps:
|
|
171
|
-
cd ${
|
|
156
|
+
cd ${name}
|
|
172
157
|
npm run dev
|
|
173
158
|
|
|
174
159
|
Ports allocated:
|
|
175
|
-
Backend : ${backendPort}
|
|
176
|
-
Frontend : ${frontendPort}
|
|
177
|
-
Admin : ${adminPort}
|
|
178
|
-
AI server : ${aiPort}
|
|
160
|
+
Backend : ${vars.backendPort}
|
|
161
|
+
Frontend : ${includeFrontend ? vars.frontendPort : '(not included)'}
|
|
162
|
+
Admin : ${includeAdmin ? vars.adminPort : '(not included)'}
|
|
163
|
+
AI server : ${includeAi ? vars.aiPort : '(not included)'}
|
|
179
164
|
`);
|
|
165
|
+
|
|
166
|
+
|
|
180
167
|
}
|
|
181
168
|
|
|
182
169
|
/** Run `npm install` in a directory; returns the exit code. */
|
|
@@ -195,13 +182,6 @@ function runNpmInstall(dir: string): Promise<number> {
|
|
|
195
182
|
});
|
|
196
183
|
}
|
|
197
184
|
|
|
198
|
-
async function listExamples(): Promise<string[]> {
|
|
199
|
-
const dir = EXAMPLES_DIR;
|
|
200
|
-
if (!existsSync(dir)) return [];
|
|
201
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
202
|
-
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
185
|
/** The canonical 26-folder Laravel-style tree under a backend's src/. Exported for `add.ts`. */
|
|
206
186
|
export const BACKEND_FOLDERS = [
|
|
207
187
|
'src/routes', 'src/graphql', 'src/ws',
|
|
@@ -222,38 +202,6 @@ async function ensureBackendFolderTree(backendDir: string): Promise<void> {
|
|
|
222
202
|
}
|
|
223
203
|
}
|
|
224
204
|
|
|
225
|
-
async function renderTemplateTree(srcDir: string, targetDir: string, vars: InitVars): Promise<void> {
|
|
226
|
-
if (!existsSync(srcDir)) {
|
|
227
|
-
console.warn(`[init] missing template dir: ${srcDir}`);
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
const entries = await readdir(srcDir, { withFileTypes: true });
|
|
231
|
-
for (const e of entries) {
|
|
232
|
-
const src = join(srcDir, e.name);
|
|
233
|
-
const renderedName = isTemplateName(e.name) ? stripTemplateSuffix(e.name) : e.name;
|
|
234
|
-
// Handle special filenames
|
|
235
|
-
const fileName = renderedName === 'gitignore' ? '.gitignore'
|
|
236
|
-
: renderedName === 'dockerignore' ? '.dockerignore'
|
|
237
|
-
: renderedName;
|
|
238
|
-
const dest = join(targetDir, fileName);
|
|
239
|
-
|
|
240
|
-
if (e.isDirectory()) {
|
|
241
|
-
await mkdir(dest, { recursive: true });
|
|
242
|
-
await renderTemplateTree(src, dest, vars);
|
|
243
|
-
} else if (e.isFile()) {
|
|
244
|
-
try {
|
|
245
|
-
const raw = await readFile(src, 'utf-8');
|
|
246
|
-
const isTemplate = isTemplateName(e.name) || raw.includes('<%');
|
|
247
|
-
const content = isTemplate ? renderString(raw, vars as unknown as Record<string, unknown>) : raw;
|
|
248
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
249
|
-
await writeFile(dest, content, 'utf-8');
|
|
250
|
-
} catch (err) {
|
|
251
|
-
console.warn(`[init] failed to render ${src}:`, err);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
205
|
function buildEnvFile(vars: InitVars): string {
|
|
258
206
|
return `# BhooAI Nexus — generated .env
|
|
259
207
|
# Secrets live here. Do not commit.
|
|
@@ -271,8 +219,11 @@ NEXUS_REDIS_URL=${vars.redisUrl}
|
|
|
271
219
|
# Auth
|
|
272
220
|
NEXUS_AUTH_JWT_SECRET=${vars.jwtSecret}
|
|
273
221
|
|
|
274
|
-
# AI
|
|
275
|
-
|
|
222
|
+
# AI (ollama is local by default; uncomment others as needed)
|
|
223
|
+
NEXUS_AI_PROVIDER=ollama
|
|
224
|
+
NEXUS_AI_MODEL=llama3.1:8b
|
|
225
|
+
OLLAMA_MODEL=llama3.1:8b
|
|
226
|
+
${vars.aiProviders.filter((p) => p !== 'ollama').map((p) => `# NEXUS_AI_${p.toUpperCase()}_API_KEY=`).join('\n')}
|
|
276
227
|
|
|
277
228
|
# Storage (uncomment for S3)
|
|
278
229
|
# NEXUS_STORAGE_S3_BUCKET=
|
|
@@ -282,5 +233,5 @@ ${vars.aiProviders.map((p) => `NEXUS_AI_${p.toUpperCase()}_API_KEY=`).join('\n')
|
|
|
282
233
|
`;
|
|
283
234
|
}
|
|
284
235
|
|
|
285
|
-
export const description = 'Scaffold a new Nexus project (
|
|
286
|
-
export const usage = 'nexus init [
|
|
236
|
+
export const description = 'Scaffold a new Nexus project (guided wizard)';
|
|
237
|
+
export const usage = 'nexus init [name] [--no-install] [--force]';
|