@gachlab/devup 0.4.0 → 0.5.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to `@gachlab/devup` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] — 2026-05-21
9
+
10
+ Config power release — six features that sharpen day-to-day debugging in a long-running stack.
11
+
12
+ ### Added
13
+ - **Regex search in logs** (#8). `/` accepts vim-style `/pattern/flags` in addition to the existing case-insensitive substring mode. `/error/`, `/^api: \d+/`, `/foo/g` all work. Case-insensitive by default — add explicit flags after the slash if needed. Invalid regex falls back to substring search and shows `(invalid regex)` in the logs panel header so the user can correct it. Plain strings (including ones with slashes inside) keep working as substring matches.
14
+ - **`healthCheck.startPeriod` grace window** (#15). New optional field, in seconds. Probes are fully suppressed during the window, status stays `starting`, `health` stays `wait`. Eliminates spurious failed probes during slow boots (Angular cold-start, big webpack builds) that otherwise inflate `state.errors` and pollute the TUI.
15
+ - **Customizable error pattern per service** (#16). New `errorPattern?: string` field on `ServiceConfig`. When set, only stderr lines matching the regex (same `/pattern/flags` grammar as `readyPattern`) bump `state.errors`. Without it, every non-empty stderr line counts (existing behavior). Useful for libraries that write info to stderr — Angular CLI is the worst offender.
16
+ - **Filter logs by level** (#19). Each log line is tagged with a level on ingestion: `error > warn > info`. New `L` key cycles the filter: `all → error → warn+error → all`. Detection is keyword-based with conjugations (`error`, `fail(ed|ure|s)`, `fatal`, `exception`, `crash(ed|es)` → error; `warn(ed|ing|s)`, `deprec` → warn). Devup's own log markers count: `❌`/`✗`/`⛔` → error, `⚠` → warn. `a` (show all) also resets the level filter.
17
+ - **Verbose stats** (#21). New `v` key toggles the stats panel between compact mode and verbose mode. Verbose mode adds two dim indented lines per service: `cmd: <cmd> <resolved args>` (after `buildProcessArgs`, so devup-injected flags like `--max-old-space-size` are visible) and `env: KEY=value ...` (only when `extraEnv` is non-empty). Env values are auto-redacted (`***`) for keys matching `/secret|token|password|api[_-]?key|auth/i`.
18
+ - **Resource awareness — RAM watchdog banner** (#24). When system RAM usage crosses 80 % the stats panel shows a banner: `⚠ RAM 84% — top: app-api 520MB, staff-web 480MB, admin-web 460MB`. Hysteresis-driven (turns off only below 75 %, no flicker at the boundary). Top consumers are sorted by `stats.get(name).mem` and capped at 3.
19
+
20
+ ### Changed
21
+ - `LogEntry` interface gains a required `level: LogLevel` field; both `pushLog()` and the manager-driven `onLog` handler compute it on ingestion.
22
+ - StatusBar shows the new `L` Level and `v` Verbose bindings.
23
+ - The Logs panel header gains `[level: error]` / `[level: warn+error]` markers when a level filter is active.
24
+
25
+ ### Internals
26
+ - New pure helpers in `utils.ts`: `compileSearchPattern`, `detectLogLevel`, `redactSecrets`, `nextRamBannerVisibility`. All exported, all individually tested.
27
+ - Test suite grown from 274 to ~299. New suites: `compileSearchPattern` (6), `detectLogLevel` (5), `redactSecrets` (3), `nextRamBannerVisibility` (4), plus 2 manager tests for `errorPattern` and 1 for `healthCheck.startPeriod`.
28
+
8
29
  ## [0.4.0] — 2026-05-21
9
30
 
10
31
  Polish + standalone CLI release. Eight focused improvements landed as a single PR with one commit per issue.
@@ -135,6 +156,7 @@ Initial release.
135
156
  - Config file resolution order: `devup.config.ts` → `.js` → `.json`, with `--config <path>` override. TypeScript loaded via the `tsx` import hook.
136
157
  - CLI flags: `--only`, `--services`, `--skip`, `--lazy`/`--no-lazy`, `--timeout`, `--proxy`, `--proxy-host`, `--proxy-conf`, `--proxy-tls`/`--no-proxy-tls`, `--proxy-entrypoint`, `--config`.
137
158
 
159
+ [0.5.0]: https://github.com/gachlab/devup/releases/tag/0.5.0
138
160
  [0.4.0]: https://github.com/gachlab/devup/releases/tag/0.4.0
139
161
  [0.3.0]: https://github.com/gachlab/devup/releases/tag/0.3.0
140
162
  [0.2.0]: https://github.com/gachlab/devup/releases/tag/0.2.0
package/README.md CHANGED
@@ -125,6 +125,7 @@ Then `devup --profile check-in` boots that subset. Composable with `--skip`. The
125
125
  | `extraEnv` | `Record<string, string>` | | Extra environment variables for this service |
126
126
  | `healthCheck` | `HealthCheckConfig` | | Override the readiness check for this service. Default: TCP probe on `port` |
127
127
  | `readyPattern` | `string` | | Regex matched against stdout/stderr lines. On match, service is marked `up` immediately, short-circuiting the next health-check poll. Plain string or vim-style `/pattern/flags`. Case-insensitive by default |
128
+ | `errorPattern` | `string` | | Only stderr lines matching this regex bump `state.errors`. Without it every non-empty stderr line counts. Same `/pattern/flags` grammar as `readyPattern` |
128
129
 
129
130
  ### `HealthCheckConfig`
130
131
 
@@ -135,6 +136,7 @@ Then `devup --profile check-in` boots that subset. Composable with `--skip`. The
135
136
  | `expect` | `number \| number[]` | | HTTP-only acceptable status code(s). Default: any 2xx (200-299) |
136
137
  | `host` | `string` | | Override target host for the HTTP check. Default: `127.0.0.1` |
137
138
  | `timeoutMs` | `number` | | Per-check socket/request timeout in ms. Default: `2000` |
139
+ | `startPeriod` | `number` | | Grace period in seconds before the first probe runs. Useful for slow boots (Angular cold-start, etc.) so failed probes during boot don't pollute `state.errors`. Default: `0` |
138
140
 
139
141
  ```typescript
140
142
  // Wait for /healthz to return 200 before considering the service up
@@ -318,14 +320,16 @@ devup writes a separate `.log` file per service to disk. Lines are prefixed with
318
320
  | `[` / `]` (or `Ctrl+B` / `Ctrl+F`) | Page up / page down |
319
321
  | `Ctrl+A` / `Ctrl+E` | Jump to top / bottom of the focused panel |
320
322
  | `f` | Filter logs by service |
321
- | `a` | Show all logs (clear filter) |
322
- | `/` | Search in logs |
323
+ | `L` | Cycle log level filter (all → error → warn+error → all) |
324
+ | `a` | Show all logs (clear service / search / level filters) |
325
+ | `/` | Search in logs (accepts `/pattern/flags` regex) |
323
326
  | `p` | Pause/resume log output (auto-engaged when you scroll up) |
324
327
  | `t` | Toggle timestamps |
325
328
  | `c` | Clear logs |
326
329
  | `s` | Cycle sort mode (name → memory → errors) |
327
330
  | `r` | Restart a service |
328
- | `o` | Open a web service in browser |
331
+ | `o` | Open a web service in browser (TLS-aware when `--proxy`) |
332
+ | `v` | Verbose stats: show resolved `cmd`/args/env per service (env secrets redacted) |
329
333
  | `T` | Toggle reverse proxy config sync |
330
334
 
331
335
  When you scroll the Logs panel up, devup auto-pauses the log stream so new lines don't push your reading position. New lines are buffered and replay when you return to the bottom (`Ctrl+E` or scroll all the way down).
@@ -17,6 +17,10 @@ export interface ServiceConfig {
17
17
  * health-check poll. Speeds up phase transitions on cold boots.
18
18
  * Examples: '/ready in \\d+ ms/' (Vite), '/compiled successfully/' (Angular). */
19
19
  readyPattern?: string;
20
+ /** Case-insensitive regex. When set, only stderr lines matching this pattern
21
+ * bump `state.errors`. Without it, every non-empty stderr line counts.
22
+ * Useful for libraries that write info messages to stderr (Angular CLI). */
23
+ errorPattern?: string;
20
24
  }
21
25
  export interface HealthCheckConfig {
22
26
  /** 'tcp' (default) checks that the port accepts connections. 'http' makes an HTTP GET. */
@@ -29,6 +33,10 @@ export interface HealthCheckConfig {
29
33
  host?: string;
30
34
  /** Per-check socket timeout in ms. Default: 2000 */
31
35
  timeoutMs?: number;
36
+ /** Grace period (seconds) before the first probe runs. Useful for slow boots
37
+ * (Angular cold-start, big webpack builds) so failed probes during boot don't
38
+ * pollute state.errors. Default: 0 (no grace). */
39
+ startPeriod?: number;
32
40
  }
33
41
  export interface LazyConfig {
34
42
  alwaysOn: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/config/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,IAAI,EAAE,KAAK,GAAG,KAAK,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC;;;sFAGkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,0FAA0F;IAC1F,IAAI,EAAE,KAAK,GAAG,MAAM,CAAC;IACrB,4CAA4C;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,GAAG,EAAE,MAAM,CAAC;IACZ,iEAAiE;IACjE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,6FAA6F;IAC7F,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACpC,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,EAAE,CAAC;CAC9B;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,cAAc,CAEnE"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/config/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,IAAI,EAAE,KAAK,GAAG,KAAK,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC;;;sFAGkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;iFAE6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,0FAA0F;IAC1F,IAAI,EAAE,KAAK,GAAG,MAAM,CAAC;IACrB,4CAA4C;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC3B,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;uDAEmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,GAAG,EAAE,MAAM,CAAC;IACZ,iEAAiE;IACjE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,6FAA6F;IAC7F,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACpC,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,eAAe,EAAE,CAAC;CAC9B;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,cAAc,GAAG,cAAc,CAEnE"}
@@ -1 +1 @@
1
- {"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../../src/config/validator.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,EAAE,CAwKrF;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,CAExE"}
1
+ {"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../../src/config/validator.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGjD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,EAAE,CA0LrF;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,CAExE"}
package/dist/index.js CHANGED
@@ -114,6 +114,19 @@ function validateConfig(config, cwd) {
114
114
  if (svc.cwd && !existsSync2(resolve2(cwd, svc.cwd))) {
115
115
  errors.push({ field: `services[${svc.name}].cwd`, message: `Directory not found: ${svc.cwd}` });
116
116
  }
117
+ if (svc.errorPattern !== void 0) {
118
+ if (typeof svc.errorPattern !== "string" || !svc.errorPattern.length) {
119
+ errors.push({ field: `services[${svc.name}].errorPattern`, message: `errorPattern must be a non-empty string` });
120
+ } else {
121
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(svc.errorPattern);
122
+ try {
123
+ if (slashed) new RegExp(slashed[1], slashed[2] || "i");
124
+ else new RegExp(svc.errorPattern, "i");
125
+ } catch (e) {
126
+ errors.push({ field: `services[${svc.name}].errorPattern`, message: `Invalid regex: ${e.message}` });
127
+ }
128
+ }
129
+ }
117
130
  if (svc.readyPattern !== void 0) {
118
131
  if (typeof svc.readyPattern !== "string" || !svc.readyPattern.length) {
119
132
  errors.push({ field: `services[${svc.name}].readyPattern`, message: `readyPattern must be a non-empty string` });
@@ -138,6 +151,9 @@ function validateConfig(config, cwd) {
138
151
  if (hc.type !== "tcp" && hc.type !== "http") {
139
152
  errors.push({ field: `services[${svc.name}].healthCheck.type`, message: `Invalid healthCheck.type: ${hc.type} (must be "tcp" or "http")` });
140
153
  }
154
+ if (hc.startPeriod !== void 0 && (typeof hc.startPeriod !== "number" || hc.startPeriod < 0)) {
155
+ errors.push({ field: `services[${svc.name}].healthCheck.startPeriod`, message: `startPeriod must be a non-negative number (seconds), got ${hc.startPeriod}` });
156
+ }
141
157
  if (hc.type === "http" && hc.path && !hc.path.startsWith("/")) {
142
158
  errors.push({ field: `services[${svc.name}].healthCheck.path`, message: `healthCheck.path must start with "/": got "${hc.path}"` });
143
159
  }
@@ -489,6 +505,41 @@ function parseEnvFile(filePath, baseEnv = {}) {
489
505
  }
490
506
  return env;
491
507
  }
508
+ function nextRamBannerVisibility(usagePct, previousVisible, highWatermark = 80, lowWatermark = 75) {
509
+ if (usagePct >= highWatermark) return true;
510
+ if (usagePct < lowWatermark) return false;
511
+ return previousVisible;
512
+ }
513
+ function redactSecrets(env) {
514
+ if (!env) return {};
515
+ const out = {};
516
+ for (const [k, v] of Object.entries(env)) {
517
+ out[k] = /secret|token|password|api[_-]?key|auth/i.test(k) ? "***" : v;
518
+ }
519
+ return out;
520
+ }
521
+ function detectLogLevel(line) {
522
+ const l = line.toLowerCase();
523
+ if (/\b(?:error|err|fail(?:ed|ure|ures|s)?|fatal|exception|crash(?:ed|es)?)\b/.test(l) || /❌|✗|⛔/.test(line)) return "error";
524
+ if (/\b(?:warn(?:ed|ing|s|ings)?|deprec)\b/.test(l) || /⚠/.test(line)) return "warn";
525
+ return "info";
526
+ }
527
+ function compileSearchPattern(term) {
528
+ if (!term) return null;
529
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(term);
530
+ if (slashed) {
531
+ const flags = slashed[2].includes("i") ? slashed[2] : slashed[2] + "i";
532
+ try {
533
+ const re = new RegExp(slashed[1], flags);
534
+ return { test: (l) => re.test(l), regex: re };
535
+ } catch {
536
+ const lower2 = term.toLowerCase();
537
+ return { test: (l) => l.toLowerCase().includes(lower2), invalid: true };
538
+ }
539
+ }
540
+ const lower = term.toLowerCase();
541
+ return { test: (l) => l.toLowerCase().includes(lower) };
542
+ }
492
543
  function fmtUptime(ms) {
493
544
  if (!ms || ms < 0) return "-";
494
545
  const s = Math.floor(ms / 1e3);
@@ -903,7 +954,7 @@ function detectProxyProvider(name) {
903
954
  }
904
955
 
905
956
  // src/tui/App.tsx
906
- import { useEffect as useEffect5, useState as useState5, useCallback as useCallback3, useRef as useRef3 } from "react";
957
+ import { useEffect as useEffect5, useState as useState6, useCallback as useCallback3, useRef as useRef3 } from "react";
907
958
  import { Box as Box6, Text as Text6, useStdout } from "ink";
908
959
 
909
960
  // src/tui/hooks/useProcessManager.ts
@@ -1072,12 +1123,14 @@ var ProcessManager = class {
1072
1123
  this.events.onStateChange(svc.name, state);
1073
1124
  }
1074
1125
  };
1126
+ const errorRegex = compileReadyPattern(svc.errorPattern);
1127
+ const countsAsError = (line) => errorRegex ? errorRegex.test(line) : true;
1075
1128
  const stdoutBuf = lineBuffer((line) => {
1076
1129
  markReadyIfMatch(line);
1077
1130
  this.log(svc.name, line, colorIdx);
1078
1131
  });
1079
1132
  const stderrBuf = lineBuffer((line) => {
1080
- state.errors += 1;
1133
+ if (countsAsError(line)) state.errors += 1;
1081
1134
  markReadyIfMatch(line);
1082
1135
  this.log(svc.name, line, colorIdx);
1083
1136
  });
@@ -1212,6 +1265,10 @@ var ProcessManager = class {
1212
1265
  st.health = st.status === "idle" ? "idle" : "down";
1213
1266
  continue;
1214
1267
  }
1268
+ const startPeriodMs = (st.svc.healthCheck?.startPeriod ?? 0) * 1e3;
1269
+ if (startPeriodMs > 0 && st.startedAt && Date.now() - st.startedAt < startPeriodMs) {
1270
+ continue;
1271
+ }
1215
1272
  const isUp = await checkHealth(st.svc.port, st.svc.healthCheck);
1216
1273
  const prev = st.health;
1217
1274
  st.health = deriveHealth(isUp, st.status);
@@ -1283,7 +1340,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
1283
1340
  events: {
1284
1341
  onLog: (svcName, text, colorIdx) => {
1285
1342
  sinkRef.current?.write(svcName, text);
1286
- const entry = { svcName, text, colorIdx, ts: Date.now() };
1343
+ const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
1287
1344
  if (pausedRef.current) {
1288
1345
  pendingLogsRef.current.push(entry);
1289
1346
  if (pendingLogsRef.current.length > 5e3) {
@@ -1341,7 +1398,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
1341
1398
  }, []);
1342
1399
  const pushLog = useCallback((svcName, text, colorIdx = 0) => {
1343
1400
  sinkRef.current?.write(svcName, text);
1344
- const entry = { svcName, text, colorIdx, ts: Date.now() };
1401
+ const entry = { svcName, text, colorIdx, ts: Date.now(), level: detectLogLevel(text) };
1345
1402
  if (pausedRef.current) {
1346
1403
  pendingLogsRef.current.push(entry);
1347
1404
  if (pendingLogsRef.current.length > 5e3) {
@@ -1414,8 +1471,11 @@ function useKeyBindings(opts) {
1414
1471
  sortIdx: 0,
1415
1472
  proxyEnabled: false,
1416
1473
  logsScrollOffset: 0,
1417
- statsScrollOffset: 0
1474
+ statsScrollOffset: 0,
1475
+ levelFilter: "all",
1476
+ verboseStats: false
1418
1477
  });
1478
+ const LEVEL_CYCLE = ["all", "error", "warn"];
1419
1479
  const setModal = useCallback2((modal) => setState((s) => ({ ...s, modal })), []);
1420
1480
  const setFilter = useCallback2((f) => setState((s) => ({ ...s, logFilter: f, modal: "none" })), []);
1421
1481
  const setSearch = useCallback2((t) => setState((s) => ({ ...s, searchTerm: t, modal: "none" })), []);
@@ -1437,14 +1497,15 @@ function useKeyBindings(opts) {
1437
1497
  else if (input === "r") setModal("restart");
1438
1498
  else if (input === "o") setModal("open");
1439
1499
  else if (input === "/") setModal("search");
1440
- else if (input === "a") setState((s) => ({ ...s, logFilter: null, searchTerm: null }));
1500
+ else if (input === "a") setState((s) => ({ ...s, logFilter: null, searchTerm: null, levelFilter: "all" }));
1441
1501
  else if (input === "p") setState((s) => ({ ...s, logsPaused: !s.logsPaused }));
1442
1502
  else if (input === "t") setState((s) => ({ ...s, showTimestamps: !s.showTimestamps }));
1443
1503
  else if (input === "s") setState((s) => ({ ...s, sortIdx: (s.sortIdx + 1) % SORT_MODES.length }));
1444
1504
  else if (input === "T") {
1445
1505
  opts.onToggleProxy();
1446
1506
  setState((s) => ({ ...s, proxyEnabled: !s.proxyEnabled }));
1447
- }
1507
+ } else if (input === "L") setState((s) => ({ ...s, levelFilter: LEVEL_CYCLE[(LEVEL_CYCLE.indexOf(s.levelFilter) + 1) % LEVEL_CYCLE.length] }));
1508
+ else if (input === "v") setState((s) => ({ ...s, verboseStats: !s.verboseStats }));
1448
1509
  }, { isActive });
1449
1510
  return {
1450
1511
  ...state,
@@ -1486,11 +1547,12 @@ function useProxySync(provider, opts, states, enabled) {
1486
1547
  }
1487
1548
 
1488
1549
  // src/tui/LogsPanel.tsx
1489
- import { useEffect as useEffect3 } from "react";
1550
+ import { useEffect as useEffect3, useMemo } from "react";
1490
1551
  import { Box, Text } from "ink";
1491
1552
  import { jsx, jsxs } from "react/jsx-runtime";
1492
- function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll }) {
1493
- const filtered = filter ? logs.filter((l) => l.svcName === filter) : logs;
1553
+ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLen, height, focused, scrollOffset, resetScroll, levelFilter = "all" }) {
1554
+ const byService = filter ? logs.filter((l) => l.svcName === filter) : logs;
1555
+ const filtered = levelFilter === "all" ? byService : levelFilter === "error" ? byService.filter((l) => l.level === "error") : byService.filter((l) => l.level === "error" || l.level === "warn");
1494
1556
  const contentHeight = Math.max(1, height - 2);
1495
1557
  const totalLines = filtered.length;
1496
1558
  const maxOffset = Math.max(0, totalLines - contentHeight);
@@ -1501,11 +1563,14 @@ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLe
1501
1563
  useEffect3(() => {
1502
1564
  resetScroll();
1503
1565
  }, [filter, searchTerm, resetScroll]);
1566
+ const matcher = useMemo(() => compileSearchPattern(searchTerm), [searchTerm]);
1504
1567
  const scrolled = effectiveOffset > 0;
1505
1568
  const label = [
1506
1569
  "Logs",
1507
1570
  filter ? `[${filter}]` : "",
1508
1571
  searchTerm ? `/${searchTerm}` : "",
1572
+ matcher?.invalid ? "(invalid regex)" : "",
1573
+ levelFilter !== "all" ? `[level: ${levelFilter}${levelFilter === "warn" ? "+error" : ""}]` : "",
1509
1574
  paused ? "[PAUSED]" : "",
1510
1575
  scrolled ? "[SCROLL]" : "",
1511
1576
  `${filtered.length} lines`,
@@ -1521,7 +1586,7 @@ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLe
1521
1586
  const color = tagColors[entry.colorIdx % tagColors.length];
1522
1587
  const ts = showTimestamps ? new Date(entry.ts).toLocaleTimeString("en-GB") + " " : "";
1523
1588
  const line = entry.text;
1524
- const isMatch = searchTerm && line.toLowerCase().includes(searchTerm.toLowerCase());
1589
+ const isMatch = matcher ? matcher.test(line) : false;
1525
1590
  return /* @__PURE__ */ jsxs(Box, { children: [
1526
1591
  showTimestamps && /* @__PURE__ */ jsx(Text, { dimColor: true, children: ts }),
1527
1592
  /* @__PURE__ */ jsxs(Text, { color, children: [
@@ -1537,7 +1602,7 @@ function LogsPanel({ logs, filter, searchTerm, paused, showTimestamps, maxNameLe
1537
1602
  }
1538
1603
 
1539
1604
  // src/tui/StatsPanel.tsx
1540
- import { useEffect as useEffect4 } from "react";
1605
+ import { useEffect as useEffect4, useState as useState3 } from "react";
1541
1606
  import { Box as Box2, Text as Text2 } from "ink";
1542
1607
  import os from "os";
1543
1608
  import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
@@ -1551,31 +1616,67 @@ var MAX_RESTARTS2 = 3;
1551
1616
  function isCrashLooped(st) {
1552
1617
  return st.status === "crashed" && st.restarts >= MAX_RESTARTS2;
1553
1618
  }
1554
- function Row({ name, st, stat, ml }) {
1619
+ function Row({ name, st, stat, ml, verbose }) {
1555
1620
  const looped = isCrashLooped(st);
1556
1621
  const indicator = looped ? /* @__PURE__ */ jsx2(Text2, { color: "red", bold: true, children: "\u2716" }) : /* @__PURE__ */ jsx2(Text2, { color: (H[st.health] ?? H["down"]).color, children: (H[st.health] ?? H["down"]).c });
1557
1622
  const color = tagColors[st.colorIdx % tagColors.length];
1558
1623
  const sc = looped ? "red" : st.status === "running" ? "green" : st.status === "starting" ? "yellow" : st.status === "idle" ? "blue" : "red";
1559
1624
  const statusLabel = looped ? "looping" : st.status;
1560
1625
  const up = st.startedAt ? fmtUptime(Date.now() - st.startedAt) : "-";
1561
- return /* @__PURE__ */ jsxs2(Text2, { children: [
1562
- indicator,
1563
- " ",
1564
- /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1565
- " ",
1566
- String(st.svc.port).padStart(5),
1567
- " ",
1568
- /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1569
- " ",
1570
- (stat?.cpu ?? "-").padStart(6),
1571
- " ",
1572
- (stat?.mem ?? "-").padStart(8),
1573
- " ",
1574
- String(st.errors).padStart(3),
1575
- " ",
1576
- String(st.restarts).padStart(3),
1577
- " ",
1578
- up.padStart(6)
1626
+ if (!verbose) {
1627
+ return /* @__PURE__ */ jsxs2(Text2, { children: [
1628
+ indicator,
1629
+ " ",
1630
+ /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1631
+ " ",
1632
+ String(st.svc.port).padStart(5),
1633
+ " ",
1634
+ /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1635
+ " ",
1636
+ (stat?.cpu ?? "-").padStart(6),
1637
+ " ",
1638
+ (stat?.mem ?? "-").padStart(8),
1639
+ " ",
1640
+ String(st.errors).padStart(3),
1641
+ " ",
1642
+ String(st.restarts).padStart(3),
1643
+ " ",
1644
+ up.padStart(6)
1645
+ ] });
1646
+ }
1647
+ const resolvedArgs = buildProcessArgs(st.svc).join(" ");
1648
+ const env = redactSecrets(st.svc.extraEnv);
1649
+ const envStr = Object.entries(env).map(([k, v]) => `${k}=${v}`).join(" ");
1650
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
1651
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1652
+ indicator,
1653
+ " ",
1654
+ /* @__PURE__ */ jsx2(Text2, { color, children: name.padEnd(ml) }),
1655
+ " ",
1656
+ String(st.svc.port).padStart(5),
1657
+ " ",
1658
+ /* @__PURE__ */ jsx2(Text2, { color: sc, bold: looped, children: statusLabel.padEnd(8) }),
1659
+ " ",
1660
+ (stat?.cpu ?? "-").padStart(6),
1661
+ " ",
1662
+ (stat?.mem ?? "-").padStart(8),
1663
+ " ",
1664
+ String(st.errors).padStart(3),
1665
+ " ",
1666
+ String(st.restarts).padStart(3),
1667
+ " ",
1668
+ up.padStart(6)
1669
+ ] }),
1670
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1671
+ " cmd: ",
1672
+ st.svc.cmd,
1673
+ " ",
1674
+ resolvedArgs
1675
+ ] }),
1676
+ envStr && /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
1677
+ " env: ",
1678
+ envStr
1679
+ ] })
1579
1680
  ] });
1580
1681
  }
1581
1682
  function ColHeader({ ml }) {
@@ -1594,7 +1695,7 @@ function ColHeader({ ml }) {
1594
1695
  "Up".padStart(6)
1595
1696
  ] });
1596
1697
  }
1597
- function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scrollOffset, resetScroll }) {
1698
+ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scrollOffset, resetScroll, verbose = false }) {
1598
1699
  const names = [...states.keys()];
1599
1700
  const stObj = Object.fromEntries([...states].map(([k, v]) => [k, { errors: v.errors }]));
1600
1701
  const statsObj = Object.fromEntries([...stats].map(([k, v]) => [k, v]));
@@ -1633,6 +1734,12 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1633
1734
  const positionInfo = focused && totalRowsLong > 0 ? `(${effectiveOffset + 1}-${Math.min(effectiveOffset + rowsPerCol, totalRowsLong)}/${totalRowsLong})` : "";
1634
1735
  const scrolled = effectiveOffset > 0;
1635
1736
  const loopedCount = [...states.values()].filter(isCrashLooped).length;
1737
+ const ramPct = parseFloat(usedGB) / parseFloat(totalGB) * 100;
1738
+ const [ramBanner, setRamBanner] = useState3(false);
1739
+ useEffect4(() => {
1740
+ setRamBanner((prev) => nextRamBannerVisibility(ramPct, prev));
1741
+ }, [ramPct]);
1742
+ const topConsumers = ramBanner ? [...stats.entries()].map(([n, s]) => ({ name: n, mb: parseFloat(s.mem) || 0 })).sort((a, b) => b.mb - a.mb).slice(0, 3) : [];
1636
1743
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: focused ? "green" : "gray", height, children: [
1637
1744
  /* @__PURE__ */ jsxs2(Box2, { children: [
1638
1745
  /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "green", children: [
@@ -1674,6 +1781,14 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1674
1781
  sortMode
1675
1782
  ] })
1676
1783
  ] }),
1784
+ ramBanner && /* @__PURE__ */ jsxs2(Box2, { children: [
1785
+ /* @__PURE__ */ jsxs2(Text2, { color: "yellow", bold: true, children: [
1786
+ " \u26A0 RAM ",
1787
+ ramPct.toFixed(0),
1788
+ "% \u2014 top: "
1789
+ ] }),
1790
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: topConsumers.map((c) => `${c.name} ${c.mb.toFixed(0)}MB`).join(", ") })
1791
+ ] }),
1677
1792
  /* @__PURE__ */ jsxs2(Box2, { flexGrow: 1, children: [
1678
1793
  /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
1679
1794
  /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
@@ -1682,7 +1797,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1682
1797
  ")"
1683
1798
  ] }),
1684
1799
  /* @__PURE__ */ jsx2(ColHeader, { ml }),
1685
- visibleApis.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
1800
+ visibleApis.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
1686
1801
  ] }),
1687
1802
  /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", width: 1, children: Array.from({ length: contentHeight }, (_, i) => /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2502" }, i)) }),
1688
1803
  /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexGrow: 1, flexBasis: 0, children: [
@@ -1692,7 +1807,7 @@ function StatsPanel({ states, stats, sortMode, maxNameLen, height, focused, scro
1692
1807
  ")"
1693
1808
  ] }),
1694
1809
  /* @__PURE__ */ jsx2(ColHeader, { ml }),
1695
- visibleWebs.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml }, n))
1810
+ visibleWebs.map((n) => /* @__PURE__ */ jsx2(Row, { name: n, st: states.get(n), stat: stats.get(n), ml, verbose }, n))
1696
1811
  ] })
1697
1812
  ] })
1698
1813
  ] });
@@ -1717,6 +1832,8 @@ function StatusBar() {
1717
1832
  " Clear ",
1718
1833
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "f" }),
1719
1834
  " Filter ",
1835
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "L" }),
1836
+ " Level ",
1720
1837
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "a" }),
1721
1838
  " All ",
1722
1839
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "r" }),
@@ -1731,23 +1848,25 @@ function StatusBar() {
1731
1848
  " Pause ",
1732
1849
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "t" }),
1733
1850
  " Time ",
1851
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "v" }),
1852
+ " Verbose ",
1734
1853
  /* @__PURE__ */ jsx3(Text3, { bold: true, children: "T" }),
1735
1854
  " Proxy"
1736
1855
  ] }) });
1737
1856
  }
1738
1857
 
1739
1858
  // src/tui/ServiceList.tsx
1740
- import { useState as useState3, useMemo } from "react";
1859
+ import { useState as useState4, useMemo as useMemo2 } from "react";
1741
1860
  import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
1742
1861
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1743
1862
  function ServiceList({ title, services, onSelect, onClose, filterType }) {
1744
- const allNames = useMemo(
1863
+ const allNames = useMemo2(
1745
1864
  () => [...services.keys()].filter((n) => !filterType || services.get(n).svc.type === filterType),
1746
1865
  [services, filterType]
1747
1866
  );
1748
- const [idx, setIdx] = useState3(0);
1749
- const [query, setQuery] = useState3("");
1750
- const names = useMemo(() => {
1867
+ const [idx, setIdx] = useState4(0);
1868
+ const [query, setQuery] = useState4("");
1869
+ const names = useMemo2(() => {
1751
1870
  if (!query) return allNames;
1752
1871
  const q = query.toLowerCase();
1753
1872
  return allNames.filter((n) => n.toLowerCase().includes(q));
@@ -1804,11 +1923,11 @@ function ServiceList({ title, services, onSelect, onClose, filterType }) {
1804
1923
  }
1805
1924
 
1806
1925
  // src/tui/SearchInput.tsx
1807
- import { useState as useState4 } from "react";
1926
+ import { useState as useState5 } from "react";
1808
1927
  import { Box as Box5, Text as Text5, useInput as useInput3 } from "ink";
1809
1928
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1810
1929
  function SearchInput({ onSubmit, onClose }) {
1811
- const [value, setValue] = useState4("");
1930
+ const [value, setValue] = useState5("");
1812
1931
  useInput3((input, key) => {
1813
1932
  if (key.escape) onClose();
1814
1933
  else if (key.return) onSubmit(value.trim() || null);
@@ -2044,7 +2163,7 @@ function buildServiceUrl(name, port, proxyActive, proxyOpts) {
2044
2163
  }
2045
2164
  function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
2046
2165
  const { stdout } = useStdout();
2047
- const [rows, setRows] = useState5(stdout?.rows ?? 40);
2166
+ const [rows, setRows] = useState6(stdout?.rows ?? 40);
2048
2167
  useEffect5(() => {
2049
2168
  if (!stdout) return;
2050
2169
  const onResize = () => setRows(stdout.rows ?? 40);
@@ -2057,11 +2176,11 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2057
2176
  const statsHeight = rows - logsHeight - 2;
2058
2177
  const maxNameLen = Math.max(...services.map((s) => s.name.length), 10);
2059
2178
  const pm = useProcessManager(platform, baseCwd, env, logSink);
2060
- const [booted, setBooted] = useState5(false);
2179
+ const [booted, setBooted] = useState6(false);
2061
2180
  const lazyProxies = useRef3(/* @__PURE__ */ new Map());
2062
2181
  const externals = useRef3([]);
2063
2182
  const shownTips = useRef3(/* @__PURE__ */ new Set());
2064
- const [activeTip, setActiveTip] = useState5(null);
2183
+ const [activeTip, setActiveTip] = useState6(null);
2065
2184
  const kb = useKeyBindings({
2066
2185
  onQuit: () => {
2067
2186
  void shutdown();
@@ -2254,7 +2373,8 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2254
2373
  height: logsHeight,
2255
2374
  focused: kb.panel === "logs",
2256
2375
  scrollOffset: kb.logsScrollOffset,
2257
- resetScroll: kb.resetLogsScroll
2376
+ resetScroll: kb.resetLogsScroll,
2377
+ levelFilter: kb.levelFilter
2258
2378
  }
2259
2379
  ),
2260
2380
  /* @__PURE__ */ jsx6(
@@ -2267,7 +2387,8 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
2267
2387
  height: statsHeight,
2268
2388
  focused: kb.panel === "stats",
2269
2389
  scrollOffset: kb.statsScrollOffset,
2270
- resetScroll: kb.resetStatsScroll
2390
+ resetScroll: kb.resetStatsScroll,
2391
+ verbose: kb.verboseStats
2271
2392
  }
2272
2393
  ),
2273
2394
  kb.modal === "filter" && /* @__PURE__ */ jsx6(ServiceList, { title: "Filter by service", services: pm.states, onSelect: handleFilterSelect, onClose: () => kb.setModal("none") }),