From 2b52959e28643e894e74f5682a8b52452c3425a1 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 30 Oct 2026 21:04:49 -0300 Subject: [PATCH 1/3] feat(environments): add environment management commands to CLI - Introduced new commands for managing project environments: `create`, `update`, `delete`, and `get`. - Enhanced error handling for environment-related operations, including checks for existing and pending environments. - Updated `CLI.md` to document new environment error codes and command usage. - Added unit tests to ensure functionality and error handling for the new commands. - Integrated environment management into the existing secrets manager command structure. --- CLI.md | 3 + packages/api/api.go | 108 ++++++ packages/api/model.go | 44 +++ packages/cmd/environments.go | 359 ++++++++++++++++++++ packages/cmd/environments_test.go | 308 +++++++++++++++++ packages/cmd/projects.go | 60 ++-- packages/cmd/secrets_manager_errors.go | 54 ++- packages/cmd/secrets_manager_errors_test.go | 5 +- packages/cmd/sm.go | 4 +- packages/cmd/testdata/command_surface.json | 104 ++++++ packages/util/log.go | 8 + packages/visualize/environments.go | 15 + 12 files changed, 1044 insertions(+), 28 deletions(-) create mode 100644 packages/cmd/environments.go create mode 100644 packages/cmd/environments_test.go create mode 100644 packages/visualize/environments.go diff --git a/CLI.md b/CLI.md index 0e3b3621a..4b47a3728 100644 --- a/CLI.md +++ b/CLI.md @@ -72,6 +72,9 @@ With `-o json`, the same error is JSON on stderr: | `project_not_found` | The project doesn't exist or isn't visible (404) | | `project_type_not_supported` | A project of another product was passed to a `secrets-manager` command | | `environment_not_found` | The environment slug doesn't exist in the project (404) | +| `environment_exists` | `environments create` (or `update --slug`) with a slug an active environment has. Nothing is written | +| `environment_pending_deletion` | The slug belongs to an environment pending deletion, which keeps it until the grace period ends. Suggests `delete --force` | +| `project_required` | A delete ran without `--projectId`. Deletes never use `INFISICAL_PROJECT_ID` or `.infisical.yaml` | | `secret_exists` | `secrets create` on a key that exists. Nothing is written | | `secret_not_found` | `secrets update` on a key that doesn't exist. Nothing is written | | `folder_not_found` | `folders get ` for a folder that doesn't exist | diff --git a/packages/api/api.go b/packages/api/api.go index 5e08b91ee..266453ce9 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -36,6 +36,11 @@ const ( operationCallListProjects = "CallListProjects" operationCallGetProject = "CallGetProject" operationCallCreateProject = "CallCreateProject" + operationCallGetProjectEnvironments = "CallGetProjectEnvironments" + operationCallGetEnvironmentBySlug = "CallGetEnvironmentBySlug" + operationCallCreateEnvironment = "CallCreateEnvironment" + operationCallUpdateEnvironment = "CallUpdateEnvironment" + operationCallDeleteEnvironment = "CallDeleteEnvironment" operationCallIsAuthenticated = "CallIsAuthenticated" operationCallGetNewAccessTokenWithRefreshToken = "CallGetNewAccessTokenWithRefreshToken" operationCallGetFoldersV1 = "CallGetFoldersV1" @@ -421,6 +426,109 @@ func CallCreateProject(httpClient *resty.Client, request CreateProjectRequest) ( return createResponse.Project, nil } +func CallGetProjectEnvironments(httpClient *resty.Client, projectID string) (ProjectEnvironments, error) { + var projectResponse GetProjectEnvironmentsResponse + response, err := httpClient. + R(). + SetResult(&projectResponse). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("%v/v1/projects/%s", config.INFISICAL_URL, url.PathEscape(projectID))) + + if err != nil { + return ProjectEnvironments{}, NewGenericRequestError(operationCallGetProjectEnvironments, err) + } + + if response.IsError() { + return ProjectEnvironments{}, NewAPIErrorWithResponse(operationCallGetProjectEnvironments, response, nil) + } + + return projectResponse.Project, nil +} + +// CallGetEnvironmentBySlug finds an active environment; one pending deletion is a 404. +func CallGetEnvironmentBySlug(httpClient *resty.Client, projectID, slug string) (Environment, error) { + var environmentResponse EnvironmentResponse + response, err := httpClient. + R(). + SetResult(&environmentResponse). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("%v/v1/projects/%s/environments/slug/%s", config.INFISICAL_URL, url.PathEscape(projectID), url.PathEscape(slug))) + + if err != nil { + return Environment{}, NewGenericRequestError(operationCallGetEnvironmentBySlug, err) + } + + if response.IsError() { + return Environment{}, NewAPIErrorWithResponse(operationCallGetEnvironmentBySlug, response, nil) + } + + return environmentResponse.Environment, nil +} + +func CallCreateEnvironment(httpClient *resty.Client, projectID string, request CreateEnvironmentRequest) (Environment, error) { + var environmentResponse EnvironmentResponse + response, err := httpClient. + R(). + SetBody(request). + SetResult(&environmentResponse). + SetHeader("User-Agent", USER_AGENT). + Post(fmt.Sprintf("%v/v1/projects/%s/environments", config.INFISICAL_URL, url.PathEscape(projectID))) + + if err != nil { + return Environment{}, NewGenericRequestError(operationCallCreateEnvironment, err) + } + + if response.IsError() { + return Environment{}, NewAPIErrorWithResponse(operationCallCreateEnvironment, response, nil) + } + + return environmentResponse.Environment, nil +} + +func CallUpdateEnvironment(httpClient *resty.Client, projectID, environmentID string, request UpdateEnvironmentRequest) (Environment, error) { + var environmentResponse EnvironmentResponse + response, err := httpClient. + R(). + SetBody(request). + SetResult(&environmentResponse). + SetHeader("User-Agent", USER_AGENT). + Patch(fmt.Sprintf("%v/v1/projects/%s/environments/%s", config.INFISICAL_URL, url.PathEscape(projectID), url.PathEscape(environmentID))) + + if err != nil { + return Environment{}, NewGenericRequestError(operationCallUpdateEnvironment, err) + } + + if response.IsError() { + return Environment{}, NewAPIErrorWithResponse(operationCallUpdateEnvironment, response, nil) + } + + return environmentResponse.Environment, nil +} + +// CallDeleteEnvironment soft-deletes the environment, or deletes it for good with hardDelete. Backends +// without soft delete ignore hardDelete and always delete for good; their response has no deleteAfter. +func CallDeleteEnvironment(httpClient *resty.Client, projectID, environmentID string, hardDelete bool) (Environment, error) { + var environmentResponse EnvironmentResponse + request := httpClient. + R(). + SetResult(&environmentResponse). + SetHeader("User-Agent", USER_AGENT) + if hardDelete { + request.SetQueryParam("hardDelete", "true") + } + response, err := request.Delete(fmt.Sprintf("%v/v1/projects/%s/environments/%s", config.INFISICAL_URL, url.PathEscape(projectID), url.PathEscape(environmentID))) + + if err != nil { + return Environment{}, NewGenericRequestError(operationCallDeleteEnvironment, err) + } + + if response.IsError() { + return Environment{}, NewAPIErrorWithResponse(operationCallDeleteEnvironment, response, nil) + } + + return environmentResponse.Environment, nil +} + func CallGetProjectBySlug(httpClient *resty.Client, slug string) (Project, error) { var projectResponse GetProjectBySlugResponse response, err := httpClient. diff --git a/packages/api/model.go b/packages/api/model.go index 851e55856..b94e0aacd 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -265,6 +265,50 @@ type CreateProjectResponse struct { Project ProjectDetails `json:"project"` } +// Environment is a project environment as the /v1/projects/:projectId/environments endpoints return +// it. DeleteAfter is set once it is soft-deleted, and is when it will be deleted for good. +type Environment struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Position int `json:"position"` + ProjectID string `json:"projectId"` + DeleteAfter string `json:"deleteAfter,omitempty"` +} + +type EnvironmentResponse struct { + Environment Environment `json:"environment"` +} + +type DeletedEnvironment struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + DeleteAfter string `json:"deleteAfter"` +} + +// ProjectEnvironments is a project with its active environments, in position order, and the ones +// pending deletion. It is decoded from the same response as ProjectDetails, which leaves out +// deletedEnvironments so `projects get -o json` keeps its shape. +type ProjectEnvironments struct { + ProjectDetails + DeletedEnvironments []DeletedEnvironment `json:"deletedEnvironments"` +} + +type GetProjectEnvironmentsResponse struct { + Project ProjectEnvironments `json:"project"` +} + +type CreateEnvironmentRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +type UpdateEnvironmentRequest struct { + Name *string `json:"name,omitempty"` + Slug *string `json:"slug,omitempty"` +} + type RawSecret struct { SecretKey string `json:"secretKey,omitempty"` SecretValue string `json:"secretValue,omitempty"` diff --git a/packages/cmd/environments.go b/packages/cmd/environments.go new file mode 100644 index 000000000..89a73a4be --- /dev/null +++ b/packages/cmd/environments.go @@ -0,0 +1,359 @@ +/* +Copyright (c) 2026 Infisical Inc. +*/ +package cmd + +import ( + "fmt" + "strings" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/Infisical/infisical-merge/packages/visualize" + "github.com/go-resty/resty/v2" + "github.com/posthog/posthog-go" + "github.com/spf13/cobra" +) + +// buildEnvironmentsCmd builds the `infisical secrets-manager environments` command tree. +func buildEnvironmentsCmd() *cobra.Command { + environmentsCmd := &cobra.Command{ + Use: "environments", + Aliases: []string{"environment"}, + Short: "List, inspect, create, update, and delete the environments of a project", + Example: `# List the environments of the project linked to this directory +infisical secrets-manager environments get + +infisical secrets-manager environments create qa --projectId +infisical secrets-manager environments delete qa --projectId `, + DisableFlagsInUseLine: true, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + + getCmd := &cobra.Command{ + Use: "get [slug]", + Short: "List the environments of a project, or show one", + Example: `infisical secrets-manager environments get --projectId +infisical secrets-manager environments get staging --projectId -o json`, + DisableFlagsInUseLine: true, + Args: cobra.MaximumNArgs(1), + Run: runEnvironmentsGet, + Annotations: serviceTokenUnsupported, + } + addEnvironmentsProjectFlag(getCmd) + addEnvironmentsCommonFlags(getCmd, "Get environments", "The output to format the environments in") + environmentsCmd.AddCommand(getCmd) + + createCmd := &cobra.Command{ + Use: "create ", + Short: "Create an environment", + Example: `infisical secrets-manager environments create qa --projectId + +# With a display name +infisical secrets-manager environments create qa --name "Quality Assurance" --projectId `, + DisableFlagsInUseLine: true, + Args: cobra.ExactArgs(1), + Run: runEnvironmentsCreate, + Annotations: serviceTokenUnsupported, + } + createCmd.Flags().String("name", "", "Display name of the environment (defaults to the slug)") + addEnvironmentsProjectFlag(createCmd) + addEnvironmentsCommonFlags(createCmd, "Create the environment", "The output to format the created environment in") + environmentsCmd.AddCommand(createCmd) + + updateCmd := &cobra.Command{ + Use: "update ", + Short: "Change an environment's name or slug", + Example: `infisical secrets-manager environments update qa --name "Quality Assurance" --projectId +infisical secrets-manager environments update qa --slug testing --projectId `, + DisableFlagsInUseLine: true, + Args: cobra.ExactArgs(1), + Run: runEnvironmentsUpdate, + Annotations: serviceTokenUnsupported, + } + updateCmd.Flags().String("name", "", "New display name") + updateCmd.Flags().String("slug", "", "New slug") + addEnvironmentsProjectFlag(updateCmd) + addEnvironmentsCommonFlags(updateCmd, "Update the environment", "The output to format the updated environment in") + environmentsCmd.AddCommand(updateCmd) + + deleteCmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an environment and all of its secrets", + Example: `infisical secrets-manager environments delete qa --projectId + +# Delete for good now, skipping the grace period (also frees the slug of one pending deletion) +infisical secrets-manager environments delete qa --force --projectId `, + DisableFlagsInUseLine: true, + Args: cobra.ExactArgs(1), + Run: runEnvironmentsDelete, + Annotations: serviceTokenUnsupported, + } + deleteCmd.Flags().String("projectId", "", "ID of the project, required (a delete never uses INFISICAL_PROJECT_ID or .infisical.yaml)") + deleteCmd.Flags().Bool("force", false, "Delete for good now instead of after the grace period, including an environment already pending deletion") + addConfirmFlags(deleteCmd) + addEnvironmentsCommonFlags(deleteCmd, "Delete the environment", "The output to format the deleted environment in") + environmentsCmd.AddCommand(deleteCmd) + + return environmentsCmd +} + +func addEnvironmentsProjectFlag(cmd *cobra.Command) { + cmd.Flags().String("projectId", "", "ID of the project (falls back to INFISICAL_PROJECT_ID, then .infisical.yaml or a legacy .infisical.json)") +} + +func addEnvironmentsCommonFlags(cmd *cobra.Command, action, outputDescription string) { + cmd.Flags().String("token", "", action+" using a machine identity access token") + addProjectsOutputFlag(cmd, outputDescription) +} + +// environmentsTarget is what every environments command starts from: the project, with its active +// environments and those pending deletion, and a client to act on it. +type environmentsTarget struct { + client *resty.Client + project api.ProjectEnvironments + scope errorScope + outputFormat string +} + +// loadEnvironmentsTarget resolves and fetches the project. With explicitProject, only --projectId +// counts, as for a delete. +func loadEnvironmentsTarget(cmd *cobra.Command, explicitProject bool) environmentsTarget { + outputFormat := crudOutputFormat(cmd, "environments") + + var projectID string + if explicitProject { + projectID, _ = cmd.Flags().GetString("projectId") + if projectID == "" { + exitWithError(cmd, errProjectRequired("an environment")) + } + } else if projectID = resolveProjectID(cmd); projectID == "" { + exitWithError(cmd, errProjectNotLinked()) + } + + client := secretsManagerHTTPClient(cmd, "manage environments") + project, err := api.CallGetProjectEnvironments(client, projectID) + if err != nil { + exitWithError(cmd, classifyAPIError(err, func(cause error) *util.CLIError { + return errProjectNotFound(errorScope{ProjectID: projectID}, cause) + }), "Unable to get the project") + } + if project.Type != "" && project.Type != secretManagerProjectType { + exitWithError(cmd, errProjectTypeNotSupported(project.Name, project.Type)) + } + + return environmentsTarget{ + client: client, + project: project, + scope: errorScope{ProjectID: projectID, ProjectName: project.Name}, + outputFormat: outputFormat, + } +} + +func (t environmentsTarget) active(slug string) (api.ProjectEnvironment, bool) { + for _, environment := range t.project.Environments { + if environment.Slug == slug { + return environment, true + } + } + return api.ProjectEnvironment{}, false +} + +func (t environmentsTarget) pending(slug string) (api.DeletedEnvironment, bool) { + for _, environment := range t.project.DeletedEnvironments { + if environment.Slug == slug { + return environment, true + } + } + return api.DeletedEnvironment{}, false +} + +// existing returns the active environment with slug, or exits with environment_pending_deletion or +// environment_not_found. +func (t environmentsTarget) existing(cmd *cobra.Command, slug string) api.ProjectEnvironment { + if environment, ok := t.active(slug); ok { + return environment + } + if environment, ok := t.pending(slug); ok { + exitWithError(cmd, errEnvironmentPending(slug, environment.DeleteAfter, t.scope)) + } + exitWithError(cmd, errEnvironmentNotFound(slug, t.scope, nil)) + return api.ProjectEnvironment{} +} + +// requireFreeSlug exits when slug is taken by an active environment or held by one pending deletion. +func (t environmentsTarget) requireFreeSlug(cmd *cobra.Command, slug string) { + if _, ok := t.active(slug); ok { + exitWithError(cmd, errEnvironmentExists(slug, t.scope)) + } + if environment, ok := t.pending(slug); ok { + exitWithError(cmd, errEnvironmentPending(slug, environment.DeleteAfter, t.scope)) + } +} + +func (t environmentsTarget) printEnvironment(environment api.Environment, success string) { + if t.outputFormat != "" { + printFormattedOutput(t.outputFormat, environment) + return + } + if success != "" { + util.PrintSuccessMessage(success) + } + visualize.PrintEnvironmentDetails(environment) +} + +func runEnvironmentsGet(cmd *cobra.Command, args []string) { + target := loadEnvironmentsTarget(cmd, false) + + if len(args) == 1 { + slug := args[0] + if environment, ok := target.pending(slug); ok { + exitWithError(cmd, errEnvironmentPending(slug, environment.DeleteAfter, target.scope)) + } + environment, err := api.CallGetEnvironmentBySlug(target.client, target.project.ID, slug) + if err != nil { + exitWithError(cmd, classifyAPIError(err, func(cause error) *util.CLIError { + return errEnvironmentNotFound(slug, target.scope, cause) + }), "Unable to get the environment") + } + target.printEnvironment(environment, "") + Telemetry.CaptureEvent("cli-command:environments get", posthog.NewProperties().Set("version", util.CLI_VERSION)) + return + } + + if target.outputFormat != "" { + printFormattedOutput(target.outputFormat, target.project.Environments) + } else if len(target.project.Environments) == 0 { + printProjectsTip(cmd, fmt.Sprintf("Project %s has no environments. Create one with [infisical secrets-manager environments create ].", util.SanitizeDisplay(target.project.Name))) + } else { + visualize.PrintProjectEnvironments(target.project.ProjectDetails) + } + if len(target.project.DeletedEnvironments) > 0 && target.outputFormat == "" { + pending := make([]string, 0, len(target.project.DeletedEnvironments)) + for _, environment := range target.project.DeletedEnvironments { + pending = append(pending, fmt.Sprintf("%s (until %s)", util.SanitizeDisplay(environment.Slug), formatDay(environment.DeleteAfter))) + } + printProjectsTip(cmd, "\nPending deletion: "+strings.Join(pending, ", ")) + } + + Telemetry.CaptureEvent("cli-command:environments get", posthog.NewProperties().Set("environmentCount", len(target.project.Environments)).Set("version", util.CLI_VERSION)) +} + +func runEnvironmentsCreate(cmd *cobra.Command, args []string) { + slug := args[0] + target := loadEnvironmentsTarget(cmd, false) + target.requireFreeSlug(cmd, slug) + + request := api.CreateEnvironmentRequest{Slug: slug, Name: slug} + if name, _ := cmd.Flags().GetString("name"); strings.TrimSpace(name) != "" { + request.Name = strings.TrimSpace(name) + } + + environment, err := api.CallCreateEnvironment(target.client, target.project.ID, request) + if err != nil { + exitWithError(cmd, classifyAPIError(err, nil), "Unable to create the environment") + } + + target.printEnvironment(environment, fmt.Sprintf("Created environment %s in project %s", util.SanitizeDisplay(environment.Slug), util.SanitizeDisplay(target.project.Name))) + Telemetry.CaptureEvent("cli-command:environments create", posthog.NewProperties().Set("version", util.CLI_VERSION)) +} + +// environmentUpdate is the PATCH body for the flags that were passed, trimmed. An explicitly empty +// --name or --slug is an error, since Changed is true for "" and the backend would get a blank value. +func environmentUpdate(cmd *cobra.Command) (api.UpdateEnvironmentRequest, error) { + var request api.UpdateEnvironmentRequest + for _, field := range []struct { + flag string + value **string + }{{"name", &request.Name}, {"slug", &request.Slug}} { + if !cmd.Flags().Changed(field.flag) { + continue + } + value, _ := cmd.Flags().GetString(field.flag) + value = strings.TrimSpace(value) + if value == "" { + return api.UpdateEnvironmentRequest{}, fmt.Errorf("--%s can't be empty", field.flag) + } + *field.value = &value + } + return request, nil +} + +func runEnvironmentsUpdate(cmd *cobra.Command, args []string) { + slug := args[0] + request, err := environmentUpdate(cmd) + if err != nil { + util.HandleError(err) + } + if request.Name == nil && request.Slug == nil { + exitWithError(cmd, errNothingToUpdate([]string{"name", "slug"})) + } + + target := loadEnvironmentsTarget(cmd, false) + existing := target.existing(cmd, slug) + if request.Slug != nil && *request.Slug != slug { + target.requireFreeSlug(cmd, *request.Slug) + } + + environment, err := api.CallUpdateEnvironment(target.client, target.project.ID, existing.ID, request) + if err != nil { + exitWithError(cmd, classifyAPIError(err, func(cause error) *util.CLIError { + return errEnvironmentNotFound(slug, target.scope, cause) + }), "Unable to update the environment") + } + + target.printEnvironment(environment, fmt.Sprintf("Updated environment %s", util.SanitizeDisplay(environment.Slug))) + Telemetry.CaptureEvent("cli-command:environments update", posthog.NewProperties().Set("version", util.CLI_VERSION)) +} + +func runEnvironmentsDelete(cmd *cobra.Command, args []string) { + slug := args[0] + force, _ := cmd.Flags().GetBool("force") + target := loadEnvironmentsTarget(cmd, true) + project := fmt.Sprintf("%q", target.project.Name) + + var environmentID, description string + if active, ok := target.active(slug); ok { + environmentID = active.ID + description = fmt.Sprintf("environment %s from project %s, with all of its secrets", util.SuggestedValue(slug, "slug"), project) + if force { + description += ", for good and without a grace period" + } + } else if pending, ok := target.pending(slug); ok && force { + environmentID = pending.ID + description = fmt.Sprintf("environment %s, already pending deletion, from project %s for good", util.SuggestedValue(slug, "slug"), project) + } else { + target.existing(cmd, slug) + } + + if err := confirmDestructive(cmd, args, destructiveAction{ + Target: "environment " + quoted(slug, "env"), + Expected: slug, + Label: "the environment slug", + Description: description, + }); err != nil { + exitWithError(cmd, err) + } + + environment, err := api.CallDeleteEnvironment(target.client, target.project.ID, environmentID, force) + if err != nil { + exitWithError(cmd, classifyAPIError(err, func(cause error) *util.CLIError { + return errEnvironmentNotFound(slug, target.scope, cause) + }), "Unable to delete the environment") + } + + if target.outputFormat != "" { + printFormattedOutput(target.outputFormat, environment) + } else if day := formatDay(environment.DeleteAfter); day != "" { + util.PrintSuccessMessage(fmt.Sprintf("Deleted environment %s. It will be removed for good on %s.", util.SanitizeDisplay(slug), day)) + printProjectsTip(cmd, fmt.Sprintf("To remove it now and free its slug, run [infisical secrets-manager environments delete %s --force --projectId %s].", + util.SuggestedValue(slug, "slug"), util.SuggestedValue(target.project.ID, "project-id"))) + } else { + util.PrintSuccessMessage(fmt.Sprintf("Deleted environment %s for good.", util.SanitizeDisplay(slug))) + } + + Telemetry.CaptureEvent("cli-command:environments delete", posthog.NewProperties().Set("hardDelete", force).Set("version", util.CLI_VERSION)) +} diff --git a/packages/cmd/environments_test.go b/packages/cmd/environments_test.go new file mode 100644 index 000000000..4fd71b3a9 --- /dev/null +++ b/packages/cmd/environments_test.go @@ -0,0 +1,308 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/Infisical/infisical-merge/packages/telemetry" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/spf13/cobra" +) + +const testEnvironmentID = "0a6c2f4e-1b3d-4e5f-8a9b-0c1d2e3f4a5b" +const testPendingID = "9f8e7d6c-5b4a-4f3e-8d2c-1b0a9f8e7d6c" + +// fakeEnvironmentsAPI answers the routes the environments commands call, like the backend does, and +// records every request so a test can assert what was (and wasn't) sent. +type fakeEnvironmentsAPI struct { + projectType string + missing bool // GET /v1/projects/:id is a 404 + mu sync.Mutex + requests []string +} + +func (f *fakeEnvironmentsAPI) serve(t *testing.T) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + line := r.Method + " " + strings.TrimPrefix(r.URL.Path, "/api") + if r.URL.RawQuery != "" { + line += "?" + r.URL.RawQuery + } + if len(body) > 0 { + line += " " + string(body) + } + f.mu.Lock() + f.requests = append(f.requests, line) + f.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + notFound := func() { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"NotFound","message":"not found","reqId":"req-test"}`)) + } + path := strings.TrimPrefix(r.URL.Path, "/api/v1/projects/"+testProjectID) + switch { + case r.Method == http.MethodGet && path == "": + if f.missing { + notFound() + return + } + projectType := f.projectType + if projectType == "" { + projectType = secretManagerProjectType + } + _ = json.NewEncoder(w).Encode(map[string]any{"project": map[string]any{ + "id": testProjectID, "name": "payments-api", "slug": "payments-api", "type": projectType, + "environments": []map[string]string{ + {"id": "e1", "name": "Development", "slug": "dev"}, + {"id": testEnvironmentID, "name": "Staging", "slug": "staging"}, + }, + "deletedEnvironments": []map[string]string{ + {"id": testPendingID, "name": "QA", "slug": "qa", "deleteAfter": "2026-11-12T10:00:00.000Z"}, + }, + }}) + case r.Method == http.MethodGet && path == "/environments/slug/staging": + _, _ = fmt.Fprintf(w, `{"environment":{"id":%q,"name":"Staging","slug":"staging","position":2,"projectId":%q}}`, testEnvironmentID, testProjectID) + case r.Method == http.MethodGet: + notFound() + case r.Method == http.MethodDelete: + _, _ = fmt.Fprintf(w, `{"environment":{"id":"x","name":"n","slug":"s","position":1,"projectId":%q}}`, testProjectID) + default: + _, _ = fmt.Fprintf(w, `{"environment":{"id":"new","name":"n","slug":"s","position":3,"projectId":%q}}`, testProjectID) + } + })) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL+"/api") +} + +// writes is every request that changes something. +func (f *fakeEnvironmentsAPI) writes() []string { + f.mu.Lock() + defer f.mu.Unlock() + var writes []string + for _, request := range f.requests { + if !strings.HasPrefix(request, http.MethodGet) { + writes = append(writes, request) + } + } + return writes +} + +type exitCalled struct{} + +// runEnvironments runs `infisical secrets-manager environments ` on a fresh command tree against +// the fake API, without a terminal, and returns stderr, stdout, and whether the command exited 1. +func runEnvironments(t *testing.T, fake *fakeEnvironmentsAPI, args ...string) (stderr, stdout string, exited bool) { + t.Helper() + fake.serve(t) + t.Setenv(util.INFISICAL_TOKEN_NAME, "identity-access-token") + t.Setenv(util.INFISICAL_PROJECT_ID_NAME, "") + t.Chdir(t.TempDir()) + fakeTerminal(t, false, "", errors.New("must not prompt")) + errOut := captureStderr(t) + var out bytes.Buffer + oldStdout, oldExit, oldTelemetry := util.GetStdoutWriter, exitProcess, Telemetry + util.GetStdoutWriter = func() io.Writer { return &out } + exitProcess = func(int) { panic(exitCalled{}) } + Telemetry = telemetry.NewTelemetry(false) + t.Cleanup(func() { util.GetStdoutWriter, exitProcess, Telemetry = oldStdout, oldExit, oldTelemetry }) + + root := &cobra.Command{Use: "infisical"} + product := &cobra.Command{Use: secretsManagerCommandName} + product.AddCommand(buildEnvironmentsCmd()) + root.AddCommand(product) + cmd, rest, err := root.Find(append([]string{secretsManagerCommandName, "environments"}, args...)) + if err != nil { + t.Fatal(err) + } + if err := cmd.ParseFlags(rest); err != nil { + t.Fatal(err) + } + if err := cmd.ValidateArgs(cmd.Flags().Args()); err != nil { + t.Fatal(err) + } + + func() { + defer func() { + if r := recover(); r != nil { + if _, ok := r.(exitCalled); !ok { + panic(r) + } + exited = true + } + }() + cmd.Run(cmd, cmd.Flags().Args()) + }() + return errOut.String(), out.String(), exited +} + +func TestEnvironmentsGetListsAndShowsOne(t *testing.T) { + fake := &fakeEnvironmentsAPI{} + _, stdout, exited := runEnvironments(t, fake, "get", "--projectId", testProjectID, "-o", "json") + var listed []map[string]string + if exited || json.Unmarshal([]byte(stdout), &listed) != nil || len(listed) != 2 || listed[1]["slug"] != "staging" { + t.Errorf("list: exited=%v stdout=%s", exited, stdout) + } + + _, stdout, exited = runEnvironments(t, fake, "get", "staging", "--projectId", testProjectID, "-o", "json") + if exited || !strings.Contains(stdout, `"position":2`) { + t.Errorf("one: exited=%v stdout=%s", exited, stdout) + } +} + +func TestEnvironmentsErrors(t *testing.T) { + cases := []struct { + name string + fake *fakeEnvironmentsAPI + args []string + code string + next string + }{ + {"missing slug", &fakeEnvironmentsAPI{}, []string{"get", "nope", "--projectId", testProjectID}, + errCodeEnvironmentNotFound, "environments get --projectId " + testProjectID}, + {"pending slug", &fakeEnvironmentsAPI{}, []string{"get", "qa", "--projectId", testProjectID}, + errCodeEnvironmentPending, "environments delete qa --force --projectId " + testProjectID}, + {"project 404", &fakeEnvironmentsAPI{missing: true}, []string{"get", "--projectId", testProjectID}, + errCodeProjectNotFound, "projects list"}, + {"another product", &fakeEnvironmentsAPI{projectType: "cert-manager"}, []string{"get", "--projectId", testProjectID}, + errCodeProjectTypeNotSupported, "projects list"}, + {"no project", &fakeEnvironmentsAPI{}, []string{"get"}, errCodeProjectNotLinked, "infisical init"}, + {"create taken", &fakeEnvironmentsAPI{}, []string{"create", "staging", "--projectId", testProjectID}, + errCodeEnvironmentExists, "environments get staging --projectId " + testProjectID}, + {"create pending", &fakeEnvironmentsAPI{}, []string{"create", "qa", "--projectId", testProjectID}, + errCodeEnvironmentPending, "delete qa --force"}, + {"update nothing", &fakeEnvironmentsAPI{}, []string{"update", "staging", "--projectId", testProjectID}, errCodeNothingToUpdate, ""}, + {"update to taken slug", &fakeEnvironmentsAPI{}, []string{"update", "staging", "--slug", "dev", "--projectId", testProjectID}, + errCodeEnvironmentExists, "environments get dev"}, + {"update missing", &fakeEnvironmentsAPI{}, []string{"update", "nope", "--name", "x", "--projectId", testProjectID}, errCodeEnvironmentNotFound, ""}, + {"delete pending without --force", &fakeEnvironmentsAPI{}, []string{"delete", "qa", "--yes", "--projectId", testProjectID}, + errCodeEnvironmentPending, "delete qa --force"}, + {"delete without a terminal", &fakeEnvironmentsAPI{}, []string{"delete", "staging", "--projectId", testProjectID}, + errCodeConfirmationRequired, "environments delete staging --output json --projectId " + testProjectID + " --yes"}, + } + for _, c := range cases { + stderr, _, exited := runEnvironments(t, c.fake, append(c.args, "-o", "json")...) + var body struct { + Error struct { + Code string `json:"code"` + Next []string `json:"next"` + } `json:"error"` + } + if !exited || json.Unmarshal([]byte(stderr), &body) != nil || body.Error.Code != c.code { + t.Errorf("%s: want %s, got exited=%v stderr=%s", c.name, c.code, exited, stderr) + continue + } + if c.next != "" && (len(body.Error.Next) == 0 || !strings.Contains(body.Error.Next[0], c.next)) { + t.Errorf("%s: next %v should contain %q", c.name, body.Error.Next, c.next) + } + if writes := c.fake.writes(); len(writes) != 0 { + t.Errorf("%s: nothing should be written, sent %v", c.name, writes) + } + } +} + +// A delete never falls back to INFISICAL_PROJECT_ID or .infisical.yaml (CLI.md), and asks nothing of +// the API before saying so. +func TestEnvironmentsDeleteRequiresProjectID(t *testing.T) { + fake := &fakeEnvironmentsAPI{} + stderr, _, exited := runEnvironments(t, fake, "delete", "staging", "--yes") + if !exited || !strings.Contains(stderr, "needs --projectId") || len(fake.requests) != 0 { + t.Errorf("exited=%v requests=%v stderr=%s", exited, fake.requests, stderr) + } +} + +func TestEnvironmentsDeleteSendsWhatWasAskedFor(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {[]string{"delete", "staging", "--yes"}, "DELETE /v1/projects/" + testProjectID + "/environments/" + testEnvironmentID}, + {[]string{"delete", "staging", "--force", "-y"}, "DELETE /v1/projects/" + testProjectID + "/environments/" + testEnvironmentID + "?hardDelete=true"}, + {[]string{"delete", "qa", "--force", "--yes"}, "DELETE /v1/projects/" + testProjectID + "/environments/" + testPendingID + "?hardDelete=true"}, + } + for _, c := range cases { + fake := &fakeEnvironmentsAPI{} + stderr, _, exited := runEnvironments(t, fake, append(c.args, "--projectId", testProjectID)...) + if writes := fake.writes(); exited || len(writes) != 1 || writes[0] != c.want { + t.Errorf("%v: exited=%v writes=%v stderr=%s, want %s", c.args, exited, writes, stderr, c.want) + } + } +} + +func TestEnvironmentsCreateAndUpdateSendOnlyWhatWasPassed(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {[]string{"create", "preview"}, `POST /v1/projects/` + testProjectID + `/environments {"name":"preview","slug":"preview"}`}, + {[]string{"create", "preview", "--name", "Preview"}, `POST /v1/projects/` + testProjectID + `/environments {"name":"Preview","slug":"preview"}`}, + {[]string{"create", "preview", "--name", " Preview "}, `POST /v1/projects/` + testProjectID + `/environments {"name":"Preview","slug":"preview"}`}, + {[]string{"create", "preview", "--name", " "}, `POST /v1/projects/` + testProjectID + `/environments {"name":"preview","slug":"preview"}`}, + {[]string{"update", "staging", "--name", " Stage "}, `PATCH /v1/projects/` + testProjectID + `/environments/` + testEnvironmentID + ` {"name":"Stage"}`}, + {[]string{"update", "staging", "--name", "Stage"}, `PATCH /v1/projects/` + testProjectID + `/environments/` + testEnvironmentID + ` {"name":"Stage"}`}, + {[]string{"update", "staging", "--slug", "stage", "--name", "Stage"}, `PATCH /v1/projects/` + testProjectID + `/environments/` + testEnvironmentID + ` {"name":"Stage","slug":"stage"}`}, + {[]string{"update", "staging", "--slug", "staging"}, `PATCH /v1/projects/` + testProjectID + `/environments/` + testEnvironmentID + ` {"slug":"staging"}`}, + } + for _, c := range cases { + fake := &fakeEnvironmentsAPI{} + stderr, _, exited := runEnvironments(t, fake, append(c.args, "--projectId", testProjectID, "-o", "json")...) + if writes := fake.writes(); exited || len(writes) != 1 || writes[0] != c.want { + t.Errorf("%v: exited=%v writes=%v stderr=%s\nwant %s", c.args, exited, writes, stderr, c.want) + } + } +} + +// An explicit empty --name or --slug is rejected before anything is sent, instead of PATCHing a +// blank value. +func TestEnvironmentUpdateRejectsEmptyValues(t *testing.T) { + cases := map[string]string{ + `--name=`: "--name can't be empty", + `--name= `: "--name can't be empty", + `--slug=`: "--slug can't be empty", + `--name=Stage`: "", + } + for flag, want := range cases { + update, _, err := buildEnvironmentsCmd().Find([]string{"update"}) + if err != nil { + t.Fatal(err) + } + if err := update.ParseFlags([]string{flag}); err != nil { + t.Fatal(err) + } + _, err = environmentUpdate(update) + if got := fmt.Sprint(err); (want == "" && err != nil) || (want != "" && got != want) { + t.Errorf("%s: got %v, want %q", flag, err, want) + } + } +} + +// Service tokens can't use these routes: one from the environment is ignored, like the projects +// commands do, so the command falls back to the login session. +func TestEnvironmentsRejectServiceTokens(t *testing.T) { + for _, name := range []string{"get", "create", "update", "delete"} { + cmd := findCommand(t, smPath([]string{"environments", name})) + if !rejectsServiceTokens(cmd) { + t.Errorf("environments %s should be marked serviceTokenUnsupported", name) + } + } +} + +func TestEnvironmentsAreRegisteredOnlyUnderSecretsManager(t *testing.T) { + for _, prefix := range []string{secretsManagerCommandName, secretsManagerAlias} { + if cmd, _, err := RootCmd.Find([]string{prefix, "environments", "get"}); err != nil || cmd.Name() != "get" { + t.Errorf("%s environments get should resolve: %v", prefix, err) + } + } + if cmd, _, _ := RootCmd.Find([]string{"environments"}); cmd != RootCmd { + t.Error("environments must not be added at the top level") + } +} diff --git a/packages/cmd/projects.go b/packages/cmd/projects.go index 8cf51be99..d8a073521 100644 --- a/packages/cmd/projects.go +++ b/packages/cmd/projects.go @@ -116,9 +116,14 @@ func ignoresEnvServiceToken(cmd *cobra.Command, token *models.TokenDetails) bool return token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER && !cmd.Flags().Changed("token") && rejectsServiceTokens(cmd) } -// projectsHTTPClient authenticates with the machine identity token from --token or the -// environment when there is one, and with the logged-in user's session otherwise. func projectsHTTPClient(cmd *cobra.Command) *resty.Client { + return secretsManagerHTTPClient(cmd, "list or create projects") +} + +// secretsManagerHTTPClient authenticates with the machine identity token from --token or the +// environment when there is one, and with the logged-in user's session otherwise. what names what a +// service token can't do, for a command marked serviceTokenUnsupported. +func secretsManagerHTTPClient(cmd *cobra.Command, what string) *resty.Client { token, err := util.GetInfisicalToken(cmd) if err != nil { util.HandleError(err, "Unable to parse flag") @@ -130,11 +135,11 @@ func projectsHTTPClient(cmd *cobra.Command) *resty.Client { } if ignoresEnvServiceToken(cmd, token) { - log.Debug().Msgf("Ignoring the service token from %s, which can't list or create projects", token.Source) + log.Debug().Msgf("Ignoring the service token from %s, which can't %s", token.Source, what) token = nil } if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER && rejectsServiceTokens(cmd) { - util.PrintErrorMessageAndExit("Service tokens can't list or create projects. Pass a machine identity access token with --token, or log in with [infisical login].") + util.PrintErrorMessageAndExit(fmt.Sprintf("Service tokens can't %s. Pass a machine identity access token with --token, or log in with [infisical login].", what)) } if token != nil { @@ -149,6 +154,11 @@ func projectsHTTPClient(cmd *cobra.Command) *resty.Client { } func projectsOutputFormat(cmd *cobra.Command) string { + return crudOutputFormat(cmd, "projects") +} + +// crudOutputFormat is -o for a command whose output is structured data: json, yaml, or "" for a table. +func crudOutputFormat(cmd *cobra.Command, noun string) string { outputFormat, err := cmd.Flags().GetString("output") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -158,7 +168,7 @@ func projectsOutputFormat(cmd *cobra.Command) string { util.HandleError(err, "Unable to format output") } if outputFormat == util.OUTPUT_FORMAT_DOTENV { - util.HandleError(fmt.Errorf("projects can't be output as dotenv; use json or yaml")) + util.HandleError(fmt.Errorf("%s can't be output as dotenv; use json or yaml", noun)) } return outputFormat } @@ -204,24 +214,9 @@ func runProjectsList(cmd *cobra.Command, args []string) { func runProjectsGet(cmd *cobra.Command, args []string) { outputFormat := projectsOutputFormat(cmd) - projectID, err := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "projectId", []string{util.INFISICAL_PROJECT_ID_NAME}, "") - if err != nil { - util.HandleError(err, "Unable to parse flag") - } + projectID := resolveProjectID(cmd) if projectID == "" { - notLinked := "Please either run infisical init to connect to a project, or pass in project id with --projectId flag (see [infisical secrets-manager projects list])" - configFile, err := util.FindWorkspaceConfigFile() - if err != nil { - util.PrintErrorMessageAndExit(notLinked) - } - workspaceFile, err := util.GetWorkSpaceFromFilePath(filepath.Dir(configFile)) - if err != nil { - util.HandleError(err, fmt.Sprintf("Unable to read the project config at %s", configFile)) - } - if workspaceFile.WorkspaceId == "" { - util.PrintErrorMessageAndExit(notLinked) - } - projectID = workspaceFile.WorkspaceId + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project, or pass in project id with --projectId flag (see [infisical secrets-manager projects list])") } project, err := api.CallGetProject(projectsHTTPClient(cmd), projectID) @@ -241,6 +236,27 @@ func runProjectsGet(cmd *cobra.Command, args []string) { Telemetry.CaptureEvent("cli-command:projects get", posthog.NewProperties().Set("version", util.CLI_VERSION)) } +// resolveProjectID is the project a command works on: --projectId, then INFISICAL_PROJECT_ID, then +// .infisical.yaml (or a legacy .infisical.json). It is "" when none is set. +func resolveProjectID(cmd *cobra.Command) string { + projectID, err := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "projectId", []string{util.INFISICAL_PROJECT_ID_NAME}, "") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if projectID != "" { + return projectID + } + configFile, err := util.FindWorkspaceConfigFile() + if err != nil { + return "" + } + workspaceFile, err := util.GetWorkSpaceFromFilePath(filepath.Dir(configFile)) + if err != nil { + util.HandleError(err, fmt.Sprintf("Unable to read the project config at %s", configFile)) + } + return workspaceFile.WorkspaceId +} + func runProjectsCreate(cmd *cobra.Command, args []string) { outputFormat := projectsOutputFormat(cmd) name, _ := cmd.Flags().GetString("name") diff --git a/packages/cmd/secrets_manager_errors.go b/packages/cmd/secrets_manager_errors.go index 3719d8bc0..350aac6a6 100644 --- a/packages/cmd/secrets_manager_errors.go +++ b/packages/cmd/secrets_manager_errors.go @@ -6,9 +6,11 @@ import ( "os" "strconv" "strings" + "time" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/util" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -21,6 +23,9 @@ const ( errCodeProjectNotFound = "project_not_found" errCodeProjectTypeNotSupported = "project_type_not_supported" errCodeEnvironmentNotFound = "environment_not_found" + errCodeEnvironmentExists = "environment_exists" + errCodeEnvironmentPending = "environment_pending_deletion" + errCodeProjectRequired = "project_required" errCodeSecretExists = "secret_exists" errCodeSecretNotFound = "secret_not_found" errCodeFolderNotFound = "folder_not_found" @@ -33,6 +38,7 @@ var errorCodes = []string{ errCodeAuthRequired, errCodePermissionDenied, errCodeProjectNotLinked, errCodeProjectNotFound, errCodeProjectTypeNotSupported, errCodeEnvironmentNotFound, errCodeSecretExists, errCodeSecretNotFound, errCodeFolderNotFound, errCodeNothingToUpdate, errCodeConfirmationRequired, errCodeConfirmationDeclined, + errCodeEnvironmentExists, errCodeEnvironmentPending, errCodeProjectRequired, } const secretsManagerPrefix = "infisical " + secretsManagerCommandName @@ -146,6 +152,43 @@ func errEnvironmentNotFound(slug string, scope errorScope, cause error) *util.CL Command: secretsManagerPrefix + " environments get" + scope.projectFlag()}}} } +func errEnvironmentExists(slug string, scope errorScope) *util.CLIError { + return &util.CLIError{Code: errCodeEnvironmentExists, + Message: fmt.Sprintf("Environment %s already exists in project %q. Nothing was written.", quoted(slug, "env"), scope.projectLabel()), + Next: []util.NextStep{{Purpose: "To see it", + Command: secretsManagerPrefix + " environments get " + util.SuggestedValue(slug, "slug") + scope.projectFlag()}}} +} + +// errEnvironmentPending is for a slug held by an environment that is soft-deleted but not yet gone: +// it can't be used, created again, or deleted again without --force until deleteAfter. +func errEnvironmentPending(slug, deleteAfter string, scope errorScope) *util.CLIError { + until := "" + if day := formatDay(deleteAfter); day != "" { + until = " until " + day + } + return &util.CLIError{Code: errCodeEnvironmentPending, + Message: fmt.Sprintf("Environment %s in project %q is pending deletion%s, and keeps its slug until then.", quoted(slug, "env"), scope.projectLabel(), until), + Next: []util.NextStep{{Purpose: "To delete it now and free the slug", + Command: secretsManagerPrefix + " environments delete " + util.SuggestedValue(slug, "slug") + " --force" + scope.projectFlag()}}} +} + +// errProjectRequired is for a destructive command run without --projectId: it never falls back to +// INFISICAL_PROJECT_ID or the workspace file (see CLI.md). +func errProjectRequired(what string) *util.CLIError { + return &util.CLIError{Code: errCodeProjectRequired, + Message: fmt.Sprintf("Deleting %s needs --projectId: a delete never uses %s or the project linked to this directory.", what, util.INFISICAL_PROJECT_ID_NAME), + Next: []util.NextStep{{Purpose: "To find the project ID", Command: secretsManagerPrefix + " projects list"}}} +} + +// formatDay turns an API timestamp into a date for a message, or "" when it can't be read. +func formatDay(timestamp string) string { + parsed, err := time.Parse(time.RFC3339Nano, timestamp) + if err != nil { + return "" + } + return parsed.Local().Format("2006-01-02") +} + func errSecretExists(names []string, scope errorScope) *util.CLIError { message := fmt.Sprintf("Secret %s already exists.", listOf(names)) switch { @@ -238,13 +281,18 @@ func errorOutputFormat(cmd *cobra.Command) string { var exitProcess = os.Exit // exitWithError prints err and exits 1: a *util.CLIError in the CLI.md format, anything else the way -// util.HandleError always has. -func exitWithError(cmd *cobra.Command, err error) { +// util.HandleError always has, with messages. +func exitWithError(cmd *cobra.Command, err error, messages ...string) { var cliErr *util.CLIError if errors.As(err, &cliErr) { + // The text format has no room for the request ID, which support asks for. + var apiErr *api.APIError + if errors.As(cliErr.Cause, &apiErr) && apiErr.ReqId != "" { + log.Debug().Msgf("request ID: %s", apiErr.ReqId) + } util.WriteCLIError(util.GetStderrWriter(), cliErr, errorOutputFormat(cmd)) exitProcess(1) return } - util.HandleError(err) + util.HandleError(err, messages...) } diff --git a/packages/cmd/secrets_manager_errors_test.go b/packages/cmd/secrets_manager_errors_test.go index c81a9342f..7ca876b01 100644 --- a/packages/cmd/secrets_manager_errors_test.go +++ b/packages/cmd/secrets_manager_errors_test.go @@ -20,8 +20,6 @@ var testScope = errorScope{ProjectID: testProjectID, ProjectName: "payments-api" // Commands that don't exist yet. Only errors with no callers suggest them, so no user can see one. // Remove an entry when its command is added; the test fails if you forget. var notYetShippedCommands = []string{ - "infisical secrets-manager environments get", - "infisical secrets-manager environments delete", "infisical secrets-manager secrets create", "infisical secrets-manager secrets update", "infisical secrets-manager folders get", @@ -37,6 +35,9 @@ func everyConstructedError() []*util.CLIError { errProjectNotFound(testScope, cause), errProjectTypeNotSupported("certs", "cert-manager"), errEnvironmentNotFound("stagng", testScope, cause), + errEnvironmentExists("qa", testScope), + errEnvironmentPending("qa", "2026-11-12T10:00:00.000Z", testScope), + errProjectRequired("an environment"), errSecretExists([]string{"API_KEY"}, testScope), errSecretNotFound([]string{"API_KEY", "DB_URL"}, testScope), errFolderNotFound("api", testScope), diff --git a/packages/cmd/sm.go b/packages/cmd/sm.go index 593abd622..3a7f3fadf 100644 --- a/packages/cmd/sm.go +++ b/packages/cmd/sm.go @@ -118,9 +118,10 @@ func init() { secretsManagerCmd := &cobra.Command{ Use: secretsManagerCommandName, Aliases: []string{secretsManagerAlias}, - Short: "Secrets Management commands (secrets, projects, dynamic-secrets, run, export)", + Short: "Secrets Management commands (secrets, projects, environments, dynamic-secrets, run, export)", Example: ` infisical secrets-manager secrets get DB_PASSWORD infisical secrets-manager projects create --name my-app + infisical secrets-manager environments create qa --projectId infisical secrets-manager dynamic-secrets lease create my-db-credentials infisical secrets-manager run --env=dev -- npm run dev infisical secrets-manager export --env=prod --format=json`, @@ -142,6 +143,7 @@ func init() { secretsManagerCmd.AddCommand(buildAgentProxyCmd()) secretsManagerCmd.AddCommand(buildProjectsCmd()) + secretsManagerCmd.AddCommand(buildEnvironmentsCmd()) for path, describe := range confirmedUnderSecretsManager { cmd, rest, err := secretsManagerCmd.Find(strings.Fields(path)) diff --git a/packages/cmd/testdata/command_surface.json b/packages/cmd/testdata/command_surface.json index e648a4701..c0e122dcc 100644 --- a/packages/cmd/testdata/command_surface.json +++ b/packages/cmd/testdata/command_surface.json @@ -632,6 +632,58 @@ "token": "|string|", "ttl": "|string|" }, + "infisical secrets-manager environment": {}, + "infisical secrets-manager environment create": { + "name": "|string|", + "output": "o|string|", + "projectId": "|string|", + "token": "|string|" + }, + "infisical secrets-manager environment delete": { + "force": "|bool|false", + "output": "o|string|", + "projectId": "|string|", + "token": "|string|", + "yes": "y|bool|false" + }, + "infisical secrets-manager environment get": { + "output": "o|string|", + "projectId": "|string|", + "token": "|string|" + }, + "infisical secrets-manager environment update": { + "name": "|string|", + "output": "o|string|", + "projectId": "|string|", + "slug": "|string|", + "token": "|string|" + }, + "infisical secrets-manager environments": {}, + "infisical secrets-manager environments create": { + "name": "|string|", + "output": "o|string|", + "projectId": "|string|", + "token": "|string|" + }, + "infisical secrets-manager environments delete": { + "force": "|bool|false", + "output": "o|string|", + "projectId": "|string|", + "token": "|string|", + "yes": "y|bool|false" + }, + "infisical secrets-manager environments get": { + "output": "o|string|", + "projectId": "|string|", + "token": "|string|" + }, + "infisical secrets-manager environments update": { + "name": "|string|", + "output": "o|string|", + "projectId": "|string|", + "slug": "|string|", + "token": "|string|" + }, "infisical secrets-manager export": { "env": "e|string|dev", "expand": "|bool|true", @@ -923,6 +975,58 @@ "token": "|string|", "ttl": "|string|" }, + "infisical sm environment": {}, + "infisical sm environment create": { + "name": "|string|", + "output": "o|string|", + "projectId": "|string|", + "token": "|string|" + }, + "infisical sm environment delete": { + "force": "|bool|false", + "output": "o|string|", + "projectId": "|string|", + "token": "|string|", + "yes": "y|bool|false" + }, + "infisical sm environment get": { + "output": "o|string|", + "projectId": "|string|", + "token": "|string|" + }, + "infisical sm environment update": { + "name": "|string|", + "output": "o|string|", + "projectId": "|string|", + "slug": "|string|", + "token": "|string|" + }, + "infisical sm environments": {}, + "infisical sm environments create": { + "name": "|string|", + "output": "o|string|", + "projectId": "|string|", + "token": "|string|" + }, + "infisical sm environments delete": { + "force": "|bool|false", + "output": "o|string|", + "projectId": "|string|", + "token": "|string|", + "yes": "y|bool|false" + }, + "infisical sm environments get": { + "output": "o|string|", + "projectId": "|string|", + "token": "|string|" + }, + "infisical sm environments update": { + "name": "|string|", + "output": "o|string|", + "projectId": "|string|", + "slug": "|string|", + "token": "|string|" + }, "infisical sm export": { "env": "e|string|dev", "expand": "|bool|true", diff --git a/packages/util/log.go b/packages/util/log.go index 9a6c7c0ca..aa5eab3ea 100644 --- a/packages/util/log.go +++ b/packages/util/log.go @@ -2,6 +2,7 @@ package util import ( "encoding/json" + "errors" "fmt" "io" "net/http" @@ -69,6 +70,13 @@ func HandleError(err error, messages ...string) { } func PrintErrorAndExit(exitCode int, err error, messages ...string) { + // A CLIError that reaches here instead of exitWithError still prints its next step. + var cliErr *CLIError + if errors.As(err, &cliErr) { + WriteCLIError(GetStderrWriter(), cliErr, "") + os.Exit(exitCode) + } + // Check if it's an API error for special formatting if apiErr, ok := err.(*api.APIError); ok { if len(messages) > 0 { diff --git a/packages/visualize/environments.go b/packages/visualize/environments.go new file mode 100644 index 000000000..3d36c5788 --- /dev/null +++ b/packages/visualize/environments.go @@ -0,0 +1,15 @@ +package visualize + +import ( + "strconv" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" +) + +func PrintEnvironmentDetails(environment api.Environment) { + GenericTable( + []string{"ENVIRONMENT NAME", "ENVIRONMENT SLUG", "POSITION", "ENVIRONMENT ID"}, + [][]string{{util.SanitizeDisplay(environment.Name), util.SanitizeDisplay(environment.Slug), strconv.Itoa(environment.Position), util.SanitizeDisplay(environment.ID)}}, + ) +} From 81f3e3d85e604db22b60b62fb6e81e67e01bc86d Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 30 Oct 2026 21:47:32 -0300 Subject: [PATCH 2/3] feat(cli): enhance environment error handling and command output - Added a new error code `invalid_flag_value` for cases where command flags are provided with invalid values, such as empty names. - Updated `CLI.md` to document the new error code and its implications for users. - Improved the output of environment deletion commands to clarify the timing of permanent deletions. - Enhanced unit tests to validate the new error handling and output behavior for environment commands, ensuring robustness in CLI interactions. --- CLI.md | 1 + packages/api/api.go | 4 +- packages/api/model.go | 2 +- packages/cmd/environments.go | 77 +++++++++++------- packages/cmd/environments_test.go | 86 +++++++++++++++++++-- packages/cmd/projects.go | 1 - packages/cmd/secrets_manager_errors.go | 9 ++- packages/cmd/secrets_manager_errors_test.go | 1 + packages/util/log.go | 5 ++ packages/visualize/environments.go | 10 +-- 10 files changed, 148 insertions(+), 48 deletions(-) diff --git a/CLI.md b/CLI.md index 4b47a3728..05018f167 100644 --- a/CLI.md +++ b/CLI.md @@ -74,6 +74,7 @@ With `-o json`, the same error is JSON on stderr: | `environment_not_found` | The environment slug doesn't exist in the project (404) | | `environment_exists` | `environments create` (or `update --slug`) with a slug an active environment has. Nothing is written | | `environment_pending_deletion` | The slug belongs to an environment pending deletion, which keeps it until the grace period ends. Suggests `delete --force` | +| `invalid_flag_value` | A flag was given a value the command can't use, such as an empty `--name`. Nothing is sent | | `project_required` | A delete ran without `--projectId`. Deletes never use `INFISICAL_PROJECT_ID` or `.infisical.yaml` | | `secret_exists` | `secrets create` on a key that exists. Nothing is written | | `secret_not_found` | `secrets update` on a key that doesn't exist. Nothing is written | diff --git a/packages/api/api.go b/packages/api/api.go index 266453ce9..f569e548d 100644 --- a/packages/api/api.go +++ b/packages/api/api.go @@ -505,8 +505,8 @@ func CallUpdateEnvironment(httpClient *resty.Client, projectID, environmentID st return environmentResponse.Environment, nil } -// CallDeleteEnvironment soft-deletes the environment, or deletes it for good with hardDelete. Backends -// without soft delete ignore hardDelete and always delete for good; their response has no deleteAfter. +// CallDeleteEnvironment soft-deletes the environment, or permanently deletes it with hardDelete. Backends +// without soft delete ignore hardDelete and always delete permanently; their response has no deleteAfter. func CallDeleteEnvironment(httpClient *resty.Client, projectID, environmentID string, hardDelete bool) (Environment, error) { var environmentResponse EnvironmentResponse request := httpClient. diff --git a/packages/api/model.go b/packages/api/model.go index b94e0aacd..16ad4a6e8 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -266,7 +266,7 @@ type CreateProjectResponse struct { } // Environment is a project environment as the /v1/projects/:projectId/environments endpoints return -// it. DeleteAfter is set once it is soft-deleted, and is when it will be deleted for good. +// it. DeleteAfter is set once it is soft-deleted, and is when it will be permanently deleted. type Environment struct { ID string `json:"id"` Name string `json:"name"` diff --git a/packages/cmd/environments.go b/packages/cmd/environments.go index 89a73a4be..917c38839 100644 --- a/packages/cmd/environments.go +++ b/packages/cmd/environments.go @@ -15,7 +15,6 @@ import ( "github.com/spf13/cobra" ) -// buildEnvironmentsCmd builds the `infisical secrets-manager environments` command tree. func buildEnvironmentsCmd() *cobra.Command { environmentsCmd := &cobra.Command{ Use: "environments", @@ -85,7 +84,7 @@ infisical secrets-manager environments update qa --slug testing --projectId -# Delete for good now, skipping the grace period (also frees the slug of one pending deletion) +# Permanently delete now, skipping the grace period (also frees the slug of one pending deletion) infisical secrets-manager environments delete qa --force --projectId `, DisableFlagsInUseLine: true, Args: cobra.ExactArgs(1), @@ -93,7 +92,7 @@ infisical secrets-manager environments delete qa --force --projectId ].", util.SanitizeDisplay(target.project.Name))) } else { @@ -257,13 +271,13 @@ func runEnvironmentsCreate(cmd *cobra.Command, args []string) { exitWithError(cmd, classifyAPIError(err, nil), "Unable to create the environment") } - target.printEnvironment(environment, fmt.Sprintf("Created environment %s in project %s", util.SanitizeDisplay(environment.Slug), util.SanitizeDisplay(target.project.Name))) + target.printEnvironment(cmd, environment, fmt.Sprintf("Created environment %s in project %s", util.SanitizeDisplay(environment.Slug), util.SanitizeDisplay(target.project.Name))) Telemetry.CaptureEvent("cli-command:environments create", posthog.NewProperties().Set("version", util.CLI_VERSION)) } -// environmentUpdate is the PATCH body for the flags that were passed, trimmed. An explicitly empty -// --name or --slug is an error, since Changed is true for "" and the backend would get a blank value. -func environmentUpdate(cmd *cobra.Command) (api.UpdateEnvironmentRequest, error) { +// An explicitly empty --name or --slug is an error: Changed is true for "", and the backend would get a +// blank value. +func environmentUpdate(cmd *cobra.Command) (api.UpdateEnvironmentRequest, *util.CLIError) { var request api.UpdateEnvironmentRequest for _, field := range []struct { flag string @@ -275,7 +289,7 @@ func environmentUpdate(cmd *cobra.Command) (api.UpdateEnvironmentRequest, error) value, _ := cmd.Flags().GetString(field.flag) value = strings.TrimSpace(value) if value == "" { - return api.UpdateEnvironmentRequest{}, fmt.Errorf("--%s can't be empty", field.flag) + return api.UpdateEnvironmentRequest{}, errEmptyFlag(field.flag) } *field.value = &value } @@ -284,9 +298,9 @@ func environmentUpdate(cmd *cobra.Command) (api.UpdateEnvironmentRequest, error) func runEnvironmentsUpdate(cmd *cobra.Command, args []string) { slug := args[0] - request, err := environmentUpdate(cmd) - if err != nil { - util.HandleError(err) + request, invalid := environmentUpdate(cmd) + if invalid != nil { + exitWithError(cmd, invalid) } if request.Name == nil && request.Slug == nil { exitWithError(cmd, errNothingToUpdate([]string{"name", "slug"})) @@ -305,7 +319,7 @@ func runEnvironmentsUpdate(cmd *cobra.Command, args []string) { }), "Unable to update the environment") } - target.printEnvironment(environment, fmt.Sprintf("Updated environment %s", util.SanitizeDisplay(environment.Slug))) + target.printEnvironment(cmd, environment, fmt.Sprintf("Updated environment %s", util.SanitizeDisplay(environment.Slug))) Telemetry.CaptureEvent("cli-command:environments update", posthog.NewProperties().Set("version", util.CLI_VERSION)) } @@ -320,11 +334,11 @@ func runEnvironmentsDelete(cmd *cobra.Command, args []string) { environmentID = active.ID description = fmt.Sprintf("environment %s from project %s, with all of its secrets", util.SuggestedValue(slug, "slug"), project) if force { - description += ", for good and without a grace period" + description += ", permanently and without a grace period" } } else if pending, ok := target.pending(slug); ok && force { environmentID = pending.ID - description = fmt.Sprintf("environment %s, already pending deletion, from project %s for good", util.SuggestedValue(slug, "slug"), project) + description = fmt.Sprintf("environment %s, already pending deletion, permanently from project %s", util.SuggestedValue(slug, "slug"), project) } else { target.existing(cmd, slug) } @@ -345,14 +359,19 @@ func runEnvironmentsDelete(cmd *cobra.Command, args []string) { }), "Unable to delete the environment") } + // A hard delete of an environment already pending deletion returns the row as it was, with its old + // deleteAfter, so whether it's gone comes from --force, not from the response. + if force { + environment.DeleteAfter = "" + } if target.outputFormat != "" { printFormattedOutput(target.outputFormat, environment) } else if day := formatDay(environment.DeleteAfter); day != "" { - util.PrintSuccessMessage(fmt.Sprintf("Deleted environment %s. It will be removed for good on %s.", util.SanitizeDisplay(slug), day)) - printProjectsTip(cmd, fmt.Sprintf("To remove it now and free its slug, run [infisical secrets-manager environments delete %s --force --projectId %s].", + printSuccessNotice(cmd, fmt.Sprintf("Deleted environment %s. It will be permanently deleted on %s.", util.SanitizeDisplay(slug), day)) + printProjectsTip(cmd, fmt.Sprintf("To permanently delete it now and free its slug, run [infisical secrets-manager environments delete %s --force --projectId %s].", util.SuggestedValue(slug, "slug"), util.SuggestedValue(target.project.ID, "project-id"))) } else { - util.PrintSuccessMessage(fmt.Sprintf("Deleted environment %s for good.", util.SanitizeDisplay(slug))) + printSuccessNotice(cmd, fmt.Sprintf("Permanently deleted environment %s.", util.SanitizeDisplay(slug))) } Telemetry.CaptureEvent("cli-command:environments delete", posthog.NewProperties().Set("hardDelete", force).Set("version", util.CLI_VERSION)) diff --git a/packages/cmd/environments_test.go b/packages/cmd/environments_test.go index 4fd71b3a9..3bf01f49d 100644 --- a/packages/cmd/environments_test.go +++ b/packages/cmd/environments_test.go @@ -20,6 +20,9 @@ import ( const testEnvironmentID = "0a6c2f4e-1b3d-4e5f-8a9b-0c1d2e3f4a5b" const testPendingID = "9f8e7d6c-5b4a-4f3e-8d2c-1b0a9f8e7d6c" +// Midday UTC, so the date in messages is the same in every time zone a test might run in. +const testDeleteAfter = "2026-11-13T12:00:00.000Z" + // fakeEnvironmentsAPI answers the routes the environments commands call, like the backend does, and // records every request so a test can assert what was (and wasn't) sent. type fakeEnvironmentsAPI struct { @@ -74,8 +77,12 @@ func (f *fakeEnvironmentsAPI) serve(t *testing.T) { _, _ = fmt.Fprintf(w, `{"environment":{"id":%q,"name":"Staging","slug":"staging","position":2,"projectId":%q}}`, testEnvironmentID, testProjectID) case r.Method == http.MethodGet: notFound() - case r.Method == http.MethodDelete: + case r.Method == http.MethodDelete && strings.HasSuffix(r.URL.Path, "/"+testPendingID): + _, _ = fmt.Fprintf(w, `{"environment":{"id":%q,"name":"QA","slug":"qa","position":3,"projectId":%q,"deleteAfter":%q}}`, testPendingID, testProjectID, testDeleteAfter) + case r.Method == http.MethodDelete && r.URL.Query().Get("hardDelete") == "true": _, _ = fmt.Fprintf(w, `{"environment":{"id":"x","name":"n","slug":"s","position":1,"projectId":%q}}`, testProjectID) + case r.Method == http.MethodDelete: + _, _ = fmt.Fprintf(w, `{"environment":{"id":"x","name":"n","slug":"s","position":1,"projectId":%q,"deleteAfter":%q}}`, testProjectID, testDeleteAfter) default: _, _ = fmt.Fprintf(w, `{"environment":{"id":"new","name":"n","slug":"s","position":3,"projectId":%q}}`, testProjectID) } @@ -117,6 +124,7 @@ func runEnvironments(t *testing.T, fake *fakeEnvironmentsAPI, args ...string) (s t.Cleanup(func() { util.GetStdoutWriter, exitProcess, Telemetry = oldStdout, oldExit, oldTelemetry }) root := &cobra.Command{Use: "infisical"} + root.PersistentFlags().Bool("silent", false, "") product := &cobra.Command{Use: secretsManagerCommandName} product.AddCommand(buildEnvironmentsCmd()) root.AddCommand(product) @@ -148,10 +156,16 @@ func runEnvironments(t *testing.T, fake *fakeEnvironmentsAPI, args ...string) (s func TestEnvironmentsGetListsAndShowsOne(t *testing.T) { fake := &fakeEnvironmentsAPI{} _, stdout, exited := runEnvironments(t, fake, "get", "--projectId", testProjectID, "-o", "json") - var listed []map[string]string - if exited || json.Unmarshal([]byte(stdout), &listed) != nil || len(listed) != 2 || listed[1]["slug"] != "staging" { + var listed struct { + Environments []map[string]string `json:"environments"` + PendingDeletion []map[string]string `json:"pendingDeletion"` + } + if exited || json.Unmarshal([]byte(stdout), &listed) != nil || len(listed.Environments) != 2 || listed.Environments[1]["slug"] != "staging" { t.Errorf("list: exited=%v stdout=%s", exited, stdout) } + if len(listed.PendingDeletion) != 1 || listed.PendingDeletion[0]["slug"] != "qa" || listed.PendingDeletion[0]["deleteAfter"] == "" { + t.Errorf("held slugs must be visible to scripts: %s", stdout) + } _, stdout, exited = runEnvironments(t, fake, "get", "staging", "--projectId", testProjectID, "-o", "json") if exited || !strings.Contains(stdout, `"position":2`) { @@ -262,7 +276,7 @@ func TestEnvironmentsCreateAndUpdateSendOnlyWhatWasPassed(t *testing.T) { } // An explicit empty --name or --slug is rejected before anything is sent, instead of PATCHing a -// blank value. +// blank value, and in the format -o asks for. func TestEnvironmentUpdateRejectsEmptyValues(t *testing.T) { cases := map[string]string{ `--name=`: "--name can't be empty", @@ -278,11 +292,69 @@ func TestEnvironmentUpdateRejectsEmptyValues(t *testing.T) { if err := update.ParseFlags([]string{flag}); err != nil { t.Fatal(err) } - _, err = environmentUpdate(update) - if got := fmt.Sprint(err); (want == "" && err != nil) || (want != "" && got != want) { - t.Errorf("%s: got %v, want %q", flag, err, want) + _, invalid := environmentUpdate(update) + switch { + case want == "" && invalid != nil: + t.Errorf("%s: got %v", flag, invalid) + case want != "" && (invalid == nil || invalid.Code != errCodeInvalidFlagValue || !strings.HasPrefix(invalid.Message, want)): + t.Errorf("%s: got %+v, want invalid_flag_value starting %q", flag, invalid, want) } } + + for _, format := range []string{"json", "yaml"} { + fake := &fakeEnvironmentsAPI{} + stderr, stdout, exited := runEnvironments(t, fake, "update", "staging", "--slug=", "--projectId", testProjectID, "-o", format) + if !exited || stdout != "" || !strings.Contains(stderr, "invalid_flag_value") || len(fake.requests) != 0 { + t.Errorf("-o %s: exited=%v requests=%v stderr=%s", format, exited, fake.requests, stderr) + } + } +} + +// A normal delete is soft: the output gives the date it is permanently deleted and how to delete it now. The +// notices go to stderr, so stdout only ever carries data. +func TestEnvironmentsDeleteReportsWhatHappened(t *testing.T) { + stderr, stdout, exited := runEnvironments(t, &fakeEnvironmentsAPI{}, "delete", "staging", "--yes", "--projectId", testProjectID) + if exited || stdout != "" || !strings.Contains(stderr, "Deleted environment staging. It will be permanently deleted on 2026-11-13.") || + !strings.Contains(stderr, "environments delete staging --force --projectId "+testProjectID) { + t.Errorf("soft delete: exited=%v stdout=%q stderr=%q", exited, stdout, stderr) + } + + stderr, stdout, _ = runEnvironments(t, &fakeEnvironmentsAPI{}, "delete", "staging", "--yes", "--force", "--projectId", testProjectID) + if stdout != "" || !strings.Contains(stderr, "Permanently deleted environment staging.") || strings.Contains(stderr, "--force") { + t.Errorf("hard delete: stdout=%q stderr=%q", stdout, stderr) + } + + stderr, _, _ = runEnvironments(t, &fakeEnvironmentsAPI{}, "delete", "qa", "--yes", "--force", "--projectId", testProjectID) + if !strings.Contains(stderr, "Permanently deleted environment qa.") || strings.Contains(stderr, "will be permanently deleted on") { + t.Errorf("--force on an environment pending deletion removes it now: %q", stderr) + } + _, stdout, _ = runEnvironments(t, &fakeEnvironmentsAPI{}, "delete", "qa", "--yes", "--force", "--projectId", testProjectID, "-o", "json") + if strings.Contains(stdout, "deleteAfter") { + t.Errorf("a permanently deleted environment has no deleteAfter: %s", stdout) + } + + _, stdout, _ = runEnvironments(t, &fakeEnvironmentsAPI{}, "delete", "staging", "--yes", "--projectId", testProjectID, "-o", "json") + if !strings.Contains(stdout, `"deleteAfter":"`+testDeleteAfter+`"`) { + t.Errorf("-o json should carry deleteAfter: %s", stdout) + } + + stderr, _, _ = runEnvironments(t, &fakeEnvironmentsAPI{}, "delete", "staging", "--yes", "--silent", "--projectId", testProjectID) + if stderr != "" { + t.Errorf("--silent should print nothing: %q", stderr) + } +} + +// The table itself goes to os.Stdout through visualize, which this harness doesn't capture; the +// end-to-end suite checks it. +func TestEnvironmentsCreateNoticeRespectsSilent(t *testing.T) { + stderr, stdout, _ := runEnvironments(t, &fakeEnvironmentsAPI{}, "create", "preview", "--projectId", testProjectID) + if !strings.Contains(stderr, "Created environment") || strings.Contains(stdout, "Created environment") { + t.Errorf("the notice belongs on stderr: stdout=%q stderr=%q", stdout, stderr) + } + stderr, _, _ = runEnvironments(t, &fakeEnvironmentsAPI{}, "create", "preview", "--silent", "--projectId", testProjectID) + if stderr != "" { + t.Errorf("--silent should print no notice: %q", stderr) + } } // Service tokens can't use these routes: one from the environment is ignored, like the projects diff --git a/packages/cmd/projects.go b/packages/cmd/projects.go index d8a073521..7bd7296ee 100644 --- a/packages/cmd/projects.go +++ b/packages/cmd/projects.go @@ -157,7 +157,6 @@ func projectsOutputFormat(cmd *cobra.Command) string { return crudOutputFormat(cmd, "projects") } -// crudOutputFormat is -o for a command whose output is structured data: json, yaml, or "" for a table. func crudOutputFormat(cmd *cobra.Command, noun string) string { outputFormat, err := cmd.Flags().GetString("output") if err != nil { diff --git a/packages/cmd/secrets_manager_errors.go b/packages/cmd/secrets_manager_errors.go index 350aac6a6..29029cd4e 100644 --- a/packages/cmd/secrets_manager_errors.go +++ b/packages/cmd/secrets_manager_errors.go @@ -26,6 +26,7 @@ const ( errCodeEnvironmentExists = "environment_exists" errCodeEnvironmentPending = "environment_pending_deletion" errCodeProjectRequired = "project_required" + errCodeInvalidFlagValue = "invalid_flag_value" errCodeSecretExists = "secret_exists" errCodeSecretNotFound = "secret_not_found" errCodeFolderNotFound = "folder_not_found" @@ -38,7 +39,7 @@ var errorCodes = []string{ errCodeAuthRequired, errCodePermissionDenied, errCodeProjectNotLinked, errCodeProjectNotFound, errCodeProjectTypeNotSupported, errCodeEnvironmentNotFound, errCodeSecretExists, errCodeSecretNotFound, errCodeFolderNotFound, errCodeNothingToUpdate, errCodeConfirmationRequired, errCodeConfirmationDeclined, - errCodeEnvironmentExists, errCodeEnvironmentPending, errCodeProjectRequired, + errCodeEnvironmentExists, errCodeEnvironmentPending, errCodeProjectRequired, errCodeInvalidFlagValue, } const secretsManagerPrefix = "infisical " + secretsManagerCommandName @@ -180,7 +181,11 @@ func errProjectRequired(what string) *util.CLIError { Next: []util.NextStep{{Purpose: "To find the project ID", Command: secretsManagerPrefix + " projects list"}}} } -// formatDay turns an API timestamp into a date for a message, or "" when it can't be read. +func errEmptyFlag(flag string) *util.CLIError { + return &util.CLIError{Code: errCodeInvalidFlagValue, + Message: fmt.Sprintf("--%s can't be empty: pass a value, or leave out --%s to keep the current one.", flag, flag)} +} + func formatDay(timestamp string) string { parsed, err := time.Parse(time.RFC3339Nano, timestamp) if err != nil { diff --git a/packages/cmd/secrets_manager_errors_test.go b/packages/cmd/secrets_manager_errors_test.go index 7ca876b01..554dffa51 100644 --- a/packages/cmd/secrets_manager_errors_test.go +++ b/packages/cmd/secrets_manager_errors_test.go @@ -38,6 +38,7 @@ func everyConstructedError() []*util.CLIError { errEnvironmentExists("qa", testScope), errEnvironmentPending("qa", "2026-11-12T10:00:00.000Z", testScope), errProjectRequired("an environment"), + errEmptyFlag("name"), errSecretExists([]string{"API_KEY"}, testScope), errSecretNotFound([]string{"API_KEY", "DB_URL"}, testScope), errFolderNotFound("api", testScope), diff --git a/packages/util/log.go b/packages/util/log.go index aa5eab3ea..6b8655539 100644 --- a/packages/util/log.go +++ b/packages/util/log.go @@ -111,6 +111,11 @@ func PrintSuccessMessage(message string) { color.New(color.FgGreen).Println(message) } +// PrintSuccessMessageStderr is PrintSuccessMessage for commands whose stdout carries only their data. +func PrintSuccessMessageStderr(message string) { + color.New(color.FgGreen).Fprintln(GetStderrWriter(), message) +} + func PrintErrorMessageAndExit(messages ...string) { if len(messages) > 0 { for _, message := range messages { diff --git a/packages/visualize/environments.go b/packages/visualize/environments.go index 3d36c5788..973b6c301 100644 --- a/packages/visualize/environments.go +++ b/packages/visualize/environments.go @@ -1,15 +1,13 @@ package visualize import ( - "strconv" - "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/util" ) func PrintEnvironmentDetails(environment api.Environment) { - GenericTable( - []string{"ENVIRONMENT NAME", "ENVIRONMENT SLUG", "POSITION", "ENVIRONMENT ID"}, - [][]string{{util.SanitizeDisplay(environment.Name), util.SanitizeDisplay(environment.Slug), strconv.Itoa(environment.Position), util.SanitizeDisplay(environment.ID)}}, - ) + headers := [...]string{"ENVIRONMENT NAME", "ENVIRONMENT SLUG", "ENVIRONMENT ID"} + rows := [][3]string{{util.SanitizeDisplay(environment.Name), util.SanitizeDisplay(environment.Slug), util.SanitizeDisplay(environment.ID)}} + + Table(headers, rows) } From 47dbb417755b63f6aeddc6269709e2ca316e45e1 Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos Date: Fri, 30 Oct 2026 21:58:41 -0300 Subject: [PATCH 3/3] fix(tests): adjust time zone handling in environment deletion tests - Updated the `TestEnvironmentsDeleteReportsWhatHappened` test to set the local time zone to UTC, ensuring consistent date formatting across different environments. - Added cleanup logic to restore the original local time zone after the test execution, maintaining test isolation and reliability. --- packages/cmd/environments_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cmd/environments_test.go b/packages/cmd/environments_test.go index 3bf01f49d..f862e7a38 100644 --- a/packages/cmd/environments_test.go +++ b/packages/cmd/environments_test.go @@ -11,6 +11,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/Infisical/infisical-merge/packages/telemetry" "github.com/Infisical/infisical-merge/packages/util" @@ -20,7 +21,6 @@ import ( const testEnvironmentID = "0a6c2f4e-1b3d-4e5f-8a9b-0c1d2e3f4a5b" const testPendingID = "9f8e7d6c-5b4a-4f3e-8d2c-1b0a9f8e7d6c" -// Midday UTC, so the date in messages is the same in every time zone a test might run in. const testDeleteAfter = "2026-11-13T12:00:00.000Z" // fakeEnvironmentsAPI answers the routes the environments commands call, like the backend does, and @@ -313,6 +313,11 @@ func TestEnvironmentUpdateRejectsEmptyValues(t *testing.T) { // A normal delete is soft: the output gives the date it is permanently deleted and how to delete it now. The // notices go to stderr, so stdout only ever carries data. func TestEnvironmentsDeleteReportsWhatHappened(t *testing.T) { + // formatDay prints the local date, which is 2026-11-14 for testDeleteAfter from UTC+12 on. + oldLocal := time.Local + time.Local = time.UTC + t.Cleanup(func() { time.Local = oldLocal }) + stderr, stdout, exited := runEnvironments(t, &fakeEnvironmentsAPI{}, "delete", "staging", "--yes", "--projectId", testProjectID) if exited || stdout != "" || !strings.Contains(stderr, "Deleted environment staging. It will be permanently deleted on 2026-11-13.") || !strings.Contains(stderr, "environments delete staging --force --projectId "+testProjectID) {