@bhooai/nexus-cli 0.1.4 → 2.0.0
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 +8 -12
- package/src/commands/add.ts +274 -0
- package/src/commands/build.ts +91 -0
- package/src/commands/db.ts +241 -0
- package/src/commands/dev.ts +149 -186
- package/src/commands/doctor.ts +102 -184
- package/src/commands/init.ts +204 -958
- package/src/commands/maintenance-commands.ts +40 -0
- package/src/commands/plugin.ts +233 -0
- package/src/commands/queue.ts +145 -0
- package/src/commands/test.ts +34 -0
- package/src/dispatcher.ts +98 -0
- package/src/generators.ts +102 -0
- package/src/index.ts +32 -90
- package/src/make/generators.ts +431 -0
- package/src/ports.ts +85 -0
- package/src/prompts.ts +68 -0
- package/src/templating/render.ts +109 -0
- package/src/util.ts +74 -112
- package/templates/base/Dockerfile.ejs +68 -0
- package/templates/base/README.md.ejs +62 -0
- package/templates/base/apps/admin/index.html.ejs +12 -0
- package/templates/base/apps/admin/nginx.conf.ejs +16 -0
- package/templates/base/apps/admin/package.json.ejs +15 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +7 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +22 -0
- package/templates/base/apps/ai-server/main.py.ejs +30 -0
- package/templates/base/apps/ai-server/requirements.txt +4 -0
- package/templates/base/apps/backend/src/main.ts +14 -0
- package/templates/base/apps/backend/src/routes/index.ts.ejs +11 -0
- package/templates/base/apps/frontend/index.html.ejs +12 -0
- package/templates/base/apps/frontend/nginx.conf.ejs +33 -0
- package/templates/base/apps/frontend/package.json.ejs +11 -0
- package/templates/base/apps/frontend/src/main.tsx.ejs +16 -0
- package/templates/base/apps/frontend/vite.config.ts.ejs +22 -0
- package/templates/base/docker-compose.yml.ejs +93 -0
- package/templates/base/dockerignore +21 -0
- package/templates/base/gitignore +43 -0
- package/templates/base/nexus.config.ts.ejs +97 -0
- package/templates/base/package.json.ejs +40 -0
- package/templates/{tsconfig.json → base/tsconfig.json} +2 -3
- package/tsconfig.json +5 -4
- package/PLAN.md +0 -141
- package/README.md +0 -34
- package/src/commands/cluster.ts +0 -133
- package/src/commands/node.ts +0 -106
- package/src/commands/pysetup.ts +0 -142
- package/src/commands/sync.ts +0 -116
- package/src/commands/uninstall.ts +0 -287
- package/src/config-sync.ts +0 -384
- package/src/dotenv.ts +0 -39
- package/src/supervisor.ts +0 -394
- package/src/wizard.ts +0 -149
- package/templates/Dockerfile +0 -60
- package/templates/README.md +0 -69
- package/templates/apps/admin/index.html +0 -12
- package/templates/apps/admin/package.json +0 -22
- package/templates/apps/admin/postcss.config.js +0 -3
- package/templates/apps/admin/src/main.tsx +0 -10
- package/templates/apps/admin/src/vite-env.d.ts +0 -18
- package/templates/apps/admin/tailwind.config.js +0 -9
- package/templates/apps/admin/tsconfig.json +0 -17
- package/templates/apps/admin/vite.config.ts +0 -64
- package/templates/apps/ai-server/main.py +0 -43
- package/templates/apps/ai-server/providers/__init__.py +0 -3
- package/templates/apps/ai-server/providers/base.py +0 -111
- package/templates/apps/ai-server/requirements.txt +0 -3
- package/templates/apps/ai-server/routers/__init__.py +0 -3
- package/templates/apps/ai-server/routers/chat.py +0 -47
- package/templates/apps/ai-server/routers/embeddings.py +0 -30
- package/templates/apps/ai-server/routers/lint.py +0 -167
- package/templates/apps/ai-server/routers/models.py +0 -23
- package/templates/apps/ai-server/routers/preflight.py +0 -169
- package/templates/apps/ai-server/settings.py +0 -48
- package/templates/apps/backend/package.json +0 -34
- package/templates/apps/backend/src/main.ts +0 -375
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +0 -732
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +0 -391
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +0 -161
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +0 -89
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +0 -242
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +0 -78
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +0 -449
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +0 -265
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +0 -220
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +0 -100
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +0 -172
- package/templates/apps/backend/src/modules/requests/requestLog.ts +0 -175
- package/templates/apps/backend/src/modules/users/userGraph.ts +0 -83
- package/templates/apps/backend/src/modules/users/userModel.ts +0 -88
- package/templates/apps/backend/src/plugins/CronScheduler.ts +0 -69
- package/templates/apps/backend/src/plugins/loadPlugins.ts +0 -107
- package/templates/apps/backend/tsconfig.json +0 -14
- package/templates/apps/frontend/index.html +0 -12
- package/templates/apps/frontend/package.json +0 -20
- package/templates/apps/frontend/postcss.config.js +0 -3
- package/templates/apps/frontend/src/index.css +0 -44
- package/templates/apps/frontend/src/main.tsx +0 -65
- package/templates/apps/frontend/tailwind.config.js +0 -9
- package/templates/apps/frontend/vite.config.ts +0 -72
- package/templates/bin/nexus.js +0 -35
- package/templates/bin/serve-all.mjs +0 -45
- package/templates/dockerignore +0 -15
- package/templates/gitignore +0 -12
- package/templates/nexus.config.ts +0 -69
- package/templates/package.json +0 -47
- package/tests/cli.test.ts +0 -52
- package/tests/config-sync.test.ts +0 -201
- package/tests/dotenv.test.ts +0 -51
- package/vitest.config.ts +0 -9
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +0 -13
- /package/templates/{uploads → base/apps/backend/storage}/.gitkeep +0 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nexus down — enable maintenance mode for the current project.
|
|
3
|
+
* nexus up — disable maintenance mode.
|
|
4
|
+
*/
|
|
5
|
+
import type { CommandContext } from '../dispatcher.js';
|
|
6
|
+
import { setMaintenance, isMaintenance, maintenanceMessage } from '@bhooai/nexus-core';
|
|
7
|
+
|
|
8
|
+
export async function runDown(ctx: CommandContext): Promise<void> {
|
|
9
|
+
const projectRoot = process.cwd();
|
|
10
|
+
const msg = ctx.args.flags.message as string | undefined;
|
|
11
|
+
await setMaintenance(projectRoot, true, msg);
|
|
12
|
+
console.log(`✓ Maintenance mode enabled${msg ? ` — "${msg}"` : ''}`);
|
|
13
|
+
console.log(` All requests will receive HTTP 503 (except /health).`);
|
|
14
|
+
console.log(` Use \`nexus up\` to restore.`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function runUp(_ctx: CommandContext): Promise<void> {
|
|
18
|
+
const projectRoot = process.cwd();
|
|
19
|
+
if (!isMaintenance(projectRoot)) {
|
|
20
|
+
console.log('Maintenance mode is not enabled.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const msg = await maintenanceMessage(projectRoot);
|
|
24
|
+
await setMaintenance(projectRoot, false);
|
|
25
|
+
console.log(`✓ Maintenance mode disabled${msg ? ` (was: "${msg}")` : ''}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const downCommand = {
|
|
29
|
+
name: 'down',
|
|
30
|
+
description: 'Enable maintenance mode (503 + Retry-After)',
|
|
31
|
+
usage: 'nexus down [--message "..."]',
|
|
32
|
+
run: runDown,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const upCommand = {
|
|
36
|
+
name: 'up',
|
|
37
|
+
description: 'Disable maintenance mode',
|
|
38
|
+
usage: 'nexus up',
|
|
39
|
+
run: runUp,
|
|
40
|
+
};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nexus plugin — manage project plugins (new / install / remove / list).
|
|
3
|
+
*
|
|
4
|
+
* nexus plugin new <name> — scaffold plugins/<name>/ with a manifest
|
|
5
|
+
* nexus plugin install <pkg-or-path> — install a plugin (npm package or local path)
|
|
6
|
+
* nexus plugin remove <name> — remove a plugin entry
|
|
7
|
+
* nexus plugin list — list registered plugins
|
|
8
|
+
*
|
|
9
|
+
* Plugin entry lives in nexus.config.ts under `plugins.entries[]`.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { resolve, join, basename } from 'node:path';
|
|
14
|
+
import type { CommandContext } from '../dispatcher.js';
|
|
15
|
+
import { slugify } from '../util.js';
|
|
16
|
+
import { confirm, isInteractive } from '../prompts.js';
|
|
17
|
+
|
|
18
|
+
const PLUGIN_MANIFEST = `{
|
|
19
|
+
"name": "<%= name %>",
|
|
20
|
+
"version": "0.1.0",
|
|
21
|
+
"description": "",
|
|
22
|
+
"main": "index.ts"
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const PLUGIN_INDEX = `/**
|
|
27
|
+
* <name> plugin — auto-loaded by createNexusApp().
|
|
28
|
+
*
|
|
29
|
+
* Export hooks:
|
|
30
|
+
* register(container) — wire singletons before routes mount
|
|
31
|
+
* routes — additional RoutesFile[] to merge
|
|
32
|
+
* boot(app) — post-init hook
|
|
33
|
+
*/
|
|
34
|
+
import type { Container } from '@bhooai/nexus-core';
|
|
35
|
+
|
|
36
|
+
export function register(container: Container): void {
|
|
37
|
+
// Register DI singletons here.
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function boot(): Promise<void> {
|
|
41
|
+
// Async init.
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// export const routes = [...]; // opt-in: additional HTTP routes
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
interface PluginEntry {
|
|
48
|
+
path: string;
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
config?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function run(ctx: CommandContext): Promise<void> {
|
|
54
|
+
const sub = ctx.args._[0];
|
|
55
|
+
switch (sub) {
|
|
56
|
+
case 'new': return pluginNew(ctx);
|
|
57
|
+
case 'install': return pluginInstall(ctx);
|
|
58
|
+
case 'remove': return pluginRemove(ctx);
|
|
59
|
+
case 'list': return pluginList(ctx);
|
|
60
|
+
default:
|
|
61
|
+
console.error('Usage: nexus plugin <new|install|remove|list> [...]');
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function pluginNew(ctx: CommandContext): Promise<void> {
|
|
67
|
+
const name = ctx.args._[1];
|
|
68
|
+
if (!name) {
|
|
69
|
+
console.error('Usage: nexus plugin new <name>');
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const slug = slugify(name);
|
|
74
|
+
const projectRoot = process.cwd();
|
|
75
|
+
const pluginsDir = resolve(projectRoot, getPluginsDir(projectRoot));
|
|
76
|
+
const pluginDir = join(pluginsDir, slug);
|
|
77
|
+
if (existsSync(pluginDir)) {
|
|
78
|
+
console.error(`✗ Plugin "${slug}" already exists at ${pluginDir}`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await mkdir(pluginDir, { recursive: true });
|
|
84
|
+
await writeFile(join(pluginDir, 'package.json'), PLUGIN_MANIFEST.replace(/<%= name %>/g, slug));
|
|
85
|
+
await writeFile(join(pluginDir, 'index.ts'), PLUGIN_INDEX.replace(/<name>/g, slug));
|
|
86
|
+
await registerPluginInConfig(projectRoot, { path: join('./', getPluginsDir(projectRoot), slug), enabled: true });
|
|
87
|
+
|
|
88
|
+
console.log(`✓ Created ${pluginDir}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function pluginInstall(ctx: CommandContext): Promise<void> {
|
|
92
|
+
const pkg = ctx.args._[1];
|
|
93
|
+
if (!pkg) {
|
|
94
|
+
console.error('Usage: nexus plugin install <pkg-or-path>');
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const projectRoot = process.cwd();
|
|
99
|
+
const isPath = pkg.startsWith('./') || pkg.startsWith('../') || pkg.includes(':\\') || pkg.startsWith('/');
|
|
100
|
+
const path = isPath ? pkg : pkg; // for npm packages, just record the name; resolution happens at require time
|
|
101
|
+
await registerPluginInConfig(projectRoot, { path, enabled: true });
|
|
102
|
+
console.log(`✓ Registered plugin: ${path}`);
|
|
103
|
+
if (!isPath) {
|
|
104
|
+
console.log(` Run \`npm install ${pkg}\` if not already installed.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function pluginRemove(ctx: CommandContext): Promise<void> {
|
|
109
|
+
const name = ctx.args._[1];
|
|
110
|
+
if (!name) {
|
|
111
|
+
console.error('Usage: nexus plugin remove <name>');
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const projectRoot = process.cwd();
|
|
116
|
+
const removed = await unregisterPluginInConfig(projectRoot, name);
|
|
117
|
+
if (!removed) {
|
|
118
|
+
console.error(`✗ Plugin "${name}" not registered in nexus.config.ts`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
console.log(`✓ Removed plugin entry: ${name}`);
|
|
123
|
+
|
|
124
|
+
// If it's a local folder under plugins/, offer to delete the files.
|
|
125
|
+
const pluginsDir = resolve(projectRoot, getPluginsDir(projectRoot));
|
|
126
|
+
const localDir = join(pluginsDir, slugify(name));
|
|
127
|
+
if (existsSync(localDir) && isInteractive()) {
|
|
128
|
+
const yes = await confirm(`Also delete ${localDir}?`, false);
|
|
129
|
+
if (yes) {
|
|
130
|
+
await rm(localDir, { recursive: true, force: true });
|
|
131
|
+
console.log(`✓ Deleted ${localDir}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function pluginList(_ctx: CommandContext): Promise<void> {
|
|
137
|
+
const projectRoot = process.cwd();
|
|
138
|
+
const entries = await readPluginEntries(projectRoot);
|
|
139
|
+
if (entries.length === 0) {
|
|
140
|
+
console.log('(no plugins registered — entries array is empty)');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
console.log(`Plugins in nexus.config.ts (${entries.length}):\n`);
|
|
144
|
+
for (const e of entries) {
|
|
145
|
+
console.log(` ${e.enabled ? '✓' : '○'} ${e.path}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ----------------------------------------------------------------------
|
|
150
|
+
// Config read/write helpers
|
|
151
|
+
// ----------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
function getPluginsDir(projectRoot: string): string {
|
|
154
|
+
const pluginsDir = readPluginsDirFromConfig(projectRoot);
|
|
155
|
+
return pluginsDir ?? 'plugins';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readPluginsDirFromConfig(projectRoot: string): string | null {
|
|
159
|
+
const cfgPath = resolve(projectRoot, 'nexus.config.ts');
|
|
160
|
+
if (!existsSync(cfgPath)) return null;
|
|
161
|
+
try {
|
|
162
|
+
const raw = require('node:fs').readFileSync(cfgPath, 'utf-8');
|
|
163
|
+
const m = raw.match(/plugins\s*:\s*\{[^}]*dir\s*:\s*['"]([^'"]+)['"]/);
|
|
164
|
+
return m ? m[1]! : null;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readPluginEntries(projectRoot: string): Promise<PluginEntry[]> {
|
|
171
|
+
const cfgPath = resolve(projectRoot, 'nexus.config.ts');
|
|
172
|
+
if (!existsSync(cfgPath)) return [];
|
|
173
|
+
try {
|
|
174
|
+
const raw = await readFile(cfgPath, 'utf-8');
|
|
175
|
+
// Naive but reliable-ish parse: find entries: [...] block
|
|
176
|
+
const m = raw.match(/entries\s*:\s*\[([\s\S]*?)\]/);
|
|
177
|
+
if (!m) return [];
|
|
178
|
+
const block = m[1]!;
|
|
179
|
+
const entries: PluginEntry[] = [];
|
|
180
|
+
const re = /\{\s*path\s*:\s*['"]([^'"]+)['"]\s*,\s*enabled\s*:\s*(true|false)/g;
|
|
181
|
+
let match;
|
|
182
|
+
while ((match = re.exec(block)) !== null) {
|
|
183
|
+
entries.push({ path: match[1]!, enabled: match[2] === 'true' });
|
|
184
|
+
}
|
|
185
|
+
return entries;
|
|
186
|
+
} catch {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function registerPluginInConfig(projectRoot: string, entry: PluginEntry): Promise<void> {
|
|
192
|
+
const cfgPath = resolve(projectRoot, 'nexus.config.ts');
|
|
193
|
+
if (!existsSync(cfgPath)) {
|
|
194
|
+
throw new Error('nexus.config.ts not found in project root');
|
|
195
|
+
}
|
|
196
|
+
const raw = await readFile(cfgPath, 'utf-8');
|
|
197
|
+
const jsonEntry = `{ path: '${entry.path}', enabled: ${entry.enabled} }`;
|
|
198
|
+
|
|
199
|
+
// If entries: [] is empty, replace with [entry]
|
|
200
|
+
if (/entries\s*:\s*\[\s*\]/.test(raw)) {
|
|
201
|
+
const updated = raw.replace(/entries\s*:\s*\[\s*\]/, `entries: [${jsonEntry}]`);
|
|
202
|
+
await writeFile(cfgPath, updated, 'utf-8');
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// If entries: [...existing...], append
|
|
206
|
+
if (/entries\s*:\s*\[/.test(raw)) {
|
|
207
|
+
const updated = raw.replace(/entries\s*:\s*\[/, `entries: [${jsonEntry}, `);
|
|
208
|
+
await writeFile(cfgPath, updated, 'utf-8');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
throw new Error('Cannot locate plugins.entries[] in nexus.config.ts');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function unregisterPluginInConfig(projectRoot: string, name: string): Promise<boolean> {
|
|
215
|
+
const cfgPath = resolve(projectRoot, 'nexus.config.ts');
|
|
216
|
+
if (!existsSync(cfgPath)) return false;
|
|
217
|
+
const raw = await readFile(cfgPath, 'utf-8');
|
|
218
|
+
const slug = slugify(name);
|
|
219
|
+
// Remove any entry whose path ends with /<slug> or is the package name
|
|
220
|
+
const pattern = new RegExp(`\\{[^}]*path\\s*:\\s*['"][^'"]*${slug.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}[^'"]*['"][^}]*\\},?\\s*`, 'g');
|
|
221
|
+
const updated = raw.replace(pattern, '');
|
|
222
|
+
if (updated === raw) return false;
|
|
223
|
+
// Cleanup: "[, " → "[", ", ]" → "]"
|
|
224
|
+
const cleaned = updated
|
|
225
|
+
.replace(/\[\s*,\s*/g, '[')
|
|
226
|
+
.replace(/,\s*\]/g, ']')
|
|
227
|
+
.replace(/,\s*,/g, ',');
|
|
228
|
+
await writeFile(cfgPath, cleaned, 'utf-8');
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const description = 'Manage project plugins (new / install / remove / list)';
|
|
233
|
+
export const usage = 'nexus plugin <subcommand> [...]';
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nexus queue — process background jobs.
|
|
3
|
+
*
|
|
4
|
+
* nexus queue:work [--queue <name>] — start a worker that polls + runs jobs
|
|
5
|
+
* nexus queue:retry <jobId> — move a failed job back onto its queue
|
|
6
|
+
*
|
|
7
|
+
* Jobs live in apps/<backend>/src/jobs/*Job.ts. Workers poll the queue
|
|
8
|
+
* adapter from nexus-core's Job subsystem.
|
|
9
|
+
*/
|
|
10
|
+
import { resolve, join } from 'node:path';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { readdir } from 'node:fs/promises';
|
|
13
|
+
import { pathToFileURL } from 'node:url';
|
|
14
|
+
import type { CommandContext } from '../dispatcher.js';
|
|
15
|
+
import type { Job, JobQueueAdapter, JobPayload } from '@bhooai/nexus-core';
|
|
16
|
+
|
|
17
|
+
interface RegisteredJobClass {
|
|
18
|
+
name: string;
|
|
19
|
+
klass: new () => Job<never[]>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const JOBS_SUBDIR = 'src/jobs';
|
|
23
|
+
|
|
24
|
+
export async function runWork(ctx: CommandContext): Promise<void> {
|
|
25
|
+
const appName = (ctx.args.flags.app as string) ?? 'backend';
|
|
26
|
+
const queueFilter = ctx.args.flags.queue as string | undefined;
|
|
27
|
+
const projectRoot = process.cwd();
|
|
28
|
+
const jobsDir = join(projectRoot, 'apps', appName, JOBS_SUBDIR);
|
|
29
|
+
|
|
30
|
+
if (!existsSync(jobsDir)) {
|
|
31
|
+
console.error(`✗ No jobs directory: ${jobsDir}`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const jobClasses = await loadJobs(jobsDir);
|
|
37
|
+
if (jobClasses.length === 0) {
|
|
38
|
+
console.log('(no jobs found)');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build queue name -> jobs mapping
|
|
43
|
+
const byQueue = new Map<string, RegisteredJobClass[]>();
|
|
44
|
+
for (const j of jobClasses) {
|
|
45
|
+
const instance = new j.klass();
|
|
46
|
+
const q = (instance as Job).queue ?? 'default';
|
|
47
|
+
if (queueFilter && queueFilter !== q) continue;
|
|
48
|
+
if (!byQueue.has(q)) byQueue.set(q, []);
|
|
49
|
+
byQueue.get(q)!.push(j);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (byQueue.size === 0) {
|
|
53
|
+
console.log(`(no jobs registered${queueFilter ? ` for queue "${queueFilter}"` : ''})`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log(`nexus queue:work — listening on queues: ${[...byQueue.keys()].join(', ')}`);
|
|
58
|
+
console.log(`Loaded ${jobClasses.length} job class(es). Ctrl+C to stop.\n`);
|
|
59
|
+
|
|
60
|
+
// The queue adapter lives in nexus-core; we need a shared instance.
|
|
61
|
+
// For now: instantiate a fresh InMemoryQueueAdapter (means the queue is
|
|
62
|
+
// process-local — production deployments should plug a Redis adapter and
|
|
63
|
+
// call this worker with the same adapter wired).
|
|
64
|
+
const { InMemoryQueueAdapter, configureQueue } = await import('@bhooai/nexus-core');
|
|
65
|
+
const adapter = new InMemoryQueueAdapter();
|
|
66
|
+
configureQueue(adapter);
|
|
67
|
+
|
|
68
|
+
let stopping = false;
|
|
69
|
+
process.on('SIGINT', () => { stopping = true; console.log('\nStopping...'); });
|
|
70
|
+
process.on('SIGTERM', () => { stopping = true; });
|
|
71
|
+
|
|
72
|
+
// Poll loop per queue
|
|
73
|
+
const workers = [...byQueue.entries()].map(([queue, jobs]) =>
|
|
74
|
+
workQueue(queue, jobs, adapter, () => stopping),
|
|
75
|
+
);
|
|
76
|
+
await Promise.all(workers);
|
|
77
|
+
|
|
78
|
+
console.log('Worker exited.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function loadJobs(jobsDir: string): Promise<RegisteredJobClass[]> {
|
|
82
|
+
const files = (await readdir(jobsDir)).filter((f) => f.endsWith('Job.ts') || f.endsWith('Job.js'));
|
|
83
|
+
const out: RegisteredJobClass[] = [];
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
try {
|
|
86
|
+
const mod = await import(pathToFileURL(join(jobsDir, file)).href);
|
|
87
|
+
const klass = (mod as any).default ?? (mod as any)[Object.keys(mod)[0]!];
|
|
88
|
+
if (typeof klass === 'function') {
|
|
89
|
+
out.push({ name: file.replace(/\.(ts|js)$/, ''), klass });
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn(`! failed to load ${file}:`, err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function workQueue(
|
|
99
|
+
queue: string,
|
|
100
|
+
jobs: RegisteredJobClass[],
|
|
101
|
+
adapter: JobQueueAdapter,
|
|
102
|
+
isStopping: () => boolean,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
while (!isStopping()) {
|
|
105
|
+
const payload = await adapter.dequeue(queue);
|
|
106
|
+
if (!payload) {
|
|
107
|
+
await sleep(500);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const target = jobs.find((j) => j.name === payload.args[0]);
|
|
112
|
+
if (!target) {
|
|
113
|
+
// Unknown job — put it back so another worker might handle it
|
|
114
|
+
await adapter.retry(payload as JobPayload, new Error('no handler'));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const instance = new target.klass();
|
|
119
|
+
try {
|
|
120
|
+
const args = payload.args.slice(1);
|
|
121
|
+
await (instance.handle as (...args: unknown[]) => Promise<void>)(...args);
|
|
122
|
+
console.log(`✓ ${target.name} (${payload.id})`);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (payload.attempts < (instance.retries ?? 3)) {
|
|
125
|
+
console.warn(`↻ ${target.name} (${payload.id}) retry ${payload.attempts + 1}`);
|
|
126
|
+
await adapter.retry(payload, err);
|
|
127
|
+
} else {
|
|
128
|
+
console.error(`✗ ${target.name} (${payload.id}) failed permanently:`, err);
|
|
129
|
+
await adapter.fail(payload, err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function runRetry(ctx: CommandContext): Promise<void> {
|
|
136
|
+
const jobId = ctx.args._[1];
|
|
137
|
+
console.log(`queue:retry ${jobId ?? '<missing>'} — requires a persistent adapter (Redis). Stub for now.`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sleep(ms: number): Promise<void> {
|
|
141
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const description = 'Process background jobs';
|
|
145
|
+
export const usage = 'nexus queue:<work|retry> [--queue <name>] [--app <backend>]';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nexus test — run vitest (unit/integration) or playwright (e2e).
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import type { CommandContext } from '../dispatcher.js';
|
|
8
|
+
|
|
9
|
+
export async function run(ctx: CommandContext): Promise<void> {
|
|
10
|
+
const isE2e = !!ctx.args.flags.e2e;
|
|
11
|
+
const isWatch = !!ctx.args.flags.watch;
|
|
12
|
+
const projectRoot = process.cwd();
|
|
13
|
+
|
|
14
|
+
const cmd = isE2e
|
|
15
|
+
? { bin: 'npx', args: ['playwright', 'test', ...(isWatch ? ['--ui'] : [])] }
|
|
16
|
+
: { bin: 'npx', args: ['vitest', ...(isWatch ? [] : ['run'])] };
|
|
17
|
+
|
|
18
|
+
// Sanity: make sure the test config exists
|
|
19
|
+
const configFile = isE2e ? 'playwright.config.ts' : 'vitest.workspace.ts';
|
|
20
|
+
if (!existsSync(resolve(projectRoot, configFile))) {
|
|
21
|
+
console.log(`No ${configFile} in ${projectRoot} — nothing to run.`);
|
|
22
|
+
console.log(`Generate one with \`nexus make:test-config\` (coming in P7).`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const code = await new Promise<number>((res) => {
|
|
27
|
+
const child = spawn(cmd.bin, cmd.args, { cwd: projectRoot, stdio: 'inherit', shell: true });
|
|
28
|
+
child.on('exit', (c) => res(c ?? 1));
|
|
29
|
+
});
|
|
30
|
+
process.exitCode = code;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const description = 'Run tests (vitest by default, playwright with --e2e)';
|
|
34
|
+
export const usage = 'nexus test [--watch] [--e2e]';
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nexus CLI entry. Dispatches to per-command modules in ./commands/.
|
|
3
|
+
*
|
|
4
|
+
* Command modules export `run(argv: string[]): Promise<void>` and a static
|
|
5
|
+
* `description` for help output.
|
|
6
|
+
*/
|
|
7
|
+
import { parseArgs } from './util.js';
|
|
8
|
+
import { closePrompts } from './prompts.js';
|
|
9
|
+
|
|
10
|
+
export interface CommandContext {
|
|
11
|
+
argv: string[];
|
|
12
|
+
args: ReturnType<typeof parseArgs>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Command {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
usage?: string;
|
|
19
|
+
run(ctx: CommandContext): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const COMMANDS: Command[] = [];
|
|
23
|
+
|
|
24
|
+
export function registerCommand(cmd: Command): void {
|
|
25
|
+
COMMANDS.push(cmd);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function run(cmd: string, argv: string[]): Promise<void> {
|
|
29
|
+
try {
|
|
30
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
31
|
+
printHelp();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
|
|
35
|
+
console.log('nexus v2.0.0');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const found = COMMANDS.find((c) => c.name === cmd);
|
|
39
|
+
if (!found) {
|
|
40
|
+
console.error(`Unknown command: ${cmd}`);
|
|
41
|
+
printHelp();
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await found.run({ argv, args: parseArgs(argv) });
|
|
46
|
+
} finally {
|
|
47
|
+
closePrompts();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function printHelp(): void {
|
|
52
|
+
console.log(`nexus — BhooAI Nexus CLI (v2)
|
|
53
|
+
|
|
54
|
+
Usage: nexus <command> [options]
|
|
55
|
+
|
|
56
|
+
Project lifecycle:
|
|
57
|
+
init [dir] [--example <name>] [--interactive] Scaffold a new project
|
|
58
|
+
dev [--only a,b] Start dev services
|
|
59
|
+
build [--target <app>|all] Build for production
|
|
60
|
+
test [--watch|--e2e] Run tests
|
|
61
|
+
doctor Environment health check
|
|
62
|
+
sync [--check|--dry-run] Rewrite derived files
|
|
63
|
+
uninstall [--purge|--dry-run|--keep-db] Remove project
|
|
64
|
+
|
|
65
|
+
Maintenance:
|
|
66
|
+
down [--message "..."] Enable maintenance mode
|
|
67
|
+
up Disable maintenance mode
|
|
68
|
+
|
|
69
|
+
Queue + database:
|
|
70
|
+
db:seed [--class <name>]
|
|
71
|
+
db:migrate [--step N]
|
|
72
|
+
db:rollback [--step N]
|
|
73
|
+
queue:work [--queue <name>]
|
|
74
|
+
queue:retry <jobId>
|
|
75
|
+
|
|
76
|
+
Multi-app scaffolding:
|
|
77
|
+
add backend <name>
|
|
78
|
+
add frontend <name> [--for <backend>]
|
|
79
|
+
add route <name> --app <backend>
|
|
80
|
+
|
|
81
|
+
Generators (make:*):
|
|
82
|
+
make:route · make:controller · make:model · make:service · make:repository ·
|
|
83
|
+
make:middleware · make:validator · make:job · make:event · make:listener ·
|
|
84
|
+
make:policy · make:resource · make:request · make:mail · make:room ·
|
|
85
|
+
make:subgraph · make:seeder · make:migration · make:provider · make:plugin
|
|
86
|
+
|
|
87
|
+
Plugins:
|
|
88
|
+
plugin new <name>
|
|
89
|
+
plugin install <pkg>
|
|
90
|
+
plugin remove <name>
|
|
91
|
+
plugin list
|
|
92
|
+
|
|
93
|
+
Python AI:
|
|
94
|
+
pysetup [pkgs...]
|
|
95
|
+
|
|
96
|
+
Run \`nexus <command> --help\` for command-specific flags (where implemented).
|
|
97
|
+
`);
|
|
98
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic make:* generators. Each generator knows:
|
|
3
|
+
* - which folder it targets (under apps/<backend>/src/)
|
|
4
|
+
* - the file suffix (e.g. Job.ts, Policy.ts, .room.ts)
|
|
5
|
+
* - the content template to render
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { resolve, join } from 'node:path';
|
|
10
|
+
import type { CommandContext } from './dispatcher.js';
|
|
11
|
+
|
|
12
|
+
export interface GeneratorSpec {
|
|
13
|
+
/** Folder under apps/<backend>/src/. */
|
|
14
|
+
folder: string;
|
|
15
|
+
/** Filename suffix appended to the slug, e.g. "Job.ts" for jobs, ".room.ts" for ws. */
|
|
16
|
+
suffix: string;
|
|
17
|
+
/** Render the file content given the slug + class name. */
|
|
18
|
+
render(input: { slug: string; className: string; name: string }): string;
|
|
19
|
+
/** Create companion file(s) — e.g. a mailable also wants a template. */
|
|
20
|
+
companions?: CompanionSpec[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CompanionSpec {
|
|
24
|
+
relativePath: (input: { slug: string; className: string }) => string;
|
|
25
|
+
content: (input: { slug: string; className: string }) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function classNameFor(slug: string): string {
|
|
29
|
+
return slug
|
|
30
|
+
.split(/[-_]/)
|
|
31
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
32
|
+
.join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Convert a user-provided name (kebab, snake, CamelCase, spaces) to kebab slug. */
|
|
36
|
+
export function slugFromName(name: string): string {
|
|
37
|
+
return name
|
|
38
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2') // split camelCase
|
|
39
|
+
.replace(/[\s_]+/g, '-')
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^a-z0-9-]+/g, '')
|
|
42
|
+
.replace(/^-+|-+$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Class-name (PascalCase) from any input. */
|
|
46
|
+
export function pascalFromName(name: string): string {
|
|
47
|
+
return slugFromName(name)
|
|
48
|
+
.split(/-/)
|
|
49
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
50
|
+
.join('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function fileNameFor(slug: string, suffix: string): string {
|
|
54
|
+
// Slug starts with a dot, the slug gets appended before it: slug + .room.ts
|
|
55
|
+
// Otherwise the slug is title-cased + suffix: slugJob.ts
|
|
56
|
+
if (suffix.startsWith('.')) return `${slug}${suffix}`;
|
|
57
|
+
return `${slug}${suffix}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runGenerator(ctx: CommandContext, spec: GeneratorSpec): Promise<void> {
|
|
61
|
+
const rawName = ctx.args._[0];
|
|
62
|
+
if (!rawName) {
|
|
63
|
+
console.error(`Usage: nexus make:<thing> <name> [--app <backend>]`);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const name = rawName;
|
|
68
|
+
const slug = slugFromName(name);
|
|
69
|
+
const className = pascalFromName(name);
|
|
70
|
+
const app = (ctx.args.flags.app as string) ?? 'backend';
|
|
71
|
+
|
|
72
|
+
const projectRoot = process.cwd();
|
|
73
|
+
const backendDir = join(projectRoot, 'apps', app);
|
|
74
|
+
if (!existsSync(backendDir)) {
|
|
75
|
+
console.error(`✗ Backend "${app}" not found. Run \`nexus add backend ${app}\` first, or pass --app.`);
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const targetDir = resolve(backendDir, 'src', spec.folder);
|
|
81
|
+
await mkdir(targetDir, { recursive: true });
|
|
82
|
+
const targetPath = join(targetDir, fileNameFor(slug, spec.suffix));
|
|
83
|
+
|
|
84
|
+
if (existsSync(targetPath)) {
|
|
85
|
+
console.error(`✗ File already exists: ${targetPath}`);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await writeFile(targetPath, spec.render({ slug, className, name }), 'utf-8');
|
|
91
|
+
console.log(`✓ Created ${targetPath.replace(projectRoot + '\\', '')}`);
|
|
92
|
+
|
|
93
|
+
for (const c of spec.companions ?? []) {
|
|
94
|
+
const relPath = c.relativePath({ slug, className });
|
|
95
|
+
const compPath = join(backendDir, 'src', relPath);
|
|
96
|
+
await mkdir(resolve(compPath, '..'), { recursive: true });
|
|
97
|
+
if (!existsSync(compPath)) {
|
|
98
|
+
await writeFile(compPath, c.content({ slug, className }), 'utf-8');
|
|
99
|
+
console.log(`✓ Created ${compPath.replace(projectRoot + '\\', '')}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|