From 79fda9ec2e4a633ab1e7d212a8f023b1ed3e23d2 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 29 Apr 2026 20:55:42 -0700 Subject: [PATCH] feat: add env init command that copies from template placeholders --- cmd/env/env.go | 5 + cmd/env/env_test.go | 1 + cmd/env/init.go | 131 +++++++++++++++ cmd/env/init_test.go | 205 +++++++++++++++++++++++ internal/slackdotenv/slackdotenv.go | 37 ++++ internal/slackdotenv/slackdotenv_test.go | 65 ++++++- internal/slackerror/errors.go | 12 ++ internal/slacktrace/slacktrace.go | 1 + 8 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 cmd/env/init.go create mode 100644 cmd/env/init_test.go diff --git a/cmd/env/env.go b/cmd/env/env.go index db556872..217df0c8 100644 --- a/cmd/env/env.go +++ b/cmd/env/env.go @@ -48,6 +48,10 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { `Explore more: {{LinkText "https://docs.slack.dev/tools/slack-cli/guides/using-environment-variables-with-the-slack-cli"}}`, }, "\n"), Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Initialize environment variables from template placeholders", + Command: "env init", + }, { Meaning: "Set an environment variable", Command: "env set MAGIC_PASSWORD abracadbra", @@ -67,6 +71,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { } // Add child commands + cmd.AddCommand(NewEnvInitCommand(clients)) cmd.AddCommand(NewEnvSetCommand(clients)) cmd.AddCommand(NewEnvListCommand(clients)) cmd.AddCommand(NewEnvUnsetCommand(clients)) diff --git a/cmd/env/env_test.go b/cmd/env/env_test.go index 03daf244..7499f1ed 100644 --- a/cmd/env/env_test.go +++ b/cmd/env/env_test.go @@ -32,6 +32,7 @@ func Test_Env_Command(t *testing.T) { testutil.TableTestCommand(t, testutil.CommandTests{ "shows the help page without commands or arguments or flags": { ExpectedStdoutOutputs: []string{ + "Initialize environment variables from placeholders", "Set an environment variable", "List all environment variables", "Unset an environment variable", diff --git a/cmd/env/init.go b/cmd/env/init.go new file mode 100644 index 00000000..8ba967de --- /dev/null +++ b/cmd/env/init.go @@ -0,0 +1,131 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package env + +import ( + "context" + "fmt" + "strings" + + "github.com/slackapi/slack-cli/internal/cmdutil" + "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/slackerror" + "github.com/slackapi/slack-cli/internal/slacktrace" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +func NewEnvInitCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize environment variables from placeholders", + Long: strings.Join([]string{ + `Initialize the project ".env" file by copying from a template placeholder file.`, + "", + `Copies content from either the ".env.sample" or ".env.example" file to the`, + `project ".env" file if those project environment variables don't already exist.`, + "", + fmt.Sprintf("Apps using ROSI features should set environment variables with %s.", style.Commandf("env set", false)), + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Initialize environment variables from template placeholders", + Command: "env init", + }, + }), + PreRunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + return preRunEnvInitCommandFunc(ctx, clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runEnvInitCommandFunc(clients, cmd) + }, + } + + return cmd +} + +// preRunEnvInitCommandFunc determines if the command is run in a valid project +func preRunEnvInitCommandFunc(_ context.Context, clients *shared.ClientFactory) error { + return cmdutil.IsValidProjectDirectory(clients) +} + +// runEnvInitCommandFunc copies a sample .env file to .env +func runEnvInitCommandFunc(clients *shared.ClientFactory, cmd *cobra.Command) error { + ctx := cmd.Context() + + // Hosted apps manage environment variables through the API, not .env files. + hosted := isHostedRuntime(ctx, clients) + if hosted { + selection, err := appSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) + if err != nil { + return err + } + if !selection.App.IsDev { + clients.IO.PrintTrace(ctx, slacktrace.EnvInitSuccess) + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "evergreen_tree", + Text: "Environment Initialize", + Secondary: []string{ + fmt.Sprintf("Set environment variables for apps using ROSI features with %s", style.Commandf("env set", false)), + }, + })) + return nil + } + } + + source, err := slackdotenv.Init(clients.Fs) + if err != nil { + switch slackerror.ToSlackError(err).Code { + case slackerror.ErrDotEnvFileAlreadyExists: + clients.IO.PrintTrace(ctx, slacktrace.EnvInitSuccess) + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "evergreen_tree", + Text: "Environment Initialize", + Secondary: []string{ + `A project ".env" file already exists and was left unchanged`, + fmt.Sprintf("Set environment variables with %s", style.Commandf("env set", false)), + }, + })) + return nil + case slackerror.ErrDotEnvPlaceholderNotFound: + clients.IO.PrintTrace(ctx, slacktrace.EnvInitSuccess) + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "evergreen_tree", + Text: "Environment Initialize", + Secondary: []string{ + `No template placeholder was found for environment variables in this project`, + fmt.Sprintf("Set environment variables with %s", style.Commandf("env set", false)), + }, + })) + return nil + default: + return err + } + } + + clients.IO.PrintTrace(ctx, slacktrace.EnvInitSuccess) + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "evergreen_tree", + Text: "Environment Initialize", + Secondary: []string{ + fmt.Sprintf(`Placeholders were copied from "%s" to a project ".env" file`, source), + `This new ".env" file shouldn't be added to version control`, + }, + })) + return nil +} diff --git a/cmd/env/init_test.go b/cmd/env/init_test.go new file mode 100644 index 00000000..fde9d913 --- /dev/null +++ b/cmd/env/init_test.go @@ -0,0 +1,205 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package env + +import ( + "context" + "testing" + + "github.com/slackapi/slack-cli/internal/app" + "github.com/slackapi/slack-cli/internal/hooks" + "github.com/slackapi/slack-cli/internal/prompts" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "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" +) + +func Test_Env_InitCommandPreRun(t *testing.T) { + tests := map[string]struct { + mockWorkingDirectory string + expectedError error + }{ + "continues if the command is run in a project": { + mockWorkingDirectory: "/slack/path/to/project", + expectedError: nil, + }, + "errors if the command is not run in a project": { + mockWorkingDirectory: "", + expectedError: slackerror.New(slackerror.ErrInvalidAppDirectory), + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + clientsMock := shared.NewClientsMock() + clients := shared.NewClientFactory(clientsMock.MockClientFactory(), func(cf *shared.ClientFactory) { + cf.SDKConfig.WorkingDirectory = tc.mockWorkingDirectory + }) + cmd := NewEnvInitCommand(clients) + err := cmd.PreRunE(cmd, nil) + if tc.expectedError != nil { + assert.Equal(t, slackerror.ToSlackError(tc.expectedError).Code, slackerror.ToSlackError(err).Code) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_Env_InitCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "copies .env.sample to .env": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{}, slackerror.New(slackerror.ErrSDKHookNotFound)) + cm.AppClient.Manifest = manifestMock + err := afero.WriteFile(cf.Fs, ".env.sample", []byte("SECRET=placeholder\n"), 0600) + assert.NoError(t, err) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything) + content, err := afero.ReadFile(cm.Fs, ".env") + assert.NoError(t, err) + assert.Equal(t, "SECRET=placeholder\n", string(content)) + }, + }, + "copies .env.example to .env": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{}, slackerror.New(slackerror.ErrSDKHookNotFound)) + cm.AppClient.Manifest = manifestMock + err := afero.WriteFile(cf.Fs, ".env.example", []byte("TOKEN=example\n"), 0600) + assert.NoError(t, err) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything) + content, err := afero.ReadFile(cm.Fs, ".env") + assert.NoError(t, err) + assert.Equal(t, "TOKEN=example\n", string(content)) + }, + }, + "prints message when .env already exists": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{}, slackerror.New(slackerror.ErrSDKHookNotFound)) + cm.AppClient.Manifest = manifestMock + err := afero.WriteFile(cf.Fs, ".env", []byte("EXISTING=value\n"), 0600) + assert.NoError(t, err) + err = afero.WriteFile(cf.Fs, ".env.sample", []byte("NEW=value\n"), 0600) + assert.NoError(t, err) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything) + content, err := afero.ReadFile(cm.Fs, ".env") + assert.NoError(t, err) + assert.Equal(t, "EXISTING=value\n", string(content)) + }, + }, + "prints message when no sample file exists": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{}, slackerror.New(slackerror.ErrSDKHookNotFound)) + cm.AppClient.Manifest = manifestMock + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything) + }, + }, + "prints ROSI message for hosted non-dev app": { + Setup: func(t *testing.T, 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.ShowAllEnvironments, prompts.ShowInstalledAppsOnly).Return(prompts.SelectedApp{Auth: mockAuth, App: mockApp}, 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 + }, + ExpectedStdoutOutputs: []string{ + "ROSI features", + "env set", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything) + _, err := afero.ReadFile(cm.Fs, ".env") + assert.Error(t, err) + }, + }, + "copies placeholder for hosted dev app": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cf.SDKConfig = hooks.NewSDKConfigMock() + cm.AddDefaultMocks() + + devApp := 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: devApp}, 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 + + err := afero.WriteFile(cf.Fs, ".env.sample", []byte("SECRET=placeholder\n"), 0600) + assert.NoError(t, err) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything) + content, err := afero.ReadFile(cm.Fs, ".env") + assert.NoError(t, err) + assert.Equal(t, "SECRET=placeholder\n", string(content)) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + cmd := NewEnvInitCommand(cf) + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil } + return cmd + }) +} diff --git a/internal/slackdotenv/slackdotenv.go b/internal/slackdotenv/slackdotenv.go index 060625b4..4a3f3f18 100644 --- a/internal/slackdotenv/slackdotenv.go +++ b/internal/slackdotenv/slackdotenv.go @@ -29,6 +29,43 @@ import ( "github.com/spf13/afero" ) +// Init copies a template placeholder file (.env.sample or .env.example) to +// .env. It returns an error if .env already exists, or if no placeholder file +// is found. +func Init(fs afero.Fs) (string, error) { + sampleFiles := []string{".env.sample", ".env.example"} + + exists, err := afero.Exists(fs, ".env") + if err != nil { + return "", slackerror.Wrap(err, slackerror.ErrDotEnvFileRead). + WithMessage("Failed to read the .env file: %s", err) + } + if exists { + return "", slackerror.New(slackerror.ErrDotEnvFileAlreadyExists) + } + + for _, name := range sampleFiles { + data, err := afero.ReadFile(fs, name) + if err != nil { + if os.IsNotExist(err) { + continue + } + return "", slackerror.Wrap(err, slackerror.ErrDotEnvFileRead). + WithMessage("Failed to read the %s file: %s", name, err) + } + if _, err := godotenv.UnmarshalBytes(data); err != nil { + return "", slackerror.Wrap(err, slackerror.ErrDotEnvFileParse). + WithMessage("Failed to parse the %s file", name) + } + if err := writeFile(fs, data); err != nil { + return "", err + } + return name, nil + } + + return "", slackerror.New(slackerror.ErrDotEnvPlaceholderNotFound) +} + // Read parses a .env file from the working directory using the provided // filesystem. It returns nil if the filesystem is nil or the file does not // exist. diff --git a/internal/slackdotenv/slackdotenv_test.go b/internal/slackdotenv/slackdotenv_test.go index 00d0e286..1b2e79b8 100644 --- a/internal/slackdotenv/slackdotenv_test.go +++ b/internal/slackdotenv/slackdotenv_test.go @@ -24,6 +24,66 @@ import ( "github.com/stretchr/testify/require" ) +func Test_Init(t *testing.T) { + tests := map[string]struct { + files map[string]string + expected string + expectedEnv string + expectErr string + }{ + "copies .env.sample to .env": { + files: map[string]string{".env.sample": "FOO=bar\n"}, + expected: ".env.sample", + expectedEnv: "FOO=bar\n", + }, + "copies .env.example to .env": { + files: map[string]string{".env.example": "BAZ=qux\n"}, + expected: ".env.example", + expectedEnv: "BAZ=qux\n", + }, + "prefers .env.sample over .env.example": { + files: map[string]string{ + ".env.sample": "FROM_SAMPLE=1\n", + ".env.example": "FROM_EXAMPLE=1\n", + }, + expected: ".env.sample", + expectedEnv: "FROM_SAMPLE=1\n", + }, + "returns error when .env already exists": { + files: map[string]string{".env": "EXISTING=value\n"}, + expectErr: slackerror.ErrDotEnvFileAlreadyExists, + }, + "returns error when placeholder file cannot be parsed": { + files: map[string]string{".env.sample": "INVALID LINE WITHOUT EQUALS\n"}, + expectErr: slackerror.ErrDotEnvFileParse, + }, + "returns error when no sample file exists": { + files: map[string]string{}, + expectErr: slackerror.ErrDotEnvPlaceholderNotFound, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + for path, content := range tc.files { + err := afero.WriteFile(fs, path, []byte(content), 0600) + require.NoError(t, err) + } + source, err := Init(fs) + if tc.expectErr != "" { + require.Error(t, err) + assert.Equal(t, tc.expectErr, slackerror.ToSlackError(err).Code) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expected, source) + content, err := afero.ReadFile(fs, ".env") + assert.NoError(t, err) + assert.Equal(t, tc.expectedEnv, string(content)) + }) + } +} + func Test_Read(t *testing.T) { tests := map[string]struct { fs afero.Fs @@ -285,9 +345,8 @@ func Test_Set(t *testing.T) { } 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) + require.Error(t, err) + assert.Equal(t, tc.expectErr, slackerror.ToSlackError(err).Code) return } assert.NoError(t, err) diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 86de955a..87bf4842 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -96,9 +96,11 @@ const ( ErrDenoNotFound = "deno_not_found" ErrDeployedAppNotSupported = "deployed_app_not_supported" ErrDocumentationGenerationFailed = "documentation_generation_failed" + ErrDotEnvFileAlreadyExists = "dotenv_file_already_exists" ErrDotEnvFileParse = "dotenv_file_parse_error" ErrDotEnvFileRead = "dotenv_file_read_error" ErrDotEnvFileWrite = "dotenv_file_write_error" + ErrDotEnvPlaceholderNotFound = "dotenv_placeholder_not_found" ErrDotEnvVarMarshal = "dotenv_var_marshal_error" ErrEnterpriseNotFound = "enterprise_not_found" ErrFailedAddingCollaborator = "failed_adding_collaborator" @@ -689,6 +691,11 @@ Otherwise start your app for local development with: %s`, Message: "Failed to generate documentation", }, + ErrDotEnvFileAlreadyExists: { + Code: ErrDotEnvFileAlreadyExists, + Message: "A .env file already exists", + }, + ErrDotEnvFileParse: { Code: ErrDotEnvFileParse, Message: "Failed to parse the .env file", @@ -704,6 +711,11 @@ Otherwise start your app for local development with: %s`, Message: "Failed to write the .env file", }, + ErrDotEnvPlaceholderNotFound: { + Code: ErrDotEnvPlaceholderNotFound, + Message: "No template placeholder was found for environment variables", + }, + ErrDotEnvVarMarshal: { Code: ErrDotEnvVarMarshal, Message: "Failed to marshal the .env variable", diff --git a/internal/slacktrace/slacktrace.go b/internal/slacktrace/slacktrace.go index a966b20d..9ebc08ec 100644 --- a/internal/slacktrace/slacktrace.go +++ b/internal/slacktrace/slacktrace.go @@ -76,6 +76,7 @@ const ( DatastoreCountTotal = "SLACK_TRACE_DATASTORE_COUNT_TOTAL" DocsSearchSuccess = "SLACK_TRACE_DOCS_SEARCH_SUCCESS" DocsSuccess = "SLACK_TRACE_DOCS_SUCCESS" + EnvInitSuccess = "SLACK_TRACE_ENV_INIT_SUCCESS" EnvListCount = "SLACK_TRACE_ENV_LIST_COUNT" EnvListVariables = "SLACK_TRACE_ENV_LIST_VARIABLES" EnvSetSuccess = "SLACK_TRACE_ENV_SET_SUCCESS"