Skip to content
1 change: 0 additions & 1 deletion cmd/platform/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ func NewRunCommand(clients *shared.ClientFactory) *cobra.Command {
{Command: "platform run --cleanup", Meaning: "Run a local development server with cleanup"},
}),
PreRunE: func(cmd *cobra.Command, args []string) error {
// Verify command is run in a project directory
return cmdutil.IsValidProjectDirectory(clients)
},
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func NewClient(
os types.Os,
) *Client {
return &Client{
Manifest: NewManifestClient(apiClient, config),
Manifest: NewManifestClient(apiClient, config, fs),
AppClientInterface: NewAppClient(config, fs, os),
}
}
Expand Down
35 changes: 33 additions & 2 deletions internal/app/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,23 @@ package app
import (
"context"
"encoding/json"
"path/filepath"
"strings"

"github.com/slackapi/slack-cli/internal/api"
"github.com/slackapi/slack-cli/internal/config"
"github.com/slackapi/slack-cli/internal/hooks"
"github.com/slackapi/slack-cli/internal/shared/types"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/spf13/afero"
)

const manifestFileName = "manifest.json"

// ManifestClient can manage the state of the project's app manifest file
type ManifestClient struct {
apiClient api.APIInterface
fs afero.Fs
domainAuthTokens string
Env map[string]string
}
Expand Down Expand Up @@ -59,17 +64,44 @@ func SetManifestEnvTeamVars(manifestEnv map[string]string, appTeamDomain string,
func NewManifestClient(
apiClient api.APIInterface,
config *config.Config,
fs afero.Fs,
) *ManifestClient {
client := &ManifestClient{
apiClient: apiClient,
fs: fs,
domainAuthTokens: config.DomainAuthTokens,
Env: config.ManifestEnv,
}
return client
}

// GetManifestLocal gathers manifest content from the "get-manifest" hook
// GetManifestLocal reads the local manifest, preferring the "get-manifest" hook
// when available. Falls back to reading manifest.json directly from the project root.
func (c *ManifestClient) GetManifestLocal(ctx context.Context, sdkConfig hooks.SDKCLIConfig, hookExecutor hooks.HookExecutor) (types.SlackYaml, error) {
if sdkConfig.Hooks.GetManifest.IsAvailable() {
return c.getManifestFromHook(ctx, sdkConfig, hookExecutor)
}
manifestPath := filepath.Join(sdkConfig.WorkingDirectory, manifestFileName)
return c.readManifestFile(manifestPath)
}

func (c *ManifestClient) readManifestFile(path string) (types.SlackYaml, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func (c *ManifestClient) readManifestFile(path string) (types.SlackYaml, error) {
func (c *ManifestClient) getManifestFromFile(sdkConfig hooks.SDKCLIConfig) (types.SlackYaml, error) {

🌵 thought: I think we should match the function name convention here and also keep path specific logic contained within this function. I'm less confident of the second thought FWIW!

var sl types.SlackYaml
data, err := afero.ReadFile(c.fs, path)
if err != nil {
return sl, slackerror.New("Failed to read manifest file").
WithRootCause(err).
WithCode(slackerror.ErrInvalidManifest)
}
if err := json.Unmarshal(data, &sl); err != nil {
return sl, slackerror.New("Failed to parse manifest file").
WithRootCause(err).
WithCode(slackerror.ErrInvalidManifest)
}
return sl, nil
}

func (c *ManifestClient) getManifestFromHook(ctx context.Context, sdkConfig hooks.SDKCLIConfig, hookExecutor hooks.HookExecutor) (types.SlackYaml, error) {
var sl types.SlackYaml

if !sdkConfig.Hooks.GetManifest.IsAvailable() {
Expand Down Expand Up @@ -104,7 +136,6 @@ func (c *ManifestClient) GetManifestLocal(ctx context.Context, sdkConfig hooks.S
if start != -1 {
slackManifestInfo = slackManifestInfo[start:]
} else {
// the app manifest has to be a json so needs to have the character `{`
return sl, slackerror.New("Invalid app manifest format, must be valid JSON").
WithRootCause(err).
WithCode(slackerror.ErrInvalidManifest)
Expand Down
174 changes: 119 additions & 55 deletions internal/app/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/slackapi/slack-cli/internal/slackcontext"
"github.com/slackapi/slack-cli/internal/slackdeps"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -68,77 +69,140 @@ func Test_AppManifest_SetManifestEnvTeamVars(t *testing.T) {
}

func Test_AppManifest_GetManifestLocal(t *testing.T) {
tests := map[string]struct {
mockManifestInfo string
mockManifestErr error
expectedErr error
expectedManifest types.SlackYaml
t.Run("uses hook when get-manifest is available", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
configMock.DomainAuthTokens = "api.slack.com"
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest", Command: "echo manifest"}

_ = fsMock.MkdirAll("/project", 0755)
_ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(`{"display_information":{"name":"file-app"}}`), 0644)

mockHookExecutor := &hooks.MockHookExecutor{}
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(`{"display_information":{"name":"hook-app"}}`, nil)
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.NoError(t, err)
assert.Equal(t, "hook-app", result.DisplayInformation.Name)
mockHookExecutor.AssertCalled(t, "Execute", mock.Anything, mock.Anything)
})

t.Run("falls back to manifest.json when no hook exists", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}

_ = fsMock.MkdirAll("/project", 0755)
_ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(`{"display_information":{"name":"file-app"}}`), 0644)

mockHookExecutor := &hooks.MockHookExecutor{}
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.NoError(t, err)
assert.Equal(t, "file-app", result.DisplayInformation.Name)
mockHookExecutor.AssertNotCalled(t, "Execute", mock.Anything, mock.Anything)
})

t.Run("errors if no hook and no manifest.json", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}

mockHookExecutor := &hooks.MockHookExecutor{}
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

_, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.Error(t, err)
assert.Equal(t, slackerror.ErrInvalidManifest, err.(*slackerror.Error).Code)
})

t.Run("errors if manifest.json contains invalid JSON", func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.WorkingDirectory = "/project"
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}

_ = fsMock.MkdirAll("/project", 0755)
_ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(`not json`), 0644)

mockHookExecutor := &hooks.MockHookExecutor{}
manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

_, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
require.Error(t, err)
assert.Equal(t, slackerror.ErrInvalidManifest, err.(*slackerror.Error).Code)
})

hookTests := map[string]struct {
hookOutput string
hookErr error
expectedName string
expectedErr string
}{
"errors if no get-manifest hook exists": {
expectedErr: slackerror.New(slackerror.ErrSDKHookNotFound),
},
"returns an existing manifest without errors": {
mockManifestInfo: `{"display_information":{"name":"my-example-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{
Name: "my-example-app",
},
},
},
"returns manifest from hook output": {
hookOutput: `{"display_information":{"name":"hook-app"}}`,
expectedName: "hook-app",
},
"errors if the hook execution errors": {
mockManifestInfo: `{}`,
mockManifestErr: slackerror.New(slackerror.ErrNoFile),
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"parses hook output with leading characters": {
hookOutput: `...{"display_information":{"name":"hook-app"}}`,
expectedName: "hook-app",
},
"parses a manifest with random leading characters": {
mockManifestInfo: `...{"display_information":{"name":"my-showcased-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{
Name: "my-showcased-app",
},
},
},
"errors if hook execution errors": {
hookOutput: `{}`,
hookErr: slackerror.New(slackerror.ErrNoFile),
expectedErr: slackerror.ErrInvalidManifest,
},
"errors if a manifest is not present in output": {
mockManifestInfo: `...unknown`,
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"errors if hook output has no JSON": {
hookOutput: `...unknown`,
expectedErr: slackerror.ErrInvalidManifest,
},
}
for name, tc := range tests {
for name, tc := range hookTests {
t.Run(name, func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
mockManifestEnv := map[string]string{"EXAMPLE": "12"}
mockSDKConfig := hooks.NewSDKConfigMock()
mockHookExecutor := &hooks.MockHookExecutor{}
if tc.mockManifestInfo != "" {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{
Name: "GetManifest",
Command: "cat manifest.json",
}
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(tc.mockManifestInfo, tc.mockManifestErr)
} else {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}
}
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
configMock.DomainAuthTokens = "api.slack.com"
configMock.ManifestEnv = mockManifestEnv
manifestClient := NewManifestClient(&api.APIMock{}, configMock)
mockSDKConfig := hooks.NewSDKConfigMock()
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest", Command: "generate-manifest"}

mockHookExecutor := &hooks.MockHookExecutor{}
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(tc.hookOutput, tc.hookErr)

manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)

actualManifest, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
if tc.expectedErr != nil {
result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
if tc.expectedErr != "" {
require.Error(t, err)
assert.Equal(t,
tc.expectedErr.(*slackerror.Error).Code, err.(*slackerror.Error).Code)
assert.Equal(t, tc.expectedErr, err.(*slackerror.Error).Code)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedManifest, actualManifest)
assert.Equal(t, tc.expectedName, result.DisplayInformation.Name)
}
})
}
Expand Down Expand Up @@ -186,7 +250,7 @@ func Test_AppManifest_GetManifestRemote(t *testing.T) {
apic := &api.APIMock{}
apic.On("ExportAppManifest", mock.Anything, mock.Anything, mock.Anything).
Return(api.ExportAppResult{Manifest: tc.mockManifestResponse}, tc.mockManifestError)
manifestClient := NewManifestClient(apic, configMock)
manifestClient := NewManifestClient(apic, configMock, fsMock)

manifest, err := manifestClient.GetManifestRemote(ctx, tc.mockToken, tc.mockAppID)
if tc.expectedError != nil {
Expand Down
Loading
Loading