22 Commits
Author SHA1 Message Date
FernandoJVideira 35b88b4dcf Merge pull request 'Scaffold clients with vue-ui-kit; API/client/monorepo scaffolding fixes' (#6) from chore/refactor into dev
Reviewed-on: #6
2026-09-16 11:51:56 +00:00
FernandoJVideiraandClaude Fable 5.1 64c57f830b Bump project-template: tabbed auth card, single-tone page background
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-16 12:41:39 +01:00
FernandoJVideiraandClaude Sonnet 5 d62383598c Bump project-template: redesign default pages, fix Nuxt Dialog/Select SSR
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-16 11:33:59 +01:00
FernandoJVideiraandClaude Sonnet 5 650e811c84 Bump project-template: remove leftover shadcn-vue/Nuxt UI cruft
No change to draft's own generated output - this commit only touches
files outside what tools/sync_templates.go pulls into assets/embedded
(package.json, main.css, and the shadcn-vue-generated files it deletes
are all excluded from the client-vue/client-nuxt sync jobs already).
Keeps the reference project itself buildable and free of dead
dependencies now that both clients use vue-ui-kit.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-16 11:02:08 +01:00
FernandoJVideiraandClaude Sonnet 5 663ffb1c09 Scaffold clients with vue-ui-kit instead of Nuxt UI / shadcn-vue
Both `draft new client` and `draft new monorepo` (which reuses the same
client scaffolding for apps/client) now wire up @fernandojvideira/vue-ui-kit
as the component library on both frameworks, installed from its Gitea npm
registry.

Vue: drops the shadcn-vue init/add steps entirely - vue-ui-kit's theme.css
is imported directly instead of a generated one, and the overlay's one
shadcn Button usage now comes from vue-ui-kit.

Nuxt: switches the nuxi starter from "ui" (Nuxt UI) to "minimal", wiring
Tailwind v4 + vue-ui-kit by hand instead (mirrors vue-ui-kit's own Nuxt
INSTALL.md instructions). Needed one real fix: the "minimal" starter has
no typescript devDependency, and pnpm resolving it unpinned lands on
TypeScript 7, which breaks @vue/compiler-sfc's cross-file type resolution
that vue-ui-kit's components rely on - pinned to ~6.0.0 to match
create-vue's own default for the Vue flow.

The actual overlay content changes (token renames, icon swaps, a new
useToast() composable for Nuxt, etc.) live in the project-template
submodule, bumped here to its latest commit.

Verified end-to-end: both frameworks install, typecheck, and build clean
from a fresh `draft new client`, and were checked live in the browser.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-16 10:57:18 +01:00
FernandoJVideira 7cec03d4c5 Merge pull request 'Chore/refactor' (#4) from chore/refactor into dev
Reviewed-on: #4
2026-08-25 21:03:22 +00:00
FernandoJVideira 145b3bb879 fix: duplicated binary built on makefile 2026-08-25 22:02:13 +01:00
FernandoJVideira 095a4c859a fix: changed help command 2026-08-25 21:52:07 +01:00
FernandoJVideira 6fc36118d9 Merge pull request 'Minor Version Fix' (#2) from chore/refactor into dev
Reviewed-on: #2
2026-08-25 20:41:55 +00:00
FernandoJVideira 3ff0633530 Minor Version Fix 2026-08-25 21:41:21 +01:00
FernandoJVideira 2fd651965d Merge pull request 'Chore/refactor' (#1) from chore/refactor into dev
Reviewed-on: #1
2026-08-25 20:35:06 +00:00
FernandoJVideira fa217290bf docs: update README for client/monorepo scaffolding and key command changes 2026-08-25 21:33:16 +01:00
FernandoJVideira f5fbd240bf fix: remove stray debug print in service generator 2026-08-25 21:33:11 +01:00
FernandoJVideira a514521c6e fix: register draft key on the root command with a working --size flag 2026-08-25 21:33:06 +01:00
FernandoJVideira 29f94539e0 feat: scaffold API, client, or monorepo projects with Nuxt/Vue client support
- draft new now offers API, Client, or Full Stack (monorepo) project shapes
- draft new client scaffolds a Nuxt or Vue app via the official nuxi/create-vue
  tooling, with a Draft-maintained auth/API overlay layered on top
- draft new monorepo combines a scaffolded API and client under one root
  with go.work, pnpm-workspace.yaml, and docker-compose wiring
- assets/embedded restructured into api/monorepo-root/client-nuxt/client-vue
  zones, synced via a generalized, job-based sync tool
2026-08-25 21:33:00 +01:00
FernandoJVideira fe48b7caee feat: wire organizations into main.go template for both HTTP and gRPC 2026-08-25 21:32:54 +01:00
FernandoJVideira 42600a5358 chore: swap testdata submodule from api_tmpl to template 2026-08-25 21:32:45 +01:00
FernandoJVideira 613efed0f2 Merge branch 'main' into dev: bring in keystone->draft rename
Resolves conflicts with dev's install-logic commit by keeping draft's
module path/binary names and dropping a stray junk file (.cursor/test.txt).
2026-08-25 13:57:29 +01:00
FernandoJVideira d7e4c554d8 chore: rename keystone to draft
Rename the module, CLI command name, and all references from
keystone/basalt to draft, and fix the stale build/README content
found along the way.
2026-08-25 13:55:24 +01:00
FernandoJVideira 8293794d13 fix: Makefile fix 2026-01-30 00:21:15 +00:00
FernandoJVideira dbade98892 fix: proj layout 2026-01-30 00:07:11 +00:00
FernandoJVideira 9d745785ed feat: added install logic 2026-01-29 22:17:11 +00:00
24 changed files with 1062 additions and 211 deletions

No files matched your search

+2 -1
View File
@@ -30,4 +30,5 @@ go.work.sum
.env
# Binary
/basalt
/draft*
prepare-embed-stamp
+4 -3
View File
@@ -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 = [email protected]:FernandoJVideira/project-template.git
branch = dev
+22 -23
View File
@@ -1,31 +1,30 @@
PKG_NAME="gitea.fernandovideira.com/FernandoJVideira/keystone"
PKG_NAME := git.fernandovideira.com/FernandoJVideira/draft
VERSION := v0.1.0
GO_SOURCES := $(shell find . -name '*.go' -not -path './assets/testdata/*')
.PHONY: version build windows win-arm linux mac prepare-embed clean
version:
@go run -ldflags "-X ${PKG_NAME}/cmd.version=v1.0.0" cmd/basalt/main.go version
@go run -ldflags "-X ${PKG_NAME}/cmd.version=${VERSION}" ./main.go version
generate-templates:
@echo "Initializing submodules..."
@git submodule update --init --recursive
@echo "Generating embedded templates..."
@go run tools/sync_templates.go
windows: ; @$(MAKE) build GOOS=windows GOARCH=amd64 OUTPUT=draft_windows_amd64.exe
win-arm: ; @$(MAKE) build GOOS=windows GOARCH=arm64 OUTPUT=draft_windows_arm64.exe
linux: ; @$(MAKE) build GOOS=linux GOARCH=amd64 OUTPUT=draft_linux_amd64
mac: ; @$(MAKE) build GOOS=darwin GOARCH=arm64 OUTPUT=draft_darwin_arm64
build: prepare-embed
build: $(OUTPUT)
$(OUTPUT): prepare-embed-stamp $(GO_SOURCES)
@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
@GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X ${PKG_NAME}/cmd.version=${VERSION}" -o $(OUTPUT) ./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:
prepare-embed-stamp:
@git submodule update --init --recursive
@mkdir -p assets/embedded
@go run tools/sync_templates.go
@GOOS="" GOARCH="" go run tools/sync_templates.go
@touch $@
prepare-embed: prepare-embed-stamp
clean:
@rm -f draft_windows_amd64.exe draft_windows_arm64.exe draft_linux_amd64 draft_darwin_arm64 prepare-embed-stamp
+84 -17
View File
@@ -1,11 +1,13 @@
# Keystone
# Draft
A powerful CLI tool for bootstrapping Go API projects with best practices built-in. Keystone helps you quickly scaffold production-ready Go APIs with support for both HTTP (REST) and gRPC architectures.
A powerful CLI tool for bootstrapping Go APIs, frontend clients, and full-stack monorepos with best practices built-in. Draft helps you quickly scaffold production-ready projects — a standalone Go API, a standalone Nuxt/Vue client, or both wired together in a single monorepo.
## Features
- 🚀 **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 [email protected]: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_<os>_<arch>` (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` 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: [`vue-ui-kit`](https://git.fernandovideira.com/FernandoJVideira/vue-ui-kit) (installed from its own Gitea npm registry - see its README for the one-time `.npmrc` token setup) + Tailwind, the same component library and theme tokens on either framework.
### 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.
+1 -1
View File
@@ -3,5 +3,5 @@ package assets
import "embed"
//go:generate go run ../tools/sync_templates.go
//go:embed templates/* embedded/*
//go:embed all:templates all:embedded
var EmbedFS embed.FS
+64 -23
View File
@@ -49,8 +49,6 @@ import (
{{ end }}
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib"
)
var interruptSignals = []os.Signal{
@@ -59,25 +57,24 @@ var interruptSignals = []os.Signal{
syscall.SIGINT,
}
// @title Simple Bank API
// @title App Template API
// @version 1.0
// @description API for Simple Bank application
// @description REST API template: accounts (register/login/email verification/refresh tokens) and multi-tenant organizations (members/invites) already wired up.
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.email [email protected]
// @contact.name App Template
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:8080
// @BasePath /v1
// @BasePath /api/v1
// @schemes http https
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
// @description PASETO access token. Format: "Bearer <token>". Some endpoints also accept the equivalent value via the "access_token" cookie.
func main() {
config, err := config.LoadConfig(".")
if err != nil {
@@ -86,6 +83,7 @@ func main() {
if config.Environment == "development" {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
ctx, cancel := signal.NotifyContext(context.Background(), interruptSignals...)
@@ -114,15 +112,20 @@ func main() {
userService := service.NewUserService(store, taskDist)
authService := service.NewAuthService(store, tokenMaker, config)
orgService := service.NewOrganizationService(store, taskDist)
wg, ctx := errgroup.WithContext(ctx)
{{ if not .UseGRPC }}
runGinServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
services := api.Services{
UserService: userService,
AuthService: authService,
OrganizationService: orgService,
}
runGinServer(ctx, wg, config, store, taskDist, tokenMaker, services)
{{ else }}
// Assuming you want Gateway AND gRPC when gRPC is selected
runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService)
runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService)
{{ end }}
runTaskProcessor(ctx, wg, redisOpt, store, config)
@@ -133,8 +136,19 @@ func main() {
}
}
func runTaskProcessor(ctx context.Context, wg *errgroup.Group, redisOpt asynq.RedisClientOpt, store db.Store, config config.Config) {
mailer := mailer.NewGmailSender(config.EmailSenderName, config.EmailSender, config.EmailPassword)
func runTaskProcessor(
ctx context.Context,
wg *errgroup.Group,
redisOpt asynq.RedisClientOpt,
store db.Store,
config config.Config,
) {
mailer := mailer.NewSMTPSender(
config.EmailSenderName,
config.EmailSender,
config.EmailPassword,
config.SMTPAddr,
)
taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer, config)
log.Info().Msg("start task processor")
if err := taskProcessor.Start(); err != nil {
@@ -164,8 +178,16 @@ func runDBMigration(migrationURL, dbSource string) {
}
{{ if not .UseGRPC }}
func runGinServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, td worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) {
s, err := api.NewServer(config, store, td, tokenMaker, userService, authService)
func runGinServer(
ctx context.Context,
wg *errgroup.Group,
config config.Config,
store db.Store,
td worker.TaskDistributor,
tokenMaker auth.Maker,
services api.Services,
) {
s, err := api.NewServer(config, store, td, tokenMaker, services)
if err != nil {
log.Fatal().Err(err).Msg("cannot create server")
}
@@ -194,8 +216,18 @@ func runGinServer(ctx context.Context, wg *errgroup.Group, config config.Config,
{{ end }}
{{ if .UseGRPC }}
func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, taskDistributor worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) {
server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService)
func runGrpcServer(
ctx context.Context,
wg *errgroup.Group,
config config.Config,
store db.Store,
td worker.TaskDistributor,
tokenMaker auth.Maker,
userService service.UserService,
authService service.AuthService,
organizationService service.OrganizationService,
) {
server, err := gapi.NewServer(config, store, td, tokenMaker, userService, authService, organizationService)
if err != nil {
log.Fatal().Err(err).Msg("cannot create server:")
}
@@ -203,7 +235,7 @@ func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config
grpcLogger := grpc.UnaryInterceptor(gapi.GrpcLogger)
grpcServer := grpc.NewServer(grpcLogger)
pb.RegisterSimpleBankServer(grpcServer, server)
pb.Register{{ .ServiceName }}Server(grpcServer, server)
reflection.Register(grpcServer)
listener, err := net.Listen("tcp", config.GrpcServerAddr)
@@ -233,8 +265,18 @@ func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config
})
}
func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, taskDistributor worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) {
server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService)
func runGatewayServer(
ctx context.Context,
wg *errgroup.Group,
config config.Config,
store db.Store,
td worker.TaskDistributor,
tokenMaker auth.Maker,
userService service.UserService,
authService service.AuthService,
organizationService service.OrganizationService,
) {
server, err := gapi.NewServer(config, store, td, tokenMaker, userService, authService, organizationService)
if err != nil {
log.Fatal().Err(err).Msg("cannot create server:")
}
@@ -250,7 +292,7 @@ func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Con
grpcMux := runtime.NewServeMux(opts)
err = pb.RegisterSimpleBankHandlerServer(ctx, grpcMux, server)
err = pb.Register{{ .ServiceName }}HandlerServer(ctx, grpcMux, server)
if err != nil {
log.Fatal().Err(err).Msg("cannot register handler server")
}
@@ -318,6 +360,5 @@ func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Con
log.Info().Msg("HTTP gateway server shut down")
return nil
})
}
{{ end }}
Vendored Submodule
+1
Submodule assets/testdata/template added at 41df89929f.
+60 -65
View File
@@ -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",
func genAPI(cmd *cobra.Command, args []string) {
runNewProject(scaffoldRecipe{label: "API"})
}
rawInput, err := projectNamePrompt.Run()
func scaffoldAPI(projectDir, destSubdir, moduleName, serviceName string, useGRPC bool) error {
destRoot := filepath.Join(projectDir, destSubdir)
err := os.MkdirAll(destRoot, 0755)
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"},
return fmt.Errorf("error creating project directory: %w", err)
}
_, 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"
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,
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)
}
}
-7
View File
@@ -1,7 +0,0 @@
package main
import "gitea.fernandovideira.com/FernandoJVideira/keystone/cmd"
func main() {
cmd.Execute()
}
+329
View File
@@ -0,0 +1,329 @@
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 {
// "minimal", not "ui" - the Nuxt UI starter is what we'd otherwise get
// for free, but this project uses vue-ui-kit as its component library
// instead, wired up by hand below (Tailwind v4 + the overlay's own
// app/assets/css/main.css already import it).
if err := runCommand("", "npx", "nuxi@latest", "init", projectDir,
"--no-install",
"--packageManager=pnpm",
"-t", "minimal",
"--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...")
if err := runCommand(projectDir, "pnpm", "add", "pinia", "@pinia/nuxt",
"@fernandojvideira/vue-ui-kit", "lucide-vue-next",
); err != nil {
return fmt.Errorf("error installing dependencies: %w", err)
}
// typescript itself: the "minimal" starter (unlike "ui", and unlike
// create-vue's own output) doesn't include it, but @vue/compiler-sfc
// needs a real TypeScript install to resolve cross-file type extends -
// exactly what several vue-ui-kit components' Props interfaces do.
// Pinned below 7.x (matching create-vue's own "~6.0.0" default) since
// @vue/compiler-sfc's cross-file resolution isn't compatible with
// TypeScript 7 yet - it fails with "No fs option provided to
// compileScript in non-Node environment" on every extended interface.
return runCommand(projectDir, "pnpm", "add", "-D", "tailwindcss", "@tailwindcss/vite", "typescript@~6.0.0", "vue-tsc")
}
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)
}
// The "minimal" starter registers no Tailwind plugin and no CSS entry
// point on its own (the "ui" starter did both, bundled inside @nuxt/ui) -
// both are added explicitly here since the overlay's own main.css needs
// to actually run.
newContent := strings.Replace(string(content),
"// https://nuxt.com/docs/api/configuration/nuxt-config\n",
"// https://nuxt.com/docs/api/configuration/nuxt-config\nimport tailwindcss from '@tailwindcss/vite'\n\n",
1,
)
if newContent == string(content) {
return fmt.Errorf("could not find the file-header anchor in nuxt.config.ts")
}
insertion := fmt.Sprintf(`
css: ['~/assets/css/main.css'],
vite: {
plugins: [tailwindcss()],
},
app: {
head: {
title: %q,
meta: [{ name: 'description', content: %q }],
},
},
runtimeConfig: {
apiInternalUrl: '',
public: {
baseUrl: '',
},
},
`, serviceName, serviceName)
finalContent := strings.Replace(newContent, "defineNuxtConfig({", "defineNuxtConfig({"+insertion, 1)
if finalContent == newContent {
return fmt.Errorf("could not find defineNuxtConfig({ anchor in nuxt.config.ts")
}
return os.WriteFile(configPath, []byte(finalContent), 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(vueUiKitMainCSS), 0644); err != nil {
return fmt.Errorf("error writing main.css: %w", err)
}
if err := patchViteConfigForTailwind(projectDir); err != nil {
return err
}
if err := patchTsconfigAlias(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", "@fernandojvideira/vue-ui-kit", "lucide-vue-next")
}
// The library's theme brings its own tokens; --success/--warning are added on
// top the same way a consumer would (see vue-ui-kit's THEMING.md) since the
// base theme deliberately doesn't define them.
const vueUiKitMainCSS = `@import "tailwindcss";
@source "../../node_modules/@fernandojvideira/vue-ui-kit/src";
@import "@fernandojvideira/vue-ui-kit/theme.css";
:root {
--success: 142 71% 35%;
--success-foreground: 0 0% 98%;
--warning: 38 92% 40%;
--warning-foreground: 0 0% 98%;
}
.dark {
--success: 142 71% 45%;
--success-foreground: 0 0% 9%;
--warning: 38 92% 55%;
--warning-foreground: 0 0% 9%;
}
@theme inline {
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
}
`
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 patchEslintConfig(projectDir string) error {
configPath := filepath.Join(projectDir, "eslint.config.ts")
content, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("error reading eslint.config.ts: %w", err)
}
override := ` {
name: 'app/rules-overrides',
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),`
newContent := strings.Replace(string(content),
" ...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),",
override,
1,
)
if newContent == string(content) {
return fmt.Errorf("could not find expected anchor in eslint.config.ts")
}
return os.WriteFile(configPath, []byte(newContent), 0644)
}
+1 -1
View File
@@ -23,7 +23,7 @@ func init() {
var handlerCmd = &cobra.Command{
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,
}
+171 -2
View File
@@ -5,13 +5,18 @@ import (
"crypto/rand"
"errors"
"fmt"
"io/fs"
"math/big"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"strings"
"text/template"
"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!")
}
+14 -20
View File
@@ -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)
},
}
+52
View File
@@ -0,0 +1,52 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
)
func init() {
newCmd.AddCommand(monorepoCmd)
}
var monorepoCmd = &cobra.Command{
Use: "monorepo",
Short: "Create a new Draft Monorepo project",
Long: `Create a new Draft Monorepo project with the necessary structure and files.`,
Run: genMonorepo,
}
func genMonorepo(cmd *cobra.Command, args []string) {
runNewProject(scaffoldRecipe{
label: "monorepo",
destSubdir: "apps/api",
preScaffold: func(projectDir string) error {
if err := os.MkdirAll(projectDir, 0755); err != nil {
return fmt.Errorf("error creating project directory: %w", err)
}
return copyEmbeddedDir("embedded/monorepo-root", projectDir, nil)
},
postScaffold: func(projectDir string) error {
if err := requirePnpmAndNpx(); err != nil {
return fmt.Errorf("error checking for pnpm and npx: %w", err)
}
isVue, resultLabel, err := promptClientFramework()
if err != nil {
return fmt.Errorf("prompt failed: %w", err)
}
clientDir := filepath.Join(projectDir, "apps/client")
serviceName := toPascalCase(filepath.Base(projectDir))
fmt.Printf("🚀 Scaffolding %s client app...\n", resultLabel)
if isVue {
return scaffoldClientVue(clientDir, serviceName)
}
return scaffoldClientNuxt(clientDir, serviceName)
},
})
}
+31 -4
View File
@@ -1,14 +1,17 @@
package cmd
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
ServiceName string
UseGRPC bool
}
@@ -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")
}
}
+3 -3
View File
@@ -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, clients, and monorepos",
Long: `Draft is a CLI tool built by LKResuts for internal usage to bootstrap Go APIs, Nuxt/Vue clients, and full-stack monorepos combining both`,
}
func Execute() {
+1 -3
View File
@@ -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)
+3 -4
View File
@@ -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)
},
}
+1 -1
View File
@@ -1,4 +1,4 @@
module gitea.fernandovideira.com/FernandoJVideira/keystone
module git.fernandovideira.com/FernandoJVideira/draft
go 1.25.5
+7
View File
@@ -0,0 +1,7 @@
package main
import "git.fernandovideira.com/FernandoJVideira/draft/cmd"
func main() {
cmd.Execute()
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"archive/zip"
"fmt"
"os"
"path/filepath"
"strings"
)
func main() {
if len(os.Args) != 3 {
fmt.Println("usage: create_zip <source_dir> <output_zip>")
os.Exit(1)
}
sourceDir := os.Args[1]
outputZip := os.Args[2]
outFile, err := os.Create(outputZip)
if err != nil {
panic(err)
}
defer outFile.Close()
w := zip.NewWriter(outFile)
defer w.Close()
err = filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil // Skip directory entries entirely
}
// Skip hidden files/directories (like .DS_Store)
if strings.HasPrefix(filepath.Base(path), ".") {
return nil
}
// Get path relative to the source directory
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
// Ensure forward slashes for zip spec compliance
relPath = filepath.ToSlash(relPath)
// Create file in zip
f, err := w.Create(relPath)
if err != nil {
return err
}
// Write content
fileContent, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = f.Write(fileContent)
return err
})
if err != nil {
panic(err)
}
}
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
set -e
# Usage: ./tools/publish-package.sh <version> <gitea-token>
VERSION=$1
TOKEN=$2
MODULE="gitea.fernandovideira.com/fernandojvideira/keystone"
# Note: The zip must contain the full domain path as the root directory
ZIP_ROOT="${MODULE}@${VERSION}"
API_URL="https://gitea.fernandovideira.com/api/packages/FernandoJVideira/go/upload"
if [ -z "$VERSION" ] || [ -z "$TOKEN" ]; then
echo "Usage: $0 <version> <gitea-token>"
exit 1
fi
echo "🚀 preparing release $VERSION..."
# 1. Generate the assets locally
make prepare-embed
# 2. Create a temporary staging area
TMP_DIR=$(mktemp -d)
TARGET_DIR="$TMP_DIR/$ZIP_ROOT"
mkdir -p "$TARGET_DIR"
# 3. Export clean git source to staging
git archive --format=tar HEAD | tar -x -C "$TARGET_DIR"
# 4. Copy the generated (and git-ignored) assets into the staging area
# We assume assets/embedded exists after 'make prepare-embed'
mkdir -p "$TARGET_DIR/assets/embedded"
cp -r assets/embedded/* "$TARGET_DIR/assets/embedded/"
# 5. Zip it up (using a custom Go tool to avoid directory entries)
ZIP_FILE="keystone-$VERSION.zip"
# We run the tool from the current directory, pointing it to the temp dir
go run tools/create_zip.go "$TMP_DIR" "$ZIP_FILE"
# 6. Upload to Gitea
echo "📦 Uploading to Gitea Package Registry..."
curl --fail --silent --show-error \
--user "FernandoJVideira:$TOKEN" \
--upload-file "$ZIP_FILE" \
"$API_URL"
# Cleanup
rm -rf "$TMP_DIR" "$ZIP_FILE"
echo "✅ Published $VERSION successfully!"
+94 -32
View File
@@ -3,67 +3,129 @@
package main
import (
"io/fs"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
)
const (
sourceDir = "assets/testdata/api_tmpl"
targetDir = "assets/embedded"
oldModuleName = "github.com/FernandoJVideira/simple_bank"
oldModuleName = "app_tmpl"
moduleTemplate = "{{ .ModuleName }}"
)
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
}
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",
},
},
}
func main() {
log.Println("Syncing templates...")
syncTemplates()
for _, job := range jobs {
syncTemplates(job)
}
}
func syncTemplates() {
func syncTemplates(job syncJob) {
err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error {
err := os.RemoveAll(job.targetDir)
if err != nil {
return err
log.Fatalf("Error removing target directory: %v", 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)
cmd := exec.Command("git", "-C", job.sourceDir, "ls-files")
output, err := cmd.Output()
if err != nil {
return err
log.Fatalf("Error listing files in source directory: %v", err)
}
if d.Name() == "go.mod" || d.Name() == "go.sum" {
paths := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, relPath := range paths {
if len(job.includePrefixes) > 0 {
included := false
for _, prefix := range job.includePrefixes {
if strings.HasPrefix(relPath, prefix) {
included = true
break
}
}
if !included {
continue
}
} else if job.excludePrefix != "" && strings.HasPrefix(relPath, job.excludePrefix) {
continue
}
srcPath := filepath.Join(job.sourceDir, relPath)
destPath := filepath.Join(job.targetDir, relPath)
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
log.Fatalf("Error creating destination directory: %v", err)
}
content, err := os.ReadFile(srcPath)
if err != nil {
log.Fatalf("Error reading source file: %v", err)
}
if filepath.Base(relPath) == "go.mod" || filepath.Base(relPath) == "go.sum" {
destPath += ".tmpl"
}
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)
}