@bhooai/nexus-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLAN.md +141 -0
- package/README.md +34 -0
- package/package.json +25 -0
- package/src/commands/cluster.ts +133 -0
- package/src/commands/dev.ts +133 -0
- package/src/commands/doctor.ts +199 -0
- package/src/commands/init.ts +960 -0
- package/src/commands/node.ts +101 -0
- package/src/commands/pysetup.ts +136 -0
- package/src/commands/sync.ts +116 -0
- package/src/commands/uninstall.ts +287 -0
- package/src/config-sync.ts +384 -0
- package/src/dotenv.ts +39 -0
- package/src/index.ts +94 -0
- package/src/supervisor.ts +384 -0
- package/src/util.ts +123 -0
- package/src/wizard.ts +149 -0
- package/templates/Dockerfile +60 -0
- package/templates/README.md +69 -0
- package/templates/apps/admin/index.html +12 -0
- package/templates/apps/admin/package.json +24 -0
- package/templates/apps/admin/postcss.config.js +6 -0
- package/templates/apps/admin/src/main.tsx +10 -0
- package/templates/apps/admin/src/vite-env.d.ts +18 -0
- package/templates/apps/admin/tailwind.config.js +9 -0
- package/templates/apps/admin/tsconfig.json +17 -0
- package/templates/apps/admin/vite.config.ts +64 -0
- package/templates/apps/ai-server/main.py +43 -0
- package/templates/apps/ai-server/providers/__init__.py +3 -0
- package/templates/apps/ai-server/providers/base.py +111 -0
- package/templates/apps/ai-server/requirements.txt +3 -0
- package/templates/apps/ai-server/routers/__init__.py +3 -0
- package/templates/apps/ai-server/routers/chat.py +47 -0
- package/templates/apps/ai-server/routers/embeddings.py +30 -0
- package/templates/apps/ai-server/routers/lint.py +167 -0
- package/templates/apps/ai-server/routers/models.py +23 -0
- package/templates/apps/ai-server/routers/preflight.py +169 -0
- package/templates/apps/ai-server/settings.py +48 -0
- package/templates/apps/backend/package.json +33 -0
- package/templates/apps/backend/src/main.ts +375 -0
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
- package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
- package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
- package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
- package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
- package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
- package/templates/apps/backend/tsconfig.json +14 -0
- package/templates/apps/frontend/index.html +12 -0
- package/templates/apps/frontend/package.json +19 -0
- package/templates/apps/frontend/src/main.tsx +64 -0
- package/templates/apps/frontend/vite.config.ts +63 -0
- package/templates/bin/nexus.js +35 -0
- package/templates/bin/serve-all.mjs +45 -0
- package/templates/dockerignore +15 -0
- package/templates/gitignore +12 -0
- package/templates/nexus.config.ts +69 -0
- package/templates/package.json +47 -0
- package/templates/tsconfig.json +17 -0
- package/templates/uploads/.gitkeep +0 -0
- package/tests/cli.test.ts +45 -0
- package/tests/config-sync.test.ts +201 -0
- package/tests/dotenv.test.ts +51 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { loadConfigAuto } from '../../../nexus-core/src/index.js';
|
|
2
|
+
import { NodeAgent, nodeIdFor } from '@bhooai/nexus-cluster';
|
|
3
|
+
const GREEN = '\x1b[32m';
|
|
4
|
+
const RED = '\x1b[31m';
|
|
5
|
+
const CYAN = '\x1b[36m';
|
|
6
|
+
const DIM = '\x1b[2m';
|
|
7
|
+
const RESET = '\x1b[0m';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `nexus node <subcommand>` - run THIS machine as a cluster node.
|
|
11
|
+
*
|
|
12
|
+
* node id --role <role> print this node's stable id
|
|
13
|
+
* node serve --role=<role> [--port] start the agent + role service
|
|
14
|
+
*
|
|
15
|
+
* Onboarding per the mesh flow:
|
|
16
|
+
* server$ nexus node serve --role=backend
|
|
17
|
+
* -> prints agent URL + pairing token; paste the URL on the central:
|
|
18
|
+
* central$ nexus cluster link http://<this-host>:7575
|
|
19
|
+
*/
|
|
20
|
+
export async function node(args: string[]): Promise<number> {
|
|
21
|
+
const sub = args[0];
|
|
22
|
+
const role = argValue(args, '--role') ?? 'backend';
|
|
23
|
+
|
|
24
|
+
const cfg = await loadConfigAuto({ root: process.cwd() });
|
|
25
|
+
const token = cfg.cluster.token;
|
|
26
|
+
const port = Number(argValue(args, '--port') ?? '7575');
|
|
27
|
+
const agentHost = cfg.cluster.nodeAgentHost;
|
|
28
|
+
const defaultPort = Number.isInteger(cfg.cluster.nodeAgentPort) ? cfg.cluster.nodeAgentPort : 7575;
|
|
29
|
+
const effectivePort = Number.isInteger(port) ? port : defaultPort;
|
|
30
|
+
|
|
31
|
+
if (sub === 'id') {
|
|
32
|
+
console.log(nodeIdFor(process.cwd(), role as never));
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (sub === 'serve') {
|
|
37
|
+
const description = roleDescription(role);
|
|
38
|
+
const command = serviceCommand(role, cfg);
|
|
39
|
+
const agent = new NodeAgent({
|
|
40
|
+
projectRoot: process.cwd(),
|
|
41
|
+
role: role as never,
|
|
42
|
+
tier: 'dev',
|
|
43
|
+
port: effectivePort,
|
|
44
|
+
token,
|
|
45
|
+
version: '0.1.0',
|
|
46
|
+
services: serviceUrls(role, cfg),
|
|
47
|
+
serviceCommand: command,
|
|
48
|
+
advertisedUrl: process.env.NEXUS_NODE_ADVERTISED_URL,
|
|
49
|
+
});
|
|
50
|
+
await agent.listen(agentHost);
|
|
51
|
+
console.log(`${CYAN}node-agent${RESET} ready - role ${role} ${description}`);
|
|
52
|
+
console.log(` ${GREEN}agent url: http://${agentHost}:${effectivePort}${RESET}`);
|
|
53
|
+
console.log(` ${GREEN}token: ${token ? `${token}` : '<empty - set cluster.token>'}`)
|
|
54
|
+
console.log(` ${DIM}link it from the central: nexus cluster link http://${agentHost}:${effectivePort}${RESET}`);
|
|
55
|
+
if (command.length) {
|
|
56
|
+
agent.startService();
|
|
57
|
+
console.log(` started role service: ${command.join(' ')}`);
|
|
58
|
+
}
|
|
59
|
+
// Hold the process.
|
|
60
|
+
return await new Promise<number>(() => {});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log('Usage: nexus node <id|serve> [--role=backend|files|database|ai] [--port=N]');
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function argValue(args: string[], name: string): string | undefined {
|
|
68
|
+
const i = args.indexOf(name);
|
|
69
|
+
if (i >= 0) return args[i + 1];
|
|
70
|
+
return args.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function roleDescription(role: string): string {
|
|
74
|
+
switch (role) {
|
|
75
|
+
case 'backend': return '(API core)';
|
|
76
|
+
case 'files': return '(static + uploads storage)';
|
|
77
|
+
case 'database': return '(Mongo + Redis)';
|
|
78
|
+
case 'ai': return '(AI inference engine)';
|
|
79
|
+
default: return '';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function serviceCommand(role: string, cfg: Awaited<ReturnType<typeof loadConfigAuto>>): string[] {
|
|
84
|
+
switch (role) {
|
|
85
|
+
case 'backend':
|
|
86
|
+
return ['tsx', 'apps/backend/src/main.ts'];
|
|
87
|
+
case 'ai':
|
|
88
|
+
return ['python', 'main.py'];
|
|
89
|
+
case 'files':
|
|
90
|
+
// Fall back to the framework uploads server launcher if present.
|
|
91
|
+
return ['tsx', 'apps/backend/src/main.ts'];
|
|
92
|
+
case 'database':
|
|
93
|
+
return ['tsx', 'apps/backend/src/main.ts'];
|
|
94
|
+
default:
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function serviceUrls(role: string, cfg: Awaited<ReturnType<typeof loadConfigAuto>>): Partial<Record<'backend' | 'files' | 'database' | 'ai', string>> {
|
|
100
|
+
return { [role]: `http://localhost:${cfg.server.port}` };
|
|
101
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { versionOf } from '../util.js';
|
|
5
|
+
import { isInteractive as wizardInteractive, confirm, prompt, closeWizard } from '../wizard.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Install the Python AI server dependencies (plus any extra packages).
|
|
9
|
+
*
|
|
10
|
+
* Locates `apps/ai-server/requirements.txt` (or a path given with --requirements),
|
|
11
|
+
* optionally creates a virtualenv with --venv, and streams pip output through so the
|
|
12
|
+
* user can watch progress.
|
|
13
|
+
*
|
|
14
|
+
* nexus pysetup install from requirements.txt
|
|
15
|
+
* nexus pysetup openai pandas install requirements.txt + extra packages
|
|
16
|
+
* nexus pysetup --venv create ./apps/ai-server/.venv and install into it
|
|
17
|
+
* nexus pysetup --interactive prompt for venv y/n + python path (TTY only)
|
|
18
|
+
* nexus pysetup --upgrade upgrade existing packages
|
|
19
|
+
* nexus pysetup --python C:/Python/Python314/python.exe
|
|
20
|
+
* nexus pysetup --requirements ./my-requirements.txt
|
|
21
|
+
*/
|
|
22
|
+
export async function pysetup(args: string[]): Promise<number> {
|
|
23
|
+
let opts: PyOpts = parseArgs(args);
|
|
24
|
+
|
|
25
|
+
// -- interactive wizard mode (TTY only) ---------------------------
|
|
26
|
+
if (opts.interactive && wizardInteractive()) {
|
|
27
|
+
opts = await interactivePrompt(opts);
|
|
28
|
+
closeWizard();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const reqFile = resolve(process.cwd(), opts.requirements || findRequirementsFile(process.cwd()));
|
|
32
|
+
const py = await resolvePython(opts.python);
|
|
33
|
+
|
|
34
|
+
if (!opts.requirements && !findRequirementsFile(process.cwd())) {
|
|
35
|
+
console.error('Could not find apps/ai-server/requirements.txt - pass one with --requirements <path>.');
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
if (!existsSync(reqFile)) {
|
|
39
|
+
console.error(`requirements file not found: ${reqFile}`);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
if (!py) {
|
|
43
|
+
console.error(`Python interpreter not found (tried python / python3). Pass --python <path>.`);
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const venvPy = opts.venv ? join(opts.venvDir, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python') : py;
|
|
48
|
+
const createVenv = async () => {
|
|
49
|
+
if (existsSync(venvPy)) return 0;
|
|
50
|
+
console.log(`\n\x1b[36m[pysetup]\x1b[0m creating venv at ${opts.venvDir}`);
|
|
51
|
+
return run(py, ['-m', 'venv', opts.venvDir]);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
console.log(`\n\x1b[36m[pysetup]\x1b[0m installing python packages`);
|
|
55
|
+
console.log(` python : ${py}${opts.venv ? ` -> ${venvPy}` : ''}`);
|
|
56
|
+
console.log(` requirements: ${reqFile}`);
|
|
57
|
+
console.log(` extras : ${opts.extras.length ? opts.extras.join(', ') : '(none)'}`);
|
|
58
|
+
if (opts.venv) console.log(` venv : ${opts.venvDir}`);
|
|
59
|
+
|
|
60
|
+
if (opts.venv) {
|
|
61
|
+
const venvCode = await createVenv();
|
|
62
|
+
if (venvCode !== 0) return venvCode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const pipArgs = ['-m', 'pip', 'install'];
|
|
66
|
+
if (opts.upgrade) pipArgs.push('--upgrade');
|
|
67
|
+
pipArgs.push('-r', reqFile, ...opts.extras);
|
|
68
|
+
|
|
69
|
+
const code = await run(venvPy ?? py, pipArgs);
|
|
70
|
+
if (code !== 0) {
|
|
71
|
+
console.error(`\n[pysetup] install failed (exit ${code}).`);
|
|
72
|
+
return code;
|
|
73
|
+
}
|
|
74
|
+
console.log(`\n\x1b[32m[pysetup]\x1b[0m done - ${opts.extras.length ? opts.extras.join(' ') : 'core requirements'} installed.`);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Interactive prompts for venv + python path. Returns updated opts. */
|
|
79
|
+
async function interactivePrompt(opts: PyOpts): Promise<PyOpts> {
|
|
80
|
+
const wantVenv = await confirm('Create a virtualenv for the AI server?', opts.venv);
|
|
81
|
+
const pythonPath = await prompt('Python interpreter path (blank = auto-detect)', opts.python || '');
|
|
82
|
+
return { ...opts, venv: wantVenv, python: pythonPath.trim() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface PyOpts { python: string; requirements: string; extras: string[]; upgrade: boolean; venv: boolean; venvDir: string; interactive: boolean }
|
|
86
|
+
|
|
87
|
+
function parseArgs(args: string[]): PyOpts {
|
|
88
|
+
let python = '';
|
|
89
|
+
let requirements = '';
|
|
90
|
+
let upgrade = false;
|
|
91
|
+
let venv = false;
|
|
92
|
+
let interactive = false;
|
|
93
|
+
const extras: string[] = [];
|
|
94
|
+
for (let i = 0; i < args.length; i++) {
|
|
95
|
+
const a = args[i];
|
|
96
|
+
if (a === '--python') { python = args[++i] ?? ''; continue; }
|
|
97
|
+
if (a === '--requirements' || a === '-r') { requirements = args[++i] ?? ''; continue; }
|
|
98
|
+
if (a === '--upgrade' || a === '-U') { upgrade = true; continue; }
|
|
99
|
+
if (a === '--venv') { venv = true; continue; }
|
|
100
|
+
if (a === '--interactive') { interactive = true; continue; }
|
|
101
|
+
if (a.startsWith('--') || a.startsWith('-')) continue;
|
|
102
|
+
extras.push(a);
|
|
103
|
+
}
|
|
104
|
+
const reqDir = requirements ? resolve(requirements) : join(process.cwd(), 'apps', 'ai-server');
|
|
105
|
+
return { python, requirements, extras, upgrade, venv, venvDir: join(reqDir, '.venv'), interactive };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Find a requirements.txt under the cwd that belongs to the AI server. */
|
|
109
|
+
function findRequirementsFile(cwd: string): string {
|
|
110
|
+
const candidates = [
|
|
111
|
+
join(cwd, 'apps', 'ai-server', 'requirements.txt'),
|
|
112
|
+
join(cwd, 'apps', 'server', 'requirements.txt'),
|
|
113
|
+
join(cwd, 'requirements.txt'),
|
|
114
|
+
];
|
|
115
|
+
return candidates.find((p) => existsSync(p)) ?? '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolvePython(explicit: string): Promise<string> {
|
|
119
|
+
if (explicit) return existsSync(explicit) ? explicit : explicit;
|
|
120
|
+
const candidates = process.platform === 'win32' ? ['python', 'py'] : ['python3', 'python'];
|
|
121
|
+
for (const c of candidates) {
|
|
122
|
+
if (await versionOf(c)) return c;
|
|
123
|
+
}
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function run(cmd: string, args: string[]): Promise<number> {
|
|
128
|
+
return new Promise((resolveRun) => {
|
|
129
|
+
const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
130
|
+
child.on('error', (err) => {
|
|
131
|
+
console.error(`\x1b[31m[pysetup]\x1b[0m failed to run ${cmd}: ${err.message}`);
|
|
132
|
+
resolveRun(1);
|
|
133
|
+
});
|
|
134
|
+
child.on('close', (code) => resolveRun(code ?? 0));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { loadConfigAuto } from '../../../nexus-core/src/index.js';
|
|
2
|
+
import {
|
|
3
|
+
readFingerprint,
|
|
4
|
+
syncConfig,
|
|
5
|
+
type FileSyncReport,
|
|
6
|
+
type SyncReport,
|
|
7
|
+
} from '../config-sync.js';
|
|
8
|
+
|
|
9
|
+
const GREEN = '\x1b[32m';
|
|
10
|
+
const CYAN = '\x1b[36m';
|
|
11
|
+
const YELLOW = '\x1b[33m';
|
|
12
|
+
const DIM = '\x1b[2m';
|
|
13
|
+
const BOLD = '\x1b[1m';
|
|
14
|
+
const RESET = '\x1b[0m';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `nexus sync` - rewrite every derived artifact (Dockerfile, docker.* helpers,
|
|
18
|
+
* bin/serve-all.mjs, apps/admin/package.json, the shared project-info DB) to
|
|
19
|
+
* match the single `nexus.config.ts` source of truth.
|
|
20
|
+
*
|
|
21
|
+
* Returns the process exit code and always prints the resolved values so the
|
|
22
|
+
* "one config prints everywhere" contract is visible.
|
|
23
|
+
*/
|
|
24
|
+
export async function syncCommand(args: string[] = []): Promise<number> {
|
|
25
|
+
const root = process.cwd();
|
|
26
|
+
const opts = {
|
|
27
|
+
write: !args.includes('--check') && !args.includes('--dry-run'),
|
|
28
|
+
db: !args.includes('--no-db'),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (!opts.write) {
|
|
32
|
+
process.stdout.write(`${DIM} nexus sync - dry run (nothing written)${RESET}\n\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cfg = await loadConfigAuto({ root });
|
|
36
|
+
const report = await syncConfig(root, cfg, opts);
|
|
37
|
+
|
|
38
|
+
printReport(report, root);
|
|
39
|
+
|
|
40
|
+
if (!opts.write) {
|
|
41
|
+
process.stdout.write(`\n ${YELLOW}Dry run - run \`nexus sync\` (no flag) to write the changes.${RESET}\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const changed = report.files.some((f) => f.status === 'updated');
|
|
45
|
+
if (changed) {
|
|
46
|
+
process.stdout.write(`\n ${GREEN}Synced. Run \`nexus dev\` to boot the stack on the new values.${RESET}\n`);
|
|
47
|
+
} else {
|
|
48
|
+
process.stdout.write(`\n ${DIM}Everything is already in sync.${RESET}\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Print the per-value summary + per-file status table. */
|
|
55
|
+
function printReport(report: SyncReport, root: string): void {
|
|
56
|
+
const v = report.values;
|
|
57
|
+
|
|
58
|
+
process.stdout.write(`\n${BOLD} * Nexus config sync${RESET} ${DIM}${root}${RESET}\n\n`);
|
|
59
|
+
|
|
60
|
+
process.stdout.write(` ${CYAN}Value${RESET}${' '.repeat(12 - 5)}Config -> everywhere\n`);
|
|
61
|
+
process.stdout.write(` -----------------------------\n`);
|
|
62
|
+
const rows: Array<[string, string]> = [
|
|
63
|
+
['backend', `${v.serverPort}`],
|
|
64
|
+
['frontend', `${v.frontendPort}`],
|
|
65
|
+
['admin', `${v.adminPort}`],
|
|
66
|
+
['node agent', `${v.nodePort}`],
|
|
67
|
+
['cluster LB', `${v.lbPort}`],
|
|
68
|
+
['AI server', `${v.aiPort}`],
|
|
69
|
+
['database', `${v.dbName}`],
|
|
70
|
+
];
|
|
71
|
+
for (const [label, value] of rows) {
|
|
72
|
+
const pad = label.padEnd(10);
|
|
73
|
+
process.stdout.write(` ${CYAN}${pad}${RESET} ${value}\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
process.stdout.write('\n');
|
|
77
|
+
for (const f of report.files) {
|
|
78
|
+
const status = statusLabel(f);
|
|
79
|
+
process.stdout.write(` ${f.file.padEnd(28)}${status}\n`);
|
|
80
|
+
if (f.status === 'updated' && f.changes.length > 0) {
|
|
81
|
+
for (const line of f.changes) {
|
|
82
|
+
process.stdout.write(` ${DIM}${line}${RESET}\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
process.stdout.write(`\n ${BOLD}Database${RESET} ${dbLabel(report.db)}\n`);
|
|
88
|
+
|
|
89
|
+
if (readFingerprint(root)) {
|
|
90
|
+
process.stdout.write(` ${DIM}fingerprint ${report.fingerprint}${RESET}\n`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function statusLabel(f: FileSyncReport): string {
|
|
95
|
+
switch (f.status) {
|
|
96
|
+
case 'updated':
|
|
97
|
+
return `${GREEN}updated${RESET}`;
|
|
98
|
+
case 'in-sync':
|
|
99
|
+
return `${DIM}in sync${RESET}`;
|
|
100
|
+
case 'missing':
|
|
101
|
+
return `${YELLOW}missing${RESET}`;
|
|
102
|
+
default:
|
|
103
|
+
return `${YELLOW}no match - review${RESET}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dbLabel(db: SyncReport['db']): string {
|
|
108
|
+
switch (db) {
|
|
109
|
+
case 'updated':
|
|
110
|
+
return `${GREEN}project record upserted${RESET}`;
|
|
111
|
+
case 'failed':
|
|
112
|
+
return `${YELLOW}unreachable - skipped${RESET}`;
|
|
113
|
+
default:
|
|
114
|
+
return `${DIM}skipped${RESET}`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { existsSync, rmSync, chmodSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve, basename, join } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { loadConfigAuto } from '../../../nexus-core/src/index.js';
|
|
5
|
+
import {
|
|
6
|
+
resolveProjectInfo,
|
|
7
|
+
connectProjectInfo,
|
|
8
|
+
getProjectInfo,
|
|
9
|
+
deleteProjectInfo,
|
|
10
|
+
dropProjectDatabase,
|
|
11
|
+
closeProjectInfo,
|
|
12
|
+
} from '@bhooai/nexus-data';
|
|
13
|
+
import { tcpReachable, parseHostPort } from '../util.js';
|
|
14
|
+
import { isInteractive as wizardInteractive, banner, confirm, prompt, closeWizard, COLORS } from '../wizard.js';
|
|
15
|
+
|
|
16
|
+
const { GREEN, RED, YELLOW, CYAN, DIM, BOLD, RESET } = COLORS;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `nexus uninstall [target]` - remove a project from disk + the databases.
|
|
20
|
+
*
|
|
21
|
+
* What it deletes:
|
|
22
|
+
* 1. The project's own MongoDB database (e.g. `test_app`)
|
|
23
|
+
* 2. The project's record in `nexus_projects.projects`
|
|
24
|
+
* 3. (optional, with --purge) the project directory itself
|
|
25
|
+
*
|
|
26
|
+
* What it does NOT touch:
|
|
27
|
+
* - The `nexus_projects` database (other projects' records remain)
|
|
28
|
+
* - The framework (`bhooai-nexus`) install
|
|
29
|
+
* - Any other project's database
|
|
30
|
+
*
|
|
31
|
+
* Flags:
|
|
32
|
+
* --target <path> project root (default: current directory)
|
|
33
|
+
* --force skip the confirmation prompt
|
|
34
|
+
* --purge also delete the project directory from disk
|
|
35
|
+
* --no-interactive never prompt (imply --force unless --dry-run)
|
|
36
|
+
* --dry-run show what would be deleted, change nothing
|
|
37
|
+
* --keep-db do not drop the project database (only remove the record)
|
|
38
|
+
* --keep-files synonym for omitting --purge (default)
|
|
39
|
+
*
|
|
40
|
+
* Run from inside the project: `nexus uninstall --purge`
|
|
41
|
+
* Or point at another project: `nexus uninstall --target ../my-app --purge`
|
|
42
|
+
* Preview only: `nexus uninstall --dry-run`
|
|
43
|
+
*/
|
|
44
|
+
export async function uninstall(args: string[]): Promise<number> {
|
|
45
|
+
const target = resolve(argValue(args, '--target') ?? '.');
|
|
46
|
+
const force = args.includes('--force');
|
|
47
|
+
const purge = args.includes('--purge');
|
|
48
|
+
const dryRun = args.includes('--dry-run');
|
|
49
|
+
const keepDb = args.includes('--keep-db');
|
|
50
|
+
const noInteractive = args.includes('--no-interactive') || !wizardInteractive();
|
|
51
|
+
|
|
52
|
+
// -- banner ------------------------------------------------------
|
|
53
|
+
banner('BhooAI Nexus - uninstall', [
|
|
54
|
+
dryRun ? 'DRY RUN - nothing will be deleted.' : 'Removes the project DB + its nexus_projects record.',
|
|
55
|
+
purge ? 'Will also DELETE the project directory from disk.' : 'Project files are kept (pass --purge to remove them).',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// -- validate the target is a nexus project ----------------------
|
|
59
|
+
if (!existsSync(resolve(target, 'package.json'))) {
|
|
60
|
+
console.log(` ${RED}[X] not a project directory: no package.json at ${target}${RESET}\n`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
const configExists = ['ts', 'js', 'mjs', 'cjs'].some((ext) => existsSync(resolve(target, `nexus.config.${ext}`)));
|
|
64
|
+
if (!configExists) {
|
|
65
|
+
console.log(` ${RED}[X] not a nexus project: no nexus.config.{ts,js,mjs,cjs} at ${target}${RESET}\n`);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// -- resolve identity + config -----------------------------------
|
|
70
|
+
const project = await resolveProjectInfo(target);
|
|
71
|
+
let cfg: Awaited<ReturnType<typeof loadConfigAuto>> | undefined;
|
|
72
|
+
try {
|
|
73
|
+
cfg = await loadConfigAuto({ root: target });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.log(` ${YELLOW}[!] could not load nexus.config: ${(err as Error).message}${RESET}`);
|
|
76
|
+
}
|
|
77
|
+
const dbName = cfg?.db?.name ?? project.dbName;
|
|
78
|
+
const mongoUri = cfg?.db?.uri ?? 'mongodb://localhost:27017';
|
|
79
|
+
|
|
80
|
+
console.log(` ${BOLD}Project${RESET}`);
|
|
81
|
+
console.log(` ${CYAN}name${RESET} ${project.name}`);
|
|
82
|
+
console.log(` ${CYAN}path${RESET} ${target}`);
|
|
83
|
+
console.log(` ${CYAN}database${RESET} ${dbName}`);
|
|
84
|
+
console.log(` ${CYAN}mongo uri${RESET} ${mongoUri}`);
|
|
85
|
+
console.log(` ${CYAN}purge files${RESET} ${purge ? 'yes' : 'no'}`);
|
|
86
|
+
console.log();
|
|
87
|
+
|
|
88
|
+
// -- confirm -----------------------------------------------------
|
|
89
|
+
if (dryRun) {
|
|
90
|
+
// Show the stored record too, for reference.
|
|
91
|
+
await previewRecord(mongoUri, project.name);
|
|
92
|
+
console.log(`\n ${DIM}Dry run complete - no changes made.${RESET}\n`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!force && !noInteractive) {
|
|
97
|
+
const ok = await confirm(`Delete the database \`${dbName}\` and remove the \`${project.name}\` record?`, false);
|
|
98
|
+
if (!ok) {
|
|
99
|
+
console.log(` ${DIM}Aborted.${RESET}`);
|
|
100
|
+
closeWizard();
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
if (purge) {
|
|
104
|
+
const ok2 = await confirm(`Also DELETE the directory ${target} from disk? This cannot be undone.`, false);
|
|
105
|
+
if (!ok2) {
|
|
106
|
+
console.log(` ${YELLOW}Keeping files - only the DB + record will be removed.${RESET}`);
|
|
107
|
+
} else {
|
|
108
|
+
// confirmed purge
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
closeWizard();
|
|
112
|
+
} else if (!force && noInteractive) {
|
|
113
|
+
// Non-interactive without --force: refuse to destructively delete.
|
|
114
|
+
console.log(` ${RED}[X] Refusing to uninstall non-interactively without --force.${RESET}`);
|
|
115
|
+
console.log(` ${DIM}Pass --force to proceed, or run interactively.${RESET}\n`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// -- 1. drop the project database --------------------------------
|
|
120
|
+
if (!keepDb) {
|
|
121
|
+
const mongo = parseHostPort(mongoUri, 27017);
|
|
122
|
+
const mongoOk = await tcpReachable(mongo.host, mongo.port, 1500);
|
|
123
|
+
if (!mongoOk) {
|
|
124
|
+
console.log(` ${YELLOW}[!] Mongo unreachable at ${mongo.host}:${mongo.port} - skipping DB drop${RESET}`);
|
|
125
|
+
} else {
|
|
126
|
+
try {
|
|
127
|
+
connectProjectInfo(mongoUri, { autoIndex: false });
|
|
128
|
+
const dropped = await dropProjectDatabase(dbName);
|
|
129
|
+
console.log(` ${dropped ? GREEN + '[OK]' : YELLOW + '[!]'} dropped database${RESET} ${CYAN}${dbName}${RESET} ${dropped ? '' : '(already absent)'}`);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.log(` ${RED}[X] failed to drop database ${dbName}: ${(err as Error).message}${RESET}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
console.log(` ${DIM}--keep-db: leaving database ${dbName} in place${RESET}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// -- 2. delete the nexus_projects.projects record ---------------
|
|
139
|
+
if (!keepDb || true) {
|
|
140
|
+
try {
|
|
141
|
+
// connectProjectInfo may already be open from the drop step; reusing is fine.
|
|
142
|
+
if (!keepDb) {
|
|
143
|
+
// already connected above
|
|
144
|
+
} else {
|
|
145
|
+
const mongo = parseHostPort(mongoUri, 27017);
|
|
146
|
+
if (await tcpReachable(mongo.host, mongo.port, 1500)) connectProjectInfo(mongoUri, { autoIndex: false });
|
|
147
|
+
}
|
|
148
|
+
const deleted = await deleteProjectInfo(project.name);
|
|
149
|
+
console.log(` ${deleted ? GREEN + '[OK]' : YELLOW + '[!]'} deleted record${RESET} ${CYAN}${project.name}${RESET} ${deleted ? 'from nexus_projects.projects' : '(not found)'}`);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.log(` ${RED}[X] failed to delete project record: ${(err as Error).message}${RESET}`);
|
|
152
|
+
} finally {
|
|
153
|
+
try { await closeProjectInfo(); } catch { /* ignore */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// -- 3. purge project files from disk ----------------------------
|
|
158
|
+
if (purge) {
|
|
159
|
+
const removed = await removeDir(target);
|
|
160
|
+
if (removed) {
|
|
161
|
+
console.log(` ${GREEN}[OK]${RESET} removed directory ${CYAN}${target}${RESET}`);
|
|
162
|
+
} else {
|
|
163
|
+
console.log(` ${RED}[X] could not fully remove ${target}${RESET}`);
|
|
164
|
+
console.log(` ${DIM}Some files may be locked by a running process (node_modules, .venv, an editor).${RESET}`);
|
|
165
|
+
console.log(` ${DIM}Close any servers/editors holding the directory and run:${RESET}`);
|
|
166
|
+
if (process.platform === 'win32') {
|
|
167
|
+
console.log(` ${CYAN}rd /s /q "${target}"${RESET}`);
|
|
168
|
+
} else {
|
|
169
|
+
console.log(` ${CYAN}rm -rf "${target}"${RESET}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log(`\n ${GREEN}[OK] Uninstall complete.${RESET}${purge ? '' : ` ${DIM}(project files kept - pass --purge to remove them)${RESET}`}\n`);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Print the stored nexus_projects record for the project (dry-run preview). */
|
|
179
|
+
async function previewRecord(mongoUri: string, name: string): Promise<void> {
|
|
180
|
+
const mongo = parseHostPort(mongoUri, 27017);
|
|
181
|
+
const mongoOk = await tcpReachable(mongo.host, mongo.port, 1500);
|
|
182
|
+
if (!mongoOk) {
|
|
183
|
+
console.log(` ${YELLOW}[!] Mongo unreachable - cannot preview the stored record${RESET}`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
connectProjectInfo(mongoUri, { autoIndex: false });
|
|
188
|
+
const rec = await getProjectInfo(name);
|
|
189
|
+
if (rec) {
|
|
190
|
+
console.log(` ${BOLD}Stored record${RESET}`);
|
|
191
|
+
console.log(` ${CYAN}name${RESET} ${rec.name}`);
|
|
192
|
+
console.log(` ${CYAN}status${RESET} ${rec.status ?? '?'}`);
|
|
193
|
+
console.log(` ${CYAN}path${RESET} ${rec.path}`);
|
|
194
|
+
console.log(` ${CYAN}dbName${RESET} ${rec.dbName}`);
|
|
195
|
+
} else {
|
|
196
|
+
console.log(` ${DIM}no record found in nexus_projects.projects for \`${name}\`${RESET}`);
|
|
197
|
+
}
|
|
198
|
+
await closeProjectInfo();
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.log(` ${YELLOW}[!] could not read record: ${(err as Error).message}${RESET}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function argValue(args: string[], name: string): string | undefined {
|
|
205
|
+
const i = args.indexOf(name);
|
|
206
|
+
if (i >= 0) return args[i + 1];
|
|
207
|
+
return args.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Robustly remove a directory tree, working around Windows EPERM errors.
|
|
212
|
+
* 1. Recursively clear the read-only attribute on every entry (Windows sets
|
|
213
|
+
* it on `node_modules/.bin` shims, `.venv` files, etc. - `rmSync` then
|
|
214
|
+
* throws EPERM instead of deleting).
|
|
215
|
+
* 2. Try `rmSync`; retry up to 3 times with a short backoff (handles files
|
|
216
|
+
* briefly locked by a just-killed process or an antivirus scan).
|
|
217
|
+
* 3. Fall back to the OS shell (`rd /s /q` on Windows, `rm -rf` elsewhere)
|
|
218
|
+
* which uses a different deletion path and often succeeds where Node's
|
|
219
|
+
* libuv-based unlink fails.
|
|
220
|
+
* Returns true if the directory is gone (or never existed), false otherwise.
|
|
221
|
+
*/
|
|
222
|
+
async function removeDir(target: string): Promise<boolean> {
|
|
223
|
+
if (!existsSync(target)) return true;
|
|
224
|
+
|
|
225
|
+
// Step 1: clear read-only attrs recursively (no-op on non-Windows; cheap).
|
|
226
|
+
clearReadOnly(target);
|
|
227
|
+
|
|
228
|
+
// Step 2: rmSync with retries.
|
|
229
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
230
|
+
try {
|
|
231
|
+
rmSync(target, { recursive: true, force: true });
|
|
232
|
+
if (!existsSync(target)) return true;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
235
|
+
if (attempt < 3 && (code === 'EPERM' || code === 'EBUSY' || code === 'ENOTEMPTY')) {
|
|
236
|
+
await new Promise((r) => setTimeout(r, 250 * attempt));
|
|
237
|
+
// Re-clear attrs in case a new locked file appeared.
|
|
238
|
+
clearReadOnly(target);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
break; // give up on rmSync, fall through to shell
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!existsSync(target)) return true;
|
|
246
|
+
|
|
247
|
+
// Step 3: OS shell fallback.
|
|
248
|
+
try {
|
|
249
|
+
if (process.platform === 'win32') {
|
|
250
|
+
// `rd /s /q` ignores read-only attrs and forces deletion.
|
|
251
|
+
const r = spawnSync('cmd', ['/c', 'rd', '/s', '/q', target], { windowsHide: true });
|
|
252
|
+
return r.status === 0 && !existsSync(target);
|
|
253
|
+
}
|
|
254
|
+
const r = spawnSync('rm', ['-rf', target]);
|
|
255
|
+
return r.status === 0 && !existsSync(target);
|
|
256
|
+
} catch {
|
|
257
|
+
return !existsSync(target);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Recursively walk `dir` and clear the read-only bit on every file/dir.
|
|
262
|
+
* Windows-only in effect; on other platforms chmod is a no-op on the
|
|
263
|
+
* immutable bits. Uses `0o666` for files and `0o777` for dirs. */
|
|
264
|
+
function clearReadOnly(dir: string): void {
|
|
265
|
+
let entries: string[];
|
|
266
|
+
try {
|
|
267
|
+
entries = readdirSync(dir);
|
|
268
|
+
} catch {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
for (const name of entries) {
|
|
272
|
+
const p = join(dir, name);
|
|
273
|
+
let st;
|
|
274
|
+
try {
|
|
275
|
+
st = statSync(p);
|
|
276
|
+
} catch {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
chmodSync(p, st.isDirectory() ? 0o777 : 0o666);
|
|
281
|
+
} catch { /* ignore - best effort */ }
|
|
282
|
+
if (st.isDirectory()) clearReadOnly(p);
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
chmodSync(dir, 0o777);
|
|
286
|
+
} catch { /* ignore */ }
|
|
287
|
+
}
|