Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
15 changes: 12 additions & 3 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
12 changes: 6 additions & 6 deletions internal/cli/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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."
Expand All @@ -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},
})
}
Expand Down Expand Up @@ -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
}
}
Expand Down
8 changes: 5 additions & 3 deletions internal/cli/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
},
Expand Down
7 changes: 5 additions & 2 deletions internal/cli/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions internal/cli/config/config.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading