From 6b9360518e76ada3b835da40f2f73b793edf848d Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 15 Oct 2026 18:07:38 +0000 Subject: [PATCH] cli: add 'komposer check' for scripts and CI komposer check [flags] [file] runs the editor's three validation layers without starting the TUI: Errors komposer's own semantic checks Warnings security / reliability advisories Compose specification the official JSON schema - file defaults to the first of compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml (Docker Compose's own order) - --strict makes warnings fail the run - --format json emits a stable machine-readable report (empty lists are [], never null) - exit codes: 0 ok, 1 problems found, 2 bad arguments / unreadable file - flags may come before or after the file name - text output is colored through lipgloss only on a color-capable terminal (NO_COLOR honored); piped output is plain Also adds 'komposer --help' / 'help' and documents the command in the README. The logic lives in internal/check so it is testable without the TUI; main.go only dispatches and supplies the styler. --- README.md | 27 +++ internal/check/check.go | 325 +++++++++++++++++++++++++++++++++++ internal/check/check_test.go | 227 ++++++++++++++++++++++++ main.go | 47 ++++- 4 files changed, 621 insertions(+), 5 deletions(-) create mode 100644 internal/check/check.go create mode 100644 internal/check/check_test.go diff --git a/README.md b/README.md index 2a8751b..dc559f3 100644 --- a/README.md +++ b/README.md @@ -232,12 +232,39 @@ with `go run .` instead, if you'd rather not produce a binary. | `q` / `Esc` | quit — if there are unsaved changes, opens the same save prompt with an explicit "quit without saving" option; quits immediately otherwise | | `Ctrl+C` | quit immediately, no prompt | +### Checking a file without the editor (`komposer check`) + +```sh +komposer check # compose.yaml / compose.yml / docker-compose.y(a)ml in the current directory +komposer check path/to/compose.yaml +komposer check --strict # warnings fail too +komposer check --format json # machine-readable +``` + +Three layers run, the same ones the editor's validation dialog shows: +**Errors** (komposer's own semantic checks), **Warnings** (security and +reliability advisories) and **Compose specification** (the official +JSON schema). + +| Exit code | Meaning | +|---|---| +| `0` | no errors and no specification issues (with `--strict`: and no warnings) | +| `1` | problems found | +| `2` | bad arguments, or the file could not be read | + +JSON output has the shape +`{"file", "strict", "passed", "errors": [...], "warnings": [...], "spec": {"valid", "issues": [...]}}`; +each finding is `{"service", "field", "message"}`. In a GitHub Actions +step, `komposer check --strict compose.yaml` is all it takes to gate a +pull request. + ## Project layout - `pkg/composer` — the domain model: services, presets, stacks, YAML import/export, and validation. No TUI dependencies — usable as a library on its own. - `internal/tui` — the Bubble Tea/Lipgloss terminal UI. +- `internal/check` — the non-interactive `komposer check` command. ## Development diff --git a/internal/check/check.go b/internal/check/check.go new file mode 100644 index 0000000..82a982f --- /dev/null +++ b/internal/check/check.go @@ -0,0 +1,325 @@ +// Package check implements `komposer check`: a non-interactive run of the +// same validation the editor performs, for scripts and CI. +package check + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/casablanque-code/komposer/pkg/composer" +) + +// Exit codes of `komposer check`. +const ( + ExitOK = 0 // nothing to report (warnings alone don't fail, unless --strict) + ExitFail = 1 // the file has problems + ExitUsage = 2 // bad arguments, or the file couldn't be read +) + +// defaultFiles are the names Docker Compose itself looks for, in its order. +var defaultFiles = []string{ + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", +} + +const usage = `Usage: komposer check [flags] [file] + +Validate a Compose file without starting the editor. Three layers run: + + Errors komposer's own semantic checks (missing image, bad ports, + unknown depends_on targets, ...) + Warnings security and reliability advisories (published ports, Docker + socket mounts, hardcoded secrets, mutable image tags, ...) + Compose specification + the official Compose Specification JSON schema + +If no file is given, the first of compose.yaml, compose.yml, +docker-compose.yaml, docker-compose.yml found in the current directory +is checked. + +Flags: + --strict also fail (exit 1) when there are warnings + --format output format: text (default) or json + -h, --help show this help + +Exit codes: + 0 no errors and no specification issues (and, with --strict, no warnings) + 1 problems found + 2 bad arguments, or the file could not be read + +Examples: + komposer check + komposer check --strict compose.prod.yaml + komposer check --format json | jq '.warnings[].message' +` + +// Finding is one error or warning from komposer's own checks. +type Finding struct { + Service string `json:"service,omitempty"` + Field string `json:"field"` + Message string `json:"message"` +} + +func (f Finding) String() string { + if f.Service != "" { + return fmt.Sprintf("service '%s': %s: %s", f.Service, f.Field, f.Message) + } + return fmt.Sprintf("%s: %s", f.Field, f.Message) +} + +// SpecReport is the result of validating against the official schema. +type SpecReport struct { + Valid bool `json:"valid"` + Issues []string `json:"issues"` +} + +// Report is everything `komposer check` found in one file. +type Report struct { + File string `json:"file"` + Strict bool `json:"strict"` + Passed bool `json:"passed"` + Errors []Finding `json:"errors"` + Warnings []Finding `json:"warnings"` + Spec SpecReport `json:"spec"` +} + +// Analyze runs all validation layers on the file at path. The error is +// only for problems reading the file; anything wrong with its content is +// reported in the Report. +func Analyze(path string, strict bool) (Report, error) { + data, err := os.ReadFile(path) + if err != nil { + return Report{}, err + } + + rep := Report{ + File: path, + Strict: strict, + Errors: []Finding{}, + Warnings: []Finding{}, + Spec: SpecReport{Valid: true, Issues: []string{}}, + } + + spec, err := composer.ValidateYAMLAgainstSpec(data) + if err != nil { + // Not parseable as YAML at all: nothing else can run. + rep.Spec = SpecReport{Valid: false, Issues: []string{err.Error()}} + rep.Passed = false + return rep, nil + } + rep.Spec.Valid = spec.Valid + if len(spec.Issues) > 0 { + rep.Spec.Issues = spec.Issues + } + + cfg, err := composer.ImportYAML(path) + if err != nil { + rep.Errors = append(rep.Errors, Finding{Field: "import", Message: err.Error()}) + } else { + res := cfg.Validate() + for _, e := range res.Errors { + rep.Errors = append(rep.Errors, Finding{Service: e.Service, Field: e.Field, Message: e.Message}) + } + for _, w := range res.Warnings { + rep.Warnings = append(rep.Warnings, Finding{Service: w.Service, Field: w.Field, Message: w.Message}) + } + } + + rep.Passed = len(rep.Errors) == 0 && rep.Spec.Valid && !(strict && len(rep.Warnings) > 0) + return rep, nil +} + +// Styler colors the text output. Each func takes plain text and returns +// it styled; PlainStyler leaves everything as is. +type Styler struct { + Error, Warn, OK, Dim, Bold func(string) string +} + +// PlainStyler returns a Styler that adds no styling. +func PlainStyler() Styler { + id := func(s string) string { return s } + return Styler{Error: id, Warn: id, OK: id, Dim: id, Bold: id} +} + +func (s Styler) withDefaults() Styler { + id := func(s string) string { return s } + for _, f := range []*func(string) string{&s.Error, &s.Warn, &s.OK, &s.Dim, &s.Bold} { + if *f == nil { + *f = id + } + } + return s +} + +type options struct { + file string + strict bool + format string + help bool +} + +func parseArgs(args []string) (options, error) { + opts := options{format: "text"} + positional := func(a string) error { + if opts.file != "" { + return fmt.Errorf("expected a single file, got %q and %q", opts.file, a) + } + opts.file = a + return nil + } + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "-h" || a == "--help": + opts.help = true + case a == "--strict": + opts.strict = true + case a == "--format": + if i+1 >= len(args) { + return opts, fmt.Errorf("--format needs a value (text or json)") + } + i++ + opts.format = args[i] + case strings.HasPrefix(a, "--format="): + opts.format = strings.TrimPrefix(a, "--format=") + case a == "--": + for _, rest := range args[i+1:] { + if err := positional(rest); err != nil { + return opts, err + } + } + i = len(args) + case strings.HasPrefix(a, "-") && a != "-": + return opts, fmt.Errorf("unknown flag %q", a) + default: + if err := positional(a); err != nil { + return opts, err + } + } + } + if opts.format != "text" && opts.format != "json" { + return opts, fmt.Errorf("unknown format %q (use text or json)", opts.format) + } + return opts, nil +} + +func findDefaultFile() (string, error) { + for _, name := range defaultFiles { + if st, err := os.Stat(name); err == nil && !st.IsDir() { + return name, nil + } + } + return "", fmt.Errorf("no compose file found in the current directory (looked for %s)", + strings.Join(defaultFiles, ", ")) +} + +// Run executes `komposer check` with the arguments after "check" and +// returns the process exit code. +func Run(args []string, stdout, stderr io.Writer, st Styler) int { + st = st.withDefaults() + + fail := func(err error) int { + fmt.Fprintf(stderr, "komposer check: %v\n\nRun 'komposer check --help' for usage.\n", err) + return ExitUsage + } + + opts, err := parseArgs(args) + if err != nil { + return fail(err) + } + if opts.help { + fmt.Fprint(stdout, usage) + return ExitOK + } + + path := opts.file + if path == "" { + if path, err = findDefaultFile(); err != nil { + return fail(err) + } + } + + rep, err := Analyze(path, opts.strict) + if err != nil { + return fail(fmt.Errorf("cannot read %s: %w", path, err)) + } + + if opts.format == "json" { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + if err := enc.Encode(rep); err != nil { + return fail(err) + } + } else { + render(stdout, rep, st) + } + + if rep.Passed { + return ExitOK + } + return ExitFail +} + +func plural(n int, word string) string { + if n == 1 { + return fmt.Sprintf("1 %s", word) + } + return fmt.Sprintf("%d %ss", n, word) +} + +func render(w io.Writer, rep Report, st Styler) { + fmt.Fprintf(w, "%s %s\n\n", st.Bold("komposer check"), rep.File) + + if len(rep.Errors) == 0 { + fmt.Fprintln(w, st.OK("✓ Errors: none")) + } else { + fmt.Fprintln(w, st.Error(fmt.Sprintf("✗ Errors (%d)", len(rep.Errors)))) + for _, f := range rep.Errors { + fmt.Fprintf(w, " %s\n", f) + } + } + + if len(rep.Warnings) == 0 { + fmt.Fprintln(w, st.OK("✓ Warnings: none")) + } else { + fmt.Fprintln(w, st.Warn(fmt.Sprintf("⚠ Warnings (%d)", len(rep.Warnings)))) + for _, f := range rep.Warnings { + fmt.Fprintf(w, " %s\n", f) + } + } + + if rep.Spec.Valid { + fmt.Fprintln(w, st.OK("✓ Compose specification: valid")) + } else { + fmt.Fprintln(w, st.Error(fmt.Sprintf("✗ Compose specification (%d): does not conform", len(rep.Spec.Issues)))) + for _, issue := range rep.Spec.Issues { + fmt.Fprintf(w, " %s\n", issue) + } + } + + fmt.Fprintln(w) + if rep.Passed { + line := "PASS" + if n := len(rep.Warnings); n > 0 { + line += " — " + plural(n, "warning") + " (use --strict to fail on warnings)" + } + fmt.Fprintln(w, st.OK(line)) + return + } + var parts []string + if n := len(rep.Errors); n > 0 { + parts = append(parts, plural(n, "error")) + } + if !rep.Spec.Valid { + parts = append(parts, plural(len(rep.Spec.Issues), "specification issue")) + } + if n := len(rep.Warnings); n > 0 && rep.Strict { + parts = append(parts, plural(n, "warning")+" (--strict)") + } + fmt.Fprintln(w, st.Error("FAIL — "+strings.Join(parts, ", "))) +} diff --git a/internal/check/check_test.go b/internal/check/check_test.go new file mode 100644 index 0000000..64373ef --- /dev/null +++ b/internal/check/check_test.go @@ -0,0 +1,227 @@ +package check + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + cleanYAML = `services: + web: + image: nginx:1.27 + ports: + - "127.0.0.1:8080:80" +` + warnYAML = `services: + web: + image: nginx:1.27 + ports: + - "8080:80" +` + errorYAML = `services: + web: + restart: always +` + specBadYAML = `services: + web: + image: nginx:1.27 +unknown-top-level-key: 1 +` +) + +func writeFile(t *testing.T, dir, name, content string) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + return p +} + +func run(t *testing.T, args ...string) (code int, stdout, stderr string) { + t.Helper() + var out, errb bytes.Buffer + code = Run(args, &out, &errb, PlainStyler()) + return code, out.String(), errb.String() +} + +func inDir(t *testing.T, dir string) { + t.Helper() + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) +} + +func TestCleanFilePasses(t *testing.T) { + p := writeFile(t, t.TempDir(), "compose.yaml", cleanYAML) + code, out, _ := run(t, p) + if code != ExitOK { + t.Fatalf("exit = %d, want 0\n%s", code, out) + } + for _, want := range []string{"✓ Errors: none", "✓ Warnings: none", "✓ Compose specification: valid", "PASS"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +func TestErrorsFail(t *testing.T) { + p := writeFile(t, t.TempDir(), "compose.yaml", errorYAML) + code, out, _ := run(t, p) + if code != ExitFail { + t.Fatalf("exit = %d, want 1\n%s", code, out) + } + if !strings.Contains(out, "✗ Errors (1)") || !strings.Contains(out, "FAIL — 1 error") { + t.Errorf("unexpected output:\n%s", out) + } +} + +func TestWarningsOnlyPassUnlessStrict(t *testing.T) { + p := writeFile(t, t.TempDir(), "compose.yaml", warnYAML) + + code, out, _ := run(t, p) + if code != ExitOK { + t.Fatalf("exit = %d, want 0 without --strict\n%s", code, out) + } + if !strings.Contains(out, "⚠ Warnings (1)") || !strings.Contains(out, "use --strict") { + t.Errorf("unexpected output:\n%s", out) + } + + for _, args := range [][]string{{"--strict", p}, {p, "--strict"}} { + code, out, _ = run(t, args...) + if code != ExitFail { + t.Fatalf("%v: exit = %d, want 1 with --strict\n%s", args, code, out) + } + if !strings.Contains(out, "FAIL — 1 warning (--strict)") { + t.Errorf("unexpected output:\n%s", out) + } + } +} + +func TestSpecViolationFails(t *testing.T) { + p := writeFile(t, t.TempDir(), "compose.yaml", specBadYAML) + code, out, _ := run(t, p) + if code != ExitFail { + t.Fatalf("exit = %d, want 1\n%s", code, out) + } + if !strings.Contains(out, "✗ Compose specification") { + t.Errorf("unexpected output:\n%s", out) + } +} + +func TestUnparseableYAMLFails(t *testing.T) { + p := writeFile(t, t.TempDir(), "compose.yaml", "services: [unclosed\n") + code, out, _ := run(t, p) + if code != ExitFail { + t.Fatalf("exit = %d, want 1\n%s", code, out) + } + if !strings.Contains(out, "✗ Compose specification") { + t.Errorf("unexpected output:\n%s", out) + } +} + +func TestJSONOutput(t *testing.T) { + dir := t.TempDir() + p := writeFile(t, dir, "compose.yaml", warnYAML) + + for _, args := range [][]string{{"--format", "json", p}, {"--format=json", p}, {p, "--format", "json"}} { + code, out, _ := run(t, args...) + if code != ExitOK { + t.Fatalf("%v: exit = %d", args, code) + } + var rep Report + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("%v: output is not JSON: %v\n%s", args, err, out) + } + if !rep.Passed || len(rep.Warnings) != 1 || rep.Warnings[0].Field != "ports" || rep.File != p { + t.Errorf("%v: unexpected report %+v", args, rep) + } + } + + // Empty lists must be [] (not null) so consumers can iterate them. + _, out, _ := run(t, "--format=json", writeFile(t, dir, "ok.yaml", cleanYAML)) + for _, want := range []string{`"errors": []`, `"warnings": []`, `"issues": []`} { + if !strings.Contains(out, want) { + t.Errorf("JSON missing %s:\n%s", want, out) + } + } + + // --strict is reflected in passed. + code, out, _ := run(t, "--format=json", "--strict", p) + if code != ExitFail || !strings.Contains(out, `"passed": false`) || !strings.Contains(out, `"strict": true`) { + t.Errorf("exit = %d, output:\n%s", code, out) + } +} + +func TestDefaultFileDiscovery(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "docker-compose.yml", errorYAML) + writeFile(t, dir, "compose.yaml", cleanYAML) // wins, as in Docker Compose + inDir(t, dir) + + code, out, _ := run(t) + if code != ExitOK || !strings.Contains(out, "compose.yaml") { + t.Fatalf("exit = %d, want compose.yaml to be picked and pass\n%s", code, out) + } +} + +func TestUsageErrors(t *testing.T) { + dir := t.TempDir() + ok := writeFile(t, dir, "compose.yaml", cleanYAML) + cases := map[string][]string{ + "unknown flag": {"--nope", ok}, + "two files": {ok, ok}, + "bad format": {"--format", "xml", ok}, + "format needs": {"--format"}, + "missing file": {filepath.Join(dir, "absent.yaml")}, + "directory arg": {dir}, + } + for name, args := range cases { + code, out, errOut := run(t, args...) + if code != ExitUsage { + t.Errorf("%s: exit = %d, want 2", name, code) + } + if out != "" { + t.Errorf("%s: stdout should be empty, got %q", name, out) + } + if !strings.Contains(errOut, "komposer check:") { + t.Errorf("%s: stderr = %q", name, errOut) + } + } + + inDir(t, t.TempDir()) + code, _, errOut := run(t) + if code != ExitUsage || !strings.Contains(errOut, "no compose file found") { + t.Errorf("no default file: exit = %d, stderr = %q", code, errOut) + } +} + +func TestHelp(t *testing.T) { + for _, flag := range []string{"-h", "--help"} { + code, out, _ := run(t, flag) + if code != ExitOK || !strings.Contains(out, "Usage: komposer check") || !strings.Contains(out, "--strict") { + t.Errorf("%s: exit = %d, output:\n%s", flag, code, out) + } + } +} + +func TestStylerIsApplied(t *testing.T) { + p := writeFile(t, t.TempDir(), "compose.yaml", errorYAML) + var out, errb bytes.Buffer + mark := func(tag string) func(string) string { + return func(s string) string { return "<" + tag + ">" + s + "" } + } + Run([]string{p}, &out, &errb, Styler{Error: mark("e"), OK: mark("o"), Warn: mark("w"), Bold: mark("b")}) + if !strings.Contains(out.String(), "✗ Errors (1)") || !strings.Contains(out.String(), "✓ Warnings: none") { + t.Errorf("styler not applied:\n%s", out.String()) + } +} diff --git a/main.go b/main.go index 88c4b74..6bca3c2 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,6 @@ -// Command komposer launches the terminal UI for building a -// docker-compose.yml file interactively. +// Command komposer is a Docker Compose editor and checker: with no +// arguments it launches the terminal UI; `komposer check` validates a +// Compose file non-interactively, for scripts and CI. package main import ( @@ -7,7 +8,9 @@ import ( "os" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/casablanque-code/komposer/internal/check" "github.com/casablanque-code/komposer/internal/tui" ) @@ -16,10 +19,27 @@ import ( // or `go run` from source keeps the "dev" placeholder. var version = "dev" +const usage = `komposer — Docker Compose editor and checker + +Usage: + komposer open the interactive editor + komposer check [file] validate a Compose file (komposer check --help) + komposer --version print the version + komposer --help show this help +` + func main() { - if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") { - fmt.Println("komposer", version) - return + if len(os.Args) > 1 { + switch os.Args[1] { + case "--version", "-v": + fmt.Println("komposer", version) + return + case "--help", "-h", "help": + fmt.Print(usage) + return + case "check": + os.Exit(check.Run(os.Args[2:], os.Stdout, os.Stderr, checkStyler())) + } } // WithMouseCellMotion enables mouse wheel scrolling — without it, @@ -33,3 +53,20 @@ func main() { os.Exit(1) } } + +// checkStyler colors `komposer check` output. The renderer detects +// whether stdout is a color-capable terminal (and honors NO_COLOR), so +// piped or redirected output stays plain. +func checkStyler() check.Styler { + r := lipgloss.NewRenderer(os.Stdout) + style := func(s lipgloss.Style) func(string) string { + return func(text string) string { return s.Render(text) } + } + return check.Styler{ + Error: style(r.NewStyle().Foreground(lipgloss.Color("9"))), + Warn: style(r.NewStyle().Foreground(lipgloss.Color("11"))), + OK: style(r.NewStyle().Foreground(lipgloss.Color("10"))), + Dim: style(r.NewStyle().Faint(true)), + Bold: style(r.NewStyle().Bold(true)), + } +}