diff --git a/cmd/apps/init.go b/cmd/apps/init.go index abbe40828eb..37abd9bcd06 100644 --- a/cmd/apps/init.go +++ b/cmd/apps/init.go @@ -618,6 +618,20 @@ func awaitTemplate(ctx context.Context, ch <-chan templateResult) (string, func( } } +// commitInPlace derives the app name from the cwd basename and verifies that +// the cwd is suitable for in-place scaffolding (empty modulo .git). +// Returns the derived app name on success. +func commitInPlace() (string, error) { + appName, err := prompt.DeriveInPlaceAppName(".") + if err != nil { + return "", err + } + if err := prompt.CheckInPlaceDirectory("."); err != nil { + return "", err + } + return appName, nil +} + // findProjectSrcDir locates the actual source directory inside a template. // Templates may nest their content inside a {{.project_name}} directory. func findProjectSrcDir(templateDir string) string { @@ -834,31 +848,82 @@ func runCreate(ctx context.Context, opts createOptions) error { }() // Step 1: Get project name (clone runs in parallel for remote templates) - destDir := opts.name - if opts.outputDir != "" { - destDir = filepath.Join(opts.outputDir, opts.name) + if opts.name == prompt.InPlaceName && opts.outputDir != "" { + return prompt.ErrNameDotWithOutputDir } - if opts.name == "" { - if !isInteractive { - return errors.New("--name is required in non-interactive mode") - } - name, err := prompt.PromptForProjectName(ctx, opts.outputDir) + var ( + destDir string + inPlace bool + ) + switch { + case opts.name == prompt.InPlaceName: + appName, err := commitInPlace() if err != nil { return err } - opts.name = name + opts.name = appName + destDir = "." + inPlace = true + case opts.name != "": + if err := prompt.ValidateProjectName(opts.name); err != nil { + return err + } destDir = opts.name if opts.outputDir != "" { destDir = filepath.Join(opts.outputDir, opts.name) } - } else { - if err := prompt.ValidateProjectName(opts.name); err != nil { - return err - } if _, err := os.Stat(destDir); err == nil { return fmt.Errorf("directory %s already exists", destDir) } + default: + if !isInteractive { + return errors.New("--name is required in non-interactive mode") + } + // Offer in-place scaffolding when the current directory is empty + // (modulo .git) and its basename is a valid app name. Skipped when + // --output-dir was set, since in-place targets cwd and would silently + // drop the flag — same reasoning as the --name . / --output-dir mutex + // above. + if opts.outputDir == "" { + if basename, ok := prompt.ShouldOfferInPlace("."); ok { + useCurrent, err := prompt.PromptScaffoldLocation(ctx, basename) + if err != nil { + return err + } + if useCurrent { + // Re-check immediately before committing — the directory may + // have changed between offer and answer. + if err := prompt.CheckInPlaceDirectory("."); err != nil { + return err + } + opts.name = basename + destDir = "." + inPlace = true + } + } + } + if !inPlace { + name, err := prompt.PromptForProjectName(ctx, opts.outputDir) + if err != nil { + return err + } + if name == prompt.InPlaceName { + appName, err := commitInPlace() + if err != nil { + return err + } + opts.name = appName + destDir = "." + inPlace = true + } else { + opts.name = name + destDir = name + if opts.outputDir != "" { + destDir = filepath.Join(opts.outputDir, name) + } + } + } } // Step 2: Wait for template (may already be done if the user took time typing the name) @@ -1026,9 +1091,17 @@ func runCreate(ctx context.Context, opts createOptions) error { var projectCreated bool var runErr error defer func() { - if runErr != nil && (projectCreated || npmInstallCh != nil) { - os.RemoveAll(destDir) + if runErr == nil || (!projectCreated && npmInstallCh == nil) { + return } + if inPlace { + // destDir is "." here; a wholesale RemoveAll would wipe the + // user's current directory (including any pre-existing .git). + // Leave the partial scaffold and tell the user to clean up. + log.Warnf(ctx, "scaffold failed in current directory; review and clean up generated files manually (e.g. with git status / git clean -fd)") + return + } + os.RemoveAll(destDir) }() // Set description default @@ -1151,9 +1224,9 @@ func runCreate(ctx context.Context, opts createOptions) error { // Show next steps only if user didn't choose to deploy or run showNextSteps := !shouldDeploy && runMode == prompt.RunModeNone if showNextSteps { - prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, nextStepsCmd) + prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, nextStepsCmd, inPlace) } else { - prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, "") + prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, "", inPlace) } // Print any onSetupMessage declared by selected plugins in the template manifest. @@ -1474,12 +1547,19 @@ func copyTemplate(ctx context.Context, src, dest string, vars templateVars) (int // removeEmptyDirs removes empty directories under root, deepest-first. // It is used to clean up directories that were created eagerly but ended up // with no files after conditional template rendering skipped their contents. +// +// .git is skipped so in-place scaffolding (root == ".") never walks into a +// pre-existing repo and deletes its empty subdirectories (refs/heads, +// refs/tags, objects/info, objects/pack are all empty after `git init`). func removeEmptyDirs(root string) error { var dirs []string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } + if d.IsDir() && d.Name() == ".git" && path != root { + return filepath.SkipDir + } if d.IsDir() && path != root { dirs = append(dirs, path) } diff --git a/cmd/apps/init_test.go b/cmd/apps/init_test.go index fe406dfb7b3..ce3d2e33b04 100644 --- a/cmd/apps/init_test.go +++ b/cmd/apps/init_test.go @@ -13,6 +13,7 @@ import ( "github.com/databricks/cli/libs/apps/manifest" "github.com/databricks/cli/libs/apps/prompt" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/env" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -1072,3 +1073,78 @@ func TestStartBackgroundNpmInstall_TemplateSubstitution(t *testing.T) { assert.Contains(t, string(got), `"cool-project"`) assert.NotContains(t, string(got), "{{.projectName}}") } + +// makeChildDir creates and returns an empty subdirectory of t.TempDir() with +// the requested name. Used to control filepath.Base(cwd) for in-place tests. +func makeChildDir(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + require.NoError(t, os.MkdirAll(dir, 0o755)) + return dir +} + +func TestCommitInPlace_Success(t *testing.T) { + dir := makeChildDir(t, "my-app") + t.Chdir(dir) + + name, err := commitInPlace() + require.NoError(t, err) + assert.Equal(t, "my-app", name) +} + +func TestCommitInPlace_AllowsDotGit(t *testing.T) { + dir := makeChildDir(t, "my-app") + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755)) + t.Chdir(dir) + + name, err := commitInPlace() + require.NoError(t, err) + assert.Equal(t, "my-app", name) +} + +func TestCommitInPlace_RejectsPreExistingGitignore(t *testing.T) { + // The template ships _gitignore that renames to .gitignore on copy. + // Allowing a pre-existing .gitignore would silently destroy the user's + // file via os.WriteFile, so we refuse the directory up front. + dir := makeChildDir(t, "my-app") + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("node_modules\n"), 0o644)) + t.Chdir(dir) + + _, err := commitInPlace() + require.Error(t, err) + assert.Contains(t, err.Error(), "not empty") +} + +func TestCommitInPlace_RejectsStrayFiles(t *testing.T) { + dir := makeChildDir(t, "my-app") + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644)) + t.Chdir(dir) + + _, err := commitInPlace() + require.Error(t, err) + assert.Contains(t, err.Error(), "not empty") +} + +func TestCommitInPlace_RejectsInvalidBasename(t *testing.T) { + dir := makeChildDir(t, "My_App") + t.Chdir(dir) + + _, err := commitInPlace() + require.Error(t, err) + assert.Contains(t, err.Error(), "My_App") +} + +func TestRunCreate_NameDotAndOutputDirAreMutuallyExclusive(t *testing.T) { + dir := makeChildDir(t, "my-app") + t.Chdir(dir) + + ctx := cmdio.MockDiscard(t.Context()) + err := runCreate(ctx, createOptions{ + name: prompt.InPlaceName, + nameProvided: true, + outputDir: "elsewhere", + }) + require.Error(t, err) + assert.ErrorIs(t, err, prompt.ErrNameDotWithOutputDir) +} diff --git a/libs/apps/prompt/prompt.go b/libs/apps/prompt/prompt.go index 277aa949e1f..1f4f2933424 100644 --- a/libs/apps/prompt/prompt.go +++ b/libs/apps/prompt/prompt.go @@ -124,8 +124,25 @@ type CreateProjectConfig struct { const ( MaxAppNameLength = 30 DevTargetPrefix = "dev-" + + // InPlaceName is the sentinel value for --name that scaffolds the app + // into the current working directory instead of a new subdirectory. + InPlaceName = "." ) +// inPlaceAllowedEntries lists entries permitted in the destination directory +// when scaffolding in place. Anything else triggers an error so that the +// template never overwrites existing user files. +// +// We don't allow anything that the template itself emits (including .gitignore via +// the _gitignore rename) — otherwise +// the user's existing file would be silently overwritten by os.WriteFile during +// copyTemplate. Symlinks named like an allow-listed entry are rejected by +// CheckInPlaceDirectory below to block symlink-follow overwrites. +var inPlaceAllowedEntries = map[string]bool{ + ".git": true, +} + // projectNamePattern is the compiled regex for validating project names. // Pre-compiled for efficiency since validation is called on every keystroke. var projectNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) @@ -133,8 +150,12 @@ var projectNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) // ValidateProjectName validates the project name for length and pattern constraints. // It checks that the name plus the "dev-" prefix doesn't exceed 30 characters, // and that the name follows the pattern: starts with a letter, contains only -// lowercase letters, numbers, or hyphens. +// lowercase letters, numbers, or hyphens. The literal "." is accepted as the +// in-place sentinel and validated separately by DeriveInPlaceAppName. func ValidateProjectName(s string) error { + if s == InPlaceName { + return nil + } if s == "" { return errors.New("project name is required") } @@ -154,6 +175,63 @@ func ValidateProjectName(s string) error { return nil } +// DeriveInPlaceAppName returns the app name to use when scaffolding in place. +// The name is taken from the basename of the absolute path of cwd and must +// pass ValidateProjectName so that it is a legal Databricks app name. +func DeriveInPlaceAppName(cwd string) (string, error) { + absCwd, err := filepath.Abs(cwd) + if err != nil { + return "", fmt.Errorf("resolve current directory: %w", err) + } + base := filepath.Base(absCwd) + if err := ValidateProjectName(base); err != nil { + maxAllowed := MaxAppNameLength - len(DevTargetPrefix) + return "", fmt.Errorf("current directory name %q is not a valid Databricks app name (must match [a-z][a-z0-9-]* and be at most %d chars): %w; rename the directory or run from one whose name is valid", base, maxAllowed, err) + } + return base, nil +} + +// CheckInPlaceDirectory verifies that dir contains nothing other than the +// allow-listed entries (currently just .git). Returns a concise error +// otherwise; we deliberately do not list the offending entries because the +// user can `ls` themselves and a long list buries the actionable part. +// +// Symlinks named like an allow-listed entry are rejected too: os.WriteFile +// follows symlinks, so a symlink named .git in cwd (or any future allow-listed +// dotfile) would let the template scaffold overwrite an arbitrary file the +// running user can reach. +func CheckInPlaceDirectory(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read directory %s: %w", dir, err) + } + for _, e := range entries { + if inPlaceAllowedEntries[e.Name()] && e.Type()&os.ModeSymlink == 0 { + continue + } + shown := dir + if abs, absErr := filepath.Abs(dir); absErr == nil { + shown = abs + } + return fmt.Errorf("%s is not empty; apps init --name . requires an empty directory (.git is allowed)", shown) + } + return nil +} + +// ShouldOfferInPlace returns the cwd basename and true when both the +// directory contents and basename are suitable for in-place scaffolding. +// Used to decide whether the interactive flow surfaces the in-place option. +func ShouldOfferInPlace(cwd string) (string, bool) { + if err := CheckInPlaceDirectory(cwd); err != nil { + return "", false + } + name, err := DeriveInPlaceAppName(cwd) + if err != nil { + return "", false + } + return name, true +} + // PrintHeader prints the AppKit header banner. func PrintHeader(ctx context.Context) { headerStyle := lipgloss.NewStyle(). @@ -169,9 +247,41 @@ func PrintHeader(ctx context.Context) { cmdio.LogString(ctx, "") } +// ErrNameDotWithOutputDir is returned when --name . (or the equivalent typed at +// the interactive prompt) is combined with a non-empty --output-dir. The two +// are mutually exclusive: --name . already targets the current directory. +var ErrNameDotWithOutputDir = errors.New("--name . and --output-dir are mutually exclusive: --name . already targets the current directory") + +// validateProjectNameForPrompt is the per-keystroke validator used by +// PromptForProjectName. Exposed (unexported) as a helper so the sentinel +// handling is unit-testable without a TTY. +func validateProjectNameForPrompt(s, outputDir string) error { + if err := ValidateProjectName(s); err != nil { + return err + } + if s == InPlaceName { + if outputDir != "" { + return ErrNameDotWithOutputDir + } + // In-place: skip the directory-exists check; the caller will + // derive the app name from the cwd and verify the directory + // is suitable. + return nil + } + destDir := s + if outputDir != "" { + destDir = filepath.Join(outputDir, s) + } + if _, err := os.Stat(destDir); err == nil { + return fmt.Errorf("directory %s already exists", destDir) + } + return nil +} + // PromptForProjectName prompts only for project name. // Used as the first step before resolving templates. -// outputDir is used to check if the destination directory already exists. +// outputDir is used to check if the destination directory already exists, +// and to reject the in-place sentinel "." when --output-dir is set. func PromptForProjectName(ctx context.Context, outputDir string) (string, error) { PrintHeader(ctx) theme := AppkitTheme() @@ -183,17 +293,7 @@ func PromptForProjectName(ctx context.Context, outputDir string) (string, error) Placeholder("my-app"). Value(&name). Validate(func(s string) error { - if err := ValidateProjectName(s); err != nil { - return err - } - destDir := s - if outputDir != "" { - destDir = filepath.Join(outputDir, s) - } - if _, err := os.Stat(destDir); err == nil { - return fmt.Errorf("directory %s already exists", destDir) - } - return nil + return validateProjectNameForPrompt(s, outputDir) }). WithTheme(theme). Run() @@ -205,6 +305,40 @@ func PromptForProjectName(ctx context.Context, outputDir string) (string, error) return name, nil } +// PromptScaffoldLocation asks where the new app should be scaffolded when the +// current directory is suitable for in-place use. Defaults to a new +// subdirectory so users who hit Enter through the prompts get today's +// behaviour. Returns true when the user opts in to in-place scaffolding. +func PromptScaffoldLocation(ctx context.Context, basename string) (bool, error) { + theme := AppkitTheme() + + const ( + locSubdir = "subdir" + locCurrent = "current" + ) + + choice := locSubdir + err := huh.NewSelect[string](). + Title("Where should we create the app?"). + Options( + huh.NewOption("Create a new subdirectory", locSubdir), + huh.NewOption(fmt.Sprintf("Use the current directory (%q)", basename), locCurrent), + ). + Value(&choice). + WithTheme(theme). + Run() + if err != nil { + return false, err + } + + if choice == locCurrent { + printAnswered(ctx, "Location", "current directory") + return true, nil + } + printAnswered(ctx, "Location", "new subdirectory") + return false, nil +} + // PromptForDeployAndRun prompts for post-creation deploy and run options. func PromptForDeployAndRun(ctx context.Context) (deploy bool, runMode RunMode, err error) { theme := AppkitTheme() @@ -1048,7 +1182,9 @@ func PromptForAppSelection(ctx context.Context, title string) (string, error) { // PrintSuccess prints a success message after project creation. // If nextStepsCmd is non-empty, also prints the "Next steps" section with the given command. -func PrintSuccess(ctx context.Context, projectName, outputDir string, fileCount int, nextStepsCmd string) { +// When inPlace is true, the "cd " line is omitted because the +// user is already in the destination directory. +func PrintSuccess(ctx context.Context, projectName, outputDir string, fileCount int, nextStepsCmd string, inPlace bool) { successStyle := lipgloss.NewStyle(). Foreground(colorYellow). Bold(true) @@ -1069,7 +1205,9 @@ func PrintSuccess(ctx context.Context, projectName, outputDir string, fileCount cmdio.LogString(ctx, "") cmdio.LogString(ctx, dimStyle.Render(" Next steps:")) cmdio.LogString(ctx, "") - cmdio.LogString(ctx, codeStyle.Render(" cd "+projectName)) + if !inPlace { + cmdio.LogString(ctx, codeStyle.Render(" cd "+projectName)) + } cmdio.LogString(ctx, codeStyle.Render(" "+nextStepsCmd)) } cmdio.LogString(ctx, "") diff --git a/libs/apps/prompt/prompt_test.go b/libs/apps/prompt/prompt_test.go index 01091cf72ce..da92a679213 100644 --- a/libs/apps/prompt/prompt_test.go +++ b/libs/apps/prompt/prompt_test.go @@ -3,6 +3,8 @@ package prompt import ( "context" "errors" + "os" + "path/filepath" "testing" "time" @@ -87,6 +89,11 @@ func TestValidateProjectName(t *testing.T) { expectError: true, errorMsg: "lowercase letters, numbers, or hyphens", }, + { + name: "in-place sentinel", + projectName: InPlaceName, + expectError: false, + }, } for _, tt := range tests { @@ -332,3 +339,187 @@ func TestRenderStabilityTier(t *testing.T) { }) } } + +// mkDirNamed creates a child directory with the given name under t.TempDir() +// and returns its absolute path. Used to control filepath.Base(absCwd) when +// exercising in-place name derivation. +func mkDirNamed(t *testing.T, name string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), name) + require.NoError(t, os.MkdirAll(dir, 0o755)) + return dir +} + +func TestDeriveInPlaceAppName(t *testing.T) { + t.Run("valid basename", func(t *testing.T) { + dir := mkDirNamed(t, "my-app") + got, err := DeriveInPlaceAppName(dir) + require.NoError(t, err) + assert.Equal(t, "my-app", got) + }) + + t.Run("uppercase basename rejected", func(t *testing.T) { + dir := mkDirNamed(t, "MyApp") + _, err := DeriveInPlaceAppName(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "MyApp") + assert.Contains(t, err.Error(), "rename the directory") + }) + + t.Run("underscore basename rejected", func(t *testing.T) { + dir := mkDirNamed(t, "my_app") + _, err := DeriveInPlaceAppName(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "my_app") + }) + + t.Run("leading digit basename rejected", func(t *testing.T) { + dir := mkDirNamed(t, "1app") + _, err := DeriveInPlaceAppName(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "1app") + }) + + t.Run("too long basename rejected", func(t *testing.T) { + dir := mkDirNamed(t, "this-is-far-too-long-for-the-app-name-limit") + _, err := DeriveInPlaceAppName(dir) + require.Error(t, err) + }) +} + +func TestCheckInPlaceDirectory(t *testing.T) { + t.Run("empty directory is OK", func(t *testing.T) { + dir := t.TempDir() + assert.NoError(t, CheckInPlaceDirectory(dir)) + }) + + t.Run("dotgit only is OK", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755)) + assert.NoError(t, CheckInPlaceDirectory(dir)) + }) + + t.Run("pre-existing gitignore is rejected", func(t *testing.T) { + // .gitignore is intentionally NOT allow-listed: the template ships + // _gitignore that renames to .gitignore, which would silently + // overwrite the user's file. + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("node_modules\n"), 0o644)) + err := CheckInPlaceDirectory(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "not empty") + }) + + t.Run("unexpected file is rejected", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644)) + err := CheckInPlaceDirectory(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "not empty") + assert.Contains(t, err.Error(), "apps init --name .") + // Concise wording: we deliberately do not enumerate offending files. + assert.NotContains(t, err.Error(), "README.md") + }) + + t.Run("mix of allowed and disallowed is rejected", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stray.txt"), []byte(""), 0o644)) + err := CheckInPlaceDirectory(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "not empty") + }) + + t.Run("symlink named .git is rejected", func(t *testing.T) { + // A symlink masquerading as .git would let os.WriteFile follow the + // link if any allow-listed name later became a write target. + dir := t.TempDir() + target := filepath.Join(t.TempDir(), "elsewhere") + require.NoError(t, os.MkdirAll(target, 0o755)) + require.NoError(t, os.Symlink(target, filepath.Join(dir, ".git"))) + err := CheckInPlaceDirectory(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "not empty") + }) + + t.Run("error message shows absolute path", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "stray.txt"), []byte(""), 0o644)) + err := CheckInPlaceDirectory(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), dir) + }) + + t.Run("missing directory returns error", func(t *testing.T) { + err := CheckInPlaceDirectory(filepath.Join(t.TempDir(), "nope")) + require.Error(t, err) + }) +} + +func TestShouldOfferInPlace(t *testing.T) { + t.Run("returns basename when dir is empty and name is valid", func(t *testing.T) { + dir := mkDirNamed(t, "my-app") + name, ok := ShouldOfferInPlace(dir) + assert.True(t, ok) + assert.Equal(t, "my-app", name) + }) + + t.Run("declines when dir has stray files", func(t *testing.T) { + dir := mkDirNamed(t, "my-app") + require.NoError(t, os.WriteFile(filepath.Join(dir, "stray.txt"), []byte(""), 0o644)) + _, ok := ShouldOfferInPlace(dir) + assert.False(t, ok) + }) + + t.Run("declines when basename is invalid", func(t *testing.T) { + dir := mkDirNamed(t, "Bad_Name") + _, ok := ShouldOfferInPlace(dir) + assert.False(t, ok) + }) + + t.Run("declines when dir does not exist", func(t *testing.T) { + _, ok := ShouldOfferInPlace(filepath.Join(t.TempDir(), "missing")) + assert.False(t, ok) + }) +} + +func TestValidateProjectNameForPrompt(t *testing.T) { + t.Run("valid name without outputDir", func(t *testing.T) { + assert.NoError(t, validateProjectNameForPrompt("my-app", "")) + }) + + t.Run("in-place sentinel without outputDir is accepted", func(t *testing.T) { + assert.NoError(t, validateProjectNameForPrompt(InPlaceName, "")) + }) + + t.Run("in-place sentinel with outputDir is rejected with sentinel error", func(t *testing.T) { + err := validateProjectNameForPrompt(InPlaceName, "/some/dir") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNameDotWithOutputDir) + }) + + t.Run("invalid name surfaces ValidateProjectName error", func(t *testing.T) { + err := validateProjectNameForPrompt("My_App", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "lowercase letters") + }) +} + +func TestPrintSuccessInPlace(t *testing.T) { + ctx, out := cmdio.NewTestContextWithStderr(t.Context()) + PrintSuccess(ctx, "my-app", "/abs/path/my-app", 12, "npm run dev", true) + got := out.String() + assert.Contains(t, got, "Location: /abs/path/my-app") + assert.Contains(t, got, "Files: 12") + assert.Contains(t, got, "npm run dev") + assert.NotContains(t, got, "cd my-app") +} + +func TestPrintSuccessNotInPlace(t *testing.T) { + ctx, out := cmdio.NewTestContextWithStderr(t.Context()) + PrintSuccess(ctx, "my-app", "/abs/path/my-app", 12, "npm run dev", false) + got := out.String() + assert.Contains(t, got, "cd my-app") + assert.Contains(t, got, "npm run dev") +}