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
40 changes: 38 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,49 @@ 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 manifest.json file
// when available. Falls back to the "get-manifest" hook.
func (c *ManifestClient) GetManifestLocal(ctx context.Context, sdkConfig hooks.SDKCLIConfig, hookExecutor hooks.HookExecutor) (types.SlackYaml, error) {
manifestPath := filepath.Join(sdkConfig.WorkingDirectory, manifestFileName)
if exists, _ := afero.Exists(c.fs, manifestPath); exists {
return c.getManifestFromFile(sdkConfig)
}
if sdkConfig.Hooks.GetManifest.IsAvailable() {
return c.getManifestFromHook(ctx, sdkConfig, hookExecutor)
}
return types.SlackYaml{}, slackerror.New("No manifest.json found and no get-manifest hook available").
WithCode(slackerror.ErrInvalidManifest)
}

func (c *ManifestClient) getManifestFromFile(sdkConfig hooks.SDKCLIConfig) (types.SlackYaml, error) {
var sl types.SlackYaml
manifestPath := filepath.Join(sdkConfig.WorkingDirectory, manifestFileName)
data, err := afero.ReadFile(c.fs, manifestPath)
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 +141,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("prefers manifest.json over hook when file exists", 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, "file-app", result.DisplayInformation.Name)
mockHookExecutor.AssertNotCalled(t, "Execute", mock.Anything, mock.Anything)
})

t.Run("falls back to hook when no manifest.json exists", 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"}

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("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 manifest from hook output": {
hookOutput: `{"display_information":{"name":"hook-app"}}`,
expectedName: "hook-app",
},
"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",
},
},
},
"parses hook output with leading characters": {
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 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