Skip to content

Commit 5aa11dd

Browse files
zimegmwbrooks
andauthored
feat: add env init command that copies from template placeholders (#516)
Co-authored-by: Michael Brooks <mbrooks@slack-corp.com>
1 parent cca9a20 commit 5aa11dd

8 files changed

Lines changed: 453 additions & 3 deletions

File tree

cmd/env/env.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
4848
`Explore more: {{LinkText "https://docs.slack.dev/tools/slack-cli/guides/using-environment-variables-with-the-slack-cli"}}`,
4949
}, "\n"),
5050
Example: style.ExampleCommandsf([]style.ExampleCommand{
51+
{
52+
Meaning: "Initialize environment variables from a template file",
53+
Command: "env init",
54+
},
5155
{
5256
Meaning: "Set an environment variable",
5357
Command: "env set MAGIC_PASSWORD abracadbra",
@@ -67,6 +71,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
6771
}
6872

6973
// Add child commands
74+
cmd.AddCommand(NewEnvInitCommand(clients))
7075
cmd.AddCommand(NewEnvSetCommand(clients))
7176
cmd.AddCommand(NewEnvListCommand(clients))
7277
cmd.AddCommand(NewEnvUnsetCommand(clients))

cmd/env/env_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ func Test_Env_Command(t *testing.T) {
3232
testutil.TableTestCommand(t, testutil.CommandTests{
3333
"shows the help page without commands or arguments or flags": {
3434
ExpectedStdoutOutputs: []string{
35+
"Initialize environment variables from a template file",
3536
"Set an environment variable",
3637
"List all environment variables",
3738
"Unset an environment variable",

cmd/env/init.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package env
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"strings"
21+
22+
"github.com/slackapi/slack-cli/internal/cmdutil"
23+
"github.com/slackapi/slack-cli/internal/prompts"
24+
"github.com/slackapi/slack-cli/internal/shared"
25+
"github.com/slackapi/slack-cli/internal/slackdotenv"
26+
"github.com/slackapi/slack-cli/internal/slackerror"
27+
"github.com/slackapi/slack-cli/internal/slacktrace"
28+
"github.com/slackapi/slack-cli/internal/style"
29+
"github.com/spf13/cobra"
30+
)
31+
32+
func NewEnvInitCommand(clients *shared.ClientFactory) *cobra.Command {
33+
cmd := &cobra.Command{
34+
Use: "init",
35+
Short: "Initialize environment variables from a template file",
36+
Long: strings.Join([]string{
37+
`Initialize the project ".env" file by copying from an ".env" template file.`,
38+
"",
39+
`Copies content from either the ".env.sample" or ".env.example" file to the`,
40+
`project ".env" file if those project environment variables don't already exist.`,
41+
"",
42+
fmt.Sprintf("Apps using ROSI features should set environment variables with %s.", style.Commandf("env set", false)),
43+
}, "\n"),
44+
Example: style.ExampleCommandsf([]style.ExampleCommand{
45+
{
46+
Meaning: "Initialize environment variables from a template file",
47+
Command: "env init",
48+
},
49+
}),
50+
PreRunE: func(cmd *cobra.Command, args []string) error {
51+
ctx := cmd.Context()
52+
return preRunEnvInitCommandFunc(ctx, clients)
53+
},
54+
RunE: func(cmd *cobra.Command, args []string) error {
55+
return runEnvInitCommandFunc(clients, cmd)
56+
},
57+
}
58+
59+
return cmd
60+
}
61+
62+
// preRunEnvInitCommandFunc determines if the command is run in a valid project
63+
func preRunEnvInitCommandFunc(_ context.Context, clients *shared.ClientFactory) error {
64+
return cmdutil.IsValidProjectDirectory(clients)
65+
}
66+
67+
// runEnvInitCommandFunc copies a sample .env file to .env
68+
func runEnvInitCommandFunc(clients *shared.ClientFactory, cmd *cobra.Command) error {
69+
ctx := cmd.Context()
70+
71+
// Hosted apps manage environment variables through the API, not .env files.
72+
hosted := isHostedRuntime(ctx, clients)
73+
if hosted {
74+
selection, err := appSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly)
75+
if err != nil {
76+
return err
77+
}
78+
if !selection.App.IsDev {
79+
clients.IO.PrintTrace(ctx, slacktrace.EnvInitSuccess)
80+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
81+
Emoji: "evergreen_tree",
82+
Text: "Environment Initialize",
83+
Secondary: []string{
84+
fmt.Sprintf("Set environment variables for apps using ROSI features with %s", style.Commandf("env set", false)),
85+
},
86+
}))
87+
return nil
88+
}
89+
}
90+
91+
source, err := slackdotenv.Init(clients.Fs)
92+
if err != nil {
93+
switch slackerror.ToSlackError(err).Code {
94+
case slackerror.ErrDotEnvFileAlreadyExists:
95+
clients.IO.PrintTrace(ctx, slacktrace.EnvInitSuccess)
96+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
97+
Emoji: "evergreen_tree",
98+
Text: "Environment Initialize",
99+
Secondary: []string{
100+
`A project ".env" file already exists and was left unchanged`,
101+
fmt.Sprintf("Set environment variables with %s", style.Commandf("env set", false)),
102+
},
103+
}))
104+
return nil
105+
case slackerror.ErrDotEnvPlaceholderNotFound:
106+
clients.IO.PrintTrace(ctx, slacktrace.EnvInitSuccess)
107+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
108+
Emoji: "evergreen_tree",
109+
Text: "Environment Initialize",
110+
Secondary: []string{
111+
`No ".env" template file was found for environment variables in this project`,
112+
fmt.Sprintf("Set environment variables with %s", style.Commandf("env set", false)),
113+
},
114+
}))
115+
return nil
116+
default:
117+
return err
118+
}
119+
}
120+
121+
clients.IO.PrintTrace(ctx, slacktrace.EnvInitSuccess)
122+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
123+
Emoji: "evergreen_tree",
124+
Text: "Environment Initialize",
125+
Secondary: []string{
126+
fmt.Sprintf(`Environment variables were copied from "%s" to a project ".env" file`, source),
127+
`This new ".env" file shouldn't be added to version control`,
128+
},
129+
}))
130+
return nil
131+
}

cmd/env/init_test.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package env
16+
17+
import (
18+
"context"
19+
"testing"
20+
21+
"github.com/slackapi/slack-cli/internal/app"
22+
"github.com/slackapi/slack-cli/internal/hooks"
23+
"github.com/slackapi/slack-cli/internal/prompts"
24+
"github.com/slackapi/slack-cli/internal/shared"
25+
"github.com/slackapi/slack-cli/internal/shared/types"
26+
"github.com/slackapi/slack-cli/internal/slackerror"
27+
"github.com/slackapi/slack-cli/internal/slacktrace"
28+
"github.com/slackapi/slack-cli/test/testutil"
29+
"github.com/spf13/afero"
30+
"github.com/spf13/cobra"
31+
"github.com/stretchr/testify/assert"
32+
"github.com/stretchr/testify/mock"
33+
)
34+
35+
func Test_Env_InitCommandPreRun(t *testing.T) {
36+
tests := map[string]struct {
37+
mockWorkingDirectory string
38+
expectedError error
39+
}{
40+
"continues if the command is run in a project": {
41+
mockWorkingDirectory: "/slack/path/to/project",
42+
expectedError: nil,
43+
},
44+
"errors if the command is not run in a project": {
45+
mockWorkingDirectory: "",
46+
expectedError: slackerror.New(slackerror.ErrInvalidAppDirectory),
47+
},
48+
}
49+
for name, tc := range tests {
50+
t.Run(name, func(t *testing.T) {
51+
clientsMock := shared.NewClientsMock()
52+
clients := shared.NewClientFactory(clientsMock.MockClientFactory(), func(cf *shared.ClientFactory) {
53+
cf.SDKConfig.WorkingDirectory = tc.mockWorkingDirectory
54+
})
55+
cmd := NewEnvInitCommand(clients)
56+
err := cmd.PreRunE(cmd, nil)
57+
if tc.expectedError != nil {
58+
assert.Equal(t, slackerror.ToSlackError(tc.expectedError).Code, slackerror.ToSlackError(err).Code)
59+
} else {
60+
assert.NoError(t, err)
61+
}
62+
})
63+
}
64+
}
65+
66+
func Test_Env_InitCommand(t *testing.T) {
67+
testutil.TableTestCommand(t, testutil.CommandTests{
68+
"copies .env.sample to .env": {
69+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
70+
cm.AddDefaultMocks()
71+
manifestMock := &app.ManifestMockObject{}
72+
manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{}, slackerror.New(slackerror.ErrSDKHookNotFound))
73+
cm.AppClient.Manifest = manifestMock
74+
err := afero.WriteFile(cf.Fs, ".env.sample", []byte("SECRET=placeholder\n"), 0600)
75+
assert.NoError(t, err)
76+
},
77+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
78+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything)
79+
content, err := afero.ReadFile(cm.Fs, ".env")
80+
assert.NoError(t, err)
81+
assert.Equal(t, "SECRET=placeholder\n", string(content))
82+
},
83+
},
84+
"copies .env.example to .env": {
85+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
86+
cm.AddDefaultMocks()
87+
manifestMock := &app.ManifestMockObject{}
88+
manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{}, slackerror.New(slackerror.ErrSDKHookNotFound))
89+
cm.AppClient.Manifest = manifestMock
90+
err := afero.WriteFile(cf.Fs, ".env.example", []byte("TOKEN=example\n"), 0600)
91+
assert.NoError(t, err)
92+
},
93+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
94+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything)
95+
content, err := afero.ReadFile(cm.Fs, ".env")
96+
assert.NoError(t, err)
97+
assert.Equal(t, "TOKEN=example\n", string(content))
98+
},
99+
},
100+
"prints message when .env already exists": {
101+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
102+
cm.AddDefaultMocks()
103+
manifestMock := &app.ManifestMockObject{}
104+
manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{}, slackerror.New(slackerror.ErrSDKHookNotFound))
105+
cm.AppClient.Manifest = manifestMock
106+
err := afero.WriteFile(cf.Fs, ".env", []byte("EXISTING=value\n"), 0600)
107+
assert.NoError(t, err)
108+
err = afero.WriteFile(cf.Fs, ".env.sample", []byte("NEW=value\n"), 0600)
109+
assert.NoError(t, err)
110+
},
111+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
112+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything)
113+
content, err := afero.ReadFile(cm.Fs, ".env")
114+
assert.NoError(t, err)
115+
assert.Equal(t, "EXISTING=value\n", string(content))
116+
},
117+
},
118+
"prints message when no sample file exists": {
119+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
120+
cm.AddDefaultMocks()
121+
manifestMock := &app.ManifestMockObject{}
122+
manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{}, slackerror.New(slackerror.ErrSDKHookNotFound))
123+
cm.AppClient.Manifest = manifestMock
124+
},
125+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
126+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything)
127+
},
128+
},
129+
"prints ROSI message for hosted non-dev app": {
130+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
131+
cf.SDKConfig = hooks.NewSDKConfigMock()
132+
cm.AddDefaultMocks()
133+
_ = cf.AppClient().SaveDeployed(ctx, mockApp)
134+
135+
appSelectMock := prompts.NewAppSelectMock()
136+
appSelectPromptFunc = appSelectMock.AppSelectPrompt
137+
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly).Return(prompts.SelectedApp{Auth: mockAuth, App: mockApp}, nil)
138+
139+
manifestMock := &app.ManifestMockObject{}
140+
manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(
141+
types.SlackYaml{
142+
AppManifest: types.AppManifest{
143+
Settings: &types.AppSettings{
144+
FunctionRuntime: types.SlackHosted,
145+
},
146+
},
147+
},
148+
nil,
149+
)
150+
cm.AppClient.Manifest = manifestMock
151+
},
152+
ExpectedStdoutOutputs: []string{
153+
"ROSI features",
154+
"env set",
155+
},
156+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
157+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything)
158+
_, err := afero.ReadFile(cm.Fs, ".env")
159+
assert.Error(t, err)
160+
},
161+
},
162+
"copies placeholder for hosted dev app": {
163+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
164+
cf.SDKConfig = hooks.NewSDKConfigMock()
165+
cm.AddDefaultMocks()
166+
167+
devApp := types.App{
168+
TeamID: "T1",
169+
TeamDomain: "team1",
170+
AppID: "A0123456789",
171+
IsDev: true,
172+
}
173+
appSelectMock := prompts.NewAppSelectMock()
174+
appSelectPromptFunc = appSelectMock.AppSelectPrompt
175+
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly).Return(prompts.SelectedApp{Auth: mockAuth, App: devApp}, nil)
176+
177+
manifestMock := &app.ManifestMockObject{}
178+
manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(
179+
types.SlackYaml{
180+
AppManifest: types.AppManifest{
181+
Settings: &types.AppSettings{
182+
FunctionRuntime: types.SlackHosted,
183+
},
184+
},
185+
},
186+
nil,
187+
)
188+
cm.AppClient.Manifest = manifestMock
189+
190+
err := afero.WriteFile(cf.Fs, ".env.sample", []byte("SECRET=placeholder\n"), 0600)
191+
assert.NoError(t, err)
192+
},
193+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
194+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.EnvInitSuccess, mock.Anything)
195+
content, err := afero.ReadFile(cm.Fs, ".env")
196+
assert.NoError(t, err)
197+
assert.Equal(t, "SECRET=placeholder\n", string(content))
198+
},
199+
},
200+
}, func(cf *shared.ClientFactory) *cobra.Command {
201+
cmd := NewEnvInitCommand(cf)
202+
cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil }
203+
return cmd
204+
})
205+
}

0 commit comments

Comments
 (0)