@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.
- package/README.md +66 -0
- package/bin/davstack.mjs +45 -0
- package/package.json +33 -0
- package/src/App.test.tsx +92 -0
- package/src/App.tsx +103 -0
- package/src/cli.ts +62 -0
- package/src/commands/check.test.ts +160 -0
- package/src/commands/check.ts +112 -0
- package/src/components/BottomBar.tsx +28 -0
- package/src/components/DaemonSupervisor.tsx +50 -0
- package/src/components/DescriptorSync.tsx +19 -0
- package/src/components/GlobalHotkeys.tsx +30 -0
- package/src/components/MainView.tsx +75 -0
- package/src/components/QuitConfirm.tsx +20 -0
- package/src/components/QuitController.tsx +69 -0
- package/src/components/StatusBar.test.tsx +47 -0
- package/src/components/StatusBar.tsx +67 -0
- package/src/hooks/useConfigDiscovery.ts +56 -0
- package/src/hooks/useDaemonProcess.test.ts +415 -0
- package/src/hooks/useDaemonProcess.ts +275 -0
- package/src/hooks/useHotkeys.test.tsx +508 -0
- package/src/hooks/useHotkeys.ts +164 -0
- package/src/hooks/useNoColor.test.ts +50 -0
- package/src/hooks/useNoColor.ts +35 -0
- package/src/hooks/useRingBuffer.test.tsx +86 -0
- package/src/hooks/useRingBuffer.ts +46 -0
- package/src/lib/config-discovery.test.ts +77 -0
- package/src/lib/config-discovery.ts +57 -0
- package/src/lib/daemon-registry.ts +143 -0
- package/src/lib/global-teardown.test.ts +75 -0
- package/src/lib/global-teardown.ts +78 -0
- package/src/lib/kill-tree.test.ts +69 -0
- package/src/lib/kill-tree.ts +31 -0
- package/src/lib/package-info.ts +35 -0
- package/src/lib/port-owner.test.ts +105 -0
- package/src/lib/port-owner.ts +90 -0
- package/src/lib/port-probe.test.ts +41 -0
- package/src/lib/port-probe.ts +29 -0
- package/src/lib/repo-root.test.ts +36 -0
- package/src/lib/repo-root.ts +30 -0
- package/src/lib/ring-buffer.test.ts +63 -0
- package/src/lib/ring-buffer.ts +47 -0
- package/src/state/daemons-context.tsx +149 -0
- package/src/state/quit-context.tsx +27 -0
- package/src/state/view-context.tsx +32 -0
- package/src/views/ServerList.test.tsx +167 -0
- package/src/views/ServerList.tsx +109 -0
- 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
|
+
}
|