Files
zeekayandHanzo Dev 472238ddb0 cd: the API is /v1, not /api/v1 — no /api prefix anywhere
House rule: /v1/ is the API prefix, everywhere, and /api/ is never added on
top of it. The api.* subdomain names the API; a path prefix restating it is
noise. This applies it to the whole route surface:

  /api/v1/*      -> /v1/*        106 proto annotations, 109 gateway patterns
  /api/version   -> /v1/version
  /api/badge     -> /v1/badge
  /api/webhook   -> /v1/webhook  (server + applicationset controller)
  /api/dex/*     -> /v1/dex/*    (constants; dex is built, not deployed)

The grpc-gateway .pb.gw.go files are generated, and the plugin toolchain is
not on this machine, so they were rewritten by a script that applies exactly
the transform codegen would: for /api/v1 paths, drop the leading "api"
literal-push opcode pair and decrement every LitPush(2,N)/Capture(5,N) pool
index; for /api/version, rename the segment. ConcatN(4,N) operands are
counts, not pool indexes, and are untouched. The .proto annotations moved in
the same commit, so the next real codegen run is a no-op against these files.

MustPattern panics at package init on a malformed pattern, so every rewritten
pattern is validated by any import of pkg/apiclient — those tests pass.

Also moved: assets/swagger.json (82 paths), the UI's two roots (apiRoot()
'/v1', version-service '/v1/version') and its two relative composers (app
logs download, badge panel), the five audit-pattern regexes in server.go, and
the tests that asserted the old paths. terminal_test's request URL claimed
/api/v1/terminal, a route that never existed — the handler is mounted at
/terminal; the fixture now says so.

NOT touched, deliberately: /api/v3 in GitHub Enterprise base-url examples
(GitHub's URL shape, not ours), util/vendored/kubeclientmetrics (parses the
KUBERNETES API server's /api/v1 paths), and util/http's cookie-path fixture
(arbitrary string exercising slash normalization).

go build ./... clean. Pre-existing failures in badge/session/webhook/account
tests reproduce identically on the parent commit (rebrand fallout — badge
colors, cookie name length, dex issuer) and are not made worse here.

External consumers checked: universe has zero references to cd.hanzo.ai/api;
the CLI speaks gRPC, not the REST gateway; UI and server ship in one image so
they move together.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 12:01:34 -07:00

541 lines
16 KiB
Go

package commands
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
timeutil "github.com/hanzoai/cd/util/vendored/time"
"github.com/golang-jwt/jwt/v5"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/term"
"sigs.k8s.io/yaml"
jwtutil "github.com/hanzoai/cd/util/jwt"
"github.com/hanzoai/cd/util/rbac"
"github.com/hanzoai/cd/cmd/cd/commands/headless"
"github.com/hanzoai/cd/cmd/cd/commands/utils"
cdclient "github.com/hanzoai/cd/pkg/apiclient"
accountpkg "github.com/hanzoai/cd/pkg/apiclient/account"
"github.com/hanzoai/cd/pkg/apiclient/session"
"github.com/hanzoai/cd/util/cli"
"github.com/hanzoai/cd/util/errors"
utilio "github.com/hanzoai/cd/util/io"
"github.com/hanzoai/cd/util/localconfig"
sessionutil "github.com/hanzoai/cd/util/session"
"github.com/hanzoai/cd/util/templates"
)
func NewAccountCommand(clientOpts *cdclient.ClientOptions) *cobra.Command {
command := &cobra.Command{
Use: "account",
Short: "Manage account settings",
Example: templates.Examples(`
# List accounts
cd account list
# Update the current user's password
cd account update-password
# Can I sync any app?
cd account can-i sync applications '*'
# Get User information
cd account get-user-info
`),
Run: func(c *cobra.Command, args []string) {
c.HelpFunc()(c, args)
os.Exit(1)
},
}
command.AddCommand(NewAccountUpdatePasswordCommand(clientOpts))
command.AddCommand(NewAccountGetUserInfoCommand(clientOpts))
command.AddCommand(NewAccountCanICommand(clientOpts))
command.AddCommand(NewAccountListCommand(clientOpts))
command.AddCommand(NewAccountGenerateTokenCommand(clientOpts))
command.AddCommand(NewAccountGetCommand(clientOpts))
command.AddCommand(NewAccountDeleteTokenCommand(clientOpts))
command.AddCommand(NewAccountSessionTokenCommand(clientOpts))
command.AddCommand(NewBcryptCmd())
return command
}
func NewAccountUpdatePasswordCommand(clientOpts *cdclient.ClientOptions) *cobra.Command {
var (
account string
currentPassword string
newPassword string
)
command := &cobra.Command{
Use: "update-password",
Short: "Update an account's password",
Long: `
This command can be used to update the password of the currently logged on
user, or an arbitrary local user account when the currently logged on user
has appropriate RBAC permissions to change other accounts.
`,
Example: `
# Update the current user's password
cd account update-password
# Update the password for user foobar
cd account update-password --account foobar
`,
Run: func(c *cobra.Command, args []string) {
ctx := c.Context()
if len(args) != 0 {
c.HelpFunc()(c, args)
os.Exit(1)
}
acdClient := headless.NewClientOrDie(clientOpts, c)
conn, usrIf := acdClient.NewAccountClientOrDie()
defer utilio.Close(conn)
userInfo := getCurrentAccount(ctx, acdClient)
if userInfo.Iss == sessionutil.SessionManagerClaimsIssuer && currentPassword == "" {
fmt.Printf("*** Enter password of currently logged in user (%s): ", userInfo.Username)
password, err := term.ReadPassword(int(os.Stdin.Fd()))
errors.CheckError(err)
currentPassword = string(password)
fmt.Print("\n")
}
if account == "" {
account = userInfo.Username
}
if newPassword == "" {
var err error
newPassword, err = cli.ReadAndConfirmPassword(account)
errors.CheckError(err)
}
updatePasswordRequest := accountpkg.UpdatePasswordRequest{
NewPassword: newPassword,
CurrentPassword: currentPassword,
Name: account,
}
_, err := usrIf.UpdatePassword(ctx, &updatePasswordRequest)
errors.CheckError(err)
fmt.Print("Password updated\n")
if account == "" || account == userInfo.Username {
// Get a new JWT token after updating the password
localCfg, err := localconfig.ReadLocalConfig(clientOpts.ConfigPath)
errors.CheckError(err)
configCtx, err := localCfg.ResolveContext(clientOpts.Context)
errors.CheckError(err)
claims, err := configCtx.User.Claims()
errors.CheckError(err)
tokenString := passwordLogin(ctx, acdClient, localconfig.GetUsername(jwtutil.StringField(claims, "sub")), newPassword)
localCfg.UpsertUser(localconfig.User{
Name: localCfg.CurrentContext,
AuthToken: tokenString,
})
err = localconfig.WriteLocalConfig(*localCfg, clientOpts.ConfigPath)
errors.CheckError(err)
fmt.Printf("Context '%s' updated\n", localCfg.CurrentContext)
}
},
}
command.Flags().StringVar(&currentPassword, "current-password", "", "Password of the currently logged on user")
command.Flags().StringVar(&newPassword, "new-password", "", "New password you want to update to")
command.Flags().StringVar(&account, "account", "", "An account name that should be updated. Defaults to current user account")
return command
}
func NewAccountGetUserInfoCommand(clientOpts *cdclient.ClientOptions) *cobra.Command {
var output string
command := &cobra.Command{
Use: "get-user-info",
Short: "Get user info",
Aliases: []string{"whoami"},
Example: templates.Examples(`
# Get User information for the currently logged-in user (see 'cd login')
cd account get-user-info
# Get User information in yaml format
cd account get-user-info -o yaml
`),
Run: func(c *cobra.Command, args []string) {
ctx := c.Context()
if len(args) != 0 {
c.HelpFunc()(c, args)
os.Exit(1)
}
conn, client := headless.NewClientOrDie(clientOpts, c).NewSessionClientOrDie()
defer utilio.Close(conn)
response, err := client.GetUserInfo(ctx, &session.GetUserInfoRequest{})
errors.CheckError(err)
switch output {
case "yaml":
yamlBytes, err := yaml.Marshal(response)
errors.CheckError(err)
fmt.Println(string(yamlBytes))
case "json":
jsonBytes, err := json.MarshalIndent(response, "", " ")
errors.CheckError(err)
fmt.Println(string(jsonBytes))
case "":
fmt.Printf("Logged In: %v\n", response.LoggedIn)
if response.LoggedIn {
fmt.Printf("Username: %s\n", response.Username)
fmt.Printf("Issuer: %s\n", response.Iss)
fmt.Printf("Groups: %v\n", strings.Join(response.Groups, ","))
}
default:
log.Fatalf("Unknown output format: %s", output)
}
},
}
command.Flags().StringVarP(&output, "output", "o", "", "Output format. One of: yaml, json")
return command
}
func NewAccountCanICommand(clientOpts *cdclient.ClientOptions) *cobra.Command {
return &cobra.Command{
Use: "can-i ACTION RESOURCE SUBRESOURCE",
Short: "Can I",
Example: fmt.Sprintf(`
# Can I sync any app?
cd account can-i sync applications '*'
# Can I update a project?
cd account can-i update projects 'default'
# Can I create a cluster?
cd account can-i create clusters '*'
Actions: %v
Resources: %v
`, rbac.Actions, rbac.Resources),
Run: func(c *cobra.Command, args []string) {
ctx := c.Context()
if len(args) != 3 {
c.HelpFunc()(c, args)
os.Exit(1)
}
conn, client := headless.NewClientOrDie(clientOpts, c).NewAccountClientOrDie()
defer utilio.Close(conn)
response, err := client.CanI(ctx, &accountpkg.CanIRequest{
Action: args[0],
Resource: args[1],
Subresource: args[2],
})
errors.CheckError(err)
fmt.Println(response.Value)
},
}
}
func printAccountNames(accounts []*accountpkg.Account) {
for _, p := range accounts {
fmt.Println(p.Name)
}
}
func printAccountsTable(items []*accountpkg.Account) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprint(w, "NAME\tENABLED\tCAPABILITIES\n")
for _, a := range items {
fmt.Fprintf(w, "%s\t%v\t%s\n", a.Name, a.Enabled, strings.Join(a.Capabilities, ", "))
}
_ = w.Flush()
}
func NewAccountListCommand(clientOpts *cdclient.ClientOptions) *cobra.Command {
var output string
cmd := &cobra.Command{
Use: "list",
Short: "List accounts",
Example: "cd account list",
Run: func(c *cobra.Command, _ []string) {
ctx := c.Context()
conn, client := headless.NewClientOrDie(clientOpts, c).NewAccountClientOrDie()
defer utilio.Close(conn)
response, err := client.ListAccounts(ctx, &accountpkg.ListAccountRequest{})
errors.CheckError(err)
switch output {
case "yaml", "json":
err := PrintResourceList(response.Items, output, false)
errors.CheckError(err)
case "name":
printAccountNames(response.Items)
case "wide", "":
printAccountsTable(response.Items)
default:
errors.CheckError(fmt.Errorf("unknown output format: %s", output))
}
},
}
cmd.Flags().StringVarP(&output, "output", "o", "wide", "Output format. One of: json|yaml|wide|name")
return cmd
}
func getCurrentAccount(ctx context.Context, clientset cdclient.Client) session.GetUserInfoResponse {
conn, client := clientset.NewSessionClientOrDie()
defer utilio.Close(conn)
userInfo, err := client.GetUserInfo(ctx, &session.GetUserInfoRequest{})
errors.CheckError(err)
return *userInfo
}
func NewAccountGetCommand(clientOpts *cdclient.ClientOptions) *cobra.Command {
var (
output string
account string
)
cmd := &cobra.Command{
Use: "get",
Short: "Get account details",
Example: `# Get the currently logged in account details
cd account get
# Get details for an account by name
cd account get --account <account-name>`,
Run: func(c *cobra.Command, _ []string) {
ctx := c.Context()
clientset := headless.NewClientOrDie(clientOpts, c)
if account == "" {
account = getCurrentAccount(ctx, clientset).Username
}
conn, client := clientset.NewAccountClientOrDie()
defer utilio.Close(conn)
acc, err := client.GetAccount(ctx, &accountpkg.GetAccountRequest{Name: account})
errors.CheckError(err)
switch output {
case "yaml", "json":
err := PrintResourceList(acc, output, true)
errors.CheckError(err)
case "name":
fmt.Println(acc.Name)
case "wide", "":
printAccountDetails(acc)
default:
errors.CheckError(fmt.Errorf("unknown output format: %s", output))
}
},
}
cmd.Flags().StringVarP(&output, "output", "o", "wide", "Output format. One of: json|yaml|wide|name")
cmd.Flags().StringVarP(&account, "account", "a", "", "Account name. Defaults to the current account.")
return cmd
}
func printAccountDetails(acc *accountpkg.Account) {
fmt.Printf(printOpFmtStr, "Name:", acc.Name)
fmt.Printf(printOpFmtStr, "Enabled:", strconv.FormatBool(acc.Enabled))
fmt.Printf(printOpFmtStr, "Capabilities:", strings.Join(acc.Capabilities, ", "))
fmt.Println("\nTokens:")
if len(acc.Tokens) == 0 {
fmt.Println("NONE")
} else {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprint(w, "ID\tISSUED AT\tEXPIRING AT\n")
for _, t := range acc.Tokens {
expiresAtFormatted := "never"
if t.ExpiresAt > 0 {
expiresAt := time.Unix(t.ExpiresAt, 0)
expiresAtFormatted = expiresAt.Format(time.RFC3339)
if expiresAt.Before(time.Now()) {
expiresAtFormatted = expiresAtFormatted + " (expired)"
}
}
fmt.Fprintf(w, "%s\t%s\t%s\n", t.Id, time.Unix(t.IssuedAt, 0).Format(time.RFC3339), expiresAtFormatted)
}
_ = w.Flush()
}
}
func NewAccountGenerateTokenCommand(clientOpts *cdclient.ClientOptions) *cobra.Command {
var (
account string
expiresIn string
id string
)
cmd := &cobra.Command{
Use: "generate-token",
Short: "Generate account token",
Example: `# Generate token for the currently logged in account
cd account generate-token
# Generate token for the account with the specified name
cd account generate-token --account <account-name>`,
Run: func(c *cobra.Command, _ []string) {
ctx := c.Context()
clientset := headless.NewClientOrDie(clientOpts, c)
conn, client := clientset.NewAccountClientOrDie()
defer utilio.Close(conn)
if account == "" {
account = getCurrentAccount(ctx, clientset).Username
}
expiresIn, err := timeutil.ParseDuration(expiresIn)
errors.CheckError(err)
response, err := client.CreateToken(ctx, &accountpkg.CreateTokenRequest{
Name: account,
ExpiresIn: int64(expiresIn.Seconds()),
Id: id,
})
errors.CheckError(err)
fmt.Println(response.Token)
},
}
cmd.Flags().StringVarP(&account, "account", "a", "", "Account name. Defaults to the current account.")
cmd.Flags().StringVarP(&expiresIn, "expires-in", "e", "0s", "Duration before the token will expire. (Default: No expiration)")
cmd.Flags().StringVar(&id, "id", "", "Optional token id. Fall back to uuid if not value specified.")
return cmd
}
func NewAccountDeleteTokenCommand(clientOpts *cdclient.ClientOptions) *cobra.Command {
var account string
cmd := &cobra.Command{
Use: "delete-token",
Short: "Deletes account token",
Example: `# Delete token of the currently logged in account
cd account delete-token ID
# Delete token of the account with the specified name
cd account delete-token --account <account-name> ID`,
Run: func(c *cobra.Command, args []string) {
ctx := c.Context()
if len(args) != 1 {
c.HelpFunc()(c, args)
os.Exit(1)
}
id := args[0]
clientset := headless.NewClientOrDie(clientOpts, c)
conn, client := clientset.NewAccountClientOrDie()
defer utilio.Close(conn)
if account == "" {
account = getCurrentAccount(ctx, clientset).Username
}
promptUtil := utils.NewPrompt(clientOpts.PromptsEnabled)
canDelete := promptUtil.Confirm(fmt.Sprintf("Are you sure you want to delete '%s' token? [y/n]", id))
if canDelete {
_, err := client.DeleteToken(ctx, &accountpkg.DeleteTokenRequest{Name: account, Id: id})
errors.CheckError(err)
} else {
fmt.Printf("The command to delete '%s' was cancelled.\n", id)
}
},
}
cmd.Flags().StringVarP(&account, "account", "a", "", "Account name. Defaults to the current account.")
return cmd
}
func NewAccountSessionTokenCommand(clientOpts *cdclient.ClientOptions) *cobra.Command {
var output string
cmd := &cobra.Command{
Use: "session-token",
Short: "Display current session token",
Long: `Display the current session token for authentication.
Automatically refreshes expired tokens using refresh token (SSO users).
For local users: Shows current token (manual relogin needed if expired)`,
Example: `# Display current session token (automatically refreshes if needed)
cd account session-token
# Show detailed token information
cd account session-token -o json
# Use in scripts
export CD_AUTH_TOKEN=$(cd account session-token)
curl -H "Authorization: Bearer $CD_AUTH_TOKEN" $CD_SERVER/v1/applications`,
Run: func(_ *cobra.Command, _ []string) {
// Create client first - this handles token refresh automatically
_, err := cdclient.NewClient(clientOpts)
if err != nil {
if strings.Contains(err.Error(), "invalid_grant") && strings.Contains(err.Error(), "Invalid refresh_token") {
log.Fatal("Refresh token is invalid or expired. Please run 'cd relogin' to re-authenticate")
}
log.Fatal(err)
}
// Read config after client creation to get potentially refreshed token
localCfg, err := localconfig.ReadLocalConfig(clientOpts.ConfigPath)
errors.CheckError(err)
if localCfg == nil {
log.Fatal("No configuration found. Please login first with 'cd login'")
}
configCtx, err := localCfg.ResolveContext(clientOpts.Context)
errors.CheckError(err)
if configCtx == nil {
log.Fatal("No context found. Please login first with 'cd login'")
}
if configCtx.User.AuthToken == "" {
log.Fatal("No authentication token found. Please login first with 'cd login'")
}
// Get token claims and validate
claims, err := configCtx.User.Claims()
if err != nil {
log.Fatal("Invalid token format. Please run 'cd relogin'")
}
validator := jwt.NewValidator()
if validator.Validate(claims) != nil {
log.Fatal("Token is invalid or expired. Please run 'cd relogin'")
}
switch output {
case "json":
iss := jwtutil.StringField(claims, "iss")
tokenInfo := map[string]any{
"type": "local",
"issuer": iss,
"username": localconfig.GetUsername(jwtutil.GetUserIdentifier(claims)),
"token": configCtx.User.AuthToken,
"has_refresh_token": configCtx.User.RefreshToken != "",
}
if iss != sessionutil.SessionManagerClaimsIssuer {
tokenInfo["type"] = "sso"
}
if iat, err := jwtutil.IssuedAtTime(claims); err == nil {
tokenInfo["issued_at"] = iat.Format(time.RFC3339)
}
if exp, err := jwtutil.ExpirationTime(claims); err == nil {
tokenInfo["expires_at"] = exp.Format(time.RFC3339)
}
jsonBytes, err := json.MarshalIndent(tokenInfo, "", " ")
errors.CheckError(err)
fmt.Println(string(jsonBytes))
default:
fmt.Println(configCtx.User.AuthToken)
}
},
}
cmd.Flags().StringVarP(&output, "output", "o", "", "Output format (json)")
return cmd
}