diff --git a/README.md b/README.md index 955b6bd..da20c35 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ cd dotx go install ./cmd/dotx ``` -#### Setup your shell to use dotx +#### Set up your shell to use dotx ```bash eval "$(dotx init )" diff --git a/internal/cli/deploy.go b/internal/cli/deploy.go index 72226f3..e9214ee 100644 --- a/internal/cli/deploy.go +++ b/internal/cli/deploy.go @@ -85,4 +85,6 @@ func runDeploy(cfg *config.Config, force bool) { logger.Info("successfully deployed", "dotfile", source.Filename()) } + + cfg.Repo.ExecuteScripts(config.OnDeploy) } diff --git a/internal/cli/init.go b/internal/cli/init.go index afe69e8..eac21b3 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,14 +1,12 @@ package cli import ( - "path/filepath" "slices" "github.com/mxlang/dotx/internal/config" "github.com/mxlang/dotx/internal/fs" "github.com/mxlang/dotx/internal/git" "github.com/mxlang/dotx/internal/logger" - "github.com/mxlang/dotx/internal/script" "github.com/mxlang/dotx/internal/tui" "github.com/spf13/cobra" ) @@ -67,7 +65,7 @@ func runInit(cfg *config.Config, opts initOptions, url string) { logger.Info("successfully cloned remote dotfiles") } - runInitScripts(cfg) + cfg.Repo.ExecuteScripts(config.OnInit) if opts.deploy { logger.Debug("automatic deploy is active") @@ -107,20 +105,3 @@ func shouldCloneDotfiles(dir fs.Path, url string) bool { return false } - -func runInitScripts(cfg *config.Config) { - for _, scriptPath := range cfg.Repo.Scripts.Init { - fullPath := fs.NewPath(filepath.Join(cfg.RepoPath, scriptPath)) - if !fullPath.Exists() { - logger.Warn("script does not exist", "script", fullPath.AbsPath()) - continue - } - - logger.Info("execute script", "script", fullPath.AbsPath()) - if err := script.Run(fullPath.AbsPath()); err != nil { - logger.Warn(err) - } else { - logger.Debug("successfully executed script", "script", fullPath.AbsPath()) - } - } -} diff --git a/internal/cli/pull.go b/internal/cli/pull.go index 436708e..1f135c5 100644 --- a/internal/cli/pull.go +++ b/internal/cli/pull.go @@ -44,6 +44,8 @@ func runPull(cfg *config.Config, opts pullOptions) { logger.Info("successfully pulled from remote dotfiles") + cfg.Repo.ExecuteScripts(config.OnPull) + if opts.deploy { logger.Debug("automatic deploy is active") runDeploy(cfg, opts.force) diff --git a/internal/script/script.go b/internal/cmd/cmd.go similarity index 73% rename from internal/script/script.go rename to internal/cmd/cmd.go index 8b2229d..bb60e2d 100644 --- a/internal/script/script.go +++ b/internal/cmd/cmd.go @@ -1,12 +1,12 @@ -package script +package cmd import ( "os" "os/exec" ) -func Run(scriptPath string) error { - cmd := exec.Command("bash", scriptPath) +func Run(command string) error { + cmd := exec.Command(command) cmd.Stdin = os.Stdin // pass input from terminal/user cmd.Stdout = os.Stdout // print output to terminal diff --git a/internal/config/app.go b/internal/config/app.go index 3017a6f..287e0ff 100644 --- a/internal/config/app.go +++ b/internal/config/app.go @@ -1,8 +1,50 @@ package config +import ( + "errors" + "os" + + "github.com/goccy/go-yaml" + "github.com/mxlang/dotx/internal/fs" + "github.com/mxlang/dotx/internal/logger" +) + type appConfig struct { Verbose bool `yaml:"verbose"` CommitMessage string `yaml:"commitMessage"` DeployOnInit bool `yaml:"deployOnInit"` DeployOnPull bool `yaml:"deployOnPull"` } + +func defaultAppConfig() appConfig { + return appConfig{ + Verbose: false, + CommitMessage: "update dotfiles", + DeployOnPull: false, + DeployOnInit: false, + } +} + +func loadAppConfig() appConfig { + // Ensure the config directory exists + appDir := fs.NewPath(appDirPath()) + if err := fs.Mkdir(appDir); err != nil { + logger.Error("error while creating dotx config directory", "error", err) + } + + config := defaultAppConfig() + content, err := os.ReadFile(appConfigFilePath()) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Error("error while reading dotx config", "error", err) + } + + return config + } + + if err := yaml.Unmarshal(content, &config); err != nil { + logger.Error("invalid dotx config", "error", err) + } + + return config +} diff --git a/internal/config/config.go b/internal/config/config.go index 6c71eb0..05799e8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,14 +1,5 @@ package config -import ( - "errors" - "os" - - "github.com/goccy/go-yaml" - "github.com/mxlang/dotx/internal/fs" - "github.com/mxlang/dotx/internal/logger" -) - type Config struct { RepoPath string // TODO change type to fs.Path @@ -27,64 +18,3 @@ func Load() *Config { Repo: repo, } } - -func loadAppConfig() appConfig { - // Ensure the config directory exists - appDir := fs.NewPath(appDirPath()) - if err := fs.Mkdir(appDir); err != nil { - logger.Error("error while creating dotx config directory", "error", err) - } - - config := defaultAppConfig() - path := appConfigFilePath() - - content, err := os.ReadFile(path) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - logger.Warn("error while reading dotx config", "error", err) - } - - return config - } - - if err := yaml.Unmarshal(content, &config); err != nil { - logger.Warn("invalid dotx config", "error", err) - } - - return config -} - -func defaultAppConfig() appConfig { - return appConfig{ - Verbose: false, - CommitMessage: "update dotfiles", - DeployOnPull: false, - DeployOnInit: false, - } -} - -func loadRepoConfig() repoConfig { - // Ensure the dotfiles directory exists - repoDir := fs.NewPath(repoDirPath()) - if err := fs.Mkdir(repoDir); err != nil { - logger.Error("error while creating dotfiles directory", "error", err) - } - - config := repoConfig{} - path := repoConfigFilePath() - - content, err := os.ReadFile(path) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - logger.Warn("error while reading dotfiles config", "error", err) - } - - return config - } - - if err := yaml.Unmarshal(content, &config); err != nil { - logger.Warn("invalid dotfiles config", "error", err) - } - - return config -} diff --git a/internal/config/data.go b/internal/config/data.go new file mode 100644 index 0000000..547e90d --- /dev/null +++ b/internal/config/data.go @@ -0,0 +1,130 @@ +package config + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/goccy/go-yaml" + "github.com/mxlang/dotx/internal/logger" +) + +type executed struct { + Path string `yaml:"path"` // TODO change type to fs.Path + Hash string `yaml:"hash"` +} + +type dataConfig struct { + Scripts []executed `yaml:"scripts"` +} + +func (d *dataConfig) alreadyExecuted(s script) bool { + for _, exec := range d.Scripts { + if exec.Path == s.Path { + return true + } + } + + return false +} + +func (d *dataConfig) hashChanged(s script) bool { + for _, exec := range d.Scripts { + if exec.Path == s.Path { + hash, err := getHash(s) + if err != nil { + // TODO + } + + if exec.Hash != hash { + return true + } + } + } + + return false +} + +func (d *dataConfig) addScript(s script) error { + hash, err := getHash(s) + if err != nil { + return fmt.Errorf("unable to create hash: %w", err) + } + + exec := executed{ + Path: s.Path, + Hash: hash, + } + + d.Scripts = append(d.Scripts, exec) + + config, err := yaml.Marshal(d) + if err != nil { + return fmt.Errorf("unable to marshal data config: %w", err) + } + + if err := os.WriteFile(dataConfigFilePath(), config, 0644); err != nil { + return fmt.Errorf("unable to write data config: %w", err) + } + + return nil +} + +func (d *dataConfig) updateScript(s script) error { + hash, err := getHash(s) + if err != nil { + return fmt.Errorf("unable to create hash: %w", err) + } + + for i, exec := range d.Scripts { + if exec.Path == s.Path { + d.Scripts[i].Hash = hash + } + } + + config, err := yaml.Marshal(d) + if err != nil { + return fmt.Errorf("unable to marshal data config: %w", err) + } + + if err := os.WriteFile(dataConfigFilePath(), config, 0644); err != nil { + return fmt.Errorf("unable to write data config: %w", err) + } + + return nil +} + +func getHash(s script) (string, error) { + file, err := os.Open(filepath.Join(repoDirPath(), s.Path)) + if err != nil { + return "", err + } + defer file.Close() + + sha := sha256.New() + if _, err := io.Copy(sha, file); err != nil { + return "", err + } + + return fmt.Sprintf("%x", sha.Sum(nil)), nil +} + +func loadDataConfig() dataConfig { + content, err := os.ReadFile(dataConfigFilePath()) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Warn("error while reading data config", "error", err) + } + } + + config := dataConfig{} + + if err := yaml.Unmarshal(content, &config); err != nil { + logger.Error("invalid data config", "error", err) + } + + return config +} diff --git a/internal/config/repo.go b/internal/config/repo.go index a459afb..57ccbf9 100644 --- a/internal/config/repo.go +++ b/internal/config/repo.go @@ -1,26 +1,24 @@ package config import ( + "errors" "fmt" "os" "strings" "github.com/goccy/go-yaml" "github.com/mxlang/dotx/internal/fs" + "github.com/mxlang/dotx/internal/logger" ) type dotfile struct { - Source string `yaml:"source"` - Destination string `yaml:"destination"` -} - -type scripts struct { - Init []string `yaml:"init"` + Source string `yaml:"source"` // TODO change type to fs.Path + Destination string `yaml:"destination"` // TODO change type to fs.Path } type repoConfig struct { Dotfiles []dotfile `yaml:"dotfiles"` - Scripts scripts `yaml:"scripts,omitempty"` + Scripts []script `yaml:"scripts"` } func (r *repoConfig) HasDotfile(source fs.Path) bool { @@ -34,7 +32,7 @@ func (r *repoConfig) HasDotfile(source fs.Path) bool { } func (r *repoConfig) AddDotfile(source fs.Path, dest fs.Path) error { - // normalize paths + // Normalize paths home, _ := os.UserHomeDir() sourcePath := strings.Replace(source.AbsPath(), home, "$HOME", 1) destinationPath := strings.Replace(dest.AbsPath(), repoDirPath(), "", 1) @@ -57,3 +55,33 @@ func (r *repoConfig) AddDotfile(source fs.Path, dest fs.Path) error { return nil } + +func (r *repoConfig) ExecuteScripts(event event) { + for _, script := range r.Scripts { + script.execute(event) + } +} + +func loadRepoConfig() repoConfig { + // Ensure the dotfiles directory exists + repoDir := fs.NewPath(repoDirPath()) + if err := fs.Mkdir(repoDir); err != nil { + logger.Error("error while creating dotfiles directory", "error", err) + } + + config := repoConfig{} + content, err := os.ReadFile(repoConfigFilePath()) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + logger.Error("error while reading dotfiles config", "error", err) + } + + return config + } + + if err := yaml.Unmarshal(content, &config); err != nil { + logger.Error("invalid dotfiles config", "error", err) + } + + return config +} diff --git a/internal/config/script.go b/internal/config/script.go new file mode 100644 index 0000000..14d05b2 --- /dev/null +++ b/internal/config/script.go @@ -0,0 +1,111 @@ +package config + +import ( + "fmt" + "path/filepath" + + "github.com/mxlang/dotx/internal/cmd" + "github.com/mxlang/dotx/internal/fs" + "github.com/mxlang/dotx/internal/logger" +) + +type event string + +const ( + OnInit event = "init" + OnPull event = "pull" + OnDeploy event = "deploy" +) + +func (e *event) UnmarshalYAML(unmarshal func(any) error) error { + var value string + if err := unmarshal(&value); err != nil { + return err + } + + switch event(value) { + case OnInit, OnPull, OnDeploy: + *e = event(value) + return nil + default: + return fmt.Errorf("invalid on value: %s. Must be one of: %s, %s, %s", value, OnInit, OnPull, OnDeploy) + } +} + +type runCondition string + +const ( + runAlways runCondition = "always" + runOnce runCondition = "once" + runChanged runCondition = "changed" +) + +func (r *runCondition) UnmarshalYAML(unmarshal func(any) error) error { + var value string + if err := unmarshal(&value); err != nil { + return err + } + + switch runCondition(value) { + case runAlways, runOnce, runChanged: + *r = runCondition(value) + return nil + default: + return fmt.Errorf("invalid run value: %s. Must be one of: %s, %s, %s", value, runAlways, runOnce, runChanged) + } +} + +type script struct { + Path string `yaml:"path"` // TODO change type to fs.Path + Event event `yaml:"on"` + RunCondition runCondition `yaml:"run,omitempty"` +} + +func (s *script) execute(event event) { + if s.Event != event { + return + } + + path := fs.NewPath(filepath.Join(repoDirPath(), s.Path)) + if !path.Exists() { + logger.Warn("not found", "script", path.AbsPath()) + return + } + + data := loadDataConfig() + + switch s.RunCondition { + case runOnce: + if data.alreadyExecuted(*s) { + logger.Debug("already executed", "script", path.AbsPath()) + return + } + + if err := data.addScript(*s); err != nil { + logger.Error("failed to write data config", "error", err) + } + case runChanged: + if data.alreadyExecuted(*s) { + if data.hashChanged(*s) { + logger.Debug("hash changed", "script", path.AbsPath()) + if err := data.updateScript(*s); err != nil { + logger.Error("failed to write data config", "error", err) + } + } else { + logger.Debug("hash not changed", "script", path.AbsPath()) + return + } + } else { + if err := data.addScript(*s); err != nil { + logger.Error("failed to write data config", "error", err) + } + } + } + + logger.Info("execute", "script", path.AbsPath(), "on", s.Event, "run", s.RunCondition) + if err := cmd.Run(path.AbsPath()); err != nil { + logger.Warn("failed to execute", "script", path.AbsPath(), "error", err) + } else { + logger.Debug("successfully executed", "script", path.AbsPath()) + } +} diff --git a/internal/config/util.go b/internal/config/util.go index 9c8e6d8..2fde7fc 100644 --- a/internal/config/util.go +++ b/internal/config/util.go @@ -1,8 +1,9 @@ package config import ( - "github.com/adrg/xdg" "path/filepath" + + "github.com/adrg/xdg" ) const ( @@ -10,6 +11,7 @@ const ( appConfigFile = "config.yaml" repoDir = "dotfiles" repoConfigFile = "dotx.yaml" + dataConfigFile = "data.yaml" ) func appDirPath() string { @@ -27,3 +29,7 @@ func repoDirPath() string { func repoConfigFilePath() string { return filepath.Join(repoDirPath(), repoConfigFile) } + +func dataConfigFilePath() string { + return filepath.Join(xdg.DataHome, baseDir, dataConfigFile) +}