@bhooai/nexus-core 2.0.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bhooai/nexus-core",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -3,7 +3,27 @@
3
3
  *
4
4
  * Auto-mounted by `createNexusApp()` when `config.admin.enabled`. Serves the
5
5
  * admin SPA's tabs: registry, health/services, per-app cards, request logs,
6
- * read-only config, users, payments, and the AI proxy.
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
7
27
  *
8
28
  * All DB-backed routes are lazy: Mongo is only connected when a route is hit,
9
29
  * so boot is never blocked and single-node apps without Mongo still work
@@ -14,9 +34,12 @@ import { readFile } from 'node:fs/promises';
14
34
  import { createConnection } from 'node:net';
15
35
  import { join, resolve } from 'node:path';
16
36
  import type { NexusConfig } from '../config/types.js';
37
+ import type { AiProviderConfig } from '../config/types.js';
17
38
  import type { Router } from '../http/Router.js';
18
39
  import type { RequestContext } from '../http/context.js';
19
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';
20
43
 
21
44
  export interface AdminModuleOptions {
22
45
  name: string;
@@ -225,24 +248,244 @@ export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, lo
225
248
  });
226
249
 
227
250
  router.post('/ai/chat', async (ctx) => {
228
- const body = ctx.body as { model?: string; messages?: unknown[]; temperature?: number; max_tokens?: number; provider?: 'openai' | 'ollama' | 'auto' };
251
+ const body = ctx.body as { model?: string; messages?: unknown[]; temperature?: number; max_tokens?: number; provider?: string };
229
252
  if (!body?.messages) {
230
253
  ctx.json({ ok: false, reason: 'messages is required' }, 400);
231
254
  return;
232
255
  }
233
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;
234
260
  const res = await aiClient.chat({
235
- model: body.model ?? config.ai.schemaModel,
261
+ model: body.model ?? defaultModel,
236
262
  messages: body.messages as never,
237
263
  temperature: body.temperature,
238
264
  max_tokens: body.max_tokens,
239
- provider: body.provider,
265
+ provider: providerId,
240
266
  });
241
267
  ctx.json({ ok: true, ...res });
242
268
  } catch (err) {
243
269
  ctx.json({ ok: false, reason: String((err as Error).message) });
244
270
  }
245
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
+ });
246
489
  }
247
490
 
248
491
  // ---------------------------------------------------------------------------
@@ -343,6 +586,11 @@ function providerStatus(config: NexusConfig): Array<{ id: string; enabled: boole
343
586
  return out;
344
587
  }
345
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
+
346
594
  /** Convenience for wiring: create a lazy Mongo db resolver from a config. */
347
595
  export function createLazyDb(config: NexusConfig): LazyDb {
348
596
  let cached: import('mongodb').Db | null | undefined;
@@ -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';
@@ -252,6 +253,10 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
252
253
  onError: (err, ctx) => errorHandler.toApiError(err),
253
254
  });
254
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
+
255
260
  // Maintenance mode (checked first)
256
261
  server.use(maintenanceMiddleware({ projectRoot }));
257
262
 
@@ -85,10 +85,10 @@ export const defaults: NexusConfig = {
85
85
  ai: {
86
86
  serverUrl: 'http://localhost:8000',
87
87
  timeoutMs: 60_000,
88
- defaultProvider: 'auto',
89
- schemaModel: 'gpt-4o-mini',
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:latest' },
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
+ }