Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
07f7c15
feat: add --manifest-source flag to run and deploy commands
srtaalej Aug 10, 2026
159f342
feat: prefer manifest.json over get-manifest hook for local manifest …
srtaalej Aug 10, 2026
4ed2068
test: rename misleading test case for manifest-source flag validation
srtaalej Aug 10, 2026
48bf21a
refactor: consolidate repetitive hook fallback tests into table-drive…
srtaalej Aug 10, 2026
b2d74ed
refactor: consolidate sync merge strategy tests into table-driven test
srtaalej Aug 10, 2026
9a27637
revert: remove --manifest-source flag (out of scope for this PR)
srtaalej Aug 10, 2026
da81af0
fix: prefer get-manifest hook over manifest.json with file as fallback
srtaalej Aug 11, 2026
eacb9da
fix: prefer manifest.json file over get-manifest hook and reduce scope
srtaalej Aug 14, 2026
99bda78
Merge branch 'main' into ale-add-force-to-run
srtaalej Aug 17, 2026
3ea43ab
refactor: remove duplicate path construction and unrelated run.go diff
srtaalej Aug 17, 2026
885dcec
fix: use hook-first precedence with manifest.json as fallback
srtaalej Aug 17, 2026
a4e02ed
Merge branch 'main' into ale-add-force-to-run
srtaalej Aug 17, 2026
1721353
Merge branch 'main' into ale-add-force-to-run
srtaalej Aug 27, 2026
6ba97d4
Merge branch 'main' into ale-add-force-to-run
srtaalej Aug 28, 2026
3b0e5ca
refactor: remove dead guard, fix error code, and consolidate fallback…
srtaalej Aug 28, 2026
d079dfc
Merge branch 'main' into ale-add-force-to-run
srtaalej Aug 31, 2026
5a0ec27
Merge branch 'main' into ale-add-force-to-run
srtaalej Sep 4, 2026
5293b38
test: address review feedback on manifest and sync tests
srtaalej Sep 8, 2026
09224e8
Merge branch 'main' into ale-add-force-to-run
srtaalej Sep 8, 2026
aed878f
refactor: use table-driven merge strategy tests with UpdateApp assertion
srtaalej Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
38 changes: 32 additions & 6 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,23 +64,45 @@ 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 from the project root.
func (c *ManifestClient) GetManifestLocal(ctx context.Context, sdkConfig hooks.SDKCLIConfig, hookExecutor hooks.HookExecutor) (types.SlackYaml, error) {
var sl types.SlackYaml
if sdkConfig.Hooks.GetManifest.IsAvailable() {
return c.getManifestFromHook(ctx, sdkConfig, hookExecutor)
}
return c.getManifestFromFile(sdkConfig)
}

if !sdkConfig.Hooks.GetManifest.IsAvailable() {
return sl, slackerror.New(slackerror.ErrSDKHookNotFound).
WithMessage("The `get-manifest` script was not found")
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.ErrNoFile)
}
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

var manifestHookOpts = hooks.HookExecOpts{
Args: map[string]string{
Expand Down Expand Up @@ -104,7 +131,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
128 changes: 85 additions & 43 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 @@ -69,76 +70,117 @@ func Test_AppManifest_SetManifestEnvTeamVars(t *testing.T) {

func Test_AppManifest_GetManifestLocal(t *testing.T) {
tests := map[string]struct {
mockManifestInfo string
mockManifestErr error
expectedErr error
hookCommand string
hookOutput string
hookErr error
manifestFile string
expectedManifest types.SlackYaml
expectedErrCode string
expectHookCall bool
}{
"errors if no get-manifest hook exists": {
expectedErr: slackerror.New(slackerror.ErrSDKHookNotFound),
"prefers hook over manifest.json when hook is available": {
hookCommand: "echo manifest",
hookOutput: `{"display_information":{"name":"hook-app"}}`,
manifestFile: `{"display_information":{"name":"file-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{Name: "hook-app"},
},
},
expectHookCall: true,
},
"returns an existing manifest without errors": {
mockManifestInfo: `{"display_information":{"name":"my-example-app"}}`,
"falls back to manifest.json when no hook exists": {
manifestFile: `{"display_information":{"name":"file-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{
Name: "my-example-app",
},
DisplayInformation: types.DisplayInformation{Name: "file-app"},
},
},
},
"errors if the hook execution errors": {
mockManifestInfo: `{}`,
mockManifestErr: slackerror.New(slackerror.ErrNoFile),
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"errors if no hook and no manifest.json": {
expectedErrCode: slackerror.ErrNoFile,
},
"parses a manifest with random leading characters": {
mockManifestInfo: `...{"display_information":{"name":"my-showcased-app"}}`,
"errors if manifest.json contains invalid JSON": {
manifestFile: `not json`,
expectedErrCode: slackerror.ErrInvalidManifest,
},
"returns manifest from hook output": {
hookCommand: "generate-manifest",
hookOutput: `{"display_information":{"name":"hook-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{
Name: "my-showcased-app",
},
DisplayInformation: types.DisplayInformation{Name: "hook-app"},
},
},
expectHookCall: true,
},
"errors if a manifest is not present in output": {
mockManifestInfo: `...unknown`,
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"parses hook output with leading characters": {
hookCommand: "generate-manifest",
hookOutput: `...{"display_information":{"name":"hook-app"}}`,
expectedManifest: types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{Name: "hook-app"},
},
},
expectHookCall: true,
},
"errors if hook execution errors": {
hookCommand: "generate-manifest",
hookOutput: `{}`,
hookErr: slackerror.New(slackerror.ErrNoFile),
expectedErrCode: slackerror.ErrInvalidManifest,
expectHookCall: true,
},
"errors if hook output has no JSON": {
hookCommand: "generate-manifest",
hookOutput: `...unknown`,
expectedErrCode: slackerror.ErrInvalidManifest,
expectHookCall: true,
},
}
for name, tc := range tests {
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.WorkingDirectory = "/project"

if tc.hookCommand != "" {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest", Command: tc.hookCommand}
} else {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}
}

if tc.manifestFile != "" {
_ = fsMock.MkdirAll("/project", 0755)
_ = afero.WriteFile(fsMock, "/project/manifest.json", []byte(tc.manifestFile), 0644)
}

actualManifest, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)
if tc.expectedErr != nil {
mockHookExecutor := &hooks.MockHookExecutor{}
if tc.hookCommand != "" {
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(tc.hookOutput, tc.hookErr)
}

manifestClient := NewManifestClient(&api.APIMock{}, configMock, fsMock)
result, err := manifestClient.GetManifestLocal(ctx, mockSDKConfig, mockHookExecutor)

if tc.expectedErrCode != "" {
require.Error(t, err)
assert.Equal(t,
tc.expectedErr.(*slackerror.Error).Code, err.(*slackerror.Error).Code)
assert.Equal(t, tc.expectedErrCode, err.(*slackerror.Error).Code)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedManifest, actualManifest)
assert.Equal(t, tc.expectedManifest, result)
}

if tc.expectHookCall {
mockHookExecutor.AssertCalled(t, "Execute", mock.Anything, mock.Anything)
} else {
mockHookExecutor.AssertNotCalled(t, "Execute", mock.Anything, mock.Anything)
}
})
}
Expand Down Expand Up @@ -186,7 +228,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
86 changes: 40 additions & 46 deletions internal/manifest/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,52 +173,46 @@ func Test_Sync(t *testing.T) {
assert.Equal(t, slackerror.ErrAppManifestUpdate, slackErr.Code)
})

t.Run("force flag merges all local and pushes to API", func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(localManifest, nil)
f.manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(remoteManifest, nil)
f.clients.Config.ForceFlag = true
f.clientsMock.API.On("UpdateApp", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(api.UpdateAppResult{}, nil)
f.cacheMock.On("NewManifestHash", mock.Anything, mock.Anything).Return(cache.Hash("newhash"), nil)
f.cacheMock.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_ = afero.WriteFile(f.fs, "/project/manifest.json", []byte(`{"display_information":{"name":"App"}}`), 0644)

result, err := Sync(f.ctx, f.clients, testApp, testAuth)

require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.True(t, result.WriteBack.Written)
f.clientsMock.API.AssertCalled(t, "UpdateApp", mock.Anything, "xoxb-test", "A123", mock.Anything, true, true)
})

t.Run("force-remote flag merges all remote and pushes to API", func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(localManifest, nil)
f.manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(remoteManifest, nil)
f.clients.Config.ForceRemoteFlag = true
f.clientsMock.API.On("UpdateApp", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(api.UpdateAppResult{}, nil)
f.cacheMock.On("NewManifestHash", mock.Anything, mock.Anything).Return(cache.Hash("newhash"), nil)
f.cacheMock.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_ = afero.WriteFile(f.fs, "/project/manifest.json", []byte(`{"display_information":{"name":"App"}}`), 0644)

result, err := Sync(f.ctx, f.clients, testApp, testAuth)

require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.True(t, result.WriteBack.Written)
// Verify remote value was used — the merged manifest should have "Remote" description
assert.Equal(t, "Remote", result.Merged.DisplayInformation.Description)
})
mergeStrategyTests := map[string]struct {
forceFlag bool
forceRemoteFlag bool
expectedDesc string
}{
"force flag merges all local and pushes to API": {
forceFlag: true,
expectedDesc: "Local",
},
"force-remote flag merges all remote and pushes to API": {
forceRemoteFlag: true,
expectedDesc: "Remote",
},
}
for name, tc := range mergeStrategyTests {
t.Run(name, func(t *testing.T) {
f := newSyncTestFixture(t)
f.projectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil)
f.manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(localManifest, nil)
f.manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(remoteManifest, nil)
f.clients.Config.ForceFlag = tc.forceFlag
f.clients.Config.ForceRemoteFlag = tc.forceRemoteFlag
f.clientsMock.API.On("UpdateApp", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(api.UpdateAppResult{}, nil)
f.cacheMock.On("NewManifestHash", mock.Anything, mock.Anything).Return(cache.Hash("newhash"), nil)
f.cacheMock.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).Return(nil)
_ = afero.WriteFile(f.fs, "/project/manifest.json", []byte(`{"display_information":{"name":"App"}}`), 0644)

result, err := Sync(f.ctx, f.clients, testApp, testAuth)

require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.HasDifferences)
assert.True(t, result.WriteBack.Written)
assert.Equal(t, tc.expectedDesc, result.Merged.DisplayInformation.Description)
f.clientsMock.API.AssertCalled(t, "UpdateApp", mock.Anything, "xoxb-test", "A123", mock.Anything, true, true)
})
}

t.Run("API UpdateApp failure is propagated", func(t *testing.T) {
f := newSyncTestFixture(t)
Expand Down
Loading