mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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]>
114 lines
2.6 KiB
Go
114 lines
2.6 KiB
Go
package commands
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// client returns an authenticated API client or an ExitAuth error.
|
|
func client() (*Client, error) {
|
|
c := NewClient()
|
|
if err := c.RequireAuth(); err != nil {
|
|
return nil, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// unwrapList accepts both top-level arrays and {"<key>": [...]} envelopes,
|
|
// which the Containr API uses inconsistently between endpoints.
|
|
func unwrapList(data []byte, keys ...string) ([]map[string]interface{}, error) {
|
|
var arr []map[string]interface{}
|
|
if err := json.Unmarshal(data, &arr); err == nil {
|
|
return arr, nil
|
|
}
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &obj); err != nil {
|
|
return nil, fmt.Errorf("unexpected response shape: %w", err)
|
|
}
|
|
for _, k := range keys {
|
|
if raw, ok := obj[k]; ok {
|
|
if err := json.Unmarshal(raw, &arr); err != nil {
|
|
return nil, fmt.Errorf("cannot decode %q: %w", k, err)
|
|
}
|
|
return arr, nil
|
|
}
|
|
}
|
|
return []map[string]interface{}{}, nil
|
|
}
|
|
|
|
func unwrapObject(data []byte, keys ...string) (map[string]interface{}, error) {
|
|
var obj map[string]interface{}
|
|
if err := json.Unmarshal(data, &obj); err != nil {
|
|
return nil, fmt.Errorf("unexpected response shape: %w", err)
|
|
}
|
|
for _, k := range keys {
|
|
if inner, ok := obj[k].(map[string]interface{}); ok {
|
|
return inner, nil
|
|
}
|
|
}
|
|
return obj, nil
|
|
}
|
|
|
|
// str extracts a display string from a response map.
|
|
func str(m map[string]interface{}, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v, ok := m[k]; ok && v != nil {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case float64:
|
|
if t == float64(int64(t)) {
|
|
return fmt.Sprintf("%d", int64(t))
|
|
}
|
|
return fmt.Sprintf("%v", t)
|
|
case bool:
|
|
return fmt.Sprintf("%t", t)
|
|
}
|
|
}
|
|
}
|
|
return "-"
|
|
}
|
|
|
|
func shortID(m map[string]interface{}) string {
|
|
id := str(m, "id")
|
|
if len(id) > 8 {
|
|
return id[:8]
|
|
}
|
|
return id
|
|
}
|
|
|
|
func relTime(m map[string]interface{}, keys ...string) string {
|
|
for _, k := range keys {
|
|
s, ok := m[k].(string)
|
|
if !ok || s == "" {
|
|
continue
|
|
}
|
|
t, err := time.Parse(time.RFC3339Nano, s)
|
|
if err != nil {
|
|
return s
|
|
}
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
case d < 24*time.Hour:
|
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
|
}
|
|
}
|
|
return "-"
|
|
}
|
|
|
|
// printRows renders a table, or raw JSON for the full response in --json mode.
|
|
func printRows(raw []byte, headers []string, rows [][]string) {
|
|
if JSONMode() {
|
|
PrintRaw(raw)
|
|
return
|
|
}
|
|
PrintTable(headers, rows)
|
|
}
|