Files
Containr/app/backend/internal/cli/commands/auth.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

152 lines
3.9 KiB
Go

package commands
import (
"fmt"
"os"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// AuthCmd groups authentication commands.
var AuthCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate with a Containr instance",
Long: `Login with a personal access token (cnp_...) created under
Settings → Personal Access Tokens, check status, or switch profiles.`,
}
var loginCmd = &cobra.Command{
Use: "login [token]",
Short: "Store a personal access token",
Long: `Login with a cnp_ token. Reads interactively when no argument is
given. The token is verified against the API before it is stored.`,
Args: cobra.MaximumNArgs(1),
RunE: runLogin,
}
var logoutCmd = &cobra.Command{
Use: "logout",
Short: "Remove the stored token",
RunE: runLogout,
}
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show the active profile and verify the token",
RunE: runStatus,
}
func init() {
loginCmd.Flags().String("url", "", "API URL to store in the profile (e.g. https://containr.example.com)")
loginCmd.Flags().String("name", "", "profile name to save under (default: active profile)")
AuthCmd.AddCommand(loginCmd, logoutCmd, statusCmd)
}
type profileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
}
func runLogin(cmd *cobra.Command, args []string) error {
var token string
if len(args) > 0 {
token = args[0]
} else if isTerminal() {
fmt.Fprint(os.Stderr, "Token: ")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
token = string(raw)
}
if token == "" {
return &APIError{Message: "token is required — pass it as an argument or pipe it via stdin", ExitCode: ExitValidation}
}
// Verify before saving so a mistyped token fails loudly.
urlFlag, _ := cmd.Flags().GetString("url")
probe := &Client{BaseURL: ResolveAPIURL(), Token: token}
if urlFlag != "" {
probe.BaseURL = normalizeBase(urlFlag)
}
var profile profileResponse
if err := probe.DoJSON("GET", "/user/profile", nil, &profile); err != nil {
return fmt.Errorf("token verification failed: %w", err)
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
name = ActiveProfile()
}
if err := SaveProfile(name, probe.BaseURL, token); err != nil {
return fmt.Errorf("failed to save profile: %w", err)
}
if JSONMode() {
return PrintJSON(map[string]interface{}{
"profile": name, "api_url": probe.BaseURL,
"user": profile,
})
}
fmt.Printf("Logged in as %s (%s) — profile %q → %s\n", profile.Name, profile.Email, name, probe.BaseURL)
return nil
}
func runLogout(cmd *cobra.Command, args []string) error {
name := ActiveProfile()
if err := ClearProfileToken(name); err != nil {
return fmt.Errorf("failed to update config: %w", err)
}
if !JSONMode() {
fmt.Printf("Removed token from profile %q\n", name)
} else {
return PrintJSON(map[string]string{"status": "ok", "profile": name})
}
return nil
}
func runStatus(cmd *cobra.Command, args []string) error {
client := NewClient()
profile := ActiveProfile()
if JSONMode() {
out := map[string]interface{}{
"profile": profile,
"api_url": client.BaseURL,
}
if client.Token != "" {
var p profileResponse
if err := client.DoJSON("GET", "/user/profile", nil, &p); err == nil {
out["user"] = p
out["authenticated"] = true
} else {
out["authenticated"] = false
}
} else {
out["authenticated"] = false
}
return PrintJSON(out)
}
fmt.Printf("Profile: %s\n", profile)
fmt.Printf("API URL: %s\n", client.BaseURL)
if client.Token == "" {
fmt.Println("Token: not set — run `containr auth login <token>`")
return nil
}
var p profileResponse
if err := client.DoJSON("GET", "/user/profile", nil, &p); err != nil {
fmt.Printf("Token: present but rejected (%v)\n", err)
return nil
}
fmt.Printf("Identity: %s <%s>\n", p.Name, p.Email)
if p.IsAdmin {
fmt.Println("Role: platform admin")
}
return nil
}