37 Commits
Author SHA1 Message Date
FernandoJVideira e36c0b3355 Merge pull request 'Bump template submodule to vue-ui-kit 0.5.0' (#13) from chore/template-vue-ui-kit-0.5.0 into main
Reviewed-on: #13
2026-10-11 20:44:31 +00:00
FernandoJVideiraandClaude Sonnet 5.5 5463e527eb chore: bump template submodule to vue-ui-kit 0.5.0
Picks up project-template #7: generated projects use @fernandojvideira/vue-ui-kit
^0.5.0 and drop the stale minimumReleaseAge exclusions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 21:43:30 +01:00
FernandoJVideira 7285594f3c Merge pull request 'Derive the build version from the git tag' (#12) from fix/version-from-git-tag into main
Reviewed-on: #12
2026-10-08 23:11:42 +00:00
FernandoJVideiraandClaude Sonnet 5.5 ab322b7d91 Derive the build version from the git tag
The version was a hand-edited constant in the Makefile, so tagging a release
without first committing the bump produced a release whose binaries disagree
with it: v0.2.1 was tagged on a commit still saying VERSION := v0.2.0, and
all four binaries attached to that release print v0.2.0 from `draft version`.

VERSION now comes from `git describe --tags --always`: exactly the tag on a
tagged commit (v0.2.1), tag-plus-distance after it (v0.2.1-3-g1a2b3c4), `dev`
outside a git checkout such as a source tarball. `make build VERSION=...`
still overrides it. There is nothing left to bump before tagging.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-09 00:08:56 +01:00
FernandoJVideira 8a11de5b0f Merge pull request 'Scaffold generated clients with @lucide/vue' (#11) from chore/lucide-vue into main
Reviewed-on: #11
2026-10-08 22:55:31 +00:00
FernandoJVideiraandClaude Sonnet 5.5 b7fe8f3f4e Scaffold generated clients with @lucide/vue
The template overlay now imports its icons from @lucide/vue (project-template#6),
so the scaffolder has to install that instead of the deprecated
lucide-vue-next: pnpm doesn't hoist the kit's own copy to the app, and a
fresh project would fail to resolve its icon imports. No version is pinned
here - these stay unpinned `pnpm add` calls, so the template remains the
version authority.

Submodule bumped to that commit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-08 23:54:00 +01:00
FernandoJVideira 0bd1550779 Merge pull request 'Generated clients: exclude vue-ui-kit from Vite pre-bundling, /signup route' (#10) from fix/generated-client-configs into main
Reviewed-on: #10
2026-09-16 12:58:56 +00:00
FernandoJVideiraandClaude Fable 5.1 1dcc135094 Generated clients: exclude vue-ui-kit from Vite pre-bundling, /signup route
Generated projects never got the optimizeDeps exclusion the template's
own configs carry - the Nuxt config is patched from nuxi's output and the
Vue one from create-vue's - so Vite bundled vue-ui-kit's index.ts with
reka-ui inlined while the kit's .vue files imported reka-ui from source.
Two module instances, mismatched provide/inject Symbols: a fresh Vue
client rendered a blank /login ("Injection Symbol(TabsRootContext) not
found") and a fresh Nuxt one crashed SSR on any page with a Dialog. Both
config patches now add the exclusion.

The Nuxt patch also adds the pages:extend hook that gives pages/login.vue
a second /signup record, matching the template's auth card whose tabs now
drive the URL (project-template#5). Submodule bumped to that commit.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-16 13:58:23 +01:00
FernandoJVideira 39678f3ea6 Merge pull request 'Make generated Vue monorepos buildable with docker compose' (#9) from fix/vue-monorepo-docker into main
Reviewed-on: #9
2026-09-16 12:27:07 +00:00
FernandoJVideiraandClaude Fable 5.1 230037c5f5 Make generated Vue monorepos buildable with docker compose
Running `draft new monorepo` with the Vue client and then
`docker compose build client` failed three separate ways, each found by
actually doing it:

- No Dockerfile: the Vue overlay never shipped one. The template now has
  an nginx-based image (project-template#4) and the client-vue sync job
  embeds it plus its nginx.conf.template, with the pnpm pin rewritten to
  the local version like the Nuxt overlay.
- Stale root lockfile: the Vue scaffold wrote a pnpm-workspace.yaml into
  apps/client (to approve vue-demi's build script), which made it its own
  workspace, so the installs updated a nested lockfile and the root one
  kept describing the template's Nuxt client - pnpm install
  --frozen-lockfile then refused it. The file is only written when the
  client isn't already inside a workspace (standalone `draft new client`).
- Missing component: the client-vue job listed components by name, so the
  AuthCard.vue that Login/Signup import was never embedded. It now embeds
  src/components/ as a whole.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-16 13:22:37 +01:00
FernandoJVideira 1195fdacb5 Merge pull request 'v0.2.0: ship the client Dockerfile with generated Nuxt apps' (#8) from chore/v0.2.0 into main
Reviewed-on: #8
2026-09-16 12:09:31 +00:00
FernandoJVideiraandClaude Fable 5.1 2f1ad93a74 Ship the client Dockerfile with generated Nuxt apps; bump to v0.2.0
A generated monorepo's docker-compose.yml already built apps/client from
apps/client/Dockerfile, but the client overlay only embedded app/ and
public/, so the file never existed and `docker compose build client`
failed on every fresh project. The client-nuxt sync job now embeds the
template's Dockerfile too, and the Nuxt scaffold rewrites its pnpm pin to
the locally detected version - the one that produces the generated
project's lockfile - the same way it already stamps packageManager.

Bumps the submodule to the template commit that mounts ~/.npmrc as a
build secret for the registry token, and the Makefile VERSION to v0.2.0
for the release.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-16 13:08:18 +01:00
FernandoJVideira b3ea1013bc Merge pull request 'Release: vue-ui-kit client scaffolding' (#7) from dev into main
Reviewed-on: #7
2026-09-16 11:57:52 +00:00
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 4806186ae6 Merge pull request 'Dev' (#5) from dev into main
Reviewed-on: #5
2026-08-25 21:03:41 +00: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 dff28b1a4c Merge pull request 'Version 0.1.0' (#3) from dev into main
Reviewed-on: #3
2026-08-25 20:42:49 +00: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 1135 additions and 212 deletions

No files matched your search

+2 -1
View File
@@ -30,4 +30,5 @@ go.work.sum
.env .env
# Binary # Binary
/basalt /draft*
prepare-embed-stamp
+4 -3
View File
@@ -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
+26 -23
View File
@@ -1,31 +1,34 @@
PKG_NAME="gitea.fernandovideira.com/FernandoJVideira/keystone" PKG_NAME := git.fernandovideira.com/FernandoJVideira/draft
# Taken from the git tag, so a build can never disagree with the release it was
# cut from: exactly `v0.2.1` on that tag, `v0.2.1-3-g1a2b3c4` three commits
# later, `dev` outside a git checkout (e.g. a source tarball). Override with
# `make build VERSION=v1.2.3`.
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
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
+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 ## 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
View File
@@ -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
+64 -23
View File
@@ -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 Submodule
+1
Submodule assets/testdata/template added at d4d5fead4b.
+61 -66
View File
@@ -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,
UseGRPC: useGRPC, 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 { 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)
} }
} }
-7
View File
@@ -1,7 +0,0 @@
package main
import "gitea.fernandovideira.com/FernandoJVideira/keystone/cmd"
func main() {
cmd.Execute()
}
+396
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}, },
} }
+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)
},
})
}
+33 -6
View File
@@ -1,15 +1,18 @@
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
UseGRPC bool ServiceName string
UseGRPC bool
} }
func init() { func init() {
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
module gitea.fernandovideira.com/FernandoJVideira/keystone module git.fernandovideira.com/FernandoJVideira/draft
go 1.25.5 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!"
+93 -30
View File
@@ -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() {
if err != nil { log.Println("Syncing templates...")
return err for _, job := range jobs {
} syncTemplates(job)
}
}
relPath, _ := filepath.Rel(sourceDir, path) func syncTemplates(job syncJob) {
destPath := filepath.Join(targetDir, relPath)
if strings.HasPrefix(d.Name(), ".") && d.Name() != "." { err := os.RemoveAll(job.targetDir)
if d.IsDir() { if err != nil {
return filepath.SkipDir log.Fatalf("Error removing target directory: %v", err)
}
cmd := exec.Command("git", "-C", job.sourceDir, "ls-files")
output, err := cmd.Output()
if err != nil {
log.Fatalf("Error listing files in source directory: %v", err)
}
paths := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, relPath := range paths {
if len(job.includePrefixes) > 0 {
included := false
for _, prefix := range job.includePrefixes {
if strings.HasPrefix(relPath, prefix) {
included = true
break
}
} }
return nil if !included {
continue
}
} else if job.excludePrefix != "" && strings.HasPrefix(relPath, job.excludePrefix) {
continue
} }
if d.IsDir() { srcPath := filepath.Join(job.sourceDir, relPath)
return os.MkdirAll(destPath, 0755) destPath := filepath.Join(job.targetDir, relPath)
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
log.Fatalf("Error creating destination directory: %v", err)
} }
content, err := os.ReadFile(path) content, err := os.ReadFile(srcPath)
if err != nil { if err != nil {
return err log.Fatalf("Error reading source file: %v", err)
} }
if d.Name() == "go.mod" || d.Name() == "go.sum" { if filepath.Base(relPath) == "go.mod" || filepath.Base(relPath) == "go.sum" {
destPath += ".tmpl" 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)
} }