aegisnode 0.0.4 → 0.0.5

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/README.md CHANGED
@@ -43,6 +43,7 @@ It keeps the development model simple, but adds enough structure and tooling to
43
43
  Core features:
44
44
 
45
45
  - CLI generators (`startproject`, `createapp`, `runserver`)
46
+ - App scaffold repair command (`fix`)
46
47
  - Startup entry generator (`generateloader`)
47
48
  - Project health checker (`doctor`)
48
49
  - Dependency updater (`updatedeps`)
@@ -61,7 +62,7 @@ Core features:
61
62
  - Root route file `routes.js` (not `routes/` folder)
62
63
  - Automatic default confirmation page on `/` when no custom `/` route exists
63
64
  - App folder uses `views.js` (not `controllers/` folder)
64
- - `createapp` uses file modules: `views.js`, `models.js`, `validators.js`, `routes.js`, `subscribers.js`, `services.js`
65
+ - `createapp` uses file modules: `views.js`, `models.js`, `validators.js`, `routes.js`, `subscribers.js`, `services.js`, `utils.js`
65
66
  - `createapp` also generates app tests in `apps/<app>/tests`
66
67
  - EJS templates configurable in `settings.js` with Django-style base layout flow
67
68
  - Built-in runtime helpers (`money`, `number`, `dateTime`, `timeElapsed`, `toObjectId`) + `jlive` bridge
@@ -90,16 +91,18 @@ npm --prefix blog install
90
91
  aegisnode runserver --project blog
91
92
 
92
93
  aegisnode createapp users --project blog
94
+ aegisnode fix --app users --project blog
93
95
  aegisnode generate view profile --app users --project blog
94
96
  aegisnode generate route profile --app users --project blog
95
97
  aegisnode generateloader --project blog
96
98
  aegisnode doctor --project blog
99
+ aegisnode doctor --app users --project blog
97
100
  aegisnode updatedeps --project blog
98
101
  ```
99
102
 
100
103
  `cd blog` is optional. You can run commands from parent folder with `--project blog`.
101
104
 
102
- `createapp`, `generate`, `runserver`, `generateloader`, `doctor`, and `updatedeps` are project-level commands.
105
+ `createapp`, `fix`, `generate`, `runserver`, `generateloader`, `doctor`, and `updatedeps` are project-level commands.
103
106
  Run them from the project root; do not `cd` into `apps/<app>`.
104
107
  Startup mode rules:
105
108
  - Development (`env === development`): start with `aegisnode runserver` only.
@@ -107,6 +110,20 @@ Startup mode rules:
107
110
  - `node app.js` and `node loader.cjs` are rejected in development mode.
108
111
  - `aegisnode runserver` is rejected outside development mode.
109
112
 
113
+ ### Trust Proxy
114
+
115
+ If your app runs behind Nginx, Apache, Passenger, or another reverse proxy that terminates HTTPS before the Node process, set top-level `trustProxy` in `settings.js`:
116
+
117
+ ```js
118
+ export default {
119
+ trustProxy: 1,
120
+ };
121
+ ```
122
+
123
+ This is the AegisNode equivalent of `app.set('trust proxy', 1)` in raw Express. It makes `req.secure`, `req.protocol`, client IP detection, secure cookies, and HTTPS-aware auth logic behave correctly behind the proxy.
124
+
125
+ Prefer an exact value such as `1`, `'loopback'`, or a subnet string instead of `true`.
126
+
110
127
  ### Deploy On Phusion Passenger
111
128
 
112
129
  AegisNode supports Passenger-style startup using the generated `loader.cjs`.
@@ -170,6 +187,26 @@ aegisnode doctor
170
187
  - Auth safety checks (JWT secret, OAuth2 `allowHttp` in production)
171
188
  - Template directory availability
172
189
 
190
+ Run app-level scaffold checks for one app:
191
+
192
+ ```bash
193
+ aegisnode doctor --app users
194
+ ```
195
+
196
+ App-level doctor focuses on the named app:
197
+ - Missing `views.js`, `models.js`, `services.js`, `validators.js`, `routes.js`, `subscribers.js`, `utils.js`
198
+ - Missing generated test files under `apps/<app>/tests`
199
+ - Missing `settings.apps` declaration for that app
200
+ - Missing central `routes.js` import/mount when `autoMountApps` is off
201
+
202
+ Repair a partially missing app scaffold:
203
+
204
+ ```bash
205
+ aegisnode fix --app users
206
+ ```
207
+
208
+ `fix` recreates missing default app files and tests without overwriting existing files. If the app is missing from `settings.apps` or central `routes.js`, it restores those registrations too.
209
+
173
210
  Regenerate project startup entry files if needed:
174
211
 
175
212
  ```bash
@@ -288,16 +325,24 @@ Each generated app usually contains:
288
325
  - `apps/<app>/views.js`
289
326
  - `apps/<app>/models.js`
290
327
  - `apps/<app>/services.js`
328
+ - `apps/<app>/utils.js`
291
329
  - `apps/<app>/subscribers.js`
292
330
  - `apps/<app>/routes.js`
293
331
 
294
332
  Usage by file:
295
333
  - `views.js`: HTTP handlers (`req`, `res`, `next`). Default signature can be context-first: `handler({ service, validator, services, validators, ... }, req, res, next)`.
334
+ Keep `views.js` thin: prefer only the view class and its imports. Avoid defining extra local helper/utility functions in the view file. Move reusable pure logic to `utils.js` and app workflows to `services.js`.
296
335
  - `models.js`: data access layer only (SQL/NoSQL operations).
297
- - `services.js`: business logic layer; orchestrates models.
336
+ - `services.js`: business logic layer; orchestrates models and uses injected runtime objects when needed.
337
+ - `utils.js`: app-local pure utility functions. Use this for small reusable helpers that belong only to the app. Do not put DB access, request validation, or business workflows here.
338
+ `utils.js` is a plain module, not an injected runtime layer. If a utility needs `jlive`, `helpers`, `i18n`, or another injected runtime object, inject that object into a view/service/model first and pass it into the utility function as an argument.
298
339
  - `subscribers.js`: event listeners (for example `app.booted`, `ws.connection`, custom events).
299
340
  - `routes.js`: route mapping only (`route.get(...)`, `route.post(...)`, `route.use(...)`) to view handlers.
300
341
 
342
+ Short rule for `utils.js` vs `services.js`:
343
+ - Use `utils.js` for pure app-local helpers such as string formatting, slug generation, payload shaping, or small mappers.
344
+ - Use `services.js` for application behavior: anything that coordinates models, injected runtime objects, or feature rules.
345
+
301
346
  Route modules are mapping-only (`register(route)`).
302
347
  Framework context is injected into handlers as first argument (when handler uses 4 args): `{ service, validator, services, models, validators, auth, mail, helpers, i18n, events, ... }`.
303
348
  `req.aegis` is also available.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegisnode",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "A view-first Node.js framework for modular web apps and JSON APIs with CLI scaffolding, runtime injection, auth, uploads, i18n, mail, and WebSocket support.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -7,6 +7,7 @@ import path from 'path';
7
7
  import fs from 'fs/promises';
8
8
  import { startProject } from '../src/cli/commands/startproject.js';
9
9
  import { createApp } from '../src/cli/commands/createapp.js';
10
+ import { runCli } from '../src/cli/index.js';
10
11
  import { generateArtifact } from '../src/cli/commands/generate.js';
11
12
  import { createKernel } from '../src/runtime/kernel.js';
12
13
  import { runServer } from '../src/cli/commands/runserver.js';
@@ -16,6 +17,7 @@ import { createAuthManager, normalizeAuthConfig } from '../src/runtime/auth.js';
16
17
  import { loadProjectConfig } from '../src/runtime/config.js';
17
18
  import { initializeDatabase, closeDatabase } from '../src/runtime/database.js';
18
19
  import { runDoctor } from '../src/cli/commands/doctor.js';
20
+ import { runFixApp } from '../src/cli/commands/fixapp.js';
19
21
  import { runUpdateDependencies } from '../src/cli/commands/updatedeps.js';
20
22
  import { createHelpers } from '../src/runtime/helpers.js';
21
23
 
@@ -590,6 +592,7 @@ dkcqnJD4SGWVeG+KhA==
590
592
  await fs.access(path.join(projectRoot, 'apps', 'users', 'models.js'));
591
593
  await fs.access(path.join(projectRoot, 'apps', 'users', 'validators.js'));
592
594
  await fs.access(path.join(projectRoot, 'apps', 'users', 'services.js'));
595
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'utils.js'));
593
596
  await fs.access(path.join(projectRoot, 'apps', 'users', 'subscribers.js'));
594
597
  await fs.access(path.join(projectRoot, 'apps', 'users', 'tests', 'models.test.js'));
595
598
  await fs.access(path.join(projectRoot, 'apps', 'users', 'tests', 'validators.test.js'));
@@ -605,6 +608,90 @@ dkcqnJD4SGWVeG+KhA==
605
608
  assert.equal(doctorReport.summary.errors, 0);
606
609
  const projectRoutesFile = await fs.readFile(path.join(projectRoot, 'routes.js'), 'utf8');
607
610
  assert.match(projectRoutesFile, /route\.use\((['"])\/users\1, users\);/);
611
+
612
+ await fs.unlink(path.join(projectRoot, 'apps', 'users', 'utils.js'));
613
+ await fs.unlink(path.join(projectRoot, 'apps', 'users', 'tests', 'routes.test.js'));
614
+ await fs.writeFile(
615
+ path.join(projectRoot, 'routes.js'),
616
+ projectRoutesFile
617
+ .replace(/import users from '\.\/apps\/users\/routes\.js';\n/, '')
618
+ .replace(/\s*route\.use\((['"])\/users\1, users\);\n/, '\n'),
619
+ 'utf8',
620
+ );
621
+ const appDoctorRouteReport = await runDoctor({
622
+ projectRoot,
623
+ appName: 'users',
624
+ failOnError: false,
625
+ output: {
626
+ log() {},
627
+ },
628
+ });
629
+ assert.equal(
630
+ appDoctorRouteReport.entries.some((entry) => entry.message.includes('Project routes.js is missing import for app "users".')),
631
+ true,
632
+ );
633
+ assert.equal(
634
+ appDoctorRouteReport.entries.some((entry) => entry.message.includes('Project routes.js is missing route.use(...) mount for app "users".')),
635
+ true,
636
+ );
637
+
638
+ const settingsBeforeFix = await fs.readFile(path.join(projectRoot, 'settings.js'), 'utf8');
639
+ await fs.writeFile(
640
+ path.join(projectRoot, 'settings.js'),
641
+ settingsBeforeFix.replace(/\n\s*\{ name: "users", mount: "\/users" \},/, ''),
642
+ 'utf8',
643
+ );
644
+
645
+ const appDoctorReport = await runDoctor({
646
+ projectRoot,
647
+ appName: 'users',
648
+ failOnError: false,
649
+ output: {
650
+ log() {},
651
+ },
652
+ });
653
+ assert.equal(appDoctorReport.summary.errors, 0);
654
+ assert.equal(
655
+ appDoctorReport.entries.some((entry) => entry.message.includes('App "users" is not declared in settings.apps.')),
656
+ true,
657
+ );
658
+ assert.equal(
659
+ appDoctorReport.entries.some((entry) => entry.message.includes('App "users" missing utils.js.')),
660
+ true,
661
+ );
662
+ assert.equal(
663
+ appDoctorReport.entries.some((entry) => entry.message.includes('App "users" missing tests/routes.test.js.')),
664
+ true,
665
+ );
666
+
667
+ const fixAppResult = await runFixApp({
668
+ appName: 'users',
669
+ projectRoot,
670
+ });
671
+ assert.equal(fixAppResult.registryUpdated, true);
672
+ assert.equal(fixAppResult.routesUpdated, true);
673
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'utils.js'));
674
+ await fs.access(path.join(projectRoot, 'apps', 'users', 'tests', 'routes.test.js'));
675
+ const fixedSettingsFile = await fs.readFile(path.join(projectRoot, 'settings.js'), 'utf8');
676
+ assert.match(fixedSettingsFile, /\{ name: "users", mount: "\/users" \},/);
677
+ const fixedRoutesFile = await fs.readFile(path.join(projectRoot, 'routes.js'), 'utf8');
678
+ assert.match(fixedRoutesFile, /import users from '\.\/apps\/users\/routes\.js';/);
679
+ assert.match(fixedRoutesFile, /route\.use\((['"])\/users\1, users\);/);
680
+ await assert.doesNotReject(
681
+ () => runCli(['doctor', '--app', 'users', '--project', projectRoot]),
682
+ );
683
+ await assert.rejects(
684
+ () => runCli(['doctor', 'users', '--project', projectRoot]),
685
+ /Doctor app target must use --app <app-name>/,
686
+ );
687
+ await assert.doesNotReject(
688
+ () => runCli(['fix', '--app', 'users', '--project', projectRoot]),
689
+ );
690
+ await assert.rejects(
691
+ () => runCli(['fix', 'users', '--project', projectRoot]),
692
+ /Fix app target must use --app <app-name>/,
693
+ );
694
+
608
695
  await generateArtifact({
609
696
  type: 'view',
610
697
  name: 'profile',
@@ -1,191 +1,28 @@
1
- import fs from 'fs/promises';
2
- import path from 'path';
3
- import { pathToFileURL } from 'url';
4
- import { ensureDir, ensureValidName, exists, normalizeMountPath, writeFile } from '../utils/fs.js';
1
+ import { ensureValidName, normalizeMountPath } from '../utils/fs.js';
5
2
  import { resolveProjectRoot } from '../utils/project.js';
6
3
  import {
7
- renderAppRoutes,
8
- renderAppViewsFile,
9
- renderAppModelsFile,
10
- renderAppValidatorsFile,
11
- renderAppServicesFile,
12
- renderAppSubscribersFile,
13
- renderAppModelTest,
14
- renderAppValidatorTest,
15
- renderAppServiceTest,
16
- renderAppRoutesTest,
17
- renderSettingsApps,
18
- } from '../utils/scaffolds.js';
19
-
20
- const APPS_START = '// AEGIS_APPS_START';
21
- const APPS_END = '// AEGIS_APPS_END';
22
-
23
- function renderAppEntries(apps) {
24
- return apps
25
- .map((app) => ` { name: ${JSON.stringify(app.name)}, mount: ${JSON.stringify(app.mount)} },`)
26
- .join('\n');
27
- }
28
-
29
- function toImportName(appName) {
30
- const safe = appName
31
- .split(/[-_\s]+/)
32
- .filter(Boolean)
33
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
34
- .join('');
35
-
36
- if (!safe) {
37
- return 'appRoutes';
38
- }
39
-
40
- return `${safe.charAt(0).toLowerCase()}${safe.slice(1)}`;
41
- }
42
-
43
- async function readDefaultExport(filePath) {
44
- const moduleUrl = `${pathToFileURL(filePath).href}?t=${Date.now()}`;
45
- const loaded = await import(moduleUrl);
46
- return loaded?.default;
47
- }
48
-
49
- async function detectSettingsMode(projectRoot) {
50
- const single = path.join(projectRoot, 'settings.js');
51
- const split = path.join(projectRoot, 'settings', 'apps.js');
52
-
53
- if (await exists(single)) {
54
- return { mode: 'single', file: single };
55
- }
56
-
57
- if (await exists(split)) {
58
- return { mode: 'split', file: split };
59
- }
60
-
61
- throw new Error(`Not an AegisNode project root: missing ${single} (or legacy ${split})`);
62
- }
63
-
64
- async function readAppsConfig(settingsMode) {
65
- const normalizeApp = (entry) => {
66
- if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string') {
67
- return null;
68
- }
69
-
70
- ensureValidName(entry.name, 'app');
71
- return {
72
- name: entry.name,
73
- mount: normalizeMountPath(entry.mount || `/${entry.name}`),
74
- };
75
- };
76
-
77
- if (settingsMode.mode === 'single') {
78
- const settings = await readDefaultExport(settingsMode.file);
79
- const apps = settings?.apps;
80
-
81
- if (!Array.isArray(apps)) {
82
- throw new Error(`settings.js must export { apps: [] }. File: ${settingsMode.file}`);
83
- }
84
-
85
- return apps.map(normalizeApp).filter(Boolean);
86
- }
87
-
88
- const apps = await readDefaultExport(settingsMode.file);
89
- if (!Array.isArray(apps)) {
90
- throw new Error(`settings/apps.js must export an array. File: ${settingsMode.file}`);
91
- }
92
-
93
- return apps.map(normalizeApp).filter(Boolean);
94
- }
95
-
96
- async function updateSingleSettingsApps(settingsFile, apps) {
97
- const current = await fs.readFile(settingsFile, 'utf8');
98
-
99
- if (!current.includes(APPS_START) || !current.includes(APPS_END)) {
100
- throw new Error(`settings.js is missing ${APPS_START}/${APPS_END} markers: ${settingsFile}`);
101
- }
102
-
103
- const replacement = `${APPS_START}\n${renderAppEntries(apps)}\n ${APPS_END}`;
104
- const updated = current.replace(/\/\/ AEGIS_APPS_START[\s\S]*?\/\/ AEGIS_APPS_END/m, replacement);
105
-
106
- await writeFile(settingsFile, updated);
107
- }
108
-
109
- async function updateAppRegistry(projectRoot, apps, settingsMode) {
110
- if (settingsMode.mode === 'single') {
111
- await updateSingleSettingsApps(settingsMode.file, apps);
112
- return;
113
- }
114
-
115
- await writeFile(path.join(projectRoot, 'settings', 'apps.js'), renderSettingsApps(apps));
116
- }
117
-
118
- async function updateProjectRoutesFile(projectRoot, appName, mountPath) {
119
- const routesFile = path.join(projectRoot, 'routes.js');
120
- if (!(await exists(routesFile))) {
121
- // Keep backward compatibility for legacy projects that still use routes/index.js.
122
- return;
123
- }
124
-
125
- const importName = toImportName(appName);
126
- const importLine = `import ${importName} from './apps/${appName}/routes.js';`;
127
- const routeLine = ` route.use(${JSON.stringify(mountPath)}, ${importName});`;
128
-
129
- let content = await fs.readFile(routesFile, 'utf8');
130
-
131
- if (!content.includes(importLine)) {
132
- if (content.includes('// AEGIS_APP_IMPORTS_START') && content.includes('// AEGIS_APP_IMPORTS_END')) {
133
- content = content.replace('// AEGIS_APP_IMPORTS_END', `${importLine}\n// AEGIS_APP_IMPORTS_END`);
134
- } else {
135
- content = `${importLine}\n${content}`;
136
- }
137
- }
138
-
139
- if (!content.includes(routeLine)) {
140
- if (content.includes('// AEGIS_PROJECT_APP_ROUTES_START') && content.includes('// AEGIS_PROJECT_APP_ROUTES_END')) {
141
- content = content.replace(' // AEGIS_PROJECT_APP_ROUTES_END', `${routeLine}\n // AEGIS_PROJECT_APP_ROUTES_END`);
142
- } else {
143
- const match = content.match(/register\s*\([^)]*\)\s*{[\s\S]*?\n\s*}/m);
144
- if (match) {
145
- content = content.replace(match[0], `${match[0]}\n${routeLine}`);
146
- }
147
- }
148
- }
149
-
150
- await writeFile(routesFile, content);
151
- }
152
-
153
- async function createScaffold(projectRoot, appName) {
154
- const appRoot = path.join(projectRoot, 'apps', appName);
155
-
156
- await ensureDir(appRoot);
157
- await ensureDir(path.join(appRoot, 'tests'));
158
-
159
- await writeFile(path.join(appRoot, 'views.js'), renderAppViewsFile(appName));
160
- await writeFile(path.join(appRoot, 'models.js'), renderAppModelsFile(appName));
161
- await writeFile(path.join(appRoot, 'validators.js'), renderAppValidatorsFile(appName));
162
- await writeFile(path.join(appRoot, 'services.js'), renderAppServicesFile(appName));
163
- await writeFile(path.join(appRoot, 'subscribers.js'), renderAppSubscribersFile(appName));
164
- await writeFile(path.join(appRoot, 'routes.js'), renderAppRoutes(appName));
165
- await writeFile(path.join(appRoot, 'tests', 'models.test.js'), renderAppModelTest(appName));
166
- await writeFile(path.join(appRoot, 'tests', 'validators.test.js'), renderAppValidatorTest(appName));
167
- await writeFile(path.join(appRoot, 'tests', 'services.test.js'), renderAppServiceTest(appName));
168
- await writeFile(path.join(appRoot, 'tests', 'routes.test.js'), renderAppRoutesTest(appName));
169
- }
4
+ detectSettingsMode,
5
+ ensureAppScaffold,
6
+ readAppsConfig,
7
+ updateAppRegistry,
8
+ updateProjectRoutesFile,
9
+ } from '../utils/apps.js';
170
10
 
171
11
  export async function createApp({ appName, projectRoot, mount }) {
172
12
  ensureValidName(appName, 'app');
173
13
 
174
14
  const resolvedRoot = await resolveProjectRoot(projectRoot);
175
-
176
15
  const settingsMode = await detectSettingsMode(resolvedRoot);
177
16
  const normalizedMount = normalizeMountPath(mount || `/${appName}`);
178
17
  const existingApps = await readAppsConfig(settingsMode);
179
18
 
180
19
  if (existingApps.some((entry) => entry.name === appName)) {
181
- throw new Error(`App \"${appName}\" already exists in project settings`);
20
+ throw new Error(`App "${appName}" already exists in project settings`);
182
21
  }
183
22
 
184
- const updatedApps = [...existingApps, { name: appName, mount: normalizedMount }];
185
-
186
- await createScaffold(resolvedRoot, appName);
187
- await updateAppRegistry(resolvedRoot, updatedApps, settingsMode);
23
+ await ensureAppScaffold(resolvedRoot, appName);
24
+ await updateAppRegistry(resolvedRoot, [...existingApps, { name: appName, mount: normalizedMount }], settingsMode);
188
25
  await updateProjectRoutesFile(resolvedRoot, appName, normalizedMount);
189
26
 
190
- console.log(`App \"${appName}\" created at ${path.join(resolvedRoot, 'apps', appName)}`);
27
+ console.log(`App "${appName}" created at ${resolvedRoot}/apps/${appName}`);
191
28
  }
@@ -1,7 +1,9 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { loadProjectConfig } from '../../runtime/config.js';
4
+ import { ensureValidName } from '../utils/fs.js';
4
5
  import { resolveProjectRoot } from '../utils/project.js';
6
+ import { getAppScaffoldEntries, toImportName } from '../utils/apps.js';
5
7
 
6
8
  function createCollector() {
7
9
  const entries = [];
@@ -26,25 +28,47 @@ async function fileExists(filePath) {
26
28
  }
27
29
  }
28
30
 
29
- async function runAppChecks(rootDir, config, collector) {
31
+ function escapeRegExp(value) {
32
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ }
34
+
35
+ async function runAppChecks(rootDir, config, collector, targetAppName = null) {
30
36
  const apps = Array.isArray(config.apps) ? config.apps : [];
37
+ const declaredApps = new Map();
38
+
39
+ for (const app of apps) {
40
+ const appName = app?.name;
41
+ if (typeof appName === 'string' && appName.trim().length > 0) {
42
+ declaredApps.set(appName, app);
43
+ }
44
+ }
31
45
 
32
- if (apps.length === 0) {
46
+ if (!targetAppName && apps.length === 0) {
33
47
  collector.warn('No apps declared in settings.apps.');
34
48
  return;
35
49
  }
36
50
 
37
- collector.ok(`Declared apps: ${apps.map((app) => app.name).join(', ')}`);
51
+ if (!targetAppName) {
52
+ collector.ok(`Declared apps: ${apps.map((app) => app.name).join(', ')}`);
53
+ }
38
54
 
39
- for (const app of apps) {
40
- const appName = app?.name;
55
+ const routesFile = path.join(rootDir, 'routes.js');
56
+ const routesFileExists = await fileExists(routesFile);
57
+ const routesContent = routesFileExists ? await fs.readFile(routesFile, 'utf8') : '';
58
+ const targetApps = targetAppName ? [targetAppName] : apps;
59
+
60
+ for (const appEntry of targetApps) {
61
+ const appName = targetAppName || appEntry?.name;
62
+ const app = targetAppName ? declaredApps.get(appName) || null : appEntry;
41
63
  const mount = app?.mount;
42
64
  if (typeof appName !== 'string' || appName.trim().length === 0) {
43
- collector.error(`Invalid app entry: ${JSON.stringify(app)}`);
65
+ collector.error(`Invalid app entry: ${JSON.stringify(appEntry)}`);
44
66
  continue;
45
67
  }
46
68
 
47
- if (typeof mount !== 'string' || !mount.startsWith('/')) {
69
+ if (!app) {
70
+ collector.warn(`App "${appName}" is not declared in settings.apps.`);
71
+ } else if (typeof mount !== 'string' || !mount.startsWith('/')) {
48
72
  collector.error(`App "${appName}" has invalid mount "${String(mount)}" (must start with /).`);
49
73
  }
50
74
 
@@ -55,17 +79,37 @@ async function runAppChecks(rootDir, config, collector) {
55
79
  continue;
56
80
  }
57
81
 
58
- const requiredFiles = ['routes.js', 'views.js', 'services.js', 'models.js', 'validators.js'];
59
- for (const fileName of requiredFiles) {
60
- const target = path.join(appRoot, fileName);
82
+ for (const entry of getAppScaffoldEntries(appName)) {
83
+ const target = path.join(rootDir, entry.target);
61
84
  if (!(await fileExists(target))) {
62
- collector.warn(`App "${appName}" missing ${fileName}.`);
85
+ collector.warn(`App "${appName}" missing ${path.relative(appRoot, target)}.`);
63
86
  }
64
87
  }
65
88
 
66
- const subscribersFile = path.join(appRoot, 'subscribers.js');
67
- if (!(await fileExists(subscribersFile))) {
68
- collector.warn(`App "${appName}" missing subscribers.js.`);
89
+ if (!app) {
90
+ continue;
91
+ }
92
+
93
+ if (config.autoMountApps === true) {
94
+ collector.ok(`App "${appName}" will be mounted automatically from settings.apps.`);
95
+ continue;
96
+ }
97
+
98
+ if (!routesFileExists) {
99
+ collector.warn(`Project routes.js is missing; app "${appName}" cannot be mounted centrally.`);
100
+ continue;
101
+ }
102
+
103
+ const importPath = `./apps/${appName}/routes.js`;
104
+ const importName = toImportName(appName);
105
+ const routePattern = new RegExp(`route\\.use\\([^\\n]*,\\s*${escapeRegExp(importName)}\\s*\\);`);
106
+
107
+ if (!routesContent.includes(importPath)) {
108
+ collector.warn(`Project routes.js is missing import for app "${appName}".`);
109
+ }
110
+
111
+ if (!routePattern.test(routesContent)) {
112
+ collector.warn(`Project routes.js is missing route.use(...) mount for app "${appName}".`);
69
113
  }
70
114
  }
71
115
  }
@@ -193,7 +237,12 @@ export async function runDoctor({
193
237
  projectRoot,
194
238
  failOnError = true,
195
239
  output = console,
240
+ appName = null,
196
241
  } = {}) {
242
+ if (appName) {
243
+ ensureValidName(appName, 'app');
244
+ }
245
+
197
246
  const resolvedRoot = await resolveProjectRoot(projectRoot || process.cwd());
198
247
  const collector = createCollector();
199
248
 
@@ -202,12 +251,17 @@ export async function runDoctor({
202
251
  const config = await loadProjectConfig(resolvedRoot);
203
252
  collector.ok(`Environment: ${config.env || 'development'}`);
204
253
 
205
- await runAppChecks(resolvedRoot, config, collector);
206
- await runStartupEntryChecks(resolvedRoot, config, collector);
207
- runSecurityChecks(config, collector);
208
- runAuthChecks(config, collector);
209
- runApiChecks(config, collector);
210
- await runTemplateChecks(resolvedRoot, config, collector);
254
+ if (appName) {
255
+ collector.ok(`Doctor scope: app "${appName}"`);
256
+ await runAppChecks(resolvedRoot, config, collector, appName);
257
+ } else {
258
+ await runAppChecks(resolvedRoot, config, collector);
259
+ await runStartupEntryChecks(resolvedRoot, config, collector);
260
+ runSecurityChecks(config, collector);
261
+ runAuthChecks(config, collector);
262
+ runApiChecks(config, collector);
263
+ await runTemplateChecks(resolvedRoot, config, collector);
264
+ }
211
265
 
212
266
  const summary = printSummary(collector.entries, output);
213
267
 
@@ -0,0 +1,65 @@
1
+ import path from 'path';
2
+ import { ensureValidName, normalizeMountPath } from '../utils/fs.js';
3
+ import { resolveProjectRoot } from '../utils/project.js';
4
+ import {
5
+ detectSettingsMode,
6
+ ensureAppScaffold,
7
+ readAppsConfig,
8
+ updateAppRegistry,
9
+ updateProjectRoutesFile,
10
+ } from '../utils/apps.js';
11
+
12
+ export async function runFixApp({ appName, projectRoot, mount } = {}) {
13
+ if (!appName) {
14
+ throw new Error('Missing app name. Usage: aegisnode fix --app <app-name>');
15
+ }
16
+
17
+ ensureValidName(appName, 'app');
18
+
19
+ const resolvedRoot = await resolveProjectRoot(projectRoot || process.cwd());
20
+ const settingsMode = await detectSettingsMode(resolvedRoot);
21
+ const existingApps = await readAppsConfig(settingsMode);
22
+ const existingApp = existingApps.find((entry) => entry.name === appName) || null;
23
+ const appMount = existingApp?.mount || normalizeMountPath(mount || `/${appName}`);
24
+
25
+ const scaffoldResult = await ensureAppScaffold(resolvedRoot, appName, { overwrite: false });
26
+
27
+ let registryUpdated = false;
28
+ if (!existingApp) {
29
+ await updateAppRegistry(
30
+ resolvedRoot,
31
+ [...existingApps, { name: appName, mount: appMount }],
32
+ settingsMode,
33
+ );
34
+ registryUpdated = true;
35
+ }
36
+
37
+ const routesResult = await updateProjectRoutesFile(resolvedRoot, appName, appMount);
38
+ const relativeWritten = scaffoldResult.written.map((target) => path.relative(resolvedRoot, target));
39
+
40
+ if (relativeWritten.length === 0 && !registryUpdated && !routesResult.updatedImport && !routesResult.updatedRoute) {
41
+ console.log(`App "${appName}" is already complete.`);
42
+ } else {
43
+ console.log(`App "${appName}" repaired at ${resolvedRoot}/apps/${appName}`);
44
+ if (relativeWritten.length > 0) {
45
+ console.log(`Created missing files: ${relativeWritten.join(', ')}`);
46
+ }
47
+ if (registryUpdated) {
48
+ console.log(`Added app "${appName}" to settings.apps with mount ${appMount}`);
49
+ }
50
+ if (routesResult.updatedImport || routesResult.updatedRoute) {
51
+ console.log(`Updated routes.js registration for app "${appName}"`);
52
+ }
53
+ }
54
+
55
+ return {
56
+ rootDir: resolvedRoot,
57
+ appName,
58
+ mount: appMount,
59
+ createdFiles: scaffoldResult.written,
60
+ skippedFiles: scaffoldResult.skipped,
61
+ registryUpdated,
62
+ routesUpdated: routesResult.updatedImport || routesResult.updatedRoute,
63
+ routesFile: routesResult.routesFile,
64
+ };
65
+ }
package/src/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import { generateArtifact } from './commands/generate.js';
5
5
  import { runDoctor } from './commands/doctor.js';
6
6
  import { runUpdateDependencies } from './commands/updatedeps.js';
7
7
  import { runGenerateLoader } from './commands/generateloader.js';
8
+ import { runFixApp } from './commands/fixapp.js';
8
9
 
9
10
  function printHelp() {
10
11
  console.log(`AegisNode CLI
@@ -12,10 +13,11 @@ function printHelp() {
12
13
  Usage:
13
14
  aegisnode startproject <project-name>
14
15
  aegisnode createapp <app-name> [--project <path>] [--mount </path>]
16
+ aegisnode fix [--app <app-name>] [--project <path>]
15
17
  aegisnode generate <type> <name> --app <app-name> [--project <path>]
16
18
  aegisnode runserver [--project <path>] [--port <number>]
17
19
  aegisnode generateloader [--project <path>]
18
- aegisnode doctor [--project <path>]
20
+ aegisnode doctor [--app <app-name>] [--project <path>]
19
21
  aegisnode updatedeps [--project <path>]
20
22
 
21
23
  Examples:
@@ -24,9 +26,11 @@ Examples:
24
26
  npm install
25
27
  aegisnode runserver
26
28
  aegisnode createapp users
29
+ aegisnode fix --app users
27
30
  aegisnode generate view user --app users
28
31
  aegisnode generate validator user --app users
29
32
  aegisnode generateloader --project blog
33
+ aegisnode doctor --app users --project blog
30
34
  aegisnode updatedeps --project blog
31
35
  `);
32
36
  }
@@ -123,8 +127,24 @@ export async function runCli(argv) {
123
127
  }
124
128
 
125
129
  case 'doctor': {
130
+ if (positional.length > 0) {
131
+ throw new Error('Doctor app target must use --app <app-name>. Usage: aegisnode doctor [--app <app-name>] [--project <path>]');
132
+ }
126
133
  await runDoctor({
127
134
  projectRoot: flags.project ? String(flags.project) : process.cwd(),
135
+ appName: flags.app ? String(flags.app) : null,
136
+ });
137
+ return;
138
+ }
139
+
140
+ case 'fix':
141
+ case 'fixapp': {
142
+ if (positional.length > 0) {
143
+ throw new Error('Fix app target must use --app <app-name>. Usage: aegisnode fix [--app <app-name>] [--project <path>]');
144
+ }
145
+ await runFixApp({
146
+ appName: flags.app ? String(flags.app) : undefined,
147
+ projectRoot: flags.project ? String(flags.project) : process.cwd(),
128
148
  });
129
149
  return;
130
150
  }
@@ -0,0 +1,253 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { pathToFileURL } from 'url';
4
+ import { ensureDir, ensureValidName, exists, normalizeMountPath, writeFile } from './fs.js';
5
+ import {
6
+ renderAppModelTest,
7
+ renderAppModelsFile,
8
+ renderAppRoutes,
9
+ renderAppRoutesTest,
10
+ renderAppServiceTest,
11
+ renderAppServicesFile,
12
+ renderAppSubscribersFile,
13
+ renderAppUtilsFile,
14
+ renderAppValidatorTest,
15
+ renderAppValidatorsFile,
16
+ renderAppViewsFile,
17
+ renderSettingsApps,
18
+ } from './scaffolds.js';
19
+
20
+ export const APPS_START = '// AEGIS_APPS_START';
21
+ export const APPS_END = '// AEGIS_APPS_END';
22
+
23
+ function renderAppEntries(apps) {
24
+ return apps
25
+ .map((app) => ` { name: ${JSON.stringify(app.name)}, mount: ${JSON.stringify(app.mount)} },`)
26
+ .join('\n');
27
+ }
28
+
29
+ function escapeRegExp(value) {
30
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ }
32
+
33
+ export function toImportName(appName) {
34
+ const safe = appName
35
+ .split(/[-_\s]+/)
36
+ .filter(Boolean)
37
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
38
+ .join('');
39
+
40
+ if (!safe) {
41
+ return 'appRoutes';
42
+ }
43
+
44
+ return `${safe.charAt(0).toLowerCase()}${safe.slice(1)}`;
45
+ }
46
+
47
+ async function readDefaultExport(filePath) {
48
+ const moduleUrl = `${pathToFileURL(filePath).href}?t=${Date.now()}`;
49
+ const loaded = await import(moduleUrl);
50
+ return loaded?.default;
51
+ }
52
+
53
+ export async function detectSettingsMode(projectRoot) {
54
+ const single = path.join(projectRoot, 'settings.js');
55
+ const split = path.join(projectRoot, 'settings', 'apps.js');
56
+
57
+ if (await exists(single)) {
58
+ return { mode: 'single', file: single };
59
+ }
60
+
61
+ if (await exists(split)) {
62
+ return { mode: 'split', file: split };
63
+ }
64
+
65
+ throw new Error(`Not an AegisNode project root: missing ${single} (or legacy ${split})`);
66
+ }
67
+
68
+ export async function readAppsConfig(settingsMode) {
69
+ const normalizeApp = (entry) => {
70
+ if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string') {
71
+ return null;
72
+ }
73
+
74
+ ensureValidName(entry.name, 'app');
75
+ return {
76
+ name: entry.name,
77
+ mount: normalizeMountPath(entry.mount || `/${entry.name}`),
78
+ };
79
+ };
80
+
81
+ if (settingsMode.mode === 'single') {
82
+ const settings = await readDefaultExport(settingsMode.file);
83
+ const apps = settings?.apps;
84
+
85
+ if (!Array.isArray(apps)) {
86
+ throw new Error(`settings.js must export { apps: [] }. File: ${settingsMode.file}`);
87
+ }
88
+
89
+ return apps.map(normalizeApp).filter(Boolean);
90
+ }
91
+
92
+ const apps = await readDefaultExport(settingsMode.file);
93
+ if (!Array.isArray(apps)) {
94
+ throw new Error(`settings/apps.js must export an array. File: ${settingsMode.file}`);
95
+ }
96
+
97
+ return apps.map(normalizeApp).filter(Boolean);
98
+ }
99
+
100
+ async function updateSingleSettingsApps(settingsFile, apps) {
101
+ const current = await fs.readFile(settingsFile, 'utf8');
102
+
103
+ if (!current.includes(APPS_START) || !current.includes(APPS_END)) {
104
+ throw new Error(`settings.js is missing ${APPS_START}/${APPS_END} markers: ${settingsFile}`);
105
+ }
106
+
107
+ const replacement = `${APPS_START}\n${renderAppEntries(apps)}\n ${APPS_END}`;
108
+ const updated = current.replace(/\/\/ AEGIS_APPS_START[\s\S]*?\/\/ AEGIS_APPS_END/m, replacement);
109
+
110
+ await writeFile(settingsFile, updated);
111
+ }
112
+
113
+ export async function updateAppRegistry(projectRoot, apps, settingsMode) {
114
+ if (settingsMode.mode === 'single') {
115
+ await updateSingleSettingsApps(settingsMode.file, apps);
116
+ return;
117
+ }
118
+
119
+ await writeFile(path.join(projectRoot, 'settings', 'apps.js'), renderSettingsApps(apps));
120
+ }
121
+
122
+ export function getAppScaffoldEntries(appName) {
123
+ const appRoot = path.join('apps', appName);
124
+ return [
125
+ {
126
+ target: path.join(appRoot, 'views.js'),
127
+ content: renderAppViewsFile(appName),
128
+ },
129
+ {
130
+ target: path.join(appRoot, 'models.js'),
131
+ content: renderAppModelsFile(appName),
132
+ },
133
+ {
134
+ target: path.join(appRoot, 'validators.js'),
135
+ content: renderAppValidatorsFile(appName),
136
+ },
137
+ {
138
+ target: path.join(appRoot, 'services.js'),
139
+ content: renderAppServicesFile(appName),
140
+ },
141
+ {
142
+ target: path.join(appRoot, 'utils.js'),
143
+ content: renderAppUtilsFile(),
144
+ },
145
+ {
146
+ target: path.join(appRoot, 'subscribers.js'),
147
+ content: renderAppSubscribersFile(appName),
148
+ },
149
+ {
150
+ target: path.join(appRoot, 'routes.js'),
151
+ content: renderAppRoutes(appName),
152
+ },
153
+ {
154
+ target: path.join(appRoot, 'tests', 'models.test.js'),
155
+ content: renderAppModelTest(appName),
156
+ },
157
+ {
158
+ target: path.join(appRoot, 'tests', 'validators.test.js'),
159
+ content: renderAppValidatorTest(appName),
160
+ },
161
+ {
162
+ target: path.join(appRoot, 'tests', 'services.test.js'),
163
+ content: renderAppServiceTest(appName),
164
+ },
165
+ {
166
+ target: path.join(appRoot, 'tests', 'routes.test.js'),
167
+ content: renderAppRoutesTest(appName),
168
+ },
169
+ ];
170
+ }
171
+
172
+ export async function ensureAppScaffold(projectRoot, appName, { overwrite = false } = {}) {
173
+ ensureValidName(appName, 'app');
174
+
175
+ const appRoot = path.join(projectRoot, 'apps', appName);
176
+ await ensureDir(appRoot);
177
+ await ensureDir(path.join(appRoot, 'tests'));
178
+
179
+ const written = [];
180
+ const skipped = [];
181
+
182
+ for (const entry of getAppScaffoldEntries(appName)) {
183
+ const target = path.join(projectRoot, entry.target);
184
+ if (!overwrite && await exists(target)) {
185
+ skipped.push(target);
186
+ continue;
187
+ }
188
+
189
+ await writeFile(target, entry.content);
190
+ written.push(target);
191
+ }
192
+
193
+ return {
194
+ appRoot,
195
+ written,
196
+ skipped,
197
+ };
198
+ }
199
+
200
+ export async function updateProjectRoutesFile(projectRoot, appName, mountPath) {
201
+ const routesFile = path.join(projectRoot, 'routes.js');
202
+ if (!(await exists(routesFile))) {
203
+ // Keep backward compatibility for legacy projects that still use routes/index.js.
204
+ return {
205
+ routesFile,
206
+ updatedImport: false,
207
+ updatedRoute: false,
208
+ skipped: true,
209
+ };
210
+ }
211
+
212
+ const importName = toImportName(appName);
213
+ const importLine = `import ${importName} from './apps/${appName}/routes.js';`;
214
+ const routeLine = ` route.use(${JSON.stringify(mountPath)}, ${importName});`;
215
+ const routePattern = new RegExp(`route\\.use\\([^\\n]*,\\s*${escapeRegExp(importName)}\\s*\\);`);
216
+
217
+ let content = await fs.readFile(routesFile, 'utf8');
218
+ let updatedImport = false;
219
+ let updatedRoute = false;
220
+
221
+ if (!content.includes(importLine)) {
222
+ if (content.includes('// AEGIS_APP_IMPORTS_START') && content.includes('// AEGIS_APP_IMPORTS_END')) {
223
+ content = content.replace('// AEGIS_APP_IMPORTS_END', `${importLine}\n// AEGIS_APP_IMPORTS_END`);
224
+ } else {
225
+ content = `${importLine}\n${content}`;
226
+ }
227
+ updatedImport = true;
228
+ }
229
+
230
+ if (!routePattern.test(content)) {
231
+ if (content.includes('// AEGIS_PROJECT_APP_ROUTES_START') && content.includes('// AEGIS_PROJECT_APP_ROUTES_END')) {
232
+ content = content.replace(' // AEGIS_PROJECT_APP_ROUTES_END', `${routeLine}\n // AEGIS_PROJECT_APP_ROUTES_END`);
233
+ updatedRoute = true;
234
+ } else {
235
+ const match = content.match(/register\s*\([^)]*\)\s*{[\s\S]*?\n\s*}/m);
236
+ if (match) {
237
+ content = content.replace(match[0], `${match[0]}\n${routeLine}`);
238
+ updatedRoute = true;
239
+ }
240
+ }
241
+ }
242
+
243
+ if (updatedImport || updatedRoute) {
244
+ await writeFile(routesFile, content);
245
+ }
246
+
247
+ return {
248
+ routesFile,
249
+ updatedImport,
250
+ updatedRoute,
251
+ skipped: false,
252
+ };
253
+ }
@@ -403,6 +403,19 @@ export default {
403
403
  `;
404
404
  }
405
405
 
406
+ export function renderAppUtilsFile() {
407
+ return `/**
408
+ * App-local pure utilities.
409
+ *
410
+ * Use this file for small reusable functions that belong only to this app.
411
+ * Keep database access in models, business workflows in services, and request
412
+ * validation/sanitization in validators.
413
+ */
414
+
415
+ export {};
416
+ `;
417
+ }
418
+
406
419
  export function renderAppSubscribersFile(appName) {
407
420
  return `export default function register${toPascalCase(appName)}Subscribers({ events, logger }) {
408
421
  events.subscribe('app.booted', ({ appName }) => {