@noego/app 0.0.10 โ†’ 0.0.12

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.
@@ -1,17 +1,15 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Read(//Users/shavauhngabay/dev/noego/dinner/**)",
5
- "Read(//Users/shavauhngabay/dev/ego/sqlstack/**)",
6
- "Read(//Users/shavauhngabay/dev/ego/forge/**)",
7
- "Read(//Users/shavauhngabay/dev/noblelaw/ui/**)",
8
- "Read(//Users/shavauhngabay/dev/noblelaw/**)",
9
- "mcp__chrome-devtools__take_screenshot",
10
- "Bash(npm run build:ui:ssr:*)",
11
- "mcp__chrome-devtools__navigate_page",
12
- "Bash(node dist/hammer.js:*)",
13
- "Bash(tee:*)",
14
- "mcp__chrome-devtools__list_console_messages"
4
+ "Bash(cat:*)",
5
+ "Bash(curl:*)",
6
+ "Bash(noego dev)",
7
+ "WebSearch",
8
+ "WebFetch(domain:mattkentzia.com)",
9
+ "Bash(node --test:*)",
10
+ "WebFetch(domain:svelte.dev)",
11
+ "WebFetch(domain:github.com)",
12
+ "Bash(tree:*)"
15
13
  ],
16
14
  "deny": [],
17
15
  "ask": []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noego/app",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "Production build tool for Dinner/Forge apps.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.js CHANGED
@@ -27,11 +27,17 @@ const FLAG_MAP = new Map([
27
27
  ['mode', 'mode'],
28
28
  ['verbose', 'verbose'],
29
29
  ['help', 'help'],
30
- ['version', 'version']
30
+ ['version', 'version'],
31
+ ['ci-server', 'testServer'],
32
+ ['ci-test', 'testCommand'],
33
+ ['ci-status', 'testStatus'],
34
+ ['ci-port', 'testPort'],
35
+ ['ci-timeout', 'testTimeout'],
36
+ ['ci-visible', 'testVisible']
31
37
  ]);
32
38
 
33
39
  const MULTI_VALUE_FLAGS = new Set(['sqlGlob', 'assets', 'clientExclude', 'watchPath']);
34
- const BOOLEAN_FLAGS = new Set(['watch', 'splitServe', 'verbose']);
40
+ const BOOLEAN_FLAGS = new Set(['watch', 'splitServe', 'verbose', 'testVisible']);
35
41
 
36
42
  export function parseCliArgs(argv) {
37
43
  const result = {
@@ -129,6 +135,15 @@ export function printHelpAndExit({ stdout = process.stdout } = {}) {
129
135
  app dev [options]
130
136
  app serve [options]
131
137
  app preview [options]
138
+ app ci [options]
139
+
140
+ CI Testing Options:
141
+ --ci-server <cmd> Server start command (default: npm run dev)
142
+ --ci-test <cmd> Test command (default: npm run test:live)
143
+ --ci-status <path> Health check endpoint (default: /api/status)
144
+ --ci-port <number> Server port (default: random 4000-8000)
145
+ --ci-timeout <seconds> Health check timeout (default: 60)
146
+ --ci-visible Run browser in visible mode
132
147
 
133
148
  Options (shared):
134
149
  --root <dir> Project root (default: .)
package/src/cli.js CHANGED
@@ -8,6 +8,7 @@ import { runBuild } from './commands/build.js';
8
8
  import { runServe } from './commands/serve.js';
9
9
  import { runPreview } from './commands/preview.js';
10
10
  import { runDev } from './commands/dev.js';
11
+ import { runTestLive } from './commands/test-live.js';
11
12
 
12
13
  export async function runCli(argv = process.argv.slice(2)) {
13
14
  try {
@@ -43,6 +44,11 @@ export async function runCli(argv = process.argv.slice(2)) {
43
44
  await runPreview(config);
44
45
  break;
45
46
  }
47
+ case 'ci': {
48
+ const config = await loadBuildConfig(options, { cwd: process.cwd() });
49
+ await runTestLive(config);
50
+ break;
51
+ }
46
52
  default:
47
53
  throw cliError(`Unknown command "${command}"`);
48
54
  }
@@ -449,58 +449,132 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
449
449
  let frontendRestartCount = 0;
450
450
  let isShuttingDown = false;
451
451
 
452
+ // Crash restart tracking
453
+ const MAX_CRASH_RESTARTS = 3;
454
+ const CRASH_RESTART_DELAY = 2000; // 2 seconds between crash restarts
455
+ const STABILITY_THRESHOLD = 30000; // Reset crash counter if running 30+ seconds
456
+ let backendCrashRestarts = 0;
457
+ let frontendCrashRestarts = 0;
458
+ let backendStartTime = 0;
459
+ let frontendStartTime = 0;
460
+
452
461
  const startBackend = () => {
453
462
  backendRestartCount++;
463
+ backendStartTime = Date.now();
454
464
  logger.info(`๐Ÿš€ [RESTART #${backendRestartCount}] Starting backend on port ${backendPort}...`);
455
465
  const backendEnv = {
456
466
  ...baseEnv,
457
467
  NOEGO_SERVICE: 'backend',
458
468
  NOEGO_PORT: String(backendPort)
459
469
  };
460
-
470
+
461
471
  backendProc = spawn(tsxExecutable, tsxArgs, {
462
472
  cwd: context.config.rootDir,
463
473
  env: backendEnv,
464
474
  stdio: 'inherit',
465
475
  detached: false
466
476
  });
467
-
468
- backendProc.on('exit', (code) => {
477
+
478
+ backendProc.on('exit', (code, signal) => {
479
+ if (isShuttingDown) return;
480
+
481
+ // Check if process was stable (ran for a while) - reset crash counter
482
+ const runDuration = Date.now() - backendStartTime;
483
+ if (runDuration > STABILITY_THRESHOLD) {
484
+ backendCrashRestarts = 0;
485
+ }
486
+
469
487
  if (code !== null && code !== 0) {
470
- logger.error(`Backend exited with code ${code}`);
488
+ logger.error(`[BACKEND] Exited with code ${code}, signal ${signal}`);
489
+
490
+ // Auto-restart on crash if under limit
491
+ if (backendCrashRestarts < MAX_CRASH_RESTARTS) {
492
+ backendCrashRestarts++;
493
+ logger.warn(`[BACKEND] Crash detected. Auto-restart ${backendCrashRestarts}/${MAX_CRASH_RESTARTS} in ${CRASH_RESTART_DELAY}ms...`);
494
+ setTimeout(() => {
495
+ if (!isShuttingDown) {
496
+ startBackend();
497
+ }
498
+ }, CRASH_RESTART_DELAY);
499
+ } else {
500
+ logger.error(`[BACKEND] Exceeded max crash restarts (${MAX_CRASH_RESTARTS}). Shutting down...`);
501
+ shutdown('backend-exceeded-restarts', 1, 'backend-crash');
502
+ }
471
503
  }
472
504
  });
505
+
506
+ backendProc.on('error', (err) => {
507
+ logger.error(`[BACKEND] Spawn error: ${err.message}`);
508
+ });
473
509
  };
474
-
510
+
475
511
  const startFrontend = () => {
476
512
  frontendRestartCount++;
513
+ frontendStartTime = Date.now();
477
514
  logger.info(`๐Ÿš€ [RESTART #${frontendRestartCount}] Starting frontend on port ${frontendPort}...`);
478
515
  const frontendEnv = {
479
516
  ...baseEnv,
480
517
  NOEGO_SERVICE: 'frontend',
481
518
  NOEGO_PORT: String(frontendPort)
482
519
  };
483
-
520
+
484
521
  frontendProc = spawn(tsxExecutable, tsxArgs, {
485
522
  cwd: context.config.rootDir,
486
523
  env: frontendEnv,
487
524
  stdio: 'inherit',
488
525
  detached: false
489
526
  });
490
-
491
- frontendProc.on('exit', (code) => {
527
+
528
+ frontendProc.on('exit', (code, signal) => {
529
+ if (isShuttingDown) return;
530
+
531
+ // Check if process was stable (ran for a while) - reset crash counter
532
+ const runDuration = Date.now() - frontendStartTime;
533
+ if (runDuration > STABILITY_THRESHOLD) {
534
+ frontendCrashRestarts = 0;
535
+ }
536
+
492
537
  if (code !== null && code !== 0) {
493
- logger.error(`Frontend exited with code ${code}`);
538
+ logger.error(`[FRONTEND] Exited with code ${code}, signal ${signal}`);
539
+
540
+ // Auto-restart on crash if under limit
541
+ if (frontendCrashRestarts < MAX_CRASH_RESTARTS) {
542
+ frontendCrashRestarts++;
543
+ logger.warn(`[FRONTEND] Crash detected. Auto-restart ${frontendCrashRestarts}/${MAX_CRASH_RESTARTS} in ${CRASH_RESTART_DELAY}ms...`);
544
+ setTimeout(() => {
545
+ if (!isShuttingDown) {
546
+ startFrontend();
547
+ }
548
+ }, CRASH_RESTART_DELAY);
549
+ } else {
550
+ logger.error(`[FRONTEND] Exceeded max crash restarts (${MAX_CRASH_RESTARTS}). Shutting down...`);
551
+ shutdown('frontend-exceeded-restarts', 1, 'frontend-crash');
552
+ }
494
553
  }
495
554
  });
555
+
556
+ frontendProc.on('error', (err) => {
557
+ logger.error(`[FRONTEND] Spawn error: ${err.message}`);
558
+ });
496
559
  };
497
-
560
+
498
561
  const stopBackend = () =>
499
562
  new Promise((resolve) => {
500
563
  if (!backendProc) return resolve();
501
564
  logger.info(`โน๏ธ Stopping backend (preparing for restart #${backendRestartCount + 1})...`);
502
- const to = setTimeout(resolve, 2000);
565
+ const pid = backendProc.pid;
566
+ let exited = false;
567
+
568
+ const to = setTimeout(() => {
569
+ if (!exited && pid) {
570
+ logger.warn(`โš ๏ธ Backend didn't exit gracefully, force killing process tree...`);
571
+ killProcessTree(pid, 'SIGKILL');
572
+ }
573
+ resolve();
574
+ }, 2000);
575
+
503
576
  backendProc.once('exit', () => {
577
+ exited = true;
504
578
  clearTimeout(to);
505
579
  resolve();
506
580
  });
@@ -515,8 +589,19 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
515
589
  new Promise((resolve) => {
516
590
  if (!frontendProc) return resolve();
517
591
  logger.info(`โน๏ธ Stopping frontend (preparing for restart #${frontendRestartCount + 1})...`);
518
- const to = setTimeout(resolve, 2000);
592
+ const pid = frontendProc.pid;
593
+ let exited = false;
594
+
595
+ const to = setTimeout(() => {
596
+ if (!exited && pid) {
597
+ logger.warn(`โš ๏ธ Frontend didn't exit gracefully, force killing process tree...`);
598
+ killProcessTree(pid, 'SIGKILL');
599
+ }
600
+ resolve();
601
+ }, 2000);
602
+
519
603
  frontendProc.once('exit', () => {
604
+ exited = true;
520
605
  clearTimeout(to);
521
606
  resolve();
522
607
  });
@@ -527,10 +612,11 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
527
612
  }
528
613
  });
529
614
 
530
- async function shutdown(signal = 'SIGTERM', exitCode = 0) {
615
+ async function shutdown(signal = 'SIGTERM', exitCode = 0, source = 'unknown') {
531
616
  if (isShuttingDown) return;
532
617
  isShuttingDown = true;
533
618
 
619
+ logger.info(`[SHUTDOWN] source=${source}, signal=${signal}, exitCode=${exitCode}`);
534
620
  logger.info(`Shutting down split-serve processes (signal: ${signal})...`);
535
621
 
536
622
  try {
@@ -555,7 +641,7 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
555
641
  }
556
642
  logger.error('Watcher error:', error);
557
643
  logger.error('File watching failed; shutting down dev server.');
558
- await shutdown('watcherError', 1);
644
+ await shutdown('watcherError', 1, 'watcher-error');
559
645
  }
560
646
 
561
647
  const handleFileChange = async (reason, file) => {
@@ -597,10 +683,12 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
597
683
  // Restart appropriate service(s)
598
684
  if (restartBackend) {
599
685
  await stopBackend();
686
+ await new Promise(r => setTimeout(r, 100)); // Small delay to ensure port release
600
687
  startBackend();
601
688
  }
602
689
  if (restartFrontend) {
603
690
  await stopFrontend();
691
+ await new Promise(r => setTimeout(r, 100)); // Small delay to ensure port release
604
692
  startFrontend();
605
693
  }
606
694
 
@@ -675,8 +763,8 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
675
763
  await import(runtimeEntryPath);
676
764
 
677
765
  // Handle graceful shutdown signals
678
- process.on('SIGINT', () => shutdown('SIGINT', 0));
679
- process.on('SIGTERM', () => shutdown('SIGTERM', 0));
766
+ process.on('SIGINT', () => shutdown('SIGINT', 0, 'signal-handler'));
767
+ process.on('SIGTERM', () => shutdown('SIGTERM', 0, 'signal-handler'));
680
768
 
681
769
  // Handle process exit - this ensures children are killed even if parent crashes
682
770
  process.on('exit', () => {
@@ -693,13 +781,13 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
693
781
  // Handle uncaught exceptions
694
782
  process.on('uncaughtException', async (error) => {
695
783
  logger.error('Uncaught exception:', error);
696
- await shutdown('uncaughtException', 1);
784
+ await shutdown('uncaughtException', 1, 'uncaught-exception');
697
785
  });
698
786
 
699
787
  // Handle unhandled promise rejections
700
788
  process.on('unhandledRejection', async (reason, promise) => {
701
789
  logger.error('Unhandled rejection at:', promise, 'reason:', reason);
702
- await shutdown('unhandledRejection', 1);
790
+ await shutdown('unhandledRejection', 1, 'unhandled-rejection');
703
791
  });
704
792
 
705
793
  // Keep process alive for file watching
@@ -807,8 +895,19 @@ async function runWithRestart(context, tsxExecutable, tsxArgs, env, watcher, log
807
895
  const stop = () =>
808
896
  new Promise((resolve) => {
809
897
  if (!child) return resolve();
810
- const to = setTimeout(resolve, 2000);
898
+ const pid = child.pid;
899
+ let exited = false;
900
+
901
+ const to = setTimeout(() => {
902
+ if (!exited && pid) {
903
+ logger.warn(`โš ๏ธ Process didn't exit gracefully, force killing process tree...`);
904
+ killProcessTree(pid, 'SIGKILL');
905
+ }
906
+ resolve();
907
+ }, 2000);
908
+
811
909
  child.once('exit', () => {
910
+ exited = true;
812
911
  clearTimeout(to);
813
912
  resolve();
814
913
  });
@@ -0,0 +1,187 @@
1
+ import { spawn } from 'child_process';
2
+ import { createBuildContext } from '../build/context.js';
3
+
4
+ /**
5
+ * Polls health check endpoint until server is ready
6
+ */
7
+ async function waitForServer(port, statusPath, timeoutSec, logger) {
8
+ const url = `http://localhost:${port}${statusPath}`;
9
+ const maxAttempts = timeoutSec;
10
+
11
+ logger.info(`Waiting for server at ${url}...`);
12
+
13
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
14
+ try {
15
+ const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
16
+
17
+ if (response.ok) {
18
+ const data = await response.json();
19
+
20
+ // For /status/deep - check both status and database
21
+ if (statusPath.includes('deep')) {
22
+ if (data.status === 'OK' && data.database === 'connected') {
23
+ logger.info('Server is ready!');
24
+ await new Promise(r => setTimeout(r, 2000)); // Extra 2s for full init
25
+ return true;
26
+ }
27
+ } else if (data.status === 'OK' || data.status === 'ok') {
28
+ logger.info('Server is ready!');
29
+ await new Promise(r => setTimeout(r, 2000));
30
+ return true;
31
+ }
32
+ }
33
+ } catch (err) {
34
+ // Connection refused or timeout - server not ready yet
35
+ }
36
+
37
+ if (attempt < maxAttempts) {
38
+ await new Promise(r => setTimeout(r, 1000)); // Wait 1s between attempts
39
+ if (attempt % 5 === 0) {
40
+ logger.info(`Still waiting... (${attempt}/${maxAttempts})`);
41
+ }
42
+ }
43
+ }
44
+
45
+ return false;
46
+ }
47
+
48
+ /**
49
+ * Spawns a command and returns a process handle
50
+ */
51
+ function spawnCommand(command, env, logger) {
52
+ logger.info(`Spawning: ${command}`);
53
+
54
+ const child = spawn(command, {
55
+ shell: true,
56
+ stdio: 'inherit',
57
+ env: { ...process.env, ...env },
58
+ detached: process.platform !== 'win32', // Process group for Unix
59
+ });
60
+
61
+ return child;
62
+ }
63
+
64
+ /**
65
+ * Kills process tree (cross-platform)
66
+ */
67
+ function killProcessTree(pid, logger) {
68
+ try {
69
+ if (process.platform === 'win32') {
70
+ spawn('taskkill', ['/pid', pid.toString(), '/T', '/F'], { stdio: 'ignore' });
71
+ } else {
72
+ process.kill(-pid, 'SIGKILL'); // Negative PID kills process group
73
+ }
74
+ } catch (err) {
75
+ logger.debug(`Process ${pid} already terminated`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Main test:live command implementation
81
+ */
82
+ export async function runTestLive(config) {
83
+ const context = createBuildContext(config);
84
+ const { logger } = context;
85
+
86
+ // Parse options - no defaults, must be specified
87
+ const serverCmd = config.testServer;
88
+ const testCmd = config.testCommand;
89
+ const statusPath = config.testStatus;
90
+ const port = config.testPort || Math.floor(Math.random() * 4000) + 4000;
91
+ const timeout = config.testTimeout || 60;
92
+ const visible = config.testVisible || false;
93
+
94
+ // Validate required options
95
+ if (!serverCmd) {
96
+ logger.error('โŒ Missing required option: --ci-server');
97
+ logger.info('Example: noego ci --ci-server "npm run dev" --ci-test "npm run test:live" --ci-status "/api/status"');
98
+ process.exit(1);
99
+ }
100
+
101
+ if (!testCmd) {
102
+ logger.error('โŒ Missing required option: --ci-test');
103
+ logger.info('Example: noego ci --ci-server "npm run dev" --ci-test "npm run test:live" --ci-status "/api/status"');
104
+ process.exit(1);
105
+ }
106
+
107
+ if (!statusPath) {
108
+ logger.error('โŒ Missing required option: --ci-status');
109
+ logger.info('Example: noego ci --ci-server "npm run dev" --ci-test "npm run test:live" --ci-status "/api/status"');
110
+ process.exit(1);
111
+ }
112
+
113
+ logger.info('๐Ÿงช Live Test Runner');
114
+ logger.info(` Server: ${serverCmd}`);
115
+ logger.info(` Tests: ${testCmd}`);
116
+ logger.info(` Port: ${port}`);
117
+ logger.info(` Health: ${statusPath}`);
118
+
119
+ let serverProcess = null;
120
+ let exitCode = 1;
121
+
122
+ const cleanup = async () => {
123
+ if (serverProcess && !serverProcess.killed) {
124
+ logger.info('Shutting down server...');
125
+ killProcessTree(serverProcess.pid, logger);
126
+ await new Promise(r => setTimeout(r, 2000));
127
+ }
128
+ };
129
+
130
+ // Handle interrupts
131
+ process.on('SIGINT', async () => {
132
+ logger.info('Interrupted by user');
133
+ await cleanup();
134
+ process.exit(130);
135
+ });
136
+
137
+ process.on('SIGTERM', async () => {
138
+ await cleanup();
139
+ process.exit(143);
140
+ });
141
+
142
+ try {
143
+ // Start server
144
+ serverProcess = spawnCommand(serverCmd, { PORT: port.toString() }, logger);
145
+
146
+ // Wait for health check
147
+ logger.info('Waiting for server to be ready...');
148
+ const ready = await waitForServer(port, statusPath, timeout, logger);
149
+
150
+ if (!ready) {
151
+ logger.error(`โŒ Server failed to become healthy within ${timeout} seconds`);
152
+ await cleanup();
153
+ process.exit(1);
154
+ }
155
+
156
+ // Run tests
157
+ logger.info('โœ… Server ready, running tests...');
158
+ const testEnv = {
159
+ PORT: port.toString(),
160
+ HEADLESS: visible ? 'false' : 'true',
161
+ };
162
+
163
+ const testProcess = spawnCommand(testCmd, testEnv, logger);
164
+
165
+ // Wait for tests to complete
166
+ exitCode = await new Promise((resolve) => {
167
+ testProcess.on('exit', (code) => resolve(code || 0));
168
+ });
169
+
170
+ if (exitCode === 0) {
171
+ logger.info('โœ… Tests passed!');
172
+ } else {
173
+ logger.error(`โŒ Tests failed with exit code ${exitCode}`);
174
+ }
175
+
176
+ } catch (err) {
177
+ logger.error('Test runner failed:', err.message);
178
+ if (err.stack) {
179
+ logger.debug(err.stack);
180
+ }
181
+ exitCode = 1;
182
+
183
+ } finally {
184
+ await cleanup();
185
+ process.exit(exitCode);
186
+ }
187
+ }
package/src/config.js CHANGED
@@ -71,6 +71,13 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
71
71
  ...config,
72
72
  rootDir: config.root,
73
73
  verbose: cliOptions.verbose || false,
74
+ // CI test options from CLI
75
+ testServer: cliOptions.testServer,
76
+ testCommand: cliOptions.testCommand,
77
+ testStatus: cliOptions.testStatus,
78
+ testPort: cliOptions.testPort,
79
+ testTimeout: cliOptions.testTimeout,
80
+ testVisible: cliOptions.testVisible,
74
81
  layout,
75
82
  server: config.server ? {
76
83
  rootDir: config.server.main_abs ? path.dirname(config.server.main_abs) : config.root,
@@ -97,12 +97,15 @@ async function setupProxyFirst(app, backendPort, config) {
97
97
  return resolve(cached.canHandle);
98
98
  }
99
99
 
100
+ // Configurable timeout - default 500ms is enough for GC pauses and startup
101
+ const checkTimeout = parseInt(process.env.NOEGO_BACKEND_CHECK_TIMEOUT) || 500;
102
+
100
103
  const options = {
101
104
  hostname: 'localhost',
102
105
  port: backendPort,
103
106
  path: pathname,
104
107
  method: 'GET', // Use GET instead of HEAD since some backends don't support HEAD
105
- timeout: 50, // Very short timeout for dev mode
108
+ timeout: checkTimeout,
106
109
  headers: {
107
110
  'X-Proxy-Check': 'true' // Indicate this is just a check
108
111
  }