-
Notifications
You must be signed in to change notification settings - Fork 32
Expand file tree
/
Copy pathlink.go
More file actions
359 lines (325 loc) · 13 KB
/
link.go
File metadata and controls
359 lines (325 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
// 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 app
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/slackapi/slack-cli/internal/cmdutil"
"github.com/slackapi/slack-cli/internal/config"
"github.com/slackapi/slack-cli/internal/iostreams"
"github.com/slackapi/slack-cli/internal/pkg/apps"
"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/internal/style"
"github.com/spf13/cobra"
)
// LinkAppConfirmPromptText is displayed when prompting to add an existing app
const LinkAppConfirmPromptText = "Do you want to add an existing app?"
// LinkAppManifestSourceConfirmPromptText is displayed before updating the manifest source
const LinkAppManifestSourceConfirmPromptText = "Do you want to update the manifest source to remote?"
// appLinkFlagSet contains flag values to reference
type appLinkFlagSet struct {
environmentFlag string
}
// appLinkFlag contains default flag values
var appLinkFlag = appLinkFlagSet{
environmentFlag: "",
}
// NewLinkCommand returns a new Cobra command for link
func NewLinkCommand(clients *shared.ClientFactory) *cobra.Command {
app := &types.App{}
cmd := &cobra.Command{
Use: "link",
Short: "Add an existing app to the project",
Long: strings.Join([]string{
"Saves an existing app to a project to be available to other commands.",
"",
"The provided App ID and Team ID are stored in the " + style.Underline("apps.json") + " or " + style.Underline("apps.dev.json"),
"files in the .slack directory of a project.",
"",
"The environment option decides how an app is handled and where information",
"should be stored. Production apps should be 'deployed' while apps used for",
"testing and development should be considered 'local'.",
"",
"Only one app can exist for each combination of Team ID and environment.",
}, "\n"),
Example: style.ExampleCommandsf([]style.ExampleCommand{
{
Meaning: "Add an existing app to a project",
Command: "app link",
},
{
Meaning: "Add a specific app without using prompts",
Command: "app link --team T0123456789 --app A0123456789 --environment deployed",
},
}),
PreRunE: func(cmd *cobra.Command, args []string) error {
clients.Config.SetFlags(cmd)
return cmdutil.IsValidProjectDirectory(clients)
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients.IO.PrintTrace(ctx, slacktrace.AppLinkStart)
return LinkCommandRunE(ctx, clients, app)
},
PostRunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients.IO.PrintTrace(ctx, slacktrace.AppLinkSuccess)
return nil
},
}
cmd.Flags().StringVarP(&appLinkFlag.environmentFlag, "environment", "E", "", "environment to save existing app (local, deployed)")
return cmd
}
// LinkCommandRunE saves details about the provided application
func LinkCommandRunE(ctx context.Context, clients *shared.ClientFactory, app *types.App) (err error) {
// Add empty line between executed command and first output
clients.IO.PrintInfo(ctx, false, "")
err = LinkExistingApp(ctx, clients, app, false)
if err != nil {
return err
}
return nil
}
// LinkAppHeaderSection displays a section explaining how to find existing apps.
// External callers can use extraSecondaryText to show additional information.
// When shouldConfirm is true, additional information is included in the header
// explaining how to link apps, in case the user declines.
func LinkAppHeaderSection(ctx context.Context, clients *shared.ClientFactory, shouldConfirm bool) {
var secondaryText = []string{
"Add an existing app from app settings",
"Find your existing apps at: " + style.Underline("https://api.slack.com/apps"),
}
if shouldConfirm {
secondaryText = append(secondaryText, "Manually add apps later with "+style.Commandf("app link", true))
}
clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{
Emoji: "house",
Text: "App Link",
Secondary: secondaryText,
}))
}
// LinkExistingApp prompts for an existing App ID and saves the details to the project.
// When shouldConfirm is true, a confirmation prompt will ask the user is they want to
// link an existing app and additional information is included in the header.
// The shouldConfirm option is encouraged for third-party callers.
// The link command requires manifest source to be remote. When it is not, a
// confirmation prompt is displayed before updating the manifest source value.
func LinkExistingApp(ctx context.Context, clients *shared.ClientFactory, app *types.App, shouldConfirm bool) (err error) {
// Header section
LinkAppHeaderSection(ctx, clients, shouldConfirm)
// Confirm to add an existing app; useful for third-party callers
if shouldConfirm {
proceed, err := clients.IO.ConfirmPrompt(ctx, LinkAppConfirmPromptText, true)
if err != nil {
clients.IO.PrintDebug(ctx, "Error prompting to add an existing app: %s", err)
return err
}
// Add newline to match the trailing newline inserted from the footer section
clients.IO.PrintInfo(ctx, false, "")
if !proceed {
return nil
}
}
// Confirm to update manifest source to remote.
// - Update the manifest source to remote when its a GBP project with a local manifest.
// - Do not update manifest source for ROSI projects, because they can only be local manifests.
manifestSource, err := clients.Config.ProjectConfig.GetManifestSource(ctx)
isManifestSourceRemote := manifestSource.Equals(config.ManifestSourceRemote)
isSlackHostedProject := cmdutil.IsSlackHostedProject(ctx, clients) == nil
if err != nil || (!isManifestSourceRemote && !isSlackHostedProject) {
// When undefined, the default is config.ManifestSourceLocal
if !manifestSource.Exists() {
manifestSource = config.ManifestSourceLocal
}
clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{
Emoji: "warning",
Text: "Warning",
Secondary: []string{
"Linking an existing app requires the app manifest source to be managed by",
fmt.Sprintf("%s.", config.ManifestSourceRemote.Human()),
" ",
fmt.Sprintf(`App manifest source can be %s or %s:`, config.ManifestSourceLocal.Human(), config.ManifestSourceRemote.Human()),
fmt.Sprintf("- %s: uses manifest from your project's source code for all apps", config.ManifestSourceLocal.String()),
fmt.Sprintf("- %s: uses manifest from app settings for each app", config.ManifestSourceRemote.String()),
" ",
fmt.Sprintf(style.Highlight(`Your manifest source is set to %s.`), manifestSource.Human()),
" ",
fmt.Sprintf("Current manifest source in %s:", style.Highlight(filepath.Join(config.ProjectConfigDirName, config.ProjectConfigJSONFilename))),
fmt.Sprintf(style.Highlight(` %s: "%s"`), "manifest.source", manifestSource.String()),
" ",
fmt.Sprintf("Updating manifest source will be changed in %s:", style.Highlight(filepath.Join(config.ProjectConfigDirName, config.ProjectConfigJSONFilename))),
fmt.Sprintf(style.Highlight(` %s: "%s"`), "manifest.source", config.ManifestSourceRemote),
},
}))
proceed, err := clients.IO.ConfirmPrompt(ctx, LinkAppManifestSourceConfirmPromptText, false)
if err != nil {
clients.IO.PrintDebug(ctx, "Error prompting to update the manifest source to %s: %s", config.ManifestSourceRemote, err)
return err
}
if !proceed {
// Add newline to match the trailing newline inserted from the footer section
clients.IO.PrintInfo(ctx, false, "")
return nil
}
if err := config.SetManifestSource(ctx, clients.Fs, clients.Os, config.ManifestSourceRemote); err != nil {
// Log the error to the verbose output
clients.IO.PrintDebug(ctx, "Error setting manifest source in project-level config: %s", err)
// Display a user-friendly error with a workaround
slackErr := slackerror.New(slackerror.ErrProjectConfigManifestSource).
WithMessage("Failed to update the manifest source to %s", config.ManifestSourceRemote).
WithRemediation(
"You can manually update the manifest source by setting the following\nproperty in %s:\n %s",
filepath.Join(config.ProjectConfigDirName, config.ProjectConfigJSONFilename),
fmt.Sprintf(`manifest.source: "%s"`, config.ManifestSourceRemote),
).
WithRootCause(err)
clients.IO.PrintError(ctx, "%s", slackErr.Error())
}
}
// Prompt to get app details
var auth *types.SlackAuth
*app, auth, err = promptExistingApp(ctx, clients)
if err != nil {
return err
}
appIDs := []string{app.AppID}
_, err = clients.API().GetAppStatus(ctx, auth.Token, appIDs, app.TeamID)
if err != nil {
return err
}
// Save the app to the project
err = saveAppToJSON(ctx, clients, *app)
if err != nil {
clients.IO.PrintDebug(ctx, "Error saving app to file when linking existing app: %s", err)
return err
}
// Footer section
LinkAppFooterSection(ctx, clients, app)
return nil
}
// LinkAppFooterSection displays the details of app that was added to the project.
func LinkAppFooterSection(ctx context.Context, clients *shared.ClientFactory, app *types.App) {
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "house",
Text: "App",
Secondary: formatListSuccess([]types.App{*app}),
}))
clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{
Emoji: "house_with_garden",
Text: "App Link",
Secondary: []string{
"Added existing app to project",
},
}))
}
// promptExistingApp gathers details to represent app information
func promptExistingApp(ctx context.Context, clients *shared.ClientFactory) (types.App, *types.SlackAuth, error) {
slackAuth, err := prompts.PromptTeamSlackAuth(ctx, clients, "Select the existing app team")
if err != nil {
return types.App{}, &types.SlackAuth{}, err
}
appID, err := promptAppID(ctx, clients)
if err != nil {
return types.App{}, &types.SlackAuth{}, err
}
isProduction, err := promptIsProduction(ctx, clients)
if err != nil {
return types.App{}, &types.SlackAuth{}, err
}
app := types.App{
AppID: appID,
EnterpriseID: slackAuth.EnterpriseID,
TeamDomain: slackAuth.TeamDomain,
TeamID: slackAuth.TeamID,
}
if !isProduction {
app.IsDev = true
app.UserID = slackAuth.UserID
}
apps, err := apps.FetchAppInstallStates(ctx, clients, []types.App{app})
if err != nil {
return app, slackAuth, nil
}
return apps[0], slackAuth, nil
}
// promptAppID retrieves an app ID from user input
func promptAppID(ctx context.Context, clients *shared.ClientFactory) (string, error) {
if clients.Config.Flags.Lookup("app").Changed {
return clients.Config.Flags.Lookup("app").Value.String(), nil
}
value, err := clients.IO.InputPrompt(
ctx,
"Enter the existing app ID",
iostreams.InputPromptConfig{
Required: true,
},
)
if err != nil {
return "", err
}
return value, nil
}
// funcPromptIsProduction decides if the app should be considered production
func promptIsProduction(ctx context.Context, clients *shared.ClientFactory) (bool, error) {
selection, err := clients.IO.SelectPrompt(
ctx,
"Choose the app environment",
[]string{"Local", "Deployed"},
iostreams.SelectPromptConfig{
Flag: clients.Config.Flags.Lookup("environment"),
Required: true,
},
)
if err != nil {
return false, err
}
if strings.ToLower(selection.Option) == "deployed" {
return true, nil
} else if strings.ToLower(selection.Option) == "local" {
return false, nil
}
return false, slackerror.New(slackerror.ErrMismatchedFlags).
WithRemediation("The environment flag must be either 'local' or 'deployed'")
}
// saveAppToJSON writes the linked app to file for later use while not writing
// app IDs that exist
func saveAppToJSON(ctx context.Context, clients *shared.ClientFactory, app types.App) error {
deploy, err := clients.AppClient().GetDeployed(ctx, app.TeamID)
if err != nil {
return err
}
local, err := clients.AppClient().GetLocal(ctx, app.TeamID)
if err != nil {
return err
}
switch app.IsDev {
case true:
if clients.Config.ForceFlag || (local.IsNew() && deploy.AppID != app.AppID) {
return clients.AppClient().SaveLocal(ctx, app)
}
case false:
if clients.Config.ForceFlag || (deploy.IsNew() && local.AppID != app.AppID) {
return clients.AppClient().SaveDeployed(ctx, app)
}
}
return slackerror.New(slackerror.ErrAppFound).
WithMessage("A saved app was found and cannot be overwritten").
WithRemediation("Remove the app from this project or try again with %s", style.Bold("--force"))
}