-
Notifications
You must be signed in to change notification settings - Fork 39
feat: add --app flag support to slack create for linking existing apps #565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
0d8bfd3
da2eb75
e57f7d5
aceb7a4
906346a
8128d5e
315c9bb
dc7d9f7
7386119
818c87a
60d222a
8ef2b29
c9f5bbf
7564232
6533815
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,13 +18,15 @@ import ( | |
| "context" | ||
| "fmt" | ||
| "math/rand" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/slackapi/slack-cli/internal/iostreams" | ||
| "github.com/slackapi/slack-cli/internal/pkg/create" | ||
| "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" | ||
|
|
@@ -67,6 +69,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`, | |
| {Command: "create my-project -t slack-samples/deno-hello-world", Meaning: "Start a new project from a specific template"}, | ||
| {Command: "create --name my-project", Meaning: "Create a project named 'my-project'"}, | ||
| {Command: "create my-project -t org/monorepo --subdir apps/my-app", Meaning: "Create from a subdirectory of a template"}, | ||
| {Command: "create my-project -t slack-samples/bolt-js-starter-template --app A0123456789", Meaning: "Create from template and link to an existing app"}, | ||
| }), | ||
| Args: cobra.MaximumNArgs(2), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
|
|
@@ -127,6 +130,30 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] | |
| WithMessage("The --subdir flag requires the --template flag") | ||
| } | ||
|
|
||
| // --app requires --template (Mode 2 deferred) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👁️🗨️question: Which mode 2 in this case? |
||
| appFlagProvided := clients.Config.AppFlag != "" && types.IsAppID(clients.Config.AppFlag) | ||
| if appFlagProvided && !templateFlagProvided { | ||
| return slackerror.New(slackerror.ErrMismatchedFlags). | ||
| WithMessage("The --app flag requires the --template flag when used with create") | ||
| } | ||
|
|
||
| // Fail fast: resolve auth and fetch manifest before creating the project | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🪬 thought: Related to comments of logic moved to 🐮 ramble: For example a minimal example seems excessive for CI use case:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. true! 🫡
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧂 ramble: Similar comment as below I wonder if this is logic can be passed as |
||
| var appAuth types.SlackAuth | ||
| var remoteManifest types.SlackYaml | ||
| if appFlagProvided { | ||
| auth, err := resolveAuthForApp(ctx, clients, clients.Config.AppFlag) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| appAuth = auth | ||
|
|
||
| manifest, err := fetchRemoteManifest(ctx, clients, auth.Token, clients.Config.AppFlag) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| remoteManifest = manifest | ||
| } | ||
|
|
||
| // Collect the template URL or select a starting template | ||
| template, err := promptTemplateSelection(cmd, clients, categoryShortcut) | ||
| if err != nil { | ||
|
|
@@ -183,6 +210,30 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] | |
| return err | ||
| } | ||
|
|
||
| if appFlagProvided { | ||
| absProjectPath, err := filepath.Abs(appDirPath) | ||
| if err != nil { | ||
| return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) | ||
| } | ||
| if nameFlagProvided { | ||
| remoteManifest.DisplayInformation.Name = displayName | ||
| } | ||
| if err := writeManifestToProject(clients.Fs, absProjectPath, remoteManifest); err != nil { | ||
| return err | ||
| } | ||
| // linkAppToProject requires the working directory to be the project | ||
| // because SaveDeployed/SaveLocal use os.Getwd() to find .slack/ | ||
| originalDir, _ := clients.Os.Getwd() | ||
| if err := os.Chdir(absProjectPath); err != nil { | ||
| return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) | ||
| } | ||
| linkErr := linkAppToProject(ctx, clients, appAuth, clients.Config.AppFlag, remoteManifest) | ||
| _ = os.Chdir(originalDir) | ||
| if linkErr != nil { | ||
| return linkErr | ||
| } | ||
| } | ||
|
zimeg marked this conversation as resolved.
|
||
|
|
||
| printCreateSuccess(ctx, clients, appDirPath) | ||
| return nil | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,116 @@ | ||||||||||||||||||||
| // 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 project | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import ( | ||||||||||||||||||||
| "context" | ||||||||||||||||||||
| "encoding/json" | ||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| "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/style" | ||||||||||||||||||||
| "github.com/spf13/afero" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // resolveAuthForApp finds an authenticated workspace that has access to the given app ID. | ||||||||||||||||||||
| func resolveAuthForApp(ctx context.Context, clients *shared.ClientFactory, appID string) (types.SlackAuth, error) { | ||||||||||||||||||||
| if clients.Config.TokenFlag != "" { | ||||||||||||||||||||
| auth, err := clients.Auth().AuthWithToken(ctx, clients.Config.TokenFlag) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return types.SlackAuth{}, slackerror.Wrap(err, slackerror.ErrNotAuthed) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return auth, nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| allAuths, err := clients.Auth().Auths(ctx) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return types.SlackAuth{}, slackerror.Wrap(err, slackerror.ErrNotAuthed) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if len(allAuths) == 0 { | ||||||||||||||||||||
| return types.SlackAuth{}, slackerror.New(slackerror.ErrNotAuthed). | ||||||||||||||||||||
| WithMessage("No workspaces connected"). | ||||||||||||||||||||
| WithRemediation("Run %s to sign in to a workspace that has access to app %s", style.Commandf("login", false), appID) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if clients.Config.TeamFlag != "" { | ||||||||||||||||||||
| for i := range allAuths { | ||||||||||||||||||||
| if allAuths[i].TeamID == clients.Config.TeamFlag || allAuths[i].TeamDomain == clients.Config.TeamFlag { | ||||||||||||||||||||
| if _, err := clients.API().GetAppStatus(ctx, allAuths[i].Token, []string{appID}, allAuths[i].TeamID); err == nil { | ||||||||||||||||||||
| return allAuths[i], nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return types.SlackAuth{}, slackerror.New(slackerror.ErrTeamNotFound). | ||||||||||||||||||||
| WithMessage("The specified team does not have access to app %s", appID). | ||||||||||||||||||||
| WithRemediation("Run %s to sign in to the workspace that owns this app", style.Commandf("login", false)) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| for i := range allAuths { | ||||||||||||||||||||
| if _, err := clients.API().GetAppStatus(ctx, allAuths[i].Token, []string{appID}, allAuths[i].TeamID); err == nil { | ||||||||||||||||||||
| return allAuths[i], nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return types.SlackAuth{}, slackerror.New(slackerror.ErrAppNotFound). | ||||||||||||||||||||
| WithMessage("No authenticated workspace has access to app %s", appID). | ||||||||||||||||||||
| WithRemediation("Run %s to sign in to the workspace that owns this app", style.Commandf("login", false)) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // fetchRemoteManifest retrieves the app manifest from the platform via apps.manifest.export. | ||||||||||||||||||||
| func fetchRemoteManifest(ctx context.Context, clients *shared.ClientFactory, token string, appID string) (types.SlackYaml, error) { | ||||||||||||||||||||
| manifest, err := clients.AppClient().Manifest.GetManifestRemote(ctx, token, appID) | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return types.SlackYaml{}, slackerror.New(slackerror.ErrInvalidManifest). | ||||||||||||||||||||
| WithMessage("Failed to fetch manifest for app %s", appID) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return manifest, nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
🪓 suggestion: Let's inline this! I think the error returned might sometimes be different from invalid manifest that we might want to surface |
||||||||||||||||||||
|
|
||||||||||||||||||||
| // writeManifestToProject writes the fetched manifest JSON to the project directory. | ||||||||||||||||||||
| func writeManifestToProject(fs afero.Fs, projectPath string, manifest types.SlackYaml) error { | ||||||||||||||||||||
| manifestData, err := json.MarshalIndent(manifest.AppManifest, "", " ") | ||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||
| return slackerror.Wrap(err, slackerror.ErrProjectFileUpdate). | ||||||||||||||||||||
| WithMessage("Failed to serialize app manifest") | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| manifestPath := filepath.Join(projectPath, "manifest.json") | ||||||||||||||||||||
| if err := afero.WriteFile(fs, manifestPath, append(manifestData, '\n'), 0644); err != nil { | ||||||||||||||||||||
| return slackerror.Wrap(err, slackerror.ErrProjectFileUpdate). | ||||||||||||||||||||
| WithMessage("Failed to write manifest to project") | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return nil | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛻 suggestion: Let's move this logic to |
||||||||||||||||||||
|
|
||||||||||||||||||||
| // linkAppToProject saves the app to the project's apps JSON file. | ||||||||||||||||||||
| // Defaults to local/dev unless the manifest explicitly uses a hosted runtime. | ||||||||||||||||||||
| func linkAppToProject(ctx context.Context, clients *shared.ClientFactory, auth types.SlackAuth, appID string, manifest types.SlackYaml) error { | ||||||||||||||||||||
| app := types.App{ | ||||||||||||||||||||
| AppID: appID, | ||||||||||||||||||||
| TeamID: auth.TeamID, | ||||||||||||||||||||
| TeamDomain: auth.TeamDomain, | ||||||||||||||||||||
| EnterpriseID: auth.EnterpriseID, | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if manifest.IsFunctionRuntimeSlackHosted() { | ||||||||||||||||||||
| return clients.AppClient().SaveDeployed(ctx, app) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| app.IsDev = true | ||||||||||||||||||||
| app.UserID = auth.UserID | ||||||||||||||||||||
| return clients.AppClient().SaveLocal(ctx, app) | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔭 question: Can we reuse logic of the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👾 issue: I'm concerned of the forced default local app here. The same CI example I share earlier is for a production app and I don't have immediate option to "deploy" the right app after using this: 🌠 suggestion: We might want to use the |
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🪬 suggestion: Including the
--environmentflag might be meaningful with this example to avoid retries in CI setup or experiments avoiding prompts?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Instead, how about we add a CI/Scripting example and allow the above example to stay focused on creating a project with an existing app ID.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agreed!