Public Access
Version 0.1.0 #3
No files matched your search
+1
-1
@@ -3,5 +3,5 @@ package assets
|
|||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:generate go run ../tools/sync_templates.go
|
//go:generate go run ../tools/sync_templates.go
|
||||||
//go:embed templates/* embedded/*
|
//go:embed all:templates all:embedded
|
||||||
var EmbedFS embed.FS
|
var EmbedFS embed.FS
|
||||||
+56
-61
@@ -4,12 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.fernandovideira.com/FernandoJVideira/draft/assets"
|
"git.fernandovideira.com/FernandoJVideira/draft/assets"
|
||||||
"github.com/manifoldco/promptui"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,67 +19,23 @@ var apiCmd = &cobra.Command{
|
|||||||
Use: "api",
|
Use: "api",
|
||||||
Short: "Create a new Draft API project",
|
Short: "Create a new Draft API project",
|
||||||
Long: `Create a new Draft API project with the necessary structure and files.`,
|
Long: `Create a new Draft API project with the necessary structure and files.`,
|
||||||
Run: genProject,
|
Run: genAPI,
|
||||||
}
|
}
|
||||||
|
|
||||||
func genProject(cmd *cobra.Command, args []string) {
|
func genAPI(cmd *cobra.Command, args []string) {
|
||||||
|
runNewProject(scaffoldRecipe{label: "API"})
|
||||||
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 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 {
|
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 {
|
err = fs.WalkDir(assets.EmbedFS, root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
|
||||||
relPath, _ := filepath.Rel(root, path)
|
relPath, _ := filepath.Rel(root, path)
|
||||||
@@ -112,40 +66,81 @@ func scaffoldProject(projectDir, moduleName string, useGRPC bool) error {
|
|||||||
|
|
||||||
contentBytes, err := assets.EmbedFS.ReadFile(path)
|
contentBytes, err := assets.EmbedFS.ReadFile(path)
|
||||||
if err != nil {
|
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)
|
content := string(contentBytes)
|
||||||
|
|
||||||
targetPath := filepath.Join(projectDir, relPath)
|
targetPath := filepath.Join(destRoot, relPath)
|
||||||
|
|
||||||
targetPath = strings.TrimSuffix(targetPath, ".tmpl")
|
targetPath = strings.TrimSuffix(targetPath, ".tmpl")
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
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(content, oldModuleName, moduleName)
|
||||||
newContent = strings.ReplaceAll(newContent, "{{ .ModuleName }}", moduleName)
|
newContent = strings.ReplaceAll(newContent, "{{ .ModuleName }}", moduleName)
|
||||||
|
newContent = strings.ReplaceAll(newContent, "AppTmpl", serviceName)
|
||||||
|
|
||||||
return os.WriteFile(targetPath, []byte(newContent), 0644)
|
return os.WriteFile(targetPath, []byte(newContent), 0644)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
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
|
// Generate main.go from template
|
||||||
return genFile(
|
return genFile(
|
||||||
"templates/main.go.tpl",
|
"templates/main.go.tpl",
|
||||||
"main",
|
"main",
|
||||||
filepath.Join(projectDir, "cmd/api", "main.go"),
|
filepath.Join(destRoot, "cmd/api", "main.go"),
|
||||||
MainTemplateData{
|
MainTemplateData{
|
||||||
ModuleName: moduleName,
|
ModuleName: moduleName,
|
||||||
|
ServiceName: serviceName,
|
||||||
UseGRPC: useGRPC,
|
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 {
|
func installTools(useGrpc bool) error {
|
||||||
tools := []string{
|
tools := []string{
|
||||||
"github.com/air-verse/air@latest",
|
"github.com/air-verse/air@latest",
|
||||||
@@ -170,7 +165,7 @@ func installTools(useGrpc bool) error {
|
|||||||
|
|
||||||
for _, tool := range tools {
|
for _, tool := range tools {
|
||||||
if err := runCommand("", "go", "install", tool); err != nil {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+321
@@ -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)
|
||||||
|
}
|
||||||
+169
-1
@@ -5,13 +5,18 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"git.fernandovideira.com/FernandoJVideira/draft/assets"
|
"git.fernandovideira.com/FernandoJVideira/draft/assets"
|
||||||
|
"github.com/manifoldco/promptui"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,7 +27,7 @@ func randomString(length int) string {
|
|||||||
s, r := make([]rune, length), []rune(chars)
|
s, r := make([]rune, length), []rune(chars)
|
||||||
|
|
||||||
for i := range s {
|
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))
|
x, y := p.Uint64(), uint64(len(r))
|
||||||
s[i] = r[x%y]
|
s[i] = r[x%y]
|
||||||
}
|
}
|
||||||
@@ -98,3 +103,166 @@ func runCommand(dir, name string, args ...string) error {
|
|||||||
|
|
||||||
return cmd.Run()
|
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!")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+29
-2
@@ -1,14 +1,17 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/manifoldco/promptui"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
const oldModuleName = "github.com/FernandoJVideira/api_tmpl"
|
const oldModuleName = "app_tmpl"
|
||||||
const srcDir = "embedded/api_tmpl"
|
|
||||||
|
|
||||||
type MainTemplateData struct {
|
type MainTemplateData struct {
|
||||||
ModuleName string
|
ModuleName string
|
||||||
|
ServiceName string
|
||||||
UseGRPC bool
|
UseGRPC bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,3 +25,27 @@ var newCmd = &cobra.Command{
|
|||||||
Long: `Create a new Draft project with the necessary structure and files.`,
|
Long: `Create a new Draft project with the necessary structure and files.`,
|
||||||
Run: genProject,
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-34
@@ -3,67 +3,129 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sourceDir = "assets/testdata/api_tmpl"
|
oldModuleName = "app_tmpl"
|
||||||
targetDir = "assets/embedded"
|
|
||||||
oldModuleName = "github.com/FernandoJVideira/api_tmpl"
|
|
||||||
moduleTemplate = "{{ .ModuleName }}"
|
moduleTemplate = "{{ .ModuleName }}"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
type syncJob struct {
|
||||||
log.Println("Syncing templates...")
|
templateName string // optional, for logging purposes
|
||||||
syncTemplates()
|
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 {
|
func main() {
|
||||||
|
log.Println("Syncing templates...")
|
||||||
|
for _, job := range jobs {
|
||||||
|
syncTemplates(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncTemplates(job syncJob) {
|
||||||
|
|
||||||
|
err := os.RemoveAll(job.targetDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Fatalf("Error removing target directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
relPath, _ := filepath.Rel(sourceDir, path)
|
cmd := exec.Command("git", "-C", job.sourceDir, "ls-files")
|
||||||
destPath := filepath.Join(targetDir, relPath)
|
output, err := cmd.Output()
|
||||||
|
|
||||||
if strings.HasPrefix(d.Name(), ".") && d.Name() != "." {
|
|
||||||
if d.IsDir() {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if d.IsDir() {
|
|
||||||
return os.MkdirAll(destPath, 0755)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Fatalf("Error listing files in source directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.Name() == "go.mod" || d.Name() == "go.sum" {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !included {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if job.excludePrefix != "" && strings.HasPrefix(relPath, job.excludePrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error reading source file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.Base(relPath) == "go.mod" || filepath.Base(relPath) == "go.sum" {
|
||||||
destPath += ".tmpl"
|
destPath += ".tmpl"
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.Name() == "go.mod" {
|
if filepath.Base(relPath) == "go.mod" {
|
||||||
strContent := string(content)
|
strContent := string(content)
|
||||||
strContent = strings.Replace(strContent, oldModuleName, moduleTemplate, 1)
|
strContent = strings.Replace(strContent, oldModuleName, moduleTemplate, 1)
|
||||||
content = []byte(strContent)
|
content = []byte(strContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(destPath, content, 0644)
|
if err := os.WriteFile(destPath, content, 0644); err != nil {
|
||||||
})
|
log.Fatalf("Error writing to destination file: %v", err)
|
||||||
|
}
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error walking source: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Templates synced successfully!")
|
log.Printf("Template %s synced successfully!", job.templateName)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user