Skip to content

Commit

Permalink
feat: Allow defining defaults for hermit.hcl in user config (#436)
Browse files Browse the repository at this point in the history
Adds support for user-level configuration defaults in Hermit. Users can
now define default settings in their ~/.hermit.hcl file that will be
applied to all new Hermit environments.

**Example ~/.hermit.hcl:**

```hcl
defaults {
  sources = [
    "https://github.com/lox/private-hermit-packages.git", 
    "https://github.com/cashapp/hermit-packages.git"
  ]
}
```

Also allows the user config file to be defined with `HERMIT_USER_CONFIG`
or `--user-config`.


This replaces the more narrowly scoped
#434.

---------

Co-authored-by: Alec Thomas <[email protected]>
  • Loading branch information
lox and alecthomas authored Feb 21, 2025
1 parent 483aef3 commit 55c98fd
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 62 deletions.
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions app/activate_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
}
Expand Down
19 changes: 11 additions & 8 deletions app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 25 additions & 6 deletions app/init_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
23 changes: 15 additions & 8 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 14 additions & 35 deletions app/user_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
}
16 changes: 16 additions & 0 deletions docs/docs/usage/user-config-schema.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
89 changes: 86 additions & 3 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<EOF
defaults {
sources = ["source1", "source2"]
manage-git = false
idea = true
}
EOF
hermit init .
echo "Generated bin/hermit.hcl content:"
cat bin/hermit.hcl
`,
expectations: exp{
filesExist("bin/hermit.hcl"),
fileContains("bin/hermit.hcl", `sources = \["source1", "source2"\]`),
fileContains("bin/hermit.hcl", `manage-git = false`),
fileContains("bin/hermit.hcl", `idea = true`)}},
{name: "InitWithUserConfigTopLevelNoGitAndIdeaOverrideDefaults",
script: `
cat > "$HERMIT_USER_CONFIG" <<EOF
no-git = true
idea = true
defaults {
manage-git = true
idea = false
}
EOF
hermit init .
echo "Generated bin/hermit.hcl content:"
cat bin/hermit.hcl
`,
expectations: exp{
filesExist("bin/hermit.hcl"),
fileContains("bin/hermit.hcl", `manage-git = false`),
fileContains("bin/hermit.hcl", `idea = true`)}},
{name: "InitWithCommandLineOverridesDefaults",
script: `
cat > "$HERMIT_USER_CONFIG" <<EOF
defaults {
sources = ["source1", "source2"]
manage-git = false
idea = false
}
EOF
hermit init --sources source3,source4 --idea .
`,
expectations: exp{
filesExist("bin/hermit.hcl"),
fileContains("bin/hermit.hcl", `sources = \["source3", "source4"\]`),
fileContains("bin/hermit.hcl", `manage-git = false`),
fileContains("bin/hermit.hcl", `idea = true`)}},
{name: "InitSourcesCommandLineOverridesDefaults",
script: `
cat > "$HERMIT_USER_CONFIG" <<EOF
defaults {
sources = ["source1", "source2"]
}
EOF
hermit init --sources source3,source4 .
cat bin/hermit.hcl
`,
expectations: exp{
filesExist("bin/hermit.hcl"),
fileContains("bin/hermit.hcl", `sources = \["source3", "source4"\]`),
fileDoesNotContain("bin/hermit.hcl", `\["source1"`),
fileDoesNotContain("bin/hermit.hcl", `\["source2"`)}},
{name: "HermitEnvarIsSet",
script: `
hermit init .
Expand Down Expand Up @@ -235,7 +302,7 @@ func TestIntegration(t *testing.T) {
preparations: prep{fixture("testenv1"), activate(".")},
script: `
hermit manifest add-digests packages/testbin1.hcl
assert grep d4f8989a4a6bf56ccc768c094448aa5f42be3b9f0287adc2f4dfd2241f80d2c0 packages/testbin1.hcl
assert grep d4f8989a4a6bf56ccc768c094448aa5f42be3b9f0287adc2f4dfd2241f80d2c0 packages/testbin1.hcl
`},
{name: "UpgradeTriggersInstallHook",
preparations: prep{fixture("testenv1"), activate(".")},
Expand Down Expand Up @@ -284,7 +351,7 @@ func TestIntegration(t *testing.T) {
hermit install binary
. child_environment/bin/activate-hermit
assert test "$(binary.sh)" = "Running from parent"
hermit install binary
assert test "$(binary.sh)" = "Running from child"
`,
Expand Down Expand Up @@ -328,8 +395,14 @@ func TestIntegration(t *testing.T) {
dir := filepath.Join(t.TempDir(), "root")
err := os.MkdirAll(dir, 0700)
assert.NoError(t, err)
testEnvars := make([]string, len(environ), len(environ)+1)
testEnvars := make([]string, len(environ), len(environ)+2)
copy(testEnvars, environ)

// Create a unique empty config file for each test
userConfigFile := filepath.Join(t.TempDir(), ".hermit.hcl")
err = os.WriteFile(userConfigFile, []byte(""), 0600)
assert.NoError(t, err)
testEnvars = append(testEnvars, "HERMIT_USER_CONFIG="+userConfigFile)
testEnvars = append(testEnvars, "HERMIT_STATE_DIR="+stateDir)

prepScript := ""
Expand Down Expand Up @@ -579,6 +652,16 @@ func fileContains(path, regex string) expectation {
}
}

// Verify that the file under the test directory does not contain the given content.
func fileDoesNotContain(path, regex string) expectation {
return func(t *testing.T, dir, stdout string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, path))
assert.NoError(t, err)
assert.False(t, regexp.MustCompile(regex).Match(data))
}
}

// Verify that the output of the test script contains the given text.
func outputContains(text string) expectation {
return func(t *testing.T, dir, output string) {
Expand Down

0 comments on commit 55c98fd

Please sign in to comment.