// Package cli owns command parsing, profile settings, and child-command // adaptation for the ReleaseKit executable. package cli import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" ) const Usage = "usage: releasekit version" type Command string const VersionCommand Command = "version" // Invocation contains parsed command settings owned by the CLI adapter. type Invocation struct { Command Command } // Parse accepts only commands that have been implemented by the executable. func Parse(args []string) (Invocation, error) { if len(args) == 1 && args[0] == string(VersionCommand) { return Invocation{Command: VersionCommand}, nil } if len(args) == 0 { return Invocation{}, errors.New("a command is required") } return Invocation{}, fmt.Errorf("unknown command %q", args[0]) } // CheckCommand contains one direct child-process invocation from a profile. type CheckCommand struct { Args []string `json:"args"` Env map[string]string `json:"env,omitempty"` } // Settings contains the product, remote, and check commands read from a // releasekit.json profile. type Settings struct { Product string `json:"product"` Remote string `json:"remote,omitempty"` Checks []CheckCommand `json:"checks,omitempty"` } // LoadSettings reads a profile path relative to repoDir unless it is absolute. // An empty configPath selects /releasekit.json. func LoadSettings(repoDir, configPath string) (Settings, error) { resolvedRepo, err := filepath.Abs(repoDir) if err != nil { return Settings{}, fmt.Errorf("resolve repository directory %q: %w", repoDir, err) } if configPath == "" { configPath = "releasekit.json" } if !filepath.IsAbs(configPath) { configPath = filepath.Join(resolvedRepo, configPath) } file, err := os.Open(configPath) if err != nil { return Settings{}, fmt.Errorf("open profile %q: %w", configPath, err) } defer file.Close() var settings Settings decoder := json.NewDecoder(file) decoder.DisallowUnknownFields() if err := decoder.Decode(&settings); err != nil { return Settings{}, fmt.Errorf("decode profile %q: %w", configPath, err) } var extra any if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { if err == nil { return Settings{}, fmt.Errorf("decode profile %q: multiple JSON values", configPath) } return Settings{}, fmt.Errorf("decode profile %q: %w", configPath, err) } if strings.TrimSpace(settings.Product) == "" { return Settings{}, fmt.Errorf("profile %q: product is required", configPath) } if settings.Remote == "" { settings.Remote = "origin" } for i, check := range settings.Checks { if len(check.Args) == 0 || check.Args[0] == "" { return Settings{}, fmt.Errorf("profile %q: checks[%d].args must include a command", configPath, i) } } return settings, nil } // RunChecks runs configured argument vectors in repoDir. Each command receives // its own environment overlay and inherits the remaining process environment. func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, stdout, stderr io.Writer) error { for i, check := range checks { if err := process.Run(ctx, process.Invocation{ Args: check.Args, Dir: repoDir, Env: check.Env, Stdout: stdout, Stderr: stderr, }); err != nil { return fmt.Errorf("run check %d: %w", i+1, err) } } return nil }