Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 22 additions & 17 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package cmd

import (
"errors"
"fmt"
"os"
"sort"

"github.com/oxgrad/knot/internal/config"
"github.com/spf13/cobra"
Expand All @@ -13,6 +15,12 @@ var (
dryRun bool
)

// ExitError signals a specific OS exit code to Execute without triggering
// Cobra's error printer — the command is responsible for its own output.
type ExitError struct{ Code int }

func (e *ExitError) Error() string { return "" }

var rootCmd = &cobra.Command{
Use: "knot",
Short: "A lightweight, configurable dotfiles manager",
Expand All @@ -23,14 +31,20 @@ based on your package definitions.`,

// Execute runs the root command.
func Execute() {
rootCmd.SilenceErrors = true // we handle printing to avoid Cobra's double-print
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
var ee *ExitError
if errors.As(err, &ee) {
os.Exit(ee.Code)
}
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}

func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "path to Knotfile (default: auto-discover)")
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
`path to Knotfile (default: $HOME/.dotfiles/Knotfile, override via KNOT_DIR)`)
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "print actions without executing them")
}

Expand All @@ -41,36 +55,27 @@ func loadConfig() (*config.Config, string, error) {
return cfg, cfgFile, err
}

cwd, err := os.Getwd()
home, err := os.UserHomeDir()
if err != nil {
return nil, "", fmt.Errorf("getting working directory: %w", err)
}

path, err := config.FindConfigFile(cwd)
if err != nil {
return nil, "", err
return nil, "", fmt.Errorf("getting home dir: %w", err)
}

path := config.DefaultKnotfilePath(home)
cfg, err := config.Load(path)
return cfg, path, err
}

// resolvePackageArgs returns the list of packages to operate on.
// If all is true, returns all package names from cfg.
// Otherwise validates and returns the provided args.
// If all is true, returns all package names from cfg sorted alphabetically.
// Otherwise returns args as-is; the linker validates individual names.
func resolvePackageArgs(args []string, all bool, cfg *config.Config) ([]string, error) {
if all {
names := make([]string, 0, len(cfg.Packages))
for name := range cfg.Packages {
names = append(names, name)
}
sort.Strings(names)
return names, nil
}

for _, name := range args {
if _, ok := cfg.Packages[name]; !ok {
return nil, fmt.Errorf("unknown package %q", name)
}
}
return args, nil
}
110 changes: 55 additions & 55 deletions cmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"sort"

"github.com/oxgrad/knot/internal/config"
"github.com/oxgrad/knot/internal/resolver"
"github.com/spf13/cobra"
)
Expand All @@ -25,62 +26,13 @@ Exit codes:
RunE: func(cmd *cobra.Command, args []string) error {
cfg, path, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading Knotfile: %v\n", err)
os.Exit(1)
return err
}

fmt.Printf("Validating Knotfile: %s\n\n", path)

var errs, warns []string

knownOS := map[string]bool{
"darwin": true, "linux": true, "windows": true, "freebsd": true,
}

if len(cfg.Packages) == 0 {
warns = append(warns, "no packages defined")
}

// Sort package names for deterministic output.
names := make([]string, 0, len(cfg.Packages))
for n := range cfg.Packages {
names = append(names, n)
}
sort.Strings(names)

home, _ := os.UserHomeDir()
fmt.Printf("Validating Knotfile: %s\n\n", path)

for _, name := range names {
pkg := cfg.Packages[name]

if pkg.Source == "" {
errs = append(errs, fmt.Sprintf(`[%s]: "source" is required`, name))
} else {
expanded := resolver.ExpandPath(pkg.Source, home)
info, statErr := os.Stat(expanded)
if statErr != nil {
errs = append(errs, fmt.Sprintf("[%s]: source directory %q does not exist", name, expanded))
} else if !info.IsDir() {
errs = append(errs, fmt.Sprintf("[%s]: source %q is not a directory", name, expanded))
}
}

if pkg.Target == "" {
errs = append(errs, fmt.Sprintf(`[%s]: "target" is required`, name))
}

if pkg.Condition != nil && pkg.Condition.OS != "" && !knownOS[pkg.Condition.OS] {
errs = append(errs, fmt.Sprintf(
"[%s]: unknown condition.os value %q (must be one of: darwin, linux, windows, freebsd)",
name, pkg.Condition.OS))
}

for _, pattern := range pkg.Ignore {
if _, matchErr := resolver.ShouldIgnore("test", []string{pattern}); matchErr != nil {
errs = append(errs, fmt.Sprintf("[%s]: invalid ignore pattern %q: %v", name, pattern, matchErr))
}
}
}
errs, warns := runValidation(cfg, home)

for _, e := range errs {
fmt.Printf(" ERROR %s\n", e)
Expand All @@ -93,17 +45,65 @@ Exit codes:
switch {
case len(errs) > 0:
fmt.Printf("Validation failed: %d error(s), %d warning(s)\n", len(errs), len(warns))
os.Exit(1)
return &ExitError{Code: 1}
case len(warns) > 0:
fmt.Printf("Validation passed with %d warning(s).\n", len(warns))
os.Exit(2)
return &ExitError{Code: 2}
default:
fmt.Printf(" OK: %d package(s) valid\n\nValidation passed.\n", len(cfg.Packages))
return nil
}
return nil
},
}

// runValidation checks cfg for errors and warnings, returning them sorted.
func runValidation(cfg *config.Config, home string) (errs, warns []string) {
if len(cfg.Packages) == 0 {
warns = append(warns, "no packages defined")
return
}

names := make([]string, 0, len(cfg.Packages))
for n := range cfg.Packages {
names = append(names, n)
}
sort.Strings(names)

for _, name := range names {
pkg := cfg.Packages[name]

if pkg.Source == "" {
errs = append(errs, fmt.Sprintf(`[%s]: "source" is required`, name))
} else {
expanded := resolver.ExpandPath(pkg.Source, home)
info, statErr := os.Stat(expanded)
if statErr != nil {
errs = append(errs, fmt.Sprintf("[%s]: source directory %q does not exist", name, expanded))
} else if !info.IsDir() {
errs = append(errs, fmt.Sprintf("[%s]: source %q is not a directory", name, expanded))
}
}

if pkg.Target == "" {
errs = append(errs, fmt.Sprintf(`[%s]: "target" is required`, name))
}

if pkg.Condition != nil && pkg.Condition.OS != "" && !config.KnownOS[pkg.Condition.OS] {
errs = append(errs, fmt.Sprintf(
"[%s]: unknown condition.os value %q (must be one of: darwin, linux, windows, freebsd)",
name, pkg.Condition.OS))
}

for _, pattern := range pkg.Ignore {
if _, matchErr := resolver.ShouldIgnore("test", []string{pattern}); matchErr != nil {
errs = append(errs, fmt.Sprintf("[%s]: invalid ignore pattern %q: %v", name, pattern, matchErr))
}
}
}
return
}

func init() {
validateCmd.SilenceUsage = true
rootCmd.AddCommand(validateCmd)
}
Loading
Loading