From ae172da7463ba2f182374e2e0520a5a395136832 Mon Sep 17 00:00:00 2001 From: RogerCecilpot Date: Sun, 25 Oct 2026 14:30:20 +0000 Subject: [PATCH 1/2] feat: config, contexts, name resolution, proxy and CA bundle - config.yaml in the config directory (0600 file, 0700 dir, atomic writes); unknown keys are kept when the CLI rewrites it. - Scope resolution with one precedence for every value: flag, then DEVLYFT_* variable, then the context (or the file for api_url and ca_bundle), then the default. --context / DEVLYFT_CONTEXT pick a context for one invocation. New global flags --context, --org, --project, --environment and --location (--environment, not --env, because `deploy --env K=V` sets environment variables). - `config get|set|list` for api_url, ca_bundle and current_context, with validation; `context use|list|show|delete`. `context use NAME` creates the context if needed and saves the scope flags given with it; `context show` without a name prints each resolved value and its source. config and context commands still run when the scope cannot resolve (e.g. a dangling current_context), so they can repair it. - internal/resolve: org by ID, slug or name; project and environment by ID or name; location by ID ignoring case. Ambiguous names are a usage error listing the matches; unknown names exit 4. - Factory.HTTPClient honours HTTPS_PROXY/HTTP_PROXY/NO_PROXY (read per invocation, not cached) and DEVLYFT_CA_BUNDLE / ca_bundle on top of the system roots, with a stderr notice whenever a bundle is in use. The API client uses it; the log stream can too. - Credentials are stored under the context's account, or "default". - Tests never read the developer's config: clitest uses a temp dir. Refs devlyft/devlyft#244 Co-Authored-By: Claude Opus 5.5 --- README.md | 34 +++ go.mod | 3 +- go.sum | 4 +- internal/auth/auth.go | 15 +- internal/cli/auth/auth.go | 12 +- internal/cli/auth_test.go | 8 +- internal/cli/completion.go | 7 +- internal/cli/config/config.go | 132 ++++++++++ internal/cli/config/context.go | 231 +++++++++++++++++ internal/cli/config_test.go | 234 +++++++++++++++++ internal/cli/root.go | 104 ++++++-- internal/cli/testdata/config_contexts.golden | 191 ++++++++++++++ .../cli/testdata/config_scope_errors.golden | 68 +++++ internal/cli/version.go | 8 +- internal/cmdutil/factory.go | 38 ++- internal/config/apiurl.go | 50 ---- internal/config/apiurl_test.go | 59 ----- internal/config/config.go | 235 ++++++++++++++++++ internal/config/config_test.go | 214 ++++++++++++++++ internal/config/http.go | 81 ++++++ internal/config/http_test.go | 115 +++++++++ internal/config/scope.go | 132 ++++++++++ internal/config/scope_test.go | 159 ++++++++++++ internal/resolve/resolve.go | 166 +++++++++++++ internal/resolve/resolve_test.go | 199 +++++++++++++++ internal/testutil/clitest/clitest.go | 24 +- 26 files changed, 2369 insertions(+), 154 deletions(-) create mode 100644 internal/cli/config/config.go create mode 100644 internal/cli/config/context.go create mode 100644 internal/cli/config_test.go create mode 100644 internal/cli/testdata/config_contexts.golden create mode 100644 internal/cli/testdata/config_scope_errors.golden delete mode 100644 internal/config/apiurl.go delete mode 100644 internal/config/apiurl_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/http.go create mode 100644 internal/config/http_test.go create mode 100644 internal/config/scope.go create mode 100644 internal/config/scope_test.go create mode 100644 internal/resolve/resolve.go create mode 100644 internal/resolve/resolve_test.go diff --git a/README.md b/README.md index 2f85de1..1291142 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,40 @@ The token is stored in the system keyring (Keychain, Credential Manager, or a Se In CI, set `DEVLYFT_API_TOKEN` instead of logging in. It takes precedence over any stored credential. Point the CLI at another API with `--api-url` or `DEVLYFT_API_URL`. +## Configuration + +Settings live in `config.yaml` in the config directory (`~/.config/devlyft` on Linux, `~/Library/Application Support/devlyft` on macOS, `%AppData%\devlyft` on Windows), written with mode 0600. + +A **context** saves the organization, project, environment, location and credential account that commands use, so you don't repeat them: + +```sh +devlyft context use prod --org acme --project web --environment production +devlyft context use staging --org acme --project web --environment staging +devlyft context list +devlyft context show # what this command line resolves to, and why +devlyft context delete staging +``` + +`context use NAME` switches to NAME, creating it if needed, and saves any of `--org`, `--project`, `--environment`, `--location` and `--account` given with it (an empty value clears one). `--account` names the stored credential the context uses: `devlyft auth login` saves the token under the active context's account, or under `default` when the context names none, so credentials stored before contexts existed keep working. + +Every value is resolved in the same order: + +| | Flag | Environment variable | Then | +|---|---|---|---| +| Context | `--context` | `DEVLYFT_CONTEXT` | `current_context` | +| Organization | `--org` | `DEVLYFT_ORG` | the context | +| Project | `--project` | `DEVLYFT_PROJECT` | the context | +| Environment | `--environment` | `DEVLYFT_ENV` | the context | +| Location | `--location` | `DEVLYFT_LOCATION` | the context | +| API URL | `--api-url` | `DEVLYFT_API_URL` | `api_url`, then `https://api.devlyft.io` | +| CA bundle | | `DEVLYFT_CA_BUNDLE` | `ca_bundle` | + +Organizations, projects and environments can be given by name or ID; organizations also by slug. A name that matches more than one resource is an error that lists the matches. Scripts should use IDs. + +Top-level settings are managed with `devlyft config get|set|list` (`api_url`, `ca_bundle`, `current_context`; an empty value unsets one). Keys the CLI does not recognise are kept when it rewrites the file. + +**Proxies and TLS inspection.** `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` are honoured. If your network re-signs TLS traffic, point `DEVLYFT_CA_BUNDLE` or `devlyft config set ca_bundle` at a PEM file of the extra CA certificates; they are trusted in addition to the system roots, and every command that connects prints a notice while one is in use. + ## Privacy and update checks devlyft has no telemetry in v1: it does not collect or send usage data. diff --git a/go.mod b/go.mod index fd28e01..88d9b9f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( go.uber.org/mock v0.6.0 go.yaml.in/yaml/v3 v3.0.5 golang.org/x/mod v0.42.0 + golang.org/x/net v0.59.0 golang.org/x/term v0.47.0 sigs.k8s.io/yaml v1.6.0 ) @@ -44,7 +45,7 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.49.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/tools v0.51.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index b6d23ec..60ab272 100644 --- a/go.sum +++ b/go.sum @@ -184,8 +184,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 6c06536..1e495de 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -17,8 +17,8 @@ import ( // in CI. const EnvToken = "DEVLYFT_API_TOKEN" //nolint:gosec // a variable name, not a credential -// DefaultAccount is the account name credentials are stored under until -// contexts (devlyft/devlyft#244) name accounts. +// DefaultAccount is the account name credentials are stored under when the +// active context does not name one. const DefaultAccount = "default" // Source says where a credential came from. @@ -48,6 +48,15 @@ type Resolver struct { // Store opens the credential store. It is not called when // DEVLYFT_API_TOKEN is set, so the keyring is never touched then. Store func() (credstore.Store, error) + // Account selects the stored credential; empty means DefaultAccount. + Account string +} + +func (r *Resolver) account() string { + if r.Account != "" { + return r.Account + } + return DefaultAccount } // Resolve returns the credential for host: DEVLYFT_API_TOKEN, then the @@ -60,7 +69,7 @@ func (r *Resolver) Resolve(ctx context.Context, host string) (*Resolved, error) if err != nil { return nil, err } - c, err := store.Get(ctx, host, DefaultAccount) + c, err := store.Get(ctx, host, r.account()) if errors.Is(err, credstore.ErrNotFound) { return nil, NotLoggedIn(host) } diff --git a/internal/cli/auth/auth.go b/internal/cli/auth/auth.go index 40a52d9..a4b492b 100644 --- a/internal/cli/auth/auth.go +++ b/internal/cli/auth/auth.go @@ -113,7 +113,7 @@ func login(ctx context.Context, f *cmdutil.Factory, withToken, insecure bool) er cred := &credstore.Credential{ Host: host, - Account: auth.DefaultAccount, + Account: f.Account(), Token: token, Organizations: toOrganizations(orgs), CreatedAt: time.Now().UTC().Truncate(time.Second), @@ -221,7 +221,7 @@ dashboard. DEVLYFT_API_TOKEN is not affected.`, if err != nil { return err } - if !hasCredential(creds, host) { + if !hasCredential(creds, host, f.Account()) { e := auth.NotLoggedIn(host) if f.Getenv(auth.EnvToken) != "" { e.Hint = auth.EnvToken + " is set; unset it to stop using that token." @@ -232,12 +232,12 @@ dashboard. DEVLYFT_API_TOKEN is not affected.`, if err != nil || !ok { return err } - if err := store.Delete(ctx, host, auth.DefaultAccount); err != nil && !errors.Is(err, credstore.ErrNotFound) { + if err := store.Delete(ctx, host, f.Account()); err != nil && !errors.Is(err, credstore.ErrNotFound) { return err } if formatRequested(f) { return f.Printer.Print(output.Result{ - Data: map[string]any{"host": host, "account": auth.DefaultAccount, "logged_out": true}, + Data: map[string]any{"host": host, "account": f.Account(), "logged_out": true}, IDs: []string{host}, }) } @@ -389,9 +389,9 @@ func orgSummary(orgs []credstore.Organization) string { return fmt.Sprintf("%d organizations: %s", len(orgs), orgNames(orgs)) } -func hasCredential(creds []credstore.Credential, host string) bool { +func hasCredential(creds []credstore.Credential, host, account string) bool { for _, c := range creds { - if c.Host == host && c.Account == auth.DefaultAccount { + if c.Host == host && c.Account == account { return true } } diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 9cc9c3a..e1e71fa 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -288,6 +288,7 @@ func TestAuthGolden(t *testing.T) { var clientTokens []string f := &cmdutil.Factory{ IO: ios, + ConfigDir: t.TempDir(), Getenv: func(k string) string { return tt.env[k] }, Credentials: func() (credstore.Store, error) { return store, nil }, NewAPIClient: func(ts api.TokenSource) (api.Client, error) { @@ -347,9 +348,10 @@ func TestAuthNeverLeaksToken(t *testing.T) { ios, in, out, errOut := iostreams.Test() in.WriteString(stdin) f := &cmdutil.Factory{ - IO: ios, - APIURL: srv.URL, - Getenv: func(k string) string { return vars[k] }, + IO: ios, + ConfigDir: t.TempDir(), + APIURL: srv.URL, + Getenv: func(k string) string { return vars[k] }, Credentials: func() (credstore.Store, error) { return credstore.New(credstore.Options{Dir: dir, Env: &env, Warn: func(string) {}}), nil }, diff --git a/internal/cli/completion.go b/internal/cli/completion.go index 7f890fe..2193049 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -4,14 +4,17 @@ import ( "fmt" "github.com/spf13/cobra" + + "github.com/devlyft/cli/internal/cmdutil" ) // newCompletionCmd replaces Cobra's default completion command (Cobra skips // its own when one named "completion" exists) so the help text is ours. func newCompletionCmd() *cobra.Command { return &cobra.Command{ - Use: "completion bash|zsh|fish|powershell", - Short: "Generate a shell completion script", + Use: "completion bash|zsh|fish|powershell", + Annotations: map[string]string{cmdutil.NoConfig: "true"}, + Short: "Generate a shell completion script", Long: `Generate a completion script for devlyft for the given shell. The script is printed to stdout. Load it in your shell's start-up file so diff --git a/internal/cli/config/config.go b/internal/cli/config/config.go new file mode 100644 index 0000000..deab161 --- /dev/null +++ b/internal/cli/config/config.go @@ -0,0 +1,132 @@ +// Package config implements `devlyft config` and `devlyft context`. +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/devlyft/cli/internal/cmdutil" + "github.com/devlyft/cli/internal/config" + "github.com/devlyft/cli/internal/output" +) + +// noScope lets these commands run, and repair the file, even when the +// current scope does not resolve (for example a dangling current_context). +var noScope = map[string]string{cmdutil.NoScope: "true"} + +type setting struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// NewConfigCmd returns `devlyft config`. +func NewConfigCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Read and change CLI settings", + Long: `Read and change the top-level settings in the config file: + + api_url DevLyft API URL (DEVLYFT_API_URL and --api-url override it) + ca_bundle PEM file of extra CA certificates to trust (DEVLYFT_CA_BUNDLE overrides it) + current_context Context used when --context and DEVLYFT_CONTEXT are not set + +Per-context defaults (org, project, environment, location, account) are +managed with 'devlyft context'.`, + Annotations: noScope, + } + cmd.AddCommand(newConfigGetCmd(f), newConfigSetCmd(f), newConfigListCmd(f)) + return cmd +} + +func newConfigGetCmd(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "get KEY", + Short: "Print a setting", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + v, err := f.Config.Get(args[0]) + if err != nil { + return err + } + return f.Printer.Print(output.Result{ + Data: setting{Key: args[0], Value: v}, + Table: &output.Table{Headers: []string{"value"}, Rows: [][]string{{v}}}, + IDs: []string{v}, + }) + }, + } +} + +func newConfigSetCmd(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "set KEY VALUE", + Short: "Change a setting; an empty VALUE unsets it", + Example: ` devlyft config set api_url https://api.devlyft.io + devlyft config set ca_bundle ~/corp-ca.pem + devlyft config set current_context ""`, + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + if err := f.Config.Set(args[0], args[1]); err != nil { + return err + } + if err := f.Config.Save(); err != nil { + return err + } + v, _ := f.Config.Get(args[0]) + return f.Printer.Print(output.Result{ + Data: setting{Key: args[0], Value: v}, + Table: &output.Table{Headers: []string{"key", "value"}, Rows: [][]string{{args[0], v}}}, + IDs: []string{args[0]}, + }) + }, + } +} + +func newConfigListCmd(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "Print every setting", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + t := &output.Table{Headers: []string{"key", "value"}} + data := make(map[string]string, len(config.Keys)+1) + for _, k := range config.Keys { + v, _ := f.Config.Get(k) + t.Rows = append(t.Rows, []string{k, v}) + data[k] = v + } + data["path"] = f.Config.Path() + t.Rows = append(t.Rows, []string{"path", f.Config.Path()}) + return f.Printer.Print(output.Result{Data: data, Table: t, IDs: config.Keys}) + }, + } +} + +// scopeFlags reads the global scope flags as given on this command line. +func scopeFlags(cmd *cobra.Command) (config.Flags, map[string]bool) { + get := func(name string) string { + v, _ := cmd.Flags().GetString(name) + return v + } + changed := map[string]bool{} + for _, n := range []string{"org", "project", "environment", "location"} { + changed[n] = cmd.Flags().Changed(n) + } + return config.Flags{ + Context: get("context"), + APIURL: get("api-url"), + Org: get("org"), + Project: get("project"), + Env: get("environment"), + Location: get("location"), + }, changed +} + +func describeContext(name string, current bool) string { + if current { + return fmt.Sprintf("context %q (current)", name) + } + return fmt.Sprintf("context %q", name) +} diff --git a/internal/cli/config/context.go b/internal/cli/config/context.go new file mode 100644 index 0000000..c3f1520 --- /dev/null +++ b/internal/cli/config/context.go @@ -0,0 +1,231 @@ +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/devlyft/cli/internal/clierr" + "github.com/devlyft/cli/internal/cmdutil" + "github.com/devlyft/cli/internal/config" + "github.com/devlyft/cli/internal/output" + "github.com/devlyft/cli/internal/prompt" +) + +// contextView is a context as printed. +type contextView struct { + Name string `json:"name"` + Current bool `json:"current"` + config.Context +} + +func view(file *config.File, name string) contextView { + c := file.Contexts[name] + if c == nil { + c = &config.Context{} + } + return contextView{Name: name, Current: file.CurrentContext == name, Context: *c} +} + +func (v *contextView) row() []string { + cur := "" + if v.Current { + cur = "*" + } + return []string{cur, v.Name, v.Account, v.Org, v.Project, v.Env, v.Location} +} + +var contextHeaders = []string{"current", "name", "account", "org", "project", "environment", "location"} + +// NewContextCmd returns `devlyft context`. +func NewContextCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "context", + Short: "Manage named sets of defaults", + Long: `A context saves the organization, project, environment, location and +account that commands use when no flag or environment variable sets them. + +Every value is resolved by the same precedence: flag, then environment +variable (DEVLYFT_ORG, DEVLYFT_PROJECT, DEVLYFT_ENV, DEVLYFT_LOCATION), then +the context, then the default. --context or DEVLYFT_CONTEXT picks a context +for one command without changing the current one.`, + Annotations: noScope, + } + cmd.AddCommand(newContextUseCmd(f), newContextListCmd(f), newContextShowCmd(f), newContextDeleteCmd(f)) + return cmd +} + +func newContextUseCmd(f *cmdutil.Factory) *cobra.Command { + var account string + cmd := &cobra.Command{ + Use: "use NAME", + Short: "Switch to a context, creating it if needed, and save the scope flags into it", + Long: `Make NAME the current context. If it does not exist it is created. + +Any of --org, --project, --environment, --location and --account given on +the same command line are saved into the context; pass an empty value +(--project "") to clear one. Values may be names or IDs; IDs are +recommended for organizations, whose names are not unique.`, + Example: ` devlyft context use prod --org acme --project web --environment production + devlyft context use staging + devlyft context use prod --project ""`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if name == "" { + return clierr.Usagef("context name must not be empty") + } + file := f.Config + if file.Contexts == nil { + file.Contexts = map[string]*config.Context{} + } + c, existed := file.Contexts[name] + if !existed { + c = &config.Context{} + file.Contexts[name] = c + } + flags, changed := scopeFlags(cmd) + if changed["org"] { + c.Org = flags.Org + } + if changed["project"] { + c.Project = flags.Project + } + if changed["environment"] { + c.Env = flags.Env + } + if changed["location"] { + c.Location = flags.Location + } + if cmd.Flags().Changed("account") { + c.Account = account + } + file.CurrentContext = name + if err := file.Save(); err != nil { + return err + } + verb := "Switched to" + if !existed { + verb = "Created and switched to" + } + _, _ = fmt.Fprintf(f.IO.ErrOut, "%s context %q.\n", verb, name) + v := view(file, name) + return f.Printer.Print(output.Result{ + Data: v, + Table: &output.Table{Headers: contextHeaders, Rows: [][]string{v.row()}}, + IDs: []string{name}, + }) + }, + } + cmd.Flags().StringVar(&account, "account", "", "Credential account to use with this context (see 'devlyft auth list')") + return cmd +} + +func newContextListCmd(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List contexts", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + names := f.Config.ContextNames() + views := make([]contextView, 0, len(names)) + t := &output.Table{Headers: contextHeaders} + for _, n := range names { + v := view(f.Config, n) + views = append(views, v) + t.Rows = append(t.Rows, v.row()) + } + return f.Printer.Print(output.Result{Data: views, Table: t, IDs: names}) + }, + } +} + +func newContextShowCmd(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "show [NAME]", + Aliases: []string{"describe"}, + Short: "Show a context, or the settings this command line resolves to", + Long: `With NAME, show that context's saved values. + +Without NAME, show the settings a command would use right now, after +flags, environment variables and the context are applied, and where each +value came from.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + name := args[0] + if _, ok := f.Config.Contexts[name]; !ok { + return config.NoSuchContext(name) + } + v := view(f.Config, name) + return f.Printer.Print(output.Result{ + Data: v, + Table: &output.Table{Headers: contextHeaders, Rows: [][]string{v.row()}}, + IDs: []string{name}, + }) + } + flags, _ := scopeFlags(cmd) + s, err := config.ResolveScope(f.Config, &flags, f.Getenv) + if err != nil { + return err + } + rows := [][]string{} + for _, kv := range []struct { + key string + v config.Value + }{ + {"context", s.Context}, + {"api_url", s.APIURL}, + {"ca_bundle", s.CABundle}, + {"account", s.Account}, + {"org", s.Org}, + {"project", s.Project}, + {"environment", s.Env}, + {"location", s.Location}, + } { + rows = append(rows, []string{kv.key, kv.v.Value, string(kv.v.Source)}) + } + return f.Printer.Print(output.Result{ + Data: s, + Table: &output.Table{Headers: []string{"setting", "value", "source"}, Rows: rows}, + IDs: []string{s.Context.Value}, + }) + }, + } +} + +func newContextDeleteCmd(f *cmdutil.Factory) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "delete NAME", + Short: "Delete a context", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + name := args[0] + if _, ok := f.Config.Contexts[name]; !ok { + return config.NoSuchContext(name) + } + current := f.Config.CurrentContext == name + ok, err := prompt.Confirm(f.IO, "Delete "+describeContext(name, current)+"?", yes) + if err != nil || !ok { + return err + } + delete(f.Config.Contexts, name) + if current { + f.Config.CurrentContext = "" + } + if err := f.Config.Save(); err != nil { + return err + } + _, _ = fmt.Fprintf(f.IO.ErrOut, "Deleted context %q.\n", name) + return f.Printer.Print(output.Result{ + Data: map[string]any{"name": name, "deleted": true}, + Table: &output.Table{Headers: []string{"name", "deleted"}, Rows: [][]string{{name, "true"}}}, + IDs: []string{name}, + }) + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, cmdutil.YesFlagUsage) + return cmd +} diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go new file mode 100644 index 0000000..788040e --- /dev/null +++ b/internal/cli/config_test.go @@ -0,0 +1,234 @@ +package cli_test + +import ( + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/devlyft/cli/internal/cmdutil" + "github.com/devlyft/cli/internal/exitcode" + "github.com/devlyft/cli/internal/testutil/clitest" + "github.com/devlyft/cli/internal/testutil/golden" +) + +// cleanScopeEnv clears every variable that feeds the scope, so the +// developer's shell cannot change the results. +var cleanScopeEnv = map[string]string{ + "DEVLYFT_CONTEXT": "", + "DEVLYFT_API_URL": "", + "DEVLYFT_CA_BUNDLE": "", + "DEVLYFT_ORG": "", + "DEVLYFT_PROJECT": "", + "DEVLYFT_ENV": "", + "DEVLYFT_LOCATION": "", + "DEVLYFT_API_TOKEN": "", + "DEVLYFT_DEBUG": "", + "NO_COLOR": "", +} + +// session runs several commands against one config directory and +// collects their transcripts, with the temporary path replaced. +type session struct { + t *testing.T + dir string + env map[string]string + config string + out strings.Builder +} + +func newSession(t *testing.T) *session { + t.Helper() + return &session{t: t, dir: t.TempDir(), env: cleanScopeEnv} +} + +func (s *session) run(args ...string) clitest.Result { + s.t.Helper() + res := clitest.Run(s.t, &clitest.Options{Args: args, Env: s.env, ConfigDir: s.dir, Config: s.config}) + s.config = "" + out := strings.ReplaceAll(res.Transcript(), s.dir, "$CONFIG_DIR") + s.out.WriteString(strings.ReplaceAll(out, `$CONFIG_DIR\`, "$CONFIG_DIR/")) + s.out.WriteString("\n") + return res +} + +func (s *session) file() string { + s.t.Helper() + b, err := os.ReadFile(filepath.Join(s.dir, "config.yaml")) + if err != nil { + s.t.Fatal(err) + } + return string(b) +} + +//nolint:paralleltest // clitest sets environment variables +func TestConfigAndContexts(t *testing.T) { + s := newSession(t) + + s.run("context", "list") + s.run("context", "use", "prod", "--org", "acme", "--project", "web", "--environment", "production", "--account", "work") + s.run("context", "use", "staging", "--org", "acme-staging", "--location", "eu-west-1") + s.run("context", "list") + s.run("context", "show") + s.run("context", "show", "--context", "prod", "--project", "api") + s.run("context", "show", "prod") + s.run("context", "use", "prod", "--project", "") + s.run("context", "show", "prod", "--format", "json") + s.run("context", "show", "nope") + s.run("context", "delete", "staging") + s.run("context", "delete", "staging", "--yes") + s.run("context", "delete", "nope", "--yes") + s.run("context", "use", "") + + s.run("config", "list") + s.run("config", "set", "api_url", "https://api.example.com/") + s.run("config", "get", "api_url") + s.run("config", "set", "api_url", "http://insecure.example.com") + s.run("config", "set", "current_context", "nope") + s.run("config", "set", "colour", "on") + s.run("config", "get", "colour") + s.run("config", "get", "current_context", "--format", "json") + s.run("context", "show") + + s.out.WriteString("--- config.yaml\n" + s.file()) + golden.Assert(t, filepath.Join("testdata", "config_contexts.golden"), s.out.String()) +} + +//nolint:paralleltest // clitest sets environment variables +func TestScopeErrors(t *testing.T) { + s := newSession(t) + + // A current_context that names a missing context blocks commands that + // need the scope, but config and context commands can still repair it. + s.config = "current_context: gone\n" + s.run("auth", "status") + s.run("context", "show") + s.run("config", "set", "current_context", "") + s.run("context", "show") + + s.run("auth", "status", "--context", "nope") + s.env = merge(cleanScopeEnv, map[string]string{"DEVLYFT_CONTEXT": "nope"}) + s.run("auth", "status") + s.env = merge(cleanScopeEnv, map[string]string{"DEVLYFT_API_URL": "http://api.example.com"}) + s.run("auth", "status") + s.env = cleanScopeEnv + s.run("auth", "status", "--api-url", "not a url") + + // A broken file fails commands that read it, but not version. + s.config = "contexts: [unclosed\n" + res := s.run("context", "list") + if res.Exit != exitcode.Error { + t.Errorf("broken config exit = %d", res.Exit) + } + if res := s.run("completion", "bash"); res.Exit != exitcode.OK { + t.Errorf("completion with broken config exit = %d", res.Exit) + } + + out := s.out.String() + // Drop the completion script itself from the transcript. + if i := strings.Index(out, "$ devlyft completion bash"); i >= 0 { + out = out[:i] + } + golden.Assert(t, filepath.Join("testdata", "config_scope_errors.golden"), out) +} + +// The --context flag and DEVLYFT_* variables reach the account that +// credentials are looked up under. +// +//nolint:paralleltest // clitest sets environment variables +func TestContextAccount(t *testing.T) { + s := newSession(t) + s.config = "contexts:\n work: {account: work}\n" + res := s.run("auth", "status", "--context", "work") + if res.Exit != exitcode.Auth || !strings.Contains(res.Stderr, "not logged in") { + t.Errorf("unexpected result %+v", res) + } +} + +func merge(a, b map[string]string) map[string]string { + out := make(map[string]string, len(a)+len(b)) + for k, v := range a { + out[k] = v + } + for k, v := range b { + out[k] = v + } + return out +} + +// pingCmd fetches a URL with the factory's HTTP client. +func pingCmd(f *cmdutil.Factory) []*cobra.Command { + return []*cobra.Command{{ + Use: "ping URL", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := f.HTTPClient() + if err != nil { + return err + } + req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, args[0], http.NoBody) + if err != nil { + return err + } + resp, err := c.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + _, err = io.Copy(cmd.OutOrStdout(), resp.Body) + return err + }, + }} +} + +//nolint:paralleltest // clitest sets environment variables +func TestCABundleNotice(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "pong") + })) + t.Cleanup(srv.Close) + bundle := filepath.Join(t.TempDir(), "corp-ca.pem") + if err := os.WriteFile(bundle, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}), 0o600); err != nil { + t.Fatal(err) + } + + res := clitest.Run(t, &clitest.Options{Args: []string{"ping", srv.URL}, Env: cleanScopeEnv, Commands: pingCmd}) + if res.Exit == exitcode.OK || strings.Contains(res.Stderr, "Notice") { + t.Errorf("without a bundle: %+v", res) + } + + res = clitest.Run(t, &clitest.Options{ + Args: []string{"ping", srv.URL, "--quiet"}, + Env: merge(cleanScopeEnv, map[string]string{"DEVLYFT_CA_BUNDLE": bundle}), + Commands: pingCmd, + }) + want := "Notice: trusting extra CA certificates from " + bundle + " (DEVLYFT_CA_BUNDLE).\n" + if res.Exit != exitcode.OK || res.Stdout != "pong" || res.Stderr != want { + t.Errorf("with DEVLYFT_CA_BUNDLE: %+v", res) + } + + res = clitest.Run(t, &clitest.Options{ + Args: []string{"ping", srv.URL}, + Env: cleanScopeEnv, + Config: "ca_bundle: " + bundle + "\n", + Commands: pingCmd, + }) + if res.Exit != exitcode.OK || !strings.Contains(res.Stderr, "(ca_bundle in the config file)") { + t.Errorf("with ca_bundle: %+v", res) + } + + res = clitest.Run(t, &clitest.Options{ + Args: []string{"ping", srv.URL}, + Env: merge(cleanScopeEnv, map[string]string{"DEVLYFT_CA_BUNDLE": filepath.Join(t.TempDir(), "missing.pem")}), + Commands: pingCmd, + }) + if res.Exit != exitcode.Usage || !strings.Contains(res.Stderr, "cannot read CA bundle") { + t.Errorf("with a missing bundle: %+v", res) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 6a0d00f..0a27e72 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "net/http" "os" "strings" "sync" @@ -17,6 +18,7 @@ import ( "github.com/devlyft/cli/internal/auth" "github.com/devlyft/cli/internal/auth/credstore" authcmd "github.com/devlyft/cli/internal/cli/auth" + configcmd "github.com/devlyft/cli/internal/cli/config" "github.com/devlyft/cli/internal/clierr" "github.com/devlyft/cli/internal/cmdutil" "github.com/devlyft/cli/internal/config" @@ -33,7 +35,7 @@ type globalFlags struct { quiet bool noColor bool verbosity string - apiURL string + scope config.Flags } // NewRootCmd returns the root `devlyft` command with all subcommands attached. @@ -47,8 +49,8 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { Long: "devlyft is the official command-line interface for DevLyft.", SilenceUsage: true, SilenceErrors: true, - PersistentPreRunE: func(*cobra.Command, []string) error { - return setup(f, &flags) + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + return setup(cmd, f, &flags) }, } root.SetIn(f.IO.In) @@ -64,17 +66,25 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { pf.BoolVarP(&flags.quiet, "quiet", "q", false, "Print only resource IDs") pf.BoolVar(&flags.noColor, "no-color", false, "Disable colour (also NO_COLOR)") pf.StringVar(&flags.verbosity, "verbosity", "warning", "Log level for diagnostics on stderr: "+strings.Join(logging.Verbosities, ", ")+" (DEVLYFT_DEBUG=1 for debug)") - pf.StringVar(&flags.apiURL, "api-url", "", "DevLyft API URL (also DEVLYFT_API_URL; default "+config.DefaultAPIURL+")") + pf.StringVar(&flags.scope.APIURL, "api-url", "", "DevLyft API URL (also "+config.EnvAPIURL+"; default "+config.DefaultAPIURL+")") + pf.StringVar(&flags.scope.Context, "context", "", "Context to use for this command instead of the current one (also "+config.EnvContext+")") + pf.StringVar(&flags.scope.Org, "org", "", "Organization ID, slug or name (also "+config.EnvOrg+")") + pf.StringVar(&flags.scope.Project, "project", "", "Project ID or name (also "+config.EnvProject+")") + pf.StringVar(&flags.scope.Env, "environment", "", "Environment ID or name (also "+config.EnvEnv+")") + pf.StringVar(&flags.scope.Location, "location", "", "Location (also "+config.EnvLocation+")") root.AddCommand(newVersionCmd()) root.AddCommand(newCompletionCmd()) root.AddCommand(authcmd.NewCmd(f)) + root.AddCommand(configcmd.NewConfigCmd(f)) + root.AddCommand(configcmd.NewContextCmd(f)) return root } -// setup validates the global flags and builds the printer and logger. -func setup(f *cmdutil.Factory, flags *globalFlags) error { +// setup validates the global flags, builds the printer and logger, loads +// the config file and resolves the scope. +func setup(cmd *cobra.Command, f *cmdutil.Factory, flags *globalFlags) error { format, err := output.ParseFormat(flags.format) if err != nil { return err @@ -99,25 +109,70 @@ func setup(f *cmdutil.Factory, flags *globalFlags) error { } f.Log = logging.NewLogger(f.IO.ErrOut, level, enabled) - setupAuth(f, flags) - return nil -} - -// setupAuth fills the API and credential dependencies tests have not -// already set. -func setupAuth(f *cmdutil.Factory, flags *globalFlags) { if f.Getenv == nil { f.Getenv = os.Getenv } + if annotated(cmd, cmdutil.NoConfig) { + return nil + } + if f.ConfigDir == "" { + dir, err := config.Dir() + if err != nil { + return err + } + f.ConfigDir = dir + } + if f.Config == nil { + file, err := config.Load(f.ConfigDir) + if err != nil { + return err + } + f.Config = file + } + if annotated(cmd, cmdutil.NoScope) { + return nil + } + if f.Scope == nil { + scope, err := config.ResolveScope(f.Config, &flags.scope, f.Getenv) + if err != nil { + return err + } + f.Scope = scope + } if f.APIURL == "" { - f.APIURL = config.ResolveAPIURL(flags.apiURL, f.Getenv) + f.APIURL = f.Scope.APIURL.Value + } + setupNetwork(f, flags) + return nil +} + +// annotated reports whether cmd or one of its parents carries key. +func annotated(cmd *cobra.Command, key string) bool { + for c := cmd; c != nil; c = c.Parent() { + if c.Annotations[key] == "true" { + return true + } + } + return false +} + +// setupNetwork fills the HTTP, API and credential dependencies tests have +// not already set. +func setupNetwork(f *cmdutil.Factory, flags *globalFlags) { + if f.HTTPClient == nil { + f.HTTPClient = sync.OnceValues(func() (*http.Client, error) { + ca := f.Scope.CABundle + if ca.Value != "" { + // Always shown, even with --quiet: trusting extra CAs + // changes who can read this traffic. + _, _ = fmt.Fprintf(f.IO.ErrOut, "Notice: trusting extra CA certificates from %s (%s).\n", ca.Value, caSource(ca.Source)) + } + return config.NewHTTPClient(config.HTTPOptions{CABundle: ca.Value, Getenv: f.Getenv}) + }) } if f.Credentials == nil { f.Credentials = sync.OnceValues(func() (credstore.Store, error) { - dir, err := config.Dir() - if err != nil { - return nil, err - } + dir := f.ConfigDir timeout := credstore.TimeoutNonInteractive if f.IO.CanPrompt() { timeout = credstore.TimeoutInteractive @@ -135,11 +190,22 @@ func setupAuth(f *cmdutil.Factory, flags *globalFlags) { } if f.NewAPIClient == nil { f.NewAPIClient = func(tokens api.TokenSource) (api.Client, error) { - return api.New(api.Options{BaseURL: f.APIURL, Tokens: tokens, Logger: f.Log}) + hc, err := f.HTTPClient() + if err != nil { + return nil, err + } + return api.New(api.Options{BaseURL: f.APIURL, Tokens: tokens, Logger: f.Log, HTTPClient: hc}) } } } +func caSource(src config.Source) string { + if src == config.SourceEnv { + return config.EnvCABundle + } + return "ca_bundle in the config file" +} + // Execute runs the CLI with args and returns the process exit code. It is // the only place errors are printed and mapped to exit codes. func Execute(ctx context.Context, ios *iostreams.IOStreams, args []string) int { diff --git a/internal/cli/testdata/config_contexts.golden b/internal/cli/testdata/config_contexts.golden new file mode 100644 index 0000000..b3188cc --- /dev/null +++ b/internal/cli/testdata/config_contexts.golden @@ -0,0 +1,191 @@ +$ devlyft context list +--- exit 0 +--- stdout +--- stderr + +$ devlyft context use prod --org acme --project web --environment production --account work +--- exit 0 +--- stdout +* prod work acme web production +--- stderr +Created and switched to context "prod". + +$ devlyft context use staging --org acme-staging --location eu-west-1 +--- exit 0 +--- stdout +* staging acme-staging eu-west-1 +--- stderr +Created and switched to context "staging". + +$ devlyft context list +--- exit 0 +--- stdout + prod work acme web production +* staging acme-staging eu-west-1 +--- stderr + +$ devlyft context show +--- exit 0 +--- stdout +context staging config +api_url https://api.devlyft.io default +ca_bundle unset +account unset +org acme-staging context +project unset +environment unset +location eu-west-1 context +--- stderr + +$ devlyft context show --context prod --project api +--- exit 0 +--- stdout +context prod flag +api_url https://api.devlyft.io default +ca_bundle unset +account work context +org acme context +project api flag +environment production context +location unset +--- stderr + +$ devlyft context show prod +--- exit 0 +--- stdout + prod work acme web production +--- stderr + +$ devlyft context use prod --project +--- exit 0 +--- stdout +* prod work acme production +--- stderr +Switched to context "prod". + +$ devlyft context show prod --format json +--- exit 0 +--- stdout +{ + "name": "prod", + "current": true, + "account": "work", + "org": "acme", + "env": "production" +} +--- stderr + +$ devlyft context show nope +--- exit 4 +--- stdout +--- stderr +Error: context "nope" does not exist +Hint: Run `devlyft context list` to see contexts, or `devlyft context use nope` to create it. + +$ devlyft context delete staging +--- exit 2 +--- stdout +--- stderr +Error: Delete context "staging": pass --yes to confirm when not running interactively +Hint: Run 'devlyft context delete --help' for usage. + +$ devlyft context delete staging --yes +--- exit 0 +--- stdout +staging true +--- stderr +Deleted context "staging". + +$ devlyft context delete nope --yes +--- exit 4 +--- stdout +--- stderr +Error: context "nope" does not exist +Hint: Run `devlyft context list` to see contexts, or `devlyft context use nope` to create it. + +$ devlyft context use +--- exit 2 +--- stdout +--- stderr +Error: context name must not be empty +Hint: Run 'devlyft context use --help' for usage. + +$ devlyft config list +--- exit 0 +--- stdout +api_url +ca_bundle +current_context prod +path $CONFIG_DIR/config.yaml +--- stderr + +$ devlyft config set api_url https://api.example.com/ +--- exit 0 +--- stdout +api_url https://api.example.com +--- stderr + +$ devlyft config get api_url +--- exit 0 +--- stdout +https://api.example.com +--- stderr + +$ devlyft config set api_url http://insecure.example.com +--- exit 2 +--- stdout +--- stderr +Error: invalid API URL "http://insecure.example.com": must use https +Hint: Run 'devlyft config set --help' for usage. + +$ devlyft config set current_context nope +--- exit 4 +--- stdout +--- stderr +Error: context "nope" does not exist +Hint: Run `devlyft context list` to see contexts, or `devlyft context use nope` to create it. + +$ devlyft config set colour on +--- exit 2 +--- stdout +--- stderr +Error: unknown config key "colour": must be one of api_url, ca_bundle, current_context +Hint: Run 'devlyft config set --help' for usage. + +$ devlyft config get colour +--- exit 2 +--- stdout +--- stderr +Error: unknown config key "colour": must be one of api_url, ca_bundle, current_context +Hint: Run 'devlyft config get --help' for usage. + +$ devlyft config get current_context --format json +--- exit 0 +--- stdout +{ + "key": "current_context", + "value": "prod" +} +--- stderr + +$ devlyft context show +--- exit 0 +--- stdout +context prod config +api_url https://api.example.com config +ca_bundle unset +account work context +org acme context +project unset +environment production context +location unset +--- stderr + +--- config.yaml +api_url: https://api.example.com +current_context: prod +contexts: + prod: + account: work + org: acme + env: production diff --git a/internal/cli/testdata/config_scope_errors.golden b/internal/cli/testdata/config_scope_errors.golden new file mode 100644 index 0000000..79ee2c7 --- /dev/null +++ b/internal/cli/testdata/config_scope_errors.golden @@ -0,0 +1,68 @@ +$ devlyft auth status +--- exit 4 +--- stdout +--- stderr +Error: context "gone" does not exist +Hint: current_context in $CONFIG_DIR/config.yaml names a missing context; run `devlyft config set current_context ""` or `devlyft context use NAME`. + +$ devlyft context show +--- exit 4 +--- stdout +--- stderr +Error: context "gone" does not exist +Hint: current_context in $CONFIG_DIR/config.yaml names a missing context; run `devlyft config set current_context ""` or `devlyft context use NAME`. + +$ devlyft config set current_context +--- exit 0 +--- stdout +current_context +--- stderr + +$ devlyft context show +--- exit 0 +--- stdout +context unset +api_url https://api.devlyft.io default +ca_bundle unset +account unset +org unset +project unset +environment unset +location unset +--- stderr + +$ devlyft auth status --context nope +--- exit 4 +--- stdout +--- stderr +Error: context "nope" does not exist +Hint: Run `devlyft context list` to see contexts, or `devlyft context use nope` to create it. + +$ devlyft auth status +--- exit 4 +--- stdout +--- stderr +Error: context "nope" does not exist +Hint: Run `devlyft context list` to see contexts, or `devlyft context use nope` to create it. + +$ devlyft auth status +--- exit 2 +--- stdout +--- stderr +Error: invalid API URL "http://api.example.com": must use https (from DEVLYFT_API_URL) +Hint: Run 'devlyft auth status --help' for usage. + +$ devlyft auth status --api-url not a url +--- exit 2 +--- stdout +--- stderr +Error: invalid API URL "not a url": must be an absolute https URL +Hint: Run 'devlyft auth status --help' for usage. + +$ devlyft context list +--- exit 1 +--- stdout +--- stderr +Error: invalid config file $CONFIG_DIR/config.yaml: yaml: line 1: did not find expected ',' or ']' +Hint: Fix or remove the file. + diff --git a/internal/cli/version.go b/internal/cli/version.go index 3e411c9..544f2b8 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -5,14 +5,16 @@ import ( "github.com/spf13/cobra" + "github.com/devlyft/cli/internal/cmdutil" "github.com/devlyft/cli/internal/version" ) func newVersionCmd() *cobra.Command { return &cobra.Command{ - Use: "version", - Short: "Print the devlyft version", - Args: cobra.NoArgs, + Use: "version", + Annotations: map[string]string{cmdutil.NoConfig: "true"}, + Short: "Print the devlyft version", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { _, err := fmt.Fprintln(cmd.OutOrStdout(), version.Get()) return err diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 74167ba..d756cdb 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -6,6 +6,7 @@ package cmdutil import ( "errors" "log/slog" + "net/http" "github.com/devlyft/cli/internal/api" "github.com/devlyft/cli/internal/auth" @@ -22,13 +23,26 @@ type Factory struct { Printer *output.Printer Log *slog.Logger - // APIURL is the API origin from --api-url, DEVLYFT_API_URL or the - // default. + // ConfigDir holds config.yaml and credentials.json; empty means + // config.Dir(). + ConfigDir string + // Config is the loaded config file. Nil for commands that do not + // read it (version, completion). + Config *config.File + // Scope is the resolved API URL, context and defaults. Nil for + // commands that do not need it (version, completion, config, context). + Scope *config.Scope + + // APIURL is the API origin from the scope. APIURL string // Getenv reads the environment. Getenv func(string) string // Credentials opens the credential store. Credentials func() (credstore.Store, error) + // HTTPClient returns the client every network call uses, with the + // proxy and CA bundle settings applied. Use it for the API and for + // anything else that dials out, such as the log stream. + HTTPClient func() (*http.Client, error) // NewAPIClient returns an API client for APIURL using tokens. NewAPIClient func(tokens api.TokenSource) (api.Client, error) @@ -40,9 +54,18 @@ type Factory struct { // Host is the API host credentials are stored under. func (f *Factory) Host() string { return config.Host(f.APIURL) } +// Account is the account credentials are stored under: the active +// context's account, or auth.DefaultAccount. +func (f *Factory) Account() string { + if f.Scope != nil && f.Scope.Account.Value != "" { + return f.Scope.Account.Value + } + return auth.DefaultAccount +} + // Resolver finds the credential for a command. func (f *Factory) Resolver() *auth.Resolver { - return &auth.Resolver{Getenv: f.Getenv, Store: f.Credentials} + return &auth.Resolver{Getenv: f.Getenv, Store: f.Credentials, Account: f.Account()} } // APIClient returns an API client authenticated with the resolved @@ -68,3 +91,12 @@ func (f *Factory) Client() (api.Client, error) { } return f.APIClient() } + +// Annotations on commands that change how the root sets them up. +const ( + // NoConfig skips loading the config file and resolving the scope. + NoConfig = "devlyft/no-config" + // NoScope loads the config file but does not resolve the scope, so + // that config and context commands can repair a broken setting. + NoScope = "devlyft/no-scope" +) diff --git a/internal/config/apiurl.go b/internal/config/apiurl.go deleted file mode 100644 index 83df5e0..0000000 --- a/internal/config/apiurl.go +++ /dev/null @@ -1,50 +0,0 @@ -// Package config reads the config file and contexts and resolves org, -// project, environment and location. -// -// Until contexts land (devlyft/devlyft#244) it only resolves the API URL and -// the config directory. -package config - -import ( - "net/url" - "os" - "path/filepath" - "strings" -) - -// DefaultAPIURL is the production API. -const DefaultAPIURL = "https://api.devlyft.io" - -// ResolveAPIURL returns the API URL by precedence: the --api-url flag, then -// DEVLYFT_API_URL, then the default. -func ResolveAPIURL(flag string, getenv func(string) string) string { - if flag != "" { - return strings.TrimRight(flag, "/") - } - if v := getenv("DEVLYFT_API_URL"); v != "" { - return strings.TrimRight(v, "/") - } - return DefaultAPIURL -} - -// Host returns the lower-case host[:port] of an API URL, the key that -// credentials are stored under. It returns the input unchanged if it does -// not parse as a URL with a host. -func Host(apiURL string) string { - u, err := url.Parse(apiURL) - if err != nil || u.Host == "" { - return apiURL - } - return strings.ToLower(u.Host) -} - -// Dir returns the CLI's config directory: ~/.config/devlyft on Linux, -// ~/Library/Application Support/devlyft on macOS and %AppData%\devlyft on -// Windows. XDG_CONFIG_HOME is honoured where os.UserConfigDir honours it. -func Dir() (string, error) { - base, err := os.UserConfigDir() - if err != nil { - return "", err - } - return filepath.Join(base, "devlyft"), nil -} diff --git a/internal/config/apiurl_test.go b/internal/config/apiurl_test.go deleted file mode 100644 index 228f795..0000000 --- a/internal/config/apiurl_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package config - -import "testing" - -func TestResolveAPIURL(t *testing.T) { - t.Parallel() - - env := func(v string) func(string) string { - return func(k string) string { - if k == "DEVLYFT_API_URL" { - return v - } - return "" - } - } - tests := []struct { - name, flag, env, want string - }{ - {"default", "", "", DefaultAPIURL}, - {"env", "", "https://api.example.com/", "https://api.example.com"}, - {"flag beats env", "https://flag.example.com", "https://api.example.com", "https://flag.example.com"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := ResolveAPIURL(tt.flag, env(tt.env)); got != tt.want { - t.Errorf("ResolveAPIURL() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestHost(t *testing.T) { - t.Parallel() - - tests := map[string]string{ - "https://API.devlyft.io": "api.devlyft.io", - "https://api.devlyft.io/v1": "api.devlyft.io", - "http://127.0.0.1:8080": "127.0.0.1:8080", - "not a url": "not a url", - } - for in, want := range tests { - if got := Host(in); got != want { - t.Errorf("Host(%q) = %q, want %q", in, got, want) - } - } -} - -func TestDir(t *testing.T) { - t.Parallel() - - dir, err := Dir() - if err != nil { - t.Skipf("no user config dir: %v", err) - } - if dir == "" { - t.Error("Dir() returned empty path") - } -} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6355376 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,235 @@ +// Package config reads and writes the config file, resolves the scope a +// command runs in (API URL, context, account, org, project, environment and +// location) and builds the HTTP client with proxy and CA bundle settings. +// +// Every scope value is resolved by the same precedence: flag, then +// environment variable, then the active context (or, for api_url and +// ca_bundle, the top level of the config file), then the default. +package config + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + "go.yaml.in/yaml/v3" + + "github.com/devlyft/cli/internal/clierr" +) + +// DefaultAPIURL is the production API. +const DefaultAPIURL = "https://api.devlyft.io" + +// FileName is the config file inside Dir. +const FileName = "config.yaml" + +// File is the config file. Keys this version does not know are kept in +// Extra and written back unchanged, so an older CLI never drops settings a +// newer one wrote. +type File struct { + APIURL string `yaml:"api_url,omitempty"` + CABundle string `yaml:"ca_bundle,omitempty"` + CurrentContext string `yaml:"current_context,omitempty"` + Contexts map[string]*Context `yaml:"contexts,omitempty"` + Extra map[string]any `yaml:",inline"` + + path string +} + +// Context is a named set of defaults for the scope a command runs in. +// Values are names or IDs, resolved when a command needs them. +type Context struct { + Account string `yaml:"account,omitempty" json:"account,omitempty"` + Org string `yaml:"org,omitempty" json:"org,omitempty"` + Project string `yaml:"project,omitempty" json:"project,omitempty"` + Env string `yaml:"env,omitempty" json:"env,omitempty"` + Location string `yaml:"location,omitempty" json:"location,omitempty"` + Extra map[string]any `yaml:",inline" json:"-"` +} + +// Dir returns the CLI's config directory: ~/.config/devlyft on Linux, +// ~/Library/Application Support/devlyft on macOS and %AppData%\devlyft on +// Windows. XDG_CONFIG_HOME is honoured where os.UserConfigDir honours it. +func Dir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(base, "devlyft"), nil +} + +// Load reads dir/config.yaml. A missing file is an empty config. +func Load(dir string) (*File, error) { + path := filepath.Join(dir, FileName) + f := &File{path: path} + b, err := os.ReadFile(path) //nolint:gosec // the CLI's own config file + if errors.Is(err, fs.ErrNotExist) { + return f, nil + } + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + if len(bytes.TrimSpace(b)) == 0 { + return f, nil + } + if err := yaml.Unmarshal(b, f); err != nil { + e := clierr.New(clierr.CodeInvalidArgument, "invalid config file %s: %v", path, err) + e.Hint = "Fix or remove the file." + return nil, e + } + for name, c := range f.Contexts { + if c == nil { + f.Contexts[name] = &Context{} + } + } + return f, nil +} + +// Path is the file the config was loaded from. +func (f *File) Path() string { return f.path } + +// Save writes the config atomically with mode 0600 in a 0700 directory. +func (f *File) Save() error { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(f); err != nil { + return err + } + if err := enc.Close(); err != nil { + return err + } + b := buf.Bytes() + dir := filepath.Dir(f.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("writing config: %w", err) + } + tmp, err := os.CreateTemp(dir, ".config-*.yaml") + if err != nil { + return fmt.Errorf("writing config: %w", err) + } + defer func() { _ = os.Remove(tmp.Name()) }() + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing config: %w", err) + } + if _, err := tmp.Write(b); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("writing config: %w", err) + } + if err := os.Rename(tmp.Name(), f.path); err != nil { + return fmt.Errorf("writing config: %w", err) + } + return nil +} + +// ContextNames returns the context names, sorted. +func (f *File) ContextNames() []string { + names := make([]string, 0, len(f.Contexts)) + for n := range f.Contexts { + names = append(names, n) + } + sort.Strings(names) + return names +} + +// Keys are the top-level settings `config get|set|list` manage. +var Keys = []string{"api_url", "ca_bundle", "current_context"} + +// Get returns a top-level setting. +func (f *File) Get(key string) (string, error) { + switch key { + case "api_url": + return f.APIURL, nil + case "ca_bundle": + return f.CABundle, nil + case "current_context": + return f.CurrentContext, nil + } + return "", unknownKey(key) +} + +// Set validates and sets a top-level setting. An empty value unsets it. +func (f *File) Set(key, value string) error { + switch key { + case "api_url": + if value != "" { + u, err := NormalizeAPIURL(value) + if err != nil { + return err + } + value = u + } + f.APIURL = value + case "ca_bundle": + if value != "" { + abs, err := filepath.Abs(value) + if err != nil { + return clierr.Usagef("invalid ca_bundle %q: %v", value, err) + } + if _, err := LoadCABundle(abs); err != nil { + return err + } + value = abs + } + f.CABundle = value + case "current_context": + if value != "" { + if _, ok := f.Contexts[value]; !ok { + return NoSuchContext(value) + } + } + f.CurrentContext = value + default: + return unknownKey(key) + } + return nil +} + +func unknownKey(key string) *clierr.Error { + return clierr.Usagef("unknown config key %q: must be one of %s", key, strings.Join(Keys, ", ")) +} + +// NoSuchContext is the error for a context name that does not exist. +func NoSuchContext(name string) *clierr.Error { + e := clierr.New(clierr.CodeNotFound, "context %q does not exist", name) + e.Hint = "Run `devlyft context list` to see contexts, or `devlyft context use " + name + "` to create it." + return e +} + +// NormalizeAPIURL validates an API URL and strips trailing slashes. It must +// use https, except for a loopback host. +func NormalizeAPIURL(s string) (string, error) { + u, err := url.Parse(s) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + return "", clierr.Usagef("invalid API URL %q: must be an absolute https URL", s) + } + if u.Scheme == "http" && !isLoopback(u.Hostname()) { + return "", clierr.Usagef("invalid API URL %q: must use https", s) + } + return strings.TrimRight(s, "/"), nil +} + +func isLoopback(host string) bool { + return host == "localhost" || host == "::1" || strings.HasPrefix(host, "127.") +} + +// Host returns the lower-case host[:port] of an API URL, the key that +// credentials are stored under. It returns the input unchanged if it does +// not parse as a URL with a host. +func Host(apiURL string) string { + u, err := url.Parse(apiURL) + if err != nil || u.Host == "" { + return apiURL + } + return strings.ToLower(u.Host) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2c697e0 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,214 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/devlyft/cli/internal/clierr" + "github.com/devlyft/cli/internal/exitcode" +) + +func TestLoadMissingAndEmpty(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + f, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if f.Path() != filepath.Join(dir, FileName) || len(f.Contexts) != 0 { + t.Errorf("unexpected config %+v", f) + } + + if err := os.WriteFile(filepath.Join(dir, FileName), []byte("\n \n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(dir); err != nil { + t.Errorf("empty file: %v", err) + } +} + +func TestLoadInvalid(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, FileName), []byte("contexts: [1, 2\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := Load(dir) + if err == nil || !strings.Contains(err.Error(), "invalid config file") { + t.Fatalf("err = %v, want invalid config file", err) + } +} + +func TestSaveRoundTripKeepsUnknownKeys(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "devlyft") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + in := `api_url: https://api.example.com +future_setting: keep me +current_context: prod +contexts: + prod: + org: acme + project: web + future_field: 42 + empty: +` + if err := os.WriteFile(filepath.Join(dir, FileName), []byte(in), 0o600); err != nil { + t.Fatal(err) + } + f, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if f.Contexts["empty"] == nil { + t.Error("null context not normalised") + } + f.Contexts["prod"].Env = "production" + if err := f.Save(); err != nil { + t.Fatal(err) + } + + b, err := os.ReadFile(filepath.Join(dir, FileName)) //nolint:gosec // test temp dir + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"future_setting: keep me", "future_field: 42", "env: production", "org: acme"} { + if !strings.Contains(string(b), want) { + t.Errorf("saved config lacks %q:\n%s", want, b) + } + } +} + +func TestSaveModes(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("Unix permissions") + } + + dir := filepath.Join(t.TempDir(), "new", "devlyft") + f, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := f.Set("api_url", "https://api.example.com/"); err != nil { + t.Fatal(err) + } + if err := f.Save(); err != nil { + t.Fatal(err) + } + st, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o700 { + t.Errorf("dir mode = %v, want 0700", st.Mode().Perm()) + } + st, err = os.Stat(filepath.Join(dir, FileName)) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Errorf("file mode = %v, want 0600", st.Mode().Perm()) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Errorf("temporary files left behind: %v", entries) + } +} + +func TestGetSet(t *testing.T) { + t.Parallel() + + ca := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(ca, testCAPEM(t), 0o600); err != nil { + t.Fatal(err) + } + notPEM := filepath.Join(t.TempDir(), "x.pem") + if err := os.WriteFile(notPEM, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + + f := &File{Contexts: map[string]*Context{"prod": {}}} + tests := []struct { + key, value, want string + exit int + }{ + {"api_url", "https://api.example.com/", "https://api.example.com", exitcode.OK}, + {"api_url", "http://127.0.0.1:8080", "http://127.0.0.1:8080", exitcode.OK}, + {"api_url", "http://api.example.com", "", exitcode.Usage}, + {"api_url", "api.example.com", "", exitcode.Usage}, + {"api_url", "", "", exitcode.OK}, + {"ca_bundle", ca, ca, exitcode.OK}, + {"ca_bundle", notPEM, "", exitcode.Usage}, + {"ca_bundle", filepath.Join(t.TempDir(), "missing.pem"), "", exitcode.Usage}, + {"current_context", "prod", "prod", exitcode.OK}, + {"current_context", "nope", "", exitcode.NotFound}, + {"current_context", "", "", exitcode.OK}, + {"colour", "on", "", exitcode.Usage}, + } + for _, tt := range tests { + err := f.Set(tt.key, tt.value) + if got := clierr.ExitCode(err); got != tt.exit { + t.Errorf("Set(%q, %q) exit = %d (%v), want %d", tt.key, tt.value, got, err, tt.exit) + continue + } + if err != nil { + continue + } + if got, _ := f.Get(tt.key); got != tt.want { + t.Errorf("after Set(%q, %q), Get = %q, want %q", tt.key, tt.value, got, tt.want) + } + } + if _, err := f.Get("colour"); err == nil { + t.Error("Get(unknown) should fail") + } +} + +func TestHost(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "https://API.devlyft.io": "api.devlyft.io", + "https://api.devlyft.io/v1": "api.devlyft.io", + "http://127.0.0.1:8080": "127.0.0.1:8080", + "not a url": "not a url", + } + for in, want := range tests { + if got := Host(in); got != want { + t.Errorf("Host(%q) = %q, want %q", in, got, want) + } + } +} + +func TestDir(t *testing.T) { + t.Parallel() + + dir, err := Dir() + if err != nil { + t.Skip("no user config dir:", err) + } + if filepath.Base(dir) != "devlyft" { + t.Errorf("Dir() = %q", dir) + } +} + +func TestNoSuchContext(t *testing.T) { + t.Parallel() + + e := NoSuchContext("prod") + if !errors.Is(e, e) || e.ExitCode() != exitcode.NotFound || !strings.Contains(e.Hint, "context use prod") { + t.Errorf("unexpected error %+v", e) + } +} diff --git a/internal/config/http.go b/internal/config/http.go new file mode 100644 index 0000000..cd959e3 --- /dev/null +++ b/internal/config/http.go @@ -0,0 +1,81 @@ +package config + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "net/http" + "net/url" + "os" + + "golang.org/x/net/http/httpproxy" + + "github.com/devlyft/cli/internal/clierr" +) + +// HTTPOptions configure the HTTP client every network call uses: the API +// facade, the log stream and anything after them. +type HTTPOptions struct { + // CABundle is a PEM file of extra trusted CAs, added to the system + // pool. Empty uses the system pool alone. + CABundle string + // Getenv reads HTTPS_PROXY, HTTP_PROXY and NO_PROXY (either case). + Getenv func(string) string +} + +// NewHTTPClient returns a client that honours the proxy environment +// variables and trusts the CA bundle on top of the system roots. +func NewHTTPClient(opts HTTPOptions) (*http.Client, error) { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, errors.New("http.DefaultTransport is not an *http.Transport") + } + t := base.Clone() + + proxy := proxyConfig(opts.Getenv).ProxyFunc() + t.Proxy = func(r *http.Request) (*url.URL, error) { return proxy(r.URL) } + + if opts.CABundle != "" { + pool, err := LoadCABundle(opts.CABundle) + if err != nil { + return nil, err + } + t.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} + } + return &http.Client{Transport: t}, nil +} + +// proxyConfig reads the proxy variables through getenv, so that tests and +// callers control them. Unlike http.ProxyFromEnvironment it does not cache +// them for the life of the process. +func proxyConfig(getenv func(string) string) *httpproxy.Config { + get := func(upper, lower string) string { + if v := getenv(upper); v != "" { + return v + } + return getenv(lower) + } + return &httpproxy.Config{ + HTTPProxy: get("HTTP_PROXY", "http_proxy"), + HTTPSProxy: get("HTTPS_PROXY", "https_proxy"), + NoProxy: get("NO_PROXY", "no_proxy"), + CGI: getenv("REQUEST_METHOD") != "", + } +} + +// LoadCABundle returns the system roots plus the certificates in the PEM +// file at path. +func LoadCABundle(path string) (*x509.CertPool, error) { + b, err := os.ReadFile(path) //nolint:gosec // a path the user configured + if err != nil { + return nil, clierr.Usagef("cannot read CA bundle: %v", err) + } + pool, err := x509.SystemCertPool() + if err != nil || pool == nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM(b) { + return nil, clierr.Usagef("CA bundle %s contains no PEM certificates", path) + } + return pool, nil +} diff --git a/internal/config/http_test.go b/internal/config/http_test.go new file mode 100644 index 0000000..839214a --- /dev/null +++ b/internal/config/http_test.go @@ -0,0 +1,115 @@ +package config + +import ( + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// testCAPEM returns the certificate of a throwaway TLS server in PEM form. +func testCAPEM(t *testing.T) []byte { + t.Helper() + srv := httptest.NewTLSServer(http.NotFoundHandler()) + t.Cleanup(srv.Close) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) +} + +func TestCABundle(t *testing.T) { + t.Parallel() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + t.Cleanup(srv.Close) + + get := func(c *http.Client) error { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, http.NoBody) + if err != nil { + return err + } + resp, err := c.Do(req) + if err != nil { + return err + } + return resp.Body.Close() + } + + plain, err := NewHTTPClient(HTTPOptions{Getenv: env(nil)}) + if err != nil { + t.Fatal(err) + } + if err := get(plain); err == nil { + t.Fatal("request to a server with an untrusted certificate succeeded without a CA bundle") + } + + bundle := filepath.Join(t.TempDir(), "ca.pem") + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) + if err := os.WriteFile(bundle, pemBytes, 0o600); err != nil { + t.Fatal(err) + } + trusting, err := NewHTTPClient(HTTPOptions{CABundle: bundle, Getenv: env(nil)}) + if err != nil { + t.Fatal(err) + } + if err := get(trusting); err != nil { + t.Fatalf("request with the CA bundle failed: %v", err) + } + + if _, err := NewHTTPClient(HTTPOptions{CABundle: filepath.Join(t.TempDir(), "missing.pem"), Getenv: env(nil)}); err == nil { + t.Error("missing CA bundle accepted") + } +} + +func TestProxyFromEnvironment(t *testing.T) { + t.Parallel() + + var proxied []string + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // A forward proxy receives the absolute URL. + proxied = append(proxied, r.URL.String()) + _, _ = io.WriteString(w, "via proxy") + })) + t.Cleanup(proxy.Close) + + do := func(c *http.Client, target string) (string, error) { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, http.NoBody) + if err != nil { + return "", err + } + resp, err := c.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + b, err := io.ReadAll(resp.Body) + return string(b), err + } + + c, err := NewHTTPClient(HTTPOptions{Getenv: env(map[string]string{"http_proxy": proxy.URL})}) + if err != nil { + t.Fatal(err) + } + body, err := do(c, "http://api.devlyft.test/v1/organizations") + if err != nil { + t.Fatal(err) + } + if body != "via proxy" || len(proxied) != 1 || proxied[0] != "http://api.devlyft.test/v1/organizations" { + t.Errorf("body %q, proxied %v", body, proxied) + } + + // NO_PROXY bypasses the proxy; the direct request then fails to resolve. + c, err = NewHTTPClient(HTTPOptions{Getenv: env(map[string]string{"HTTP_PROXY": proxy.URL, "NO_PROXY": ".devlyft.test"})}) + if err != nil { + t.Fatal(err) + } + if _, err := do(c, "http://api.devlyft.test/"); err == nil { + t.Error("NO_PROXY host went through the proxy") + } + if len(proxied) != 1 { + t.Errorf("proxied %v", proxied) + } +} diff --git a/internal/config/scope.go b/internal/config/scope.go new file mode 100644 index 0000000..a301ddb --- /dev/null +++ b/internal/config/scope.go @@ -0,0 +1,132 @@ +package config + +import ( + "errors" + "fmt" + + "github.com/devlyft/cli/internal/clierr" +) + +// Environment variables read by ResolveScope. +const ( + EnvContext = "DEVLYFT_CONTEXT" + EnvAPIURL = "DEVLYFT_API_URL" + EnvCABundle = "DEVLYFT_CA_BUNDLE" + EnvOrg = "DEVLYFT_ORG" + EnvProject = "DEVLYFT_PROJECT" + EnvEnv = "DEVLYFT_ENV" + EnvLocation = "DEVLYFT_LOCATION" +) + +// Source says where a resolved value came from. +type Source string + +// Sources, highest precedence first. +const ( + SourceFlag Source = "flag" + SourceEnv Source = "env" + SourceContext Source = "context" + SourceConfig Source = "config" + SourceDefault Source = "default" + SourceUnset Source = "unset" +) + +// Value is a resolved setting and where it came from. +type Value struct { + Value string `json:"value"` + Source Source `json:"source"` +} + +// Flags are the global flags that feed the scope. Empty means not given. +type Flags struct { + Context string + APIURL string + Org string + Project string + Env string + Location string +} + +// Scope is what a command runs against. Org, project, env and location +// are names, slugs or IDs as the user gave them; package resolve turns +// them into IDs. +type Scope struct { + Context Value `json:"context"` + APIURL Value `json:"api_url"` + CABundle Value `json:"ca_bundle"` + Account Value `json:"account"` + Org Value `json:"org"` + Project Value `json:"project"` + Env Value `json:"env"` + Location Value `json:"location"` +} + +// ResolveScope applies the precedence flag > environment variable > +// context (or config file) > default to every value. +func ResolveScope(f *File, flags *Flags, getenv func(string) string) (*Scope, error) { + s := &Scope{} + + s.Context = pick(flags.Context, getenv(EnvContext), f.CurrentContext, SourceConfig, "") + var ctx *Context + if s.Context.Value != "" { + c, ok := f.Contexts[s.Context.Value] + if !ok { + e := NoSuchContext(s.Context.Value) + if s.Context.Source == SourceConfig { + e.Hint = fmt.Sprintf("current_context in %s names a missing context; run `devlyft config set current_context \"\"` or `devlyft context use NAME`.", f.Path()) + } + return nil, e + } + ctx = c + } + if ctx == nil { + ctx = &Context{} + } + + s.APIURL = pick(flags.APIURL, getenv(EnvAPIURL), f.APIURL, SourceConfig, DefaultAPIURL) + u, err := NormalizeAPIURL(s.APIURL.Value) + if err != nil { + if e, ok := errors.AsType[*clierr.Error](err); ok && s.APIURL.Source != SourceFlag { + e.Message += fmt.Sprintf(" (from %s)", describe(s.APIURL.Source, EnvAPIURL)) + } + return nil, err + } + s.APIURL.Value = u + + s.CABundle = pick("", getenv(EnvCABundle), f.CABundle, SourceConfig, "") + s.Account = pick("", "", ctx.Account, SourceContext, "") + s.Org = pick(flags.Org, getenv(EnvOrg), ctx.Org, SourceContext, "") + s.Project = pick(flags.Project, getenv(EnvProject), ctx.Project, SourceContext, "") + s.Env = pick(flags.Env, getenv(EnvEnv), ctx.Env, SourceContext, "") + s.Location = pick(flags.Location, getenv(EnvLocation), ctx.Location, SourceContext, "") + return s, nil +} + +func pick(flag, env, stored string, storedSource Source, def string) Value { + switch { + case flag != "": + return Value{flag, SourceFlag} + case env != "": + return Value{env, SourceEnv} + case stored != "": + return Value{stored, storedSource} + case def != "": + return Value{def, SourceDefault} + } + return Value{"", SourceUnset} +} + +func describe(src Source, envVar string) string { + if src == SourceEnv { + return envVar + } + return string(src) +} + +// Missing returns the usage error for a scope value a command needs but +// that is not set anywhere. +func Missing(what, flag, envVar string) *clierr.Error { + e := clierr.Usagef("no %s set: pass --%s or set %s", what, flag, envVar) + e.Hint = fmt.Sprintf("Or save it in a context: `devlyft context use NAME --%s VALUE`.", flag) + return e +} diff --git a/internal/config/scope_test.go b/internal/config/scope_test.go new file mode 100644 index 0000000..caa2b1c --- /dev/null +++ b/internal/config/scope_test.go @@ -0,0 +1,159 @@ +package config + +import ( + "testing" + + "github.com/devlyft/cli/internal/clierr" + "github.com/devlyft/cli/internal/exitcode" +) + +func env(vars map[string]string) func(string) string { + return func(k string) string { return vars[k] } +} + +func testFile() *File { + return &File{ + APIURL: "https://config.example.com", + CABundle: "/etc/ca.pem", + CurrentContext: "prod", + Contexts: map[string]*Context{ + "prod": {Account: "work", Org: "acme", Project: "web", Env: "production", Location: "eu-west"}, + "staging": {Org: "acme-staging", Project: "web", Env: "staging"}, + }, + } +} + +// Every value follows flag > env > context (or config file) > default. +func TestResolveScopePrecedence(t *testing.T) { + t.Parallel() + + type field func(*Scope) Value + fields := map[string]struct { + get field + setFlag func(*Flags, string) + envVar string + fromFile, def string + fileSource, defaultSource Source + }{ + "api_url": { + get: func(s *Scope) Value { return s.APIURL }, setFlag: func(f *Flags, v string) { f.APIURL = v }, + envVar: EnvAPIURL, fromFile: "https://config.example.com", def: DefaultAPIURL, + fileSource: SourceConfig, defaultSource: SourceDefault, + }, + "org": { + get: func(s *Scope) Value { return s.Org }, setFlag: func(f *Flags, v string) { f.Org = v }, + envVar: EnvOrg, fromFile: "acme", fileSource: SourceContext, defaultSource: SourceUnset, + }, + "project": { + get: func(s *Scope) Value { return s.Project }, setFlag: func(f *Flags, v string) { f.Project = v }, + envVar: EnvProject, fromFile: "web", fileSource: SourceContext, defaultSource: SourceUnset, + }, + "env": { + get: func(s *Scope) Value { return s.Env }, setFlag: func(f *Flags, v string) { f.Env = v }, + envVar: EnvEnv, fromFile: "production", fileSource: SourceContext, defaultSource: SourceUnset, + }, + "location": { + get: func(s *Scope) Value { return s.Location }, setFlag: func(f *Flags, v string) { f.Location = v }, + envVar: EnvLocation, fromFile: "eu-west", fileSource: SourceContext, defaultSource: SourceUnset, + }, + } + for name, fl := range fields { + t.Run(name, func(t *testing.T) { + t.Parallel() + flagVal, envVal := "https://flag.example.com", "https://env.example.com" + if name != "api_url" { + flagVal, envVal = "from-flag", "from-env" + } + + var flags Flags + fl.setFlag(&flags, flagVal) + check := func(desc string, s *Scope, err error, want Value) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", desc, err) + } + if got := fl.get(s); got != want { + t.Errorf("%s: got %+v, want %+v", desc, got, want) + } + } + + s, err := ResolveScope(testFile(), &flags, env(map[string]string{fl.envVar: envVal})) + check("flag beats env", s, err, Value{flagVal, SourceFlag}) + + s, err = ResolveScope(testFile(), &Flags{}, env(map[string]string{fl.envVar: envVal})) + check("env beats file", s, err, Value{envVal, SourceEnv}) + + s, err = ResolveScope(testFile(), &Flags{}, env(nil)) + check("file", s, err, Value{fl.fromFile, fl.fileSource}) + + s, err = ResolveScope(&File{}, &Flags{}, env(nil)) + check("default", s, err, Value{fl.def, fl.defaultSource}) + }) + } +} + +func TestResolveScopeContextSelection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags Flags + env map[string]string + file *File + context Value + org string + account string + exit int + }{ + {name: "current context", file: testFile(), context: Value{"prod", SourceConfig}, org: "acme", account: "work"}, + {name: "env picks context", env: map[string]string{EnvContext: "staging"}, file: testFile(), context: Value{"staging", SourceEnv}, org: "acme-staging"}, + {name: "flag beats env", flags: Flags{Context: "prod"}, env: map[string]string{EnvContext: "staging"}, file: testFile(), context: Value{"prod", SourceFlag}, org: "acme", account: "work"}, + {name: "no context", file: &File{}, context: Value{"", SourceUnset}}, + {name: "unknown flag context", flags: Flags{Context: "nope"}, file: testFile(), exit: exitcode.NotFound}, + {name: "dangling current context", file: &File{CurrentContext: "gone"}, exit: exitcode.NotFound}, + {name: "invalid api url from env", env: map[string]string{EnvAPIURL: "http://api.example.com"}, file: &File{}, exit: exitcode.Usage}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + s, err := ResolveScope(tt.file, &tt.flags, env(tt.env)) + if got := clierr.ExitCode(err); got != tt.exit { + t.Fatalf("exit = %d (%v), want %d", got, err, tt.exit) + } + if err != nil { + return + } + if s.Context != tt.context || s.Org.Value != tt.org || s.Account.Value != tt.account { + t.Errorf("got context %+v org %q account %q", s.Context, s.Org.Value, s.Account.Value) + } + }) + } +} + +func TestResolveScopeCABundle(t *testing.T) { + t.Parallel() + + s, err := ResolveScope(testFile(), &Flags{}, env(nil)) + if err != nil { + t.Fatal(err) + } + if s.CABundle != (Value{"/etc/ca.pem", SourceConfig}) { + t.Errorf("CABundle = %+v", s.CABundle) + } + s, err = ResolveScope(testFile(), &Flags{}, env(map[string]string{EnvCABundle: "/tmp/ca.pem"})) + if err != nil { + t.Fatal(err) + } + if s.CABundle != (Value{"/tmp/ca.pem", SourceEnv}) { + t.Errorf("CABundle = %+v", s.CABundle) + } +} + +func TestMissing(t *testing.T) { + t.Parallel() + + e := Missing("project", "project", EnvProject) + if e.ExitCode() != exitcode.Usage || e.Hint == "" { + t.Errorf("unexpected %+v", e) + } +} diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go new file mode 100644 index 0000000..82d2b39 --- /dev/null +++ b/internal/resolve/resolve.go @@ -0,0 +1,166 @@ +// Package resolve turns the names, slugs and IDs users type into the IDs +// the API takes. Names are looked up with list calls on every invocation; +// nothing is cached. A name that matches several resources is an error +// that lists them, since organization, project and environment names are +// not guaranteed to be unique; scripts should pass IDs. +package resolve + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/google/uuid" + + "github.com/devlyft/cli/internal/api" + "github.com/devlyft/cli/internal/clierr" +) + +// Match is one resource a name could refer to. +type Match struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Resolver resolves references through the API. +type Resolver struct { + Client api.Client +} + +// IsID reports whether ref is a UUID, which is used as-is without a lookup. +func IsID(ref string) bool { + _, err := uuid.Parse(ref) + return err == nil +} + +// Org resolves an organization by ID, slug or name. Slugs are unique, so a +// slug match wins over name matches. +func (r *Resolver) Org(ctx context.Context, ref string) (string, error) { + if IsID(ref) { + return ref, nil + } + orgs, err := r.Client.ListOrganizations(ctx) + if err != nil { + return "", err + } + for i := range orgs { + if orgs[i].Slug == ref { + return orgs[i].Id.String(), nil + } + } + matches := make([]Match, 0, 1) + for i := range orgs { + matches = append(matches, Match{ID: orgs[i].Id.String(), Name: orgs[i].Name}) + } + return pick("organization", "org", ref, matches) +} + +// Project resolves a project by ID, or by name within orgRef. orgRef may +// be empty when ref is an ID. +func (r *Resolver) Project(ctx context.Context, orgRef, ref string) (string, error) { + if IsID(ref) { + return ref, nil + } + if orgRef == "" { + e := clierr.Usagef("project %q is a name, so an organization is needed to find it: pass --org or set DEVLYFT_ORG", ref) + e.Hint = "Or pass the project ID." + return "", e + } + orgID, err := r.Org(ctx, orgRef) + if err != nil { + return "", err + } + projects, err := r.Client.ListProjects(ctx, orgID) + if err != nil { + return "", err + } + matches := make([]Match, 0, len(projects)) + for i := range projects { + matches = append(matches, Match{ID: projects[i].Id.String(), Name: projects[i].Name}) + } + return pick("project", "project", ref, matches) +} + +// Env resolves an environment by ID, or by name within the project +// (resolved with orgRef when it is a name). +func (r *Resolver) Env(ctx context.Context, orgRef, projectRef, ref string) (string, error) { + if IsID(ref) { + return ref, nil + } + if projectRef == "" { + e := clierr.Usagef("environment %q is a name, so a project is needed to find it: pass --project or set DEVLYFT_PROJECT", ref) + e.Hint = "Or pass the environment ID." + return "", e + } + projectID, err := r.Project(ctx, orgRef, projectRef) + if err != nil { + return "", err + } + envs, err := r.Client.ListEnvironments(ctx, projectID) + if err != nil { + return "", err + } + matches := make([]Match, 0, len(envs)) + for i := range envs { + matches = append(matches, Match{ID: envs[i].Id.String(), Name: envs[i].Name}) + } + return pick("environment", "environment", ref, matches) +} + +// Location validates a location ID (a region name), ignoring case. +func (r *Resolver) Location(ctx context.Context, ref string) (string, error) { + locs, err := r.Client.ListLocations(ctx) + if err != nil { + return "", err + } + ids := make([]string, 0, len(locs)) + for i := range locs { + if strings.EqualFold(locs[i].Id, ref) { + return locs[i].Id, nil + } + ids = append(ids, locs[i].Id) + } + sort.Strings(ids) + e := clierr.New(clierr.CodeNotFound, "location %q not found", ref) + if len(ids) > 0 { + e.Hint = "Available locations: " + strings.Join(ids, ", ") + "." + } + return "", e +} + +// pick finds ref among candidates by exact name, then by case-insensitive +// name. +func pick(kind, flag, ref string, candidates []Match) (string, error) { + for _, eq := range []func(a, b string) bool{ + func(a, b string) bool { return a == b }, + strings.EqualFold, + } { + var found []Match + for _, m := range candidates { + if eq(m.Name, ref) { + found = append(found, m) + } + } + switch len(found) { + case 0: + continue + case 1: + return found[0].ID, nil + } + return "", ambiguous(kind, flag, ref, found) + } + return "", clierr.New(clierr.CodeNotFound, "%s %q not found", kind, ref) +} + +func ambiguous(kind, flag, ref string, found []Match) error { + sort.Slice(found, func(i, j int) bool { return found[i].ID < found[j].ID }) + lines := make([]string, len(found)) + for i, m := range found { + lines[i] = fmt.Sprintf(" %s %s", m.ID, m.Name) + } + e := clierr.Usagef("%s name %q matches %d resources:\n%s", kind, ref, len(found), strings.Join(lines, "\n")) + e.Hint = fmt.Sprintf("Pass the ID with --%s; scripts should always use IDs.", flag) + e.Details = map[string]any{"matches": found} + return e +} diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go new file mode 100644 index 0000000..8865372 --- /dev/null +++ b/internal/resolve/resolve_test.go @@ -0,0 +1,199 @@ +package resolve + +import ( + "errors" + "strings" + "testing" + + "github.com/google/uuid" + "go.uber.org/mock/gomock" + + "github.com/devlyft/cli/internal/api" + "github.com/devlyft/cli/internal/api/mock" + "github.com/devlyft/cli/internal/clierr" + "github.com/devlyft/cli/internal/exitcode" +) + +var ( + acme = api.Organization{Id: uuid.MustParse("11111111-1111-4111-8111-111111111111"), Name: "Acme", Slug: "acme"} + acmeTwin = api.Organization{Id: uuid.MustParse("22222222-2222-4222-8222-222222222222"), Name: "Acme", Slug: "acme-eu"} + globex = api.Organization{Id: uuid.MustParse("33333333-3333-4333-8333-333333333333"), Name: "Globex", Slug: "acme-labs"} + web = api.Project{Id: uuid.MustParse("44444444-4444-4444-8444-444444444444"), Name: "web"} + webCopy = api.Project{Id: uuid.MustParse("55555555-5555-4555-8555-555555555555"), Name: "Web"} + api2 = api.Project{Id: uuid.MustParse("66666666-6666-4666-8666-666666666666"), Name: "api"} + prod = api.Environment{Id: uuid.MustParse("77777777-7777-4777-8777-777777777777"), Name: "production"} + locations = []api.Location{{Id: "eu-west-1"}, {Id: "us-east-1"}} +) + +func newResolver(t *testing.T) (*Resolver, *mock.MockClient) { + t.Helper() + m := mock.NewMockClient(gomock.NewController(t)) + return &Resolver{Client: m}, m +} + +func TestOrg(t *testing.T) { + t.Parallel() + + tests := []struct { + ref string + want string + exit int + }{ + {ref: acme.Id.String(), want: acme.Id.String()}, + {ref: "acme", want: acme.Id.String()}, + {ref: "acme-labs", want: globex.Id.String()}, // slug wins + {ref: "Globex", want: globex.Id.String()}, + {ref: "globex", want: globex.Id.String()}, // case-insensitive fallback + {ref: "Acme", exit: exitcode.Usage}, // two orgs share the name + {ref: "initech", exit: exitcode.NotFound}, + } + for _, tt := range tests { + t.Run(tt.ref, func(t *testing.T) { + t.Parallel() + r, m := newResolver(t) + if !IsID(tt.ref) { + m.EXPECT().ListOrganizations(gomock.Any()).Return([]api.Organization{acme, acmeTwin, globex}, nil) + } + got, err := r.Org(t.Context(), tt.ref) + if code := clierr.ExitCode(err); code != tt.exit { + t.Fatalf("exit = %d (%v), want %d", code, err, tt.exit) + } + if got != tt.want { + t.Errorf("Org(%q) = %q, want %q", tt.ref, got, tt.want) + } + }) + } +} + +func TestAmbiguityListsMatches(t *testing.T) { + t.Parallel() + + r, m := newResolver(t) + m.EXPECT().ListOrganizations(gomock.Any()).Return([]api.Organization{acmeTwin, acme}, nil) + _, err := r.Org(t.Context(), "Acme") + e, ok := errors.AsType[*clierr.Error](err) + if !ok { + t.Fatalf("err = %v", err) + } + for _, want := range []string{acme.Id.String(), acmeTwin.Id.String(), "matches 2"} { + if !strings.Contains(e.Message, want) { + t.Errorf("message %q lacks %q", e.Message, want) + } + } + if !strings.Contains(e.Hint, "--org") || e.Details["matches"] == nil { + t.Errorf("hint %q details %v", e.Hint, e.Details) + } +} + +func TestProject(t *testing.T) { + t.Parallel() + + t.Run("id needs no org", func(t *testing.T) { + t.Parallel() + r, _ := newResolver(t) + got, err := r.Project(t.Context(), "", web.Id.String()) + if err != nil || got != web.Id.String() { + t.Fatalf("got %q, %v", got, err) + } + }) + t.Run("name needs org", func(t *testing.T) { + t.Parallel() + r, _ := newResolver(t) + _, err := r.Project(t.Context(), "", "web") + if clierr.ExitCode(err) != exitcode.Usage { + t.Fatalf("err = %v", err) + } + }) + t.Run("name in org by slug", func(t *testing.T) { + t.Parallel() + r, m := newResolver(t) + m.EXPECT().ListOrganizations(gomock.Any()).Return([]api.Organization{acme}, nil) + m.EXPECT().ListProjects(gomock.Any(), acme.Id.String()).Return([]api.Project{web, api2}, nil) + got, err := r.Project(t.Context(), "acme", "api") + if err != nil || got != api2.Id.String() { + t.Fatalf("got %q, %v", got, err) + } + }) + t.Run("exact name beats case-insensitive", func(t *testing.T) { + t.Parallel() + r, m := newResolver(t) + m.EXPECT().ListProjects(gomock.Any(), acme.Id.String()).Return([]api.Project{web, webCopy}, nil) + got, err := r.Project(t.Context(), acme.Id.String(), "Web") + if err != nil || got != webCopy.Id.String() { + t.Fatalf("got %q, %v", got, err) + } + }) + t.Run("case-insensitive ambiguity", func(t *testing.T) { + t.Parallel() + r, m := newResolver(t) + m.EXPECT().ListProjects(gomock.Any(), acme.Id.String()).Return([]api.Project{web, webCopy}, nil) + _, err := r.Project(t.Context(), acme.Id.String(), "WEB") + if clierr.ExitCode(err) != exitcode.Usage { + t.Fatalf("err = %v", err) + } + }) + t.Run("list error passes through", func(t *testing.T) { + t.Parallel() + r, m := newResolver(t) + m.EXPECT().ListProjects(gomock.Any(), acme.Id.String()).Return(nil, clierr.FromHTTP(403, "forbidden", "")) + _, err := r.Project(t.Context(), acme.Id.String(), "web") + if clierr.ExitCode(err) != exitcode.Auth { + t.Fatalf("err = %v", err) + } + }) +} + +func TestEnv(t *testing.T) { + t.Parallel() + + t.Run("id", func(t *testing.T) { + t.Parallel() + r, _ := newResolver(t) + got, err := r.Env(t.Context(), "", "", prod.Id.String()) + if err != nil || got != prod.Id.String() { + t.Fatalf("got %q, %v", got, err) + } + }) + t.Run("name needs project", func(t *testing.T) { + t.Parallel() + r, _ := newResolver(t) + _, err := r.Env(t.Context(), "acme", "", "production") + if clierr.ExitCode(err) != exitcode.Usage { + t.Fatalf("err = %v", err) + } + }) + t.Run("name in project id", func(t *testing.T) { + t.Parallel() + r, m := newResolver(t) + m.EXPECT().ListEnvironments(gomock.Any(), web.Id.String()).Return([]api.Environment{prod}, nil) + got, err := r.Env(t.Context(), "", web.Id.String(), "production") + if err != nil || got != prod.Id.String() { + t.Fatalf("got %q, %v", got, err) + } + }) + t.Run("not found", func(t *testing.T) { + t.Parallel() + r, m := newResolver(t) + m.EXPECT().ListEnvironments(gomock.Any(), web.Id.String()).Return([]api.Environment{prod}, nil) + _, err := r.Env(t.Context(), "", web.Id.String(), "staging") + if clierr.ExitCode(err) != exitcode.NotFound { + t.Fatalf("err = %v", err) + } + }) +} + +func TestLocation(t *testing.T) { + t.Parallel() + + r, m := newResolver(t) + m.EXPECT().ListLocations(gomock.Any()).Return(locations, nil).Times(2) + got, err := r.Location(t.Context(), "EU-WEST-1") + if err != nil || got != "eu-west-1" { + t.Fatalf("got %q, %v", got, err) + } + _, err = r.Location(t.Context(), "mars-1") + e, ok := errors.AsType[*clierr.Error](err) + if !ok || e.ExitCode() != exitcode.NotFound || !strings.Contains(e.Hint, "eu-west-1, us-east-1") { + t.Fatalf("err = %+v", err) + } +} diff --git a/internal/testutil/clitest/clitest.go b/internal/testutil/clitest/clitest.go index 1fcd7db..fd7bfbe 100644 --- a/internal/testutil/clitest/clitest.go +++ b/internal/testutil/clitest/clitest.go @@ -15,6 +15,8 @@ package clitest import ( "context" "fmt" + "os" + "path/filepath" "strings" "testing" @@ -44,6 +46,11 @@ type Options struct { Commands func(*cmdutil.Factory) []*cobra.Command // Context defaults to t.Context(). Context context.Context + // ConfigDir holds config.yaml; empty uses a new temporary directory, + // so tests never read the developer's own config. + ConfigDir string + // Config, if set, is written to config.yaml before the run. + Config string } // Result is what an invocation produced. @@ -52,10 +59,12 @@ type Result struct { Exit int Stdout string Stderr string + // ConfigDir is the config directory the run used. + ConfigDir string } // Transcript formats the result for golden files. -func (r Result) Transcript() string { +func (r *Result) Transcript() string { return fmt.Sprintf("$ devlyft %s\n--- exit %d\n--- stdout\n%s--- stderr\n%s", strings.Join(r.Args, " "), r.Exit, r.Stdout, r.Stderr) } @@ -71,7 +80,16 @@ func Run(tb testing.TB, opts *Options) Result { ios.SetTTY(opts.TTY) in.WriteString(opts.Stdin) - f := &cmdutil.Factory{IO: ios, Credentials: tempCredentials(tb)} + dir := opts.ConfigDir + if dir == "" { + dir = tb.TempDir() + } + if opts.Config != "" { + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(opts.Config), 0o600); err != nil { + tb.Fatal(err) + } + } + f := &cmdutil.Factory{IO: ios, ConfigDir: dir, Credentials: tempCredentials(tb)} if opts.API != nil { client := opts.API f.API = func() (api.Client, error) { return client, nil } @@ -86,7 +104,7 @@ func Run(tb testing.TB, opts *Options) Result { ctx = tb.Context() } code := cli.ExecuteWith(ctx, f, opts.Args, extra...) - return Result{Args: opts.Args, Exit: code, Stdout: stdout.String(), Stderr: stderr.String()} + return Result{Args: opts.Args, Exit: code, Stdout: stdout.String(), Stderr: stderr.String(), ConfigDir: dir} } // tempCredentials returns an empty file-only credential store in a From eb395d25617023d0db6d8e324b5ec5a12a6668b5 Mon Sep 17 00:00:00 2001 From: RogerCecilpot Date: Sun, 25 Oct 2026 18:58:37 +0000 Subject: [PATCH 2/2] test: give testscripts their own config directory testscript leaves AppData unset on Windows, so os.UserConfigDir failed. Co-Authored-By: Claude Opus 5.5 --- internal/cli/script_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/cli/script_test.go b/internal/cli/script_test.go index 2e64c56..00c8f48 100644 --- a/internal/cli/script_test.go +++ b/internal/cli/script_test.go @@ -49,6 +49,12 @@ func TestScripts(t *testing.T) { // The update notifier is off without a terminal anyway; be explicit. env.Setenv("DEVLYFT_NO_UPDATE_NOTIFIER", "1") env.Setenv("NO_COLOR", "") + // testscript points HOME at a missing directory and leaves + // AppData unset on Windows, so give every script its own + // config directory. + cfg := filepath.Join(env.WorkDir, ".config") + env.Setenv("XDG_CONFIG_HOME", cfg) + env.Setenv("AppData", cfg) return nil }, Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){