@bhooai/nexus-cli 0.1.5 → 2.0.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 (112) hide show
  1. package/package.json +8 -12
  2. package/src/commands/add.ts +274 -0
  3. package/src/commands/build.ts +91 -0
  4. package/src/commands/db.ts +241 -0
  5. package/src/commands/dev.ts +149 -186
  6. package/src/commands/doctor.ts +102 -184
  7. package/src/commands/init.ts +204 -958
  8. package/src/commands/maintenance-commands.ts +40 -0
  9. package/src/commands/plugin.ts +233 -0
  10. package/src/commands/queue.ts +145 -0
  11. package/src/commands/test.ts +34 -0
  12. package/src/dispatcher.ts +98 -0
  13. package/src/generators.ts +102 -0
  14. package/src/index.ts +32 -90
  15. package/src/make/generators.ts +431 -0
  16. package/src/ports.ts +85 -0
  17. package/src/prompts.ts +68 -0
  18. package/src/templating/render.ts +109 -0
  19. package/src/util.ts +74 -112
  20. package/templates/base/Dockerfile.ejs +68 -0
  21. package/templates/base/README.md.ejs +62 -0
  22. package/templates/base/apps/admin/index.html.ejs +12 -0
  23. package/templates/base/apps/admin/nginx.conf.ejs +16 -0
  24. package/templates/base/apps/admin/package.json.ejs +15 -0
  25. package/templates/base/apps/admin/src/main.tsx.ejs +7 -0
  26. package/templates/base/apps/admin/vite.config.ts.ejs +22 -0
  27. package/templates/base/apps/ai-server/main.py.ejs +30 -0
  28. package/templates/base/apps/ai-server/requirements.txt +4 -0
  29. package/templates/base/apps/backend/src/main.ts +14 -0
  30. package/templates/base/apps/backend/src/routes/index.ts.ejs +11 -0
  31. package/templates/base/apps/frontend/index.html.ejs +12 -0
  32. package/templates/base/apps/frontend/nginx.conf.ejs +33 -0
  33. package/templates/base/apps/frontend/package.json.ejs +11 -0
  34. package/templates/base/apps/frontend/src/main.tsx.ejs +16 -0
  35. package/templates/base/apps/frontend/vite.config.ts.ejs +22 -0
  36. package/templates/base/docker-compose.yml.ejs +93 -0
  37. package/templates/base/dockerignore +21 -0
  38. package/templates/base/gitignore +43 -0
  39. package/templates/base/nexus.config.ts.ejs +97 -0
  40. package/templates/base/package.json.ejs +40 -0
  41. package/templates/{tsconfig.json → base/tsconfig.json} +2 -3
  42. package/tsconfig.json +5 -4
  43. package/PLAN.md +0 -141
  44. package/README.md +0 -34
  45. package/src/commands/cluster.ts +0 -133
  46. package/src/commands/node.ts +0 -106
  47. package/src/commands/pysetup.ts +0 -142
  48. package/src/commands/sync.ts +0 -116
  49. package/src/commands/uninstall.ts +0 -287
  50. package/src/config-sync.ts +0 -384
  51. package/src/dotenv.ts +0 -39
  52. package/src/supervisor.ts +0 -394
  53. package/src/wizard.ts +0 -149
  54. package/templates/Dockerfile +0 -60
  55. package/templates/README.md +0 -69
  56. package/templates/apps/admin/index.html +0 -12
  57. package/templates/apps/admin/package.json +0 -22
  58. package/templates/apps/admin/postcss.config.js +0 -3
  59. package/templates/apps/admin/src/main.tsx +0 -10
  60. package/templates/apps/admin/src/vite-env.d.ts +0 -18
  61. package/templates/apps/admin/tailwind.config.js +0 -9
  62. package/templates/apps/admin/tsconfig.json +0 -17
  63. package/templates/apps/admin/vite.config.ts +0 -64
  64. package/templates/apps/ai-server/main.py +0 -43
  65. package/templates/apps/ai-server/providers/__init__.py +0 -3
  66. package/templates/apps/ai-server/providers/base.py +0 -111
  67. package/templates/apps/ai-server/requirements.txt +0 -3
  68. package/templates/apps/ai-server/routers/__init__.py +0 -3
  69. package/templates/apps/ai-server/routers/chat.py +0 -47
  70. package/templates/apps/ai-server/routers/embeddings.py +0 -30
  71. package/templates/apps/ai-server/routers/lint.py +0 -167
  72. package/templates/apps/ai-server/routers/models.py +0 -23
  73. package/templates/apps/ai-server/routers/preflight.py +0 -169
  74. package/templates/apps/ai-server/settings.py +0 -48
  75. package/templates/apps/backend/package.json +0 -34
  76. package/templates/apps/backend/src/main.ts +0 -375
  77. package/templates/apps/backend/src/modules/admin/adminRoutes.ts +0 -732
  78. package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +0 -391
  79. package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +0 -161
  80. package/templates/apps/backend/src/modules/admin/lintProxy.ts +0 -89
  81. package/templates/apps/backend/src/modules/admin/preflightProxy.ts +0 -242
  82. package/templates/apps/backend/src/modules/admin/roleCatalog.ts +0 -78
  83. package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +0 -449
  84. package/templates/apps/backend/src/modules/ai/aiProxy.ts +0 -265
  85. package/templates/apps/backend/src/modules/auth/authRoutes.ts +0 -220
  86. package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +0 -100
  87. package/templates/apps/backend/src/modules/payments/paymentStore.ts +0 -172
  88. package/templates/apps/backend/src/modules/requests/requestLog.ts +0 -175
  89. package/templates/apps/backend/src/modules/users/userGraph.ts +0 -83
  90. package/templates/apps/backend/src/modules/users/userModel.ts +0 -88
  91. package/templates/apps/backend/src/plugins/CronScheduler.ts +0 -69
  92. package/templates/apps/backend/src/plugins/loadPlugins.ts +0 -107
  93. package/templates/apps/backend/tsconfig.json +0 -14
  94. package/templates/apps/frontend/index.html +0 -12
  95. package/templates/apps/frontend/package.json +0 -20
  96. package/templates/apps/frontend/postcss.config.js +0 -3
  97. package/templates/apps/frontend/src/index.css +0 -44
  98. package/templates/apps/frontend/src/main.tsx +0 -65
  99. package/templates/apps/frontend/tailwind.config.js +0 -9
  100. package/templates/apps/frontend/vite.config.ts +0 -72
  101. package/templates/bin/nexus.js +0 -35
  102. package/templates/bin/serve-all.mjs +0 -45
  103. package/templates/dockerignore +0 -15
  104. package/templates/gitignore +0 -12
  105. package/templates/nexus.config.ts +0 -69
  106. package/templates/package.json +0 -47
  107. package/tests/cli.test.ts +0 -52
  108. package/tests/config-sync.test.ts +0 -201
  109. package/tests/dotenv.test.ts +0 -51
  110. package/vitest.config.ts +0 -9
  111. package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +0 -13
  112. /package/templates/{uploads → base/apps/backend/storage}/.gitkeep +0 -0
@@ -1,287 +0,0 @@
1
- import { existsSync, rmSync, chmodSync, readdirSync, statSync } from 'node:fs';
2
- import { resolve, basename, join } from 'node:path';
3
- import { spawnSync } from 'node:child_process';
4
- import { loadConfigAuto } from '../../../nexus-core/src/index.js';
5
- import {
6
- resolveProjectInfo,
7
- connectProjectInfo,
8
- getProjectInfo,
9
- deleteProjectInfo,
10
- dropProjectDatabase,
11
- closeProjectInfo,
12
- } from '@bhooai/nexus-data';
13
- import { tcpReachable, parseHostPort } from '../util.js';
14
- import { isInteractive as wizardInteractive, banner, confirm, prompt, closeWizard, COLORS } from '../wizard.js';
15
-
16
- const { GREEN, RED, YELLOW, CYAN, DIM, BOLD, RESET } = COLORS;
17
-
18
- /**
19
- * `nexus uninstall [target]` - remove a project from disk + the databases.
20
- *
21
- * What it deletes:
22
- * 1. The project's own MongoDB database (e.g. `test_app`)
23
- * 2. The project's record in `nexus_projects.projects`
24
- * 3. (optional, with --purge) the project directory itself
25
- *
26
- * What it does NOT touch:
27
- * - The `nexus_projects` database (other projects' records remain)
28
- * - The framework (`bhooai-nexus`) install
29
- * - Any other project's database
30
- *
31
- * Flags:
32
- * --target <path> project root (default: current directory)
33
- * --force skip the confirmation prompt
34
- * --purge also delete the project directory from disk
35
- * --no-interactive never prompt (imply --force unless --dry-run)
36
- * --dry-run show what would be deleted, change nothing
37
- * --keep-db do not drop the project database (only remove the record)
38
- * --keep-files synonym for omitting --purge (default)
39
- *
40
- * Run from inside the project: `nexus uninstall --purge`
41
- * Or point at another project: `nexus uninstall --target ../my-app --purge`
42
- * Preview only: `nexus uninstall --dry-run`
43
- */
44
- export async function uninstall(args: string[]): Promise<number> {
45
- const target = resolve(argValue(args, '--target') ?? '.');
46
- const force = args.includes('--force');
47
- const purge = args.includes('--purge');
48
- const dryRun = args.includes('--dry-run');
49
- const keepDb = args.includes('--keep-db');
50
- const noInteractive = args.includes('--no-interactive') || !wizardInteractive();
51
-
52
- // -- banner ------------------------------------------------------
53
- banner('BhooAI Nexus - uninstall', [
54
- dryRun ? 'DRY RUN - nothing will be deleted.' : 'Removes the project DB + its nexus_projects record.',
55
- purge ? 'Will also DELETE the project directory from disk.' : 'Project files are kept (pass --purge to remove them).',
56
- ]);
57
-
58
- // -- validate the target is a nexus project ----------------------
59
- if (!existsSync(resolve(target, 'package.json'))) {
60
- console.log(` ${RED}[X] not a project directory: no package.json at ${target}${RESET}\n`);
61
- return 1;
62
- }
63
- const configExists = ['ts', 'js', 'mjs', 'cjs'].some((ext) => existsSync(resolve(target, `nexus.config.${ext}`)));
64
- if (!configExists) {
65
- console.log(` ${RED}[X] not a nexus project: no nexus.config.{ts,js,mjs,cjs} at ${target}${RESET}\n`);
66
- return 1;
67
- }
68
-
69
- // -- resolve identity + config -----------------------------------
70
- const project = await resolveProjectInfo(target);
71
- let cfg: Awaited<ReturnType<typeof loadConfigAuto>> | undefined;
72
- try {
73
- cfg = await loadConfigAuto({ root: target });
74
- } catch (err) {
75
- console.log(` ${YELLOW}[!] could not load nexus.config: ${(err as Error).message}${RESET}`);
76
- }
77
- const dbName = cfg?.db?.name ?? project.dbName;
78
- const mongoUri = cfg?.db?.uri ?? 'mongodb://localhost:27017';
79
-
80
- console.log(` ${BOLD}Project${RESET}`);
81
- console.log(` ${CYAN}name${RESET} ${project.name}`);
82
- console.log(` ${CYAN}path${RESET} ${target}`);
83
- console.log(` ${CYAN}database${RESET} ${dbName}`);
84
- console.log(` ${CYAN}mongo uri${RESET} ${mongoUri}`);
85
- console.log(` ${CYAN}purge files${RESET} ${purge ? 'yes' : 'no'}`);
86
- console.log();
87
-
88
- // -- confirm -----------------------------------------------------
89
- if (dryRun) {
90
- // Show the stored record too, for reference.
91
- await previewRecord(mongoUri, project.name);
92
- console.log(`\n ${DIM}Dry run complete - no changes made.${RESET}\n`);
93
- return 0;
94
- }
95
-
96
- if (!force && !noInteractive) {
97
- const ok = await confirm(`Delete the database \`${dbName}\` and remove the \`${project.name}\` record?`, false);
98
- if (!ok) {
99
- console.log(` ${DIM}Aborted.${RESET}`);
100
- closeWizard();
101
- return 1;
102
- }
103
- if (purge) {
104
- const ok2 = await confirm(`Also DELETE the directory ${target} from disk? This cannot be undone.`, false);
105
- if (!ok2) {
106
- console.log(` ${YELLOW}Keeping files - only the DB + record will be removed.${RESET}`);
107
- } else {
108
- // confirmed purge
109
- }
110
- }
111
- closeWizard();
112
- } else if (!force && noInteractive) {
113
- // Non-interactive without --force: refuse to destructively delete.
114
- console.log(` ${RED}[X] Refusing to uninstall non-interactively without --force.${RESET}`);
115
- console.log(` ${DIM}Pass --force to proceed, or run interactively.${RESET}\n`);
116
- return 1;
117
- }
118
-
119
- // -- 1. drop the project database --------------------------------
120
- if (!keepDb) {
121
- const mongo = parseHostPort(mongoUri, 27017);
122
- const mongoOk = await tcpReachable(mongo.host, mongo.port, 1500);
123
- if (!mongoOk) {
124
- console.log(` ${YELLOW}[!] Mongo unreachable at ${mongo.host}:${mongo.port} - skipping DB drop${RESET}`);
125
- } else {
126
- try {
127
- connectProjectInfo(mongoUri, { autoIndex: false });
128
- const dropped = await dropProjectDatabase(dbName);
129
- console.log(` ${dropped ? GREEN + '[OK]' : YELLOW + '[!]'} dropped database${RESET} ${CYAN}${dbName}${RESET} ${dropped ? '' : '(already absent)'}`);
130
- } catch (err) {
131
- console.log(` ${RED}[X] failed to drop database ${dbName}: ${(err as Error).message}${RESET}`);
132
- }
133
- }
134
- } else {
135
- console.log(` ${DIM}--keep-db: leaving database ${dbName} in place${RESET}`);
136
- }
137
-
138
- // -- 2. delete the nexus_projects.projects record ---------------
139
- if (!keepDb || true) {
140
- try {
141
- // connectProjectInfo may already be open from the drop step; reusing is fine.
142
- if (!keepDb) {
143
- // already connected above
144
- } else {
145
- const mongo = parseHostPort(mongoUri, 27017);
146
- if (await tcpReachable(mongo.host, mongo.port, 1500)) connectProjectInfo(mongoUri, { autoIndex: false });
147
- }
148
- const deleted = await deleteProjectInfo(project.name);
149
- console.log(` ${deleted ? GREEN + '[OK]' : YELLOW + '[!]'} deleted record${RESET} ${CYAN}${project.name}${RESET} ${deleted ? 'from nexus_projects.projects' : '(not found)'}`);
150
- } catch (err) {
151
- console.log(` ${RED}[X] failed to delete project record: ${(err as Error).message}${RESET}`);
152
- } finally {
153
- try { await closeProjectInfo(); } catch { /* ignore */ }
154
- }
155
- }
156
-
157
- // -- 3. purge project files from disk ----------------------------
158
- if (purge) {
159
- const removed = await removeDir(target);
160
- if (removed) {
161
- console.log(` ${GREEN}[OK]${RESET} removed directory ${CYAN}${target}${RESET}`);
162
- } else {
163
- console.log(` ${RED}[X] could not fully remove ${target}${RESET}`);
164
- console.log(` ${DIM}Some files may be locked by a running process (node_modules, .venv, an editor).${RESET}`);
165
- console.log(` ${DIM}Close any servers/editors holding the directory and run:${RESET}`);
166
- if (process.platform === 'win32') {
167
- console.log(` ${CYAN}rd /s /q "${target}"${RESET}`);
168
- } else {
169
- console.log(` ${CYAN}rm -rf "${target}"${RESET}`);
170
- }
171
- }
172
- }
173
-
174
- console.log(`\n ${GREEN}[OK] Uninstall complete.${RESET}${purge ? '' : ` ${DIM}(project files kept - pass --purge to remove them)${RESET}`}\n`);
175
- return 0;
176
- }
177
-
178
- /** Print the stored nexus_projects record for the project (dry-run preview). */
179
- async function previewRecord(mongoUri: string, name: string): Promise<void> {
180
- const mongo = parseHostPort(mongoUri, 27017);
181
- const mongoOk = await tcpReachable(mongo.host, mongo.port, 1500);
182
- if (!mongoOk) {
183
- console.log(` ${YELLOW}[!] Mongo unreachable - cannot preview the stored record${RESET}`);
184
- return;
185
- }
186
- try {
187
- connectProjectInfo(mongoUri, { autoIndex: false });
188
- const rec = await getProjectInfo(name);
189
- if (rec) {
190
- console.log(` ${BOLD}Stored record${RESET}`);
191
- console.log(` ${CYAN}name${RESET} ${rec.name}`);
192
- console.log(` ${CYAN}status${RESET} ${rec.status ?? '?'}`);
193
- console.log(` ${CYAN}path${RESET} ${rec.path}`);
194
- console.log(` ${CYAN}dbName${RESET} ${rec.dbName}`);
195
- } else {
196
- console.log(` ${DIM}no record found in nexus_projects.projects for \`${name}\`${RESET}`);
197
- }
198
- await closeProjectInfo();
199
- } catch (err) {
200
- console.log(` ${YELLOW}[!] could not read record: ${(err as Error).message}${RESET}`);
201
- }
202
- }
203
-
204
- function argValue(args: string[], name: string): string | undefined {
205
- const i = args.indexOf(name);
206
- if (i >= 0) return args[i + 1];
207
- return args.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1);
208
- }
209
-
210
- /**
211
- * Robustly remove a directory tree, working around Windows EPERM errors.
212
- * 1. Recursively clear the read-only attribute on every entry (Windows sets
213
- * it on `node_modules/.bin` shims, `.venv` files, etc. - `rmSync` then
214
- * throws EPERM instead of deleting).
215
- * 2. Try `rmSync`; retry up to 3 times with a short backoff (handles files
216
- * briefly locked by a just-killed process or an antivirus scan).
217
- * 3. Fall back to the OS shell (`rd /s /q` on Windows, `rm -rf` elsewhere)
218
- * which uses a different deletion path and often succeeds where Node's
219
- * libuv-based unlink fails.
220
- * Returns true if the directory is gone (or never existed), false otherwise.
221
- */
222
- async function removeDir(target: string): Promise<boolean> {
223
- if (!existsSync(target)) return true;
224
-
225
- // Step 1: clear read-only attrs recursively (no-op on non-Windows; cheap).
226
- clearReadOnly(target);
227
-
228
- // Step 2: rmSync with retries.
229
- for (let attempt = 1; attempt <= 3; attempt++) {
230
- try {
231
- rmSync(target, { recursive: true, force: true });
232
- if (!existsSync(target)) return true;
233
- } catch (err) {
234
- const code = (err as NodeJS.ErrnoException).code;
235
- if (attempt < 3 && (code === 'EPERM' || code === 'EBUSY' || code === 'ENOTEMPTY')) {
236
- await new Promise((r) => setTimeout(r, 250 * attempt));
237
- // Re-clear attrs in case a new locked file appeared.
238
- clearReadOnly(target);
239
- continue;
240
- }
241
- break; // give up on rmSync, fall through to shell
242
- }
243
- }
244
-
245
- if (!existsSync(target)) return true;
246
-
247
- // Step 3: OS shell fallback.
248
- try {
249
- if (process.platform === 'win32') {
250
- // `rd /s /q` ignores read-only attrs and forces deletion.
251
- const r = spawnSync('cmd', ['/c', 'rd', '/s', '/q', target], { windowsHide: true });
252
- return r.status === 0 && !existsSync(target);
253
- }
254
- const r = spawnSync('rm', ['-rf', target]);
255
- return r.status === 0 && !existsSync(target);
256
- } catch {
257
- return !existsSync(target);
258
- }
259
- }
260
-
261
- /** Recursively walk `dir` and clear the read-only bit on every file/dir.
262
- * Windows-only in effect; on other platforms chmod is a no-op on the
263
- * immutable bits. Uses `0o666` for files and `0o777` for dirs. */
264
- function clearReadOnly(dir: string): void {
265
- let entries: string[];
266
- try {
267
- entries = readdirSync(dir);
268
- } catch {
269
- return;
270
- }
271
- for (const name of entries) {
272
- const p = join(dir, name);
273
- let st;
274
- try {
275
- st = statSync(p);
276
- } catch {
277
- continue;
278
- }
279
- try {
280
- chmodSync(p, st.isDirectory() ? 0o777 : 0o666);
281
- } catch { /* ignore - best effort */ }
282
- if (st.isDirectory()) clearReadOnly(p);
283
- }
284
- try {
285
- chmodSync(dir, 0o777);
286
- } catch { /* ignore */ }
287
- }
@@ -1,384 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { readFile, writeFile } from 'node:fs/promises';
4
- import { basename, join } from 'node:path';
5
- import type { NexusConfig } from '../../nexus-core/src/index.js';
6
-
7
- /**
8
- * Config sync - propagate the single `nexus.config.ts` source of truth into
9
- * every derived artifact that still hardcodes a host/port/URL:
10
- *
11
- * - Dockerfile (EXPOSE, healthcheck, docker-run comments)
12
- * - docker.ps1 / docker.sh / docker.bat (port vars, -p mappings, URLs)
13
- * - bin/serve-all.mjs (cluster node port + frontend/admin preview ports)
14
- * - apps/admin/package.json (the `vite --port NNNN` dev script)
15
- * - shared project-info database (upsertProjectInfo so the DB record shows
16
- * the same value the admin dashboard reads)
17
- *
18
- * A `.nexus-sync.json` fingerprint at the project root lets `nexus dev` detect
19
- * "config changed since last sync" and re-sync every artifact before booting.
20
- */
21
-
22
- export interface SyncValues {
23
- /** Docker image / container name (package.json `name`). */
24
- image: string;
25
- serverPort: string;
26
- frontendPort: string;
27
- adminPort: string;
28
- nodePort: string;
29
- lbPort: string;
30
- aiPort: string;
31
- /** Host-service URLs as the docker helpers should reference them. */
32
- dbUri: string;
33
- redisUri: string;
34
- aiUrl: string;
35
- /** Mongo database name parsed from `db.uri`. */
36
- dbName: string;
37
- }
38
-
39
- export interface FileSyncReport {
40
- file: string;
41
- status: 'updated' | 'in-sync' | 'no-match' | 'missing';
42
- changes: string[];
43
- }
44
-
45
- export interface SyncReport {
46
- values: SyncValues;
47
- fingerprint: string;
48
- files: FileSyncReport[];
49
- db: 'updated' | 'skipped' | 'failed';
50
- }
51
-
52
- export interface SyncOptions {
53
- /** Write files (default true). False = dry-run / diff-only audit. */
54
- write?: boolean;
55
- /** Upsert the project-info MongoDB record (default true). */
56
- db?: boolean;
57
- }
58
-
59
- /* -- helpers -------------------------------------------------------------- */
60
-
61
- /** Stable hash of the values that drive every derived artifact. */
62
- export function fingerprintOf(values: SyncValues): string {
63
- return createHash('sha256')
64
- .update(
65
- [
66
- values.image,
67
- values.serverPort,
68
- values.frontendPort,
69
- values.adminPort,
70
- values.nodePort,
71
- values.lbPort,
72
- values.aiPort,
73
- values.dbUri,
74
- values.redisUri,
75
- values.aiUrl,
76
- values.dbName,
77
- ].join('|'),
78
- )
79
- .digest('hex')
80
- .slice(0, 16);
81
- }
82
-
83
- /** Path to the `.nexus-sync.json` fingerprint file. */
84
- export function syncFingerprintPath(root: string): string {
85
- return join(root, '.nexus-sync.json');
86
- }
87
-
88
- /** Read the last-synced record, or undefined. */
89
- export function readFingerprint(root: string): { fingerprint: string; values: SyncValues; syncedAt?: string } | undefined {
90
- const file = syncFingerprintPath(root);
91
- if (!existsSync(file)) return undefined;
92
- try {
93
- return JSON.parse(readFileSync(file, 'utf8')) as { fingerprint: string; values: SyncValues; syncedAt?: string };
94
- } catch {
95
- return undefined;
96
- }
97
- }
98
-
99
- /** MongoDB database name parsed out of a connection string. */
100
- export function dbNameFromUrl(uri: string): string {
101
- try {
102
- const url = new URL(uri);
103
- const name = decodeURIComponent(url.pathname.replace(/^\//, ''));
104
- return name || 'platform';
105
- } catch {
106
- return 'platform';
107
- }
108
- }
109
-
110
- /** Rewrite a host-service URL so a container reaches it via the host gateway. */
111
- function dockerHost(url: string): string {
112
- return url.replace(/\/\/(localhost|127\.0\.0\.1|\[::1\])([:/])/g, (m, _h, sep) => `//host.docker.internal${sep}`);
113
- }
114
-
115
- /** Port parsed from a URL (falls back on a sensible default). */
116
- function portFrom(url: string, fallback: string): string {
117
- try {
118
- return new URL(url).port || fallback;
119
- } catch {
120
- return fallback;
121
- }
122
- }
123
-
124
- /** Package.json `name`, else the project folder name. */
125
- function projectImageName(root: string): string {
126
- try {
127
- const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: string };
128
- if (pkg.name) return pkg.name;
129
- } catch {
130
- /* no valid package.json - fall through */
131
- }
132
- return basename(root);
133
- }
134
-
135
- /** Role order used when scanning a contiguous `-p <port>:<port>` run. */
136
- const PORT_ROLES = ['server', 'frontend', 'admin', 'node'] as const;
137
-
138
- /** Value for a role before a `-p` token maps to. */
139
- function forRole(role: string, v: SyncValues): string {
140
- switch (role) {
141
- case 'server':
142
- return v.serverPort;
143
- case 'frontend':
144
- return v.frontendPort;
145
- case 'admin':
146
- return v.adminPort;
147
- case 'node':
148
- return v.nodePort;
149
- default:
150
- return role;
151
- }
152
- }
153
-
154
- /**
155
- * Rewrite every group of contiguous `-p <port>:<port>` mapping tokens into the
156
- * configured ports. Roles are assigned positionally (server, frontend, admin,
157
- * node) so the rewrite is idempotent even after an earlier run wrote a custom
158
- * port (e.g. 3120) that role-splitting cannot recognize by number alone.
159
- *
160
- * The tokens in one `docker run` may be separated by whitespace, line-continuation
161
- * carets (`^` in .bat / `\` in .sh), and newlines, but never by another flag.
162
- */
163
- function syncPortMap(content: string, v: SyncValues): string {
164
- return content.replace(/-p\s+\d+:\d+(?:[ \t\r\n^\\]*-p\s+\d+:\d+)*/g, (run) => {
165
- let idx = 0;
166
- return run.replace(/-p\s+\d+:\d+/g, (token) => {
167
- const p = forRole(PORT_ROLES[idx % PORT_ROLES.length], v);
168
- idx++;
169
- return token.replace(/\d+:\d+/, `${p}:${p}`);
170
- });
171
- });
172
- }
173
-
174
- /** Run a value-aware pipeline over file content. */
175
- function render(content: string, v: SyncValues, steps: Array<(c: string) => string>): { out: string; changed: boolean } {
176
- let out = content;
177
- for (const step of steps) out = step(out);
178
- return { out, changed: out !== content };
179
- }
180
-
181
- /** Show the human-readable diff between two file versions. */
182
- function diffLines(before: string, after: string): string[] {
183
- const changes: string[] = [];
184
- const b = before.split('\n');
185
- const a = after.split('\n');
186
- const max = Math.max(b.length, a.length);
187
- for (let i = 0; i < max; i++) {
188
- if (b[i] !== a[i]) {
189
- changes.push(`line ${i + 1}: ${(b[i] ?? '').trim()} -> ${(a[i] ?? '').trim()}`);
190
- }
191
- }
192
- return changes;
193
- }
194
-
195
- /* -- per-file renderers --------------------------------------------------- */
196
-
197
- function renderDockerfile(content: string, v: SyncValues): string {
198
- return render(content, v, [
199
- (c) => c.replace(/^EXPOSE .*$/m, `EXPOSE ${v.frontendPort} ${v.adminPort} ${v.serverPort} ${v.nodePort}`),
200
- (c) => c.replace(/NEXUS_SERVER_PORT:-?\d+/g, `NEXUS_SERVER_PORT:-${v.serverPort}`),
201
- (c) => syncPortMap(c, v),
202
- (c) => c.replace(/-e NEXUS_SERVER_PORT=\d+/g, `-e NEXUS_SERVER_PORT=${v.serverPort}`),
203
- (c) => c.replace(/-e NEXUS_NODE_PORT=\d+/g, `-e NEXUS_NODE_PORT=${v.nodePort}`),
204
- (c) => c.replace(/(frontend\s+http:\/\/localhost:)\d+/g, `$1${v.frontendPort}`),
205
- (c) => c.replace(/(admin\s+http:\/\/localhost:)\d+/g, `$1${v.adminPort}`),
206
- (c) => c.replace(/(backend\s+http:\/\/localhost:)\d+/g, `$1${v.serverPort}`),
207
- (c) => c.replace(/(node\s+http:\/\/localhost:)\d+/g, `$1${v.nodePort}`),
208
- ]).out;
209
- }
210
-
211
- function renderServeAll(content: string, v: SyncValues): string {
212
- return render(content, v, [
213
- (c) => c.replace(/process\.env\.NEXUS_NODE_PORT\s*\?\?\s*'[^']+'/, `process.env.NEXUS_NODE_PORT ?? '${v.nodePort}'`),
214
- // Each service's preview port: match inside the `run('frontend'/'admin'` line.
215
- (c) => c.replace(/(run\('frontend',[^\n]*?'--port',\s*')[0-9]+(')/, `$1${v.frontendPort}$2`),
216
- (c) => c.replace(/(run\('admin',[^\n]*?'--port',\s*')[0-9]+(')/, `$1${v.adminPort}$2`),
217
- (c) => c.replace(/(frontend.*?on\s+:)[0-9]+/g, `$1${v.frontendPort}`),
218
- (c) => c.replace(/(admin.*?on\s+:)[0-9]+/g, `$1${v.adminPort}`),
219
- ]).out;
220
- }
221
-
222
- function renderAdminPackage(content: string, v: SyncValues): string {
223
- return render(content, v, [
224
- (c) => c.replace(/"dev":\s*"vite[^"]*"/, `"dev": "vite --port ${v.adminPort}"`),
225
- ]).out;
226
- }
227
-
228
- function renderDockerPs1(content: string, v: SyncValues): string {
229
- const set = (c: string, key: string, value: string) =>
230
- c.replace(new RegExp(`(\\$${key}\\s*=\\s*')[0-9]+(')`, 'g'), `$1${value}$2`);
231
- return render(content, v, [
232
- (c) => set(c, 'serverPort', v.serverPort),
233
- (c) => set(c, 'frontendPort', v.frontendPort),
234
- (c) => set(c, 'adminPort', v.adminPort),
235
- (c) => set(c, 'nodePort', v.nodePort),
236
- (c) => c.replace(/(\$dbUri\s*=\s*')([^']*)(')/g, `$1${v.dbUri}$3`),
237
- (c) => c.replace(/(\$redisUrl\s*=\s*')([^']*)(')/g, `$1${v.redisUri}$3`),
238
- (c) => c.replace(/(\$aiUrl\s*=\s*')([^']*)(')/g, `$1${v.aiUrl}$3`),
239
- (c) => syncPortMap(c, v),
240
- ]).out;
241
- }
242
-
243
- function renderDockerSh(content: string, v: SyncValues): string {
244
- return render(content, v, [
245
- (c) => c.replace(/(DB_URI=")([^"]*)(")/g, `$1${v.dbUri}$3`),
246
- (c) => c.replace(/(REDIS_URL=")([^"]*)(")/g, `$1${v.redisUri}$3`),
247
- (c) => c.replace(/(AI_URL=")([^"]*)(")/g, `$1${v.aiUrl}$3`),
248
- (c) => c.replace(/(NODE_PORT=")([^"]*)(")/g, `$1${v.nodePort}$3`),
249
- (c) => syncPortMap(c, v),
250
- (c) => c.replace(/(frontend http:\/\/localhost:)\d+/g, `$1${v.frontendPort}`),
251
- (c) => c.replace(/(admin\s+http:\/\/localhost:)\d+/g, `$1${v.adminPort}`),
252
- (c) => c.replace(/(backend\s+http:\/\/localhost:)\d+/g, `$1${v.serverPort}`),
253
- ]).out;
254
- }
255
-
256
- function renderDockerBat(content: string, v: SyncValues): string {
257
- return render(content, v, [
258
- (c) => syncPortMap(c, v),
259
- (c) => c.replace(/NEXUS_DB_URI=mongodb:\/\/[^\s^&]+/g, `NEXUS_DB_URI=${v.dbUri}`),
260
- (c) => c.replace(/NEXUS_REDIS_URL=redis:\/\/[^\s^&]+/g, `NEXUS_REDIS_URL=${v.redisUri}`),
261
- (c) => c.replace(/NEXUS_AI_SERVER_URL=http:\/\/[^\s^&]+/g, `NEXUS_AI_SERVER_URL=${v.aiUrl}`),
262
- (c) => c.replace(/NEXUS_NODE_PORT=\d+/g, `NEXUS_NODE_PORT=${v.nodePort}`),
263
- (c) => c.replace(/(frontend http:\/\/localhost:)\d+/g, `$1${v.frontendPort}`),
264
- (c) => c.replace(/(admin\s+http:\/\/localhost:)\d+/g, `$1${v.adminPort}`),
265
- (c) => c.replace(/(backend\s+http:\/\/localhost:)\d+/g, `$1${v.serverPort}`),
266
- ]).out;
267
- }
268
-
269
- /* -- targets -------------------------------------------------------------- */
270
-
271
- type FileRenderer = (content: string, v: SyncValues) => string;
272
-
273
- const TARGETS: Array<{ rel: string; render: FileRenderer }> = [
274
- { rel: 'Dockerfile', render: renderDockerfile },
275
- { rel: 'bin/serve-all.mjs', render: renderServeAll },
276
- { rel: 'apps/admin/package.json', render: renderAdminPackage },
277
- { rel: 'docker.ps1', render: renderDockerPs1 },
278
- { rel: 'docker.sh', render: renderDockerSh },
279
- { rel: 'docker.bat', render: renderDockerBat },
280
- ];
281
-
282
- /* -- public API ----------------------------------------------------------- */
283
-
284
- /** Derive the full set of printable / embeddable values from the effective config. */
285
- export function deriveValues(cfg: NexusConfig, image: string): SyncValues {
286
- return {
287
- image,
288
- serverPort: String(cfg.server.port),
289
- frontendPort: String(cfg.frontend.port),
290
- adminPort: String(cfg.admin.port),
291
- nodePort: String(cfg.cluster.nodeAgentPort ?? 7575),
292
- lbPort: String(cfg.cluster.lbPort ?? 8080),
293
- aiPort: portFrom(cfg.ai.serverUrl, '8000'),
294
- dbUri: dockerHost(cfg.db.uri),
295
- redisUri: dockerHost(cfg.redis.url),
296
- aiUrl: dockerHost(cfg.ai.serverUrl),
297
- dbName: dbNameFromUrl(cfg.db.uri),
298
- };
299
- }
300
-
301
- /**
302
- * Sync the effective config into every derived file + the project-info DB.
303
- * Returns a field-level report; with `opts.write === false` it only audits.
304
- */
305
- export async function syncConfig(root: string, cfg: NexusConfig, opts: SyncOptions = {}): Promise<SyncReport> {
306
- const write = opts.write !== false;
307
- const image = projectImageName(root);
308
- const values = deriveValues(cfg, image);
309
- const fingerprint = fingerprintOf(values);
310
-
311
- const files: FileSyncReport[] = [];
312
- for (const target of TARGETS) {
313
- const abs = join(root, target.rel);
314
- if (!existsSync(abs)) {
315
- files.push({ file: target.rel, status: 'missing', changes: [] });
316
- continue;
317
- }
318
- try {
319
- const current = await readFile(abs, 'utf8');
320
- const next = target.render(current, values);
321
- if (next === current) {
322
- files.push({ file: target.rel, status: 'in-sync', changes: [] });
323
- continue;
324
- }
325
- if (write) await writeFile(abs, next, 'utf8');
326
- files.push({ file: target.rel, status: 'updated', changes: diffLines(current, next) });
327
- } catch (err) {
328
- files.push({ file: target.rel, status: 'no-match', changes: [String((err as Error).message)] });
329
- }
330
- }
331
-
332
- if (write) {
333
- const record = { fingerprint, values, syncedAt: new Date().toISOString() };
334
- writeFileSync(syncFingerprintPath(root), JSON.stringify(record, null, 2), 'utf8');
335
- }
336
-
337
- const db = opts.db === false ? 'skipped' : await syncDatabase(root, cfg, values, write);
338
-
339
- return { values, fingerprint, files, db };
340
- }
341
-
342
- /** Upsert the shared `nexus_projects` record so the DB mirrors the config. */
343
- async function syncDatabase(root: string, cfg: NexusConfig, values: SyncValues, write: boolean): Promise<SyncReport['db']> {
344
- if (!write) return 'skipped';
345
- try {
346
- const { resolveProjectInfo, connectProjectInfo, upsertProjectInfo, closeProjectInfo } = await import(
347
- '../../../nexus-data/src/index.js'
348
- );
349
- const project = await resolveProjectInfo(root);
350
- connectProjectInfo(cfg.db.uri, { autoIndex: false });
351
- await upsertProjectInfo({
352
- ...project,
353
- status: 'stopped',
354
- settings: {
355
- env: cfg.env,
356
- host: cfg.server.host,
357
- serverPort: cfg.server.port,
358
- frontendPort: cfg.frontend.port,
359
- adminPort: cfg.admin.port,
360
- lbPort: cfg.cluster?.lbPort,
361
- nodePort: cfg.cluster?.nodeAgentPort,
362
- aiPort: Number(values.aiPort),
363
- database: values.dbName,
364
- dbUri: cfg.db.uri,
365
- redisUrl: cfg.redis.url,
366
- aiUrl: cfg.ai.serverUrl,
367
- graphqlPath: cfg.graphql.path,
368
- wsPath: cfg.ws.path,
369
- },
370
- });
371
- await closeProjectInfo();
372
- return 'updated';
373
- } catch {
374
- return 'failed';
375
- }
376
- }
377
-
378
- /** True when the stored fingerprint differs from the current config. */
379
- export function configChangedSinceSync(root: string, cfg: NexusConfig): boolean {
380
- const image = projectImageName(root);
381
- const current = fingerprintOf(deriveValues(cfg, image));
382
- const saved = readFingerprint(root);
383
- return !saved || saved.fingerprint !== current;
384
- }