From e6cf706d8dce4f9e91d7b9cef8bfece19b883b03 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Thu, 19 Feb 2026 15:58:02 -0500 Subject: [PATCH] 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",