@bhooai/nexus-cli 2.0.11 → 2.0.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-cli",
3
- "version": "2.0.11",
3
+ "version": "2.0.13",
4
4
  "description": "BhooAI Nexus v2 CLI — init, dev, build, test, scaffolding, plugins, queue, db.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -81,24 +81,16 @@ export async function run(ctx: CommandContext): Promise<void> {
81
81
  }
82
82
  }
83
83
 
84
- async function addBackend(ctx: CommandContext): Promise<void> {
85
- const name = ctx.args._[1];
86
- if (!name) {
87
- console.error('Usage: nexus add backend <name>');
88
- process.exitCode = 1;
89
- return;
90
- }
84
+ /**
85
+ * Programmatic backend creator — used by CLI and example _setup.mjs.
86
+ * If the directory already exists but is incomplete (e.g. overlay-only),
87
+ * missing files are filled in and port is ensured.
88
+ */
89
+ export async function createBackendApp(projectRoot: string, name: string): Promise<{ appName: string; port: number; created: boolean }> {
91
90
  const slug = slugify(name);
92
91
  const appName = `backend-${slug}`;
93
- const projectRoot = process.cwd();
94
92
  const appDir = join(projectRoot, 'apps', appName);
95
-
96
- if (existsSync(appDir)) {
97
- console.error(`✗ ${appName} already exists`);
98
- process.exitCode = 1;
99
- return;
100
- }
101
-
93
+ const existed = existsSync(appDir);
102
94
  const port = await ensurePort(projectRoot, appName);
103
95
 
104
96
  // Create directory structure (shared with init.ts's BACKEND_FOLDERS constant)
@@ -106,73 +98,101 @@ async function addBackend(ctx: CommandContext): Promise<void> {
106
98
  await mkdir(join(appDir, f), { recursive: true });
107
99
  }
108
100
 
109
- // main.ts — pass the assigned port via env (NEXUS_PORT) so dev and prod use the same.
110
- await writeFile(join(appDir, 'src', 'main.ts'),
111
- BACKEND_MAIN_TEMPLATE.replace(/<%= appName %>/g, appName));
101
+ // main.ts — only if missing (preserve user edits on re-run)
102
+ const mainPath = join(appDir, 'src', 'main.ts');
103
+ if (!existsSync(mainPath)) {
104
+ await writeFile(mainPath, BACKEND_MAIN_TEMPLATE.replace(/<%= appName %>/g, appName));
105
+ }
112
106
 
113
- // routes/index.ts
114
- await writeFile(join(appDir, 'src', 'routes', 'index.ts'),
115
- BACKEND_ROUTE_TEMPLATE.replace(/<%= appName %>/g, appName));
107
+ // routes/index.ts — only if missing
108
+ const routePath = join(appDir, 'src', 'routes', 'index.ts');
109
+ if (!existsSync(routePath)) {
110
+ await writeFile(routePath, BACKEND_ROUTE_TEMPLATE.replace(/<%= appName %>/g, appName));
111
+ }
116
112
 
117
- // package.json
118
- await writeFile(join(appDir, 'package.json'), JSON.stringify({
119
- name: `@${slug}/${appName}`,
120
- private: true,
121
- version: '0.1.0',
122
- type: 'module',
123
- scripts: { dev: 'tsx watch src/main.ts' },
124
- }, null, 2));
113
+ // package.json — only if missing
114
+ const pkgPath = join(appDir, 'package.json');
115
+ if (!existsSync(pkgPath)) {
116
+ await writeFile(pkgPath, JSON.stringify({
117
+ name: `@${slug}/${appName}`,
118
+ private: true,
119
+ version: '0.1.0',
120
+ type: 'module',
121
+ scripts: { dev: 'tsx watch src/main.ts' },
122
+ }, null, 2));
123
+ }
125
124
 
126
- console.log(`✓ Created apps/${appName}`);
127
- console.log(` srcRoot: apps/${appName}/src`);
128
- console.log(` port: ${port} (persisted in .nexus-ports.json)`);
129
- console.log(`\nAdd to apps/dev list when you want \`nexus dev\` to pick it up.`);
125
+ return { appName, port, created: !existed };
130
126
  }
131
127
 
132
- async function addFrontend(ctx: CommandContext): Promise<void> {
128
+ async function addBackend(ctx: CommandContext): Promise<void> {
133
129
  const name = ctx.args._[1];
134
130
  if (!name) {
135
- console.error('Usage: nexus add frontend <name> [--for <backend>]');
131
+ console.error('Usage: nexus add backend <name>');
136
132
  process.exitCode = 1;
137
133
  return;
138
134
  }
139
135
  const slug = slugify(name);
140
- const appName = `frontend-${slug}`;
136
+ const appName = `backend-${slug}`;
141
137
  const projectRoot = process.cwd();
142
138
  const appDir = join(projectRoot, 'apps', appName);
143
139
 
144
- if (existsSync(appDir)) {
140
+ if (existsSync(appDir) && existsSync(join(appDir, 'src', 'main.ts')) && existsSync(join(appDir, 'package.json'))) {
145
141
  console.error(`✗ ${appName} already exists`);
146
142
  process.exitCode = 1;
147
143
  return;
148
144
  }
149
145
 
150
- // Pick backend target
151
- let backend = ctx.args.flags.for as string | undefined;
146
+ const { port, created } = await createBackendApp(projectRoot, name);
147
+ if (created) {
148
+ console.log(`✓ Created apps/${appName}`);
149
+ } else {
150
+ console.log(`✓ Repaired apps/${appName} (filled missing scaffold files)`);
151
+ }
152
+ console.log(` srcRoot: apps/${appName}/src`);
153
+ console.log(` port: ${port} (persisted in .nexus-ports.json)`);
154
+ console.log(`\nAdd to apps/dev list when you want \`nexus dev\` to pick it up.`);
155
+ }
156
+
157
+ /**
158
+ * Programmatic frontend creator — used by CLI and example _setup.mjs.
159
+ * If the directory already exists but is incomplete, missing files are filled in.
160
+ */
161
+ export async function createFrontendApp(projectRoot: string, name: string, opts: { for?: string } = {}): Promise<{ appName: string; port: number; backend: string; backendPort: number; created: boolean }> {
162
+ const slug = slugify(name);
163
+ const appName = `frontend-${slug}`;
164
+ const appDir = join(projectRoot, 'apps', appName);
165
+ const existed = existsSync(appDir);
166
+
167
+ let backend = opts.for;
152
168
  if (!backend && isInteractive()) {
153
169
  backend = await pickBackend(projectRoot);
154
170
  }
155
- backend = backend ?? 'backend-main';
171
+ backend = backend ?? 'backend';
156
172
 
157
173
  const port = await ensurePort(projectRoot, appName);
158
-
159
174
  await mkdir(join(appDir, 'src'), { recursive: true });
160
175
 
161
- // package.json
162
- await writeFile(join(appDir, 'package.json'), JSON.stringify({
163
- name: `@${slug}/${appName}`,
164
- private: true,
165
- version: '0.1.0',
166
- type: 'module',
167
- scripts: {
168
- dev: `vite --port ${port}`,
169
- build: 'vite build',
170
- },
171
- }, null, 2));
176
+ // package.json — only if missing
177
+ const pkgPath = join(appDir, 'package.json');
178
+ if (!existsSync(pkgPath)) {
179
+ await writeFile(pkgPath, JSON.stringify({
180
+ name: `@${slug}/${appName}`,
181
+ private: true,
182
+ version: '0.1.0',
183
+ type: 'module',
184
+ scripts: {
185
+ dev: `vite --port ${port}`,
186
+ build: 'vite build',
187
+ },
188
+ }, null, 2));
189
+ }
172
190
 
173
- // vite.config.ts — resolve the backend's port from the registry.
174
- const backendPort = (await lookupPort(projectRoot, backend)) ?? await ensurePort(projectRoot, backend);
175
- await writeFile(join(appDir, 'vite.config.ts'), `import { defineConfig } from 'vite';
191
+ // vite.config.ts — only if missing (preserve user edits)
192
+ const vitePath = join(appDir, 'vite.config.ts');
193
+ if (!existsSync(vitePath)) {
194
+ const backendPort = (await lookupPort(projectRoot, backend)) ?? await ensurePort(projectRoot, backend);
195
+ await writeFile(vitePath, `import { defineConfig } from 'vite';
176
196
 
177
197
  // Backend host the dev proxy targets. Override with NEXUS_BACKEND_HOST when
178
198
  // deploying to a VPS (or running the backend on another machine). Defaults to
@@ -190,16 +210,25 @@ export default defineConfig({
190
210
  },
191
211
  });
192
212
  `);
213
+ }
193
214
 
194
- // index.html + main.tsx
195
- await writeFile(join(appDir, 'index.html'), `<!doctype html>
215
+ // index.html only if missing
216
+ const htmlPath = join(appDir, 'index.html');
217
+ if (!existsSync(htmlPath)) {
218
+ await writeFile(htmlPath, `<!doctype html>
196
219
  <html lang="en">
197
220
  <head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>${name}</title></head>
198
221
  <body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
199
222
  </html>
200
223
  `);
224
+ }
201
225
 
202
- await writeFile(join(appDir, 'src', 'main.tsx'), `import React from 'react';
226
+ // src/main.tsx only if missing
227
+ const mainPath = join(appDir, 'src', 'main.tsx');
228
+ if (!existsSync(mainPath)) {
229
+ const backendPort = (await lookupPort(projectRoot, backend)) ?? port;
230
+ void backendPort;
231
+ await writeFile(mainPath, `import React from 'react';
203
232
  import { createRoot } from 'react-dom/client';
204
233
 
205
234
  function App() {
@@ -212,8 +241,42 @@ function App() {
212
241
  const el = document.getElementById('root');
213
242
  if (el) createRoot(el).render(<App />);
214
243
  `);
244
+ }
215
245
 
216
- console.log(`✓ Created apps/${appName} (port ${port}, proxying to ${backend}:${backendPort})`);
246
+ const backendPort = (await lookupPort(projectRoot, backend)) ?? await ensurePort(projectRoot, backend);
247
+ return { appName, port, backend, backendPort, created: !existed };
248
+ }
249
+
250
+ async function addFrontend(ctx: CommandContext): Promise<void> {
251
+ const name = ctx.args._[1];
252
+ if (!name) {
253
+ console.error('Usage: nexus add frontend <name> [--for <backend>]');
254
+ process.exitCode = 1;
255
+ return;
256
+ }
257
+ const slug = slugify(name);
258
+ const appName = `frontend-${slug}`;
259
+ const projectRoot = process.cwd();
260
+ const appDir = join(projectRoot, 'apps', appName);
261
+
262
+ if (existsSync(appDir) && existsSync(join(appDir, 'package.json')) && existsSync(join(appDir, 'vite.config.ts'))) {
263
+ console.error(`✗ ${appName} already exists`);
264
+ process.exitCode = 1;
265
+ return;
266
+ }
267
+
268
+ let backend = ctx.args.flags.for as string | undefined;
269
+ if (!backend && isInteractive()) {
270
+ backend = await pickBackend(projectRoot);
271
+ }
272
+ backend = backend ?? 'backend';
273
+
274
+ const { port, backendPort, created } = await createFrontendApp(projectRoot, name, { for: backend });
275
+ if (created) {
276
+ console.log(`✓ Created apps/${appName} (port ${port}, proxying to ${backend}:${backendPort})`);
277
+ } else {
278
+ console.log(`✓ Repaired apps/${appName} (port ${port}, proxying to ${backend}:${backendPort})`);
279
+ }
217
280
  }
218
281
 
219
282
  async function addRoute(ctx: CommandContext): Promise<void> {
@@ -3,16 +3,19 @@
3
3
  *
4
4
  * nexus init open the full-screen setup wizard
5
5
  * nexus init my-app open the wizard with the name pre-filled
6
+ * nexus init --dev my-app point bhooai-nexus + @bhooai/* to local file: (live symlink)
6
7
  *
7
8
  * Always interactive (the wizard). Requires a TTY; in a non-interactive
8
9
  * context it prints a message and exits non-zero. `--no-install` skips the
9
10
  * dependency install, `--force` overwrites an existing target directory.
11
+ * `--dev` rewrites bhooai-nexus + @bhooai/* to file:../../PACKAGES/bhooai-nexus
12
+ * (relative) and runs `npm link` for live symlink.
10
13
  */
11
- import { mkdir, writeFile } from 'node:fs/promises';
14
+ import { mkdir, writeFile, readFile } from 'node:fs/promises';
12
15
  import { existsSync } from 'node:fs';
13
16
  import { spawn } from 'node:child_process';
14
- import { join, resolve, dirname } from 'node:path';
15
- import { fileURLToPath } from 'node:url';
17
+ import { join, resolve, dirname, relative, sep } from 'node:path';
18
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
19
  import type { CommandContext } from '../dispatcher.js';
17
20
  import { confirm } from '../prompts.js';
18
21
  import { randomSecret } from '../util.js';
@@ -41,6 +44,12 @@ interface InitVars {
41
44
 
42
45
  export async function run(ctx: CommandContext): Promise<void> {
43
46
  const args = ctx.args;
47
+ // Support --dev before or after name: `nexus init --dev myapp` and `nexus init myapp --dev`
48
+ const isDev = !!args.flags.dev;
49
+ // If --dev was parsed as positional (unlikely), also check raw argv
50
+ const rawHasDev = ctx.argv.includes('--dev');
51
+ const devMode = isDev || rawHasDev;
52
+ // Normalize name when --dev is before name: args._[0] is still the name (parseArgs strips flags)
44
53
  const prefillName = (args._[0] as string) ?? '';
45
54
 
46
55
  // `nexus init` is always the guided wizard — it needs a terminal.
@@ -108,11 +117,46 @@ export async function run(ctx: CommandContext): Promise<void> {
108
117
  // 2b. Create the full Laravel-style folder tree for the default backend.
109
118
  await ensureBackendFolderTree(join(targetDir, 'apps', 'backend'));
110
119
 
120
+ // 2c. Run example _setup.mjs before overlay (if present) — e.g. multi-app scaffolds extra apps.
121
+ // Warn-and-continue on failure; excluded from template copy.
122
+ if (vars.example && vars.example !== 'empty' && result.examplesDir) {
123
+ const exampleDir = join(result.examplesDir, vars.example);
124
+ const setupPath = join(exampleDir, '_setup.mjs');
125
+ if (existsSync(setupPath)) {
126
+ try {
127
+ const mod = await import(pathToFileURL(setupPath).href);
128
+ const fn = (mod as { default?: (ctx: SetupContext) => Promise<void> }).default ?? (mod as { setup?: (ctx: SetupContext) => Promise<void> }).setup;
129
+ if (typeof fn === 'function') {
130
+ const { createBackendApp, createFrontendApp } = await import('./add.js');
131
+ const ctx: SetupContext = {
132
+ targetDir,
133
+ projectRoot: targetDir,
134
+ vars,
135
+ utils: {
136
+ addBackend: (name: string) => createBackendApp(targetDir, name),
137
+ addFrontend: (name: string, opts?: { for?: string }) => createFrontendApp(targetDir, name, opts),
138
+ log: (msg: string) => console.log(msg),
139
+ },
140
+ };
141
+ await fn(ctx);
142
+ console.log(`✓ Example setup script ran: ${vars.example}/_setup.mjs`);
143
+ } else {
144
+ console.warn(`! Example setup script has no default export: ${setupPath}`);
145
+ }
146
+ } catch (e) {
147
+ console.warn(`! Example setup script failed for "${vars.example}": ${(e as Error).message} — continuing.`);
148
+ }
149
+ }
150
+ }
151
+
111
152
  // 3. Apply example overlay (if not 'empty').
112
153
  if (vars.example && vars.example !== 'empty') {
113
154
  const exampleDir = join(result.examplesDir, vars.example);
114
155
  if (result.examplesDir && existsSync(exampleDir)) {
115
- await renderTemplateTree(exampleDir, targetDir, vars as unknown as Record<string, unknown>);
156
+ // Exclude the setup script itself from being copied into the project
157
+ await renderTemplateTree(exampleDir, targetDir, vars as unknown as Record<string, unknown>, {
158
+ exclude: (rel) => rel === '_setup.mjs' || rel === '_setup.js' || rel.endsWith('/_setup.mjs') || rel.endsWith('/_setup.js'),
159
+ });
116
160
  console.log(`✓ Applied example: ${vars.example}`);
117
161
  } else {
118
162
  console.warn(`! Example "${vars.example}" not found — continuing with empty scaffold.`);
@@ -133,10 +177,22 @@ export async function run(ctx: CommandContext): Promise<void> {
133
177
  await writeFile(envPath, buildEnvFile(vars), 'utf-8');
134
178
  }
135
179
 
180
+ // 4b. --dev: rewrite bhooai-nexus + @bhooai/* to local file: + live symlink
181
+ if (devMode) {
182
+ const rewrote = await rewriteToLocalFileDeps(targetDir);
183
+ if (rewrote.count > 0) {
184
+ console.log(`\n[dev] Rewrote ${rewrote.count} deps to local file: (${rewrote.root})`);
185
+ for (const { name, spec } of rewrote.entries) console.log(` ${name} → ${spec}`);
186
+ } else {
187
+ console.warn(`\n[dev] No local bhooai-nexus found — keeping registry versions.`);
188
+ }
189
+ }
190
+
136
191
  // 5. Install dependencies (unless --no-install).
137
192
  const skipInstall = !!args.flags['no-install'];
138
193
  if (skipInstall) {
139
194
  console.log(`\n( --no-install given — run \`npm install\` in ${targetDir} manually. )`);
195
+ if (devMode) console.log(`[dev] After manual install, run: npm link --prefix "${targetDir}" bhooai-nexus && npm link --prefix "${targetDir}" @bhooai/* (or re-run with --dev)`);
140
196
  } else {
141
197
  console.log(`\nInstalling dependencies in ${targetDir}...\n`);
142
198
  const code = await runNpmInstall(targetDir);
@@ -145,6 +201,12 @@ export async function run(ctx: CommandContext): Promise<void> {
145
201
  process.exitCode = code ?? 1;
146
202
  } else {
147
203
  console.log('\n✓ Dependencies installed.');
204
+ if (devMode) {
205
+ console.log(`\n[dev] Linking local packages for live symlink...\n`);
206
+ const linkCode = await runNpmLinkForDev(targetDir);
207
+ if (linkCode === 0) console.log(`\n[dev] Live symlink ready — edits in ${resolveLocalBhooaiNexusRoot() ?? 'PACKAGES/bhooai-nexus'} reflect instantly.`);
208
+ else console.warn(`\n[dev] npm link failed (code ${linkCode}) — fallback to file: copy (re-run npm install after edits).`);
209
+ }
148
210
  }
149
211
  }
150
212
 
@@ -182,6 +244,96 @@ function runNpmInstall(dir: string): Promise<number> {
182
244
  });
183
245
  }
184
246
 
247
+ function resolveLocalBhooaiNexusRoot(): string | null {
248
+ // Candidates in dev machine order — first existing wins
249
+ const candidates = [
250
+ resolve(HERE, '..', '..', '..', '..'), // from packages/nexus-cli/src/commands -> repo root (PACKAGES/bhooai-nexus)
251
+ 'C:/server/PACKAGES/bhooai-nexus',
252
+ 'C:\\server\\PACKAGES\\bhooai-nexus',
253
+ resolve(process.cwd(), 'PACKAGES/bhooai-nexus'),
254
+ resolve(process.cwd(), '../PACKAGES/bhooai-nexus'),
255
+ ];
256
+ for (const p of candidates) {
257
+ try { if (existsSync(join(p, 'package.json')) && existsSync(join(p, 'packages', 'nexus-core', 'package.json'))) return p; } catch {}
258
+ }
259
+ return null;
260
+ }
261
+
262
+ async function rewriteToLocalFileDeps(targetDir: string): Promise<{ count: number; root: string; entries: Array<{ name: string; spec: string }> }> {
263
+ const localRoot = resolveLocalBhooaiNexusRoot();
264
+ if (!localRoot) return { count: 0, root: '', entries: [] };
265
+ const pkgPath = join(targetDir, 'package.json');
266
+ let raw: string;
267
+ try { raw = await readFile(pkgPath, 'utf8'); } catch { return { count: 0, root: localRoot, entries: [] }; }
268
+ let pkg: any;
269
+ try { pkg = JSON.parse(raw); } catch { return { count: 0, root: localRoot, entries: [] }; }
270
+ const entries: Array<{ name: string; spec: string }> = [];
271
+ const toFileSpec = (abs: string) => {
272
+ let rel = relative(targetDir, abs).replace(/\\/g, '/');
273
+ if (!rel.startsWith('.')) rel = './' + rel;
274
+ // Use forward slashes for file: spec (npm supports both, but posix is more portable)
275
+ return `file:${rel}`;
276
+ };
277
+ const maybeRewrite = (obj: Record<string, string> | undefined) => {
278
+ if (!obj) return;
279
+ for (const name of Object.keys(obj)) {
280
+ if (name === 'bhooai-nexus') {
281
+ const spec = toFileSpec(localRoot);
282
+ obj[name] = spec;
283
+ entries.push({ name, spec });
284
+ } else if (name.startsWith('@bhooai/')) {
285
+ const short = name.replace('@bhooai/', '');
286
+ // Map @bhooai/nexus-* -> packages/nexus-<short> ; @bhooai/nexus -> packages/nexus-core? but we keep generic
287
+ const pkgDir = join(localRoot, 'packages', short === 'nexus' ? 'nexus-core' : short.startsWith('nexus-') ? short : `nexus-${short}`);
288
+ // Fallback: try packages/<name without @bhooai/> and packages/nexus-<name>
289
+ const candidates = [join(localRoot, 'packages', name.replace('@bhooai/', '')), join(localRoot, 'packages', `nexus-${short}`), pkgDir];
290
+ let found: string | null = null;
291
+ for (const c of candidates) if (existsSync(join(c, 'package.json'))) { found = c; break; }
292
+ if (found) {
293
+ const spec = toFileSpec(found);
294
+ obj[name] = spec;
295
+ entries.push({ name, spec });
296
+ }
297
+ }
298
+ }
299
+ };
300
+ maybeRewrite(pkg.dependencies);
301
+ maybeRewrite(pkg.devDependencies);
302
+ if (entries.length) await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
303
+ return { count: entries.length, root: localRoot, entries };
304
+ }
305
+
306
+ async function runNpmLinkForDev(targetDir: string): Promise<number> {
307
+ const localRoot = resolveLocalBhooaiNexusRoot();
308
+ if (!localRoot) return 1;
309
+ // Ensure global link exists for bhooai-nexus (best-effort)
310
+ const linkRoot = await new Promise<number>((resolve) => {
311
+ const c = spawn('npm', ['link'], { cwd: localRoot, stdio: 'inherit', shell: process.platform === 'win32' });
312
+ c.on('error', () => resolve(1));
313
+ c.on('exit', (code) => resolve(code ?? 1));
314
+ });
315
+ if (linkRoot !== 0) console.warn(`[dev] npm link in ${localRoot} failed — continuing with file: only`);
316
+ // Link bhooai-nexus into target
317
+ const linkTarget = await new Promise<number>((resolve) => {
318
+ const c = spawn('npm', ['link', 'bhooai-nexus'], { cwd: targetDir, stdio: 'inherit', shell: process.platform === 'win32' });
319
+ c.on('error', () => resolve(1));
320
+ c.on('exit', (code) => resolve(code ?? 1));
321
+ });
322
+ // Also link each @bhooai/* that we rewrote to file: (best-effort, ignore failures)
323
+ try {
324
+ const pkgRaw = await readFile(join(targetDir, 'package.json'), 'utf8');
325
+ const pkg = JSON.parse(pkgRaw);
326
+ const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
327
+ for (const name of Object.keys(allDeps)) if (name.startsWith('@bhooai/')) {
328
+ await new Promise<void>((r) => {
329
+ const c = spawn('npm', ['link', name], { cwd: targetDir, stdio: 'inherit', shell: process.platform === 'win32' });
330
+ c.on('exit', () => r()); c.on('error', () => r());
331
+ });
332
+ }
333
+ } catch {}
334
+ return linkTarget;
335
+ }
336
+
185
337
  /** The canonical 26-folder Laravel-style tree under a backend's src/. Exported for `add.ts`. */
186
338
  export const BACKEND_FOLDERS = [
187
339
  'src/routes', 'src/graphql', 'src/ws',
@@ -196,6 +348,17 @@ export const BACKEND_FOLDERS = [
196
348
  'storage', 'storage/uploads', 'storage/private', 'storage/temp', 'storage/cache', 'storage/logs',
197
349
  ];
198
350
 
351
+ export interface SetupContext {
352
+ targetDir: string;
353
+ projectRoot: string;
354
+ vars: InitVars;
355
+ utils: {
356
+ addBackend: (name: string) => Promise<{ appName: string; port: number; created: boolean }>;
357
+ addFrontend: (name: string, opts?: { for?: string }) => Promise<{ appName: string; port: number; backend: string; backendPort: number; created: boolean }>;
358
+ log: (msg: string) => void;
359
+ };
360
+ }
361
+
199
362
  async function ensureBackendFolderTree(backendDir: string): Promise<void> {
200
363
  for (const f of BACKEND_FOLDERS) {
201
364
  await mkdir(join(backendDir, f), { recursive: true });
@@ -236,5 +399,5 @@ ${vars.aiProviders.filter((p) => p !== 'ollama').map((p) => `# NEXUS_AI_${p.toUp
236
399
  `;
237
400
  }
238
401
 
239
- export const description = 'Scaffold a new Nexus project (guided wizard)';
240
- export const usage = 'nexus init [name] [--no-install] [--force]';
402
+ export const description = 'Scaffold a new Nexus project (guided wizard) — --dev uses local file: + npm link';
403
+ export const usage = 'nexus init [name] [--dev] [--no-install] [--force]';
package/src/devPanel.ts CHANGED
@@ -35,6 +35,8 @@ export interface DevPanelOptions {
35
35
 
36
36
  type Tab = 'services' | 'logs' | 'commands' | 'features' | 'settings';
37
37
 
38
+ let isCapturing = false;
39
+
38
40
  const TABS: Array<{ id: Tab; label: string }> = [
39
41
  { id: 'services', label: 'Services' },
40
42
  { id: 'logs', label: 'Logs' },
@@ -121,16 +123,27 @@ export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
121
123
  };
122
124
 
123
125
  let lastRender = 0;
126
+ let throttleTimer: ReturnType<typeof setTimeout> | null = null;
124
127
 
125
128
  function throttledRender(): void {
129
+ if (isCapturing || state.quit) return;
126
130
  const now = Date.now();
127
- if (now - lastRender < 80) return;
131
+ if (now - lastRender < 80) {
132
+ if (!throttleTimer) {
133
+ throttleTimer = setTimeout(() => {
134
+ throttleTimer = null;
135
+ lastRender = Date.now();
136
+ render();
137
+ }, 80);
138
+ }
139
+ return;
140
+ }
128
141
  lastRender = now;
129
142
  render();
130
143
  }
131
144
 
132
145
  function render(): void {
133
- if (state.quit) return;
146
+ if (state.quit || isCapturing) return;
134
147
  tui.draw(buildRows(state, services, palette, projectRoot), helpText(state));
135
148
  }
136
149
 
@@ -146,6 +159,7 @@ export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
146
159
  render();
147
160
 
148
161
  await tui.wait();
162
+ if (throttleTimer) { clearTimeout(throttleTimer); throttleTimer = null; }
149
163
  tui.exit();
150
164
  services.onLog = null;
151
165
  opts.onQuit?.();
@@ -206,12 +220,6 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
206
220
  ctx.redraw();
207
221
  return;
208
222
  }
209
- if (key.name === 'q') {
210
- state.confirmQuit = true;
211
- state.quitIndex = 0;
212
- ctx.redraw();
213
- return;
214
- }
215
223
  if (key.name === 'escape') {
216
224
  if (state.tab === 'commands' && state.cmdFilter) {
217
225
  state.cmdFilter = '';
@@ -231,7 +239,8 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
231
239
  }
232
240
  if (state.showHelp) return;
233
241
 
234
- // Type-to-filter in the Commands tab.
242
+ // Type-to-filter in the Commands tab — must be before single-letter shortcuts
243
+ // so 'q','s','t','r','l','a','d' act as filter characters instead of hotkeys.
235
244
  if (state.tab === 'commands' && str && !key.ctrl && !key.meta && str.length === 1 && str !== '?') {
236
245
  state.cmdFilter += str;
237
246
  const fp = filteredPalette(ctx.palette, state.cmdFilter);
@@ -255,6 +264,12 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
255
264
  ctx.redraw();
256
265
  return;
257
266
  }
267
+ if (key.name === 'q') {
268
+ state.confirmQuit = true;
269
+ state.quitIndex = 0;
270
+ ctx.redraw();
271
+ return;
272
+ }
258
273
 
259
274
  switch (key.name) {
260
275
  case 'tab': {
@@ -286,13 +301,13 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
286
301
  void activate(state, ctx);
287
302
  break;
288
303
  case 's':
289
- ctx.services.start(ctx.services.services[state.svcIndex]?.name ?? '');
304
+ if (state.tab === 'services' || state.tab === 'logs') ctx.services.start(ctx.services.services[state.svcIndex]?.name ?? '');
290
305
  break;
291
306
  case 't':
292
- ctx.services.stop(ctx.services.services[state.svcIndex]?.name ?? '');
307
+ if (state.tab === 'services' || state.tab === 'logs') ctx.services.stop(ctx.services.services[state.svcIndex]?.name ?? '');
293
308
  break;
294
309
  case 'r':
295
- ctx.services.restart(ctx.services.services[state.svcIndex]?.name ?? '');
310
+ if (state.tab === 'services' || state.tab === 'logs') ctx.services.restart(ctx.services.services[state.svcIndex]?.name ?? '');
296
311
  break;
297
312
  case 'l':
298
313
  state.tab = 'logs';
@@ -394,7 +409,10 @@ async function runSelectedCommand(state: PanelState, cmd: CommandItem, inlineArg
394
409
  ctx.redraw();
395
410
 
396
411
  if (cmd.interactive) {
397
- // Suspend panel → real TTY → resume.
412
+ // Suspend panel → real TTY → resume. Suppress log redraws while suspended
413
+ const prevLog = ctx.services.onLog;
414
+ ctx.services.onLog = null;
415
+ isCapturing = true;
398
416
  ctx.tui.suspend();
399
417
  try {
400
418
  const bin = resolveCliBin();
@@ -404,6 +422,8 @@ async function runSelectedCommand(state: PanelState, cmd: CommandItem, inlineArg
404
422
  push(state, `✗ ${cmd.name} failed: ${(err as Error).message}`);
405
423
  }
406
424
  ctx.tui.resume();
425
+ isCapturing = false;
426
+ ctx.services.onLog = prevLog;
407
427
  ctx.redraw();
408
428
  return;
409
429
  }
@@ -411,6 +431,9 @@ async function runSelectedCommand(state: PanelState, cmd: CommandItem, inlineArg
411
431
  const argv = inlineArgs ? inlineArgs.trim().split(/\s+/) : [];
412
432
  const origOut = process.stdout.write.bind(process.stdout);
413
433
  const origErr = process.stderr.write.bind(process.stderr);
434
+ const prevLog2 = ctx.services.onLog;
435
+ isCapturing = true;
436
+ ctx.services.onLog = null;
414
437
  const sink = (chunk: string | Buffer) => {
415
438
  push(state, String(chunk));
416
439
  return true;
@@ -425,6 +448,8 @@ async function runSelectedCommand(state: PanelState, cmd: CommandItem, inlineArg
425
448
  } finally {
426
449
  (process.stdout as unknown as { write: Function }).write = origOut;
427
450
  (process.stderr as unknown as { write: Function }).write = origErr;
451
+ isCapturing = false;
452
+ ctx.services.onLog = prevLog2;
428
453
  }
429
454
  ctx.redraw();
430
455
  }
@@ -437,12 +462,17 @@ function push(state: PanelState, text: string): void {
437
462
  }
438
463
 
439
464
  async function runSpawned(cmd: string, args: string[], ctx: Ctx): Promise<void> {
465
+ const prevLog = ctx.services.onLog;
466
+ ctx.services.onLog = null;
467
+ isCapturing = true;
440
468
  ctx.tui.suspend();
441
469
  try {
442
470
  const bin = resolveCliBin();
443
471
  await spawnNode(bin, [cmd, ...args]);
444
472
  } catch { /* ignore */ }
445
473
  ctx.tui.resume();
474
+ isCapturing = false;
475
+ ctx.services.onLog = prevLog;
446
476
  ctx.redraw();
447
477
  }
448
478
 
@@ -530,23 +560,6 @@ function prevSelectable(list: PaletteRow[], current: number, dir: number): numbe
530
560
  function filteredPalette(palette: PaletteRow[], filter: string): PaletteRow[] {
531
561
  if (!filter) return palette;
532
562
  const q = filter.toLowerCase();
533
- const result: PaletteRow[] = [];
534
- let currentHeader: PaletteRow | null = null;
535
- let headerHasMatch = false;
536
- for (const row of palette) {
537
- if (row.kind === 'header') {
538
- if (currentHeader && headerHasMatch) result.push(currentHeader);
539
- currentHeader = row;
540
- headerHasMatch = false;
541
- continue;
542
- }
543
- if (row.cmd.name.toLowerCase().includes(q) || row.cmd.desc.toLowerCase().includes(q)) {
544
- headerHasMatch = true;
545
- result.push(row);
546
- }
547
- }
548
- if (currentHeader && headerHasMatch) result.push(currentHeader);
549
- // Re-headers got pushed to the end; rebuild with headers in the right place.
550
563
  const ordered: PaletteRow[] = [];
551
564
  let pendingHeader: PaletteRow | null = null;
552
565
  for (const row of palette) {
@@ -819,5 +832,5 @@ function helpText(state: PanelState): string {
819
832
  }
820
833
 
821
834
  function clamp(n: number, lo: number, hi: number): number {
822
- return Math.min(Math.max(n, lo), Math.max(lo, hi));
835
+ return Math.min(Math.max(n, lo), hi);
823
836
  }
@@ -219,12 +219,23 @@ export class ServiceManager {
219
219
  }
220
220
 
221
221
  private push(svc: ManagedService, text: string, source: 'stdout' | 'stderr' | 'system' = 'stdout'): void {
222
- const lines = text.split(/\r?\n/);
222
+ // Sanitize terminal control sequences that would otherwise move the cursor
223
+ // when rendered in the TUI panel (e.g. \r progress bars, clear lines, alt buffer)
224
+ const sanitized = text
225
+ .replace(/\r/g, '')
226
+ .replace(/\x1b\[\?25[hl]/g, '')
227
+ .replace(/\x1b\[\?1049[hl]/g, '')
228
+ .replace(/\x1b\[2[JK]/g, '')
229
+ .replace(/\x1b\[[0-9;]*[ABCDGKHf]/g, '');
230
+ const lines = sanitized.split(/\n/);
223
231
  for (const line of lines) {
224
232
  if (!line.trim()) continue;
233
+ // Keep color codes for panel rendering, but strip for level detection
234
+ const stripped = line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
235
+ if (!stripped.trim()) continue;
225
236
  svc.logBuffer.push(line);
226
237
  if (svc.logBuffer.length > MAX_LOG_LINES) svc.logBuffer.shift();
227
- this.allLogs.push({ service: svc.name, ts: Date.now(), source, level: detectLevel(line), line });
238
+ this.allLogs.push({ service: svc.name, ts: Date.now(), source, level: detectLevel(stripped), line });
228
239
  if (this.allLogs.length > MAX_ALL_LOGS) this.allLogs.shift();
229
240
  }
230
241
  this.onLog?.(svc.name);
package/src/dispatcher.ts CHANGED
@@ -75,7 +75,7 @@ function printHelp(): void {
75
75
  Usage: nexus <command> [options]
76
76
 
77
77
  Project lifecycle:
78
- init [name] [--no-install] [--force] Scaffold a new project (guided wizard)
78
+ init [name] [--dev] [--no-install] [--force] Scaffold a new project (guided wizard) — --dev uses local file: + npm link
79
79
  dev [--only a,b] Start dev services
80
80
  build [--target <app>|all] Build for production
81
81
  test [--watch|--e2e] Run tests
package/src/launcher.ts CHANGED
@@ -95,10 +95,22 @@ function handleMenuKey(state: MenuState, items: MenuItem[], tui: Tui, _str: stri
95
95
  return;
96
96
  }
97
97
  if (key.name === 'up') {
98
- state.index = Math.max(0, state.index - 1);
98
+ let next = state.index;
99
+ for (let i = 0; i < items.length; i++) {
100
+ next = Math.max(0, next - 1);
101
+ if (items[next]?.enabled) break;
102
+ if (next === 0) break;
103
+ }
104
+ state.index = next;
99
105
  renderMenu(state, items, tui);
100
106
  } else if (key.name === 'down') {
101
- state.index = Math.min(items.length - 1, state.index + 1);
107
+ let next = state.index;
108
+ for (let i = 0; i < items.length; i++) {
109
+ next = Math.min(items.length - 1, next + 1);
110
+ if (items[next]?.enabled) break;
111
+ if (next === items.length - 1) break;
112
+ }
113
+ state.index = next;
102
114
  renderMenu(state, items, tui);
103
115
  } else if (key.name === 'return' || key.name === 'enter') {
104
116
  const item = items[state.index];
@@ -42,7 +42,9 @@ async function renderDir(
42
42
  const childRel = rel ? `${rel}/${e.name}` : e.name;
43
43
  if (opts.exclude?.(childRel)) continue;
44
44
 
45
- const renderedName = isTemplateName(e.name) ? stripTemplateSuffix(e.name) : e.name;
45
+ // Mail runtime EJS (mail/templates/*.ejs) must be copied verbatim, not rendered with InitVars
46
+ const isMailRuntimeEjs = (src.includes('mail') && src.includes('templates') && e.name.endsWith('.ejs')) || childRel.includes('mail/templates/');
47
+ const renderedName = isMailRuntimeEjs ? e.name : (isTemplateName(e.name) ? stripTemplateSuffix(e.name) : e.name);
46
48
  const fileName = renderedName === 'gitignore' ? '.gitignore'
47
49
  : renderedName === 'dockerignore' ? '.dockerignore'
48
50
  : renderedName;
@@ -54,7 +56,7 @@ async function renderDir(
54
56
  } else if (e.isFile()) {
55
57
  try {
56
58
  const raw = await readFile(src, 'utf-8');
57
- const isTemplate = isTemplateName(e.name) || raw.includes('<%');
59
+ const isTemplate = !isMailRuntimeEjs && (isTemplateName(e.name) || raw.includes('<%'));
58
60
  const content = isTemplate ? renderString(raw, vars) : raw;
59
61
  await mkdir(dirname(dest), { recursive: true });
60
62
  await writeFile(dest, content, 'utf-8');
package/src/tui.ts CHANGED
@@ -91,27 +91,40 @@ export class Tui {
91
91
  readline.emitKeypressEvents(input);
92
92
  input.on('keypress', this.keypress);
93
93
  }
94
- output.write(ANSI.altOn + ANSI.hideCursor);
94
+ if (output.isTTY) output.write(ANSI.altOn + ANSI.hideCursor + ANSI.clear);
95
95
  process.on('SIGINT', this.onSignal);
96
96
  process.on('SIGTERM', this.onSignal);
97
97
  }
98
98
 
99
99
  /** Leave the screen so a child process can own the TTY. */
100
100
  suspend(): void {
101
- input.setRawMode?.(false);
102
- output.write(ANSI.altOff + ANSI.showCursor);
101
+ input.removeListener('keypress', this.keypress);
102
+ process.removeListener('SIGINT', this.onSignal);
103
+ process.removeListener('SIGTERM', this.onSignal);
104
+ if (input.isTTY) input.setRawMode?.(false);
105
+ if (output.isTTY) output.write(ANSI.altOff + ANSI.showCursor);
103
106
  }
104
107
 
105
108
  resume(): void {
106
- input.setRawMode?.(true);
107
- input.resume?.();
108
- output.write(ANSI.altOn + ANSI.hideCursor);
109
+ // Ensure idempotent — remove stale handlers before re-adding.
110
+ input.removeListener('keypress', this.keypress);
111
+ process.removeListener('SIGINT', this.onSignal);
112
+ process.removeListener('SIGTERM', this.onSignal);
113
+ if (input.isTTY) {
114
+ input.setRawMode?.(true);
115
+ input.resume?.();
116
+ readline.emitKeypressEvents(input);
117
+ input.on('keypress', this.keypress);
118
+ }
119
+ process.on('SIGINT', this.onSignal);
120
+ process.on('SIGTERM', this.onSignal);
121
+ if (output.isTTY) output.write(ANSI.altOn + ANSI.hideCursor + ANSI.clear);
109
122
  }
110
123
 
111
124
  exit(): void {
112
125
  input.removeListener('keypress', this.keypress);
113
- input.setRawMode?.(false);
114
- output.write(ANSI.altOff + ANSI.showCursor);
126
+ if (input.isTTY) input.setRawMode?.(false);
127
+ if (output.isTTY) output.write(ANSI.altOff + ANSI.showCursor);
115
128
  process.removeListener('SIGINT', this.onSignal);
116
129
  process.removeListener('SIGTERM', this.onSignal);
117
130
  }
@@ -119,25 +132,71 @@ export class Tui {
119
132
  /** Paint a frame. `rows` are the body; `footer` is the final status line. */
120
133
  draw(rows: string[], footer = ''): void {
121
134
  const H = output.rows || 24;
135
+ const W = output.columns || 80;
122
136
  const body = fitLines(rows);
123
- let out = ANSI.clear;
137
+ const clippedFooter = footer ? clipAnsi(footer, W) : '';
138
+ let out = '';
139
+ if (output.isTTY) out += ANSI.clear;
124
140
  out += body.join('\n');
125
- out += ANSI.move(H, 1);
126
- out += footer;
127
- output.write(out);
141
+ // Pad body to H-1 lines so previous longer frames don't leave stale lines.
142
+ const bodyLines = body.length;
143
+ if (bodyLines < H - 1) out += '\n'.repeat(H - 1 - bodyLines);
144
+ out += ANSI.move(H, 1) + '\x1b[2K';
145
+ if (clippedFooter) out += clippedFooter;
146
+ if (output.isTTY) output.write(out);
147
+ else output.write(body.join('\n') + (clippedFooter ? '\n' + stripAnsi(clippedFooter) : '') + '\n');
128
148
  }
129
149
 
130
150
  /** Resolve once `quit` becomes true. */
131
151
  async wait(): Promise<void> {
132
152
  while (!this._quit) {
133
- await new Promise((r) => setTimeout(r, 100));
153
+ await new Promise((r) => setTimeout(r, 50));
134
154
  }
135
155
  }
136
156
  }
137
157
 
138
- /** Strip ANSI color codes from a string. */
158
+ /** Strip ANSI escape codes from a string (SGR + CSI + ESC sequences). */
139
159
  export function stripAnsi(text: string): string {
140
- return text.replace(/\x1b\[[0-9;]*m/g, '');
160
+ return text
161
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
162
+ .replace(/\x1b\][^\x07]*\x07/g, '')
163
+ .replace(/\x1b\(B/g, '')
164
+ .replace(/\x1b\[[0-9;]*m/g, '');
165
+ }
166
+
167
+ /** Clip an ANSI-colored string to `width` visible columns, preserving leading color. */
168
+ function clipAnsi(text: string, width: number): string {
169
+ if (stripAnsi(text).length <= width) return text;
170
+ let out = '';
171
+ let used = 0;
172
+ let i = 0;
173
+ let sawColor = false;
174
+ while (i < text.length && used < width) {
175
+ const ch = text[i]!;
176
+ if (ch === '\x1b') {
177
+ let j = i + 1;
178
+ if (text.charCodeAt(j) === 0x5b) {
179
+ j++;
180
+ while (j < text.length) {
181
+ const c = text.charCodeAt(j);
182
+ if (c >= 0x20 && c <= 0x3f) { j++; continue; }
183
+ break;
184
+ }
185
+ if (j < text.length) j++;
186
+ } else if (j < text.length) {
187
+ j++;
188
+ }
189
+ out += text.slice(i, j);
190
+ sawColor = true;
191
+ i = j;
192
+ } else {
193
+ out += ch;
194
+ used++;
195
+ i++;
196
+ }
197
+ }
198
+ if (sawColor && !out.endsWith(ANSI.reset)) out += ANSI.reset;
199
+ return out;
141
200
  }
142
201
 
143
202
  /** Spawn `node <bin> <args>` with inherited stdio (used for interactive CLI commands). */
package/src/util.ts CHANGED
@@ -14,18 +14,24 @@ export interface ParsedArgs {
14
14
  export function parseArgs(argv: string[]): ParsedArgs {
15
15
  const _: string[] = [];
16
16
  const flags: Record<string, string | boolean> = {};
17
+ // Flags that are always boolean and should not consume the next arg
18
+ const booleanFlags = new Set(['dev', 'force', 'no-install', 'help', 'h', 'version', 'v']);
17
19
  for (let i = 0; i < argv.length; i++) {
18
20
  const a = argv[i]!;
19
21
  if (a.startsWith('--')) {
20
22
  const eq = a.indexOf('=');
21
23
  if (eq === -1) {
22
24
  const key = a.slice(2);
23
- const next = argv[i + 1];
24
- if (next !== undefined && !next.startsWith('-')) {
25
- flags[key] = next;
26
- i++;
27
- } else {
25
+ if (booleanFlags.has(key)) {
28
26
  flags[key] = true;
27
+ } else {
28
+ const next = argv[i + 1];
29
+ if (next !== undefined && !next.startsWith('-')) {
30
+ flags[key] = next;
31
+ i++;
32
+ } else {
33
+ flags[key] = true;
34
+ }
29
35
  }
30
36
  } else {
31
37
  flags[a.slice(2, eq)] = a.slice(eq + 1);
package/src/wizard.ts CHANGED
@@ -300,10 +300,9 @@ function pageExamples(state: WizardState, key: KeyInfo, tui: Tui): void {
300
300
  }
301
301
 
302
302
  function pageAdmin(state: WizardState, str: string, key: KeyInfo): void {
303
- const isYes = state.features.has('admin');
304
303
  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');
304
+ if (state.features.has('admin')) state.features.delete('admin');
305
+ else state.features.add('admin');
307
306
  } else if (key.name === 'return' || key.name === 'enter') {
308
307
  state.page = 'features';
309
308
  } else if (str && /y/i.test(str)) {
@@ -318,7 +317,7 @@ function pageFeatures(state: WizardState, key: KeyInfo): void {
318
317
  state.featureIndex = Math.max(0, state.featureIndex - 1);
319
318
  } else if (key.name === 'down') {
320
319
  state.featureIndex = Math.min(FEATURES.length - 1, state.featureIndex + 1);
321
- } else if (key.name === ' ') {
320
+ } else if (key.name === 'space' || key.sequence === ' ' || key.name === ' ') {
322
321
  const id = FEATURES[state.featureIndex]?.id;
323
322
  if (id) {
324
323
  if (state.features.has(id)) state.features.delete(id);
@@ -501,7 +500,7 @@ function titleBar(state: WizardState, W: number): string {
501
500
  const n = stepIndex(state.page) + 1;
502
501
  const right = `${ANSI.dim}Step ${n} of ${STEPS.length}${ANSI.reset}`;
503
502
  const gap = Math.max(1, W - visibleWidth(left) - visibleWidth(right));
504
- return `${left}${' '.repeat(gap)}${right}`;
503
+ return clipVisible(`${left}${' '.repeat(gap)}${right}`, W);
505
504
  }
506
505
 
507
506
  function progressDots(state: WizardState): string {
@@ -25,7 +25,8 @@
25
25
  "@bhooai/nexus-ai-client": "^2.0.1",
26
26
  "@bhooai/nexus-graphql": "^2.0.1",
27
27
  "@bhooai/nexus-telemetry": "^2.0.1",
28
- "tsx": "^4.19.0"
28
+ "tsx": "^4.19.0",
29
+ "zod": "^3.23.8"
29
30
  },
30
31
  "devDependencies": {
31
32
  "bhooai-nexus": "^2.0.1",
@@ -2,55 +2,54 @@
2
2
  * Realtime feature — WebSocket chat room. Auto-discovered from ws/*.room.ts
3
3
  * and mounted on the /ws endpoint.
4
4
  *
5
- * Client protocol:
6
- * → join { roomId, username }
7
- * ← presence { type, username }
8
- * → msg { roomId, text }
5
+ * Client protocol (supports both legacy roomId and generic room):
6
+ * → join { roomId|room, username }
7
+ * ← presence { type, username } (via broadcast)
8
+ * → msg { roomId|room, text }
9
9
  * ← msg { from, text, at }
10
- * → typing { roomId }
10
+ * → typing { roomId|room }
11
11
  * ← typing { username }
12
12
  */
13
13
  export default {
14
14
  name: 'chat',
15
15
 
16
- async onJoin(socket: any, payload: { roomId: string; username: string }) {
16
+ async onJoin(socket: any, payload: { roomId?: string; room?: string; username: string }) {
17
+ const roomId = payload.roomId ?? payload.room ?? 'lobby';
18
+ socket.data = socket.data ?? {};
17
19
  socket.data.username = payload.username;
18
- socket.join(`chat:${payload.roomId}`);
19
- socket.to(`chat:${payload.roomId}`).emit('presence', {
20
- type: 'join',
21
- username: payload.username,
22
- });
20
+ try { socket.join?.(`chat:${roomId}`); } catch {}
21
+ try { socket.join?.(roomId); } catch {}
22
+ const data = { type: 'join', username: payload.username };
23
+ try { socket.to?.(`chat:${roomId}`)?.emit?.('presence', data); } catch {}
24
+ try { socket.to?.(roomId)?.emit?.('presence', data); } catch {}
23
25
  },
24
26
 
25
- async onLeave(socket: any, payload: { roomId: string }) {
26
- socket.to(`chat:${payload.roomId}`).emit('presence', {
27
- type: 'leave',
28
- username: socket.data?.username,
29
- });
27
+ async onLeave(socket: any, payload: { roomId?: string; room?: string }) {
28
+ const roomId = payload.roomId ?? payload.room ?? 'lobby';
29
+ const data = { type: 'leave', username: socket.data?.username };
30
+ try { socket.to?.(`chat:${roomId}`)?.emit?.('presence', data); } catch {}
31
+ try { socket.to?.(roomId)?.emit?.('presence', data); } catch {}
30
32
  },
31
33
 
32
34
  onMessage: {
33
- 'msg': async (socket: any, payload: { roomId: string; text: string }, ctx: any) => {
34
- const msg = {
35
- from: socket.data?.username ?? 'anon',
36
- text: payload.text,
37
- at: new Date().toISOString(),
38
- };
35
+ 'msg': async (socket: any, payload: { roomId?: string; room?: string; text: string }, ctx: any) => {
36
+ const roomId = payload.roomId ?? payload.room ?? 'lobby';
37
+ const msg = { from: socket.data?.username ?? 'anon', text: payload.text, at: new Date().toISOString() };
39
38
  try {
40
39
  const { Message } = await import('../models/Message.js');
41
- await Message.create({
42
- roomId: payload.roomId,
43
- userId: socket.data?.userId ?? 'anon',
44
- username: msg.from,
45
- text: msg.text,
46
- });
47
- } catch { /* non-fatal */ }
48
- ctx.server.to(`chat:${payload.roomId}`).emit('msg', msg);
40
+ await Message.create({ roomId, userId: socket.data?.userId ?? 'anon', username: msg.from, text: msg.text });
41
+ } catch {}
42
+ try { ctx.server.to?.(`chat:${roomId}`)?.emit?.('msg', msg); } catch {}
43
+ try { ctx.server.to?.(roomId)?.emit?.('msg', msg); } catch {}
44
+ try { ctx.server.broadcast?.(roomId, 'msg', msg); } catch {}
45
+ try { ctx.server.broadcast?.(`chat:${roomId}`, 'msg', msg); } catch {}
49
46
  },
50
- 'typing': (socket: any, payload: { roomId: string }, ctx: any) => {
51
- socket.to(`chat:${payload.roomId}`).emit('typing', {
52
- username: socket.data?.username,
53
- });
47
+ 'typing': (socket: any, payload: { roomId?: string; room?: string }, ctx: any) => {
48
+ const roomId = payload.roomId ?? payload.room ?? 'lobby';
49
+ const data = { username: socket.data?.username };
50
+ try { socket.to?.(`chat:${roomId}`)?.emit?.('typing', data); } catch {}
51
+ try { socket.to?.(roomId)?.emit?.('typing', data); } catch {}
52
+ try { ctx.server.broadcast?.(roomId, 'typing', data); } catch {}
54
53
  },
55
54
  },
56
- };
55
+ };