@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 +83 -74
- package/package.json +2 -1
- package/src/args.js +2 -17
- package/src/cli.js +0 -6
- package/src/commands/dev.js +190 -40
- package/src/config.js +0 -7
- package/src/utils/file-watcher-observable.js +124 -0
- package/src/utils/port.js +104 -0
- package/src/utils/process-observable.js +218 -0
- package/.claude/settings.local.json +0 -17
- package/src/commands/test-live.js +0 -187
package/AGENTS.md
CHANGED
|
@@ -12,37 +12,43 @@ Objective
|
|
|
12
12
|
|
|
13
13
|
Entry Points
|
|
14
14
|
|
|
15
|
-
- Server entry: `
|
|
16
|
-
- Page entry: `
|
|
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`)
|
|
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/
|
|
23
|
-
- Dinner: `/Users/shavauhngabay/dev/noego/dinner`
|
|
24
|
-
- Sqlstack: `/Users/shavauhngabay/dev/
|
|
25
|
-
- App (this CLI): `/Users/shavauhngabay/dev/app`
|
|
26
|
-
- Example dist outputs in this doc are under the application project: `
|
|
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 (
|
|
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/
|
|
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 `
|
|
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/
|
|
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
|
-
- `
|
|
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
|
|
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 (`
|
|
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., `
|
|
147
|
-
- `rootDir`: e.g., `
|
|
148
|
-
- `controllers`: e.g., `
|
|
149
|
-
- `middleware`: e.g., `
|
|
150
|
-
- `openapi`: e.g., `
|
|
151
|
-
- `sqlGlobs`: list of globs, e.g., `['
|
|
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., `
|
|
154
|
-
- `options`: e.g., `
|
|
155
|
-
- `rootDir`: e.g., `
|
|
156
|
-
- `openapi`: e.g., `
|
|
157
|
-
- `assets`: list of globs, e.g., `['
|
|
158
|
-
- `clientExclude`: list of globs for CSR exclusions, e.g., `['
|
|
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., `
|
|
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: `
|
|
184
|
+
- Example: `{project}/app.yaml`
|
|
179
185
|
- server:
|
|
180
|
-
entry:
|
|
181
|
-
rootDir:
|
|
182
|
-
controllers:
|
|
183
|
-
middleware:
|
|
184
|
-
openapi:
|
|
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
|
-
-
|
|
192
|
+
- {project}/server/repo/**/*.sql
|
|
187
193
|
- ui:
|
|
188
|
-
page:
|
|
189
|
-
options:
|
|
190
|
-
rootDir:
|
|
191
|
-
openapi:
|
|
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
|
-
-
|
|
194
|
-
-
|
|
199
|
+
- {project}/ui/resources/**
|
|
200
|
+
- {project}/ui/styles/**
|
|
195
201
|
clientExclude:
|
|
196
|
-
-
|
|
197
|
-
-
|
|
202
|
+
- {project}/server/**
|
|
203
|
+
- {project}/middleware/**
|
|
198
204
|
- dev:
|
|
199
205
|
watch: true
|
|
200
206
|
watchPaths:
|
|
201
|
-
-
|
|
202
|
-
-
|
|
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:
|
|
208
|
-
override:
|
|
213
|
+
configFile: {project}/vite.config.js
|
|
214
|
+
override: {project}/vite.client.override.json
|
|
209
215
|
ssr:
|
|
210
|
-
configFile:
|
|
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:
|
|
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 (`
|
|
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 (`
|
|
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 `
|
|
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 `
|
|
240
|
-
- If inference fails or is ambiguous, App falls back to
|
|
241
|
-
- `--ui-openapi` remains required for SSR discovery unless the project embeds its location in code; App defaults to `
|
|
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
|
-
- `
|
|
254
|
-
- `
|
|
255
|
-
- `
|
|
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 `
|
|
261
|
-
- Forge’s `open_api_path` resolves correctly under `
|
|
262
|
-
- sqlstack finds `.sql` files next to the transpiled repo JS under `
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
-
- `
|
|
416
|
-
- `
|
|
417
|
-
- `
|
|
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
|
|
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/
|
|
452
|
-
`/Users/shavauhngabay/dev/noego/dinner`
|
|
453
|
-
`/Users/shavauhngabay/dev/
|
|
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
|
|
457
|
-
`/Users/shavauhngabay/dev/
|
|
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.
|
|
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'
|
|
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
|
}
|
package/src/commands/dev.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
}
|