Files
Containr/app/backend/internal/cli/commands/config.go
T
Tomas Dvorak e51b20b2d4 feat(cli): v2 — full API coverage, profiles, --json/--yes, containr up
Rewrite the CLI as a thin client over the authenticated API so agents
and scripts reach every resource the UI does (ROADMAP2 phase A2).

- shared Client with bearer auth, error-envelope decoding, and stable
  exit codes: 0 ok / 1 error / 2 auth / 3 not-found / 4 validation /
  5 unreachable
- named profiles in ~/.containr.yaml (current_profile + profiles.*);
  resolution order --flag > env > profile > localhost default;
  CONTAINR_TOKEN / CONTAINR_API_URL / CONTAINR_PROFILE env support
- --json prints raw API responses; --yes skips confirmations;
  destructive commands refuse non-interactively without it
- auth login verifies the token against /user/profile before saving,
  interactive prompt via term.ReadPassword
- commands for every resource: tokens, projects, services, deploy,
  deployments (logs/rollback), logs --follow (SSE), exec, variables
  (set/unset merge with secret masking preserved), databases
  (backup/restore/download), cron, templates, nodes + agent tokens,
  scaling, ha, security, gateway, admin, notifications
- containr up: git remote + branch → create-or-reuse service → deploy

Verified live: login/status/tokens/projects/services, up creating a
service + deployment, non-TTY confirmation refusal, scope-capped admin
rejection (exit 2), revoke.

Authored By: TDvorak <[email protected]>
2026-10-08 15:19:31 +02:00

107 lines
2.7 KiB
Go

package commands
import (
"fmt"
"os"
"strings"
"github.com/spf13/viper"
)
// ActiveProfile resolves which profile applies: --profile flag >
// CONTAINR_PROFILE env > current_profile in config > "default".
func ActiveProfile() string {
if p := viper.GetString("profile"); p != "" {
return p
}
if p := os.Getenv("CONTAINR_PROFILE"); p != "" {
return p
}
if p := viper.GetString("current_profile"); p != "" {
return p
}
return "default"
}
// ResolveAPIURL returns the effective API base URL:
// --api-url flag > CONTAINR_API_URL env > profile.api_url > localhost default.
func ResolveAPIURL() string {
if u := viper.GetString("api-url"); u != "" {
return normalizeBase(u)
}
if u := os.Getenv("CONTAINR_API_URL"); u != "" {
return normalizeBase(u)
}
if u := viper.GetString("profiles." + ActiveProfile() + ".api_url"); u != "" {
return normalizeBase(u)
}
return "http://localhost:8080/api/v1"
}
// ResolveToken returns the effective auth token:
// --token flag > CONTAINR_TOKEN env > profile.token.
func ResolveToken() string {
if t := viper.GetString("token"); t != "" {
return t
}
if t := os.Getenv("CONTAINR_TOKEN"); t != "" {
return t
}
return viper.GetString("profiles." + ActiveProfile() + ".token")
}
func normalizeBase(u string) string {
u = strings.TrimSuffix(strings.TrimSpace(u), "/")
// Accept the bare host too — append the API prefix when missing.
if u != "" && !strings.HasSuffix(u, "/api/v1") {
u += "/api/v1"
}
return u
}
// SaveProfile persists api_url/token under a named profile and makes it
// current. Creates the config file if none exists.
func SaveProfile(name, apiURL, token string) error {
base := "profiles." + name
if apiURL != "" {
viper.Set(base+".api_url", apiURL)
}
if token != "" {
viper.Set(base+".token", token)
}
viper.Set("current_profile", name)
return writeConfig()
}
// ClearProfileToken removes only the token from the active profile.
func ClearProfileToken(name string) error {
viper.Set("profiles."+name+".token", "")
return writeConfig()
}
// ListProfiles returns profile name → api_url for `auth status --all`.
func ListProfiles() map[string]string {
out := map[string]string{}
profiles := viper.GetStringMap("profiles")
for name := range profiles {
if u := viper.GetString("profiles." + name + ".api_url"); u != "" {
out[name] = u
} else {
out[name] = "(no url)"
}
}
return out
}
func writeConfig() error {
if err := viper.WriteConfig(); err != nil {
// No config file yet — create one.
home, herr := os.UserHomeDir()
if herr != nil {
return fmt.Errorf("cannot locate home directory: %w", herr)
}
return viper.WriteConfigAs(home + "/.containr.yaml")
}
return nil
}