Public Access
Compare commits
33
Commits
v0.0.1
...
8a11de5b0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a11de5b0f | ||
|
|
b7fe8f3f4e | ||
|
|
0bd1550779 | ||
|
|
1dcc135094 | ||
|
|
39678f3ea6 | ||
|
|
230037c5f5 | ||
|
|
1195fdacb5 | ||
|
|
2f1ad93a74 | ||
|
|
b3ea1013bc | ||
|
|
35b88b4dcf | ||
|
|
64c57f830b | ||
|
|
d62383598c | ||
|
|
650e811c84 | ||
|
|
663ffb1c09 | ||
|
|
4806186ae6 | ||
|
|
7cec03d4c5 | ||
|
|
145b3bb879 | ||
|
|
095a4c859a | ||
|
|
dff28b1a4c | ||
|
|
6fc36118d9 | ||
|
|
3ff0633530 | ||
|
|
2fd651965d | ||
|
|
fa217290bf | ||
|
|
f5fbd240bf | ||
|
|
a514521c6e | ||
|
|
29f94539e0 | ||
|
|
fe48b7caee | ||
|
|
42600a5358 | ||
|
|
613efed0f2 | ||
|
|
d7e4c554d8 | ||
|
|
8293794d13
|
||
|
|
dbade98892
|
||
|
|
9d745785ed
|
No files matched your search
+2
-1
@@ -30,4 +30,5 @@ go.work.sum
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
# Binary
|
# Binary
|
||||||
/basalt
|
/draft*
|
||||||
|
prepare-embed-stamp
|
||||||
+4
-3
@@ -1,3 +1,4 @@
|
|||||||
[submodule "assets/testdata/api_tmpl"]
|
[submodule "assets/testdata/template"]
|
||||||
path = assets/testdata/api_tmpl
|
path = assets/testdata/template
|
||||||
url = git@gitea.fernandovideira.com:FernandoJVideira/api_tmpl.git
|
url = [email protected]:FernandoJVideira/project-template.git
|
||||||
|
branch = dev
|
||||||
@@ -1,31 +1,30 @@
|
|||||||
PKG_NAME="gitea.fernandovideira.com/FernandoJVideira/keystone"
|
PKG_NAME := git.fernandovideira.com/FernandoJVideira/draft
|
||||||
|
VERSION := v0.2.0
|
||||||
|
GO_SOURCES := $(shell find . -name '*.go' -not -path './assets/testdata/*')
|
||||||
|
|
||||||
|
.PHONY: version build windows win-arm linux mac prepare-embed clean
|
||||||
|
|
||||||
version:
|
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:
|
windows: ; @$(MAKE) build GOOS=windows GOARCH=amd64 OUTPUT=draft_windows_amd64.exe
|
||||||
@echo "Initializing submodules..."
|
win-arm: ; @$(MAKE) build GOOS=windows GOARCH=arm64 OUTPUT=draft_windows_arm64.exe
|
||||||
@git submodule update --init --recursive
|
linux: ; @$(MAKE) build GOOS=linux GOARCH=amd64 OUTPUT=draft_linux_amd64
|
||||||
@echo "Generating embedded templates..."
|
mac: ; @$(MAKE) build GOOS=darwin GOARCH=arm64 OUTPUT=draft_darwin_arm64
|
||||||
@go run tools/sync_templates.go
|
|
||||||
|
|
||||||
build: prepare-embed
|
build: $(OUTPUT)
|
||||||
|
|
||||||
|
$(OUTPUT): prepare-embed-stamp $(GO_SOURCES)
|
||||||
@echo "Building for $(GOOS)/$(GOARCH)..."
|
@echo "Building for $(GOOS)/$(GOARCH)..."
|
||||||
@GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X ${PKG_NAME}/cmd.version=v0.0.1" -o $(OUTPUT) ./cmd/basalt/main.go
|
@GOOS=$(GOOS) GOARCH=$(GOARCH) go build -ldflags "-X ${PKG_NAME}/cmd.version=${VERSION}" -o $(OUTPUT) ./main.go
|
||||||
|
|
||||||
windows:
|
prepare-embed-stamp:
|
||||||
@$(MAKE) build GOOS=windows GOARCH=amd64 OUTPUT=basalt.exe
|
|
||||||
|
|
||||||
win-arm:
|
|
||||||
@$(MAKE) build GOOS=windows GOARCH=arm64 OUTPUT=basalt.exe
|
|
||||||
|
|
||||||
linux:
|
|
||||||
@$(MAKE) build GOOS=linux GOARCH=amd64 OUTPUT=basalt
|
|
||||||
|
|
||||||
mac:
|
|
||||||
@$(MAKE) build GOOS=darwin GOARCH=arm64 OUTPUT=basalt
|
|
||||||
|
|
||||||
prepare-embed:
|
|
||||||
@git submodule update --init --recursive
|
@git submodule update --init --recursive
|
||||||
@mkdir -p assets/embedded
|
@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
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
# Keystone
|
# Draft
|
||||||
|
|
||||||
A powerful CLI tool for bootstrapping Go API projects with best practices built-in. Keystone helps you quickly scaffold production-ready Go APIs with support for both HTTP (REST) and gRPC architectures.
|
A powerful CLI tool for bootstrapping Go APIs, frontend clients, and full-stack monorepos with best practices built-in. Draft helps you quickly scaffold production-ready projects — a standalone Go API, a standalone Nuxt/Vue client, or both wired together in a single monorepo.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🚀 **Quick Project Scaffolding** - Generate complete API projects with a single command
|
- 🚀 **Quick Project Scaffolding** - Generate complete projects with a single command
|
||||||
|
- 🧩 **Three Project Shapes** - API only, client only, or a full-stack monorepo combining both
|
||||||
- 🔄 **Multiple API Types** - Support for both HTTP (REST) and gRPC architectures
|
- 🔄 **Multiple API Types** - Support for both HTTP (REST) and gRPC architectures
|
||||||
|
- 🖼️ **Multiple Client Frameworks** - Nuxt or Vue, built on top of the official `nuxi`/`create-vue` tooling so the boilerplate always stays current
|
||||||
- 📦 **Service Generation** - Easily create new services with proper structure
|
- 📦 **Service Generation** - Easily create new services with proper structure
|
||||||
- 🎯 **Handler Generation** - Generate HTTP or gRPC handlers on-demand
|
- 🎯 **Handler Generation** - Generate HTTP or gRPC handlers on-demand
|
||||||
- 🔐 **Cryptographic Key Generator** - Built-in secure key generation
|
- 🔐 **Cryptographic Key Generator** - Built-in secure key generation
|
||||||
@@ -17,8 +19,8 @@ A powerful CLI tool for bootstrapping Go API projects with best practices built-
|
|||||||
### From Source
|
### From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/yourusername/keystone.git
|
git clone [email protected]:FernandoJVideira/draft.git
|
||||||
cd keystone
|
cd draft
|
||||||
make build
|
make build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -38,14 +40,22 @@ make windows
|
|||||||
make win-arm
|
make win-arm
|
||||||
```
|
```
|
||||||
|
|
||||||
The compiled binary will be named `basalt` (or `basalt.exe` on Windows).
|
The compiled binary will be named `draft_<os>_<arch>` (e.g. `draft_darwin_arm64`, or `draft_windows_amd64.exe` on Windows).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
### Create a New Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
draft new
|
||||||
|
```
|
||||||
|
|
||||||
|
Prompts you to pick a project shape — **API**, **Client**, or **Full Stack** (monorepo) — then runs the matching flow below. Each shape is also available directly as its own subcommand if you already know what you want.
|
||||||
|
|
||||||
### Create a New API Project
|
### Create a New API Project
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
keystone new api
|
draft new api
|
||||||
```
|
```
|
||||||
|
|
||||||
This interactive command will:
|
This interactive command will:
|
||||||
@@ -64,10 +74,34 @@ This interactive command will:
|
|||||||
- `swag` - Swagger documentation (HTTP only)
|
- `swag` - Swagger documentation (HTTP only)
|
||||||
- `protoc-gen-*` - Protocol Buffer generators (gRPC only)
|
- `protoc-gen-*` - Protocol Buffer generators (gRPC only)
|
||||||
|
|
||||||
|
### Create a New Client Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
draft new client
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Prompts for a project name
|
||||||
|
2. Prompts you to pick a framework — **Nuxt** or **Vue**
|
||||||
|
3. Scaffolds the base app via the official tooling (`nuxi init` for Nuxt, `create-vue` 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. Both clients ship with a Dockerfile that mounts that `~/.npmrc` as a build secret (wired up in the monorepo's `docker-compose.yml`), so container builds resolve the package without baking the token into the image - Nuxt runs its Nitro server, Vue is served by nginx with an SPA fallback and `/api/` proxied to the api container.
|
||||||
|
|
||||||
|
### Create a New Monorepo Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
draft new monorepo
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs the API flow and the client flow together under one root, wired up as a single project:
|
||||||
|
- `apps/api` — the scaffolded Go API
|
||||||
|
- `apps/client` — the scaffolded Nuxt or Vue client
|
||||||
|
- Root-level `go.work`, `pnpm-workspace.yaml`, `docker-compose.yml`, and `Justfile` tying both together
|
||||||
|
|
||||||
### Generate a New Service
|
### Generate a New Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
keystone new service
|
draft new service
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates a new service file in `internal/service/` with:
|
Creates a new service file in `internal/service/` with:
|
||||||
@@ -78,7 +112,7 @@ Creates a new service file in `internal/service/` with:
|
|||||||
### Generate a New Handler
|
### Generate a New Handler
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
keystone new handler
|
draft new handler
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates a new handler in the appropriate directory:
|
Creates a new handler in the appropriate directory:
|
||||||
@@ -88,20 +122,21 @@ Creates a new handler in the appropriate directory:
|
|||||||
### Generate a Cryptographic Key
|
### Generate a Cryptographic Key
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
keystone new key
|
draft key
|
||||||
|
draft key --size 48
|
||||||
```
|
```
|
||||||
|
|
||||||
Generates a secure random key for use in your application (default: 32 characters).
|
Generates a secure random key for use in your application (default and minimum size: 32 characters). Passing `--size` below 32 is rejected rather than silently substituted.
|
||||||
|
|
||||||
### Check Version
|
### Check Version
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
keystone version
|
draft version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
When you create a new project, Keystone generates a structured Go API with:
|
### API
|
||||||
|
|
||||||
```
|
```
|
||||||
your-project/
|
your-project/
|
||||||
@@ -118,10 +153,38 @@ your-project/
|
|||||||
└── go.mod
|
└── go.mod
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
```
|
||||||
|
your-client/
|
||||||
|
├── app/ (Nuxt) or src/ (Vue)
|
||||||
|
│ ├── stores/auth.ts
|
||||||
|
│ ├── composables/useApi.ts
|
||||||
|
│ ├── pages/ or views/
|
||||||
|
│ └── ...
|
||||||
|
├── nuxt.config.ts (Nuxt) or vite.config.ts + components.json (Vue)
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monorepo
|
||||||
|
|
||||||
|
```
|
||||||
|
your-project/
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ (Go API, its own go.mod)
|
||||||
|
│ └── client/ (Nuxt or Vue client)
|
||||||
|
├── go.work
|
||||||
|
├── pnpm-workspace.yaml
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── Justfile
|
||||||
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Go 1.25.5 or higher
|
- Go 1.25.5 or higher
|
||||||
- Make (for building from source)
|
- Make (for building from source)
|
||||||
|
- `pnpm` and `npx` (Node.js) — required for client and monorepo scaffolding
|
||||||
|
- `protoc` — required only for gRPC API projects
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
@@ -143,6 +206,14 @@ make build
|
|||||||
make version
|
make version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Sync Templates
|
||||||
|
|
||||||
|
Draft's embedded API/client templates are synced from a reference monorepo checked out as a git submodule (`assets/testdata/template`). After updating that submodule, regenerate the embedded copies with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run tools/sync_templates.go
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
@@ -154,7 +225,3 @@ This project is licensed under the MIT License - see the LICENSE file for detail
|
|||||||
## Author
|
## Author
|
||||||
|
|
||||||
Built by LKResuts for internal usage to bootstrap Go APIs.
|
Built by LKResuts for internal usage to bootstrap Go APIs.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Note:** The binary is compiled as `basalt`, but the CLI tool is referred to as `keystone` in documentation and usage.
|
|
||||||
+1
-1
@@ -3,5 +3,5 @@ package assets
|
|||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:generate go run ../tools/sync_templates.go
|
//go:generate go run ../tools/sync_templates.go
|
||||||
//go:embed templates/* embedded/*
|
//go:embed all:templates all:embedded
|
||||||
var EmbedFS embed.FS
|
var EmbedFS embed.FS
|
||||||
@@ -49,8 +49,6 @@ import (
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var interruptSignals = []os.Signal{
|
var interruptSignals = []os.Signal{
|
||||||
@@ -59,25 +57,24 @@ var interruptSignals = []os.Signal{
|
|||||||
syscall.SIGINT,
|
syscall.SIGINT,
|
||||||
}
|
}
|
||||||
|
|
||||||
// @title Simple Bank API
|
// @title App Template API
|
||||||
// @version 1.0
|
// @version 1.0
|
||||||
// @description API for Simple Bank application
|
// @description REST API template: accounts (register/login/email verification/refresh tokens) and multi-tenant organizations (members/invites) already wired up.
|
||||||
// @termsOfService http://swagger.io/terms/
|
// @termsOfService http://swagger.io/terms/
|
||||||
|
|
||||||
// @contact.name API Support
|
// @contact.name App Template
|
||||||
// @contact.email [email protected]
|
|
||||||
|
|
||||||
// @license.name MIT
|
// @license.name MIT
|
||||||
// @license.url https://opensource.org/licenses/MIT
|
// @license.url https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
// @host localhost:8080
|
// @host localhost:8080
|
||||||
// @BasePath /v1
|
// @BasePath /api/v1
|
||||||
// @schemes http https
|
// @schemes http https
|
||||||
|
|
||||||
// @securityDefinitions.apikey BearerAuth
|
// @securityDefinitions.apikey BearerAuth
|
||||||
// @in header
|
// @in header
|
||||||
// @name Authorization
|
// @name Authorization
|
||||||
// @description Type "Bearer" followed by a space and JWT token.
|
// @description PASETO access token. Format: "Bearer <token>". Some endpoints also accept the equivalent value via the "access_token" cookie.
|
||||||
func main() {
|
func main() {
|
||||||
config, err := config.LoadConfig(".")
|
config, err := config.LoadConfig(".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,6 +83,7 @@ func main() {
|
|||||||
|
|
||||||
if config.Environment == "development" {
|
if config.Environment == "development" {
|
||||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||||
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), interruptSignals...)
|
ctx, cancel := signal.NotifyContext(context.Background(), interruptSignals...)
|
||||||
@@ -114,15 +112,20 @@ func main() {
|
|||||||
|
|
||||||
userService := service.NewUserService(store, taskDist)
|
userService := service.NewUserService(store, taskDist)
|
||||||
authService := service.NewAuthService(store, tokenMaker, config)
|
authService := service.NewAuthService(store, tokenMaker, config)
|
||||||
|
orgService := service.NewOrganizationService(store, taskDist)
|
||||||
|
|
||||||
wg, ctx := errgroup.WithContext(ctx)
|
wg, ctx := errgroup.WithContext(ctx)
|
||||||
|
|
||||||
{{ if not .UseGRPC }}
|
{{ if not .UseGRPC }}
|
||||||
runGinServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
|
services := api.Services{
|
||||||
|
UserService: userService,
|
||||||
|
AuthService: authService,
|
||||||
|
OrganizationService: orgService,
|
||||||
|
}
|
||||||
|
runGinServer(ctx, wg, config, store, taskDist, tokenMaker, services)
|
||||||
{{ else }}
|
{{ else }}
|
||||||
// Assuming you want Gateway AND gRPC when gRPC is selected
|
runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService)
|
||||||
runGatewayServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
|
runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService, orgService)
|
||||||
runGrpcServer(ctx, wg, config, store, taskDist, tokenMaker, userService, authService)
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
runTaskProcessor(ctx, wg, redisOpt, store, config)
|
runTaskProcessor(ctx, wg, redisOpt, store, config)
|
||||||
@@ -133,8 +136,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTaskProcessor(ctx context.Context, wg *errgroup.Group, redisOpt asynq.RedisClientOpt, store db.Store, config config.Config) {
|
func runTaskProcessor(
|
||||||
mailer := mailer.NewGmailSender(config.EmailSenderName, config.EmailSender, config.EmailPassword)
|
ctx context.Context,
|
||||||
|
wg *errgroup.Group,
|
||||||
|
redisOpt asynq.RedisClientOpt,
|
||||||
|
store db.Store,
|
||||||
|
config config.Config,
|
||||||
|
) {
|
||||||
|
mailer := mailer.NewSMTPSender(
|
||||||
|
config.EmailSenderName,
|
||||||
|
config.EmailSender,
|
||||||
|
config.EmailPassword,
|
||||||
|
config.SMTPAddr,
|
||||||
|
)
|
||||||
taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer, config)
|
taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer, config)
|
||||||
log.Info().Msg("start task processor")
|
log.Info().Msg("start task processor")
|
||||||
if err := taskProcessor.Start(); err != nil {
|
if err := taskProcessor.Start(); err != nil {
|
||||||
@@ -164,8 +178,16 @@ func runDBMigration(migrationURL, dbSource string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{{ if not .UseGRPC }}
|
{{ if not .UseGRPC }}
|
||||||
func runGinServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, td worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) {
|
func runGinServer(
|
||||||
s, err := api.NewServer(config, store, td, tokenMaker, userService, authService)
|
ctx context.Context,
|
||||||
|
wg *errgroup.Group,
|
||||||
|
config config.Config,
|
||||||
|
store db.Store,
|
||||||
|
td worker.TaskDistributor,
|
||||||
|
tokenMaker auth.Maker,
|
||||||
|
services api.Services,
|
||||||
|
) {
|
||||||
|
s, err := api.NewServer(config, store, td, tokenMaker, services)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("cannot create server")
|
log.Fatal().Err(err).Msg("cannot create server")
|
||||||
}
|
}
|
||||||
@@ -194,8 +216,18 @@ func runGinServer(ctx context.Context, wg *errgroup.Group, config config.Config,
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ if .UseGRPC }}
|
{{ if .UseGRPC }}
|
||||||
func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, taskDistributor worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) {
|
func runGrpcServer(
|
||||||
server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService)
|
ctx context.Context,
|
||||||
|
wg *errgroup.Group,
|
||||||
|
config config.Config,
|
||||||
|
store db.Store,
|
||||||
|
td worker.TaskDistributor,
|
||||||
|
tokenMaker auth.Maker,
|
||||||
|
userService service.UserService,
|
||||||
|
authService service.AuthService,
|
||||||
|
organizationService service.OrganizationService,
|
||||||
|
) {
|
||||||
|
server, err := gapi.NewServer(config, store, td, tokenMaker, userService, authService, organizationService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("cannot create server:")
|
log.Fatal().Err(err).Msg("cannot create server:")
|
||||||
}
|
}
|
||||||
@@ -203,7 +235,7 @@ func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config
|
|||||||
grpcLogger := grpc.UnaryInterceptor(gapi.GrpcLogger)
|
grpcLogger := grpc.UnaryInterceptor(gapi.GrpcLogger)
|
||||||
|
|
||||||
grpcServer := grpc.NewServer(grpcLogger)
|
grpcServer := grpc.NewServer(grpcLogger)
|
||||||
pb.RegisterSimpleBankServer(grpcServer, server)
|
pb.Register{{ .ServiceName }}Server(grpcServer, server)
|
||||||
reflection.Register(grpcServer)
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", config.GrpcServerAddr)
|
listener, err := net.Listen("tcp", config.GrpcServerAddr)
|
||||||
@@ -233,8 +265,18 @@ func runGrpcServer(ctx context.Context, wg *errgroup.Group, config config.Config
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Config, store db.Store, taskDistributor worker.TaskDistributor, tokenMaker auth.Maker, userService service.UserService, authService service.AuthService) {
|
func runGatewayServer(
|
||||||
server, err := gapi.NewServer(config, store, taskDistributor, tokenMaker, userService, authService)
|
ctx context.Context,
|
||||||
|
wg *errgroup.Group,
|
||||||
|
config config.Config,
|
||||||
|
store db.Store,
|
||||||
|
td worker.TaskDistributor,
|
||||||
|
tokenMaker auth.Maker,
|
||||||
|
userService service.UserService,
|
||||||
|
authService service.AuthService,
|
||||||
|
organizationService service.OrganizationService,
|
||||||
|
) {
|
||||||
|
server, err := gapi.NewServer(config, store, td, tokenMaker, userService, authService, organizationService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("cannot create server:")
|
log.Fatal().Err(err).Msg("cannot create server:")
|
||||||
}
|
}
|
||||||
@@ -250,7 +292,7 @@ func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Con
|
|||||||
|
|
||||||
grpcMux := runtime.NewServeMux(opts)
|
grpcMux := runtime.NewServeMux(opts)
|
||||||
|
|
||||||
err = pb.RegisterSimpleBankHandlerServer(ctx, grpcMux, server)
|
err = pb.Register{{ .ServiceName }}HandlerServer(ctx, grpcMux, server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("cannot register handler server")
|
log.Fatal().Err(err).Msg("cannot register handler server")
|
||||||
}
|
}
|
||||||
@@ -318,6 +360,5 @@ func runGatewayServer(ctx context.Context, wg *errgroup.Group, config config.Con
|
|||||||
log.Info().Msg("HTTP gateway server shut down")
|
log.Info().Msg("HTTP gateway server shut down")
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
Vendored
-1
Submodule assets/testdata/api_tmpl deleted from 961bdd81d4.
+1
Submodule assets/testdata/template added at 2a37943d70.
+59
-64
@@ -4,12 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.fernandovideira.com/FernandoJVideira/keystone/assets"
|
"git.fernandovideira.com/FernandoJVideira/draft/assets"
|
||||||
"github.com/manifoldco/promptui"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,69 +17,25 @@ func init() {
|
|||||||
|
|
||||||
var apiCmd = &cobra.Command{
|
var apiCmd = &cobra.Command{
|
||||||
Use: "api",
|
Use: "api",
|
||||||
Short: "Create a new Keystone API project",
|
Short: "Create a new Draft API project",
|
||||||
Long: `Create a new Keystone API project with the necessary structure and files.`,
|
Long: `Create a new Draft API project with the necessary structure and files.`,
|
||||||
Run: genProject,
|
Run: genAPI,
|
||||||
}
|
}
|
||||||
|
|
||||||
func genProject(cmd *cobra.Command, args []string) {
|
func genAPI(cmd *cobra.Command, args []string) {
|
||||||
|
runNewProject(scaffoldRecipe{label: "API"})
|
||||||
projectNamePrompt := promptui.Prompt{
|
|
||||||
Label: "Project Name",
|
|
||||||
}
|
|
||||||
|
|
||||||
rawInput, err := projectNamePrompt.Run()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Prompt failed %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
moduleName := strings.TrimSpace(rawInput)
|
|
||||||
projectDir := path.Base(strings.TrimSuffix(moduleName, "/"))
|
|
||||||
|
|
||||||
apiType := promptui.Select{
|
|
||||||
Label: "Select API Type",
|
|
||||||
Items: []string{"HTTP (REST)", "gRPC"},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, result, err := apiType.Run()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Prompt failed %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logic to map the selection to a boolean
|
|
||||||
useGRPC := strings.Contains(result, "gRPC")
|
|
||||||
|
|
||||||
fmt.Printf("🚀 Scaffolding '%s' as %s...\n", moduleName, result)
|
|
||||||
if err := scaffoldProject(projectDir, moduleName, useGRPC); err != nil {
|
|
||||||
fmt.Printf("error scaffolding project: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("📦 Installing tools...")
|
|
||||||
if err := installTools(useGRPC); err != nil {
|
|
||||||
fmt.Printf("error installing tools: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("🧹 Tidying up Go modules...")
|
|
||||||
if err := runCommand(projectDir, "go", "mod", "tidy"); err != nil {
|
|
||||||
fmt.Printf("error running 'go mod tidy': %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("✅ Done!")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func scaffoldProject(projectDir, moduleName string, useGRPC bool) error {
|
func scaffoldAPI(projectDir, destSubdir, moduleName, serviceName string, useGRPC bool) error {
|
||||||
|
|
||||||
err := os.MkdirAll(projectDir, 0755)
|
destRoot := filepath.Join(projectDir, destSubdir)
|
||||||
|
|
||||||
|
err := os.MkdirAll(destRoot, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating project directory: %v", err)
|
return fmt.Errorf("error creating project directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
root := "embedded"
|
root := "embedded/api"
|
||||||
err = fs.WalkDir(assets.EmbedFS, root, func(path string, d fs.DirEntry, err error) error {
|
err = fs.WalkDir(assets.EmbedFS, root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
|
||||||
relPath, _ := filepath.Rel(root, path)
|
relPath, _ := filepath.Rel(root, path)
|
||||||
@@ -112,40 +66,81 @@ func scaffoldProject(projectDir, moduleName string, useGRPC bool) error {
|
|||||||
|
|
||||||
contentBytes, err := assets.EmbedFS.ReadFile(path)
|
contentBytes, err := assets.EmbedFS.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error reading embedded file %s: %v", path, err)
|
return fmt.Errorf("error reading embedded file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
content := string(contentBytes)
|
content := string(contentBytes)
|
||||||
|
|
||||||
targetPath := filepath.Join(projectDir, relPath)
|
targetPath := filepath.Join(destRoot, relPath)
|
||||||
|
|
||||||
targetPath = strings.TrimSuffix(targetPath, ".tmpl")
|
targetPath = strings.TrimSuffix(targetPath, ".tmpl")
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||||
return fmt.Errorf("error creating directory %s: %v", filepath.Dir(targetPath), err)
|
return fmt.Errorf("error creating directory %s: %w", filepath.Dir(targetPath), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newContent := strings.ReplaceAll(content, oldModuleName, moduleName)
|
newContent := strings.ReplaceAll(content, oldModuleName, moduleName)
|
||||||
newContent = strings.ReplaceAll(newContent, "{{ .ModuleName }}", moduleName)
|
newContent = strings.ReplaceAll(newContent, "{{ .ModuleName }}", moduleName)
|
||||||
|
newContent = strings.ReplaceAll(newContent, "AppTmpl", serviceName)
|
||||||
|
|
||||||
return os.WriteFile(targetPath, []byte(newContent), 0644)
|
return os.WriteFile(targetPath, []byte(newContent), 0644)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Error copying template files: %v\n", err)
|
return fmt.Errorf("error copying template files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if useGRPC {
|
||||||
|
if err := regenerateProto(destRoot, serviceName); err != nil {
|
||||||
|
return fmt.Errorf("error regenerating proto: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate main.go from template
|
// Generate main.go from template
|
||||||
return genFile(
|
return genFile(
|
||||||
"templates/main.go.tpl",
|
"templates/main.go.tpl",
|
||||||
"main",
|
"main",
|
||||||
filepath.Join(projectDir, "cmd/api", "main.go"),
|
filepath.Join(destRoot, "cmd/api", "main.go"),
|
||||||
MainTemplateData{
|
MainTemplateData{
|
||||||
ModuleName: moduleName,
|
ModuleName: moduleName,
|
||||||
|
ServiceName: serviceName,
|
||||||
UseGRPC: useGRPC,
|
UseGRPC: useGRPC,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func regenerateProto(projectDir, serviceName string) error {
|
||||||
|
protoPath := filepath.Join(projectDir, "proto", fmt.Sprintf("service_%s.proto", oldModuleName))
|
||||||
|
content, err := os.ReadFile(protoPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading service proto: %w", err)
|
||||||
|
}
|
||||||
|
newContent := strings.Replace(string(content), "service AppTmpl {", "service "+serviceName+" {", 1)
|
||||||
|
if err := os.WriteFile(protoPath, []byte(newContent), 0644); err != nil {
|
||||||
|
return fmt.Errorf("error rewriting service proto: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protoFiles, err := filepath.Glob(filepath.Join(projectDir, "proto", "*.proto"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error globbing proto files: %w", err)
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
"--proto_path=proto",
|
||||||
|
"--go_out=pb", "--go_opt=paths=source_relative",
|
||||||
|
"--go-grpc_out=pb", "--go-grpc_opt=paths=source_relative",
|
||||||
|
"--grpc-gateway_out=pb", "--grpc-gateway_opt=paths=source_relative",
|
||||||
|
"--openapiv2_out=docs/swagger", "--openapiv2_opt=allow_merge=true,merge_file_name=" + filepath.Base(projectDir),
|
||||||
|
}
|
||||||
|
for _, f := range protoFiles {
|
||||||
|
rel, _ := filepath.Rel(projectDir, f)
|
||||||
|
args = append(args, rel)
|
||||||
|
}
|
||||||
|
if err := runCommand(projectDir, "protoc", args...); err != nil {
|
||||||
|
return fmt.Errorf("error running protoc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCommand(projectDir, "statik", "-src=./docs/swagger", "-dest=./docs")
|
||||||
|
}
|
||||||
|
|
||||||
func installTools(useGrpc bool) error {
|
func installTools(useGrpc bool) error {
|
||||||
tools := []string{
|
tools := []string{
|
||||||
"github.com/air-verse/air@latest",
|
"github.com/air-verse/air@latest",
|
||||||
@@ -170,7 +165,7 @@ func installTools(useGrpc bool) error {
|
|||||||
|
|
||||||
for _, tool := range tools {
|
for _, tool := range tools {
|
||||||
if err := runCommand("", "go", "install", tool); err != nil {
|
if err := runCommand("", "go", "install", tool); err != nil {
|
||||||
return fmt.Errorf("error installing tool %s: %v", tool, err)
|
return fmt.Errorf("error installing tool %s: %w", tool, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import "gitea.fernandovideira.com/FernandoJVideira/keystone/cmd"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cmd.Execute()
|
|
||||||
}
|
|
||||||
+396
@@ -0,0 +1,396 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := pnpmVersion()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error detecting pnpm version: %w", err)
|
||||||
|
}
|
||||||
|
replacements := map[string]string{
|
||||||
|
"App Template": serviceName,
|
||||||
|
// The overlay's Dockerfile pins the pnpm that produced the template's
|
||||||
|
// lockfile; the generated project's lockfile comes from the local pnpm.
|
||||||
|
"[email protected]": "pnpm@" + version,
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
); 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()],
|
||||||
|
// vue-ui-kit ships raw .vue/.ts source. Left to Vite's dependency
|
||||||
|
// pre-bundler, its index.ts gets bundled with reka-ui inlined while its
|
||||||
|
// .vue files import reka-ui from source - two module instances, so
|
||||||
|
// reka-ui's provide/inject contexts (Dialog, Tabs, Select...) can't find
|
||||||
|
// each other. Excluding it keeps every import on the one reka-ui.
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@fernandojvideira/vue-ui-kit'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
// /login and /signup are the two tabs of one auth card, served by the
|
||||||
|
// same page file. A second route record (not an alias - the router
|
||||||
|
// would treat switching between a route and its alias as a duplicate
|
||||||
|
// navigation and drop it) lets the tab change the URL while NuxtPage
|
||||||
|
// keeps the same component instance thanks to the page's fixed key.
|
||||||
|
'pages:extend'(pages) {
|
||||||
|
const login = pages.find((page) => page.path === '/login')
|
||||||
|
if (login) pages.push({ ...login, name: 'signup', path: '/signup' })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
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 insidePnpmWorkspace(dir string) bool {
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "pnpm-workspace.yaml")); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// A standalone client is its own workspace root and needs vue-demi's build
|
||||||
|
// script approved. Inside a monorepo the root pnpm-workspace.yaml already
|
||||||
|
// does that - and writing one here would make apps/client a separate
|
||||||
|
// workspace, so the installs below would update a nested lockfile and
|
||||||
|
// leave the root one describing the template's client instead of this one.
|
||||||
|
if !insidePnpmWorkspace(parentDir) {
|
||||||
|
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,
|
||||||
|
// The overlay's Dockerfile pins the pnpm that produced the template's
|
||||||
|
// lockfile; the generated project's lockfile comes from the local pnpm.
|
||||||
|
"[email protected]": "pnpm@" + version,
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// create-vue's config ends with the resolve.alias block; hang the
|
||||||
|
// optimizeDeps exclusion off its closing brace.
|
||||||
|
newContent = strings.Replace(newContent,
|
||||||
|
" '@': fileURLToPath(new URL('./src', import.meta.url)),\n },\n },\n})",
|
||||||
|
" '@': fileURLToPath(new URL('./src', import.meta.url)),\n },\n },\n"+viteOptimizeDepsExclude+"})",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if newContent == string(content) || !strings.Contains(newContent, "optimizeDeps") {
|
||||||
|
return fmt.Errorf("could not find expected anchors in vite.config.ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(configPath, []byte(newContent), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vue-ui-kit ships raw .vue/.ts source. Left to Vite's dependency pre-bundler,
|
||||||
|
// its index.ts gets bundled with reka-ui inlined while its .vue files import
|
||||||
|
// reka-ui from source - two module instances, so reka-ui's provide/inject
|
||||||
|
// contexts (Dialog, Tabs, Select...) can't find each other. Excluding it keeps
|
||||||
|
// every import on the one reka-ui.
|
||||||
|
const viteOptimizeDepsExclude = ` optimizeDeps: {
|
||||||
|
exclude: ['@fernandojvideira/vue-ui-kit'],
|
||||||
|
},
|
||||||
|
`
|
||||||
|
|
||||||
|
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
@@ -23,7 +23,7 @@ func init() {
|
|||||||
var handlerCmd = &cobra.Command{
|
var handlerCmd = &cobra.Command{
|
||||||
Use: "handler",
|
Use: "handler",
|
||||||
Short: "Create a new handler",
|
Short: "Create a new handler",
|
||||||
Long: `Create a new handler for your Keystone project.`,
|
Long: `Create a new handler for your Draft project.`,
|
||||||
Run: genHandler,
|
Run: genHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+171
-2
@@ -5,13 +5,18 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"gitea.fernandovideira.com/FernandoJVideira/keystone/assets"
|
"git.fernandovideira.com/FernandoJVideira/draft/assets"
|
||||||
|
"github.com/manifoldco/promptui"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,7 +27,7 @@ func randomString(length int) string {
|
|||||||
s, r := make([]rune, length), []rune(chars)
|
s, r := make([]rune, length), []rune(chars)
|
||||||
|
|
||||||
for i := range s {
|
for i := range s {
|
||||||
p, _ := rand.Prime(rand.Reader, len(r))
|
p, _ := rand.Int(rand.Reader, big.NewInt(int64(len(r))))
|
||||||
x, y := p.Uint64(), uint64(len(r))
|
x, y := p.Uint64(), uint64(len(r))
|
||||||
s[i] = r[x%y]
|
s[i] = r[x%y]
|
||||||
}
|
}
|
||||||
@@ -98,3 +103,167 @@ func runCommand(dir, name string, args ...string) error {
|
|||||||
|
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func commandExists(name string) bool {
|
||||||
|
_, err := exec.LookPath(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requirePnpmAndNpx() error {
|
||||||
|
if !commandExists("pnpm") {
|
||||||
|
return fmt.Errorf("pnpm is required but not found on PATH")
|
||||||
|
}
|
||||||
|
if !commandExists("npx") {
|
||||||
|
return fmt.Errorf("npx is required but not found on PATH")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptClientFramework() (isVue bool, resultLabel string, err error) {
|
||||||
|
prompt := promptui.Select{
|
||||||
|
Label: "Select Client Framework",
|
||||||
|
Items: []string{"Nuxt", "Vue"},
|
||||||
|
}
|
||||||
|
_, result, err := prompt.Run()
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
return result == "Vue", result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pnpmVersion() (string, error) {
|
||||||
|
out, err := exec.Command("pnpm", "--version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error getting pnpm version: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPascalCase(s string) string {
|
||||||
|
parts := regexp.MustCompile(`[^a-zA-Z0-9]+`).Split(s, -1)
|
||||||
|
var b strings.Builder
|
||||||
|
for _, p := range parts {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(strings.ToUpper(p[:1]))
|
||||||
|
b.WriteString(p[1:])
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptProjectName() (moduleName, projectDir, serviceName string, err error) {
|
||||||
|
prompt := promptui.Prompt{Label: "Project Name"}
|
||||||
|
rawInput, err := prompt.Run()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
moduleName = strings.TrimSpace(rawInput)
|
||||||
|
projectDir = path.Base(strings.TrimSuffix(moduleName, "/"))
|
||||||
|
serviceName = toPascalCase(projectDir)
|
||||||
|
return moduleName, projectDir, serviceName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptAPIType() (useGRPC bool, resultLabel string, err error) {
|
||||||
|
prompt := promptui.Select{
|
||||||
|
Label: "Select API Type",
|
||||||
|
Items: []string{"HTTP (REST)", "gRPC"},
|
||||||
|
}
|
||||||
|
_, result, err := prompt.Run()
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
return strings.Contains(result, "gRPC"), result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyEmbeddedDir(root, destDir string, replacements map[string]string) error {
|
||||||
|
return fs.WalkDir(assets.EmbedFS, root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, _ := filepath.Rel(root, path)
|
||||||
|
contentBytes, err := assets.EmbedFS.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading embedded file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(contentBytes)
|
||||||
|
for old, new := range replacements {
|
||||||
|
content = strings.ReplaceAll(content, old, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPath := filepath.Join(destDir, relPath)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("error creating directory %s: %w", filepath.Dir(targetPath), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(targetPath, []byte(content), 0644)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type scaffoldRecipe struct {
|
||||||
|
label string
|
||||||
|
destSubdir string
|
||||||
|
preScaffold func(projectDir string) error
|
||||||
|
postScaffold func(projectDir string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNewProject(recipe scaffoldRecipe) {
|
||||||
|
moduleName, projectDir, serviceName, err := promptProjectName()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Prompt failed %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useGRPC, resultLabel, err := promptAPIType()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Prompt failed %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if useGRPC && !commandExists("protoc") {
|
||||||
|
fmt.Println("❌ protoc is required for gRPC projects but wasn't found on PATH.")
|
||||||
|
fmt.Println(" Install it (e.g. `brew install protobuf`) and try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("🚀 Scaffolding %s '%s' as %s...\n", recipe.label, moduleName, resultLabel)
|
||||||
|
|
||||||
|
if recipe.preScaffold != nil {
|
||||||
|
if err := recipe.preScaffold(projectDir); err != nil {
|
||||||
|
fmt.Printf("error preparing project: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scaffoldAPI(projectDir, recipe.destSubdir, moduleName, serviceName, useGRPC); err != nil {
|
||||||
|
fmt.Printf("error scaffolding project: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("📦 Installing tools...")
|
||||||
|
if err := installTools(useGRPC); err != nil {
|
||||||
|
fmt.Printf("error installing tools: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("🧹 Tidying up Go modules...")
|
||||||
|
destRoot := filepath.Join(projectDir, recipe.destSubdir)
|
||||||
|
if err := runCommand(destRoot, "go", "mod", "tidy"); err != nil {
|
||||||
|
fmt.Printf("error running 'go mod tidy': %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if recipe.postScaffold != nil {
|
||||||
|
if err := recipe.postScaffold(projectDir); err != nil {
|
||||||
|
fmt.Printf("error finishing project: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✅ Done!")
|
||||||
|
}
|
||||||
+14
-20
@@ -6,32 +6,26 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
const defaultKeySize = 32
|
||||||
newCmd.AddCommand(keyCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var size int
|
var keySize int
|
||||||
var defaultSize = 32
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(keyCmd)
|
||||||
|
keyCmd.Flags().IntVar(&keySize, "size", defaultKeySize, "Length of the generated key")
|
||||||
|
}
|
||||||
|
|
||||||
var keyCmd = &cobra.Command{
|
var keyCmd = &cobra.Command{
|
||||||
Use: "key [size]",
|
Use: "key",
|
||||||
Short: "Generate a new cryptographic key",
|
Short: "Generate a new cryptographic key",
|
||||||
Long: `Generate a new cryptographic key for use in Keystone projects.`,
|
Long: `Generate a new cryptographic key for use in Draft projects.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
var keyLength int
|
if cmd.Flags().Changed("size") && keySize < defaultKeySize {
|
||||||
if cmd.Flags().Lookup("size").Changed {
|
fmt.Printf("❌ Key size must be at least %d for adequate security.\n", defaultKeySize)
|
||||||
keyLength = size
|
return
|
||||||
} else {
|
|
||||||
keyLength = defaultSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyLength <= 32 {
|
key := randomString(keySize)
|
||||||
fmt.Println("Warning: It is recommended to use a key size greater than 32 for better security.")
|
fmt.Println("Generated Key:", key)
|
||||||
fmt.Println("Generating a key of default size:", defaultSize)
|
|
||||||
keyLength = defaultSize
|
|
||||||
}
|
|
||||||
|
|
||||||
key := randomString(keyLength)
|
|
||||||
println("Generated Key:", key)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -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
@@ -1,14 +1,17 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/manifoldco/promptui"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
const oldModuleName = "github.com/FernandoJVideira/simple_bank"
|
const oldModuleName = "app_tmpl"
|
||||||
const srcDir = "embedded/api_tmpl"
|
|
||||||
|
|
||||||
type MainTemplateData struct {
|
type MainTemplateData struct {
|
||||||
ModuleName string
|
ModuleName string
|
||||||
|
ServiceName string
|
||||||
UseGRPC bool
|
UseGRPC bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +21,31 @@ func init() {
|
|||||||
|
|
||||||
var newCmd = &cobra.Command{
|
var newCmd = &cobra.Command{
|
||||||
Use: "new",
|
Use: "new",
|
||||||
Short: "Create a new Keystone project",
|
Short: "Create a new Draft project",
|
||||||
Long: `Create a new Keystone project with the necessary structure and files.`,
|
Long: `Create a new Draft project with the necessary structure and files.`,
|
||||||
Run: genProject,
|
Run: genProject,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func genProject(cmd *cobra.Command, args []string) {
|
||||||
|
projType := promptui.Select{
|
||||||
|
Label: "Select Project Type",
|
||||||
|
Items: []string{"API", "Client", "Full Stack"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, result, err := projType.Run()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Prompt failed %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case "API":
|
||||||
|
genAPI(cmd, args)
|
||||||
|
case "Client":
|
||||||
|
genClient(cmd, args)
|
||||||
|
case "Full Stack":
|
||||||
|
genMonorepo(cmd, args)
|
||||||
|
default:
|
||||||
|
fmt.Println("Invalid selection")
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -8,9 +8,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "keystone",
|
Use: "draft",
|
||||||
Short: "Keystone is a CLI tool for bootstrapping Go APIs",
|
Short: "Draft is a CLI tool for bootstrapping Go APIs, clients, and monorepos",
|
||||||
Long: `Keystone is a CLI tool built by LKResuts for internal usage to bootstrap Go APIs`,
|
Long: `Draft is a CLI tool built by LKResuts for internal usage to bootstrap Go APIs, Nuxt/Vue clients, and full-stack monorepos combining both`,
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
|
|||||||
+1
-3
@@ -24,7 +24,7 @@ type ServiceTemplateData struct {
|
|||||||
var serviceCmd = &cobra.Command{
|
var serviceCmd = &cobra.Command{
|
||||||
Use: "service",
|
Use: "service",
|
||||||
Short: "Create a new service",
|
Short: "Create a new service",
|
||||||
Long: `Create a new service for your Keystone project.`,
|
Long: `Create a new service for your Draft project.`,
|
||||||
Run: genService,
|
Run: genService,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +45,6 @@ func genService(cmd *cobra.Command, args []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
print(moduleName)
|
|
||||||
|
|
||||||
serviceName := strings.ToLower(strings.TrimSpace(serviceNameInput))
|
serviceName := strings.ToLower(strings.TrimSpace(serviceNameInput))
|
||||||
cases := cases.Title(language.English)
|
cases := cases.Title(language.English)
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -6,7 +6,6 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default version (we will change this dynamically later!)
|
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -15,9 +14,9 @@ func init() {
|
|||||||
|
|
||||||
var versionCmd = &cobra.Command{
|
var versionCmd = &cobra.Command{
|
||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Display the version of Keystone",
|
Short: "Display the version of Draft",
|
||||||
Long: `Display the current version of the Keystone CLI tool.`,
|
Long: `Display the current version of the Draft CLI tool.`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
fmt.Printf("Keystone CLI version: %s\n", version)
|
fmt.Printf("Draft CLI version: %s\n", version)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module gitea.fernandovideira.com/FernandoJVideira/keystone
|
module git.fernandovideira.com/FernandoJVideira/draft
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "git.fernandovideira.com/FernandoJVideira/draft/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+49
@@ -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!"
|
||||||
+97
-34
@@ -3,67 +3,130 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sourceDir = "assets/testdata/api_tmpl"
|
oldModuleName = "app_tmpl"
|
||||||
targetDir = "assets/embedded"
|
|
||||||
oldModuleName = "github.com/FernandoJVideira/simple_bank"
|
|
||||||
moduleTemplate = "{{ .ModuleName }}"
|
moduleTemplate = "{{ .ModuleName }}"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
type syncJob struct {
|
||||||
log.Println("Syncing templates...")
|
templateName string // optional, for logging purposes
|
||||||
syncTemplates()
|
sourceDir string
|
||||||
|
targetDir string
|
||||||
|
excludePrefix string // relative-path prefix to skip, e.g. "apps/"; empty = sync everything
|
||||||
|
includePrefixes []string // if set, only paths with one of these prefixes sync; empty = sync everything
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncTemplates() {
|
var jobs = []syncJob{
|
||||||
|
{
|
||||||
|
templateName: "api",
|
||||||
|
sourceDir: "assets/testdata/template/apps/api",
|
||||||
|
targetDir: "assets/embedded/api",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateName: "monorepo-root",
|
||||||
|
sourceDir: "assets/testdata/template",
|
||||||
|
targetDir: "assets/embedded/monorepo-root",
|
||||||
|
excludePrefix: "apps/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateName: "client-nuxt",
|
||||||
|
sourceDir: "assets/testdata/template/apps/client",
|
||||||
|
targetDir: "assets/embedded/client-nuxt",
|
||||||
|
includePrefixes: []string{"app/", "public/", "Dockerfile"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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/",
|
||||||
|
"src/views/",
|
||||||
|
"src/shared/",
|
||||||
|
".env.example",
|
||||||
|
"Dockerfile",
|
||||||
|
"nginx.conf.template",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
err := filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, err error) error {
|
func main() {
|
||||||
|
log.Println("Syncing templates...")
|
||||||
|
for _, job := range jobs {
|
||||||
|
syncTemplates(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncTemplates(job syncJob) {
|
||||||
|
|
||||||
|
err := os.RemoveAll(job.targetDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Fatalf("Error removing target directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
relPath, _ := filepath.Rel(sourceDir, path)
|
cmd := exec.Command("git", "-C", job.sourceDir, "ls-files")
|
||||||
destPath := filepath.Join(targetDir, relPath)
|
output, err := cmd.Output()
|
||||||
|
|
||||||
if strings.HasPrefix(d.Name(), ".") && d.Name() != "." {
|
|
||||||
if d.IsDir() {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if d.IsDir() {
|
|
||||||
return os.MkdirAll(destPath, 0755)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
log.Fatalf("Error listing files in source directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.Name() == "go.mod" || d.Name() == "go.sum" {
|
paths := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||||
|
|
||||||
|
for _, relPath := range paths {
|
||||||
|
|
||||||
|
if len(job.includePrefixes) > 0 {
|
||||||
|
included := false
|
||||||
|
for _, prefix := range job.includePrefixes {
|
||||||
|
if strings.HasPrefix(relPath, prefix) {
|
||||||
|
included = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !included {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if job.excludePrefix != "" && strings.HasPrefix(relPath, job.excludePrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
srcPath := filepath.Join(job.sourceDir, relPath)
|
||||||
|
destPath := filepath.Join(job.targetDir, relPath)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||||
|
log.Fatalf("Error creating destination directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error reading source file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filepath.Base(relPath) == "go.mod" || filepath.Base(relPath) == "go.sum" {
|
||||||
destPath += ".tmpl"
|
destPath += ".tmpl"
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.Name() == "go.mod" {
|
if filepath.Base(relPath) == "go.mod" {
|
||||||
strContent := string(content)
|
strContent := string(content)
|
||||||
strContent = strings.Replace(strContent, oldModuleName, moduleTemplate, 1)
|
strContent = strings.Replace(strContent, oldModuleName, moduleTemplate, 1)
|
||||||
content = []byte(strContent)
|
content = []byte(strContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(destPath, content, 0644)
|
if err := os.WriteFile(destPath, content, 0644); err != nil {
|
||||||
})
|
log.Fatalf("Error writing to destination file: %v", err)
|
||||||
|
}
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error walking source: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Templates synced successfully!")
|
log.Printf("Template %s synced successfully!", job.templateName)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user