@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.
Files changed (75) hide show
  1. package/PLAN.md +141 -0
  2. package/README.md +34 -0
  3. package/package.json +25 -0
  4. package/src/commands/cluster.ts +133 -0
  5. package/src/commands/dev.ts +133 -0
  6. package/src/commands/doctor.ts +199 -0
  7. package/src/commands/init.ts +960 -0
  8. package/src/commands/node.ts +101 -0
  9. package/src/commands/pysetup.ts +136 -0
  10. package/src/commands/sync.ts +116 -0
  11. package/src/commands/uninstall.ts +287 -0
  12. package/src/config-sync.ts +384 -0
  13. package/src/dotenv.ts +39 -0
  14. package/src/index.ts +94 -0
  15. package/src/supervisor.ts +384 -0
  16. package/src/util.ts +123 -0
  17. package/src/wizard.ts +149 -0
  18. package/templates/Dockerfile +60 -0
  19. package/templates/README.md +69 -0
  20. package/templates/apps/admin/index.html +12 -0
  21. package/templates/apps/admin/package.json +24 -0
  22. package/templates/apps/admin/postcss.config.js +6 -0
  23. package/templates/apps/admin/src/main.tsx +10 -0
  24. package/templates/apps/admin/src/vite-env.d.ts +18 -0
  25. package/templates/apps/admin/tailwind.config.js +9 -0
  26. package/templates/apps/admin/tsconfig.json +17 -0
  27. package/templates/apps/admin/vite.config.ts +64 -0
  28. package/templates/apps/ai-server/main.py +43 -0
  29. package/templates/apps/ai-server/providers/__init__.py +3 -0
  30. package/templates/apps/ai-server/providers/base.py +111 -0
  31. package/templates/apps/ai-server/requirements.txt +3 -0
  32. package/templates/apps/ai-server/routers/__init__.py +3 -0
  33. package/templates/apps/ai-server/routers/chat.py +47 -0
  34. package/templates/apps/ai-server/routers/embeddings.py +30 -0
  35. package/templates/apps/ai-server/routers/lint.py +167 -0
  36. package/templates/apps/ai-server/routers/models.py +23 -0
  37. package/templates/apps/ai-server/routers/preflight.py +169 -0
  38. package/templates/apps/ai-server/settings.py +48 -0
  39. package/templates/apps/backend/package.json +33 -0
  40. package/templates/apps/backend/src/main.ts +375 -0
  41. package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
  42. package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
  43. package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
  44. package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
  45. package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
  46. package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
  47. package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
  48. package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
  49. package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
  50. package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
  51. package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
  52. package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
  53. package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
  54. package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
  55. package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
  56. package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
  57. package/templates/apps/backend/tsconfig.json +14 -0
  58. package/templates/apps/frontend/index.html +12 -0
  59. package/templates/apps/frontend/package.json +19 -0
  60. package/templates/apps/frontend/src/main.tsx +64 -0
  61. package/templates/apps/frontend/vite.config.ts +63 -0
  62. package/templates/bin/nexus.js +35 -0
  63. package/templates/bin/serve-all.mjs +45 -0
  64. package/templates/dockerignore +15 -0
  65. package/templates/gitignore +12 -0
  66. package/templates/nexus.config.ts +69 -0
  67. package/templates/package.json +47 -0
  68. package/templates/tsconfig.json +17 -0
  69. package/templates/uploads/.gitkeep +0 -0
  70. package/tests/cli.test.ts +45 -0
  71. package/tests/config-sync.test.ts +201 -0
  72. package/tests/dotenv.test.ts +51 -0
  73. package/tsconfig.json +9 -0
  74. package/vitest.config.ts +9 -0
  75. package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
@@ -0,0 +1,732 @@
1
+ import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, extname, join, resolve } from 'node:path';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { createRequire } from 'node:module';
6
+ import { createContext, runInContext } from 'node:vm';
7
+ import { Router } from '@bhooai/nexus-core/http';
8
+ import type { DeepPartial, NexusConfig } from '@bhooai/nexus-core';
9
+ import { mergeConfig, discoverUserConfigPath, frameworkDefaultConfigPath } from '@bhooai/nexus-core';
10
+ import { AiClient } from '@bhooai/nexus-ai-client';
11
+ import { AuthService, authToken, requireRole } from '@bhooai/nexus-auth';
12
+ import type { PaymentsService } from '@bhooai/nexus-payments';
13
+ import type { AdminExtensions } from '@bhooai/nexus-plugins';
14
+ import { getUserModel } from '../users/userModel.js';
15
+ import { getOrderModel, getTransactionModel } from '../payments/paymentStore.js';
16
+ import { registerDatabaseRoutes } from './databaseRoutes.js';
17
+ import { registerLintRoutes } from './lintProxy.js';
18
+ import { registerPreflightRoutes } from './preflightProxy.js';
19
+ import { registerClusterRoutes } from './clusterRoutes.js';
20
+ import { registerSchemaRoutes, type SchemaAIConfig } from './schemaRoutes.js';
21
+ import { ROLE_CATALOG, findRole } from './roleCatalog.js';
22
+ import { readRequestSeries, tailRequestLogs, type RequestSeriesRange } from '../requests/requestLog.js';
23
+ import { getProjectInfo, listProjectInfo, resolveProjectInfo, upsertProjectInfo, deleteProjectInfo, ObjectId, getProjectInfoCollection, type ProjectInfo } from '@bhooai/nexus-data';
24
+
25
+ export interface AdminRouteDeps {
26
+ /** Project root — where nexus.runtime.json lives. */
27
+ root: string;
28
+ /** The currently-running project (identity + info-store record). Optional —
29
+ * callers that don't run the project-info stack get a basic identity derived
30
+ * from the root (package.json name). */
31
+ project?: ProjectInfo;
32
+ /** Plugin admin overrides (pages/slots). */
33
+ adminExtensions: AdminExtensions;
34
+ /** Optional metrics snapshot provider. */
35
+ metrics?: () => Record<string, unknown>;
36
+ /** AI config used by the schema-generator endpoint. */
37
+ ai?: SchemaAIConfig;
38
+ /** Mutable AI providers array (shared with the AI proxy so API key changes
39
+ * are visible without restarting). When provided, overrides config.ai.providers. */
40
+ aiProviders?: Array<{ id: string; label: string; baseUrl: string; enabled: boolean; apiKey?: string; defaultModel?: string }>;
41
+ /** Enabled payment providers (used by the payments test console). */
42
+ payments?: PaymentsService;
43
+ /** Cluster manager — shared instance for auto-start + admin control. */
44
+ cluster?: import('@bhooai/nexus-cluster').ClusterManager;
45
+ }
46
+
47
+ /**
48
+ * Mount /admin/* routes. They are protected by bearer-token auth + the 'admin'
49
+ * role (the first registered user is bootstrapped as admin). The admin app
50
+ * uses these to edit the config file + runtime overrides, list plugins, users,
51
+ * payments, administer databases, and generate schemas with AI.
52
+ *
53
+ * GET /admin/config → { runtime, config, file, project } (secrets redacted)
54
+ * GET /admin/projects → registered projects from the project-info database
55
+ * PUT /admin/config → write a DeepPartial override to nexus.runtime.json
56
+ * PUT /admin/config/file → write the human-edited nexus.config.js/.ts back (validated)
57
+ * GET /admin/env → masked .env key/value entries
58
+ * PUT /admin/env → update .env key/value entries
59
+ * GET /admin/plugins → { pages, slots } contributed by plugins
60
+ * GET /admin/users → user list (no password hashes)
61
+ * GET /admin/roles → role catalog (grants + restrictions)
62
+ * PUT /admin/users/:id → set a user's roles (admin only)
63
+ * GET /admin/metrics → metrics snapshot
64
+ * GET /admin/payments/orders → persisted payment orders
65
+ * GET /admin/payments/transactions → transaction log (webhook events)
66
+ * ... databases + schema endpoints (see databaseRoutes.ts / schemaRoutes.ts)
67
+ */
68
+ export function registerAdminRoutes(router: Router, config: NexusConfig, deps: AdminRouteDeps): AuthService {
69
+ const service = new AuthService(
70
+ { secret: config.auth.jwt.secret, algorithm: 'HS256', issuer: config.auth.jwt.issuer, audience: config.auth.jwt.audience, accessTtl: config.auth.jwt.accessTtl, refreshTtl: config.auth.jwt.refreshTtl },
71
+ // Admin only verifies access tokens (stateless); no session store needed.
72
+ { add: () => {}, get: () => undefined, revoke: () => {}, revokeFamily: () => {} } as any,
73
+ );
74
+ const guard: import('@bhooai/nexus-core').Middleware[] = [authToken(service, { cookieName: config.auth.cookieName }), requireRole('admin')];
75
+ const runtimePath = join(deps.root, 'nexus.runtime.json');
76
+
77
+ async function readRuntime(): Promise<DeepPartial<NexusConfig>> {
78
+ if (!existsSync(runtimePath)) return {};
79
+ try { return JSON.parse(await readFile(runtimePath, 'utf8')) as DeepPartial<NexusConfig>; }
80
+ catch { return {}; }
81
+ }
82
+
83
+ /** Redact secret-ish fields from a config snapshot before sending to the UI. */
84
+ const PROVIDER_SECRET_KEYS = ['apiKey', 'secret', 'clientSecret', 'keySecret', 'salt', 'secretWord', 'webhookSecret'];
85
+ function redact(cfg: NexusConfig): Record<string, any> {
86
+ const c = JSON.parse(JSON.stringify(cfg)) as Record<string, any>;
87
+ if (c.auth?.jwt) c.auth.jwt.secret = '***';
88
+ if (c.payments) {
89
+ const providers: Record<string, any> = c.payments.providers ?? c.payments;
90
+ for (const k of Object.keys(providers ?? {})) {
91
+ const v = providers[k];
92
+ if (v && typeof v === 'object') {
93
+ for (const sk of PROVIDER_SECRET_KEYS) if (v[sk]) v[sk] = '***';
94
+ }
95
+ }
96
+ }
97
+ if (c.email?.smtp) c.email.smtp.pass = c.email.smtp.pass ? '***' : undefined;
98
+ return c;
99
+ }
100
+
101
+ router.get('/admin/config', async (ctx) => {
102
+ const project = deps.project ?? (await resolveProjectInfo(deps.root));
103
+ // Prefer the stored record matched by path so a renamed project is found.
104
+ const stored = (await listProjectInfo().catch(() => [] as ProjectInfo[])).find((p) => p.path === project.path)
105
+ ?? (await getProjectInfo(project.name).catch(() => null));
106
+ const pkg = await readPackageJson(deps.root);
107
+ const resolved = stored ?? project;
108
+ ctx.json({
109
+ runtime: await readRuntime(),
110
+ config: redact(config),
111
+ file: await userConfigFile(deps.root),
112
+ project: { ...resolved, version: pkg?.version ?? resolved.version },
113
+ });
114
+ }, guard);
115
+
116
+ // Supervisor discovery — where the running `nexus dev` control API lives
117
+ // (the port in `supervisor.json`, auto-allotted upward when 7474 is busy).
118
+ router.get('/admin/supervisor', async (ctx) => {
119
+ const infoPath = join(deps.root, 'supervisor.json');
120
+ if (!existsSync(infoPath)) {
121
+ ctx.json({ port: null, url: null, note: 'no supervisor.json — run `nexus dev` to start its control API' });
122
+ return;
123
+ }
124
+ try {
125
+ const info = JSON.parse(await readFile(infoPath, 'utf8')) as { port?: number; url?: string; writtenAt?: string };
126
+ ctx.json({ port: info.port ?? null, url: info.url ?? null, writtenAt: info.writtenAt ?? null });
127
+ } catch {
128
+ ctx.json({ port: null, url: null, note: 'supervisor.json is unreadable' });
129
+ }
130
+ }, guard);
131
+
132
+ // Project registry — reads the shared project-info database so a project can
133
+ // see its own (and every other) project's name, database and settings.
134
+ router.get('/admin/projects', async (ctx) => {
135
+ ctx.json({ projects: await listProjectInfo() });
136
+ }, guard);
137
+
138
+ // PUT /admin/project — update the project's registry record (name) and/or
139
+ // its package.json on disk (version). The physical database is not renamed.
140
+ router.put('/admin/project', async (ctx) => {
141
+ const body = (ctx.body ?? {}) as { name?: string; version?: string };
142
+ const project = deps.project ?? (await resolveProjectInfo(deps.root));
143
+ const stored = (await listProjectInfo().catch(() => [] as ProjectInfo[])).find((p) => p.path === project.path)
144
+ ?? await getProjectInfo(project.name).catch(() => null);
145
+ const current = stored ?? project;
146
+ const next: ProjectInfo = { ...current };
147
+
148
+ if (typeof body.name === 'string' && body.name.trim() && body.name.trim() !== current.name) {
149
+ const newName = body.name.trim();
150
+ if (!/^[\w@/. -]+$/.test(newName)) throw new Error('project name contains unsupported characters');
151
+ const clash = (await listProjectInfo().catch(() => [] as ProjectInfo[])).find(
152
+ (p) => p.name === newName && p.path !== current.path,
153
+ );
154
+ if (clash) throw new Error(`a project named "${newName}" already exists`);
155
+ next.name = newName;
156
+ }
157
+
158
+ if (typeof body.version === 'string' && body.version.trim() && body.version.trim() !== (await readPackageJson(deps.root))?.version) {
159
+ const version = body.version.trim();
160
+ if (!/^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(version)) throw new Error(`"${version}" is not a valid semver version`);
161
+ const pkgPath = join(deps.root, 'package.json');
162
+ let pkg: Record<string, unknown> = {};
163
+ try { pkg = JSON.parse(await readFile(pkgPath, 'utf8')) as Record<string, unknown>; }
164
+ catch { throw new Error('package.json is missing or unreadable'); }
165
+ pkg.version = version;
166
+ await writeTextAtomic(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
167
+ next.version = version;
168
+ }
169
+
170
+ if (next.name !== current.name || next.version !== current.version) {
171
+ await upsertProjectInfo(next);
172
+ if (next.name !== current.name) await deleteProjectInfo(current.name).catch(() => {});
173
+ }
174
+ ctx.json({ ok: true, project: next });
175
+ }, guard);
176
+
177
+ router.put('/admin/config', async (ctx) => {
178
+ const overrides = (ctx.body ?? {}) as DeepPartial<NexusConfig>;
179
+ // Validate by merging with defaults + parsing the full schema (throws on invalid).
180
+ mergeConfig(overrides);
181
+ await writeTextAtomic(runtimePath, JSON.stringify(overrides, null, 2) + '\n');
182
+ ctx.json({ ok: true, note: 'runtime overrides written — restart services to apply' });
183
+ }, guard);
184
+
185
+ const resolveEnvFile = (file: unknown): { fileName: string; envPath: string } => {
186
+ const name = typeof file === 'string' && file.trim() ? file.trim() : '.env';
187
+ if (!/^\.env(?:\.\w+)*$/.test(name)) {
188
+ throw new Error(`unsupported environment file: ${name}`);
189
+ }
190
+ return { fileName: name, envPath: join(deps.root, name) };
191
+ };
192
+
193
+ router.get('/admin/env', async (ctx) => {
194
+ const { fileName, envPath } = resolveEnvFile(ctx.query?.file);
195
+ const content = existsSync(envPath) ? await readFile(envPath, 'utf8') : '';
196
+ ctx.json({
197
+ fileName,
198
+ path: envPath,
199
+ exists: existsSync(envPath),
200
+ entries: parseEnvEntries(content),
201
+ note: 'Secret values are masked. Send null for an unchanged masked secret.',
202
+ });
203
+ }, guard);
204
+
205
+ router.put('/admin/env', async (ctx) => {
206
+ const body = (ctx.body ?? {}) as { entries?: unknown; file?: unknown };
207
+ let fileName: string;
208
+ let envPath: string;
209
+ try {
210
+ ({ fileName, envPath } = resolveEnvFile(body.file ?? ctx.query?.file));
211
+ } catch (error) {
212
+ ctx.json({ error: (error as Error).message }, 400);
213
+ return;
214
+ }
215
+ if (!Array.isArray(body.entries)) {
216
+ ctx.json({ error: 'entries (array) is required' }, 400);
217
+ return;
218
+ }
219
+
220
+ const entries = new Map<string, string | null>();
221
+ for (const raw of body.entries) {
222
+ const entry = (raw ?? {}) as { key?: unknown; value?: unknown };
223
+ const key = typeof entry.key === 'string' ? entry.key.trim() : '';
224
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
225
+ ctx.json({ error: `invalid environment key: ${key || '(empty)'}` }, 400);
226
+ return;
227
+ }
228
+ if (entries.has(key)) {
229
+ ctx.json({ error: `duplicate environment key: ${key}` }, 400);
230
+ return;
231
+ }
232
+ if (entry.value !== null && typeof entry.value !== 'string') {
233
+ ctx.json({ error: `environment value must be a string or null: ${key}` }, 400);
234
+ return;
235
+ }
236
+ if (entry.value === null && isSecretEnvKey(key) && !existsSync(envPath)) {
237
+ ctx.json({ error: `a value is required for new secret key: ${key}` }, 400);
238
+ return;
239
+ }
240
+ entries.set(key, entry.value as string | null);
241
+ }
242
+
243
+ const original = existsSync(envPath) ? await readFile(envPath, 'utf8') : '';
244
+ let next: string;
245
+ try {
246
+ next = updateEnvContent(original, entries);
247
+ } catch (error) {
248
+ ctx.json({ error: (error as Error).message }, 400);
249
+ return;
250
+ }
251
+ await writeTextAtomic(envPath, next);
252
+ ctx.json({
253
+ ok: true,
254
+ fileName,
255
+ path: envPath,
256
+ keys: [...entries.keys()],
257
+ note: 'Environment file saved — restart services to apply changes',
258
+ });
259
+ }, guard);
260
+
261
+ /** Save the raw human-edited config file back to disk, after validating it. */
262
+ router.put('/admin/config/file', async (ctx) => {
263
+ const content = (ctx.body as { content?: unknown } | undefined)?.content;
264
+ if (typeof content !== 'string') {
265
+ ctx.json({ error: 'content (string) is required' }, 400);
266
+ return;
267
+ }
268
+ const info = await userConfigFile(deps.root);
269
+ if (!info) {
270
+ ctx.json({ error: 'No user config file found (nexus.config.js / .ts)' }, 404);
271
+ return;
272
+ }
273
+ // Write first, then validate by loading the file the same way the boot
274
+ // loader does. On failure, roll the original content back.
275
+ const original = info.content;
276
+ await writeFile(info.path, content, 'utf8');
277
+ try {
278
+ await validateUserConfigFile(info.path, content);
279
+ ctx.json({ ok: true, path: info.path, note: 'Saved to config file — restart services to apply' });
280
+ } catch (e) {
281
+ await writeFile(info.path, original, 'utf8');
282
+ ctx.json({ error: `Config file invalid — not saved: ${(e as Error).message}` }, 400);
283
+ }
284
+ }, guard);
285
+
286
+ router.get('/admin/plugins', async (ctx) => {
287
+ ctx.json(deps.adminExtensions.toJSON());
288
+ }, guard);
289
+
290
+ router.get('/admin/users', async (ctx) => {
291
+ const User = getUserModel();
292
+ const users = await User.find({}, { passwordHash: 0 }).lean();
293
+ ctx.json({ users });
294
+ }, guard);
295
+
296
+ // Role catalog — assignable roles with what each can do / is restricted from.
297
+ router.get('/admin/roles', async (ctx) => {
298
+ ctx.json({ roles: ROLE_CATALOG });
299
+ }, guard);
300
+
301
+ // Update a user's roles. Guards: valid role names, never remove the last
302
+ // admin, and never let an admin strip their own admin role.
303
+ router.put('/admin/users/:id', async (ctx) => {
304
+ const body = (ctx.body ?? {}) as { roles?: unknown };
305
+ const incoming = body.roles;
306
+ if (!Array.isArray(incoming) || !incoming.length || incoming.some((r) => typeof r !== 'string')) {
307
+ ctx.json({ error: 'roles (a non-empty string array) is required' }, 400);
308
+ return;
309
+ }
310
+ const nextRoles = [...new Set(incoming as string[])];
311
+ const unknown = nextRoles.filter((r) => !findRole(r));
312
+ if (unknown.length) {
313
+ ctx.json({ error: `unknown role(s): ${unknown.join(', ')}` }, 400);
314
+ return;
315
+ }
316
+
317
+ const User = getUserModel();
318
+ const id = String(ctx.params.id ?? '');
319
+ let oid: import('mongodb').ObjectId;
320
+ try { oid = new ObjectId(id); } catch { ctx.json({ error: 'invalid user id' }, 400); return; }
321
+ const target = await User.findOne({ _id: oid }).lean();
322
+ if (!target) {
323
+ ctx.json({ error: 'user not found' }, 404);
324
+ return;
325
+ }
326
+
327
+ const currentRoles = (target.roles ?? []) as string[];
328
+ const isAdmin = currentRoles.includes('admin');
329
+ const keepsAdmin = nextRoles.includes('admin');
330
+ const caller = ctx.state.user as { id?: string; roles?: string[] } | undefined;
331
+
332
+ if (isAdmin && !keepsAdmin) {
333
+ const adminCount = await User.countDocuments({ roles: 'admin' });
334
+ if (adminCount <= 1) {
335
+ ctx.json({ error: 'cannot remove the last admin from the system' }, 400);
336
+ return;
337
+ }
338
+ if (caller?.id === id) {
339
+ ctx.json({ error: 'you cannot remove your own admin role' }, 400);
340
+ return;
341
+ }
342
+ }
343
+
344
+ await User.updateOne({ _id: oid }, { $set: { roles: nextRoles } });
345
+ const updated = await User.findOne({ _id: oid }).lean();
346
+ ctx.json({ ok: true, user: updated });
347
+ }, guard);
348
+
349
+ router.get('/admin/metrics', async (ctx) => {
350
+ const pkg = await readPackageJson(deps.root);
351
+ ctx.json({
352
+ metrics: deps.metrics?.() ?? {},
353
+ version: pkg?.version ?? null,
354
+ node: process.version,
355
+ uptime: process.uptime(),
356
+ pid: process.pid,
357
+ });
358
+ }, guard);
359
+
360
+ // HTTP request log — datewise JSON files under the project logging dir.
361
+ const requestLogDir = join(deps.root, config.logging.dir);
362
+
363
+ // GET /admin/requests?limit=100 → newest-first raw request entries.
364
+ router.get('/admin/requests', async (ctx) => {
365
+ const limit = clampInt(ctx.query.limit, 100);
366
+ ctx.json({ requests: tailRequestLogs(requestLogDir, limit) });
367
+ }, guard);
368
+
369
+ // GET /admin/requests/series?range=today|5d|week|month|year → aggregated buckets.
370
+ router.get('/admin/requests/series', async (ctx) => {
371
+ const range = (ctx.query.range ?? 'today') as RequestSeriesRange;
372
+ const series = readRequestSeries(requestLogDir, ['today', '5d', 'week', 'month', 'year'].includes(range) ? range : 'today');
373
+ ctx.json({ series });
374
+ }, guard);
375
+
376
+ // Payments — persisted orders + transactions (recorded by paymentRoutes + webhooks).
377
+ router.get('/admin/payments/orders', async (ctx) => {
378
+ const limit = clampInt(ctx.query.limit, 100);
379
+ const filter = providerFilter(ctx.query.provider);
380
+ const orders = await getOrderModel().find(filter).sort({ createdAt: -1 }).limit(limit).lean();
381
+ ctx.json({ orders });
382
+ }, guard);
383
+
384
+ router.get('/admin/payments/transactions', async (ctx) => {
385
+ const limit = clampInt(ctx.query.limit, 100);
386
+ const filter = providerFilter(ctx.query.provider);
387
+ const transactions = await getTransactionModel().find(filter).sort({ createdAt: -1 }).limit(limit).lean();
388
+ ctx.json({ transactions });
389
+ }, guard);
390
+
391
+ // Payments test console — per-provider status: enabled/configured from config,
392
+ // plus a live probe via the enabled provider instance when it supports one.
393
+ router.get('/admin/payments/status', async (ctx) => {
394
+ const paymentsCfg = (config.payments ?? {}) as Record<string, any>;
395
+ const results: Array<Record<string, unknown>> = [];
396
+ for (const name of PAYMENT_PROVIDER_NAMES) {
397
+ const pc = paymentsCfg[name] ?? {};
398
+ const instance = deps.payments?.providers.get(name);
399
+ const live = !!instance;
400
+ const keyFields = PAYMENT_PROVIDER_KEYS[name];
401
+ const entry: Record<string, unknown> = {
402
+ name,
403
+ enabled: live || !!pc.enabled,
404
+ sandbox: !!pc.sandbox,
405
+ configured: keyFields.every((k) => typeof pc[k] === 'string' && (pc[k] as string).length > 0),
406
+ live,
407
+ fields: keyFields.map((k) => ({
408
+ field: k,
409
+ label: PAYMENT_FIELD_LABELS[k] ?? k,
410
+ hasValue: typeof pc[k] === 'string' && (pc[k] as string).length > 0,
411
+ })),
412
+ };
413
+ if (instance?.testConnection) {
414
+ try {
415
+ const t = await instance.testConnection();
416
+ entry.ok = t.ok;
417
+ entry.detail = t.detail;
418
+ entry.error = t.error;
419
+ } catch (e) {
420
+ entry.ok = false;
421
+ entry.error = (e as Error).message;
422
+ }
423
+ } else {
424
+ entry.ok = live || (!!pc.enabled && entry.configured);
425
+ entry.note = 'no public probe — create a test order to verify';
426
+ }
427
+ results.push(entry);
428
+ }
429
+ ctx.json({ checkedAt: new Date().toISOString(), providers: results });
430
+ }, guard);
431
+
432
+ /** PUT /admin/payments/providers/:id — toggle a payment provider's enabled/sandbox state
433
+ * and/or save credential fields. Credentials (keyId/keySecret/…) are written to
434
+ * .env as NEXUS_PAYMENTS_<PROVIDER>_<FIELD> so they survive restarts; the
435
+ * enabled/sandbox state is persisted to nexus.runtime.json + MongoDB settings. */
436
+ router.put('/admin/payments/providers/:id', async (ctx) => {
437
+ const id = ctx.params.id;
438
+ if (!PAYMENT_PROVIDER_NAMES.includes(id as any)) { ctx.json({ error: `unknown provider "${id}"` }, 400); return; }
439
+ const body = (ctx.body ?? {}) as Record<string, unknown>;
440
+ // config is frozen at the top level (Object.freeze), so mutate the payments
441
+ // object in place instead of reassigning config.payments.
442
+ const paymentsCfg = (config.payments ?? {}) as Record<string, any>;
443
+ const pc = (paymentsCfg[id] ?? {}) as Record<string, unknown>;
444
+ if (typeof body.enabled === 'boolean') pc.enabled = body.enabled;
445
+ if (typeof body.sandbox === 'boolean') pc.sandbox = body.sandbox;
446
+ const savedFields: string[] = [];
447
+ for (const field of PAYMENT_PROVIDER_KEYS[id as (typeof PAYMENT_PROVIDER_NAMES)[number]]) {
448
+ const value = body[field];
449
+ if (typeof value === 'string' && value.trim()) {
450
+ pc[field] = value.trim();
451
+ savedFields.push(field);
452
+ }
453
+ }
454
+ paymentsCfg[id] = pc;
455
+ deps.payments?.refresh(paymentsCfg);
456
+ if (savedFields.length > 0) await writeEnvKeys(deps.root, id, pc);
457
+ const persistence = await persistPaymentProvider(deps.root, deps.project?.name, id, pc);
458
+ ctx.json({
459
+ ok: true,
460
+ persistence,
461
+ savedFields,
462
+ provider: {
463
+ name: id,
464
+ enabled: !!pc.enabled,
465
+ sandbox: !!pc.sandbox,
466
+ configured: PAYMENT_PROVIDER_KEYS[id as (typeof PAYMENT_PROVIDER_NAMES)[number]].every((k) => typeof pc[k] === 'string' && (pc[k] as string).length > 0),
467
+ },
468
+ });
469
+ }, guard);
470
+
471
+ registerDatabaseRoutes(router, guard);
472
+ registerPreflightRoutes(router, config, guard);
473
+ registerLintRoutes(router, config, deps.root, guard);
474
+ registerClusterRoutes(router, guard, { root: deps.root, config, manager: deps.cluster });
475
+ registerSchemaRoutes(
476
+ router,
477
+ deps.ai ?? { serverUrl: config.ai?.serverUrl ?? 'http://localhost:8000', timeoutMs: config.ai?.timeoutMs ?? 60_000, model: config.ai?.schemaModel, providers: deps.aiProviders ?? config.ai?.providers, root: deps.root, projectName: deps.project?.name },
478
+ guard,
479
+ );
480
+
481
+ return service;
482
+ }
483
+
484
+ /** Read the nearest human-edited `nexus.config.{ts,js,mjs,cjs}` (skipping the framework default). */
485
+ async function userConfigFile(root: string): Promise<{ path: string; content: string } | null> {
486
+ const path = discoverUserConfigPath(resolve(root), frameworkDefaultConfigPath());
487
+ if (!path || !existsSync(path)) return null;
488
+ return { path, content: await readFile(path, 'utf8') };
489
+ }
490
+
491
+ /**
492
+ * Validate a config file by loading it exactly like the boot loader does:
493
+ * dynamic `import()` for ESM (.ts/.mts/.mjs — the runtime runs under tsx), a
494
+ * `vm` sandbox for CommonJS (.js/.cjs) so we never pollute this process's
495
+ * require cache. Throws with a readable message when the file is broken.
496
+ */
497
+ async function validateUserConfigFile(absPath: string, content: string): Promise<void> {
498
+ const ext = extname(absPath);
499
+ const cfg = await loadUserConfigFile(absPath, content, ext);
500
+ if (!cfg || typeof cfg !== 'object') {
501
+ throw new Error('config file must export an object (default export, named "config", or module.exports)');
502
+ }
503
+ mergeConfig(cfg as DeepPartial<NexusConfig>);
504
+ }
505
+
506
+ async function loadUserConfigFile(absPath: string, content: string, ext: string): Promise<unknown> {
507
+ if (ext === '.mjs' || ext === '.mts' || ext === '.ts') {
508
+ // Cache-bust so a previously-imported path re-loads the new content.
509
+ const url = `${pathToFileURL(absPath).href}?t=${Date.now()}`;
510
+ const mod = (await import(url)) as Record<string, unknown>;
511
+ return mod.default ?? mod.config;
512
+ }
513
+ // CommonJS — evaluate in a sandbox (no require cache pollution).
514
+ const module = { exports: {} as Record<string, unknown> };
515
+ const sandbox = {
516
+ module,
517
+ exports: module.exports as Record<string, unknown>,
518
+ require: createRequire(absPath),
519
+ __dirname: dirname(absPath),
520
+ __filename: absPath,
521
+ process,
522
+ console,
523
+ };
524
+ const context = createContext(sandbox);
525
+ runInContext(content, context, { filename: absPath, timeout: 3000 });
526
+ const built = module.exports as Record<string, unknown>;
527
+ return built.default ?? built;
528
+ }
529
+
530
+ function clampInt(v: unknown, fallback: number): number {
531
+ const n = typeof v === 'string' ? Number(v) : NaN;
532
+ return Number.isFinite(n) && n > 0 && n <= 500 ? Math.floor(n) : fallback;
533
+ }
534
+
535
+ const PAYMENT_PROVIDER_NAMES = ['razorpay', 'paypal', 'payu', 'skrill', 'payoneer'] as const;
536
+
537
+ /** Credential fields that make a provider "configured". */
538
+ const PAYMENT_PROVIDER_KEYS: Record<(typeof PAYMENT_PROVIDER_NAMES)[number], string[]> = {
539
+ razorpay: ['keyId', 'keySecret'],
540
+ paypal: ['clientId', 'clientSecret'],
541
+ payu: ['merchantKey', 'salt'],
542
+ skrill: ['merchantEmail'],
543
+ payoneer: ['programId', 'apiKey'],
544
+ };
545
+
546
+ /** Human-friendly labels for the credential fields shown in the admin dialog. */
547
+ const PAYMENT_FIELD_LABELS: Record<string, string> = {
548
+ keyId: 'Key ID',
549
+ keySecret: 'Key secret',
550
+ clientId: 'Client ID',
551
+ clientSecret: 'Client secret',
552
+ merchantKey: 'Merchant key',
553
+ salt: 'Salt',
554
+ merchantEmail: 'Merchant email',
555
+ programId: 'Program ID',
556
+ apiKey: 'API key',
557
+ };
558
+
559
+ /** Env var name for a payment credential, e.g. razorpay.keyId → NEXUS_PAYMENTS_RAZORPAY_KEY_ID. */
560
+ function paymentEnvKey(provider: string, field: string): string {
561
+ const snake = field.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
562
+ return `NEXUS_PAYMENTS_${provider.toUpperCase()}_${snake}`;
563
+ }
564
+
565
+ /** Write credential fields for a provider into the project .env (creates/updates)
566
+ * and sync the running process.env so the current process picks them up. */
567
+ async function writeEnvKeys(root: string, provider: string, pc: Record<string, unknown>): Promise<void> {
568
+ const envPath = resolve(root, '.env');
569
+ const content = existsSync(envPath) ? await readFile(envPath, 'utf8') : '';
570
+ const lines = content.split(/\r?\n/);
571
+ const output = [...lines];
572
+ for (const field of PAYMENT_PROVIDER_KEYS[provider as (typeof PAYMENT_PROVIDER_NAMES)[number]]) {
573
+ const value = pc[field];
574
+ if (typeof value !== 'string' || !value) continue;
575
+ const envKey = paymentEnvKey(provider, field);
576
+ process.env[envKey] = value;
577
+ const regex = new RegExp(`^(\\s*export\\s+)?${envKey}\\s*=`);
578
+ const found = output.some((line) => regex.test(line));
579
+ if (found) {
580
+ for (let i = 0; i < output.length; i++) {
581
+ if (regex.test(output[i])) output[i] = `${envKey}=${value}`;
582
+ }
583
+ } else {
584
+ output.push(`${envKey}=${value}`);
585
+ }
586
+ }
587
+ await writeTextAtomic(envPath, `${output.join('\n')}\n`);
588
+ }
589
+
590
+ /** Persist a payment provider's enabled/sandbox state to nexus.runtime.json + MongoDB settings. */
591
+ async function persistPaymentProvider(
592
+ root: string,
593
+ projectName: string | undefined,
594
+ provider: string,
595
+ pc: Record<string, unknown>,
596
+ ): Promise<{ runtime: boolean; database: boolean }> {
597
+ const runtimeState = { enabled: !!pc.enabled, sandbox: !!pc.sandbox };
598
+ let runtime = false;
599
+ let database = false;
600
+ try {
601
+ const runtimePath = resolve(root, 'nexus.runtime.json');
602
+ let overrides: Record<string, any> = {};
603
+ if (existsSync(runtimePath)) {
604
+ try { overrides = JSON.parse(await readFile(runtimePath, 'utf8')) as Record<string, any>; }
605
+ catch { /* corrupt file — start fresh */ }
606
+ }
607
+ const payments = (overrides.payments ?? {}) as Record<string, any>;
608
+ payments[provider] = { ...(payments[provider] ?? {}), ...runtimeState };
609
+ overrides.payments = payments;
610
+ await writeTextAtomic(runtimePath, JSON.stringify(overrides, null, 2) + '\n');
611
+ runtime = true;
612
+ } catch { /* surfaced via persistence flags */ }
613
+ try {
614
+ if (projectName) {
615
+ const coll = await getProjectInfoCollection();
616
+ const result = await coll.updateOne(
617
+ { name: projectName },
618
+ { $set: { [`settings.payments.${provider}`]: runtimeState } },
619
+ { upsert: false },
620
+ );
621
+ if (result.matchedCount === 0) throw new Error(`project "${projectName}" was not found in nexus_projects`);
622
+ database = true;
623
+ }
624
+ } catch { /* surfaced via persistence flags */ }
625
+ return { runtime, database };
626
+ }
627
+
628
+ function providerFilter(v: unknown): { provider?: string } {
629
+ const p = typeof v === 'string' ? v.toLowerCase().trim() : '';
630
+ return p ? { provider: p } : {};
631
+ }
632
+
633
+ interface EnvEntry {
634
+ key: string;
635
+ value: string | null;
636
+ secret: boolean;
637
+ configPath?: string;
638
+ }
639
+
640
+ function parseEnvEntries(content: string): EnvEntry[] {
641
+ const entries: EnvEntry[] = [];
642
+ for (const line of content.split(/\r?\n/)) {
643
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line.trim());
644
+ if (!match) continue;
645
+ const key = match[1]!;
646
+ const secret = isSecretEnvKey(key);
647
+ entries.push({
648
+ key,
649
+ value: secret && match[2] ? null : decodeEnvValue(match[2]!),
650
+ secret,
651
+ ...(key.startsWith('NEXUS_') ? { configPath: key.slice(6).toLowerCase().replaceAll('_', '.') } : {}),
652
+ });
653
+ }
654
+ return entries;
655
+ }
656
+
657
+ function updateEnvContent(content: string, entries: Map<string, string | null>): string {
658
+ const lines = content.split(/\r?\n/);
659
+ const seen = new Set<string>();
660
+ const output: string[] = [];
661
+
662
+ for (const line of lines) {
663
+ const match = /^(\s*)(export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
664
+ if (!match) {
665
+ output.push(line);
666
+ continue;
667
+ }
668
+ const key = match[3]!;
669
+ const value = entries.get(key);
670
+ seen.add(key);
671
+ if (value === undefined) continue;
672
+ if (value === null) {
673
+ output.push(line);
674
+ } else {
675
+ output.push(`${match[1] ?? ''}${match[2] ?? ''}${key}=${encodeEnvValue(value)}`);
676
+ }
677
+ }
678
+
679
+ for (const [key, value] of entries) {
680
+ if (seen.has(key)) continue;
681
+ if (value === null) throw new Error(`a value is required for new environment key: ${key}`);
682
+ output.push(`${key}=${encodeEnvValue(value)}`);
683
+ }
684
+
685
+ while (output.length > 1 && output.at(-1) === '') output.pop();
686
+ return `${output.join('\n')}\n`;
687
+ }
688
+
689
+ function decodeEnvValue(value: string): string {
690
+ const trimmed = value.trim();
691
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
692
+ return trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\n/g, '\n');
693
+ }
694
+ if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
695
+ return trimmed.slice(1, -1);
696
+ }
697
+ return trimmed;
698
+ }
699
+
700
+ function encodeEnvValue(value: string): string {
701
+ return /^[A-Za-z0-9_./:@%+\-]+$/.test(value) ? value : JSON.stringify(value);
702
+ }
703
+
704
+ function isSecretEnvKey(key: string): boolean {
705
+ return /(SECRET|PASSWORD|PASS|TOKEN|PRIVATE|API_KEY|CLIENT_SECRET|ACCESS_KEY|KEY_SECRET)/i.test(key);
706
+ }
707
+
708
+ async function writeTextAtomic(path: string, content: string): Promise<void> {
709
+ const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
710
+ await writeFile(tempPath, content, 'utf8');
711
+ try {
712
+ await rename(tempPath, path);
713
+ } catch (error) {
714
+ // Windows does not replace an existing file with rename(). Keep the write
715
+ // safe while supporting the local development platform.
716
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST' && (error as NodeJS.ErrnoException).code !== 'EPERM') throw error;
717
+ await unlink(path).catch(() => undefined);
718
+ await rename(tempPath, path);
719
+ }
720
+ }
721
+
722
+ async function readPackageJson(root: string): Promise<{ name?: string; version?: string } | null> {
723
+ try {
724
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as Record<string, unknown>;
725
+ return {
726
+ name: typeof pkg.name === 'string' ? pkg.name : undefined,
727
+ version: typeof pkg.version === 'string' ? pkg.version : undefined,
728
+ };
729
+ } catch {
730
+ return null;
731
+ }
732
+ }