Merge pull request 'Chore/refactor' (#1) from chore/refactor into dev

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
FernandoJVideira committed 2026-08-25 20:35:06 +00:00
commit 2fd651965d
14 files changed
+880 -151

No files matched your search

+3 -3
View File
@@ -1,4 +1,4 @@
[submodule "assets/testdata/api_tmpl"]
path = assets/testdata/api_tmpl
url = [email protected]:FernandoJVideira/api_tmpl.git
[submodule "assets/testdata/template"]
path = assets/testdata/template
url = [email protected]:FernandoJVideira/project-template.git
branch = dev
+76 -5
View File
@@ -1,11 +1,13 @@
# Draft
A powerful CLI tool for bootstrapping Go API projects with best practices built-in. Draft helps you quickly scaffold production-ready Go APIs with support for both HTTP (REST) and gRPC architectures.
A powerful CLI tool for bootstrapping Go APIs, frontend clients, and full-stack monorepos with best practices built-in. Draft helps you quickly scaffold production-ready projects — a standalone Go API, a standalone Nuxt/Vue client, or both wired together in a single monorepo.
## Features
- 🚀 **Quick Project Scaffolding** - Generate complete API projects with a single command
- 🚀 **Quick Project Scaffolding** - Generate complete projects with a single command
- 🧩 **Three Project Shapes** - API only, client only, or a full-stack monorepo combining both
- 🔄 **Multiple API Types** - Support for both HTTP (REST) and gRPC architectures
- 🖼️ **Multiple Client Frameworks** - Nuxt or Vue, built on top of the official `nuxi`/`create-vue` tooling so the boilerplate always stays current
- 📦 **Service Generation** - Easily create new services with proper structure
- 🎯 **Handler Generation** - Generate HTTP or gRPC handlers on-demand
- 🔐 **Cryptographic Key Generator** - Built-in secure key generation
@@ -42,6 +44,14 @@ The compiled binary will be named `draft_<os>_<arch>` (e.g. `draft_darwin_arm64`
## Usage
### Create a New Project
```bash
draft new
```
Prompts you to pick a project shape — **API**, **Client**, or **Full Stack** (monorepo) — then runs the matching flow below. Each shape is also available directly as its own subcommand if you already know what you want.
### Create a New API Project
```bash
@@ -64,6 +74,30 @@ This interactive command will:
- `swag` - Swagger documentation (HTTP only)
- `protoc-gen-*` - Protocol Buffer generators (gRPC only)
### Create a New Client Project
```bash
draft new client
```
1. Prompts for a project name
2. Prompts you to pick a framework — **Nuxt** or **Vue**
3. Scaffolds the base app via the official tooling (`nuxi init` for Nuxt, `create-vue` + `shadcn-vue` for Vue), so the generated boilerplate, dependency versions, and lockfile are always current rather than pinned by Draft
4. Layers a Draft-maintained overlay on top: an auth store (login/logout/register/token refresh), route guards, API composable, and pages for login/signup/profile/organizations/email verification/invites — all wired up to talk to a Draft-generated API out of the box
Both frameworks produce a comparable app: Nuxt UI + Tailwind for Nuxt, shadcn-vue + Tailwind for Vue.
### Create a New Monorepo Project
```bash
draft new monorepo
```
Runs the API flow and the client flow together under one root, wired up as a single project:
- `apps/api` — the scaffolded Go API
- `apps/client` — the scaffolded Nuxt or Vue client
- Root-level `go.work`, `pnpm-workspace.yaml`, `docker-compose.yml`, and `Justfile` tying both together
### Generate a New Service
```bash
@@ -88,10 +122,11 @@ Creates a new handler in the appropriate directory:
### Generate a Cryptographic Key
```bash
draft new key
draft key
draft key --size 48
```
Generates a secure random key for use in your application (default: 32 characters).
Generates a secure random key for use in your application (default and minimum size: 32 characters). Passing `--size` below 32 is rejected rather than silently substituted.
### Check Version
@@ -101,7 +136,7 @@ draft version
## Project Structure
When you create a new project, Draft generates a structured Go API with:
### API
```
your-project/
@@ -118,10 +153,38 @@ your-project/
└── go.mod
```
### Client
```
your-client/
├── app/ (Nuxt) or src/ (Vue)
│ ├── stores/auth.ts
│ ├── composables/useApi.ts
│ ├── pages/ or views/
│ └── ...
├── nuxt.config.ts (Nuxt) or vite.config.ts + components.json (Vue)
└── package.json
```
### Monorepo
```
your-project/
├── apps/
│ ├── api/ (Go API, its own go.mod)
│ └── client/ (Nuxt or Vue client)
├── go.work
├── pnpm-workspace.yaml
├── docker-compose.yml
└── Justfile
```
## Requirements
- Go 1.25.5 or higher
- Make (for building from source)
- `pnpm` and `npx` (Node.js) — required for client and monorepo scaffolding
- `protoc` — required only for gRPC API projects
## Dependencies
@@ -143,6 +206,14 @@ make build
make version
```
### Sync Templates
Draft's embedded API/client templates are synced from a reference monorepo checked out as a git submodule (`assets/testdata/template`). After updating that submodule, regenerate the embedded copies with:
```bash
go run tools/sync_templates.go
```
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
+1 -1
View File
@@ -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
+64 -23
View File
@@ -49,8 +49,6 @@ import (
{{ end }}
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
)
var interruptSignals = []os.Signal{
@@ -59,25 +57,24 @@ var interruptSignals = []os.Signal{
syscall.SIGINT,
}
// @title Simple Bank API
// @title App Template API
// @version 1.0
// @description API for Simple Bank application
// @description REST API template: accounts (register/login/email verification/refresh tokens) and multi-tenant organizations (members/invites) already wired up.
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.email [email protected]
// @contact.name App Template
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /v1
// @BasePath /api/v1
// @schemes http https
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
// @description PASETO access token. Format: "Bearer <token>". Some endpoints also accept the equivalent value via the "access_token" cookie.
func main() {
config, err := config.LoadConfig(".")
if err != nil {
@@ -86,6 +83,7 @@ func main() {
if config.Environment == "development" {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
ctx, cancel := signal.NotifyContext(context.Background(), interruptSignals...)
@@ -114,15 +112,20 @@ func main() {
userService := service.NewUserService(store, taskDist)
authService := service.NewAuthService(store, tokenMaker, config)
orgService := service.NewOrganizationService(store, taskDist)
wg, ctx := errgroup.WithContext(ctx)
{{ if not .UseGRPC }}
runGinServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
services := api.Services{
UserService: userService,
AuthService: authService,
OrganizationService: orgService,
}
runGinServer(ctx, wg, config, store, taskDist, tokenMaker, services)
{{ else }}
// Assuming you want Gateway AND gRPC when gRPC is selected
runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService)
runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService)
{{ end }}
runTaskProcessor(ctx, wg, redisOpt, store, config)
@@ -133,8 +136,19 @@ func main() {
}
}
func runTaskProcessor(ctx context.Context, wg *errgroup.Group, redisOpt asynq.RedisClientOpt, store db.Store, config config.Config) {
mailer := mailer.NewGmailSender(config.EmailSenderName, config.EmailSender, config.EmailPassword)
func runTaskProcessor(
ctx context.Context,
wg *errgroup.Group,
redisOpt asynq.RedisClientOpt,
store db.Store,
config config.Config,
) {
mailer := mailer.NewSMTPSender(
config.EmailSenderName,
config.EmailSender,
config.EmailPassword,
config.SMTPAddr,
)
taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer, config)
log.Info().Msg("start task processor")
if err := taskProcessor.Start(); err != nil {
@@ -164,8 +178,16 @@ func runDBMigration(migrationURL, dbSource string) {
}
{{ if not .UseGRPC }}
func runGinServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, td worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) {
s, err := api.NewServer(config, store, td, tokenMaker, userService, authService)
func runGinServer(
ctx context.Context,
wg *errgroup.Group,
config config.Config,
store db.Store,
td worker.TaskDistributor,
tokenMaker auth.Maker,
services api.Services,
) {
s, err := api.NewServer(config, store, td, tokenMaker, services)
if err != nil {
log.Fatal().Err(err).Msg("cannot create server")
}
@@ -194,8 +216,18 @@ func runGinServer(ctx context.Context, wg *errgroup.Group, config config.Config,
{{ end }}
{{ if .UseGRPC }}
func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, taskDistributor worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) {
server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService)
func runGrpcServer(
ctx context.Context,
wg *errgroup.Group,
config config.Config,
store db.Store,
td worker.TaskDistributor,
tokenMaker auth.Maker,
userService service.UserService,
authService service.AuthService,
organizationService service.OrganizationService,
) {
server, err := gapi.NewServer(config, store, td, tokenMaker, userService, authService, organizationService)
if err != nil {
log.Fatal().Err(err).Msg("cannot create server:")
}
@@ -203,7 +235,7 @@ func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config
grpcLogger := grpc.UnaryInterceptor(gapi.GrpcLogger)
grpcServer := grpc.NewServer(grpcLogger)
pb.RegisterSimpleBankServer(grpcServer, server)
pb.Register{{ .ServiceName }}Server(grpcServer, server)
reflection.Register(grpcServer)
listener, err := net.Listen("tcp", config.GrpcServerAddr)
@@ -233,8 +265,18 @@ func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config
})
}
func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, taskDistributor worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) {
server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService)
func runGatewayServer(
ctx context.Context,
wg *errgroup.Group,
config config.Config,
store db.Store,
td worker.TaskDistributor,
tokenMaker auth.Maker,
userService service.UserService,
authService service.AuthService,
organizationService service.OrganizationService,
) {
server, err := gapi.NewServer(config, store, td, tokenMaker, userService, authService, organizationService)
if err != nil {
log.Fatal().Err(err).Msg("cannot create server:")
}
@@ -250,7 +292,7 @@ func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Con
grpcMux := runtime.NewServeMux(opts)
err = pb.RegisterSimpleBankHandlerServer(ctx, grpcMux, server)
err = pb.Register{{ .ServiceName }}HandlerServer(ctx, grpcMux, server)
if err != nil {
log.Fatal().Err(err).Msg("cannot register handler server")
}
@@ -318,6 +360,5 @@ func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Con
log.Info().Msg("HTTP gateway server shut down")
return nil
})
}
{{ end }}
Vendored Submodule
+1
Submodule assets/testdata/template added at 3945b8b64a.
+58 -63
View File
@@ -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)
}
}
+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)
}
+169 -1
View File
@@ -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!")
}
+12 -18
View File
@@ -6,32 +6,26 @@ import (
"github.com/spf13/cobra"
)
const defaultKeySize = 32
var keySize int
func init() {
newCmd.AddCommand(keyCmd)
rootCmd.AddCommand(keyCmd)
keyCmd.Flags().IntVar(&keySize, "size", defaultKeySize, "Length of the generated key")
}
var size int
var defaultSize = 32
var keyCmd = &cobra.Command{
Use: "key [size]",
Use: "key",
Short: "Generate a new cryptographic key",
Long: `Generate a new cryptographic key for use in Draft projects.`,
Run: func(cmd *cobra.Command, args []string) {
var keyLength int
if cmd.Flags().Lookup("size").Changed {
keyLength = size
} else {
keyLength = defaultSize
if cmd.Flags().Changed("size") && keySize < defaultKeySize {
fmt.Printf("❌ Key size must be at least %d for adequate security.\n", defaultKeySize)
return
}
if keyLength <= 32 {
fmt.Println("Warning: It is recommended to use a key size greater than 32 for better security.")
fmt.Println("Generating a key of default size:", defaultSize)
keyLength = defaultSize
}
key := randomString(keyLength)
println("Generated Key:", key)
key := randomString(keySize)
fmt.Println("Generated Key:", key)
},
}
+52
View File
@@ -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)
},
})
}
+31 -4
View File
@@ -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")
}
}
-2
View File
@@ -45,8 +45,6 @@ func genService(cmd *cobra.Command, args []string) {
return
}
print(moduleName)
serviceName := strings.ToLower(strings.TrimSpace(serviceNameInput))
cases := cases.Title(language.English)
+92 -30
View File
@@ -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)
}