@davstack/tui 0.2.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.
Files changed (48) hide show
  1. package/README.md +66 -0
  2. package/bin/davstack.mjs +45 -0
  3. package/package.json +33 -0
  4. package/src/App.test.tsx +92 -0
  5. package/src/App.tsx +103 -0
  6. package/src/cli.ts +62 -0
  7. package/src/commands/check.test.ts +160 -0
  8. package/src/commands/check.ts +112 -0
  9. package/src/components/BottomBar.tsx +28 -0
  10. package/src/components/DaemonSupervisor.tsx +50 -0
  11. package/src/components/DescriptorSync.tsx +19 -0
  12. package/src/components/GlobalHotkeys.tsx +30 -0
  13. package/src/components/MainView.tsx +75 -0
  14. package/src/components/QuitConfirm.tsx +20 -0
  15. package/src/components/QuitController.tsx +69 -0
  16. package/src/components/StatusBar.test.tsx +47 -0
  17. package/src/components/StatusBar.tsx +67 -0
  18. package/src/hooks/useConfigDiscovery.ts +56 -0
  19. package/src/hooks/useDaemonProcess.test.ts +415 -0
  20. package/src/hooks/useDaemonProcess.ts +275 -0
  21. package/src/hooks/useHotkeys.test.tsx +508 -0
  22. package/src/hooks/useHotkeys.ts +164 -0
  23. package/src/hooks/useNoColor.test.ts +50 -0
  24. package/src/hooks/useNoColor.ts +35 -0
  25. package/src/hooks/useRingBuffer.test.tsx +86 -0
  26. package/src/hooks/useRingBuffer.ts +46 -0
  27. package/src/lib/config-discovery.test.ts +77 -0
  28. package/src/lib/config-discovery.ts +57 -0
  29. package/src/lib/daemon-registry.ts +143 -0
  30. package/src/lib/global-teardown.test.ts +75 -0
  31. package/src/lib/global-teardown.ts +78 -0
  32. package/src/lib/kill-tree.test.ts +69 -0
  33. package/src/lib/kill-tree.ts +31 -0
  34. package/src/lib/package-info.ts +35 -0
  35. package/src/lib/port-owner.test.ts +105 -0
  36. package/src/lib/port-owner.ts +90 -0
  37. package/src/lib/port-probe.test.ts +41 -0
  38. package/src/lib/port-probe.ts +29 -0
  39. package/src/lib/repo-root.test.ts +36 -0
  40. package/src/lib/repo-root.ts +30 -0
  41. package/src/lib/ring-buffer.test.ts +63 -0
  42. package/src/lib/ring-buffer.ts +47 -0
  43. package/src/state/daemons-context.tsx +149 -0
  44. package/src/state/quit-context.tsx +27 -0
  45. package/src/state/view-context.tsx +32 -0
  46. package/src/views/ServerList.test.tsx +167 -0
  47. package/src/views/ServerList.tsx +109 -0
  48. package/src/views/ServerLogView.tsx +79 -0
@@ -0,0 +1,50 @@
1
+ // useNoColor reads env once and caches. Tests must reset the cache
2
+ // before each scenario because the module-level cache survives across
3
+ // tests in the same worker.
4
+
5
+ import { afterEach, beforeEach, expect, test } from "vitest"
6
+
7
+ import { __resetNoColorCacheForTests, colorOrUndef, useNoColor } from "./useNoColor.ts"
8
+
9
+ let prevNoColor: string | undefined
10
+ let prevDavstackNoColor: string | undefined
11
+
12
+ beforeEach(() => {
13
+ prevNoColor = process.env.NO_COLOR
14
+ prevDavstackNoColor = process.env.DAVSTACK_NO_COLOR
15
+ delete process.env.NO_COLOR
16
+ delete process.env.DAVSTACK_NO_COLOR
17
+ __resetNoColorCacheForTests()
18
+ })
19
+
20
+ afterEach(() => {
21
+ if (prevNoColor === undefined) delete process.env.NO_COLOR
22
+ else process.env.NO_COLOR = prevNoColor
23
+ if (prevDavstackNoColor === undefined) delete process.env.DAVSTACK_NO_COLOR
24
+ else process.env.DAVSTACK_NO_COLOR = prevDavstackNoColor
25
+ __resetNoColorCacheForTests()
26
+ })
27
+
28
+ test("returns false when no env is set", () => {
29
+ expect(useNoColor()).toBe(false)
30
+ })
31
+
32
+ test("returns true when NO_COLOR is set to any non-empty value", () => {
33
+ process.env.NO_COLOR = "1"
34
+ __resetNoColorCacheForTests()
35
+ expect(useNoColor()).toBe(true)
36
+ })
37
+
38
+ test("returns true when DAVSTACK_NO_COLOR is set (mirrors --no-color CLI flag)", () => {
39
+ process.env.DAVSTACK_NO_COLOR = "1"
40
+ __resetNoColorCacheForTests()
41
+ expect(useNoColor()).toBe(true)
42
+ })
43
+
44
+ test("colorOrUndef returns the value when noColor is false", () => {
45
+ expect(colorOrUndef("red", false)).toBe("red")
46
+ })
47
+
48
+ test("colorOrUndef returns undefined when noColor is true", () => {
49
+ expect(colorOrUndef("red", true)).toBeUndefined()
50
+ })
@@ -0,0 +1,35 @@
1
+ // Reads the NO_COLOR signal once at module load. We honour two sources:
2
+ //
3
+ // 1. The `NO_COLOR` environment variable (https://no-color.org) — any
4
+ // non-empty value disables color.
5
+ // 2. The `DAVSTACK_NO_COLOR` env (set by cli.ts when --no-color is passed).
6
+ //
7
+ // Components call this hook to decide whether to forward `color={...}`
8
+ // props or leave them undefined. Ink doesn't expose a theme-level toggle
9
+ // so each colored seam consults the hook explicitly.
10
+
11
+ let cached: boolean | null = null
12
+
13
+ function compute(): boolean {
14
+ const env = process.env.NO_COLOR
15
+ if (typeof env === "string" && env.length > 0) return true
16
+ const dav = process.env.DAVSTACK_NO_COLOR
17
+ if (typeof dav === "string" && dav.length > 0) return true
18
+ return false
19
+ }
20
+
21
+ export function useNoColor(): boolean {
22
+ if (cached === null) cached = compute()
23
+ return cached
24
+ }
25
+
26
+ // Test-only escape hatch — the cache lives at module scope so tests that
27
+ // toggle env need to reset it.
28
+ export function __resetNoColorCacheForTests(): void {
29
+ cached = null
30
+ }
31
+
32
+ // Helper for the common "maybe-color" pattern in components.
33
+ export function colorOrUndef(value: string | undefined, noColor: boolean): string | undefined {
34
+ return noColor ? undefined : value
35
+ }
@@ -0,0 +1,86 @@
1
+ // Hook test for useRingBuffer via ink-testing-library probe. Asserts that
2
+ // `push` triggers a re-render carrying the new lines, and `clear` resets.
3
+
4
+ import React from "react"
5
+ import { afterEach, expect, test } from "vitest"
6
+ import { render } from "ink-testing-library"
7
+ import { Text } from "ink"
8
+
9
+ import { useRingBuffer, type UseRingBufferResult } from "./useRingBuffer.ts"
10
+
11
+ function Probe({
12
+ onRender,
13
+ }: {
14
+ onRender: (r: UseRingBufferResult) => void
15
+ }): React.ReactElement {
16
+ const r = useRingBuffer(3)
17
+ onRender(r)
18
+ return React.createElement(Text, null, `n=${r.lines.length}`)
19
+ }
20
+
21
+ let active: ReturnType<typeof render> | null = null
22
+ afterEach(() => {
23
+ active?.unmount()
24
+ active = null
25
+ })
26
+
27
+ async function tick(): Promise<void> {
28
+ await new Promise<void>((resolve) => setImmediate(resolve))
29
+ }
30
+
31
+ test("push triggers re-render with new lines; clear resets", async () => {
32
+ let captured: UseRingBufferResult | null = null
33
+ active = render(
34
+ React.createElement(Probe, {
35
+ onRender: (r) => {
36
+ captured = r
37
+ },
38
+ }),
39
+ )
40
+
41
+ expect(captured!.lines).toEqual([])
42
+
43
+ captured!.push({ ts: 1, stream: "out", text: "a" })
44
+ await tick()
45
+ captured!.push({ ts: 2, stream: "out", text: "b" })
46
+ await tick()
47
+ expect(captured!.lines.map((l) => l.text)).toEqual(["a", "b"])
48
+
49
+ // Wrap.
50
+ captured!.push({ ts: 3, stream: "out", text: "c" })
51
+ captured!.push({ ts: 4, stream: "err", text: "d" })
52
+ await tick()
53
+ expect(captured!.lines.map((l) => l.text)).toEqual(["b", "c", "d"])
54
+
55
+ captured!.clear()
56
+ await tick()
57
+ expect(captured!.lines).toEqual([])
58
+ })
59
+
60
+ test("lines array reference is stable across renders unless push/clear fires", async () => {
61
+ // Regression: without memoization, toArray() allocates a fresh array
62
+ // every render. Consumers that include `lines` in effect deps
63
+ // (DaemonSupervisor) then loop forever, even when contents are
64
+ // identical. Lines MUST share a reference between renders that didn't
65
+ // mutate the buffer.
66
+ const seenLines: LogLine[][] = []
67
+ const Spy = (): React.ReactElement => {
68
+ const r = useRingBuffer(3)
69
+ seenLines.push(r.lines)
70
+ return React.createElement(Text, null, " ")
71
+ }
72
+
73
+ active = render(React.createElement(Spy))
74
+ const initial = seenLines[0]
75
+ active.rerender(React.createElement(Spy))
76
+ active.rerender(React.createElement(Spy))
77
+ active.rerender(React.createElement(Spy))
78
+
79
+ expect(seenLines.length).toBeGreaterThanOrEqual(4)
80
+ for (const snap of seenLines) {
81
+ expect(snap).toBe(initial)
82
+ }
83
+ })
84
+
85
+ // Local type import for the regression test.
86
+ type LogLine = { ts: number; stream: "out" | "err"; text: string }
@@ -0,0 +1,46 @@
1
+ // React hook wrapping a RingBuffer<LogLine> behind a re-render counter.
2
+ //
3
+ // We deliberately do NOT keep lines in React state — that would force a
4
+ // copy on every push, which gets expensive for chatty daemons. Instead we
5
+ // keep a stable ref, tick a counter on push, and snapshot `toArray()` on
6
+ // each render.
7
+
8
+ import { useCallback, useMemo, useRef, useState } from "react"
9
+
10
+ import { RingBuffer } from "../lib/ring-buffer.ts"
11
+
12
+ export type LogLine = {
13
+ ts: number
14
+ stream: "out" | "err"
15
+ text: string
16
+ }
17
+
18
+ export type UseRingBufferResult = {
19
+ lines: LogLine[]
20
+ push: (line: LogLine) => void
21
+ clear: () => void
22
+ }
23
+
24
+ export function useRingBuffer(capacity: number = 10_000): UseRingBufferResult {
25
+ const bufRef = useRef<RingBuffer<LogLine> | null>(null)
26
+ if (bufRef.current === null) bufRef.current = new RingBuffer<LogLine>(capacity)
27
+ const [tick, setTick] = useState(0)
28
+
29
+ const push = useCallback((line: LogLine) => {
30
+ bufRef.current!.push(line)
31
+ setTick((t) => (t + 1) | 0)
32
+ }, [])
33
+
34
+ const clear = useCallback(() => {
35
+ bufRef.current!.clear()
36
+ setTick((t) => (t + 1) | 0)
37
+ }, [])
38
+
39
+ // Snapshot the buffer only when tick changes. Without this memo
40
+ // every render produces a new array reference, and consumers that
41
+ // include `lines` in their effect deps (e.g. DaemonSupervisor) loop
42
+ // forever — even when the buffer's contents are identical.
43
+ const lines = useMemo(() => bufRef.current!.toArray(), [tick])
44
+
45
+ return { lines, push, clear }
46
+ }
@@ -0,0 +1,77 @@
1
+ // Tests for discoverEnabledDaemons — fixture-driven, using a temp dir.
2
+
3
+ import fs from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { afterEach, beforeEach, describe, expect, test } from "vitest"
7
+
8
+ import { discoverEnabledDaemons, findConfigRoot } from "./config-discovery.ts"
9
+
10
+ let tmpRoot = ""
11
+
12
+ beforeEach(async () => {
13
+ tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "davstack-tui-cfg-"))
14
+ })
15
+
16
+ afterEach(async () => {
17
+ await fs.rm(tmpRoot, { recursive: true, force: true })
18
+ })
19
+
20
+ async function writeConfig(name: string): Promise<void> {
21
+ const dir = path.join(tmpRoot, ".davstack", "config")
22
+ await fs.mkdir(dir, { recursive: true })
23
+ await fs.writeFile(path.join(dir, name), "export default {}\n", "utf8")
24
+ }
25
+
26
+ describe("discoverEnabledDaemons", () => {
27
+ test("returns all three keys when all configs present", async () => {
28
+ await writeConfig("logs-server.config.ts")
29
+ await writeConfig("vitest-server.config.ts")
30
+ await writeConfig("playwright-server.config.ts")
31
+ const set = await discoverEnabledDaemons(tmpRoot)
32
+ expect(set).toEqual(new Set(["logs", "vitest", "playwright"]))
33
+ })
34
+
35
+ test("returns only the subset that exists", async () => {
36
+ await writeConfig("logs-server.config.ts")
37
+ await writeConfig("vitest-server.config.ts")
38
+ const set = await discoverEnabledDaemons(tmpRoot)
39
+ expect(set).toEqual(new Set(["logs", "vitest"]))
40
+ })
41
+
42
+ test("returns empty set when .davstack/config is missing entirely", async () => {
43
+ const set = await discoverEnabledDaemons(tmpRoot)
44
+ expect(set.size).toBe(0)
45
+ })
46
+
47
+ test("ignores unrecognized files in the config dir", async () => {
48
+ await writeConfig("logs-server.config.ts")
49
+ await writeConfig("README.md")
50
+ await writeConfig("open-agents.config.ts")
51
+ const set = await discoverEnabledDaemons(tmpRoot)
52
+ expect(set).toEqual(new Set(["logs"]))
53
+ })
54
+ })
55
+
56
+ describe("findConfigRoot", () => {
57
+ test("returns the dir holding .davstack/config when called from itself", async () => {
58
+ await writeConfig("logs-server.config.ts")
59
+ expect(findConfigRoot(tmpRoot)).toBe(path.resolve(tmpRoot))
60
+ })
61
+
62
+ test("walks up from a workspace subdir to find the configs", async () => {
63
+ await writeConfig("logs-server.config.ts")
64
+ const sub = path.join(tmpRoot, "apps", "web", "src")
65
+ await fs.mkdir(sub, { recursive: true })
66
+ expect(findConfigRoot(sub)).toBe(path.resolve(tmpRoot))
67
+ })
68
+
69
+ test("returns null when no .davstack/config exists on the chain", async () => {
70
+ const lonelyDir = await fs.mkdtemp(path.join(os.tmpdir(), "davstack-noconf-"))
71
+ try {
72
+ expect(findConfigRoot(lonelyDir)).toBe(null)
73
+ } finally {
74
+ await fs.rm(lonelyDir, { recursive: true, force: true })
75
+ }
76
+ })
77
+ })
@@ -0,0 +1,57 @@
1
+ // Discover which davstack daemons are configured in the current repo.
2
+ //
3
+ // v1 strategy: existence-check `.davstack/config/<tool>.config.ts`. The
4
+ // daemon packages themselves load these via tsx — the TUI does NOT parse
5
+ // them. Ports/hosts stay hardcoded in daemon-registry.ts for now.
6
+ //
7
+ // TODO: tolerant regex parse of `port:` / `host:` / `enabled:` from
8
+ // the config file text, OR have the daemons emit a resolved config JSON
9
+ // that the TUI can read.
10
+
11
+ import fs from "node:fs/promises"
12
+ import fsSync from "node:fs"
13
+ import path from "node:path"
14
+
15
+ import type { DaemonKey } from "./daemon-registry.ts"
16
+
17
+ // File name -> daemon key. Mirrors the templates in
18
+ // packages/init/src/templates/. Daemons whose config file isn't present
19
+ // won't render a row in the TUI.
20
+ const CONFIG_FILE_TO_KEY: Record<string, DaemonKey> = {
21
+ "logs-server.config.ts": "logs",
22
+ "vitest-server.config.ts": "vitest",
23
+ "playwright-server.config.ts": "playwright",
24
+ }
25
+
26
+ export async function discoverEnabledDaemons(repoRoot: string): Promise<Set<DaemonKey>> {
27
+ const configDir = path.join(repoRoot, ".davstack", "config")
28
+ const enabled = new Set<DaemonKey>()
29
+ let entries: string[]
30
+ try {
31
+ entries = await fs.readdir(configDir)
32
+ } catch {
33
+ return enabled
34
+ }
35
+ for (const entry of entries) {
36
+ const key = CONFIG_FILE_TO_KEY[entry]
37
+ if (key !== undefined) enabled.add(key)
38
+ }
39
+ return enabled
40
+ }
41
+
42
+ // Walk up from `startDir` looking for the nearest `.davstack/config/`
43
+ // directory; returns the parent dir (treat as the config root) or null.
44
+ // Consumers can park `.davstack/config/` at the monorepo root while
45
+ // running the TUI from a workspace subdir.
46
+ export function findConfigRoot(startDir: string): string | null {
47
+ let dir = path.resolve(startDir)
48
+ while (true) {
49
+ try {
50
+ const stat = fsSync.statSync(path.join(dir, ".davstack", "config"))
51
+ if (stat.isDirectory()) return dir
52
+ } catch {}
53
+ const parent = path.dirname(dir)
54
+ if (parent === dir) return null
55
+ dir = parent
56
+ }
57
+ }
@@ -0,0 +1,143 @@
1
+ // Static registry of supervisable davstack daemons.
2
+ //
3
+ // Each descriptor wraps a `spawn()` factory that returns an attached
4
+ // ChildProcess with piped stdout/stderr (NOT inherited — we want the TUI
5
+ // to capture the streams into a ring buffer).
6
+ //
7
+ // Per-daemon defaults (read from each daemon's `src/index.ts`):
8
+ //
9
+ // logs-server port=7077 ready=/listening on http:\/\//i
10
+ // bin: packages/logs-server/bin/logs-server.mjs
11
+ // shutdown route: NONE (no /shutdown endpoint; SIGTERM only)
12
+ //
13
+ // vitest-server port=5179 ready=/\[vitest-server\] listening on http:\/\//i
14
+ // bin: packages/vitest-server/bin/vitest-server.mjs
15
+ // shutdown route: POST http://127.0.0.1:5179/shutdown
16
+ // (see packages/vitest-server/src/http.ts:60)
17
+ //
18
+ // playwright-server port=5180 ready=/\[playwright-server\] listening on http:\/\//i
19
+ // bin: packages/playwright-server/bin/playwright-server.mjs
20
+ // shutdown route: POST http://127.0.0.1:5180/shutdown
21
+ // (see packages/playwright-server/src/http.ts:76)
22
+ //
23
+ // TODO(P5+): parse `.davstack/config/<tool>.config.ts` to honor user-set
24
+ // ports/hosts. For v1 we existence-check the config files (see
25
+ // config-discovery.ts) and keep these hardcoded defaults.
26
+
27
+ import { spawn, type ChildProcess } from "node:child_process"
28
+ import fs from "node:fs"
29
+ import path from "node:path"
30
+
31
+ import { findRepoRoot } from "./repo-root.ts"
32
+
33
+ export type DaemonKey = "logs" | "vitest" | "playwright"
34
+
35
+ export type DaemonDescriptor = {
36
+ key: DaemonKey
37
+ label: string
38
+ port: number
39
+ host?: string
40
+ readyRegex: RegExp
41
+ spawn: () => ChildProcess
42
+ // When set, stop() POSTs to this URL before falling back to killTree.
43
+ shutdownUrl?: string
44
+ // Grace period for the HTTP /shutdown round-trip + clean child exit
45
+ // before SIGTERM escalation. Defaults to 1500ms in useDaemonProcess.
46
+ shutdownTimeoutMs?: number
47
+ }
48
+
49
+ const LOGS_DEFAULT_PORT = 7077
50
+ const VITEST_DEFAULT_PORT = 5179
51
+ const PLAYWRIGHT_DEFAULT_PORT = 5180
52
+
53
+ const DEFAULT_HOST = "127.0.0.1"
54
+
55
+ function pkgLauncher(pkgName: string, binName: string): string {
56
+ // Walk up from cwd looking for node_modules/@davstack/<pkg>/bin/<bin>.mjs.
57
+ // We can't use require.resolve('@davstack/<pkg>/package.json') because
58
+ // Node's strict exports field blocks subpath access to /package.json
59
+ // for every daemon package (they only export `.` and `./config`).
60
+ let dir = process.cwd()
61
+ while (true) {
62
+ const candidate = path.join(
63
+ dir,
64
+ "node_modules",
65
+ "@davstack",
66
+ pkgName,
67
+ "bin",
68
+ `${binName}.mjs`,
69
+ )
70
+ if (fs.existsSync(candidate)) return candidate
71
+ const parent = path.dirname(dir)
72
+ if (parent === dir) break
73
+ dir = parent
74
+ }
75
+ // Fallback: in-repo dev (running from the davstack monorepo itself).
76
+ const repoRoot = findRepoRoot(process.cwd())
77
+ return path.join(repoRoot, "packages", pkgName, "bin", `${binName}.mjs`)
78
+ }
79
+
80
+ function spawnLauncher(launcher: string, args: string[]): ChildProcess {
81
+ return spawn(process.execPath, [launcher, ...args], {
82
+ stdio: ["ignore", "pipe", "pipe"],
83
+ windowsHide: true,
84
+ })
85
+ }
86
+
87
+ export const daemonRegistry: DaemonDescriptor[] = [
88
+ {
89
+ key: "logs",
90
+ label: "logs",
91
+ port: LOGS_DEFAULT_PORT,
92
+ host: DEFAULT_HOST,
93
+ readyRegex: /listening on http:\/\//i,
94
+ spawn: () =>
95
+ spawnLauncher(pkgLauncher("logs-server", "logs-server"), [
96
+ "serve",
97
+ "--port",
98
+ String(LOGS_DEFAULT_PORT),
99
+ "--host",
100
+ DEFAULT_HOST,
101
+ ]),
102
+ },
103
+ {
104
+ key: "vitest",
105
+ label: "vitest",
106
+ port: VITEST_DEFAULT_PORT,
107
+ host: DEFAULT_HOST,
108
+ readyRegex: /\[vitest-server\] listening on http:\/\//i,
109
+ shutdownUrl: `http://${DEFAULT_HOST}:${VITEST_DEFAULT_PORT}/shutdown`,
110
+ spawn: () => {
111
+ const repoRoot = findRepoRoot(process.cwd())
112
+ return spawnLauncher(pkgLauncher("vitest-server", "vitest-server"), [
113
+ "serve",
114
+ "--port",
115
+ String(VITEST_DEFAULT_PORT),
116
+ "--host",
117
+ DEFAULT_HOST,
118
+ "--cwd",
119
+ repoRoot,
120
+ ])
121
+ },
122
+ },
123
+ {
124
+ key: "playwright",
125
+ label: "playwright",
126
+ port: PLAYWRIGHT_DEFAULT_PORT,
127
+ host: DEFAULT_HOST,
128
+ readyRegex: /\[playwright-server\] listening on http:\/\//i,
129
+ shutdownUrl: `http://${DEFAULT_HOST}:${PLAYWRIGHT_DEFAULT_PORT}/shutdown`,
130
+ spawn: () => {
131
+ const repoRoot = findRepoRoot(process.cwd())
132
+ return spawnLauncher(pkgLauncher("playwright-server", "playwright-server"), [
133
+ "serve",
134
+ "--port",
135
+ String(PLAYWRIGHT_DEFAULT_PORT),
136
+ "--host",
137
+ DEFAULT_HOST,
138
+ "--cwd",
139
+ repoRoot,
140
+ ])
141
+ },
142
+ },
143
+ ]
@@ -0,0 +1,75 @@
1
+ // Register a fake PID, assert it shows up in the snapshot, install
2
+ // handlers, fire an uncaughtException, assert taskkill/process.kill was
3
+ // invoked. We mock child_process.exec so no real taskkill runs.
4
+
5
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
6
+
7
+ vi.mock("node:child_process", () => {
8
+ return { exec: vi.fn() }
9
+ })
10
+
11
+ import { exec } from "node:child_process"
12
+ import {
13
+ _resetForTests,
14
+ _supervisedSnapshot,
15
+ installGlobalTeardown,
16
+ registerChild,
17
+ unregisterChild,
18
+ } from "./global-teardown.ts"
19
+
20
+ const originalPlatform = process.platform
21
+
22
+ function setPlatform(p: NodeJS.Platform): void {
23
+ Object.defineProperty(process, "platform", { value: p, configurable: true })
24
+ }
25
+
26
+ describe("global-teardown", () => {
27
+ let exitSpy: ReturnType<typeof vi.spyOn>
28
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>
29
+
30
+ beforeEach(() => {
31
+ _resetForTests()
32
+ vi.mocked(exec).mockClear()
33
+ // Stub process.exit so the test doesn't actually terminate.
34
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_c?: number) => {
35
+ return undefined as never
36
+ }) as never)
37
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
38
+ })
39
+
40
+ afterEach(() => {
41
+ setPlatform(originalPlatform)
42
+ process.removeAllListeners("uncaughtException")
43
+ process.removeAllListeners("unhandledRejection")
44
+ exitSpy.mockRestore()
45
+ consoleErrorSpy.mockRestore()
46
+ _resetForTests()
47
+ })
48
+
49
+ test("register/unregister manages the supervised set", () => {
50
+ registerChild(111)
51
+ registerChild(222)
52
+ expect(_supervisedSnapshot().sort()).toEqual([111, 222])
53
+ unregisterChild(111)
54
+ expect(_supervisedSnapshot()).toEqual([222])
55
+ })
56
+
57
+ test("uncaughtException handler kills registered children on Windows", () => {
58
+ setPlatform("win32")
59
+ registerChild(4242)
60
+ installGlobalTeardown()
61
+ process.emit("uncaughtException", new Error("boom"))
62
+ expect(exec).toHaveBeenCalledWith("taskkill /T /F /PID 4242")
63
+ expect(exitSpy).toHaveBeenCalledWith(1)
64
+ })
65
+
66
+ test("uncaughtException handler uses process.kill on POSIX", () => {
67
+ setPlatform("linux")
68
+ registerChild(7777)
69
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true)
70
+ installGlobalTeardown()
71
+ process.emit("uncaughtException", new Error("boom"))
72
+ expect(killSpy).toHaveBeenCalledWith(7777, "SIGKILL")
73
+ killSpy.mockRestore()
74
+ })
75
+ })
@@ -0,0 +1,78 @@
1
+ // Process-level emergency teardown.
2
+ //
3
+ // Keeps a module-level Set of supervised child PIDs. `useDaemonProcess`
4
+ // registers on spawn and unregisters on exit. If the TUI itself dies
5
+ // (uncaughtException / unhandledRejection / process.exit), we synchronously
6
+ // best-effort kill the lot so we don't leave orphan bun/node grandchildren.
7
+ //
8
+ // `exit` is sync — we fire taskkill /T /F on Windows (the OS handles
9
+ // reaping) and process.kill SIGKILL elsewhere. We do NOT await; if the
10
+ // process is on its way out anyway, the OS will clean us up.
11
+
12
+ import { exec } from "node:child_process"
13
+
14
+ const supervised = new Set<number>()
15
+ let installed = false
16
+
17
+ export function registerChild(pid: number): void {
18
+ if (pid > 0) supervised.add(pid)
19
+ }
20
+
21
+ export function unregisterChild(pid: number): void {
22
+ supervised.delete(pid)
23
+ }
24
+
25
+ export function isSupervisedChild(pid: number): boolean {
26
+ return supervised.has(pid)
27
+ }
28
+
29
+ // Sync best-effort kill — used inside process exit handlers where async
30
+ // has no time to run.
31
+ function killAllSync(): void {
32
+ for (const pid of supervised) {
33
+ try {
34
+ if (process.platform === "win32") {
35
+ // Fire-and-forget. We're inside an exit handler — don't await.
36
+ exec(`taskkill /T /F /PID ${pid}`)
37
+ } else {
38
+ process.kill(pid, "SIGKILL")
39
+ }
40
+ } catch {
41
+ // Ignore — best effort.
42
+ }
43
+ }
44
+ }
45
+
46
+ export function installGlobalTeardown(): void {
47
+ if (installed) return
48
+ installed = true
49
+
50
+ process.on("exit", () => {
51
+ killAllSync()
52
+ })
53
+
54
+ process.on("uncaughtException", (err) => {
55
+ killAllSync()
56
+ // Re-surface so the user actually sees what crashed.
57
+ // eslint-disable-next-line no-console
58
+ console.error("[davstack tui] uncaughtException — killed children:", err)
59
+ process.exit(1)
60
+ })
61
+
62
+ process.on("unhandledRejection", (reason) => {
63
+ killAllSync()
64
+ // eslint-disable-next-line no-console
65
+ console.error("[davstack tui] unhandledRejection — killed children:", reason)
66
+ process.exit(1)
67
+ })
68
+ }
69
+
70
+ // Test-only helpers.
71
+ export function _resetForTests(): void {
72
+ supervised.clear()
73
+ installed = false
74
+ }
75
+
76
+ export function _supervisedSnapshot(): number[] {
77
+ return Array.from(supervised)
78
+ }