Merge pull request 'Version 0.1.0' (#3) from dev into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
FernandoJVideira committed 2026-08-25 20:42:49 +00:00
commit dff28b1a4c
24 files changed
+1033 -191

No files matched your search

-1
View File
@@ -1 +0,0 @@
test
+1 -1
View File
@@ -30,4 +30,4 @@ go.work.sum
.env .env
# Binary # Binary
/keystone* /draft*
+4 -3
View File
@@ -1,3 +1,4 @@
[submodule "assets/testdata/api_tmpl"] [submodule "assets/testdata/template"]
path = assets/testdata/api_tmpl path = assets/testdata/template
url = git@gitea.fernandovideira.com:FernandoJVideira/api_tmpl.git url = [email protected]:FernandoJVideira/project-template.git
branch = dev
+7 -7
View File
@@ -1,23 +1,23 @@
PKG_NAME="gitea.fernandovideira.com/FernandoJVideira/keystone" PKG_NAME="git.fernandovideira.com/FernandoJVideira/draft"
version: version:
@go run -ldflags "-X ${PKG_NAME}/cmd.version=v1.0.0" ./main.go version @go run -ldflags "-X ${PKG_NAME}/cmd.version=v0.1.0" ./main.go version
build: prepare-embed build: prepare-embed
@echo "Building for $(GOOS)/$(GOARCH)..." @echo "Building for $(GOOS)/$(GOARCH)..."
@GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X ${PKG_NAME}/cmd.version=v0.0.1" -o $(OUTPUT) ./main.go @GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X ${PKG_NAME}/cmd.version=v0.1.0" -o $(OUTPUT) ./main.go
windows: windows:
@$(MAKE) build GOOS=windows GOARCH=amd64 OUTPUT=keystone_windows_amd64.exe @$(MAKE) build GOOS=windows GOARCH=amd64 OUTPUT=draft_windows_amd64.exe
win-arm: win-arm:
@$(MAKE) build GOOS=windows GOARCH=arm64 OUTPUT=keystone_windows_arm64.exe @$(MAKE) build GOOS=windows GOARCH=arm64 OUTPUT=draft_windows_arm64.exe
linux: linux:
@$(MAKE) build GOOS=linux GOARCH=amd64 OUTPUT=keystone_linux_amd64 @$(MAKE) build GOOS=linux GOARCH=amd64 OUTPUT=draft_linux_amd64
mac: mac:
@$(MAKE) build GOOS=darwin GOARCH=arm64 OUTPUT=keystone_darwin_arm64 @$(MAKE) build GOOS=darwin GOARCH=arm64 OUTPUT=draft_darwin_arm64
prepare-embed: prepare-embed:
@git submodule update --init --recursive @git submodule update --init --recursive
+84 -17
View File
@@ -1,11 +1,13 @@
# Keystone # Draft
A powerful CLI tool for bootstrapping Go API projects with best practices built-in. Keystone 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 ## 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 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 - 📦 **Service Generation** - Easily create new services with proper structure
- 🎯 **Handler Generation** - Generate HTTP or gRPC handlers on-demand - 🎯 **Handler Generation** - Generate HTTP or gRPC handlers on-demand
- 🔐 **Cryptographic Key Generator** - Built-in secure key generation - 🔐 **Cryptographic Key Generator** - Built-in secure key generation
@@ -17,8 +19,8 @@ A powerful CLI tool for bootstrapping Go API projects with best practices built-
### From Source ### From Source
```bash ```bash
git clone https://github.com/yourusername/keystone.git git clone [email protected]:FernandoJVideira/draft.git
cd keystone cd draft
make build make build
``` ```
@@ -38,14 +40,22 @@ make windows
make win-arm make win-arm
``` ```
The compiled binary will be named `basalt` (or `basalt.exe` on Windows). The compiled binary will be named `draft_<os>_<arch>` (e.g. `draft_darwin_arm64`, or `draft_windows_amd64.exe` on Windows).
## Usage ## 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 ### Create a New API Project
```bash ```bash
keystone new api draft new api
``` ```
This interactive command will: This interactive command will:
@@ -64,10 +74,34 @@ This interactive command will:
- `swag` - Swagger documentation (HTTP only) - `swag` - Swagger documentation (HTTP only)
- `protoc-gen-*` - Protocol Buffer generators (gRPC 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 ### Generate a New Service
```bash ```bash
keystone new service draft new service
``` ```
Creates a new service file in `internal/service/` with: Creates a new service file in `internal/service/` with:
@@ -78,7 +112,7 @@ Creates a new service file in `internal/service/` with:
### Generate a New Handler ### Generate a New Handler
```bash ```bash
keystone new handler draft new handler
``` ```
Creates a new handler in the appropriate directory: Creates a new handler in the appropriate directory:
@@ -88,20 +122,21 @@ Creates a new handler in the appropriate directory:
### Generate a Cryptographic Key ### Generate a Cryptographic Key
```bash ```bash
keystone 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 ### Check Version
```bash ```bash
keystone version draft version
``` ```
## Project Structure ## Project Structure
When you create a new project, Keystone generates a structured Go API with: ### API
``` ```
your-project/ your-project/
@@ -118,10 +153,38 @@ your-project/
└── go.mod └── 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 ## Requirements
- Go 1.25.5 or higher - Go 1.25.5 or higher
- Make (for building from source) - Make (for building from source)
- `pnpm` and `npx` (Node.js) — required for client and monorepo scaffolding
- `protoc` — required only for gRPC API projects
## Dependencies ## Dependencies
@@ -143,6 +206,14 @@ make build
make version 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 ## Contributing
Contributions are welcome! Please feel free to submit a Pull Request. Contributions are welcome! Please feel free to submit a Pull Request.
@@ -154,7 +225,3 @@ This project is licensed under the MIT License - see the LICENSE file for detail
## Author ## Author
Built by LKResuts for internal usage to bootstrap Go APIs. Built by LKResuts for internal usage to bootstrap Go APIs.
---
**Note:** The binary is compiled as `basalt`, but the CLI tool is referred to as `keystone` in documentation and usage.
+1 -1
View File
@@ -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
+64 -23
View File
@@ -49,8 +49,6 @@ import (
{{ end }} {{ end }}
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
) )
var interruptSignals = []os.Signal{ var interruptSignals = []os.Signal{
@@ -59,25 +57,24 @@ var interruptSignals = []os.Signal{
syscall.SIGINT, syscall.SIGINT,
} }
// @title Simple Bank API // @title App Template API
// @version 1.0 // @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/ // @termsOfService http://swagger.io/terms/
// @contact.name API Support // @contact.name App Template
// @contact.email [email protected]
// @license.name MIT // @license.name MIT
// @license.url https://opensource.org/licenses/MIT // @license.url https://opensource.org/licenses/MIT
// @host localhost:8080 // @host localhost:8080
// @BasePath /v1 // @BasePath /api/v1
// @schemes http https // @schemes http https
// @securityDefinitions.apikey BearerAuth // @securityDefinitions.apikey BearerAuth
// @in header // @in header
// @name Authorization // @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() { func main() {
config, err := config.LoadConfig(".") config, err := config.LoadConfig(".")
if err != nil { if err != nil {
@@ -86,6 +83,7 @@ func main() {
if config.Environment == "development" { if config.Environment == "development" {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
zerolog.SetGlobalLevel(zerolog.DebugLevel)
} }
ctx, cancel := signal.NotifyContext(context.Background(), interruptSignals...) ctx, cancel := signal.NotifyContext(context.Background(), interruptSignals...)
@@ -114,15 +112,20 @@ func main() {
userService := service.NewUserService(store, taskDist) userService := service.NewUserService(store, taskDist)
authService := service.NewAuthService(store, tokenMaker, config) authService := service.NewAuthService(store, tokenMaker, config)
orgService := service.NewOrganizationService(store, taskDist)
wg, ctx := errgroup.WithContext(ctx) wg, ctx := errgroup.WithContext(ctx)
{{ if not .UseGRPC }} {{ 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 }} {{ else }}
// Assuming you want Gateway AND gRPC when gRPC is selected runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService)
runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService) runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService)
runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
{{ end }} {{ end }}
runTaskProcessor(ctx, wg, redisOpt, store, config) 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) { func runTaskProcessor(
mailer := mailer.NewGmailSender(config.EmailSenderName, config.EmailSender, config.EmailPassword) 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) taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer, config)
log.Info().Msg("start task processor") log.Info().Msg("start task processor")
if err := taskProcessor.Start(); err != nil { if err := taskProcessor.Start(); err != nil {
@@ -164,8 +178,16 @@ func runDBMigration(migrationURL, dbSource string) {
} }
{{ if not .UseGRPC }} {{ 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) { func runGinServer(
s, err := api.NewServer(config, store, td, tokenMaker, userService, authService) 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 { if err != nil {
log.Fatal().Err(err).Msg("cannot create server") log.Fatal().Err(err).Msg("cannot create server")
} }
@@ -194,8 +216,18 @@ func runGinServer(ctx context.Context, wg *errgroup.Group, config config.Config,
{{ end }} {{ end }}
{{ if .UseGRPC }} {{ 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) { func runGrpcServer(
server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService) 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 { if err != nil {
log.Fatal().Err(err).Msg("cannot create server:") 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) grpcLogger := grpc.UnaryInterceptor(gapi.GrpcLogger)
grpcServer := grpc.NewServer(grpcLogger) grpcServer := grpc.NewServer(grpcLogger)
pb.RegisterSimpleBankServer(grpcServer, server) pb.Register{{ .ServiceName }}Server(grpcServer, server)
reflection.Register(grpcServer) reflection.Register(grpcServer)
listener, err := net.Listen("tcp", config.GrpcServerAddr) 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) { func runGatewayServer(
server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService) 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 { if err != nil {
log.Fatal().Err(err).Msg("cannot create server:") 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) grpcMux := runtime.NewServeMux(opts)
err = pb.RegisterSimpleBankHandlerServer(ctx, grpcMux, server) err = pb.Register{{ .ServiceName }}HandlerServer(ctx, grpcMux, server)
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("cannot register handler server") 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") log.Info().Msg("HTTP gateway server shut down")
return nil return nil
}) })
} }
{{ end }} {{ end }}
Vendored Submodule
+1
Submodule assets/testdata/template added at 3945b8b64a.
+59 -64
View File
@@ -4,12 +4,10 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.fernandovideira.com/FernandoJVideira/keystone/assets" "git.fernandovideira.com/FernandoJVideira/draft/assets"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -19,69 +17,25 @@ func init() {
var apiCmd = &cobra.Command{ var apiCmd = &cobra.Command{
Use: "api", Use: "api",
Short: "Create a new Keystone API project", Short: "Create a new Draft API project",
Long: `Create a new Keystone 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
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)
}
+1 -1
View File
@@ -23,7 +23,7 @@ func init() {
var handlerCmd = &cobra.Command{ var handlerCmd = &cobra.Command{
Use: "handler", Use: "handler",
Short: "Create a new handler", Short: "Create a new handler",
Long: `Create a new handler for your Keystone project.`, Long: `Create a new handler for your Draft project.`,
Run: genHandler, Run: genHandler,
} }
+171 -2
View File
@@ -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"
"gitea.fernandovideira.com/FernandoJVideira/keystone/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,167 @@ 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]))
b.WriteString(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!")
}
+14 -20
View File
@@ -6,32 +6,26 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func init() { const defaultKeySize = 32
newCmd.AddCommand(keyCmd)
}
var size int var keySize int
var defaultSize = 32
func init() {
rootCmd.AddCommand(keyCmd)
keyCmd.Flags().IntVar(&keySize, "size", defaultKeySize, "Length of the generated key")
}
var keyCmd = &cobra.Command{ var keyCmd = &cobra.Command{
Use: "key [size]", Use: "key",
Short: "Generate a new cryptographic key", Short: "Generate a new cryptographic key",
Long: `Generate a new cryptographic key for use in Keystone projects.`, Long: `Generate a new cryptographic key for use in Draft projects.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
var keyLength int if cmd.Flags().Changed("size") && keySize < defaultKeySize {
if cmd.Flags().Lookup("size").Changed { fmt.Printf("❌ Key size must be at least %d for adequate security.\n", defaultKeySize)
keyLength = size return
} else {
keyLength = defaultSize
} }
if keyLength <= 32 { key := randomString(keySize)
fmt.Println("Warning: It is recommended to use a key size greater than 32 for better security.") fmt.Println("Generated Key:", key)
fmt.Println("Generating a key of default size:", defaultSize)
keyLength = defaultSize
}
key := randomString(keyLength)
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,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/simple_bank" 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
} }
@@ -18,7 +21,31 @@ func init() {
var newCmd = &cobra.Command{ var newCmd = &cobra.Command{
Use: "new", Use: "new",
Short: "Create a new Keystone project", Short: "Create a new Draft project",
Long: `Create a new Keystone 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")
}
}
+3 -3
View File
@@ -8,9 +8,9 @@ import (
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "keystone", Use: "draft",
Short: "Keystone is a CLI tool for bootstrapping Go APIs", Short: "Draft is a CLI tool for bootstrapping Go APIs",
Long: `Keystone is a CLI tool built by LKResuts for internal usage to bootstrap Go APIs`, Long: `Draft is a CLI tool built by LKResuts for internal usage to bootstrap Go APIs`,
} }
func Execute() { func Execute() {
+1 -3
View File
@@ -24,7 +24,7 @@ type ServiceTemplateData struct {
var serviceCmd = &cobra.Command{ var serviceCmd = &cobra.Command{
Use: "service", Use: "service",
Short: "Create a new service", Short: "Create a new service",
Long: `Create a new service for your Keystone project.`, Long: `Create a new service for your Draft project.`,
Run: genService, Run: genService,
} }
@@ -45,8 +45,6 @@ func genService(cmd *cobra.Command, args []string) {
return return
} }
print(moduleName)
serviceName := strings.ToLower(strings.TrimSpace(serviceNameInput)) serviceName := strings.ToLower(strings.TrimSpace(serviceNameInput))
cases := cases.Title(language.English) cases := cases.Title(language.English)
+3 -4
View File
@@ -6,7 +6,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Default version (we will change this dynamically later!)
var version = "dev" var version = "dev"
func init() { func init() {
@@ -15,9 +14,9 @@ func init() {
var versionCmd = &cobra.Command{ var versionCmd = &cobra.Command{
Use: "version", Use: "version",
Short: "Display the version of Keystone", Short: "Display the version of Draft",
Long: `Display the current version of the Keystone CLI tool.`, Long: `Display the current version of the Draft CLI tool.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Keystone CLI version: %s\n", version) fmt.Printf("Draft CLI version: %s\n", version)
}, },
} }
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.fernandovideira.com/FernandoJVideira/keystone module git.fernandovideira.com/FernandoJVideira/draft
go 1.25.5 go 1.25.5
+1 -1
View File
@@ -1,6 +1,6 @@
package main package main
import "gitea.fernandovideira.com/FernandoJVideira/keystone/cmd" import "git.fernandovideira.com/FernandoJVideira/draft/cmd"
func main() { func main() {
cmd.Execute() cmd.Execute()
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"archive/zip"
"fmt"
"os"
"path/filepath"
"strings"
)
func main() {
if len(os.Args) != 3 {
fmt.Println("usage: create_zip <source_dir> <output_zip>")
os.Exit(1)
}
sourceDir := os.Args[1]
outputZip := os.Args[2]
outFile, err := os.Create(outputZip)
if err != nil {
panic(err)
}
defer outFile.Close()
w := zip.NewWriter(outFile)
defer w.Close()
err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil // Skip directory entries entirely
}
// Skip hidden files/directories (like .DS_Store)
if strings.HasPrefix(filepath.Base(path), ".") {
return nil
}
// Get path relative to the source directory
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
// Ensure forward slashes for zip spec compliance
relPath = filepath.ToSlash(relPath)
// Create file in zip
f, err := w.Create(relPath)
if err != nil {
return err
}
// Write content
fileContent, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = f.Write(fileContent)
return err
})
if err != nil {
panic(err)
}
}
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
set -e
# Usage: ./tools/publish-package.sh <version> <gitea-token>
VERSION=$1
TOKEN=$2
MODULE="gitea.fernandovideira.com/fernandojvideira/keystone"
# Note: The zip must contain the full domain path as the root directory
ZIP_ROOT="${MODULE}@${VERSION}"
API_URL="https://gitea.fernandovideira.com/api/packages/FernandoJVideira/go/upload"
if [ -z "$VERSION" ] || [ -z "$TOKEN" ]; then
echo "Usage: $0 <version> <gitea-token>"
exit 1
fi
echo "🚀 preparing release $VERSION..."
# 1. Generate the assets locally
make prepare-embed
# 2. Create a temporary staging area
TMP_DIR=$(mktemp -d)
TARGET_DIR="$TMP_DIR/$ZIP_ROOT"
mkdir -p "$TARGET_DIR"
# 3. Export clean git source to staging
git archive --format=tar HEAD | tar -x -C "$TARGET_DIR"
# 4. Copy the generated (and git-ignored) assets into the staging area
# We assume assets/embedded exists after 'make prepare-embed'
mkdir -p "$TARGET_DIR/assets/embedded"
cp -r assets/embedded/* "$TARGET_DIR/assets/embedded/"
# 5. Zip it up (using a custom Go tool to avoid directory entries)
ZIP_FILE="keystone-$VERSION.zip"
# We run the tool from the current directory, pointing it to the temp dir
go run tools/create_zip.go "$TMP_DIR" "$ZIP_FILE"
# 6. Upload to Gitea
echo "📦 Uploading to Gitea Package Registry..."
curl --fail --silent --show-error \
--user "FernandoJVideira:$TOKEN" \
--upload-file "$ZIP_FILE" \
"$API_URL"
# Cleanup
rm -rf "$TMP_DIR" "$ZIP_FILE"
echo "✅ Published $VERSION successfully!"
+96 -34
View File
@@ -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/simple_bank"
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)
} }