Files
draft/cmd/client.go
T
FernandoJVideiraandClaude Sonnet 5.5 b7fe8f3f4e Scaffold generated clients with @lucide/vue
The template overlay now imports its icons from @lucide/vue (project-template#6),
so the scaffolder has to install that instead of the deprecated
lucide-vue-next: pnpm doesn't hoist the kit's own copy to the app, and a
fresh project would fail to resolve its icon imports. No version is pinned
here - these stay unpinned `pnpm add` calls, so the template remains the
version authority.

Submodule bumped to that commit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 23:54:00 +01:00

397 lines
13 KiB
Go

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.
"[email protected]": "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",
); 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.
"[email protected]": "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")
}
// 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)
}