feat: basalt 0.0.1

This commit is contained in:
FernandoJVideira committed 2026-01-27 17:33:14 +00:00
1 parent 487d1d324d
commit 5e31fc2f23
22 files changed
+1364 -1

No files matched your search

+6
View File
@@ -1,7 +1,11 @@
.DS_Store
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
assets/embedded/
# Binaries for programs and plugins
*.exe
*.exe~
@@ -25,3 +29,5 @@ go.work.sum
# env file
.env
# Binary
/basalt
+3
View File
@@ -0,0 +1,3 @@
[submodule "assets/testdata/api_tmpl"]
path = assets/testdata/api_tmpl
url = [email protected]:FernandoJVideira/api_tmpl.git
+31
View File
@@ -0,0 +1,31 @@
PKG_NAME="gitea.fernandovideira.com/FernandoJVideira/keystone"
version:
@go run -ldflags "-X ${PKG_NAME}/cmd.version=v1.0.0" cmd/basalt/main.go version
generate-templates:
@echo "Initializing submodules..."
@git submodule update --init --recursive
@echo "Generating embedded templates..."
@go run tools/sync_templates.go
build: prepare-embed
@echo "Building for $(GOOS)/$(GOARCH)..."
@GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X ${PKG_NAME}/cmd.version=v0.0.1" -o $(OUTPUT) ./cmd/basalt/main.go
windows:
@$(MAKE) build GOOS=windows GOARCH=amd64 OUTPUT=basalt.exe
win-arm:
@$(MAKE) build GOOS=windows GOARCH=arm64 OUTPUT=basalt.exe
linux:
@$(MAKE) build GOOS=linux GOARCH=amd64 OUTPUT=basalt
mac:
@$(MAKE) build GOOS=darwin GOARCH=arm64 OUTPUT=basalt
prepare-embed:
@git submodule update --init --recursive
@mkdir -p assets/embedded
@go run tools/sync_templates.go
+159 -1
View File
@@ -1,2 +1,160 @@
# keystone
# Keystone
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.
## Features
- 🚀 **Quick Project Scaffolding** - Generate complete API projects with a single command
- 🔄 **Multiple API Types** - Support for both HTTP (REST) and gRPC architectures
- 📦 **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
- 🛠️ **Pre-configured Tooling** - Automatic installation of essential Go tools
- 📝 **Template-based** - Consistent code generation using embedded templates
## Installation
### From Source
```bash
git clone https://github.com/yourusername/keystone.git
cd keystone
make build
```
### Build for Different Platforms
```bash
# macOS (ARM64)
make mac
# Linux (AMD64)
make linux
# Windows (AMD64)
make windows
# Windows (ARM64)
make win-arm
```
The compiled binary will be named `basalt` (or `basalt.exe` on Windows).
## Usage
### Create a New API Project
```bash
keystone new api
```
This interactive command will:
1. Prompt you for a project name (module name)
2. Ask you to select an API type (HTTP REST or gRPC)
3. Scaffold the complete project structure
4. Install required development tools
5. Run `go mod tidy` to organize dependencies
**Tools Automatically Installed:**
- `air` - Live reload for Go apps
- `golangci-lint` - Go linters aggregator
- `mockgen` - Mock generation for testing
- `sqlc` - SQL compiler for type-safe database code
- `migrate` - Database migration tool
- `swag` - Swagger documentation (HTTP only)
- `protoc-gen-*` - Protocol Buffer generators (gRPC only)
### Generate a New Service
```bash
keystone new service
```
Creates a new service file in `internal/service/` with:
- Service interface definition
- Service struct implementation
- Compile-time interface checks
### Generate a New Handler
```bash
keystone new handler
```
Creates a new handler in the appropriate directory:
- HTTP handlers → `internal/handlers/http/`
- gRPC handlers → `internal/handlers/grpc/`
### Generate a Cryptographic Key
```bash
keystone new key
```
Generates a secure random key for use in your application (default: 32 characters).
### Check Version
```bash
keystone version
```
## Project Structure
When you create a new project, Keystone generates a structured Go API with:
```
your-project/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── handlers/
│ │ ├── http/ (if HTTP selected)
│ │ └── grpc/ (if gRPC selected)
│ └── service/
├── proto/ (if gRPC selected)
├── pb/ (if gRPC selected)
└── go.mod
```
## Requirements
- Go 1.25.5 or higher
- Make (for building from source)
## Dependencies
- [Cobra](https://github.com/spf13/cobra) - CLI framework
- [promptui](https://github.com/manifoldco/promptui) - Interactive prompts
- [golang.org/x/text](https://golang.org/x/text) - Text processing utilities
## Development
### Build
```bash
make build
```
### Run with Version Flag
```bash
make version
```
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Author
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.
+7
View File
@@ -0,0 +1,7 @@
package assets
import "embed"
//go:generate go run ../tools/sync_templates.go
//go:embed templates/* embedded/*
var EmbedFS embed.FS
+39
View File
@@ -0,0 +1,39 @@
package gapi
import (
"context"
"{{ .ModuleName }}/pb" // We need the module name to import pb correctly!
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/genproto/googleapis/rpc/errdetails"
)
func (s *Server) {{ .Name }}(ctx context.Context, req *pb.{{ .Name }}Request) (*pb.{{ .Name }}Response, error) {
violations := validate{{ .Name }}Request(req)
if violations != nil {
return nil, invalidArgumentError(violations)
}
// TODO: Call service layer
// result, err := s.service.{{ .Name }}(ctx, ...)
/*
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to execute: %v", err)
}
*/
resp := &pb.{{ .Name }}Response{
// TODO: Map result to response
}
return resp, nil
}
func validate{{ .Name }}Request(req *pb.{{ .Name }}Request) (violations []*errdetails.BadRequest_FieldViolation) {
// TODO: Add validation logic
// if err := val.ValidateString(req.GetField()); err != nil {
// violations = append(violations, fieldViolation("field", err))
// }
return violations
}
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
// We can assume imports like 'response' are available in the same package
// or we might need to fix imports if this file is in a subfolder.
)
type {{ .Name }}Request struct {
// TODO: Add request fields
// Username string `json:"username" binding:"required"`
}
type {{ .Name }}Response struct {
// TODO: Add response fields
}
// {{ .Name }} godoc
// @Summary {{ .Name }}
// @Description {{ .Name }} handler
// @Accept json
// @Produce json
// @Param request body {{ .Name }}Request true "Request body"
// @Success 200 {object} response.JSONResponse{data={{ .Name }}Response}
// @Router /v1/{{ .Name }} [post]
func (s *Server) create{{ .Name }}(ctx *gin.Context) {
var req {{ .Name }}Request
// We assume response.JSONResponse is available or imported
var resp response.JSONResponse
if err := ctx.ShouldBindJSON(&req); err != nil {
resp = response.JSONResponse{
Success: false,
Error: err.Error(),
}
ctx.JSON(http.StatusBadRequest, resp)
return
}
// TODO: Call your service here
// result, err := s.service.{{ .Name }}(ctx, req)
/*
if err != nil {
resp = response.JSONResponse{
Success: false,
Error: err.Error(),
}
ctx.JSON(http.StatusInternalServerError, resp)
return
}
*/
resp = response.JSONResponse{
Success: true,
Message: "Operation successful",
Data: nil, // Replace with actual data
}
ctx.JSON(http.StatusOK, resp)
}
+323
View File
@@ -0,0 +1,323 @@
package main
import (
"context"
{{ if .UseGRPC }}
"errors"
"net"
"net/http"
{{ end }}
"os"
"os/signal"
"syscall"
"github.com/hibiken/asynq"
{{ if .UseGRPC }}
"github.com/rs/cors"
{{ end }}
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"golang.org/x/sync/errgroup"
"{{ .ModuleName }}/internal/auth"
"{{ .ModuleName }}/internal/config"
db "{{ .ModuleName }}/internal/db/sqlc"
{{ if .UseGRPC }}
gapi "{{ .ModuleName }}/internal/handlers/grpc"
{{ end }}
{{ if not .UseGRPC }}
api "{{ .ModuleName }}/internal/handlers/http"
{{ end }}
"{{ .ModuleName }}/internal/mailer"
"{{ .ModuleName }}/internal/service"
"{{ .ModuleName }}/internal/worker"
{{ if .UseGRPC }}
"{{ .ModuleName }}/pb"
{{ end }}
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
{{ if .UseGRPC }}
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/rakyll/statik/fs"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"google.golang.org/protobuf/encoding/protojson"
_ "{{ .ModuleName }}/docs/statik"
{{ end }}
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
)
var interruptSignals = []os.Signal{
os.Interrupt,
syscall.SIGTERM,
syscall.SIGINT,
}
// @title Simple Bank API
// @version 1.0
// @description API for Simple Bank application
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.email [email protected]
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /v1
// @schemes http https
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
func main() {
config, err := config.LoadConfig(".")
if err != nil {
log.Fatal().Err(err).Msg("cannot load config")
}
if config.Environment == "development" {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
}
ctx, cancel := signal.NotifyContext(context.Background(), interruptSignals...)
defer cancel()
connPool, err := pgxpool.New(ctx, config.DBAddr)
if err != nil {
log.Fatal().Err(err).Msg("cannot connect to db")
}
runDBMigration(config.MigrationURL, config.DBAddr)
redisOpt := asynq.RedisClientOpt{
Addr: config.RedisAddr,
Password: config.RedisPassword,
DB: config.RedisDB,
}
taskDist := worker.NewRedisTaskDistributor(redisOpt)
store := db.NewPostgresStore(connPool)
tokenMaker, err := auth.NewPasetoMaker(config)
if err != nil {
log.Fatal().Err(err).Msg("cannot create token maker")
}
userService := service.NewUserService(store, taskDist)
authService := service.NewAuthService(store, tokenMaker, config)
wg, ctx := errgroup.WithContext(ctx)
{{ if not .UseGRPC }}
runGinServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
{{ 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)
{{ end }}
runTaskProcessor(ctx, wg, redisOpt, store, config)
err = wg.Wait()
if err != nil {
log.Fatal().Err(err).Msg("application terminated with error")
}
}
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)
taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer, config)
log.Info().Msg("start task processor")
if err := taskProcessor.Start(); err != nil {
log.Fatal().Err(err).Msg("cannot start task processor")
}
wg.Go(func() error {
<-ctx.Done()
log.Info().Msg("gracefully shutting down task processor")
taskProcessor.Shutdown()
log.Info().Msg("task processor shut down")
return nil
})
}
func runDBMigration(migrationURL, dbSource string) {
migration, err := migrate.New(migrationURL, dbSource)
if err != nil {
log.Fatal().Err(err).Msg("cannot create new migrate instance")
}
if err = migration.Up(); err != nil && err != migrate.ErrNoChange {
log.Fatal().Err(err).Msg("failed to run migrate up")
}
log.Info().Msg("db migrated successfully")
}
{{ 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)
if err != nil {
log.Fatal().Err(err).Msg("cannot create server")
}
wg.Go(func() error {
err = s.Start(config.HTTPServerAddr)
if err != nil {
log.Fatal().Err(err).Msg("cannot start server:")
return err
}
return nil
})
wg.Go(func() error {
<-ctx.Done()
log.Info().Msg("shutting down server")
err := s.Shutdown(ctx)
if err != nil {
log.Error().Err(err).Msg("cannot shutdown server")
return err
}
log.Info().Msg("server shut down")
return nil
})
}
{{ 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)
if err != nil {
log.Fatal().Err(err).Msg("cannot create server:")
}
grpcLogger := grpc.UnaryInterceptor(gapi.GrpcLogger)
grpcServer := grpc.NewServer(grpcLogger)
pb.RegisterSimpleBankServer(grpcServer, server)
reflection.Register(grpcServer)
listener, err := net.Listen("tcp", config.GrpcServerAddr)
if err != nil {
log.Fatal().Err(err).Msg("cannot create listener")
}
wg.Go(func() error {
log.Info().Msgf("start gRPC server at %s", listener.Addr().String())
err = grpcServer.Serve(listener)
if err != nil {
if errors.Is(err, grpc.ErrServerStopped) {
return nil
}
log.Error().Err(err).Msg("gRPC server failed to serve")
return err
}
return nil
})
wg.Go(func() error {
<-ctx.Done()
log.Info().Msg("gracefully shutting down gRPC server")
grpcServer.GracefulStop()
log.Info().Msg("gRPC server shut down")
return nil
})
}
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)
if err != nil {
log.Fatal().Err(err).Msg("cannot create server:")
}
opts := runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{
MarshalOptions: protojson.MarshalOptions{
UseProtoNames: true,
},
UnmarshalOptions: protojson.UnmarshalOptions{
DiscardUnknown: true,
},
})
grpcMux := runtime.NewServeMux(opts)
err = pb.RegisterSimpleBankHandlerServer(ctx, grpcMux, server)
if err != nil {
log.Fatal().Err(err).Msg("cannot register handler server")
}
mux := http.NewServeMux()
mux.Handle("/", grpcMux)
statikFS, err := fs.New()
if err != nil {
log.Fatal().Err(err).Msg("cannot create statik fs")
}
swaggerHandler := http.StripPrefix("/swagger/", http.FileServer(statikFS))
mux.Handle("/swagger/", swaggerHandler)
c := cors.New(
cors.Options{
AllowedOrigins: config.AllowedOrigins,
AllowedMethods: []string{
http.MethodGet,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodOptions,
},
AllowedHeaders: []string{
"Accept",
"Authorization",
"Content-Type",
"X-CSRF-Token",
},
AllowCredentials: true,
Debug: config.Environment == "development",
},
)
handler := c.Handler(gapi.HTTPLogger(mux))
httpServer := &http.Server{
Handler: handler,
Addr: config.HTTPServerAddr,
}
wg.Go(func() error {
log.Info().Msgf("start HTTP gateway server at %s", httpServer.Addr)
err = httpServer.ListenAndServe()
if err != nil {
if errors.Is(err, http.ErrServerClosed) {
return nil
}
log.Error().Err(err).Msg("cannot start http gateway server")
return err
}
return nil
})
wg.Go(func() error {
<-ctx.Done()
log.Info().Msg("gracefully shutting down HTTP gateway server")
err := httpServer.Shutdown(context.Background())
if err != nil {
log.Error().Err(err).Msg("cannot shut down http gateway server")
return err
}
log.Info().Msg("HTTP gateway server shut down")
return nil
})
}
{{ end }}
+23
View File
@@ -0,0 +1,23 @@
package service
import (
"context"
"fmt"
db "{{ .ModuleName }}/internal/db/sqlc"
// TODO: Add other imports as needed
// "{{ .ModuleName }}/internal/worker"
)
type {{ .LowerName }}Service struct {
store db.Store
// TODO: Add other dependencies
// taskDistributor worker.TaskDistributor
}
// New{{ .Name }}Service creates a new {{ .LowerName }} service
func New{{ .Name }}Service(store db.Store) {{ .Name }}Service {
return &{{ .LowerName }}Service{
store: store,
}
}
Vendored Submodule
+1
Submodule assets/testdata/api_tmpl added at 961bdd81d4.
+178
View File
@@ -0,0 +1,178 @@
package cmd
import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"gitea.fernandovideira.com/FernandoJVideira/keystone/assets"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
)
func init() {
newCmd.AddCommand(apiCmd)
}
var apiCmd = &cobra.Command{
Use: "api",
Short: "Create a new Keystone API project",
Long: `Create a new Keystone API project with the necessary structure and files.`,
Run: genProject,
}
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 scaffoldProject(projectDir, moduleName string, useGRPC bool) error {
err := os.MkdirAll(projectDir, 0755)
if err != nil {
return fmt.Errorf("error creating project directory: %v", err)
}
root := "embedded"
err = fs.WalkDir(assets.EmbedFS, root, func(path string, d fs.DirEntry, err error) error {
relPath, _ := filepath.Rel(root, path)
if !useGRPC {
// If User chose HTTP: Remove gRPC stuff
if strings.HasPrefix(relPath, "pb") ||
strings.HasPrefix(relPath, "proto") ||
strings.Contains(relPath, "handlers/grpc") ||
strings.HasPrefix(relPath, "docs/statik") ||
strings.HasPrefix(relPath, "docs/swagger") {
return nil
}
} else {
// If User chose gRPC: Remove HTTP stuff
if strings.Contains(relPath, "handlers/http") {
return nil
}
}
if strings.HasSuffix(relPath, "cmd/api/main.go") {
return nil
}
if d.IsDir() {
return nil
}
contentBytes, err := assets.EmbedFS.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading embedded file %s: %v", path, err)
}
content := string(contentBytes)
targetPath := filepath.Join(projectDir, 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)
}
newContent := strings.ReplaceAll(content, oldModuleName, moduleName)
newContent = strings.ReplaceAll(newContent, "{{ .ModuleName }}", moduleName)
return os.WriteFile(targetPath, []byte(newContent), 0644)
})
if err != nil {
return fmt.Errorf("Error copying template files: %v\n", err)
}
// Generate main.go from template
return genFile(
"templates/main.go.tpl",
"main",
filepath.Join(projectDir, "cmd/api", "main.go"),
MainTemplateData{
ModuleName: moduleName,
UseGRPC: useGRPC,
},
)
}
func installTools(useGrpc bool) error {
tools := []string{
"github.com/air-verse/air@latest",
"github.com/golangci/golangci-lint/cmd/golangci-lint@latest",
"go.uber.org/mock/mockgen@latest",
"github.com/sqlc-dev/sqlc/cmd/sqlc@latest",
"github.com/golang-migrate/migrate/v4/cmd/migrate@latest",
}
if useGrpc {
tools = append(tools,
"google.golang.org/protobuf/cmd/protoc-gen-go@latest",
"google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest",
"github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest",
"github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest",
)
} else {
tools = append(tools,
"github.com/swaggo/swag/cmd/swag@latest",
)
}
for _, tool := range tools {
if err := runCommand("", "go", "install", tool); err != nil {
return fmt.Errorf("error installing tool %s: %v", tool, err)
}
}
return nil
}
+7
View File
@@ -0,0 +1,7 @@
package main
import "gitea.fernandovideira.com/FernandoJVideira/keystone/cmd"
func main() {
cmd.Execute()
}
+83
View File
@@ -0,0 +1,83 @@
package cmd
import (
"fmt"
"path/filepath"
"strings"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var handlerTypes = map[string]string{
"HTTP (REST)": "http",
"gRPC": "grpc",
}
func init() {
newCmd.AddCommand(handlerCmd)
}
var handlerCmd = &cobra.Command{
Use: "handler",
Short: "Create a new handler",
Long: `Create a new handler for your Keystone project.`,
Run: genHandler,
}
type HandlerTemplateData struct {
ModuleName string
Name string
}
func genHandler(cmd *cobra.Command, args []string) {
namePrompt := promptui.Prompt{
Label: "Handler Name",
}
handlerNameInput, err := namePrompt.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return
}
handlerTypeInput := promptui.Select{
Label: "Select API Type",
Items: []string{"HTTP (REST)", "gRPC"},
}
_, result, err := handlerTypeInput.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return
}
moduleName, err := getModuleName()
if err != nil {
fmt.Printf("❌ Error retrieving module name: %v\n", err)
return
}
mappedHandlerType := handlerTypes[result]
handlerName := strings.ToLower(handlerNameInput)
cases := cases.Title(language.English)
fmt.Printf("🚀 Creating handler '%s' as %s...\n", handlerNameInput, result)
if err := genFile(
fmt.Sprintf("templates/handler_%s.go.tpl", mappedHandlerType),
handlerName,
filepath.Join("internal/handlers", mappedHandlerType, fmt.Sprintf("%s.go", handlerName)),
HandlerTemplateData{
ModuleName: moduleName,
Name: cases.String(handlerNameInput),
},
); err != nil {
fmt.Printf("❌ Error creating handler: %v\n", err)
return
}
fmt.Printf("✅ Handler '%s' created successfully at %s\n", handlerNameInput, filepath.Join("internal/handlers", mappedHandlerType, fmt.Sprintf("%s.go", handlerName)))
}
+100
View File
@@ -0,0 +1,100 @@
package cmd
import (
"bufio"
"crypto/rand"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"gitea.fernandovideira.com/FernandoJVideira/keystone/assets"
)
const (
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_+"
)
func randomString(length int) string {
s, r := make([]rune, length), []rune(chars)
for i := range s {
p, _ := rand.Prime(rand.Reader, len(r))
x, y := p.Uint64(), uint64(len(r))
s[i] = r[x%y]
}
return string(s)
}
func genFile(tplPath, tplName, outputPath string, data any) error {
tplContent, err := assets.EmbedFS.ReadFile(tplPath)
if err != nil {
return fmt.Errorf("error reading %s template: %w", tplPath, err)
}
tpl, err := template.New(tplName).Parse(string(tplContent))
if err != nil {
return fmt.Errorf("error parsing %s template: %w", tplPath, err)
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return fmt.Errorf("error ensuring directory for %s: %w", outputPath, err)
}
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("error creating %s: %w", outputPath, err)
}
defer outFile.Close()
return tpl.Execute(outFile, data)
}
func getModuleName() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %w", err)
}
for {
goModPath := filepath.Join(dir, "go.mod")
file, err := os.Open(goModPath)
if err == nil {
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if str, ok := strings.CutPrefix(line, "module "); ok {
return strings.TrimSpace(str), nil
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("failed to read %s: %w", goModPath, err)
}
return "", fmt.Errorf("module directive not found in %s", goModPath)
}
if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("failed to open %s: %w", goModPath, err)
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("go.mod not found starting from %s", dir)
}
dir = parent
}
}
func runCommand(dir, name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+37
View File
@@ -0,0 +1,37 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
func init() {
newCmd.AddCommand(keyCmd)
}
var size int
var defaultSize = 32
var keyCmd = &cobra.Command{
Use: "key [size]",
Short: "Generate a new cryptographic key",
Long: `Generate a new cryptographic key for use in Keystone projects.`,
Run: func(cmd *cobra.Command, args []string) {
var keyLength int
if cmd.Flags().Lookup("size").Changed {
keyLength = size
} else {
keyLength = defaultSize
}
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)
},
}
+24
View File
@@ -0,0 +1,24 @@
package cmd
import (
"github.com/spf13/cobra"
)
const oldModuleName = "github.com/FernandoJVideira/simple_bank"
const srcDir = "embedded/api_tmpl"
type MainTemplateData struct {
ModuleName string
UseGRPC bool
}
func init() {
rootCmd.AddCommand(newCmd)
}
var newCmd = &cobra.Command{
Use: "new",
Short: "Create a new Keystone project",
Long: `Create a new Keystone project with the necessary structure and files.`,
Run: genProject,
}
+21
View File
@@ -0,0 +1,21 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "keystone",
Short: "Keystone is a CLI tool for bootstrapping Go APIs",
Long: `Keystone is a CLI tool built by LKResuts for internal usage to bootstrap Go APIs`,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
+123
View File
@@ -0,0 +1,123 @@
package cmd
import (
"fmt"
"os"
"strings"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func init() {
newCmd.AddCommand(serviceCmd)
}
type ServiceTemplateData struct {
ModuleName string
Name string
LowerName string
}
var serviceCmd = &cobra.Command{
Use: "service",
Short: "Create a new service",
Long: `Create a new service for your Keystone project.`,
Run: genService,
}
func genService(cmd *cobra.Command, args []string) {
namePrompt := promptui.Prompt{
Label: "Service Name",
}
serviceNameInput, err := namePrompt.Run()
if err != nil {
fmt.Printf("Prompt failed %v\n", err)
return
}
moduleName, err := getModuleName()
if err != nil {
fmt.Printf("❌ Error retrieving module name: %v\n", err)
return
}
print(moduleName)
serviceName := strings.ToLower(strings.TrimSpace(serviceNameInput))
cases := cases.Title(language.English)
fmt.Printf("🚀 Creating service '%s'...\n", serviceName)
if err := genFile(
"templates/service.go.tpl",
serviceName,
fmt.Sprintf("internal/service/%s.go", serviceName),
ServiceTemplateData{
ModuleName: moduleName,
Name: cases.String(serviceName),
LowerName: serviceName,
},
); err != nil {
fmt.Printf("❌ Error creating service: %v\n", err)
return
}
updateServiceFile(serviceName, cases)
fmt.Printf("✅ Service '%s' created successfully!\n", serviceNameInput)
}
func updateServiceFile(serviceName string, cases cases.Caser) {
currDir, err := os.Getwd()
if err != nil {
fmt.Printf("❌ Error getting current directory: %v\n", err)
return
}
serviceFilePath := fmt.Sprintf("%s/internal/service/service.go", currDir)
content, err := os.ReadFile(serviceFilePath)
if err != nil {
fmt.Printf("❌ Error reading service file: %v\n", err)
return
}
fileContent := string(content)
srvc := cases.String(serviceName)
interfaceSnippet := fmt.Sprintf(`// %sService defines operations for %s
type %sService interface {
// TODO: Add service methods
}
`, srvc, srvc, srvc)
compileTimeCheck := fmt.Sprintf("\t_ %sService = (*%sService)(nil)",
srvc, serviceName)
fileContent = insertInterface(fileContent, interfaceSnippet)
fileContent = strings.Replace(fileContent, "var (", "var (\n"+compileTimeCheck, 1)
err = os.WriteFile(serviceFilePath, []byte(fileContent), 0644)
if err != nil {
fmt.Printf("❌ Error writing to service file: %v\n", err)
return
}
fmt.Printf("✅ Service interface for '%s' added successfully!\n", serviceName)
}
func insertInterface(fileContent, interfaceSnippet string) string {
parts := strings.SplitN(fileContent, "var (", 2)
if len(parts) != 2 {
return fileContent
}
return parts[0] + interfaceSnippet + "var (" + parts[1]
}
+23
View File
@@ -0,0 +1,23 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// Default version (we will change this dynamically later!)
var version = "dev"
func init() {
rootCmd.AddCommand(versionCmd)
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Display the version of Keystone",
Long: `Display the current version of the Keystone CLI tool.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Keystone CLI version: %s\n", version)
},
}
+16
View File
@@ -0,0 +1,16 @@
module gitea.fernandovideira.com/FernandoJVideira/keystone
go 1.25.5
require (
github.com/manifoldco/promptui v0.9.0
github.com/spf13/cobra v1.10.2
golang.org/x/text v0.31.0
)
require (
github.com/chzyer/readline v1.5.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.38.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+69
View File
@@ -0,0 +1,69 @@
//go:build ignore
package main
import (
"io/fs"
"log"
"os"
"path/filepath"
"strings"
)
const (
sourceDir = "assets/testdata/api_tmpl"
targetDir = "assets/embedded"
oldModuleName = "github.com/FernandoJVideira/simple_bank"
moduleTemplate = "{{ .ModuleName }}"
)
func main() {
log.Println("Syncing templates...")
syncTemplates()
}
func syncTemplates() {
err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, _ := filepath.Rel(sourceDir, path)
destPath := filepath.Join(targetDir, relPath)
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 {
return err
}
if d.Name() == "go.mod" || d.Name() == "go.sum" {
destPath += ".tmpl"
}
if d.Name() == "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)
}
log.Println("Templates synced successfully!")
}