@noego/app 0.0.12 → 0.0.13

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/AGENTS.md CHANGED
@@ -12,37 +12,43 @@ Objective
12
12
 
13
13
  Entry Points
14
14
 
15
- - Server entry: `noblelaw/index.ts` (spawns/hosts Express + @noego/dinner API + @noego/forge SSR UI)
16
- - Page entry: `noblelaw/ui/index.html` (loads `noblelaw/ui/client.ts` to hydrate in browser)
15
+ - Server entry: `{project}/index.ts` (spawns/hosts Express + @noego/dinner API + @noego/forge SSR UI)
16
+ - Page entry: `{project}/ui/index.html` (loads `{project}/ui/client.ts` to hydrate in browser)
17
17
 
18
18
  Repository Roots and Path Referencing
19
19
 
20
- - Unless explicitly prefixed, relative paths in this document refer to the application project root (`--root`), which in our environment is `noblelaw` at `/Users/shavauhngabay/dev/noblelaw`.
20
+ - Unless explicitly prefixed, relative paths in this document refer to the application project root (`--root`).
21
+ - Available website projects:
22
+ - bashly: `/Users/shavauhngabay/dev/noego_manager/websites/bashly`
23
+ - mindful_essence: `/Users/shavauhngabay/dev/noego_manager/websites/mindful_essence`
24
+ - noego_landing: `/Users/shavauhngabay/dev/noego_manager/websites/noego_landing`
25
+ - {project}: `/Users/shavauhngabay/dev/noego_manager/websites/{project}`
21
26
  - Source checkouts for frameworks/tools:
22
- - Forge: `/Users/shavauhngabay/dev/ego/forge`
23
- - Dinner: `/Users/shavauhngabay/dev/noego/dinner`
24
- - Sqlstack: `/Users/shavauhngabay/dev/ego/sqlstack`
25
- - App (this CLI): `/Users/shavauhngabay/dev/app`
26
- - Example dist outputs in this doc are under the application project: `noblelaw/dist/...`.
27
+ - Forge: `/Users/shavauhngabay/dev/noego_manager/noego/forge`
28
+ - Dinner: `/Users/shavauhngabay/dev/noego_manager/noego/dinner`
29
+ - Sqlstack: `/Users/shavauhngabay/dev/noego_manager/noego/sqlstack`
30
+ - App (this CLI): `/Users/shavauhngabay/dev/noego_manager/noego/app`
31
+ - Example dist outputs in this doc are under the application project: `{project}/dist/...`.
32
+ - In examples below, `{project}` refers to any of the website projects listed above.
27
33
 
28
34
  Current Architecture
29
35
 
30
36
  - API
31
- - @noego/dinner (source: `/Users/shavauhngabay/dev/noego/dinner`) loads OpenAPI (default noblelaw uses `noblelaw/server/stitch.yaml`), dynamically wires controllers (`noblelaw/server/controller/**`) and middleware (`noblelaw/middleware/**`) based on configured base dirs
37
+ - @noego/dinner (source: `/Users/shavauhngabay/dev/noego_manager/noego/dinner`) loads OpenAPI (uses `{project}/server/stitch.yaml`), dynamically wires controllers (`{project}/server/controller/**`) and middleware (`{project}/middleware/**`) based on configured base dirs
32
38
  - Paths are resolved with process.cwd() and are runtime file-based; both controllers base and middleware base are configurable
33
39
  - UI SSR
34
- - @noego/forge/server (source: `/Users/shavauhngabay/dev/ego/forge`) uses options from `noblelaw/ui/options.ts`
40
+ - @noego/forge/server (source: `/Users/shavauhngabay/dev/noego_manager/noego/forge`) uses options from `{project}/ui/options.ts`
35
41
  - In dev, Vite middleware compiles .svelte on demand
36
42
  - In prod, must be precompiled (no loaders)
37
43
  - Client
38
- - Hydration bootstrap at `noblelaw/ui/client.ts`, page template `noblelaw/ui/index.html`
44
+ - Hydration bootstrap at `{project}/ui/client.ts`, page template `{project}/ui/index.html`
39
45
  - DB
40
- - Repositories (server/repo/**) use sqlstack decorators (source: `/Users/shavauhngabay/dev/ego/sqlstack`)
46
+ - Repositories (server/repo/**) use sqlstack decorators (source: `/Users/shavauhngabay/dev/noego_manager/noego/sqlstack`)
41
47
  - sqlstack reads .sql from filesystem at runtime, adjacent to transpiled repo JS
42
48
  - Tests/CI
43
49
  - Jest + Python runner; not directly relevant to build targets, but artifacts must be runnable in CI
44
50
  - Config files
45
- - `noblelaw/tsconfig.json`, `noblelaw/vite.config.js`, `noblelaw/proper.json`, `noblelaw/server/stitch.yaml`, `noblelaw/ui/stitch.yaml`
51
+ - `{project}/tsconfig.json`, `{project}/vite.config.js`, `{project}/proper.json`, `{project}/server/stitch.yaml`, `{project}/ui/stitch.yaml`
46
52
 
47
53
  Constraints and Implications
48
54
 
@@ -60,7 +66,7 @@ Repository Roots and Path Referencing
60
66
  CLI Contract
61
67
 
62
68
  - Command: app build --server index.ts --page ui/index.html
63
- - Options (all optional; default values assume noblelaw layout)
69
+ - Options (all optional; default values assume {project} layout)
64
70
  - --root . project root
65
71
  - --out dist output directory root
66
72
  - --server-root . where to resolve server entry from (default --root)
@@ -138,24 +144,24 @@ YAML Configuration (Preferred)
138
144
  - While every option is available via CLI, the preferred way to configure App is a YAML config file committed to the repo. This provides a single, reviewable source of truth. CLI flags remain available for ad‑hoc overrides.
139
145
 
140
146
  - Location and discovery
141
- - Default filenames searched at the application root (`noblelaw`): `app.yaml`, `app.yml`, `app.config.yaml`, `app.config.yml`, or `app.config.json`.
147
+ - Default filenames searched at the application root (`{project}`): `app.yaml`, `app.yml`, `app.config.yaml`, `app.config.yml`, or `app.config.json`.
142
148
  - A future `--config <path>` CLI flag may explicitly point to a YAML file. Paths inside the YAML resolve relative to the YAML file itself unless absolute.
143
149
 
144
150
  - Schema (high‑level)
145
151
  - Server
146
- - `entry`: e.g., `noblelaw/index.ts`
147
- - `rootDir`: e.g., `noblelaw`
148
- - `controllers`: e.g., `noblelaw/server/controller`
149
- - `middleware`: e.g., `noblelaw/middleware`
150
- - `openapi`: e.g., `noblelaw/server/stitch.yaml`
151
- - `sqlGlobs`: list of globs, e.g., `['noblelaw/server/repo/**/*.sql']`
152
+ - `entry`: e.g., `{project}/index.ts`
153
+ - `rootDir`: e.g., `{project}`
154
+ - `controllers`: e.g., `{project}/server/controller`
155
+ - `middleware`: e.g., `{project}/middleware`
156
+ - `openapi`: e.g., `{project}/server/stitch.yaml`
157
+ - `sqlGlobs`: list of globs, e.g., `['{project}/server/repo/**/*.sql']`
152
158
  - UI
153
- - `page`: e.g., `noblelaw/ui/index.html`
154
- - `options`: e.g., `noblelaw/ui/options.ts` (Forge server options entry)
155
- - `rootDir`: e.g., `noblelaw/ui`
156
- - `openapi`: e.g., `noblelaw/ui/stitch.yaml`
157
- - `assets`: list of globs, e.g., `['noblelaw/ui/resources/**']`
158
- - `clientExclude`: list of globs for CSR exclusions, e.g., `['noblelaw/server/**', 'noblelaw/middleware/**']`
159
+ - `page`: e.g., `{project}/ui/index.html`
160
+ - `options`: e.g., `{project}/ui/options.ts` (Forge server options entry)
161
+ - `rootDir`: e.g., `{project}/ui`
162
+ - `openapi`: e.g., `{project}/ui/stitch.yaml`
163
+ - `assets`: list of globs, e.g., `['{project}/ui/resources/**']`
164
+ - `clientExclude`: list of globs for CSR exclusions, e.g., `['{project}/server/**', '{project}/middleware/**']`
159
165
  - Dev
160
166
  - `watch`: boolean
161
167
  - `watchPaths`: list of globs (preferred)
@@ -169,51 +175,51 @@ YAML Configuration (Preferred)
169
175
  - `configFile`: path to a Vite config for SSR build
170
176
  - `override`: object or path to a JSON file with deep‑merge overrides
171
177
  - Output
172
- - `outDir`: e.g., `noblelaw/dist`
178
+ - `outDir`: e.g., `{project}/dist`
173
179
 
174
180
  - Referencing JSON files
175
181
  - Any `override` field under `vite.client` or `vite.ssr` may be either an inline object or a string path to a JSON file. App will load JSON and deep‑merge it into the Vite config (after the file’s own `configFile` is applied, before enforcing invariants like `outDir`).
176
182
  - This pattern can be extended to other future sections where JSON is a convenient format.
177
183
 
178
- - Example: `noblelaw/app.yaml`
184
+ - Example: `{project}/app.yaml`
179
185
  - server:
180
- entry: noblelaw/index.ts
181
- rootDir: noblelaw
182
- controllers: noblelaw/server/controller
183
- middleware: noblelaw/middleware
184
- openapi: noblelaw/server/stitch.yaml
186
+ entry: {project}/index.ts
187
+ rootDir: {project}
188
+ controllers: {project}/server/controller
189
+ middleware: {project}/middleware
190
+ openapi: {project}/server/stitch.yaml
185
191
  sqlGlobs:
186
- - noblelaw/server/repo/**/*.sql
192
+ - {project}/server/repo/**/*.sql
187
193
  - ui:
188
- page: noblelaw/ui/index.html
189
- options: noblelaw/ui/options.ts
190
- rootDir: noblelaw/ui
191
- openapi: noblelaw/ui/stitch.yaml
194
+ page: {project}/ui/index.html
195
+ options: {project}/ui/options.ts
196
+ rootDir: {project}/ui
197
+ openapi: {project}/ui/stitch.yaml
192
198
  assets:
193
- - noblelaw/ui/resources/**
194
- - noblelaw/ui/styles/**
199
+ - {project}/ui/resources/**
200
+ - {project}/ui/styles/**
195
201
  clientExclude:
196
- - noblelaw/server/**
197
- - noblelaw/middleware/**
202
+ - {project}/server/**
203
+ - {project}/middleware/**
198
204
  - dev:
199
205
  watch: true
200
206
  watchPaths:
201
- - noblelaw/server/**/*.ts
202
- - noblelaw/ui/openapi/**/*.yaml
207
+ - {project}/server/**/*.ts
208
+ - {project}/ui/openapi/**/*.yaml
203
209
  splitServe: true
204
210
  frontendCmd: vite
205
211
  - vite:
206
212
  client:
207
- configFile: noblelaw/vite.config.js
208
- override: noblelaw/vite.client.override.json
213
+ configFile: {project}/vite.config.js
214
+ override: {project}/vite.client.override.json
209
215
  ssr:
210
- configFile: noblelaw/vite.config.js
216
+ configFile: {project}/vite.config.js
211
217
  override:
212
218
  build:
213
219
  ssr: true
214
220
  rollupOptions:
215
221
  preserveModules: true
216
- - outDir: noblelaw/dist
222
+ - outDir: {project}/dist
217
223
 
218
224
  - Precedence and merging
219
225
  - Defaults → YAML → CLI: App starts from sane defaults, applies YAML config, then applies CLI flags as the final layer. Required invariants for production builds still apply (e.g., outDir, manifest, SSR flags), unless explicitly documented otherwise.
@@ -224,21 +230,21 @@ OpenAPI‑Driven Discovery (Build/Serve Optimization)
224
230
  - The OpenAPI specs (server and UI stitch YAML) already declare every API route, controller reference, middleware reference, and UI view/layout. App can parse these ahead of time to optimize build and reduce required options.
225
231
 
226
232
  - What App derives
227
- - Server OpenAPI (`noblelaw/server/stitch.yaml`):
233
+ - Server OpenAPI (`{project}/server/stitch.yaml`):
228
234
  - All controller identifiers from `x-controller` across routes.
229
235
  - All middleware identifiers from `x-middleware`.
230
- - UI OpenAPI (`noblelaw/ui/stitch.yaml`):
236
+ - UI OpenAPI (`{project}/ui/stitch.yaml`):
231
237
  - All pages, layouts, and component paths that need SSR precompilation.
232
238
 
233
239
  - How we use it
234
240
  - SSR entry set: feed the exact list of Svelte components (views/layouts) into the SSR Vite build, avoiding filesystem globs.
235
- - Validation: after transpile/copy, verify all referenced controllers/middleware resolve under `noblelaw/dist` and fail fast with actionable errors.
241
+ - Validation: after transpile/copy, verify all referenced controllers/middleware resolve under `{project}/dist` and fail fast with actionable errors.
236
242
  - Manifest: emit a debug manifest including discovered controllers, middleware, and SSR components for troubleshooting.
237
243
 
238
244
  - Option minimization (sane defaults with override)
239
- - If `--controllers` or `--middleware` are omitted, App attempts to infer base directories by computing the longest common directory prefix from discovered identifiers. Example: if `x-controller` uses `controllers/*.controller`, infer `noblelaw/server/controller`.
240
- - If inference fails or is ambiguous, App falls back to NobleLaw defaults and warns. Users retain full control by setting flags explicitly.
241
- - `--ui-openapi` remains required for SSR discovery unless the project embeds its location in code; App defaults to `noblelaw/ui/stitch.yaml`.
245
+ - If `--controllers` or `--middleware` are omitted, App attempts to infer base directories by computing the longest common directory prefix from discovered identifiers. Example: if `x-controller` uses `controllers/*.controller`, infer `{project}/server/controller`.
246
+ - If inference fails or is ambiguous, App falls back to {project} defaults and warns. Users retain full control by setting flags explicitly.
247
+ - `--ui-openapi` remains required for SSR discovery unless the project embeds its location in code; App defaults to `{project}/ui/stitch.yaml`.
242
248
 
243
249
  - Serve improvements
244
250
  - During `app serve`, App may parse OpenAPI once at startup to log missing controllers/middleware early, while still delegating runtime wiring to Dinner/Forge.
@@ -250,16 +256,16 @@ OpenAPI‑Driven Discovery (Build/Serve Optimization)
250
256
  - Treat CLI values as relative to --root unless absolute
251
257
  - --page determines --ui-root when not provided (dirname of --page)
252
258
  - Output layout (example)
253
- - `noblelaw/dist/server/**` transpiled JS, mirrored structure of `noblelaw/server/**` and `noblelaw/middleware/**`, plus copied `.sql` and `noblelaw/server/openapi/**`, `noblelaw/server/stitch.yaml`
254
- - `noblelaw/dist/client/**` bundled CSR assets + `manifest.json`
255
- - `noblelaw/dist/ssr/**` SSR bundle/modules + `ssr-manifest.json` (or embed SSR output under `noblelaw/dist/server/ssr/**`)
259
+ - `{project}/dist/server/**` transpiled JS, mirrored structure of `{project}/server/**` and `{project}/middleware/**`, plus copied `.sql` and `{project}/server/openapi/**`, `{project}/server/stitch.yaml`
260
+ - `{project}/dist/client/**` bundled CSR assets + `manifest.json`
261
+ - `{project}/dist/ssr/**` SSR bundle/modules + `ssr-manifest.json` (or embed SSR output under `{project}/dist/server/ssr/**`)
256
262
  - Runtime path resolution
257
263
  - All server code that used process.cwd() should continue to work if server is launched with cwd at dist root OR the dist structure mirrors
258
264
  source-tree paths
259
265
  - App must ensure:
260
- - Dinner’s `openapi_path` points to a real YAML under `noblelaw/dist/server/...`
261
- - Forge’s `open_api_path` resolves correctly under `noblelaw/dist` (either copy UI YAML or generate a stitched snapshot consumed by SSR)
262
- - sqlstack finds `.sql` files next to the transpiled repo JS under `noblelaw/dist/server/repo/**`
266
+ - Dinner’s `openapi_path` points to a real YAML under `{project}/dist/server/...`
267
+ - Forge’s `open_api_path` resolves correctly under `{project}/dist` (either copy UI YAML or generate a stitched snapshot consumed by SSR)
268
+ - sqlstack finds `.sql` files next to the transpiled repo JS under `{project}/dist/server/repo/**`
263
269
  - If any path cannot be made to resolve from cwd reliably, App provides a tiny generated bootstrap wrapper that rewrites those path
264
270
  strings to __dirname-based equivalents at build time (no change to public interfaces)
265
271
 
@@ -285,8 +291,8 @@ Path Customization and Framework Mapping
285
291
  - Root directory for SQL resolution is inferred from decorator location or explicitly passed via @Query(). No bundling or inlining — files must exist at runtime.
286
292
 
287
293
  - App CLI flags → runtime mapping
288
- - --controllers: the source directory to mirror into dist for runtime controller discovery. Must correspond to the app’s controllers_base_path at runtime. Defaults to server/controller for NobleLaw, but any path is valid.
289
- - --middleware: the source directory to mirror into dist for runtime middleware discovery. Must correspond to the app’s middleware_path (or controllers_base_path fallback). Defaults to middleware for NobleLaw.
294
+ - --controllers: the source directory to mirror into dist for runtime controller discovery. Must correspond to the app’s controllers_base_path at runtime. Defaults to server/controller for {project}, but any path is valid.
295
+ - --middleware: the source directory to mirror into dist for runtime middleware discovery. Must correspond to the app’s middleware_path (or controllers_base_path fallback). Defaults to middleware for {project}.
290
296
  - --openapi / --ui-openapi: OpenAPI stitch YAML locations for API and UI. App copies these into dist/server and dist/ui respectively.
291
297
  - --sql-glob: one or more glob patterns for SQL files to copy next to compiled JS outputs. Use this to support custom repo locations.
292
298
  - --page / --ui-root: determines client entry and build root used by Vite; paths are normalized relative to --root unless absolute.
@@ -332,7 +338,7 @@ Client Bundle Exclusions
332
338
  - Prevent accidental inclusion of server-only code in the browser bundle while allowing projects to keep shared TypeScript without extensions.
333
339
 
334
340
  - CLI
335
- - `--client-exclude <glob>`: may be specified multiple times. Paths are resolved relative to `--root` unless absolute. Example defaults for NobleLaw: `server/**`, `middleware/**`.
341
+ - `--client-exclude <glob>`: may be specified multiple times. Paths are resolved relative to `--root` unless absolute. Example defaults for {project}: `server/**`, `middleware/**`.
336
342
 
337
343
  - Behavior
338
344
  - During the Vite/Rollup client build, App installs a plugin that intercepts resolved module IDs. If the absolute file path matches any exclusion pattern, the loader returns a virtual stub module instead of the real source.
@@ -371,7 +377,7 @@ Non‑Default Layout Examples
371
377
  - `--watch` enables restart on server‑side changes
372
378
  - `--watch-path <glob>` adds additional watch patterns (repeatable). Preferred: use globs to cover directories rather than listing many files.
373
379
  - Defaults watch: controllers, middleware, server OpenAPI (and openapi/**), UI OpenAPI (and openapi/**), and SQL globs
374
- - UI Svelte/TS/CSS/HTML under `noblelaw/ui/**` are excluded from restart and continue to reload via Vite HMR
380
+ - UI Svelte/TS/CSS/HTML under `{project}/ui/**` are excluded from restart and continue to reload via Vite HMR
375
381
  - Optional split processes in watch mode:
376
382
  - `--split-serve` starts the frontend dev server (vite) as a separate process so API restarts don’t disrupt HMR
377
383
  - `--frontend-cmd vite` selects the frontend runner (currently only vite supported)
@@ -412,9 +418,9 @@ Non‑Default Layout Examples
412
418
  Acceptance Criteria
413
419
 
414
420
  - Running app build --server index.ts --page ui/index.html produces:
415
- - `noblelaw/dist/server` transpiled ESM with mirrored structure and copied `.sql`, OpenAPI YAML
416
- - `noblelaw/dist/client` minified assets + client manifest
417
- - `noblelaw/dist/ssr` precompiled SSR output + SSR manifest (or nested under `noblelaw/dist/server/ssr`)
421
+ - `{project}/dist/server` transpiled ESM with mirrored structure and copied `.sql`, OpenAPI YAML
422
+ - `{project}/dist/client` minified assets + client manifest
423
+ - `{project}/dist/ssr` precompiled SSR output + SSR manifest (or nested under `{project}/dist/server/ssr`)
418
424
  - Starting node dist/server/index.js:
419
425
  - API endpoints function unchanged
420
426
  - GET / returns SSR HTML; hydration works
@@ -433,7 +439,7 @@ Non‑Default Layout Examples
433
439
  - Record both source and output absolute paths inside a build manifest for troubleshooting
434
440
  - If the app is started from outside dist, require cwd to be dist (documented), or generate a wrapper that sets process.chdir(__dirname +
435
441
  '/../') at startup
436
- - Defaults for noblelaw
442
+ - Defaults for {project}
437
443
  - --root .
438
444
  - --server index.ts
439
445
  - --page ui/index.html
@@ -448,10 +454,13 @@ Non‑Default Layout Examples
448
454
 
449
455
 
450
456
  Framework Reference
451
- `/Users/shavauhngabay/dev/ego/forge`
452
- `/Users/shavauhngabay/dev/noego/dinner`
453
- `/Users/shavauhngabay/dev/ego/sqlstack`
457
+ `/Users/shavauhngabay/dev/noego_manager/noego/forge`
458
+ `/Users/shavauhngabay/dev/noego_manager/noego/dinner`
459
+ `/Users/shavauhngabay/dev/noego_manager/noego/sqlstack`
454
460
 
455
461
 
456
- Project Reference
457
- `/Users/shavauhngabay/dev/noblelaw`
462
+ Project References
463
+ `/Users/shavauhngabay/dev/noego_manager/websites/bashly`
464
+ `/Users/shavauhngabay/dev/noego_manager/websites/mindful_essence`
465
+ `/Users/shavauhngabay/dev/noego_manager/websites/noego_landing`
466
+ `/Users/shavauhngabay/dev/noego_manager/websites/noblelaw`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noego/app",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Production build tool for Dinner/Forge apps.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "glob-parent": "^6.0.2",
34
34
  "http-proxy": "^1.18.1",
35
35
  "picomatch": "^2.3.1",
36
+ "rxjs": "^7.8.1",
36
37
  "yaml": "^2.6.0"
37
38
  },
38
39
  "peerDependencies": {
package/src/args.js CHANGED
@@ -27,17 +27,11 @@ const FLAG_MAP = new Map([
27
27
  ['mode', 'mode'],
28
28
  ['verbose', 'verbose'],
29
29
  ['help', 'help'],
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']
30
+ ['version', 'version']
37
31
  ]);
38
32
 
39
33
  const MULTI_VALUE_FLAGS = new Set(['sqlGlob', 'assets', 'clientExclude', 'watchPath']);
40
- const BOOLEAN_FLAGS = new Set(['watch', 'splitServe', 'verbose', 'testVisible']);
34
+ const BOOLEAN_FLAGS = new Set(['watch', 'splitServe', 'verbose']);
41
35
 
42
36
  export function parseCliArgs(argv) {
43
37
  const result = {
@@ -135,15 +129,6 @@ export function printHelpAndExit({ stdout = process.stdout } = {}) {
135
129
  app dev [options]
136
130
  app serve [options]
137
131
  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
147
132
 
148
133
  Options (shared):
149
134
  --root <dir> Project root (default: .)
package/src/cli.js CHANGED
@@ -8,7 +8,6 @@ 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';
12
11
 
13
12
  export async function runCli(argv = process.argv.slice(2)) {
14
13
  try {
@@ -44,11 +43,6 @@ export async function runCli(argv = process.argv.slice(2)) {
44
43
  await runPreview(config);
45
44
  break;
46
45
  }
47
- case 'ci': {
48
- const config = await loadBuildConfig(options, { cwd: process.cwd() });
49
- await runTestLive(config);
50
- break;
51
- }
52
46
  default:
53
47
  throw cliError(`Unknown command "${command}"`);
54
48
  }
@@ -5,6 +5,11 @@ import { createBuildContext } from '../build/context.js';
5
5
  import { findConfigFile } from '../runtime/index.js';
6
6
  import { loadConfig } from '../runtime/config-loader.js';
7
7
  import globParent from 'glob-parent';
8
+ import { Subject, concat, of, EMPTY } from 'rxjs';
9
+ import { debounceTime, filter, exhaustMap, tap, catchError, takeUntil, finalize, map, switchMap } from 'rxjs/operators';
10
+ import { waitForPortFree } from '../utils/port.js';
11
+ import { stopProcess, killProcessTree as killProcessTreeUtil } from '../utils/process-observable.js';
12
+ import { watcherToObservable, FileEventType } from '../utils/file-watcher-observable.js';
8
13
 
9
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
15
 
@@ -444,11 +449,16 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
444
449
 
445
450
  let backendProc = null;
446
451
  let frontendProc = null;
447
- let pending = false;
448
452
  let backendRestartCount = 0;
449
453
  let frontendRestartCount = 0;
450
454
  let isShuttingDown = false;
451
455
 
456
+ // RxJS subjects for managing restart streams
457
+ const shutdownSubject = new Subject();
458
+ const backendRestartSubject = new Subject();
459
+ const frontendRestartSubject = new Subject();
460
+ const subscriptions = [];
461
+
452
462
  // Crash restart tracking
453
463
  const MAX_CRASH_RESTARTS = 3;
454
464
  const CRASH_RESTART_DELAY = 2000; // 2 seconds between crash restarts
@@ -558,10 +568,72 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
558
568
  });
559
569
  };
560
570
 
571
+ // RxJS-based stop functions that VERIFY port release before resolving
572
+ // This is the KEY FIX for the race condition bug
573
+
574
+ /**
575
+ * Stop backend and wait for port to be verified free
576
+ * Returns an Observable that completes only when the port is confirmed available
577
+ */
578
+ const stopBackendAndWait$ = () => {
579
+ if (!backendProc) {
580
+ return of(undefined);
581
+ }
582
+
583
+ logger.info(`⏹️ Stopping backend (preparing for restart #${backendRestartCount + 1})...`);
584
+ const proc = backendProc;
585
+
586
+ return concat(
587
+ // First: Stop the process and wait for exit
588
+ stopProcess(proc, 2000, killProcessTree),
589
+ // Then: VERIFY port is actually free (THIS IS THE KEY FIX)
590
+ waitForPortFree(backendPort, 5000, 50).pipe(
591
+ tap(() => logger.debug(`[BACKEND] Port ${backendPort} verified free`))
592
+ )
593
+ ).pipe(
594
+ tap(() => { backendProc = null; }),
595
+ catchError((err) => {
596
+ logger.warn(`[BACKEND] Stop warning: ${err.message}. Proceeding anyway.`);
597
+ backendProc = null;
598
+ return of(undefined);
599
+ })
600
+ );
601
+ };
602
+
603
+ /**
604
+ * Stop frontend and wait for port to be verified free
605
+ * Returns an Observable that completes only when the port is confirmed available
606
+ */
607
+ const stopFrontendAndWait$ = () => {
608
+ if (!frontendProc) {
609
+ return of(undefined);
610
+ }
611
+
612
+ logger.info(`⏹️ Stopping frontend (preparing for restart #${frontendRestartCount + 1})...`);
613
+ const proc = frontendProc;
614
+
615
+ return concat(
616
+ // First: Stop the process and wait for exit
617
+ stopProcess(proc, 2000, killProcessTree),
618
+ // Then: VERIFY port is actually free (THIS IS THE KEY FIX)
619
+ waitForPortFree(frontendPort, 5000, 50).pipe(
620
+ tap(() => logger.debug(`[FRONTEND] Port ${frontendPort} verified free`))
621
+ )
622
+ ).pipe(
623
+ tap(() => { frontendProc = null; }),
624
+ catchError((err) => {
625
+ logger.warn(`[FRONTEND] Stop warning: ${err.message}. Proceeding anyway.`);
626
+ frontendProc = null;
627
+ return of(undefined);
628
+ })
629
+ );
630
+ };
631
+
632
+ // Legacy Promise-based stop functions for shutdown (non-RxJS paths)
561
633
  const stopBackend = () =>
562
634
  new Promise((resolve) => {
563
635
  if (!backendProc) return resolve();
564
- logger.info(`⏹️ Stopping backend (preparing for restart #${backendRestartCount + 1})...`);
636
+ logger.info(`⏹️ Stopping backend...`);
565
637
  const pid = backendProc.pid;
566
638
  let exited = false;
567
639
 
@@ -584,11 +656,11 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
584
656
  resolve();
585
657
  }
586
658
  });
587
-
659
+
588
660
  const stopFrontend = () =>
589
661
  new Promise((resolve) => {
590
662
  if (!frontendProc) return resolve();
591
- logger.info(`⏹️ Stopping frontend (preparing for restart #${frontendRestartCount + 1})...`);
663
+ logger.info(`⏹️ Stopping frontend...`);
592
664
  const pid = frontendProc.pid;
593
665
  let exited = false;
594
666
 
@@ -620,6 +692,19 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
620
692
  logger.info(`Shutting down split-serve processes (signal: ${signal})...`);
621
693
 
622
694
  try {
695
+ // Signal RxJS streams to stop
696
+ shutdownSubject.next();
697
+ shutdownSubject.complete();
698
+
699
+ // Unsubscribe from all RxJS subscriptions
700
+ for (const sub of subscriptions) {
701
+ try {
702
+ sub.unsubscribe();
703
+ } catch {
704
+ // Ignore unsubscribe errors
705
+ }
706
+ }
707
+
623
708
  if (watcher) {
624
709
  await watcher.close();
625
710
  }
@@ -644,60 +729,125 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
644
729
  await shutdown('watcherError', 1, 'watcher-error');
645
730
  }
646
731
 
647
- const handleFileChange = async (reason, file) => {
648
- if (pending) return;
649
- pending = true;
650
-
732
+ /**
733
+ * Classify a file change event to determine which services need restarting
734
+ */
735
+ const classifyFileChange = (file, reason) => {
651
736
  // With cwd set, chokidar reports relative paths
652
- // Our patterns are now relative, so match against the relative path
653
737
  const relativePath = path.isAbsolute(file) ? path.relative(root, file) : file;
654
738
 
655
- // Determine which service(s) to restart
656
739
  let restartBackend = false;
657
740
  let restartFrontend = false;
741
+ let type = 'UNKNOWN';
658
742
 
659
743
  if (sharedMatcher && sharedMatcher(relativePath)) {
660
- // Shared file changed - restart both
661
- logger.info(`\n${'='.repeat(80)}`);
662
- logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
663
- logger.info(` Type: SHARED - Will restart BOTH backend and frontend`);
664
- logger.info('='.repeat(80));
665
744
  restartBackend = true;
666
745
  restartFrontend = true;
746
+ type = 'SHARED';
667
747
  } else if (backendMatcher && backendMatcher(relativePath)) {
668
- // Backend file changed
669
- logger.info(`\n${'='.repeat(80)}`);
670
- logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
671
- logger.info(` Type: BACKEND - Will restart backend only`);
672
- logger.info('='.repeat(80));
673
748
  restartBackend = true;
749
+ type = 'BACKEND';
674
750
  } else if (frontendMatcher && frontendMatcher(relativePath)) {
675
- // Frontend file changed
676
- logger.info(`\n${'='.repeat(80)}`);
677
- logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
678
- logger.info(` Type: FRONTEND - Will restart frontend only`);
679
- logger.info('='.repeat(80));
680
751
  restartFrontend = true;
752
+ type = 'FRONTEND';
681
753
  }
682
754
 
683
- // Restart appropriate service(s)
684
- if (restartBackend) {
685
- await stopBackend();
686
- await new Promise(r => setTimeout(r, 100)); // Small delay to ensure port release
687
- startBackend();
688
- }
689
- if (restartFrontend) {
690
- await stopFrontend();
691
- await new Promise(r => setTimeout(r, 100)); // Small delay to ensure port release
692
- startFrontend();
693
- }
755
+ return { relativePath, reason, restartBackend, restartFrontend, type };
756
+ };
694
757
 
695
- if (restartBackend || restartFrontend) {
696
- logger.info(`✅ Restart complete\n`);
697
- }
758
+ /**
759
+ * RxJS-based restart pipeline for backend
760
+ * Uses exhaustMap to ignore new requests while restarting
761
+ * Uses port verification to ensure clean restart (THE KEY FIX)
762
+ */
763
+ const setupBackendRestartPipeline = () => {
764
+ return backendRestartSubject.pipe(
765
+ takeUntil(shutdownSubject),
766
+ // exhaustMap ignores new restart requests while one is in progress
767
+ // This prevents restart-during-restart chaos
768
+ exhaustMap((change) => {
769
+ logger.info(`\n${'='.repeat(80)}`);
770
+ logger.info(`🔄 FILE CHANGE DETECTED: ${change.relativePath} (${change.reason})`);
771
+ logger.info(` Type: ${change.type} - Restarting backend`);
772
+ logger.info('='.repeat(80));
773
+
774
+ return concat(
775
+ // Stop backend and WAIT for port to be verified free
776
+ stopBackendAndWait$(),
777
+ // Then start the new backend
778
+ of(undefined).pipe(
779
+ tap(() => {
780
+ startBackend();
781
+ logger.info(`✅ Backend restart complete\n`);
782
+ })
783
+ )
784
+ ).pipe(
785
+ catchError((err) => {
786
+ logger.error(`[BACKEND] Restart failed: ${err.message}`);
787
+ return of(undefined);
788
+ })
789
+ );
790
+ })
791
+ );
792
+ };
698
793
 
699
- pending = false;
794
+ /**
795
+ * RxJS-based restart pipeline for frontend
796
+ * Uses exhaustMap to ignore new requests while restarting
797
+ * Uses port verification to ensure clean restart (THE KEY FIX)
798
+ */
799
+ const setupFrontendRestartPipeline = () => {
800
+ return frontendRestartSubject.pipe(
801
+ takeUntil(shutdownSubject),
802
+ // exhaustMap ignores new restart requests while one is in progress
803
+ exhaustMap((change) => {
804
+ logger.info(`\n${'='.repeat(80)}`);
805
+ logger.info(`🔄 FILE CHANGE DETECTED: ${change.relativePath} (${change.reason})`);
806
+ logger.info(` Type: ${change.type} - Restarting frontend`);
807
+ logger.info('='.repeat(80));
808
+
809
+ return concat(
810
+ // Stop frontend and WAIT for port to be verified free
811
+ stopFrontendAndWait$(),
812
+ // Then start the new frontend
813
+ of(undefined).pipe(
814
+ tap(() => {
815
+ startFrontend();
816
+ logger.info(`✅ Frontend restart complete\n`);
817
+ })
818
+ )
819
+ ).pipe(
820
+ catchError((err) => {
821
+ logger.error(`[FRONTEND] Restart failed: ${err.message}`);
822
+ return of(undefined);
823
+ })
824
+ );
825
+ })
826
+ );
700
827
  };
828
+
829
+ /**
830
+ * Handle file change event by routing to appropriate restart subject
831
+ */
832
+ const handleFileChange = (reason, file) => {
833
+ if (isShuttingDown) return;
834
+
835
+ const change = classifyFileChange(file, reason);
836
+
837
+ // Route to appropriate restart subject(s)
838
+ // The RxJS pipelines handle deduplication via exhaustMap
839
+ if (change.restartBackend) {
840
+ backendRestartSubject.next(change);
841
+ }
842
+ if (change.restartFrontend) {
843
+ frontendRestartSubject.next(change);
844
+ }
845
+ };
846
+
847
+ // Set up RxJS restart pipelines
848
+ const backendRestartSub = setupBackendRestartPipeline().subscribe();
849
+ const frontendRestartSub = setupFrontendRestartPipeline().subscribe();
850
+ subscriptions.push(backendRestartSub, frontendRestartSub);
701
851
 
702
852
  // Create watcher
703
853
  // Use cwd with relative patterns for proper glob support
package/src/config.js CHANGED
@@ -71,13 +71,6 @@ 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,
81
74
  layout,
82
75
  server: config.server ? {
83
76
  rootDir: config.server.main_abs ? path.dirname(config.server.main_abs) : config.root,
@@ -0,0 +1,124 @@
1
+ import { Observable, Subject, merge, fromEvent } from 'rxjs';
2
+ import { map, takeUntil, share, finalize, debounceTime } from 'rxjs/operators';
3
+
4
+ /**
5
+ * File event types emitted by the watcher observable
6
+ */
7
+ export const FileEventType = {
8
+ ADD: 'add',
9
+ CHANGE: 'change',
10
+ UNLINK: 'unlink',
11
+ ADD_DIR: 'addDir',
12
+ UNLINK_DIR: 'unlinkDir',
13
+ READY: 'ready',
14
+ ERROR: 'error'
15
+ };
16
+
17
+ /**
18
+ * Convert a chokidar watcher instance to an RxJS Observable stream
19
+ * @param {FSWatcher} watcher - A chokidar watcher instance
20
+ * @returns {Observable<{type: string, path: string}>} - Observable of file events
21
+ */
22
+ export function watcherToObservable(watcher) {
23
+ return new Observable((subscriber) => {
24
+ const handlers = {
25
+ add: (path) => subscriber.next({ type: FileEventType.ADD, path }),
26
+ change: (path) => subscriber.next({ type: FileEventType.CHANGE, path }),
27
+ unlink: (path) => subscriber.next({ type: FileEventType.UNLINK, path }),
28
+ addDir: (path) => subscriber.next({ type: FileEventType.ADD_DIR, path }),
29
+ unlinkDir: (path) => subscriber.next({ type: FileEventType.UNLINK_DIR, path }),
30
+ ready: () => subscriber.next({ type: FileEventType.READY, path: '' }),
31
+ error: (error) => subscriber.error(error)
32
+ };
33
+
34
+ // Attach event handlers
35
+ Object.entries(handlers).forEach(([event, handler]) => {
36
+ watcher.on(event, handler);
37
+ });
38
+
39
+ // Cleanup on unsubscription
40
+ return () => {
41
+ Object.entries(handlers).forEach(([event, handler]) => {
42
+ watcher.off(event, handler);
43
+ });
44
+ };
45
+ }).pipe(
46
+ // Share the observable so multiple subscribers don't create duplicate handlers
47
+ share()
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Create a file change stream with classification capabilities
53
+ * @param {FSWatcher} watcher - A chokidar watcher instance
54
+ * @param {object} matchers - Object containing picomatch matchers for classification
55
+ * @param {Function} matchers.shared - Matcher for shared files (restart both)
56
+ * @param {Function} matchers.backend - Matcher for backend files
57
+ * @param {Function} matchers.frontend - Matcher for frontend files
58
+ * @returns {Observable<{type: string, path: string, restartBackend: boolean, restartFrontend: boolean}>}
59
+ */
60
+ export function createClassifiedFileStream(watcher, matchers) {
61
+ return watcherToObservable(watcher).pipe(
62
+ // Filter to only file change events (not directories or ready)
63
+ map((event) => {
64
+ if (!event.path || event.type === FileEventType.READY ||
65
+ event.type === FileEventType.ADD_DIR || event.type === FileEventType.UNLINK_DIR) {
66
+ return null;
67
+ }
68
+
69
+ const { path } = event;
70
+ let restartBackend = false;
71
+ let restartFrontend = false;
72
+
73
+ // Check shared patterns first (restart both)
74
+ if (matchers.shared && matchers.shared(path)) {
75
+ restartBackend = true;
76
+ restartFrontend = true;
77
+ } else {
78
+ // Check individual patterns
79
+ if (matchers.backend && matchers.backend(path)) {
80
+ restartBackend = true;
81
+ }
82
+ if (matchers.frontend && matchers.frontend(path)) {
83
+ restartFrontend = true;
84
+ }
85
+ }
86
+
87
+ // Only return events that trigger restarts
88
+ if (!restartBackend && !restartFrontend) {
89
+ return null;
90
+ }
91
+
92
+ return {
93
+ ...event,
94
+ restartBackend,
95
+ restartFrontend
96
+ };
97
+ }),
98
+ // Filter out null events
99
+ map((event) => event) // This will pass through the filter in the pipe where it's used
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Create a debounced file change stream
105
+ * Useful for batching rapid file changes
106
+ * @param {FSWatcher} watcher - A chokidar watcher instance
107
+ * @param {number} debounceMs - Debounce time in milliseconds (default: 50)
108
+ * @returns {Observable<{type: string, path: string}>}
109
+ */
110
+ export function createDebouncedFileStream(watcher, debounceMs = 50) {
111
+ return watcherToObservable(watcher).pipe(
112
+ // Filter to only actual file events
113
+ map((event) => {
114
+ if (event.type === FileEventType.READY ||
115
+ event.type === FileEventType.ADD_DIR ||
116
+ event.type === FileEventType.UNLINK_DIR) {
117
+ return null;
118
+ }
119
+ return event;
120
+ }),
121
+ // Filter out nulls - this happens in the subscription
122
+ debounceTime(debounceMs)
123
+ );
124
+ }
@@ -0,0 +1,104 @@
1
+ import { Observable, interval, of, throwError } from 'rxjs';
2
+ import { map, switchMap, filter, take, timeout, catchError } from 'rxjs/operators';
3
+ import net from 'node:net';
4
+
5
+ /**
6
+ * Check if a port is currently free (available for binding)
7
+ * @param {number} port - The port number to check
8
+ * @returns {Observable<boolean>} - Observable that emits true if port is free, false if occupied
9
+ */
10
+ export function checkPort(port) {
11
+ return new Observable((subscriber) => {
12
+ const server = net.createServer();
13
+
14
+ server.once('error', (err) => {
15
+ if (err.code === 'EADDRINUSE') {
16
+ // Port is in use
17
+ subscriber.next(false);
18
+ subscriber.complete();
19
+ } else {
20
+ subscriber.error(err);
21
+ }
22
+ });
23
+
24
+ server.once('listening', () => {
25
+ // Port is free - close the server and report success
26
+ server.close(() => {
27
+ subscriber.next(true);
28
+ subscriber.complete();
29
+ });
30
+ });
31
+
32
+ // Attempt to bind to the port
33
+ server.listen(port, '127.0.0.1');
34
+
35
+ // Cleanup on unsubscription
36
+ return () => {
37
+ try {
38
+ server.close();
39
+ } catch {
40
+ // Server may already be closed
41
+ }
42
+ };
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Wait for a port to become free by polling at regular intervals
48
+ * @param {number} port - The port number to monitor
49
+ * @param {number} timeoutMs - Maximum time to wait in milliseconds (default: 5000)
50
+ * @param {number} pollInterval - Time between checks in milliseconds (default: 50)
51
+ * @returns {Observable<void>} - Observable that completes when port is free, errors on timeout
52
+ */
53
+ export function waitForPortFree(port, timeoutMs = 5000, pollInterval = 50) {
54
+ return interval(pollInterval).pipe(
55
+ // Check port availability on each interval tick
56
+ switchMap(() => checkPort(port)),
57
+ // Only pass through when port is free
58
+ filter((isFree) => isFree === true),
59
+ // Complete after first confirmation that port is free
60
+ take(1),
61
+ // Map to void (we don't need the boolean value)
62
+ map(() => undefined),
63
+ // Timeout if port doesn't become free within the specified time
64
+ timeout({
65
+ each: timeoutMs,
66
+ with: () => throwError(() => new Error(`Port ${port} did not become free within ${timeoutMs}ms`))
67
+ }),
68
+ // Provide graceful fallback on timeout (continue anyway but log warning)
69
+ catchError((err) => {
70
+ console.warn(`[port.js] Warning: ${err.message}. Proceeding anyway.`);
71
+ return of(undefined);
72
+ })
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Wait for a port to be in use (server started)
78
+ * @param {number} port - The port number to monitor
79
+ * @param {number} timeoutMs - Maximum time to wait in milliseconds (default: 10000)
80
+ * @param {number} pollInterval - Time between checks in milliseconds (default: 100)
81
+ * @returns {Observable<void>} - Observable that completes when port is in use, errors on timeout
82
+ */
83
+ export function waitForPortInUse(port, timeoutMs = 10000, pollInterval = 100) {
84
+ return interval(pollInterval).pipe(
85
+ // Check port availability on each interval tick
86
+ switchMap(() => checkPort(port)),
87
+ // Only pass through when port is occupied (server running)
88
+ filter((isFree) => isFree === false),
89
+ // Complete after first confirmation that port is in use
90
+ take(1),
91
+ // Map to void
92
+ map(() => undefined),
93
+ // Timeout if server doesn't start within the specified time
94
+ timeout({
95
+ each: timeoutMs,
96
+ with: () => throwError(() => new Error(`Port ${port} did not become active within ${timeoutMs}ms`))
97
+ }),
98
+ // Provide graceful fallback on timeout
99
+ catchError((err) => {
100
+ console.warn(`[port.js] Warning: ${err.message}. Proceeding anyway.`);
101
+ return of(undefined);
102
+ })
103
+ );
104
+ }
@@ -0,0 +1,218 @@
1
+ import { Observable, of, timer, race, EMPTY } from 'rxjs';
2
+ import { map, switchMap, take, catchError, finalize, tap } from 'rxjs/operators';
3
+ import { spawn, execSync } from 'node:child_process';
4
+
5
+ /**
6
+ * Process event types emitted by spawnProcess observable
7
+ */
8
+ export const ProcessEvent = {
9
+ SPAWNED: 'spawned',
10
+ STDOUT: 'stdout',
11
+ STDERR: 'stderr',
12
+ EXITED: 'exited',
13
+ ERROR: 'error'
14
+ };
15
+
16
+ /**
17
+ * Kill a process and all its descendants (entire process tree)
18
+ * This ensures no orphaned processes remain when killing a parent
19
+ * @param {number} pid - Process ID to kill
20
+ * @param {string} signal - Signal to send (default: 'SIGKILL')
21
+ */
22
+ export function killProcessTree(pid, signal = 'SIGKILL') {
23
+ if (!pid) return;
24
+
25
+ try {
26
+ // On Unix systems, use pkill to kill all descendants
27
+ if (process.platform !== 'win32') {
28
+ // Kill all child processes first
29
+ try {
30
+ execSync(`pkill -P ${pid}`, { stdio: 'ignore' });
31
+ } catch {
32
+ // No children or already dead, that's fine
33
+ }
34
+ // Then kill the parent
35
+ try {
36
+ process.kill(pid, signal);
37
+ } catch {
38
+ // Process may already be dead
39
+ }
40
+ } else {
41
+ // On Windows, use taskkill with /T flag to kill process tree
42
+ try {
43
+ execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' });
44
+ } catch {
45
+ // Process may already be dead
46
+ }
47
+ }
48
+ } catch {
49
+ // Ignore errors - process might already be dead
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Spawn a child process and return it as an Observable stream of events
55
+ * @param {string} command - The command to execute
56
+ * @param {string[]} args - Command arguments
57
+ * @param {object} options - Spawn options (cwd, env, etc.)
58
+ * @returns {Observable<{type: string, process?: ChildProcess, data?: any, code?: number, signal?: string}>}
59
+ */
60
+ export function spawnProcess(command, args, options = {}) {
61
+ return new Observable((subscriber) => {
62
+ let proc = null;
63
+
64
+ try {
65
+ proc = spawn(command, args, {
66
+ ...options,
67
+ detached: false
68
+ });
69
+
70
+ // Emit spawned event with process reference
71
+ subscriber.next({
72
+ type: ProcessEvent.SPAWNED,
73
+ process: proc,
74
+ pid: proc.pid
75
+ });
76
+
77
+ // Handle stdout if not inherited
78
+ if (proc.stdout) {
79
+ proc.stdout.on('data', (data) => {
80
+ subscriber.next({
81
+ type: ProcessEvent.STDOUT,
82
+ data: data.toString()
83
+ });
84
+ });
85
+ }
86
+
87
+ // Handle stderr if not inherited
88
+ if (proc.stderr) {
89
+ proc.stderr.on('data', (data) => {
90
+ subscriber.next({
91
+ type: ProcessEvent.STDERR,
92
+ data: data.toString()
93
+ });
94
+ });
95
+ }
96
+
97
+ // Handle process exit
98
+ proc.on('exit', (code, signal) => {
99
+ subscriber.next({
100
+ type: ProcessEvent.EXITED,
101
+ code,
102
+ signal
103
+ });
104
+ subscriber.complete();
105
+ });
106
+
107
+ // Handle spawn errors
108
+ proc.on('error', (error) => {
109
+ subscriber.next({
110
+ type: ProcessEvent.ERROR,
111
+ error
112
+ });
113
+ subscriber.error(error);
114
+ });
115
+ } catch (error) {
116
+ subscriber.error(error);
117
+ }
118
+
119
+ // Cleanup on unsubscription
120
+ return () => {
121
+ if (proc && !proc.killed) {
122
+ try {
123
+ proc.kill('SIGTERM');
124
+ } catch {
125
+ // Process may already be dead
126
+ }
127
+ }
128
+ };
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Stop a process gracefully with timeout-based force kill
134
+ * @param {ChildProcess} proc - The child process to stop
135
+ * @param {number} gracefulTimeout - Time to wait before force-killing (default: 5000ms)
136
+ * @param {function} killTreeFn - Function to kill process tree (optional, uses killProcessTree by default)
137
+ * @returns {Observable<void>} - Observable that completes when process has exited
138
+ */
139
+ export function stopProcess(proc, gracefulTimeout = 5000, killTreeFn = killProcessTree) {
140
+ if (!proc || proc.killed) {
141
+ return of(undefined);
142
+ }
143
+
144
+ return new Observable((subscriber) => {
145
+ const pid = proc.pid;
146
+ let exited = false;
147
+ let forceKillTimer = null;
148
+
149
+ // Set up force kill timer
150
+ forceKillTimer = setTimeout(() => {
151
+ if (!exited && pid) {
152
+ console.warn(`[process-observable] Process ${pid} didn't exit gracefully, force killing...`);
153
+ killTreeFn(pid, 'SIGKILL');
154
+ }
155
+ }, gracefulTimeout);
156
+
157
+ // Listen for exit event
158
+ const onExit = () => {
159
+ exited = true;
160
+ if (forceKillTimer) {
161
+ clearTimeout(forceKillTimer);
162
+ forceKillTimer = null;
163
+ }
164
+ subscriber.next(undefined);
165
+ subscriber.complete();
166
+ };
167
+
168
+ proc.once('exit', onExit);
169
+
170
+ // Send SIGTERM to request graceful shutdown
171
+ try {
172
+ proc.kill('SIGTERM');
173
+ } catch {
174
+ // Process may already be dead
175
+ if (!exited) {
176
+ exited = true;
177
+ if (forceKillTimer) {
178
+ clearTimeout(forceKillTimer);
179
+ forceKillTimer = null;
180
+ }
181
+ subscriber.next(undefined);
182
+ subscriber.complete();
183
+ }
184
+ }
185
+
186
+ // Cleanup on unsubscription
187
+ return () => {
188
+ if (forceKillTimer) {
189
+ clearTimeout(forceKillTimer);
190
+ }
191
+ proc.off('exit', onExit);
192
+ };
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Stop a process and verify it has completely stopped
198
+ * This is a higher-level function that combines stopProcess with verification
199
+ * @param {ChildProcess} proc - The child process to stop
200
+ * @param {number} gracefulTimeout - Time to wait before force-killing (default: 5000ms)
201
+ * @param {function} killTreeFn - Function to kill process tree
202
+ * @returns {Observable<void>} - Observable that completes when process is confirmed stopped
203
+ */
204
+ export function stopProcessAndVerify(proc, gracefulTimeout = 5000, killTreeFn = killProcessTree) {
205
+ if (!proc || proc.killed) {
206
+ return of(undefined);
207
+ }
208
+
209
+ return stopProcess(proc, gracefulTimeout, killTreeFn).pipe(
210
+ // Add a small delay to ensure OS has released resources
211
+ switchMap(() => timer(50)),
212
+ map(() => undefined),
213
+ catchError((err) => {
214
+ console.warn(`[process-observable] Error stopping process: ${err.message}`);
215
+ return of(undefined);
216
+ })
217
+ );
218
+ }
@@ -1,17 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
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:*)"
13
- ],
14
- "deny": [],
15
- "ask": []
16
- }
17
- }
@@ -1,187 +0,0 @@
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
- }