diff --git a/.cursor/test.txt b/.cursor/test.txt deleted file mode 100644 index 9daeafb..0000000 --- a/.cursor/test.txt +++ /dev/null @@ -1 +0,0 @@ -test diff --git a/.gitignore b/.gitignore index ba6b67a..e5ac5bd 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,4 @@ go.work.sum .env # Binary -/keystone* +/draft* diff --git a/.gitmodules b/.gitmodules index 4883ac7..c4d8277 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ -[submodule "assets/testdata/api_tmpl"] - path = assets/testdata/api_tmpl - url = git@gitea.fernandovideira.com:FernandoJVideira/api_tmpl.git +[submodule "assets/testdata/template"] + path = assets/testdata/template + url = git@git.fernandovideira.com:FernandoJVideira/project-template.git + branch = dev diff --git a/Makefile b/Makefile index 9e94625..865a55b 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,23 @@ -PKG_NAME="gitea.fernandovideira.com/FernandoJVideira/keystone" +PKG_NAME="git.fernandovideira.com/FernandoJVideira/draft" 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 @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: - @$(MAKE) build GOOS=windows GOARCH=amd64 OUTPUT=keystone_windows_amd64.exe + @$(MAKE) build GOOS=windows GOARCH=amd64 OUTPUT=draft_windows_amd64.exe 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: - @$(MAKE) build GOOS=linux GOARCH=amd64 OUTPUT=keystone_linux_amd64 + @$(MAKE) build GOOS=linux GOARCH=amd64 OUTPUT=draft_linux_amd64 mac: - @$(MAKE) build GOOS=darwin GOARCH=arm64 OUTPUT=keystone_darwin_arm64 + @$(MAKE) build GOOS=darwin GOARCH=arm64 OUTPUT=draft_darwin_arm64 prepare-embed: @git submodule update --init --recursive diff --git a/README.md b/README.md index 95c4dc7..8f5eb79 100644 --- a/README.md +++ b/README.md @@ -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 -- ๐Ÿš€ **Quick Project Scaffolding** - Generate complete API projects with a single command +- ๐Ÿš€ **Quick Project Scaffolding** - Generate complete projects with a single command +- ๐Ÿงฉ **Three Project Shapes** - API only, client only, or a full-stack monorepo combining both - ๐Ÿ”„ **Multiple API Types** - Support for both HTTP (REST) and gRPC architectures +- ๐Ÿ–ผ๏ธ **Multiple Client Frameworks** - Nuxt or Vue, built on top of the official `nuxi`/`create-vue` tooling so the boilerplate always stays current - ๐Ÿ“ฆ **Service Generation** - Easily create new services with proper structure - ๐ŸŽฏ **Handler Generation** - Generate HTTP or gRPC handlers on-demand - ๐Ÿ” **Cryptographic Key Generator** - Built-in secure key generation @@ -17,8 +19,8 @@ A powerful CLI tool for bootstrapping Go API projects with best practices built- ### From Source ```bash -git clone https://github.com/yourusername/keystone.git -cd keystone +git clone git@git.fernandovideira.com:FernandoJVideira/draft.git +cd draft make build ``` @@ -38,14 +40,22 @@ make windows make win-arm ``` -The compiled binary will be named `basalt` (or `basalt.exe` on Windows). +The compiled binary will be named `draft__` (e.g. `draft_darwin_arm64`, or `draft_windows_amd64.exe` on Windows). ## Usage +### Create a New Project + +```bash +draft new +``` + +Prompts you to pick a project shape โ€” **API**, **Client**, or **Full Stack** (monorepo) โ€” then runs the matching flow below. Each shape is also available directly as its own subcommand if you already know what you want. + ### Create a New API Project ```bash -keystone new api +draft new api ``` This interactive command will: @@ -64,10 +74,34 @@ This interactive command will: - `swag` - Swagger documentation (HTTP only) - `protoc-gen-*` - Protocol Buffer generators (gRPC only) +### Create a New Client Project + +```bash +draft new client +``` + +1. Prompts for a project name +2. Prompts you to pick a framework โ€” **Nuxt** or **Vue** +3. Scaffolds the base app via the official tooling (`nuxi init` for Nuxt, `create-vue` + `shadcn-vue` for Vue), so the generated boilerplate, dependency versions, and lockfile are always current rather than pinned by Draft +4. Layers a Draft-maintained overlay on top: an auth store (login/logout/register/token refresh), route guards, API composable, and pages for login/signup/profile/organizations/email verification/invites โ€” all wired up to talk to a Draft-generated API out of the box + +Both frameworks produce a comparable app: Nuxt UI + Tailwind for Nuxt, shadcn-vue + Tailwind for Vue. + +### Create a New Monorepo Project + +```bash +draft new monorepo +``` + +Runs the API flow and the client flow together under one root, wired up as a single project: +- `apps/api` โ€” the scaffolded Go API +- `apps/client` โ€” the scaffolded Nuxt or Vue client +- Root-level `go.work`, `pnpm-workspace.yaml`, `docker-compose.yml`, and `Justfile` tying both together + ### Generate a New Service ```bash -keystone new service +draft new service ``` 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 ```bash -keystone new handler +draft new handler ``` Creates a new handler in the appropriate directory: @@ -88,20 +122,21 @@ Creates a new handler in the appropriate directory: ### Generate a Cryptographic Key ```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 ```bash -keystone version +draft version ``` ## Project Structure -When you create a new project, Keystone generates a structured Go API with: +### API ``` your-project/ @@ -118,10 +153,38 @@ your-project/ โ””โ”€โ”€ go.mod ``` +### Client + +``` +your-client/ +โ”œโ”€โ”€ app/ (Nuxt) or src/ (Vue) +โ”‚ โ”œโ”€โ”€ stores/auth.ts +โ”‚ โ”œโ”€โ”€ composables/useApi.ts +โ”‚ โ”œโ”€โ”€ pages/ or views/ +โ”‚ โ””โ”€โ”€ ... +โ”œโ”€โ”€ nuxt.config.ts (Nuxt) or vite.config.ts + components.json (Vue) +โ””โ”€โ”€ package.json +``` + +### Monorepo + +``` +your-project/ +โ”œโ”€โ”€ apps/ +โ”‚ โ”œโ”€โ”€ api/ (Go API, its own go.mod) +โ”‚ โ””โ”€โ”€ client/ (Nuxt or Vue client) +โ”œโ”€โ”€ go.work +โ”œโ”€โ”€ pnpm-workspace.yaml +โ”œโ”€โ”€ docker-compose.yml +โ””โ”€โ”€ Justfile +``` + ## Requirements - Go 1.25.5 or higher - Make (for building from source) +- `pnpm` and `npx` (Node.js) โ€” required for client and monorepo scaffolding +- `protoc` โ€” required only for gRPC API projects ## Dependencies @@ -143,6 +206,14 @@ make build make version ``` +### Sync Templates + +Draft's embedded API/client templates are synced from a reference monorepo checked out as a git submodule (`assets/testdata/template`). After updating that submodule, regenerate the embedded copies with: + +```bash +go run tools/sync_templates.go +``` + ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. @@ -154,7 +225,3 @@ This project is licensed under the MIT License - see the LICENSE file for detail ## 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 index cae4efe..26f0c8a 100644 --- a/assets/efs.go +++ b/assets/efs.go @@ -3,5 +3,5 @@ package assets import "embed" //go:generate go run ../tools/sync_templates.go -//go:embed templates/* embedded/* +//go:embed all:templates all:embedded var EmbedFS embed.FS diff --git a/assets/templates/main.go.tpl b/assets/templates/main.go.tpl index 5c160b4..7f42193 100644 --- a/assets/templates/main.go.tpl +++ b/assets/templates/main.go.tpl @@ -49,8 +49,6 @@ import ( {{ end }} "github.com/jackc/pgx/v5/pgxpool" _ "github.com/jackc/pgx/v5/stdlib" - - ) var interruptSignals = []os.Signal{ @@ -59,25 +57,24 @@ var interruptSignals = []os.Signal{ syscall.SIGINT, } -// @title Simple Bank API +// @title App Template API // @version 1.0 -// @description API for Simple Bank application +// @description REST API template: accounts (register/login/email verification/refresh tokens) and multi-tenant organizations (members/invites) already wired up. // @termsOfService http://swagger.io/terms/ -// @contact.name API Support -// @contact.email support@simplebank.com +// @contact.name App Template // @license.name MIT // @license.url https://opensource.org/licenses/MIT // @host localhost:8080 -// @BasePath /v1 +// @BasePath /api/v1 // @schemes http https // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization -// @description Type "Bearer" followed by a space and JWT token. +// @description PASETO access token. Format: "Bearer ". Some endpoints also accept the equivalent value via the "access_token" cookie. func main() { config, err := config.LoadConfig(".") if err != nil { @@ -86,6 +83,7 @@ func main() { if config.Environment == "development" { log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + zerolog.SetGlobalLevel(zerolog.DebugLevel) } ctx, cancel := signal.NotifyContext(context.Background(), interruptSignals...) @@ -114,15 +112,20 @@ func main() { userService := service.NewUserService(store, taskDist) authService := service.NewAuthService(store, tokenMaker, config) + orgService := service.NewOrganizationService(store, taskDist) wg, ctx := errgroup.WithContext(ctx) {{ if not .UseGRPC }} - runGinServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService) + services := api.Services{ + UserService: userService, + AuthService: authService, + OrganizationService: orgService, + } + runGinServer(ctx, wg, config, store, taskDist, tokenMaker, services) {{ else }} - // Assuming you want Gateway AND gRPC when gRPC is selected - runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService) - runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService) + runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService) + runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService) {{ end }} runTaskProcessor(ctx, wg, redisOpt, store, config) @@ -133,8 +136,19 @@ func main() { } } -func runTaskProcessor(ctx context.Context, wg *errgroup.Group, redisOpt asynq.RedisClientOpt, store db.Store, config config.Config) { - mailer := mailer.NewGmailSender(config.EmailSenderName, config.EmailSender, config.EmailPassword) +func runTaskProcessor( + ctx context.Context, + wg *errgroup.Group, + redisOpt asynq.RedisClientOpt, + store db.Store, + config config.Config, +) { + mailer := mailer.NewSMTPSender( + config.EmailSenderName, + config.EmailSender, + config.EmailPassword, + config.SMTPAddr, + ) taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer, config) log.Info().Msg("start task processor") if err := taskProcessor.Start(); err != nil { @@ -164,8 +178,16 @@ func runDBMigration(migrationURL, dbSource string) { } {{ if not .UseGRPC }} -func runGinServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, td worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) { - s, err := api.NewServer(config, store, td, tokenMaker, userService, authService) +func runGinServer( + ctx context.Context, + wg *errgroup.Group, + config config.Config, + store db.Store, + td worker.TaskDistributor, + tokenMaker auth.Maker, + services api.Services, +) { + s, err := api.NewServer(config, store, td, tokenMaker, services) if err != nil { log.Fatal().Err(err).Msg("cannot create server") } @@ -194,8 +216,18 @@ func runGinServer(ctx context.Context, wg *errgroup.Group, config config.Config, {{ end }} {{ if .UseGRPC }} -func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, taskDistributor worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) { - server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService) +func runGrpcServer( + ctx context.Context, + wg *errgroup.Group, + config config.Config, + store db.Store, + td worker.TaskDistributor, + tokenMaker auth.Maker, + userService service.UserService, + authService service.AuthService, + organizationService service.OrganizationService, +) { + server, err := gapi.NewServer(config, store, td, tokenMaker, userService, authService, organizationService) if err != nil { log.Fatal().Err(err).Msg("cannot create server:") } @@ -203,7 +235,7 @@ func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config grpcLogger := grpc.UnaryInterceptor(gapi.GrpcLogger) grpcServer := grpc.NewServer(grpcLogger) - pb.RegisterSimpleBankServer(grpcServer, server) + pb.Register{{ .ServiceName }}Server(grpcServer, server) reflection.Register(grpcServer) listener, err := net.Listen("tcp", config.GrpcServerAddr) @@ -233,8 +265,18 @@ func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config }) } -func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, taskDistributor worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) { - server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService) +func runGatewayServer( + ctx context.Context, + wg *errgroup.Group, + config config.Config, + store db.Store, + td worker.TaskDistributor, + tokenMaker auth.Maker, + userService service.UserService, + authService service.AuthService, + organizationService service.OrganizationService, +) { + server, err := gapi.NewServer(config, store, td, tokenMaker, userService, authService, organizationService) if err != nil { log.Fatal().Err(err).Msg("cannot create server:") } @@ -250,7 +292,7 @@ func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Con grpcMux := runtime.NewServeMux(opts) - err = pb.RegisterSimpleBankHandlerServer(ctx, grpcMux, server) + err = pb.Register{{ .ServiceName }}HandlerServer(ctx, grpcMux, server) if err != nil { log.Fatal().Err(err).Msg("cannot register handler server") } @@ -318,6 +360,5 @@ func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Con log.Info().Msg("HTTP gateway server shut down") return nil }) - } {{ end }} diff --git a/assets/testdata/api_tmpl b/assets/testdata/api_tmpl deleted file mode 160000 index 961bdd8..0000000 --- a/assets/testdata/api_tmpl +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 961bdd81d4542bbf1f247095def56effae6ef3d2 diff --git a/assets/testdata/template b/assets/testdata/template new file mode 160000 index 0000000..3945b8b --- /dev/null +++ b/assets/testdata/template @@ -0,0 +1 @@ +Subproject commit 3945b8b64a9adcaedc7dd9421dab7b7f3a73a415 diff --git a/cmd/api.go b/cmd/api.go index 34cfc4a..8bb51ee 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -4,12 +4,10 @@ import ( "fmt" "io/fs" "os" - "path" "path/filepath" "strings" - "gitea.fernandovideira.com/FernandoJVideira/keystone/assets" - "github.com/manifoldco/promptui" + "git.fernandovideira.com/FernandoJVideira/draft/assets" "github.com/spf13/cobra" ) @@ -19,69 +17,25 @@ func init() { 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, + Short: "Create a new Draft API project", + Long: `Create a new Draft API project with the necessary structure and files.`, + Run: genAPI, } -func genProject(cmd *cobra.Command, args []string) { - - projectNamePrompt := promptui.Prompt{ - Label: "Project Name", - } - - rawInput, err := projectNamePrompt.Run() - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return - } - moduleName := strings.TrimSpace(rawInput) - projectDir := path.Base(strings.TrimSuffix(moduleName, "/")) - - apiType := promptui.Select{ - Label: "Select API Type", - Items: []string{"HTTP (REST)", "gRPC"}, - } - - _, result, err := apiType.Run() - - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return - } - - // Logic to map the selection to a boolean - useGRPC := strings.Contains(result, "gRPC") - - fmt.Printf("๐Ÿš€ Scaffolding '%s' as %s...\n", moduleName, result) - if err := scaffoldProject(projectDir, moduleName, useGRPC); err != nil { - fmt.Printf("error scaffolding project: %v", err) - return - } - - fmt.Println("๐Ÿ“ฆ Installing tools...") - if err := installTools(useGRPC); err != nil { - fmt.Printf("error installing tools: %v", err) - return - } - - fmt.Println("๐Ÿงน Tidying up Go modules...") - if err := runCommand(projectDir, "go", "mod", "tidy"); err != nil { - fmt.Printf("error running 'go mod tidy': %v", err) - return - } - - fmt.Println("โœ… Done!") +func genAPI(cmd *cobra.Command, args []string) { + runNewProject(scaffoldRecipe{label: "API"}) } -func scaffoldProject(projectDir, moduleName string, useGRPC bool) error { +func scaffoldAPI(projectDir, destSubdir, moduleName, serviceName string, useGRPC bool) error { - err := os.MkdirAll(projectDir, 0755) + destRoot := filepath.Join(projectDir, destSubdir) + + err := os.MkdirAll(destRoot, 0755) if err != nil { - return fmt.Errorf("error creating project directory: %v", err) + return fmt.Errorf("error creating project directory: %w", err) } - root := "embedded" + root := "embedded/api" err = fs.WalkDir(assets.EmbedFS, root, func(path string, d fs.DirEntry, err error) error { relPath, _ := filepath.Rel(root, path) @@ -112,40 +66,81 @@ func scaffoldProject(projectDir, moduleName string, useGRPC bool) error { contentBytes, err := assets.EmbedFS.ReadFile(path) if err != nil { - return fmt.Errorf("error reading embedded file %s: %v", path, err) + return fmt.Errorf("error reading embedded file %s: %w", path, err) } content := string(contentBytes) - targetPath := filepath.Join(projectDir, relPath) + targetPath := filepath.Join(destRoot, relPath) targetPath = strings.TrimSuffix(targetPath, ".tmpl") if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { - return fmt.Errorf("error creating directory %s: %v", filepath.Dir(targetPath), err) + return fmt.Errorf("error creating directory %s: %w", filepath.Dir(targetPath), err) } newContent := strings.ReplaceAll(content, oldModuleName, moduleName) newContent = strings.ReplaceAll(newContent, "{{ .ModuleName }}", moduleName) + newContent = strings.ReplaceAll(newContent, "AppTmpl", serviceName) return os.WriteFile(targetPath, []byte(newContent), 0644) }) if err != nil { - return fmt.Errorf("Error copying template files: %v\n", err) + return fmt.Errorf("error copying template files: %w", err) + } + + if useGRPC { + if err := regenerateProto(destRoot, serviceName); err != nil { + return fmt.Errorf("error regenerating proto: %w", err) + } } // Generate main.go from template return genFile( "templates/main.go.tpl", "main", - filepath.Join(projectDir, "cmd/api", "main.go"), + filepath.Join(destRoot, "cmd/api", "main.go"), MainTemplateData{ - ModuleName: moduleName, - UseGRPC: useGRPC, + ModuleName: moduleName, + ServiceName: serviceName, + UseGRPC: useGRPC, }, ) } +func regenerateProto(projectDir, serviceName string) error { + protoPath := filepath.Join(projectDir, "proto", fmt.Sprintf("service_%s.proto", oldModuleName)) + content, err := os.ReadFile(protoPath) + if err != nil { + return fmt.Errorf("error reading service proto: %w", err) + } + newContent := strings.Replace(string(content), "service AppTmpl {", "service "+serviceName+" {", 1) + if err := os.WriteFile(protoPath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("error rewriting service proto: %w", err) + } + + protoFiles, err := filepath.Glob(filepath.Join(projectDir, "proto", "*.proto")) + if err != nil { + return fmt.Errorf("error globbing proto files: %w", err) + } + args := []string{ + "--proto_path=proto", + "--go_out=pb", "--go_opt=paths=source_relative", + "--go-grpc_out=pb", "--go-grpc_opt=paths=source_relative", + "--grpc-gateway_out=pb", "--grpc-gateway_opt=paths=source_relative", + "--openapiv2_out=docs/swagger", "--openapiv2_opt=allow_merge=true,merge_file_name=" + filepath.Base(projectDir), + } + for _, f := range protoFiles { + rel, _ := filepath.Rel(projectDir, f) + args = append(args, rel) + } + if err := runCommand(projectDir, "protoc", args...); err != nil { + return fmt.Errorf("error running protoc: %w", err) + } + + return runCommand(projectDir, "statik", "-src=./docs/swagger", "-dest=./docs") +} + func installTools(useGrpc bool) error { tools := []string{ "github.com/air-verse/air@latest", @@ -170,7 +165,7 @@ func installTools(useGrpc bool) error { for _, tool := range tools { if err := runCommand("", "go", "install", tool); err != nil { - return fmt.Errorf("error installing tool %s: %v", tool, err) + return fmt.Errorf("error installing tool %s: %w", tool, err) } } diff --git a/cmd/client.go b/cmd/client.go new file mode 100644 index 0000000..34cc6e2 --- /dev/null +++ b/cmd/client.go @@ -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) +} diff --git a/cmd/handler.go b/cmd/handler.go index fa7aecb..4bb8489 100644 --- a/cmd/handler.go +++ b/cmd/handler.go @@ -23,7 +23,7 @@ func init() { var handlerCmd = &cobra.Command{ Use: "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, } diff --git a/cmd/helpers.go b/cmd/helpers.go index b654dcf..c818bc7 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -5,13 +5,18 @@ import ( "crypto/rand" "errors" "fmt" + "io/fs" + "math/big" "os" "os/exec" + "path" "path/filepath" + "regexp" "strings" "text/template" - "gitea.fernandovideira.com/FernandoJVideira/keystone/assets" + "git.fernandovideira.com/FernandoJVideira/draft/assets" + "github.com/manifoldco/promptui" ) const ( @@ -22,7 +27,7 @@ func randomString(length int) string { s, r := make([]rune, length), []rune(chars) for i := range s { - p, _ := rand.Prime(rand.Reader, len(r)) + p, _ := rand.Int(rand.Reader, big.NewInt(int64(len(r)))) x, y := p.Uint64(), uint64(len(r)) s[i] = r[x%y] } @@ -98,3 +103,167 @@ func runCommand(dir, name string, args ...string) error { return cmd.Run() } + +func commandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func requirePnpmAndNpx() error { + if !commandExists("pnpm") { + return fmt.Errorf("pnpm is required but not found on PATH") + } + if !commandExists("npx") { + return fmt.Errorf("npx is required but not found on PATH") + } + return nil +} + +func promptClientFramework() (isVue bool, resultLabel string, err error) { + prompt := promptui.Select{ + Label: "Select Client Framework", + Items: []string{"Nuxt", "Vue"}, + } + _, result, err := prompt.Run() + if err != nil { + return false, "", err + } + return result == "Vue", result, nil +} + +func pnpmVersion() (string, error) { + out, err := exec.Command("pnpm", "--version").Output() + if err != nil { + return "", fmt.Errorf("error getting pnpm version: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +func toPascalCase(s string) string { + parts := regexp.MustCompile(`[^a-zA-Z0-9]+`).Split(s, -1) + var b strings.Builder + for _, p := range parts { + if p == "" { + continue + } + b.WriteString(strings.ToUpper(p[:1])) + 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!") +} diff --git a/cmd/key.go b/cmd/key.go index 2878ea3..a34b28e 100644 --- a/cmd/key.go +++ b/cmd/key.go @@ -6,32 +6,26 @@ import ( "github.com/spf13/cobra" ) -func init() { - newCmd.AddCommand(keyCmd) -} +const defaultKeySize = 32 -var size int -var defaultSize = 32 +var keySize int + +func init() { + rootCmd.AddCommand(keyCmd) + keyCmd.Flags().IntVar(&keySize, "size", defaultKeySize, "Length of the generated key") +} var keyCmd = &cobra.Command{ - Use: "key [size]", + Use: "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) { - var keyLength int - if cmd.Flags().Lookup("size").Changed { - keyLength = size - } else { - keyLength = defaultSize + if cmd.Flags().Changed("size") && keySize < defaultKeySize { + fmt.Printf("โŒ Key size must be at least %d for adequate security.\n", defaultKeySize) + return } - if keyLength <= 32 { - fmt.Println("Warning: It is recommended to use a key size greater than 32 for better security.") - fmt.Println("Generating a key of default size:", defaultSize) - keyLength = defaultSize - } - - key := randomString(keyLength) - println("Generated Key:", key) + key := randomString(keySize) + fmt.Println("Generated Key:", key) }, } diff --git a/cmd/monorepo.go b/cmd/monorepo.go new file mode 100644 index 0000000..8b43d0a --- /dev/null +++ b/cmd/monorepo.go @@ -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) + }, + }) +} diff --git a/cmd/new.go b/cmd/new.go index 0f08067..be57c1b 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -1,15 +1,18 @@ package cmd import ( + "fmt" + + "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) -const oldModuleName = "github.com/FernandoJVideira/simple_bank" -const srcDir = "embedded/api_tmpl" +const oldModuleName = "app_tmpl" type MainTemplateData struct { - ModuleName string - UseGRPC bool + ModuleName string + ServiceName string + UseGRPC bool } func init() { @@ -18,7 +21,31 @@ func init() { var newCmd = &cobra.Command{ Use: "new", - Short: "Create a new Keystone project", - Long: `Create a new Keystone project with the necessary structure and files.`, + Short: "Create a new Draft project", + Long: `Create a new Draft project with the necessary structure and files.`, Run: genProject, } + +func genProject(cmd *cobra.Command, args []string) { + projType := promptui.Select{ + Label: "Select Project Type", + Items: []string{"API", "Client", "Full Stack"}, + } + + _, result, err := projType.Run() + if err != nil { + fmt.Printf("Prompt failed %v\n", err) + return + } + + switch result { + case "API": + genAPI(cmd, args) + case "Client": + genClient(cmd, args) + case "Full Stack": + genMonorepo(cmd, args) + default: + fmt.Println("Invalid selection") + } +} diff --git a/cmd/root.go b/cmd/root.go index 1ec5764..a3314d9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,9 +8,9 @@ import ( ) 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`, + Use: "draft", + Short: "Draft is a CLI tool for bootstrapping Go APIs", + Long: `Draft is a CLI tool built by LKResuts for internal usage to bootstrap Go APIs`, } func Execute() { diff --git a/cmd/service.go b/cmd/service.go index 83e3a74..1e5adca 100644 --- a/cmd/service.go +++ b/cmd/service.go @@ -24,7 +24,7 @@ type ServiceTemplateData struct { var serviceCmd = &cobra.Command{ Use: "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, } @@ -45,8 +45,6 @@ func genService(cmd *cobra.Command, args []string) { return } - print(moduleName) - serviceName := strings.ToLower(strings.TrimSpace(serviceNameInput)) cases := cases.Title(language.English) diff --git a/cmd/version.go b/cmd/version.go index 45b99b6..b743db8 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" ) -// Default version (we will change this dynamically later!) var version = "dev" func init() { @@ -15,9 +14,9 @@ func init() { var versionCmd = &cobra.Command{ Use: "version", - Short: "Display the version of Keystone", - Long: `Display the current version of the Keystone CLI tool.`, + Short: "Display the version of Draft", + Long: `Display the current version of the Draft CLI tool.`, Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("Keystone CLI version: %s\n", version) + fmt.Printf("Draft CLI version: %s\n", version) }, } diff --git a/go.mod b/go.mod index da40ee8..0fd6545 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitea.fernandovideira.com/FernandoJVideira/keystone +module git.fernandovideira.com/FernandoJVideira/draft go 1.25.5 diff --git a/main.go b/main.go index bb38bd2..fcec31c 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,6 @@ package main -import "gitea.fernandovideira.com/FernandoJVideira/keystone/cmd" +import "git.fernandovideira.com/FernandoJVideira/draft/cmd" func main() { cmd.Execute() diff --git a/tools/create_zip.go b/tools/create_zip.go new file mode 100644 index 0000000..a7cc2b4 --- /dev/null +++ b/tools/create_zip.go @@ -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 ") + 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) + } +} diff --git a/tools/publish-package.sh b/tools/publish-package.sh new file mode 100755 index 0000000..ec2df77 --- /dev/null +++ b/tools/publish-package.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -e + +# Usage: ./tools/publish-package.sh +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 " + 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!" \ No newline at end of file diff --git a/tools/sync_templates.go b/tools/sync_templates.go index 07ffe84..3670065 100644 --- a/tools/sync_templates.go +++ b/tools/sync_templates.go @@ -3,67 +3,129 @@ package main import ( - "io/fs" "log" "os" + "os/exec" "path/filepath" "strings" ) const ( - sourceDir = "assets/testdata/api_tmpl" - targetDir = "assets/embedded" - oldModuleName = "github.com/FernandoJVideira/simple_bank" + oldModuleName = "app_tmpl" moduleTemplate = "{{ .ModuleName }}" ) -func main() { - log.Println("Syncing templates...") - syncTemplates() +type syncJob struct { + templateName string // optional, for logging purposes + sourceDir string + targetDir string + excludePrefix string // relative-path prefix to skip, e.g. "apps/"; empty = sync everything + includePrefixes []string // if set, only paths with one of these prefixes sync; empty = sync everything } -func syncTemplates() { +var jobs = []syncJob{ + { + templateName: "api", + sourceDir: "assets/testdata/template/apps/api", + targetDir: "assets/embedded/api", + }, + { + templateName: "monorepo-root", + sourceDir: "assets/testdata/template", + targetDir: "assets/embedded/monorepo-root", + excludePrefix: "apps/", + }, + { + templateName: "client-nuxt", + sourceDir: "assets/testdata/template/apps/client", + targetDir: "assets/embedded/client-nuxt", + includePrefixes: []string{"app/", "public/"}, + }, + { + templateName: "client-vue", + sourceDir: "assets/testdata/template/apps/vue-client", + targetDir: "assets/embedded/client-vue", + includePrefixes: []string{ + "src/App.vue", + "src/main.ts", + "src/router/", + "src/stores/", + "src/composables/", + "src/lib/api-client.ts", + "src/components/AppBrand.vue", + "src/components/AppLayout.vue", + "src/views/", + "src/shared/", + ".env.example", + }, + }, +} - err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } +func main() { + log.Println("Syncing templates...") + for _, job := range jobs { + syncTemplates(job) + } +} - relPath, _ := filepath.Rel(sourceDir, path) - destPath := filepath.Join(targetDir, relPath) +func syncTemplates(job syncJob) { - if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { - if d.IsDir() { - return filepath.SkipDir + err := os.RemoveAll(job.targetDir) + if err != nil { + log.Fatalf("Error removing target directory: %v", err) + } + + cmd := exec.Command("git", "-C", job.sourceDir, "ls-files") + output, err := cmd.Output() + if err != nil { + log.Fatalf("Error listing files in source directory: %v", err) + } + + paths := strings.Split(strings.TrimSpace(string(output)), "\n") + + for _, relPath := range paths { + + if len(job.includePrefixes) > 0 { + included := false + for _, prefix := range job.includePrefixes { + if strings.HasPrefix(relPath, prefix) { + included = true + break + } } - return nil + if !included { + continue + } + } else if job.excludePrefix != "" && strings.HasPrefix(relPath, job.excludePrefix) { + continue } - if d.IsDir() { - return os.MkdirAll(destPath, 0755) + srcPath := filepath.Join(job.sourceDir, relPath) + destPath := filepath.Join(job.targetDir, relPath) + + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + log.Fatalf("Error creating destination directory: %v", err) } - content, err := os.ReadFile(path) + content, err := os.ReadFile(srcPath) if err != nil { - return err + log.Fatalf("Error reading source file: %v", err) } - if d.Name() == "go.mod" || d.Name() == "go.sum" { + if filepath.Base(relPath) == "go.mod" || filepath.Base(relPath) == "go.sum" { destPath += ".tmpl" } - if d.Name() == "go.mod" { + if filepath.Base(relPath) == "go.mod" { strContent := string(content) strContent = strings.Replace(strContent, oldModuleName, moduleTemplate, 1) content = []byte(strContent) } - return os.WriteFile(destPath, content, 0644) - }) - - if err != nil { - log.Fatalf("Error walking source: %v", err) + if err := os.WriteFile(destPath, content, 0644); err != nil { + log.Fatalf("Error writing to destination file: %v", err) + } } - log.Println("Templates synced successfully!") + log.Printf("Template %s synced successfully!", job.templateName) }