diff --git a/CLI.md b/CLI.md index 0e3b3621a..05018f167 100644 --- a/CLI.md +++ b/CLI.md @@ -72,6 +72,10 @@ 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` | +| `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 | | `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..f569e548d 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 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. + 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..16ad4a6e8 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 permanently deleted. +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..917c38839 --- /dev/null +++ b/packages/cmd/environments.go @@ -0,0 +1,378 @@ +/* +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" +) + +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 + +# 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), + 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, "Permanently delete 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) +} + +type environmentsTarget struct { + client *resty.Client + project api.ProjectEnvironments + scope errorScope + outputFormat string +} + +// A delete passes explicitProject: it never falls back to INFISICAL_PROJECT_ID or .infisical.yaml. +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 +} + +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{} +} + +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(cmd *cobra.Command, environment api.Environment, notice string) { + if t.outputFormat != "" { + printFormattedOutput(t.outputFormat, environment) + return + } + if notice != "" { + printSuccessNotice(cmd, notice) + } + visualize.PrintEnvironmentDetails(environment) +} + +func printSuccessNotice(cmd *cobra.Command, message string) { + if silent, _ := cmd.Flags().GetBool("silent"); !silent { + util.PrintSuccessMessageStderr(message) + } +} + +// environmentList is the structured output of `environments get`. Pending environments are listed +// apart, so a script iterating environments never picks a slug it can't use, but can still see +// which slugs are held and until when. +type environmentList struct { + Environments []api.ProjectEnvironment `json:"environments"` + PendingDeletion []api.DeletedEnvironment `json:"pendingDeletion"` +} + +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(cmd, environment, "") + Telemetry.CaptureEvent("cli-command:environments get", posthog.NewProperties().Set("version", util.CLI_VERSION)) + return + } + + if target.outputFormat != "" { + list := environmentList{Environments: target.project.Environments, PendingDeletion: target.project.DeletedEnvironments} + if list.Environments == nil { + list.Environments = []api.ProjectEnvironment{} + } + if list.PendingDeletion == nil { + list.PendingDeletion = []api.DeletedEnvironment{} + } + printFormattedOutput(target.outputFormat, list) + } 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(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)) +} + +// 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 + 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{}, errEmptyFlag(field.flag) + } + *field.value = &value + } + return request, nil +} + +func runEnvironmentsUpdate(cmd *cobra.Command, args []string) { + slug := args[0] + request, invalid := environmentUpdate(cmd) + if invalid != nil { + exitWithError(cmd, invalid) + } + 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(cmd, 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 += ", 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, permanently from project %s", 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") + } + + // 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 != "" { + 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 { + 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 new file mode 100644 index 000000000..f862e7a38 --- /dev/null +++ b/packages/cmd/environments_test.go @@ -0,0 +1,385 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "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" + +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 { + 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 && 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) + } + })) + 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"} + root.PersistentFlags().Bool("silent", false, "") + 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 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`) { + 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, and in the format -o asks for. +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) + } + _, 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) { + // 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) { + 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 +// 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..7bd7296ee 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,10 @@ func projectsHTTPClient(cmd *cobra.Command) *resty.Client { } func projectsOutputFormat(cmd *cobra.Command) string { + return crudOutputFormat(cmd, "projects") +} + +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 +167,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 +213,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 +235,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..29029cd4e 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,10 @@ const ( errCodeProjectNotFound = "project_not_found" errCodeProjectTypeNotSupported = "project_type_not_supported" errCodeEnvironmentNotFound = "environment_not_found" + 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" @@ -33,6 +39,7 @@ var errorCodes = []string{ errCodeAuthRequired, errCodePermissionDenied, errCodeProjectNotLinked, errCodeProjectNotFound, errCodeProjectTypeNotSupported, errCodeEnvironmentNotFound, errCodeSecretExists, errCodeSecretNotFound, errCodeFolderNotFound, errCodeNothingToUpdate, errCodeConfirmationRequired, errCodeConfirmationDeclined, + errCodeEnvironmentExists, errCodeEnvironmentPending, errCodeProjectRequired, errCodeInvalidFlagValue, } const secretsManagerPrefix = "infisical " + secretsManagerCommandName @@ -146,6 +153,47 @@ 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"}}} +} + +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 { + 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 +286,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..554dffa51 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,10 @@ 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"), + errEmptyFlag("name"), 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..6b8655539 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 { @@ -103,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 new file mode 100644 index 000000000..973b6c301 --- /dev/null +++ b/packages/visualize/environments.go @@ -0,0 +1,13 @@ +package visualize + +import ( + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" +) + +func PrintEnvironmentDetails(environment api.Environment) { + 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) +}