@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 +1 -1
- package/src/commands/add.ts +122 -59
- package/src/commands/init.ts +169 -6
- package/src/devPanel.ts +44 -31
- package/src/devServiceManager.ts +13 -2
- package/src/dispatcher.ts +1 -1
- package/src/launcher.ts +14 -2
- package/src/templating/tree.ts +4 -2
- package/src/tui.ts +74 -15
- package/src/util.ts +11 -5
- package/src/wizard.ts +4 -5
- package/templates/base/package.json.ejs +2 -1
- package/templates/features/realtime/apps/backend/src/ws/chat.room.ts +34 -35
package/package.json
CHANGED
package/src/commands/add.ts
CHANGED
|
@@ -81,24 +81,16 @@ export async function run(ctx: CommandContext): Promise<void> {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 —
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = `
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 —
|
|
174
|
-
const
|
|
175
|
-
|
|
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
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> {
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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),
|
|
835
|
+
return Math.min(Math.max(n, lo), hi);
|
|
823
836
|
}
|
package/src/devServiceManager.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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];
|
package/src/templating/tree.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
input.
|
|
108
|
-
|
|
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
|
-
|
|
137
|
+
const clippedFooter = footer ? clipAnsi(footer, W) : '';
|
|
138
|
+
let out = '';
|
|
139
|
+
if (output.isTTY) out += ANSI.clear;
|
|
124
140
|
out += body.join('\n');
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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,
|
|
153
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
134
154
|
}
|
|
135
155
|
}
|
|
136
156
|
}
|
|
137
157
|
|
|
138
|
-
/** Strip ANSI
|
|
158
|
+
/** Strip ANSI escape codes from a string (SGR + CSI + ESC sequences). */
|
|
139
159
|
export function stripAnsi(text: string): string {
|
|
140
|
-
return text
|
|
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
|
-
|
|
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.
|
|
306
|
-
|
|
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 {
|
|
@@ -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
|
|
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:${
|
|
19
|
-
socket.
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
34
|
-
const
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
};
|