diff --git a/Makefile b/Makefile index d8d410f0..f7c68ea6 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ lint: ## run golangci-lint test: ## run tests ./bin/go test -v ./... +test-integration: ## run integration tests + ./bin/go test -tags integration -v ./integration + build: ## builds binary and gzips it mkdir -p $(BUILD_DIR) CGO_ENABLED=0 ./bin/go build -ldflags "-X main.version=$(VERSION) -X main.channel=$(CHANNEL)" -o $(BIN) $(ROOT)/cmd/hermit diff --git a/app/activate_cmd.go b/app/activate_cmd.go index 2ec4ba35..00528057 100644 --- a/app/activate_cmd.go +++ b/app/activate_cmd.go @@ -22,7 +22,7 @@ type activateCmd struct { ShortPrompt bool `help:"Use a minimal prompt in active environments." hidden:""` } -func (a *activateCmd) Run(l *ui.UI, cache *cache.Cache, sta *state.State, globalState GlobalState, config Config, defaultClient *http.Client) error { +func (a *activateCmd) Run(l *ui.UI, cache *cache.Cache, sta *state.State, globalState GlobalState, config Config, defaultClient *http.Client, userConfig UserConfig) error { realdir, err := resolveActivationDir(a.Dir) if err != nil { return errors.WithStack(err) @@ -58,7 +58,15 @@ func (a *activateCmd) Run(l *ui.UI, cache *cache.Cache, sta *state.State, global return errors.WithStack(err) } environ := envars.Parse(os.Environ()).Apply(env.Root(), ops).Changed(true) - prompt := a.Prompt + // Apply user config settings + prompt := userConfig.Prompt + if userConfig.ShortPrompt { + prompt = "short" + } + // Apply command line overrides + if a.Prompt != "" { + prompt = a.Prompt + } if a.ShortPrompt { prompt = "short" } diff --git a/app/commands.go b/app/commands.go index 3900cf94..3bea2f2e 100644 --- a/app/commands.go +++ b/app/commands.go @@ -23,17 +23,19 @@ type cliInterface interface { getLevel() ui.Level getGlobalState() GlobalState getLockTimeout() time.Duration + getUserConfigFile() string } type cliBase struct { - VersionFlag kong.VersionFlag `help:"Show version." name:"version"` - CPUProfile string `placeholder:"PATH" name:"cpu-profile" help:"Enable CPU profiling to PATH." hidden:""` - MemProfile string `placeholder:"PATH" name:"mem-profile" help:"Enable memory profiling to PATH." hidden:""` - Debug bool `help:"Enable debug logging." short:"d"` - Trace bool `help:"Enable trace logging." short:"t"` - Quiet bool `help:"Disable logging and progress UI, except fatal errors." env:"HERMIT_QUIET" short:"q"` - Level ui.Level `help:"Set minimum log level (${enum})." env:"HERMIT_LOG" default:"auto" enum:"auto,trace,debug,info,warn,error,fatal"` - LockTimeout time.Duration `help:"Timeout for waiting on the lock" default:"30s" env:"HERMIT_LOCK_TIMEOUT"` + VersionFlag kong.VersionFlag `help:"Show version." name:"version"` + CPUProfile string `placeholder:"PATH" name:"cpu-profile" help:"Enable CPU profiling to PATH." hidden:""` + MemProfile string `placeholder:"PATH" name:"mem-profile" help:"Enable memory profiling to PATH." hidden:""` + Debug bool `help:"Enable debug logging." short:"d"` + Trace bool `help:"Enable trace logging." short:"t"` + Quiet bool `help:"Disable logging and progress UI, except fatal errors." env:"HERMIT_QUIET" short:"q"` + Level ui.Level `help:"Set minimum log level (${enum})." env:"HERMIT_LOG" default:"auto" enum:"auto,trace,debug,info,warn,error,fatal"` + LockTimeout time.Duration `help:"Timeout for waiting on the lock" default:"30s" env:"HERMIT_LOCK_TIMEOUT"` + UserConfigFile string `help:"Path to Hermit user configuration file." name:"user-config" default:"~/.hermit.hcl" env:"HERMIT_USER_CONFIG"` GlobalState Init initCmd `cmd:"" help:"Initialise an environment (idempotent)." group:"env"` @@ -63,6 +65,7 @@ func (u *cliBase) getQuiet() bool { return u.Quiet } func (u *cliBase) getLevel() ui.Level { return ui.AutoLevel(u.Level) } func (u *cliBase) getGlobalState() GlobalState { return u.GlobalState } func (u *cliBase) getLockTimeout() time.Duration { return u.LockTimeout } +func (u *cliBase) getUserConfigFile() string { return u.UserConfigFile } // CLI structure. type unactivated struct { diff --git a/app/init_cmd.go b/app/init_cmd.go index 52a7fcc0..17dfc7b7 100644 --- a/app/init_cmd.go +++ b/app/init_cmd.go @@ -13,14 +13,33 @@ type initCmd struct { Dir string `arg:"" help:"Directory to create environment in (${default})." default:"${env}" predictor:"dir"` } -func (i *initCmd) Run(w *ui.UI, config Config) error { +func (i *initCmd) Run(w *ui.UI, config Config, userConfig UserConfig) error { _, sum, err := GenInstaller(config) if err != nil { return errors.WithStack(err) } - return hermit.Init(w, i.Dir, config.BaseDistURL, hermit.UserStateDir, hermit.Config{ - Sources: i.Sources, - ManageGit: !i.NoGit, - AddIJPlugin: i.Idea, - }, sum) + + // Load defaults from user config (or zero value) + hermitConfig := userConfig.Defaults + + // Apply top-level user config settings + if userConfig.NoGit { + hermitConfig.ManageGit = false + } + if userConfig.Idea { + hermitConfig.AddIJPlugin = true + } + + // Apply command line overrides (these take precedence over everything) + if i.Sources != nil { + hermitConfig.Sources = i.Sources + } + if i.NoGit { + hermitConfig.ManageGit = false + } + if i.Idea { + hermitConfig.AddIJPlugin = true + } + + return hermit.Init(w, i.Dir, config.BaseDistURL, hermit.UserStateDir, hermitConfig, sum) } diff --git a/app/main.go b/app/main.go index ef1dd3b9..02ad8893 100644 --- a/app/main.go +++ b/app/main.go @@ -167,11 +167,6 @@ func Main(config Config) { cli = &unactivated{cliBase: common} } - userConfig, err := LoadUserConfig() - if err != nil { - log.Printf("%s: %s", userConfigPath, err) - } - githubToken := os.Getenv("HERMIT_GITHUB_TOKEN") if githubToken == "" { githubToken = os.Getenv("GITHUB_TOKEN") @@ -185,11 +180,10 @@ func Main(config Config) { "env": "Environment:\nCommands for creating and managing environments.", "global": "Global:\nCommands for interacting with the shared global Hermit state.", }, - kong.Resolvers(UserConfigResolver(userConfig)), kong.UsageOnError(), kong.Description(help), kong.BindTo(cli, (*cliInterface)(nil)), - kong.Bind(userConfig, config), + kong.Bind(config), kong.AutoGroup(func(parent kong.Visitable, flag *kong.Flag) *kong.Group { node, ok := parent.(*kong.Command) if !ok { @@ -255,6 +249,19 @@ func Main(config Config) { parser.FatalIfErrorf(err) configureLogging(cli, ctx.Command(), p) + var userConfig UserConfig + userConfigPath := cli.getUserConfigFile() + + if IsUserConfigExists(userConfigPath) { + p.Tracef("Loading user config from: %s", userConfigPath) + userConfig, err = LoadUserConfig(userConfigPath) + if err != nil { + log.Printf("%s: %s", userConfigPath, err) + } + } else { + p.Tracef("No user config found at: %s", userConfigPath) + } + config.State.LockTimeout = cli.getLockTimeout() sta, err = state.Open(hermit.UserStateDir, config.State, cache) if err != nil { @@ -296,7 +303,7 @@ func Main(config Config) { err = pprof.WriteHeapProfile(f) fatalIfError(p, err) } - err = ctx.Run(env, p, sta, config, cli.getGlobalState(), ghClient, defaultHTTPClient, cache) + err = ctx.Run(env, p, sta, config, cli.getGlobalState(), ghClient, defaultHTTPClient, cache, userConfig) if err != nil && p.WillLog(ui.LevelDebug) { p.Fatalf("%+v", err) } else { diff --git a/app/user_config.go b/app/user_config.go index 239ec04c..431404ea 100644 --- a/app/user_config.go +++ b/app/user_config.go @@ -6,11 +6,10 @@ import ( "github.com/alecthomas/hcl" "github.com/alecthomas/kong" + "github.com/cashapp/hermit" "github.com/cashapp/hermit/errors" ) -const userConfigPath = "~/.hermit.hcl" - var userConfigSchema = func() string { schema, err := hcl.Schema(&UserConfig{}) if err != nil { @@ -25,18 +24,25 @@ var userConfigSchema = func() string { // UserConfig is stored in ~/.hermit.hcl type UserConfig struct { - Prompt string `hcl:"prompt,optional" default:"env" enum:"env,short,none" help:"Modify prompt to include hermit environment (env), just an icon (short) or nothing (none)"` - ShortPrompt bool `hcl:"short-prompt,optional" help:"If true use a short prompt when an environment is activated."` - NoGit bool `hcl:"no-git,optional" help:"If true Hermit will never add/remove files from Git automatically."` - Idea bool `hcl:"idea,optional" help:"If true Hermit will try to add the IntelliJ IDEA plugin automatically."` + Prompt string `hcl:"prompt,optional" default:"env" enum:"env,short,none" help:"Modify prompt to include hermit environment (env), just an icon (short) or nothing (none)"` + ShortPrompt bool `hcl:"short-prompt,optional" help:"If true use a short prompt when an environment is activated."` + NoGit bool `hcl:"no-git,optional" help:"If true Hermit will never add/remove files from Git automatically."` + Idea bool `hcl:"idea,optional" help:"If true Hermit will try to add the IntelliJ IDEA plugin automatically."` + Defaults hermit.Config `hcl:"defaults,block,optional" help:"Default configuration values for new Hermit environments."` +} + +// IsUserConfigExists checks if the user config file exists at the given path. +func IsUserConfigExists(configPath string) bool { + _, err := os.Stat(kong.ExpandPath(configPath)) + return err == nil } // LoadUserConfig from disk. -func LoadUserConfig() (UserConfig, error) { +func LoadUserConfig(configPath string) (UserConfig, error) { config := UserConfig{} // always return a valid config on error, with defaults set. _ = hcl.Unmarshal([]byte{}, &config) - data, err := os.ReadFile(kong.ExpandPath(userConfigPath)) + data, err := os.ReadFile(kong.ExpandPath(configPath)) if os.IsNotExist(err) { return config, nil } else if err != nil { @@ -48,30 +54,3 @@ func LoadUserConfig() (UserConfig, error) { } return config, nil } - -// UserConfigResolver is a Kong configuration resolver for the Hermit user configuration file. -func UserConfigResolver(userConfig UserConfig) kong.Resolver { - return &userConfigResolver{userConfig} -} - -type userConfigResolver struct{ config UserConfig } - -func (u *userConfigResolver) Validate(app *kong.Application) error { return nil } -func (u *userConfigResolver) Resolve(context *kong.Context, parent *kong.Path, flag *kong.Flag) (interface{}, error) { - switch flag.Name { - case "no-git": - return u.config.NoGit, nil - - case "prompt": - return u.config.Prompt, nil - - case "short-prompt": - return u.config.ShortPrompt, nil - - case "idea": - return u.config.Idea, nil - - default: - return nil, nil - } -} diff --git a/docs/docs/usage/user-config-schema.hcl b/docs/docs/usage/user-config-schema.hcl index b4bf7d64..ec376497 100644 --- a/docs/docs/usage/user-config-schema.hcl +++ b/docs/docs/usage/user-config-schema.hcl @@ -8,3 +8,19 @@ short-prompt = boolean # (optional) no-git = boolean # (optional) # If true Hermit will try to add the IntelliJ IDEA plugin automatically. idea = boolean # (optional) +# Default configuration values for new Hermit environments. +defaults = { + # Package manifest sources. + sources = [string] # (optional) + # Whether Hermit should automatically 'git add' new packages. + manage-git = boolean # (optional) + # Whether this environment inherits a potential parent environment from one of the parent directories. + inherit-parent = boolean # (optional) + # Whether Hermit should automatically add the IntelliJ IDEA plugin. + idea = boolean # (optional) + # When to use GitHub token authentication. + github-token-auth = { + # One or more glob patterns. If any of these match the 'owner/repo' pair of a GitHub repository, the GitHub token from the current environment will be used to fetch their artifacts. + match = [string] # (optional) + } # (optional) +} # (optional) diff --git a/integration/integration_test.go b/integration/integration_test.go index cf028e12..9e067a7e 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -95,6 +95,73 @@ func TestIntegration(t *testing.T) { "bin/hermit", ".idea/externalDependencies.xml", "bin/activate-hermit", "bin/hermit.hcl"), outputContains("Creating new Hermit environment")}}, + {name: "InitWithUserConfigDefaults", + script: ` + cat > "$HERMIT_USER_CONFIG" < "$HERMIT_USER_CONFIG" < "$HERMIT_USER_CONFIG" < "$HERMIT_USER_CONFIG" <