mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 00:01:24 +00:00
246 lines
7.3 KiB
Go
246 lines
7.3 KiB
Go
// Package config is where the `warmbly` CLI remembers who you are.
|
|
//
|
|
// Two files, the split gh made conventional: config.yml holds preferences and
|
|
// aliases and is safe to read; hosts.yml holds one credential per host and is
|
|
// written 0600. Environment variables override both and are never written
|
|
// back, so CI sets WARMBLY_TOKEN and never runs a login.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// DefaultHost is the hosted service. A bare `warmbly auth login` means this.
|
|
const DefaultHost = "warmbly.com"
|
|
|
|
// Env vars, all documented. TokenEnv is checked first; APIKeyEnv is the name
|
|
// warmblyctl already taught people and keeps working.
|
|
const (
|
|
DirEnv = "WARMBLY_CONFIG_DIR"
|
|
TokenEnv = "WARMBLY_TOKEN"
|
|
APIKeyEnv = "WARMBLY_API_KEY"
|
|
HostEnv = "WARMBLY_HOST"
|
|
APIURLEnv = "WARMBLY_API_URL"
|
|
)
|
|
|
|
// Host is one signed-in instance.
|
|
type Host struct {
|
|
// APIURL is the base the CLI calls, without a trailing slash and without
|
|
// the /v1 prefix. Resolved once at login so no later command has to guess.
|
|
APIURL string `yaml:"api_url"`
|
|
// AppURL is the dashboard origin, as the instance reports it. Stored at
|
|
// sign-in so `warmbly browse` opens the right page rather than guessing
|
|
// from the hostname, which is wrong on any non-default layout.
|
|
AppURL string `yaml:"app_url,omitempty"`
|
|
Token string `yaml:"token,omitempty"`
|
|
|
|
User string `yaml:"user,omitempty"`
|
|
UserID string `yaml:"user_id,omitempty"`
|
|
Organization string `yaml:"organization,omitempty"`
|
|
OrganizationID string `yaml:"organization_id,omitempty"`
|
|
Scopes []string `yaml:"scopes,omitempty"`
|
|
// APIKeyID is what `warmbly auth logout` revokes.
|
|
APIKeyID string `yaml:"api_key_id,omitempty"`
|
|
AddedAt time.Time `yaml:"added_at,omitempty"`
|
|
}
|
|
|
|
// Config is the preference file.
|
|
type Config struct {
|
|
// ActiveHost is what commands use when no --host is given.
|
|
ActiveHost string `yaml:"active_host,omitempty"`
|
|
// Output is the default renderer: table or json.
|
|
Output string `yaml:"output,omitempty"`
|
|
// Confirm is "always" or "sends". "sends" (the default) prompts only for
|
|
// commands that put real mail on the wire.
|
|
Confirm string `yaml:"confirm,omitempty"`
|
|
// Pager is the command long output is piped through, or "cat" to disable.
|
|
Pager string `yaml:"pager,omitempty"`
|
|
// Browser overrides the command used to open a URL.
|
|
Browser string `yaml:"browser,omitempty"`
|
|
Aliases map[string]string `yaml:"aliases,omitempty"`
|
|
}
|
|
|
|
// Keys are the settable config fields, with what each one does. `warmbly
|
|
// config set` refuses anything not in here so a typo is not silently stored.
|
|
var Keys = []struct {
|
|
Name, Help, Default string
|
|
}{
|
|
{"active_host", "Which signed-in host commands use by default", DefaultHost},
|
|
{"output", "Default output format: table or json", "table"},
|
|
{"confirm", "When to prompt before a command that sends: sends or always", "sends"},
|
|
{"pager", "Program to page long output through; cat disables paging", "$PAGER"},
|
|
{"browser", "Program used to open a URL", "$BROWSER"},
|
|
}
|
|
|
|
// Dir is where both files live: WARMBLY_CONFIG_DIR, then XDG, then ~/.config.
|
|
func Dir() string {
|
|
if v := strings.TrimSpace(os.Getenv(DirEnv)); v != "" {
|
|
return v
|
|
}
|
|
if v := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); v != "" {
|
|
return filepath.Join(v, "warmbly")
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ".warmbly"
|
|
}
|
|
return filepath.Join(home, ".config", "warmbly")
|
|
}
|
|
|
|
func configPath() string { return filepath.Join(Dir(), "config.yml") }
|
|
func hostsPath() string { return filepath.Join(Dir(), "hosts.yml") }
|
|
|
|
// Load reads config.yml. A missing file is an empty config, not an error: the
|
|
// CLI has to work on a machine that has never run it.
|
|
func Load() (*Config, error) {
|
|
c := &Config{}
|
|
raw, err := os.ReadFile(configPath())
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return c, nil
|
|
}
|
|
return c, fmt.Errorf("reading %s: %w", configPath(), err)
|
|
}
|
|
if err := yaml.Unmarshal(raw, c); err != nil {
|
|
return c, fmt.Errorf("%s is not valid YAML: %w", configPath(), err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Config) Save() error {
|
|
raw, err := yaml.Marshal(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFile(configPath(), raw, 0o600)
|
|
}
|
|
|
|
// Get reads one settable key, falling back to its default.
|
|
func (c *Config) Get(key string) string {
|
|
switch key {
|
|
case "active_host":
|
|
return c.ActiveHost
|
|
case "output":
|
|
if c.Output == "" {
|
|
return "table"
|
|
}
|
|
return c.Output
|
|
case "confirm":
|
|
if c.Confirm == "" {
|
|
return "sends"
|
|
}
|
|
return c.Confirm
|
|
case "pager":
|
|
return c.Pager
|
|
case "browser":
|
|
return c.Browser
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Set writes one settable key. Values are validated here rather than at use,
|
|
// so a bad value is rejected while the user is still looking at it.
|
|
func (c *Config) Set(key, value string) error {
|
|
value = strings.TrimSpace(value)
|
|
switch key {
|
|
case "active_host":
|
|
c.ActiveHost = value
|
|
case "output":
|
|
if value != "table" && value != "json" {
|
|
return fmt.Errorf("output must be table or json, not %q", value)
|
|
}
|
|
c.Output = value
|
|
case "confirm":
|
|
if value != "sends" && value != "always" {
|
|
return fmt.Errorf("confirm must be sends or always, not %q", value)
|
|
}
|
|
c.Confirm = value
|
|
case "pager":
|
|
c.Pager = value
|
|
case "browser":
|
|
c.Browser = value
|
|
default:
|
|
return fmt.Errorf("unknown config key %q. Run `warmbly config list` for the settable keys.", key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Hosts is hosts.yml: host name to credential.
|
|
type Hosts map[string]*Host
|
|
|
|
func LoadHosts() (Hosts, error) {
|
|
h := Hosts{}
|
|
raw, err := os.ReadFile(hostsPath())
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return h, nil
|
|
}
|
|
return h, fmt.Errorf("reading %s: %w", hostsPath(), err)
|
|
}
|
|
if err := yaml.Unmarshal(raw, &h); err != nil {
|
|
return h, fmt.Errorf("%s is not valid YAML: %w", hostsPath(), err)
|
|
}
|
|
for name, entry := range h {
|
|
if entry == nil {
|
|
delete(h, name)
|
|
}
|
|
}
|
|
return h, nil
|
|
}
|
|
|
|
// SaveHosts writes the credential file at 0600. An empty map removes the file
|
|
// rather than leaving a stub, so `auth logout` leaves nothing behind.
|
|
func (h Hosts) Save() error {
|
|
if len(h) == 0 {
|
|
if err := os.Remove(hostsPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
raw, err := yaml.Marshal(map[string]*Host(h))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFile(hostsPath(), raw, 0o600)
|
|
}
|
|
|
|
// Names returns the configured hosts in a stable order.
|
|
func (h Hosts) Names() []string {
|
|
out := make([]string, 0, len(h))
|
|
for name := range h {
|
|
out = append(out, name)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func writeFile(path string, data []byte, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return fmt.Errorf("creating %s: %w", filepath.Dir(path), err)
|
|
}
|
|
// Write-then-rename so an interrupted save cannot truncate a working file
|
|
// and lock someone out of their own CLI.
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, mode); err != nil {
|
|
return fmt.Errorf("writing %s: %w", path, err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("writing %s: %w", path, err)
|
|
}
|
|
return os.Chmod(path, mode)
|
|
}
|
|
|
|
// HostsPath and ConfigPath are exported for `auth status`, which tells people
|
|
// where their credentials actually live.
|
|
func HostsPath() string { return hostsPath() }
|
|
func ConfigPath() string { return configPath() }
|