Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion internal/assist/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ dhq language-versions list -p <project> (alias: dhq lv list)

SSH & Deployment Commands:
dhq ssh-commands list|show|create|update|delete -p <project>
dhq ssh-keys list|create|delete
dhq ssh-keys list|create|download|delete

Integrations & Automation:
dhq integrations list|show|create|update|delete -p <project>
Expand All @@ -126,7 +126,12 @@ dhq templates list|show|public|public-show|create|update|delete

Account Resources:
dhq agents list|create|update|delete|revoke
dhq folders list|create|update|delete
dhq global-servers list|show|create|update|delete|copy-to-project
dhq users list|show|create|update|delete|resend-invitation
dhq account get|update|billing
dhq profile get|update
dhq api-keys create|delete
dhq zones list

Dashboard & Activity:
Expand Down
164 changes: 164 additions & 0 deletions internal/commands/account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package commands

import (
"strconv"

"github.com/deployhq/deployhq-cli/internal/output"
"github.com/deployhq/deployhq-cli/pkg/sdk"
"github.com/spf13/cobra"
)

func newAccountCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "account",
Short: "Manage the current account",
Long: `The current DeployHQ account. A singular resource — there is no list.`,
}
cmd.AddCommand(
newAccountGetCmd(),
newAccountUpdateCmd(),
newAccountBillingCmd(),
)
return cmd
}

func newAccountGetCmd() *cobra.Command {
return &cobra.Command{
Use: "get", Short: "Show the current account",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := cliCtx.Client()
if err != nil {
return err
}
a, err := client.GetAccount(cliCtx.Background())
if err != nil {
return err
}
env := cliCtx.Envelope
if env.WantsJSON() {
return env.WriteJSON(output.NewResponse(a, a.Name,
output.Breadcrumb{Action: "update", Cmd: "dhq account update"},
output.Breadcrumb{Action: "billing", Cmd: "dhq account billing"},
))
}
limit := "unlimited"
if a.ProjectLimit != nil {
limit = strconv.Itoa(*a.ProjectLimit)
}
env.WriteTable(
[]string{"Field", "Value"},
[][]string{
{"Name", a.Name},
{"Permalink", a.Permalink},
{"Time zone", a.TimeZone},
{"Package", a.Package},
{"Trialling", strconv.FormatBool(a.Trialling)},
{"Suspended", strconv.FormatBool(a.Suspended)},
{"Projects", strconv.Itoa(a.ProjectCount)},
{"Project limit", limit},
{"AI features disabled", strconv.FormatBool(a.AIFeaturesDisabled)},
},
)
return nil
},
}
}

func newAccountUpdateCmd() *cobra.Command {
var (
name, permalink, timeZone, cname string
ipRestricted, aiDisabled, twoFactorRequired, strongPassword bool
)
cmd := &cobra.Command{
Use: "update", Short: "Update the current account",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := cliCtx.Client()
if err != nil {
return err
}
req := sdk.AccountUpdateRequest{}
if cmd.Flags().Changed("name") {
req.Name = &name
}
if cmd.Flags().Changed("permalink") {
req.Permalink = &permalink
}
if cmd.Flags().Changed("time-zone") {
req.TimeZone = &timeZone
}
if cmd.Flags().Changed("cname") {
req.CName = &cname
}
if cmd.Flags().Changed("ip-restricted") {
req.IPRestricted = &ipRestricted
}
if cmd.Flags().Changed("ai-features-disabled") {
req.AIFeaturesDisabled = &aiDisabled
}
if cmd.Flags().Changed("two-factor-required") {
req.TwoFactorAuthRequired = &twoFactorRequired
}
if cmd.Flags().Changed("strong-password-required") {
req.StrongPasswordRequired = &strongPassword
}
a, err := client.UpdateAccount(cliCtx.Background(), req)
if err != nil {
return err
}
env := cliCtx.Envelope
if env.WantsJSON() {
return env.WriteJSON(output.NewResponse(a, "Updated account: "+a.Name,
output.Breadcrumb{Action: "get", Cmd: "dhq account get"},
))
}
env.Status("Updated account: %s", a.Name)
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "Account name")
cmd.Flags().StringVar(&permalink, "permalink", "", "Account permalink")
cmd.Flags().StringVar(&timeZone, "time-zone", "", "Time zone")
cmd.Flags().StringVar(&cname, "cname", "", "Custom CNAME")
cmd.Flags().BoolVar(&ipRestricted, "ip-restricted", false, "Restrict access by IP")
cmd.Flags().BoolVar(&aiDisabled, "ai-features-disabled", false, "Disable AI features")
cmd.Flags().BoolVar(&twoFactorRequired, "two-factor-required", false, "Require two-factor authentication")
cmd.Flags().BoolVar(&strongPassword, "strong-password-required", false, "Require strong passwords")
return cmd
}

func newAccountBillingCmd() *cobra.Command {
return &cobra.Command{
Use: "billing", Short: "Show billing status",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := cliCtx.Client()
if err != nil {
return err
}
b, err := client.GetBillingStatus(cliCtx.Background())
if err != nil {
return err
}
env := cliCtx.Envelope
if env.WantsJSON() {
return env.WriteJSON(output.NewResponse(b, "Billing status",
output.Breadcrumb{Action: "get", Cmd: "dhq account get"},
))
}
rows := [][]string{
{"Package", b.Package},
{"Frequency", strconv.Itoa(b.Frequency)},
{"Trialling", strconv.FormatBool(b.Trialling)},
{"Suspended", strconv.FormatBool(b.Suspended)},
}
if b.ScheduledChange != nil {
rows = append(rows,
[]string{"Scheduled package", b.ScheduledChange.TargetPackage},
[]string{"Scheduled frequency", strconv.Itoa(b.ScheduledChange.TargetFrequency)},
[]string{"Effective at", b.ScheduledChange.EffectiveAt},
)
}
env.WriteTable([]string{"Field", "Value"}, rows)
return nil
},
}
}
61 changes: 61 additions & 0 deletions internal/commands/account_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package commands

import (
"bytes"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAccountCommand_Registered(t *testing.T) {
cmd := NewRootCmd("test")
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"account", "--help"})

require.NoError(t, cmd.Execute())
out := stdout.String()
assert.Contains(t, out, "get")
assert.Contains(t, out, "update")
assert.Contains(t, out, "billing")
}

func TestAccountUpdateCommand_Help(t *testing.T) {
cmd := NewRootCmd("test")
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"account", "update", "--help"})

require.NoError(t, cmd.Execute())
out := stdout.String()
assert.Contains(t, out, "--name")
assert.Contains(t, out, "--ip-restricted")
assert.Contains(t, out, "--cname")
}

func TestAgentMetadata_AccountGet(t *testing.T) {
m := lookupAgentMetadata("dhq account get")
assert.True(t, m.Idempotent)
assert.True(t, m.SupportsJSON)
assert.True(t, m.SafeForAutomation)
assert.False(t, m.Destructive)
assert.Contains(t, m.ResourceTypes, "account")
}

func TestAgentMetadata_AccountUpdate(t *testing.T) {
m := lookupAgentMetadata("dhq account update")
assert.True(t, m.Idempotent)
assert.True(t, m.SupportsJSON)
assert.True(t, m.SafeForAutomation)
assert.False(t, m.Destructive)
assert.Contains(t, m.ResourceTypes, "account")
}

func TestAgentMetadata_AccountBilling(t *testing.T) {
m := lookupAgentMetadata("dhq account billing")
assert.True(t, m.Idempotent)
assert.True(t, m.SupportsJSON)
assert.True(t, m.SafeForAutomation)
assert.Contains(t, m.ResourceTypes, "account")
}
90 changes: 90 additions & 0 deletions internal/commands/agent_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,96 @@ var commandMetadataTable = map[string]AgentMetadata{
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"ssh_key"},
},
"dhq ssh-keys download": {
// Emits raw private key material. Idempotent, but sensitive enough that
// an agent should confirm before running and not treat it as safe to run
// unattended (the key would land in logs/transcripts). Requires an admin
// on a paid account (else 403).
Idempotent: true, RequiresConfirmation: true,
SupportsJSON: true, SafeForAutomation: false,
ResourceTypes: []string{"ssh_key"},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
// Users
"dhq users list": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"user"},
},
"dhq users show": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"user"},
},
"dhq users create": {
SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"user"},
},
"dhq users update": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"user"},
},
"dhq users delete": {
Destructive: true, RequiresConfirmation: true,
SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"user"},
},
"dhq users resend-invitation": {
// Not idempotent: each call sends a fresh invitation email, so agents
// should not treat it as blindly retry-safe.
Idempotent: false, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"user"},
},

// Account
"dhq account get": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"account"},
},
"dhq account update": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"account"},
},
"dhq account billing": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"account"},
},

// Profile
"dhq profile get": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"profile"},
},
"dhq profile update": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"profile"},
},

// API keys
"dhq api-keys create": {
SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"api_key"},
},
"dhq api-keys delete": {
Destructive: true, RequiresConfirmation: true,
SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"api_key"},
},

"dhq folders list": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"folder"},
},
"dhq folders create": {
SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"folder"},
},
"dhq folders update": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"folder"},
},
"dhq folders delete": {
Destructive: true, RequiresConfirmation: true,
SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"folder"},
},
"dhq templates list": {
Idempotent: true, SupportsJSON: true, SafeForAutomation: true,
ResourceTypes: []string{"template"},
Expand Down
Loading
Loading