package cli import ( "context" "encoding/json" "flag" "fmt" "io" "os" "sort" "strings" "time" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" "gitea.maximumdirect.net/eric/notarius/internal/core/config" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) const defaultConfigPath = "/usr/local/etc/notarius/config.yml" const usage = `Usage: notarius help notarius config validate --config path/to/config.yml [--pipeline pipeline-id] [--only lane-a,lane-b] notarius pipelines list --config path/to/config.yml [--json] ` type Options struct { Catalog pipeline.ModuleCatalog Registries pipeline.Registries LLMClientFactory LLMClientFactory LookupEnv func(string) (string, bool) Now func() time.Time } type LLMClientFactory func(ctx context.Context, cfg config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) // Run executes the command-line interface and returns a process exit code. func Run(args []string, stdout, stderr io.Writer) int { return RunWithOptions(args, stdout, stderr, Options{}) } func RunWithOptions(args []string, stdout, stderr io.Writer, opts Options) int { opts = normalizeOptions(opts) if len(args) == 0 { writeUsage(stdout) return 0 } switch args[0] { case "help", "--help", "-h": writeUsage(stdout) return 0 case "config": return runConfig(args[1:], stdout, stderr, opts) case "pipelines": return runPipelines(args[1:], stdout, stderr, opts) default: fmt.Fprintf(stderr, "notarius: unknown command %q\n", args[0]) writeUsage(stderr) return 2 } } func writeUsage(w io.Writer) { fmt.Fprint(w, usage) } func normalizeOptions(opts Options) Options { if opts.LookupEnv == nil { opts.LookupEnv = os.LookupEnv } if opts.Now == nil { opts.Now = time.Now } if opts.LLMClientFactory == nil { opts.LLMClientFactory = productionLLMClientFactory } return opts } func runConfig(args []string, stdout, stderr io.Writer, opts Options) int { if len(args) == 0 { fmt.Fprintln(stderr, "notarius: config requires a subcommand") writeUsage(stderr) return 2 } switch args[0] { case "validate": return runConfigValidate(args[1:], stdout, stderr, opts) default: fmt.Fprintf(stderr, "notarius: unknown config subcommand %q\n", args[0]) writeUsage(stderr) return 2 } } func runConfigValidate(args []string, stdout, stderr io.Writer, opts Options) int { fs := flag.NewFlagSet("config validate", flag.ContinueOnError) fs.SetOutput(io.Discard) configPath := fs.String("config", "", "config file path") pipelineID := fs.String("pipeline", "", "pipeline ID") onlyRaw := fs.String("only", "", "comma-separated artifact lanes") if err := fs.Parse(args); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } if fs.NArg() != 0 { fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0)) return 2 } if strings.TrimSpace(*onlyRaw) != "" && strings.TrimSpace(*pipelineID) == "" { fmt.Fprintln(stderr, "notarius: --only requires --pipeline") return 2 } only, err := parseOnly(*onlyRaw) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } cfg, path, err := loadConfig(*configPath, opts) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } if strings.TrimSpace(*pipelineID) != "" { catalog, err := effectiveCatalog(opts) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } if _, err := cfg.Resolve(config.ResolveInput{ PipelineID: *pipelineID, Only: only, Catalog: catalog, }); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } fmt.Fprintf(stdout, "config %q is valid for pipeline %q\n", path, strings.TrimSpace(*pipelineID)) return 0 } if err := cfg.Validate(); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } fmt.Fprintf(stdout, "config %q is valid\n", path) return 0 } func runPipelines(args []string, stdout, stderr io.Writer, opts Options) int { if len(args) == 0 { fmt.Fprintln(stderr, "notarius: pipelines requires a subcommand") writeUsage(stderr) return 2 } switch args[0] { case "list": return runPipelinesList(args[1:], stdout, stderr, opts) default: fmt.Fprintf(stderr, "notarius: unknown pipelines subcommand %q\n", args[0]) writeUsage(stderr) return 2 } } func runPipelinesList(args []string, stdout, stderr io.Writer, opts Options) int { fs := flag.NewFlagSet("pipelines list", flag.ContinueOnError) fs.SetOutput(io.Discard) configPath := fs.String("config", "", "config file path") jsonOutput := fs.Bool("json", false, "write JSON output") if err := fs.Parse(args); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 2 } if fs.NArg() != 0 { fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0)) return 2 } cfg, _, err := loadConfig(*configPath, opts) if err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } if err := cfg.Validate(); err != nil { fmt.Fprintf(stderr, "notarius: %v\n", err) return 1 } ids := sortedPipelineIDs(cfg) if *jsonOutput { payload := struct { Pipelines []string `json:"pipelines"` }{Pipelines: ids} encoded, err := json.Marshal(payload) if err != nil { fmt.Fprintf(stderr, "notarius: marshal pipeline list: %v\n", err) return 1 } fmt.Fprintf(stdout, "%s\n", encoded) return 0 } for _, id := range ids { fmt.Fprintln(stdout, id) } return 0 } func loadConfig(configPath string, opts Options) (config.Config, string, error) { path, err := discoverConfigPath(configPath, opts) if err != nil { return config.Config{}, "", err } fileCfg, err := config.LoadFileConfig(path) if err != nil { return config.Config{}, "", err } cfg := config.Default() if err := cfg.ApplyFileConfigWithLookup(fileCfg, opts.LookupEnv); err != nil { return config.Config{}, "", err } if err := cfg.ApplyEnvOverridesWithLookup(opts.LookupEnv); err != nil { return config.Config{}, "", err } return cfg, path, nil } func discoverConfigPath(configPath string, opts Options) (string, error) { if path := strings.TrimSpace(configPath); path != "" { if err := requireConfigFile(path); err != nil { return "", err } return path, nil } if path, ok := opts.LookupEnv("NOTARIUS_CONFIG"); ok && strings.TrimSpace(path) != "" { path = strings.TrimSpace(path) if err := requireConfigFile(path); err != nil { return "", err } return path, nil } if _, err := os.Stat(defaultConfigPath); err == nil { return defaultConfigPath, nil } else if err != nil && !os.IsNotExist(err) { return "", fmt.Errorf("check default config %q: %w", defaultConfigPath, err) } return "", fmt.Errorf("config file not found; pass --config or set NOTARIUS_CONFIG") } func requireConfigFile(path string) error { info, err := os.Stat(path) if err != nil { return fmt.Errorf("config file %q is not available: %w", path, err) } if info.IsDir() { return fmt.Errorf("config file %q is a directory", path) } return nil } func parseOnly(raw string) ([]string, error) { if strings.TrimSpace(raw) == "" { return nil, nil } parts := strings.Split(raw, ",") result := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed == "" { return nil, fmt.Errorf("--only must contain comma-separated non-empty artifact lane IDs") } result = append(result, trimmed) } return result, nil } func sortedPipelineIDs(cfg config.Config) []string { ids := make([]string, 0, len(cfg.Pipelines)) for id := range cfg.Pipelines { ids = append(ids, strings.TrimSpace(id)) } sort.Strings(ids) return ids }