From 22d042423204d01be6f7a1498e3f710c062c44e9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 31 May 2026 01:47:39 +0000 Subject: [PATCH] Add initial distributor CLI skeleton --- README.md | 10 ++++- cmd/distributor/main.go | 12 ++++++ docs/cli.md | 33 +++++++++++++++ go.mod | 3 ++ internal/app/app.go | 14 +++++++ internal/app/app_test.go | 16 +++++++ internal/app/inspect.go | 14 +++++++ internal/app/pipeline.go | 5 +++ internal/app/run.go | 12 ++++++ internal/app/validate.go | 14 +++++++ internal/cli/inspect.go | 36 ++++++++++++++++ internal/cli/root.go | 84 +++++++++++++++++++++++++++++++++++++ internal/cli/root_test.go | 75 +++++++++++++++++++++++++++++++++ internal/cli/run.go | 32 ++++++++++++++ internal/cli/validate.go | 36 ++++++++++++++++ internal/cli/version.go | 29 +++++++++++++ internal/config/config.go | 3 ++ internal/config/defaults.go | 3 ++ internal/logging/logging.go | 7 ++++ 19 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 cmd/distributor/main.go create mode 100644 docs/cli.md create mode 100644 go.mod create mode 100644 internal/app/app.go create mode 100644 internal/app/app_test.go create mode 100644 internal/app/inspect.go create mode 100644 internal/app/pipeline.go create mode 100644 internal/app/run.go create mode 100644 internal/app/validate.go create mode 100644 internal/cli/inspect.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go create mode 100644 internal/cli/run.go create mode 100644 internal/cli/validate.go create mode 100644 internal/cli/version.go create mode 100644 internal/config/config.go create mode 100644 internal/config/defaults.go create mode 100644 internal/logging/logging.go diff --git a/README.md b/README.md index ee53587..8968ed2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # distributor -`distributor` is a planned Go application for validating and publishing manifested Markdown bundles. +`distributor` is a Go application shell for validating and publishing manifested report bundles. -Implementation has not started yet. Current design and implementation planning lives under `docs/roadmap/`. +Current implemented behavior is limited to CLI help, version output, and placeholder operational commands: + +```sh +go run ./cmd/distributor --help +``` + +See [docs/cli.md](docs/cli.md) for the implemented CLI surface. Current design and implementation planning lives under `docs/roadmap/`. diff --git a/cmd/distributor/main.go b/cmd/distributor/main.go new file mode 100644 index 0000000..c772cef --- /dev/null +++ b/cmd/distributor/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "context" + "os" + + "gitea.maximumdirect.net/eric/distributor/internal/cli" +) + +func main() { + os.Exit(cli.Execute(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..f20ec1b --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,33 @@ +# Distributor CLI + +## Shortest useful command + +```sh +go run ./cmd/distributor --help +``` + +This prints the currently implemented command shell. + +## Command overview + +```sh +distributor --help +distributor version +distributor run +distributor validate +distributor inspect +``` + +`version` prints the application name and version. The default development version is `dev`; release builds may replace it at build time. + +`run`, `validate`, and `inspect` are command placeholders. They intentionally fail with a clear `not implemented` error until the corresponding application behavior exists. + +## Flag reference + +The root command supports: + +- `--help`, `-h`: print root help. + +Each implemented subcommand supports: + +- `--help`, `-h`: print command-specific help. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..47963af --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.maximumdirect.net/eric/distributor + +go 1.26 diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..2c4000c --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,14 @@ +package app + +import "errors" + +const Name = "distributor" + +// Version can be replaced at build time with -ldflags "-X .../internal/app.Version=". +var Version = "dev" + +var ErrNotImplemented = errors.New("not implemented") + +func VersionString() string { + return Name + " " + Version +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..aa8d2ed --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,16 @@ +package app + +import "testing" + +func TestVersionString(t *testing.T) { + oldVersion := Version + t.Cleanup(func() { + Version = oldVersion + }) + + Version = "1.2.3" + + if got, want := VersionString(), "distributor 1.2.3"; got != want { + t.Fatalf("VersionString() = %q, want %q", got, want) + } +} diff --git a/internal/app/inspect.go b/internal/app/inspect.go new file mode 100644 index 0000000..d9210bf --- /dev/null +++ b/internal/app/inspect.go @@ -0,0 +1,14 @@ +package app + +import ( + "context" + "fmt" +) + +type InspectOptions struct { + Path string +} + +func Inspect(context.Context, InspectOptions) error { + return fmt.Errorf("inspect command: %w", ErrNotImplemented) +} diff --git a/internal/app/pipeline.go b/internal/app/pipeline.go new file mode 100644 index 0000000..60d396b --- /dev/null +++ b/internal/app/pipeline.go @@ -0,0 +1,5 @@ +package app + +type Pipeline struct { + ID string +} diff --git a/internal/app/run.go b/internal/app/run.go new file mode 100644 index 0000000..a409407 --- /dev/null +++ b/internal/app/run.go @@ -0,0 +1,12 @@ +package app + +import ( + "context" + "fmt" +) + +type RunOptions struct{} + +func Run(context.Context, RunOptions) error { + return fmt.Errorf("run command: %w", ErrNotImplemented) +} diff --git a/internal/app/validate.go b/internal/app/validate.go new file mode 100644 index 0000000..68e514d --- /dev/null +++ b/internal/app/validate.go @@ -0,0 +1,14 @@ +package app + +import ( + "context" + "fmt" +) + +type ValidateOptions struct { + Path string +} + +func Validate(context.Context, ValidateOptions) error { + return fmt.Errorf("validate command: %w", ErrNotImplemented) +} diff --git a/internal/cli/inspect.go b/internal/cli/inspect.go new file mode 100644 index 0000000..9a103ac --- /dev/null +++ b/internal/cli/inspect.go @@ -0,0 +1,36 @@ +package cli + +import ( + "context" + "fmt" + "io" + + "gitea.maximumdirect.net/eric/distributor/internal/app" +) + +func inspectCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if hasHelp(args) { + printInspectHelp(stdout) + return exitOK + } + if len(args) > 1 { + fmt.Fprintf(stderr, "%s: inspect accepts at most one path\n", app.Name) + return exitUsage + } + var path string + if len(args) == 1 { + path = args[0] + } + if err := app.Inspect(ctx, app.InspectOptions{Path: path}); err != nil { + return fail(stderr, err) + } + return exitOK +} + +func printInspectHelp(w io.Writer) { + fmt.Fprint(w, `Usage: + distributor inspect + +The inspect command is present but bundle inspection is not implemented yet. +`) +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..8dc4fca --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,84 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "gitea.maximumdirect.net/eric/distributor/internal/app" +) + +const ( + exitOK = 0 + exitError = 1 + exitUsage = 2 +) + +func Execute(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printRootHelp(stdout) + return exitOK + } + + switch args[0] { + case "-h", "--help", "help": + printRootHelp(stdout) + return exitOK + case "version": + return versionCommand(ctx, args[1:], stdout, stderr) + case "run": + return runCommand(ctx, args[1:], stdout, stderr) + case "validate": + return validateCommand(ctx, args[1:], stdout, stderr) + case "inspect": + return inspectCommand(ctx, args[1:], stdout, stderr) + default: + fmt.Fprintf(stderr, "%s: unknown command %q\n\n", app.Name, args[0]) + printRootHelp(stderr) + return exitUsage + } +} + +func printRootHelp(w io.Writer) { + fmt.Fprintf(w, `%s validates and publishes manifested report bundles. + +Usage: + %s [options] + +Commands: + version Print version information + run Run configured distribution pipelines + validate Validate a source bundle or bundle tree + inspect Inspect bundles or distributor state + +Use "%s --help" for command-specific help. +`, app.Name, app.Name, app.Name) +} + +func hasHelp(args []string) bool { + for _, arg := range args { + if arg == "-h" || arg == "--help" { + return true + } + } + return false +} + +func fail(stderr io.Writer, err error) int { + if errors.Is(err, app.ErrNotImplemented) { + fmt.Fprintf(stderr, "%s: %s\n", app.Name, err) + return exitError + } + fmt.Fprintf(stderr, "%s: %s\n", app.Name, err) + return exitError +} + +func rejectExtraArgs(stderr io.Writer, command string, args []string) bool { + if len(args) == 0 { + return false + } + fmt.Fprintf(stderr, "%s: %s does not accept arguments: %s\n", app.Name, command, strings.Join(args, " ")) + return true +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..bee5103 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestExecuteRootHelp(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Execute(context.Background(), []string{"--help"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("exit code = %d, want %d", code, exitOK) + } + if !strings.Contains(stdout.String(), "Usage:") { + t.Fatalf("stdout = %q, want help text", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestExecuteVersion(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Execute(context.Background(), []string{"version"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("exit code = %d, want %d", code, exitOK) + } + if got, want := stdout.String(), "distributor dev\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestPlaceholderCommandsFailClearly(t *testing.T) { + tests := []string{"run", "validate", "inspect"} + + for _, command := range tests { + t.Run(command, func(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Execute(context.Background(), []string{command}, &stdout, &stderr) + + if code != exitError { + t.Fatalf("exit code = %d, want %d", code, exitError) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "not implemented") { + t.Fatalf("stderr = %q, want not implemented error", stderr.String()) + } + }) + } +} + +func TestUnknownCommandIsUsageError(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := Execute(context.Background(), []string{"nope"}, &stdout, &stderr) + + if code != exitUsage { + t.Fatalf("exit code = %d, want %d", code, exitUsage) + } + if !strings.Contains(stderr.String(), "unknown command") { + t.Fatalf("stderr = %q, want unknown command error", stderr.String()) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..45a2643 --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,32 @@ +package cli + +import ( + "context" + "fmt" + "io" + + "gitea.maximumdirect.net/eric/distributor/internal/app" +) + +func runCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if hasHelp(args) { + printRunHelp(stdout) + return exitOK + } + if len(args) > 0 { + fmt.Fprintf(stderr, "%s: run does not accept options yet: %v\n", app.Name, args) + return exitUsage + } + if err := app.Run(ctx, app.RunOptions{}); err != nil { + return fail(stderr, err) + } + return exitOK +} + +func printRunHelp(w io.Writer) { + fmt.Fprint(w, `Usage: + distributor run + +The run command is present but distribution behavior is not implemented yet. +`) +} diff --git a/internal/cli/validate.go b/internal/cli/validate.go new file mode 100644 index 0000000..8eab57b --- /dev/null +++ b/internal/cli/validate.go @@ -0,0 +1,36 @@ +package cli + +import ( + "context" + "fmt" + "io" + + "gitea.maximumdirect.net/eric/distributor/internal/app" +) + +func validateCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if hasHelp(args) { + printValidateHelp(stdout) + return exitOK + } + if len(args) > 1 { + fmt.Fprintf(stderr, "%s: validate accepts at most one path\n", app.Name) + return exitUsage + } + var path string + if len(args) == 1 { + path = args[0] + } + if err := app.Validate(ctx, app.ValidateOptions{Path: path}); err != nil { + return fail(stderr, err) + } + return exitOK +} + +func printValidateHelp(w io.Writer) { + fmt.Fprint(w, `Usage: + distributor validate + +The validate command is present but bundle validation is not implemented yet. +`) +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..ef40117 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,29 @@ +package cli + +import ( + "context" + "fmt" + "io" + + "gitea.maximumdirect.net/eric/distributor/internal/app" +) + +func versionCommand(_ context.Context, args []string, stdout, stderr io.Writer) int { + if hasHelp(args) { + printVersionHelp(stdout) + return exitOK + } + if rejectExtraArgs(stderr, "version", args) { + return exitUsage + } + fmt.Fprintln(stdout, app.VersionString()) + return exitOK +} + +func printVersionHelp(w io.Writer) { + fmt.Fprint(w, `Usage: + distributor version + +Print version information. +`) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..90b9d4a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,3 @@ +package config + +type Config struct{} diff --git a/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 0000000..9f3e1d6 --- /dev/null +++ b/internal/config/defaults.go @@ -0,0 +1,3 @@ +package config + +const DefaultConfigPath = "/usr/local/etc/distributor/config.yml" diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..62b9f91 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,7 @@ +package logging + +import "io" + +func Configure(io.Writer) error { + return nil +}