Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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
152 changes: 102 additions & 50 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,128 @@ 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
fallbackTests := map[string]struct {
hookCommand string
hookOutput string
manifestFile string
expectedName string
expectedErrCode string
expectHookCall bool
}{
"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",
},
},
},
"prefers hook over manifest.json when hook is available": {
hookCommand: "echo manifest",
hookOutput: `{"display_information":{"name":"hook-app"}}`,
manifestFile: `{"display_information":{"name":"file-app"}}`,
expectedName: "hook-app",
expectHookCall: true,
},
"errors if the hook execution errors": {
mockManifestInfo: `{}`,
mockManifestErr: slackerror.New(slackerror.ErrNoFile),
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"falls back to manifest.json when no hook exists": {
manifestFile: `{"display_information":{"name":"file-app"}}`,
expectedName: "file-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 no hook and no manifest.json": {
expectedErrCode: slackerror.ErrNoFile,
},
"errors if a manifest is not present in output": {
mockManifestInfo: `...unknown`,
expectedErr: slackerror.New(slackerror.ErrInvalidManifest),
"errors if manifest.json contains invalid JSON": {
manifestFile: `not json`,
expectedErrCode: slackerror.ErrInvalidManifest,
},
}
for name, tc := range tests {
for name, tc := range fallbackTests {
t.Run(name, func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
mockManifestEnv := map[string]string{"EXAMPLE": "12"}
fsMock := slackdeps.NewFsMock()
osMock := slackdeps.NewOsMock()
osMock.AddDefaultMocks()
configMock := config.NewConfig(fsMock, osMock)
configMock.DomainAuthTokens = "api.slack.com"
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)
}

mockHookExecutor := &hooks.MockHookExecutor{}
if tc.mockManifestInfo != "" {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{
Name: "GetManifest",
Command: "cat manifest.json",
}
if tc.hookCommand != "" {
mockHookExecutor.On("Execute", mock.Anything, mock.Anything).
Return(tc.mockManifestInfo, tc.mockManifestErr)
Return(tc.hookOutput, nil)
}

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

if tc.expectedErrCode != "" {
require.Error(t, err)
assert.Equal(t, tc.expectedErrCode, err.(*slackerror.Error).Code)
} else {
mockSDKConfig.Hooks.GetManifest = hooks.HookScript{Name: "GetManifest"}
require.NoError(t, err)
assert.Equal(t, tc.expectedName, result.DisplayInformation.Name)
}

if tc.expectHookCall {
mockHookExecutor.AssertCalled(t, "Execute", mock.Anything, mock.Anything)
} else {
mockHookExecutor.AssertNotCalled(t, "Execute", mock.Anything, mock.Anything)
}
})
}

hookTests := map[string]struct {
hookOutput string
hookErr error
expectedName string
expectedErr string
}{
Comment on lines +146 to +151

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.

🧪 suggestion: Let's combine these test cases with the ones above for this function. This'll help us extend these later if needed with a solid foundation.

"returns manifest from hook output": {
hookOutput: `{"display_information":{"name":"hook-app"}}`,
expectedName: "hook-app",
},
"parses hook output with leading characters": {
hookOutput: `...{"display_information":{"name":"hook-app"}}`,
expectedName: "hook-app",
},
"errors if hook execution errors": {
hookOutput: `{}`,
hookErr: slackerror.New(slackerror.ErrNoFile),
expectedErr: slackerror.ErrInvalidManifest,
},
"errors if hook output has no JSON": {
hookOutput: `...unknown`,
expectedErr: slackerror.ErrInvalidManifest,
},
}
for name, tc := range hookTests {
t.Run(name, 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"
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)
Comment on lines -141 to +193

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.

🧪 suggestion: Might we keep the entire manifest assertion here? These unit tests are capturing a meaningful interface to build confidence to IMHO.

}
})
}
Expand Down Expand Up @@ -186,7 +238,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
85 changes: 39 additions & 46 deletions internal/manifest/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,52 +173,45 @@ 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 {

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.

👁️‍🗨️ question: Were these test expectations changed? I'm noticing we don't assert a call to the UpdateApp function now which concerns me somewhat since logic adjacent is being changed in this PR too.

forceFlag bool
forceRemoteFlag bool
expectedDesc string
}{
"force flag merges all local": {
forceFlag: true,
expectedDesc: "Local",
},
"force-remote flag merges all remote": {
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)
})
}

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