diff --git a/.claude/settings.json b/.claude/settings.json index 9d288d05..d8f7e469 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,6 +16,7 @@ "Bash(git log:*)", "Bash(git status:*)", "Bash(go build:*)", + "Bash(go doc:*)", "Bash(go mod graph:*)", "Bash(go mod tidy:*)", "Bash(go mod tidy:*)", diff --git a/cmd/env/add.go b/cmd/env/add.go index f48c47dd..a9c6bb47 100644 --- a/cmd/env/add.go +++ b/cmd/env/add.go @@ -23,22 +23,27 @@ import ( "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/prompts" "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackdotenv" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/afero" "github.com/spf13/cobra" ) func NewEnvAddCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ - Use: "add [flags]", - Short: "Add an environment variable to the app", + Use: "add [value] [flags]", + Short: "Add an environment variable to the project", Long: strings.Join([]string{ - "Add an environment variable to an app deployed to Slack managed infrastructure.", + "Add an environment variable to the project.", "", "If a name or value is not provided, you will be prompted to provide these.", "", - "This command is supported for apps deployed to Slack managed infrastructure but", - "other apps can attempt to run the command with the --force flag.", + "Commands that run in the context of a project source environment variables from", + `the ".env" file. This includes the "run" command.`, + "", + `The "deploy" command gathers environment variables from the ".env" file as well`, + "unless the app is using ROSI features.", }, "\n"), Example: style.ExampleCommandsf([]style.ExampleCommand{ { @@ -69,18 +74,11 @@ func NewEnvAddCommand(clients *shared.ClientFactory) *cobra.Command { return cmd } -// preRunEnvAddCommandFunc determines if the command is supported for a project +// preRunEnvAddCommandFunc determines if the command is run in a valid project // and configures flags func preRunEnvAddCommandFunc(ctx context.Context, clients *shared.ClientFactory, cmd *cobra.Command) error { clients.Config.SetFlags(cmd) - err := cmdutil.IsValidProjectDirectory(clients) - if err != nil { - return err - } - if clients.Config.ForceFlag { - return nil - } - return cmdutil.IsSlackHostedProject(ctx, clients) + return cmdutil.IsValidProjectDirectory(clients) } // runEnvAddCommandFunc sets an app environment variable to given values @@ -88,7 +86,7 @@ func runEnvAddCommandFunc(clients *shared.ClientFactory, cmd *cobra.Command, arg ctx := cmd.Context() // Get the workspace from the flag or prompt - selection, err := appSelectPromptFunc(ctx, clients, prompts.ShowHostedOnly, prompts.ShowInstalledAppsOnly) + selection, err := appSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) if err != nil { return err } @@ -127,27 +125,47 @@ func runEnvAddCommandFunc(clients *shared.ClientFactory, cmd *cobra.Command, arg variableValue = args[1] } - err = clients.API().AddVariable( - ctx, - selection.Auth.Token, - selection.App.AppID, - variableName, - variableValue, - ) - if err != nil { - return err + // Add the environment variable using either the Slack API method or the + // project ".env" file depending on the app hosting. + if !selection.App.IsDev && cmdutil.IsSlackHostedProject(ctx, clients) == nil { + err = clients.API().AddVariable( + ctx, + selection.Auth.Token, + selection.App.AppID, + variableName, + variableValue, + ) + if err != nil { + return err + } + clients.IO.PrintTrace(ctx, slacktrace.EnvAddSuccess) + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "evergreen_tree", + Text: "App Environment", + Secondary: []string{ + fmt.Sprintf("Successfully added \"%s\" as an app environment variable", variableName), + }, + })) + } else { + exists, err := afero.Exists(clients.Fs, ".env") + if err != nil { + return err + } + err = slackdotenv.Set(clients.Fs, variableName, variableValue) + if err != nil { + return err + } + clients.IO.PrintTrace(ctx, slacktrace.EnvAddSuccess) + var details []string + if !exists { + details = append(details, "Created a project .env file that shouldn't be added to version control") + } + details = append(details, fmt.Sprintf("Successfully added \"%s\" as a project environment variable", variableName)) + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "evergreen_tree", + Text: "App Environment", + Secondary: details, + })) } - - clients.IO.PrintTrace(ctx, slacktrace.EnvAddSuccess) - clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ - Emoji: "evergreen_tree", - Text: "App Environment", - Secondary: []string{ - fmt.Sprintf( - "Successfully added \"%s\" as an environment variable", - variableName, - ), - }, - })) return nil } diff --git a/cmd/env/add_test.go b/cmd/env/add_test.go index 20b4ab86..377dda9f 100644 --- a/cmd/env/add_test.go +++ b/cmd/env/add_test.go @@ -28,6 +28,7 @@ import ( "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -46,88 +47,22 @@ var mockApp = types.App{ func Test_Env_AddCommandPreRun(t *testing.T) { tests := map[string]struct { - mockFlagForce bool - mockManifestResponse types.SlackYaml - mockManifestError error - mockManifestSource config.ManifestSource mockWorkingDirectory string expectedError error }{ - "continues if the application is hosted on slack": { - mockManifestResponse: types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: types.SlackHosted, - }, - }, - }, - mockManifestError: nil, - mockManifestSource: config.ManifestSourceLocal, - mockWorkingDirectory: "/slack/path/to/project", - expectedError: nil, - }, - "errors if the application is not hosted on slack": { - mockManifestResponse: types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - }, - }, - }, - mockManifestError: nil, - mockManifestSource: config.ManifestSourceLocal, - mockWorkingDirectory: "/slack/path/to/project", - expectedError: slackerror.New(slackerror.ErrAppNotHosted), - }, - "continues if the force flag is used in a project": { - mockFlagForce: true, + "continues if the command is run in a project": { mockWorkingDirectory: "/slack/path/to/project", expectedError: nil, }, - "errors if the project manifest cannot be retrieved": { - mockManifestResponse: types.SlackYaml{}, - mockManifestError: slackerror.New(slackerror.ErrSDKHookInvocationFailed), - mockManifestSource: config.ManifestSourceLocal, - mockWorkingDirectory: "/slack/path/to/project", - expectedError: slackerror.New(slackerror.ErrSDKHookInvocationFailed), - }, "errors if the command is not run in a project": { - mockManifestResponse: types.SlackYaml{}, - mockManifestError: slackerror.New(slackerror.ErrSDKHookNotFound), mockWorkingDirectory: "", expectedError: slackerror.New(slackerror.ErrInvalidAppDirectory), }, - "errors if the manifest source is set to remote": { - mockManifestSource: config.ManifestSourceRemote, - mockWorkingDirectory: "/slack/path/to/project", - expectedError: slackerror.New(slackerror.ErrAppNotHosted), - }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { clientsMock := shared.NewClientsMock() - manifestMock := &app.ManifestMockObject{} - manifestMock.On( - "GetManifestLocal", - mock.Anything, - mock.Anything, - mock.Anything, - ).Return( - tc.mockManifestResponse, - tc.mockManifestError, - ) - clientsMock.AppClient.Manifest = manifestMock - projectConfigMock := config.NewProjectConfigMock() - projectConfigMock.On( - "GetManifestSource", - mock.Anything, - ).Return( - tc.mockManifestSource, - nil, - ) - clientsMock.Config.ProjectConfig = projectConfigMock clients := shared.NewClientFactory(clientsMock.MockClientFactory(), func(cf *shared.ClientFactory) { - cf.Config.ForceFlag = tc.mockFlagForce cf.SDKConfig.WorkingDirectory = tc.mockWorkingDirectory }) cmd := NewEnvAddCommand(clients) @@ -146,7 +81,7 @@ func Test_Env_AddCommand(t *testing.T) { "add a variable using arguments": { CmdArgs: []string{"ENV_NAME", "ENV_VALUE"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - setupEnvAddCommandMocks(ctx, cm, cf) + setupEnvAddHostedMocks(ctx, cm, cf) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { cm.API.AssertCalled( @@ -170,7 +105,7 @@ func Test_Env_AddCommand(t *testing.T) { "provide a variable name by argument and value by prompt": { CmdArgs: []string{"ENV_NAME"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - setupEnvAddCommandMocks(ctx, cm, cf) + setupEnvAddHostedMocks(ctx, cm, cf) cm.IO.On( "PasswordPrompt", mock.Anything, @@ -201,7 +136,7 @@ func Test_Env_AddCommand(t *testing.T) { "provide a variable name by argument and value by flag": { CmdArgs: []string{"ENV_NAME", "--value", "example_value"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - setupEnvAddCommandMocks(ctx, cm, cf) + setupEnvAddHostedMocks(ctx, cm, cf) cm.IO.On( "PasswordPrompt", mock.Anything, @@ -232,7 +167,7 @@ func Test_Env_AddCommand(t *testing.T) { "provide both variable name and value by prompt": { CmdArgs: []string{}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - setupEnvAddCommandMocks(ctx, cm, cf) + setupEnvAddHostedMocks(ctx, cm, cf) cm.IO.On( "InputPrompt", mock.Anything, @@ -269,6 +204,55 @@ func Test_Env_AddCommand(t *testing.T) { ) }, }, + "add a numeric variable using prompts to the .env file": { + CmdArgs: []string{}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupEnvAddDotenvMocks(ctx, cm, cf) + cm.IO.On( + "InputPrompt", + mock.Anything, + "Variable name", + mock.Anything, + ).Return( + "PORT", + nil, + ) + cm.IO.On( + "PasswordPrompt", + mock.Anything, + "Variable value", + iostreams.MatchPromptConfig(iostreams.PasswordPromptConfig{ + Flag: cm.Config.Flags.Lookup("value"), + }), + ).Return( + iostreams.PasswordPromptResponse{ + Prompt: true, + Value: "3000", + }, + nil, + ) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "AddVariable") + content, err := afero.ReadFile(cm.Fs, ".env") + assert.NoError(t, err) + assert.Equal(t, "PORT=3000\n", string(content)) + }, + }, + "add a variable to the .env file for non-hosted app": { + CmdArgs: []string{"NEW_VAR", "new_value"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupEnvAddDotenvMocks(ctx, cm, cf) + err := afero.WriteFile(cf.Fs, ".env", []byte("# Config\nEXISTING=value\n"), 0600) + assert.NoError(t, err) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "AddVariable") + content, err := afero.ReadFile(cm.Fs, ".env") + assert.NoError(t, err) + assert.Equal(t, "# Config\nEXISTING=value\nNEW_VAR=\"new_value\"\n", string(content)) + }, + }, }, func(cf *shared.ClientFactory) *cobra.Command { cmd := NewEnvAddCommand(cf) cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil } @@ -276,17 +260,52 @@ func Test_Env_AddCommand(t *testing.T) { }) } -// setupEnvAddCommandMocks prepares common mocks for these tests -func setupEnvAddCommandMocks(ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { +// setupEnvAddHostedMocks prepares common mocks for hosted app tests +func setupEnvAddHostedMocks(ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cf.SDKConfig = hooks.NewSDKConfigMock() cm.AddDefaultMocks() _ = cf.AppClient().SaveDeployed(ctx, mockApp) appSelectMock := prompts.NewAppSelectMock() appSelectPromptFunc = appSelectMock.AppSelectPrompt - appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowHostedOnly, prompts.ShowInstalledAppsOnly).Return(prompts.SelectedApp{Auth: mockAuth, App: mockApp}, nil) + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly).Return(prompts.SelectedApp{Auth: mockAuth, App: mockApp}, nil) cm.Config.Flags.String("value", "", "mock value flag") cm.API.On("AddVariable", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return( + types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: types.SlackHosted, + }, + }, + }, + nil, + ) + cm.AppClient.Manifest = manifestMock + projectConfigMock := config.NewProjectConfigMock() + projectConfigMock.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil) + cm.Config.ProjectConfig = projectConfigMock + cf.SDKConfig.WorkingDirectory = "/slack/path/to/project" +} + +// setupEnvAddDotenvMocks prepares common mocks for non-hosted (dotenv) app tests +func setupEnvAddDotenvMocks(_ context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cf.SDKConfig = hooks.NewSDKConfigMock() + cm.AddDefaultMocks() + + mockDevApp := types.App{ + TeamID: "T1", + TeamDomain: "team1", + AppID: "A0123456789", + IsDev: true, + } + appSelectMock := prompts.NewAppSelectMock() + appSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly).Return(prompts.SelectedApp{Auth: mockAuth, App: mockDevApp}, nil) + + cm.Config.Flags.String("value", "", "mock value flag") } diff --git a/cmd/env/env.go b/cmd/env/env.go index 78a93e10..abf6e9ea 100644 --- a/cmd/env/env.go +++ b/cmd/env/env.go @@ -36,11 +36,13 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { Aliases: []string{"var", "vars", "variable", "variables"}, Short: "Add, remove, or list environment variables", Long: strings.Join([]string{ - "Add, remove, or list environment variables for apps deployed to Slack managed", - "infrastructure.", + "Add, remove, or list environment variables for the app.", "", - "This command is supported for apps deployed to Slack managed infrastructure but", - "other apps can attempt to run the command with the --force flag.", + "Commands that run in the context of a project source environment variables from", + "the \".env\" file. This includes the \"run\" command.", + "", + "The \"deploy\" command gathers environment variables from the \".env\" file as well", + "unless the app is using ROSI features.", "", `Explore more: {{LinkText "https://docs.slack.dev/tools/slack-cli/guides/using-environment-variables-with-the-slack-cli"}}`, }, "\n"), diff --git a/internal/slackdotenv/slackdotenv.go b/internal/slackdotenv/slackdotenv.go index c85d58db..2cae1533 100644 --- a/internal/slackdotenv/slackdotenv.go +++ b/internal/slackdotenv/slackdotenv.go @@ -21,6 +21,8 @@ package slackdotenv import ( "os" + "regexp" + "strings" "github.com/joho/godotenv" "github.com/slackapi/slack-cli/internal/slackerror" @@ -49,3 +51,96 @@ func Read(fs afero.Fs) (map[string]string, error) { } return vars, nil } + +// Set sets a single environment variable in the .env file, preserving +// comments, blank lines, and other formatting. If the key already exists its +// value is replaced in-place. Otherwise the entry is appended. The file is +// created if it does not exist. +func Set(fs afero.Fs, name string, value string) error { + newEntry, err := godotenv.Marshal(map[string]string{name: value}) + if err != nil { + return slackerror.Wrap(err, slackerror.ErrDotEnvVarMarshal). + WithMessage("Failed to marshal the .env variable: %s", err) + } + + // Verify the marshaled entry can be parsed back to avoid writing values + // that would corrupt the .env file for future reads. + if _, err := godotenv.Unmarshal(newEntry); err != nil { + return slackerror.Wrap(err, slackerror.ErrDotEnvVarMarshal). + WithMessage("Failed to marshal the .env variable: %s", err) + } + + // Check for an existing .env file and parse it to detect existing keys. + existing, err := Read(fs) + if err != nil { + return err + } + + // If the file does not exist, create it with the new entry. + if existing == nil { + return writeFile(fs, []byte(newEntry+"\n")) + } + + // Read the raw file content once for either the append or replace path. + raw, err := afero.ReadFile(fs, ".env") + if err != nil { + return slackerror.Wrap(err, slackerror.ErrDotEnvFileRead). + WithMessage("Failed to read the .env file: %s", err) + } + content := string(raw) + + // If the key is new, append the entry. + _, found := existing[name] + if !found { + if len(content) > 0 && !strings.HasSuffix(content, "\n") { + content += "\n" + } + return writeFile(fs, []byte(content+newEntry+"\n")) + } + + // Build a regex that matches any form of the existing entry, allowing + // optional spaces around the equals sign and optional export prefix. + // The value portion matches to the end of the line, handling quoted + // (single, double, backtick) and unquoted values, including multiline + // double-quoted values with embedded newlines. + re := regexp.MustCompile( + `(?m)(^[^\S\n]*export[^\S\n]+|^[^\S\n]*)` + regexp.QuoteMeta(name) + `[^\S\n]*=[^\S\n]*` + + `(?:` + + `"(?:[^"\\]|\\.)*"` + // double-quoted (with escapes) + `|'[^']*'` + // single-quoted + "|`[^`]*`" + // backtick-quoted + `|(?:[^\s\n#]|\S#)*` + // unquoted: stop before inline comment (space + #) + `)` + + `([^\S\n]+#[^\n]*)?`, // optional inline comment + ) + + match := re.FindStringSubmatchIndex(content) + if match != nil { + prefix := "" + if strings.Contains(content[match[0]:match[1]], "export") { + prefix = "export " + } + comment := "" + if match[4] >= 0 { + comment = content[match[4]:match[5]] + } + content = content[:match[0]] + prefix + newEntry + comment + content[match[1]:] + } else { + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += newEntry + "\n" + } + return writeFile(fs, []byte(content)) +} + +// writeFile writes data to the .env file, wrapping any error with a structured +// error code. +func writeFile(fs afero.Fs, data []byte) error { + err := afero.WriteFile(fs, ".env", data, 0600) + if err != nil { + return slackerror.Wrap(err, slackerror.ErrDotEnvFileWrite). + WithMessage("Failed to write the .env file: %s", err) + } + return nil +} diff --git a/internal/slackdotenv/slackdotenv_test.go b/internal/slackdotenv/slackdotenv_test.go index 73eff8e1..56294283 100644 --- a/internal/slackdotenv/slackdotenv_test.go +++ b/internal/slackdotenv/slackdotenv_test.go @@ -111,3 +111,188 @@ func Test_Read(t *testing.T) { }) } } + +func Test_Set(t *testing.T) { + tests := map[string]struct { + existingEnv string + writeExisting bool + name string + value string + expectedFile string + expectErr string + }{ + "creates .env file when it does not exist": { + name: "FOO", + value: "bar", + expectedFile: "FOO=\"bar\"\n", + }, + "adds a variable to an empty .env file": { + existingEnv: "", + writeExisting: true, + name: "FOO", + value: "bar", + expectedFile: "FOO=\"bar\"\n", + }, + "adds a variable preserving existing variables": { + existingEnv: "EXISTING=value\n", + writeExisting: true, + name: "NEW_VAR", + value: "new_value", + expectedFile: "EXISTING=value\nNEW_VAR=\"new_value\"\n", + }, + "adds a variable preserving newline comments and blank lines": { + existingEnv: "# Database config\nDB_HOST=localhost\n\n# API keys\nAPI_KEY=secret\n", + writeExisting: true, + name: "NEW_VAR", + value: "new_value", + expectedFile: "# Database config\nDB_HOST=localhost\n\n# API keys\nAPI_KEY=secret\nNEW_VAR=\"new_value\"\n", + }, + "updates an existing unquoted variable in-place": { + existingEnv: "# Config\nFOO=old_value\nBAR=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "# Config\nFOO=\"new_value\"\nBAR=keep\n", + }, + "updates an existing quoted variable in-place": { + existingEnv: "FOO=\"old_value\"\nBAR=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "FOO=\"new_value\"\nBAR=keep\n", + }, + "updates a variable with export prefix": { + existingEnv: "export SECRET=old_secret\nOTHER=keep\n", + writeExisting: true, + name: "SECRET", + value: "new_secret", + expectedFile: "export SECRET=\"new_secret\"\nOTHER=keep\n", + }, + "escapes special characters in values": { + name: "SPECIAL", + value: "has \"quotes\" and $vars and \\ backslash", + expectedFile: "SPECIAL=\"has \\\"quotes\\\" and \\$vars and \\\\ backslash\"\n", + }, + "replaces a multiline value in-place": { + existingEnv: "export DB_KEY=\"---START---\npassword\n---END---\"\nOTHER=keep\n", + writeExisting: true, + name: "DB_KEY", + value: "new_key", + expectedFile: "export DB_KEY=\"new_key\"\nOTHER=keep\n", + }, + "returns error for value that cannot round-trip": { + name: "KEY", + value: `idk\`, + expectErr: slackerror.ErrDotEnvVarMarshal, + }, + "round-trips through Read": { + name: "ROUND_TRIP", + value: "hello world", + expectedFile: "ROUND_TRIP=\"hello world\"\n", + }, + "updates a variable with spaces around equals": { + existingEnv: "BEFORE=keep\nFOO = old_value\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nFOO=\"new_value\"\nAFTER=keep\n", + }, + "updates a variable with space before equals": { + existingEnv: "BEFORE=keep\nFOO =old_value\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nFOO=\"new_value\"\nAFTER=keep\n", + }, + "updates a variable with space after equals": { + existingEnv: "BEFORE=keep\nFOO= old_value\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nFOO=\"new_value\"\nAFTER=keep\n", + }, + "updates an existing empty value": { + existingEnv: "BEFORE=keep\nFOO=\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nFOO=\"new_value\"\nAFTER=keep\n", + }, + "updates an existing empty value with spaces": { + existingEnv: "BEFORE=keep\nFOO = \nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nFOO=\"new_value\"\nAFTER=keep\n", + }, + "updates export variable with spaces around equals": { + existingEnv: "BEFORE=keep\nexport FOO = old_value\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nexport FOO=\"new_value\"\nAFTER=keep\n", + }, + "updates a variable with leading spaces": { + existingEnv: "BEFORE=keep\n FOO=old_value\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nFOO=\"new_value\"\nAFTER=keep\n", + }, + "updates a variable with leading tab": { + existingEnv: "BEFORE=keep\n\tFOO=old_value\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nFOO=\"new_value\"\nAFTER=keep\n", + }, + "updates export variable with leading spaces": { + existingEnv: "BEFORE=keep\n export FOO=old_value\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nexport FOO=\"new_value\"\nAFTER=keep\n", + }, + "preserves inline comment on unquoted value": { + existingEnv: "BEFORE=keep\nFOO=old_value # important note\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nFOO=\"new_value\" # important note\nAFTER=keep\n", + }, + "preserves inline comment on quoted value": { + existingEnv: "BEFORE=keep\nFOO=\"old_value\" # important note\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nFOO=\"new_value\" # important note\nAFTER=keep\n", + }, + "preserves inline comment on export variable": { + existingEnv: "BEFORE=keep\nexport FOO=old_value # important note\nAFTER=keep\n", + writeExisting: true, + name: "FOO", + value: "new_value", + expectedFile: "BEFORE=keep\nexport FOO=\"new_value\" # important note\nAFTER=keep\n", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + if tc.writeExisting { + err := afero.WriteFile(fs, ".env", []byte(tc.existingEnv), 0600) + assert.NoError(t, err) + } + err := Set(fs, tc.name, tc.value) + if tc.expectErr != "" { + var slackErr *slackerror.Error + require.ErrorAs(t, err, &slackErr) + assert.Equal(t, tc.expectErr, slackErr.Code) + return + } + assert.NoError(t, err) + content, err := afero.ReadFile(fs, ".env") + assert.NoError(t, err) + assert.Equal(t, tc.expectedFile, string(content)) + }) + } +} diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 4b5b50a7..807eb16b 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -99,6 +99,8 @@ const ( ErrDocsSearchFlagRequired = "docs_search_flag_required" ErrDotEnvFileParse = "dotenv_file_parse_error" ErrDotEnvFileRead = "dotenv_file_read_error" + ErrDotEnvFileWrite = "dotenv_file_write_error" + ErrDotEnvVarMarshal = "dotenv_var_marshal_error" ErrEnterpriseNotFound = "enterprise_not_found" ErrFailedAddingCollaborator = "failed_adding_collaborator" ErrFailedCreatingApp = "failed_creating_app" @@ -704,6 +706,16 @@ Otherwise start your app for local development with: %s`, Message: "Failed to read the .env file", }, + ErrDotEnvFileWrite: { + Code: ErrDotEnvFileWrite, + Message: "Failed to write the .env file", + }, + + ErrDotEnvVarMarshal: { + Code: ErrDotEnvVarMarshal, + Message: "Failed to marshal the .env variable", + }, + ErrEnterpriseNotFound: { Code: ErrEnterpriseNotFound, Message: "The `enterprise` was not found",