From 786bf1f16fb0a18e433637081ef9493ec33a0cc3 Mon Sep 17 00:00:00 2001 From: Marta Date: Fri, 10 Jul 2026 13:46:21 +0200 Subject: [PATCH 1/2] feat: add folders, global SSH key download, and account resources Extend CLI coverage of the DeployHQ API (DHQ-639): - folders (project categories): dhq folders list|create|update|delete - global SSH key download: dhq ssh-keys download [--output] - users: dhq users list|show|create|update|delete|resend-invitation - account: dhq account get|update|billing - profile: dhq profile get|update - api-keys: dhq api-keys create|delete Each resource ships an SDK client, cobra command, agent metadata, assist allowlist entry, and unit tests. Verified end-to-end against a live backend. --- internal/assist/prompt.go | 7 +- internal/commands/account.go | 164 +++++++++++++++++ internal/commands/account_test.go | 61 ++++++ internal/commands/agent_metadata.go | 87 +++++++++ internal/commands/api_keys.go | 93 ++++++++++ internal/commands/api_keys_test.go | 51 +++++ internal/commands/folders.go | 138 ++++++++++++++ internal/commands/folders_test.go | 63 +++++++ internal/commands/profile.go | 112 +++++++++++ internal/commands/profile_test.go | 52 ++++++ internal/commands/root.go | 5 + internal/commands/ssh_keys.go | 85 +++++++++ internal/commands/ssh_keys_test.go | 34 ++++ internal/commands/users.go | 276 ++++++++++++++++++++++++++++ internal/commands/users_test.go | 73 ++++++++ pkg/sdk/account.go | 75 ++++++++ pkg/sdk/account_test.go | 127 +++++++++++++ pkg/sdk/api_keys.go | 44 +++++ pkg/sdk/api_keys_test.go | 116 ++++++++++++ pkg/sdk/folders.go | 56 ++++++ pkg/sdk/folders_test.go | 163 ++++++++++++++++ pkg/sdk/profile.go | 70 +++++++ pkg/sdk/profile_test.go | 87 +++++++++ pkg/sdk/ssh_keys.go | 13 ++ pkg/sdk/ssh_keys_test.go | 68 +++++++ pkg/sdk/users.go | 93 ++++++++++ pkg/sdk/users_test.go | 195 ++++++++++++++++++++ 27 files changed, 2407 insertions(+), 1 deletion(-) create mode 100644 internal/commands/account.go create mode 100644 internal/commands/account_test.go create mode 100644 internal/commands/api_keys.go create mode 100644 internal/commands/api_keys_test.go create mode 100644 internal/commands/folders.go create mode 100644 internal/commands/folders_test.go create mode 100644 internal/commands/profile.go create mode 100644 internal/commands/profile_test.go create mode 100644 internal/commands/ssh_keys_test.go create mode 100644 internal/commands/users.go create mode 100644 internal/commands/users_test.go create mode 100644 pkg/sdk/account.go create mode 100644 pkg/sdk/account_test.go create mode 100644 pkg/sdk/api_keys.go create mode 100644 pkg/sdk/api_keys_test.go create mode 100644 pkg/sdk/folders.go create mode 100644 pkg/sdk/folders_test.go create mode 100644 pkg/sdk/profile.go create mode 100644 pkg/sdk/profile_test.go create mode 100644 pkg/sdk/ssh_keys_test.go create mode 100644 pkg/sdk/users.go create mode 100644 pkg/sdk/users_test.go diff --git a/internal/assist/prompt.go b/internal/assist/prompt.go index 3e07be3..59b004a 100644 --- a/internal/assist/prompt.go +++ b/internal/assist/prompt.go @@ -114,7 +114,7 @@ dhq language-versions list -p (alias: dhq lv list) SSH & Deployment Commands: dhq ssh-commands list|show|create|update|delete -p -dhq ssh-keys list|create|delete +dhq ssh-keys list|create|download|delete Integrations & Automation: dhq integrations list|show|create|update|delete -p @@ -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: diff --git a/internal/commands/account.go b/internal/commands/account.go new file mode 100644 index 0000000..834633b --- /dev/null +++ b/internal/commands/account.go @@ -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 + }, + } +} diff --git a/internal/commands/account_test.go b/internal/commands/account_test.go new file mode 100644 index 0000000..c423a62 --- /dev/null +++ b/internal/commands/account_test.go @@ -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") +} diff --git a/internal/commands/agent_metadata.go b/internal/commands/agent_metadata.go index 0002152..4e4ebde 100644 --- a/internal/commands/agent_metadata.go +++ b/internal/commands/agent_metadata.go @@ -276,6 +276,93 @@ var commandMetadataTable = map[string]AgentMetadata{ Idempotent: true, SupportsJSON: true, SafeForAutomation: true, ResourceTypes: []string{"ssh_key"}, }, + "dhq ssh-keys download": { + // Reads private key material; idempotent and safe to retry, but the + // output is sensitive. Requires an admin on a paid account (else 403). + Idempotent: true, SupportsJSON: true, SafeForAutomation: true, + ResourceTypes: []string{"ssh_key"}, + }, + // 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"}, diff --git a/internal/commands/api_keys.go b/internal/commands/api_keys.go new file mode 100644 index 0000000..202f55f --- /dev/null +++ b/internal/commands/api_keys.go @@ -0,0 +1,93 @@ +package commands + +import ( + "fmt" + + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/deployhq/deployhq-cli/pkg/sdk" + "github.com/spf13/cobra" +) + +func newAPIKeysCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "api-keys", + Short: "Manage API keys", + Long: `Account API keys. + +The plaintext key is shown ONCE, at creation time. Store it securely — it cannot be retrieved again.`, + } + cmd.AddCommand( + newAPIKeysCreateCmd(), + newAPIKeysDeleteCmd(), + ) + return cmd +} + +func newAPIKeysCreateCmd() *cobra.Command { + var ( + description string + readOnly bool + ) + cmd := &cobra.Command{ + Use: "create", Short: "Create an API key", + RunE: func(cmd *cobra.Command, args []string) error { + if description == "" { + return &output.UserError{Message: "--description is required"} + } + client, err := cliCtx.Client() + if err != nil { + return err + } + req := sdk.APIKeyCreateRequest{Description: description} + if cmd.Flags().Changed("read-only") { + req.ReadOnly = &readOnly + } + k, err := client.CreateAPIKey(cliCtx.Background(), req) + if err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + // In JSON mode the plaintext key is included in the payload as-is. + return env.WriteJSON(output.NewResponse(k, fmt.Sprintf("Created API key: %s", k.Description), + output.Breadcrumb{Action: "delete", Cmd: "dhq api-keys delete ", Resource: "api_key", ID: k.Identifier}, + )) + } + // Human output: surface the plaintext key prominently with a warning + // that this is the only time it will ever be shown. + env.Status("Created API key: %s (%s)", k.Description, k.Identifier) + env.Status("") + env.Status(" API key: %s", k.Key) + env.Status("") + env.Warn("This is the ONLY time the key will be shown. Store it securely now.") + return nil + }, + } + cmd.Flags().StringVar(&description, "description", "", "Key description (required)") + cmd.Flags().BoolVar(&readOnly, "read-only", false, "Create a read-only key") + return cmd +} + +func newAPIKeysDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", Short: "Delete an API key", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + if err := client.DeleteAPIKey(cliCtx.Background(), args[0]); err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse( + map[string]string{"identifier": args[0], "status": "deleted"}, + fmt.Sprintf("Deleted: %s", args[0]), + )) + } + env.Status("Deleted API key: %s", args[0]) + return nil + }, + } +} diff --git a/internal/commands/api_keys_test.go b/internal/commands/api_keys_test.go new file mode 100644 index 0000000..06329c8 --- /dev/null +++ b/internal/commands/api_keys_test.go @@ -0,0 +1,51 @@ +package commands + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIKeysCommand_Registered(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"api-keys", "--help"}) + + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, "create") + assert.Contains(t, out, "delete") +} + +func TestAPIKeysCreateCommand_Help(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"api-keys", "create", "--help"}) + + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, "--description") + assert.Contains(t, out, "--read-only") +} + +func TestAgentMetadata_APIKeysCreate(t *testing.T) { + m := lookupAgentMetadata("dhq api-keys create") + assert.False(t, m.Idempotent) + assert.True(t, m.SupportsJSON) + assert.True(t, m.SafeForAutomation) + assert.False(t, m.Destructive) + assert.Contains(t, m.ResourceTypes, "api_key") +} + +func TestAgentMetadata_APIKeysDelete(t *testing.T) { + m := lookupAgentMetadata("dhq api-keys delete") + assert.True(t, m.Destructive) + assert.True(t, m.RequiresConfirmation) + assert.True(t, m.SupportsJSON) + assert.True(t, m.SafeForAutomation) + assert.Contains(t, m.ResourceTypes, "api_key") +} diff --git a/internal/commands/folders.go b/internal/commands/folders.go new file mode 100644 index 0000000..1f75f7c --- /dev/null +++ b/internal/commands/folders.go @@ -0,0 +1,138 @@ +package commands + +import ( + "fmt" + "strconv" + + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/deployhq/deployhq-cli/pkg/sdk" + "github.com/spf13/cobra" +) + +func newFoldersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "folders", + Short: "Manage project folders", + Long: `Account-level folders (project categories) for organising projects into groups. + +Folders are a purely organisational grouping — deleting a folder ungroups its projects, it does not delete them.`, + } + cmd.AddCommand( + &cobra.Command{ + Use: "list", Short: "List folders", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + folders, err := client.ListFolders(cliCtx.Background(), nil) + if err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse(folders, fmt.Sprintf("%d folders", len(folders)), + output.Breadcrumb{Action: "create", Cmd: "dhq folders create "}, + output.Breadcrumb{Action: "update", Cmd: "dhq folders update --name "}, + output.Breadcrumb{Action: "delete", Cmd: "dhq folders delete "}, + )) + } + if env.QuietMode { + identifiers := make([]string, len(folders)) + for i, f := range folders { + identifiers[i] = f.Identifier + } + env.WriteQuiet(identifiers) + return nil + } + rows := make([][]string, len(folders)) + for i, f := range folders { + position := "" + if f.Position != nil { + position = strconv.Itoa(*f.Position) + } + rows[i] = []string{f.Name, f.Identifier, strconv.Itoa(f.ProjectsCount), position} + } + env.WriteTable([]string{"Name", "Identifier", "Projects", "Position"}, rows) + return nil + }, + }, + &cobra.Command{ + Use: "create ", Short: "Create a folder", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + f, err := client.CreateFolder(cliCtx.Background(), sdk.FolderCreateRequest{Name: args[0]}) + if err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse(f, fmt.Sprintf("Created: %s", f.Name), + output.Breadcrumb{Action: "update", Cmd: "dhq folders update --name ", Resource: "folder", ID: f.Identifier}, + output.Breadcrumb{Action: "delete", Cmd: "dhq folders delete ", Resource: "folder", ID: f.Identifier}, + )) + } + env.Status("Created folder: %s (%s)", f.Name, f.Identifier) + return nil + }, + }, + newFoldersUpdateCmd(), + &cobra.Command{ + Use: "delete ", Short: "Delete a folder", Args: cobra.ExactArgs(1), + Long: "Delete a folder. Projects in the folder are ungrouped, not deleted.", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + if err := client.DeleteFolder(cliCtx.Background(), args[0]); err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse( + map[string]string{"identifier": args[0], "status": "deleted"}, + fmt.Sprintf("Deleted: %s", args[0]), + )) + } + env.Status("Deleted folder: %s", args[0]) + return nil + }, + }, + ) + return cmd +} + +func newFoldersUpdateCmd() *cobra.Command { + var name string + cmd := &cobra.Command{ + Use: "update ", Short: "Rename a folder", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return &output.UserError{Message: "--name is required"} + } + client, err := cliCtx.Client() + if err != nil { + return err + } + f, err := client.UpdateFolder(cliCtx.Background(), args[0], sdk.FolderCreateRequest{Name: name}) + if err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse(f, fmt.Sprintf("Updated: %s", f.Name), + output.Breadcrumb{Action: "delete", Cmd: "dhq folders delete ", Resource: "folder", ID: f.Identifier}, + output.Breadcrumb{Action: "list", Cmd: "dhq folders list"}, + )) + } + env.Status("Updated folder: %s (%s)", f.Name, f.Identifier) + return nil + }, + } + cmd.Flags().StringVar(&name, "name", "", "New folder name (required)") + return cmd +} diff --git a/internal/commands/folders_test.go b/internal/commands/folders_test.go new file mode 100644 index 0000000..3982434 --- /dev/null +++ b/internal/commands/folders_test.go @@ -0,0 +1,63 @@ +package commands + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The folders command must be registered on the root command and expose its +// full subcommand tree. +func TestFoldersCommand_Registered(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"folders", "--help"}) + + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, "list") + assert.Contains(t, out, "create") + assert.Contains(t, out, "update") + assert.Contains(t, out, "delete") +} + +// update requires --name; its help must advertise the flag. +func TestFoldersUpdateCommand_Help(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"folders", "update", "--help"}) + + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), "--name") +} + +func TestAgentMetadata_FoldersList(t *testing.T) { + m := lookupAgentMetadata("dhq folders list") + 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, "folder") +} + +func TestAgentMetadata_FoldersUpdate(t *testing.T) { + m := lookupAgentMetadata("dhq folders update") + assert.True(t, m.Idempotent, "renaming a known folder to a given name is retry-safe") + assert.True(t, m.SupportsJSON) + assert.True(t, m.SafeForAutomation) + assert.False(t, m.Destructive) + assert.Contains(t, m.ResourceTypes, "folder") +} + +func TestAgentMetadata_FoldersDelete(t *testing.T) { + m := lookupAgentMetadata("dhq folders delete") + assert.True(t, m.Destructive) + assert.True(t, m.RequiresConfirmation) + assert.True(t, m.SupportsJSON, "delete emits a JSON envelope in --json mode") + assert.True(t, m.SafeForAutomation, "deterministic; gated by Destructive/RequiresConfirmation") + assert.Contains(t, m.ResourceTypes, "folder") +} diff --git a/internal/commands/profile.go b/internal/commands/profile.go new file mode 100644 index 0000000..47a8039 --- /dev/null +++ b/internal/commands/profile.go @@ -0,0 +1,112 @@ +package commands + +import ( + "strconv" + + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/deployhq/deployhq-cli/pkg/sdk" + "github.com/spf13/cobra" +) + +func newProfileCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "profile", + Short: "Manage your own profile", + Long: `Your own user profile (the currently authenticated user).`, + } + cmd.AddCommand( + newProfileGetCmd(), + newProfileUpdateCmd(), + ) + return cmd +} + +func newProfileGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get", Short: "Show your profile", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + p, err := client.GetProfile(cliCtx.Background()) + if err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse(p, p.EmailAddress, + output.Breadcrumb{Action: "update", Cmd: "dhq profile update"}, + )) + } + env.WriteTable( + []string{"Field", "Value"}, + [][]string{ + {"Name", p.FirstName + " " + p.LastName}, + {"Email", p.EmailAddress}, + {"Identifier", p.Identifier}, + {"Time zone", p.TimeZone}, + {"Admin", strconv.FormatBool(p.AccountAdministrator)}, + {"Account", p.Account.Name}, + {"Package", p.Account.Package}, + }, + ) + return nil + }, + } +} + +func newProfileUpdateCmd() *cobra.Command { + var ( + firstName, lastName, email, timeZone string + alphabeticSort, promotionsEnabled bool + ) + cmd := &cobra.Command{ + Use: "update", Short: "Update your profile", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + req := sdk.ProfileUpdateRequest{} + if cmd.Flags().Changed("first-name") { + req.FirstName = &firstName + } + if cmd.Flags().Changed("last-name") { + req.LastName = &lastName + } + if cmd.Flags().Changed("email") { + req.EmailAddress = &email + } + if cmd.Flags().Changed("time-zone") { + req.TimeZone = &timeZone + } + if cmd.Flags().Changed("alphabetic-sort") { + req.AlphabeticSort = &alphabeticSort + } + if cmd.Flags().Changed("promotions-enabled") { + req.PromotionsEnabled = &promotionsEnabled + } + if err := client.UpdateProfile(cliCtx.Background(), req); err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse( + map[string]string{"status": "ok"}, + "Profile updated", + output.Breadcrumb{Action: "get", Cmd: "dhq profile get"}, + )) + } + env.Status("Profile updated") + return nil + }, + } + cmd.Flags().StringVar(&firstName, "first-name", "", "First name") + cmd.Flags().StringVar(&lastName, "last-name", "", "Last name") + cmd.Flags().StringVar(&email, "email", "", "Email address") + cmd.Flags().StringVar(&timeZone, "time-zone", "", "Time zone") + cmd.Flags().BoolVar(&alphabeticSort, "alphabetic-sort", false, "Sort projects alphabetically") + cmd.Flags().BoolVar(&promotionsEnabled, "promotions-enabled", false, "Receive promotional emails") + return cmd +} diff --git a/internal/commands/profile_test.go b/internal/commands/profile_test.go new file mode 100644 index 0000000..3a7ac83 --- /dev/null +++ b/internal/commands/profile_test.go @@ -0,0 +1,52 @@ +package commands + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProfileCommand_Registered(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"profile", "--help"}) + + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, "get") + assert.Contains(t, out, "update") +} + +func TestProfileUpdateCommand_Help(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"profile", "update", "--help"}) + + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, "--first-name") + assert.Contains(t, out, "--alphabetic-sort") + assert.Contains(t, out, "--promotions-enabled") +} + +func TestAgentMetadata_ProfileGet(t *testing.T) { + m := lookupAgentMetadata("dhq profile 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, "profile") +} + +func TestAgentMetadata_ProfileUpdate(t *testing.T) { + m := lookupAgentMetadata("dhq profile 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, "profile") +} diff --git a/internal/commands/root.go b/internal/commands/root.go index 7f9b66b..8a8065f 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -186,6 +186,11 @@ Support: support@deployhq.com`, newIntegrationsCmd(), newAgentsCmd(), newSSHKeysCmd(), + newUsersCmd(), + newAccountCmd(), + newProfileCmd(), + newAPIKeysCmd(), + newFoldersCmd(), newGlobalServersCmd(), newGlobalEnvVarsCmd(), newGlobalConfigFilesCmd(), diff --git a/internal/commands/ssh_keys.go b/internal/commands/ssh_keys.go index 503f4ad..72224a9 100644 --- a/internal/commands/ssh_keys.go +++ b/internal/commands/ssh_keys.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "os" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" @@ -41,6 +42,7 @@ Centralizing keys here means rotation is one update instead of touching every se }, }, newSSHKeysCreateCmd(), + newSSHKeysDownloadCmd(), &cobra.Command{ Use: "delete ", Short: "Delete an SSH key", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -90,3 +92,86 @@ func newSSHKeysCreateCmd() *cobra.Command { cmd.Flags().StringVar(&keyType, "type", "ED25519", "Key type: RSA or ED25519") return cmd } + +func newSSHKeysDownloadCmd() *cobra.Command { + var outputFile string + cmd := &cobra.Command{ + Use: "download ", Short: "Download an SSH key's private key", Args: cobra.ExactArgs(1), + Long: `Download the private key material for a global SSH key. + +Only available to account admins on a paid plan; other accounts receive a +permission error. + +In an interactive terminal the raw key is printed to stdout. When output is +piped or redirected it is emitted as JSON (the standard CLI data contract), so +to save the raw key to a file use --output rather than shell redirection: + + dhq ssh-keys download --output key.pem # raw key, mode 0600 + +--output writes with secure owner-only (0600) permissions.`, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + privateKey, err := client.DownloadSSHKeyPrivateKey(cliCtx.Background(), args[0]) + if err != nil { + return err + } + // Guard against a success response with no key material rather than + // silently emitting an empty file / empty stdout. + if privateKey == "" { + return fmt.Errorf("server returned an empty private key for %s", args[0]) + } + env := cliCtx.Envelope + + if outputFile != "" { + if err := writeSecureFile(outputFile, privateKey); err != nil { + return fmt.Errorf("write key to %s: %w", outputFile, err) + } + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse( + map[string]string{"identifier": args[0], "output": outputFile, "status": "written"}, + fmt.Sprintf("Wrote private key to %s", outputFile), + )) + } + env.Status("Wrote private key to %s (mode 0600)", outputFile) + return nil + } + + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse( + map[string]string{"identifier": args[0], "private_key": privateKey}, + "Private key downloaded", + )) + } + + env.Warn("Printing private key material to stdout — handle with care.") + fmt.Fprintln(env.Stdout, privateKey) + return nil + }, + } + cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Write the private key to a file (mode 0600) instead of stdout") + return cmd +} + +// writeSecureFile writes private key material with owner-only (0600) +// permissions. Unlike os.WriteFile, it also tightens the mode of a +// pre-existing file — os.WriteFile leaves an existing file's permissions +// untouched, which could leave key material world-readable. +func writeSecureFile(path, contents string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + // Enforce 0600 even if the file already existed with looser permissions. + if err := f.Chmod(0o600); err != nil { + _ = f.Close() + return err + } + if _, err := f.WriteString(contents); err != nil { + _ = f.Close() + return err + } + return f.Close() +} diff --git a/internal/commands/ssh_keys_test.go b/internal/commands/ssh_keys_test.go new file mode 100644 index 0000000..489c516 --- /dev/null +++ b/internal/commands/ssh_keys_test.go @@ -0,0 +1,34 @@ +package commands + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The ssh-keys command must expose the download subcommand and its --output flag. +func TestSSHKeysDownloadCommand_Help(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"ssh-keys", "--help"}) + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), "download") + + var dl bytes.Buffer + cmd2 := NewRootCmd("test") + cmd2.SetOut(&dl) + cmd2.SetArgs([]string{"ssh-keys", "download", "--help"}) + require.NoError(t, cmd2.Execute()) + assert.Contains(t, dl.String(), "--output") +} + +func TestAgentMetadata_SSHKeysDownload(t *testing.T) { + m := lookupAgentMetadata("dhq ssh-keys download") + assert.True(t, m.Idempotent, "reading a key is retry-safe") + assert.True(t, m.SupportsJSON) + assert.False(t, m.Destructive, "downloading does not mutate the key") + assert.Contains(t, m.ResourceTypes, "ssh_key") +} diff --git a/internal/commands/users.go b/internal/commands/users.go new file mode 100644 index 0000000..e582db0 --- /dev/null +++ b/internal/commands/users.go @@ -0,0 +1,276 @@ +package commands + +import ( + "fmt" + "strconv" + + "github.com/deployhq/deployhq-cli/internal/output" + "github.com/deployhq/deployhq-cli/pkg/sdk" + "github.com/spf13/cobra" +) + +func newUsersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "users", + Short: "Manage account users", + Long: `Account-level team members. Users are keyed by their string identifier.`, + } + cmd.AddCommand( + newUsersListCmd(), + newUsersShowCmd(), + newUsersCreateCmd(), + newUsersUpdateCmd(), + newUsersDeleteCmd(), + newUsersResendInvitationCmd(), + ) + return cmd +} + +func newUsersListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", Short: "List users", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + users, err := client.ListUsers(cliCtx.Background(), nil) + if err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse(users, fmt.Sprintf("%d users", len(users)), + output.Breadcrumb{Action: "show", Cmd: "dhq users show "}, + output.Breadcrumb{Action: "create", Cmd: "dhq users create --email --first-name --last-name "}, + output.Breadcrumb{Action: "delete", Cmd: "dhq users delete "}, + )) + } + if env.QuietMode { + identifiers := make([]string, len(users)) + for i, u := range users { + identifiers[i] = u.Identifier + } + env.WriteQuiet(identifiers) + return nil + } + rows := make([][]string, len(users)) + for i, u := range users { + rows[i] = []string{ + fmt.Sprintf("%s %s", u.FirstName, u.LastName), + u.EmailAddress, + u.Identifier, + strconv.FormatBool(u.AccountAdministrator), + strconv.FormatBool(u.Activated), + } + } + env.WriteTable([]string{"Name", "Email", "Identifier", "Admin", "Activated"}, rows) + return nil + }, + } +} + +func newUsersShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show ", Short: "Show a user", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + u, err := client.GetUser(cliCtx.Background(), args[0]) + if err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse(u, fmt.Sprintf("%s %s", u.FirstName, u.LastName), + output.Breadcrumb{Action: "update", Cmd: "dhq users update ", Resource: "user", ID: u.Identifier}, + output.Breadcrumb{Action: "delete", Cmd: "dhq users delete ", Resource: "user", ID: u.Identifier}, + )) + } + env.WriteTable( + []string{"Field", "Value"}, + [][]string{ + {"Name", fmt.Sprintf("%s %s", u.FirstName, u.LastName)}, + {"Email", u.EmailAddress}, + {"Identifier", u.Identifier}, + {"Time zone", u.TimeZone}, + {"Admin", strconv.FormatBool(u.AccountAdministrator)}, + {"Activated", strconv.FormatBool(u.Activated)}, + {"All projects", strconv.FormatBool(u.AllProjectsAllowed)}, + }, + ) + return nil + }, + } +} + +// userRequestFromFlags builds a UserRequest from the flags the caller actually +// set. Only changed flags are included so an update never clobbers untouched +// attributes. +func userRequestFromFlags(cmd *cobra.Command, firstName, lastName, email, timeZone *string, allProjects, admin, manageUsers, manageBilling, manageAgents, createProjects *bool) sdk.UserRequest { + req := sdk.UserRequest{} + if cmd.Flags().Changed("first-name") { + req.FirstName = firstName + } + if cmd.Flags().Changed("last-name") { + req.LastName = lastName + } + if cmd.Flags().Changed("email") { + req.EmailAddress = email + } + if cmd.Flags().Changed("time-zone") { + req.TimeZone = timeZone + } + if cmd.Flags().Changed("all-projects") { + req.AllProjectsAllowed = allProjects + } + if cmd.Flags().Changed("admin") { + req.AccountAdministrator = admin + } + if cmd.Flags().Changed("can-manage-users") { + req.CanManageUsers = manageUsers + } + if cmd.Flags().Changed("can-manage-billing") { + req.CanManageBilling = manageBilling + } + if cmd.Flags().Changed("can-manage-agents") { + req.CanManageAgents = manageAgents + } + if cmd.Flags().Changed("can-create-projects") { + req.CanCreateProjects = createProjects + } + return req +} + +func addUserFlags(cmd *cobra.Command, firstName, lastName, email, timeZone *string, allProjects, admin, manageUsers, manageBilling, manageAgents, createProjects *bool) { + cmd.Flags().StringVar(firstName, "first-name", "", "First name") + cmd.Flags().StringVar(lastName, "last-name", "", "Last name") + cmd.Flags().StringVar(email, "email", "", "Email address") + cmd.Flags().StringVar(timeZone, "time-zone", "", "Time zone") + cmd.Flags().BoolVar(allProjects, "all-projects", false, "Grant access to all projects") + cmd.Flags().BoolVar(admin, "admin", false, "Account administrator (admin only)") + cmd.Flags().BoolVar(manageUsers, "can-manage-users", false, "Can manage users (admin only)") + cmd.Flags().BoolVar(manageBilling, "can-manage-billing", false, "Can manage billing (admin only)") + cmd.Flags().BoolVar(manageAgents, "can-manage-agents", false, "Can manage agents (admin only)") + cmd.Flags().BoolVar(createProjects, "can-create-projects", false, "Can create projects (admin only)") +} + +func newUsersCreateCmd() *cobra.Command { + var ( + firstName, lastName, email, timeZone string + allProjects, admin, manageUsers, manageBilling, manageAgents, createProjects bool + ) + cmd := &cobra.Command{ + Use: "create", Short: "Create (invite) a user", + RunE: func(cmd *cobra.Command, args []string) error { + if email == "" { + return &output.UserError{Message: "--email is required"} + } + client, err := cliCtx.Client() + if err != nil { + return err + } + req := userRequestFromFlags(cmd, &firstName, &lastName, &email, &timeZone, + &allProjects, &admin, &manageUsers, &manageBilling, &manageAgents, &createProjects) + u, err := client.CreateUser(cliCtx.Background(), req) + if err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse(u, fmt.Sprintf("Invited: %s", u.EmailAddress), + output.Breadcrumb{Action: "resend-invitation", Cmd: "dhq users resend-invitation ", Resource: "user", ID: u.Identifier}, + output.Breadcrumb{Action: "update", Cmd: "dhq users update ", Resource: "user", ID: u.Identifier}, + )) + } + env.Status("Invited user: %s (%s)", u.EmailAddress, u.Identifier) + return nil + }, + } + addUserFlags(cmd, &firstName, &lastName, &email, &timeZone, + &allProjects, &admin, &manageUsers, &manageBilling, &manageAgents, &createProjects) + return cmd +} + +func newUsersUpdateCmd() *cobra.Command { + var ( + firstName, lastName, email, timeZone string + allProjects, admin, manageUsers, manageBilling, manageAgents, createProjects bool + ) + cmd := &cobra.Command{ + Use: "update ", Short: "Update a user", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + req := userRequestFromFlags(cmd, &firstName, &lastName, &email, &timeZone, + &allProjects, &admin, &manageUsers, &manageBilling, &manageAgents, &createProjects) + u, err := client.UpdateUser(cliCtx.Background(), args[0], req) + if err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse(u, fmt.Sprintf("Updated: %s", u.EmailAddress), + output.Breadcrumb{Action: "show", Cmd: "dhq users show ", Resource: "user", ID: u.Identifier}, + )) + } + env.Status("Updated user: %s (%s)", u.EmailAddress, u.Identifier) + return nil + }, + } + addUserFlags(cmd, &firstName, &lastName, &email, &timeZone, + &allProjects, &admin, &manageUsers, &manageBilling, &manageAgents, &createProjects) + return cmd +} + +func newUsersDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", Short: "Delete a user", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + if err := client.DeleteUser(cliCtx.Background(), args[0]); err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse( + map[string]string{"identifier": args[0], "status": "deleted"}, + fmt.Sprintf("Deleted: %s", args[0]), + )) + } + env.Status("Deleted user: %s", args[0]) + return nil + }, + } +} + +func newUsersResendInvitationCmd() *cobra.Command { + return &cobra.Command{ + Use: "resend-invitation ", Short: "Resend a user's activation invitation", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := cliCtx.Client() + if err != nil { + return err + } + if err := client.ResendUserInvitation(cliCtx.Background(), args[0]); err != nil { + return err + } + env := cliCtx.Envelope + if env.WantsJSON() { + return env.WriteJSON(output.NewResponse( + map[string]string{"identifier": args[0], "status": "ok"}, + fmt.Sprintf("Invitation resent: %s", args[0]), + )) + } + env.Status("Resent invitation to user: %s", args[0]) + return nil + }, + } +} diff --git a/internal/commands/users_test.go b/internal/commands/users_test.go new file mode 100644 index 0000000..feab124 --- /dev/null +++ b/internal/commands/users_test.go @@ -0,0 +1,73 @@ +package commands + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUsersCommand_Registered(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"users", "--help"}) + + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, "list") + assert.Contains(t, out, "show") + assert.Contains(t, out, "create") + assert.Contains(t, out, "update") + assert.Contains(t, out, "delete") + assert.Contains(t, out, "resend-invitation") +} + +func TestUsersCreateCommand_Help(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"users", "create", "--help"}) + + require.NoError(t, cmd.Execute()) + out := stdout.String() + assert.Contains(t, out, "--email") + assert.Contains(t, out, "--first-name") + assert.Contains(t, out, "--admin") +} + +func TestAgentMetadata_UsersList(t *testing.T) { + m := lookupAgentMetadata("dhq users list") + 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, "user") +} + +func TestAgentMetadata_UsersCreate(t *testing.T) { + m := lookupAgentMetadata("dhq users create") + assert.False(t, m.Idempotent) + assert.True(t, m.SupportsJSON) + assert.True(t, m.SafeForAutomation) + assert.False(t, m.Destructive) + assert.Contains(t, m.ResourceTypes, "user") +} + +func TestAgentMetadata_UsersDelete(t *testing.T) { + m := lookupAgentMetadata("dhq users delete") + assert.True(t, m.Destructive) + assert.True(t, m.RequiresConfirmation) + assert.True(t, m.SupportsJSON) + assert.True(t, m.SafeForAutomation) + assert.Contains(t, m.ResourceTypes, "user") +} + +func TestAgentMetadata_UsersResendInvitation(t *testing.T) { + m := lookupAgentMetadata("dhq users resend-invitation") + assert.False(t, m.Idempotent, "each call sends a fresh invitation email") + assert.True(t, m.SupportsJSON) + assert.True(t, m.SafeForAutomation) + assert.Contains(t, m.ResourceTypes, "user") +} diff --git a/pkg/sdk/account.go b/pkg/sdk/account.go new file mode 100644 index 0000000..7a43970 --- /dev/null +++ b/pkg/sdk/account.go @@ -0,0 +1,75 @@ +package sdk + +import "context" + +// Account represents the current DeployHQ account. It is a singular resource — +// there is no id in the path and no list endpoint. +type Account struct { + Name string `json:"name"` + Permalink string `json:"permalink"` + TimeZone string `json:"time_zone"` + Package string `json:"package,omitempty"` // nullable + Trialling bool `json:"trialling"` + Suspended bool `json:"suspended"` + ProjectCount int `json:"project_count"` + ProjectLimit *int `json:"project_limit"` // nullable — no limit when null + UsersAllowed bool `json:"users_allowed"` + AIFeaturesDisabled bool `json:"ai_features_disabled"` +} + +// AccountUpdateRequest is the payload for updating the account. Pointer fields +// are only serialised when set so a single attribute can change in isolation. +type AccountUpdateRequest struct { + Name *string `json:"name,omitempty"` + Permalink *string `json:"permalink,omitempty"` + TimeZone *string `json:"time_zone,omitempty"` + IPRestricted *bool `json:"ip_restricted,omitempty"` + AIFeaturesDisabled *bool `json:"ai_features_disabled,omitempty"` + TwoFactorAuthRequired *bool `json:"two_factor_auth_required,omitempty"` + StrongPasswordRequired *bool `json:"strong_password_required,omitempty"` + CName *string `json:"cname,omitempty"` +} + +// BillingStatus is the subscription/billing snapshot for the account. +type BillingStatus struct { + Package string `json:"package,omitempty"` // nullable + Frequency int `json:"frequency"` + Trialling bool `json:"trialling"` + Suspended bool `json:"suspended"` + ScheduledChange *ScheduledBillingChange `json:"scheduled_change,omitempty"` +} + +// ScheduledBillingChange describes a pending package/frequency change. It is +// omitted from the billing status entirely when no change is scheduled. +type ScheduledBillingChange struct { + TargetPackage string `json:"target_package"` + TargetFrequency int `json:"target_frequency"` + EffectiveAt string `json:"effective_at"` +} + +func (c *Client) GetAccount(ctx context.Context) (*Account, error) { + var account Account + if err := c.get(ctx, "/account", &account); err != nil { + return nil, err + } + return &account, nil +} + +func (c *Client) UpdateAccount(ctx context.Context, req AccountUpdateRequest) (*Account, error) { + body := struct { + Account AccountUpdateRequest `json:"account"` + }{Account: req} + var account Account + if err := c.patch(ctx, "/account", body, &account); err != nil { + return nil, err + } + return &account, nil +} + +func (c *Client) GetBillingStatus(ctx context.Context) (*BillingStatus, error) { + var status BillingStatus + if err := c.get(ctx, "/account/billing_status", &status); err != nil { + return nil, err + } + return &status, nil +} diff --git a/pkg/sdk/account_test.go b/pkg/sdk/account_test.go new file mode 100644 index 0000000..04d4801 --- /dev/null +++ b/pkg/sdk/account_test.go @@ -0,0 +1,127 @@ +package sdk + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetAccount(t *testing.T) { + limit := 25 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/account", r.URL.Path) + _ = json.NewEncoder(w).Encode(Account{ + Name: "Acme", Permalink: "acme", TimeZone: "UTC", Package: "standard", + Trialling: false, Suspended: false, ProjectCount: 4, ProjectLimit: &limit, + UsersAllowed: true, AIFeaturesDisabled: false, + }) + })) + defer server.Close() + + c := newTestClient(t, server) + account, err := c.GetAccount(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Acme", account.Name) + assert.Equal(t, "standard", account.Package) + require.NotNil(t, account.ProjectLimit) + assert.Equal(t, 25, *account.ProjectLimit) +} + +// project_limit and package are nullable; they must round-trip as null/empty. +func TestGetAccount_NullableFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"name":"Acme","permalink":"acme","time_zone":"UTC","package":null,"trialling":true,"suspended":false,"project_count":0,"project_limit":null,"users_allowed":true,"ai_features_disabled":false}`)) + })) + defer server.Close() + + c := newTestClient(t, server) + account, err := c.GetAccount(context.Background()) + require.NoError(t, err) + assert.Equal(t, "", account.Package) + assert.Nil(t, account.ProjectLimit) + assert.True(t, account.Trialling) +} + +func TestUpdateAccount(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/account", r.URL.Path) + + // Request body must be wrapped: {"account":{...}}. + var body struct { + Account AccountUpdateRequest `json:"account"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.Account.Name) + assert.Equal(t, "Renamed", *body.Account.Name) + require.NotNil(t, body.Account.IPRestricted) + assert.True(t, *body.Account.IPRestricted) + // Unset fields must not be serialised. + assert.Nil(t, body.Account.Permalink) + + _ = json.NewEncoder(w).Encode(Account{Name: "Renamed", Permalink: "acme", TimeZone: "UTC"}) + })) + defer server.Close() + + c := newTestClient(t, server) + account, err := c.UpdateAccount(context.Background(), AccountUpdateRequest{ + Name: strPtr("Renamed"), + IPRestricted: boolPtr(true), + }) + require.NoError(t, err) + assert.Equal(t, "Renamed", account.Name) +} + +// Non-administrators get a 403 on show/update. +func TestUpdateAccount_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "access_denied"}) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.UpdateAccount(context.Background(), AccountUpdateRequest{Name: strPtr("X")}) + require.Error(t, err) + assert.True(t, IsForbidden(err)) +} + +func TestGetBillingStatus_WithScheduledChange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/account/billing_status", r.URL.Path) + _ = json.NewEncoder(w).Encode(BillingStatus{ + Package: "standard", Frequency: 1, Trialling: false, Suspended: false, + ScheduledChange: &ScheduledBillingChange{TargetPackage: "premium", TargetFrequency: 12, EffectiveAt: "2026-08-01"}, + }) + })) + defer server.Close() + + c := newTestClient(t, server) + status, err := c.GetBillingStatus(context.Background()) + require.NoError(t, err) + assert.Equal(t, "standard", status.Package) + assert.Equal(t, 1, status.Frequency) + require.NotNil(t, status.ScheduledChange) + assert.Equal(t, "premium", status.ScheduledChange.TargetPackage) + assert.Equal(t, 12, status.ScheduledChange.TargetFrequency) +} + +// scheduled_change is omitted when no change is pending. +func TestGetBillingStatus_NoScheduledChange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"package":"standard","frequency":1,"trialling":false,"suspended":false}`)) + })) + defer server.Close() + + c := newTestClient(t, server) + status, err := c.GetBillingStatus(context.Background()) + require.NoError(t, err) + assert.Nil(t, status.ScheduledChange) +} diff --git a/pkg/sdk/api_keys.go b/pkg/sdk/api_keys.go new file mode 100644 index 0000000..437f451 --- /dev/null +++ b/pkg/sdk/api_keys.go @@ -0,0 +1,44 @@ +package sdk + +import ( + "context" + "fmt" +) + +// APIKey represents an API key. The plaintext key (Key) is only ever returned +// once, at creation time — it is not retrievable afterwards. The API keys the +// resource by its string identifier, which is what DeleteAPIKey expects. +type APIKey struct { + Key string `json:"api_key"` // plaintext, shown ONCE at creation + Identifier string `json:"identifier"` + Description string `json:"description"` + UserID int `json:"user_id"` + Device string `json:"device,omitempty"` // nullable + ReadOnly bool `json:"read_only"` +} + +// APIKeyCreateRequest is the payload for creating an API key. Description is +// required; ReadOnly is optional. +type APIKeyCreateRequest struct { + Description string `json:"description"` + ReadOnly *bool `json:"read_only,omitempty"` +} + +// CreateAPIKey creates a new API key. The response includes the plaintext key +// in APIKey.Key — the only time it is ever exposed. Note the endpoint returns +// 200 (not 201) on success. +func (c *Client) CreateAPIKey(ctx context.Context, req APIKeyCreateRequest) (*APIKey, error) { + body := struct { + APIKey APIKeyCreateRequest `json:"api_key"` + }{APIKey: req} + var key APIKey + if err := c.post(ctx, "/security/api_keys", body, &key); err != nil { + return nil, err + } + return &key, nil +} + +// DeleteAPIKey deletes an API key by its string identifier. +func (c *Client) DeleteAPIKey(ctx context.Context, identifier string) error { + return c.delete(ctx, fmt.Sprintf("/security/api_keys/%s", identifier)) +} diff --git a/pkg/sdk/api_keys_test.go b/pkg/sdk/api_keys_test.go new file mode 100644 index 0000000..8e7c191 --- /dev/null +++ b/pkg/sdk/api_keys_test.go @@ -0,0 +1,116 @@ +package sdk + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateAPIKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/security/api_keys", r.URL.Path) + + // Request body must be wrapped: {"api_key":{...}}. + var body struct { + APIKey APIKeyCreateRequest `json:"api_key"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "CI token", body.APIKey.Description) + require.NotNil(t, body.APIKey.ReadOnly) + assert.True(t, *body.APIKey.ReadOnly) + + // The endpoint returns 200 (not 201). The server also returns an "html" + // field which the SDK intentionally ignores. + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "api_key": "plaintext-secret-key-value", + "identifier": "k-1", + "description": "CI token", + "user_id": 7, + "device": nil, + "read_only": true, + "html": "
ignored
", + }) + })) + defer server.Close() + + c := newTestClient(t, server) + key, err := c.CreateAPIKey(context.Background(), APIKeyCreateRequest{ + Description: "CI token", + ReadOnly: boolPtr(true), + }) + require.NoError(t, err) + // The plaintext key is surfaced (shown once). + assert.Equal(t, "plaintext-secret-key-value", key.Key) + assert.Equal(t, "k-1", key.Identifier) + assert.Equal(t, 7, key.UserID) + assert.True(t, key.ReadOnly) +} + +// ReadOnly is optional; when omitted it must not be serialised. +func TestCreateAPIKey_ReadOnlyOmitted(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + APIKey APIKeyCreateRequest `json:"api_key"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Nil(t, body.APIKey.ReadOnly) + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(APIKey{Key: "k", Identifier: "k-2", Description: "token"}) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.CreateAPIKey(context.Background(), APIKeyCreateRequest{Description: "token"}) + require.NoError(t, err) +} + +func TestCreateAPIKey_ValidationError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string][]string{"description": {"can't be blank"}}) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.CreateAPIKey(context.Background(), APIKeyCreateRequest{}) + require.Error(t, err) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, 422, apiErr.StatusCode) + assert.True(t, apiErr.IsValidationError()) +} + +func TestDeleteAPIKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/security/api_keys/k-1", r.URL.Path) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.DeleteAPIKey(context.Background(), "k-1") + require.NoError(t, err) +} + +func TestDeleteAPIKey_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.DeleteAPIKey(context.Background(), "does-not-exist") + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} diff --git a/pkg/sdk/folders.go b/pkg/sdk/folders.go new file mode 100644 index 0000000..c61b9de --- /dev/null +++ b/pkg/sdk/folders.go @@ -0,0 +1,56 @@ +package sdk + +import ( + "context" + "fmt" +) + +// Folder represents an account-level project folder (project category). +// The API keys folders by their string identifier (a UUID), never an integer id. +type Folder struct { + Identifier string `json:"identifier"` + Name string `json:"name"` + Position *int `json:"position"` // nullable, read-only via the API (reorder is UI-only) + ProjectsCount int `json:"projects_count"` +} + +// FolderCreateRequest is the payload for creating or updating a folder. +// Only name is writable. +type FolderCreateRequest struct { + Name string `json:"name"` +} + +func (c *Client) ListFolders(ctx context.Context, opts *ListOptions) ([]Folder, error) { + var folders []Folder + path := appendListParams("/folders", opts) + if err := c.get(ctx, path, &folders); err != nil { + return nil, err + } + return folders, nil +} + +func (c *Client) CreateFolder(ctx context.Context, req FolderCreateRequest) (*Folder, error) { + body := struct { + Folder FolderCreateRequest `json:"folder"` + }{Folder: req} + var folder Folder + if err := c.post(ctx, "/folders", body, &folder); err != nil { + return nil, err + } + return &folder, nil +} + +func (c *Client) UpdateFolder(ctx context.Context, identifier string, req FolderCreateRequest) (*Folder, error) { + body := struct { + Folder FolderCreateRequest `json:"folder"` + }{Folder: req} + var folder Folder + if err := c.put(ctx, fmt.Sprintf("/folders/%s", identifier), body, &folder); err != nil { + return nil, err + } + return &folder, nil +} + +func (c *Client) DeleteFolder(ctx context.Context, identifier string) error { + return c.delete(ctx, fmt.Sprintf("/folders/%s", identifier)) +} diff --git a/pkg/sdk/folders_test.go b/pkg/sdk/folders_test.go new file mode 100644 index 0000000..38a98a7 --- /dev/null +++ b/pkg/sdk/folders_test.go @@ -0,0 +1,163 @@ +package sdk + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListFolders(t *testing.T) { + pos := 2 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/folders", r.URL.Path) + _ = json.NewEncoder(w).Encode([]Folder{ + {Identifier: "uuid-1", Name: "Internal Tools", Position: nil, ProjectsCount: 0}, + {Identifier: "uuid-2", Name: "Client Sites", Position: &pos, ProjectsCount: 3}, + }) + })) + defer server.Close() + + c := newTestClient(t, server) + folders, err := c.ListFolders(context.Background(), nil) + require.NoError(t, err) + require.Len(t, folders, 2) + + // position round-trips as *int, including null. + assert.Equal(t, "Internal Tools", folders[0].Name) + assert.Nil(t, folders[0].Position) + assert.Equal(t, 0, folders[0].ProjectsCount) + + assert.Equal(t, "Client Sites", folders[1].Name) + require.NotNil(t, folders[1].Position) + assert.Equal(t, 2, *folders[1].Position) + assert.Equal(t, 3, folders[1].ProjectsCount) +} + +func TestCreateFolder(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/folders", r.URL.Path) + + // Request body must be wrapped: {"folder":{"name":"..."}}. + var body struct { + Folder FolderCreateRequest `json:"folder"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "Internal Tools", body.Folder.Name) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Folder{Identifier: "uuid-new", Name: "Internal Tools", Position: nil, ProjectsCount: 0}) + })) + defer server.Close() + + c := newTestClient(t, server) + folder, err := c.CreateFolder(context.Background(), FolderCreateRequest{Name: "Internal Tools"}) + require.NoError(t, err) + assert.Equal(t, "uuid-new", folder.Identifier) + assert.Equal(t, "Internal Tools", folder.Name) + assert.Nil(t, folder.Position) +} + +func TestCreateFolder_ValidationError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string][]string{"name": {"has already been taken"}}) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.CreateFolder(context.Background(), FolderCreateRequest{Name: "Internal Tools"}) + require.Error(t, err) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, 422, apiErr.StatusCode) + assert.True(t, apiErr.IsValidationError()) +} + +func TestUpdateFolder(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/folders/uuid-1", r.URL.Path) + + var body struct { + Folder FolderCreateRequest `json:"folder"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "Renamed Folder", body.Folder.Name) + + _ = json.NewEncoder(w).Encode(Folder{Identifier: "uuid-1", Name: "Renamed Folder", Position: nil, ProjectsCount: 1}) + })) + defer server.Close() + + c := newTestClient(t, server) + folder, err := c.UpdateFolder(context.Background(), "uuid-1", FolderCreateRequest{Name: "Renamed Folder"}) + require.NoError(t, err) + assert.Equal(t, "Renamed Folder", folder.Name) + assert.Equal(t, 1, folder.ProjectsCount) +} + +func TestDeleteFolder(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/folders/uuid-1", r.URL.Path) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.DeleteFolder(context.Background(), "uuid-1") + require.NoError(t, err) +} + +// The backend requires unrestricted-global (or team-global with no exclusions) +// access to rename a folder; an under-privileged API user gets a 403. +func TestUpdateFolder_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "access_denied"}) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.UpdateFolder(context.Background(), "uuid-1", FolderCreateRequest{Name: "Renamed"}) + require.Error(t, err) + assert.True(t, IsForbidden(err)) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, 403, apiErr.StatusCode) +} + +// An unknown identifier — or one belonging to another account (cross-account +// isolation) — is indistinguishable to the caller: the backend returns 404. +func TestUpdateFolder_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.UpdateFolder(context.Background(), "not-a-real-identifier", FolderCreateRequest{Name: "X"}) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestDeleteFolder_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.DeleteFolder(context.Background(), "other-account-folder") + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} diff --git a/pkg/sdk/profile.go b/pkg/sdk/profile.go new file mode 100644 index 0000000..b0b8f85 --- /dev/null +++ b/pkg/sdk/profile.go @@ -0,0 +1,70 @@ +package sdk + +import "context" + +// Profile represents the currently authenticated user's own profile. It carries +// the same user fields as User plus a nested account summary. PATCH /profile +// returns only {"status":"ok"}, so UpdateProfile does not return the object. +type Profile struct { + ID int `json:"id"` + Identifier string `json:"identifier"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + EmailAddress string `json:"email_address"` + TimeZone string `json:"time_zone"` + AccountAdministrator bool `json:"account_administrator"` + Activated bool `json:"activated"` + CanManageUsers bool `json:"can_manage_users"` + CanManageBilling bool `json:"can_manage_billing"` + CanCreateProjects bool `json:"can_create_projects"` + CanManageAgents bool `json:"can_manage_agents"` + AllProjectsAllowed bool `json:"all_projects_allowed"` + Account ProfileAccount `json:"account"` +} + +// ProfileAccount is the account summary nested in the profile response. It +// carries the base account fields plus eligibility flags not present on the +// plain /account resource. +type ProfileAccount struct { + Name string `json:"name"` + Permalink string `json:"permalink"` + TimeZone string `json:"time_zone"` + Package string `json:"package,omitempty"` // nullable + Trialling bool `json:"trialling"` + Suspended bool `json:"suspended"` + ProjectCount int `json:"project_count"` + ProjectLimit *int `json:"project_limit"` // nullable + UsersAllowed bool `json:"users_allowed"` + AIFeaturesDisabled bool `json:"ai_features_disabled"` + BetaFeatures bool `json:"beta_features"` + StaticHostingEligible bool `json:"static_hosting_eligible"` + ManagedVPSEligible bool `json:"managed_vps_eligible"` +} + +// ProfileUpdateRequest is the payload for updating the current user's profile. +// Pointer fields are only serialised when set. +type ProfileUpdateRequest struct { + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + EmailAddress *string `json:"email_address,omitempty"` + TimeZone *string `json:"time_zone,omitempty"` + AlphabeticSort *bool `json:"alphabetic_sort,omitempty"` + PromotionsEnabled *bool `json:"promotions_enabled,omitempty"` +} + +func (c *Client) GetProfile(ctx context.Context) (*Profile, error) { + var profile Profile + if err := c.get(ctx, "/profile", &profile); err != nil { + return nil, err + } + return &profile, nil +} + +// UpdateProfile updates the current user's profile. The endpoint returns only a +// status acknowledgement, not the updated object, so there is no return value. +func (c *Client) UpdateProfile(ctx context.Context, req ProfileUpdateRequest) error { + body := struct { + User ProfileUpdateRequest `json:"user"` + }{User: req} + return c.patch(ctx, "/profile", body, nil) +} diff --git a/pkg/sdk/profile_test.go b/pkg/sdk/profile_test.go new file mode 100644 index 0000000..e8c67b0 --- /dev/null +++ b/pkg/sdk/profile_test.go @@ -0,0 +1,87 @@ +package sdk + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetProfile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/profile", r.URL.Path) + _ = json.NewEncoder(w).Encode(Profile{ + ID: 1, Identifier: "u-1", FirstName: "Ada", LastName: "Lovelace", + EmailAddress: "ada@example.com", TimeZone: "UTC", AccountAdministrator: true, + Account: ProfileAccount{ + Name: "Acme", Permalink: "acme", TimeZone: "UTC", Package: "standard", + BetaFeatures: true, StaticHostingEligible: true, ManagedVPSEligible: false, + }, + }) + })) + defer server.Close() + + c := newTestClient(t, server) + profile, err := c.GetProfile(context.Background()) + require.NoError(t, err) + assert.Equal(t, "u-1", profile.Identifier) + assert.Equal(t, "Ada", profile.FirstName) + // Nested account with the eligibility extras. + assert.Equal(t, "Acme", profile.Account.Name) + assert.True(t, profile.Account.BetaFeatures) + assert.True(t, profile.Account.StaticHostingEligible) + assert.False(t, profile.Account.ManagedVPSEligible) +} + +func TestUpdateProfile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/profile", r.URL.Path) + + // Request body must be wrapped: {"user":{...}}. + var body struct { + User ProfileUpdateRequest `json:"user"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.User.FirstName) + assert.Equal(t, "Augusta", *body.User.FirstName) + require.NotNil(t, body.User.AlphabeticSort) + assert.True(t, *body.User.AlphabeticSort) + // Unset fields must not be serialised. + assert.Nil(t, body.User.LastName) + + // PATCH /profile returns only a status acknowledgement. + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.UpdateProfile(context.Background(), ProfileUpdateRequest{ + FirstName: strPtr("Augusta"), + AlphabeticSort: boolPtr(true), + }) + require.NoError(t, err) +} + +func TestUpdateProfile_ValidationError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string][]string{"email_address": {"is invalid"}}) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.UpdateProfile(context.Background(), ProfileUpdateRequest{EmailAddress: strPtr("not-an-email")}) + require.Error(t, err) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, 422, apiErr.StatusCode) + assert.True(t, apiErr.IsValidationError()) +} diff --git a/pkg/sdk/ssh_keys.go b/pkg/sdk/ssh_keys.go index 9182be1..2f526ea 100644 --- a/pkg/sdk/ssh_keys.go +++ b/pkg/sdk/ssh_keys.go @@ -44,3 +44,16 @@ func (c *Client) CreateSSHKey(ctx context.Context, req SSHKeyCreateRequest) (*SS func (c *Client) DeleteSSHKey(ctx context.Context, keyID string) error { return c.delete(ctx, fmt.Sprintf("/ssh_keys/%s", keyID)) } + +// DownloadSSHKeyPrivateKey returns the private key material for a global SSH +// key. The API gates this on the account being an admin on a paid plan; a +// non-admin or free account receives a 403 (see IsForbidden). +func (c *Client) DownloadSSHKeyPrivateKey(ctx context.Context, keyID string) (string, error) { + var resp struct { + PrivateKey string `json:"private_key"` + } + if err := c.get(ctx, fmt.Sprintf("/ssh_keys/%s/download_private_key", keyID), &resp); err != nil { + return "", err + } + return resp.PrivateKey, nil +} diff --git a/pkg/sdk/ssh_keys_test.go b/pkg/sdk/ssh_keys_test.go new file mode 100644 index 0000000..f61b21e --- /dev/null +++ b/pkg/sdk/ssh_keys_test.go @@ -0,0 +1,68 @@ +package sdk + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDownloadSSHKeyPrivateKey(t *testing.T) { + const pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/ssh_keys/key-1/download_private_key", r.URL.Path) + _ = json.NewEncoder(w).Encode(map[string]string{"private_key": pem}) + })) + defer server.Close() + + c := newTestClient(t, server) + key, err := c.DownloadSSHKeyPrivateKey(context.Background(), "key-1") + require.NoError(t, err) + assert.Equal(t, pem, key) +} + +// The endpoint is gated on admin + paid account; a free or non-admin account +// receives a 403 with an explanatory error. +func TestDownloadSSHKeyPrivateKey_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "This feature is only available on paid accounts."}) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.DownloadSSHKeyPrivateKey(context.Background(), "key-1") + require.Error(t, err) + assert.True(t, IsForbidden(err)) +} + +// A 200 with an empty private_key yields an empty string; the command layer +// turns this into an explicit error rather than writing an empty file. +func TestDownloadSSHKeyPrivateKey_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"private_key": ""}) + })) + defer server.Close() + + c := newTestClient(t, server) + key, err := c.DownloadSSHKeyPrivateKey(context.Background(), "key-1") + require.NoError(t, err) + assert.Empty(t, key) +} + +func TestDownloadSSHKeyPrivateKey_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.DownloadSSHKeyPrivateKey(context.Background(), "missing") + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} diff --git a/pkg/sdk/users.go b/pkg/sdk/users.go new file mode 100644 index 0000000..293732b --- /dev/null +++ b/pkg/sdk/users.go @@ -0,0 +1,93 @@ +package sdk + +import ( + "context" + "fmt" +) + +// User represents an account user (team member). The API keys users by their +// string identifier, never an integer id. +type User struct { + ID int `json:"id"` + Identifier string `json:"identifier"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + EmailAddress string `json:"email_address"` + TimeZone string `json:"time_zone"` + AccountAdministrator bool `json:"account_administrator"` + Activated bool `json:"activated"` + CanManageUsers bool `json:"can_manage_users"` + CanManageBilling bool `json:"can_manage_billing"` + CanCreateProjects bool `json:"can_create_projects"` + CanManageAgents bool `json:"can_manage_agents"` + AllProjectsAllowed bool `json:"all_projects_allowed"` +} + +// UserRequest is the payload for creating or updating a user. +// +// Pointer fields are only serialised when set, so an update can change a single +// attribute without clobbering the rest. The backend strips the admin-only +// fields (account_administrator and the can_* capabilities) for callers who are +// not themselves account administrators. +type UserRequest struct { + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + EmailAddress *string `json:"email_address,omitempty"` + TimeZone *string `json:"time_zone,omitempty"` + AllProjectsAllowed *bool `json:"all_projects_allowed,omitempty"` + AccountAdministrator *bool `json:"account_administrator,omitempty"` + CanManageUsers *bool `json:"can_manage_users,omitempty"` + CanManageBilling *bool `json:"can_manage_billing,omitempty"` + CanManageAgents *bool `json:"can_manage_agents,omitempty"` + CanCreateProjects *bool `json:"can_create_projects,omitempty"` +} + +func (c *Client) ListUsers(ctx context.Context, opts *ListOptions) ([]User, error) { + var users []User + path := appendListParams("/users", opts) + if err := c.get(ctx, path, &users); err != nil { + return nil, err + } + return users, nil +} + +func (c *Client) GetUser(ctx context.Context, identifier string) (*User, error) { + var user User + if err := c.get(ctx, fmt.Sprintf("/users/%s", identifier), &user); err != nil { + return nil, err + } + return &user, nil +} + +func (c *Client) CreateUser(ctx context.Context, req UserRequest) (*User, error) { + body := struct { + User UserRequest `json:"user"` + }{User: req} + var user User + if err := c.post(ctx, "/users", body, &user); err != nil { + return nil, err + } + return &user, nil +} + +func (c *Client) UpdateUser(ctx context.Context, identifier string, req UserRequest) (*User, error) { + body := struct { + User UserRequest `json:"user"` + }{User: req} + var user User + if err := c.patch(ctx, fmt.Sprintf("/users/%s", identifier), body, &user); err != nil { + return nil, err + } + return &user, nil +} + +func (c *Client) DeleteUser(ctx context.Context, identifier string) error { + return c.delete(ctx, fmt.Sprintf("/users/%s", identifier)) +} + +// ResendUserInvitation re-sends the activation invitation email to a user who +// has not yet activated their account. The backend returns 422 with +// {"error":"User already activated"} if the user is already active. +func (c *Client) ResendUserInvitation(ctx context.Context, identifier string) error { + return c.post(ctx, fmt.Sprintf("/users/%s/resend_invitation", identifier), nil, nil) +} diff --git a/pkg/sdk/users_test.go b/pkg/sdk/users_test.go new file mode 100644 index 0000000..1b3b62e --- /dev/null +++ b/pkg/sdk/users_test.go @@ -0,0 +1,195 @@ +package sdk + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func boolPtr(b bool) *bool { return &b } + +func TestListUsers(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/users", r.URL.Path) + _ = json.NewEncoder(w).Encode([]User{ + {ID: 1, Identifier: "u-1", FirstName: "Ada", LastName: "Lovelace", EmailAddress: "ada@example.com", AccountAdministrator: true, Activated: true}, + {ID: 2, Identifier: "u-2", FirstName: "Alan", LastName: "Turing", EmailAddress: "alan@example.com", Activated: false}, + }) + })) + defer server.Close() + + c := newTestClient(t, server) + users, err := c.ListUsers(context.Background(), nil) + require.NoError(t, err) + require.Len(t, users, 2) + assert.Equal(t, "Ada", users[0].FirstName) + assert.True(t, users[0].AccountAdministrator) + assert.True(t, users[0].Activated) + assert.False(t, users[1].Activated) +} + +func TestGetUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/users/u-1", r.URL.Path) + _ = json.NewEncoder(w).Encode(User{ID: 1, Identifier: "u-1", FirstName: "Ada", EmailAddress: "ada@example.com", TimeZone: "UTC"}) + })) + defer server.Close() + + c := newTestClient(t, server) + user, err := c.GetUser(context.Background(), "u-1") + require.NoError(t, err) + assert.Equal(t, "u-1", user.Identifier) + assert.Equal(t, "UTC", user.TimeZone) +} + +func TestCreateUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/users", r.URL.Path) + + // Request body must be wrapped: {"user":{...}}. + var body struct { + User UserRequest `json:"user"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.User.EmailAddress) + assert.Equal(t, "new@example.com", *body.User.EmailAddress) + require.NotNil(t, body.User.FirstName) + assert.Equal(t, "New", *body.User.FirstName) + require.NotNil(t, body.User.AccountAdministrator) + assert.True(t, *body.User.AccountAdministrator) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(User{ID: 3, Identifier: "u-new", FirstName: "New", EmailAddress: "new@example.com", AccountAdministrator: true}) + })) + defer server.Close() + + c := newTestClient(t, server) + user, err := c.CreateUser(context.Background(), UserRequest{ + FirstName: strPtr("New"), + EmailAddress: strPtr("new@example.com"), + AccountAdministrator: boolPtr(true), + }) + require.NoError(t, err) + assert.Equal(t, "u-new", user.Identifier) + assert.True(t, user.AccountAdministrator) +} + +func TestCreateUser_ValidationError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string][]string{"email_address": {"has already been taken"}}) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.CreateUser(context.Background(), UserRequest{EmailAddress: strPtr("dup@example.com")}) + require.Error(t, err) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, 422, apiErr.StatusCode) + assert.True(t, apiErr.IsValidationError()) +} + +func TestUpdateUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/users/u-1", r.URL.Path) + + var body struct { + User UserRequest `json:"user"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.User.LastName) + assert.Equal(t, "Byron", *body.User.LastName) + // Unset fields must not be serialised. + assert.Nil(t, body.User.EmailAddress) + + _ = json.NewEncoder(w).Encode(User{ID: 1, Identifier: "u-1", FirstName: "Ada", LastName: "Byron"}) + })) + defer server.Close() + + c := newTestClient(t, server) + user, err := c.UpdateUser(context.Background(), "u-1", UserRequest{LastName: strPtr("Byron")}) + require.NoError(t, err) + assert.Equal(t, "Byron", user.LastName) +} + +func TestDeleteUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/users/u-1", r.URL.Path) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.DeleteUser(context.Background(), "u-1") + require.NoError(t, err) +} + +// Deleting the last account administrator is forbidden (403). +func TestDeleteUser_Forbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "You cannot remove the last account administrator"}) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.DeleteUser(context.Background(), "u-admin") + require.Error(t, err) + assert.True(t, IsForbidden(err)) +} + +func TestGetUser_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + c := newTestClient(t, server) + _, err := c.GetUser(context.Background(), "does-not-exist") + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestResendUserInvitation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/users/u-2/resend_invitation", r.URL.Path) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.ResendUserInvitation(context.Background(), "u-2") + require.NoError(t, err) +} + +// Resending to an already-activated user returns 422. +func TestResendUserInvitation_AlreadyActivated(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "User already activated"}) + })) + defer server.Close() + + c := newTestClient(t, server) + err := c.ResendUserInvitation(context.Background(), "u-1") + require.Error(t, err) + + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, 422, apiErr.StatusCode) +} From 0780585ed3009f96bf2572ff9cfa8df30de34ddf Mon Sep 17 00:00:00 2001 From: Marta Date: Fri, 10 Jul 2026 14:35:11 +0200 Subject: [PATCH 2/2] fix: address CI lint and review feedback on ssh-keys download - check errcheck on best-effort stdout write (fixes failing golangci-lint) - write downloaded private keys atomically (temp file + rename) so an interrupted write can't destroy an existing key - mark 'ssh-keys download' as requires-confirmation / not safe-for-automation since it emits raw private key material --- internal/commands/agent_metadata.go | 9 +++++--- internal/commands/ssh_keys.go | 32 +++++++++++++++++++---------- internal/commands/ssh_keys_test.go | 2 ++ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/internal/commands/agent_metadata.go b/internal/commands/agent_metadata.go index 4e4ebde..56bbd44 100644 --- a/internal/commands/agent_metadata.go +++ b/internal/commands/agent_metadata.go @@ -277,9 +277,12 @@ var commandMetadataTable = map[string]AgentMetadata{ ResourceTypes: []string{"ssh_key"}, }, "dhq ssh-keys download": { - // Reads private key material; idempotent and safe to retry, but the - // output is sensitive. Requires an admin on a paid account (else 403). - Idempotent: true, SupportsJSON: true, SafeForAutomation: true, + // 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"}, }, // Users diff --git a/internal/commands/ssh_keys.go b/internal/commands/ssh_keys.go index 72224a9..2a63dc6 100644 --- a/internal/commands/ssh_keys.go +++ b/internal/commands/ssh_keys.go @@ -3,6 +3,7 @@ package commands import ( "fmt" "os" + "path/filepath" "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" @@ -147,7 +148,7 @@ to save the raw key to a file use --output rather than shell redirection: } env.Warn("Printing private key material to stdout — handle with care.") - fmt.Fprintln(env.Stdout, privateKey) + fmt.Fprintln(env.Stdout, privateKey) //nolint:errcheck // best-effort stdout return nil }, } @@ -156,22 +157,31 @@ to save the raw key to a file use --output rather than shell redirection: } // writeSecureFile writes private key material with owner-only (0600) -// permissions. Unlike os.WriteFile, it also tightens the mode of a -// pre-existing file — os.WriteFile leaves an existing file's permissions -// untouched, which could leave key material world-readable. +// permissions, atomically. It writes to a temporary 0600 file in the same +// directory and renames it into place, so a write failure or interruption +// never truncates or partially overwrites an existing valid key — and the +// destination ends up 0600 even if a looser-permissioned file was already +// there. func writeSecureFile(path, contents string) error { - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".dhq-key-*") if err != nil { return err } - // Enforce 0600 even if the file already existed with looser permissions. - if err := f.Chmod(0o600); err != nil { - _ = f.Close() + tmpName := tmp.Name() + // Best-effort cleanup if we bail before the rename. + defer func() { _ = os.Remove(tmpName) }() + + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.WriteString(contents); err != nil { + _ = tmp.Close() return err } - if _, err := f.WriteString(contents); err != nil { - _ = f.Close() + if err := tmp.Close(); err != nil { return err } - return f.Close() + return os.Rename(tmpName, path) } diff --git a/internal/commands/ssh_keys_test.go b/internal/commands/ssh_keys_test.go index 489c516..9123c2d 100644 --- a/internal/commands/ssh_keys_test.go +++ b/internal/commands/ssh_keys_test.go @@ -30,5 +30,7 @@ func TestAgentMetadata_SSHKeysDownload(t *testing.T) { assert.True(t, m.Idempotent, "reading a key is retry-safe") assert.True(t, m.SupportsJSON) assert.False(t, m.Destructive, "downloading does not mutate the key") + assert.True(t, m.RequiresConfirmation, "emits sensitive private key material") + assert.False(t, m.SafeForAutomation, "key material should not be printed unattended") assert.Contains(t, m.ResourceTypes, "ssh_key") }