Skip to content

Commit 44cb5f9

Browse files
srtaalejmwbrookszimeg
authored
feat(create): add '--subdir <path>' flag to 'create' command (#345)
Co-authored-by: Michael Brooks <mbrooks@slack-corp.com> Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
1 parent fbab2b6 commit 44cb5f9

File tree

5 files changed

+271
-4
lines changed

5 files changed

+271
-4
lines changed

cmd/project/create.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/slackapi/slack-cli/internal/logger"
2727
"github.com/slackapi/slack-cli/internal/pkg/create"
2828
"github.com/slackapi/slack-cli/internal/shared"
29+
"github.com/slackapi/slack-cli/internal/slackerror"
2930
"github.com/slackapi/slack-cli/internal/slacktrace"
3031
"github.com/slackapi/slack-cli/internal/style"
3132
"github.com/spf13/cobra"
@@ -36,6 +37,7 @@ var createTemplateURLFlag string
3637
var createGitBranchFlag string
3738
var createAppNameFlag string
3839
var createListFlag bool
40+
var createSubdirFlag string
3941

4042
// Handle to client's create function used for testing
4143
// TODO - Find best practice, such as using an Interface and Struct to create a client
@@ -69,6 +71,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`,
6971
{Command: "create agent my-agent-app", Meaning: "Create a new AI Agent app"},
7072
{Command: "create my-project -t slack-samples/deno-hello-world", Meaning: "Start a new project from a specific template"},
7173
{Command: "create --name my-project", Meaning: "Create a project named 'my-project'"},
74+
{Command: "create my-project -t org/monorepo --subdir apps/my-app", Meaning: "Create from a subdirectory of a template"},
7275
}),
7376
Args: cobra.MaximumNArgs(2),
7477
RunE: func(cmd *cobra.Command, args []string) error {
@@ -82,6 +85,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`,
8285
cmd.Flags().StringVarP(&createGitBranchFlag, "branch", "b", "", "name of git branch to checkout")
8386
cmd.Flags().StringVarP(&createAppNameFlag, "name", "n", "", "name for your app (overrides the name argument)")
8487
cmd.Flags().BoolVar(&createListFlag, "list", false, "list available app templates")
88+
cmd.Flags().StringVar(&createSubdirFlag, "subdir", "", "subdirectory in the template to use as project")
8589

8690
return cmd
8791
}
@@ -131,6 +135,12 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
131135
return listTemplates(ctx, clients, categoryShortcut)
132136
}
133137

138+
// --subdir requires --template
139+
if cmd.Flags().Changed("subdir") && !templateFlagProvided {
140+
return slackerror.New(slackerror.ErrMismatchedFlags).
141+
WithMessage("The --subdir flag requires the --template flag")
142+
}
143+
134144
// Collect the template URL or select a starting template
135145
template, err := promptTemplateSelection(cmd, clients, categoryShortcut)
136146
if err != nil {
@@ -163,6 +173,7 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
163173
AppName: appNameArg,
164174
Template: template,
165175
GitBranch: createGitBranchFlag,
176+
Subdir: createSubdirFlag,
166177
}
167178
clients.EventTracker.SetAppTemplate(template.GetTemplatePath())
168179

cmd/project/create_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,61 @@ func TestCreateCommand(t *testing.T) {
447447
cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
448448
},
449449
},
450+
"subdir without template flag returns error": {
451+
CmdArgs: []string{"--subdir", "apps/my-app"},
452+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
453+
createClientMock = new(CreateClientMock)
454+
CreateFunc = createClientMock.Create
455+
},
456+
ExpectedErrorStrings: []string{"The --subdir flag requires the --template flag"},
457+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
458+
createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
459+
},
460+
},
461+
"passes subdir flag to create function": {
462+
CmdArgs: []string{"--template", "slack-samples/bolt-js-starter-template", "--subdir", "apps/my-app"},
463+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
464+
cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything).
465+
Return(
466+
iostreams.SelectPromptResponse{
467+
Flag: true,
468+
Option: "slack-samples/bolt-js-starter-template",
469+
},
470+
nil,
471+
)
472+
cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything).
473+
Return(
474+
iostreams.SelectPromptResponse{
475+
Flag: true,
476+
Option: "slack-samples/bolt-js-starter-template",
477+
},
478+
nil,
479+
)
480+
createClientMock = new(CreateClientMock)
481+
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil)
482+
CreateFunc = createClientMock.Create
483+
},
484+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
485+
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
486+
require.NoError(t, err)
487+
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(args create.CreateArgs) bool {
488+
return args.AppName != "" && args.Template == template && args.Subdir == "apps/my-app"
489+
}))
490+
},
491+
},
492+
"list flag ignores subdir": {
493+
CmdArgs: []string{"--list", "--subdir", "foo"},
494+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
495+
createClientMock = new(CreateClientMock)
496+
CreateFunc = createClientMock.Create
497+
},
498+
ExpectedOutputs: []string{
499+
"Getting started",
500+
},
501+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
502+
createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
503+
},
504+
},
450505
"lists all templates with --list flag": {
451506
CmdArgs: []string{"--list"},
452507
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {

internal/pkg/create/create.go

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,18 @@ import (
4343
"github.com/spf13/afero"
4444
)
4545

46+
// copyIgnoreDirectories are directories to skip when copying a template.
47+
var copyIgnoreDirectories = []string{".git", ".venv", "node_modules"}
48+
49+
// copyIgnoreFiles are files to skip when copying a template.
50+
var copyIgnoreFiles = []string{".DS_Store"}
51+
4652
// CreateArgs are the arguments passed into the Create function
4753
type CreateArgs struct {
4854
AppName string
4955
Template Template
5056
GitBranch string
57+
Subdir string
5158
}
5259

5360
// Create will create a new Slack app on the file system and app manifest on the Slack API.
@@ -119,8 +126,19 @@ func Create(ctx context.Context, clients *shared.ClientFactory, log *logger.Logg
119126
}))
120127

121128
// Create the project from a templateURL
122-
if err := createApp(ctx, projectDirPath, createArgs.Template, createArgs.GitBranch, log, clients.Fs); err != nil {
123-
return "", slackerror.Wrap(err, slackerror.ErrAppCreate)
129+
subdir, err := normalizeSubdir(createArgs.Subdir)
130+
if err != nil {
131+
return "", err
132+
}
133+
134+
if subdir != "" {
135+
if err := createAppFromSubdir(ctx, projectDirPath, createArgs.Template, createArgs.GitBranch, subdir, log, clients.Fs); err != nil {
136+
return "", slackerror.Wrap(err, slackerror.ErrAppCreate)
137+
}
138+
} else {
139+
if err := createApp(ctx, projectDirPath, createArgs.Template, createArgs.GitBranch, log, clients.Fs); err != nil {
140+
return "", slackerror.Wrap(err, slackerror.ErrAppCreate)
141+
}
124142
}
125143

126144
// Change into the project directory to configure defaults and dependencies
@@ -315,8 +333,8 @@ func createApp(ctx context.Context, dirPath string, template Template, gitBranch
315333
copyDirectoryOpts := goutils.CopyDirectoryOpts{
316334
Src: template.path,
317335
Dst: dirPath,
318-
IgnoreDirectories: []string{".git", ".venv", "node_modules"},
319-
IgnoreFiles: []string{".DS_Store"},
336+
IgnoreDirectories: copyIgnoreDirectories,
337+
IgnoreFiles: copyIgnoreFiles,
320338
}
321339
if err := goutils.CopyDirectory(copyDirectoryOpts); err != nil {
322340
return slackerror.Wrap(err, "error copying local template")
@@ -333,6 +351,60 @@ func createApp(ctx context.Context, dirPath string, template Template, gitBranch
333351
return nil
334352
}
335353

354+
// normalizeSubdir cleans the subdir path and returns "" if it resolves to root.
355+
func normalizeSubdir(subdir string) (string, error) {
356+
if subdir == "" {
357+
return "", nil
358+
}
359+
cleaned := filepath.Clean(subdir)
360+
if cleaned == "." || cleaned == "/" {
361+
return "", nil
362+
}
363+
if !filepath.IsLocal(cleaned) {
364+
return "", slackerror.New(slackerror.ErrSubdirNotFound).
365+
WithMessage("Subdirectory path %q must be relative and within the template", subdir)
366+
}
367+
return cleaned, nil
368+
}
369+
370+
// createAppFromSubdir clones the full template into a temp directory, then copies
371+
// only the specified subdirectory to the final project path.
372+
func createAppFromSubdir(ctx context.Context, dirPath string, template Template, gitBranch string, subdir string, log *logger.Logger, fs afero.Fs) error {
373+
tmpDirRoot := afero.GetTempDir(fs, "")
374+
tmpDir, err := afero.TempDir(fs, tmpDirRoot, "slack-create-")
375+
if err != nil {
376+
return slackerror.Wrap(err, "failed to create temporary directory")
377+
}
378+
defer func() { _ = fs.RemoveAll(tmpDir) }()
379+
380+
cloneDir := filepath.Join(tmpDir, "repo")
381+
if err := createApp(ctx, cloneDir, template, gitBranch, log, fs); err != nil {
382+
return err
383+
}
384+
385+
subdirPath := filepath.Join(cloneDir, subdir)
386+
info, err := fs.Stat(subdirPath)
387+
if err != nil {
388+
if os.IsNotExist(err) {
389+
return slackerror.New(slackerror.ErrSubdirNotFound).
390+
WithMessage("Subdirectory %q was not found in the template", subdir).
391+
WithRemediation("Check that the path exists in the template at %q", template.GetTemplatePath())
392+
}
393+
return slackerror.Wrap(err, "failed to access subdirectory")
394+
}
395+
if !info.IsDir() {
396+
return slackerror.New(slackerror.ErrSubdirNotFound).
397+
WithMessage("Path %q in the template is not a directory", subdir)
398+
}
399+
400+
return goutils.CopyDirectory(goutils.CopyDirectoryOpts{
401+
Src: subdirPath,
402+
Dst: dirPath,
403+
IgnoreDirectories: copyIgnoreDirectories,
404+
IgnoreFiles: copyIgnoreFiles,
405+
})
406+
}
407+
336408
// InstallProjectDependencies installs the project runtime dependencies or
337409
// continues with next steps if that fails. You can specify the manifestSource
338410
// for the project configuration file (default: ManifestSourceLocal)

internal/pkg/create/create_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222

2323
"github.com/slackapi/slack-cli/internal/config"
2424
"github.com/slackapi/slack-cli/internal/experiment"
25+
"github.com/slackapi/slack-cli/internal/logger"
2526
"github.com/slackapi/slack-cli/internal/shared"
2627
"github.com/slackapi/slack-cli/internal/slackcontext"
2728
"github.com/slackapi/slack-cli/internal/slackhttp"
@@ -198,6 +199,128 @@ func TestCreateGitArgs(t *testing.T) {
198199
assert.Equal(t, expectedArgs, testGitArgs)
199200
}
200201

202+
func TestNormalizeSubdir(t *testing.T) {
203+
tests := map[string]struct {
204+
input string
205+
expected string
206+
expectError bool
207+
}{
208+
"empty string returns empty": {
209+
input: "",
210+
expected: "",
211+
},
212+
"dot returns empty": {
213+
input: ".",
214+
expected: "",
215+
},
216+
"slash returns empty": {
217+
input: "/",
218+
expected: "",
219+
},
220+
"simple subdir": {
221+
input: "pydantic-ai/",
222+
expected: "pydantic-ai",
223+
},
224+
"dot-prefixed subdir": {
225+
input: "./my-app",
226+
expected: "my-app",
227+
},
228+
"nested subdir": {
229+
input: "apps/my-app",
230+
expected: "apps/my-app",
231+
},
232+
"parent traversal is rejected": {
233+
input: "../escape",
234+
expectError: true,
235+
},
236+
"nested parent traversal is rejected": {
237+
input: "foo/../../escape",
238+
expectError: true,
239+
},
240+
}
241+
for name, tc := range tests {
242+
t.Run(name, func(t *testing.T) {
243+
result, err := normalizeSubdir(tc.input)
244+
if tc.expectError {
245+
assert.Error(t, err)
246+
} else {
247+
assert.NoError(t, err)
248+
assert.Equal(t, tc.expected, result)
249+
}
250+
})
251+
}
252+
}
253+
254+
func TestCreateAppFromSubdir(t *testing.T) {
255+
tests := map[string]struct {
256+
setupTemplate func(t *testing.T, fs afero.Fs) string
257+
subdir string
258+
expectError bool
259+
errorContains string
260+
expectFiles []string
261+
}{
262+
"extracts subdirectory from local template": {
263+
setupTemplate: func(t *testing.T, fs afero.Fs) string {
264+
tmpDir := t.TempDir()
265+
// Create a subdirectory with a file
266+
subdir := filepath.Join(tmpDir, "apps", "my-app")
267+
require.NoError(t, fs.MkdirAll(subdir, 0755))
268+
require.NoError(t, afero.WriteFile(fs, filepath.Join(subdir, "manifest.json"), []byte(`{}`), 0644))
269+
// Create a file at root that should NOT be copied
270+
require.NoError(t, afero.WriteFile(fs, filepath.Join(tmpDir, "README.md"), []byte("root readme"), 0644))
271+
return tmpDir
272+
},
273+
subdir: "apps/my-app",
274+
expectFiles: []string{"manifest.json"},
275+
},
276+
"returns error for nonexistent subdirectory": {
277+
setupTemplate: func(t *testing.T, fs afero.Fs) string {
278+
return t.TempDir()
279+
},
280+
subdir: "nonexistent",
281+
expectError: true,
282+
errorContains: "was not found in the template",
283+
},
284+
"returns error when subdir path is a file": {
285+
setupTemplate: func(t *testing.T, fs afero.Fs) string {
286+
tmpDir := t.TempDir()
287+
require.NoError(t, afero.WriteFile(fs, filepath.Join(tmpDir, "not-a-dir"), []byte("file"), 0644))
288+
return tmpDir
289+
},
290+
subdir: "not-a-dir",
291+
expectError: true,
292+
errorContains: "is not a directory",
293+
},
294+
}
295+
for name, tc := range tests {
296+
t.Run(name, func(t *testing.T) {
297+
fs := afero.NewOsFs()
298+
templateDir := tc.setupTemplate(t, fs)
299+
outputDir := t.TempDir()
300+
// Remove output dir so CopyDirectory can create it
301+
require.NoError(t, fs.Remove(outputDir))
302+
303+
template := Template{path: templateDir, isLocal: true}
304+
log := logger.New(func(event *logger.LogEvent) {})
305+
306+
err := createAppFromSubdir(t.Context(), outputDir, template, "", tc.subdir, log, fs)
307+
308+
if tc.expectError {
309+
assert.Error(t, err)
310+
if tc.errorContains != "" {
311+
assert.Contains(t, err.Error(), tc.errorContains)
312+
}
313+
} else {
314+
assert.NoError(t, err)
315+
for _, f := range tc.expectFiles {
316+
_, statErr := fs.Stat(filepath.Join(outputDir, f))
317+
assert.NoError(t, statErr, "expected file %s to exist", f)
318+
}
319+
}
320+
})
321+
}
322+
}
323+
201324
func Test_Create_installProjectDependencies(t *testing.T) {
202325
tests := map[string]struct {
203326
experiments []string

internal/slackerror/errors.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ const (
225225
ErrSocketConnection = "socket_connection_error"
226226
ErrScopesExceedAppConfig = "scopes_exceed_app_config"
227227
ErrStreamingActivityLogs = "streaming_activity_logs_error"
228+
ErrSubdirNotFound = "subdir_not_found"
228229
ErrSurveyConfigNotFound = "survey_config_not_found"
229230
ErrSystemConfigIDNotFound = "system_config_id_not_found"
230231
ErrSystemRequirementsFailed = "system_requirements_failed"
@@ -1391,6 +1392,11 @@ Otherwise start your app for local development with: %s`,
13911392
Message: "Failed to stream the most recent activity logs",
13921393
},
13931394

1395+
ErrSubdirNotFound: {
1396+
Code: ErrSubdirNotFound,
1397+
Message: "The specified subdirectory was not found in the template repository",
1398+
},
1399+
13941400
ErrSurveyConfigNotFound: {
13951401
Code: ErrSurveyConfigNotFound,
13961402
Message: "Survey config not found",

0 commit comments

Comments
 (0)