Skip to content

Commit e6cf706

Browse files
committed
feat(create): add 'subdir' flag to create command
1 parent 113aa42 commit e6cf706

5 files changed

Lines changed: 248 additions & 2 deletions

File tree

cmd/project/create.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ var createTemplateURLFlag string
3333
var createGitBranchFlag string
3434
var createAppNameFlag string
3535
var createListFlag bool
36+
var createSubdirFlag string
3637

3738
// Handle to client's create function used for testing
3839
// 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.`,
6667
{Command: "create agent my-agent-app", Meaning: "Create a new AI Agent app"},
6768
{Command: "create my-project -t slack-samples/deno-hello-world", Meaning: "Start a new project from a specific template"},
6869
{Command: "create --name my-project", Meaning: "Create a project named 'my-project'"},
70+
{Command: "create my-project -t org/monorepo --subdir apps/my-app", Meaning: "Create from a subdirectory of a template"},
6971
}),
7072
Args: cobra.MaximumNArgs(2),
7173
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.`,
7981
cmd.Flags().StringVarP(&createGitBranchFlag, "branch", "b", "", "name of git branch to checkout")
8082
cmd.Flags().StringVarP(&createAppNameFlag, "name", "n", "", "name for your app (overrides the name argument)")
8183
cmd.Flags().BoolVar(&createListFlag, "list", false, "list available app templates")
84+
cmd.Flags().StringVar(&createSubdirFlag, "subdir", "", "subdirectory within the template to use as project root")
8285

8386
return cmd
8487
}
@@ -141,6 +144,7 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
141144
AppName: appNameArg,
142145
Template: template,
143146
GitBranch: createGitBranchFlag,
147+
Subdir: createSubdirFlag,
144148
}
145149
clients.EventTracker.SetAppTemplate(template.GetTemplatePath())
146150

cmd/project/create_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,52 @@ func TestCreateCommand(t *testing.T) {
320320
cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything)
321321
},
322322
},
323+
"passes subdir flag to create function": {
324+
CmdArgs: []string{"--template", "slack-samples/bolt-js-starter-template", "--subdir", "apps/my-app"},
325+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
326+
cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything).
327+
Return(
328+
iostreams.SelectPromptResponse{
329+
Flag: true,
330+
Option: "slack-samples/bolt-js-starter-template",
331+
},
332+
nil,
333+
)
334+
cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything).
335+
Return(
336+
iostreams.SelectPromptResponse{
337+
Flag: true,
338+
Option: "slack-samples/bolt-js-starter-template",
339+
},
340+
nil,
341+
)
342+
createClientMock = new(CreateClientMock)
343+
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil)
344+
CreateFunc = createClientMock.Create
345+
},
346+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
347+
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
348+
require.NoError(t, err)
349+
expected := create.CreateArgs{
350+
Template: template,
351+
Subdir: "apps/my-app",
352+
}
353+
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
354+
},
355+
},
356+
"list flag ignores subdir": {
357+
CmdArgs: []string{"--list", "--subdir", "foo"},
358+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
359+
createClientMock = new(CreateClientMock)
360+
CreateFunc = createClientMock.Create
361+
},
362+
ExpectedOutputs: []string{
363+
"Getting started",
364+
},
365+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
366+
createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
367+
},
368+
},
323369
"lists all templates with --list flag": {
324370
CmdArgs: []string{"--list"},
325371
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {

internal/pkg/create/create.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type CreateArgs struct {
5050
AppName string
5151
Template Template
5252
GitBranch string
53+
Subdir string
5354
}
5455

5556
// 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
121122
}))
122123

123124
// Create the project from a templateURL
124-
if err := createApp(ctx, projectDirPath, createArgs.Template, createArgs.GitBranch, log, clients.Fs); err != nil {
125-
return "", slackerror.Wrap(err, slackerror.ErrAppCreate)
125+
subdir, err := normalizeSubdir(createArgs.Subdir)
126+
if err != nil {
127+
return "", err
128+
}
129+
130+
if subdir != "" {
131+
if err := createAppFromSubdir(ctx, projectDirPath, createArgs.Template, createArgs.GitBranch, subdir, log, clients.Fs); err != nil {
132+
return "", slackerror.Wrap(err, slackerror.ErrAppCreate)
133+
}
134+
} else {
135+
if err := createApp(ctx, projectDirPath, createArgs.Template, createArgs.GitBranch, log, clients.Fs); err != nil {
136+
return "", slackerror.Wrap(err, slackerror.ErrAppCreate)
137+
}
126138
}
127139

128140
// Change into the project directory to configure defaults and dependencies
@@ -343,6 +355,60 @@ func createApp(ctx context.Context, dirPath string, template Template, gitBranch
343355
return nil
344356
}
345357

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

internal/pkg/create/create_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ package create
1717
import (
1818
"fmt"
1919
"net/http"
20+
"os"
2021
"path/filepath"
2122
"testing"
2223

2324
"github.com/slackapi/slack-cli/internal/config"
2425
"github.com/slackapi/slack-cli/internal/experiment"
26+
"github.com/slackapi/slack-cli/internal/logger"
2527
"github.com/slackapi/slack-cli/internal/shared"
2628
"github.com/slackapi/slack-cli/internal/slackcontext"
2729
"github.com/slackapi/slack-cli/internal/slackhttp"
@@ -183,6 +185,128 @@ func TestCreateGitArgs(t *testing.T) {
183185
assert.Equal(t, expectedArgs, testGitArgs)
184186
}
185187

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