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]>
This commit is contained in:
Tomas Dvorak committed 2026-10-08 15:19:31 +02:00
1 parent 17e429b86b
commit e51b20b2d4
26 files changed
+2798 -350

No files matched your search

+56 -36
View File
@@ -12,23 +12,35 @@ import (
var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "containr",
Short: "Containr CLI - Manage your self-hosted PaaS",
Long: `Containr CLI is a command-line interface for managing your Containr platform.
You can manage projects, services, deployments, databases, and more from your terminal.`,
Version: "1.0.0",
Short: "Containr CLI - manage your self-hosted PaaS",
Long: `Containr CLI controls every part of a Containr instance: projects,
services, deployments, databases, cron jobs, templates, nodes, scaling,
HA, security, and the API gateway.
Authenticate once with a personal access token (Settings → Personal
Access Tokens in the web UI):
containr auth login cnp_...
Every command supports --json for machine-readable output, and
destructive actions accept --yes to skip confirmation.`,
Version: "2.0.0",
SilenceUsage: true,
SilenceErrors: true,
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
cobra.CheckErr(rootCmd.Execute())
// Execute runs the root command and returns the process exit code.
func Execute() int {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return commands.ExitCode(err)
}
return commands.ExitOK
}
// SetVersion overrides the version reported by `containr --version`.
// Called by main with the value injected via -ldflags at build time.
func SetVersion(v string) {
rootCmd.Version = v
}
@@ -36,46 +48,54 @@ func SetVersion(v string) {
func init() {
cobra.OnInitialize(initConfig)
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.containr.yaml)")
rootCmd.PersistentFlags().String("api-url", "", "Containr API URL (default is https://api.containr.dev)")
rootCmd.PersistentFlags().String("token", "", "Authentication token")
pf := rootCmd.PersistentFlags()
pf.StringVar(&cfgFile, "config", "", "config file (default $HOME/.containr.yaml)")
pf.String("api-url", "", "Containr API URL (overrides profile and CONTAINR_API_URL)")
pf.String("token", "", "auth token (overrides profile and CONTAINR_TOKEN)")
pf.StringP("profile", "p", "", "config profile to use (default current_profile)")
pf.Bool("json", false, "machine-readable JSON output")
pf.BoolP("yes", "y", false, "skip confirmation prompts (required for destructive ops in scripts)")
// Bind flags to viper
viper.BindPFlag("api-url", rootCmd.PersistentFlags().Lookup("api-url"))
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
viper.BindPFlag("api-url", pf.Lookup("api-url"))
viper.BindPFlag("token", pf.Lookup("token"))
viper.BindPFlag("profile", pf.Lookup("profile"))
viper.BindPFlag("json", pf.Lookup("json"))
viper.BindPFlag("yes", pf.Lookup("yes"))
// Add command groups
rootCmd.AddCommand(commands.AuthCmd)
rootCmd.AddCommand(commands.TokensCmd)
rootCmd.AddCommand(commands.ProjectsCmd)
rootCmd.AddCommand(commands.ServicesCmd)
rootCmd.AddCommand(commands.DeployCmd)
rootCmd.AddCommand(commands.DeploymentsCmd)
rootCmd.AddCommand(commands.LogsCmd)
rootCmd.AddCommand(commands.ExecCmd)
rootCmd.AddCommand(commands.VariablesCmd)
rootCmd.AddCommand(commands.DatabasesCmd)
rootCmd.AddCommand(commands.CronCmd)
rootCmd.AddCommand(commands.TemplatesCmd)
rootCmd.AddCommand(commands.NodesCmd)
rootCmd.AddCommand(commands.ScalingCmd)
rootCmd.AddCommand(commands.HACmd)
rootCmd.AddCommand(commands.SecurityCmd)
rootCmd.AddCommand(commands.GatewayCmd)
rootCmd.AddCommand(commands.AdminCmd)
rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.UpCmd)
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find home directory.
home, err := os.UserHomeDir()
cobra.CheckErr(err)
// Search config in home directory with name ".containr" (without extension).
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".containr")
}
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
// Run executes the CLI
func Run() error {
rootCmd.Execute()
return nil
viper.AutomaticEnv()
// CONTAINR_TOKEN / CONTAINR_API_URL are read explicitly in config.go;
// AutomaticEnv covers them only when bound, so this is belt-and-braces.
viper.ReadInConfig()
}