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
This commit is contained in:
FernandoJVideira committed 2026-08-25 21:33:00 +01:00
1 parent fe48b7caee
commit 29f94539e0
7 files changed
+724 -99

No files matched your search

+321
View File
@@ -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)
}