Skip to content

Commit 8241c8d

Browse files
zimegmwbrooks
andauthored
feat: env add command supports the ".env" file for Bolt Framework apps (#451)
Co-authored-by: Michael Brooks <mbrooks@slack-corp.com>
1 parent 3621ff8 commit 8241c8d

File tree

7 files changed

+446
-114
lines changed

7 files changed

+446
-114
lines changed

.claude/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"Bash(git log:*)",
1717
"Bash(git status:*)",
1818
"Bash(go build:*)",
19+
"Bash(go doc:*)",
1920
"Bash(go mod graph:*)",
2021
"Bash(go mod tidy:*)",
2122
"Bash(go mod tidy:*)",

cmd/env/add.go

Lines changed: 54 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,27 @@ import (
2323
"github.com/slackapi/slack-cli/internal/iostreams"
2424
"github.com/slackapi/slack-cli/internal/prompts"
2525
"github.com/slackapi/slack-cli/internal/shared"
26+
"github.com/slackapi/slack-cli/internal/slackdotenv"
2627
"github.com/slackapi/slack-cli/internal/slacktrace"
2728
"github.com/slackapi/slack-cli/internal/style"
29+
"github.com/spf13/afero"
2830
"github.com/spf13/cobra"
2931
)
3032

3133
func NewEnvAddCommand(clients *shared.ClientFactory) *cobra.Command {
3234
cmd := &cobra.Command{
33-
Use: "add <name> <value> [flags]",
34-
Short: "Add an environment variable to the app",
35+
Use: "add <name> [value] [flags]",
36+
Short: "Add an environment variable to the project",
3537
Long: strings.Join([]string{
36-
"Add an environment variable to an app deployed to Slack managed infrastructure.",
38+
"Add an environment variable to the project.",
3739
"",
3840
"If a name or value is not provided, you will be prompted to provide these.",
3941
"",
40-
"This command is supported for apps deployed to Slack managed infrastructure but",
41-
"other apps can attempt to run the command with the --force flag.",
42+
"Commands that run in the context of a project source environment variables from",
43+
`the ".env" file. This includes the "run" command.`,
44+
"",
45+
`The "deploy" command gathers environment variables from the ".env" file as well`,
46+
"unless the app is using ROSI features.",
4247
}, "\n"),
4348
Example: style.ExampleCommandsf([]style.ExampleCommand{
4449
{
@@ -69,26 +74,19 @@ func NewEnvAddCommand(clients *shared.ClientFactory) *cobra.Command {
6974
return cmd
7075
}
7176

72-
// preRunEnvAddCommandFunc determines if the command is supported for a project
77+
// preRunEnvAddCommandFunc determines if the command is run in a valid project
7378
// and configures flags
7479
func preRunEnvAddCommandFunc(ctx context.Context, clients *shared.ClientFactory, cmd *cobra.Command) error {
7580
clients.Config.SetFlags(cmd)
76-
err := cmdutil.IsValidProjectDirectory(clients)
77-
if err != nil {
78-
return err
79-
}
80-
if clients.Config.ForceFlag {
81-
return nil
82-
}
83-
return cmdutil.IsSlackHostedProject(ctx, clients)
81+
return cmdutil.IsValidProjectDirectory(clients)
8482
}
8583

8684
// runEnvAddCommandFunc sets an app environment variable to given values
8785
func runEnvAddCommandFunc(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
8886
ctx := cmd.Context()
8987

9088
// Get the workspace from the flag or prompt
91-
selection, err := appSelectPromptFunc(ctx, clients, prompts.ShowHostedOnly, prompts.ShowInstalledAppsOnly)
89+
selection, err := appSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly)
9290
if err != nil {
9391
return err
9492
}
@@ -127,27 +125,47 @@ func runEnvAddCommandFunc(clients *shared.ClientFactory, cmd *cobra.Command, arg
127125
variableValue = args[1]
128126
}
129127

130-
err = clients.API().AddVariable(
131-
ctx,
132-
selection.Auth.Token,
133-
selection.App.AppID,
134-
variableName,
135-
variableValue,
136-
)
137-
if err != nil {
138-
return err
128+
// Add the environment variable using either the Slack API method or the
129+
// project ".env" file depending on the app hosting.
130+
if !selection.App.IsDev && cmdutil.IsSlackHostedProject(ctx, clients) == nil {
131+
err = clients.API().AddVariable(
132+
ctx,
133+
selection.Auth.Token,
134+
selection.App.AppID,
135+
variableName,
136+
variableValue,
137+
)
138+
if err != nil {
139+
return err
140+
}
141+
clients.IO.PrintTrace(ctx, slacktrace.EnvAddSuccess)
142+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
143+
Emoji: "evergreen_tree",
144+
Text: "App Environment",
145+
Secondary: []string{
146+
fmt.Sprintf("Successfully added \"%s\" as an app environment variable", variableName),
147+
},
148+
}))
149+
} else {
150+
exists, err := afero.Exists(clients.Fs, ".env")
151+
if err != nil {
152+
return err
153+
}
154+
err = slackdotenv.Set(clients.Fs, variableName, variableValue)
155+
if err != nil {
156+
return err
157+
}
158+
clients.IO.PrintTrace(ctx, slacktrace.EnvAddSuccess)
159+
var details []string
160+
if !exists {
161+
details = append(details, "Created a project .env file that shouldn't be added to version control")
162+
}
163+
details = append(details, fmt.Sprintf("Successfully added \"%s\" as a project environment variable", variableName))
164+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
165+
Emoji: "evergreen_tree",
166+
Text: "App Environment",
167+
Secondary: details,
168+
}))
139169
}
140-
141-
clients.IO.PrintTrace(ctx, slacktrace.EnvAddSuccess)
142-
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
143-
Emoji: "evergreen_tree",
144-
Text: "App Environment",
145-
Secondary: []string{
146-
fmt.Sprintf(
147-
"Successfully added \"%s\" as an environment variable",
148-
variableName,
149-
),
150-
},
151-
}))
152170
return nil
153171
}

cmd/env/add_test.go

Lines changed: 93 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/slackapi/slack-cli/internal/slackerror"
2929
"github.com/slackapi/slack-cli/internal/slacktrace"
3030
"github.com/slackapi/slack-cli/test/testutil"
31+
"github.com/spf13/afero"
3132
"github.com/spf13/cobra"
3233
"github.com/stretchr/testify/assert"
3334
"github.com/stretchr/testify/mock"
@@ -46,88 +47,22 @@ var mockApp = types.App{
4647

4748
func Test_Env_AddCommandPreRun(t *testing.T) {
4849
tests := map[string]struct {
49-
mockFlagForce bool
50-
mockManifestResponse types.SlackYaml
51-
mockManifestError error
52-
mockManifestSource config.ManifestSource
5350
mockWorkingDirectory string
5451
expectedError error
5552
}{
56-
"continues if the application is hosted on slack": {
57-
mockManifestResponse: types.SlackYaml{
58-
AppManifest: types.AppManifest{
59-
Settings: &types.AppSettings{
60-
FunctionRuntime: types.SlackHosted,
61-
},
62-
},
63-
},
64-
mockManifestError: nil,
65-
mockManifestSource: config.ManifestSourceLocal,
66-
mockWorkingDirectory: "/slack/path/to/project",
67-
expectedError: nil,
68-
},
69-
"errors if the application is not hosted on slack": {
70-
mockManifestResponse: types.SlackYaml{
71-
AppManifest: types.AppManifest{
72-
Settings: &types.AppSettings{
73-
FunctionRuntime: types.Remote,
74-
},
75-
},
76-
},
77-
mockManifestError: nil,
78-
mockManifestSource: config.ManifestSourceLocal,
79-
mockWorkingDirectory: "/slack/path/to/project",
80-
expectedError: slackerror.New(slackerror.ErrAppNotHosted),
81-
},
82-
"continues if the force flag is used in a project": {
83-
mockFlagForce: true,
53+
"continues if the command is run in a project": {
8454
mockWorkingDirectory: "/slack/path/to/project",
8555
expectedError: nil,
8656
},
87-
"errors if the project manifest cannot be retrieved": {
88-
mockManifestResponse: types.SlackYaml{},
89-
mockManifestError: slackerror.New(slackerror.ErrSDKHookInvocationFailed),
90-
mockManifestSource: config.ManifestSourceLocal,
91-
mockWorkingDirectory: "/slack/path/to/project",
92-
expectedError: slackerror.New(slackerror.ErrSDKHookInvocationFailed),
93-
},
9457
"errors if the command is not run in a project": {
95-
mockManifestResponse: types.SlackYaml{},
96-
mockManifestError: slackerror.New(slackerror.ErrSDKHookNotFound),
9758
mockWorkingDirectory: "",
9859
expectedError: slackerror.New(slackerror.ErrInvalidAppDirectory),
9960
},
100-
"errors if the manifest source is set to remote": {
101-
mockManifestSource: config.ManifestSourceRemote,
102-
mockWorkingDirectory: "/slack/path/to/project",
103-
expectedError: slackerror.New(slackerror.ErrAppNotHosted),
104-
},
10561
}
10662
for name, tc := range tests {
10763
t.Run(name, func(t *testing.T) {
10864
clientsMock := shared.NewClientsMock()
109-
manifestMock := &app.ManifestMockObject{}
110-
manifestMock.On(
111-
"GetManifestLocal",
112-
mock.Anything,
113-
mock.Anything,
114-
mock.Anything,
115-
).Return(
116-
tc.mockManifestResponse,
117-
tc.mockManifestError,
118-
)
119-
clientsMock.AppClient.Manifest = manifestMock
120-
projectConfigMock := config.NewProjectConfigMock()
121-
projectConfigMock.On(
122-
"GetManifestSource",
123-
mock.Anything,
124-
).Return(
125-
tc.mockManifestSource,
126-
nil,
127-
)
128-
clientsMock.Config.ProjectConfig = projectConfigMock
12965
clients := shared.NewClientFactory(clientsMock.MockClientFactory(), func(cf *shared.ClientFactory) {
130-
cf.Config.ForceFlag = tc.mockFlagForce
13166
cf.SDKConfig.WorkingDirectory = tc.mockWorkingDirectory
13267
})
13368
cmd := NewEnvAddCommand(clients)
@@ -146,7 +81,7 @@ func Test_Env_AddCommand(t *testing.T) {
14681
"add a variable using arguments": {
14782
CmdArgs: []string{"ENV_NAME", "ENV_VALUE"},
14883
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
149-
setupEnvAddCommandMocks(ctx, cm, cf)
84+
setupEnvAddHostedMocks(ctx, cm, cf)
15085
},
15186
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
15287
cm.API.AssertCalled(
@@ -170,7 +105,7 @@ func Test_Env_AddCommand(t *testing.T) {
170105
"provide a variable name by argument and value by prompt": {
171106
CmdArgs: []string{"ENV_NAME"},
172107
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
173-
setupEnvAddCommandMocks(ctx, cm, cf)
108+
setupEnvAddHostedMocks(ctx, cm, cf)
174109
cm.IO.On(
175110
"PasswordPrompt",
176111
mock.Anything,
@@ -201,7 +136,7 @@ func Test_Env_AddCommand(t *testing.T) {
201136
"provide a variable name by argument and value by flag": {
202137
CmdArgs: []string{"ENV_NAME", "--value", "example_value"},
203138
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
204-
setupEnvAddCommandMocks(ctx, cm, cf)
139+
setupEnvAddHostedMocks(ctx, cm, cf)
205140
cm.IO.On(
206141
"PasswordPrompt",
207142
mock.Anything,
@@ -232,7 +167,7 @@ func Test_Env_AddCommand(t *testing.T) {
232167
"provide both variable name and value by prompt": {
233168
CmdArgs: []string{},
234169
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
235-
setupEnvAddCommandMocks(ctx, cm, cf)
170+
setupEnvAddHostedMocks(ctx, cm, cf)
236171
cm.IO.On(
237172
"InputPrompt",
238173
mock.Anything,
@@ -269,24 +204,108 @@ func Test_Env_AddCommand(t *testing.T) {
269204
)
270205
},
271206
},
207+
"add a numeric variable using prompts to the .env file": {
208+
CmdArgs: []string{},
209+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
210+
setupEnvAddDotenvMocks(ctx, cm, cf)
211+
cm.IO.On(
212+
"InputPrompt",
213+
mock.Anything,
214+
"Variable name",
215+
mock.Anything,
216+
).Return(
217+
"PORT",
218+
nil,
219+
)
220+
cm.IO.On(
221+
"PasswordPrompt",
222+
mock.Anything,
223+
"Variable value",
224+
iostreams.MatchPromptConfig(iostreams.PasswordPromptConfig{
225+
Flag: cm.Config.Flags.Lookup("value"),
226+
}),
227+
).Return(
228+
iostreams.PasswordPromptResponse{
229+
Prompt: true,
230+
Value: "3000",
231+
},
232+
nil,
233+
)
234+
},
235+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
236+
cm.API.AssertNotCalled(t, "AddVariable")
237+
content, err := afero.ReadFile(cm.Fs, ".env")
238+
assert.NoError(t, err)
239+
assert.Equal(t, "PORT=3000\n", string(content))
240+
},
241+
},
242+
"add a variable to the .env file for non-hosted app": {
243+
CmdArgs: []string{"NEW_VAR", "new_value"},
244+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
245+
setupEnvAddDotenvMocks(ctx, cm, cf)
246+
err := afero.WriteFile(cf.Fs, ".env", []byte("# Config\nEXISTING=value\n"), 0600)
247+
assert.NoError(t, err)
248+
},
249+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
250+
cm.API.AssertNotCalled(t, "AddVariable")
251+
content, err := afero.ReadFile(cm.Fs, ".env")
252+
assert.NoError(t, err)
253+
assert.Equal(t, "# Config\nEXISTING=value\nNEW_VAR=\"new_value\"\n", string(content))
254+
},
255+
},
272256
}, func(cf *shared.ClientFactory) *cobra.Command {
273257
cmd := NewEnvAddCommand(cf)
274258
cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil }
275259
return cmd
276260
})
277261
}
278262

279-
// setupEnvAddCommandMocks prepares common mocks for these tests
280-
func setupEnvAddCommandMocks(ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
263+
// setupEnvAddHostedMocks prepares common mocks for hosted app tests
264+
func setupEnvAddHostedMocks(ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
281265
cf.SDKConfig = hooks.NewSDKConfigMock()
282266
cm.AddDefaultMocks()
283267
_ = cf.AppClient().SaveDeployed(ctx, mockApp)
284268

285269
appSelectMock := prompts.NewAppSelectMock()
286270
appSelectPromptFunc = appSelectMock.AppSelectPrompt
287-
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowHostedOnly, prompts.ShowInstalledAppsOnly).Return(prompts.SelectedApp{Auth: mockAuth, App: mockApp}, nil)
271+
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly).Return(prompts.SelectedApp{Auth: mockAuth, App: mockApp}, nil)
288272

289273
cm.Config.Flags.String("value", "", "mock value flag")
290274

291275
cm.API.On("AddVariable", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
276+
277+
manifestMock := &app.ManifestMockObject{}
278+
manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(
279+
types.SlackYaml{
280+
AppManifest: types.AppManifest{
281+
Settings: &types.AppSettings{
282+
FunctionRuntime: types.SlackHosted,
283+
},
284+
},
285+
},
286+
nil,
287+
)
288+
cm.AppClient.Manifest = manifestMock
289+
projectConfigMock := config.NewProjectConfigMock()
290+
projectConfigMock.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
291+
cm.Config.ProjectConfig = projectConfigMock
292+
cf.SDKConfig.WorkingDirectory = "/slack/path/to/project"
293+
}
294+
295+
// setupEnvAddDotenvMocks prepares common mocks for non-hosted (dotenv) app tests
296+
func setupEnvAddDotenvMocks(_ context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
297+
cf.SDKConfig = hooks.NewSDKConfigMock()
298+
cm.AddDefaultMocks()
299+
300+
mockDevApp := types.App{
301+
TeamID: "T1",
302+
TeamDomain: "team1",
303+
AppID: "A0123456789",
304+
IsDev: true,
305+
}
306+
appSelectMock := prompts.NewAppSelectMock()
307+
appSelectPromptFunc = appSelectMock.AppSelectPrompt
308+
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly).Return(prompts.SelectedApp{Auth: mockAuth, App: mockDevApp}, nil)
309+
310+
cm.Config.Flags.String("value", "", "mock value flag")
292311
}

0 commit comments

Comments
 (0)