@noego/app 0.0.6 → 0.0.9

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.
@@ -3,7 +3,8 @@
3
3
  "allow": [
4
4
  "Bash(cat:*)",
5
5
  "Bash(curl:*)",
6
- "Bash(noego dev)"
6
+ "Bash(noego dev)",
7
+ "WebSearch"
7
8
  ],
8
9
  "deny": [],
9
10
  "ask": []
package/DEVELOPING.md ADDED
@@ -0,0 +1,73 @@
1
+ **Purpose**
2
+ - Run and test dev watchers without blocking your terminal.
3
+ - Verify backend/frontend restarts on file changes using short, backgrounded runs with logs and PIDs.
4
+
5
+ **Prerequisites**
6
+ - Node 20+.
7
+ - This repo installed (run `npm install` once).
8
+
9
+ **Local Watch Harness (Fast, Backgrounded)**
10
+ - Start (runs ~10–15s, then exits on its own):
11
+ - `Timestamp=$(date +%s); LOG=watch-harness-$Timestamp.log; node scripts/watch-harness.mjs > $LOG 2>&1 & echo $! > watch-harness.pid`
12
+ - Observe:
13
+ - `tail -f $LOG`
14
+ - Look for lines like:
15
+ - `🔄 change detected (change): server/stitch.yaml` (BACKEND)
16
+ - `🔄 change detected (change): hammer.config.yml` (SHARED)
17
+ - `🚀 [backend restart #X]` / `🚀 [frontend restart #X]`
18
+ - `✅ restart sequence complete`
19
+ - Stop (if ever needed):
20
+ - `kill $(cat watch-harness.pid)`
21
+ - What it does
22
+ - Seeds a temp tree under `.watch-harness/`.
23
+ - Watches directories (server/ui/middleware/openapi/repo) and classifies events using your globs.
24
+ - Spawns mock backend/frontend child processes, simulates backend/frontend/shared changes, restarts the right process(es), then shuts down.
25
+
26
+ **Real App Dev (noblelaw) in Background**
27
+ - Start dev for the noblelaw project in the background with logs:
28
+ - `NO_COLOR=1 node ./bin/app.js dev --root /Users/shavauhngabay/dev/noblelaw > noblelaw-dev.log 2>&1 & echo $! > noblelaw-dev.pid`
29
+ - Observe:
30
+ - `tail -f noblelaw-dev.log`
31
+ - Expect on startup: backend on port 3002, frontend (Vite) on 3001, router on 3000.
32
+ - Make edits to trigger restarts:
33
+ - Backend restart: edit `server/services/*.ts`, `middleware/**/*.ts`, `server/openapi/**/*.yaml`, or `server/repo/**/*.sql`.
34
+ - Frontend restart: edit `ui/**/*.ts`, `ui/openapi/**/*.yaml`.
35
+ - Shared restart: edit `index.ts` or `hammer.config.yml`.
36
+ - Note: `.svelte` changes do not restart the server; Vite HMR handles them.
37
+ - Look for logs:
38
+ - `🔄 FILE CHANGE DETECTED: …`
39
+ - `Type: BACKEND | FRONTEND | SHARED`
40
+ - `🚀 [RESTART #…] Starting …`
41
+ - `✅ Restart complete`
42
+ - Stop:
43
+ - `kill $(cat noblelaw-dev.pid)`
44
+ - Free busy ports (if you see EADDRINUSE):
45
+ - `for p in 3000 3001 3002; do P=$(lsof -t -nP -iTCP:$p -sTCP:LISTEN 2>/dev/null); [ -n "$P" ] && kill -9 $P; done`
46
+
47
+ **How The Watcher Works**
48
+ - Chokidar now watches concrete directories/files so it always attaches:
49
+ - Directories: `server/`, `ui/`, `middleware/`, `server/openapi/`, `server/repo/`, `ui/openapi/`.
50
+ - Files: `index.ts`, `hammer.config.yml`, `server/stitch.yaml`, `ui/stitch.yaml`.
51
+ - Classification still uses your original globs (picomatch):
52
+ - Backend: `server/**/*.ts`, `middleware/**/*.ts`, `server/stitch.yaml`, `server/openapi/**/*.yaml`, `server/repo/**/*.sql`.
53
+ - Frontend: `ui/**/*.ts`, `ui/stitch.yaml`, `ui/openapi/**/*.yaml`.
54
+ - Shared: `index.ts`, `hammer.config.yml` (plus any app-level watch entries).
55
+ - This avoids the “ready but no watchers” problem when globs don’t match initial files.
56
+
57
+ **Troubleshooting**
58
+ - No restart logs after editing a file:
59
+ - Confirm the file lives under a watched directory and matches one of the classification globs above.
60
+ - `.svelte` isn’t supposed to restart—use Vite HMR in the browser.
61
+ - Watcher error (EMFILE or similar):
62
+ - The CLI now fails fast on watcher errors with a clear log and exit.
63
+ - Ensure you’re not watching the entire repo root or `node_modules/`; keep to `server/`, `ui/`, `middleware/` and the specific YAML/SQL roots.
64
+ - Ports in use (EADDRINUSE):
65
+ - Free 3000/3001/3002 using the snippet above and restart dev.
66
+ - Clean up stray mock processes from the harness (if any):
67
+ - `pkill -f "\[backend\]"; pkill -f "\[frontend\]"`
68
+
69
+ **Operational Tips**
70
+ - Always background long-running scripts and write the PID to a file.
71
+ - Tail logs for verification and kill by PID when done.
72
+ - Keep a hard timeout in any custom test harness so it self-exits if something goes wrong.
73
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noego/app",
3
- "version": "0.0.6",
3
+ "version": "0.0.9",
4
4
  "description": "Production build tool for Dinner/Forge apps.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,10 @@
14
14
  "./client": {
15
15
  "import": "./src/client.js",
16
16
  "types": "./types/client.d.ts"
17
+ },
18
+ "./config": {
19
+ "import": "./src/runtime/config.js",
20
+ "types": "./types/config.d.ts"
17
21
  }
18
22
  },
19
23
  "scripts": {
@@ -25,6 +29,7 @@
25
29
  "license": "MIT",
26
30
  "author": "App Build CLI",
27
31
  "dependencies": {
32
+ "glob-parent": "^6.0.2",
28
33
  "deepmerge": "^4.3.1",
29
34
  "picomatch": "^2.3.1",
30
35
  "yaml": "^2.6.0"
@@ -34,5 +39,7 @@
34
39
  "@noego/forge": "*",
35
40
  "express": "*"
36
41
  },
37
- "devDependencies": {}
42
+ "devDependencies": {
43
+ "chokidar": "^4.0.3"
44
+ }
38
45
  }
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import chokidar from 'chokidar';
6
+ import picomatch from 'picomatch';
7
+
8
+ const PLAYGROUND_DIR = path.join(process.cwd(), '.watch-harness');
9
+ const sharedPatterns = ['index.ts', 'hammer.config.yml'];
10
+ const backendPatterns = [
11
+ 'server/**/*.ts',
12
+ 'middleware/**/*.ts',
13
+ 'server/stitch.yaml',
14
+ 'server/openapi/**/*.yaml',
15
+ 'server/repo/**/*.sql'
16
+ ];
17
+ const frontendPatterns = [
18
+ 'ui/**/*.ts',
19
+ 'ui/stitch.yaml',
20
+ 'ui/openapi/**/*.yaml'
21
+ ];
22
+ const allPatterns = [...sharedPatterns, ...backendPatterns, ...frontendPatterns];
23
+
24
+ const seedFiles = {
25
+ 'index.ts': '// root entry\n',
26
+ 'hammer.config.yml': '# config\n',
27
+ 'server/server.ts': '// server bootstrap\n',
28
+ 'server/services/admin_service.ts': '// admin service seed\n',
29
+ 'middleware/logger.ts': '// middleware seed\n',
30
+ 'server/stitch.yaml': 'openapi: 3.0.0\n',
31
+ 'server/openapi/routes.yaml': 'paths: {}\n',
32
+ 'server/repo/example/query.sql': 'select 1;\n',
33
+ 'ui/frontend.ts': '// frontend entry\n',
34
+ 'ui/stitch.yaml': 'paths: {}\n',
35
+ 'ui/openapi/page.yaml': 'paths: {}\n',
36
+ 'ui/pages/home.ts': 'export const home = true;\n'
37
+ };
38
+
39
+ const changeSequence = [
40
+ { label: 'backend', file: 'server/services/admin_service.ts' },
41
+ { label: 'frontend', file: 'ui/pages/home.ts' },
42
+ { label: 'shared', file: 'hammer.config.yml' }
43
+ ];
44
+
45
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
46
+
47
+ let backendProc = null;
48
+ let frontendProc = null;
49
+ let watcher = null;
50
+ let shuttingDown = false;
51
+ let pendingRestart = false;
52
+ let backendRestartCount = 0;
53
+ let frontendRestartCount = 0;
54
+ let readyHandled = false;
55
+
56
+ async function preparePlayground() {
57
+ await fs.rm(PLAYGROUND_DIR, { recursive: true, force: true }).catch(() => {});
58
+ await fs.mkdir(PLAYGROUND_DIR, { recursive: true });
59
+ await Promise.all(
60
+ Object.entries(seedFiles).map(async ([relative, contents]) => {
61
+ const absolute = path.join(PLAYGROUND_DIR, relative);
62
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
63
+ await fs.writeFile(absolute, contents);
64
+ })
65
+ );
66
+ }
67
+
68
+ function spawnChild(label) {
69
+ const child = spawn(process.execPath, ['-e', `
70
+ console.log('[${label}] booting (pid', process.pid, ')');
71
+ let counter = 0;
72
+ setInterval(() => {
73
+ counter += 1;
74
+ console.log('[${label}] heartbeat #' + counter);
75
+ }, 750);
76
+ `], {
77
+ stdio: ['ignore', 'inherit', 'inherit']
78
+ });
79
+ child.on('exit', (code, signal) => {
80
+ console.log(`[${label}] exited`, { code, signal });
81
+ });
82
+ return child;
83
+ }
84
+
85
+ function startBackend() {
86
+ backendRestartCount += 1;
87
+ console.log(`🚀 [backend restart #${backendRestartCount}] starting mock backend`);
88
+ backendProc = spawnChild('backend');
89
+ }
90
+
91
+ function startFrontend() {
92
+ frontendRestartCount += 1;
93
+ console.log(`🚀 [frontend restart #${frontendRestartCount}] starting mock frontend`);
94
+ frontendProc = spawnChild('frontend');
95
+ }
96
+
97
+ function stopProcess(proc, label) {
98
+ return new Promise((resolve) => {
99
+ if (!proc || proc.killed) {
100
+ return resolve();
101
+ }
102
+ const timeout = setTimeout(() => {
103
+ console.warn(`[${label}] did not exit in time, forcing SIGKILL`);
104
+ try {
105
+ proc.kill('SIGKILL');
106
+ } catch {}
107
+ resolve();
108
+ }, 2000);
109
+ proc.once('exit', () => {
110
+ clearTimeout(timeout);
111
+ resolve();
112
+ });
113
+ try {
114
+ proc.kill('SIGTERM');
115
+ } catch {
116
+ clearTimeout(timeout);
117
+ resolve();
118
+ }
119
+ });
120
+ }
121
+
122
+ async function shutdown(exitCode = 0) {
123
+ if (shuttingDown) return;
124
+ shuttingDown = true;
125
+ console.log('[harness] shutting down...');
126
+ try {
127
+ if (watcher) {
128
+ await watcher.close();
129
+ }
130
+ } catch (error) {
131
+ console.error('[harness] watcher close error', error);
132
+ }
133
+ await stopProcess(backendProc, 'backend');
134
+ await stopProcess(frontendProc, 'frontend');
135
+ await fs.rm(PLAYGROUND_DIR, { recursive: true, force: true }).catch(() => {});
136
+ process.exit(exitCode);
137
+ }
138
+
139
+ async function handleFileChange(reason, relativePath, matchers) {
140
+ if (pendingRestart) return;
141
+ pendingRestart = true;
142
+ const normalized = relativePath.replace(/\\/g, '/');
143
+ const { sharedMatcher, backendMatcher, frontendMatcher } = matchers;
144
+ let restartBackend = false;
145
+ let restartFrontend = false;
146
+
147
+ if (sharedMatcher && sharedMatcher(normalized)) {
148
+ console.log(`\n${'='.repeat(60)}`);
149
+ console.log(`🔄 change detected (${reason}): ${normalized}`);
150
+ console.log(' type: SHARED -> restarting backend + frontend');
151
+ console.log(`${'='.repeat(60)}\n`);
152
+ restartBackend = true;
153
+ restartFrontend = true;
154
+ } else if (backendMatcher && backendMatcher(normalized)) {
155
+ console.log(`\n${'='.repeat(60)}`);
156
+ console.log(`🔄 change detected (${reason}): ${normalized}`);
157
+ console.log(' type: BACKEND -> restarting backend only');
158
+ console.log(`${'='.repeat(60)}\n`);
159
+ restartBackend = true;
160
+ } else if (frontendMatcher && frontendMatcher(normalized)) {
161
+ console.log(`\n${'='.repeat(60)}`);
162
+ console.log(`🔄 change detected (${reason}): ${normalized}`);
163
+ console.log(' type: FRONTEND -> restarting frontend only');
164
+ console.log(`${'='.repeat(60)}\n`);
165
+ restartFrontend = true;
166
+ } else {
167
+ console.log(`[harness] change ignored (no matcher): ${normalized}`);
168
+ }
169
+
170
+ if (restartBackend) {
171
+ await stopProcess(backendProc, 'backend');
172
+ startBackend();
173
+ }
174
+ if (restartFrontend) {
175
+ await stopProcess(frontendProc, 'frontend');
176
+ startFrontend();
177
+ }
178
+ if (restartBackend || restartFrontend) {
179
+ console.log('✅ restart sequence complete\n');
180
+ }
181
+ pendingRestart = false;
182
+ }
183
+
184
+ async function simulateChanges() {
185
+ for (const step of changeSequence) {
186
+ await delay(500);
187
+ const target = path.join(PLAYGROUND_DIR, step.file);
188
+ console.log(`[harness] simulating ${step.label} change: ${step.file}`);
189
+ await fs.appendFile(target, `\n// touched ${Date.now()}`);
190
+ }
191
+ console.log('[harness] change simulation complete');
192
+ }
193
+
194
+ async function runHarness() {
195
+ await preparePlayground();
196
+
197
+ const normalizePattern = (pattern) => pattern;
198
+ const sharedMatcher = picomatch(sharedPatterns.map(normalizePattern));
199
+ const backendMatcher = picomatch(backendPatterns.map(normalizePattern));
200
+ const frontendMatcher = picomatch(frontendPatterns.map(normalizePattern));
201
+
202
+ watcher = chokidar.watch(allPatterns, {
203
+ cwd: PLAYGROUND_DIR,
204
+ ignoreInitial: true
205
+ });
206
+
207
+ const autoTimeout = setTimeout(() => {
208
+ console.error('[harness] timeout waiting for restarts');
209
+ shutdown(1);
210
+ }, 15000);
211
+
212
+ watcher.on('ready', async () => {
213
+ if (readyHandled) {
214
+ return;
215
+ }
216
+ readyHandled = true;
217
+ console.log('[harness] watcher ready');
218
+ startBackend();
219
+ startFrontend();
220
+ await simulateChanges();
221
+ setTimeout(async () => {
222
+ clearTimeout(autoTimeout);
223
+ await shutdown(0);
224
+ }, 1500);
225
+ });
226
+
227
+ watcher.on('all', (event, file) => {
228
+ if (!['add', 'change', 'unlink'].includes(event)) {
229
+ return;
230
+ }
231
+ handleFileChange(event, file, { sharedMatcher, backendMatcher, frontendMatcher });
232
+ });
233
+
234
+ watcher.on('error', async (error) => {
235
+ console.error('[harness] watcher error', error);
236
+ await shutdown(1);
237
+ });
238
+ }
239
+
240
+ process.on('SIGINT', () => shutdown(0));
241
+ process.on('SIGTERM', () => shutdown(0));
242
+
243
+ runHarness().catch((error) => {
244
+ console.error('[harness] fatal error', error);
245
+ shutdown(1);
246
+ });
@@ -1,12 +1,48 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { spawn } from 'node:child_process';
3
+ import { spawn, execSync } from 'node:child_process';
4
4
  import { createBuildContext } from '../build/context.js';
5
5
  import { findConfigFile } from '../runtime/index.js';
6
6
  import { loadConfig } from '../runtime/config-loader.js';
7
+ import globParent from 'glob-parent';
7
8
 
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
 
11
+ /**
12
+ * Kill a process and all its descendants (entire process tree)
13
+ * This ensures no orphaned processes remain when killing a parent
14
+ */
15
+ function killProcessTree(pid, signal = 'SIGKILL') {
16
+ if (!pid) return;
17
+
18
+ try {
19
+ // On Unix systems, use pkill to kill all descendants
20
+ if (process.platform !== 'win32') {
21
+ // Kill all child processes first
22
+ try {
23
+ execSync(`pkill -P ${pid}`, { stdio: 'ignore' });
24
+ } catch (e) {
25
+ // No children or already dead, that's fine
26
+ }
27
+ // Then kill the parent
28
+ try {
29
+ process.kill(pid, signal);
30
+ } catch (e) {
31
+ // Process may already be dead
32
+ }
33
+ } else {
34
+ // On Windows, use taskkill with /T flag to kill process tree
35
+ try {
36
+ execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' });
37
+ } catch (e) {
38
+ // Process may already be dead
39
+ }
40
+ }
41
+ } catch (error) {
42
+ // Ignore errors - process might already be dead
43
+ }
44
+ }
45
+
10
46
  export async function runDev(config) {
11
47
  const context = createBuildContext(config);
12
48
  const { logger } = context;
@@ -222,8 +258,14 @@ async function runSplitServeNoWatch(tsxExecutable, tsxArgs, baseEnv, routerPort,
222
258
  detached: false
223
259
  });
224
260
 
261
+ // Track if we're already shutting down
262
+ let isShuttingDown = false;
263
+
225
264
  // Handle shutdown
226
265
  const shutdown = (exitCode = 0) => {
266
+ if (isShuttingDown) return;
267
+ isShuttingDown = true;
268
+
227
269
  logger.info('Shutting down split-serve processes...');
228
270
  if (backendProc && !backendProc.killed) {
229
271
  backendProc.kill('SIGTERM');
@@ -259,10 +301,34 @@ async function runSplitServeNoWatch(tsxExecutable, tsxArgs, baseEnv, routerPort,
259
301
  shutdown(1);
260
302
  });
261
303
 
262
- // Set up signal handlers to clean up on termination
304
+ // Handle graceful shutdown signals
263
305
  process.on('SIGINT', () => shutdown(0));
264
306
  process.on('SIGTERM', () => shutdown(0));
265
307
 
308
+ // Handle process exit - ensures children are killed even if parent crashes
309
+ process.on('exit', () => {
310
+ // Synchronous cleanup only - exit event doesn't allow async
311
+ // Kill entire process tree (including tsx wrappers and their children)
312
+ if (backendProc && backendProc.pid && !backendProc.killed) {
313
+ killProcessTree(backendProc.pid);
314
+ }
315
+ if (frontendProc && frontendProc.pid && !frontendProc.killed) {
316
+ killProcessTree(frontendProc.pid);
317
+ }
318
+ });
319
+
320
+ // Handle uncaught exceptions
321
+ process.on('uncaughtException', (error) => {
322
+ logger.error('Uncaught exception:', error);
323
+ shutdown(1);
324
+ });
325
+
326
+ // Handle unhandled promise rejections
327
+ process.on('unhandledRejection', (reason, promise) => {
328
+ logger.error('Unhandled rejection at:', promise, 'reason:', reason);
329
+ shutdown(1);
330
+ });
331
+
266
332
  // Wait a moment for spawned processes to start
267
333
  await new Promise(resolve => setTimeout(resolve, 2000));
268
334
 
@@ -291,44 +357,74 @@ async function runSplitServeNoWatch(tsxExecutable, tsxArgs, baseEnv, routerPort,
291
357
  */
292
358
  async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv, yamlConfig, configFilePath, routerPort, frontendPort, backendPort, logger) {
293
359
  const { createRequire } = await import('node:module');
294
- const root = yamlConfig.root || path.dirname(configFilePath);
360
+ // Use context.config.rootDir for consistency with spawned processes
361
+ const root = context.config.rootDir;
362
+ logger.debug(`Using root directory for file watching: ${root}`);
295
363
  const requireFromRoot = createRequire(path.join(root, 'package.json'));
296
364
  const chokidar = requireFromRoot('chokidar');
297
365
  const picomatch = (await import('picomatch')).default;
298
366
 
299
- const resolvePattern = (pattern) => {
300
- if (path.isAbsolute(pattern)) return pattern;
301
- return path.resolve(root, pattern);
367
+ // Keep patterns relative for chokidar to work properly with globs
368
+ // Absolute patterns with globs don't work well, especially in chokidar v4
369
+ const normalizePattern = (pattern) => {
370
+ // If pattern is absolute, make it relative to root
371
+ if (path.isAbsolute(pattern)) {
372
+ const normalized = path.relative(root, pattern);
373
+ logger.debug(`Normalized pattern: ${pattern} -> ${normalized}`);
374
+ return normalized;
375
+ }
376
+ logger.debug(`Pattern already relative: ${pattern}`);
377
+ return pattern;
302
378
  };
303
-
379
+
304
380
  // Collect watch patterns
305
381
  const backendPatterns = [];
306
382
  const frontendPatterns = [];
307
383
  const sharedPatterns = [];
308
-
384
+
309
385
  // App watch patterns (restart both)
310
386
  if (yamlConfig.app?.watch) {
311
387
  for (const pattern of yamlConfig.app.watch) {
312
- sharedPatterns.push(resolvePattern(pattern));
388
+ sharedPatterns.push(normalizePattern(pattern));
313
389
  }
314
390
  }
315
-
391
+
316
392
  // Server watch patterns (restart backend only)
317
393
  if (yamlConfig.server?.watch) {
318
394
  for (const pattern of yamlConfig.server.watch) {
319
- backendPatterns.push(resolvePattern(pattern));
395
+ backendPatterns.push(normalizePattern(pattern));
320
396
  }
321
397
  }
322
-
398
+
323
399
  // Client watch patterns (restart frontend only)
324
400
  if (yamlConfig.client?.watch) {
325
401
  for (const pattern of yamlConfig.client.watch) {
326
- frontendPatterns.push(resolvePattern(pattern));
402
+ frontendPatterns.push(normalizePattern(pattern));
327
403
  }
328
404
  }
329
405
 
330
- // Combine all patterns for chokidar
406
+ // Combine all patterns (for classification)
331
407
  const allPatterns = [...sharedPatterns, ...backendPatterns, ...frontendPatterns];
408
+
409
+ // Derive concrete watch targets (directories/files) from the patterns
410
+ // We watch directories; we use picomatch on the original patterns to classify events.
411
+ const watchTargets = new Set();
412
+ const GLOB_CHARS = /[\\*?\[\]\{\}\(\)!+@]/; // simple glob detector
413
+ const addWatchTarget = (pattern) => {
414
+ if (!pattern) return;
415
+ const rel = normalizePattern(pattern);
416
+ // Negative patterns are for filtering; don't watch them directly
417
+ if (rel.startsWith('!')) return;
418
+ const isGlob = GLOB_CHARS.test(rel);
419
+ if (!isGlob) {
420
+ // Literal path; watch the file itself
421
+ watchTargets.add(rel);
422
+ return;
423
+ }
424
+ const base = globParent(rel);
425
+ watchTargets.add(base && base.length > 0 ? base : '.');
426
+ };
427
+ for (const p of allPatterns) addWatchTarget(p);
332
428
 
333
429
  if (allPatterns.length === 0) {
334
430
  logger.warn('No watch patterns configured. Watching disabled.');
@@ -339,18 +435,23 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
339
435
  logger.info(` Shared: ${sharedPatterns.length} patterns`);
340
436
  logger.info(` Backend: ${backendPatterns.length} patterns`);
341
437
  logger.info(` Frontend: ${frontendPatterns.length} patterns`);
438
+ logger.info(` Watch targets (${watchTargets.size}): ${JSON.stringify(Array.from(watchTargets), null, 2)}`);
342
439
 
343
440
  // Create matchers
344
441
  const sharedMatcher = sharedPatterns.length > 0 ? picomatch(sharedPatterns) : null;
345
442
  const backendMatcher = backendPatterns.length > 0 ? picomatch(backendPatterns) : null;
346
443
  const frontendMatcher = frontendPatterns.length > 0 ? picomatch(frontendPatterns) : null;
347
-
444
+
348
445
  let backendProc = null;
349
446
  let frontendProc = null;
350
447
  let pending = false;
351
-
448
+ let backendRestartCount = 0;
449
+ let frontendRestartCount = 0;
450
+ let isShuttingDown = false;
451
+
352
452
  const startBackend = () => {
353
- logger.info(`Starting backend on port ${backendPort}...`);
453
+ backendRestartCount++;
454
+ logger.info(`🚀 [RESTART #${backendRestartCount}] Starting backend on port ${backendPort}...`);
354
455
  const backendEnv = {
355
456
  ...baseEnv,
356
457
  NOEGO_SERVICE: 'backend',
@@ -372,7 +473,8 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
372
473
  };
373
474
 
374
475
  const startFrontend = () => {
375
- logger.info(`Starting frontend on port ${frontendPort}...`);
476
+ frontendRestartCount++;
477
+ logger.info(`🚀 [RESTART #${frontendRestartCount}] Starting frontend on port ${frontendPort}...`);
376
478
  const frontendEnv = {
377
479
  ...baseEnv,
378
480
  NOEGO_SERVICE: 'frontend',
@@ -396,6 +498,7 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
396
498
  const stopBackend = () =>
397
499
  new Promise((resolve) => {
398
500
  if (!backendProc) return resolve();
501
+ logger.info(`⏹️ Stopping backend (preparing for restart #${backendRestartCount + 1})...`);
399
502
  const to = setTimeout(resolve, 2000);
400
503
  backendProc.once('exit', () => {
401
504
  clearTimeout(to);
@@ -411,6 +514,7 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
411
514
  const stopFrontend = () =>
412
515
  new Promise((resolve) => {
413
516
  if (!frontendProc) return resolve();
517
+ logger.info(`⏹️ Stopping frontend (preparing for restart #${frontendRestartCount + 1})...`);
414
518
  const to = setTimeout(resolve, 2000);
415
519
  frontendProc.once('exit', () => {
416
520
  clearTimeout(to);
@@ -422,32 +526,74 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
422
526
  resolve();
423
527
  }
424
528
  });
425
-
529
+
530
+ async function shutdown(signal = 'SIGTERM', exitCode = 0) {
531
+ if (isShuttingDown) return;
532
+ isShuttingDown = true;
533
+
534
+ logger.info(`Shutting down split-serve processes (signal: ${signal})...`);
535
+
536
+ try {
537
+ if (watcher) {
538
+ await watcher.close();
539
+ }
540
+
541
+ await stopBackend();
542
+ await stopFrontend();
543
+
544
+ logger.info('All processes shut down successfully');
545
+ } catch (error) {
546
+ logger.error('Error during shutdown:', error);
547
+ }
548
+
549
+ process.exit(exitCode);
550
+ }
551
+
552
+ async function handleWatcherError(error) {
553
+ if (isShuttingDown) {
554
+ return;
555
+ }
556
+ logger.error('Watcher error:', error);
557
+ logger.error('File watching failed; shutting down dev server.');
558
+ await shutdown('watcherError', 1);
559
+ }
560
+
426
561
  const handleFileChange = async (reason, file) => {
427
562
  if (pending) return;
428
563
  pending = true;
429
-
430
- const absPath = path.isAbsolute(file) ? file : path.resolve(root, file);
431
-
564
+
565
+ // With cwd set, chokidar reports relative paths
566
+ // Our patterns are now relative, so match against the relative path
567
+ const relativePath = path.isAbsolute(file) ? path.relative(root, file) : file;
568
+
432
569
  // Determine which service(s) to restart
433
570
  let restartBackend = false;
434
571
  let restartFrontend = false;
435
-
436
- if (sharedMatcher && sharedMatcher(absPath)) {
572
+
573
+ if (sharedMatcher && sharedMatcher(relativePath)) {
437
574
  // Shared file changed - restart both
438
- logger.info(`Shared file changed (${reason}): ${file}`);
575
+ logger.info(`\n${'='.repeat(80)}`);
576
+ logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
577
+ logger.info(` Type: SHARED - Will restart BOTH backend and frontend`);
578
+ logger.info('='.repeat(80));
439
579
  restartBackend = true;
440
580
  restartFrontend = true;
441
- } else if (backendMatcher && backendMatcher(absPath)) {
581
+ } else if (backendMatcher && backendMatcher(relativePath)) {
442
582
  // Backend file changed
443
- logger.info(`Backend file changed (${reason}): ${file}`);
583
+ logger.info(`\n${'='.repeat(80)}`);
584
+ logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
585
+ logger.info(` Type: BACKEND - Will restart backend only`);
586
+ logger.info('='.repeat(80));
444
587
  restartBackend = true;
445
- } else if (frontendMatcher && frontendMatcher(absPath)) {
588
+ } else if (frontendMatcher && frontendMatcher(relativePath)) {
446
589
  // Frontend file changed
447
- logger.info(`Frontend file changed (${reason}): ${file}`);
590
+ logger.info(`\n${'='.repeat(80)}`);
591
+ logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
592
+ logger.info(` Type: FRONTEND - Will restart frontend only`);
593
+ logger.info('='.repeat(80));
448
594
  restartFrontend = true;
449
595
  }
450
-
596
+
451
597
  // Restart appropriate service(s)
452
598
  if (restartBackend) {
453
599
  await stopBackend();
@@ -457,20 +603,51 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
457
603
  await stopFrontend();
458
604
  startFrontend();
459
605
  }
460
-
606
+
607
+ if (restartBackend || restartFrontend) {
608
+ logger.info(`✅ Restart complete\n`);
609
+ }
610
+
461
611
  pending = false;
462
612
  };
463
613
 
464
614
  // Create watcher
465
- const watcher = chokidar.watch(allPatterns, {
466
- ignoreInitial: true,
467
- cwd: root
468
- });
469
-
615
+ // Use cwd with relative patterns for proper glob support
616
+ logger.info(`[WATCHER] Creating watcher with cwd: ${root}`);
617
+ logger.info(`[WATCHER] Patterns (classification only): ${JSON.stringify(allPatterns, null, 2)}`);
618
+ logger.info(`[WATCHER] Concrete targets: ${JSON.stringify(Array.from(watchTargets), null, 2)}`);
619
+
620
+ let watcher;
621
+ try {
622
+ watcher = chokidar.watch(Array.from(watchTargets), {
623
+ cwd: root,
624
+ ignoreInitial: true
625
+ });
626
+ } catch (error) {
627
+ await handleWatcherError(error);
628
+ return;
629
+ }
630
+
631
+ // Debug: Log what patterns we're actually watching
632
+ logger.info('Watch patterns:', allPatterns);
633
+
470
634
  watcher
471
- .on('add', (p) => handleFileChange('add', p))
472
- .on('change', (p) => handleFileChange('change', p))
473
- .on('unlink', (p) => handleFileChange('unlink', p));
635
+ .on('add', (p) => {
636
+ logger.info(`[WATCHER] File add event: ${p}`);
637
+ handleFileChange('add', p);
638
+ })
639
+ .on('change', (p) => {
640
+ logger.info(`[WATCHER] File change event: ${p}`);
641
+ handleFileChange('change', p);
642
+ })
643
+ .on('unlink', (p) => {
644
+ logger.info(`[WATCHER] File unlink event: ${p}`);
645
+ handleFileChange('unlink', p);
646
+ })
647
+ .on('ready', () => {
648
+ logger.info('File watcher is ready');
649
+ })
650
+ .on('error', handleWatcherError);
474
651
 
475
652
  // Start initial processes
476
653
  startBackend();
@@ -497,17 +674,38 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
497
674
  // Use dynamic import instead of require for ES module compatibility
498
675
  await import(runtimeEntryPath);
499
676
 
500
- // Handle shutdown
501
- const shutdown = async () => {
502
- logger.info('Shutting down split-serve processes...');
503
- await stopBackend();
504
- await stopFrontend();
505
- await watcher.close();
506
- process.exit(0);
507
- };
508
-
509
- process.on('SIGINT', shutdown);
510
- process.on('SIGTERM', shutdown);
677
+ // Handle graceful shutdown signals
678
+ process.on('SIGINT', () => shutdown('SIGINT', 0));
679
+ process.on('SIGTERM', () => shutdown('SIGTERM', 0));
680
+
681
+ // Handle process exit - this ensures children are killed even if parent crashes
682
+ process.on('exit', () => {
683
+ // Synchronous cleanup only - exit event doesn't allow async
684
+ // Kill entire process tree (including tsx wrappers and their children)
685
+ if (backendProc && backendProc.pid && !backendProc.killed) {
686
+ killProcessTree(backendProc.pid);
687
+ }
688
+ if (frontendProc && frontendProc.pid && !frontendProc.killed) {
689
+ killProcessTree(frontendProc.pid);
690
+ }
691
+ });
692
+
693
+ // Handle uncaught exceptions
694
+ process.on('uncaughtException', async (error) => {
695
+ logger.error('Uncaught exception:', error);
696
+ await shutdown('uncaughtException', 1);
697
+ });
698
+
699
+ // Handle unhandled promise rejections
700
+ process.on('unhandledRejection', async (reason, promise) => {
701
+ logger.error('Unhandled rejection at:', promise, 'reason:', reason);
702
+ await shutdown('unhandledRejection', 1);
703
+ });
704
+
705
+ // Keep process alive for file watching
706
+ // This promise never resolves, keeping the event loop running
707
+ // The process will only exit via the signal handlers above
708
+ await new Promise(() => {});
511
709
  }
512
710
 
513
711
  async function createWatcher(context, yamlConfig, configFilePath) {
@@ -518,49 +716,75 @@ async function createWatcher(context, yamlConfig, configFilePath) {
518
716
  const chokidar = requireFromRoot('chokidar');
519
717
 
520
718
  const patterns = new Set();
521
-
522
- const resolvePattern = (pattern) => {
523
- if (path.isAbsolute(pattern)) return pattern;
524
- return path.resolve(root, pattern);
719
+
720
+ // Keep patterns relative for chokidar to work properly with globs
721
+ const normalizePattern = (pattern) => {
722
+ // If pattern is absolute, make it relative to root
723
+ if (path.isAbsolute(pattern)) {
724
+ return path.relative(root, pattern);
725
+ }
726
+ return pattern;
525
727
  };
526
-
527
- // User-supplied watch paths
528
- if (yamlConfig.dev?.watchPaths) {
529
- for (const pattern of yamlConfig.dev.watchPaths) {
530
- patterns.add(resolvePattern(pattern));
728
+
729
+ // App watch patterns
730
+ if (yamlConfig.app?.watch) {
731
+ for (const pattern of yamlConfig.app.watch) {
732
+ patterns.add(normalizePattern(pattern));
531
733
  }
532
734
  }
533
-
534
- // Default watch patterns from config
535
- if (yamlConfig.server?.controllers) {
536
- patterns.add(resolvePattern(`${yamlConfig.server.controllers}/**/*.{ts,js}`));
537
- }
538
- if (yamlConfig.server?.middleware) {
539
- patterns.add(resolvePattern(`${yamlConfig.server.middleware}/**/*.{ts,js}`));
735
+
736
+ // Server watch patterns
737
+ if (yamlConfig.server?.watch) {
738
+ for (const pattern of yamlConfig.server.watch) {
739
+ patterns.add(normalizePattern(pattern));
740
+ }
540
741
  }
541
- if (yamlConfig.server?.openapi) {
542
- patterns.add(resolvePattern(yamlConfig.server.openapi));
543
- const openapiDir = path.dirname(resolvePattern(yamlConfig.server.openapi));
544
- patterns.add(path.join(openapiDir, 'openapi/**/*.yaml'));
742
+
743
+ // Client watch patterns
744
+ if (yamlConfig.client?.watch) {
745
+ for (const pattern of yamlConfig.client.watch) {
746
+ patterns.add(normalizePattern(pattern));
747
+ }
545
748
  }
546
- if (yamlConfig.client?.openapi) {
547
- patterns.add(resolvePattern(yamlConfig.client.openapi));
548
- const openapiDir = path.dirname(resolvePattern(yamlConfig.client.openapi));
549
- patterns.add(path.join(openapiDir, 'openapi/**/*.yaml'));
749
+
750
+ if (patterns.size === 0) {
751
+ logger.warn('No watch patterns configured. Watching disabled.');
752
+ return null;
550
753
  }
551
- if (yamlConfig.assets) {
552
- for (const asset of yamlConfig.assets) {
553
- if (asset.includes('*.sql')) {
554
- patterns.add(resolvePattern(asset));
555
- }
754
+
755
+ // Convert patterns to concrete watch targets (directories/files)
756
+ const GLOB_CHARS = /[\\*?\[\]\{\}\(\)!+@]/;
757
+ const watchTargets = new Set();
758
+ for (const p of patterns) {
759
+ const isGlob = GLOB_CHARS.test(p);
760
+ if (!isGlob) {
761
+ watchTargets.add(p);
762
+ } else {
763
+ const base = globParent(p);
764
+ watchTargets.add(base && base.length > 0 ? base : '.');
556
765
  }
557
766
  }
558
-
767
+
559
768
  logger.info('Watching for changes to restart server...');
560
- const watcher = chokidar.watch(Array.from(patterns), {
561
- ignoreInitial: true,
562
- cwd: root
769
+ logger.info(`[WATCHER] Concrete targets: ${JSON.stringify(Array.from(watchTargets), null, 2)}`);
770
+ // Use cwd with relative targets for proper glob support
771
+ let watcher;
772
+ try {
773
+ watcher = chokidar.watch(Array.from(watchTargets), {
774
+ cwd: root,
775
+ ignoreInitial: true
776
+ });
777
+ } catch (error) {
778
+ logger.error('Failed to start file watcher:', error);
779
+ throw error;
780
+ }
781
+
782
+ watcher.on('error', (error) => {
783
+ logger.error('Watcher error:', error);
784
+ logger.error('File watching is required for dev mode; exiting.');
785
+ process.exit(1);
563
786
  });
787
+
564
788
  return watcher;
565
789
  }
566
790
 
@@ -604,19 +828,23 @@ async function runWithRestart(context, tsxExecutable, tsxArgs, env, watcher, log
604
828
  start();
605
829
  pending = false;
606
830
  };
607
-
608
- watcher
609
- .on('add', (p) => scheduleRestart('add', p))
610
- .on('change', (p) => scheduleRestart('change', p))
611
- .on('unlink', (p) => scheduleRestart('unlink', p));
612
-
831
+
832
+ if (watcher) {
833
+ watcher
834
+ .on('add', (p) => scheduleRestart('add', p))
835
+ .on('change', (p) => scheduleRestart('change', p))
836
+ .on('unlink', (p) => scheduleRestart('unlink', p));
837
+ }
838
+
613
839
  // Start initial server
614
840
  start();
615
-
841
+
616
842
  // Keep process alive until SIGINT/SIGTERM
617
843
  const shutdown = async () => {
618
844
  await stop();
619
- await watcher.close();
845
+ if (watcher) {
846
+ await watcher.close();
847
+ }
620
848
  process.exit(0);
621
849
  };
622
850
  process.on('SIGINT', shutdown);
@@ -74,39 +74,52 @@ async function createWatcher(context) {
74
74
  patterns.add(value);
75
75
  };
76
76
 
77
- const resolveGlob = (entry) => {
77
+ // Keep patterns relative for chokidar to work properly with globs
78
+ const normalizePattern = (entry) => {
78
79
  if (!entry) return null;
79
80
  if (typeof entry === 'string') {
80
- return path.isAbsolute(entry) ? entry : path.join(config.rootDir, entry);
81
+ // If pattern is absolute, make it relative to rootDir
82
+ if (path.isAbsolute(entry)) {
83
+ return path.relative(config.rootDir, entry);
84
+ }
85
+ return entry;
81
86
  }
87
+ // Handle object-style patterns
82
88
  if (entry.isAbsolute) {
83
- return entry.pattern;
89
+ return path.relative(config.rootDir, entry.pattern);
84
90
  }
85
91
  if (entry.cwd) {
86
- return path.join(entry.cwd, entry.pattern);
92
+ const absolute = path.join(entry.cwd, entry.pattern);
93
+ return path.relative(config.rootDir, absolute);
87
94
  }
88
95
  return entry.pattern;
89
96
  };
90
97
 
91
- // User-supplied patterns
92
- for (const entry of config.dev.watchPaths) {
93
- const pattern = resolveGlob(entry);
94
- if (pattern) addPattern(pattern);
98
+ // App watch patterns - restart server
99
+ if (config.app?.watch) {
100
+ for (const entry of config.app.watch) {
101
+ const pattern = normalizePattern(entry);
102
+ if (pattern) addPattern(pattern);
103
+ }
95
104
  }
96
105
 
97
- // Server-side defaults
98
- addPattern(path.join(config.server.controllersDir, '**/*.{ts,js}'));
99
- addPattern(path.join(config.server.middlewareDir, '**/*.{ts,js}'));
100
- addPattern(config.server.openapiFile);
101
- addPattern(path.join(path.dirname(config.server.openapiFile), 'openapi/**/*.yaml'));
102
- for (const entry of config.server.sqlGlobs) {
103
- const pattern = resolveGlob(entry);
104
- if (pattern) addPattern(pattern);
106
+ // Server watch patterns - restart server
107
+ if (config.server?.watch) {
108
+ for (const entry of config.server.watch) {
109
+ const pattern = normalizePattern(entry);
110
+ if (pattern) addPattern(pattern);
111
+ }
105
112
  }
106
113
 
107
- // UI OpenAPI changes should restart (routes/manifest)
108
- addPattern(config.ui.openapiFile);
109
- addPattern(path.join(path.dirname(config.ui.openapiFile), 'openapi/**/*.yaml'));
114
+ // Client watch patterns - restart server (in non-split mode)
115
+ // Check both ui.watch (from buildConfig) and client.watch (from spread)
116
+ const clientWatch = config.ui?.watch || config.client?.watch;
117
+ if (clientWatch) {
118
+ for (const entry of clientWatch) {
119
+ const pattern = normalizePattern(entry);
120
+ if (pattern) addPattern(pattern);
121
+ }
122
+ }
110
123
 
111
124
  // Ignore Svelte/client files to let Vite HMR handle them
112
125
  const uiIgnores = [
@@ -114,11 +127,17 @@ async function createWatcher(context) {
114
127
  path.join(config.ui.rootDir, '**/*.{ts,tsx,css,scss,html}')
115
128
  ];
116
129
 
130
+ if (patterns.size === 0) {
131
+ logger.warn('No watch patterns configured. Watching disabled.');
132
+ return null;
133
+ }
134
+
117
135
  logger.info('Watching for changes to restart server...');
136
+ // Use cwd with relative patterns for proper glob support
118
137
  const watcher = chokidar.watch(Array.from(patterns), {
138
+ cwd: config.rootDir,
119
139
  ignoreInitial: true,
120
- ignored: uiIgnores,
121
- cwd: config.rootDir
140
+ ignored: uiIgnores
122
141
  });
123
142
  return watcher;
124
143
  }
@@ -166,10 +185,12 @@ async function runWithRestart(context, runner, watcher, frontendProc) {
166
185
  pending = false;
167
186
  };
168
187
 
169
- watcher
170
- .on('add', (p) => scheduleRestart('add', p))
171
- .on('change', (p) => scheduleRestart('change', p))
172
- .on('unlink', (p) => scheduleRestart('unlink', p));
188
+ if (watcher) {
189
+ watcher
190
+ .on('add', (p) => scheduleRestart('add', p))
191
+ .on('change', (p) => scheduleRestart('change', p))
192
+ .on('unlink', (p) => scheduleRestart('unlink', p));
193
+ }
173
194
 
174
195
  // Start initial server
175
196
  start();
@@ -177,7 +198,9 @@ async function runWithRestart(context, runner, watcher, frontendProc) {
177
198
  // Keep process alive until SIGINT/SIGTERM
178
199
  const shutdown = async () => {
179
200
  await stop();
180
- await watcher.close();
201
+ if (watcher) {
202
+ await watcher.close();
203
+ }
181
204
  if (frontendProc) {
182
205
  try { frontendProc.kill('SIGTERM'); } catch {}
183
206
  }
package/src/config.js CHANGED
@@ -73,7 +73,8 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
73
73
  controllersDir: config.server.controllers_abs,
74
74
  middlewareDir: config.server.middleware_abs,
75
75
  openapiFile: config.server.openapi_abs,
76
- sqlGlobs: serverSqlGlobs
76
+ sqlGlobs: serverSqlGlobs,
77
+ watch: config.server.watch // Preserve watch property
77
78
  } : null,
78
79
  ui: config.client ? {
79
80
  rootDir: uiRootDir,
@@ -85,7 +86,8 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
85
86
  options: {}, // This seems to be deprecated
86
87
  openapiFile: config.client.openapi_abs,
87
88
  assets: assetSpecs,
88
- clientExclude: clientExcludeSpecs
89
+ clientExclude: clientExcludeSpecs,
90
+ watch: config.client.watch // Preserve watch property
89
91
  } : null,
90
92
  assets: assetSpecs,
91
93
  vite: {
@@ -0,0 +1,82 @@
1
+ import path from 'node:path';
2
+ import { loadConfig, findConfigFile, loadConfigFromEnv } from './config-loader.js';
3
+ import {setContext} from "../client";
4
+
5
+
6
+
7
+
8
+ /**
9
+ * Loads and applies the resolved project configuration to the application's runtime context.
10
+ *
11
+ * @param {(config:any) => any} getAppFunction - A function that returns the application instance (e.g., Express app).
12
+ * @returns {Promise<object>} - The fully resolved and normalized configuration object.
13
+ */
14
+ export async function buildConfig(getAppFunction) {
15
+ // Retrieve the application instance from the provided function.
16
+ /** @type {any} */
17
+ const app = getAppFunction();
18
+ // Load and resolve the configuration via getConfig (auto-detects config file or env).
19
+ const { config } = await getConfig();
20
+ // Attach the loaded config to the runtime context for downstream usage.
21
+ setContext(app, config);
22
+ // Return the config for consumers (generates env and internal path invariants).
23
+ return {config,app};
24
+ }
25
+
26
+ /**
27
+ * Automatically load configuration from environment variable or config file.
28
+ *
29
+ * This function:
30
+ * 1. First checks NOEGO_CONFIGURATION env var (uses loadConfigFromEnv if present)
31
+ * 2. Otherwise searches for config file starting from rootDir (or process.cwd())
32
+ * and walks up directories until a config file is found
33
+ * 3. Sets all environment variables automatically
34
+ * 4. Returns the fully resolved config object
35
+ *
36
+ * @param {string} [rootDir] - Optional starting directory for config file search. Defaults to process.cwd()
37
+ * @returns {Promise<{root: string, config: object, configFilePath: string|null}>}
38
+ */
39
+ export async function getConfig(rootDir) {
40
+ // First priority: check NOEGO_CONFIGURATION env var
41
+ if (process.env.NOEGO_CONFIGURATION) {
42
+ return loadConfigFromEnv();
43
+ }
44
+
45
+ // Second priority: find and load config file
46
+ const startDir = rootDir ? path.resolve(rootDir) : process.cwd();
47
+
48
+
49
+ // Walk up directories to find config file
50
+ let currentDir = startDir;
51
+ let lastDir = '';
52
+
53
+ while (currentDir !== lastDir) {
54
+ try {
55
+ const configFilePath = await findConfigFile(currentDir);
56
+ // Found config file, load it
57
+ // Pass rootDir if provided, otherwise let loadConfig determine root from config file
58
+ return await loadConfig(configFilePath, rootDir);
59
+ } catch (error) {
60
+ // Config file not found in this directory, try parent
61
+ lastDir = currentDir;
62
+ currentDir = path.dirname(currentDir);
63
+
64
+ // Stop if we've reached filesystem root
65
+ if (currentDir === lastDir) {
66
+ throw new Error(
67
+ `No configuration file found starting from ${startDir}.\n` +
68
+ `Please create hammer.config.yml (or a similar named file) in your project root.`
69
+ );
70
+ }
71
+ }
72
+ }
73
+
74
+
75
+
76
+ // Should never reach here, but just in case
77
+ throw new Error(
78
+ `No configuration file found starting from ${startDir}.\n` +
79
+ `Please create hammer.config.yml (or a similar named file) in your project root.`
80
+ );
81
+ }
82
+
@@ -0,0 +1,23 @@
1
+ import type { Express } from 'express';
2
+
3
+ /**
4
+ * Result returned by buildConfig
5
+ */
6
+ export interface BuildConfigResult {
7
+ config: Record<string, unknown>;
8
+ app: Express;
9
+ }
10
+
11
+ /**
12
+ * Loads and applies the resolved project configuration to the application's runtime context.
13
+ *
14
+ * This function:
15
+ * 1. Retrieves the application instance from the provided function
16
+ * 2. Loads configuration via getConfig (auto-detects config file or env)
17
+ * 3. Attaches the loaded config to the runtime context via setContext
18
+ * 4. Returns both the config object and app instance for consumers
19
+ *
20
+ * @param getAppFunction - A function that returns the application instance (e.g., Express app)
21
+ * @returns Promise resolving to an object containing both the config and app
22
+ */
23
+ export function buildConfig(getAppFunction: (config:any) => Express): Promise<BuildConfigResult>;