diff --git a/.gitignore b/.gitignore index 5b90e79..d069908 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4883ac7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "assets/testdata/api_tmpl"] + path = assets/testdata/api_tmpl + url = git@gitea.fernandovideira.com:FernandoJVideira/api_tmpl.git diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3909ced --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index b52c491..95c4dc7 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/assets/efs.go b/assets/efs.go new file mode 100644 index 0000000..cae4efe --- /dev/null +++ b/assets/efs.go @@ -0,0 +1,7 @@ +package assets + +import "embed" + +//go:generate go run ../tools/sync_templates.go +//go:embed templates/* embedded/* +var EmbedFS embed.FS diff --git a/assets/templates/handler_grpc.go.tpl b/assets/templates/handler_grpc.go.tpl new file mode 100644 index 0000000..ccfa7bc --- /dev/null +++ b/assets/templates/handler_grpc.go.tpl @@ -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 +} diff --git a/assets/templates/handler_http.go.tpl b/assets/templates/handler_http.go.tpl new file mode 100644 index 0000000..c422364 --- /dev/null +++ b/assets/templates/handler_http.go.tpl @@ -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) +} diff --git a/assets/templates/main.go.tpl b/assets/templates/main.go.tpl new file mode 100644 index 0000000..5c160b4 --- /dev/null +++ b/assets/templates/main.go.tpl @@ -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 support@simplebank.com + +// @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 }} diff --git a/assets/templates/service.go.tpl b/assets/templates/service.go.tpl new file mode 100644 index 0000000..11edaa8 --- /dev/null +++ b/assets/templates/service.go.tpl @@ -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, + } +} diff --git a/assets/testdata/api_tmpl b/assets/testdata/api_tmpl new file mode 160000 index 0000000..961bdd8 --- /dev/null +++ b/assets/testdata/api_tmpl @@ -0,0 +1 @@ +Subproject commit 961bdd81d4542bbf1f247095def56effae6ef3d2 diff --git a/cmd/api.go b/cmd/api.go new file mode 100644 index 0000000..34cfc4a --- /dev/null +++ b/cmd/api.go @@ -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 +} diff --git a/cmd/basalt/main.go b/cmd/basalt/main.go new file mode 100644 index 0000000..bb38bd2 --- /dev/null +++ b/cmd/basalt/main.go @@ -0,0 +1,7 @@ +package main + +import "gitea.fernandovideira.com/FernandoJVideira/keystone/cmd" + +func main() { + cmd.Execute() +} diff --git a/cmd/handler.go b/cmd/handler.go new file mode 100644 index 0000000..fa7aecb --- /dev/null +++ b/cmd/handler.go @@ -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))) +} diff --git a/cmd/helpers.go b/cmd/helpers.go new file mode 100644 index 0000000..b654dcf --- /dev/null +++ b/cmd/helpers.go @@ -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() +} diff --git a/cmd/key.go b/cmd/key.go new file mode 100644 index 0000000..2878ea3 --- /dev/null +++ b/cmd/key.go @@ -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) + }, +} diff --git a/cmd/new.go b/cmd/new.go new file mode 100644 index 0000000..0f08067 --- /dev/null +++ b/cmd/new.go @@ -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, +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..1ec5764 --- /dev/null +++ b/cmd/root.go @@ -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) + } +} diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 0000000..83e3a74 --- /dev/null +++ b/cmd/service.go @@ -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] +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..45b99b6 --- /dev/null +++ b/cmd/version.go @@ -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) + }, +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..da40ee8 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..31f5dc1 --- /dev/null +++ b/go.sum @@ -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= diff --git a/tools/sync_templates.go b/tools/sync_templates.go new file mode 100644 index 0000000..07ffe84 --- /dev/null +++ b/tools/sync_templates.go @@ -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!") +}