From 29f94539e0b562d755d06982dc44897a69eea9ef Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Tue, 25 Aug 2026 21:33:00 +0100 Subject: [PATCH] feat: scaffold API, client, or monorepo projects with Nuxt/Vue client support - draft new now offers API, Client, or Full Stack (monorepo) project shapes - draft new client scaffolds a Nuxt or Vue app via the official nuxi/create-vue tooling, with a Draft-maintained auth/API overlay layered on top - draft new monorepo combines a scaffolded API and client under one root with go.work, pnpm-workspace.yaml, and docker-compose wiring - assets/embedded restructured into api/monorepo-root/client-nuxt/client-vue zones, synced via a generalized, job-based sync tool --- assets/efs.go | 2 +- cmd/api.go | 121 ++++++++------- cmd/client.go | 321 ++++++++++++++++++++++++++++++++++++++++ cmd/helpers.go | 170 ++++++++++++++++++++- cmd/monorepo.go | 52 +++++++ cmd/new.go | 35 ++++- tools/sync_templates.go | 122 +++++++++++---- 7 files changed, 724 insertions(+), 99 deletions(-) create mode 100644 cmd/client.go create mode 100644 cmd/monorepo.go diff --git a/assets/efs.go b/assets/efs.go index cae4efe..26f0c8a 100644 --- a/assets/efs.go +++ b/assets/efs.go @@ -3,5 +3,5 @@ package assets import "embed" //go:generate go run ../tools/sync_templates.go -//go:embed templates/* embedded/* +//go:embed all:templates all:embedded var EmbedFS embed.FS diff --git a/cmd/api.go b/cmd/api.go index 188eb6a..8bb51ee 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -4,12 +4,10 @@ import ( "fmt" "io/fs" "os" - "path" "path/filepath" "strings" "git.fernandovideira.com/FernandoJVideira/draft/assets" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -21,67 +19,23 @@ var apiCmd = &cobra.Command{ Use: "api", Short: "Create a new Draft API project", Long: `Create a new Draft API project with the necessary structure and files.`, - Run: genProject, + Run: genAPI, } -func genProject(cmd *cobra.Command, args []string) { - - projectNamePrompt := promptui.Prompt{ - Label: "Project Name", - } - - rawInput, err := projectNamePrompt.Run() - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return - } - moduleName := strings.TrimSpace(rawInput) - projectDir := path.Base(strings.TrimSuffix(moduleName, "/")) - - apiType := promptui.Select{ - Label: "Select API Type", - Items: []string{"HTTP (REST)", "gRPC"}, - } - - _, result, err := apiType.Run() - - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return - } - - // Logic to map the selection to a boolean - useGRPC := strings.Contains(result, "gRPC") - - fmt.Printf("๐Ÿš€ Scaffolding '%s' as %s...\n", moduleName, result) - if err := scaffoldProject(projectDir, moduleName, useGRPC); err != nil { - fmt.Printf("error scaffolding project: %v", err) - return - } - - fmt.Println("๐Ÿ“ฆ Installing tools...") - if err := installTools(useGRPC); err != nil { - fmt.Printf("error installing tools: %v", err) - return - } - - fmt.Println("๐Ÿงน Tidying up Go modules...") - if err := runCommand(projectDir, "go", "mod", "tidy"); err != nil { - fmt.Printf("error running 'go mod tidy': %v", err) - return - } - - fmt.Println("โœ… Done!") +func genAPI(cmd *cobra.Command, args []string) { + runNewProject(scaffoldRecipe{label: "API"}) } -func scaffoldProject(projectDir, moduleName string, useGRPC bool) error { +func scaffoldAPI(projectDir, destSubdir, moduleName, serviceName string, useGRPC bool) error { - err := os.MkdirAll(projectDir, 0755) + destRoot := filepath.Join(projectDir, destSubdir) + + err := os.MkdirAll(destRoot, 0755) if err != nil { - return fmt.Errorf("error creating project directory: %v", err) + return fmt.Errorf("error creating project directory: %w", err) } - root := "embedded" + root := "embedded/api" err = fs.WalkDir(assets.EmbedFS, root, func(path string, d fs.DirEntry, err error) error { relPath, _ := filepath.Rel(root, path) @@ -112,40 +66,81 @@ func scaffoldProject(projectDir, moduleName string, useGRPC bool) error { contentBytes, err := assets.EmbedFS.ReadFile(path) if err != nil { - return fmt.Errorf("error reading embedded file %s: %v", path, err) + return fmt.Errorf("error reading embedded file %s: %w", path, err) } content := string(contentBytes) - targetPath := filepath.Join(projectDir, relPath) + targetPath := filepath.Join(destRoot, relPath) targetPath = strings.TrimSuffix(targetPath, ".tmpl") if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { - return fmt.Errorf("error creating directory %s: %v", filepath.Dir(targetPath), err) + return fmt.Errorf("error creating directory %s: %w", filepath.Dir(targetPath), err) } newContent := strings.ReplaceAll(content, oldModuleName, moduleName) newContent = strings.ReplaceAll(newContent, "{{ .ModuleName }}", moduleName) + newContent = strings.ReplaceAll(newContent, "AppTmpl", serviceName) return os.WriteFile(targetPath, []byte(newContent), 0644) }) if err != nil { - return fmt.Errorf("Error copying template files: %v\n", err) + return fmt.Errorf("error copying template files: %w", err) + } + + if useGRPC { + if err := regenerateProto(destRoot, serviceName); err != nil { + return fmt.Errorf("error regenerating proto: %w", err) + } } // Generate main.go from template return genFile( "templates/main.go.tpl", "main", - filepath.Join(projectDir, "cmd/api", "main.go"), + filepath.Join(destRoot, "cmd/api", "main.go"), MainTemplateData{ - ModuleName: moduleName, - UseGRPC: useGRPC, + ModuleName: moduleName, + ServiceName: serviceName, + UseGRPC: useGRPC, }, ) } +func regenerateProto(projectDir, serviceName string) error { + protoPath := filepath.Join(projectDir, "proto", fmt.Sprintf("service_%s.proto", oldModuleName)) + content, err := os.ReadFile(protoPath) + if err != nil { + return fmt.Errorf("error reading service proto: %w", err) + } + newContent := strings.Replace(string(content), "service AppTmpl {", "service "+serviceName+" {", 1) + if err := os.WriteFile(protoPath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("error rewriting service proto: %w", err) + } + + protoFiles, err := filepath.Glob(filepath.Join(projectDir, "proto", "*.proto")) + if err != nil { + return fmt.Errorf("error globbing proto files: %w", err) + } + args := []string{ + "--proto_path=proto", + "--go_out=pb", "--go_opt=paths=source_relative", + "--go-grpc_out=pb", "--go-grpc_opt=paths=source_relative", + "--grpc-gateway_out=pb", "--grpc-gateway_opt=paths=source_relative", + "--openapiv2_out=docs/swagger", "--openapiv2_opt=allow_merge=true,merge_file_name=" + filepath.Base(projectDir), + } + for _, f := range protoFiles { + rel, _ := filepath.Rel(projectDir, f) + args = append(args, rel) + } + if err := runCommand(projectDir, "protoc", args...); err != nil { + return fmt.Errorf("error running protoc: %w", err) + } + + return runCommand(projectDir, "statik", "-src=./docs/swagger", "-dest=./docs") +} + func installTools(useGrpc bool) error { tools := []string{ "github.com/air-verse/air@latest", @@ -170,7 +165,7 @@ func installTools(useGrpc bool) error { for _, tool := range tools { if err := runCommand("", "go", "install", tool); err != nil { - return fmt.Errorf("error installing tool %s: %v", tool, err) + return fmt.Errorf("error installing tool %s: %w", tool, err) } } diff --git a/cmd/client.go b/cmd/client.go new file mode 100644 index 0000000..34cc6e2 --- /dev/null +++ b/cmd/client.go @@ -0,0 +1,321 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +func init() { + newCmd.AddCommand(clientCmd) +} + +var clientCmd = &cobra.Command{ + Use: "client", + Short: "Create a new Draft Client project", + Long: `Create a new Draft Client project with the necessary structure and files.`, + Run: genClient, +} + +func genClient(cmd *cobra.Command, args []string) { + _, projectDir, serviceName, err := promptProjectName() + if err != nil { + fmt.Printf("Prompt failed %v\n", err) + return + } + + isVue, resultLabel, err := promptClientFramework() + if err != nil { + fmt.Printf("Prompt failed %v\n", err) + return + } + + if err := requirePnpmAndNpx(); err != nil { + fmt.Printf("error checking for pnpm and npx: %v", err) + return + } + + fmt.Printf("๐Ÿš€ Scaffolding %s client '%s'...\n", resultLabel, projectDir) + + if isVue { + err = scaffoldClientVue(projectDir, serviceName) + } else { + err = scaffoldClientNuxt(projectDir, serviceName) + } + if err != nil { + fmt.Printf("error scaffolding client: %v", err) + return + } + + fmt.Println("โœ… Done!") +} + +func scaffoldClientNuxt(projectDir, serviceName string) error { + if err := runCommand("", "npx", "nuxi@latest", "init", projectDir, + "--no-install", + "--packageManager=pnpm", + "-t", "ui", + "--gitInit=false", + "-M", "@pinia/nuxt", + ); err != nil { + return fmt.Errorf("error running nuxi init: %w", err) + } + + replacements := map[string]string{ + "App Template": serviceName, + } + if err := copyEmbeddedDir("embedded/client-nuxt", projectDir, replacements); err != nil { + return fmt.Errorf("error copying client overlay: %w", err) + } + + if err := patchNuxtConfig(projectDir, serviceName); err != nil { + return err + } + + fmt.Println("๐Ÿ“ฆ Installing dependencies...") + return runCommand(projectDir, "pnpm", "add", "pinia", "@pinia/nuxt") +} + +func patchNuxtConfig(projectDir, serviceName string) error { + configPath := filepath.Join(projectDir, "nuxt.config.ts") + content, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("error reading nuxt.config.ts: %w", err) + } + + insertion := fmt.Sprintf(` + app: { + head: { + title: %q, + meta: [{ name: 'description', content: %q }], + }, + }, + + runtimeConfig: { + apiInternalUrl: '', + public: { + baseUrl: '', + }, + }, +`, serviceName, serviceName) + + newContent := strings.Replace(string(content), "defineNuxtConfig({", "defineNuxtConfig({"+insertion, 1) + if newContent == string(content) { + return fmt.Errorf("could not find defineNuxtConfig({ anchor in nuxt.config.ts") + } + + return os.WriteFile(configPath, []byte(newContent), 0644) +} + +func scaffoldClientVue(projectDir, serviceName string) error { + parentDir := filepath.Dir(projectDir) + leafName := filepath.Base(projectDir) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("error creating parent directory: %w", err) + } + if err := runCommand(parentDir, "pnpm", "create", "vue@latest", leafName, + "--ts", "--router", "--pinia", "--eslint", "--bare", + ); err != nil { + return fmt.Errorf("error running create-vue: %w", err) + } + + version, err := pnpmVersion() + if err != nil { + return err + } + if err := patchPackageManagerField(projectDir, version); err != nil { + return err + } + + if err := os.WriteFile( + filepath.Join(projectDir, "pnpm-workspace.yaml"), + []byte("allowBuilds:\n vue-demi: true\n"), + 0644, + ); err != nil { + return fmt.Errorf("error writing pnpm-workspace.yaml: %w", err) + } + + fmt.Println("๐Ÿ“ฆ Installing base dependencies...") + if err := runCommand(projectDir, "pnpm", "install"); err != nil { + return fmt.Errorf("error running pnpm install: %w", err) + } + + if err := runCommand(projectDir, "pnpm", "add", "tailwindcss", "@tailwindcss/vite"); err != nil { + return fmt.Errorf("error adding tailwindcss: %w", err) + } + + assetsDir := filepath.Join(projectDir, "src", "assets") + if err := os.MkdirAll(assetsDir, 0755); err != nil { + return fmt.Errorf("error creating src/assets: %w", err) + } + if err := os.WriteFile(filepath.Join(assetsDir, "main.css"), []byte("@import \"tailwindcss\";\n"), 0644); err != nil { + return fmt.Errorf("error writing main.css stub: %w", err) + } + + if err := patchViteConfigForTailwind(projectDir); err != nil { + return err + } + if err := patchTsconfigAlias(projectDir); err != nil { + return err + } + + fmt.Println("๐ŸŽจ Setting up shadcn-vue...") + if err := runCommand(projectDir, "pnpm", "dlx", "shadcn-vue@latest", "init", + "-t", "vite", "-b", "neutral", "--icon-library", "lucide", "-y", "-d", + ); err != nil { + return fmt.Errorf("error running shadcn-vue init: %w", err) + } + + if err := runCommand(projectDir, "pnpm", "dlx", "shadcn-vue@latest", "add", + "button", "input", "label", "select", "-y", + ); err != nil { + return fmt.Errorf("error adding shadcn-vue components: %w", err) + } + + if err := patchMainCssTokens(projectDir); err != nil { + return err + } + if err := patchEslintConfig(projectDir); err != nil { + return err + } + + replacements := map[string]string{ + "App Template": serviceName, + } + if err := copyEmbeddedDir("embedded/client-vue", projectDir, replacements); err != nil { + return fmt.Errorf("error copying client overlay: %w", err) + } + + fmt.Println("๐Ÿ“ฆ Installing remaining dependencies...") + return runCommand(projectDir, "pnpm", "add", "ofetch", "vue-sonner") +} + +func patchPackageManagerField(projectDir, version string) error { + pkgPath := filepath.Join(projectDir, "package.json") + content, err := os.ReadFile(pkgPath) + if err != nil { + return fmt.Errorf("error reading package.json: %w", err) + } + + newContent := strings.Replace(string(content), "{", + fmt.Sprintf("{\n \"packageManager\": \"pnpm@%s\",", version), 1) + if newContent == string(content) { + return fmt.Errorf("could not find opening brace in package.json") + } + + return os.WriteFile(pkgPath, []byte(newContent), 0644) +} + +func patchViteConfigForTailwind(projectDir string) error { + configPath := filepath.Join(projectDir, "vite.config.ts") + content, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("error reading vite.config.ts: %w", err) + } + + newContent := strings.Replace(string(content), + "import vueDevTools from 'vite-plugin-vue-devtools'", + "import vueDevTools from 'vite-plugin-vue-devtools'\nimport tailwindcss from '@tailwindcss/vite'", + 1, + ) + newContent = strings.Replace(newContent, "vueDevTools(),", "vueDevTools(),\n tailwindcss(),", 1) + + if newContent == string(content) { + return fmt.Errorf("could not find expected anchors in vite.config.ts") + } + + return os.WriteFile(configPath, []byte(newContent), 0644) +} + +func patchTsconfigAlias(projectDir string) error { + configPath := filepath.Join(projectDir, "tsconfig.json") + content, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("error reading tsconfig.json: %w", err) + } + + aliasBlock := ` ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +}` + newContent := strings.Replace(string(content), " ]\n}", aliasBlock, 1) + if newContent == string(content) { + return fmt.Errorf("could not find expected anchor in tsconfig.json") + } + + return os.WriteFile(configPath, []byte(newContent), 0644) +} + +func patchMainCssTokens(projectDir string) error { + cssPath := filepath.Join(projectDir, "src", "assets", "main.css") + content, err := os.ReadFile(cssPath) + if err != nil { + return fmt.Errorf("error reading main.css: %w", err) + } + s := string(content) + + s2 := strings.Replace(s, + "--color-background: var(--background);", + "--color-background: var(--background);\n --color-success: var(--success);\n --color-success-foreground: var(--success-foreground);\n --color-warning: var(--warning);\n --color-warning-foreground: var(--warning-foreground);", + 1, + ) + if s2 == s { + return fmt.Errorf("could not find @theme anchor in main.css") + } + + s3 := strings.Replace(s2, + "}\n\n.dark {", + " --success: oklch(0.6 0.15 145);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.75 0.15 80);\n --warning-foreground: oklch(0.145 0 0);\n}\n\n.dark {", + 1, + ) + if s3 == s2 { + return fmt.Errorf("could not find :root close anchor in main.css") + } + + s4 := strings.Replace(s3, + "}\n\n@layer base {", + " --success: oklch(0.65 0.15 145);\n --success-foreground: oklch(0.145 0 0);\n --warning: oklch(0.8 0.15 80);\n --warning-foreground: oklch(0.145 0 0);\n}\n\n@layer base {", + 1, + ) + if s4 == s3 { + return fmt.Errorf("could not find .dark close anchor in main.css") + } + + return os.WriteFile(cssPath, []byte(s4), 0644) +} + +func patchEslintConfig(projectDir string) error { + configPath := filepath.Join(projectDir, "eslint.config.ts") + content, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("error reading eslint.config.ts: %w", err) + } + + override := ` { + name: 'app/rules-overrides', + rules: { + 'vue/multi-word-component-names': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + + ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),` + + newContent := strings.Replace(string(content), + " ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),", + override, + 1, + ) + if newContent == string(content) { + return fmt.Errorf("could not find expected anchor in eslint.config.ts") + } + + return os.WriteFile(configPath, []byte(newContent), 0644) +} diff --git a/cmd/helpers.go b/cmd/helpers.go index 175754c..e6abe63 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -5,13 +5,18 @@ import ( "crypto/rand" "errors" "fmt" + "io/fs" + "math/big" "os" "os/exec" + "path" "path/filepath" + "regexp" "strings" "text/template" "git.fernandovideira.com/FernandoJVideira/draft/assets" + "github.com/manifoldco/promptui" ) const ( @@ -22,7 +27,7 @@ func randomString(length int) string { s, r := make([]rune, length), []rune(chars) for i := range s { - p, _ := rand.Prime(rand.Reader, len(r)) + p, _ := rand.Int(rand.Reader, big.NewInt(int64(len(r)))) x, y := p.Uint64(), uint64(len(r)) s[i] = r[x%y] } @@ -98,3 +103,166 @@ func runCommand(dir, name string, args ...string) error { return cmd.Run() } + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func requirePnpmAndNpx() error { + if !commandExists("pnpm") { + return fmt.Errorf("pnpm is required but not found on PATH") + } + if !commandExists("npx") { + return fmt.Errorf("npx is required but not found on PATH") + } + return nil +} + +func promptClientFramework() (isVue bool, resultLabel string, err error) { + prompt := promptui.Select{ + Label: "Select Client Framework", + Items: []string{"Nuxt", "Vue"}, + } + _, result, err := prompt.Run() + if err != nil { + return false, "", err + } + return result == "Vue", result, nil +} + +func pnpmVersion() (string, error) { + out, err := exec.Command("pnpm", "--version").Output() + if err != nil { + return "", fmt.Errorf("error getting pnpm version: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +func toPascalCase(s string) string { + parts := regexp.MustCompile(`[^a-zA-Z0-9]+`).Split(s, -1) + var b strings.Builder + for _, p := range parts { + if p == "" { + continue + } + b.WriteString(strings.ToUpper(p[:1]) + p[1:]) + } + return b.String() +} + +func promptProjectName() (moduleName, projectDir, serviceName string, err error) { + prompt := promptui.Prompt{Label: "Project Name"} + rawInput, err := prompt.Run() + if err != nil { + return "", "", "", err + } + moduleName = strings.TrimSpace(rawInput) + projectDir = path.Base(strings.TrimSuffix(moduleName, "/")) + serviceName = toPascalCase(projectDir) + return moduleName, projectDir, serviceName, nil +} + +func promptAPIType() (useGRPC bool, resultLabel string, err error) { + prompt := promptui.Select{ + Label: "Select API Type", + Items: []string{"HTTP (REST)", "gRPC"}, + } + _, result, err := prompt.Run() + if err != nil { + return false, "", err + } + return strings.Contains(result, "gRPC"), result, nil +} + +func copyEmbeddedDir(root, destDir string, replacements map[string]string) error { + return fs.WalkDir(assets.EmbedFS, root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + relPath, _ := filepath.Rel(root, path) + contentBytes, err := assets.EmbedFS.ReadFile(path) + if err != nil { + return fmt.Errorf("error reading embedded file %s: %w", path, err) + } + + content := string(contentBytes) + for old, new := range replacements { + content = strings.ReplaceAll(content, old, new) + } + + targetPath := filepath.Join(destDir, relPath) + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return fmt.Errorf("error creating directory %s: %w", filepath.Dir(targetPath), err) + } + + return os.WriteFile(targetPath, []byte(content), 0644) + }) +} + +type scaffoldRecipe struct { + label string + destSubdir string + preScaffold func(projectDir string) error + postScaffold func(projectDir string) error +} + +func runNewProject(recipe scaffoldRecipe) { + moduleName, projectDir, serviceName, err := promptProjectName() + if err != nil { + fmt.Printf("Prompt failed %v\n", err) + return + } + + useGRPC, resultLabel, err := promptAPIType() + if err != nil { + fmt.Printf("Prompt failed %v\n", err) + return + } + + if useGRPC && !commandExists("protoc") { + fmt.Println("โŒ protoc is required for gRPC projects but wasn't found on PATH.") + fmt.Println(" Install it (e.g. `brew install protobuf`) and try again.") + return + } + + fmt.Printf("๐Ÿš€ Scaffolding %s '%s' as %s...\n", recipe.label, moduleName, resultLabel) + + if recipe.preScaffold != nil { + if err := recipe.preScaffold(projectDir); err != nil { + fmt.Printf("error preparing project: %v", err) + return + } + } + + if err := scaffoldAPI(projectDir, recipe.destSubdir, moduleName, serviceName, useGRPC); err != nil { + fmt.Printf("error scaffolding project: %v", err) + return + } + + fmt.Println("๐Ÿ“ฆ Installing tools...") + if err := installTools(useGRPC); err != nil { + fmt.Printf("error installing tools: %v", err) + return + } + + fmt.Println("๐Ÿงน Tidying up Go modules...") + destRoot := filepath.Join(projectDir, recipe.destSubdir) + if err := runCommand(destRoot, "go", "mod", "tidy"); err != nil { + fmt.Printf("error running 'go mod tidy': %v", err) + return + } + + if recipe.postScaffold != nil { + if err := recipe.postScaffold(projectDir); err != nil { + fmt.Printf("error finishing project: %v", err) + return + } + } + + fmt.Println("โœ… Done!") +} diff --git a/cmd/monorepo.go b/cmd/monorepo.go new file mode 100644 index 0000000..8b43d0a --- /dev/null +++ b/cmd/monorepo.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +func init() { + newCmd.AddCommand(monorepoCmd) +} + +var monorepoCmd = &cobra.Command{ + Use: "monorepo", + Short: "Create a new Draft Monorepo project", + Long: `Create a new Draft Monorepo project with the necessary structure and files.`, + Run: genMonorepo, +} + +func genMonorepo(cmd *cobra.Command, args []string) { + runNewProject(scaffoldRecipe{ + label: "monorepo", + destSubdir: "apps/api", + preScaffold: func(projectDir string) error { + if err := os.MkdirAll(projectDir, 0755); err != nil { + return fmt.Errorf("error creating project directory: %w", err) + } + return copyEmbeddedDir("embedded/monorepo-root", projectDir, nil) + }, + postScaffold: func(projectDir string) error { + if err := requirePnpmAndNpx(); err != nil { + return fmt.Errorf("error checking for pnpm and npx: %w", err) + } + + isVue, resultLabel, err := promptClientFramework() + if err != nil { + return fmt.Errorf("prompt failed: %w", err) + } + + clientDir := filepath.Join(projectDir, "apps/client") + serviceName := toPascalCase(filepath.Base(projectDir)) + + fmt.Printf("๐Ÿš€ Scaffolding %s client app...\n", resultLabel) + if isVue { + return scaffoldClientVue(clientDir, serviceName) + } + return scaffoldClientNuxt(clientDir, serviceName) + }, + }) +} diff --git a/cmd/new.go b/cmd/new.go index 8646e54..be57c1b 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -1,15 +1,18 @@ package cmd import ( + "fmt" + + "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) -const oldModuleName = "github.com/FernandoJVideira/api_tmpl" -const srcDir = "embedded/api_tmpl" +const oldModuleName = "app_tmpl" type MainTemplateData struct { - ModuleName string - UseGRPC bool + ModuleName string + ServiceName string + UseGRPC bool } func init() { @@ -22,3 +25,27 @@ var newCmd = &cobra.Command{ Long: `Create a new Draft project with the necessary structure and files.`, Run: genProject, } + +func genProject(cmd *cobra.Command, args []string) { + projType := promptui.Select{ + Label: "Select Project Type", + Items: []string{"API", "Client", "Full Stack"}, + } + + _, result, err := projType.Run() + if err != nil { + fmt.Printf("Prompt failed %v\n", err) + return + } + + switch result { + case "API": + genAPI(cmd, args) + case "Client": + genClient(cmd, args) + case "Full Stack": + genMonorepo(cmd, args) + default: + fmt.Println("Invalid selection") + } +} diff --git a/tools/sync_templates.go b/tools/sync_templates.go index 41cb907..3670065 100644 --- a/tools/sync_templates.go +++ b/tools/sync_templates.go @@ -3,67 +3,129 @@ package main import ( - "io/fs" "log" "os" + "os/exec" "path/filepath" "strings" ) const ( - sourceDir = "assets/testdata/api_tmpl" - targetDir = "assets/embedded" - oldModuleName = "github.com/FernandoJVideira/api_tmpl" + oldModuleName = "app_tmpl" moduleTemplate = "{{ .ModuleName }}" ) -func main() { - log.Println("Syncing templates...") - syncTemplates() +type syncJob struct { + templateName string // optional, for logging purposes + sourceDir string + targetDir string + excludePrefix string // relative-path prefix to skip, e.g. "apps/"; empty = sync everything + includePrefixes []string // if set, only paths with one of these prefixes sync; empty = sync everything } -func syncTemplates() { +var jobs = []syncJob{ + { + templateName: "api", + sourceDir: "assets/testdata/template/apps/api", + targetDir: "assets/embedded/api", + }, + { + templateName: "monorepo-root", + sourceDir: "assets/testdata/template", + targetDir: "assets/embedded/monorepo-root", + excludePrefix: "apps/", + }, + { + templateName: "client-nuxt", + sourceDir: "assets/testdata/template/apps/client", + targetDir: "assets/embedded/client-nuxt", + includePrefixes: []string{"app/", "public/"}, + }, + { + templateName: "client-vue", + sourceDir: "assets/testdata/template/apps/vue-client", + targetDir: "assets/embedded/client-vue", + includePrefixes: []string{ + "src/App.vue", + "src/main.ts", + "src/router/", + "src/stores/", + "src/composables/", + "src/lib/api-client.ts", + "src/components/AppBrand.vue", + "src/components/AppLayout.vue", + "src/views/", + "src/shared/", + ".env.example", + }, + }, +} - err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } +func main() { + log.Println("Syncing templates...") + for _, job := range jobs { + syncTemplates(job) + } +} - relPath, _ := filepath.Rel(sourceDir, path) - destPath := filepath.Join(targetDir, relPath) +func syncTemplates(job syncJob) { - if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { - if d.IsDir() { - return filepath.SkipDir + err := os.RemoveAll(job.targetDir) + if err != nil { + log.Fatalf("Error removing target directory: %v", err) + } + + cmd := exec.Command("git", "-C", job.sourceDir, "ls-files") + output, err := cmd.Output() + if err != nil { + log.Fatalf("Error listing files in source directory: %v", err) + } + + paths := strings.Split(strings.TrimSpace(string(output)), "\n") + + for _, relPath := range paths { + + if len(job.includePrefixes) > 0 { + included := false + for _, prefix := range job.includePrefixes { + if strings.HasPrefix(relPath, prefix) { + included = true + break + } } - return nil + if !included { + continue + } + } else if job.excludePrefix != "" && strings.HasPrefix(relPath, job.excludePrefix) { + continue } - if d.IsDir() { - return os.MkdirAll(destPath, 0755) + srcPath := filepath.Join(job.sourceDir, relPath) + destPath := filepath.Join(job.targetDir, relPath) + + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + log.Fatalf("Error creating destination directory: %v", err) } - content, err := os.ReadFile(path) + content, err := os.ReadFile(srcPath) if err != nil { - return err + log.Fatalf("Error reading source file: %v", err) } - if d.Name() == "go.mod" || d.Name() == "go.sum" { + if filepath.Base(relPath) == "go.mod" || filepath.Base(relPath) == "go.sum" { destPath += ".tmpl" } - if d.Name() == "go.mod" { + if filepath.Base(relPath) == "go.mod" { strContent := string(content) strContent = strings.Replace(strContent, oldModuleName, moduleTemplate, 1) content = []byte(strContent) } - return os.WriteFile(destPath, content, 0644) - }) - - if err != nil { - log.Fatalf("Error walking source: %v", err) + if err := os.WriteFile(destPath, content, 0644); err != nil { + log.Fatalf("Error writing to destination file: %v", err) + } } - log.Println("Templates synced successfully!") + log.Printf("Template %s synced successfully!", job.templateName) }