From e6cf706d8dce4f9e91d7b9cef8bfece19b883b03 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Thu, 19 Feb 2026 15:58:02 -0500 Subject: [PATCH 1/7] feat(create): add 'subdir' flag to create command --- cmd/project/create.go | 4 + cmd/project/create_test.go | 46 +++++++++++ internal/pkg/create/create.go | 70 +++++++++++++++- internal/pkg/create/create_test.go | 124 +++++++++++++++++++++++++++++ internal/slackerror/errors.go | 6 ++ 5 files changed, 248 insertions(+), 2 deletions(-) diff --git a/cmd/project/create.go b/cmd/project/create.go index b869abc8..6d0c7fdf 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -33,6 +33,7 @@ var createTemplateURLFlag string var createGitBranchFlag string var createAppNameFlag string var createListFlag bool +var createSubdirFlag string // Handle to client's create function used for testing // TODO - Find best practice, such as using an Interface and Struct to create a client @@ -66,6 +67,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`, {Command: "create agent my-agent-app", Meaning: "Create a new AI Agent app"}, {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"}, }), Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { @@ -79,6 +81,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`, cmd.Flags().StringVarP(&createGitBranchFlag, "branch", "b", "", "name of git branch to checkout") cmd.Flags().StringVarP(&createAppNameFlag, "name", "n", "", "name for your app (overrides the name argument)") cmd.Flags().BoolVar(&createListFlag, "list", false, "list available app templates") + cmd.Flags().StringVar(&createSubdirFlag, "subdir", "", "subdirectory within the template to use as project root") return cmd } @@ -141,6 +144,7 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] AppName: appNameArg, Template: template, GitBranch: createGitBranchFlag, + Subdir: createSubdirFlag, } clients.EventTracker.SetAppTemplate(template.GetTemplatePath()) diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index 2874643c..2cc538e7 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -320,6 +320,52 @@ func TestCreateCommand(t *testing.T) { cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything) }, }, + "passes subdir flag to create function": { + CmdArgs: []string{"--template", "slack-samples/bolt-js-starter-template", "--subdir", "apps/my-app"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything). + Return( + iostreams.SelectPromptResponse{ + Flag: true, + Option: "slack-samples/bolt-js-starter-template", + }, + nil, + ) + cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything). + Return( + iostreams.SelectPromptResponse{ + Flag: true, + Option: "slack-samples/bolt-js-starter-template", + }, + nil, + ) + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template") + require.NoError(t, err) + expected := create.CreateArgs{ + Template: template, + Subdir: "apps/my-app", + } + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) + }, + }, + "list flag ignores subdir": { + CmdArgs: []string{"--list", "--subdir", "foo"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedOutputs: []string{ + "Getting started", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, "lists all templates with --list flag": { CmdArgs: []string{"--list"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { diff --git a/internal/pkg/create/create.go b/internal/pkg/create/create.go index 6e37f41b..e9525a76 100644 --- a/internal/pkg/create/create.go +++ b/internal/pkg/create/create.go @@ -50,6 +50,7 @@ type CreateArgs struct { AppName string Template Template GitBranch string + Subdir string } // Create will create a new Slack app on the file system and app manifest on the Slack API. @@ -121,8 +122,19 @@ func Create(ctx context.Context, clients *shared.ClientFactory, log *logger.Logg })) // Create the project from a templateURL - if err := createApp(ctx, projectDirPath, createArgs.Template, createArgs.GitBranch, log, clients.Fs); err != nil { - return "", slackerror.Wrap(err, slackerror.ErrAppCreate) + subdir, err := normalizeSubdir(createArgs.Subdir) + if err != nil { + return "", err + } + + if subdir != "" { + if err := createAppFromSubdir(ctx, projectDirPath, createArgs.Template, createArgs.GitBranch, subdir, log, clients.Fs); err != nil { + return "", slackerror.Wrap(err, slackerror.ErrAppCreate) + } + } else { + if err := createApp(ctx, projectDirPath, createArgs.Template, createArgs.GitBranch, log, clients.Fs); err != nil { + return "", slackerror.Wrap(err, slackerror.ErrAppCreate) + } } // Change into the project directory to configure defaults and dependencies @@ -343,6 +355,60 @@ func createApp(ctx context.Context, dirPath string, template Template, gitBranch return nil } +// normalizeSubdir cleans the subdir path and returns "" if it resolves to root. +func normalizeSubdir(subdir string) (string, error) { + if subdir == "" { + return "", nil + } + cleaned := filepath.Clean(subdir) + if cleaned == "." || cleaned == "/" { + return "", nil + } + if strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { + return "", slackerror.New(slackerror.ErrSubdirNotFound). + WithMessage("subdirectory path %q must be relative and within the template", subdir) + } + return cleaned, nil +} + +// createAppFromSubdir clones the full template into a temp directory, then copies +// only the specified subdirectory to the final project path. +func createAppFromSubdir(ctx context.Context, dirPath string, template Template, gitBranch string, subdir string, log *logger.Logger, fs afero.Fs) error { + tmpDir, err := os.MkdirTemp("", "slack-create-*") + if err != nil { + return slackerror.Wrap(err, "failed to create temporary directory") + } + // Remove so createApp can create it fresh (go-git requires non-existent target) + os.Remove(tmpDir) + defer os.RemoveAll(tmpDir) + + if err := createApp(ctx, tmpDir, template, gitBranch, log, fs); err != nil { + return err + } + + subdirPath := filepath.Join(tmpDir, subdir) + info, err := os.Stat(subdirPath) + if err != nil { + if os.IsNotExist(err) { + return slackerror.New(slackerror.ErrSubdirNotFound). + WithMessage("subdirectory %q was not found in the template", subdir). + WithRemediation("Check that the path exists in the template at %q", template.GetTemplatePath()) + } + return slackerror.Wrap(err, "failed to access subdirectory") + } + if !info.IsDir() { + return slackerror.New(slackerror.ErrSubdirNotFound). + WithMessage("path %q in the template is not a directory", subdir) + } + + return goutils.CopyDirectory(goutils.CopyDirectoryOpts{ + Src: subdirPath, + Dst: dirPath, + IgnoreDirectories: []string{".git", ".venv", "node_modules"}, + IgnoreFiles: []string{".DS_Store"}, + }) +} + // InstallProjectDependencies installs the project runtime dependencies or // continues with next steps if that fails. You can specify the manifestSource // for the project configuration file (default: ManifestSourceLocal) diff --git a/internal/pkg/create/create_test.go b/internal/pkg/create/create_test.go index 67b8991d..c8785d08 100644 --- a/internal/pkg/create/create_test.go +++ b/internal/pkg/create/create_test.go @@ -17,11 +17,13 @@ package create import ( "fmt" "net/http" + "os" "path/filepath" "testing" "github.com/slackapi/slack-cli/internal/config" "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/logger" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/slackcontext" "github.com/slackapi/slack-cli/internal/slackhttp" @@ -183,6 +185,128 @@ func TestCreateGitArgs(t *testing.T) { assert.Equal(t, expectedArgs, testGitArgs) } +func TestNormalizeSubdir(t *testing.T) { + tests := map[string]struct { + input string + expected string + expectError bool + }{ + "empty string returns empty": { + input: "", + expected: "", + }, + "dot returns empty": { + input: ".", + expected: "", + }, + "slash returns empty": { + input: "/", + expected: "", + }, + "simple subdir": { + input: "pydantic-ai/", + expected: "pydantic-ai", + }, + "dot-prefixed subdir": { + input: "./my-app", + expected: "my-app", + }, + "nested subdir": { + input: "apps/my-app", + expected: "apps/my-app", + }, + "parent traversal is rejected": { + input: "../escape", + expectError: true, + }, + "nested parent traversal is rejected": { + input: "foo/../../escape", + expectError: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := normalizeSubdir(tc.input) + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + } + }) + } +} + +func TestCreateAppFromSubdir(t *testing.T) { + tests := map[string]struct { + setupTemplate func(t *testing.T) string + subdir string + expectError bool + errorContains string + expectFiles []string + }{ + "extracts subdirectory from local template": { + setupTemplate: func(t *testing.T) string { + tmpDir := t.TempDir() + // Create a subdirectory with a file + subdir := filepath.Join(tmpDir, "apps", "my-app") + require.NoError(t, os.MkdirAll(subdir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(subdir, "manifest.json"), []byte(`{}`), 0644)) + // Create a file at root that should NOT be copied + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "README.md"), []byte("root readme"), 0644)) + return tmpDir + }, + subdir: "apps/my-app", + expectFiles: []string{"manifest.json"}, + }, + "returns error for nonexistent subdirectory": { + setupTemplate: func(t *testing.T) string { + return t.TempDir() + }, + subdir: "nonexistent", + expectError: true, + errorContains: "was not found in the template", + }, + "returns error when subdir path is a file": { + setupTemplate: func(t *testing.T) string { + tmpDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "not-a-dir"), []byte("file"), 0644)) + return tmpDir + }, + subdir: "not-a-dir", + expectError: true, + errorContains: "is not a directory", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + templateDir := tc.setupTemplate(t) + outputDir := t.TempDir() + // Remove output dir so CopyDirectory can create it + require.NoError(t, os.Remove(outputDir)) + + template := Template{path: templateDir, isLocal: true} + log := logger.New(func(event *logger.LogEvent) {}) + fs := afero.NewOsFs() + + err := createAppFromSubdir(t.Context(), outputDir, template, "", tc.subdir, log, fs) + + if tc.expectError { + assert.Error(t, err) + if tc.errorContains != "" { + assert.Contains(t, err.Error(), tc.errorContains) + } + } else { + assert.NoError(t, err) + for _, f := range tc.expectFiles { + _, statErr := os.Stat(filepath.Join(outputDir, f)) + assert.NoError(t, statErr, "expected file %s to exist", f) + } + } + }) + } +} + func Test_Create_installProjectDependencies(t *testing.T) { tests := map[string]struct { experiments []string diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 0ad6dd7e..8055e0ed 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -225,6 +225,7 @@ const ( ErrSocketConnection = "socket_connection_error" ErrScopesExceedAppConfig = "scopes_exceed_app_config" ErrStreamingActivityLogs = "streaming_activity_logs_error" + ErrSubdirNotFound = "subdir_not_found" ErrSurveyConfigNotFound = "survey_config_not_found" ErrSystemConfigIDNotFound = "system_config_id_not_found" ErrSystemRequirementsFailed = "system_requirements_failed" @@ -1391,6 +1392,11 @@ Otherwise start your app for local development with: %s`, Message: "Failed to stream the most recent activity logs", }, + ErrSubdirNotFound: { + Code: ErrSubdirNotFound, + Message: "The specified subdirectory was not found in the template repository", + }, + ErrSurveyConfigNotFound: { Code: ErrSurveyConfigNotFound, Message: "Survey config not found", From 62d6b43008515fc0b0023f673d079b52865587d7 Mon Sep 17 00:00:00 2001 From: Ale Mercado <104795114+srtaalej@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:28:20 -0500 Subject: [PATCH 2/7] Update cmd/project/create.go Co-authored-by: Michael Brooks --- cmd/project/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/project/create.go b/cmd/project/create.go index 6d0c7fdf..198e9546 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -81,7 +81,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`, cmd.Flags().StringVarP(&createGitBranchFlag, "branch", "b", "", "name of git branch to checkout") cmd.Flags().StringVarP(&createAppNameFlag, "name", "n", "", "name for your app (overrides the name argument)") cmd.Flags().BoolVar(&createListFlag, "list", false, "list available app templates") - cmd.Flags().StringVar(&createSubdirFlag, "subdir", "", "subdirectory within the template to use as project root") + cmd.Flags().StringVar(&createSubdirFlag, "subdir", "", "subdirectory in the template to use as project") return cmd } From e477a49e9266987d4d32679f8b0c8accdf9371ab Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 23 Feb 2026 12:35:02 -0500 Subject: [PATCH 3/7] fix: compress complicated temp directory code --- internal/pkg/create/create.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/pkg/create/create.go b/internal/pkg/create/create.go index e9525a76..45576e51 100644 --- a/internal/pkg/create/create.go +++ b/internal/pkg/create/create.go @@ -378,15 +378,14 @@ func createAppFromSubdir(ctx context.Context, dirPath string, template Template, if err != nil { return slackerror.Wrap(err, "failed to create temporary directory") } - // Remove so createApp can create it fresh (go-git requires non-existent target) - os.Remove(tmpDir) defer os.RemoveAll(tmpDir) - if err := createApp(ctx, tmpDir, template, gitBranch, log, fs); err != nil { + cloneDir := filepath.Join(tmpDir, "repo") + if err := createApp(ctx, cloneDir, template, gitBranch, log, fs); err != nil { return err } - subdirPath := filepath.Join(tmpDir, subdir) + subdirPath := filepath.Join(cloneDir, subdir) info, err := os.Stat(subdirPath) if err != nil { if os.IsNotExist(err) { From b1312a7a0252f7f8d35b1f486f839fe4aa7f71fd Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 23 Feb 2026 12:47:11 -0500 Subject: [PATCH 4/7] subdir errors without template flag --- cmd/project/create.go | 7 +++++++ cmd/project/create_test.go | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/cmd/project/create.go b/cmd/project/create.go index 198e9546..b1d7c6d1 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -23,6 +23,7 @@ import ( "github.com/slackapi/slack-cli/internal/logger" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" "github.com/spf13/cobra" @@ -131,6 +132,12 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] return listTemplates(ctx, clients, categoryShortcut) } + // --subdir requires --template + if cmd.Flags().Changed("subdir") && !templateFlagProvided { + return slackerror.New(slackerror.ErrMismatchedFlags). + WithMessage("the --subdir flag requires the --template flag") + } + // Collect the template URL or select a starting template template, err := promptTemplateSelection(cmd, clients, categoryShortcut) if err != nil { diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index 2cc538e7..65d49019 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -320,6 +320,17 @@ func TestCreateCommand(t *testing.T) { cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything) }, }, + "subdir without template flag returns error": { + CmdArgs: []string{"--subdir", "apps/my-app"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"the --subdir flag requires the --template flag"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, "passes subdir flag to create function": { CmdArgs: []string{"--template", "slack-samples/bolt-js-starter-template", "--subdir", "apps/my-app"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { From a62cbcf272a8c73eb351a5437905a5a9aacebf19 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 23 Feb 2026 15:48:56 -0500 Subject: [PATCH 5/7] extract shared copy ignore lists into package-level variables --- internal/pkg/create/create.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/pkg/create/create.go b/internal/pkg/create/create.go index 45576e51..d65ab804 100644 --- a/internal/pkg/create/create.go +++ b/internal/pkg/create/create.go @@ -45,6 +45,12 @@ import ( "github.com/spf13/afero" ) +// copyIgnoreDirectories are directories to skip when copying a template. +var copyIgnoreDirectories = []string{".git", ".venv", "node_modules"} + +// copyIgnoreFiles are files to skip when copying a template. +var copyIgnoreFiles = []string{".DS_Store"} + // CreateArgs are the arguments passed into the Create function type CreateArgs struct { AppName string @@ -337,8 +343,8 @@ func createApp(ctx context.Context, dirPath string, template Template, gitBranch copyDirectoryOpts := goutils.CopyDirectoryOpts{ Src: template.path, Dst: dirPath, - IgnoreDirectories: []string{".git", ".venv", "node_modules"}, - IgnoreFiles: []string{".DS_Store"}, + IgnoreDirectories: copyIgnoreDirectories, + IgnoreFiles: copyIgnoreFiles, } if err := goutils.CopyDirectory(copyDirectoryOpts); err != nil { return slackerror.Wrap(err, "error copying local template") @@ -403,8 +409,8 @@ func createAppFromSubdir(ctx context.Context, dirPath string, template Template, return goutils.CopyDirectory(goutils.CopyDirectoryOpts{ Src: subdirPath, Dst: dirPath, - IgnoreDirectories: []string{".git", ".venv", "node_modules"}, - IgnoreFiles: []string{".DS_Store"}, + IgnoreDirectories: copyIgnoreDirectories, + IgnoreFiles: copyIgnoreFiles, }) } From 78ccb0621fae12c585277d785ddcc9ca35cbafb1 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 23 Feb 2026 15:54:20 -0500 Subject: [PATCH 6/7] use filepath.IsLocal for subdir path validation --- internal/pkg/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/create/create.go b/internal/pkg/create/create.go index d65ab804..28814556 100644 --- a/internal/pkg/create/create.go +++ b/internal/pkg/create/create.go @@ -370,7 +370,7 @@ func normalizeSubdir(subdir string) (string, error) { if cleaned == "." || cleaned == "/" { return "", nil } - if strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { + if !filepath.IsLocal(cleaned) { return "", slackerror.New(slackerror.ErrSubdirNotFound). WithMessage("subdirectory path %q must be relative and within the template", subdir) } From 03222ca76831992f30f65bd27f1ce2bd7d54d713 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 23 Feb 2026 16:11:52 -0500 Subject: [PATCH 7/7] use afero filesystem for createAppFromSubdir instead of os calls --- internal/pkg/create/create.go | 7 ++++--- internal/pkg/create/create_test.go | 25 ++++++++++++------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/pkg/create/create.go b/internal/pkg/create/create.go index 28814556..5650fa67 100644 --- a/internal/pkg/create/create.go +++ b/internal/pkg/create/create.go @@ -380,11 +380,12 @@ func normalizeSubdir(subdir string) (string, error) { // createAppFromSubdir clones the full template into a temp directory, then copies // only the specified subdirectory to the final project path. func createAppFromSubdir(ctx context.Context, dirPath string, template Template, gitBranch string, subdir string, log *logger.Logger, fs afero.Fs) error { - tmpDir, err := os.MkdirTemp("", "slack-create-*") + tmpDirRoot := afero.GetTempDir(fs, "") + tmpDir, err := afero.TempDir(fs, tmpDirRoot, "slack-create-") if err != nil { return slackerror.Wrap(err, "failed to create temporary directory") } - defer os.RemoveAll(tmpDir) + defer func() { _ = fs.RemoveAll(tmpDir) }() cloneDir := filepath.Join(tmpDir, "repo") if err := createApp(ctx, cloneDir, template, gitBranch, log, fs); err != nil { @@ -392,7 +393,7 @@ func createAppFromSubdir(ctx context.Context, dirPath string, template Template, } subdirPath := filepath.Join(cloneDir, subdir) - info, err := os.Stat(subdirPath) + info, err := fs.Stat(subdirPath) if err != nil { if os.IsNotExist(err) { return slackerror.New(slackerror.ErrSubdirNotFound). diff --git a/internal/pkg/create/create_test.go b/internal/pkg/create/create_test.go index c8785d08..9f796817 100644 --- a/internal/pkg/create/create_test.go +++ b/internal/pkg/create/create_test.go @@ -17,7 +17,6 @@ package create import ( "fmt" "net/http" - "os" "path/filepath" "testing" @@ -239,28 +238,28 @@ func TestNormalizeSubdir(t *testing.T) { func TestCreateAppFromSubdir(t *testing.T) { tests := map[string]struct { - setupTemplate func(t *testing.T) string + setupTemplate func(t *testing.T, fs afero.Fs) string subdir string expectError bool errorContains string expectFiles []string }{ "extracts subdirectory from local template": { - setupTemplate: func(t *testing.T) string { + setupTemplate: func(t *testing.T, fs afero.Fs) string { tmpDir := t.TempDir() // Create a subdirectory with a file subdir := filepath.Join(tmpDir, "apps", "my-app") - require.NoError(t, os.MkdirAll(subdir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(subdir, "manifest.json"), []byte(`{}`), 0644)) + require.NoError(t, fs.MkdirAll(subdir, 0755)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(subdir, "manifest.json"), []byte(`{}`), 0644)) // Create a file at root that should NOT be copied - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "README.md"), []byte("root readme"), 0644)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(tmpDir, "README.md"), []byte("root readme"), 0644)) return tmpDir }, subdir: "apps/my-app", expectFiles: []string{"manifest.json"}, }, "returns error for nonexistent subdirectory": { - setupTemplate: func(t *testing.T) string { + setupTemplate: func(t *testing.T, fs afero.Fs) string { return t.TempDir() }, subdir: "nonexistent", @@ -268,9 +267,9 @@ func TestCreateAppFromSubdir(t *testing.T) { errorContains: "was not found in the template", }, "returns error when subdir path is a file": { - setupTemplate: func(t *testing.T) string { + setupTemplate: func(t *testing.T, fs afero.Fs) string { tmpDir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "not-a-dir"), []byte("file"), 0644)) + require.NoError(t, afero.WriteFile(fs, filepath.Join(tmpDir, "not-a-dir"), []byte("file"), 0644)) return tmpDir }, subdir: "not-a-dir", @@ -280,14 +279,14 @@ func TestCreateAppFromSubdir(t *testing.T) { } for name, tc := range tests { t.Run(name, func(t *testing.T) { - templateDir := tc.setupTemplate(t) + fs := afero.NewOsFs() + templateDir := tc.setupTemplate(t, fs) outputDir := t.TempDir() // Remove output dir so CopyDirectory can create it - require.NoError(t, os.Remove(outputDir)) + require.NoError(t, fs.Remove(outputDir)) template := Template{path: templateDir, isLocal: true} log := logger.New(func(event *logger.LogEvent) {}) - fs := afero.NewOsFs() err := createAppFromSubdir(t.Context(), outputDir, template, "", tc.subdir, log, fs) @@ -299,7 +298,7 @@ func TestCreateAppFromSubdir(t *testing.T) { } else { assert.NoError(t, err) for _, f := range tc.expectFiles { - _, statErr := os.Stat(filepath.Join(outputDir, f)) + _, statErr := fs.Stat(filepath.Join(outputDir, f)) assert.NoError(t, statErr, "expected file %s to exist", f) } }