// Package cli owns command parsing, profile settings, and child-command // adaptation for the ReleaseKit executable. package cli import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" ) const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION" type Command string const ( VersionCommand Command = "version" CheckAction Command = "check" PublishAction Command = "publish" BuildAction Command = "build" DeliveryAction Command = "deliver" ) // Invocation contains parsed command settings owned by the CLI adapter. type Invocation struct { Command Command Repository string ConfigPath string OutputDir string AssetsDir string Version string } // Parse accepts only commands that have been implemented by the executable. func Parse(args []string) (Invocation, error) { if len(args) == 0 { return Invocation{}, errors.New("a command is required") } switch args[0] { case string(VersionCommand): if len(args) != 1 { return Invocation{}, errors.New("version takes no arguments") } return Invocation{Command: VersionCommand}, nil case string(CheckAction), string(PublishAction), string(BuildAction), string(DeliveryAction): action := Command(args[0]) flags := flag.NewFlagSet(string(action), flag.ContinueOnError) flags.SetOutput(io.Discard) repository := flags.String("repo", ".", "repository directory") configPath := flags.String("config", "", "profile path") var outputDir string if action == BuildAction { flags.StringVar(&outputDir, "output", "", "build output directory") } var assetsDir string if action == DeliveryAction { flags.StringVar(&assetsDir, "assets", "", "verified build assets directory") } if err := flags.Parse(args[1:]); err != nil { return Invocation{}, err } if flags.NArg() != 1 { return Invocation{}, fmt.Errorf("%s requires one version argument", action) } return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Version: flags.Arg(0)}, nil default: 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"` Build *BuildSettings `json:"build,omitempty"` Delivery *DeliverySettings `json:"delivery,omitempty"` } // DeliverySettings selects the hosted Gitea release destination. type DeliverySettings struct { BaseURL string `json:"base_url"` Owner string `json:"owner"` Repository string `json:"repository"` TokenEnv string `json:"token_env,omitempty"` } // BuildSettings selects the Go application package, artifact name, and targets. type BuildSettings struct { MainPackage string `json:"main_package,omitempty"` BinaryName string `json:"binary_name,omitempty"` Targets []string `json:"targets,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) } } if settings.Build != nil { if len(settings.Build.Targets) != 0 { if strings.TrimSpace(settings.Build.MainPackage) == "" { return Settings{}, fmt.Errorf("profile %q: build.main_package is required when targets are selected", configPath) } if strings.TrimSpace(settings.Build.BinaryName) == "" { return Settings{}, fmt.Errorf("profile %q: build.binary_name is required when targets are selected", configPath) } if strings.ContainsAny(settings.Build.BinaryName, "/\\\r\n") { return Settings{}, fmt.Errorf("profile %q: build.binary_name must be a filename component", configPath) } } for i, target := range settings.Build.Targets { if strings.Count(target, "/") != 1 { return Settings{}, fmt.Errorf("profile %q: build.targets[%d] must be GOOS/GOARCH", configPath, i) } goos, goarch, _ := strings.Cut(target, "/") if strings.TrimSpace(goos) == "" || strings.TrimSpace(goarch) == "" { return Settings{}, fmt.Errorf("profile %q: build.targets[%d] must be GOOS/GOARCH", configPath, i) } if strings.ContainsAny(goos+goarch, "\\\r\n") { return Settings{}, fmt.Errorf("profile %q: build.targets[%d] contains an unsafe filename component", 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, ownedEnv map[string]string, stdout, stderr io.Writer) error { for i, check := range checks { environment := make(map[string]string, len(check.Env)+len(ownedEnv)) for key, value := range check.Env { environment[key] = value } for key, value := range ownedEnv { environment[key] = value } if err := process.Run(ctx, process.Invocation{ Args: check.Args, Dir: repoDir, Env: environment, Stdout: stdout, Stderr: stderr, }); err != nil { return fmt.Errorf("run check %d: %w", i+1, err) } } return nil } // TemporaryBuildDir allocates command-owned storage outside its source checkout. func TemporaryBuildDir(repository string) (string, error) { return tempdir.Outside(repository, "releasekit-delivery-") }