diff --git a/cli/cmd/encore/config/config.go b/cli/cmd/encore/config/config.go new file mode 100644 index 0000000000..0723ba80fd --- /dev/null +++ b/cli/cmd/encore/config/config.go @@ -0,0 +1,127 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "encr.dev/cli/cmd/encore/cmdutil" + "encr.dev/cli/cmd/encore/root" + "encr.dev/internal/userconfig" + "github.com/spf13/cobra" +) + +var ( + forceApp, forceGlobal bool + viewAllSettings bool +) + +var autoCompleteConfigKeys = cmdutil.AutoCompleteFromStaticList(userconfig.Keys()...) + +var longDocs = `Gets or sets configuration values for customizing the behavior of the Encore CLI. + +Configuration options can be set both for individual Encore applications, +as well as globally for the local user. + +Configuration options can be set using ` + bt("encore config ") + `, +and options can similarly be read using ` + bt("encore config ") + `. + +When running ` + bt("encore config") + ` within an Encore application, +it automatically sets and gets configuration for that application. + +To set or get global configuration, use the ` + bt("--global") + ` flag. + +Available configuration settings are: + +` + userconfig.CLIDocs() + +var configCmd = &cobra.Command{ + Use: "config []", + Short: "Get or set a configuration value", + Long: longDocs, + Args: cobra.RangeArgs(0, 2), + + Run: func(cmd *cobra.Command, args []string) { + appRoot, _, _ := cmdutil.MaybeAppRoot() + + appScope := appRoot != "" + if forceApp { + appScope = true + } else if forceGlobal { + appScope = false + } + + if appScope && appRoot == "" { + // If the user specified --app, error if there is no app. + cmdutil.Fatal(cmdutil.ErrNoEncoreApp) + } + + if len(args) == 2 { + var err error + if appScope { + err = userconfig.SetForApp(appRoot, args[0], args[1]) + } else { + err = userconfig.SetGlobal(args[0], args[1]) + } + if err != nil { + cmdutil.Fatal(err) + } + } else { + var ( + cfg *userconfig.Config + err error + ) + if appScope { + appRoot, _ := cmdutil.AppRoot() + cfg, err = userconfig.ForApp(appRoot).Get() + } else { + cfg, err = userconfig.Global().Get() + } + if err != nil { + cmdutil.Fatal(err) + } + + if viewAllSettings { + if len(args) > 0 { + cmdutil.Fatalf("cannot specify a settings key when using --all") + } + s := strings.TrimSuffix(cfg.Render(), "\n") + fmt.Println(s) + return + } + + if len(args) == 0 { + // No args are only allowed when --all is specified. + _ = cmd.Usage() + os.Exit(1) + } + + val, ok := cfg.GetByKey(args[0]) + if !ok { + cmdutil.Fatalf("unknown key %q", args[0]) + } + fmt.Printf("%v\n", val) + } + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // Completing the first argument, the config key + return autoCompleteConfigKeys(cmd, args, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, +} + +func init() { + configCmd.Flags().BoolVar(&viewAllSettings, "all", false, "view all settings") + configCmd.Flags().BoolVar(&forceApp, "app", false, "set the value for the current app") + configCmd.Flags().BoolVar(&forceGlobal, "global", false, "set the value at the global level") + configCmd.MarkFlagsMutuallyExclusive("app", "global") + + root.Cmd.AddCommand(configCmd) +} + +// bt renders a backtick-enclosed string. +func bt(val string) string { + return fmt.Sprintf("`%s`", val) +} diff --git a/cli/cmd/encore/main.go b/cli/cmd/encore/main.go index 07b1cb048f..c77c615608 100644 --- a/cli/cmd/encore/main.go +++ b/cli/cmd/encore/main.go @@ -10,8 +10,10 @@ import ( "encr.dev/cli/cmd/encore/cmdutil" "encr.dev/cli/cmd/encore/root" + // Register commands _ "encr.dev/cli/cmd/encore/app" + _ "encr.dev/cli/cmd/encore/config" _ "encr.dev/cli/cmd/encore/k8s" _ "encr.dev/cli/cmd/encore/namespace" _ "encr.dev/cli/cmd/encore/secrets" diff --git a/cli/daemon/run.go b/cli/daemon/run.go index c12c9bd5d9..095f945a32 100644 --- a/cli/daemon/run.go +++ b/cli/daemon/run.go @@ -11,6 +11,7 @@ import ( "encr.dev/cli/daemon/run" "encr.dev/internal/optracker" + "encr.dev/internal/userconfig" "encr.dev/internal/version" "encr.dev/pkg/fns" daemonpb "encr.dev/proto/encore/daemon" @@ -30,6 +31,13 @@ func (s *Server) Run(req *daemonpb.RunRequest, stream daemonpb.Daemon_RunServer) }) } + userConfig, err := userconfig.ForApp(req.AppRoot).Get() + if err != nil { + _, _ = fmt.Fprintln(stderr, aurora.Sprintf(aurora.Red("failed to load config: %v"), err)) + sendExit(1) + return nil + } + ctx, tracer, err := s.beginTracing(ctx, req.AppRoot, req.WorkingDir, req.TraceFile) if err != nil { _, _ = fmt.Fprintln(stderr, aurora.Sprintf(aurora.Red("failed to begin tracing: %v"), err)) @@ -120,6 +128,11 @@ func (s *Server) Run(req *daemonpb.RunRequest, stream daemonpb.Daemon_RunServer) displayListenAddr = "localhost" + req.ListenAddr } + browser := run.BrowserModeFromProto(req.Browser) + if browser == run.BrowserModeAuto { + browser = run.BrowserModeFromConfig(userConfig) + } + runInstance, err := s.mgr.Start(ctx, run.StartParams{ App: app, NS: ns, @@ -129,7 +142,7 @@ func (s *Server) Run(req *daemonpb.RunRequest, stream daemonpb.Daemon_RunServer) Watch: req.Watch, Environ: req.Environ, OpsTracker: ops, - Browser: run.BrowserModeFromProto(req.Browser), + Browser: browser, Debug: run.DebugModeFromProto(req.DebugMode), }) if err != nil { diff --git a/cli/daemon/run/run.go b/cli/daemon/run/run.go index c81d1fe97f..ae0945e1fb 100644 --- a/cli/daemon/run/run.go +++ b/cli/daemon/run/run.go @@ -34,6 +34,7 @@ import ( "encr.dev/cli/daemon/run/infra" "encr.dev/cli/daemon/secret" "encr.dev/internal/optracker" + "encr.dev/internal/userconfig" "encr.dev/internal/version" "encr.dev/pkg/builder" "encr.dev/pkg/builder/builderimpl" @@ -108,6 +109,17 @@ const ( BrowserModeAlways // always open ) +func BrowserModeFromConfig(cfg *userconfig.Config) BrowserMode { + switch cfg.RunBrowser { + case "never": + return BrowserModeNever + case "always": + return BrowserModeAlways + default: + return BrowserModeAuto + } +} + func BrowserModeFromProto(b daemonpb.RunRequest_BrowserMode) BrowserMode { switch b { case daemonpb.RunRequest_BROWSER_AUTO: diff --git a/docs/go/cli/cli-reference.md b/docs/go/cli/cli-reference.md index 7ecbe9b079..a8227dfdbe 100644 --- a/docs/go/cli/cli-reference.md +++ b/docs/go/cli/cli-reference.md @@ -242,7 +242,7 @@ Note that this strips trailing newlines from the secret value. Lists secrets, optionally for a specific key -```shell +```shell $ encore secret list [keys...] ``` diff --git a/docs/go/cli/config-reference.md b/docs/go/cli/config-reference.md new file mode 100644 index 0000000000..5f05dbdc96 --- /dev/null +++ b/docs/go/cli/config-reference.md @@ -0,0 +1,50 @@ +--- +seotitle: Encore CLI Configuration Options +seodesc: Configuration options to customize the behavior of the Encore CLI. +title: Configuration Reference +subtitle: Configuration options to customize the behavior of the Encore CLI. +lang: go +--- + + +The Encore CLI has a number of configuration options to customize its behavior. + +Configuration options can be set both for individual Encore applications, as well as +globally for the local user. + +Configuration options can be set using `encore config `, +and options can similarly be read using `encore config `. + +When running `encore config` within an Encore application, it automatically +sets and gets configuration for that application. + +To set or get global configuration, use the `--global` flag. + +## Configuration files + +The configuration is stored in one ore more TOML files on the filesystem. + +The configuration is read from the following files, in order: + +### Global configuration +* `$XDG_CONFIG_HOME/encore/config` +* `$HOME/.config/encore/config` +* `$HOME/.encoreconfig` + +### Application-specific configuration +* `$APP_ROOT/.encore/config` + +Where `$APP_ROOT` is the directory containing the `encore.app` file. + +The files are read and merged, in the order defined above, with latter files taking precedence over earlier files. + +## Configuration options + +#### run.browser +Type: string
+Default: auto
+Must be one of: always, never, or auto + +Whether to open the Local Development Dashboard in the browser on `encore run`. +If set to "auto", the browser will be opened if the dashboard is not already open. + diff --git a/docs/menu.cue b/docs/menu.cue index 4ca732f29c..fa78819452 100644 --- a/docs/menu.cue +++ b/docs/menu.cue @@ -400,6 +400,11 @@ text: "Infra Namespaces" path: "/go/cli/infra-namespaces" file: "go/cli/infra-namespaces" + }, { + kind: "basic" + text: "CLI Configuration" + path: "/go/cli/config-reference" + file: "go/cli/config-reference" }, { kind: "basic" text: "Telemetry" @@ -823,6 +828,11 @@ text: "Infra Namespaces" path: "/ts/cli/infra-namespaces" file: "ts/cli/infra-namespaces" + }, { + kind: "basic" + text: "CLI Configuration" + path: "/ts/cli/config-reference" + file: "ts/cli/config-reference" }, { kind: "basic" text: "Telemetry" diff --git a/docs/ts/cli/config-reference.md b/docs/ts/cli/config-reference.md new file mode 100644 index 0000000000..cb4dc5e965 --- /dev/null +++ b/docs/ts/cli/config-reference.md @@ -0,0 +1,50 @@ +--- +seotitle: Encore CLI Configuration Options +seodesc: Configuration options to customize the behavior of the Encore CLI. +title: Configuration Reference +subtitle: Configuration options to customize the behavior of the Encore CLI. +lang: ts +--- + + +The Encore CLI has a number of configuration options to customize its behavior. + +Configuration options can be set both for individual Encore applications, as well as +globally for the local user. + +Configuration options can be set using `encore config `, +and options can similarly be read using `encore config `. + +When running `encore config` within an Encore application, it automatically +sets and gets configuration for that application. + +To set or get global configuration, use the `--global` flag. + +## Configuration files + +The configuration is stored in one ore more TOML files on the filesystem. + +The configuration is read from the following files, in order: + +### Global configuration +* `$XDG_CONFIG_HOME/encore/config` +* `$HOME/.config/encore/config` +* `$HOME/.encoreconfig` + +### Application-specific configuration +* `$APP_ROOT/.encore/config` + +Where `$APP_ROOT` is the directory containing the `encore.app` file. + +The files are read and merged, in the order defined above, with latter files taking precedence over earlier files. + +## Configuration options + +#### run.browser +Type: string
+Default: auto
+Must be one of: always, never, or auto + +Whether to open the Local Development Dashboard in the browser on `encore run`. +If set to "auto", the browser will be opened if the dashboard is not already open. + diff --git a/go.mod b/go.mod index 104ec027a7..14ba0161c3 100644 --- a/go.mod +++ b/go.mod @@ -25,8 +25,8 @@ require ( github.com/fatih/color v1.15.0 github.com/fatih/structtag v1.2.0 github.com/fmstephe/unsafeutil v1.0.0 - github.com/frankban/quicktest v1.14.5 - github.com/fsnotify/fsnotify v1.6.0 + github.com/frankban/quicktest v1.14.6 + github.com/fsnotify/fsnotify v1.8.0 github.com/getkin/kin-openapi v0.115.0 github.com/gofrs/uuid v4.4.0+incompatible github.com/golang-migrate/migrate/v4 v4.15.2 @@ -45,6 +45,10 @@ require ( github.com/json-iterator/go v1.1.12 github.com/julienschmidt/httprouter v1.3.0 github.com/jwalton/go-supportscolor v1.1.0 + github.com/knadh/koanf/parsers/toml/v2 v2.1.0 + github.com/knadh/koanf/providers/file v1.1.2 + github.com/knadh/koanf/providers/rawbytes v0.1.0 + github.com/knadh/koanf/v2 v2.1.2 github.com/lib/pq v1.10.9 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/logrusorgru/aurora/v3 v3.0.0 @@ -53,6 +57,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 github.com/nsqio/go-nsq v1.1.0 github.com/nsqio/nsq v1.2.1 + github.com/pelletier/go-toml v1.9.5 github.com/peterbourgon/diskv v2.0.1+incompatible github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e @@ -74,7 +79,7 @@ require ( golang.org/x/net v0.30.0 golang.org/x/oauth2 v0.23.0 golang.org/x/sync v0.8.0 - golang.org/x/sys v0.26.0 + golang.org/x/sys v0.29.0 golang.org/x/term v0.25.0 golang.org/x/text v0.19.0 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d @@ -108,7 +113,7 @@ require ( github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/cubicdaiya/gonp v1.0.4 // indirect github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.7.0 // indirect github.com/docker/cli v27.1.1+incompatible // indirect @@ -127,6 +132,7 @@ require ( github.com/go-openapi/swag v0.22.4 // indirect github.com/go-redis/redis/v8 v8.11.5 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/glog v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -147,7 +153,8 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/compress v1.17.1 // indirect + github.com/klauspost/compress v1.17.2 // indirect + github.com/knadh/koanf/maps v0.1.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -155,8 +162,10 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -168,12 +177,14 @@ require ( github.com/nsqio/go-diskqueue v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc5 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/perimeterx/marshmallow v1.1.4 // indirect github.com/pganalyze/pg_query_go/v4 v4.2.4-0.20231205012101-7463430c7b73 // indirect github.com/pingcap/errors v0.11.5-0.20210425183316-da1aaba5fb63 // indirect github.com/pingcap/failpoint v0.0.0-20220801062533-2eaa32854a6c // indirect github.com/pingcap/log v1.1.0 // indirect github.com/pingcap/tidb/pkg/parser v0.0.0-20231103154709-4f00ece106b1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20220131092820-39736dd543b4 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.4 // indirect diff --git a/go.sum b/go.sum index d79b49ffa4..daa0c2a8f5 100644 --- a/go.sum +++ b/go.sum @@ -416,8 +416,9 @@ github.com/d2g/hardwareaddr v0.0.0-20190221164911-e7d9fbe030e4/go.mod h1:bMl4RjI github.com/dave/jennifer v1.7.0 h1:uRbSBH9UTS64yXbh4FrMHfgfY762RD+C7bUPKODpSJE= github.com/dave/jennifer v1.7.0/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= @@ -502,12 +503,12 @@ github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoD github.com/form3tech-oss/jwt-go v3.2.3+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/form3tech-oss/jwt-go v3.2.5+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= -github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= -github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsouza/fake-gcs-server v1.17.0/go.mod h1:D1rTE4YCyHFNa99oyJJ5HyclvN/0uQR+pM/VdlL83bw= github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA= github.com/gabriel-vasile/mimetype v1.3.1/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= @@ -576,6 +577,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= @@ -929,8 +932,18 @@ github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY github.com/klauspost/compress v1.13.1/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.17.1 h1:NE3C767s2ak2bweCZo3+rdP4U/HoyVXLv/X9f2gPS5g= -github.com/klauspost/compress v1.17.1/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= +github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/toml/v2 v2.1.0 h1:EUdIKIeezfDj6e1ABDhIjhbURUpyrP1HToqW6tz8R0I= +github.com/knadh/koanf/parsers/toml/v2 v2.1.0/go.mod h1:0KtwfsWJt4igUTQnsn0ZjFWVrP80Jv7edTBRbQFd2ho= +github.com/knadh/koanf/providers/file v1.1.2 h1:aCC36YGOgV5lTtAFz2qkgtWdeQsgfxUkxDOe+2nQY3w= +github.com/knadh/koanf/providers/file v1.1.2/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI= +github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME= +github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c= +github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= +github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -1017,6 +1030,8 @@ github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3N github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -1029,6 +1044,8 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/sys/mountinfo v0.4.0/go.mod h1:rEr8tzG/lsIZHBtN/JjGG+LMYx9eXgW2JI+6q0qou+A= @@ -1150,6 +1167,10 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9 github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw= github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= @@ -1181,8 +1202,9 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -1316,6 +1338,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -1326,6 +1349,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= @@ -1788,13 +1812,12 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/internal/userconfig/config.go b/internal/userconfig/config.go new file mode 100644 index 0000000000..f0b9ebd450 --- /dev/null +++ b/internal/userconfig/config.go @@ -0,0 +1,8 @@ +package userconfig + +// Config describes the configuration structure we support. +type Config struct { + // Whether to open the Local Development Dashboard in the browser on `encore run`. + // If set to "auto", the browser will be opened if the dashboard is not already open. + RunBrowser string `koanf:"run.browser" oneof:"always,never,auto" default:"auto"` +} diff --git a/internal/userconfig/def.go b/internal/userconfig/def.go new file mode 100644 index 0000000000..e029949329 --- /dev/null +++ b/internal/userconfig/def.go @@ -0,0 +1,52 @@ +package userconfig + +import ( + "fmt" + "maps" + "reflect" + "slices" + "sort" + "strings" +) + +func (c *Config) GetByKey(key string) (v Value, ok bool) { + val := reflect.ValueOf(c).Elem() + desc, ok := descs[key] + if !ok { + return Value{}, false + } + + f := val.FieldByName(desc.FieldName) + if !f.IsValid() { + return Value{}, false + } + + return Value{Val: f.Interface(), Type: desc.Type}, true +} + +func (c *Config) Render() string { + var buf strings.Builder + for _, key := range configKeys { + v, ok := c.GetByKey(key) + if !ok { + continue + } + buf.WriteString(fmt.Sprintf("%s: %s\n", key, v)) + } + return buf.String() +} + +var configKeys = (func() []string { + keys := slices.Collect(maps.Keys(descs)) + sort.Strings(keys) + return keys +})() + +func GetType(key string) (Type, bool) { + typ, ok := descs[key] + return typ.Type, ok +} + +func Keys() []string { + return configKeys +} diff --git a/internal/userconfig/docs.go b/internal/userconfig/docs.go new file mode 100644 index 0000000000..1717f6ad90 --- /dev/null +++ b/internal/userconfig/docs.go @@ -0,0 +1,127 @@ +package userconfig + +import ( + "fmt" + "strings" +) + +//go:generate go run ./gendocs + +func CLIDocs() string { + var buf strings.Builder + for _, key := range configKeys { + desc := descs[key] + doc := desc.Doc + fmt.Fprintf(&buf, "%s (%s)\n", key, desc.Type.Kind.String()) + + if doc != "" { + rem := doc + for rem != "" { + var line string + if idx := strings.IndexByte(rem, '\n'); idx != -1 { + line = rem[:idx] + rem = rem[idx+1:] + } else { + line = rem + rem = "" + } + buf.WriteString(" ") + buf.WriteString(line) + buf.WriteByte('\n') + } + } else { + buf.WriteString(" No documentation available.\n") + } + + buf.WriteByte('\n') + + didWriteMore := false + if desc.Type.Default != nil { + fmt.Fprintf(&buf, " Default: %v\n", RenderValue(*desc.Type.Default)) + didWriteMore = true + } + if len(desc.Type.Oneof) > 0 { + fmt.Fprintf(&buf, " Must be one of: %v\n", RenderOneof(desc.Type.Oneof)) + didWriteMore = true + } + + // Add an extra newline if we wrote validation details. + if didWriteMore { + buf.WriteByte('\n') + } + } + + return buf.String() +} + +// bt renders a backtick-enclosed string. +func bt(val string) string { + return fmt.Sprintf("`%s`", val) +} + +var markdownHeader = ` +The Encore CLI has a number of configuration options to customize its behavior. + +Configuration options can be set both for individual Encore applications, as well as +globally for the local user. + +Configuration options can be set using ` + bt("encore config ") + `, +and options can similarly be read using ` + bt("encore config ") + `. + +When running ` + bt("encore config") + ` within an Encore application, it automatically +sets and gets configuration for that application. + +To set or get global configuration, use the ` + bt("--global") + ` flag. + +## Configuration files + +The configuration is stored in one ore more TOML files on the filesystem. + +The configuration is read from the following files, in order: + +### Global configuration +* ` + bt("$XDG_CONFIG_HOME/encore/config") + ` +* ` + bt("$HOME/.config/encore/config") + ` +* ` + bt("$HOME/.encoreconfig") + ` + +### Application-specific configuration +* ` + bt("$APP_ROOT/.encore/config") + ` + +Where ` + bt("$APP_ROOT") + ` is the directory containing the ` + bt("encore.app") + ` file. + +The files are read and merged, in the order defined above, with latter files taking precedence over earlier files. + +## Configuration options + +` + +func MarkdownDocs() string { + var buf strings.Builder + + buf.WriteString(markdownHeader) + + for _, key := range configKeys { + desc := descs[key] + doc := desc.Doc + + fmt.Fprintf(&buf, "#### %s\n", key) + fmt.Fprintf(&buf, "Type: %s
\n", desc.Type.Kind.String()) + if desc.Type.Default != nil { + fmt.Fprintf(&buf, "Default: %v
\n", RenderValue(*desc.Type.Default)) + } + if len(desc.Type.Oneof) > 0 { + fmt.Fprintf(&buf, "Must be one of: %v\n", RenderOneof(desc.Type.Oneof)) + } + buf.WriteByte('\n') + + if doc != "" { + buf.WriteString(doc) + } else { + buf.WriteString("No documentation available.\n") + } + + buf.WriteByte('\n') + } + + return buf.String() +} diff --git a/internal/userconfig/files.go b/internal/userconfig/files.go new file mode 100644 index 0000000000..cce3a2fa3f --- /dev/null +++ b/internal/userconfig/files.go @@ -0,0 +1,104 @@ +package userconfig + +import ( + "io/fs" + "os" + "os/user" + "path/filepath" + "slices" + "sync" + "time" + + "encr.dev/internal/goldfish" + "github.com/cockroachdb/errors" + "github.com/knadh/koanf/parsers/toml/v2" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/rawbytes" + "github.com/knadh/koanf/v2" +) + +const globalCacheKey = "#global#" + +var ( + goldfishMu sync.Mutex + goldfishes = make(map[string]*Cached) +) + +type Cached = goldfish.Cache[*Config] + +func ForApp(appRoot string) *Cached { + appRoot = filepath.Clean(appRoot) + paths := slices.Clone(userPaths) + paths = append(paths, appFilePath(appRoot)) + return forCacheKey(appRoot, paths) +} + +func Global() *Cached { + return forCacheKey(globalCacheKey, userPaths) +} + +func forCacheKey(key string, paths []string) *Cached { + goldfishMu.Lock() + defer goldfishMu.Unlock() + + if c, ok := goldfishes[key]; ok { + return c + } + + c := goldfish.New(1*time.Second, func() (*Config, error) { + return newInstance(paths...) + }) + goldfishes[key] = c + return c +} + +func appFilePath(appRoot string) string { + return filepath.Join(appRoot, ".encore", "config") +} + +var userPaths []string = func() []string { + var paths []string + + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome != "" { + paths = append(paths, filepath.Join(configHome, "encore", "config")) + } + + if u, err := user.Current(); err == nil { + if configHome == "" { + paths = append(paths, filepath.Join(u.HomeDir, ".config", "encore", "config")) + } + paths = append(paths, filepath.Join(u.HomeDir, ".encoreconfig")) + } + + return paths +}() + +var tomlParser = toml.Parser() + +func newInstance(paths ...string) (*Config, error) { + k := koanf.New(".") + + for _, path := range paths { + f := file.Provider(path) + err := k.Load(f, tomlParser) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, errors.Wrap(err, "unable to parse config file") + } + } + + cfg := &Config{} + err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{ + Tag: "koanf", + FlatPaths: true, + }) + if err != nil { + return nil, errors.Wrap(err, "unable to unmarshal config") + } + return cfg, nil +} + +func validateConfig(data []byte) error { + k := koanf.New(".") + return k.Load(rawbytes.Provider(data), tomlParser) +} diff --git a/internal/userconfig/gendocs/gendocs.go b/internal/userconfig/gendocs/gendocs.go new file mode 100644 index 0000000000..df21239d49 --- /dev/null +++ b/internal/userconfig/gendocs/gendocs.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "log" + "os/exec" + "path/filepath" + "strings" + + "encr.dev/internal/userconfig" + "encr.dev/pkg/xos" +) + +func main() { + repoRoot := resolveRepoRoot() + docsDir := filepath.Join(repoRoot, "docs") + + for _, lang := range []string{"go", "ts"} { + docs := generateDocs(lang) + dst := filepath.Join(docsDir, lang, "cli", "config-reference.md") + if err := xos.WriteFile(dst, []byte(docs), 0644); err != nil { + log.Fatalf("error writing %s docs file: %v\n", lang, err) + } + } + log.Printf("successfully regenerated docs") +} + +func generateDocs(lang string) string { + return docsHeader(lang) + "\n" + userconfig.MarkdownDocs() +} + +func docsHeader(lang string) string { + return fmt.Sprintf(`--- +seotitle: Encore CLI Configuration Options +seodesc: Configuration options to customize the behavior of the Encore CLI. +title: Configuration Reference +subtitle: Configuration options to customize the behavior of the Encore CLI. +lang: %s +--- +`, lang) +} + +func resolveRepoRoot() string { + // Use `git rev-parse --show-toplevel` to get the root of the repository + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + out, err := cmd.CombinedOutput() + if err != nil { + log.Fatalf("Error running git rev-parse: %v\n", err) + } + return filepath.Clean(strings.TrimSpace(string(out))) +} diff --git a/internal/userconfig/reflect.go b/internal/userconfig/reflect.go new file mode 100644 index 0000000000..dec3d9bc09 --- /dev/null +++ b/internal/userconfig/reflect.go @@ -0,0 +1,166 @@ +package userconfig + +import ( + _ "embed" + "fmt" + "go/ast" + "go/doc" + "go/parser" + "go/token" + "reflect" + "strings" + + "github.com/cockroachdb/errors" + "github.com/fatih/structtag" +) + +func keyForField(f *reflect.StructField) (string, error) { + tags, err := structtag.Parse(string(f.Tag)) + if err != nil { + return "", err + } + tag, err := tags.Get("koanf") + if err != nil { + return "", err + } + key := tag.Name + if key == "" { + return "", errors.New("empty key") + } + return key, nil +} + +type keyDesc struct { + Doc string + Type Type + FieldName string // field name in the Config struct +} + +func newKeyDesc(f *reflect.StructField) (key string, desc keyDesc, err error) { + tags, err := structtag.Parse(string(f.Tag)) + if err != nil { + return "", keyDesc{}, err + } + tag, err := tags.Get("koanf") + if err != nil { + return "", keyDesc{}, errors.Wrap(err, "failed to get koanf tag") + } + key = tag.Name + if key == "" { + return "", keyDesc{}, errors.New("empty key") + } + + kind, ok := kindFromReflect(f.Type.Kind()) + if !ok { + return "", keyDesc{}, errors.Errorf("unsupported type %v", f.Type) + } + + ty := Type{Kind: kind} + + // Do we have a default? + if def, _ := tags.Get("default"); def != nil { + val, err := kind.parseValue(def.Name) + if err != nil { + return "", keyDesc{}, errors.Wrap(err, "parse default value") + } + ty.Default = &val + } + + // Do we have a oneof? + if tag := f.Tag.Get("oneof"); tag != "" { + var oneof []any + for _, part := range strings.Split(tag, ",") { + val, err := kind.parseValue(part) + if err != nil { + return "", keyDesc{}, errors.Wrap(err, "parse oneof value") + } + oneof = append(oneof, val) + } + ty.Oneof = oneof + } + + desc = keyDesc{ + Doc: docComments[f.Name], + Type: ty, + FieldName: f.Name, + } + return key, desc, nil +} + +var descs = (func() map[string]keyDesc { + var cfg Config + t := reflect.TypeOf(cfg) + descs := make(map[string]keyDesc, t.NumField()) + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + key, desc, err := newKeyDesc(&f) + if err != nil { + panic(fmt.Sprintf("invalid userconfig definition for field %s: %v", f.Name, err)) + } + if _, ok := descs[key]; ok { + panic(fmt.Sprintf("duplicate key %s in userconfig.Config", key)) + } + descs[key] = desc + } + + return descs +})() + +func kindFromReflect(kind reflect.Kind) (Kind, bool) { + switch kind { + case reflect.String: + return String, true + case reflect.Bool: + return Bool, true + case reflect.Int: + return Int, true + case reflect.Uint: + return Uint, true + default: + return 0, false + } +} + +//go:embed config.go +var configGo string + +// doc comments, keyed by field name. +var docComments = (func() map[string]string { + // Parse config.go as a Go file to extract the doc comments. + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "config.go", configGo, parser.ParseComments) + if err != nil { + panic(fmt.Sprintf("userconfig/config.go is invalid: %v", err)) + } + + // Compute package documentation with examples. + p, err := doc.NewFromFiles(fset, []*ast.File{f}, "encr.dev/internal/userconfig") + if err != nil { + panic(fmt.Sprintf("userconfig/config.go is invalid: %v", err)) + } + + for _, typ := range p.Types { + if typ.Name == "Config" { + comments := make(map[string]string) + + // Extract comments for each field. + structType := typ.Decl.Specs[0].(*ast.TypeSpec).Type.(*ast.StructType) + for _, f := range structType.Fields.List { + if f.Doc == nil { + continue + } + if len(f.Names) == 0 { + panic("field has no name") + } + text := f.Doc.Text() + for _, name := range f.Names { + comments[name.Name] = text + } + } + return comments + } + } + + panic("Config type not found in userconfig/config.go") +})() diff --git a/internal/userconfig/value.go b/internal/userconfig/value.go new file mode 100644 index 0000000000..1be16d9855 --- /dev/null +++ b/internal/userconfig/value.go @@ -0,0 +1,160 @@ +package userconfig + +import ( + "fmt" + "strconv" + "strings" + + "github.com/cockroachdb/errors" +) + +type Kind int + +const ( + String Kind = iota + 1 + Bool + Int + Uint +) + +func (k Kind) String() string { + switch k { + case String: + return "string" + case Bool: + return "bool" + case Int: + return "int" + case Uint: + return "uint" + default: + return "unknown kind" + } +} + +func (k Kind) HumanString() string { + switch k { + case String: + return "a string" + case Bool: + return "a boolean (true/false)" + case Int: + return "an integer" + case Uint: + return "an unsigned integer (>=0)" + default: + return "an unknown kind" + } +} + +type Type struct { + Kind Kind + Default *any // nil means no default + Oneof []any // nil means no restrictions +} + +type Value struct { + Val any + Type Type +} + +func (v Value) String() string { + return RenderValue(v.Val) +} + +func (t Type) ParseAndValidate(val string) (any, error) { + parsed, err := t.Kind.parseValue(val) + if err != nil { + return nil, err + } else if err := t.validate(parsed); err != nil { + return nil, err + } + return parsed, nil +} + +func (t Type) validate(val any) error { + if val == nil { + return errors.New("value cannot be nil") + } + if len(t.Oneof) > 0 { + for _, v := range t.Oneof { + if val == v { + return nil + } + } + + strVal := fmt.Sprintf("%v", val) + return errors.Errorf("value %q is not one of: %s", strVal, RenderOneof(t.Oneof)) + } + + if k, ok := kindOf(val); ok { + if k != t.Kind { + return errors.Errorf("value v is not %s", t.Kind.HumanString()) + } + } + + return nil +} + +func RenderValue(v any) string { + return fmt.Sprintf("%v", v) +} + +func RenderOneof(oneof []any) string { + if len(oneof) == 0 { + return "" + } + + // Render as "a, b, or c" + var s strings.Builder + for i, v := range oneof { + if i > 0 { + if i == len(oneof)-1 { + if len(oneof) > 2 { + s.WriteString(", or ") + } else { + s.WriteString(" or ") + } + } else { + s.WriteString(", ") + } + } + + s.WriteString(RenderValue(v)) + } + return s.String() +} + +func (k Kind) parseValue(value string) (any, error) { + switch k { + case String: + return value, nil + case Bool: + return strconv.ParseBool(value) + case Int: + return strconv.ParseInt(value, 10, 64) + case Uint: + return strconv.ParseUint(value, 10, 64) + default: + return nil, fmt.Errorf("unknown kind %v", k) + } +} + +func KindOf[T interface{ string | bool | int | uint }](val T) (k Kind, ok bool) { + return kindOf(val) +} + +func kindOf(val any) (k Kind, ok bool) { + switch val.(type) { + case string: + return String, true + case bool: + return Bool, true + case int: + return Int, true + case uint: + return Uint, true + default: + return 0, false + } +} diff --git a/internal/userconfig/write.go b/internal/userconfig/write.go new file mode 100644 index 0000000000..7f0129e0a9 --- /dev/null +++ b/internal/userconfig/write.go @@ -0,0 +1,86 @@ +package userconfig + +import ( + "os" + "path/filepath" + "strings" + + "encr.dev/pkg/xos" + "github.com/cockroachdb/errors" + "github.com/pelletier/go-toml" +) + +func SetForApp(appRoot, key, value string) error { + if _, err := os.Stat(appRoot); err != nil { + return errors.Wrap(err, "app root directory does not exist") + } + dst := appFilePath(appRoot) + return updateConfig(dst, key, value) +} + +func SetGlobal(key, value string) error { + if len(userPaths) == 0 { + return errors.New("no global config file location found") + } + + // Find the last path in the list that exists. + for i := len(userPaths) - 1; i >= 0; i-- { + if _, err := os.Stat(userPaths[i]); err == nil { + return updateConfig(userPaths[i], key, value) + } + } + + // Otherwise fall back to the lowest-priority entry. + dst := userPaths[0] + return updateConfig(dst, key, value) +} + +func updateConfig(dstPath, key, value string) error { + desc, ok := descs[key] + if !ok { + return errors.Errorf("unknown key: %q", key) + } + val, err := desc.Type.ParseAndValidate(value) + if err != nil { + return err + } + + // Read the existing config. + // If it doesn't exist it's initialized to an emty config. + var conf *toml.Tree + { + data, err := os.ReadFile(dstPath) + if err != nil && !os.IsNotExist(err) { + return errors.Wrap(err, "failed to read existing config") + } + if data != nil { + conf, err = toml.LoadBytes(data) + } else { + conf, err = toml.TreeFromMap(map[string]any{}) + } + if err != nil { + return errors.Wrap(err, "failed to parse existing config") + } + } + + keys := strings.Split(key, ".") + conf.SetPath(keys, val) + + // Write the config back out. + data, err := conf.Marshal() + if err != nil { + return errors.Wrap(err, "failed to marshal config") + } + + if err := validateConfig(data); err != nil { + return errors.Wrap(err, "resulting config is invalid") + } + + if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { + return errors.Wrap(err, "failed to create config file") + } + if err := xos.WriteFile(dstPath, data, 0644); err != nil { + return errors.Wrap(err, "failed to write config file") + } + return nil +}