diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index 4aa5036..b84a3bf 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -113,6 +113,51 @@ func resolveStartRevision(server *sdk.Server, group *sdk.ServerGroup, flagStart return "" } +// resolveDeployProject returns the project identifier for a deploy. +// +// When the user did not pass --project (or set DEPLOYHQ_PROJECT / a +// .deployhq.toml), we mirror the server auto-pick behaviour: if the account +// has exactly one project, use it. Otherwise return a UserError that lists +// the available projects so callers (including agents) can retry with a +// concrete identifier. +func resolveDeployProject(ctx context.Context, client *sdk.Client, configured string, env *output.Envelope) (string, error) { + if configured != "" { + return configured, nil + } + + projects, err := client.ListProjects(ctx, nil) + if err != nil { + // Don't swallow the API error — but keep the original "No project + // specified" message as the headline so the failure bucket stays + // recognisable in telemetry. + return "", &output.UserError{ + Message: "No project specified", + Hint: fmt.Sprintf("Could not auto-detect a project: %v\nPass --project or set DEPLOYHQ_PROJECT.", err), + } + } + + switch len(projects) { + case 0: + return "", &output.UserError{ + Message: "No project specified", + Hint: "This account has no projects yet. Create one in the DeployHQ dashboard, then re-run with --project .", + } + case 1: + env.Status("Auto-selected project: %s", projects[0].Name) + return projects[0].Identifier, nil + default: + items := make([]string, 0, len(projects)) + for _, p := range projects { + items = append(items, fmt.Sprintf("%s (%s)", p.Identifier, p.Name)) + } + return "", &output.UserError{ + Message: "No project specified", + Hint: fmt.Sprintf("Account has %d projects — pass --project . Available: %s", + len(projects), strings.Join(items, ", ")), + } + } +} + // resolveLatestRevision tries to find the latest revision for a project, // falling back to the most recent deployment's end revision if the // repository endpoint fails (e.g. empty repo, missing default branch). @@ -260,7 +305,7 @@ func newDeployCmd() *cobra.Command { cmd := &cobra.Command{ Use: "deploy", Short: "Deploy to a server (shortcut for deployments create)", - Long: "Create a deployment. Shortcut for 'dhq deployments create'.\n\nBy default deploys are incremental: the start revision defaults to the server's last successfully deployed commit. Use --full to deploy the entire branch from the first commit.\n\nUse --wait (-w) to watch the deployment until it completes or fails.", + Long: "Create a deployment. Shortcut for 'dhq deployments create'.\n\nBy default deploys are incremental: the start revision defaults to the server's last successfully deployed commit. Use --full to deploy the entire branch from the first commit.\n\nUse --wait (-w) to watch the deployment until it completes or fails.\n\nWhen --project is not provided (and DEPLOYHQ_PROJECT / .deployhq.toml don't set it), `dhq deploy` auto-selects the only project on the account; if there are multiple, the error lists them so you can pick.", Example: ` # Deploy the latest revision (auto-selects the only server, uses the server's preferred branch) dhq deploy @@ -282,11 +327,6 @@ func newDeployCmd() *cobra.Command { # Deploy the entire branch from the first commit (overrides incremental default) dhq deploy --full`, RunE: func(cmd *cobra.Command, args []string) error { - projectID, err := cliCtx.RequireProject() - if err != nil { - return err - } - if dryRun && wait { return &output.UserError{ Message: "--dry-run and --wait are mutually exclusive", @@ -305,6 +345,11 @@ func newDeployCmd() *cobra.Command { env := cliCtx.Envelope + projectID, err := resolveDeployProject(cliCtx.Background(), client, cliCtx.Config.Project, env) + if err != nil { + return err + } + // Track the Server or ServerGroup we resolved to, so branch/revision lookup // can reuse them without extra round-trips. Exactly one of these will be // non-nil once the target is locked in (or both nil for a project-wide deploy). diff --git a/internal/commands/deploy_test.go b/internal/commands/deploy_test.go index 507aff7..210e3b8 100644 --- a/internal/commands/deploy_test.go +++ b/internal/commands/deploy_test.go @@ -1,12 +1,15 @@ package commands import ( + "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" "testing" + "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -505,6 +508,99 @@ func TestResolveBranchAndRevision_ServerPreferredBranchBeatsGroup(t *testing.T) assert.Equal(t, "sha-of-server", revision) } +// testEnvelope returns an Envelope wired to a bytes.Buffer so Status messages +// are captured for assertion. +func testEnvelope() (*output.Envelope, *bytes.Buffer) { + var buf bytes.Buffer + return &output.Envelope{Stdout: io.Discard, Stderr: &buf}, &buf +} + +func TestResolveDeployProject_ConfiguredShortCircuits(t *testing.T) { + // No API server: a configured project must NOT trigger ListProjects. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, _ := testEnvelope() + + id, err := resolveDeployProject(t.Context(), client, "my-app", env) + require.NoError(t, err) + assert.Equal(t, "my-app", id) +} + +func TestResolveDeployProject_AutoPicksLoneProject(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/projects", r.URL.Path) + _ = json.NewEncoder(w).Encode([]sdk.Project{{Identifier: "only-id", Name: "Only Project"}}) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, stderr := testEnvelope() + + id, err := resolveDeployProject(t.Context(), client, "", env) + require.NoError(t, err) + assert.Equal(t, "only-id", id) + assert.Contains(t, stderr.String(), "Auto-selected project: Only Project") +} + +func TestResolveDeployProject_MultipleProjectsListsThem(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]sdk.Project{ + {Identifier: "a-id", Name: "Alpha"}, + {Identifier: "b-id", Name: "Beta"}, + {Identifier: "c-id", Name: "Gamma"}, + }) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, _ := testEnvelope() + + id, err := resolveDeployProject(t.Context(), client, "", env) + require.Error(t, err) + assert.Empty(t, id) + msg := err.Error() + // Headline preserved for telemetry continuity. + firstLine := strings.SplitN(msg, "\n", 2)[0] + assert.Equal(t, "No project specified", firstLine) + // Hint lists each project so agents can self-correct on retry. + assert.Contains(t, msg, "3 projects") + assert.Contains(t, msg, "a-id (Alpha)") + assert.Contains(t, msg, "b-id (Beta)") + assert.Contains(t, msg, "c-id (Gamma)") +} + +func TestResolveDeployProject_ZeroProjects(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]sdk.Project{}) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, _ := testEnvelope() + + id, err := resolveDeployProject(t.Context(), client, "", env) + require.Error(t, err) + assert.Empty(t, id) + assert.Contains(t, err.Error(), "No project specified") + assert.Contains(t, err.Error(), "no projects yet") +} + +func TestResolveDeployProject_APIErrorKeepsHeadline(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, _ := testEnvelope() + + _, err := resolveDeployProject(t.Context(), client, "", env) + require.Error(t, err) + // The "No project specified" headline must survive telemetry sanitization + // (which keeps only the first line) so the failure bucket stays recognisable. + firstLine := strings.SplitN(err.Error(), "\n", 2)[0] + assert.Equal(t, "No project specified", firstLine) +} + // TestResolveLatestRevision_EmbedsStatusCodeInMessage guards the telemetry // regression: SanitizeErrorMessage keeps only the first line of err.Error(), // so anything in the Hint never reaches the dashboard. The status code must