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 { // "minimal", not "ui" - the Nuxt UI starter is what we'd otherwise get // for free, but this project uses vue-ui-kit as its component library // instead, wired up by hand below (Tailwind v4 + the overlay's own // app/assets/css/main.css already import it). if err := runCommand("", "npx", "nuxi@latest", "init", projectDir, "--no-install", "--packageManager=pnpm", "-t", "minimal", "--gitInit=false", "-M", "@pinia/nuxt", ); err != nil { return fmt.Errorf("error running nuxi init: %w", err) } version, err := pnpmVersion() if err != nil { return fmt.Errorf("error detecting pnpm version: %w", err) } replacements := map[string]string{ "App Template": serviceName, // The overlay's Dockerfile pins the pnpm that produced the template's // lockfile; the generated project's lockfile comes from the local pnpm. "pnpm@11.22.0": "pnpm@" + version, } 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...") if err := runCommand(projectDir, "pnpm", "add", "pinia", "@pinia/nuxt", "@fernandojvideira/vue-ui-kit", "lucide-vue-next", ); err != nil { return fmt.Errorf("error installing dependencies: %w", err) } // typescript itself: the "minimal" starter (unlike "ui", and unlike // create-vue's own output) doesn't include it, but @vue/compiler-sfc // needs a real TypeScript install to resolve cross-file type extends - // exactly what several vue-ui-kit components' Props interfaces do. // Pinned below 7.x (matching create-vue's own "~6.0.0" default) since // @vue/compiler-sfc's cross-file resolution isn't compatible with // TypeScript 7 yet - it fails with "No fs option provided to // compileScript in non-Node environment" on every extended interface. return runCommand(projectDir, "pnpm", "add", "-D", "tailwindcss", "@tailwindcss/vite", "typescript@~6.0.0", "vue-tsc") } 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) } // The "minimal" starter registers no Tailwind plugin and no CSS entry // point on its own (the "ui" starter did both, bundled inside @nuxt/ui) - // both are added explicitly here since the overlay's own main.css needs // to actually run. newContent := strings.Replace(string(content), "// https://nuxt.com/docs/api/configuration/nuxt-config\n", "// https://nuxt.com/docs/api/configuration/nuxt-config\nimport tailwindcss from '@tailwindcss/vite'\n\n", 1, ) if newContent == string(content) { return fmt.Errorf("could not find the file-header anchor in nuxt.config.ts") } insertion := fmt.Sprintf(` css: ['~/assets/css/main.css'], vite: { plugins: [tailwindcss()], // vue-ui-kit ships raw .vue/.ts source. Left to Vite's dependency // pre-bundler, its index.ts gets bundled with reka-ui inlined while its // .vue files import reka-ui from source - two module instances, so // reka-ui's provide/inject contexts (Dialog, Tabs, Select...) can't find // each other. Excluding it keeps every import on the one reka-ui. optimizeDeps: { exclude: ['@fernandojvideira/vue-ui-kit'], }, }, hooks: { // /login and /signup are the two tabs of one auth card, served by the // same page file. A second route record (not an alias - the router // would treat switching between a route and its alias as a duplicate // navigation and drop it) lets the tab change the URL while NuxtPage // keeps the same component instance thanks to the page's fixed key. 'pages:extend'(pages) { const login = pages.find((page) => page.path === '/login') if (login) pages.push({ ...login, name: 'signup', path: '/signup' }) }, }, app: { head: { title: %q, meta: [{ name: 'description', content: %q }], }, }, runtimeConfig: { apiInternalUrl: '', public: { baseUrl: '', }, }, `, serviceName, serviceName) finalContent := strings.Replace(newContent, "defineNuxtConfig({", "defineNuxtConfig({"+insertion, 1) if finalContent == newContent { return fmt.Errorf("could not find defineNuxtConfig({ anchor in nuxt.config.ts") } return os.WriteFile(configPath, []byte(finalContent), 0644) } func insidePnpmWorkspace(dir string) bool { for { if _, err := os.Stat(filepath.Join(dir, "pnpm-workspace.yaml")); err == nil { return true } parent := filepath.Dir(dir) if parent == dir { return false } dir = parent } } 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 } // A standalone client is its own workspace root and needs vue-demi's build // script approved. Inside a monorepo the root pnpm-workspace.yaml already // does that - and writing one here would make apps/client a separate // workspace, so the installs below would update a nested lockfile and // leave the root one describing the template's client instead of this one. if !insidePnpmWorkspace(parentDir) { 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(vueUiKitMainCSS), 0644); err != nil { return fmt.Errorf("error writing main.css: %w", err) } if err := patchViteConfigForTailwind(projectDir); err != nil { return err } if err := patchTsconfigAlias(projectDir); err != nil { return err } if err := patchEslintConfig(projectDir); err != nil { return err } replacements := map[string]string{ "App Template": serviceName, // The overlay's Dockerfile pins the pnpm that produced the template's // lockfile; the generated project's lockfile comes from the local pnpm. "pnpm@11.22.0": "pnpm@" + version, } 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", "@fernandojvideira/vue-ui-kit", "lucide-vue-next") } // The library's theme brings its own tokens; --success/--warning are added on // top the same way a consumer would (see vue-ui-kit's THEMING.md) since the // base theme deliberately doesn't define them. const vueUiKitMainCSS = `@import "tailwindcss"; @source "../../node_modules/@fernandojvideira/vue-ui-kit/src"; @import "@fernandojvideira/vue-ui-kit/theme.css"; :root { --success: 142 71% 35%; --success-foreground: 0 0% 98%; --warning: 38 92% 40%; --warning-foreground: 0 0% 98%; } .dark { --success: 142 71% 45%; --success-foreground: 0 0% 9%; --warning: 38 92% 55%; --warning-foreground: 0 0% 9%; } @theme inline { --color-success: hsl(var(--success)); --color-success-foreground: hsl(var(--success-foreground)); --color-warning: hsl(var(--warning)); --color-warning-foreground: hsl(var(--warning-foreground)); } ` 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) // create-vue's config ends with the resolve.alias block; hang the // optimizeDeps exclusion off its closing brace. newContent = strings.Replace(newContent, " '@': fileURLToPath(new URL('./src', import.meta.url)),\n },\n },\n})", " '@': fileURLToPath(new URL('./src', import.meta.url)),\n },\n },\n"+viteOptimizeDepsExclude+"})", 1, ) if newContent == string(content) || !strings.Contains(newContent, "optimizeDeps") { return fmt.Errorf("could not find expected anchors in vite.config.ts") } return os.WriteFile(configPath, []byte(newContent), 0644) } // vue-ui-kit ships raw .vue/.ts source. Left to Vite's dependency pre-bundler, // its index.ts gets bundled with reka-ui inlined while its .vue files import // reka-ui from source - two module instances, so reka-ui's provide/inject // contexts (Dialog, Tabs, Select...) can't find each other. Excluding it keeps // every import on the one reka-ui. const viteOptimizeDepsExclude = ` optimizeDeps: { exclude: ['@fernandojvideira/vue-ui-kit'], }, ` 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 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) }