diff --git a/.gitignore b/.gitignore index 0665fbe..d25bde8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Compiled Go binaries # Replace 'bootstrapper' with your actual executable name bootstrap -c.out +coverage.out # Project folders generated by your tool # This pattern will ignore any folders starting with "new_project" diff --git a/cmd/apply.go b/cmd/apply.go new file mode 100644 index 0000000..c5f9f77 --- /dev/null +++ b/cmd/apply.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/upsaurav12/bootstrap/pkg/parser" +) + +var applyCmd = cobra.Command{ + Use: "apply", + Short: "Apply project configuration from YAML", + Run: func(cmd *cobra.Command, args []string) { + + if yamlPath == "" { + fmt.Fprintln(cmd.OutOrStdout(), "Error: --yaml is required") + return + } + + yamlConfig, err := parser.ReadYAML(yamlPath) + if err != nil { + fmt.Fprintln(cmd.OutOrStdout(), "error reading yaml:", err) + return + } + + YAMLPath = yamlPath + projectRouter = yamlConfig.Project.Router + projectPort = strconv.Itoa(yamlConfig.Project.Port) + DBType = yamlConfig.Project.Database + Entities = yamlConfig.Entities + + createNewProject( + yamlConfig.Project.Name, + projectRouter, + yamlConfig.Project.Type, + cmd.OutOrStdout(), + ) + }, +} + +var yamlPath string + +type Config struct { + Project Project `yaml:"project"` + Entities []string `yaml:"entities"` + CustomLogic []string `yaml:"custom_logic"` +} + +type Project struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Port int `yaml:"port"` + Location string `yaml:"location"` + Database string `yaml:"db"` + Router string `yaml:"router"` +} + +func init() { + + applyCmd.Flags().StringVar(&yamlPath, "yaml", "", "yaml configuation") + rootCmd.AddCommand(&applyCmd) +} diff --git a/cmd/new.go b/cmd/new.go index 91f6187..9384f00 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -13,7 +13,6 @@ import ( "log" "os" "path/filepath" - "strconv" "strings" "text/template" "unicode" @@ -24,11 +23,7 @@ import ( "github.com/upsaurav12/bootstrap/pkg/parser" "github.com/upsaurav12/bootstrap/templates" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/common-nighthawk/go-figure" ) type ProjectInput struct { @@ -40,272 +35,6 @@ type ProjectInput struct { Entities []string } -var asciiStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(primaryBlue)). - Bold(true) - -const ( - stepName = iota - stepType - stepRouter - stepPort - stepDB - stepConfirm -) - -var ( - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color(softBlue)) - - labelStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(softBlue)) - - hintStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(mutedGray)) - - boxStyle = lipgloss.NewStyle(). - Padding(3, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(borderGray). - Width(60) -) - -func renderStep(m wizardModel, title, label, body, hint string) string { - content := lipgloss.JoinVertical( - lipgloss.Left, - titleStyle.Render(title), - "", - labelStyle.Render(label), - "", - body, - "", - hintStyle.Render(hint), - ) - - box := boxStyle. - Width(m.width - 4). - Height(m.height - 2). - Render(content) - - return box -} - -type wizardModel struct { - step int - input ProjectInput - - text textinput.Model - list list.Model - quit bool - - width int - height int -} - -type item string - -func (i item) Title() string { return string(i) } -func (i item) Description() string { return "" } -func (i item) FilterValue() string { return string(i) } - -func initialWizardModel() wizardModel { - return wizardModel{ - step: stepName, - text: newTextInput(""), - } -} - -const ( - primaryBlue = lipgloss.Color("33") // bright blue - softBlue = lipgloss.Color("39") // lighter blue - mutedGray = lipgloss.Color("241") // hints - borderGray = lipgloss.Color("238") // borders -) - -func newTextInput(placeholder string) textinput.Model { - ti := textinput.New() - ti.Prompt = "› " - ti.Placeholder = placeholder - ti.SetValue("") // ← critical - ti.Focus() - return ti -} - -func (m wizardModel) Init() tea.Cmd { - return nil -} - -func renderHeader() string { - fig := figure.NewFigure("Bootstrap CLI", "slant", true) - - ascii := strings.Trim(fig.String(), "\n") - - return asciiStyle.Render(ascii) -} - -func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - - switch msg := msg.(type) { - - // ✅ HANDLE WINDOW SIZE FIRST - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - - // ✅ HANDLE KEYS - case tea.KeyMsg: - switch msg.String() { - - case "ctrl+c", "esc": - m.quit = true - return m, tea.Quit - - case "enter": - switch m.step { - - case stepName: - m.input.Name = m.text.Value() - m.step = stepType - m.list = newList("Project type", []string{"rest"}) - return m, nil - - case stepType: - m.input.Type = m.list.SelectedItem().(item).Title() - m.step = stepRouter - m.list = newList("Router", []string{"gin", "chi", "echo"}) - return m, nil - - case stepRouter: - m.input.Router = m.list.SelectedItem().(item).Title() - m.step = stepPort - m.text = newTextInput("") - return m, nil - - case stepPort: - port := m.text.Value() - if port == "" { - port = "8080" - } - if _, err := strconv.Atoi(port); err != nil { - return m, nil - } - m.input.Port = port - m.step = stepDB - m.list = newList("Database", []string{"postgres", "mysql", "mongo"}) - return m, nil - - case stepDB: - m.input.DB = m.list.SelectedItem().(item).Title() - m.step = stepConfirm - return m, nil - - case stepConfirm: - return m, tea.Quit - } - } - } - - var cmd tea.Cmd - if m.step == stepName || m.step == stepPort { - m.text, cmd = m.text.Update(msg) - return m, cmd - } - - if m.step == stepType || m.step == stepRouter || m.step == stepDB { - m.list, cmd = m.list.Update(msg) - return m, cmd - } - - return m, nil -} - -func (m wizardModel) View() string { - if m.quit { - return "" - } - - switch m.step { - - case stepName: - return renderStep( - m, - renderHeader()+"\nCreate New Project", - "Project name", - m.text.View(), - "Enter to continue • Esc to quit", - ) - - case stepType: - return renderStep( - m, - "Project Type", - "Select project type", - m.list.View(), - "↑↓ navigate • Enter select • Esc quit", - ) - - case stepRouter: - return renderStep( - m, - "Router", - "Select router", - m.list.View(), - "↑↓ navigate • Enter select • Esc quit", - ) - - case stepPort: - return renderStep( - m, - "Application Port", - "Port (default: 8080)", - m.text.View(), - "Enter to continue • Esc quit", - ) - - case stepDB: - return renderStep( - m, - "Database", - "Select database", - m.list.View(), - "↑↓ navigate • Enter select • Esc quit", - ) - - case stepConfirm: - summary := fmt.Sprintf( - "Project: %s\nType: %s\nRouter: %s\nPort: %s\nDatabase: %s", - m.input.Name, - m.input.Type, - m.input.Router, - m.input.Port, - m.input.DB, - ) - - return renderStep( - m, - "Confirm Configuration", - "Review your selections", - summary, - "Enter to generate • Esc to cancel", - ) - } - - return "" -} - -func newList(title string, values []string) list.Model { - items := make([]list.Item, len(values)) - for i, v := range values { - items[i] = item(v) - } - - l := list.New(items, list.NewDefaultDelegate(), 20, 10) - l.Title = title - return l -} - func copyProjectYAML(srcPath, destDir string) error { if srcPath == "" { return nil // nothing to copy @@ -394,7 +123,6 @@ var DBType string var YAMLPath string var Entitys string var Entities []string -var yamlFile string type TemplateData struct { Name string @@ -450,7 +178,7 @@ func init() { newCmd.Flags().StringVar(&projectPort, "port", "", "port of the project") newCmd.Flags().StringVar(&projectRouter, "router", "", "router of the project") newCmd.Flags().StringVar(&DBType, "db", "", "data type of the project") - newCmd.Flags().StringVar(&YAMLPath, "yaml", "", "yaml file path") + // newCmd.Flags().StringVar(&YAMLPath, "yaml", "", "yaml file path") newCmd.Flags().StringVar(&Entitys, "entity", "", "entity") newCmd.Flags().StringSliceVar(&Entities, "entities", nil, "different entities") newCmd.Flags().Bool("interactive", false, "run interactive project setup") @@ -560,13 +288,10 @@ func createNewProject(projectName, projectRouter, template string, out io.Writer log.Fatalf("error occured while creating a new project %s: ", projectName) } - // ✅ COPY project.yaml if provided if err := copyProjectYAML(YAMLPath, projectName); err != nil { fmt.Fprintf(out, "warning: could not copy project.yaml: %v\n", err) } - // Prepare configs - var frameworkConfig framework.FrameworkConfig frameworkConfig = framework.FrameworkRegistory[projectRouter] @@ -683,15 +408,17 @@ func renderTemplateDir(templatePath, destinationPath string, data TemplateData) if templatePath == "common" { base := filepath.Base(fileName) - if base == "env" || base == "golang-ci.yml" { + if base == "env" || base == "golang-ci.yml" || base == "gitignore" || base == "github" { fileName = "." + fileName } } if len(data.Entities) == 0 { - return writeSingle(data, fileName, path, content, destinationPath) + return writeSingle(data, fileName, path, content, destinationPath, false) } + fmt.Println("entities: ", Entities) + for _, entity := range data.Entities { // correct file renaming @@ -706,7 +433,7 @@ func renderTemplateDir(templatePath, destinationPath string, data TemplateData) entityData.Entity = strings.Title(entity) entityData.LowerEntity = strings.ToLower(entity) // capture errors!! - if err := writeSingle(entityData, newFile, path, content, destinationPath); err != nil { + if err := writeSingle(entityData, newFile, path, content, destinationPath, true); err != nil { return err } } @@ -715,17 +442,21 @@ func renderTemplateDir(templatePath, destinationPath string, data TemplateData) }) } -func writeSingle(data TemplateData, fileName string, tmpltPath string, content []byte, destinationPath string) error { - newFile := strings.Replace( - fileName, - "example", - "user", - 1, // only first replacement - ) - +func writeSingle(data TemplateData, fileName string, tmpltPath string, content []byte, destinationPath string, isEntites bool) error { entityData := data - entityData.Entity = strings.Title("user") - entityData.LowerEntity = strings.ToLower("user") + + var newFile string + newFile = fileName + if !isEntites { + newFile = strings.Replace( + fileName, + "example", + "user", + 1, // only first replacement + ) + entityData.Entity = strings.Title("user") + entityData.LowerEntity = strings.ToLower("user") + } targetPath := filepath.Join(destinationPath, newFile) tmpl, err := template.New(filepath.Base(tmpltPath)).Parse(string(content)) diff --git a/cmd/new_test.go b/cmd/new_test.go index b457e4f..e2a6d7c 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -1,8 +1,6 @@ package cmd import ( - "bytes" - "fmt" "os" "path/filepath" "testing" @@ -10,59 +8,86 @@ import ( "github.com/stretchr/testify/assert" ) -func TestCreateNewProject_Success(t *testing.T) { - tempDir := t.TempDir() - projectName := "test-project" - fullPath := filepath.Join(tempDir, projectName) - - // Change to temp directory to create projectName relative to it - oldDir, err := os.Getwd() - assert.NoError(t, err, "Failed to get working directory") - defer os.Chdir(oldDir) - err = os.Chdir(tempDir) - assert.NoError(t, err, "Failed to change to temp directory") - - var out bytes.Buffer - createNewProject(projectName, "gin", "go", &out) - - // Check if directory was created - _, err = os.Stat(fullPath) - assert.NoError(t, err, "Expected project directory to be created") - - // Check output - expected := fmt.Sprintf("✓ Created '%s' successfully\n", projectName) - fmt.Println(out.String()) - assert.Equal(t, expected, out.String(), "Unexpected output") +func TestReturnUppercase(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"user", "User"}, + {"product", "Product"}, + {"", ""}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, returnUppercase(tt.in)) + } } -/* +func TestIsHidden(t *testing.T) { + hidden, err := IsHidden("/tmp/.env") + assert.NoError(t, err) + assert.True(t, hidden) -func TestCreateNewProject_DirectoryAlreadyExists(t *testing.T) { - tempDir := t.TempDir() - projectName := "test-project" - fullPath := filepath.Join(tempDir, projectName) + notHidden, err := IsHidden("/tmp/main.go") + assert.NoError(t, err) + assert.False(t, notHidden) +} + +func TestCopyProjectYAML(t *testing.T) { + tmpDir := t.TempDir() + + src := filepath.Join(tmpDir, "project.yaml") + err := os.WriteFile(src, []byte("name: test"), 0644) + assert.NoError(t, err) + + destDir := t.TempDir() - err := os.Mkdir(fullPath, 0755) - assert.NoError(t, err, "Failed to set-up directory") - var out bytes.Buffer - createNewProject(projectName, "go", &out) + err = copyProjectYAML(src, destDir) + assert.NoError(t, err) - _, err = os.Stat(fullPath) - assert.NoError(t, err, "Expected directory to still exists") + out, err := os.ReadFile(filepath.Join(destDir, "project.yaml")) + assert.NoError(t, err) + assert.Equal(t, "name: test", string(out)) +} + +func TestCopyProjectYAML_EmptySource(t *testing.T) { + err := copyProjectYAML("", t.TempDir()) + assert.NoError(t, err) +} - assert.Contains(t, out.String(), "Error creating directory", "Expected error message") -}*/ +func TestWriteSingle_CreatesFile(t *testing.T) { + tmpDir := t.TempDir() + + data := TemplateData{ + Entity: "User", + } + + content := []byte("Hello {{.Entity}}") + + err := writeSingle( + data, + "example.txt", + "example.tmpl", + content, + tmpDir, + false, + ) + + assert.NoError(t, err) + + out, err := os.ReadFile(filepath.Join(tmpDir, "user.txt")) + assert.NoError(t, err) + assert.Equal(t, "Hello User", string(out)) +} -func TestCreateNewProject_InvalidPath(t *testing.T) { - tempDir := t.TempDir() - projectName := "invalid\000name" - invalidPath := filepath.Join(tempDir, projectName) +func TestRenderTemplateDir_NoError(t *testing.T) { + tmpDir := t.TempDir() - var out bytes.Buffer - createNewProject(projectName, "gin", "go ", &out) + data := TemplateData{ + Entities: []string{}, + } - _, err := os.Stat(invalidPath) - assert.Error(t, err, "Expected no directory to be created") + err := renderTemplateDir("common", tmpDir, data) - assert.Contains(t, out.String(), "Error creating directory", "Expected error message") + assert.NoError(t, err) } diff --git a/cmd/ui.go b/cmd/ui.go new file mode 100644 index 0000000..6cb9097 --- /dev/null +++ b/cmd/ui.go @@ -0,0 +1,277 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/common-nighthawk/go-figure" +) + +var asciiStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(primaryBlue)). + Bold(true) + +const ( + stepName = iota + stepType + stepRouter + stepPort + stepDB + stepConfirm +) + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(softBlue)) + + labelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(softBlue)) + + hintStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(mutedGray)) + + boxStyle = lipgloss.NewStyle(). + Padding(3, 2). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderGray). + Width(60) +) + +func renderStep(m wizardModel, title, label, body, hint string) string { + content := lipgloss.JoinVertical( + lipgloss.Left, + titleStyle.Render(title), + "", + labelStyle.Render(label), + "", + body, + "", + hintStyle.Render(hint), + ) + + box := boxStyle. + Width(m.width - 4). + Height(m.height - 2). + Render(content) + + return box +} + +type wizardModel struct { + step int + input ProjectInput + + text textinput.Model + list list.Model + quit bool + + width int + height int +} + +type item string + +func (i item) Title() string { return string(i) } +func (i item) Description() string { return "" } +func (i item) FilterValue() string { return string(i) } + +func initialWizardModel() wizardModel { + return wizardModel{ + step: stepName, + text: newTextInput(""), + } +} + +const ( + primaryBlue = lipgloss.Color("33") // bright blue + softBlue = lipgloss.Color("39") // lighter blue + mutedGray = lipgloss.Color("241") // hints + borderGray = lipgloss.Color("238") // borders +) + +func newTextInput(placeholder string) textinput.Model { + ti := textinput.New() + ti.Prompt = "› " + ti.Placeholder = placeholder + ti.SetValue("") // ← critical + ti.Focus() + return ti +} + +func (m wizardModel) Init() tea.Cmd { + return nil +} + +func renderHeader() string { + fig := figure.NewFigure("Bootstrap CLI", "slant", true) + + ascii := strings.Trim(fig.String(), "\n") + + return asciiStyle.Render(ascii) +} + +func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + + case "ctrl+c", "esc": + m.quit = true + return m, tea.Quit + + case "enter": + switch m.step { + + case stepName: + m.input.Name = m.text.Value() + m.step = stepType + m.list = newList("Project type", []string{"rest"}) + return m, nil + + case stepType: + m.input.Type = m.list.SelectedItem().(item).Title() + m.step = stepRouter + m.list = newList("Router", []string{"gin", "chi", "echo"}) + return m, nil + + case stepRouter: + m.input.Router = m.list.SelectedItem().(item).Title() + m.step = stepPort + m.text = newTextInput("") + return m, nil + + case stepPort: + port := m.text.Value() + if port == "" { + port = "8080" + } + if _, err := strconv.Atoi(port); err != nil { + return m, nil + } + m.input.Port = port + m.step = stepDB + m.list = newList("Database", []string{"postgres", "mysql", "mongo"}) + return m, nil + + case stepDB: + m.input.DB = m.list.SelectedItem().(item).Title() + m.step = stepConfirm + return m, nil + + case stepConfirm: + return m, tea.Quit + } + } + } + + var cmd tea.Cmd + if m.step == stepName || m.step == stepPort { + m.text, cmd = m.text.Update(msg) + return m, cmd + } + + if m.step == stepType || m.step == stepRouter || m.step == stepDB { + m.list, cmd = m.list.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m wizardModel) View() string { + if m.quit { + return "" + } + + switch m.step { + + case stepName: + return renderStep( + m, + renderHeader()+"\nCreate New Project", + "Project name", + m.text.View(), + "Enter to continue • Esc to quit", + ) + + case stepType: + return renderStep( + m, + "Project Type", + "Select project type", + m.list.View(), + "↑↓ navigate • Enter select • Esc quit", + ) + + case stepRouter: + return renderStep( + m, + "Router", + "Select router", + m.list.View(), + "↑↓ navigate • Enter select • Esc quit", + ) + + case stepPort: + return renderStep( + m, + "Application Port", + "Port (default: 8080)", + m.text.View(), + "Enter to continue • Esc quit", + ) + + case stepDB: + return renderStep( + m, + "Database", + "Select database", + m.list.View(), + "↑↓ navigate • Enter select • Esc quit", + ) + + case stepConfirm: + summary := fmt.Sprintf( + "Project: %s\nType: %s\nRouter: %s\nPort: %s\nDatabase: %s", + m.input.Name, + m.input.Type, + m.input.Router, + m.input.Port, + m.input.DB, + ) + + return renderStep( + m, + "Confirm Configuration", + "Review your selections", + summary, + "Enter to generate • Esc to cancel", + ) + } + + return "" +} + +func newList(title string, values []string) list.Model { + items := make([]list.Item, len(values)) + for i, v := range values { + items[i] = item(v) + } + + l := list.New(items, list.NewDefaultDelegate(), 20, 10) + l.Title = title + return l +} diff --git a/templates/embed.go b/templates/embed.go index 068db6d..5ea1aae 100644 --- a/templates/embed.go +++ b/templates/embed.go @@ -8,7 +8,7 @@ import "embed" // the paths 'common' and 'rest' correctly refer to the template folders. // -//go:embed common/** +//go:embed common //go:embed rest/** //go:embed db/** var FS embed.FS diff --git a/templates/rest/clean/internal/config/config_test.go.tmpl b/templates/rest/clean/internal/config/config_test.go.tmpl new file mode 100644 index 0000000..94b9397 --- /dev/null +++ b/templates/rest/clean/internal/config/config_test.go.tmpl @@ -0,0 +1,23 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNew_PortFromEnv(t *testing.T) { + t.Setenv("PORT", "9090") + + cfg := New() + + assert.Equal(t, "9090", cfg.Port) +} + +func TestNew_DefaultPortWhenEnvMissing(t *testing.T) { + t.Setenv("PORT", "") + + cfg := New() + + assert.Equal(t, "8080", cfg.Port) +}