From 927c851f6e1615a16305146e644e1d423303b4e8 Mon Sep 17 00:00:00 2001 From: Isaque Pinheiro Date: Thu, 25 Jun 2026 09:21:14 -0300 Subject: [PATCH] feat(boss): add new command to generate project and package skeletons (issue #67) --- internal/adapters/primary/cli/new.go | 236 ++++++++++++++++++ internal/adapters/primary/cli/new_test.go | 208 +++++++++++++++ internal/adapters/primary/cli/root.go | 1 + .../secondary/repository/repository_test.go | 8 + 4 files changed, 453 insertions(+) create mode 100644 internal/adapters/primary/cli/new.go create mode 100644 internal/adapters/primary/cli/new_test.go diff --git a/internal/adapters/primary/cli/new.go b/internal/adapters/primary/cli/new.go new file mode 100644 index 00000000..c9efe6b7 --- /dev/null +++ b/internal/adapters/primary/cli/new.go @@ -0,0 +1,236 @@ +// Package cli provides command-line interface implementation for Boss. +package cli + +import ( + "crypto/rand" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/hashload/boss/internal/core/domain" + "github.com/hashload/boss/pkg/consts" + "github.com/hashload/boss/pkg/msg" + "github.com/hashload/boss/pkg/pkgmanager" + "github.com/spf13/cobra" +) + +var ( + projectType string + quietNew bool +) + +const dprTemplate = `program %s; + +{$APPTYPE CONSOLE} + +{$R *.res} + +uses + System.SysUtils; + +begin + try + Writeln('Hello from %s!'); + except + on E: Exception do + Writeln(E.ClassName, ': ', E.Message); + end; +end. +` + +const dpkTemplate = `package %s; + +{$R *.res} +{$IFDEF IMPLICITBUILDING} +{$IMPLICITBUILD ON} +{$ENDIF} + +requires + rtl; + +contains + // Add package units here + ; + +end. +` + +const dprojTemplate = ` + + %s + 19.5 + None + True + Debug + Win32 + 1 + %s + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + + MainSource + + + Cfg_2 + Base + + + Base + + + Cfg_1 + Base + + + + Delphi.Personality.12 + %s + + + + %s.%s + + + + 12 + + + + +` + +// generateGUID generates a random GUID in the standard {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX} format. +func generateGUID() string { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + return "{00000000-0000-0000-0000-000000000000}" + } + return fmt.Sprintf("{%02X%02X%02X%02X-%02X%02X-%02X%02X-%02X%02X-%02X%02X%02X%02X%02X%02X}", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]) +} + +// newCmdRegister registers the new command. +func newCmdRegister(root *cobra.Command) { + var newCmd = &cobra.Command{ + Use: "new [project_name]", + Short: "Create a new Delphi project skeleton", + Long: "Create a new Delphi project skeleton with source directories, templates, and boss.json", + Args: cobra.MaximumNArgs(1), + Example: ` Create a new console application: + boss new my_project + + Create a new package/library: + boss new my_package --type pkg`, + Run: func(cmd *cobra.Command, args []string) { + var name string + if len(args) > 0 { + name = args[0] + } + doCreateProject(name, projectType, quietNew) + }, + } + + newCmd.Flags().StringVarP(&projectType, "type", "t", "app", "type of project to generate (app or pkg)") + newCmd.Flags().BoolVarP(&quietNew, "quiet", "q", false, "without asking questions") + + root.AddCommand(newCmd) +} + +// doCreateProject performs the project creation. +func doCreateProject(name string, pType string, quiet bool) { + if !quiet && name == "" { + name = getParamOrDef("Project name", "") + } + name = strings.TrimSpace(name) + if name == "" { + msg.Die("❌ Project name is required.") + } + + pType = strings.ToLower(strings.TrimSpace(pType)) + if pType != "app" && pType != "pkg" { + msg.Die("❌ Invalid project type. Supported types: 'app' (default) or 'pkg'.") + } + + cwd, err := os.Getwd() + if err != nil { + msg.Die("❌ Failed to get current working directory: %v", err) + } + + projectDir := filepath.Join(cwd, name) + if _, err := os.Stat(projectDir); !os.IsNotExist(err) { + msg.Die("❌ Directory '%s' already exists.", name) + } + + if !quiet { + msg.Info("🚀 Creating a new Delphi project skeleton in %s...", projectDir) + } + + // Create directories + srcDir := filepath.Join(projectDir, "src") + testsDir := filepath.Join(projectDir, "tests") + if err := os.MkdirAll(srcDir, 0755); err != nil { + msg.Die("❌ Failed to create src directory: %v", err) + } + if err := os.MkdirAll(testsDir, 0755); err != nil { + msg.Die("❌ Failed to create tests directory: %v", err) + } + + // Save boss.json + packageData := domain.NewPackage() + packageData.Name = name + packageData.Version = "1.0.0" + packageData.MainSrc = "src" + + packageJsonPath := filepath.Join(projectDir, consts.FilePackage) + if err := pkgmanager.SavePackage(packageData, packageJsonPath); err != nil { + msg.Die("❌ Failed to save boss.json: %v", err) + } + + // Write Delphi files + guid := generateGUID() + var dprojContent string + if pType == "app" { + dprPath := filepath.Join(projectDir, name+".dpr") + dprContent := fmt.Sprintf(dprTemplate, name, name) + if err := os.WriteFile(dprPath, []byte(dprContent), 0644); err != nil { + msg.Die("❌ Failed to create .dpr project file: %v", err) + } + dprojContent = fmt.Sprintf(dprojTemplate, guid, "Console", "Application", name, "dpr") + } else { + dpkPath := filepath.Join(projectDir, name+".dpk") + dpkContent := fmt.Sprintf(dpkTemplate, name) + if err := os.WriteFile(dpkPath, []byte(dpkContent), 0644); err != nil { + msg.Die("❌ Failed to create .dpk package file: %v", err) + } + dprojContent = fmt.Sprintf(dprojTemplate, guid, "Package", "Package", name, "dpk") + } + + dprojPath := filepath.Join(projectDir, name+".dproj") + if err := os.WriteFile(dprojPath, []byte(dprojContent), 0644); err != nil { + msg.Die("❌ Failed to create .dproj configuration file: %v", err) + } + + if !quiet { + msg.Info("✨ Project '%s' created successfully!", name) + } +} diff --git a/internal/adapters/primary/cli/new_test.go b/internal/adapters/primary/cli/new_test.go new file mode 100644 index 00000000..8e17ccc6 --- /dev/null +++ b/internal/adapters/primary/cli/new_test.go @@ -0,0 +1,208 @@ +//nolint:testpackage // Testing internal command implementation +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hashload/boss/internal/adapters/secondary/filesystem" + "github.com/hashload/boss/internal/adapters/secondary/repository" + "github.com/hashload/boss/internal/core/domain" + "github.com/hashload/boss/internal/core/services/packages" + "github.com/hashload/boss/pkg/consts" + "github.com/hashload/boss/pkg/pkgmanager" + "github.com/spf13/cobra" +) + +// TestNewCommandRegistration tests that the new command registers correctly. +func TestNewCommandRegistration(t *testing.T) { + root := &cobra.Command{Use: "boss"} + newCmdRegister(root) + + var newCmd *cobra.Command + for _, cmd := range root.Commands() { + if cmd.Use == "new [project_name]" { + newCmd = cmd + break + } + } + + if newCmd == nil { + t.Fatal("New command not found") + } + + if newCmd.Short == "" { + t.Error("New command should have a short description") + } + + typeFlag := newCmd.Flags().Lookup("type") + if typeFlag == nil { + t.Error("New command should have --type flag") + } + if typeFlag.DefValue != "app" { + t.Errorf("New command --type flag default value should be 'app', got %s", typeFlag.DefValue) + } + + quietFlag := newCmd.Flags().Lookup("quiet") + if quietFlag == nil { + t.Error("New command should have --quiet flag") + } +} + +// TestDoCreateProject_App tests bootstrapping an application project. +func TestDoCreateProject_App(t *testing.T) { + tempDir := t.TempDir() + + // Redirect working directory + oldWd, err := os.Getwd() + if err == nil { + defer func() { _ = os.Chdir(oldWd) }() + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize package manager + fs := filesystem.NewOSFileSystem() + packageRepo := repository.NewFilePackageRepository(fs) + lockRepo := repository.NewFileLockRepository(fs) + packageService := packages.NewPackageService(packageRepo, lockRepo) + pkgmanager.SetInstance(packageService) + + projectName := "testapp" + doCreateProject(projectName, "app", true) + + projectPath := filepath.Join(tempDir, projectName) + if _, err := os.Stat(projectPath); os.IsNotExist(err) { + t.Fatalf("Project directory was not created") + } + + // Check folders + if _, err := os.Stat(filepath.Join(projectPath, "src")); os.IsNotExist(err) { + t.Error("src directory was not created") + } + if _, err := os.Stat(filepath.Join(projectPath, "tests")); os.IsNotExist(err) { + t.Error("tests directory was not created") + } + + // Check boss.json + bossJsonPath := filepath.Join(projectPath, consts.FilePackage) + if _, err := os.Stat(bossJsonPath); os.IsNotExist(err) { + t.Fatal("boss.json was not created") + } + + bossBytes, err := os.ReadFile(bossJsonPath) + if err != nil { + t.Fatalf("Failed to read boss.json: %v", err) + } + + var pkg domain.Package + if err := json.Unmarshal(bossBytes, &pkg); err != nil { + t.Fatalf("Failed to parse boss.json: %v", err) + } + + if pkg.Name != projectName { + t.Errorf("Expected package name %q, got %q", projectName, pkg.Name) + } + if pkg.Version != "1.0.0" { + t.Errorf("Expected package version '1.0.0', got %q", pkg.Version) + } + if pkg.MainSrc != "src" { + t.Errorf("Expected mainsrc 'src', got %q", pkg.MainSrc) + } + + // Check .dpr file + dprPath := filepath.Join(projectPath, projectName+".dpr") + if _, err := os.Stat(dprPath); os.IsNotExist(err) { + t.Fatal(".dpr file was not created") + } + + dprBytes, err := os.ReadFile(dprPath) + if err != nil { + t.Fatalf("Failed to read .dpr file: %v", err) + } + dprContent := string(dprBytes) + if !strings.Contains(dprContent, "program "+projectName) { + t.Errorf("Expected .dpr file to contain program declaration") + } + + // Check .dproj file + dprojPath := filepath.Join(projectPath, projectName+".dproj") + if _, err := os.Stat(dprojPath); os.IsNotExist(err) { + t.Fatal(".dproj file was not created") + } + + dprojBytes, err := os.ReadFile(dprojPath) + if err != nil { + t.Fatalf("Failed to read .dproj file: %v", err) + } + dprojContent := string(dprojBytes) + if !strings.Contains(dprojContent, "") { + t.Error("Expected .dproj to contain ProjectGuid") + } + if !strings.Contains(dprojContent, "Console") { + t.Error("Expected .dproj to have AppType Console") + } +} + +// TestDoCreateProject_Pkg tests bootstrapping a package project. +func TestDoCreateProject_Pkg(t *testing.T) { + tempDir := t.TempDir() + + // Redirect working directory + oldWd, err := os.Getwd() + if err == nil { + defer func() { _ = os.Chdir(oldWd) }() + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize package manager + fs := filesystem.NewOSFileSystem() + packageRepo := repository.NewFilePackageRepository(fs) + lockRepo := repository.NewFileLockRepository(fs) + packageService := packages.NewPackageService(packageRepo, lockRepo) + pkgmanager.SetInstance(packageService) + + projectName := "testpkg" + doCreateProject(projectName, "pkg", true) + + projectPath := filepath.Join(tempDir, projectName) + if _, err := os.Stat(projectPath); os.IsNotExist(err) { + t.Fatalf("Project directory was not created") + } + + // Check .dpk file + dpkPath := filepath.Join(projectPath, projectName+".dpk") + if _, err := os.Stat(dpkPath); os.IsNotExist(err) { + t.Fatal(".dpk file was not created") + } + + dpkBytes, err := os.ReadFile(dpkPath) + if err != nil { + t.Fatalf("Failed to read .dpk file: %v", err) + } + dpkContent := string(dpkBytes) + if !strings.Contains(dpkContent, "package "+projectName) { + t.Errorf("Expected .dpk file to contain package declaration") + } + + // Check .dproj file + dprojPath := filepath.Join(projectPath, projectName+".dproj") + if _, err := os.Stat(dprojPath); os.IsNotExist(err) { + t.Fatal(".dproj file was not created") + } + + dprojBytes, err := os.ReadFile(dprojPath) + if err != nil { + t.Fatalf("Failed to read .dproj file: %v", err) + } + dprojContent := string(dprojBytes) + if !strings.Contains(dprojContent, "Package") { + t.Error("Expected .dproj to have AppType Package") + } +} diff --git a/internal/adapters/primary/cli/root.go b/internal/adapters/primary/cli/root.go index d24d18a7..d4eeaa1f 100644 --- a/internal/adapters/primary/cli/root.go +++ b/internal/adapters/primary/cli/root.go @@ -50,6 +50,7 @@ func Execute() error { config.RegisterConfigCommand(root) initCmdRegister(root) + newCmdRegister(root) installCmdRegister(root) loginCmdRegister(root) runCmdRegister(root) diff --git a/internal/adapters/secondary/repository/repository_test.go b/internal/adapters/secondary/repository/repository_test.go index 813a305b..6098cec2 100644 --- a/internal/adapters/secondary/repository/repository_test.go +++ b/internal/adapters/secondary/repository/repository_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "os" + "path/filepath" "testing" "time" @@ -27,6 +28,7 @@ func NewMockFileSystem() *MockFileSystem { } func (m *MockFileSystem) ReadFile(name string) ([]byte, error) { + name = filepath.ToSlash(name) if data, ok := m.files[name]; ok { return data, nil } @@ -34,6 +36,7 @@ func (m *MockFileSystem) ReadFile(name string) ([]byte, error) { } func (m *MockFileSystem) WriteFile(name string, data []byte, _ os.FileMode) error { + name = filepath.ToSlash(name) m.files[name] = data return nil } @@ -43,6 +46,7 @@ func (m *MockFileSystem) MkdirAll(_ string, _ os.FileMode) error { } func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) { + name = filepath.ToSlash(name) if _, ok := m.files[name]; ok { //nolint:nilnil // Mock for testing return nil, nil @@ -51,6 +55,7 @@ func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) { } func (m *MockFileSystem) Remove(name string) error { + name = filepath.ToSlash(name) delete(m.files, name) return nil } @@ -60,6 +65,8 @@ func (m *MockFileSystem) RemoveAll(_ string) error { } func (m *MockFileSystem) Rename(oldpath, newpath string) error { + oldpath = filepath.ToSlash(oldpath) + newpath = filepath.ToSlash(newpath) if data, ok := m.files[oldpath]; ok { m.files[newpath] = data delete(m.files, oldpath) @@ -82,6 +89,7 @@ func (m *MockFileSystem) Create(_ string) (io.WriteCloser, error) { } func (m *MockFileSystem) Exists(name string) bool { + name = filepath.ToSlash(name) _, ok := m.files[name] return ok }