@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
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @davstack/tui
2
+
3
+ Long-running terminal UI that spawns, owns, and surfaces the davstack
4
+ daemons (`logs-server`, `vitest-server`, `playwright-server`). One process
5
+ to launch on `cd`, one quit to stop everything cleanly.
6
+
7
+ ## Install / run
8
+
9
+ From inside a davstack-shaped repo (one with `.davstack/config/*.config.ts`):
10
+
11
+ ```sh
12
+ pnpm dlx @davstack/tui start
13
+ ```
14
+
15
+ The TUI auto-discovers which daemons are enabled by scanning
16
+ `.davstack/config/<tool>.config.ts` at the repo root, spawns each one,
17
+ streams its output into a ring buffer, and shows live status pills.
18
+
19
+ Flags:
20
+
21
+ - `--no-color` — disable ANSI colors (also honours `NO_COLOR` env).
22
+
23
+ ## Checking daemon health
24
+
25
+ `davstack check` probes every configured daemon and exits 0 if all are
26
+ running, 1 if any are missing, 2 if no davstack configs exist. Cheap
27
+ enough to run at the start of any agent workflow.
28
+
29
+ ## Keybindings
30
+
31
+ | Key | Where | What |
32
+ |----------|------------|--------------------------------------------------|
33
+ | `1`-`9` | any view | jump to that daemon's log view |
34
+ | `↑` / `↓`| list view | move focus between rows |
35
+ | `enter` | list view | drill into the focused daemon's log view |
36
+ | `s` | list view | start/stop the focused daemon |
37
+ | `esc` | log view | back to the daemon list |
38
+ | `c` | log view | clear the current daemon's ring buffer |
39
+ | `q` | any view | quit (confirms first if any daemon is running) |
40
+ | `ctrl-c` | any view | same as `q` |
41
+
42
+ When `q` triggers confirm-on-quit, only `y` / `n` / `esc` are active.
43
+
44
+ ## Daemons
45
+
46
+ | Daemon | Default port | Purpose |
47
+ |---------------------|--------------|---------------------------------------------------------|
48
+ | `logs-server` | `7077` | Local log sink — Sentry-shaped store + `diag` queries. |
49
+ | `vitest-server` | `5179` | Warm vitest daemon for fast unit/storybook reruns. |
50
+ | `playwright-server` | `5180` | Warm Playwright daemon for fast spec reruns. |
51
+
52
+ Each is independently enabled by dropping its config under
53
+ `.davstack/config/`. Daemons that aren't configured are skipped — the TUI
54
+ only shows what you've opted into.
55
+
56
+ ## Troubleshooting
57
+
58
+ - **Port already in use**: a daemon's row shows `blocked :PORT` instead of
59
+ starting. Kill whatever else is on that port, then press `s` on the row.
60
+ - **Windows orphan handling**: shutdown uses HTTP `/shutdown` then SIGTERM,
61
+ finally SIGKILL via `taskkill /F /T` so bun grandchildren can't orphan.
62
+ - **Requires Node 24+**: the launcher runs `tsx` under your installed
63
+ Node. Older Node versions are unsupported.
64
+
65
+ See the monorepo root [README](../../README.md) for the broader davstack
66
+ toolkit.
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // davstack TUI launcher. Default to tsx (handles TS under node_modules,
3
+ // which Node 24's --experimental-transform-types refuses). Opt into bun
4
+ // with DAVSTACK_TUI_RUNTIME=bun, or plain Node with =node (the latter
5
+ // only works when src is NOT under node_modules).
6
+ import { spawn } from 'node:child_process'
7
+ import { createRequire } from 'node:module'
8
+ import { fileURLToPath } from 'node:url'
9
+ import path from 'node:path'
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url))
12
+ const entry = path.join(here, '..', 'src', 'cli.ts')
13
+ const runtime = process.env.DAVSTACK_TUI_RUNTIME ?? 'tsx'
14
+
15
+ let cmd, args
16
+ if (runtime === 'tsx') {
17
+ const require = createRequire(import.meta.url)
18
+ const tsxCli = require.resolve('tsx/cli')
19
+ cmd = process.execPath
20
+ args = [tsxCli, entry, ...process.argv.slice(2)]
21
+ } else if (runtime === 'bun') {
22
+ cmd = 'bun'
23
+ args = [entry, ...process.argv.slice(2)]
24
+ } else if (runtime === 'node') {
25
+ cmd = process.execPath
26
+ args = ['--experimental-transform-types', entry, ...process.argv.slice(2)]
27
+ } else {
28
+ console.error(`davstack: unknown DAVSTACK_TUI_RUNTIME='${runtime}' (expected 'tsx', 'bun', or 'node')`)
29
+ process.exit(2)
30
+ }
31
+
32
+ const needsShell = process.platform === 'win32' && runtime === 'bun'
33
+ const child = spawn(cmd, args, { stdio: 'inherit', shell: needsShell })
34
+ child.on('error', (err) => {
35
+ if (err.code === 'ENOENT' && runtime === 'bun') {
36
+ console.error("davstack: bun not found on PATH. Install bun (https://bun.sh) or unset DAVSTACK_TUI_RUNTIME.")
37
+ } else {
38
+ console.error('davstack: launcher error:', err)
39
+ }
40
+ process.exit(1)
41
+ })
42
+ child.on('exit', (code, signal) => {
43
+ if (signal) process.kill(process.pid, signal)
44
+ else process.exit(code ?? 0)
45
+ })
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@davstack/tui",
3
+ "version": "0.2.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "Long-running terminal UI that spawns, owns, and surfaces the davstack daemons (vitest-server, playwright-server, logs-server).",
7
+ "bin": {
8
+ "davstack": "./bin/davstack.mjs"
9
+ },
10
+ "files": [
11
+ "bin/**",
12
+ "src/**"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "dependencies": {
18
+ "commander": "^12.0.0",
19
+ "ink": "^5.0.1",
20
+ "react": "^18.3.1",
21
+ "tsx": "^4.19.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "^18.3.3",
25
+ "ink-testing-library": "^4.0.0"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "test": "pnpm -w exec vitest run --project tui"
32
+ }
33
+ }
@@ -0,0 +1,92 @@
1
+ // Shell-level smoke tests for the App composition. Navigation hotkey
2
+ // behaviour is covered as a unit in `hooks/useHotkeys.test.tsx` — those
3
+ // tests render the providers without App and drive the dispatcher
4
+ // directly, sidestepping ink's raw-mode plumbing which
5
+ // ink-testing-library doesn't simulate.
6
+
7
+ import React from "react"
8
+ import { EventEmitter } from "node:events"
9
+ import { PassThrough } from "node:stream"
10
+ import { test, expect, afterEach } from "vitest"
11
+ import { render } from "ink-testing-library"
12
+
13
+ import { App } from "./App.tsx"
14
+ import type { DaemonDescriptor, DaemonKey } from "./lib/daemon-registry.ts"
15
+
16
+ function makeFakeDescriptor(key: DaemonKey, label: string, port: number): DaemonDescriptor {
17
+ return {
18
+ key,
19
+ label,
20
+ port,
21
+ readyRegex: /listening on http:\/\//i,
22
+ spawn: () => {
23
+ const ee = new EventEmitter() as EventEmitter & {
24
+ stdout: PassThrough
25
+ stderr: PassThrough
26
+ kill: () => boolean
27
+ }
28
+ ee.stdout = new PassThrough()
29
+ ee.stderr = new PassThrough()
30
+ ee.kill = () => true
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ return ee as any
33
+ },
34
+ }
35
+ }
36
+
37
+ let active: ReturnType<typeof render> | null = null
38
+
39
+ afterEach(() => {
40
+ active?.unmount()
41
+ active = null
42
+ })
43
+
44
+ test("renders title, list view with logs row, and a status bar pill", () => {
45
+ active = render(
46
+ <App
47
+ registry={[makeFakeDescriptor("logs", "logs", 7077)]}
48
+ autoStart={false}
49
+ skipConfigDiscovery
50
+ />,
51
+ )
52
+ const frame = active.lastFrame() ?? ""
53
+
54
+ expect(frame).toContain("davstack")
55
+ expect(frame).toContain("Daemons")
56
+ expect(frame).toContain("logs")
57
+ // Idle glyph appears for both the row and the bottom pill (○).
58
+ expect(frame).toMatch(/○/)
59
+ })
60
+
61
+ test("renders the empty state with version + init command when no daemons configured", () => {
62
+ active = render(<App registry={[]} autoStart={false} skipConfigDiscovery />)
63
+ const frame = active.lastFrame() ?? ""
64
+
65
+ expect(frame).toContain("davstack TUI v")
66
+ expect(frame).toContain("No davstack configs found")
67
+ expect(frame).toContain("pnpm dlx @davstack/init")
68
+ expect(frame).toContain("Press q to quit")
69
+ })
70
+
71
+ test("renders three daemon rows + three status pills when all configured", () => {
72
+ active = render(
73
+ <App
74
+ registry={[
75
+ makeFakeDescriptor("logs", "logs", 7077),
76
+ makeFakeDescriptor("vitest", "vitest", 5179),
77
+ makeFakeDescriptor("playwright", "playwright", 5180),
78
+ ]}
79
+ autoStart={false}
80
+ skipConfigDiscovery
81
+ />,
82
+ )
83
+ const frame = active.lastFrame() ?? ""
84
+
85
+ expect(frame).toContain("logs")
86
+ expect(frame).toContain("vitest")
87
+ expect(frame).toContain("playwright")
88
+ // Status pills are numbered 1/2/3 in the bottom bar.
89
+ expect(frame).toMatch(/1.*logs/)
90
+ expect(frame).toMatch(/2.*vitest/)
91
+ expect(frame).toMatch(/3.*playwright/)
92
+ })
package/src/App.tsx ADDED
@@ -0,0 +1,103 @@
1
+ // Composition root for `davstack start`. Wires up providers, supervisors
2
+ // and view components — keeps no logic of its own beyond glue.
3
+
4
+ import React, { useEffect } from "react"
5
+ import { Box, Text } from "ink"
6
+
7
+ import { daemonRegistry, type DaemonDescriptor } from "./lib/daemon-registry.ts"
8
+ import { installGlobalTeardown } from "./lib/global-teardown.ts"
9
+
10
+ import { ViewProvider } from "./state/view-context.tsx"
11
+ import { DaemonsProvider } from "./state/daemons-context.tsx"
12
+ import { QuitProvider, useQuit } from "./state/quit-context.tsx"
13
+
14
+ import { DaemonSupervisor } from "./components/DaemonSupervisor.tsx"
15
+ import { DescriptorSync } from "./components/DescriptorSync.tsx"
16
+ import { GlobalHotkeys } from "./components/GlobalHotkeys.tsx"
17
+ import { QuitController } from "./components/QuitController.tsx"
18
+ import { MainView } from "./components/MainView.tsx"
19
+ import { BottomBar } from "./components/BottomBar.tsx"
20
+ import { QuitConfirm } from "./components/QuitConfirm.tsx"
21
+
22
+ import { useConfigDiscovery } from "./hooks/useConfigDiscovery.ts"
23
+
24
+ interface AppProps {
25
+ registry?: DaemonDescriptor[]
26
+ // When false, daemons are NOT auto-started on mount. Tests use this.
27
+ autoStart?: boolean
28
+ // When true, skip the .davstack/config discovery filter. Tests pass a
29
+ // pre-filtered registry directly.
30
+ skipConfigDiscovery?: boolean
31
+ }
32
+
33
+ export function App({
34
+ registry = daemonRegistry,
35
+ autoStart = true,
36
+ skipConfigDiscovery = false,
37
+ }: AppProps): React.ReactElement {
38
+ // Install the process-level emergency teardown once. Idempotent.
39
+ useEffect(() => {
40
+ installGlobalTeardown()
41
+ }, [])
42
+
43
+ const { done, filtered } = useConfigDiscovery(registry, skipConfigDiscovery)
44
+
45
+ return (
46
+ <ViewProvider>
47
+ <DaemonsProvider descriptors={filtered}>
48
+ <QuitProvider>
49
+ <DescriptorSync descriptors={filtered} />
50
+ {filtered.map((d) => (
51
+ <DaemonSupervisor key={d.key} descriptor={d} autoStart={autoStart} />
52
+ ))}
53
+ <QuitController>
54
+ {({ quit, quitting }) => (
55
+ <AppFrame
56
+ quit={quit}
57
+ quitting={quitting}
58
+ done={done}
59
+ hasAnyDaemon={filtered.length > 0}
60
+ />
61
+ )}
62
+ </QuitController>
63
+ </QuitProvider>
64
+ </DaemonsProvider>
65
+ </ViewProvider>
66
+ )
67
+ }
68
+
69
+ // Inner frame — pulled out so it can `useQuit()` (which requires being
70
+ // inside QuitProvider). Renders the confirm overlay when active.
71
+ function AppFrame({
72
+ quit,
73
+ quitting,
74
+ done,
75
+ hasAnyDaemon,
76
+ }: {
77
+ quit: () => void
78
+ quitting: boolean
79
+ done: boolean
80
+ hasAnyDaemon: boolean
81
+ }): React.ReactElement {
82
+ const { confirming } = useQuit()
83
+ return (
84
+ <Box flexDirection="column">
85
+ <GlobalHotkeys onQuit={quit} />
86
+ <Box>
87
+ <Text bold>davstack</Text>
88
+ </Box>
89
+ <Box marginTop={1} flexDirection="column">
90
+ <MainView discoveryDone={done} hasAnyDaemon={hasAnyDaemon} />
91
+ </Box>
92
+ <Box marginTop={1}>
93
+ <BottomBar />
94
+ </Box>
95
+ {confirming ? <QuitConfirm /> : null}
96
+ {quitting ? (
97
+ <Box marginTop={1}>
98
+ <Text dimColor>shutting down daemons…</Text>
99
+ </Box>
100
+ ) : null}
101
+ </Box>
102
+ )
103
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,62 @@
1
+ // @davstack/tui — subcommand dispatcher.
2
+ //
3
+ // Ships `davstack start` (Ink shell) and `davstack check` (one-shot
4
+ // daemon probe + start hint). Other subcommands (stop/status/logs) are
5
+ // reserved.
6
+
7
+ import { Command } from "commander"
8
+ import React from "react"
9
+ import { render } from "ink"
10
+
11
+ import { App } from "./App.tsx"
12
+ import { runCheck, formatResult, exitCodeFor } from "./commands/check.ts"
13
+
14
+ function runStart(opts: { noColor?: boolean }): void {
15
+ if (opts.noColor) process.env.DAVSTACK_NO_COLOR = "1"
16
+ // Wipe stale shell scrollback so the Ink UI takes over a clean screen.
17
+ // Sequence: ED(2) clears viewport, ED(3) clears scrollback (xterm/WT),
18
+ // CUP homes the cursor.
19
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H")
20
+ render(React.createElement(App))
21
+ }
22
+
23
+ const program = new Command()
24
+
25
+ program
26
+ .name("davstack")
27
+ .description("Long-running TUI that owns the davstack daemons.")
28
+ .showHelpAfterError()
29
+ .exitOverride()
30
+
31
+ program
32
+ .command("start")
33
+ .description("Launch the TUI.")
34
+ .option("--no-color", "Disable ANSI colors (also respects NO_COLOR env)")
35
+ .action((opts: { color?: boolean }) => {
36
+ // commander maps `--no-color` to opts.color === false.
37
+ runStart({ noColor: opts.color === false })
38
+ })
39
+
40
+ program
41
+ .command("check")
42
+ .description("Probe configured daemons; reports running status + start hint if missing.")
43
+ .option("--no-color", "Disable ANSI colors (also respects NO_COLOR env)")
44
+ .action(async (opts: { color?: boolean }) => {
45
+ const result = await runCheck()
46
+ const useColor =
47
+ opts.color !== false && !process.env.NO_COLOR && !process.env.DAVSTACK_NO_COLOR
48
+ process.stdout.write(formatResult(result, useColor) + "\n")
49
+ process.exit(exitCodeFor(result))
50
+ })
51
+
52
+ try {
53
+ program.parse(process.argv)
54
+ } catch (err) {
55
+ const exitErr = err as { code?: string; exitCode?: number }
56
+ if (exitErr.code === "commander.helpDisplayed" || exitErr.code === "commander.help") {
57
+ process.exit(0)
58
+ }
59
+ // commander already prints help-after-error for unknown commands/options
60
+ // (via showHelpAfterError above), so we just propagate the exit code.
61
+ process.exit(exitErr.exitCode ?? 1)
62
+ }
@@ -0,0 +1,160 @@
1
+ // Unit tests for `davstack check`. Pure: no real network, no real daemons.
2
+ // Uses fake DaemonDescriptors + a spy `probe` so we can assert which
3
+ // ports got hit and what the formatter does with the result.
4
+
5
+ import fs from "node:fs/promises"
6
+ import os from "node:os"
7
+ import path from "node:path"
8
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
9
+
10
+ import { runCheck, formatResult, exitCodeFor } from "./check.ts"
11
+ import type { DaemonDescriptor, DaemonKey } from "../lib/daemon-registry.ts"
12
+
13
+ function fakeDescriptor(key: DaemonKey, label: string, port: number): DaemonDescriptor {
14
+ return {
15
+ key,
16
+ label,
17
+ port,
18
+ host: "127.0.0.1",
19
+ readyRegex: /listening on http:\/\//i,
20
+ spawn: () => {
21
+ throw new Error("check must never call spawn")
22
+ },
23
+ }
24
+ }
25
+
26
+ const FAKE_REGISTRY: DaemonDescriptor[] = [
27
+ fakeDescriptor("logs", "logs", 7077),
28
+ fakeDescriptor("vitest", "vitest", 5179),
29
+ fakeDescriptor("playwright", "playwright", 5180),
30
+ ]
31
+
32
+ let tmpRoot = ""
33
+
34
+ beforeEach(async () => {
35
+ tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "davstack-tui-check-"))
36
+ })
37
+
38
+ afterEach(async () => {
39
+ await fs.rm(tmpRoot, { recursive: true, force: true })
40
+ })
41
+
42
+ async function writeConfig(name: string): Promise<void> {
43
+ const dir = path.join(tmpRoot, ".davstack", "config")
44
+ await fs.mkdir(dir, { recursive: true })
45
+ await fs.writeFile(path.join(dir, name), "export default {}\n", "utf8")
46
+ }
47
+
48
+ describe("runCheck", () => {
49
+ test("returns no-config when .davstack/config is missing", async () => {
50
+ const result = await runCheck({
51
+ repoRoot: tmpRoot,
52
+ probe: vi.fn(),
53
+ registry: FAKE_REGISTRY,
54
+ })
55
+ expect(result.kind).toBe("no-config")
56
+ if (result.kind === "no-config") {
57
+ expect(result.repoRoot).toBe(tmpRoot)
58
+ }
59
+ })
60
+
61
+ test("probes only configured daemons", async () => {
62
+ await writeConfig("logs-server.config.ts")
63
+ await writeConfig("vitest-server.config.ts")
64
+ const probe = vi.fn().mockResolvedValue(true)
65
+ const result = await runCheck({
66
+ repoRoot: tmpRoot,
67
+ probe,
68
+ registry: FAKE_REGISTRY,
69
+ })
70
+ expect(probe).toHaveBeenCalledTimes(2)
71
+ const probedPorts = probe.mock.calls.map((c) => c[1]).sort()
72
+ expect(probedPorts).toEqual([5179, 7077])
73
+ expect(result.kind).toBe("checked")
74
+ })
75
+
76
+ test("exit 1 when any daemon's probe returns false", async () => {
77
+ await writeConfig("logs-server.config.ts")
78
+ await writeConfig("vitest-server.config.ts")
79
+ const probe = vi.fn(async (_h: string, port: number) => port === 7077)
80
+ const result = await runCheck({
81
+ repoRoot: tmpRoot,
82
+ probe,
83
+ registry: FAKE_REGISTRY,
84
+ })
85
+ expect(exitCodeFor(result)).toBe(1)
86
+ })
87
+
88
+ test("exit 0 when all probes return true", async () => {
89
+ await writeConfig("logs-server.config.ts")
90
+ const probe = vi.fn().mockResolvedValue(true)
91
+ const result = await runCheck({
92
+ repoRoot: tmpRoot,
93
+ probe,
94
+ registry: FAKE_REGISTRY,
95
+ })
96
+ expect(exitCodeFor(result)).toBe(0)
97
+ })
98
+
99
+ test("exit 2 for no-config", async () => {
100
+ const result = await runCheck({
101
+ repoRoot: tmpRoot,
102
+ probe: vi.fn(),
103
+ registry: FAKE_REGISTRY,
104
+ })
105
+ expect(exitCodeFor(result)).toBe(2)
106
+ })
107
+ })
108
+
109
+ describe("formatResult", () => {
110
+ test("renders the no-config message with the init command", () => {
111
+ const out = formatResult({ kind: "no-config", repoRoot: "/repo" }, false)
112
+ expect(out).toContain("No davstack configs found")
113
+ expect(out).toContain("pnpm dlx @davstack/init")
114
+ expect(out).toContain("/repo")
115
+ })
116
+
117
+ test("all-running output omits the start hint", () => {
118
+ const out = formatResult(
119
+ {
120
+ kind: "checked",
121
+ repoRoot: "/repo",
122
+ rows: [
123
+ { descriptor: FAKE_REGISTRY[0]!, running: true },
124
+ { descriptor: FAKE_REGISTRY[1]!, running: true },
125
+ ],
126
+ },
127
+ false,
128
+ )
129
+ expect(out).toContain("All configured daemons running.")
130
+ expect(out).not.toContain("pnpm dlx @davstack/tui start")
131
+ })
132
+
133
+ test("some-missing output includes the start hint", () => {
134
+ const out = formatResult(
135
+ {
136
+ kind: "checked",
137
+ repoRoot: "/repo",
138
+ rows: [
139
+ { descriptor: FAKE_REGISTRY[0]!, running: true },
140
+ { descriptor: FAKE_REGISTRY[1]!, running: false },
141
+ ],
142
+ },
143
+ false,
144
+ )
145
+ expect(out).toContain("1 daemon(s) not running.")
146
+ expect(out).toContain("pnpm dlx @davstack/tui start")
147
+ })
148
+
149
+ test("color=true emits ANSI escapes; color=false does not", () => {
150
+ const rows = [
151
+ { descriptor: FAKE_REGISTRY[0]!, running: true },
152
+ { descriptor: FAKE_REGISTRY[1]!, running: false },
153
+ ]
154
+ const colored = formatResult({ kind: "checked", repoRoot: "/r", rows }, true)
155
+ const plain = formatResult({ kind: "checked", repoRoot: "/r", rows }, false)
156
+ expect(colored).toContain("\x1b[")
157
+ expect(plain).not.toContain("\x1b[")
158
+ })
159
+ })
160
+
@@ -0,0 +1,112 @@
1
+ // `davstack check` — probe every configured daemon and report status.
2
+ //
3
+ // Pure (no Ink, no spawning, no stdin). Designed to be safe to run in CI,
4
+ // piped, or as a one-liner at the top of any agent workflow. The TUI's
5
+ // `start` is the only spawner; `check` only reports.
6
+ //
7
+ // Exit codes:
8
+ // 0 — all configured daemons are reachable
9
+ // 1 — at least one configured daemon is not reachable
10
+ // 2 — no davstack configs found in this repo
11
+
12
+ import { discoverEnabledDaemons, findConfigRoot } from "../lib/config-discovery.ts"
13
+ import {
14
+ daemonRegistry,
15
+ type DaemonDescriptor,
16
+ type DaemonKey,
17
+ } from "../lib/daemon-registry.ts"
18
+ import { probePort } from "../lib/port-probe.ts"
19
+ import { findRepoRoot } from "../lib/repo-root.ts"
20
+
21
+ const PROBE_TIMEOUT_MS = 300
22
+ const DEFAULT_PROBE_HOST = "127.0.0.1"
23
+
24
+ export type CheckRow = { descriptor: DaemonDescriptor; running: boolean }
25
+
26
+ export type CheckResult =
27
+ | { kind: "no-config"; repoRoot: string }
28
+ | { kind: "checked"; repoRoot: string; rows: CheckRow[] }
29
+
30
+ export type CheckDeps = {
31
+ repoRoot?: string
32
+ probe?: (host: string, port: number) => Promise<boolean>
33
+ registry?: DaemonDescriptor[]
34
+ // Test-only override of the discovery step (default reads filesystem).
35
+ discover?: (repoRoot: string) => Promise<Set<DaemonKey>>
36
+ }
37
+
38
+ export async function runCheck(deps: CheckDeps = {}): Promise<CheckResult> {
39
+ const cwd = process.cwd()
40
+ const repoRoot = deps.repoRoot ?? findConfigRoot(cwd) ?? findRepoRoot(cwd)
41
+ const discover = deps.discover ?? discoverEnabledDaemons
42
+ const probe = deps.probe ?? ((h, p) => probePort(h, p, PROBE_TIMEOUT_MS))
43
+ const registry = deps.registry ?? daemonRegistry
44
+
45
+ const enabled = await discover(repoRoot)
46
+ if (enabled.size === 0) return { kind: "no-config", repoRoot }
47
+
48
+ const descriptors = registry.filter((d) => enabled.has(d.key))
49
+ const rows = await Promise.all(
50
+ descriptors.map(async (descriptor) => {
51
+ const host = descriptor.host ?? DEFAULT_PROBE_HOST
52
+ const running = await probe(host, descriptor.port)
53
+ return { descriptor, running }
54
+ }),
55
+ )
56
+ return { kind: "checked", repoRoot, rows }
57
+ }
58
+
59
+ function green(s: string, useColor: boolean): string {
60
+ return useColor ? `\x1b[32m${s}\x1b[0m` : s
61
+ }
62
+
63
+ function red(s: string, useColor: boolean): string {
64
+ return useColor ? `\x1b[31m${s}\x1b[0m` : s
65
+ }
66
+
67
+ const START_HINT = [
68
+ "To start the missing daemons, run this in a separate terminal:",
69
+ "",
70
+ " pnpm dlx @davstack/tui start",
71
+ "",
72
+ "The TUI supervises every configured daemon together; closing it",
73
+ "cleans them up. Re-run `davstack check` to confirm.",
74
+ ].join("\n")
75
+
76
+ export function formatResult(result: CheckResult, useColor: boolean): string {
77
+ const header = `davstack check (cwd: ${result.repoRoot})`
78
+
79
+ if (result.kind === "no-config") {
80
+ return [header, "", "No davstack configs found. Run: pnpm dlx @davstack/init"].join("\n")
81
+ }
82
+
83
+ const labelWidth = Math.max(...result.rows.map((r) => r.descriptor.label.length), 1)
84
+ const portWidth = Math.max(...result.rows.map((r) => `:${r.descriptor.port}`.length), 1)
85
+
86
+ const lines: string[] = [header, ""]
87
+ let missing = 0
88
+ for (const row of result.rows) {
89
+ const marker = row.running ? green("●", useColor) : red("✗", useColor)
90
+ const label = row.descriptor.label.padEnd(labelWidth)
91
+ const port = `:${row.descriptor.port}`.padEnd(portWidth)
92
+ const status = row.running ? "running" : "not running"
93
+ lines.push(` ${marker} ${label} ${port} ${status}`)
94
+ if (!row.running) missing += 1
95
+ }
96
+ lines.push("")
97
+
98
+ if (missing === 0) {
99
+ lines.push("All configured daemons running.")
100
+ } else {
101
+ lines.push(`${missing} daemon(s) not running.`)
102
+ lines.push("")
103
+ lines.push(START_HINT)
104
+ }
105
+
106
+ return lines.join("\n")
107
+ }
108
+
109
+ export function exitCodeFor(result: CheckResult): number {
110
+ if (result.kind === "no-config") return 2
111
+ return result.rows.every((r) => r.running) ? 0 : 1
112
+ }