Files
probo/pkg/cli/config/config.go
Sacha Al Himdani 4c57d201a4 Make license declarations consistently MIT
The source headers, LICENSE files, and license metadata had drifted
apart. Align the entire project to MIT:

- Convert every source-file header to the MIT text across all comment
  styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including
  SPDX-License-Identifier tags
- Set the root and cookie-banner LICENSE files to the MIT text with a
  "MIT License" title line
- Switch the package.json license fields, Docker image label, and
  cookie-banner README to MIT
- Update docs and the genmodels header generator accordingly
- Normalize copyright lines to a single format
  (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the
  hello@getprobo.com and hello@probo.inc emails to hello@probo.com and
  the comma-separated years to a hyphenated range

Genuine third-party references are intentionally left untouched: the
Lucide icon attributions (Lucide is ISC) and the trivy dependency
license allowlist.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
2026-07-13 16:21:14 +02:00

291 lines
6.8 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package config
import (
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
DefaultHTTPTimeout = 30 * time.Second
// CLIClientID is the well-known OAuth2 client ID for the Probo CLI,
// pre-provisioned in every Probo database via migration.
CLIClientID = "AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp"
// CLIClientScopes is the space-separated scope string requested at prb login.
// Keep in sync with iam_oauth2_clients.scopes for CLIClientID (see migrations).
CLIClientScopes = "openid profile email offline_access " +
"v1:access-review " +
"v1:agent " +
"v1:asset " +
"v1:audit " +
"v1:common-third-party " +
"v1:compliance-page " +
"v1:connector " +
"v1:control " +
"v1:datum " +
"v1:document " +
"v1:iam " +
"v1:org " +
"v1:privacy " +
"v1:risk " +
"v1:slack-connection " +
"v1:task " +
"v1:third-party " +
"v1:webhook"
)
type (
Config struct {
Editor string `yaml:"editor,omitempty"`
Browser string `yaml:"browser,omitempty"`
Pager string `yaml:"pager,omitempty"`
Prompt string `yaml:"prompt,omitempty"`
HTTPTimeout string `yaml:"http_timeout,omitempty"`
ActiveHost string `yaml:"active_host,omitempty"`
Hosts map[string]*HostConfig `yaml:"hosts"`
}
HostConfig struct {
Token string `yaml:"token"`
RefreshToken string `yaml:"refresh_token,omitempty"`
TokenEndpoint string `yaml:"token_endpoint,omitempty"`
Organization string `yaml:"organization"`
}
)
var (
ValidKeys = []string{
"editor",
"browser",
"pager",
"prompt",
"http_timeout",
}
)
func (c *Config) Get(key string) (string, error) {
switch key {
case "editor":
return c.Editor, nil
case "browser":
return c.Browser, nil
case "pager":
return c.Pager, nil
case "prompt":
return c.Prompt, nil
case "http_timeout":
return c.HTTPTimeout, nil
default:
return "", fmt.Errorf("unknown configuration key: %s", key)
}
}
func (c *Config) Set(key, value string) error {
switch key {
case "editor":
c.Editor = value
case "browser":
c.Browser = value
case "pager":
c.Pager = value
case "prompt":
if value != "enabled" && value != "disabled" {
return fmt.Errorf("valid values for prompt are 'enabled' or 'disabled'")
}
c.Prompt = value
case "http_timeout":
if _, err := time.ParseDuration(value); err != nil {
return fmt.Errorf("invalid duration for http_timeout: %w", err)
}
c.HTTPTimeout = value
default:
return fmt.Errorf("unknown configuration key: %s", key)
}
return nil
}
func (c *Config) HTTPTimeoutDuration() time.Duration {
if c.HTTPTimeout == "" {
return DefaultHTTPTimeout
}
d, err := time.ParseDuration(c.HTTPTimeout)
if err != nil {
return DefaultHTTPTimeout
}
return d
}
func configDir() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("cannot determine config directory: %w", err)
}
return filepath.Join(dir, "prb"), nil
}
func configPath() (string, error) {
dir, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "config.yaml"), nil
}
func Load() (*Config, error) {
path, err := configPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Config{Hosts: make(map[string]*HostConfig)}, nil
}
return nil, fmt.Errorf("cannot read config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("cannot parse config file: %w", err)
}
if cfg.Hosts == nil {
cfg.Hosts = make(map[string]*HostConfig)
}
normalized := make(map[string]*HostConfig, len(cfg.Hosts))
for host, hc := range cfg.Hosts {
normalized[normalizeHost(host)] = hc
}
cfg.Hosts = normalized
if cfg.ActiveHost != "" {
cfg.ActiveHost = normalizeHost(cfg.ActiveHost)
}
return &cfg, nil
}
func (c *Config) Save() error {
path, err := configPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("cannot create config directory: %w", err)
}
data, err := yaml.Marshal(c)
if err != nil {
return fmt.Errorf("cannot marshal config: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("cannot write config file: %w", err)
}
return nil
}
func normalizeHost(host string) string {
lower := strings.ToLower(host)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
if u, err := url.Parse(host); err == nil {
return strings.TrimRight(u.Scheme+"://"+u.Host, "/")
}
}
return strings.TrimRight(host, "/")
}
func (c *Config) DefaultHost() (string, *HostConfig, error) {
if host := os.Getenv("PROBO_HOST"); host != "" {
host = normalizeHost(host)
hc := &HostConfig{}
if saved, ok := c.Hosts[host]; ok {
*hc = *saved
}
if token := os.Getenv("PROBO_TOKEN"); token != "" {
hc.Token = token
}
return host, hc, nil
}
hosts := slices.Sorted(maps.Keys(c.Hosts))
if token := os.Getenv("PROBO_TOKEN"); token != "" {
if len(hosts) == 0 {
return "", nil, fmt.Errorf("PROBO_TOKEN is set but no host configured; run 'prb auth login' first")
}
host := hosts[0]
if c.ActiveHost != "" {
if _, ok := c.Hosts[c.ActiveHost]; ok {
host = c.ActiveHost
}
}
return host, &HostConfig{
Token: token,
Organization: c.Hosts[host].Organization,
}, nil
}
if c.ActiveHost != "" {
if hc, ok := c.Hosts[c.ActiveHost]; ok {
return c.ActiveHost, hc, nil
}
}
if len(hosts) > 0 {
host := hosts[0]
return host, c.Hosts[host], nil
}
return "", nil, fmt.Errorf("not logged in; run 'prb auth login' first")
}