feat(create): add '--subdir <path>' flag to 'create' command#345
feat(create): add '--subdir <path>' flag to 'create' command#345
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #345 +/- ##
==========================================
+ Coverage 64.62% 64.77% +0.14%
==========================================
Files 212 212
Lines 17820 17869 +49
==========================================
+ Hits 11517 11575 +58
+ Misses 5225 5213 -12
- Partials 1078 1081 +3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
mwbrooks
left a comment
There was a problem hiding this comment.
🙌🏻 Amazing @srtaalej! Holy smokes, I can't believe you shared this PR today 👌🏻
🧪 Testing locally works great!
$ slack create -t https://github.com/slack-samples/bolt-python-examples --subdir block-kit📝 I've left some feedback. For the afero feedback, think about it.
Generally we've put a huge amount of effort into ensuring the CLI uses afero whenever possible. In unit tests, Afero is a "virtual" memory-based file system - so there's no risk to the developer's machine and the tests run faster because it's not actually reading/writing to disk. However, sometimes it's difficult to use it certain areas. If you use
aferoyou may need to change your tests to not uset.TempDir()and instead use Afero to create the temp directory.
| // normalizeSubdir cleans the subdir path and returns "" if it resolves to root. | ||
| func normalizeSubdir(subdir string) (string, error) { |
| cleaned := filepath.Clean(subdir) | ||
| if cleaned == "." || cleaned == "/" { | ||
| return "", nil | ||
| } |
There was a problem hiding this comment.
suggestion: After filepath.Clean() maybe we should use filepath.isLocal(cleaned).
This function appears to check if the file path is within the subtree and prevent traversal attacks where the path tries to access files outside of the root directory (template directory).
I haven't used it personally, but it seems like a good security measure.
| 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) | ||
| } |
There was a problem hiding this comment.
praise: clean implementation choice!
There was a problem hiding this comment.
🪬 question(non-blocking): Would passing subdir to createApp in all instances - either a default "." or the normalized path - reduce this implementation more? I'm concerned that a change to the temporary directory logic might require repeated change if we keep separate setups for this.
internal/pkg/create/create.go
Outdated
| // 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-*") |
There was a problem hiding this comment.
suggestion: We should not use os. calls directly whenever possible. These calls are very hard to test because we can't mock it - it will create a temp directory during the unit test.
Instead, we should use our fs afero.Fs filesystem library. This uses os in production and a memory-based file system in tests.
I imagine we can use afero.GetTempDir() and afero.TempDir() to look something like:
tempDirRoot := afero.GetTempDir(fs, "")
tempDirPath, err := afero.TempDir(fs, tmpDirRoot, "slack-create-")There was a problem hiding this comment.
ty for sharing - i didnt know we were using a package for temp directories 👍 this is a game changer 💯
internal/pkg/create/create.go
Outdated
| if err := createApp(ctx, tmpDir, template, gitBranch, log, fs); err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
praise: Clever and clean approach!
| 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()) |
internal/pkg/create/create.go
Outdated
| // Remove so createApp can create it fresh (go-git requires non-existent target) | ||
| os.Remove(tmpDir) |
There was a problem hiding this comment.
question: Why do we make the temp directory on L377 and then immediately delete it?
There was a problem hiding this comment.
MkdirTemp generates unique path names which we want for createApp. But we don't want the directory itself which is why its deleted right away. Its a workaround to extract the unique name creation part of the MkdirTemp function so we can use it in createApp.
Co-authored-by: Michael Brooks <mbrooks@slack-corp.com>
zimeg
left a comment
There was a problem hiding this comment.
@srtaalej Super nice changesets here! I'm leaving a few comments but this works well for me - it's a neat unlock! 🎉
Before merging we might want to revisit error messages and codes. Please correct me if sentence casings should be lowercased, but I find that this is confusing in most implementations for me... The error code itself had another note, but these aren't blocking changes!
I also resolved a few comments with recent commits but found this a bit confusing in review. If I can kind request these be marked resolved with matching commits before next review it'd be kind 💌
| // --subdir requires --template | ||
| if cmd.Flags().Changed("subdir") && !templateFlagProvided { | ||
| return slackerror.New(slackerror.ErrMismatchedFlags). | ||
| WithMessage("the --subdir flag requires the --template flag") |
There was a problem hiding this comment.
| WithMessage("the --subdir flag requires the --template flag") | |
| WithMessage("The --subdir flag requires the --template flag") |
🏁 polish: To match surrounding error messages. Though I'm not confident in how consistent we are with this... Let's take note to include this in a Style Guide soon!
| ErrSocketConnection = "socket_connection_error" | ||
| ErrScopesExceedAppConfig = "scopes_exceed_app_config" | ||
| ErrStreamingActivityLogs = "streaming_activity_logs_error" | ||
| ErrSubdirNotFound = "subdir_not_found" |
There was a problem hiding this comment.
💡 thought(non-blocking): Would it make sense to group this alongside other template errors? To me the referenced subdir wouldn't be clear at a glance. I'd suggest:
template_subdir_not_found
Which is so close to template_path_not_found!
| 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) | ||
| } |
There was a problem hiding this comment.
🪬 question(non-blocking): Would passing subdir to createApp in all instances - either a default "." or the normalized path - reduce this implementation more? I'm concerned that a change to the temporary directory logic might require repeated change if we keep separate setups for this.
| } | ||
| if !filepath.IsLocal(cleaned) { | ||
| return "", slackerror.New(slackerror.ErrSubdirNotFound). | ||
| WithMessage("subdirectory path %q must be relative and within the template", subdir) |
There was a problem hiding this comment.
| WithMessage("subdirectory path %q must be relative and within the template", subdir) | |
| WithMessage("Subdirectory path %q must be relative and within the template", subdir) |
🧮 quibble: Casing to errors as a complete sentence!
| if err != nil { | ||
| if os.IsNotExist(err) { | ||
| return slackerror.New(slackerror.ErrSubdirNotFound). | ||
| WithMessage("subdirectory %q was not found in the template", subdir). |
There was a problem hiding this comment.
| WithMessage("subdirectory %q was not found in the template", subdir). | |
| WithMessage("Subdirectory %q was not found in the template", subdir). |
🚢 quibble: Casings to the sentence structure!
| } | ||
| if !info.IsDir() { | ||
| return slackerror.New(slackerror.ErrSubdirNotFound). | ||
| WithMessage("path %q in the template is not a directory", subdir) |
There was a problem hiding this comment.
| WithMessage("path %q in the template is not a directory", subdir) | |
| WithMessage("Path %q in the template is not a directory", subdir) |
📠 quibble: Forbid this error be reached, but let's start with a capital path here.
Changelog
Added
--subdirflag to slack create for extracting a subdirectory from a template repository as the project root. This supports monorepo-style templates where multiple apps live in subdirectories (e.g.slack create my-app -t org/monorepo --subdir apps/my-app). 🦋Summary
This PR adds a
--subdir <path>flag to slack create that works alongside--templateand--branch. When provided, the full template is cloned into a temporary directory, then only the specified subdirectory is copied to the final project path.Behavior:
--subdir pydantic-ai/→ extracts the pydantic-ai directory from the template--subdir . or --subdir /→ equivalent to omitting the flag (full repo)--subdir ../escape→ rejected with an error (path traversal)--subdir nonexistent→ error with remediation hint pointing to the template--list --subdir foo→ --list returns early, --subdir is ignoredRequirements