@nebulord/sickbay 0.1.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 ADDED
@@ -0,0 +1,231 @@
1
+ # @nebulord/sickbay
2
+
3
+ The terminal interface for Sickbay. Built with [Ink](https://github.com/vadimdemedes/ink) (React for terminals) and [Commander](https://github.com/tj/commander.js).
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ sickbay [options]
9
+ ```
10
+
11
+ ### Commands
12
+
13
+ | Command | Description |
14
+ | ------------------ | ------------------------------------------------------------------ |
15
+ | `init [options]` | Scaffold `.sickbay/`, run baseline scan, seed history |
16
+ | `fix [options]` | Interactively fix issues found by sickbay scan |
17
+ | `trend [options]` | Show score history and trends over time |
18
+ | `stats [options]` | Show a quick codebase overview and project summary |
19
+ | `doctor [options]` | Diagnose project setup and configuration issues |
20
+ | `tui [options]` | Persistent live dashboard with file watching and activity tracking |
21
+
22
+ ### Flags
23
+
24
+ | Flag | Default | Description |
25
+ | ---------------------- | --------------- | --------------------------------- |
26
+ | `-p, --path <path>` | `process.cwd()` | Path to the project to analyze |
27
+ | `-c, --checks <names>` | all | Comma-separated check IDs to run |
28
+ | `--json` | false | Output raw JSON to stdout (no UI) |
29
+ | `--web` | false | Open web dashboard after scan |
30
+ | `--verbose` | false | Show tool output during checks |
31
+ | `-V, --version` | | Print version |
32
+ | `-h, --help` | | Show help |
33
+
34
+ ### Examples
35
+
36
+ ```bash
37
+ # Analyze current directory
38
+ sickbay
39
+
40
+ # Analyze another project
41
+ sickbay -p ~/projects/my-app
42
+
43
+ # Run specific checks only
44
+ sickbay --checks knip,npm-audit,depcheck
45
+
46
+ # JSON output for CI
47
+ sickbay --json | jq '.overallScore'
48
+
49
+ # Get just the summary
50
+ sickbay --json | jq '.summary'
51
+
52
+ # List all check names and their scores
53
+ sickbay --json | jq '.checks[] | {name, score}'
54
+
55
+ # Get only failing checks
56
+ sickbay --json | jq '.checks[] | select(.status == "fail")'
57
+
58
+ # Open web dashboard
59
+ sickbay --web
60
+
61
+ # Initialize .sickbay/ folder with baseline scan
62
+ sickbay init
63
+
64
+ # Initialize for a specific project
65
+ sickbay init --path ~/projects/my-app
66
+
67
+ # Interactively fix issues
68
+ sickbay fix
69
+
70
+ # View score history and trends
71
+ sickbay trend
72
+
73
+ # Get quick project stats
74
+ sickbay stats
75
+
76
+ # Diagnose project setup
77
+ sickbay doctor
78
+
79
+ # Launch tui dashboard (current directory, file watching enabled)
80
+ sickbay tui
81
+
82
+ # TUI for a specific project, disable file watching
83
+ sickbay tui --path ~/projects/my-app --no-watch
84
+
85
+ # TUI with faster auto-refresh (60 seconds) and specific checks only
86
+ sickbay tui --path ~/projects/my-app --refresh 60 --checks knip,npm-audit,eslint
87
+ ```
88
+
89
+ ## `sickbay init` vs `sickbay`
90
+
91
+ **Run `sickbay init` once when setting up a project for the first time.**
92
+
93
+ It scaffolds the `.sickbay/` data folder, saves a `baseline.json` snapshot of the project's current health, and wires up `.gitignore` entries so `history.json` doesn't pollute your repo. Think of it as "onboarding" Sickbay to a project.
94
+
95
+ **Run `sickbay` for every subsequent scan.**
96
+
97
+ Each scan automatically appends an entry to `.sickbay/history.json`, so your score trend builds up over time without any extra steps. The History tab in the web dashboard (`sickbay --web`) reads from this file.
98
+
99
+ | | First time | Ongoing |
100
+ | ------------------------- | ------------- | -------------- |
101
+ | Command | `sickbay init` | `sickbay` |
102
+ | Creates `.sickbay/` | ✓ | ✓ (if missing) |
103
+ | Saves `baseline.json` | ✓ | ✗ |
104
+ | Updates root `.gitignore` | ✓ | ✗ |
105
+ | Appends to `history.json` | ✓ | ✓ |
106
+
107
+ > If you skip `sickbay init` and go straight to `sickbay`, history will still accumulate — you just won't have a baseline snapshot or gitignore entries for `.sickbay/`. But you can always ignore it manually.
108
+
109
+ ## TUI Dashboard
110
+
111
+ `sickbay tui` opens a persistent split-pane TUI that continuously monitors your project. Unlike a one-shot scan, it stays running, watches for file changes, and lets you interact with results in real time.
112
+
113
+ ### TUI Flags
114
+
115
+ | Flag | Default | Description |
116
+ | ----------------------- | --------------- | ------------------------------------- |
117
+ | `-p, --path <path>` | `process.cwd()` | Project path to monitor |
118
+ | `--no-watch` | watch enabled | Disable file-watching auto-refresh |
119
+ | `--refresh <seconds>` | `300` | Auto-refresh interval in seconds |
120
+ | `-c, --checks <checks>` | all | Comma-separated list of checks to run |
121
+
122
+ ### Panels
123
+
124
+ The tui displays six panels arranged in a responsive grid:
125
+
126
+ | Panel | Key | Content |
127
+ | -------------- | --- | -------------------------------------------------------------------------- |
128
+ | **Health** | `h` | All check results with status icons, names, and score bars |
129
+ | **Score** | — | Overall score (0–100), color-coded status, issue counts, score delta |
130
+ | **Trend** | `t` | Sparkline charts for overall score and each category (last 10 scans) |
131
+ | **Git** | `g` | Branch, commits ahead/behind, modified/staged/untracked files, last commit |
132
+ | **Quick Wins** | `q` | Top 5 actionable fixes prioritized by severity |
133
+ | **Activity** | `a` | Time-stamped event log (scans, file changes, regressions, git changes) |
134
+
135
+ ### Keyboard Controls
136
+
137
+ | Key | Action |
138
+ | -------- | ------------------------------------------ |
139
+ | `r` | Manually trigger a rescan |
140
+ | `w` | Launch web dashboard (without AI) |
141
+ | `W` | Launch web dashboard with AI analysis |
142
+ | `f` | Expand focused panel to fullscreen |
143
+ | `Escape` | Unfocus current panel |
144
+ | `↑ / ↓` | Scroll Health Panel results (when focused) |
145
+ | `h` | Focus Health panel |
146
+ | `g` | Focus Git panel |
147
+ | `t` | Focus Trend panel |
148
+ | `q` | Focus Quick Wins panel |
149
+ | `a` | Focus Activity panel |
150
+
151
+ ### Automatic Triggers
152
+
153
+ - **Startup** — Initial scan runs immediately
154
+ - **File watch** — Rescans when TypeScript, JavaScript, or JSON files change (debounced 2s)
155
+ - **Auto-refresh** — Periodic rescan at the configured interval (default 5 minutes)
156
+ - **Regression detection** — Activity panel flags category score decreases automatically
157
+
158
+ ## Architecture
159
+
160
+ ```
161
+ src/
162
+ ├── index.ts # Commander entry — parses flags, renders Ink <App>
163
+ ├── commands/
164
+ │ └── web.ts # HTTP server (Node built-in) for the dashboard
165
+ └── components/
166
+ ├── App.tsx # Root Ink component — manages phases & state
167
+ ├── Header.tsx # ASCII art banner + project name
168
+ ├── ProgressList.tsx # Animated check progress (pending → running → done)
169
+ ├── CheckResult.tsx # Single check: name, status, score bar, issues
170
+ ├── ScoreBar.tsx # Colored horizontal bar (green/yellow/red)
171
+ ├── Summary.tsx # Overall score + issue counts
172
+ ├── QuickWins.tsx # Top actionable fix suggestions
173
+ └── tui/
174
+ ├── TUIApp.tsx # TUI root — layout, keyboard input, state
175
+ ├── HealthPanel.tsx # Check results with status icons and score bars
176
+ ├── ScorePanel.tsx # Overall score, issue counts, delta from last scan
177
+ ├── TrendPanel.tsx # Sparkline charts for score history (last 10 scans)
178
+ ├── GitPanel.tsx # Branch, ahead/behind, staged/modified file counts
179
+ ├── QuickWinsPanel.tsx # Top 5 actionable fixes by severity
180
+ ├── ActivityPanel.tsx # Timestamped event log
181
+ ├── HotkeyBar.tsx # Fixed footer with keyboard shortcut reference
182
+ ├── PanelBorder.tsx # Focused/unfocused border styling
183
+ └── hooks/
184
+ ├── useSickbayRunner.ts # Manages check execution and scan state
185
+ ├── useFileWatcher.ts # chokidar file watcher with debounce
186
+ ├── useGitStatus.ts # Polls git status every 10 seconds
187
+ └── useTerminalSize.ts # Tracks terminal dimensions for responsive layout
188
+ ```
189
+
190
+ ### UI Phases
191
+
192
+ The `<App>` component cycles through phases:
193
+
194
+ 1. **`loading`** — Shows progress list with animated spinners while checks run
195
+ 2. **`results`** — Displays all check results + summary + quick wins
196
+ 3. **`opening-web`** — Starts HTTP server, opens browser, stays alive until Ctrl+C
197
+ 4. **`error`** — Shows error message and exits
198
+
199
+ ### `--web` flag flow
200
+
201
+ When `--web` is passed:
202
+
203
+ 1. Scan completes normally
204
+ 2. `serveWeb(report)` starts an HTTP server on port 3030 (or next free port)
205
+ 3. Server serves `packages/web/dist/` as static files
206
+ 4. Server responds to `GET /sickbay-report.json` with the in-memory report
207
+ 5. `open` package opens the browser
208
+ 6. Process stays alive until Ctrl+C
209
+
210
+ ### `--json` flag flow
211
+
212
+ Skips the Ink UI entirely, writes `JSON.stringify(report, null, 2)` to stdout, then exits.
213
+
214
+ ## Local Development
215
+
216
+ ```bash
217
+ # Watch mode — rebuilds on file changes
218
+ pnpm dev
219
+
220
+ # Test against a project
221
+ node dist/index.js --path ~/Desktop/sickbay-test-app
222
+ node dist/index.js --path ~/Desktop/sickbay-test-app --web
223
+ node dist/index.js --path ~/Desktop/sickbay-test-app --json
224
+ ```
225
+
226
+ ## Build
227
+
228
+ ```bash
229
+ pnpm build # tsup → dist/index.js + dist/web-*.js (code-split)
230
+ pnpm clean # rm -rf dist/
231
+ ```
@@ -0,0 +1,187 @@
1
+ import {
2
+ Header
3
+ } from "./chunk-BIK4EL4H.js";
4
+
5
+ // src/components/DiffApp.tsx
6
+ import React, { useState, useEffect } from "react";
7
+ import { Box, Text, useApp } from "ink";
8
+ import Spinner from "ink-spinner";
9
+
10
+ // src/commands/diff.ts
11
+ import { execFileSync } from "child_process";
12
+ function loadBaseReport(projectPath, branch) {
13
+ try {
14
+ const output = execFileSync(
15
+ "git",
16
+ ["show", `${branch}:.sickbay/last-report.json`],
17
+ { cwd: projectPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
18
+ );
19
+ return JSON.parse(output);
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ var STATUS_ORDER = {
25
+ regressed: 0,
26
+ improved: 1,
27
+ new: 2,
28
+ removed: 3,
29
+ unchanged: 4
30
+ };
31
+ function compareReports(current, base, branch) {
32
+ const baseMap = new Map(base.checks.map((c) => [c.id, c]));
33
+ const currentMap = new Map(current.checks.map((c) => [c.id, c]));
34
+ const checks = [];
35
+ for (const check of current.checks) {
36
+ const baseCheck = baseMap.get(check.id);
37
+ if (!baseCheck) {
38
+ checks.push({
39
+ id: check.id,
40
+ name: check.name,
41
+ category: check.category,
42
+ currentScore: check.score,
43
+ baseScore: 0,
44
+ delta: check.score,
45
+ status: "new"
46
+ });
47
+ } else {
48
+ const delta = check.score - baseCheck.score;
49
+ checks.push({
50
+ id: check.id,
51
+ name: check.name,
52
+ category: check.category,
53
+ currentScore: check.score,
54
+ baseScore: baseCheck.score,
55
+ delta,
56
+ status: delta > 0 ? "improved" : delta < 0 ? "regressed" : "unchanged"
57
+ });
58
+ }
59
+ }
60
+ for (const check of base.checks) {
61
+ if (!currentMap.has(check.id)) {
62
+ checks.push({
63
+ id: check.id,
64
+ name: check.name,
65
+ category: check.category,
66
+ currentScore: 0,
67
+ baseScore: check.score,
68
+ delta: -check.score,
69
+ status: "removed"
70
+ });
71
+ }
72
+ }
73
+ checks.sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]);
74
+ const summary = {
75
+ improved: checks.filter((c) => c.status === "improved").length,
76
+ regressed: checks.filter((c) => c.status === "regressed").length,
77
+ unchanged: checks.filter((c) => c.status === "unchanged").length,
78
+ newChecks: checks.filter((c) => c.status === "new").length,
79
+ removedChecks: checks.filter((c) => c.status === "removed").length
80
+ };
81
+ return {
82
+ branch,
83
+ currentScore: current.overallScore,
84
+ baseScore: base.overallScore,
85
+ scoreDelta: current.overallScore - base.overallScore,
86
+ checks,
87
+ summary
88
+ };
89
+ }
90
+
91
+ // src/components/DiffApp.tsx
92
+ var STATUS_ICONS = {
93
+ improved: "\u2191",
94
+ regressed: "\u2193",
95
+ unchanged: "=",
96
+ new: "+",
97
+ removed: "\u2212"
98
+ };
99
+ var STATUS_COLORS = {
100
+ improved: "green",
101
+ regressed: "red",
102
+ unchanged: "gray",
103
+ new: "cyan",
104
+ removed: "yellow"
105
+ };
106
+ function formatDelta(delta) {
107
+ if (delta > 0) return `+${delta}`;
108
+ if (delta < 0) return `${delta}`;
109
+ return "0";
110
+ }
111
+ function DiffTable({ diff }) {
112
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { bold: true }, "Overall: "), /* @__PURE__ */ React.createElement(Text, { bold: true }, diff.baseScore), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, " \u2192 "), /* @__PURE__ */ React.createElement(Text, { bold: true }, diff.currentScore), /* @__PURE__ */ React.createElement(Text, null, " "), /* @__PURE__ */ React.createElement(
113
+ Text,
114
+ {
115
+ color: diff.scoreDelta > 0 ? "green" : diff.scoreDelta < 0 ? "red" : "gray",
116
+ bold: true
117
+ },
118
+ "(",
119
+ formatDelta(diff.scoreDelta),
120
+ ")"
121
+ )), /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2 }, /* @__PURE__ */ React.createElement(Box, null, /* @__PURE__ */ React.createElement(Text, { bold: true }, " Check".padEnd(30)), /* @__PURE__ */ React.createElement(Text, { bold: true }, "Current".padEnd(10)), /* @__PURE__ */ React.createElement(Text, { bold: true }, "Base".padEnd(10)), /* @__PURE__ */ React.createElement(Text, { bold: true }, "Delta")), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "\u2501".repeat(56)), diff.checks.map((check) => /* @__PURE__ */ React.createElement(Box, { key: check.id }, /* @__PURE__ */ React.createElement(Text, { color: STATUS_COLORS[check.status] }, STATUS_ICONS[check.status], " "), /* @__PURE__ */ React.createElement(Text, null, check.name.padEnd(28)), /* @__PURE__ */ React.createElement(Text, null, String(check.currentScore || "\u2014").padEnd(10)), /* @__PURE__ */ React.createElement(Text, null, String(check.baseScore || "\u2014").padEnd(10)), /* @__PURE__ */ React.createElement(Text, { color: STATUS_COLORS[check.status], bold: true }, check.status === "new" ? "new" : check.status === "removed" ? "removed" : formatDelta(check.delta))))), /* @__PURE__ */ React.createElement(Box, { marginTop: 1, marginLeft: 2 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, diff.summary.improved > 0 && /* @__PURE__ */ React.createElement(Text, { color: "green" }, diff.summary.improved, " improved"), diff.summary.improved > 0 && diff.summary.regressed > 0 && /* @__PURE__ */ React.createElement(Text, null, ", "), diff.summary.regressed > 0 && /* @__PURE__ */ React.createElement(Text, { color: "red" }, diff.summary.regressed, " regressed"), (diff.summary.improved > 0 || diff.summary.regressed > 0) && diff.summary.unchanged > 0 && /* @__PURE__ */ React.createElement(Text, null, ", "), diff.summary.unchanged > 0 && /* @__PURE__ */ React.createElement(Text, null, diff.summary.unchanged, " unchanged"), diff.summary.newChecks > 0 && /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, ", ", diff.summary.newChecks, " new"), diff.summary.removedChecks > 0 && /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, ", ", diff.summary.removedChecks, " removed"))));
122
+ }
123
+ function DiffApp({
124
+ projectPath,
125
+ branch,
126
+ jsonOutput,
127
+ checks,
128
+ verbose
129
+ }) {
130
+ const { exit } = useApp();
131
+ const [phase, setPhase] = useState("scanning");
132
+ const [diff, setDiff] = useState(null);
133
+ const [error, setError] = useState(null);
134
+ useEffect(() => {
135
+ (async () => {
136
+ try {
137
+ const { runSickbay } = await import("@nebulord/sickbay-core");
138
+ const currentReport = await runSickbay({
139
+ projectPath,
140
+ checks,
141
+ verbose
142
+ });
143
+ try {
144
+ const { saveEntry, saveLastReport } = await import("./history-DYFJ65XH.js");
145
+ saveEntry(currentReport);
146
+ saveLastReport(currentReport);
147
+ } catch {
148
+ }
149
+ setPhase("loading-base");
150
+ const baseReport = loadBaseReport(projectPath, branch);
151
+ if (!baseReport) {
152
+ setError(
153
+ `No saved report found on "${branch}". Run \`sickbay\` on that branch and commit .sickbay/last-report.json so it can be read via git.`
154
+ );
155
+ setPhase("error");
156
+ setTimeout(() => exit(), 100);
157
+ return;
158
+ }
159
+ const result = compareReports(currentReport, baseReport, branch);
160
+ setDiff(result);
161
+ setPhase("results");
162
+ if (jsonOutput) {
163
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
164
+ }
165
+ setTimeout(() => exit(), 100);
166
+ } catch (err) {
167
+ setError(err instanceof Error ? err.message : String(err));
168
+ setPhase("error");
169
+ setTimeout(() => exit(), 100);
170
+ }
171
+ })();
172
+ }, []);
173
+ if (phase === "scanning") {
174
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React.createElement(Header, null), /* @__PURE__ */ React.createElement(Text, null, /* @__PURE__ */ React.createElement(Text, { color: "green" }, /* @__PURE__ */ React.createElement(Spinner, { type: "dots" })), " ", "Scanning current branch..."));
175
+ }
176
+ if (phase === "loading-base") {
177
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React.createElement(Header, null), /* @__PURE__ */ React.createElement(Text, null, /* @__PURE__ */ React.createElement(Text, { color: "green" }, /* @__PURE__ */ React.createElement(Spinner, { type: "dots" })), " ", "Loading ", branch, " baseline..."));
178
+ }
179
+ if (phase === "error") {
180
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React.createElement(Header, null), /* @__PURE__ */ React.createElement(Text, { color: "red" }, "\u2717 ", error));
181
+ }
182
+ if (jsonOutput || !diff) return null;
183
+ return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React.createElement(Header, null), /* @__PURE__ */ React.createElement(Text, { bold: true }, "Branch Diff: current vs ", branch), /* @__PURE__ */ React.createElement(DiffTable, { diff }));
184
+ }
185
+ export {
186
+ DiffApp
187
+ };