@bhooai/nexus-core 2.0.1 → 2.0.3
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 +27 -27
- package/src/app/adminModule.ts +613 -0
- package/src/app/createNexusApp.ts +36 -0
- package/src/app/index.ts +1 -0
- package/src/config/defaults.ts +3 -3
- package/src/config/envFile.ts +112 -0
- package/src/config/runtimeJson.ts +55 -0
package/package.json
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name":
|
|
3
|
-
"version":
|
|
4
|
-
"publishConfig":
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"type":
|
|
8
|
-
"main":
|
|
9
|
-
"types":
|
|
10
|
-
"exports":
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"scripts":
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"dependencies":
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"devDependencies":
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
2
|
+
"name": "@bhooai/nexus-core",
|
|
3
|
+
"version": "2.0.3",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./config": "./src/config/index.ts",
|
|
13
|
+
"./di": "./src/di/index.ts",
|
|
14
|
+
"./http": "./src/http/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"test": "vitest run"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"zod": "^3.23.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.5.0",
|
|
25
|
+
"typescript": "^5.6.2",
|
|
26
|
+
"vitest": "^2.1.1"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adminModule — framework-provided admin backend routes.
|
|
3
|
+
*
|
|
4
|
+
* Auto-mounted by `createNexusApp()` when `config.admin.enabled`. Serves the
|
|
5
|
+
* admin SPA's tabs: registry, health/services, per-app cards, request logs,
|
|
6
|
+
* config, users, payments, AI proxy, AI provider CRUD, and .env management.
|
|
7
|
+
*
|
|
8
|
+
* Routes:
|
|
9
|
+
* GET /admin/health/services — TCP probes for infra services
|
|
10
|
+
* GET /admin/apps — registry + per-app /health probe
|
|
11
|
+
* GET /admin/logs/tail — recent request logs
|
|
12
|
+
* POST /admin/logs/clear — clear the log buffer
|
|
13
|
+
* GET /admin/config — read-only redacted config
|
|
14
|
+
* PUT /admin/config — write overrides to nexus.runtime.json
|
|
15
|
+
* GET /admin/env — read .env entries (values masked)
|
|
16
|
+
* PUT /admin/env — update .env entries (API keys etc)
|
|
17
|
+
* GET /admin/users — lazy Mongo
|
|
18
|
+
* GET /admin/payments — lazy Mongo
|
|
19
|
+
* GET /ai/models — proxy to Python AI server
|
|
20
|
+
* POST /ai/chat — proxy to Python AI server
|
|
21
|
+
* GET /admin/ai/status — probe AI server + list providers
|
|
22
|
+
* GET /admin/ai/providers — list configured providers (keys masked)
|
|
23
|
+
* PUT /admin/ai/providers/:id — update a provider
|
|
24
|
+
* POST /admin/ai/providers — add a custom provider
|
|
25
|
+
* DELETE /admin/ai/providers/:id — remove a provider + .env key
|
|
26
|
+
* POST /admin/ai/providers/:id/test — probe provider connectivity
|
|
27
|
+
*
|
|
28
|
+
* All DB-backed routes are lazy: Mongo is only connected when a route is hit,
|
|
29
|
+
* so boot is never blocked and single-node apps without Mongo still work
|
|
30
|
+
* (endpoints return { ok: false, reason: '...' }).
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync } from 'node:fs';
|
|
33
|
+
import { readFile } from 'node:fs/promises';
|
|
34
|
+
import { createConnection } from 'node:net';
|
|
35
|
+
import { join, resolve } from 'node:path';
|
|
36
|
+
import type { NexusConfig } from '../config/types.js';
|
|
37
|
+
import type { AiProviderConfig } from '../config/types.js';
|
|
38
|
+
import type { Router } from '../http/Router.js';
|
|
39
|
+
import type { RequestContext } from '../http/context.js';
|
|
40
|
+
import { AiClient } from '@bhooai/nexus-ai-client';
|
|
41
|
+
import { readRuntimeJson, writeRuntimeJson, mergeRuntimeJson } from '../config/runtimeJson.js';
|
|
42
|
+
import { readEnvFile, writeEnvEntries, writeEnvKey, deleteEnvKey } from '../config/envFile.js';
|
|
43
|
+
|
|
44
|
+
export interface AdminModuleOptions {
|
|
45
|
+
name: string;
|
|
46
|
+
config: NexusConfig;
|
|
47
|
+
projectRoot: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LazyDb {
|
|
51
|
+
/** Resolve a connected DB, or null if Mongo is unreachable. */
|
|
52
|
+
db(): Promise<import('mongodb').Db | null>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A bounded in-memory request log entry. */
|
|
56
|
+
export interface RequestLogEntry {
|
|
57
|
+
ts: number;
|
|
58
|
+
method: string;
|
|
59
|
+
path: string;
|
|
60
|
+
status: number;
|
|
61
|
+
latencyMs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const MAX_LOG_ENTRIES = 1000;
|
|
65
|
+
|
|
66
|
+
/** In-memory ring buffer of recent requests (source for /admin/logs/tail). */
|
|
67
|
+
export class RequestLogBuffer {
|
|
68
|
+
private entries: RequestLogEntry[] = [];
|
|
69
|
+
|
|
70
|
+
push(entry: RequestLogEntry): void {
|
|
71
|
+
this.entries.push(entry);
|
|
72
|
+
if (this.entries.length > MAX_LOG_ENTRIES) {
|
|
73
|
+
this.entries.splice(0, this.entries.length - MAX_LOG_ENTRIES);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
tail(lines: number, level?: string): RequestLogEntry[] {
|
|
78
|
+
const n = Math.min(Math.max(lines, 1), MAX_LOG_ENTRIES);
|
|
79
|
+
const slice = this.entries.slice(-n);
|
|
80
|
+
if (!level) return slice;
|
|
81
|
+
return slice.filter((e) => levelOf(e.status) === level);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
clear(): void {
|
|
85
|
+
this.entries = [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get size(): number {
|
|
89
|
+
return this.entries.length;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function levelOf(status: number): string {
|
|
94
|
+
if (status >= 500) return 'error';
|
|
95
|
+
if (status >= 400) return 'warn';
|
|
96
|
+
return 'info';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Probe a TCP port with a short timeout. */
|
|
100
|
+
function tcpReachable(host: string, port: number, timeoutMs = 1200): Promise<boolean> {
|
|
101
|
+
return new Promise((res) => {
|
|
102
|
+
const socket = createConnection({ host, port, timeout: timeoutMs });
|
|
103
|
+
socket.once('connect', () => { socket.end(); res(true); });
|
|
104
|
+
socket.once('error', () => res(false));
|
|
105
|
+
socket.once('timeout', () => { socket.destroy(); res(false); });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Register all admin + AI proxy routes onto the router. */
|
|
110
|
+
export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, logBuffer: RequestLogBuffer, lazy: LazyDb): void {
|
|
111
|
+
const { config, projectRoot } = opts;
|
|
112
|
+
|
|
113
|
+
// ------------------------------------------------------------------
|
|
114
|
+
// /admin/health/services — TCP probes for infra services
|
|
115
|
+
// ------------------------------------------------------------------
|
|
116
|
+
router.get('/admin/health/services', async (ctx) => {
|
|
117
|
+
const mongoHost = hostOf(config.db.uri, 'localhost');
|
|
118
|
+
const mongoPort = portOf(config.db.uri, 27017);
|
|
119
|
+
const redisHost = hostOf(config.redis.url, 'localhost');
|
|
120
|
+
const redisPort = portOf(config.redis.url, 6379);
|
|
121
|
+
const aiPort = portOf(config.ai.serverUrl, 8000);
|
|
122
|
+
|
|
123
|
+
const [mongo, redis, ai] = await Promise.all([
|
|
124
|
+
tcpReachable(mongoHost, mongoPort),
|
|
125
|
+
tcpReachable(redisHost, redisPort),
|
|
126
|
+
tcpReachable('127.0.0.1', aiPort),
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
ctx.json({
|
|
130
|
+
services: [
|
|
131
|
+
{ id: 'mongo', label: 'MongoDB', ok: mongo, detail: `${mongoHost}:${mongoPort}` },
|
|
132
|
+
{ id: 'redis', label: 'Redis', ok: redis, detail: `${redisHost}:${redisPort}` },
|
|
133
|
+
{ id: 'ai', label: 'AI server', ok: ai, detail: `127.0.0.1:${aiPort}` },
|
|
134
|
+
{ id: 'storage', label: 'Storage', ok: true, detail: 'local disks configured' },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ------------------------------------------------------------------
|
|
140
|
+
// /admin/apps — registry + per-app /health probe
|
|
141
|
+
// ------------------------------------------------------------------
|
|
142
|
+
router.get('/admin/apps', async (ctx) => {
|
|
143
|
+
const registry = await readRegistry(projectRoot);
|
|
144
|
+
const apps = await Promise.all(
|
|
145
|
+
Object.entries(registry).map(async ([appName, port]) => {
|
|
146
|
+
const kind = kindOf(appName);
|
|
147
|
+
const probe = await probeHealth(port, kind);
|
|
148
|
+
return {
|
|
149
|
+
name: appName,
|
|
150
|
+
port,
|
|
151
|
+
healthy: probe.ok,
|
|
152
|
+
uptimeSec: probe.uptimeSec ?? null,
|
|
153
|
+
kind,
|
|
154
|
+
};
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
ctx.json({ apps });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ------------------------------------------------------------------
|
|
161
|
+
// /admin/logs/tail — recent request logs
|
|
162
|
+
// ------------------------------------------------------------------
|
|
163
|
+
router.get('/admin/logs/tail', (ctx) => {
|
|
164
|
+
const lines = parseInt(String(ctx.query.lines ?? '100'), 10) || 100;
|
|
165
|
+
const level = (ctx.query.level as string) || undefined;
|
|
166
|
+
ctx.json({ entries: logBuffer.tail(lines, level) });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
router.post('/admin/logs/clear', (ctx) => {
|
|
170
|
+
logBuffer.clear();
|
|
171
|
+
ctx.json({ ok: true });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ------------------------------------------------------------------
|
|
175
|
+
// /admin/config — read-only, redacted
|
|
176
|
+
// ------------------------------------------------------------------
|
|
177
|
+
router.get('/admin/config', (ctx) => {
|
|
178
|
+
ctx.json({ config: redactConfig(config) });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ------------------------------------------------------------------
|
|
182
|
+
// /admin/users + /admin/payments — lazy Mongo
|
|
183
|
+
// ------------------------------------------------------------------
|
|
184
|
+
router.get('/admin/users', async (ctx) => {
|
|
185
|
+
const db = await lazy.db();
|
|
186
|
+
if (!db) {
|
|
187
|
+
ctx.json({ ok: false, reason: 'MongoDB not reachable' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const users = await db.collection('users').find({}).sort({ createdAt: -1 }).limit(200).toArray();
|
|
192
|
+
ctx.json({
|
|
193
|
+
ok: true,
|
|
194
|
+
users: users.map((u) => ({
|
|
195
|
+
id: String(u._id),
|
|
196
|
+
email: u.email,
|
|
197
|
+
name: u.name,
|
|
198
|
+
roles: u.roles ?? ['user'],
|
|
199
|
+
createdAt: u.createdAt,
|
|
200
|
+
})),
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
router.get('/admin/payments', async (ctx) => {
|
|
208
|
+
const db = await lazy.db();
|
|
209
|
+
if (!db) {
|
|
210
|
+
ctx.json({ ok: false, reason: 'MongoDB not reachable' });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const orders = await db.collection('orders').find({}).sort({ createdAt: -1 }).limit(50).toArray();
|
|
215
|
+
ctx.json({
|
|
216
|
+
ok: true,
|
|
217
|
+
orders: orders.map((o) => ({
|
|
218
|
+
id: String(o._id),
|
|
219
|
+
userId: o.userId,
|
|
220
|
+
provider: o.provider,
|
|
221
|
+
amount: o.amount,
|
|
222
|
+
currency: o.currency,
|
|
223
|
+
status: o.status,
|
|
224
|
+
createdAt: o.createdAt,
|
|
225
|
+
})),
|
|
226
|
+
providers: providerStatus(config),
|
|
227
|
+
});
|
|
228
|
+
} catch (err) {
|
|
229
|
+
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ------------------------------------------------------------------
|
|
234
|
+
// /ai/* — proxy to the Python AI server via AiClient
|
|
235
|
+
// ------------------------------------------------------------------
|
|
236
|
+
const aiClient = new AiClient({
|
|
237
|
+
serverUrl: config.ai.serverUrl,
|
|
238
|
+
timeoutMs: config.ai.timeoutMs,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
router.get('/ai/models', async (ctx) => {
|
|
242
|
+
try {
|
|
243
|
+
const models = await aiClient.listModels();
|
|
244
|
+
ctx.json({ ok: true, models });
|
|
245
|
+
} catch (err) {
|
|
246
|
+
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
router.post('/ai/chat', async (ctx) => {
|
|
251
|
+
const body = ctx.body as { model?: string; messages?: unknown[]; temperature?: number; max_tokens?: number; provider?: string };
|
|
252
|
+
if (!body?.messages) {
|
|
253
|
+
ctx.json({ ok: false, reason: 'messages is required' }, 400);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const providerId = body.provider ?? config.ai.defaultProvider;
|
|
258
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
259
|
+
const defaultModel = provider?.defaultModel ?? config.ai.schemaModel;
|
|
260
|
+
const res = await aiClient.chat({
|
|
261
|
+
model: body.model ?? defaultModel,
|
|
262
|
+
messages: body.messages as never,
|
|
263
|
+
temperature: body.temperature,
|
|
264
|
+
max_tokens: body.max_tokens,
|
|
265
|
+
provider: providerId,
|
|
266
|
+
});
|
|
267
|
+
ctx.json({ ok: true, ...res });
|
|
268
|
+
} catch (err) {
|
|
269
|
+
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ------------------------------------------------------------------
|
|
274
|
+
// AI provider management
|
|
275
|
+
// ------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
// Mutable providers array — shared across routes, updated in-place.
|
|
278
|
+
const providers: AiProviderConfig[] = config.ai.providers ? [...config.ai.providers] : [];
|
|
279
|
+
|
|
280
|
+
// On boot, load any persisted API keys from .env into the in-memory providers.
|
|
281
|
+
for (const p of providers) {
|
|
282
|
+
const envKey = providerEnvKey(p.id);
|
|
283
|
+
const envVal = process.env[envKey];
|
|
284
|
+
if (envVal) p.apiKey = envVal;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Persist providers (sans API keys) to nexus.runtime.json. */
|
|
288
|
+
const persistProviders = async (): Promise<void> => {
|
|
289
|
+
try {
|
|
290
|
+
const existing = await readRuntimeJson(projectRoot);
|
|
291
|
+
const ai = (existing.ai ?? {}) as Record<string, unknown>;
|
|
292
|
+
ai.providers = providers.map((p) => ({
|
|
293
|
+
id: p.id, label: p.label, baseUrl: p.baseUrl,
|
|
294
|
+
enabled: p.enabled,
|
|
295
|
+
...(p.defaultModel ? { defaultModel: p.defaultModel } : {}),
|
|
296
|
+
}));
|
|
297
|
+
existing.ai = ai;
|
|
298
|
+
await writeRuntimeJson(projectRoot, existing);
|
|
299
|
+
} catch { /* non-fatal */ }
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** GET /admin/ai/providers — list all configured providers (keys masked). */
|
|
303
|
+
router.get('/admin/ai/providers', async (ctx) => {
|
|
304
|
+
ctx.json({
|
|
305
|
+
providers: providers.map((p) => ({
|
|
306
|
+
...p,
|
|
307
|
+
apiKey: p.apiKey ? '••••••••' : '',
|
|
308
|
+
hasApiKey: !!p.apiKey,
|
|
309
|
+
})),
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
/** GET /admin/ai/status — probe the Python AI server + per-provider health. */
|
|
314
|
+
router.get('/admin/ai/status', async (ctx) => {
|
|
315
|
+
const probe = async <T>(fn: () => Promise<T>): Promise<{ ok: boolean; error?: string; detail?: T }> => {
|
|
316
|
+
try {
|
|
317
|
+
return { ok: true, detail: await fn() };
|
|
318
|
+
} catch (e) {
|
|
319
|
+
return { ok: false, error: (e as Error).message || 'unknown error' };
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Probe the Python AI server
|
|
324
|
+
const aiServer = await probe(async () => {
|
|
325
|
+
const ctrl = new AbortController();
|
|
326
|
+
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
|
327
|
+
try {
|
|
328
|
+
const res = await fetch(`${config.ai.serverUrl.replace(/\/+$/, '')}/health`, { signal: ctrl.signal });
|
|
329
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
330
|
+
return (await res.json()) as { status?: string; providers?: string[] };
|
|
331
|
+
} finally { clearTimeout(timer); }
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
ctx.json({
|
|
335
|
+
checkedAt: new Date().toISOString(),
|
|
336
|
+
aiServer,
|
|
337
|
+
defaultProvider: config.ai.defaultProvider,
|
|
338
|
+
schemaModel: config.ai.schemaModel,
|
|
339
|
+
serverUrl: config.ai.serverUrl,
|
|
340
|
+
timeoutMs: config.ai.timeoutMs,
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
/** PUT /admin/ai/providers/:id — update a provider. */
|
|
345
|
+
router.put('/admin/ai/providers/:id', async (ctx) => {
|
|
346
|
+
const id = ctx.params.id ?? '';
|
|
347
|
+
const body = (ctx.body ?? {}) as Record<string, unknown>;
|
|
348
|
+
const provider = providers.find((p) => p.id === id);
|
|
349
|
+
if (!provider) { ctx.json({ error: `provider "${id}" not found` }, 404); return; }
|
|
350
|
+
if (typeof body.enabled === 'boolean') provider.enabled = body.enabled;
|
|
351
|
+
if (typeof body.defaultModel === 'string' && body.defaultModel.trim()) provider.defaultModel = body.defaultModel.trim();
|
|
352
|
+
if (typeof body.label === 'string' && body.label.trim()) provider.label = body.label.trim();
|
|
353
|
+
if (typeof body.baseUrl === 'string' && body.baseUrl.trim()) provider.baseUrl = body.baseUrl.trim();
|
|
354
|
+
const newKey = typeof body.apiKey === 'string' ? body.apiKey : undefined;
|
|
355
|
+
if (newKey && newKey !== '••••••••') {
|
|
356
|
+
provider.apiKey = newKey;
|
|
357
|
+
try {
|
|
358
|
+
await writeEnvKey(projectRoot, providerEnvKey(id), newKey);
|
|
359
|
+
process.env[providerEnvKey(id)] = newKey;
|
|
360
|
+
} catch { /* non-fatal — in-memory key still works */ }
|
|
361
|
+
}
|
|
362
|
+
await persistProviders();
|
|
363
|
+
ctx.json({
|
|
364
|
+
ok: true,
|
|
365
|
+
provider: { ...provider, apiKey: provider.apiKey ? '••••••••' : '', hasApiKey: !!provider.apiKey },
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
/** POST /admin/ai/providers — add a custom provider. */
|
|
370
|
+
router.post('/admin/ai/providers', async (ctx) => {
|
|
371
|
+
const body = (ctx.body ?? {}) as Record<string, unknown>;
|
|
372
|
+
const id = typeof body.id === 'string' ? body.id.trim().toLowerCase() : '';
|
|
373
|
+
const label = typeof body.label === 'string' ? body.label.trim() : '';
|
|
374
|
+
const baseUrl = typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '';
|
|
375
|
+
if (!id || !label || !baseUrl) { ctx.json({ error: 'id, label and baseUrl are required' }, 400); return; }
|
|
376
|
+
if (providers.find((p) => p.id === id)) { ctx.json({ error: `provider "${id}" already exists` }, 409); return; }
|
|
377
|
+
const apiKey = typeof body.apiKey === 'string' && body.apiKey ? body.apiKey : undefined;
|
|
378
|
+
const provider: AiProviderConfig = {
|
|
379
|
+
id, label, baseUrl,
|
|
380
|
+
enabled: typeof body.enabled === 'boolean' ? body.enabled : true,
|
|
381
|
+
apiKey,
|
|
382
|
+
defaultModel: typeof body.defaultModel === 'string' && body.defaultModel.trim() ? body.defaultModel.trim() : undefined,
|
|
383
|
+
};
|
|
384
|
+
providers.push(provider);
|
|
385
|
+
if (apiKey) {
|
|
386
|
+
try {
|
|
387
|
+
await writeEnvKey(projectRoot, providerEnvKey(id), apiKey);
|
|
388
|
+
process.env[providerEnvKey(id)] = apiKey;
|
|
389
|
+
} catch { /* non-fatal */ }
|
|
390
|
+
}
|
|
391
|
+
await persistProviders();
|
|
392
|
+
ctx.json({
|
|
393
|
+
ok: true,
|
|
394
|
+
provider: { ...provider, apiKey: provider.apiKey ? '••••••••' : '', hasApiKey: !!provider.apiKey },
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
/** DELETE /admin/ai/providers/:id — remove a provider + its .env key. */
|
|
399
|
+
router.delete('/admin/ai/providers/:id', async (ctx) => {
|
|
400
|
+
const id = ctx.params.id ?? '';
|
|
401
|
+
const idx = providers.findIndex((p) => p.id === id);
|
|
402
|
+
if (idx === -1) { ctx.json({ error: `provider "${id}" not found` }, 404); return; }
|
|
403
|
+
providers.splice(idx, 1);
|
|
404
|
+
try { await deleteEnvKey(projectRoot, providerEnvKey(id)); } catch { /* non-fatal */ }
|
|
405
|
+
await persistProviders();
|
|
406
|
+
ctx.json({ ok: true });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
/** POST /admin/ai/providers/:id/test — probe a single provider's connectivity. */
|
|
410
|
+
router.post('/admin/ai/providers/:id/test', async (ctx) => {
|
|
411
|
+
const id = ctx.params.id ?? '';
|
|
412
|
+
const provider = providers.find((p) => p.id === id);
|
|
413
|
+
if (!provider) { ctx.json({ error: `provider "${id}" not found` }, 404); return; }
|
|
414
|
+
try {
|
|
415
|
+
const client = new AiClient({ serverUrl: config.ai.serverUrl, timeoutMs: 8_000 });
|
|
416
|
+
const res = await client.listModels(id);
|
|
417
|
+
const models = (res.data ?? []).map((m) => String(m.id ?? m)).slice(0, 20);
|
|
418
|
+
ctx.json({
|
|
419
|
+
ok: true,
|
|
420
|
+
provider: id,
|
|
421
|
+
modelCount: (res.data ?? []).length,
|
|
422
|
+
models,
|
|
423
|
+
checkedAt: new Date().toISOString(),
|
|
424
|
+
});
|
|
425
|
+
} catch (e) {
|
|
426
|
+
ctx.json({
|
|
427
|
+
ok: false,
|
|
428
|
+
provider: id,
|
|
429
|
+
error: (e as Error).message || 'unknown error',
|
|
430
|
+
checkedAt: new Date().toISOString(),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// ------------------------------------------------------------------
|
|
436
|
+
// Config write-back + .env management
|
|
437
|
+
// ------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
/** PUT /admin/config — write DeepPartial overrides to nexus.runtime.json. */
|
|
440
|
+
router.put('/admin/config', async (ctx) => {
|
|
441
|
+
const patch = (ctx.body ?? {}) as Record<string, unknown>;
|
|
442
|
+
if (!patch || typeof patch !== 'object') {
|
|
443
|
+
ctx.json({ error: 'request body must be a JSON object' }, 400);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
await mergeRuntimeJson(projectRoot, patch);
|
|
448
|
+
ctx.json({ ok: true });
|
|
449
|
+
} catch (err) {
|
|
450
|
+
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
/** GET /admin/env — read .env entries (values masked). */
|
|
455
|
+
router.get('/admin/env', async (ctx) => {
|
|
456
|
+
try {
|
|
457
|
+
const entries = await readEnvFile(projectRoot, true);
|
|
458
|
+
ctx.json({ ok: true, entries });
|
|
459
|
+
} catch (err) {
|
|
460
|
+
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
/** PUT /admin/env — update .env entries (API keys etc). */
|
|
465
|
+
router.put('/admin/env', async (ctx) => {
|
|
466
|
+
const body = (ctx.body ?? {}) as Record<string, unknown>;
|
|
467
|
+
const entries = body.entries;
|
|
468
|
+
if (!Array.isArray(entries)) {
|
|
469
|
+
ctx.json({ error: 'entries (array of {key, value}) is required' }, 400);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
// Filter out masked values — don't write •••••••• back to .env
|
|
474
|
+
const toWrite = (entries as Array<Record<string, unknown>>)
|
|
475
|
+
.filter((e) => typeof e.key === 'string' && typeof e.value === 'string' && e.value !== '••••••••')
|
|
476
|
+
.map((e) => ({ key: e.key as string, value: e.value as string }));
|
|
477
|
+
if (toWrite.length > 0) {
|
|
478
|
+
await writeEnvEntries(projectRoot, toWrite);
|
|
479
|
+
// Also update process.env so changes are live
|
|
480
|
+
for (const { key, value } of toWrite) {
|
|
481
|
+
process.env[key] = value;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
ctx.json({ ok: true, updated: toWrite.length });
|
|
485
|
+
} catch (err) {
|
|
486
|
+
ctx.json({ ok: false, reason: String((err as Error).message) }, 500);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Helpers
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
function kindOf(appName: string): string {
|
|
496
|
+
if (appName.startsWith('backend')) return 'backend';
|
|
497
|
+
if (appName.startsWith('frontend')) return 'frontend';
|
|
498
|
+
if (appName.startsWith('admin')) return 'admin';
|
|
499
|
+
return 'other';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Probe a registered app's liveness.
|
|
504
|
+
*
|
|
505
|
+
* Backend / ai-server / other apps expose a JSON /health with `{ ok: true }`.
|
|
506
|
+
* Frontend and admin are Vite SPAs — they return HTML for any path (history
|
|
507
|
+
* fallback), so a bare 200 on `/` means the dev server is up.
|
|
508
|
+
*/
|
|
509
|
+
async function probeHealth(port: number, kind: string): Promise<{ ok: boolean; uptimeSec?: number }> {
|
|
510
|
+
try {
|
|
511
|
+
const controller = new AbortController();
|
|
512
|
+
const timer = setTimeout(() => controller.abort(), 1500);
|
|
513
|
+
const isSpa = kind === 'frontend' || kind === 'admin';
|
|
514
|
+
const path = isSpa ? '/' : '/health';
|
|
515
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: controller.signal });
|
|
516
|
+
clearTimeout(timer);
|
|
517
|
+
if (isSpa) return { ok: res.ok };
|
|
518
|
+
const data = (await res.json()) as { ok?: boolean };
|
|
519
|
+
return { ok: !!data.ok };
|
|
520
|
+
} catch {
|
|
521
|
+
return { ok: false };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function readRegistry(projectRoot: string): Promise<Record<string, number>> {
|
|
526
|
+
const p = resolve(projectRoot, '.nexus-ports.json');
|
|
527
|
+
if (!existsSync(p)) return {};
|
|
528
|
+
try {
|
|
529
|
+
return JSON.parse(await readFile(p, 'utf-8')) as Record<string, number>;
|
|
530
|
+
} catch {
|
|
531
|
+
return {};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function hostOf(uri: string, fallback: string): string {
|
|
536
|
+
try {
|
|
537
|
+
const u = new URL(uri);
|
|
538
|
+
return u.hostname || fallback;
|
|
539
|
+
} catch {
|
|
540
|
+
return fallback;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function portOf(uri: string, fallback: number): number {
|
|
545
|
+
try {
|
|
546
|
+
const u = new URL(uri);
|
|
547
|
+
return u.port ? parseInt(u.port, 10) : fallback;
|
|
548
|
+
} catch {
|
|
549
|
+
return fallback;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Deep-clone config, masking secret-ish fields. */
|
|
554
|
+
function redactConfig(config: NexusConfig): Record<string, unknown> {
|
|
555
|
+
const clone = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
|
|
556
|
+
redact(clone, /(secret|token|clientsecret|apikey|keyid|password|pass|refresh)/i);
|
|
557
|
+
return clone;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function redact(obj: unknown, pattern: RegExp): void {
|
|
561
|
+
if (!obj || typeof obj !== 'object') return;
|
|
562
|
+
if (Array.isArray(obj)) {
|
|
563
|
+
for (const item of obj) redact(item, pattern);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
567
|
+
if (typeof v === 'string' && pattern.test(k)) {
|
|
568
|
+
(obj as Record<string, unknown>)[k] = '••••••••';
|
|
569
|
+
} else if (v && typeof v === 'object') {
|
|
570
|
+
redact(v, pattern);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function providerStatus(config: NexusConfig): Array<{ id: string; enabled: boolean; sandbox: boolean }> {
|
|
576
|
+
const p = config.payments as unknown as Record<string, unknown>;
|
|
577
|
+
const out: Array<{ id: string; enabled: boolean; sandbox: boolean }> = [];
|
|
578
|
+
for (const [id, val] of Object.entries(p)) {
|
|
579
|
+
if (typeof val === 'object' && val !== null) {
|
|
580
|
+
const v = val as { enabled?: boolean; sandbox?: boolean };
|
|
581
|
+
if (typeof v.enabled === 'boolean') {
|
|
582
|
+
out.push({ id, enabled: v.enabled, sandbox: !!v.sandbox });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Env var name for a provider's API key (e.g. "together" → "NEXUS_AI_TOGETHER_API_KEY"). */
|
|
590
|
+
function providerEnvKey(providerId: string): string {
|
|
591
|
+
return `NEXUS_AI_${providerId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Convenience for wiring: create a lazy Mongo db resolver from a config. */
|
|
595
|
+
export function createLazyDb(config: NexusConfig): LazyDb {
|
|
596
|
+
let cached: import('mongodb').Db | null | undefined;
|
|
597
|
+
return {
|
|
598
|
+
async db() {
|
|
599
|
+
if (cached !== undefined) return cached;
|
|
600
|
+
try {
|
|
601
|
+
const { connect } = await import('@bhooai/nexus-data');
|
|
602
|
+
const connection = connect(config.db.uri, { maxPoolSize: 2 });
|
|
603
|
+
cached = await connection.db;
|
|
604
|
+
return cached;
|
|
605
|
+
} catch (err) {
|
|
606
|
+
cached = null;
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export type { RequestContext };
|
|
@@ -19,6 +19,7 @@ import { loadConfigAuto, type NexusConfig } from '../config/index.js';
|
|
|
19
19
|
import { Container } from '../di/Container.js';
|
|
20
20
|
import { Router } from '../http/Router.js';
|
|
21
21
|
import { NexusServer } from '../http/Server.js';
|
|
22
|
+
import { bodyParser } from '../http/bodyParser.js';
|
|
22
23
|
import type { Handler, Middleware } from '../http/context.js';
|
|
23
24
|
import { discoverBackend, importDefault, type DiscoveryResult } from './discover.js';
|
|
24
25
|
import type { RoutesFile, RouteDef } from './defineRoutes.js';
|
|
@@ -30,6 +31,7 @@ import { configureMailDriver, configureMailRenderer, logMailDriver } from './Mai
|
|
|
30
31
|
import { policyRegistry } from './policies.js';
|
|
31
32
|
import { configureStorageFromEnv } from './Storage.js';
|
|
32
33
|
import { maintenanceMiddleware } from './maintenance.js';
|
|
34
|
+
import { createLazyDb, registerAdminRoutes, RequestLogBuffer } from './adminModule.js';
|
|
33
35
|
|
|
34
36
|
export interface CreateNexusAppOptions {
|
|
35
37
|
/** Identifier for this backend, used in admin, telemetry, logs. */
|
|
@@ -228,6 +230,16 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
228
230
|
ctx.json({ apps });
|
|
229
231
|
});
|
|
230
232
|
|
|
233
|
+
// ------------------------------------------------------------------
|
|
234
|
+
// Admin module — request log buffer, lazy DB, admin + AI proxy routes
|
|
235
|
+
// ------------------------------------------------------------------
|
|
236
|
+
const logBuffer = new RequestLogBuffer();
|
|
237
|
+
const lazyDb = createLazyDb(config);
|
|
238
|
+
|
|
239
|
+
if (config.admin.enabled) {
|
|
240
|
+
registerAdminRoutes(router, { name, config, projectRoot }, logBuffer, lazyDb);
|
|
241
|
+
}
|
|
242
|
+
|
|
231
243
|
// ------------------------------------------------------------------
|
|
232
244
|
// HTTP server with error handler + maintenance middleware wired in
|
|
233
245
|
// ------------------------------------------------------------------
|
|
@@ -241,9 +253,33 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
241
253
|
onError: (err, ctx) => errorHandler.toApiError(err),
|
|
242
254
|
});
|
|
243
255
|
|
|
256
|
+
// Body parsing (JSON / urlencoded / multipart) — before everything else so
|
|
257
|
+
// POST/PUT/PATCH handlers see ctx.body.
|
|
258
|
+
server.use(bodyParser(config.server.bodyLimit));
|
|
259
|
+
|
|
244
260
|
// Maintenance mode (checked first)
|
|
245
261
|
server.use(maintenanceMiddleware({ projectRoot }));
|
|
246
262
|
|
|
263
|
+
// Request logging into the ring buffer (source for /admin/logs/tail).
|
|
264
|
+
server.use(async (ctx, next) => {
|
|
265
|
+
const start = performance.now();
|
|
266
|
+
const res = ctx.res;
|
|
267
|
+
const originalEnd = res.end.bind(res);
|
|
268
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
269
|
+
(res as any).end = (...args: any[]) => {
|
|
270
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
271
|
+
logBuffer.push({
|
|
272
|
+
ts: Date.now(),
|
|
273
|
+
method: ctx.method,
|
|
274
|
+
path: ctx.path,
|
|
275
|
+
status: res.statusCode,
|
|
276
|
+
latencyMs,
|
|
277
|
+
});
|
|
278
|
+
return originalEnd(...args);
|
|
279
|
+
};
|
|
280
|
+
await next();
|
|
281
|
+
});
|
|
282
|
+
|
|
247
283
|
// User-supplied global middleware
|
|
248
284
|
for (const mw of opts.middleware ?? []) server.use(mw);
|
|
249
285
|
|
package/src/app/index.ts
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -85,10 +85,10 @@ export const defaults: NexusConfig = {
|
|
|
85
85
|
ai: {
|
|
86
86
|
serverUrl: 'http://localhost:8000',
|
|
87
87
|
timeoutMs: 60_000,
|
|
88
|
-
defaultProvider: '
|
|
89
|
-
schemaModel: '
|
|
88
|
+
defaultProvider: 'ollama',
|
|
89
|
+
schemaModel: 'llama3.1:8b',
|
|
90
90
|
providers: [
|
|
91
|
-
{ id: 'ollama', label: 'Ollama (local)', baseUrl: 'http://localhost:11434', enabled: true, defaultModel: 'llama3:
|
|
91
|
+
{ id: 'ollama', label: 'Ollama (local)', baseUrl: 'http://localhost:11434', enabled: true, defaultModel: 'llama3.1:8b' },
|
|
92
92
|
{ id: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', enabled: false, defaultModel: 'gpt-4o-mini' },
|
|
93
93
|
{ id: 'anthropic', label: 'Anthropic Claude', baseUrl: 'https://api.anthropic.com/v1', enabled: false, defaultModel: 'claude-sonnet-4-20250514' },
|
|
94
94
|
{ id: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1', enabled: false, defaultModel: 'gemini-2.0-flash' },
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read / write helpers for `.env` files.
|
|
3
|
+
*
|
|
4
|
+
* Used by the admin API to let users manage AI provider API keys and other
|
|
5
|
+
* secrets without touching the source config file.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export interface EnvEntry {
|
|
12
|
+
key: string;
|
|
13
|
+
/** The raw value. When returned from `readEnvFile` in "masked" mode the
|
|
14
|
+
* value is replaced with `••••••••`. */
|
|
15
|
+
value: string;
|
|
16
|
+
/** Whether the value was masked (never true on write). */
|
|
17
|
+
masked?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read all key=value pairs from the project's `.env` file.
|
|
22
|
+
*
|
|
23
|
+
* When `masked` is true, values that look like secrets (contain >4 chars and
|
|
24
|
+
* match common patterns) are replaced with `••••••••`.
|
|
25
|
+
*/
|
|
26
|
+
export async function readEnvFile(projectRoot: string, masked = true): Promise<EnvEntry[]> {
|
|
27
|
+
const p = resolve(projectRoot, '.env');
|
|
28
|
+
if (!existsSync(p)) return [];
|
|
29
|
+
const content = await readFile(p, 'utf8');
|
|
30
|
+
return content.split(/\r?\n/).reduce<EnvEntry[]>((acc, line) => {
|
|
31
|
+
const trimmed = line.trim();
|
|
32
|
+
if (!trimmed || trimmed.startsWith('#')) return acc;
|
|
33
|
+
// Strip optional `export ` prefix
|
|
34
|
+
const clean = trimmed.replace(/^export\s+/, '');
|
|
35
|
+
const eq = clean.indexOf('=');
|
|
36
|
+
if (eq === -1) return acc;
|
|
37
|
+
const key = clean.slice(0, eq).trim();
|
|
38
|
+
let value = clean.slice(eq + 1).trim();
|
|
39
|
+
// Strip surrounding quotes
|
|
40
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
41
|
+
value = value.slice(1, -1);
|
|
42
|
+
}
|
|
43
|
+
const isSecret = masked && looksLikeSecret(key) && value.length > 0;
|
|
44
|
+
acc.push({ key, value: isSecret ? '••••••••' : value, masked: isSecret });
|
|
45
|
+
return acc;
|
|
46
|
+
}, []);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Write (add or update) entries in the `.env` file. */
|
|
50
|
+
export async function writeEnvEntries(projectRoot: string, updates: EnvEntry[]): Promise<void> {
|
|
51
|
+
const p = resolve(projectRoot, '.env');
|
|
52
|
+
const lines = existsSync(p) ? (await readFile(p, 'utf8')).split(/\r?\n/) : [];
|
|
53
|
+
|
|
54
|
+
for (const { key, value } of updates) {
|
|
55
|
+
const regex = new RegExp(`^(\\s*export\\s+)?${escapeRegex(key)}\\s*=`);
|
|
56
|
+
let found = false;
|
|
57
|
+
for (let i = 0; i < lines.length; i++) {
|
|
58
|
+
if (regex.test(lines[i]!)) {
|
|
59
|
+
lines[i] = `${key}=${value}`;
|
|
60
|
+
found = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!found) {
|
|
65
|
+
// Append before trailing empty line, or at end
|
|
66
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
67
|
+
lines.splice(lines.length - 1, 0, `${key}=${value}`);
|
|
68
|
+
} else {
|
|
69
|
+
lines.push(`${key}=${value}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
75
|
+
await writeFile(tmp, `${lines.join('\n')}\n`, 'utf8');
|
|
76
|
+
try {
|
|
77
|
+
await rename(tmp, p);
|
|
78
|
+
} catch {
|
|
79
|
+
// Fallback: write directly
|
|
80
|
+
await writeFile(p, `${lines.join('\n')}\n`, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Delete a key from the `.env` file. */
|
|
85
|
+
export async function deleteEnvKey(projectRoot: string, key: string): Promise<void> {
|
|
86
|
+
const p = resolve(projectRoot, '.env');
|
|
87
|
+
if (!existsSync(p)) return;
|
|
88
|
+
const content = await readFile(p, 'utf8');
|
|
89
|
+
const regex = new RegExp(`^(\\s*export\\s+)?${escapeRegex(key)}\\s*=`);
|
|
90
|
+
const output = content.split(/\r?\n/).filter((line) => !regex.test(line));
|
|
91
|
+
if (output.length === content.split(/\r?\n/).length) return; // nothing removed
|
|
92
|
+
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
93
|
+
await writeFile(tmp, `${output.join('\n')}\n`, 'utf8');
|
|
94
|
+
try {
|
|
95
|
+
await rename(tmp, p);
|
|
96
|
+
} catch {
|
|
97
|
+
await writeFile(p, `${output.join('\n')}\n`, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Write a single env key=value (convenience wrapper). */
|
|
102
|
+
export async function writeEnvKey(projectRoot: string, key: string, value: string): Promise<void> {
|
|
103
|
+
return writeEnvEntries(projectRoot, [{ key, value }]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function escapeRegex(s: string): string {
|
|
107
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function looksLikeSecret(key: string): boolean {
|
|
111
|
+
return /(secret|token|apikey|key|password|credential)/i.test(key);
|
|
112
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read / write helpers for `nexus.runtime.json`.
|
|
3
|
+
*
|
|
4
|
+
* The runtime file stores DeepPartial config overrides that survive restarts
|
|
5
|
+
* without touching the human-authored `nexus.config.ts`. API keys are NEVER
|
|
6
|
+
* written here — they live in `.env`.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const FILENAME = 'nexus.runtime.json';
|
|
13
|
+
|
|
14
|
+
export function runtimePath(projectRoot: string): string {
|
|
15
|
+
return join(projectRoot, FILENAME);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readRuntimeJson(projectRoot: string): Promise<Record<string, unknown>> {
|
|
19
|
+
const p = runtimePath(projectRoot);
|
|
20
|
+
if (!existsSync(p)) return {};
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(await readFile(p, 'utf8')) as Record<string, unknown>;
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function writeRuntimeJson(projectRoot: string, data: Record<string, unknown>): Promise<void> {
|
|
29
|
+
const p = runtimePath(projectRoot);
|
|
30
|
+
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
31
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
32
|
+
try {
|
|
33
|
+
await rename(tmp, p);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
36
|
+
if (code !== 'EEXIST' && code !== 'EPERM') throw err;
|
|
37
|
+
await unlink(p).catch(() => undefined);
|
|
38
|
+
await rename(tmp, p);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Merge a partial update into the runtime JSON (deep merge on `ai.providers`,
|
|
44
|
+
* shallow merge everywhere else).
|
|
45
|
+
*/
|
|
46
|
+
export async function mergeRuntimeJson(projectRoot: string, patch: Record<string, unknown>): Promise<void> {
|
|
47
|
+
const existing = await readRuntimeJson(projectRoot);
|
|
48
|
+
const merged = { ...existing, ...patch };
|
|
49
|
+
// If both have ai.providers, prefer the patch's value (caller controls the
|
|
50
|
+
// full array). If patch only has other ai fields, spread them.
|
|
51
|
+
if (patch.ai && typeof patch.ai === 'object' && existing.ai && typeof existing.ai === 'object') {
|
|
52
|
+
merged.ai = { ...(existing as { ai: Record<string, unknown> }).ai, ...(patch.ai as Record<string, unknown>) };
|
|
53
|
+
}
|
|
54
|
+
await writeRuntimeJson(projectRoot, merged);
|
|
55
|
+
}
|