diff --git a/.agents/skills/find-skills/SKILL.md b/.agents/skills/find-skills/SKILL.md
new file mode 100644
index 00000000..a41bdd07
--- /dev/null
+++ b/.agents/skills/find-skills/SKILL.md
@@ -0,0 +1,141 @@
+---
+name: find-skills
+description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
+---
+
+# Find Skills
+
+This skill helps you discover and install skills from the open agent skills ecosystem.
+
+## When to Use This Skill
+
+Use this skill when the user:
+
+- Asks "how do I do X" where X might be a common task with an existing skill
+- Says "find a skill for X" or "is there a skill for X"
+- Asks "can you do X" where X is a specialized capability
+- Expresses interest in extending agent capabilities
+- Wants to search for tools, templates, or workflows
+- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
+
+## What is the Skills CLI?
+
+The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
+
+**Key commands:**
+
+- `npx skills find [query] [--owner ]` - Search for skills interactively or by keyword, optionally scoped to a GitHub owner
+- `npx skills add ` - Install a skill from GitHub or other sources
+- `npx skills update` - Update all installed skills
+
+**Browse skills at:** https://skills.sh/
+
+## How to Help Users Find Skills
+
+### Step 1: Understand What They Need
+
+When a user asks for help with something, identify:
+
+1. The domain (e.g., React, testing, design, deployment)
+2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
+3. Whether this is a common enough task that a skill likely exists
+
+### Step 2: Check the Leaderboard First
+
+Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
+
+For example, top skills for web development include:
+- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
+- `anthropics/skills` — Frontend design, document processing (100K+ installs)
+
+### Step 3: Search for Skills
+
+If the leaderboard doesn't cover the user's need, run the find command:
+
+```bash
+npx skills find [query] [--owner ]
+```
+
+For example:
+
+- User asks "how do I make my React app faster?" → `npx skills find react performance`
+- User asks "can you help me with PR reviews?" → `npx skills find pr review`
+- User asks "I need to create a changelog" → `npx skills find changelog`
+
+### Step 4: Verify Quality Before Recommending
+
+**Do not recommend a skill based solely on search results.** Always verify:
+
+1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
+2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
+3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
+
+### Step 5: Present Options to the User
+
+When you find relevant skills, present them to the user with:
+
+1. The skill name and what it does
+2. The install count and source
+3. The install command they can run
+4. A link to learn more at skills.sh
+
+Example response:
+
+```
+I found a skill that might help! The "react-best-practices" skill provides
+React and Next.js performance optimization guidelines from Vercel Engineering.
+(185K installs)
+
+To install it:
+npx skills add vercel-labs/agent-skills@react-best-practices
+
+Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
+```
+
+### Step 6: Offer to Install
+
+If the user wants to proceed, you can install the skill for them:
+
+```bash
+npx skills add -g -y
+```
+
+The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
+
+## Common Skill Categories
+
+When searching, consider these common categories:
+
+| Category | Example Queries |
+| --------------- | ---------------------------------------- |
+| Web Development | react, nextjs, typescript, css, tailwind |
+| Testing | testing, jest, playwright, e2e |
+| DevOps | deploy, docker, kubernetes, ci-cd |
+| Documentation | docs, readme, changelog, api-docs |
+| Code Quality | review, lint, refactor, best-practices |
+| Design | ui, ux, design-system, accessibility |
+| Productivity | workflow, automation, git |
+
+## Tips for Effective Searches
+
+1. **Use specific keywords**: "react testing" is better than just "testing"
+2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
+3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
+
+## When No Skills Are Found
+
+If no relevant skills exist:
+
+1. Acknowledge that no existing skill was found
+2. Offer to help with the task directly using your general capabilities
+3. Suggest the user could create their own skill with `npx skills init`
+
+Example:
+
+```
+I searched for skills related to "xyz" but didn't find any matches.
+I can still help you with this task directly! Would you like me to proceed?
+
+If this is something you do often, you could create your own skill:
+npx skills init my-xyz-skill
+```
diff --git a/.agents/skills/golang-patterns/SKILL.md b/.agents/skills/golang-patterns/SKILL.md
new file mode 100644
index 00000000..85e4b3f7
--- /dev/null
+++ b/.agents/skills/golang-patterns/SKILL.md
@@ -0,0 +1,676 @@
+---
+name: golang-patterns
+description: Idiomatic Go patterns, best practices, and conventions for building robust, efficient, and maintainable Go applications. Use when writing or reviewing Go code and idiomatic structure or conventions are in question.
+metadata:
+ origin: ECC
+---
+
+# Go Development Patterns
+
+Idiomatic Go patterns and best practices for building robust, efficient, and maintainable applications.
+
+## When to Activate
+
+- Writing new Go code
+- Reviewing Go code
+- Refactoring existing Go code
+- Designing Go packages/modules
+
+## Core Principles
+
+### 1. Simplicity and Clarity
+
+Go favors simplicity over cleverness. Code should be obvious and easy to read.
+
+```go
+// Good: Clear and direct
+func GetUser(id string) (*User, error) {
+ user, err := db.FindUser(id)
+ if err != nil {
+ return nil, fmt.Errorf("get user %s: %w", id, err)
+ }
+ return user, nil
+}
+
+// Bad: Overly clever
+func GetUser(id string) (*User, error) {
+ return func() (*User, error) {
+ if u, e := db.FindUser(id); e == nil {
+ return u, nil
+ } else {
+ return nil, e
+ }
+ }()
+}
+```
+
+### 2. Make the Zero Value Useful
+
+Design types so their zero value is immediately usable without initialization.
+
+```go
+// Good: Zero value is useful
+type Counter struct {
+ mu sync.Mutex
+ count int // zero value is 0, ready to use
+}
+
+func (c *Counter) Inc() {
+ c.mu.Lock()
+ c.count++
+ c.mu.Unlock()
+}
+
+// Good: bytes.Buffer works with zero value
+var buf bytes.Buffer
+buf.WriteString("hello")
+
+// Bad: Requires initialization
+type BadCounter struct {
+ counts map[string]int // nil map will panic
+}
+```
+
+### 3. Accept Interfaces, Return Structs
+
+Functions should accept interface parameters and return concrete types.
+
+```go
+// Good: Accepts interface, returns concrete type
+func ProcessData(r io.Reader) (*Result, error) {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return nil, err
+ }
+ return &Result{Data: data}, nil
+}
+
+// Bad: Returns interface (hides implementation details unnecessarily)
+func ProcessData(r io.Reader) (io.Reader, error) {
+ // ...
+}
+```
+
+## Error Handling Patterns
+
+### Error Wrapping with Context
+
+```go
+// Good: Wrap errors with context
+func LoadConfig(path string) (*Config, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("load config %s: %w", path, err)
+ }
+
+ var cfg Config
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return nil, fmt.Errorf("parse config %s: %w", path, err)
+ }
+
+ return &cfg, nil
+}
+```
+
+### Custom Error Types
+
+```go
+// Define domain-specific errors
+type ValidationError struct {
+ Field string
+ Message string
+}
+
+func (e *ValidationError) Error() string {
+ return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
+}
+
+// Sentinel errors for common cases
+var (
+ ErrNotFound = errors.New("resource not found")
+ ErrUnauthorized = errors.New("unauthorized")
+ ErrInvalidInput = errors.New("invalid input")
+)
+```
+
+### Error Checking with errors.Is and errors.As
+
+```go
+func HandleError(err error) {
+ // Check for specific error
+ if errors.Is(err, sql.ErrNoRows) {
+ log.Println("No records found")
+ return
+ }
+
+ // Check for error type
+ var validationErr *ValidationError
+ if errors.As(err, &validationErr) {
+ log.Printf("Validation error on field %s: %s",
+ validationErr.Field, validationErr.Message)
+ return
+ }
+
+ // Unknown error
+ log.Printf("Unexpected error: %v", err)
+}
+```
+
+### Never Ignore Errors
+
+```go
+// Bad: Ignoring error with blank identifier
+result, _ := doSomething()
+
+// Good: Handle or explicitly document why it's safe to ignore
+result, err := doSomething()
+if err != nil {
+ return err
+}
+
+// Acceptable: When error truly doesn't matter (rare)
+_ = writer.Close() // Best-effort cleanup, error logged elsewhere
+```
+
+## Concurrency Patterns
+
+### Worker Pool
+
+```go
+func WorkerPool(jobs <-chan Job, results chan<- Result, numWorkers int) {
+ var wg sync.WaitGroup
+
+ for i := 0; i < numWorkers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for job := range jobs {
+ results <- process(job)
+ }
+ }()
+ }
+
+ wg.Wait()
+ close(results)
+}
+```
+
+### Context for Cancellation and Timeouts
+
+```go
+func FetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create request: %w", err)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetch %s: %w", url, err)
+ }
+ defer resp.Body.Close()
+
+ return io.ReadAll(resp.Body)
+}
+```
+
+### Graceful Shutdown
+
+```go
+func GracefulShutdown(server *http.Server) {
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+
+ <-quit
+ log.Println("Shutting down server...")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ if err := server.Shutdown(ctx); err != nil {
+ log.Fatalf("Server forced to shutdown: %v", err)
+ }
+
+ log.Println("Server exited")
+}
+```
+
+### errgroup for Coordinated Goroutines
+
+```go
+import "golang.org/x/sync/errgroup"
+
+func FetchAll(ctx context.Context, urls []string) ([][]byte, error) {
+ g, ctx := errgroup.WithContext(ctx)
+ results := make([][]byte, len(urls))
+
+ for i, url := range urls {
+ i, url := i, url // Capture loop variables
+ g.Go(func() error {
+ data, err := FetchWithTimeout(ctx, url)
+ if err != nil {
+ return err
+ }
+ results[i] = data
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ return nil, err
+ }
+ return results, nil
+}
+```
+
+### Avoiding Goroutine Leaks
+
+```go
+// Bad: Goroutine leak if context is cancelled
+func leakyFetch(ctx context.Context, url string) <-chan []byte {
+ ch := make(chan []byte)
+ go func() {
+ data, _ := fetch(url)
+ ch <- data // Blocks forever if no receiver
+ }()
+ return ch
+}
+
+// Good: Properly handles cancellation
+func safeFetch(ctx context.Context, url string) <-chan []byte {
+ ch := make(chan []byte, 1) // Buffered channel
+ go func() {
+ data, err := fetch(url)
+ if err != nil {
+ return
+ }
+ select {
+ case ch <- data:
+ case <-ctx.Done():
+ }
+ }()
+ return ch
+}
+```
+
+## Interface Design
+
+### Small, Focused Interfaces
+
+```go
+// Good: Single-method interfaces
+type Reader interface {
+ Read(p []byte) (n int, err error)
+}
+
+type Writer interface {
+ Write(p []byte) (n int, err error)
+}
+
+type Closer interface {
+ Close() error
+}
+
+// Compose interfaces as needed
+type ReadWriteCloser interface {
+ Reader
+ Writer
+ Closer
+}
+```
+
+### Define Interfaces Where They're Used
+
+```go
+// In the consumer package, not the provider
+package service
+
+// UserStore defines what this service needs
+type UserStore interface {
+ GetUser(id string) (*User, error)
+ SaveUser(user *User) error
+}
+
+type Service struct {
+ store UserStore
+}
+
+// Concrete implementation can be in another package
+// It doesn't need to know about this interface
+```
+
+### Optional Behavior with Type Assertions
+
+```go
+type Flusher interface {
+ Flush() error
+}
+
+func WriteAndFlush(w io.Writer, data []byte) error {
+ if _, err := w.Write(data); err != nil {
+ return err
+ }
+
+ // Flush if supported
+ if f, ok := w.(Flusher); ok {
+ return f.Flush()
+ }
+ return nil
+}
+```
+
+## Package Organization
+
+### Standard Project Layout
+
+```text
+myproject/
+├── cmd/
+│ └── myapp/
+│ └── main.go # Entry point
+├── internal/
+│ ├── handler/ # HTTP handlers
+│ ├── service/ # Business logic
+│ ├── repository/ # Data access
+│ └── config/ # Configuration
+├── pkg/
+│ └── client/ # Public API client
+├── api/
+│ └── v1/ # API definitions (proto, OpenAPI)
+├── testdata/ # Test fixtures
+├── go.mod
+├── go.sum
+└── Makefile
+```
+
+### Package Naming
+
+```go
+// Good: Short, lowercase, no underscores
+package http
+package json
+package user
+
+// Bad: Verbose, mixed case, or redundant
+package httpHandler
+package json_parser
+package userService // Redundant 'Service' suffix
+```
+
+### Avoid Package-Level State
+
+```go
+// Bad: Global mutable state
+var db *sql.DB
+
+func init() {
+ db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
+}
+
+// Good: Dependency injection
+type Server struct {
+ db *sql.DB
+}
+
+func NewServer(db *sql.DB) *Server {
+ return &Server{db: db}
+}
+```
+
+## Struct Design
+
+### Functional Options Pattern
+
+```go
+type Server struct {
+ addr string
+ timeout time.Duration
+ logger *log.Logger
+}
+
+type Option func(*Server)
+
+func WithTimeout(d time.Duration) Option {
+ return func(s *Server) {
+ s.timeout = d
+ }
+}
+
+func WithLogger(l *log.Logger) Option {
+ return func(s *Server) {
+ s.logger = l
+ }
+}
+
+func NewServer(addr string, opts ...Option) *Server {
+ s := &Server{
+ addr: addr,
+ timeout: 30 * time.Second, // default
+ logger: log.Default(), // default
+ }
+ for _, opt := range opts {
+ opt(s)
+ }
+ return s
+}
+
+// Usage
+server := NewServer(":8080",
+ WithTimeout(60*time.Second),
+ WithLogger(customLogger),
+)
+```
+
+### Embedding for Composition
+
+```go
+type Logger struct {
+ prefix string
+}
+
+func (l *Logger) Log(msg string) {
+ fmt.Printf("[%s] %s\n", l.prefix, msg)
+}
+
+type Server struct {
+ *Logger // Embedding - Server gets Log method
+ addr string
+}
+
+func NewServer(addr string) *Server {
+ return &Server{
+ Logger: &Logger{prefix: "SERVER"},
+ addr: addr,
+ }
+}
+
+// Usage
+s := NewServer(":8080")
+s.Log("Starting...") // Calls embedded Logger.Log
+```
+
+## Memory and Performance
+
+### Preallocate Slices When Size is Known
+
+```go
+// Bad: Grows slice multiple times
+func processItems(items []Item) []Result {
+ var results []Result
+ for _, item := range items {
+ results = append(results, process(item))
+ }
+ return results
+}
+
+// Good: Single allocation
+func processItems(items []Item) []Result {
+ results := make([]Result, 0, len(items))
+ for _, item := range items {
+ results = append(results, process(item))
+ }
+ return results
+}
+```
+
+### Use sync.Pool for Frequent Allocations
+
+```go
+var bufferPool = sync.Pool{
+ New: func() interface{} {
+ return new(bytes.Buffer)
+ },
+}
+
+func ProcessRequest(data []byte) []byte {
+ buf := bufferPool.Get().(*bytes.Buffer)
+ defer func() {
+ buf.Reset()
+ bufferPool.Put(buf)
+ }()
+
+ buf.Write(data)
+ // Process...
+ return buf.Bytes()
+}
+```
+
+### Avoid String Concatenation in Loops
+
+```go
+// Bad: Creates many string allocations
+func join(parts []string) string {
+ var result string
+ for _, p := range parts {
+ result += p + ","
+ }
+ return result
+}
+
+// Good: Single allocation with strings.Builder
+func join(parts []string) string {
+ var sb strings.Builder
+ for i, p := range parts {
+ if i > 0 {
+ sb.WriteString(",")
+ }
+ sb.WriteString(p)
+ }
+ return sb.String()
+}
+
+// Best: Use standard library
+func join(parts []string) string {
+ return strings.Join(parts, ",")
+}
+```
+
+## Go Tooling Integration
+
+### Essential Commands
+
+```bash
+# Build and run
+go build ./...
+go run ./cmd/myapp
+
+# Testing
+go test ./...
+go test -race ./...
+go test -cover ./...
+
+# Static analysis
+go vet ./...
+staticcheck ./...
+golangci-lint run
+
+# Module management
+go mod tidy
+go mod verify
+
+# Formatting
+gofmt -w .
+goimports -w .
+```
+
+### Recommended Linter Configuration (.golangci.yml)
+
+```yaml
+linters:
+ enable:
+ - errcheck
+ - gosimple
+ - govet
+ - ineffassign
+ - staticcheck
+ - unused
+ - gofmt
+ - goimports
+ - misspell
+ - unconvert
+ - unparam
+
+linters-settings:
+ errcheck:
+ check-type-assertions: true
+ govet:
+ enable:
+ - shadow
+
+issues:
+ exclude-use-default: false
+```
+
+## Quick Reference: Go Idioms
+
+| Idiom | Description |
+|-------|-------------|
+| Accept interfaces, return structs | Functions accept interface params, return concrete types |
+| Errors are values | Treat errors as first-class values, not exceptions |
+| Don't communicate by sharing memory | Use channels for coordination between goroutines |
+| Make the zero value useful | Types should work without explicit initialization |
+| A little copying is better than a little dependency | Avoid unnecessary external dependencies |
+| Clear is better than clever | Prioritize readability over cleverness |
+| gofmt is no one's favorite but everyone's friend | Always format with gofmt/goimports |
+| Return early | Handle errors first, keep happy path unindented |
+
+## Anti-Patterns to Avoid
+
+```go
+// Bad: Naked returns in long functions
+func process() (result int, err error) {
+ // ... 50 lines ...
+ return // What is being returned?
+}
+
+// Bad: Using panic for control flow
+func GetUser(id string) *User {
+ user, err := db.Find(id)
+ if err != nil {
+ panic(err) // Don't do this
+ }
+ return user
+}
+
+// Bad: Passing context in struct
+type Request struct {
+ ctx context.Context // Context should be first param
+ ID string
+}
+
+// Good: Context as first parameter
+func ProcessRequest(ctx context.Context, id string) error {
+ // ...
+}
+
+// Bad: Mixing value and pointer receivers
+type Counter struct{ n int }
+func (c Counter) Value() int { return c.n } // Value receiver
+func (c *Counter) Increment() { c.n++ } // Pointer receiver
+// Pick one style and be consistent
+```
+
+**Remember**: Go code should be boring in the best way - predictable, consistent, and easy to understand. When in doubt, keep it simple.
diff --git a/.agents/skills/golang-testing/SKILL.md b/.agents/skills/golang-testing/SKILL.md
new file mode 100644
index 00000000..45ca4871
--- /dev/null
+++ b/.agents/skills/golang-testing/SKILL.md
@@ -0,0 +1,721 @@
+---
+name: golang-testing
+description: Go testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage. Follows TDD methodology with idiomatic Go practices. Use when writing Go tests — table-driven cases, subtests, benchmarks, fuzzing, or coverage.
+metadata:
+ origin: ECC
+---
+
+# Go Testing Patterns
+
+Comprehensive Go testing patterns for writing reliable, maintainable tests following TDD methodology.
+
+## When to Activate
+
+- Writing new Go functions or methods
+- Adding test coverage to existing code
+- Creating benchmarks for performance-critical code
+- Implementing fuzz tests for input validation
+- Following TDD workflow in Go projects
+
+## TDD Workflow for Go
+
+### The RED-GREEN-REFACTOR Cycle
+
+```
+RED → Write a failing test first
+GREEN → Write minimal code to pass the test
+REFACTOR → Improve code while keeping tests green
+REPEAT → Continue with next requirement
+```
+
+### Step-by-Step TDD in Go
+
+```go
+// Step 1: Define the interface/signature
+// calculator.go
+package calculator
+
+func Add(a, b int) int {
+ panic("not implemented") // Placeholder
+}
+
+// Step 2: Write failing test (RED)
+// calculator_test.go
+package calculator
+
+import "testing"
+
+func TestAdd(t *testing.T) {
+ got := Add(2, 3)
+ want := 5
+ if got != want {
+ t.Errorf("Add(2, 3) = %d; want %d", got, want)
+ }
+}
+
+// Step 3: Run test - verify FAIL
+// $ go test
+// --- FAIL: TestAdd (0.00s)
+// panic: not implemented
+
+// Step 4: Implement minimal code (GREEN)
+func Add(a, b int) int {
+ return a + b
+}
+
+// Step 5: Run test - verify PASS
+// $ go test
+// PASS
+
+// Step 6: Refactor if needed, verify tests still pass
+```
+
+## Table-Driven Tests
+
+The standard pattern for Go tests. Enables comprehensive coverage with minimal code.
+
+```go
+func TestAdd(t *testing.T) {
+ tests := []struct {
+ name string
+ a, b int
+ expected int
+ }{
+ {"positive numbers", 2, 3, 5},
+ {"negative numbers", -1, -2, -3},
+ {"zero values", 0, 0, 0},
+ {"mixed signs", -1, 1, 0},
+ {"large numbers", 1000000, 2000000, 3000000},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := Add(tt.a, tt.b)
+ if got != tt.expected {
+ t.Errorf("Add(%d, %d) = %d; want %d",
+ tt.a, tt.b, got, tt.expected)
+ }
+ })
+ }
+}
+```
+
+### Table-Driven Tests with Error Cases
+
+```go
+func TestParseConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want *Config
+ wantErr bool
+ }{
+ {
+ name: "valid config",
+ input: `{"host": "localhost", "port": 8080}`,
+ want: &Config{Host: "localhost", Port: 8080},
+ },
+ {
+ name: "invalid JSON",
+ input: `{invalid}`,
+ wantErr: true,
+ },
+ {
+ name: "empty input",
+ input: "",
+ wantErr: true,
+ },
+ {
+ name: "minimal config",
+ input: `{}`,
+ want: &Config{}, // Zero value config
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := ParseConfig(tt.input)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Error("expected error, got nil")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("got %+v; want %+v", got, tt.want)
+ }
+ })
+ }
+}
+```
+
+## Subtests and Sub-benchmarks
+
+### Organizing Related Tests
+
+```go
+func TestUser(t *testing.T) {
+ // Setup shared by all subtests
+ db := setupTestDB(t)
+
+ t.Run("Create", func(t *testing.T) {
+ user := &User{Name: "Alice"}
+ err := db.CreateUser(user)
+ if err != nil {
+ t.Fatalf("CreateUser failed: %v", err)
+ }
+ if user.ID == "" {
+ t.Error("expected user ID to be set")
+ }
+ })
+
+ t.Run("Get", func(t *testing.T) {
+ user, err := db.GetUser("alice-id")
+ if err != nil {
+ t.Fatalf("GetUser failed: %v", err)
+ }
+ if user.Name != "Alice" {
+ t.Errorf("got name %q; want %q", user.Name, "Alice")
+ }
+ })
+
+ t.Run("Update", func(t *testing.T) {
+ // ...
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ // ...
+ })
+}
+```
+
+### Parallel Subtests
+
+```go
+func TestParallel(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ }{
+ {"case1", "input1"},
+ {"case2", "input2"},
+ {"case3", "input3"},
+ }
+
+ for _, tt := range tests {
+ tt := tt // Capture range variable
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel() // Run subtests in parallel
+ result := Process(tt.input)
+ // assertions...
+ _ = result
+ })
+ }
+}
+```
+
+## Test Helpers
+
+### Helper Functions
+
+```go
+func setupTestDB(t *testing.T) *sql.DB {
+ t.Helper() // Marks this as a helper function
+
+ db, err := sql.Open("sqlite3", ":memory:")
+ if err != nil {
+ t.Fatalf("failed to open database: %v", err)
+ }
+
+ // Cleanup when test finishes
+ t.Cleanup(func() {
+ db.Close()
+ })
+
+ // Run migrations
+ if _, err := db.Exec(schema); err != nil {
+ t.Fatalf("failed to create schema: %v", err)
+ }
+
+ return db
+}
+
+func assertNoError(t *testing.T, err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func assertEqual[T comparable](t *testing.T, got, want T) {
+ t.Helper()
+ if got != want {
+ t.Errorf("got %v; want %v", got, want)
+ }
+}
+```
+
+### Temporary Files and Directories
+
+```go
+func TestFileProcessing(t *testing.T) {
+ // Create temp directory - automatically cleaned up
+ tmpDir := t.TempDir()
+
+ // Create test file
+ testFile := filepath.Join(tmpDir, "test.txt")
+ err := os.WriteFile(testFile, []byte("test content"), 0644)
+ if err != nil {
+ t.Fatalf("failed to create test file: %v", err)
+ }
+
+ // Run test
+ result, err := ProcessFile(testFile)
+ if err != nil {
+ t.Fatalf("ProcessFile failed: %v", err)
+ }
+
+ // Assert...
+ _ = result
+}
+```
+
+## Golden Files
+
+Testing against expected output files stored in `testdata/`.
+
+```go
+var update = flag.Bool("update", false, "update golden files")
+
+func TestRender(t *testing.T) {
+ tests := []struct {
+ name string
+ input Template
+ }{
+ {"simple", Template{Name: "test"}},
+ {"complex", Template{Name: "test", Items: []string{"a", "b"}}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := Render(tt.input)
+
+ golden := filepath.Join("testdata", tt.name+".golden")
+
+ if *update {
+ // Update golden file: go test -update
+ err := os.WriteFile(golden, got, 0644)
+ if err != nil {
+ t.Fatalf("failed to update golden file: %v", err)
+ }
+ }
+
+ want, err := os.ReadFile(golden)
+ if err != nil {
+ t.Fatalf("failed to read golden file: %v", err)
+ }
+
+ if !bytes.Equal(got, want) {
+ t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", got, want)
+ }
+ })
+ }
+}
+```
+
+## Mocking with Interfaces
+
+### Interface-Based Mocking
+
+```go
+// Define interface for dependencies
+type UserRepository interface {
+ GetUser(id string) (*User, error)
+ SaveUser(user *User) error
+}
+
+// Production implementation
+type PostgresUserRepository struct {
+ db *sql.DB
+}
+
+func (r *PostgresUserRepository) GetUser(id string) (*User, error) {
+ // Real database query
+}
+
+// Mock implementation for tests
+type MockUserRepository struct {
+ GetUserFunc func(id string) (*User, error)
+ SaveUserFunc func(user *User) error
+}
+
+func (m *MockUserRepository) GetUser(id string) (*User, error) {
+ return m.GetUserFunc(id)
+}
+
+func (m *MockUserRepository) SaveUser(user *User) error {
+ return m.SaveUserFunc(user)
+}
+
+// Test using mock
+func TestUserService(t *testing.T) {
+ mock := &MockUserRepository{
+ GetUserFunc: func(id string) (*User, error) {
+ if id == "123" {
+ return &User{ID: "123", Name: "Alice"}, nil
+ }
+ return nil, ErrNotFound
+ },
+ }
+
+ service := NewUserService(mock)
+
+ user, err := service.GetUserProfile("123")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if user.Name != "Alice" {
+ t.Errorf("got name %q; want %q", user.Name, "Alice")
+ }
+}
+```
+
+## Benchmarks
+
+### Basic Benchmarks
+
+```go
+func BenchmarkProcess(b *testing.B) {
+ data := generateTestData(1000)
+ b.ResetTimer() // Don't count setup time
+
+ for i := 0; i < b.N; i++ {
+ Process(data)
+ }
+}
+
+// Run: go test -bench=BenchmarkProcess -benchmem
+// Output: BenchmarkProcess-8 10000 105234 ns/op 4096 B/op 10 allocs/op
+```
+
+### Benchmark with Different Sizes
+
+```go
+func BenchmarkSort(b *testing.B) {
+ sizes := []int{100, 1000, 10000, 100000}
+
+ for _, size := range sizes {
+ b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
+ data := generateRandomSlice(size)
+ b.ResetTimer()
+
+ for i := 0; i < b.N; i++ {
+ // Make a copy to avoid sorting already sorted data
+ tmp := make([]int, len(data))
+ copy(tmp, data)
+ sort.Ints(tmp)
+ }
+ })
+ }
+}
+```
+
+### Memory Allocation Benchmarks
+
+```go
+func BenchmarkStringConcat(b *testing.B) {
+ parts := []string{"hello", "world", "foo", "bar", "baz"}
+
+ b.Run("plus", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ var s string
+ for _, p := range parts {
+ s += p
+ }
+ _ = s
+ }
+ })
+
+ b.Run("builder", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ var sb strings.Builder
+ for _, p := range parts {
+ sb.WriteString(p)
+ }
+ _ = sb.String()
+ }
+ })
+
+ b.Run("join", func(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _ = strings.Join(parts, "")
+ }
+ })
+}
+```
+
+## Fuzzing (Go 1.18+)
+
+### Basic Fuzz Test
+
+```go
+func FuzzParseJSON(f *testing.F) {
+ // Add seed corpus
+ f.Add(`{"name": "test"}`)
+ f.Add(`{"count": 123}`)
+ f.Add(`[]`)
+ f.Add(`""`)
+
+ f.Fuzz(func(t *testing.T, input string) {
+ var result map[string]interface{}
+ err := json.Unmarshal([]byte(input), &result)
+
+ if err != nil {
+ // Invalid JSON is expected for random input
+ return
+ }
+
+ // If parsing succeeded, re-encoding should work
+ _, err = json.Marshal(result)
+ if err != nil {
+ t.Errorf("Marshal failed after successful Unmarshal: %v", err)
+ }
+ })
+}
+
+// Run: go test -fuzz=FuzzParseJSON -fuzztime=30s
+```
+
+### Fuzz Test with Multiple Inputs
+
+```go
+func FuzzCompare(f *testing.F) {
+ f.Add("hello", "world")
+ f.Add("", "")
+ f.Add("abc", "abc")
+
+ f.Fuzz(func(t *testing.T, a, b string) {
+ result := Compare(a, b)
+
+ // Property: Compare(a, a) should always equal 0
+ if a == b && result != 0 {
+ t.Errorf("Compare(%q, %q) = %d; want 0", a, b, result)
+ }
+
+ // Property: Compare(a, b) and Compare(b, a) should have opposite signs
+ reverse := Compare(b, a)
+ if (result > 0 && reverse >= 0) || (result < 0 && reverse <= 0) {
+ if result != 0 || reverse != 0 {
+ t.Errorf("Compare(%q, %q) = %d, Compare(%q, %q) = %d; inconsistent",
+ a, b, result, b, a, reverse)
+ }
+ }
+ })
+}
+```
+
+## Test Coverage
+
+### Running Coverage
+
+```bash
+# Basic coverage
+go test -cover ./...
+
+# Generate coverage profile
+go test -coverprofile=coverage.out ./...
+
+# View coverage in browser
+go tool cover -html=coverage.out
+
+# View coverage by function
+go tool cover -func=coverage.out
+
+# Coverage with race detection
+go test -race -coverprofile=coverage.out ./...
+```
+
+### Coverage Targets
+
+| Code Type | Target |
+|-----------|--------|
+| Critical business logic | 100% |
+| Public APIs | 90%+ |
+| General code | 80%+ |
+| Generated code | Exclude |
+
+### Excluding Generated Code from Coverage
+
+```go
+//go:generate mockgen -source=interface.go -destination=mock_interface.go
+
+// In coverage profile, exclude with build tags:
+// go test -cover -tags=!generate ./...
+```
+
+## HTTP Handler Testing
+
+```go
+func TestHealthHandler(t *testing.T) {
+ // Create request
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ w := httptest.NewRecorder()
+
+ // Call handler
+ HealthHandler(w, req)
+
+ // Check response
+ resp := w.Result()
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("got status %d; want %d", resp.StatusCode, http.StatusOK)
+ }
+
+ body, _ := io.ReadAll(resp.Body)
+ if string(body) != "OK" {
+ t.Errorf("got body %q; want %q", body, "OK")
+ }
+}
+
+func TestAPIHandler(t *testing.T) {
+ tests := []struct {
+ name string
+ method string
+ path string
+ body string
+ wantStatus int
+ wantBody string
+ }{
+ {
+ name: "get user",
+ method: http.MethodGet,
+ path: "/users/123",
+ wantStatus: http.StatusOK,
+ wantBody: `{"id":"123","name":"Alice"}`,
+ },
+ {
+ name: "not found",
+ method: http.MethodGet,
+ path: "/users/999",
+ wantStatus: http.StatusNotFound,
+ },
+ {
+ name: "create user",
+ method: http.MethodPost,
+ path: "/users",
+ body: `{"name":"Bob"}`,
+ wantStatus: http.StatusCreated,
+ },
+ }
+
+ handler := NewAPIHandler()
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var body io.Reader
+ if tt.body != "" {
+ body = strings.NewReader(tt.body)
+ }
+
+ req := httptest.NewRequest(tt.method, tt.path, body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ if w.Code != tt.wantStatus {
+ t.Errorf("got status %d; want %d", w.Code, tt.wantStatus)
+ }
+
+ if tt.wantBody != "" && w.Body.String() != tt.wantBody {
+ t.Errorf("got body %q; want %q", w.Body.String(), tt.wantBody)
+ }
+ })
+ }
+}
+```
+
+## Testing Commands
+
+```bash
+# Run all tests
+go test ./...
+
+# Run tests with verbose output
+go test -v ./...
+
+# Run specific test
+go test -run TestAdd ./...
+
+# Run tests matching pattern
+go test -run "TestUser/Create" ./...
+
+# Run tests with race detector
+go test -race ./...
+
+# Run tests with coverage
+go test -cover -coverprofile=coverage.out ./...
+
+# Run short tests only
+go test -short ./...
+
+# Run tests with timeout
+go test -timeout 30s ./...
+
+# Run benchmarks
+go test -bench=. -benchmem ./...
+
+# Run fuzzing
+go test -fuzz=FuzzParse -fuzztime=30s ./...
+
+# Count test runs (for flaky test detection)
+go test -count=10 ./...
+```
+
+## Best Practices
+
+**DO:**
+- Write tests FIRST (TDD)
+- Use table-driven tests for comprehensive coverage
+- Test behavior, not implementation
+- Use `t.Helper()` in helper functions
+- Use `t.Parallel()` for independent tests
+- Clean up resources with `t.Cleanup()`
+- Use meaningful test names that describe the scenario
+
+**DON'T:**
+- Test private functions directly (test through public API)
+- Use `time.Sleep()` in tests (use channels or conditions)
+- Ignore flaky tests (fix or remove them)
+- Mock everything (prefer integration tests when possible)
+- Skip error path testing
+
+## Integration with CI/CD
+
+```yaml
+# GitHub Actions example
+test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.22'
+
+ - name: Run tests
+ run: go test -race -coverprofile=coverage.out ./...
+
+ - name: Check coverage
+ run: |
+ go tool cover -func=coverage.out | grep total | awk '{print $3}' | \
+ awk -F'%' '{if ($1 < 80) exit 1}'
+```
+
+**Remember**: Tests are documentation. They show how your code is meant to be used. Write them clearly and keep them up to date.
diff --git a/.agents/skills/one-cli/SKILL.md b/.agents/skills/one-cli/SKILL.md
new file mode 100644
index 00000000..4daa97fd
--- /dev/null
+++ b/.agents/skills/one-cli/SKILL.md
@@ -0,0 +1,55 @@
+---
+name: one-cli
+description: Develop and manage One CLI workspaces, discover installed commands and tasks, and apply project layouts within apps, services, and packages.
+---
+
+# One CLI
+
+Find the nearest `one.manifest.toml`; use its project names, paths, toolchains,
+and groups rather than assuming a single application. Read applicable
+`AGENTS.md` and project README files. Team instructions and the user's chosen
+stack take precedence over examples in installed skills.
+
+For `empty-app`, `empty-service`, and `empty-library`, the user defines the
+language, frameworks, UI, database, and other tooling. These templates only
+establish the One workspace location and workflow, not a default stack or
+internal architecture. Do not infer a stack from sibling projects or impose
+dependencies from another template or installed skill. Once a stack is chosen,
+adapt project tasks and relevant development skills to that choice.
+
+Discover commands with `one --help`, `one help --all`, and command-specific
+help. Discover templates with `one templates -o json` before `one add`.
+The installed CLI is the authority; do not invent flags or commands.
+
+Use `one run` to discover tasks and `one run -p ` for project
+checks, builds, tests, dependency preparation, and scripts. If a task is
+missing, define it in the project's package.json/Taskfile.yml or root
+mise.toml, synchronize with `one init mise`, and run it through One.
+Keep shared multi-step scripts in root `scripts/`; project scripts belong
+in the project. Follow the repository's script language convention.
+
+Use `-o json` when parsing One's own structured results; branch on error
+codes and context. `one skills`, `one mise`, and `one hk` forward native
+tool output and flags, so their output does not use One's JSON envelope.
+
+Manage runtime variables through One or Dashboard; application code reads
+the injected process environment. Do not introduce another .env source.
+Publishing credentials belong in Shared credentials, configured by the
+user, and are injected by a task using `one exec --global`. Never request
+or print their values.
+
+Read [project layouts](references/layouts.md) when adding or restructuring
+a project. Read [stack adaptations](references/stacks.md) when an upstream
+skill's examples differ from this project's dependencies or conventions.
+
+For deeper template-specific work, select the installed one-nestjs, one-go,
+one-expo, or one-web skill for that stack. Electron process boundaries use
+one-electron; Fumadocs content and search use one-fumadocs. Read only the skill
+and references needed for the current task. Their absence does not prescribe
+a stack for an empty/custom project; follow its actual dependencies and README.
+
+Business skills are optional instructions maintained in separate GitHub
+repositories. Install them only when the user requests installation, using
+`one skills add --skill ` from the workspace root. Install
+skills in the project's `.agents/skills`, without `--global`. Installing a
+skill does not authorize implementing a feature or provisioning an external service.
diff --git a/.agents/skills/one-cli/agents/openai.yaml b/.agents/skills/one-cli/agents/openai.yaml
new file mode 100644
index 00000000..e99914ac
--- /dev/null
+++ b/.agents/skills/one-cli/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One CLI"
+ short_description: "Work with One workspaces and their project layouts"
diff --git a/.agents/skills/one-cli/references/layouts.md b/.agents/skills/one-cli/references/layouts.md
new file mode 100644
index 00000000..2c30c62a
--- /dev/null
+++ b/.agents/skills/one-cli/references/layouts.md
@@ -0,0 +1,47 @@
+# Project layouts inside a One workspace
+
+The outer convention is `apps/` for runnable clients/sites, `services/` for
+backends/workers, and `packages/` for reusable libraries. Preserve existing
+project structure and public import paths. Add directories when they hold
+real code; do not generate empty architectural scaffolding.
+
+Empty templates leave the project's internal layout and stack to the user.
+The examples below apply only when the project uses that stack; they do not
+require an empty project to adopt it. Follow the selected framework's layout
+and the user's architecture while keeping the One workspace placement.
+
+- **Go services:** keep binaries under `cmd/` and private code under
+ `internal/`. Group handlers, application logic, persistence, and configuration
+ according to the existing service. Use constructor/function injection where
+ needed. Libraries should expose deliberate public packages; `pkg/` is not
+ required for every Go module. Module paths follow the project's actual
+ publishing/import identity. See the [official Go module layout guide](https://go.dev/doc/modules/layout).
+- **Node services:** organize substantial business domains into modules that
+ own their transport, application logic, and data access. Keep reusable
+ infrastructure separate. For NestJS, follow its modules/providers and
+ Drizzle when persistence is requested instead of transplanting Express directories.
+ The minimal starter does not pre-create domains, repositories, or user models.
+ See [NestJS modules](https://docs.nestjs.com/modules) and
+ [Node Best Practices](https://github.com/goldbergyoni/nodebestpractices).
+- **React SPA:** use `src/app` for composition/routing and `src/features/`
+ when a domain warrants colocated API code, components, hooks, and state.
+ Shared UI and utilities stay outside features. Avoid cross-feature cycles;
+ do not move small existing applications just to match an example.
+ See [Bulletproof React's layout reference](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md).
+- **Next.js:** routing belongs in `app/` or `src/app/`; preserve the chosen
+ location. Keep route-specific code colocated, reusable business code in
+ features/modules, and shared UI outside the route tree. Static sites and
+ docs keep their static-export constraints.
+- **Expo:** keep Expo Router route entries in `app/` or `src/app/` and reusable
+ screens/components outside routing directories. Respect the committed
+ layout; the upstream project-structure skill is for new unstructured apps.
+- **TypeScript libraries:** keep public exports deliberate, implementation in
+ `src/`, and tests near the behavior they cover. Preserve tsdown output,
+ package exports, and the project's actual test convention.
+- **Electron:** a desktop project is a group with `-renderer` under
+ apps, `-main` under services, and `-preload` under packages.
+ Consult the installed `one-electron` skill for the process boundaries.
+
+Upstream examples may use Make, ESLint, npm, or a different DI style. Reuse
+One tasks, the workspace package manager, existing lint tooling, and existing
+functional factories rather than creating competing build systems.
diff --git a/.agents/skills/one-cli/references/stacks.md b/.agents/skills/one-cli/references/stacks.md
new file mode 100644
index 00000000..423361ba
--- /dev/null
+++ b/.agents/skills/one-cli/references/stacks.md
@@ -0,0 +1,57 @@
+# Adapt skills to the installed stack
+
+Read the target project's package.json/go.mod, configuration, and task files
+before applying upstream advice. Root skills may serve several projects;
+choose references for the project being changed, not every installed skill.
+
+Empty templates have no prescribed stack. When dependencies are not yet
+defined, follow the user's choices rather than applying another template's
+defaults. The adaptations below describe projects already using those stacks.
+
+For template-specific work, select the installed adaptation skill:
+
+- `one-nestjs`: NestJS services, Drizzle, and service startup.
+- `one-go`: Go API services and public libraries.
+- `one-expo`: native styling, development clients, and mobile data/state lifecycle.
+- `one-web`: React SPA and Next.js; also Web UI in Electron and Fumadocs.
+- `one-electron`: desktop process boundaries and packaging.
+- `one-fumadocs`: documentation content, navigation, and static search.
+
+Read the relevant skill and its references only when needed. If it is not
+installed, inspect the project's README and existing implementation rather than
+assuming its absence changes the stack. The brief adaptations below also apply
+to existing workspaces that have only one-cli installed.
+
+- **Web UI:** inspect components.json to identify shadcn style and primitive
+ library. Generated web templates use Base UI; existing applications may
+ still use Radix. Base UI composition uses `render`; do not copy Radix's
+ `asChild` blindly. Generated templates use Base UI Toast, not Sonner.
+ Preserve Tailwind version, theme, translations, and accessible interactions.
+- **Next.js:** resolve `next` from the target subproject and consult its
+ `node_modules/next/dist/docs` version-matched documentation when available.
+ Do not rely on the retired next-best-practices package. Static-export
+ projects cannot adopt server-only runtime behavior without a product change.
+- **NestJS:** preserve Nest modules/providers, class-validator, Drizzle ORM/Kit,
+ and Jest. The starter has no database connection or driver. Add a driver,
+ schema, and migration tasks only after the user chooses persistence. An upstream ORM or logging
+ example does not authorize replacing the project's data or log stack.
+- **Expo:** the One template uses React Native styles, Axios/SWR, MMKV, and
+ development clients for native dependencies. Do not introduce NativeWind,
+ Tailwind, or replace the data stack merely because a skill prefers them.
+ Check Expo SDK compatibility before adding a native dependency.
+- **Go:** reuse current Gin/Gorm/Viper/Zap choices in services and the actual
+ module API in libraries. Do not introduce Makefiles or another check runner.
+- **Vite/Vitest/tsdown/pnpm:** use project-local versions and config; the root
+ workspace owns package-manager settings. Keep Oxlint/Oxfmt when present.
+ Do not install unrelated Vue/Nuxt/ESLint conventions from the same skill repo.
+
+Keep credentials used by One/Infisical separate from application account
+authentication. Business behavior is implemented only after the user requests
+that feature; the starter templates do not contain JWT, login, user CRUD, or default
+databases. Do not add these as prerequisite infrastructure.
+
+Use Axios as a thin HTTP client and SWR for remote data/cache ownership. Do not
+add token interceptors or duplicate SWR data in Zustand. Zustand is for shared
+UI state; use React state locally and create Next.js stores per Provider rather
+than as mutable server singletons. Expo's MMKV stores UI preferences, not a
+second remote cache. Retain the chosen dependencies when removing demo code.
diff --git a/.agents/skills/one-electron/SKILL.md b/.agents/skills/one-electron/SKILL.md
new file mode 100644
index 00000000..9ab53988
--- /dev/null
+++ b/.agents/skills/one-electron/SKILL.md
@@ -0,0 +1,43 @@
+---
+name: one-electron
+description: Develop Electron applications generated by One CLI with separate renderer, main, and preload projects and Awilix function factories.
+---
+
+# One Electron
+
+Read the manifest group and all three package.json files before changing paths.
+For a project named `desktop`, the group contains:
+
+- `apps/desktop-renderer`: React, Vite, shadcn/Base UI, and Tailwind.
+- `services/desktop-main`: Electron lifecycle, native capabilities, Awilix,
+ tsdown, development launcher, and electron-builder packaging configuration.
+- `packages/desktop-preload`: the contextBridge API and its shared types/build.
+
+Use the user's actual project name in imports, package names, artifact paths,
+and group task references. Do not create a nested One workspace or move these
+projects under the main service. Discover tasks with `one run`; start a group
+with `one run :dev`.
+
+Keep main-process services as factory functions with explicit dependencies.
+Register them using the existing Awilix container and lifetime conventions;
+do not introduce decorators, class-based Inversify bindings, or a second DI
+container. Keep cleanup and window lifecycle ownership explicit.
+
+The renderer uses the typed preload API. Expose narrow contextBridge methods;
+keep Node access, filesystem operations, and privileged work in main.
+Update main handlers, preload contracts, and renderer callers together when
+changing IPC. Validate incoming arguments, sender window, main frame, and trusted URL in
+the privileged process. The starter exposes only `getAppInfo()`; add narrow
+methods when the user requests privileged features, never raw invoke/on APIs.
+
+Read existing dev/build scripts before changing startup dependencies. Preserve
+renderer/preload build ordering, relative production paths, and packaged assets.
+Production uses `loadFile`, relative Vite assets, and HashRouter. Keep local
+packaging separate from publishing; automatic updates require explicit setup.
+On Ubuntu, use the launcher's sandbox/AppArmor checks and desktop-session
+detection; do not apply Ubuntu setup on every Linux distribution or disable
+the sandbox as a default fix. Headless execution needs an intentional test setup.
+
+Verify affected process builds and meaningful IPC/lifecycle behavior through
+One tasks. Keep application account credentials separate from One CLI login;
+business skills and business features require the user's explicit request.
diff --git a/.agents/skills/one-electron/agents/openai.yaml b/.agents/skills/one-electron/agents/openai.yaml
new file mode 100644
index 00000000..26db5ea4
--- /dev/null
+++ b/.agents/skills/one-electron/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Electron"
+ short_description: "Develop the three-project One Electron template"
diff --git a/.agents/skills/one-expo/SKILL.md b/.agents/skills/one-expo/SKILL.md
new file mode 100644
index 00000000..613ed15e
--- /dev/null
+++ b/.agents/skills/one-expo/SKILL.md
@@ -0,0 +1,50 @@
+---
+name: one-expo
+description: Develop Expo mobile projects generated by One CLI with Expo Router, native styles, Axios/SWR, Zustand/MMKV, and native development clients.
+---
+
+# One Expo
+
+Read package.json, app.json, src/app/_layout.tsx, the One manifest, and the project
+README. The mobile app lives under apps/. Discover its tasks with
+`one run` and use One for development, checks, and builds. Follow the installed
+Expo SDK and user choices rather than version assumptions in upstream examples.
+
+The starter uses React Native styles, Expo Router, Axios, SWR, Zustand, MMKV,
+Nitro native modules, and an Expo development client. Its home, not-found screen,
+and theme switching are the starting surface. Do not introduce NativeWind or
+Tailwind, auth/token storage, user screens, or a new remote-data stack as default
+infrastructure. Business features and their skills require the user's request.
+
+Keep route entries in src/app/ and reusable components/hooks outside the route
+tree. Extend the existing Stack only when navigation needs another screen;
+add tabs or animation/gesture dependencies when the feature warrants them.
+Preserve safe-area handling, status bar appearance, and the system theme default.
+Respect light/dark/system preferences without forcing the device's appearance.
+
+Axios is a thin client using the public EXPO_PUBLIC_API_URL. Public Expo
+variables are bundled into the app and must not contain secrets. Let One inject
+build/runtime variables; .env.example describes keys rather than adding a second
+configuration loader. Do not copy token refresh or business-code interceptors
+from an upstream data-fetching example.
+
+SWR owns remote data and caching; use its scoped Map provider to initialize
+native focus/reconnect hooks. Keep initFocus and initReconnect cleanup functions.
+Online/visibility reads must be synchronous and must not register listeners.
+Maintain online state through the existing NetInfo listener, and revalidate on
+an offline-to-online or background-to-active transition. Do not add a second
+persisted remote cache or listeners inside every isOnline call. Web should use
+SWR's normal browser event handling rather than native lifecycle hooks.
+
+Zustand owns shared UI preferences; local component state stays in React and
+remote data stays in SWR. Persist preferences through the existing MMKV adapter.
+MMKV/Nitro need a native development client; Expo Go cannot validate them. Keep
+native and web behavior compatible, and avoid native-only side effects during
+web prerendering. Do not add AsyncStorage or SecureStore merely to duplicate the
+existing UI preference storage.
+
+Check SDK dependency compatibility with the installed Expo CLI's `install --check`
+through a project task. Verify relevant typechecks, tests, and web export; use a
+native development build for native changes. Listener tests should cover state
+transitions and cleanup. State clearly which platforms/devices were exercised;
+a passing web export does not prove iOS/Android runtime compatibility.
diff --git a/.agents/skills/one-expo/agents/openai.yaml b/.agents/skills/one-expo/agents/openai.yaml
new file mode 100644
index 00000000..0918abdf
--- /dev/null
+++ b/.agents/skills/one-expo/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Expo"
+ short_description: "Develop One Expo apps with native styles and MMKV"
diff --git a/.agents/skills/one-fumadocs/SKILL.md b/.agents/skills/one-fumadocs/SKILL.md
new file mode 100644
index 00000000..b2b859ae
--- /dev/null
+++ b/.agents/skills/one-fumadocs/SKILL.md
@@ -0,0 +1,29 @@
+---
+name: one-fumadocs
+description: Maintain Next.js and Fumadocs documentation projects in One workspaces, including MDX sources, localized navigation, static export, and search.
+---
+
+# One Fumadocs
+
+Inspect the project's package.json, source.config, content tree, app routes,
+and Next configuration. Resolve Next documentation from that project's
+installed version. Fumadocs Core, MDX, and UI can have different version lines.
+
+The One docs template uses `fumadocs-ui` aliased to `@fumadocs/base-ui`.
+Existing workspaces can use the regular UI package. Follow the installed
+provider, import paths, and components.json; do not switch UI primitives
+while editing content.
+
+Keep content and navigation synchronized in English and Chinese unless the
+user scopes the change to a language. Match each language's metadata, links,
+and routes. Use maintained source loaders and generated MDX types rather than
+building a second content pipeline or checking generated output into Git.
+
+For static-export projects, retain `output: 'export'`, generate required
+localized route parameters, and build static search data. Do not introduce
+a runtime search API or server-only feature that cannot work in the exported
+site. Resolve links and search URLs with the actual base path and locale.
+
+Run the project's checks/build using One tasks. Verify an exported page and
+the generated search data for both languages when routing/search changes.
+Business skills remain opt-in and live in separate repositories.
diff --git a/.agents/skills/one-fumadocs/agents/openai.yaml b/.agents/skills/one-fumadocs/agents/openai.yaml
new file mode 100644
index 00000000..7bee5392
--- /dev/null
+++ b/.agents/skills/one-fumadocs/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Fumadocs"
+ short_description: "Maintain Next.js and Fumadocs documentation projects"
diff --git a/.agents/skills/one-go/SKILL.md b/.agents/skills/one-go/SKILL.md
new file mode 100644
index 00000000..0a2b6c3e
--- /dev/null
+++ b/.agents/skills/one-go/SKILL.md
@@ -0,0 +1,52 @@
+---
+name: one-go
+description: Develop Go API and library projects in One CLI workspaces, applying service-specific Gin/Gorm/Viper/Zap conventions and deliberate public library APIs.
+---
+
+# One Go
+
+Read go.mod, Taskfile.yml, the One manifest, and the target project's entry points.
+Discover tasks with `one run` and use the project's One tasks for checks, builds,
+and tests. Respect the actual module identity and existing stack; an arbitrary
+Go project or an empty template does not need to adopt Gin or Gorm.
+
+## API services
+
+The generated go-api lives under services/, with cmd/server for the
+binary and internal/ for private implementation. It uses Gin, Gorm, Viper, Zap,
+and OpenAPI. Its initial routes are application information, database-independent
+health, and documentation. It has no JWT, login, user model, CRUD, database driver,
+default connection, or migration. Add domain packages only for requested behavior;
+small handlers need not grow a transport/service/repository hierarchy.
+
+Use functions and constructors with explicit dependencies. Keep assembly in
+internal/app and HTTP setup in internal/http; place persistence behind the
+business code that actually needs it. Avoid package globals for mutable service
+resources or a DI container that obscures resource ownership.
+
+Viper reads One-injected environment variables and optional project config.
+Preserve its existing nested-key mapping; do not add an independent dotenv
+loader or generate config files containing secrets. Keep Zap logging, request
+IDs, panic recovery, exact CORS origin matching, and graceful HTTP shutdown.
+Update api/openapi.yaml when real routes change.
+
+Gorm is retained without a driver. When the user requests persistence, install
+the driver for the selected database and pass its dialector to the existing
+internal/platform/database.Open entry point. Own and close the resulting
+connection explicitly, then inject it into the feature that needs it. Run
+migrations as explicit tasks. Do not open an implicit in-memory database or
+silently fall back when a configured connection fails.
+
+## Libraries
+
+The generated go-lib lives under packages/. Keep public exports small,
+with behavior tests near their package, and retain the project's module/import
+identity. Its pkg/ layout is an existing convention, not a requirement for every
+Go library. Do not introduce service dependencies, environment loading, Gin, or
+Gorm into a library that does not use them.
+
+For service routes, use httptest to verify externally observable status/body and
+middleware behavior. For library APIs, use table-driven cases where inputs vary.
+Run existing formatting, vet, tests, and build tasks; use the project's race
+checks when changing concurrent ownership. Authentication and user CRUD belong
+to requested business work, and business skills remain manually installed.
diff --git a/.agents/skills/one-go/agents/openai.yaml b/.agents/skills/one-go/agents/openai.yaml
new file mode 100644
index 00000000..4aff3ec3
--- /dev/null
+++ b/.agents/skills/one-go/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Go"
+ short_description: "Develop One Go services and public libraries"
diff --git a/.agents/skills/one-nestjs/SKILL.md b/.agents/skills/one-nestjs/SKILL.md
new file mode 100644
index 00000000..eb00740c
--- /dev/null
+++ b/.agents/skills/one-nestjs/SKILL.md
@@ -0,0 +1,45 @@
+---
+name: one-nestjs
+description: Develop NestJS API services generated by One CLI, preserving Drizzle, injected configuration, validation, logging, and the minimal database-free starter.
+---
+
+# One NestJS
+
+Read the target project's package.json, src/app.module.ts, src/main.ts, README,
+and One manifest before changing the service. Use the user's project name under
+services/, discover tasks with `one run`, and run project tasks through One.
+The actual project and user choices take precedence over template defaults.
+
+The starter has application information, a database-independent liveness check,
+and Swagger documentation. NestJS, class-validator/class-transformer, Drizzle
+ORM/Kit, Pino, and Jest remain part of its stack. It has no JWT, login, user CRUD,
+database driver, connection, schema, seed, or automatic migration. Add business
+features when requested; upstream examples do not make them prerequisites.
+
+Keep the composition in AppModule and bootstrap in main.ts. Create a feature
+module when a domain warrants it, with DTOs and providers colocated with its
+behavior. Do not add empty controller/service/repository layers for a small
+route. Reuse the global ValidationPipe and ordinary Nest HTTP exceptions rather
+than introducing another business-code response envelope or token interceptor.
+
+Read runtime configuration from One-injected process environment through the
+existing ConfigModule. Preserve ignoreEnvFile; .env.example documents keys but
+is not another loading mechanism. Validate new required values when the related
+feature is enabled. Keep liveness available without persistence and distinguish
+it from readiness if a requested feature needs dependency checks.
+
+When persistence is requested, follow the user's selected database and install
+its Drizzle-compatible driver. Add schema, connection ownership, shutdown cleanup,
+and Drizzle Kit configuration for that database. Run migration/seed work as
+explicit tasks, separate from HTTP startup. Do not default to PostgreSQL, SQLite,
+or PGlite, or add a fallback database on connection failure.
+
+Keep Pino's existing basic logging and the OpenAPI document accurate for actual
+routes and DTOs. Do not add log rotation, authentication schemes, or response
+wrappers unless the requested behavior needs them. One CLI credentials are
+separate from application authentication; business skills are installed manually.
+
+Verify typecheck, build, and relevant Jest tests through the project's One tasks.
+Use HTTP tests for changed route behavior, including validation and status codes.
+The starter's health test must still pass without application auth or database
+settings; extend it only when the feature changes that contract.
diff --git a/.agents/skills/one-nestjs/agents/openai.yaml b/.agents/skills/one-nestjs/agents/openai.yaml
new file mode 100644
index 00000000..7230656f
--- /dev/null
+++ b/.agents/skills/one-nestjs/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One NestJS"
+ short_description: "Develop One NestJS API templates with Drizzle"
diff --git a/.agents/skills/one-web/SKILL.md b/.agents/skills/one-web/SKILL.md
new file mode 100644
index 00000000..d47d5455
--- /dev/null
+++ b/.agents/skills/one-web/SKILL.md
@@ -0,0 +1,39 @@
+---
+name: one-web
+description: Develop React SPA and Next.js App/Site projects in One CLI workspaces, including shared Web UI in Electron renderers and Fumadocs, with Base UI and clear data/state ownership.
+---
+
+# One Web
+
+Read the target package.json, components.json, build config, and One manifest.
+Use the user's project name under apps/; discover tasks with `one run` and verify
+through the project's One tasks. Actual dependencies and user choices override
+template defaults. This skill does not prescribe a stack for empty templates or
+React Native apps.
+
+Generated Web projects use shadcn Base UI, Tailwind CSS, semantic design tokens,
+and Base UI Toast. Inspect components.json before composing UI: Base UI uses
+`render`, and Radix `asChild` examples cannot be copied mechanically. Existing
+applications may use Radix; preserve their actual primitive library. Add only
+components the requested screen needs and preserve keyboard/focus behavior,
+accessible labels, and light/dark styling. Reuse the mounted Toaster and its
+manager; do not add Sonner, another notification layer, or a toast-history store.
+
+Where Axios, SWR, and Zustand are installed, retain their distinct roles:
+Axios handles HTTP and its fetcher returns response data; SWR owns remote data,
+cache, and revalidation; Zustand owns shared UI state. Use React for local state.
+Do not duplicate SWR results in Zustand or invent business-code/token wrappers.
+Do not add these dependencies to static content projects merely for consistency.
+Authentication, user models, and business APIs are requested features, not
+starter infrastructure; business skills are installed manually.
+
+Read only the reference for the current runtime:
+
+- [React SPA](references/react-spa.md): Vite, routing, client state, and renderer adaptations.
+- [Next.js](references/nextjs.md): server/client boundaries, per-Provider stores, and static export.
+
+Electron renderer changes must also respect the installed one-electron guidance
+and typed preload contract; privileged capabilities belong in main. Fumadocs
+content, navigation, MDX, and search changes use one-fumadocs guidance when
+installed. Framework-specific skills complement this skill rather than replacing
+the project's One conventions.
diff --git a/.agents/skills/one-web/agents/openai.yaml b/.agents/skills/one-web/agents/openai.yaml
new file mode 100644
index 00000000..81aa5afc
--- /dev/null
+++ b/.agents/skills/one-web/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Web"
+ short_description: "Develop One React and Next.js application templates"
diff --git a/.agents/skills/one-web/references/nextjs.md b/.agents/skills/one-web/references/nextjs.md
new file mode 100644
index 00000000..775f6d5a
--- /dev/null
+++ b/.agents/skills/one-web/references/nextjs.md
@@ -0,0 +1,41 @@
+# Next.js runtime and static export
+
+Resolve Next from the target subproject and read its installed documentation
+under node_modules/next/dist/docs when available. Read next.config and the actual
+route/layout structure before applying upstream patterns. In the templates,
+route entries are under src/app/; keep route-specific code colocated and shared
+UI outside the route tree.
+
+## Application runtime
+
+nextjs-app supports a server runtime. Keep server data access and private values
+in server code; NEXT_PUBLIC_API_URL is public and only configures the browser's
+Axios client. Add client boundaries only where hooks, browser APIs, or user
+interaction need them. Shared UI stores must be created per Provider rather
+than as mutable module singletons reused by server requests. Keep initial server
+and client state compatible during hydration. Use the existing next-themes
+provider instead of duplicating theme state in Zustand.
+
+Client SWR requests should use the configured fetcher and stable feature keys.
+Do not add mock route handlers or authentication APIs as scaffolding for an
+unrelated page. Server-only variables are not browser API configuration.
+
+## Static sites and documentation
+
+nextjs-site and fumadocs-docs use static export. Keep localized en/zh routes,
+HTML language, navigation, theme, and SEO metadata consistent. NEXT_PUBLIC_SITE_URL
+is public site metadata configuration. Server components can run at build time;
+request-time cookies, server actions, runtime API handlers, and private dynamic
+data need a deliberately chosen server deployment rather than an incidental
+change to the output mode.
+
+Fumadocs' api/search route generates the static search index during the build;
+preserve it. It is part of the exported site's core behavior, even though it is
+located under an API route directory. Content/search work follows the installed
+one-fumadocs guidance.
+
+Run typecheck, lint/format, and production build tasks. Check exported localized
+pages and search after static content changes. When both Expo and Next projects
+share a workspace, use explicit React type imports in reusable Web components
+and verify against the target project's React types rather than a global React
+namespace that may resolve to Expo's version.
diff --git a/.agents/skills/one-web/references/react-spa.md b/.agents/skills/one-web/references/react-spa.md
new file mode 100644
index 00000000..1a3fcb84
--- /dev/null
+++ b/.agents/skills/one-web/references/react-spa.md
@@ -0,0 +1,28 @@
+# React SPA and renderer runtime
+
+The react-spa template uses Vite, React Router, an Axios fetcher, SWRConfig,
+a Zustand UI theme store, semantic CSS tokens, and Base UI Toast. Keep routing
+in the existing composition and add features when real domains warrant them;
+a small screen does not need an empty architectural tree.
+
+Read Vite's public environment prefix and base path from the target config.
+Use only public values on the client; React SPA's optional API URL is VITE_API_URL.
+Keep the workspace package manager and existing Oxlint/Oxfmt configuration.
+After changing scripts, synchronize One task definitions when required.
+
+Use the existing shared UI theme store and provider, keep persisted preferences
+small, and avoid persisting server responses or introducing an auth store by
+copying a starter from another project. Keep error and not-found states usable
+with accessible recovery actions.
+
+Electron renderer uses its own Vite APP environment prefix, relative assets,
+and HashRouter for file-based production loading. Do not switch to BrowserRouter,
+absolute asset URLs, or a custom protocol just because the browser template uses
+them. Read native application information through window.electron.getAppInfo();
+keep Node/filesystem access outside the renderer and update preload types with
+main handlers when a requested feature needs another narrow method.
+
+Run the target project's typecheck, lint/format, and production build tasks.
+For Electron renderer changes, also verify the preload/main build dependencies
+and assembled production resources; Vite development success alone does not
+verify the packaged application.
diff --git a/.agents/skills/pnpm/GENERATION.md b/.agents/skills/pnpm/GENERATION.md
new file mode 100644
index 00000000..0e20fde2
--- /dev/null
+++ b/.agents/skills/pnpm/GENERATION.md
@@ -0,0 +1,5 @@
+# Generation Info
+
+- **Source:** `sources/pnpm`
+- **Git SHA:** `e3bc12c916c3a778de213e1dacb892f66ef6b2e7`
+- **Generated:** 2026-09-25
diff --git a/.agents/skills/pnpm/SKILL.md b/.agents/skills/pnpm/SKILL.md
new file mode 100644
index 00000000..972b47c4
--- /dev/null
+++ b/.agents/skills/pnpm/SKILL.md
@@ -0,0 +1,50 @@
+---
+name: pnpm
+description: Node.js package manager with strict dependency resolution. Use when running pnpm specific commands, configuring workspaces via pnpm-workspace.yaml, or managing dependencies with catalogs, patches, overrides, config dependencies, or the global virtual store.
+metadata:
+ author: Anthony Fu
+ version: "2026.9.25"
+ source: Generated from https://github.com/pnpm/pnpm, scripts located at https://github.com/antfu/skills
+---
+
+pnpm is a fast, disk space efficient package manager. It uses a content-addressable store to deduplicate packages across all projects on a machine, and enforces strict dependency resolution by default, preventing phantom dependencies.
+
+**pnpm v12 is a Rust rewrite** of v11: stable, and keeps v11's commands, flags, settings, and lockfile format — so most guidance here applies to both. A handful of v12 behaviors differ (git deps resolve via HTTPS, project-aware global bins, other package managers, `packageImportMethod: auto` hardlinks first on Linux, `--resolution-only` removed) — see best-practices-migration.
+
+**Configuration model (important):** pnpm settings live in `pnpm-workspace.yaml` (and the global `config.yaml`) using **camelCase** keys. `.npmrc` is used **only** for authentication/registry credentials, and the `pnpm` field of `package.json` is no longer read. When working in a pnpm project, check `pnpm-workspace.yaml` for settings/workspace structure and `.npmrc` only for auth. Always use `--frozen-lockfile` (or `pnpm ci`) in CI.
+
+> The skill is based on pnpm 12.x, generated at 2026-09-25. It covers v11+v12 behavior (config split, isolated global packages, `allowBuilds`, `pmOnFail`, global virtual store, native release management, workspace task orchestration, and experimental Python/Cargo support) where current docs describe them.
+
+## Core
+
+| Topic | Description | Reference |
+|-------|-------------|-----------|
+| CLI Commands | install/add/remove/update, run, dlx/pnx, workspace, runtime, publishing (version, view, sbom, stage) | [core-cli](references/core-cli.md) |
+| Configuration | pnpm-workspace.yaml settings (camelCase), global config.yaml, packageConfigs, .npmrc auth | [core-config](references/core-config.md) |
+| Workspaces | Monorepo support: filtering, workspace protocol, shared lockfile, packageConfigs | [core-workspaces](references/core-workspaces.md) |
+| Store | Content-addressable store, virtual store, node linker modes, frozen/read-only store | [core-store](references/core-store.md) |
+
+## Features
+
+| Topic | Description | Reference |
+|-------|-------------|-----------|
+| Catalogs | Centralized dependency versions; catalogMode, catalog: in overrides | [features-catalogs](references/features-catalogs.md) |
+| Overrides | Force versions (incl. transitive & peer deps); packageExtensions | [features-overrides](references/features-overrides.md) |
+| Patches | Modify third-party packages; patchedDependencies in pnpm-workspace.yaml | [features-patches](references/features-patches.md) |
+| Aliases | Install under custom names (npm:) and registry aliases (namedRegistries) | [features-aliases](references/features-aliases.md) |
+| Hooks | .pnpmfile.mjs hooks (readPackage, updateConfig, beforePacking), finders, resolvers/fetchers | [features-hooks](references/features-hooks.md) |
+| Peer Dependencies | Auto-install, strict mode, rules, dedupePeers, peers check | [features-peer-deps](references/features-peer-deps.md) |
+| Config Dependencies | Share hooks/settings/catalogs/patches across repos via configDependencies | [features-config-dependencies](references/features-config-dependencies.md) |
+| Global Virtual Store & Shims | Shared node_modules, git-worktree multi-agent setups, isolated global packages, project-aware bins, other package managers | [features-global-virtual-store](references/features-global-virtual-store.md) |
+| Supply-Chain Security | Build approval (allowBuilds), minimumReleaseAge, trustPolicy, lockfile integrity | [features-supply-chain-security](references/features-supply-chain-security.md) |
+| Task Orchestration | Cross-project task graphs (tasks/dependsOn), concurrency groups, priority, pnpm pipeline | [features-task-orchestration](references/features-task-orchestration.md) |
+| Release Management | Native versioning: pnpm change/version -r/lane, lanes, epics, fixed groups | [features-versioning](references/features-versioning.md) |
+| Multi-Ecosystem | Python (pypi:) and Cargo (crate:) dependencies alongside npm (experimental) | [features-multi-ecosystem](references/features-multi-ecosystem.md) |
+
+## Best Practices
+
+| Topic | Description | Reference |
+|-------|-------------|-----------|
+| CI/CD Setup | GitHub Actions, GitLab, Docker, pnpm ci, store caching, frozen lockfiles | [best-practices-ci](references/best-practices-ci.md) |
+| Migration | npm/Yarn → pnpm, phantom deps, and pnpm v10 → v11 → v12 upgrade notes | [best-practices-migration](references/best-practices-migration.md) |
+| Performance | Install optimizations, allowBuilds, global virtual store, workspace parallelization | [best-practices-performance](references/best-practices-performance.md) |
diff --git a/.agents/skills/pnpm/references/best-practices-ci.md b/.agents/skills/pnpm/references/best-practices-ci.md
new file mode 100644
index 00000000..f31a2b10
--- /dev/null
+++ b/.agents/skills/pnpm/references/best-practices-ci.md
@@ -0,0 +1,312 @@
+---
+name: pnpm-ci-cd-setup
+description: Optimizing pnpm for continuous integration and deployment workflows
+---
+
+# pnpm CI/CD Setup
+
+Best practices for using pnpm in CI/CD environments for fast, reliable builds.
+
+> **CI auto-behaviors:** When pnpm detects a CI environment it switches to **frozen-lockfile** mode automatically and (since v11) **fails on an incompatible lockfile** written by a newer pnpm major instead of rewriting it — keep the CI pnpm version in sync with the one that generated the lockfile. The global virtual store is auto-disabled in CI (no warm cache).
+
+## Installing pnpm in CI
+
+Outside GitHub Actions (which has its own action), the docs now recommend the **standalone install script** over Corepack:
+
+```sh
+curl -fsSL https://get.pnpm.io/install.sh | sh -
+```
+
+- **No Node.js needed** — pnpm is self-contained and can install a runtime afterwards (`pnpm runtime set node lts -g`).
+- **Follows the project's version** — after bootstrap, pnpm switches to the `packageManager`/`devEngines.packageManager` pin on first use, so you don't pin twice.
+- The script sets `PNPM_HOME` and edits a shell profile CI never reloads — export `PNPM_HOME` and put `$PNPM_HOME/bin` on `PATH` yourself.
+
+> Corepack installs a JS shim, so every `pnpm` call starts Node.js to run the shim first — a cost paid on each of CI's many invocations. Installing pnpm itself avoids it.
+
+### Cache the metadata cache too
+
+Since v11.22.0, `pnpm cache path` prints pnpm's metadata cache directory. Caching it (alongside the store) also caches the lockfile verification log, letting a job skip re-checking an unchanged lockfile against supply-chain policies — the dominant install cost once the store is warm.
+
+## GitHub Actions
+
+### Basic Setup
+
+```yaml
+name: CI
+
+on: [push, pull_request]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: 'pnpm'
+
+ - run: pnpm install --frozen-lockfile # or: pnpm ci
+ - run: pnpm test
+ - run: pnpm build
+```
+
+> `pnpm ci` (aliases `clean-install`, `install-clean`) = `pnpm clean` + `pnpm install --frozen-lockfile`, ideal for fully reproducible CI builds.
+
+### With Store Caching
+
+For larger projects, cache the pnpm store:
+
+```yaml
+- uses: pnpm/action-setup@v4
+ with:
+ version: 10
+
+- name: Get pnpm store directory
+ shell: bash
+ run: |
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+- uses: actions/cache@v4
+ name: Setup pnpm cache
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+- run: pnpm install --frozen-lockfile
+```
+
+> **Trust:** only cache/restore the pnpm store and cache dir between *trusted* jobs. A store an untrusted job can write to must not be reused by trusted jobs — it is part of pnpm's trust domain.
+
+### Matrix Testing
+
+```yaml
+jobs:
+ test:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ node: [18, 20, 22]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node }}
+ cache: 'pnpm'
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm test
+```
+
+## GitLab CI
+
+```yaml
+image: node:20
+
+stages:
+ - install
+ - test
+ - build
+
+variables:
+ PNPM_HOME: /root/.local/share/pnpm
+ PATH: $PNPM_HOME:$PATH
+
+before_script:
+ - corepack enable
+ - corepack prepare pnpm@latest --activate
+
+cache:
+ key: ${CI_COMMIT_REF_SLUG}
+ paths:
+ - .pnpm-store
+
+install:
+ stage: install
+ script:
+ - pnpm config set store-dir .pnpm-store
+ - pnpm install --frozen-lockfile
+
+test:
+ stage: test
+ script:
+ - pnpm test
+
+build:
+ stage: build
+ script:
+ - pnpm build
+```
+
+## Docker
+
+> **PATH change (v11):** global pnpm binaries now live in `$PNPM_HOME/bin`. In Docker set `ENV PATH="$PNPM_HOME/bin:$PATH"` (not `$PNPM_HOME`). There is also an official image `ghcr.io/pnpm/pnpm:` (Debian slim, pnpm only — choose Node yourself via `pnpm runtime set node -g` or `devEngines.runtime`).
+
+### Multi-Stage Build
+
+```dockerfile
+# Build stage
+FROM node:24-slim AS builder
+
+# Enable corepack for pnpm
+RUN corepack enable
+
+WORKDIR /app
+
+# Copy package files first for layer caching
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+COPY packages/*/package.json ./packages/
+
+# Install dependencies
+RUN pnpm install --frozen-lockfile
+
+# Copy source and build
+COPY . .
+RUN pnpm build
+
+# Production stage
+FROM node:20-slim AS runner
+
+RUN corepack enable
+WORKDIR /app
+
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/package.json ./
+COPY --from=builder /app/pnpm-lock.yaml ./
+
+# Production install
+RUN pnpm install --frozen-lockfile --prod
+
+CMD ["node", "dist/index.js"]
+```
+
+### Optimized for Monorepos
+
+```dockerfile
+FROM node:20-slim AS builder
+RUN corepack enable
+WORKDIR /app
+
+# Copy workspace config
+COPY pnpm-lock.yaml pnpm-workspace.yaml ./
+
+# Copy all package.json files maintaining structure
+COPY packages/core/package.json ./packages/core/
+COPY packages/api/package.json ./packages/api/
+
+# Install all dependencies
+RUN pnpm install --frozen-lockfile
+
+# Copy source
+COPY . .
+
+# Build specific package
+RUN pnpm --filter @myorg/api build
+```
+
+## Key CI Flags
+
+### --frozen-lockfile
+
+**Always use in CI.** Fails if `pnpm-lock.yaml` needs updates:
+
+```bash
+pnpm install --frozen-lockfile
+```
+
+### --prefer-offline
+
+Use cached packages when available:
+
+```bash
+pnpm install --frozen-lockfile --prefer-offline
+```
+
+### --ignore-scripts
+
+Skip lifecycle scripts for faster installs (use cautiously):
+
+```bash
+pnpm install --frozen-lockfile --ignore-scripts
+```
+
+## Pinning the pnpm version
+
+Pin with `packageManager` (exact) or `devEngines.packageManager` (range, resolved version stored in the lockfile):
+
+```json
+// package.json
+{ "packageManager": "pnpm@10.0.0" }
+```
+
+The standalone install script and `pnpm/action-setup` both honor this pin. To skip the pin check when version management is external (asdf/mise/Volta), set `pmOnFail: ignore` in `pnpm-workspace.yaml`, or run a one-off with `pnpm with current `.
+
+> Corepack still works but is no longer the recommended CI path (it adds a Node.js shim to every `pnpm` call). Prefer the standalone script above.
+
+## Monorepo CI Strategies
+
+### Build Changed Packages Only
+
+```yaml
+- name: Build changed packages
+ run: |
+ pnpm --filter "...[origin/main]" build
+```
+
+### Parallel Jobs per Package
+
+```yaml
+jobs:
+ detect-changes:
+ runs-on: ubuntu-latest
+ outputs:
+ packages: ${{ steps.changes.outputs.packages }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - id: changes
+ run: |
+ echo "packages=$(pnpm --filter '...[origin/main]' list --json | jq -c '[.[].name]')" >> $GITHUB_OUTPUT
+
+ test:
+ needs: detect-changes
+ if: needs.detect-changes.outputs.packages != '[]'
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ package: ${{ fromJson(needs.detect-changes.outputs.packages) }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm --filter ${{ matrix.package }} test
+```
+
+## Best Practices Summary
+
+1. **Use `pnpm ci` or `--frozen-lockfile`** in CI
+2. **Install via the standalone script** (`get.pnpm.io/install.sh`) rather than Corepack
+3. **Cache the pnpm store and metadata cache** (`pnpm cache path`), only across trusted jobs
+4. **Match the CI pnpm major** to the one that wrote the lockfile (CI fails on incompatible lockfiles)
+5. **Pin `packageManager`** (or `devEngines.packageManager`) in package.json
+6. **Use `--filter`** in monorepos to build only what changed
+7. **Multi-stage Docker builds**; set `PATH=$PNPM_HOME/bin:$PATH`
+
+
+
diff --git a/.agents/skills/pnpm/references/best-practices-migration.md b/.agents/skills/pnpm/references/best-practices-migration.md
new file mode 100644
index 00000000..0eeea3fd
--- /dev/null
+++ b/.agents/skills/pnpm/references/best-practices-migration.md
@@ -0,0 +1,328 @@
+---
+name: migration-to-pnpm
+description: Migrating from npm or Yarn to pnpm with minimal friction
+---
+
+# Migration to pnpm
+
+Guide for migrating existing projects from npm or Yarn to pnpm, plus upgrading pnpm v10 → v11 and v11 → v12.
+
+## Upgrading pnpm v11 → v12
+
+pnpm 12 is a **Rust rewrite** and is stable. It keeps v11's commands, flags, settings, and lockfile format — upgrading is not a migration. Only these differ (six change a result, one fails outright):
+
+- **`pnpm install --resolution-only` is removed** and errors. Use `pnpm peers check` (reads issues from the lockfile). Grep CI scripts for it before switching.
+- **Git dependency resolution:** GitHub/GitLab/Bitbucket specifiers resolve via the host's HTTPS URL and pnpm never records an SSH URL — one lockfile works with or without SSH keys. Run `pnpm update ` once to re-resolve an old `git@…` entry. For private SSH, use `git config --global url."git@github.com:".insteadOf https://github.com/`.
+- **Naming a package manager** installs the tool, not the npm package: `pnpm add -g yarn` installs Yarn (not the Classic `yarn` package), `pnx node@22` runs that Node.js release. A specifier like `yarn@npm:yarn@1.22.22` still installs the package.
+- **Project-aware global bins:** a global `node`/`deno`/`bun` runs the version the current project pins.
+- **`packageImportMethod: auto` hardlinks first on Linux** (was clone-first). Use `clone`/`clone-or-copy` if you edit files in `node_modules`.
+- **Cyclic dependency graphs** produce deterministic lockfiles; the first re-resolving install rewrites cyclic peer variants (one-time diff).
+- **`engineStrict`** now fails when a package depends through regular `dependencies` on an incompatible engine even under an `optionalDependencies` subtree.
+
+> `latest` on npm still points at the v11 line; install v12 from the `latest-12` tag until it graduates.
+
+## Upgrading pnpm v10 → v11
+
+v11 changes how configuration is read. Most of it is mechanical — run the codemod:
+
+```bash
+cd /path/to/project
+pnpx codemod run pnpm-v10-to-v11
+```
+
+The codemod automatically:
+
+- **Moves `package.json#pnpm` settings into `pnpm-workspace.yaml`** (the `pnpm` field is no longer read).
+- **Splits `.npmrc`**: only auth/registry settings stay in `.npmrc`; every other key moves to `pnpm-workspace.yaml` as **camelCase** (e.g. `node-linker` → `nodeLinker`). Per-subproject `.npmrc` files become `packageConfigs[""]`.
+- **Consolidates build settings** (`onlyBuiltDependencies`, `neverBuiltDependencies`, `ignoredBuiltDependencies`, `onlyBuiltDependenciesFile`) into one `allowBuilds: { name: true|false }` map.
+- **Replaces** `managePackageManagerVersions`/`packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail: download|ignore|warn|error`.
+- **Renames** `allowNonAppliedPatches` → `allowUnusedPatches`, `auditConfig.ignoreCves` → `auditConfig.ignoreGhsas`.
+- **Converts** `useNodeVersion` → `devEngines.runtime`, and bumps `packageManager`.
+
+Manual follow-ups (not automatable):
+
+- Convert `CVE-…` IDs to `GHSA-…` in `auditConfig.ignoreGhsas`.
+- `ignorePatchFailures` removed — failed patches now always throw.
+- `npm_config_*` env vars → `pnpm_config_*` (CI, shell profiles, Docker).
+- `pnpm link ` → use a path (`pnpm link ./foo`); `pnpm link --global` → `pnpm add -g .`.
+- `pnpm install -g` (no args) and `pnpm server` removed.
+- A `package.json` script named `clean`/`setup`/`deploy`/`rebuild` now shadows the built-in — use `pnpm pm ` for the built-in.
+
+## Migrating from npm / Yarn
+
+## Quick Migration
+
+### From npm
+
+```bash
+# Remove npm lockfile and node_modules
+rm -rf node_modules package-lock.json
+
+# Install with pnpm
+pnpm install
+```
+
+### From Yarn
+
+```bash
+# Remove yarn lockfile and node_modules
+rm -rf node_modules yarn.lock
+
+# Install with pnpm
+pnpm install
+```
+
+### Import Existing Lockfile
+
+pnpm can import existing lockfiles:
+
+```bash
+# Import from npm or yarn lockfile
+pnpm import
+
+# This creates pnpm-lock.yaml from:
+# - package-lock.json (npm)
+# - yarn.lock (yarn)
+# - npm-shrinkwrap.json (npm)
+```
+
+## Handling Common Issues
+
+### Phantom Dependencies
+
+pnpm is strict about dependencies. If code imports a package not in `package.json`, it will fail.
+
+**Problem:**
+```js
+// Works with npm (hoisted), fails with pnpm
+import lodash from 'lodash' // Not in dependencies, installed by another package
+```
+
+**Solution:** Add missing dependencies explicitly:
+```bash
+pnpm add lodash
+```
+
+### Missing Peer Dependencies
+
+pnpm reports peer dependency issues by default.
+
+**Option 1:** Let pnpm auto-install (default in v8+):
+```yaml title="pnpm-workspace.yaml"
+autoInstallPeers: true
+```
+
+**Option 2:** Install manually:
+```bash
+pnpm add react react-dom
+```
+
+**Option 3:** Suppress warnings if acceptable:
+```yaml title="pnpm-workspace.yaml"
+peerDependencyRules:
+ ignoreMissing:
+ - react
+```
+
+### Symlink Issues
+
+Some tools don't work with symlinks. Use hoisted mode:
+
+```yaml title="pnpm-workspace.yaml"
+nodeLinker: hoisted
+```
+
+Or hoist specific packages:
+
+```yaml title="pnpm-workspace.yaml"
+publicHoistPattern:
+ - '*eslint*'
+ - '*babel*'
+```
+
+### Native Module Rebuilds
+
+If native modules fail, try:
+
+```bash
+# Rebuild all native modules
+pnpm rebuild
+
+# Or reinstall
+rm -rf node_modules
+pnpm install
+```
+
+## Monorepo Migration
+
+### From npm Workspaces
+
+1. Create `pnpm-workspace.yaml`:
+ ```yaml
+ packages:
+ - 'packages/*'
+ ```
+
+2. Update internal dependencies to use workspace protocol:
+ ```json
+ {
+ "dependencies": {
+ "@myorg/utils": "workspace:^"
+ }
+ }
+ ```
+
+3. Install:
+ ```bash
+ rm -rf node_modules packages/*/node_modules package-lock.json
+ pnpm install
+ ```
+
+### From Yarn Workspaces
+
+1. Remove Yarn-specific files:
+ ```bash
+ rm yarn.lock .yarnrc.yml
+ rm -rf .yarn
+ ```
+
+2. Create `pnpm-workspace.yaml` matching `workspaces` in package.json:
+ ```yaml
+ packages:
+ - 'packages/*'
+ ```
+
+3. Update `package.json` - remove Yarn workspace config if not needed:
+ ```json
+ {
+ // Remove "workspaces" field (optional, pnpm uses pnpm-workspace.yaml)
+ }
+ ```
+
+4. Convert workspace references:
+ ```json
+ // From Yarn
+ "@myorg/utils": "*"
+
+ // To pnpm
+ "@myorg/utils": "workspace:*"
+ ```
+
+### From Lerna
+
+pnpm can replace Lerna for most use cases:
+
+```bash
+# Lerna: run script in all packages
+lerna run build
+
+# pnpm equivalent
+pnpm -r run build
+
+# Lerna: run in specific package
+lerna run build --scope=@myorg/app
+
+# pnpm equivalent
+pnpm --filter @myorg/app run build
+
+# Lerna: publish
+lerna publish
+
+# pnpm: use changesets instead
+pnpm add -Dw @changesets/cli
+pnpm changeset
+pnpm changeset version
+pnpm publish -r
+```
+
+## Configuration Migration
+
+Keep only **auth/registry** in `.npmrc`; put everything else in `pnpm-workspace.yaml` (camelCase).
+
+```ini title=".npmrc (auth only, gitignored)"
+//registry.npmjs.org/:_authToken=${NPM_TOKEN}
+//npm.myorg.com/:_authToken=${MYORG_TOKEN}
+```
+
+```yaml title="pnpm-workspace.yaml"
+registries:
+ default: https://registry.npmjs.org/
+ '@myorg': https://npm.myorg.com/
+autoInstallPeers: true
+strictPeerDependencies: false
+```
+
+### Scripts Migration
+
+Most scripts work unchanged. Update pnpm-specific patterns:
+
+```json
+{
+ "scripts": {
+ // npm: recursive scripts
+ "build:all": "npm run build --workspaces",
+ // pnpm: use -r flag
+ "build:all": "pnpm -r run build",
+
+ // npm: run in specific workspace
+ "dev:app": "npm run dev -w packages/app",
+ // pnpm: use --filter
+ "dev:app": "pnpm --filter @myorg/app run dev"
+ }
+}
+```
+
+## CI/CD Migration
+
+Update CI configuration:
+
+```yaml
+# Before (npm)
+- run: npm ci
+
+# After (pnpm)
+- uses: pnpm/action-setup@v4
+- run: pnpm install --frozen-lockfile # or: pnpm ci
+```
+
+Add to `package.json` for Corepack:
+```json
+{
+ "packageManager": "pnpm@10.0.0"
+}
+```
+
+## Gradual Migration
+
+For large projects, migrate gradually:
+
+1. **Start with CI**: Use pnpm in CI, keep npm/yarn locally
+2. **Add pnpm-lock.yaml**: Run `pnpm import` to create lockfile
+3. **Test thoroughly**: Ensure builds work with pnpm
+4. **Update documentation**: Update README, CONTRIBUTING
+5. **Remove old files**: Delete old lockfiles after team adoption
+
+## Rollback Plan
+
+If migration causes issues:
+
+```bash
+# Remove pnpm files
+rm -rf node_modules pnpm-lock.yaml pnpm-workspace.yaml
+
+# Restore npm
+npm install
+
+# Or restore Yarn
+yarn install
+```
+
+Keep old lockfile in git history for easy rollback.
+
+
+
diff --git a/.agents/skills/pnpm/references/best-practices-performance.md b/.agents/skills/pnpm/references/best-practices-performance.md
new file mode 100644
index 00000000..36e3c808
--- /dev/null
+++ b/.agents/skills/pnpm/references/best-practices-performance.md
@@ -0,0 +1,261 @@
+---
+name: pnpm-performance-optimization
+description: Tips and tricks for faster installs and better performance
+---
+
+# pnpm Performance Optimization
+
+pnpm is fast by default, but these optimizations can make it even faster.
+
+## Install Optimizations
+
+### Use Frozen Lockfile
+
+Skip resolution when lockfile exists:
+
+```bash
+pnpm install --frozen-lockfile
+```
+
+This is faster because pnpm skips the resolution phase entirely.
+
+### Prefer Offline Mode
+
+Use cached packages when available:
+
+```bash
+pnpm install --prefer-offline
+```
+
+### Skip Optional Dependencies
+
+If you don't need optional deps:
+
+```bash
+pnpm install --no-optional
+```
+
+### Skip Scripts
+
+For CI or when scripts aren't needed:
+
+```bash
+pnpm install --ignore-scripts
+```
+
+**Caution:** Some packages require postinstall scripts to work correctly.
+
+### Only Build Specific Dependencies
+
+Build-script approval is a single `allowBuilds` map (replaces `onlyBuiltDependencies`/`neverBuiltDependencies`). Only allowed packages run install scripts:
+
+```yaml title="pnpm-workspace.yaml"
+allowBuilds:
+ esbuild: true
+ '@swc/core': true
+ core-js: false # explicitly skip
+```
+
+Packages not listed are treated as unreviewed (blocked by default). See `features-supply-chain-security` for the full build-approval workflow.
+
+## Store Optimizations
+
+### Side Effects Cache
+
+Cache native module build results (enabled by default):
+
+```yaml title="pnpm-workspace.yaml"
+sideEffectsCache: true
+```
+
+This caches the results of postinstall scripts, speeding up subsequent installs.
+
+### Global Virtual Store
+
+For many checkouts of the same repo (e.g. git worktrees / multiple agents), enable the global virtual store so each project's `node_modules` is just symlinks into one shared store — near-zero per-checkout cost. Auto-disabled in CI.
+
+```yaml title="pnpm-workspace.yaml"
+virtualStoreType: global
+```
+
+### Shared Store
+
+A single content-addressable store is used for all projects by default:
+
+```yaml title="pnpm-workspace.yaml"
+storeDir: ~/.local/share/pnpm/store
+```
+
+Benefits: packages downloaded once, hard links save disk space, faster cached installs.
+
+### Store Maintenance
+
+Periodically clean unused packages:
+
+```bash
+# Remove unreferenced packages
+pnpm store prune
+
+# Check store integrity
+pnpm store status
+```
+
+## Workspace Optimizations
+
+### Parallel Execution
+
+Run workspace scripts in parallel:
+
+```bash
+pnpm -r --parallel run build
+```
+
+Control concurrency:
+```yaml title="pnpm-workspace.yaml"
+workspaceConcurrency: 8
+```
+
+### Stream Output
+
+See output in real-time:
+
+```bash
+pnpm -r --stream run build
+```
+
+### Filter to Changed Packages
+
+Only build what changed:
+
+```bash
+# Build packages changed since main branch
+pnpm --filter "...[origin/main]" run build
+```
+
+### Topological Order
+
+Build dependencies before dependents:
+
+```bash
+pnpm -r run build
+# Automatically runs in topological order
+```
+
+For explicit sequential builds:
+```bash
+pnpm -r --workspace-concurrency=1 run build
+```
+
+## Network Optimizations
+
+Network/registry settings are camelCase in `pnpm-workspace.yaml` (registry URLs may also go in `registries`):
+
+```yaml title="pnpm-workspace.yaml"
+registries:
+ default: https://registry.npmmirror.com/
+fetchRetries: 3
+fetchRetryMintimeout: 10000
+fetchRetryMaxtimeout: 60000
+networkConcurrency: 16 # auto: clamp(workers x 3, 16, 64)
+httpProxy: http://proxy.company.com:8080
+httpsProxy: http://proxy.company.com:8080
+```
+
+## Lockfile Optimization
+
+### Single Lockfile (Monorepos)
+
+Use shared lockfile for all packages (default):
+
+```yaml title="pnpm-workspace.yaml"
+sharedWorkspaceLockfile: true
+```
+
+Benefits:
+- Single source of truth
+- Faster resolution
+- Consistent versions across workspace
+
+### Lockfile-only Mode
+
+Only update lockfile without installing:
+
+```bash
+pnpm install --lockfile-only
+```
+
+## Benchmarking
+
+### Compare Install Times
+
+```bash
+# Clean install
+rm -rf node_modules pnpm-lock.yaml
+time pnpm install
+
+# Cached install (with lockfile)
+rm -rf node_modules
+time pnpm install --frozen-lockfile
+
+# With store cache
+time pnpm install --frozen-lockfile --prefer-offline
+```
+
+### Profile Resolution
+
+Debug slow installs:
+
+```bash
+# Verbose logging
+pnpm install --reporter=append-only
+
+# Debug mode
+DEBUG=pnpm:* pnpm install
+```
+
+## Configuration Summary
+
+Optimized `pnpm-workspace.yaml` for performance:
+
+```yaml title="pnpm-workspace.yaml"
+# Install behavior
+autoInstallPeers: true
+sideEffectsCache: true
+optimisticRepeatInstall: true
+
+# Build approval (only what's necessary)
+allowBuilds:
+ esbuild: true
+ '@swc/core': true
+
+# Network
+fetchRetries: 3
+networkConcurrency: 16
+
+# Workspace
+workspaceConcurrency: 4
+
+# Many checkouts of the same repo
+virtualStoreType: global
+```
+
+## Quick Reference
+
+| Scenario | Command/Setting |
+|----------|-----------------|
+| CI installs | `pnpm ci` / `pnpm install --frozen-lockfile` |
+| Offline development | `--prefer-offline` |
+| Control native builds | `allowBuilds` map |
+| Parallel workspace | `pnpm -r --parallel run build` |
+| Build changed only | `pnpm --filter "...[origin/main]" build` |
+| Clean store | `pnpm store prune` |
+| Many worktrees/agents | `virtualStoreType: global` |
+
+
+
diff --git a/.agents/skills/pnpm/references/core-cli.md b/.agents/skills/pnpm/references/core-cli.md
new file mode 100644
index 00000000..e18a7687
--- /dev/null
+++ b/.agents/skills/pnpm/references/core-cli.md
@@ -0,0 +1,257 @@
+---
+name: pnpm-cli-commands
+description: Essential pnpm commands for package management, running scripts, workspaces, publishing, and runtimes
+---
+
+# pnpm CLI Commands
+
+pnpm provides a comprehensive CLI. Commands resemble npm/yarn but with unique features.
+
+> **pnpm v12 is a Rust rewrite** of v11, stable, keeping v11's commands, flags, settings, and lockfile format. A few behaviors differ (see best-practices-migration). One removed flag fails outright: `pnpm install --resolution-only` is gone — use `pnpm peers check`.
+
+## Installation Commands
+
+```bash
+pnpm install # install all deps (alias: pnpm i)
+pnpm add # production dependency
+pnpm add -D # devDependency (also -d)
+pnpm add -O # optionalDependency (also -o)
+pnpm add -E # exact version (also -e)
+pnpm add @
+pnpm remove # aliases: rm, uninstall, un
+pnpm update # alias: up
+pnpm update --latest # ignore semver ranges (-L)
+pnpm update -i # interactive
+```
+
+### Clean / reproducible installs
+
+```bash
+pnpm install --frozen-lockfile # fail if lockfile would change (auto in CI)
+pnpm ci # clean install = pnpm clean + install --frozen-lockfile
+pnpm clean # remove node_modules in all workspace projects (alias: purge)
+pnpm clean --lockfile # also delete pnpm-lock.yaml
+```
+
+> Since v11, an integrity mismatch against the lockfile is a hard error (`ERR_PNPM_TARBALL_INTEGRITY`). Use `pnpm install --update-checksums` only after verifying the new bytes. In CI, pnpm also fails on lockfiles written by a newer pnpm major.
+
+## Script Commands
+
+```bash
+pnpm run
+```
+
+## CSS Modules
+
+Any `.module.css` file treated as CSS module:
+
+```ts
+import styles from './component.module.css'
+element.className = styles.button
+```
+
+With camelCase conversion:
+
+```ts
+// .my-class -> myClass (if css.modules.localsConvention configured)
+import { myClass } from './component.module.css'
+```
+
+## JSON Import
+
+```ts
+import pkg from './package.json'
+import { version } from './package.json' // Named import with tree-shaking
+```
+
+## HMR API
+
+```ts
+if (import.meta.hot) {
+ import.meta.hot.accept((newModule) => {
+ // Handle update
+ })
+
+ import.meta.hot.dispose((data) => {
+ // Cleanup before module is replaced
+ })
+
+ import.meta.hot.invalidate() // Force full reload
+}
+```
+
+## WebAssembly
+
+### ESM Integration (Vite 8)
+
+Import a `.wasm` file directly — Vite instantiates it and re-exposes its exports as named ESM exports. Behaves as an async module (needs top-level `await`):
+
+```js
+import { add } from './add.wasm'
+console.log(add(1, 2)) // 3
+```
+
+For TS support, enable `allowArbitraryExtensions` and add `add.d.wasm.ts`. SSR builds rely on `node:fs`, so this only works in Node-compatible runtimes.
+
+### Manual Initialization
+
+```js
+import init from './example.wasm?init'
+const instance = await init({ imports: { /* ... */ } })
+instance.exports.test()
+```
+
+## Chunk Import Map Optimization (Vite 8, experimental)
+
+Set `build.chunkImportMap: true` to reference chunks by stable ID via an import map instead of hashed URLs. Prevents cascading cache invalidation (updating a leaf chunk no longer invalidates its importers). Requires `import.meta.resolve` support (use `@vitejs/plugin-legacy` for older browsers).
+
+
diff --git a/.agents/skills/vite/references/core-plugin-api.md b/.agents/skills/vite/references/core-plugin-api.md
new file mode 100644
index 00000000..a1ee1339
--- /dev/null
+++ b/.agents/skills/vite/references/core-plugin-api.md
@@ -0,0 +1,303 @@
+---
+name: vite-plugin-api
+description: Vite plugin authoring with Vite-specific hooks
+---
+
+# Vite Plugin API
+
+Vite plugins extend Rolldown's plugin interface with Vite-specific hooks.
+
+## Basic Structure
+
+```ts
+function myPlugin(): Plugin {
+ return {
+ name: 'my-plugin',
+ // hooks...
+ }
+}
+```
+
+## Vite-Specific Hooks
+
+### config
+
+Modify config before resolution:
+
+```ts
+const plugin = () => ({
+ name: 'add-alias',
+ config: () => ({
+ resolve: {
+ alias: { foo: 'bar' },
+ },
+ }),
+})
+```
+
+### configResolved
+
+Access final resolved config:
+
+```ts
+const plugin = () => {
+ let config: ResolvedConfig
+ return {
+ name: 'read-config',
+ configResolved(resolvedConfig) {
+ config = resolvedConfig
+ },
+ transform(code, id) {
+ if (config.command === 'serve') { /* dev */ }
+ },
+ }
+}
+```
+
+### configureServer
+
+Add custom middleware to dev server:
+
+```ts
+const plugin = () => ({
+ name: 'custom-middleware',
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ // handle request
+ next()
+ })
+ },
+})
+```
+
+Return function to run **after** internal middlewares:
+
+```ts
+configureServer(server) {
+ return () => {
+ server.middlewares.use((req, res, next) => {
+ // runs after Vite's middlewares
+ })
+ }
+}
+```
+
+### transformIndexHtml
+
+Transform HTML entry files:
+
+```ts
+const plugin = () => ({
+ name: 'html-transform',
+ transformIndexHtml(html) {
+ return html.replace(/(.*?)<\/title>/, 'New Title ')
+ },
+})
+```
+
+Inject tags:
+
+```ts
+transformIndexHtml() {
+ return [
+ { tag: 'script', attrs: { src: '/inject.js' }, injectTo: 'body' },
+ ]
+}
+```
+
+### handleHotUpdate
+
+Custom HMR handling:
+
+```ts
+handleHotUpdate({ server, modules, timestamp }) {
+ server.ws.send({ type: 'custom', event: 'special-update', data: {} })
+ return [] // empty = skip default HMR
+}
+```
+
+## Virtual Modules
+
+Serve virtual content without files on disk:
+
+```ts
+const plugin = () => {
+ const virtualModuleId = 'virtual:my-module'
+ const resolvedId = '\0' + virtualModuleId
+
+ return {
+ name: 'virtual-module',
+ resolveId(id) {
+ if (id === virtualModuleId) return resolvedId
+ },
+ load(id) {
+ if (id === resolvedId) {
+ return `export const msg = "from virtual module"`
+ }
+ },
+ }
+}
+```
+
+Usage:
+
+```ts
+import { msg } from 'virtual:my-module'
+```
+
+Convention: prefix user-facing path with `virtual:`, prefix resolved id with `\0`.
+
+## Plugin Ordering
+
+Use `enforce` to control execution order:
+
+```ts
+{
+ name: 'pre-plugin',
+ enforce: 'pre', // runs before core plugins
+}
+
+{
+ name: 'post-plugin',
+ enforce: 'post', // runs after build plugins
+}
+```
+
+Order: Alias → `enforce: 'pre'` → Core → User (no enforce) → Build → `enforce: 'post'` → Post-build
+
+## Conditional Application
+
+```ts
+{
+ name: 'build-only',
+ apply: 'build', // or 'serve'
+}
+
+// Function form:
+{
+ apply(config, { command }) {
+ return command === 'build' && !config.build.ssr
+ }
+}
+```
+
+## Rolldown Hooks (dev + build)
+
+These come from Rolldown and are **per-environment** (`this.environment` available):
+
+- `resolveId(id, importer)` - Resolve import paths
+- `load(id)` - Load module content
+- `transform(code, id)` - Transform module code
+
+```ts
+transform(code, id) {
+ if (id.endsWith('.custom')) {
+ return { code: compile(code), map: null }
+ }
+}
+```
+
+### Hook Filters (Vite 8)
+
+Prefer the object form with `filter` + `handler` for `transform`/`resolveId`/`load` — filtering runs in Rust, avoiding a JS call per module:
+
+```ts
+import { exactRegex } from '@rolldown/pluginutils' // also from 'rolldown/filter'
+
+const plugin = () => ({
+ name: 'transform-file',
+ transform: {
+ filter: { id: /\.custom$/ },
+ handler(code, id) {
+ return { code: compile(code), map: null }
+ },
+ },
+})
+```
+
+`@rolldown/pluginutils` exports helpers like `exactRegex` and `prefixRegex`.
+
+## Per-Environment vs Global Hooks (Vite 8)
+
+- **Global** (called once, no `this.environment`): `config`, `configResolved`, `configureServer`, `configurePreviewServer`, `closeServer`, `closePreviewServer`, `buildApp`.
+- **Per-environment** (called per environment, expose `this.environment`): all Rolldown hooks, `transformIndexHtml`, `handleHotUpdate`/`hotUpdate`, `configEnvironment`, `applyToEnvironment`.
+
+## Cleanup Hooks (Vite 8)
+
+`closeServer({ reason })` runs after the dev server is torn down (`reason` is `'restart'` or `'close'`); `closePreviewServer()` is the preview equivalent. Use to dispose resources created in `configureServer`.
+
+```ts
+{
+ name: 'close-server',
+ configureServer(server) { this.resource = createResource() },
+ async closeServer({ reason }) {
+ if (reason === 'close') await this.resource.dispose()
+ },
+}
+```
+
+## Plugin Context Meta (Vite 8)
+
+- `this.meta.viteVersion` — current Vite version string.
+- `this.meta.rolldownVersion` — only defined on Rolldown-powered Vite (8+); use it to branch behavior.
+
+## Output Bundle Metadata
+
+During build, Vite augments Rolldown output objects with `viteMetadata` — inspect emitted CSS/assets without `build.manifest`:
+
+```ts
+{
+ name: 'output-metadata',
+ enforce: 'post',
+ generateBundle(_, bundle) {
+ for (const output of Object.values(bundle)) {
+ const css = output.viteMetadata?.importedCss // Set
+ const assets = output.viteMetadata?.importedAssets // Set
+ }
+ },
+}
+```
+
+## Referencing Emitted Assets
+
+`this.emitFile({ type: 'asset', ... })` returns a `referenceId`; resolve its final URL later:
+
+- In JS: `import.meta.ROLLDOWN_FILE_URL_`
+- In CSS/HTML: `__VITE_ASSET____`
+
+## Client-Server Communication
+
+Server to client:
+
+```ts
+configureServer(server) {
+ server.ws.send('my:event', { msg: 'hello' })
+}
+```
+
+Client side:
+
+```ts
+if (import.meta.hot) {
+ import.meta.hot.on('my:event', (data) => {
+ console.log(data.msg)
+ })
+}
+```
+
+Client to server:
+
+```ts
+// Client
+import.meta.hot.send('my:from-client', { msg: 'Hey!' })
+
+// Server
+server.ws.on('my:from-client', (data, client) => {
+ client.send('my:ack', { msg: 'Got it!' })
+})
+```
+
+
diff --git a/.agents/skills/vite/references/environment-api.md b/.agents/skills/vite/references/environment-api.md
new file mode 100644
index 00000000..bbf77adc
--- /dev/null
+++ b/.agents/skills/vite/references/environment-api.md
@@ -0,0 +1,150 @@
+---
+name: vite-environment-api
+description: Vite 6+ Environment API for multiple runtime environments
+---
+
+# Environment API (Vite 6+)
+
+The Environment API formalizes multiple runtime environments beyond the traditional client/SSR split.
+
+## Concept
+
+Before Vite 6: Two implicit environments (`client` and `ssr`).
+
+Vite 6+: Configure as many environments as needed (browser, node server, edge server, etc.).
+
+## Basic Configuration
+
+For SPA/MPA, nothing changes—options apply to the implicit `client` environment:
+
+```ts
+export default defineConfig({
+ build: { sourcemap: false },
+ optimizeDeps: { include: ['lib'] },
+})
+```
+
+## Multiple Environments
+
+```ts
+export default defineConfig({
+ build: { sourcemap: false }, // Inherited by all environments
+ optimizeDeps: { include: ['lib'] }, // Client only
+ environments: {
+ // SSR environment
+ server: {},
+ // Edge runtime environment
+ edge: {
+ resolve: { noExternal: true },
+ },
+ },
+})
+```
+
+Environments inherit top-level config. Some options (like `optimizeDeps`) only apply to `client` by default.
+
+## Environment Options
+
+```ts
+interface EnvironmentOptions {
+ define?: Record
+ resolve?: EnvironmentResolveOptions
+ optimizeDeps: DepOptimizationOptions
+ consumer?: 'client' | 'server'
+ dev: DevOptions
+ build: BuildOptions
+}
+```
+
+## Custom Environment Instances
+
+Runtime providers can define custom environments:
+
+```ts
+import { customEnvironment } from 'vite-environment-provider'
+
+export default defineConfig({
+ environments: {
+ ssr: customEnvironment({
+ build: { outDir: '/dist/ssr' },
+ }),
+ },
+})
+```
+
+Example: Cloudflare's Vite plugin runs code in `workerd` runtime during development.
+
+## Dev Environment Types
+
+Environments run modules through a runner. Two dev environment shapes exist:
+
+- **`RunnableDevEnvironment`** — evaluates modules in the **same runtime** as the Vite server, so it exchanges arbitrary JS values in-process. Its `runner.import(url)` is the modern replacement for `server.ssrLoadModule` (full HMR). Default for `ssr` and other non-client envs. Guard with `isRunnableDevEnvironment(env)`.
+
+```ts
+if (isRunnableDevEnvironment(server.environments.ssr)) {
+ const mod = await server.environments.ssr.runner.import('/src/entry-server.ts')
+}
+```
+
+- **`FetchableDevEnvironment`** — communicates only via the **Fetch API** (`Request`/`Response`), so it works for runtimes that can't run Vite directly (e.g. Cloudflare Workers). Preferred over `RunnableDevEnvironment` for such runtimes. Handle requests with `handleRequest`; dispatch via `env.dispatchFetch(new Request(url))`.
+
+## Building Environments
+
+`vite build` / `vite build --ssr` still build only client/ssr for back-compat. Setting the `builder` option (even `{}`, which is what `vite build --app` does) opts into building **all** environments.
+
+```js [build.js]
+import { createBuilder } from 'vite'
+
+const builder = await createBuilder() // build-time equivalent of createServer
+await builder.buildApp() // build every configured environment
+// await builder.build(builder.environments.ssr) // or one at a time
+```
+
+`createBuilder` supersedes the standalone `build()` for environment-aware builds. Customize with the `builder.buildApp` config option or the global `buildApp` plugin hook (use `environment.isBuilt` to skip already-built envs).
+
+## Per-Environment Plugins
+
+Restrict a plugin to specific environments with `applyToEnvironment`, or configure each env with the `configEnvironment` hook (set defaults in `config`, since the full env list isn't known there):
+
+```ts
+{
+ name: 'ssr-only',
+ applyToEnvironment(env) { return env.name === 'ssr' },
+}
+```
+
+## Backward Compatibility
+
+- `server.moduleGraph` returns mixed client/SSR view
+- `ssrLoadModule` still works
+- Existing SSR apps work unchanged
+
+## When to Use
+
+- **End users**: Usually don't need to configure—frameworks handle it
+- **Plugin authors**: Use for environment-aware transformations
+- **Framework authors**: Create custom environments for their runtime needs
+
+## Plugin Environment Access
+
+Plugins can access environment in hooks:
+
+```ts
+{
+ name: 'env-aware',
+ transform(code, id, options) {
+ if (options?.ssr) {
+ // SSR-specific transform
+ }
+ },
+}
+```
+
+
diff --git a/.agents/skills/vite/references/rolldown-migration.md b/.agents/skills/vite/references/rolldown-migration.md
new file mode 100644
index 00000000..de37f69e
--- /dev/null
+++ b/.agents/skills/vite/references/rolldown-migration.md
@@ -0,0 +1,172 @@
+---
+name: vite-rolldown
+description: Vite 8 Rolldown bundler and Oxc transformer migration
+---
+
+# Rolldown Migration (Vite 8)
+
+Vite 8 replaces esbuild + Rollup with [Rolldown](https://rolldown.rs/) (Rust bundler) and [Oxc](https://oxc.rs/) (Rust transformer/minifier).
+
+## What Changed
+
+| Before (Vite 7) | After (Vite 8) |
+|-----------------|----------------|
+| esbuild (dev/JS transform) | Oxc Transformer |
+| esbuild (dep pre-bundling) | Rolldown |
+| esbuild (CSS minify) | Lightning CSS (default) |
+| Rollup (production build) | Rolldown |
+| `build.rollupOptions` | `build.rolldownOptions` |
+| `worker.rollupOptions` | `worker.rolldownOptions` |
+| `optimizeDeps.esbuildOptions` | `optimizeDeps.rolldownOptions` |
+| `esbuild` option | `oxc` option |
+
+## Backward Compatibility (Auto-Conversion)
+
+Old options are **deprecated but still work** — Vite converts them internally:
+
+- `esbuild` → `oxc`
+- `build.rollupOptions` → `build.rolldownOptions` (alias)
+- `optimizeDeps.esbuildOptions` → `optimizeDeps.rolldownOptions`
+
+Migrate to the new names; the compat shims will be removed in a future major. Read the converted values from the `configResolved` hook (`config.oxc`, `config.optimizeDeps.rolldownOptions`).
+
+## Default Browser Target Change
+
+`build.target` / `'baseline-widely-available'` moved to newer versions (Baseline Widely Available as of 2026-01-01):
+
+- Chrome 107 → 111, Edge 107 → 111, Firefox 104 → 114, Safari 16.0 → 16.4
+- Resolves to `['chrome111', 'edge111', 'firefox114', 'safari16.4', 'ios16.4']`
+- Default `--minify` is now `oxc` (was `esbuild`)
+
+## Config Migration
+
+### rollupOptions → rolldownOptions
+
+```ts
+export default defineConfig({
+ build: {
+ rolldownOptions: { // was rollupOptions
+ external: ['vue'],
+ output: { globals: { vue: 'Vue' } },
+ },
+ },
+})
+```
+
+Prefer the top-level [`input`](../references/core-config.md) option over `build.rolldownOptions.input` — it applies in dev too.
+
+### esbuild → oxc
+
+```ts
+export default defineConfig({
+ oxc: { // was esbuild
+ jsx: {
+ runtime: 'classic',
+ pragma: 'h',
+ pragmaFrag: 'Fragment',
+ },
+ },
+})
+```
+
+`esbuild.jsxFactory`→`oxc.jsx.pragma`, `jsxFragment`→`oxc.jsx.pragmaFrag`, `jsx: 'transform'`→`oxc.jsx: { runtime: 'classic' }`, `jsx: 'automatic'`→`{ runtime: 'automatic' }`. `esbuild.banner`/`footer` have no equivalent — use a custom plugin `transform` hook.
+
+### JSX Configuration
+
+```ts
+export default defineConfig({
+ oxc: {
+ jsx: {
+ runtime: 'automatic', // or 'classic'
+ importSource: 'react', // for automatic runtime
+ },
+ jsxInject: `import React from 'react'`, // auto-inject
+ },
+})
+```
+
+### Custom Transform Targets
+
+```ts
+export default defineConfig({
+ oxc: {
+ include: ['**/*.ts', '**/*.tsx'],
+ exclude: ['node_modules/**'],
+ },
+})
+```
+
+## Notable Breaking Changes
+
+- **`build()` throws `BundleError`** (JS API): typed `Error & { errors?: RolldownError[] }`. Access individual errors via `e.errors`.
+- **`build.rollupOptions.output.manualChunks`**: object form removed, function form deprecated. Use Rolldown's [`codeSplitting`](https://rolldown.rs/reference/OutputOptions.codeSplitting).
+- **`build.rollupOptions.watch.chokidar`** removed → `build.rolldownOptions.watch.watcher`.
+- **`resolve.alias[].customResolver`** deprecated → use a custom plugin with a `resolveId` hook and `enforce: 'pre'`.
+- **`build.commonjsOptions`** is now a no-op.
+- **`require` calls for externalized modules** are preserved as `require`. To convert to `import`, use the re-exported `esmExternalRequirePlugin`.
+- **`import.meta.hot.accept(url)`** no longer accepts a URL — pass an id.
+- Unsupported by Rolldown: output format `'system'`/`'amd'`, `shouldTransformCachedModule`, `resolveImportMeta`, `renderDynamicImport`, `resolveFileUrl` hooks.
+- Native decorator lowering is not yet supported by Oxc — use `@rolldown/plugin-babel` or `@rollup/plugin-swc` as a workaround.
+
+## Detecting Rolldown-powered Vite
+
+```ts
+buildStart() {
+ // only defined on Vite 8+ (Rolldown)
+ if (this.meta.rolldownVersion) { /* ... */ }
+}
+```
+
+Also exported from `vite`: `version`, `rolldownVersion` (`esbuildVersion`/`rollupVersion` kept only for back-compat).
+
+## Plugin Compatibility
+
+Most Vite plugins work unchanged — Rolldown supports Rollup's plugin API. Rolldown runs all parallel Rollup hooks sequentially. If a plugin only works during build:
+
+```ts
+{
+ ...rollupPlugin(),
+ enforce: 'post',
+ apply: 'build',
+}
+```
+
+## New Capabilities
+
+- Full bundle mode (experimental, `vite --experimentalBundle`)
+- Chunk import map optimization (`build.chunkImportMap`)
+- More flexible chunk splitting / Module Federation support
+
+## Gradual Migration
+
+`rolldown-vite` is Vite 7 running on Rolldown *without* other Vite 8 changes — an intermediate step:
+
+```bash
+# Step 1: swap vite for rolldown-vite on Vite 7 (npm alias in package.json)
+# "vite": "npm:rolldown-vite@^7"
+# Step 2: undo the alias and upgrade to Vite 8
+pnpm add -D vite@8
+```
+
+If migrating from `rolldown-vite`, only the "NRV"-badged sections of the official migration guide apply.
+
+## Overriding Vite in Frameworks
+
+When a framework depends on older Vite:
+
+```json
+{
+ "pnpm": {
+ "overrides": {
+ "vite": "8.0.0"
+ }
+ }
+}
+```
+
+
diff --git a/.agents/skills/vitest/GENERATION.md b/.agents/skills/vitest/GENERATION.md
new file mode 100644
index 00000000..a4179187
--- /dev/null
+++ b/.agents/skills/vitest/GENERATION.md
@@ -0,0 +1,5 @@
+# Generation Info
+
+- **Source:** `sources/vitest`
+- **Git SHA:** `ea1c44fb581978421f9f0901ce8c635970ea72fb`
+- **Generated:** 2026-09-25
diff --git a/.agents/skills/vitest/SKILL.md b/.agents/skills/vitest/SKILL.md
new file mode 100644
index 00000000..642bca61
--- /dev/null
+++ b/.agents/skills/vitest/SKILL.md
@@ -0,0 +1,55 @@
+---
+name: vitest
+description: Vitest fast unit testing framework powered by Vite with Jest-compatible API. Use when writing tests, mocking, configuring coverage, or working with test filtering and fixtures.
+metadata:
+ author: Anthony Fu
+ version: "2026.9.25"
+ source: Generated from https://github.com/vitest-dev/vitest, scripts located at https://github.com/antfu/skills
+---
+
+Vitest is a next-generation testing framework powered by Vite. It provides a Jest-compatible API with native ESM, TypeScript, and JSX support out of the box. Vitest shares the same config, transformers, resolvers, and plugins with your Vite app.
+
+**Key Features:**
+- Vite-native: Uses Vite's transformation pipeline for fast HMR-like test updates
+- Jest-compatible: Drop-in replacement for most Jest test suites
+- Smart watch mode: Only reruns affected tests based on module graph
+- Native ESM, TypeScript, JSX support without configuration
+- Multi-threaded workers for parallel test execution
+- Built-in coverage via V8 or Istanbul
+- Snapshot testing, mocking, and spy utilities
+
+> The skill is based on Vitest 5.0.1, generated at 2026-09-25.
+
+## Core
+
+| Topic | Description | Reference |
+|-------|-------------|-----------|
+| Configuration | Vitest and Vite config integration, defineConfig usage | [core-config](references/core-config.md) |
+| CLI | Command line interface, commands and options | [core-cli](references/core-cli.md) |
+| Test API | test/it function, modifiers like skip, only, concurrent | [core-test-api](references/core-test-api.md) |
+| Describe API | describe/suite for grouping tests and nested suites | [core-describe](references/core-describe.md) |
+| Expect API | Assertions with toBe, toEqual, matchers and asymmetric matchers | [core-expect](references/core-expect.md) |
+| Hooks | beforeEach, afterEach, beforeAll, afterAll, aroundEach | [core-hooks](references/core-hooks.md) |
+
+## Features
+
+| Topic | Description | Reference |
+|-------|-------------|-----------|
+| Mocking | Mock functions, modules, timers, dates with vi utilities | [features-mocking](references/features-mocking.md) |
+| Snapshots | Snapshot testing with toMatchSnapshot and inline snapshots | [features-snapshots](references/features-snapshots.md) |
+| Coverage | Code coverage with V8 or Istanbul providers | [features-coverage](references/features-coverage.md) |
+| Test Context | Test fixtures, context.expect, test.extend for custom fixtures | [features-context](references/features-context.md) |
+| Concurrency | Concurrent tests, parallel execution, sharding | [features-concurrency](references/features-concurrency.md) |
+| Filtering | Filter tests by name, file patterns, tags | [features-filtering](references/features-filtering.md) |
+| Test Tags | Label tests with tags to filter runs and apply shared options | [features-test-tags](references/features-test-tags.md) |
+| Reporters | Built-in reporters, default selection, CI/output config | [features-reporters](references/features-reporters.md) |
+| Benchmarking | Write benchmarks with the bench fixture (Tinybench) | [features-benchmarking](references/features-benchmarking.md) |
+
+## Advanced
+
+| Topic | Description | Reference |
+|-------|-------------|-----------|
+| Vi Utilities | vi helper: mock, spyOn, fake timers, hoisted, waitFor | [advanced-vi](references/advanced-vi.md) |
+| Environments | Test environments: node, jsdom, happy-dom, custom | [advanced-environments](references/advanced-environments.md) |
+| Type Testing | Type-level testing with expectTypeOf and assertType | [advanced-type-testing](references/advanced-type-testing.md) |
+| Projects | Multi-project workspaces, different configs per project | [advanced-projects](references/advanced-projects.md) |
diff --git a/.agents/skills/vitest/references/advanced-environments.md b/.agents/skills/vitest/references/advanced-environments.md
new file mode 100644
index 00000000..ded10d39
--- /dev/null
+++ b/.agents/skills/vitest/references/advanced-environments.md
@@ -0,0 +1,272 @@
+---
+name: test-environments
+description: Configure environments like jsdom, happy-dom for browser APIs
+---
+
+# Test Environments
+
+## Available Environments
+
+- `node` (default) - Node.js environment
+- `jsdom` - Browser-like with DOM APIs
+- `happy-dom` - Faster alternative to jsdom
+- `edge-runtime` - Vercel Edge Runtime
+
+## Configuration
+
+```ts
+// vitest.config.ts
+defineConfig({
+ test: {
+ environment: 'jsdom',
+
+ // Environment-specific options
+ environmentOptions: {
+ jsdom: {
+ url: 'http://localhost',
+ },
+ },
+ },
+})
+```
+
+## Installing Environment Packages
+
+```bash
+# jsdom
+npm i -D jsdom
+
+# happy-dom (faster, fewer APIs)
+npm i -D happy-dom
+```
+
+## Per-File Environment
+
+Use magic comment at top of file:
+
+```ts
+// @vitest-environment jsdom
+
+import { expect, test } from 'vitest'
+
+test('DOM test', () => {
+ const div = document.createElement('div')
+ expect(div).toBeInstanceOf(HTMLDivElement)
+})
+```
+
+## jsdom Environment
+
+Full browser environment simulation:
+
+```ts
+// @vitest-environment jsdom
+
+test('DOM manipulation', () => {
+ document.body.innerHTML = '
'
+
+ const app = document.getElementById('app')
+ app.textContent = 'Hello'
+
+ expect(app.textContent).toBe('Hello')
+})
+
+test('window APIs', () => {
+ expect(window.location.href).toBeDefined()
+ expect(localStorage).toBeDefined()
+})
+```
+
+### jsdom Options
+
+```ts
+defineConfig({
+ test: {
+ environmentOptions: {
+ jsdom: {
+ url: 'http://localhost:3000',
+ html: '',
+ userAgent: 'custom-agent',
+ resources: 'usable',
+ },
+ },
+ },
+})
+```
+
+## happy-dom Environment
+
+Faster but fewer APIs:
+
+```ts
+// @vitest-environment happy-dom
+
+test('basic DOM', () => {
+ const el = document.createElement('div')
+ el.className = 'test'
+ expect(el.className).toBe('test')
+})
+```
+
+## Multiple Environments per Project
+
+Use projects for different environments:
+
+```ts
+defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'unit',
+ include: ['tests/unit/**/*.test.ts'],
+ environment: 'node',
+ },
+ },
+ {
+ test: {
+ name: 'dom',
+ include: ['tests/dom/**/*.test.ts'],
+ environment: 'jsdom',
+ },
+ },
+ ],
+ },
+})
+```
+
+## Custom Environment
+
+Create custom environment package:
+
+```ts
+// vitest-environment-custom/index.ts
+import type { Environment } from 'vitest/runtime'
+
+export default {
+ name: 'custom',
+ viteEnvironment: 'ssr', // or 'client'
+
+ setup() {
+ // Setup global state
+ globalThis.myGlobal = 'value'
+
+ return {
+ teardown() {
+ delete globalThis.myGlobal
+ },
+ }
+ },
+}
+```
+
+Use with:
+
+```ts
+defineConfig({
+ test: {
+ environment: 'custom',
+ },
+})
+```
+
+## Environment with VM
+
+For full isolation:
+
+```ts
+export default {
+ name: 'isolated',
+ viteEnvironment: 'ssr',
+
+ async setupVM() {
+ const vm = await import('node:vm')
+ const context = vm.createContext()
+
+ return {
+ getVmContext() {
+ return context
+ },
+ teardown() {},
+ }
+ },
+
+ setup() {
+ return { teardown() {} }
+ },
+}
+```
+
+## Browser Mode (Separate from Environments)
+
+For real browser testing, use Vitest Browser Mode. In **v4 the provider is an object** (not a string), and the context imports from `vitest/browser`:
+
+```ts
+import { playwright } from '@vitest/browser-playwright'
+
+defineConfig({
+ test: {
+ browser: {
+ enabled: true,
+ provider: playwright({ launchOptions: { slowMo: 100 } }),
+ instances: [{ browser: 'chromium' }], // or 'firefox', 'webkit'
+ },
+ },
+})
+```
+
+```ts
+import { page } from 'vitest/browser' // v4: was '@vitest/browser/context'
+```
+
+> v5: DOM-environment global assignments (e.g. `window.innerWidth`) now propagate to the underlying jsdom/happy-dom implementation. Locators are exact/strict by default (`getByText('Item')` no longer matches `Item 1`). `browser.api` is deprecated — move it to the top-level `api` option. Browser mode adds a built-in Trace View (`browser.traceView: true`).
+
+## CSS and Assets
+
+In jsdom/happy-dom, configure CSS handling:
+
+```ts
+defineConfig({
+ test: {
+ css: true, // Process CSS
+
+ // Or with options
+ css: {
+ include: /\.module\.css$/,
+ modules: {
+ classNameStrategy: 'non-scoped',
+ },
+ },
+ },
+})
+```
+
+## Fixing External Dependencies
+
+If external deps fail with CSS/asset errors:
+
+```ts
+defineConfig({
+ test: {
+ server: {
+ deps: {
+ inline: ['problematic-package'],
+ },
+ },
+ },
+})
+```
+
+## Key Points
+
+- Default is `node` - no browser APIs
+- Use `jsdom` for full browser simulation
+- Use `happy-dom` for faster tests with basic DOM
+- Per-file environment via `// @vitest-environment` comment
+- Use projects for multiple environment configurations
+- Browser Mode is for real browser testing, not environment
+
+
diff --git a/.agents/skills/vitest/references/advanced-projects.md b/.agents/skills/vitest/references/advanced-projects.md
new file mode 100644
index 00000000..b9534f5a
--- /dev/null
+++ b/.agents/skills/vitest/references/advanced-projects.md
@@ -0,0 +1,341 @@
+---
+name: projects-workspaces
+description: Multi-project configuration for monorepos and different test types
+---
+
+# Projects
+
+Run different test configurations in the same Vitest process.
+
+## v5 Config Inheritance & Nested Projects
+
+- **Inline projects inherit the root config by default** — `extends` now defaults to `true`, so root Vite options (`plugins`, `resolve.alias`) and test options are inherited. Arrays like `setupFiles` are appended, not replaced. Opt out with `extends: false`, or inherit from another file with `extends: './vitest.shared.ts'`. Projects referenced as config files/directories still don't inherit the root.
+- **Referenced config files can declare their own `projects`** — such a config acts as a container providing *nested projects* named `app (unit)`, `app (e2e)`, etc. In v4 a referenced config's `projects` field was silently ignored, so audit merged configs that pull one in.
+- **Inline projects share the declaring config's Vite server by default** ([`sharedViteServer`](core-config.md)) — the declaring config runs once, so plugin `config` hooks no longer run per project. A project gets its own server only when it changes the Vite config (`plugins`, `alias`, `css`, `deps.optimizer`, `root`, `browser`, `mode`). Set `sharedViteServer: false` if a plugin must be re-instantiated per project.
+
+```ts
+import { defineConfig } from 'vitest/config'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()], // inherited by every inline project (v4 needed extends: true)
+ test: {
+ projects: [
+ { test: { name: 'unit', include: ['**/*.unit.test.ts'] } },
+ // a package that declares its own projects becomes a nested container:
+ './packages/app/vitest.config.ts', // -> "app (unit)", "app (e2e)", ...
+ ],
+ },
+})
+```
+
+## Basic Projects Setup
+
+```ts
+// vitest.config.ts
+defineConfig({
+ test: {
+ projects: [
+ // Glob patterns for config files
+ 'packages/*',
+
+ // Inline config
+ {
+ test: {
+ name: 'unit',
+ include: ['tests/unit/**/*.test.ts'],
+ environment: 'node',
+ },
+ },
+ {
+ test: {
+ name: 'integration',
+ include: ['tests/integration/**/*.test.ts'],
+ environment: 'jsdom',
+ },
+ },
+ ],
+ },
+})
+```
+
+## Monorepo Pattern
+
+```ts
+defineConfig({
+ test: {
+ projects: [
+ // Each package has its own vitest.config.ts
+ 'packages/core',
+ 'packages/cli',
+ 'packages/utils',
+ ],
+ },
+})
+```
+
+Package config:
+
+```ts
+// packages/core/vitest.config.ts
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ name: 'core',
+ include: ['src/**/*.test.ts'],
+ environment: 'node',
+ },
+})
+```
+
+## Different Environments
+
+Run same tests in different environments:
+
+```ts
+defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'happy-dom',
+ root: './shared-tests',
+ environment: 'happy-dom',
+ setupFiles: ['./setup.happy-dom.ts'],
+ },
+ },
+ {
+ test: {
+ name: 'node',
+ root: './shared-tests',
+ environment: 'node',
+ setupFiles: ['./setup.node.ts'],
+ },
+ },
+ ],
+ },
+})
+```
+
+## Browser + Node Projects
+
+```ts
+defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'unit',
+ include: ['tests/unit/**/*.test.ts'],
+ environment: 'node',
+ },
+ },
+ {
+ test: {
+ name: 'browser',
+ include: ['tests/browser/**/*.test.ts'],
+ browser: {
+ enabled: true,
+ name: 'chromium',
+ provider: 'playwright',
+ },
+ },
+ },
+ ],
+ },
+})
+```
+
+## Shared Configuration
+
+```ts
+// vitest.shared.ts
+export const sharedConfig = {
+ testTimeout: 10000,
+ setupFiles: ['./tests/setup.ts'],
+}
+
+// vitest.config.ts
+import { sharedConfig } from './vitest.shared'
+
+defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ ...sharedConfig,
+ name: 'unit',
+ include: ['tests/unit/**/*.test.ts'],
+ },
+ },
+ {
+ test: {
+ ...sharedConfig,
+ name: 'e2e',
+ include: ['tests/e2e/**/*.test.ts'],
+ },
+ },
+ ],
+ },
+})
+```
+
+## Project-Specific Dependencies
+
+Each project can have different dependencies inlined:
+
+```ts
+defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'project-a',
+ server: {
+ deps: {
+ inline: ['package-a'],
+ },
+ },
+ },
+ },
+ ],
+ },
+})
+```
+
+## Running Specific Projects
+
+```bash
+# Run specific project (v5 adds the -p shorthand)
+vitest --project unit
+vitest -p integration
+
+# Multiple projects / wildcards
+vitest --project unit --project e2e
+vitest --project="packages*"
+
+# Exclude a project
+vitest --project="!browser"
+
+# Nested projects: --project matches the prefix
+vitest -p app # every project of the "app" config
+vitest -p "app (unit)" # just one nested project
+```
+
+## Providing Values to Projects
+
+Share values from config to tests:
+
+```ts
+// vitest.config.ts
+defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'staging',
+ provide: {
+ apiUrl: 'https://staging.api.com',
+ debug: true,
+ },
+ },
+ },
+ {
+ test: {
+ name: 'production',
+ provide: {
+ apiUrl: 'https://api.com',
+ debug: false,
+ },
+ },
+ },
+ ],
+ },
+})
+
+// In tests, use inject
+import { inject } from 'vitest'
+
+test('uses correct api', () => {
+ const url = inject('apiUrl')
+ expect(url).toContain('api.com')
+})
+```
+
+## With Fixtures
+
+```ts
+const test = base.extend({
+ apiUrl: ['/default', { injected: true }],
+})
+
+test('uses injected url', ({ apiUrl }) => {
+ // apiUrl comes from project's provide config
+})
+```
+
+## Per-Project Pool & Isolation (v4)
+
+Since the v4 pool rework, isolation, parallelism, and Node CLI options can be set **per project**:
+
+```ts
+defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'unit',
+ isolate: false, // fast, non-isolated unit tests
+ exclude: ['**/*.integration.test.ts'],
+ },
+ },
+ {
+ test: {
+ name: 'sequential',
+ include: ['**/*.sequential.test.ts'],
+ fileParallelism: false, // run these files one at a time
+ },
+ },
+ {
+ test: {
+ name: 'staging',
+ execArgv: ['--env-file=.env.staging'], // per-project Node flags
+ },
+ },
+ ],
+ },
+})
+```
+
+## Global Setup per Project
+
+```ts
+defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ name: 'with-db',
+ globalSetup: ['./tests/db-setup.ts'],
+ },
+ },
+ ],
+ },
+})
+```
+
+## Key Points
+
+- Projects run in same Vitest process (replaces the removed `workspace` option)
+- Each project can have different environment, pool, isolation, and config
+- Use glob patterns for monorepo packages
+- Run specific projects with `--project` (supports wildcards and `!` exclusion)
+- Use `provide` to inject config values into tests
+- Inline projects inherit root config by default (v5 `extends: true`); set `extends: false` to opt out
+- Referenced configs that declare `projects` provide nested projects (`name (child)`)
+
+
diff --git a/.agents/skills/vitest/references/advanced-type-testing.md b/.agents/skills/vitest/references/advanced-type-testing.md
new file mode 100644
index 00000000..3b583d12
--- /dev/null
+++ b/.agents/skills/vitest/references/advanced-type-testing.md
@@ -0,0 +1,239 @@
+---
+name: type-testing
+description: Test TypeScript types with expectTypeOf and assertType
+---
+
+# Type Testing
+
+Test TypeScript types without runtime execution.
+
+## Setup
+
+Type tests use `.test-d.ts` extension:
+
+```ts
+// math.test-d.ts
+import { expectTypeOf } from 'vitest'
+import { add } from './math'
+
+test('add returns number', () => {
+ expectTypeOf(add).returns.toBeNumber()
+})
+```
+
+## Configuration
+
+```ts
+defineConfig({
+ test: {
+ typecheck: {
+ enabled: true,
+
+ // Only type check
+ only: false,
+
+ // Checker: 'tsc' or 'vue-tsc'
+ checker: 'tsc',
+
+ // Include patterns
+ include: ['**/*.test-d.ts'],
+
+ // tsconfig to use
+ tsconfig: './tsconfig.json',
+ },
+ },
+})
+```
+
+## expectTypeOf API
+
+```ts
+import { expectTypeOf } from 'vitest'
+
+// Basic type checks
+expectTypeOf().toBeString()
+expectTypeOf().toBeNumber()
+expectTypeOf().toBeBoolean()
+expectTypeOf().toBeNull()
+expectTypeOf().toBeUndefined()
+expectTypeOf().toBeVoid()
+expectTypeOf().toBeNever()
+expectTypeOf().toBeAny()
+expectTypeOf().toBeUnknown()
+expectTypeOf().toBeObject()
+expectTypeOf().toBeFunction()
+expectTypeOf<[]>().toBeArray()
+expectTypeOf().toBeSymbol()
+```
+
+## Value Type Checking
+
+```ts
+const value = 'hello'
+expectTypeOf(value).toBeString()
+
+const obj = { name: 'test', count: 42 }
+expectTypeOf(obj).toExtend<{ name: string }>()
+expectTypeOf(obj).toHaveProperty('name')
+```
+
+## Function Types
+
+```ts
+function greet(name: string): string {
+ return `Hello, ${name}`
+}
+
+expectTypeOf(greet).toBeFunction()
+expectTypeOf(greet).parameters.toEqualTypeOf<[string]>()
+expectTypeOf(greet).returns.toBeString()
+
+// Parameter checking
+expectTypeOf(greet).parameter(0).toBeString()
+```
+
+## Object Types
+
+```ts
+interface User {
+ id: number
+ name: string
+ email?: string
+}
+
+expectTypeOf().toHaveProperty('id')
+expectTypeOf().toHaveProperty('name').toBeString()
+
+// Check shape
+expectTypeOf({ id: 1, name: 'test' }).toExtend()
+```
+
+## Equality vs Matching
+
+`toMatchTypeOf` is **deprecated** (expect-type v1.2+) — use `toExtend` for subset matching:
+
+```ts
+interface A { x: number }
+interface B { x: number; y: string }
+
+// toExtend - subset matching (replaces toMatchTypeOf)
+expectTypeOf().toExtend() // B extends A
+
+// toEqualTypeOf - exact match
+expectTypeOf ().not.toEqualTypeOf() // Not exact match
+expectTypeOf().toEqualTypeOf<{ x: number }>() // Exact match
+```
+
+## Branded Types
+
+```ts
+type UserId = number & { __brand: 'UserId' }
+type PostId = number & { __brand: 'PostId' }
+
+expectTypeOf().not.toEqualTypeOf()
+expectTypeOf().not.toEqualTypeOf()
+```
+
+## Generic Types
+
+```ts
+function identity(value: T): T {
+ return value
+}
+
+expectTypeOf(identity).returns.toBeString()
+expectTypeOf(identity).returns.toBeNumber()
+```
+
+## Nullable Types
+
+```ts
+type MaybeString = string | null | undefined
+
+expectTypeOf().toBeNullable()
+expectTypeOf().not.toBeNullable()
+```
+
+## assertType
+
+Assert a value matches a type (no assertion at runtime):
+
+```ts
+import { assertType } from 'vitest'
+
+function getUser(): User | null {
+ return { id: 1, name: 'test' }
+}
+
+test('returns user', () => {
+ const result = getUser()
+
+ // @ts-expect-error - should fail type check
+ assertType(result)
+
+ // Correct type
+ assertType(result)
+})
+```
+
+## Using @ts-expect-error
+
+Test that code produces type error:
+
+```ts
+test('rejects wrong types', () => {
+ function requireString(s: string) {}
+
+ // @ts-expect-error - number not assignable to string
+ requireString(123)
+})
+```
+
+## Running Type Tests
+
+```bash
+# Run type tests
+vitest typecheck
+
+# Run alongside unit tests
+vitest --typecheck
+
+# Type tests only
+vitest --typecheck.only
+```
+
+## Mixed Test Files
+
+Combine runtime and type tests:
+
+```ts
+// user.test.ts
+import { describe, expect, expectTypeOf, test } from 'vitest'
+import { createUser } from './user'
+
+describe('createUser', () => {
+ test('runtime: creates user', () => {
+ const user = createUser('John')
+ expect(user.name).toBe('John')
+ })
+
+ test('types: returns User type', () => {
+ expectTypeOf(createUser).returns.toExtend<{ name: string }>()
+ })
+})
+```
+
+## Key Points
+
+- Use `.test-d.ts` for type-only tests
+- `expectTypeOf` for type assertions
+- `toExtend` for subset matching (`toMatchTypeOf` is deprecated)
+- `toEqualTypeOf` for exact type matching
+- Use `@ts-expect-error` to test type errors
+- Run with `vitest typecheck` or `--typecheck`
+
+
diff --git a/.agents/skills/vitest/references/advanced-vi.md b/.agents/skills/vitest/references/advanced-vi.md
new file mode 100644
index 00000000..bcd38e82
--- /dev/null
+++ b/.agents/skills/vitest/references/advanced-vi.md
@@ -0,0 +1,290 @@
+---
+name: vi-utilities
+description: vi helper for mocking, timers, utilities
+---
+
+# Vi Utilities
+
+The `vi` helper provides mocking and utility functions.
+
+```ts
+import { vi } from 'vitest'
+```
+
+## Mock Functions
+
+```ts
+// Create mock
+const fn = vi.fn()
+const fnWithImpl = vi.fn((x) => x * 2)
+
+// Check if mock
+vi.isMockFunction(fn) // true
+
+// Mock methods
+fn.mockReturnValue(42)
+fn.mockReturnValueOnce(1)
+fn.mockResolvedValue(data)
+fn.mockRejectedValue(error)
+fn.mockImplementation(() => 'result')
+fn.mockImplementationOnce(() => 'once')
+
+// Clear/reset
+fn.mockClear() // Clear call history
+fn.mockReset() // Clear history + implementation
+fn.mockRestore() // Restore original (for spies)
+```
+
+## Spying
+
+```ts
+const obj = { method: () => 'original' }
+
+const spy = vi.spyOn(obj, 'method')
+obj.method()
+
+expect(spy).toHaveBeenCalled()
+
+// Mock implementation
+spy.mockReturnValue('mocked')
+
+// Spy on getter/setter
+vi.spyOn(obj, 'prop', 'get').mockReturnValue('value')
+```
+
+## Module Mocking
+
+```ts
+// Hoisted to top of file
+vi.mock('./module', () => ({
+ fn: vi.fn(),
+}))
+
+// Partial mock
+vi.mock('./module', async (importOriginal) => ({
+ ...(await importOriginal()),
+ specificFn: vi.fn(),
+}))
+
+// Spy mode - keep implementation
+vi.mock('./module', { spy: true })
+
+// Import actual module inside mock
+const actual = await vi.importActual('./module')
+
+// Import as mock
+const mocked = await vi.importMock('./module')
+```
+
+## Dynamic Mocking
+
+```ts
+// Not hoisted - use with dynamic imports
+vi.doMock('./config', () => ({ key: 'value' }))
+const config = await import('./config')
+
+// Unmock
+vi.doUnmock('./config')
+vi.unmock('./module') // Hoisted
+```
+
+## Conditional Mocking — vi.when (v5)
+
+Argument-specific spy behaviors:
+
+```ts
+vi.when(spy)
+ .calledWith(1).thenReturn('one')
+ .calledWith(2).thenReturn('two')
+
+// then* actions: thenReturn / thenThrow / thenResolve / thenReject (+ *Once)
+// options: { times }, second arg { onUnmatched: 'throw' | 'passthrough' | fn }
+
+vi.isWhenChain(w) // type guard for a When chain
+```
+
+See [features-mocking](features-mocking.md) for FIFO/LIFO matching and `toHaveBeenExhausted`.
+
+## Assertion Helpers — vi.defineHelper (4.1+)
+
+Wrap reusable assertion functions so failures point at the **call site**, not inside the helper:
+
+```ts
+const expectValidUser = vi.defineHelper((user: unknown) => {
+ expect(user).toHaveProperty('id')
+ expect(user).toHaveProperty('email')
+})
+
+test('returns a valid user', async () => {
+ expectValidUser(await fetchUser('alice')) // failures reported here
+})
+```
+
+## Reset Modules
+
+```ts
+// Clear module cache
+vi.resetModules()
+
+// Wait for dynamic imports
+await vi.dynamicImportSettled()
+```
+
+## Fake Timers
+
+```ts
+vi.useFakeTimers()
+
+// Choose which timers to fake (toFake and toNotFake are mutually exclusive)
+vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+vi.useFakeTimers({ toNotFake: ['setInterval'] })
+
+// v5: Temporal is faked alongside Date when present on globalThis;
+// exclude it with toNotFake: ['Temporal']
+
+setTimeout(() => console.log('done'), 1000)
+
+// Advance time
+vi.advanceTimersByTime(1000)
+vi.advanceTimersByTimeAsync(1000) // For async callbacks
+vi.advanceTimersToNextTimer()
+vi.advanceTimersToNextFrame() // requestAnimationFrame
+
+// Run all timers
+vi.runAllTimers()
+vi.runAllTimersAsync()
+vi.runOnlyPendingTimers()
+
+// Clear timers
+vi.clearAllTimers()
+
+// Check state
+vi.getTimerCount()
+vi.isFakeTimers()
+
+// Restore
+vi.useRealTimers()
+```
+
+## Mock Date/Time
+
+```ts
+vi.setSystemTime(new Date('2024-01-01'))
+expect(new Date().getFullYear()).toBe(2024)
+// v5: also drives Temporal.Now when Temporal is available
+
+vi.getMockedSystemTime() // Get mocked date
+vi.getRealSystemTime() // Get real time (ms)
+```
+
+## Global/Env Mocking
+
+```ts
+// Stub global
+vi.stubGlobal('fetch', vi.fn())
+vi.unstubAllGlobals()
+
+// Stub environment
+vi.stubEnv('API_KEY', 'test')
+vi.stubEnv('NODE_ENV', 'test')
+vi.unstubAllEnvs()
+```
+
+## Hoisted Code
+
+Run code before imports:
+
+```ts
+const mock = vi.hoisted(() => vi.fn())
+
+vi.mock('./module', () => ({
+ fn: mock, // Can reference hoisted variable
+}))
+```
+
+## Waiting Utilities
+
+```ts
+// Wait for callback to succeed
+await vi.waitFor(async () => {
+ const el = document.querySelector('.loaded')
+ expect(el).toBeTruthy()
+}, { timeout: 5000, interval: 100 })
+
+// Wait for truthy value
+const element = await vi.waitUntil(
+ () => document.querySelector('.loaded'),
+ { timeout: 5000 }
+)
+```
+
+## Mock Object
+
+Mock all methods of an object:
+
+```ts
+const original = {
+ method: () => 'real',
+ nested: { fn: () => 'nested' },
+}
+
+const mocked = vi.mockObject(original)
+mocked.method() // undefined (mocked)
+mocked.method.mockReturnValue('mocked')
+
+// Spy mode
+const spied = vi.mockObject(original, { spy: true })
+spied.method() // 'real'
+expect(spied.method).toHaveBeenCalled()
+```
+
+## Test Configuration
+
+```ts
+vi.setConfig({
+ testTimeout: 10_000,
+ hookTimeout: 10_000,
+})
+
+vi.resetConfig()
+```
+
+## Global Mock Management
+
+```ts
+vi.clearAllMocks() // Clear all mock call history
+vi.resetAllMocks() // Reset + clear implementation
+vi.restoreAllMocks() // Restore originals (spies)
+```
+
+## vi.mocked Type Helper
+
+TypeScript helper for mocked values:
+
+```ts
+import { myFn } from './module'
+vi.mock('./module')
+
+// Type as mock
+vi.mocked(myFn).mockReturnValue('typed')
+
+// Deep mocking
+vi.mocked(myModule, { deep: true })
+
+// Partial mock typing
+vi.mocked(fn, { partial: true }).mockResolvedValue({ ok: true })
+```
+
+## Key Points
+
+- `vi.mock` is hoisted - use `vi.doMock` for dynamic mocking
+- `vi.hoisted` lets you reference variables in mock factories
+- Use `vi.spyOn` to spy on existing methods (v4: supports constructors)
+- Use `vi.when` for argument-specific behaviors and `vi.defineHelper` for assertion helpers
+- Fake timers require explicit setup and teardown
+- `vi.waitFor` retries until assertion passes
+
+
diff --git a/.agents/skills/vitest/references/core-cli.md b/.agents/skills/vitest/references/core-cli.md
new file mode 100644
index 00000000..b5921b1c
--- /dev/null
+++ b/.agents/skills/vitest/references/core-cli.md
@@ -0,0 +1,189 @@
+---
+name: vitest-cli
+description: Command line interface commands and options
+---
+
+# Command Line Interface
+
+## Commands
+
+### `vitest`
+
+Start Vitest in watch mode (dev) or run mode (CI):
+
+```bash
+vitest # Watch mode in dev, run mode in CI
+vitest foobar # Run tests containing "foobar" in path
+vitest basic/foo.test.ts:10 # Run specific test by file and line number
+```
+
+### `vitest run`
+
+Run tests once without watch mode:
+
+```bash
+vitest run
+vitest run --coverage
+```
+
+### `vitest watch`
+
+Explicitly start watch mode:
+
+```bash
+vitest watch
+```
+
+### `vitest related`
+
+Run tests that import specific files (useful with lint-staged):
+
+```bash
+vitest related src/index.ts src/utils.ts --run
+```
+
+### `vitest bench`
+
+Run only benchmark tests:
+
+```bash
+vitest bench
+```
+
+### `vitest list`
+
+List all matching tests without running them:
+
+```bash
+vitest list # List test names
+vitest list --json # Output as JSON
+vitest list --filesOnly # List only test files
+```
+
+> v5: `list` parses test files **statically** instead of running them. Pass `--no-static-parse` to run them; tune with `--static-parse-concurrency`.
+
+### `vitest doctor` (v5)
+
+Run the suite under alternative configs and recommend faster options (e.g. a different `pool`, `isolate: false`, `fsModuleCache`, lower `maxWorkers`). Needs a passing baseline; takes several times a normal run:
+
+```bash
+vitest doctor
+```
+
+### `vitest init`
+
+Initialize project setup:
+
+```bash
+vitest init browser # Set up browser testing
+```
+
+### `vitest --list-tags`
+
+List tags defined in config without running tests:
+
+```bash
+vitest --list-tags # Human-readable list
+vitest --list-tags=json # JSON output
+```
+
+## Common Options
+
+```bash
+# Configuration
+--config # Path to config file
+--project, -p # Run specific project (v5 adds the -p shorthand)
+
+# Filtering
+--testNamePattern, -t # Run tests matching pattern
+--tagsFilter # Run tests by tag expression, e.g. "db && !flaky"
+--changed # Run tests for changed files
+--changed HEAD~1 # Tests for last commit changes
+--dir # Limit test discovery to a directory
+
+# Reporters
+--reporter # default, verbose, tree, dot, json, html, junit, minimal, blob
+--reporter=json --outputFile=report.json
+
+# Coverage
+--coverage # Enable coverage
+--coverage.provider v8 # Use v8 provider
+--coverage.reporter text,html
+
+# Execution
+--shard / # Split tests across machines
+--bail # Stop after n failures
+--retry # Retry failed tests n times
+--repeats # v5: repeat every test n times (hunt flaky tests)
+--shuffle # Randomize test order
+--no-file-parallelism # Run test files one at a time
+
+# Watch mode
+--no-watch # Disable watch mode
+--standalone # Start without running (v4: runs matched files if a filter is passed)
+
+# Environment
+--environment # jsdom, happy-dom, node
+--globals # Enable global APIs
+
+# Debugging
+--inspect # Enable Node inspector
+--inspect-brk # Break on start
+
+# Output
+--silent # Suppress console output
+--no-color # Disable colors
+```
+
+## Package.json Scripts
+
+```json
+{
+ "scripts": {
+ "test": "vitest",
+ "test:run": "vitest run",
+ "test:ui": "vitest --ui",
+ "coverage": "vitest run --coverage"
+ }
+}
+```
+
+## Sharding for CI
+
+Split tests across multiple machines. The blob reporter writes to `.vitest/blob/` by default:
+
+```bash
+# Machine 1
+vitest run --shard=1/3 --reporter=blob --outputFile=reports/blob-1.json
+
+# Machine 2
+vitest run --shard=2/3 --reporter=blob --outputFile=reports/blob-2.json
+
+# Merge all blobs into a final report
+vitest --merge-reports=reports --reporter=junit --reporter=default
+```
+
+## Watch Mode Keyboard Shortcuts
+
+In watch mode, press:
+- `a` - Run all tests
+- `f` - Run only failed tests
+- `u` - Update snapshots
+- `p` - Filter by filename pattern
+- `t` - Filter by test name pattern
+- `q` - Quit
+
+## Key Points
+
+- Watch mode is default in dev, run mode in CI (when `process.env.CI` is set)
+- Use `--run` flag to ensure single run (important for lint-staged)
+- Both camelCase (`--testTimeout`) and kebab-case (`--test-timeout`) work
+- Boolean options can be negated with `--no-` prefix
+- Filter tests by tag with `--tagsFilter` (tags must be declared in config) — see [features-test-tags](features-test-tags.md)
+- `--merge-reports` and `--reporter=blob` do not work in watch mode (`--merge-reports` now handles non-sharded multi-environment runs)
+- v5: use `-p` as shorthand for `--project`; `vitest doctor` suggests faster config
+
+
diff --git a/.agents/skills/vitest/references/core-config.md b/.agents/skills/vitest/references/core-config.md
new file mode 100644
index 00000000..5dbb8dac
--- /dev/null
+++ b/.agents/skills/vitest/references/core-config.md
@@ -0,0 +1,216 @@
+---
+name: vitest-configuration
+description: Configure Vitest with vite.config.ts or vitest.config.ts
+---
+
+# Configuration
+
+Vitest reads configuration from `vitest.config.ts` or `vite.config.ts`. It shares the same config format as Vite.
+
+## Basic Setup
+
+```ts
+// vitest.config.ts
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ // test options
+ },
+})
+```
+
+## Using with Existing Vite Config
+
+Add Vitest types reference and use the `test` property:
+
+```ts
+// vite.config.ts
+///
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ },
+})
+```
+
+## Merging Configs
+
+If you have separate config files, use `mergeConfig`:
+
+```ts
+// vitest.config.ts
+import { defineConfig, mergeConfig } from 'vitest/config'
+import viteConfig from './vite.config'
+
+export default mergeConfig(viteConfig, defineConfig({
+ test: {
+ environment: 'jsdom',
+ },
+}))
+```
+
+## Common Options
+
+```ts
+defineConfig({
+ test: {
+ // Enable global APIs (describe, it, expect) without imports
+ globals: true,
+
+ // Test environment: 'node', 'jsdom', 'happy-dom'
+ environment: 'node',
+
+ // Setup files to run before each test file
+ setupFiles: ['./tests/setup.ts'],
+
+ // Include patterns for test files
+ include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
+
+ // Exclude patterns
+ exclude: ['**/node_modules/**', '**/dist/**'],
+
+ // Limit test discovery to a directory (faster than broad excludes)
+ dir: './src',
+
+ // Test timeout in ms
+ testTimeout: 5000,
+
+ // Hook timeout in ms
+ hookTimeout: 10000,
+
+ // Coverage configuration (v4+: define `include`, no more `all`)
+ coverage: {
+ provider: 'v8', // or 'istanbul'
+ reporter: ['text', 'html'],
+ include: ['src/**/*.ts'],
+ },
+
+ // Run each file in an isolated module graph (threads/forks pools only)
+ isolate: true,
+
+ // Pool: 'forks' (default), 'threads', 'vmForks', 'vmThreads'
+ pool: 'forks',
+
+ // v4+: pool options are top-level (poolOptions was removed)
+ maxWorkers: 4,
+ fileParallelism: true,
+
+ // Clear mock call history before each test (v5: defaults to true)
+ clearMocks: true,
+
+ // Restore spies created with vi.spyOn between tests
+ restoreMocks: true,
+
+ // Retry failed tests
+ retry: 0,
+
+ // Stop after first failure
+ bail: 0,
+
+ // v5: repeat every test N times regardless of result (flaky-hunting)
+ repeats: 0,
+ },
+})
+```
+
+## v5 Performance Config
+
+```ts
+defineConfig({
+ test: {
+ // Persist transformed modules on disk, reused across reruns AND separate
+ // processes (stable in v5; was experimental.fsModuleCache). Cache lives in
+ // node_modules by default; clear with `vitest --clearCache`. Not for browser.
+ fsModuleCache: true,
+
+ // Inline projects that don't change the Vite config reuse the declaring
+ // config's Vite server (default true) — shared files transformed once.
+ // Set false if a plugin must be re-instantiated per project.
+ sharedViteServer: true,
+
+ // Inject CJS globals (module/exports/require/__filename/__dirname) into
+ // every module (default true). Set false for stricter ESM-like scope.
+ injectCjsGlobals: false,
+
+ // Detect async resources leaking past a test file (slow; debugging only)
+ detectAsyncLeaks: false,
+ },
+})
+```
+
+## v4/v5 Config Changes
+
+- **v5 requires Vite >= 6.4.0 and Node >= 22.12.0.** `vite` is now a required peer dependency (Yarn users must `yarn add -D vite`).
+- **`clearMocks` defaults to `true`** in v5 — `vi.clearAllMocks()` runs before every test (history cleared, implementations kept). Set `clearMocks: false` for the old behavior.
+- **Inline projects inherit the root config by default** (`extends` defaults to `true`) — including Vite `plugins`/`resolve.alias`. Set `extends: false` to opt out. See [advanced-projects](advanced-projects.md).
+- **`testNamePattern` (`-t`) matches the `>`-joined full name** (`'math > adds'`), not the space-joined Jest form.
+- **`vi.mock`/`vi.unmock`/`vi.hoisted` must be at the top level** — nested calls now throw (they were only warned before).
+- **Pool default is `forks`** (child processes), not `threads`.
+- **`poolOptions` removed** — `maxThreads`/`maxForks` are now top-level `maxWorkers`; `singleThread`/`singleFork` become `maxWorkers: 1, isolate: false`; VM `memoryLimit` is `vmMemoryLimit`. `minWorkers` was removed.
+- **`workspace` removed** — use [`projects`](advanced-projects.md). `vitest.workspace.ts` no longer supported.
+- **`coverage.all` and `coverage.extensions` removed** — by default only covered files are reported; set `coverage.include` explicitly.
+- **Simplified `exclude`** — only `node_modules`/`.git` excluded by default. Use `test.dir` to scope discovery, or spread `configDefaults.exclude`.
+- **Config not looked up from parent dirs** — pass `--config` explicitly when running from a subdirectory.
+- **`.vitest` artifact dir** — blob reports (`.vitest/blob/`), attachments (`.vitest/attachments/`), and the `html`/`json`/`junit` reporters now all live under a single `.vitest/` directory; add one entry to `.gitignore`.
+- **`test.sequential`/`describe.sequential`/`sequential` removed** — use `{ concurrent: false }`.
+- `deps.optimizer.web` renamed to `deps.optimizer.client`; `deps.inline`/`deps.external` moved under `server.deps`.
+
+## Conditional Configuration
+
+Use `mode` or `process.env.VITEST` for test-specific config:
+
+```ts
+export default defineConfig(({ mode }) => ({
+ plugins: mode === 'test' ? [] : [myPlugin()],
+ test: {
+ // test options
+ },
+}))
+```
+
+## Projects (Monorepos)
+
+Run different configurations in the same Vitest process:
+
+```ts
+defineConfig({
+ test: {
+ projects: [
+ 'packages/*',
+ {
+ test: {
+ name: 'unit',
+ include: ['tests/unit/**/*.test.ts'],
+ environment: 'node',
+ },
+ },
+ {
+ test: {
+ name: 'integration',
+ include: ['tests/integration/**/*.test.ts'],
+ environment: 'jsdom',
+ },
+ },
+ ],
+ },
+})
+```
+
+## Key Points
+
+- Vitest uses Vite's transformation pipeline - same `resolve.alias`, plugins work
+- `vitest.config.ts` takes priority over `vite.config.ts`
+- Use `--config` flag to specify a custom config path (required from subdirectories in v5)
+- `process.env.VITEST` is set to `true` when running tests
+- Test config uses `test` property, rest is Vite config
+- v5 requires **Vite >= 6.4.0** and **Node >= 22.12.0**; `vite` is a required peer dependency
+
+
diff --git a/.agents/skills/vitest/references/core-describe.md b/.agents/skills/vitest/references/core-describe.md
new file mode 100644
index 00000000..d8ea086f
--- /dev/null
+++ b/.agents/skills/vitest/references/core-describe.md
@@ -0,0 +1,195 @@
+---
+name: describe-api
+description: describe/suite for grouping tests into logical blocks
+---
+
+# Describe API
+
+Group related tests into suites for organization and shared setup.
+
+## Basic Usage
+
+```ts
+import { describe, expect, test } from 'vitest'
+
+describe('Math', () => {
+ test('adds numbers', () => {
+ expect(1 + 1).toBe(2)
+ })
+
+ test('subtracts numbers', () => {
+ expect(3 - 1).toBe(2)
+ })
+})
+
+// Alias: suite
+import { suite } from 'vitest'
+suite('equivalent to describe', () => {})
+```
+
+## Nested Suites
+
+```ts
+describe('User', () => {
+ describe('when logged in', () => {
+ test('shows dashboard', () => {})
+ test('can update profile', () => {})
+ })
+
+ describe('when logged out', () => {
+ test('shows login page', () => {})
+ })
+})
+```
+
+## Suite Options
+
+```ts
+// All tests inherit options
+describe('slow tests', { timeout: 30_000 }, () => {
+ test('test 1', () => {}) // 30s timeout
+ test('test 2', () => {}) // 30s timeout
+})
+```
+
+## Suite Modifiers
+
+### Skip Suites
+
+```ts
+describe.skip('skipped suite', () => {
+ test('wont run', () => {})
+})
+
+// Conditional
+describe.skipIf(process.env.CI)('not in CI', () => {})
+describe.runIf(!process.env.CI)('only local', () => {})
+```
+
+### Focus Suites
+
+```ts
+describe.only('only this suite runs', () => {
+ test('runs', () => {})
+})
+```
+
+### Todo Suites
+
+```ts
+describe.todo('implement later')
+```
+
+### Concurrent Suites
+
+```ts
+// All tests run in parallel
+describe.concurrent('parallel tests', () => {
+ test('test 1', async ({ expect }) => {})
+ test('test 2', async ({ expect }) => {})
+})
+```
+
+### Opt Out of Concurrency
+
+`describe.sequential` was **removed in v5**. Use `{ concurrent: false }` to opt a suite out of inherited/global concurrency:
+
+```ts
+describe.concurrent('parallel', () => {
+ test('concurrent 1', async () => {})
+
+ describe('must be sequential', { concurrent: false }, () => {
+ test('step 1', async () => {})
+ test('step 2', async () => {})
+ })
+})
+```
+
+### Shuffle Tests
+
+```ts
+describe.shuffle('random order', () => {
+ test('test 1', () => {})
+ test('test 2', () => {})
+ test('test 3', () => {})
+})
+
+// Or with option
+describe('random', { shuffle: true }, () => {})
+```
+
+## Parameterized Suites
+
+### describe.each
+
+```ts
+describe.each([
+ { name: 'Chrome', version: 100 },
+ { name: 'Firefox', version: 90 },
+])('$name browser', ({ name, version }) => {
+ test('has version', () => {
+ expect(version).toBeGreaterThan(0)
+ })
+})
+```
+
+### describe.for
+
+```ts
+describe.for([
+ ['Chrome', 100],
+ ['Firefox', 90],
+])('%s browser', ([name, version]) => {
+ test('has version', () => {
+ expect(version).toBeGreaterThan(0)
+ })
+})
+```
+
+## Hooks in Suites
+
+```ts
+describe('Database', () => {
+ let db
+
+ beforeAll(async () => {
+ db = await createDb()
+ })
+
+ afterAll(async () => {
+ await db.close()
+ })
+
+ beforeEach(async () => {
+ await db.clear()
+ })
+
+ test('insert works', async () => {
+ await db.insert({ name: 'test' })
+ expect(await db.count()).toBe(1)
+ })
+})
+```
+
+## Modifier Combinations
+
+Modifiers can be chained:
+
+```ts
+describe.skip.concurrent('skipped concurrent', () => {})
+describe.only.shuffle('only and shuffled', () => {})
+```
+
+## Key Points
+
+- Top-level tests belong to an implicit file suite
+- Nested suites inherit parent's options (timeout, retry, concurrency, etc.)
+- Hooks are scoped to their suite and nested suites
+- Use `describe.concurrent` with context's `expect` for snapshots
+- Shuffle order depends on `sequence.seed` config
+- `describe.sequential` was removed in v5 — use `{ concurrent: false }`
+
+
diff --git a/.agents/skills/vitest/references/core-expect.md b/.agents/skills/vitest/references/core-expect.md
new file mode 100644
index 00000000..6b8d9992
--- /dev/null
+++ b/.agents/skills/vitest/references/core-expect.md
@@ -0,0 +1,311 @@
+---
+name: expect-api
+description: Assertions with matchers, asymmetric matchers, and custom matchers
+---
+
+# Expect API
+
+Vitest uses Chai assertions with Jest-compatible API.
+
+## Basic Assertions
+
+```ts
+import { expect, test } from 'vitest'
+
+test('assertions', () => {
+ // Equality
+ expect(1 + 1).toBe(2) // Strict equality (===)
+ expect({ a: 1 }).toEqual({ a: 1 }) // Deep equality
+
+ // Truthiness
+ expect(true).toBeTruthy()
+ expect(false).toBeFalsy()
+ expect(null).toBeNull()
+ expect(undefined).toBeUndefined()
+ expect('value').toBeDefined()
+
+ // Numbers
+ expect(10).toBeGreaterThan(5)
+ expect(10).toBeGreaterThanOrEqual(10)
+ expect(5).toBeLessThan(10)
+ expect(0.1 + 0.2).toBeCloseTo(0.3, 5)
+
+ // Strings
+ expect('hello world').toMatch(/world/)
+ expect('hello').toContain('ell')
+
+ // Arrays
+ expect([1, 2, 3]).toContain(2)
+ expect([{ a: 1 }]).toContainEqual({ a: 1 })
+ expect([1, 2, 3]).toHaveLength(3)
+
+ // Objects
+ expect({ a: 1, b: 2 }).toHaveProperty('a')
+ expect({ a: 1, b: 2 }).toHaveProperty('a', 1)
+ expect({ a: { b: 1 } }).toHaveProperty('a.b', 1)
+ expect({ a: 1 }).toMatchObject({ a: 1 })
+
+ // Types
+ expect('string').toBeTypeOf('string')
+ expect(new Date()).toBeInstanceOf(Date)
+})
+```
+
+## Negation
+
+```ts
+expect(1).not.toBe(2)
+expect({ a: 1 }).not.toEqual({ a: 2 })
+```
+
+## Error Assertions
+
+```ts
+// Sync errors - wrap in function
+expect(() => throwError()).toThrow()
+expect(() => throwError()).toThrow('message') // string = substring of the message
+expect(() => throwError()).toThrow(/pattern/)
+expect(() => throwError()).toThrow(CustomError)
+
+// v5: toThrow('') matches ANY message (empty string is a substring of all).
+// To assert an empty message, match the pattern: .toThrow(/^$/)
+
+// Async errors - use rejects
+await expect(asyncThrow()).rejects.toThrow('error')
+```
+
+## Promise Assertions
+
+```ts
+// Resolves
+await expect(Promise.resolve(1)).resolves.toBe(1)
+await expect(fetchData()).resolves.toEqual({ data: true })
+
+// Rejects
+await expect(Promise.reject('error')).rejects.toBe('error')
+await expect(failingFetch()).rejects.toThrow()
+```
+
+> **v5:** unawaited async assertions (`resolves`, `rejects`, `toMatchFileSnapshot`) now **fail the test** — v4 only printed a warning and auto-awaited at the end. Always `await` them.
+
+## Spy/Mock Assertions
+
+```ts
+const fn = vi.fn()
+fn('arg1', 'arg2')
+fn('arg3')
+
+expect(fn).toHaveBeenCalled()
+expect(fn).toHaveBeenCalledTimes(2)
+expect(fn).toHaveBeenCalledWith('arg1', 'arg2')
+expect(fn).toHaveBeenLastCalledWith('arg3')
+expect(fn).toHaveBeenNthCalledWith(1, 'arg1', 'arg2')
+
+expect(fn).toHaveReturned()
+expect(fn).toHaveReturnedWith(value)
+
+// v4 additions
+expect(fn).toHaveBeenCalledExactlyOnceWith('arg1', 'arg2')
+expect(fnA).toHaveBeenCalledBefore(fnB)
+expect(fnA).toHaveBeenCalledAfter(fnB)
+```
+
+### Chai-Style Spy Assertions (4.1+)
+
+Sinon-chai-compatible aliases, useful when migrating from Sinon:
+
+```ts
+expect(spy).to.have.been.called
+expect(spy).to.have.been.calledOnce
+expect(spy).to.have.been.calledWith('arg1', 'arg2')
+expect(spy).to.have.been.calledOnceWith('arg')
+```
+
+### Conditional Mock Exhaustion (v5)
+
+Assert every `vi.when` behavior was consumed:
+
+```ts
+const w = vi.when(spy).calledWith(1).thenReturnOnce('a')
+spy(1)
+expect(w).toHaveBeenExhausted()
+```
+
+## Asymmetric Matchers
+
+Use inside `toEqual`, `toHaveBeenCalledWith`, etc:
+
+```ts
+expect({ id: 1, name: 'test' }).toEqual({
+ id: expect.any(Number),
+ name: expect.any(String),
+})
+
+expect({ a: 1, b: 2, c: 3 }).toEqual(
+ expect.objectContaining({ a: 1 })
+)
+
+expect([1, 2, 3, 4]).toEqual(
+ expect.arrayContaining([1, 3])
+)
+
+expect('hello world').toEqual(
+ expect.stringContaining('world')
+)
+
+expect('hello world').toEqual(
+ expect.stringMatching(/world$/)
+)
+
+expect({ value: null }).toEqual({
+ value: expect.anything() // Matches anything except null/undefined
+})
+
+// Negate with expect.not
+expect([1, 2]).toEqual(
+ expect.not.arrayContaining([3])
+)
+
+// toBeOneOf - value matches any option (great for optional props)
+expect(user).toEqual({
+ name: expect.any(String),
+ middleName: expect.toBeOneOf([expect.any(String), undefined]),
+})
+
+// schemaMatching (4.0+) - matches any Standard Schema (Zod, Valibot, ArkType)
+import { z } from 'zod'
+expect(payload).toEqual({
+ email: expect.schemaMatching(z.string().email()),
+})
+expect(repo.save).toHaveBeenCalledWith(expect.schemaMatching(UserSchema))
+```
+
+## Soft Assertions
+
+Prefer `expect.soft` for **non-critical assertions** — it marks the test failed but continues so all failures are reported together:
+
+```ts
+expect.soft(response.status).toBe(200) // non-critical, keeps going
+expect.soft(response.headers.get('x-id')).toBeTruthy()
+expect(response.body).toBeDefined() // critical: hard expect stops on failure
+```
+
+## Type-Narrowing Assertions (4.0+)
+
+`expect.assert` throws at runtime **and** narrows the TypeScript type (unlike `toBeTruthy`/`toBeDefined`, which return `void`):
+
+```ts
+const user = cache.get('alice') // { id, name } | undefined
+expect.assert(user) // throws if undefined, narrows below
+expect(user.name).toBe('Alice') // no `!`, no `as`
+
+// Narrows typeof / instanceof too
+expect.assert(typeof input === 'string')
+input.toUpperCase()
+
+// Chai assert helpers via the same namespace
+expect.assert.isDefined(maybeUser)
+expect.assert.instanceOf(error, MyError)
+```
+
+## Poll Assertions
+
+Retry until passes:
+
+```ts
+await expect.poll(() => fetchStatus()).toBe('ready')
+
+await expect.poll(
+ () => document.querySelector('.element'),
+ { interval: 100, timeout: 5000 }
+).toBeTruthy()
+```
+
+> **v5:** `expect.poll` now **rejects when it times out** (v4 could still pass on a late attempt). The callback receives an `AbortSignal` that aborts on timeout so you can cancel in-flight work:
+
+```ts
+await expect.poll(async ({ signal }) => {
+ const res = await fetch('/api/status', { signal })
+ return res.status
+}, { timeout: 1000 }).toBe(200)
+```
+
+## Assertion Count
+
+```ts
+test('async assertions', async () => {
+ expect.assertions(2) // Exactly 2 assertions must run
+
+ await doAsync((data) => {
+ expect(data).toBeDefined()
+ expect(data.id).toBe(1)
+ })
+})
+
+test('at least one', () => {
+ expect.hasAssertions() // At least 1 assertion must run
+})
+```
+
+## Extending Matchers
+
+```ts
+expect.extend({
+ toBeWithinRange(received, floor, ceiling) {
+ const pass = received >= floor && received <= ceiling
+ return {
+ pass,
+ message: () =>
+ `expected ${received} to be within range ${floor} - ${ceiling}`,
+ }
+ },
+})
+
+test('custom matcher', () => {
+ expect(100).toBeWithinRange(90, 110)
+})
+```
+
+### TypeScript Declarations (v5)
+
+The `Matchers` interface now takes the **return type first** (`R`) and the received type second (`T`). `R` is `void` synchronously, `Promise` through `.resolves`/`.rejects`/`expect.poll`/`expect.element`:
+
+```ts
+import 'vitest'
+
+declare module 'vitest' {
+ interface Matchers {
+ toBeFoo: () => R
+ toEqualTyped: (expected: T) => R // T mirrors the received value's type
+ }
+}
+```
+
+Referencing assertion types directly also needs the return type first: `Assertion` (sync) / `Assertion, string>` (async). v5 no longer reads matchers from the global `jest.Matchers` interface — augment `vitest.Matchers` separately.
+
+## Snapshot Assertions
+
+```ts
+expect(data).toMatchSnapshot()
+expect(data).toMatchInlineSnapshot(`{ "id": 1 }`)
+await expect(result).toMatchFileSnapshot('./expected.json')
+
+expect(() => throw new Error('fail')).toThrowErrorMatchingSnapshot()
+```
+
+## Key Points
+
+- Use `toBe` for primitives, `toEqual` for objects/arrays
+- `toStrictEqual` checks undefined properties and array sparseness
+- Always `await` async assertions (`resolves`, `rejects`, `poll`)
+- Use context's `expect` in concurrent tests for correct tracking
+- `toThrow` requires wrapping sync code in a function
+- Use `expect.soft` for non-critical assertions; reserve hard `expect` for must-pass conditions
+- Use `expect.assert` (not `toBeTruthy`) when you also need TypeScript narrowing
+
+
diff --git a/.agents/skills/vitest/references/core-hooks.md b/.agents/skills/vitest/references/core-hooks.md
new file mode 100644
index 00000000..453fa141
--- /dev/null
+++ b/.agents/skills/vitest/references/core-hooks.md
@@ -0,0 +1,244 @@
+---
+name: lifecycle-hooks
+description: beforeEach, afterEach, beforeAll, afterAll, and around hooks
+---
+
+# Lifecycle Hooks
+
+## Basic Hooks
+
+```ts
+import { afterAll, afterEach, beforeAll, beforeEach, test } from 'vitest'
+
+beforeAll(async () => {
+ // Runs once before all tests in file/suite
+ await setupDatabase()
+})
+
+afterAll(async () => {
+ // Runs once after all tests in file/suite
+ await teardownDatabase()
+})
+
+beforeEach(async () => {
+ // Runs before each test
+ await clearTestData()
+})
+
+afterEach(async () => {
+ // Runs after each test
+ await cleanupMocks()
+})
+```
+
+## Cleanup Return Pattern
+
+Return cleanup function from `before*` hooks:
+
+```ts
+beforeAll(async () => {
+ const server = await startServer()
+
+ // Returned function runs as afterAll
+ return async () => {
+ await server.close()
+ }
+})
+
+beforeEach(async () => {
+ const connection = await connect()
+
+ // Runs as afterEach
+ return () => connection.close()
+})
+```
+
+## Scoped Hooks
+
+Hooks apply to current suite and nested suites:
+
+```ts
+describe('outer', () => {
+ beforeEach(() => console.log('outer before'))
+
+ test('test 1', () => {}) // outer before → test
+
+ describe('inner', () => {
+ beforeEach(() => console.log('inner before'))
+
+ test('test 2', () => {}) // outer before → inner before → test
+ })
+})
+```
+
+## Hook Timeout
+
+```ts
+beforeAll(async () => {
+ await slowSetup()
+}, 30_000) // 30 second timeout
+```
+
+## Around Hooks
+
+Wrap tests with setup/teardown context:
+
+```ts
+import { aroundEach, test } from 'vitest'
+
+// Wrap each test in database transaction
+aroundEach(async (runTest) => {
+ await db.beginTransaction()
+ await runTest() // Must be called!
+ await db.rollback()
+})
+
+test('insert user', async () => {
+ await db.insert({ name: 'Alice' })
+ // Automatically rolled back after test
+})
+```
+
+### aroundAll
+
+Wrap entire suite:
+
+```ts
+import { aroundAll, test } from 'vitest'
+
+aroundAll(async (runSuite) => {
+ console.log('before all tests')
+ await runSuite() // Must be called!
+ console.log('after all tests')
+})
+```
+
+### Multiple Around Hooks
+
+Nested like onion layers:
+
+```ts
+aroundEach(async (runTest) => {
+ console.log('outer before')
+ await runTest()
+ console.log('outer after')
+})
+
+aroundEach(async (runTest) => {
+ console.log('inner before')
+ await runTest()
+ console.log('inner after')
+})
+
+// Order: outer before → inner before → test → inner after → outer after
+```
+
+## Test Hooks
+
+Inside test body:
+
+```ts
+import { onTestFailed, onTestFinished, test } from 'vitest'
+
+test('with cleanup', () => {
+ const db = connect()
+
+ // Runs after test finishes (pass or fail)
+ onTestFinished(() => db.close())
+
+ // Only runs if test fails
+ onTestFailed(({ task }) => {
+ console.log('Failed:', task.result?.errors)
+ })
+
+ db.query('SELECT * FROM users')
+})
+```
+
+### Reusable Cleanup Pattern
+
+```ts
+function useTestDb() {
+ const db = connect()
+ onTestFinished(() => db.close())
+ return db
+}
+
+test('query users', () => {
+ const db = useTestDb()
+ expect(db.query('SELECT * FROM users')).toBeDefined()
+})
+
+test('query orders', () => {
+ const db = useTestDb() // Fresh connection, auto-closed
+ expect(db.query('SELECT * FROM orders')).toBeDefined()
+})
+```
+
+## Concurrent Test Hooks
+
+For concurrent tests, use context's hooks:
+
+```ts
+test.concurrent('concurrent', ({ onTestFinished }) => {
+ const resource = allocate()
+ onTestFinished(() => resource.release())
+})
+```
+
+## Extended Test Hooks
+
+With `test.extend`, hooks are type-aware and must be called on the extended `test`:
+
+```ts
+const test = base
+ .extend('db', { scope: 'file' }, async ({}, { onCleanup }) => {
+ const db = await createDb()
+ onCleanup(() => db.close())
+ return db
+ })
+
+// Test-level hooks see fixtures
+test.beforeEach(({ db }) => db.seed())
+test.afterEach(({ db }) => db.clear())
+
+// Suite-level hooks (4.1+) can access file/worker-scoped fixtures
+test.beforeAll(({ db }) => db.createUsers())
+test.aroundAll(async (runSuite, { db }) => db.transaction(runSuite))
+```
+
+Suite-level hooks (`beforeAll`/`afterAll`/`aroundAll`) only see **file/worker-scoped** fixtures, and the **global** `beforeAll`/`afterAll` cannot access custom fixtures — use `test.beforeAll` etc.
+
+## Hook Execution Order
+
+Default order (stack):
+1. `beforeAll` (in order)
+2. `beforeEach` (in order)
+3. Test
+4. `afterEach` (reverse order)
+5. `afterAll` (reverse order)
+
+Configure with `sequence.hooks`:
+
+```ts
+defineConfig({
+ test: {
+ sequence: {
+ hooks: 'list', // 'stack' (default), 'list', 'parallel'
+ },
+ },
+})
+```
+
+## Key Points
+
+- Hooks are not called during type checking
+- Return cleanup function from `before*` to avoid `after*` duplication
+- `aroundEach`/`aroundAll` must call `runTest()`/`runSuite()`
+- `onTestFinished` always runs, even if test fails
+- Use context hooks for concurrent tests
+
+
diff --git a/.agents/skills/vitest/references/core-test-api.md b/.agents/skills/vitest/references/core-test-api.md
new file mode 100644
index 00000000..347632df
--- /dev/null
+++ b/.agents/skills/vitest/references/core-test-api.md
@@ -0,0 +1,267 @@
+---
+name: test-api
+description: test/it function for defining tests with modifiers
+---
+
+# Test API
+
+## Basic Test
+
+```ts
+import { expect, test } from 'vitest'
+
+test('adds numbers', () => {
+ expect(1 + 1).toBe(2)
+})
+
+// Alias: it
+import { it } from 'vitest'
+
+it('works the same', () => {
+ expect(true).toBe(true)
+})
+```
+
+## Async Tests
+
+```ts
+test('async test', async () => {
+ const result = await fetchData()
+ expect(result).toBeDefined()
+})
+
+// Promises are automatically awaited
+test('returns promise', () => {
+ return fetchData().then(result => {
+ expect(result).toBeDefined()
+ })
+})
+```
+
+## Test Options
+
+```ts
+// Timeout (default: 5000ms)
+test('slow test', async () => {
+ // ...
+}, 10_000)
+
+// Or with options object
+test('with options', { timeout: 10_000, retry: 2 }, async () => {
+ // ...
+})
+```
+
+## Test Modifiers
+
+### Skip Tests
+
+```ts
+test.skip('skipped test', () => {
+ // Won't run
+})
+
+// Conditional skip
+test.skipIf(process.env.CI)('not in CI', () => {})
+test.runIf(process.env.CI)('only in CI', () => {})
+
+// Dynamic skip via context
+test('dynamic skip', ({ skip }) => {
+ skip(someCondition, 'reason')
+ // ...
+})
+```
+
+### Focus Tests
+
+```ts
+test.only('only this runs', () => {
+ // Other tests in file are skipped
+})
+```
+
+### Todo Tests
+
+```ts
+test.todo('implement later')
+
+test.todo('with body', () => {
+ // Not run, shows in report
+})
+```
+
+### Failing Tests
+
+```ts
+test.fails('expected to fail', () => {
+ expect(1).toBe(2) // Test passes because assertion fails
+})
+```
+
+### Concurrent Tests
+
+```ts
+// Run tests in parallel
+test.concurrent('test 1', async ({ expect }) => {
+ // Use context.expect for concurrent tests
+ expect(await fetch1()).toBe('result')
+})
+
+test.concurrent('test 2', async ({ expect }) => {
+ expect(await fetch2()).toBe('result')
+})
+```
+
+### Opt Out of Concurrency
+
+`test.sequential` was **removed in v5**. Use `concurrent: false` to opt a test out of inherited or globally configured concurrency:
+
+```ts
+test('must run alone', { concurrent: false }, async () => {})
+```
+
+## Parameterized Tests
+
+### test.each
+
+```ts
+test.each([
+ [1, 1, 2],
+ [1, 2, 3],
+ [2, 1, 3],
+])('add(%i, %i) = %i', (a, b, expected) => {
+ expect(a + b).toBe(expected)
+})
+
+// With objects
+test.each([
+ { a: 1, b: 1, expected: 2 },
+ { a: 1, b: 2, expected: 3 },
+])('add($a, $b) = $expected', ({ a, b, expected }) => {
+ expect(a + b).toBe(expected)
+})
+
+// Template literal
+test.each`
+ a | b | expected
+ ${1} | ${1} | ${2}
+ ${1} | ${2} | ${3}
+`('add($a, $b) = $expected', ({ a, b, expected }) => {
+ expect(a + b).toBe(expected)
+})
+```
+
+### test.for
+
+Preferred over `.each` - doesn't spread arrays:
+
+```ts
+test.for([
+ [1, 1, 2],
+ [1, 2, 3],
+])('add(%i, %i) = %i', ([a, b, expected], { expect }) => {
+ // Second arg is TestContext
+ expect(a + b).toBe(expected)
+})
+```
+
+> **v5:** titles are formatted with `pretty-format`, and a string interpolated through a `$` placeholder is **no longer quoted** (`case $id` → `case a1`, not `case 'a1'`). Interpolated-value length is capped by `taskTitleValueFormatTruncate` (default 40).
+
+## Test Context
+
+First argument provides context utilities:
+
+```ts
+test('with context', ({ expect, skip, task, signal, annotate }) => {
+ console.log(task.name) // Test metadata
+ skip(someCondition, 'reason') // Skip dynamically
+ expect(1).toBe(1) // Context-bound expect
+})
+
+// signal (3.2+): AbortSignal aborted on timeout/cancel/bail
+test('aborts on timeout', async ({ signal }) => {
+ await fetch('/resource', { signal })
+}, 2000)
+
+// annotate (3.2+): attach notes shown by the reporter
+test('annotated', async ({ annotate }) => {
+ await annotate('see issue #123', 'issues')
+})
+```
+
+## Custom Test with Fixtures
+
+Prefer the **builder pattern** (4.1+) for automatic type inference:
+
+```ts
+import { test as base } from 'vitest'
+
+const test = base
+ .extend('db', async ({}, { onCleanup }) => {
+ const db = await createDb()
+ onCleanup(() => db.close()) // runs after the test/scope
+ return db
+ })
+
+test('query', async ({ db }) => {
+ const users = await db.query('SELECT * FROM users')
+ expect(users).toBeDefined()
+})
+```
+
+See [features-context](features-context.md) for fixture scopes, `test.override`, and the Playwright-compatible object syntax.
+
+## Retry Configuration
+
+```ts
+test('flaky test', { retry: 3 }, async () => {
+ // Retries up to 3 times on failure
+})
+
+// Advanced retry options
+test('with delay', {
+ retry: {
+ count: 3,
+ delay: 1000,
+ condition: /timeout/i, // Only retry on timeout errors
+ },
+}, async () => {})
+```
+
+## Tags
+
+Tags must be declared in config first, then applied to tests (4.1+):
+
+```ts
+test('database test', { tags: ['db', 'slow'] }, async () => {})
+
+// Run with a tag expression:
+// vitest --tagsFilter "db && !flaky"
+```
+
+See [features-test-tags](features-test-tags.md) for defining tags and filter syntax.
+
+## Benchmarks (v5)
+
+`bench` is no longer a top-level import — it is a [test-context fixture](features-benchmarking.md) used inside `test()`:
+
+```ts
+// file must match benchmark.include (e.g. *.bench.ts)
+test('sort', async ({ bench }) => {
+ await bench('Array.sort', () => [3, 1, 2].sort()).run()
+})
+```
+
+## Key Points
+
+- Pass options as the **second argument**; the 3rd-arg options object was removed in v4 (a trailing timeout number is still allowed)
+- Tests with no body are marked as `todo`
+- `test.only` throws in CI unless `allowOnly: true`
+- Use context's `expect` for concurrent tests and snapshots
+- Function name is used as test name if passed as first arg
+- `test.sequential` was removed in v5 — use `{ concurrent: false }`
+
+
diff --git a/.agents/skills/vitest/references/features-benchmarking.md b/.agents/skills/vitest/references/features-benchmarking.md
new file mode 100644
index 00000000..e2074f45
--- /dev/null
+++ b/.agents/skills/vitest/references/features-benchmarking.md
@@ -0,0 +1,100 @@
+---
+name: benchmarking
+description: Write benchmarks with the v5 bench test-context fixture (Tinybench)
+---
+
+# Benchmarking (v5)
+
+In v5 the benchmark API was rewritten: `bench` is **no longer a top-level import**. It is a [test-context fixture](features-context.md) used inside a regular `test()`, available only in files matched by `benchmark.include` (default `**/*.{bench,benchmark}.?(c|m)[jt]s?(x)`). Benchmarks are powered by [Tinybench](https://github.com/tinylibs/tinybench).
+
+## Defining & Running
+
+```ts
+import { expect, test } from 'vitest'
+
+test('parse performance', async ({ bench }) => {
+ // bench() registers; .run() executes and returns the result
+ const result = await bench('parse', () => {
+ const data = JSON.parse('{"key":"value"}')
+ use(data) // consume the result — engines may eliminate dead code
+ }).run()
+
+ expect(result.throughput.mean).toBeGreaterThan(10_000)
+})
+```
+
+Run benchmarks:
+
+```bash
+vitest bench # only benchmarks (implicitly enables them)
+vitest bench parser # filter by filename
+vitest bench -t JSON # filter by test name
+```
+
+Set `benchmark: { enabled: true }` to run them alongside regular tests in a separate isolated group.
+
+## Comparing Implementations
+
+```ts
+test('compare parsers', async ({ bench }) => {
+ const result = await bench.compare(
+ bench('JSON.parse', () => { JSON.parse(input) }),
+ bench('custom', { beforeEach: () => reset() }, () => { customParse(input) }),
+ { iterations: 100, time: 1000 }, // shared Tinybench options (last arg)
+ )
+
+ // Assertion matchers (delta avoids flaky failures)
+ expect(result.get('JSON.parse')).toBeFasterThan(result.get('custom'), { delta: 0.1 })
+ expect(result.get('custom')).toBeSlowerThan(result.get('JSON.parse'))
+})
+```
+
+`bench.compare` interleaves iterations to reduce environmental bias and prints a comparison table after the test.
+
+## Storing & Replaying Baselines
+
+```ts
+test('compare against baseline', async ({ bench }) => {
+ await bench.compare(
+ bench('current', { writeResult: './benchmarks/parse.json' }, () => parse(input)),
+ bench.from('previous', './benchmarks/parse.json'), // reads a stored result, no run
+ bench.from('remote', () => fetch(url).then(r => r.json())),
+ )
+})
+```
+
+- `writeResult` overwrites the JSON file on every successful run (no skip-when-cached).
+- `bench.from(name, source)` reads a stored result without invoking any function.
+- For multi-project workspaces, pass `{ perProject: true }` and use `${projectName}` in `writeResult` paths to collect a cross-project comparison table.
+
+## Stability Notes
+
+- Benchmark files run sequentially and never in parallel; `retry` and the `delta` option reduce flakiness.
+- Consume the result inside the bench fn — JS engines eliminate side-effect-free code.
+- In Node mode every imported binding goes through Vite's module-runner getter; store hot references locally (`const _parse = parse`), benchmark the built package, or disable `experimental.viteModuleRunner` for the bench project.
+
+## Custom Provider (experimental)
+
+Replace the built-in Tinybench engine by pointing `benchmark.provider` at a module whose default export implements `BenchmarkProvider`:
+
+```ts
+defineConfig({ test: { benchmark: { provider: './benchmark-provider.ts' } } })
+```
+
+## v5 Migration
+
+- `bench` top-level import → `({ bench })` from the test context
+- `bench.skip/only/todo` removed → use `test.skip/only/todo` on the surrounding test
+- `benchmark.reporters`/`outputFile`/`compare`/`outputJson` and `--compare`/`--outputJson` removed → use `--reporter=json --outputFile` (JSON now has a `benchmarks` field)
+
+## Key Points
+
+- Benchmarks live in `*.bench.ts` files and run inside `test()` via `{ bench }`
+- Use `bench.compare` + `toBeFasterThan`/`toBeSlowerThan` (with `delta`) for relative perf
+- Persist baselines with `writeResult` and replay with `bench.from`
+
+
diff --git a/.agents/skills/vitest/references/features-concurrency.md b/.agents/skills/vitest/references/features-concurrency.md
new file mode 100644
index 00000000..cff37cc4
--- /dev/null
+++ b/.agents/skills/vitest/references/features-concurrency.md
@@ -0,0 +1,223 @@
+---
+name: concurrency-parallelism
+description: Concurrent tests, parallel execution, and sharding
+---
+
+# Concurrency & Parallelism
+
+## File Parallelism
+
+By default, Vitest runs test files in parallel across workers:
+
+```ts
+defineConfig({
+ test: {
+ // Run files in parallel (default: true)
+ fileParallelism: true,
+
+ // Max concurrent workers (v4: replaces maxThreads/maxForks; minWorkers removed)
+ maxWorkers: 4,
+
+ // Pool type: 'forks' (default), 'threads', 'vmForks', 'vmThreads'
+ pool: 'forks',
+ },
+})
+```
+
+> **v4 pool rework:** `poolOptions` was removed — all pool settings are now top-level. `singleThread`/`singleFork` become `maxWorkers: 1, isolate: false`. VM `memoryLimit` is `vmMemoryLimit`. These can now be set **per project**.
+
+## Concurrent Tests
+
+Run tests within a file in parallel:
+
+```ts
+// Individual concurrent tests
+test.concurrent('test 1', async ({ expect }) => {
+ expect(await fetch1()).toBe('result')
+})
+
+test.concurrent('test 2', async ({ expect }) => {
+ expect(await fetch2()).toBe('result')
+})
+
+// All tests in suite concurrent
+describe.concurrent('parallel suite', () => {
+ test('test 1', async ({ expect }) => {})
+ test('test 2', async ({ expect }) => {})
+})
+```
+
+**Important:** Use `{ expect }` from context for concurrent tests.
+
+## Opting Out of Concurrency
+
+`test.sequential`/`describe.sequential` were **removed in v5**. Use `{ concurrent: false }`:
+
+```ts
+describe.concurrent('mostly parallel', () => {
+ test('parallel 1', async () => {})
+
+ // Opt this test out of inherited concurrency
+ test('must run alone', { concurrent: false }, async () => {})
+})
+
+// Or an entire suite
+describe('sequential suite', { concurrent: false }, () => {
+ test('first', () => {})
+ test('second', () => {})
+})
+```
+
+Set `sequence.concurrent: true` to make all tests concurrent by default.
+
+## Max Concurrency
+
+Limit concurrent tests:
+
+```ts
+defineConfig({
+ test: {
+ maxConcurrency: 5, // Max concurrent tests per file
+ },
+})
+```
+
+## Isolation
+
+Each file runs in isolated environment by default:
+
+```ts
+defineConfig({
+ test: {
+ // Disable isolation for faster runs (less safe)
+ isolate: false,
+ },
+})
+```
+
+## Sharding
+
+Split tests across machines:
+
+```bash
+# Machine 1
+vitest run --shard=1/3
+
+# Machine 2
+vitest run --shard=2/3
+
+# Machine 3
+vitest run --shard=3/3
+```
+
+### CI Example (GitHub Actions)
+
+```yaml
+jobs:
+ test:
+ strategy:
+ matrix:
+ shard: [1, 2, 3]
+ steps:
+ - run: vitest run --shard=${{ matrix.shard }}/3 --reporter=blob
+
+ merge:
+ needs: test
+ steps:
+ - run: vitest --merge-reports --reporter=junit
+```
+
+### Merge Reports
+
+```bash
+# Each shard outputs blob
+vitest run --shard=1/3 --reporter=blob --coverage
+vitest run --shard=2/3 --reporter=blob --coverage
+
+# Merge all blobs
+vitest --merge-reports --reporter=json --coverage
+```
+
+## Test Sequence
+
+Control test order:
+
+```ts
+defineConfig({
+ test: {
+ sequence: {
+ // Run tests in random order
+ shuffle: true,
+
+ // Seed for reproducible shuffle
+ seed: 12345,
+
+ // Hook execution order
+ hooks: 'stack', // 'stack', 'list', 'parallel'
+
+ // All tests concurrent by default
+ concurrent: true,
+
+ // Order projects/groups run in (3.2+); lower runs first
+ groupOrder: 0,
+ },
+ },
+})
+```
+
+## Shuffle Tests
+
+Randomize to catch hidden dependencies:
+
+```ts
+// Via CLI
+vitest --shuffle
+
+// Per suite
+describe.shuffle('random order', () => {
+ test('test 1', () => {})
+ test('test 2', () => {})
+ test('test 3', () => {})
+})
+```
+
+## Pools (v4)
+
+`poolOptions` was removed; pool settings are now top-level and can be set per project:
+
+```ts
+defineConfig({
+ test: {
+ pool: 'forks', // 'forks' (default) | 'threads' | 'vmForks' | 'vmThreads'
+ maxWorkers: 8,
+ isolate: true, // threads/forks only; vm* pools are always isolated
+ vmMemoryLimit: '512MB',
+ },
+})
+```
+
+For per-project parallelism/isolation settings, see [advanced-projects](advanced-projects.md).
+
+## Bail on Failure
+
+Stop after first failure:
+
+```bash
+vitest --bail 1 # Stop after 1 failure
+vitest --bail # Stop on first failure (same as --bail 1)
+```
+
+## Key Points
+
+- Files run in parallel by default (`pool: 'forks'`); tests within a file run sequentially unless `.concurrent`
+- `concurrent` only speeds up tests that **await** (I/O, timers); pure sync tests still block the thread
+- Always use context's `expect` in concurrent tests
+- Use `{ concurrent: false }` (not `.sequential`) to opt out
+- `maxWorkers` (not `maxThreads`/`maxForks`); `poolOptions` removed in v4
+- Sharding splits tests across CI machines; `--merge-reports` combines blob results
+
+
diff --git a/.agents/skills/vitest/references/features-context.md b/.agents/skills/vitest/references/features-context.md
new file mode 100644
index 00000000..08a08958
--- /dev/null
+++ b/.agents/skills/vitest/references/features-context.md
@@ -0,0 +1,163 @@
+---
+name: test-context-fixtures
+description: Test context, custom fixtures with test.extend (builder pattern), scopes, and test.override
+---
+
+# Test Context & Fixtures
+
+## Built-in Context
+
+Every test receives context as its first argument:
+
+```ts
+test('context', ({ task, expect, skip, signal, annotate }) => {
+ console.log(task.name) // Test metadata (readonly)
+ expect(1).toBe(1) // Expect bound to this test
+ skip(condition, 'reason') // Skip dynamically
+})
+```
+
+Properties:
+- `task` — test metadata (name, file, etc.)
+- `expect` — expect bound to this test (required for concurrent snapshot tests)
+- `skip(condition?, message?)` — skip the test
+- `signal` (3.2+) — `AbortSignal` aborted on timeout/cancel/bail
+- `annotate(message, type?, attachment?)` (3.2+) — attach reporter annotations
+- `onTestFinished(fn)` / `onTestFailed(fn)` — per-test cleanup/handlers
+- `bench` (v5) — benchmark fixture (only in `*.bench.ts` files)
+
+## Custom Fixtures — Builder Pattern (4.1+, recommended)
+
+`.extend(name, options?, fixture)` infers types automatically. Use `onCleanup` for teardown:
+
+```ts
+import { test as baseTest } from 'vitest'
+
+export const test = baseTest
+ // Plain value — type inferred as { port: number; host: string }
+ .extend('config', { port: 3000, host: 'localhost' })
+ // Function fixture — can read previously defined fixtures
+ .extend('server', async ({ config }, { onCleanup }) => {
+ const server = await startServer(config)
+ onCleanup(() => server.close()) // runs after test/scope ends
+ return server
+ })
+
+test('uses server', ({ config, server }) => {
+ expect(server.url).toContain(String(config.port))
+})
+```
+
+> `onCleanup` can be called **once per fixture**. For multiple resources, split into separate fixtures.
+
+### Fixture Options
+
+```ts
+const test = baseTest
+ .extend('metrics', { auto: true }, () => new Metrics()) // runs for every test
+ .extend('config', { scope: 'worker' }, () => loadConfig()) // once per worker
+ .extend('db', { scope: 'file' }, async ({ config }, { onCleanup }) => {
+ const db = await createDatabase(config)
+ onCleanup(() => db.close())
+ return db
+ })
+ .extend('baseUrl', { injected: true }, () => 'http://localhost:3000') // overridable via config
+```
+
+## Object Syntax (Playwright-compatible)
+
+Uses the `use()` callback; types must be declared manually:
+
+```ts
+const test = baseTest.extend<{ page: Page; baseUrl: string }>({
+ page: async ({}, use) => {
+ const page = await browser.newPage()
+ await use(page) // test runs here
+ await page.close() // cleanup after
+ },
+ baseUrl: 'http://localhost:3000',
+})
+```
+
+Tuple form sets options: `fixture: [async ({}, use) => {…}, { scope: 'file' }]`.
+
+## Fixture Scopes (3.2+)
+
+| Scope | Lifetime | Can access |
+|-------|----------|------------|
+| `test` (default) | each test | worker + file + test fixtures + built-in context |
+| `file` | once per file | worker + file fixtures |
+| `worker` | once per worker process | only worker fixtures |
+
+Only `test`-scoped fixtures can access the built-in context (`task`, `expect`, …). In file/worker fixtures use `expect.getState().testPath` for the file path. By default every file is its own worker, so `file` and `worker` behave the same unless [isolation is disabled](features-concurrency.md).
+
+## Injected Fixtures (per-project values)
+
+```ts
+// fixtures.ts
+const test = baseTest.extend('url', { injected: true }, '/default')
+
+// vitest.config.ts — provide per project
+defineConfig({
+ test: {
+ projects: [
+ { test: { name: 'prod', provide: { url: 'https://prod' } } },
+ ],
+ },
+})
+```
+
+Read raw provided values without fixtures via `import { inject } from 'vitest'`.
+
+## Overriding Fixtures — test.override (4.1+)
+
+`test.override` replaces fixture values for a suite and its children (replaces the deprecated `test.scoped`):
+
+```ts
+describe('production', () => {
+ test
+ .override('config', { port: 8080, host: 'api.example.com' })
+ .override('debug', false) // chainable
+
+ test('uses prod config', ({ server }) => {
+ expect(server.url).toBe('http://api.example.com:8080')
+ })
+})
+
+// Function override (reads other fixtures) with cleanup
+test.override('db', async ({ config }, { onCleanup }) => {
+ const db = await createTestDatabase(config)
+ onCleanup(() => db.drop())
+ return db
+})
+```
+
+You cannot introduce new fixtures or change `scope`/`auto` via `override`; use `test.extend` for new fixtures.
+
+## Composing & Hooks
+
+Extend an already-extended test, and use type-aware hooks on the extended `test`:
+
+```ts
+import { test as dbTest } from './db-test'
+
+export const test = dbTest.extend('user', ({ db }) => db.createUser())
+
+test.beforeEach(({ db }) => db.seed()) // sees fixtures
+test.beforeAll(({ db }) => db.migrate()) // file/worker fixtures only (4.1+)
+test.aroundAll(async (run, { db }) => db.tx(run))
+```
+
+## Key Points
+
+- Prefer the **builder pattern** — types are inferred, cleanup via `onCleanup`
+- Fixtures are lazy — only initialized when destructured
+- Always destructure `{ db }` (not `context.db`)
+- Use `{ scope: 'file' | 'worker' }` for expensive shared resources
+- Use `test.override` (not `test.scoped`) to vary fixture values per suite
+- Use `{ injected: true }` + project `provide` for per-project values
+
+
diff --git a/.agents/skills/vitest/references/features-coverage.md b/.agents/skills/vitest/references/features-coverage.md
new file mode 100644
index 00000000..4aef2a36
--- /dev/null
+++ b/.agents/skills/vitest/references/features-coverage.md
@@ -0,0 +1,226 @@
+---
+name: code-coverage
+description: Code coverage with V8 or Istanbul providers
+---
+
+# Code Coverage
+
+## Setup
+
+```bash
+# Run tests with coverage
+vitest run --coverage
+```
+
+## Configuration
+
+```ts
+// vitest.config.ts
+defineConfig({
+ test: {
+ coverage: {
+ // Provider: 'v8' (default, faster) or 'istanbul' (more compatible)
+ provider: 'v8',
+
+ // Enable coverage
+ enabled: true,
+
+ // Reporters
+ reporter: ['text', 'json', 'html'],
+
+ // v4: define `include` to report uncovered files too.
+ // Without it, only files loaded during the run are reported.
+ include: ['src/**/*.{ts,tsx}'],
+
+ // Exclusion is applied to files matched by `include`
+ exclude: [
+ '**/*.d.ts',
+ '**/*.test.ts',
+ ],
+
+ // Thresholds
+ thresholds: {
+ lines: 80,
+ functions: 80,
+ branches: 80,
+ statements: 80,
+ },
+
+ // v5 (v8 only): also cover node:child_process / node:worker_threads
+ // spawned during the run (adds overhead via NODE_V8_COVERAGE)
+ autoAttachSubprocess: false,
+ },
+ },
+})
+```
+
+## Providers
+
+### V8 (Default)
+
+```bash
+npm i -D @vitest/coverage-v8
+```
+
+- Faster, no pre-instrumentation
+- Uses V8's native coverage
+- v4 uses **AST-based remapping** (as accurate as Istanbul); expect coverage numbers to shift when upgrading from v3
+- Recommended for most projects
+
+### Istanbul
+
+```bash
+npm i -D @vitest/coverage-istanbul
+```
+
+- Pre-instruments code
+- Works in any JS runtime
+- More overhead but widely compatible
+
+## Reporters
+
+```ts
+coverage: {
+ reporter: [
+ 'text', // Terminal output
+ 'text-summary', // Summary only
+ 'json', // JSON file
+ 'html', // HTML report
+ 'lcov', // For CI tools
+ 'cobertura', // XML format
+ ],
+ reportsDirectory: './coverage',
+}
+```
+
+## Thresholds
+
+Fail tests if coverage is below threshold:
+
+```ts
+coverage: {
+ thresholds: {
+ // Global thresholds
+ lines: 80,
+ functions: 75,
+ branches: 70,
+ statements: 80,
+
+ // Per-file thresholds
+ perFile: true,
+
+ // Auto-update thresholds (for gradual improvement)
+ // v5: a function receives (newThreshold, previousThreshold)
+ autoUpdate: true,
+
+ // v5: glob thresholds no longer inherit top-level `perFile` — set it per glob
+ 'src/utils/**': { lines: 80, perFile: true },
+ },
+}
+```
+
+## Ignoring Code
+
+### V8
+
+```ts
+/* v8 ignore next -- @preserve */
+function ignored() {
+ return 'not covered'
+}
+
+/* v8 ignore start -- @preserve */
+// All code here ignored
+/* v8 ignore stop -- @preserve */
+```
+
+### Istanbul
+
+```ts
+/* istanbul ignore next -- @preserve */
+function ignored() {}
+
+/* istanbul ignore if -- @preserve */
+if (condition) {
+ // ignored
+}
+```
+
+Note: `@preserve` keeps comments through esbuild.
+
+## Package.json Scripts
+
+```json
+{
+ "scripts": {
+ "test": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "test:coverage:watch": "vitest --coverage"
+ }
+}
+```
+
+## Vitest UI Coverage
+
+Enable HTML coverage in Vitest UI:
+
+```ts
+coverage: {
+ enabled: true,
+ reporter: ['text', 'html'],
+}
+```
+
+Run with `vitest --ui` to view coverage visually.
+
+## CI Integration
+
+```yaml
+# GitHub Actions
+- name: Run tests with coverage
+ run: npm run test:coverage
+
+- name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v3
+ with:
+ files: ./coverage/lcov.info
+```
+
+## Coverage with Sharding
+
+Merge coverage from sharded runs (blobs default to `.vitest/blob/`):
+
+```bash
+vitest run --shard=1/3 --coverage --reporter=blob
+vitest run --shard=2/3 --coverage --reporter=blob
+vitest run --shard=3/3 --coverage --reporter=blob
+
+vitest --merge-reports --coverage --reporter=json
+```
+
+## v5 Changes
+
+- **`include`/`exclude` match relative paths** (not absolute-with-`contains`), so patterns catch fewer files than v4 — a wildcard-free pattern like `'src'` means `src/**`. Re-verify the reported file set after upgrading.
+- **Glob thresholds don't inherit top-level `perFile`** — set `perFile` on each glob that needs it.
+- **`coverage.autoAttachSubprocess`** (v8) tracks child-process/worker-thread coverage.
+- **Istanbul moved to the maintained [`@vitest/istanbuljs`](https://github.com/vitest-dev/istanbuljs) fork**; the v8 provider merges reports with bounded memory.
+
+## v4 Changes
+
+- **`coverage.all` and `coverage.extensions` removed** — only covered files are reported unless `coverage.include` is set.
+- **`coverage.ignoreEmptyLines` removed**; lines without runtime code are no longer counted.
+- **`coverage.experimentalAstAwareRemapping` removed** — AST remapping is the default and only mode for V8.
+- Programmatic coverage APIs moved from `vitest/coverage` to `vitest/node`.
+
+## Key Points
+
+- V8 is faster, Istanbul is more compatible
+- Use `--coverage` flag or `coverage.enabled: true`
+- Define `coverage.include` to report uncovered source files
+- Set thresholds to enforce minimum coverage
+- Use `@preserve` comment to keep ignore hints (e.g. `/* v8 ignore next -- @preserve */`)
+
+
diff --git a/.agents/skills/vitest/references/features-filtering.md b/.agents/skills/vitest/references/features-filtering.md
new file mode 100644
index 00000000..fc8396db
--- /dev/null
+++ b/.agents/skills/vitest/references/features-filtering.md
@@ -0,0 +1,212 @@
+---
+name: test-filtering
+description: Filter tests by name, file patterns, and tags
+---
+
+# Test Filtering
+
+## CLI Filtering
+
+### By File Path
+
+```bash
+# Run files containing "user"
+vitest user
+
+# Multiple patterns
+vitest user auth
+
+# Specific file
+vitest src/user.test.ts
+
+# By line number
+vitest src/user.test.ts:25
+```
+
+### By Test Name
+
+```bash
+# Tests matching pattern
+vitest -t "login"
+vitest --testNamePattern "should.*work"
+
+# Regex patterns
+vitest -t "/user|auth/"
+```
+
+## Changed Files
+
+```bash
+# Uncommitted changes
+vitest --changed
+
+# Since specific commit
+vitest --changed HEAD~1
+vitest --changed abc123
+
+# Since branch
+vitest --changed origin/main
+```
+
+## Related Files
+
+Run tests that import specific files:
+
+```bash
+vitest related src/utils.ts src/api.ts --run
+```
+
+Useful with lint-staged:
+
+```js
+// .lintstagedrc.js
+export default {
+ '*.{ts,tsx}': 'vitest related --run',
+}
+```
+
+## Focus Tests (.only)
+
+```ts
+test.only('only this runs', () => {})
+
+describe.only('only this suite', () => {
+ test('runs', () => {})
+})
+```
+
+In CI, `.only` throws error unless configured:
+
+```ts
+defineConfig({
+ test: {
+ allowOnly: true, // Allow .only in CI
+ },
+})
+```
+
+## Skip Tests
+
+```ts
+test.skip('skipped', () => {})
+
+// Conditional
+test.skipIf(process.env.CI)('not in CI', () => {})
+test.runIf(!process.env.CI)('local only', () => {})
+
+// Dynamic skip
+test('dynamic', ({ skip }) => {
+ skip(someCondition, 'reason')
+})
+```
+
+## Tags
+
+Tags must be declared in config, then applied to tests/suites and filtered with a tag expression:
+
+```ts
+// vitest.config.ts
+defineConfig({
+ test: {
+ tags: [{ name: 'db' }, { name: 'slow' }, { name: 'flaky' }],
+ },
+})
+
+// test file
+test('database test', { tags: ['db'] }, () => {})
+```
+
+```bash
+vitest --tagsFilter "db && !flaky"
+vitest --tagsFilter "unit || e2e"
+vitest --list-tags # show defined tags
+```
+
+Full syntax, priority, and per-tag options: see [features-test-tags](features-test-tags.md).
+
+## Include/Exclude Patterns
+
+```ts
+defineConfig({
+ test: {
+ // Test file patterns
+ include: ['**/*.{test,spec}.{ts,tsx}'],
+
+ // Exclude patterns
+ exclude: [
+ '**/node_modules/**',
+ '**/e2e/**',
+ '**/*.skip.test.ts',
+ ],
+
+ // Include source for in-source testing
+ includeSource: ['src/**/*.ts'],
+
+ // Scope discovery to a directory (faster than broad excludes)
+ dir: './src',
+ },
+})
+```
+
+> v4 simplified default `exclude` to only `node_modules`/`.git`. Prefer `test.dir` to limit where tests are found; spread `configDefaults.exclude` to restore the old excludes.
+
+## Watch Mode Filtering
+
+In watch mode, press:
+- `p` - Filter by filename pattern
+- `t` - Filter by test name pattern
+- `a` - Run all tests
+- `f` - Run only failed tests
+
+## Projects Filtering
+
+Run specific project:
+
+```bash
+vitest --project unit
+vitest --project integration --project e2e
+```
+
+## Environment-based Filtering
+
+```ts
+const isDev = process.env.NODE_ENV === 'development'
+const isCI = process.env.CI
+
+describe.skipIf(isCI)('local only tests', () => {})
+describe.runIf(isDev)('dev tests', () => {})
+```
+
+## Combining Filters
+
+```bash
+# File pattern + test name + changed
+vitest user -t "login" --changed
+
+# Related files + run mode
+vitest related src/auth.ts --run
+```
+
+## List Tests Without Running
+
+```bash
+vitest list # Show all test names
+vitest list -t "user" # Filter by name
+vitest list --filesOnly # Show only file paths
+vitest list --json # JSON output
+```
+
+## Key Points
+
+- Use `-t` for test name pattern filtering
+- `--changed` runs only tests affected by changes
+- `--related` runs tests importing specific files
+- Tags provide semantic test grouping
+- Use `.only` for debugging, but configure CI to reject it
+- Watch mode has interactive filtering
+
+
diff --git a/.agents/skills/vitest/references/features-mocking.md b/.agents/skills/vitest/references/features-mocking.md
new file mode 100644
index 00000000..81192fd9
--- /dev/null
+++ b/.agents/skills/vitest/references/features-mocking.md
@@ -0,0 +1,334 @@
+---
+name: mocking
+description: Mock functions, modules, timers, and dates with vi utilities
+---
+
+# Mocking
+
+## Mock Functions
+
+```ts
+import { expect, vi } from 'vitest'
+
+// Create mock function
+const fn = vi.fn()
+fn('hello')
+
+expect(fn).toHaveBeenCalled()
+expect(fn).toHaveBeenCalledWith('hello')
+
+// With implementation
+const add = vi.fn((a, b) => a + b)
+expect(add(1, 2)).toBe(3)
+
+// Mock return values
+fn.mockReturnValue(42)
+fn.mockReturnValueOnce(1).mockReturnValueOnce(2)
+fn.mockResolvedValue({ data: true })
+fn.mockRejectedValue(new Error('fail'))
+
+// Mock implementation
+fn.mockImplementation((x) => x * 2)
+fn.mockImplementationOnce(() => 'first call')
+```
+
+## Spying on Objects
+
+```ts
+const cart = {
+ getTotal: () => 100,
+}
+
+const spy = vi.spyOn(cart, 'getTotal')
+cart.getTotal()
+
+expect(spy).toHaveBeenCalled()
+
+// Mock implementation
+spy.mockReturnValue(200)
+expect(cart.getTotal()).toBe(200)
+
+// Restore original
+spy.mockRestore()
+```
+
+Since v4, `vi.spyOn`/`vi.fn` can mock **constructors** — provide a `function` or `class` implementation (an arrow function throws "not a constructor"):
+
+```ts
+const Spy = vi.spyOn(cart, 'Apples').mockImplementation(class {
+ getApples() { return 0 }
+})
+const instance = new Spy()
+```
+
+## Conditional Mocking with vi.when (v5)
+
+Define per-argument behaviors without writing `if`/`switch` in `mockImplementation`:
+
+```ts
+vi.when(db.findById)
+ .calledWith(1)
+ .thenResolve({ id: 1, name: 'Ella' })
+ .calledWith(2)
+ .thenResolve({ id: 2, name: 'Gracie' })
+
+// Actions: thenReturn / thenThrow / thenResolve / thenReject (+ *Once variants)
+// `calledWith` supports asymmetric matchers
+vi.when(sendEmail).calledWith(expect.stringContaining('@')).thenReturn({ ok: true })
+```
+
+- Behaviors match **first-in-first-out** (register specific before broad); stacked actions on one behavior consume **last-in-first-out**, with `{ times }` to limit.
+- Handle unmatched calls with `{ onUnmatched: 'throw' | fn }` (default falls through to the original implementation).
+- Assert all behaviors ran with `expect(w).toHaveBeenExhausted()`.
+
+## Auto-Cleanup with `using`
+
+In runtimes with Explicit Resource Management (Node 24+, TS 5.2+), declare spies/mocks with `using` to auto-restore when the block exits — works with `vi.spyOn`, `vi.fn`, `vi.doMock`, and `vi.when`:
+
+```ts
+it('mocks console only here', () => {
+ using spy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ debug('message')
+ expect(spy).toHaveBeenCalled()
+} ) // console.log restored automatically — no afterEach
+```
+
+## Module Mocking
+
+`vi.mock`, `vi.unmock`, and `vi.hoisted` are hoisted to the top of the file. In v5 calling them inside a function, block, or `describe`/`test` callback **throws** (it only warned before) — keep them at the module top level. Use `vi.doMock`/`vi.doUnmock` for non-hoisted, in-scope mocking.
+
+```ts
+// vi.mock is hoisted to top of file
+vi.mock('./api', () => ({
+ fetchUser: vi.fn(() => ({ id: 1, name: 'Mock' })),
+}))
+
+import { fetchUser } from './api'
+
+test('mocked module', () => {
+ expect(fetchUser()).toEqual({ id: 1, name: 'Mock' })
+})
+```
+
+### Partial Mock
+
+```ts
+vi.mock('./utils', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ specificFunction: vi.fn(),
+ }
+})
+```
+
+### Auto-mock with Spy
+
+```ts
+// Keep implementation but spy on calls
+vi.mock('./calculator', { spy: true })
+
+import { add } from './calculator'
+
+test('spy on module', () => {
+ const result = add(1, 2) // Real implementation
+ expect(result).toBe(3)
+ expect(add).toHaveBeenCalledWith(1, 2)
+})
+```
+
+### Manual Mocks (__mocks__)
+
+```
+src/
+ __mocks__/
+ axios.ts # Mocks 'axios'
+ api/
+ __mocks__/
+ client.ts # Mocks './client'
+ client.ts
+```
+
+```ts
+// Just call vi.mock with no factory
+vi.mock('axios')
+vi.mock('./api/client')
+```
+
+## Dynamic Mocking (vi.doMock)
+
+Not hoisted - use for dynamic imports:
+
+```ts
+test('dynamic mock', async () => {
+ vi.doMock('./config', () => ({
+ apiUrl: 'http://test.local',
+ }))
+
+ const { apiUrl } = await import('./config')
+ expect(apiUrl).toBe('http://test.local')
+
+ vi.doUnmock('./config')
+})
+```
+
+## Mock Timers
+
+```ts
+import { afterEach, beforeEach, vi } from 'vitest'
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+test('timers', () => {
+ const fn = vi.fn()
+ setTimeout(fn, 1000)
+
+ expect(fn).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(1000)
+ expect(fn).toHaveBeenCalled()
+})
+
+// Other timer methods
+vi.runAllTimers() // Run all pending timers
+vi.runOnlyPendingTimers() // Run only currently pending
+vi.advanceTimersToNextTimer() // Advance to next timer
+```
+
+### Async Timer Methods
+
+```ts
+test('async timers', async () => {
+ vi.useFakeTimers()
+
+ let resolved = false
+ setTimeout(() => Promise.resolve().then(() => { resolved = true }), 100)
+
+ await vi.advanceTimersByTimeAsync(100)
+ expect(resolved).toBe(true)
+})
+```
+
+## Mock Dates
+
+```ts
+vi.setSystemTime(new Date('2024-01-01'))
+expect(new Date().getFullYear()).toBe(2024)
+
+vi.useRealTimers() // Restore
+```
+
+v5 fake timers (and `vi.setSystemTime` used without them) also mock `Temporal` when it's on the global object, not just `Date`:
+
+```ts
+vi.setSystemTime(0)
+Temporal.Now.instant().epochMilliseconds // 0
+
+// keep Temporal native:
+vi.useFakeTimers({ toNotFake: ['Temporal'] })
+```
+
+## Mock Globals
+
+```ts
+vi.stubGlobal('fetch', vi.fn(() =>
+ Promise.resolve({ json: () => ({ data: 'mock' }) })
+))
+
+// Restore
+vi.unstubAllGlobals()
+```
+
+## Mock Environment Variables
+
+```ts
+vi.stubEnv('API_KEY', 'test-key')
+expect(import.meta.env.API_KEY).toBe('test-key')
+
+// Restore
+vi.unstubAllEnvs()
+```
+
+## Clearing Mocks
+
+```ts
+const fn = vi.fn()
+fn()
+
+fn.mockClear() // Clear call history
+fn.mockReset() // Clear history + implementation
+fn.mockRestore() // Restore original (for spies)
+
+// Global
+vi.clearAllMocks()
+vi.resetAllMocks()
+vi.restoreAllMocks()
+```
+
+## Config Auto-Reset
+
+```ts
+// vitest.config.ts
+defineConfig({
+ test: {
+ clearMocks: true, // Clear call history before each test — v5 DEFAULT
+ mockReset: true, // Reset before each test
+ restoreMocks: true, // Restore after each test
+ unstubEnvs: true, // Restore env vars
+ unstubGlobals: true, // Restore globals
+ },
+})
+```
+
+> **v5:** `clearMocks` defaults to `true`, so mock call history no longer leaks between tests. Mocks set up outside the test body (setup files, module top level, `beforeAll`) are most affected — their recorded calls are cleared before the asserting test runs. Set `clearMocks: false` to restore the old behavior.
+
+## Hoisted Variables for Mocks
+
+```ts
+const mockFn = vi.hoisted(() => vi.fn())
+
+vi.mock('./module', () => ({
+ getData: mockFn,
+}))
+
+import { getData } from './module'
+
+test('hoisted mock', () => {
+ mockFn.mockReturnValue('test')
+ expect(getData()).toBe('test')
+})
+```
+
+## v5 Behavior Changes
+
+- **Class mocks keep prototype methods.** `vi.fn(Dog)`, `vi.spyOn(obj, 'Dog')`, and `.mockImplementation(class …)` now chain the mock's `prototype` to the implementation's, so instance methods work and `instanceof Dog` passes. `mockReset` reverts the chain.
+- **Automocked modules stay automocked in the browser** — their exports return `undefined` unless you pass `{ spy: true }` or a factory.
+
+## v4 Behavior Changes
+
+- `vi.fn().getMockName()` returns `'vi.fn()'` (was `'spy'`); snapshots show `[MockFunction]` instead of `[MockFunction spy]`.
+- `vi.restoreAllMocks` (and `restoreMocks: true`) now **only restore `vi.spyOn` spies**; automocks are unaffected. `.mockRestore` still resets a mock's implementation/state.
+- `vi.fn().mock.invocationCallOrder` starts at `1` (Jest parity).
+- Automocked getters return `undefined` by default; automocked methods can't be restored.
+
+## Key Points
+
+- Prefer `vi.mock` for module mocking (hoisted - called before imports)
+- Use `vi.doMock` for dynamic, non-hoisted mocking
+- Use `vi.when` for argument-specific behaviors; `using` for scoped auto-restore
+- Use `{ spy: true }` to keep implementation but track calls
+- `vi.hoisted` lets you reference variables in mock factories
+
+
diff --git a/.agents/skills/vitest/references/features-reporters.md b/.agents/skills/vitest/references/features-reporters.md
new file mode 100644
index 00000000..4b293b01
--- /dev/null
+++ b/.agents/skills/vitest/references/features-reporters.md
@@ -0,0 +1,110 @@
+---
+name: reporters
+description: Built-in reporters, default selection, and CI/output configuration
+---
+
+# Reporters
+
+Select reporters via `--reporter` or `reporters` config. Configuring `reporters` **replaces** the default list — spread `configDefaults.reporters` to keep them.
+
+```ts
+import { configDefaults, defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ reporters: ['verbose', ['junit', { suiteName: 'UI tests' }]],
+ // keep defaults and add one:
+ // reporters: ['json', ...configDefaults.reporters],
+ },
+})
+```
+
+## Default Selection
+
+When `reporters` is unset, Vitest auto-selects:
+
+- `default` for normal terminal runs
+- **`minimal` (alias `agent`)** when it detects an **AI coding agent** — only failed tests + errors, no summary or passing logs, optimized to cut token usage
+- `github-actions` is added when `process.env.GITHUB_ACTIONS === 'true'`
+
+## Built-in Reporters
+
+| Reporter | Use |
+|----------|-----|
+| `default` | Summary + collapses passing files; prints full tree for single/failing file |
+| `verbose` | One line per finished test (flat list in v4); only reporter that shows annotations on pass |
+| `tree` | Like `default` but always shows each test (the old v3 verbose) |
+| `dot` | One dot per test; details only for failures |
+| `minimal` / `agent` | Failures only; best for AI/LLM workflows |
+| `junit` | JUnit XML (templated, see below) |
+| `json` | Jest-compatible JSON; includes `coverageMap` when coverage enabled |
+| `tap` / `tap-flat` | TAP (nested / flat) |
+| `html` | Interactive UI report (needs `@vitest/ui`) |
+| `blob` | Serialized results for `--merge-reports` |
+| `github-actions` | Workflow annotations + job summary |
+| `hanging-process` | Lists processes preventing exit (debugging) |
+
+> v4 removed the `basic` reporter (equivalent to `['default', { summary: false }]`). The old `verbose` flat behavior moved here; use `tree` for the nested view.
+
+## Output Files
+
+> **v5:** the `json` and `junit` reporters now **write to a file by default** (`.vitest/json/output.json`, `.vitest/junit/output.xml`) instead of stdout. If you piped output (`vitest --reporter=json | jq`), read the artifact instead or opt back in with `reporters: [['json', { stdout: true }]]`. An explicit `outputFile` is still respected.
+
+```bash
+vitest --reporter=json --outputFile=./test-output.json
+```
+
+```ts
+defineConfig({
+ test: {
+ reporters: ['junit', 'json'],
+ outputFile: { junit: './junit.xml', json: './report.json' },
+ },
+})
+```
+
+## JUnit Templating
+
+```ts
+reporters: [['junit', {
+ suiteNameTemplate: '{title}', // {title} {filename} {basename} {displayName}
+ classnameTemplate: '{classname}', // {classname} {title} {suitename} {filename} ...
+ titleTemplate: '{title}',
+ ancestorSeparator: ' > ',
+ addFileAttribute: true,
+}]]
+```
+
+`{filename}` is the **relative** path (use `{basename}` for the bare name). Templates can also be functions receiving all variables.
+
+## HTML Report (v5 paths)
+
+The HTML reporter writes a directory via `outputDir` (default `.vitest`); the entry is `/index.html`. Use `singleFile: true` for a self-contained shareable file (large; coverage not inlined).
+
+```ts
+reporters: [['html', { singleFile: true }]]
+```
+
+## Blob & Merge (CI/sharding)
+
+Blobs default to `.vitest/blob/`. Label environments with `VITEST_BLOB_LABEL` or the reporter `label` option:
+
+```bash
+vitest run --reporter=blob --outputFile=reports/blob-1.json
+vitest --merge-reports=reports --reporter=junit --reporter=default
+```
+
+Blob reports don't include file attachments — merge `attachmentsDir` (`.vitest/attachments/`) separately. `--reporter=blob`/`--merge-reports` don't work in watch mode.
+
+## Key Points
+
+- Configuring `reporters` replaces defaults — spread `configDefaults.reporters` to keep them
+- The `minimal`/`agent` reporter is auto-selected for AI agents and minimizes token usage
+- Use `tree` for the nested per-test view (old v3 `verbose`)
+- v5 artifacts (blob, attachments, HTML) live under `.vitest/`
+
+
diff --git a/.agents/skills/vitest/references/features-snapshots.md b/.agents/skills/vitest/references/features-snapshots.md
new file mode 100644
index 00000000..60d3d5e2
--- /dev/null
+++ b/.agents/skills/vitest/references/features-snapshots.md
@@ -0,0 +1,253 @@
+---
+name: snapshot-testing
+description: Snapshot testing with file, inline, and file snapshots
+---
+
+# Snapshot Testing
+
+Snapshot tests capture output and compare against stored references.
+
+## Basic Snapshot
+
+```ts
+import { expect, test } from 'vitest'
+
+test('snapshot', () => {
+ const result = generateOutput()
+ expect(result).toMatchSnapshot()
+})
+```
+
+First run creates `.snap` file:
+
+```js
+// __snapshots__/test.spec.ts.snap
+exports['snapshot 1'] = `
+{
+ "id": 1,
+ "name": "test"
+}
+`
+```
+
+## Inline Snapshots
+
+Stored directly in test file:
+
+```ts
+test('inline snapshot', () => {
+ const data = { foo: 'bar' }
+ expect(data).toMatchInlineSnapshot()
+})
+```
+
+Vitest updates the test file:
+
+```ts
+test('inline snapshot', () => {
+ const data = { foo: 'bar' }
+ expect(data).toMatchInlineSnapshot(`
+ {
+ "foo": "bar",
+ }
+ `)
+})
+```
+
+## File Snapshots
+
+Compare against explicit file:
+
+```ts
+test('render html', async () => {
+ const html = renderComponent()
+ await expect(html).toMatchFileSnapshot('./expected/component.html')
+})
+```
+
+## Snapshot Hints
+
+Add descriptive hints:
+
+```ts
+test('multiple snapshots', () => {
+ expect(header).toMatchSnapshot('header')
+ expect(body).toMatchSnapshot('body content')
+ expect(footer).toMatchSnapshot('footer')
+})
+```
+
+## Object Shape Matching
+
+Match partial structure:
+
+```ts
+test('shape snapshot', () => {
+ const data = {
+ id: Math.random(),
+ created: new Date(),
+ name: 'test'
+ }
+
+ expect(data).toMatchSnapshot({
+ id: expect.any(Number),
+ created: expect.any(Date),
+ })
+})
+```
+
+## Error Snapshots
+
+```ts
+test('error message', () => {
+ expect(() => {
+ throw new Error('Something went wrong')
+ }).toThrowErrorMatchingSnapshot()
+})
+
+test('inline error', () => {
+ expect(() => {
+ throw new Error('Bad input')
+ }).toThrowErrorMatchingInlineSnapshot(`[Error: Bad input]`)
+})
+```
+
+## Updating Snapshots
+
+```bash
+# Update all snapshots
+vitest -u
+vitest --update
+
+# In watch mode, press 'u' to update failed snapshots
+```
+
+In CI (`process.env.CI`), Vitest **never writes** snapshots: mismatches, missing snapshots, and **obsolete snapshots** (entries no longer matching any test) all fail the run.
+
+## Visual & ARIA Snapshots (Browser Mode)
+
+```ts
+import { expect, test } from 'vitest'
+import { page } from 'vitest/browser' // v4: import from 'vitest/browser'
+
+test('button looks correct', async () => {
+ await expect(page.getByRole('button')).toMatchScreenshot('primary-button')
+})
+
+// ARIA snapshot — assert the accessibility tree (4.1+, experimental)
+test('nav structure', async () => {
+ await expect.element(page.getByRole('navigation')).toMatchAriaInlineSnapshot(`
+ - navigation "Main":
+ - link "Home"
+ `)
+})
+```
+
+## Custom Snapshot Matchers (4.1+)
+
+Build matchers on the composable `Snapshots` helpers from `vitest` (replaces importing from `jest-snapshot`):
+
+```ts
+import { expect, Snapshots } from 'vitest'
+
+const { toMatchSnapshot, toMatchInlineSnapshot } = Snapshots
+
+expect.extend({
+ toMatchTrimmedSnapshot(received: string, length: number) {
+ return toMatchSnapshot.call(this, received.slice(0, length))
+ },
+ toMatchTrimmedInlineSnapshot(received: string, inlineSnapshot?: string) {
+ return toMatchInlineSnapshot.call(this, received.slice(0, 10), inlineSnapshot)
+ },
+})
+```
+
+The inline snapshot string must be the **last** argument. File snapshot matchers must be `async`.
+
+## Custom Serializers
+
+Add custom snapshot formatting:
+
+```ts
+expect.addSnapshotSerializer({
+ test(val) {
+ return val && typeof val.toJSON === 'function'
+ },
+ serialize(val, config, indentation, depth, refs, printer) {
+ return printer(val.toJSON(), config, indentation, depth, refs)
+ },
+})
+```
+
+Or via config:
+
+```ts
+// vitest.config.ts
+defineConfig({
+ test: {
+ snapshotSerializers: ['./my-serializer.ts'],
+ },
+})
+```
+
+## Snapshot Format Options
+
+```ts
+defineConfig({
+ test: {
+ snapshotFormat: {
+ printBasicPrototype: false, // Don't print Array/Object prototypes (Vitest default)
+ escapeString: false,
+ printShadowRoot: true, // v4 default: custom elements print their shadow root
+ },
+ },
+})
+```
+
+## Concurrent Test Snapshots
+
+Use context's expect:
+
+```ts
+test.concurrent('concurrent 1', async ({ expect }) => {
+ expect(await getData()).toMatchSnapshot()
+})
+
+test.concurrent('concurrent 2', async ({ expect }) => {
+ expect(await getOther()).toMatchSnapshot()
+})
+```
+
+## Snapshot File Location
+
+Default: `__snapshots__/.snap`
+
+Customize:
+
+```ts
+defineConfig({
+ test: {
+ resolveSnapshotPath: (testPath, snapExtension) => {
+ return testPath.replace('__tests__', '__snapshots__') + snapExtension
+ },
+ },
+})
+```
+
+## Key Points
+
+- Commit snapshot files to version control
+- Review snapshot changes in code review
+- Use hints for multiple snapshots in one test
+- Use `toMatchFileSnapshot` for large outputs (HTML, JSON)
+- Inline snapshots auto-update in test file
+- Use context's `expect` for concurrent tests
+- CI fails on obsolete snapshots; clean them with `--update`
+- v4 prints custom-element shadow roots; disable via `snapshotFormat.printShadowRoot: false`
+
+
diff --git a/.agents/skills/vitest/references/features-test-tags.md b/.agents/skills/vitest/references/features-test-tags.md
new file mode 100644
index 00000000..d7671c1f
--- /dev/null
+++ b/.agents/skills/vitest/references/features-test-tags.md
@@ -0,0 +1,123 @@
+---
+name: test-tags
+description: Label tests with tags to filter runs and apply shared runner options
+---
+
+# Test Tags (4.1+)
+
+Tags label tests so you can filter what runs and apply shared options (timeout, retry) to a *category* of tests that span many files. Reach for tags over projects when the category needs different timeouts/retries (not different pools/environments).
+
+## Defining Tags
+
+Tags **must be declared in config** — using an undefined tag throws unless `strictTags: false`. Each tag can carry options applied to every test marked with it:
+
+```ts
+// vitest.config.ts
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ tags: [
+ { name: 'frontend', description: 'Frontend tests.' },
+ { name: 'db', description: 'Database queries.', timeout: 60_000 },
+ {
+ name: 'flaky',
+ retry: process.env.CI ? 3 : 0,
+ timeout: 30_000,
+ priority: 1, // lower priority number wins on conflicts
+ },
+ ],
+ strictTags: true, // default: error on unknown tags
+ },
+})
+```
+
+Type-safe tag names (augment `TestTags`, include the file in your tsconfig):
+
+```ts
+import 'vitest'
+
+declare module 'vitest' {
+ interface TestTags {
+ tags: 'frontend' | 'backend' | 'db' | 'flaky'
+ }
+}
+```
+
+## Applying Tags
+
+```ts
+import { describe, test } from 'vitest'
+
+test('renders homepage', { tags: ['frontend'] }, () => {})
+
+// Tags inherit from the parent suite
+describe('API endpoints', { tags: ['backend'] }, () => {
+ test('validates input', { tags: ['validation'] }, () => {
+ // has both "backend" (inherited) and "validation"
+ })
+})
+```
+
+Tag every test in a file with a JSDoc `@module-tag` at the top of the file (applies to **all** tests in the file, not just the next one):
+
+```ts
+/**
+ * @module-tag admin/pages/dashboard
+ */
+test('dashboard renders', () => {})
+```
+
+### Option conflict resolution
+
+When several tags set the same option on a test, `priority` wins first (lower number), then array order. Options on the test itself always win:
+
+```ts
+test('flaky db test', { tags: ['flaky', 'db'] }) // timeout 30_000 (flaky priority 1), retry 3
+test('override', { tags: ['flaky', 'db'], timeout: 120_000 }) // timeout 120_000, retry 3
+```
+
+## Filtering by Tag
+
+Use `--tagsFilter` with an expression:
+
+```bash
+vitest --tagsFilter "frontend"
+vitest --tagsFilter "db && !flaky"
+vitest --tagsFilter "(unit || e2e) && !slow"
+vitest --tagsFilter "api/*" # wildcard
+vitest --list-tags # list defined tags (=json for JSON)
+```
+
+Operators: `and`/`&&`, `or`/`||`, `not`/`!`, `*` wildcard, `()` grouping. Precedence: `not` > `and` > `or`. Multiple `--tagsFilter` flags combine with AND. Tag names can't be `and`/`or`/`not` or contain special chars/spaces.
+
+Programmatic: pass `tagsFilter: ['frontend and backend']` to `startVitest`/`createVitest`.
+
+## Checking the Filter at Runtime
+
+Skip expensive setup when no matching tests are scheduled:
+
+```ts
+import { beforeAll, TestRunner } from 'vitest'
+
+beforeAll(async () => {
+ if (TestRunner.matchesTags(['db'])) {
+ await seedDatabase()
+ }
+})
+```
+
+Returns `true` when the active `--tagsFilter` would include a test with those tags (or when no filter is active).
+
+## Key Points
+
+- Tags must be declared in config; the CLI filter is `--tagsFilter` (not `--tags`)
+- Tags inherit from parent suites and `@module-tag` JSDoc comments
+- Use tags for cross-cutting categories with shared timeout/retry; use [projects](advanced-projects.md) for different pools/environments
+- `TestRunner.matchesTags` gates expensive `globalSetup`/`beforeAll` work
+
+
diff --git a/.gitignore b/.gitignore
index b96faf03..17aba7f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@ go-dist/
packages/cli/internal/resources/bundled/registry.json
packages/cli/internal/resources/bundled/_templates/
packages/cli/internal/resources/bundled/_web/
+packages/cli/internal/resources/bundled/_skills/
# go-task
.task/
@@ -60,4 +61,5 @@ bin-go/
/packages/templates/**/pnpm-lock.yaml
/packages/templates/**/go.work.sum
.one-run-*/
+/.one/
/.cache
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 766eb98e..dc597834 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -37,6 +37,9 @@ builds:
- -s -w
- -X main.version={{.Version}}
- -X github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck.buildChannel=release
+ # Published releases restore One skills from this exact source commit.
+ # Local snapshots retain their embedded, unpublished skill files.
+ - -X github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills.bundledSourceRef={{ if not .IsSnapshot }}{{ .FullCommit }}{{ end }}
flags:
- -trimpath
diff --git a/README.md b/README.md
index f5b4ca62..64122634 100644
--- a/README.md
+++ b/README.md
@@ -66,9 +66,9 @@ One CLI includes starters for common product work:
| Need | Starters |
|---|---|
-| Web apps | Next.js, React SPA, Astro |
+| Web apps | Next.js, React SPA, Next.js static website |
| Backends | NestJS API, Go API |
-| Documentation | Starlight docs |
+| Documentation | Next.js + Fumadocs Base UI + MDX |
| Mobile apps | Expo |
| Desktop apps | Electron |
| Shared libraries | TypeScript library, Go library |
@@ -210,3 +210,7 @@ Drag in the log pane and press `y` to copy the selection. With no selection, `y`
Ctrl+C or a task failure stops the invocation and its child processes. The terminal restores automatically when execution ends. Raw mode requires a graph with one executable task and preserves terminal input. Structured results stay on stdout and child logs go to stderr. Save `"taskUI": "tui"` or `"taskUI": "stream"` in `~/.config/one/preferences.json` for a personal default.
Process Compose has no global concurrency limit. One rejects `--concurrency` values below the number of command tasks. `one run build --force` bypasses timestamp freshness; artifact caching is disabled. Unsupported selected task fields fail before commands start. See the [task guide](apps/docs/content/docs/en/run.md) for the static compatibility subset, variables, and terminal behavior.
+
+### Agent skills
+
+`one skills` forwards directly to `npx skills@1.7.0`. Workspace creation and project addition install curated usage and stack skills by default; pass `--skip-skills` to opt out. Business skills are maintained in independent GitHub repositories and installed manually. See the [English](apps/docs/content/docs/en/skills.md) and [Chinese](apps/docs/content/docs/zh/skills.md) guides.
diff --git a/apps/dashboard/src/api/project-creation.ts b/apps/dashboard/src/api/project-creation.ts
index 082c7f43..a1c8efed 100644
--- a/apps/dashboard/src/api/project-creation.ts
+++ b/apps/dashboard/src/api/project-creation.ts
@@ -8,6 +8,7 @@ export interface ProjectTemplate {
category: "frontend" | "backend" | "library";
directory: string;
toolchain: "node" | "go" | "none";
+ projects?: { directory: string; suffix: string }[];
}
export interface CreatedProject {
@@ -15,6 +16,7 @@ export interface CreatedProject {
relativeDir: string;
templateId: string;
warnings?: string[];
+ projects?: CreatedProject[];
}
export const projectTemplatesKey = "/project-templates";
@@ -24,7 +26,7 @@ export async function getProjectTemplates(): Promise<{ templates: ProjectTemplat
export async function createProject(
entryId: string,
- input: { name: string; templateId: string },
+ input: { name: string; templateId: string; skipSkills?: boolean },
): Promise {
- return http.post(`${workspaceBasePath(entryId)}/projects`, input, { timeout: 60000 });
+ return http.post(`${workspaceBasePath(entryId)}/projects`, input, { timeout: 360000 });
}
diff --git a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx
index ce046728..2a84276e 100644
--- a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx
+++ b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx
@@ -28,6 +28,19 @@ const templates = [
directory: "apps",
toolchain: "node",
},
+ {
+ id: "electron-app",
+ name: "Electron",
+ description: "Electron",
+ category: "frontend",
+ directory: "apps",
+ toolchain: "node",
+ projects: [
+ { directory: "apps", suffix: "-renderer" },
+ { directory: "services", suffix: "-main" },
+ { directory: "packages", suffix: "-preload" },
+ ],
+ },
{
id: "go-api",
name: "Go",
@@ -67,6 +80,32 @@ afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("project creation", () => {
+ it.each(["en-US", "zh-CN"])("can skip development skills in %s", async (locale) => {
+ await i18n.changeLanguage(locale);
+ const user = userEvent.setup();
+ let payload: unknown;
+ server.use(
+ http.post("http://localhost/api/workspaces/:entryId/projects", async ({ request }) => {
+ payload = await request.json();
+ return HttpResponse.json({ name: "web", relativeDir: "apps/web", templateId: "react-spa" });
+ }),
+ );
+ const { onCreated } = show();
+ await user.type(screen.getByLabelText(i18n.t("projectCreate.name")), "web");
+ const toggle = screen.getByRole("switch", { name: i18n.t("projectCreate.installSkills") });
+ expect(toggle.getAttribute("aria-checked")).toBe("true");
+ await user.click(toggle);
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole("button", { name: i18n.t("projectCreate.submit") })
+ .hasAttribute("disabled"),
+ ).toBe(false),
+ );
+ await user.click(screen.getByRole("button", { name: i18n.t("projectCreate.submit") }));
+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith("web"));
+ expect(payload).toEqual({ name: "web", templateId: "react-spa", skipSkills: true });
+ });
it.each(
["en-US", "zh-CN"].flatMap((locale) =>
[...emptyTemplates, { id: "go-api", directory: "services" }].map((template) => ({
@@ -106,6 +145,78 @@ describe("project creation", () => {
expect(onClose).toHaveBeenCalledOnce();
});
+ it.each(["en-US", "zh-CN"])("previews and creates all Electron members in %s", async (locale) => {
+ await i18n.changeLanguage(locale);
+ const user = userEvent.setup();
+ server.use(
+ http.post("http://localhost/api/workspaces/:entryId/projects", async ({ request }) => {
+ expect(await request.json()).toEqual({ name: "desktop", templateId: "electron-app" });
+ return HttpResponse.json(
+ {
+ name: "desktop",
+ relativeDir: ".",
+ templateId: "electron-app",
+ projects: [
+ {
+ name: "desktop-renderer",
+ relativeDir: "apps/desktop-renderer",
+ templateId: "electron-app",
+ },
+ {
+ name: "desktop-main",
+ relativeDir: "services/desktop-main",
+ templateId: "electron-app",
+ },
+ {
+ name: "desktop-preload",
+ relativeDir: "packages/desktop-preload",
+ templateId: "electron-app",
+ },
+ ],
+ },
+ { status: 201 },
+ );
+ }),
+ );
+ const { onCreated } = show();
+ await user.type(screen.getByLabelText(i18n.t("projectCreate.name")), "desktop");
+ const select = await screen.findByRole("combobox", { name: i18n.t("projectCreate.template") });
+ await waitFor(() => expect((select as HTMLButtonElement).disabled).toBe(false));
+ await user.click(select);
+ await user.click(
+ await screen.findByRole("option", {
+ name: i18n.t("projectCreate.templates.electron-app.name"),
+ }),
+ );
+ for (const dir of [
+ "apps/desktop-renderer",
+ "services/desktop-main",
+ "packages/desktop-preload",
+ ])
+ expect(screen.getByText(dir)).toBeDefined();
+ await user.click(screen.getByRole("button", { name: i18n.t("projectCreate.submit") }));
+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith("desktop-renderer"));
+ });
+
+ it("rejects a conflicting Electron member before creation", async () => {
+ const user = userEvent.setup();
+ const post = vi.fn();
+ server.use(http.post("http://localhost/api/workspaces/:entryId/projects", post));
+ show(["desktop-main"]);
+ await user.type(screen.getByLabelText("Project name"), "desktop");
+ const select = await screen.findByRole("combobox", { name: i18n.t("projectCreate.template") });
+ await waitFor(() => expect((select as HTMLButtonElement).disabled).toBe(false));
+ await user.click(select);
+ await user.click(
+ await screen.findByRole("option", {
+ name: i18n.t("projectCreate.templates.electron-app.name"),
+ }),
+ );
+ await user.click(screen.getByRole("button", { name: "Create project" }));
+ expect(screen.getByRole("alert")).toBeDefined();
+ expect(post).not.toHaveBeenCalled();
+ });
+
it("validates names and duplicates without sending requests", async () => {
const user = userEvent.setup();
const post = vi.fn();
diff --git a/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx b/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx
index 999d077d..99aefee3 100644
--- a/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx
+++ b/apps/dashboard/src/features/project-creation/CreateProjectDialog.tsx
@@ -25,6 +25,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/useToast";
import type { HttpError } from "@/types/api";
@@ -44,6 +45,7 @@ export function CreateProjectDialog({ entryId, projectNames, onClose, onCreated
const submitting = useRef(false);
const [name, setName] = useState("");
const [templateId, setTemplateId] = useState("react-spa");
+ const [installSkills, setInstallSkills] = useState(true);
const [pending, setPending] = useState(false);
const [nameError, setNameError] = useState("");
const [failure, setFailure] = useState();
@@ -52,6 +54,9 @@ export function CreateProjectDialog({ entryId, projectNames, onClose, onCreated
const selected = templates.find((entry) => entry.id === templateId) ?? templates[0];
const cleanName = name.trim();
const validName = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(cleanName);
+ const generatedNames = selected?.projects?.map((project) => `${cleanName}${project.suffix}`) ?? [
+ cleanName,
+ ];
const categoryKeys = ["frontend", "backend", "library"] as const;
async function submit(event: React.FormEvent) {
@@ -59,7 +64,7 @@ export function CreateProjectDialog({ entryId, projectNames, onClose, onCreated
if (submitting.current || !selected) return;
const validation = !validName
? "projectCreate.nameInvalid"
- : projectNames.includes(cleanName)
+ : [cleanName, ...generatedNames].some((project) => projectNames.includes(project))
? "projectCreate.nameExists"
: "";
setNameError(validation);
@@ -72,7 +77,11 @@ export function CreateProjectDialog({ entryId, projectNames, onClose, onCreated
setFailure(undefined);
let result;
try {
- result = await createProject(entryId, { name: cleanName, templateId: selected.id });
+ result = await createProject(entryId, {
+ name: cleanName,
+ templateId: selected.id,
+ ...(!installSkills && { skipSkills: true }),
+ });
} catch (cause) {
setFailure(cause as HttpError);
setPending(false);
@@ -84,8 +93,13 @@ export function CreateProjectDialog({ entryId, projectNames, onClose, onCreated
mutate((key) => typeof key === "string" && key.startsWith(`${workspaceBasePath(entryId)}/`)),
mutate(workspacesKey),
]);
- onCreated(result.name);
- toast.success(t("projectCreate.success", { name: result.name }));
+ onCreated(result.projects?.[0]?.name ?? result.name);
+ toast.success(
+ t(result.projects?.length ? "projectCreate.groupSuccess" : "projectCreate.success", {
+ name: result.name,
+ count: result.projects?.length,
+ }),
+ );
for (const warning of result.warnings ?? []) toast.warning(warning);
if (refreshed.some((item) => item.status === "rejected"))
toast.warning(t("projectCreate.refreshFailed"));
@@ -201,14 +215,41 @@ export function CreateProjectDialog({ entryId, projectNames, onClose, onCreated
{selected && (
{t("projectCreate.location")}
-
- {selected.directory}/{validName ? cleanName : "…"}
-
+ {(selected.projects ?? [{ directory: selected.directory, suffix: "" }]).map(
+ (project) => (
+
+ {project.directory}/{validName ? cleanName : "…"}
+ {project.suffix}
+
+ ),
+ )}
- {t("projectCreate.locationHint")}
+ {t(
+ selected.projects?.length
+ ? "projectCreate.groupLocationHint"
+ : "projectCreate.locationHint",
+ )}
)}
+
+
+ {t("projectCreate.installSkills")}
+
+
+
+ {t("projectCreate.skillsHint")}
+
+
{failure && (
{t("projectCreate.failed")}
diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json
index b67a9b0d..2c37f01a 100644
--- a/apps/dashboard/src/locales/en-US.json
+++ b/apps/dashboard/src/locales/en-US.json
@@ -401,13 +401,13 @@
"name": "Go API service",
"description": "Gin + Gorm + Viper + Zap for APIs and backend services"
},
- "astro-site": {
- "name": "Astro static site",
- "description": "Static site template for company, marketing, and content websites"
+ "nextjs-site": {
+ "name": "Next.js static site",
+ "description": "Next.js + React static export for company, marketing, and content websites"
},
- "starlight-docs": {
- "name": "Starlight documentation",
- "description": "Documentation project based on Astro + Starlight"
+ "fumadocs-docs": {
+ "name": "Fumadocs documentation",
+ "description": "Next.js + Fumadocs + Base UI with English/Chinese docs and static search"
},
"nextjs-app": {
"name": "Next.js application",
@@ -431,7 +431,7 @@
},
"electron-app": {
"name": "Electron desktop app",
- "description": "Electron + React + Vite with inversify, electron-log, electron-store, and electron-updater"
+ "description": "Electron + React + Vite with top-level renderer, main, and preload projects, using Awilix in main."
},
"empty-app": {
"name": "Empty application",
@@ -445,7 +445,11 @@
"name": "Empty library",
"description": "Create an empty project under packages/ and configure a technology stack later."
}
- }
+ },
+ "groupSuccess": "Created project group {{name}} with {{count}} projects",
+ "groupLocationHint": "Creates related projects using your name and the workspace root dependency configuration.",
+ "installSkills": "Install development skills",
+ "skillsHint": "Add One usage and technology-stack guidance. Business skills are installed separately on request."
},
"appMenu": {
"label": "One CLI navigation menu"
@@ -470,7 +474,7 @@
"stack": "NestJS · TypeScript · Drizzle",
"feature1": "JWT authentication and user modules",
"feature2": "Drizzle data access and database migrations",
- "feature3": "Swagger documentation, health checks, and metrics"
+ "feature3": "Swagger documentation, health checks, and request logging"
},
"go-api": {
"description": "Build Go APIs with separate HTTP, service, and data-access layers.",
@@ -479,37 +483,37 @@
"feature2": "Gorm data access and SQL migrations",
"feature3": "OpenAPI documentation, configuration, and logging"
},
- "astro-site": {
- "description": "Create company websites, landing pages, and content sites with static page output.",
- "stack": "Astro · TypeScript · Tailwind CSS",
- "feature1": "File-based pages and shared layouts",
- "feature2": "Shared design tokens and Tailwind styles",
- "feature3": "Type checking, linting, and formatting"
+ "nextjs-site": {
+ "description": "Next.js + React static export for company, marketing, and content websites",
+ "stack": "Next.js · React · TypeScript · Base UI",
+ "feature1": "Static export with English and Chinese pages",
+ "feature2": "Base UI components, themes, and design tokens",
+ "feature3": "SEO metadata and static hosting"
},
- "starlight-docs": {
- "description": "Publish product documentation, developer guides, and team knowledge bases.",
- "stack": "Astro · Starlight · Markdown / MDX",
- "feature1": "Markdown and MDX documentation pages",
- "feature2": "Guide and reference sections with a configured sidebar",
- "feature3": "Customizable documentation theme"
+ "fumadocs-docs": {
+ "description": "Next.js + Fumadocs + Base UI with English/Chinese docs and static search",
+ "stack": "Next.js · Fumadocs · Base UI · MDX",
+ "feature1": "Markdown / MDX in English and Chinese",
+ "feature2": "Sidebar, table of contents, and syntax highlighting",
+ "feature3": "Browser-side static search and language switching"
},
"nextjs-app": {
"description": "Build web applications with server rendering, API routes, and search-friendly pages.",
"stack": "Next.js · React · TypeScript",
"feature1": "App Router pages and route handlers",
- "feature2": "shadcn/ui components and theme switching",
+ "feature2": "shadcn/ui (Base UI) components and theme switching",
"feature3": "SWR data fetching and Zustand state management"
},
"react-spa": {
"description": "Create interactive dashboards, admin panels, and browser-based business applications.",
"stack": "React · Vite · TypeScript",
"feature1": "Client-side routing and error boundaries",
- "feature2": "shadcn/ui components and light / dark themes",
+ "feature2": "shadcn/ui (Base UI) components and light / dark themes",
"feature3": "SWR data fetching and Zustand state management"
},
"expo-mobile": {
"description": "Build iOS and Android apps with React Native and a shared Expo project.",
- "stack": "Expo · React Native · NativeWind",
+ "stack": "Expo · React Native · StyleSheet",
"feature1": "Expo Router navigation and tab pages",
"feature2": "SWR, Zustand, and MMKV local storage",
"feature3": "Native modules use an Expo development build"
@@ -530,9 +534,9 @@
},
"electron-app": {
"description": "Create desktop applications with an Electron main process and a React user interface.",
- "stack": "Electron · React · Vite · Inversify",
- "feature1": "Separate main, renderer, and preload packages",
- "feature2": "Typed IPC bridge and dependency injection",
+ "stack": "Electron · React · Vite · Awilix",
+ "feature1": "Top-level apps, services, and packages projects",
+ "feature2": "Typed IPC bridge and Awilix factory functions",
"feature3": "Logging, local settings, and updater modules"
},
"empty-app": {
diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json
index f86e45b7..07fc81f3 100644
--- a/apps/dashboard/src/locales/zh-CN.json
+++ b/apps/dashboard/src/locales/zh-CN.json
@@ -401,13 +401,13 @@
"name": "Go API 服务",
"description": "Gin + Gorm + Viper + Zap,适合 API 服务与业务后台"
},
- "astro-site": {
- "name": "Astro 静态网站",
- "description": "前端静态站模板,适合官网、营销站与内容站点"
+ "nextjs-site": {
+ "name": "Next.js 静态网站",
+ "description": "Next.js + React 静态导出,适合官网、营销页与内容站点"
},
- "starlight-docs": {
- "name": "Starlight 文档站点",
- "description": "基于 Astro + Starlight 的文档项目模板"
+ "fumadocs-docs": {
+ "name": "Fumadocs 文档站点",
+ "description": "Next.js + Fumadocs + Base UI,支持中英文文档与静态搜索"
},
"nextjs-app": {
"name": "Next.js 全栈应用",
@@ -431,7 +431,7 @@
},
"electron-app": {
"name": "Electron 桌面应用",
- "description": "Electron + React + Vite 桌面应用模板,主进程基于 inversify,内置 electron-log/store/updater"
+ "description": "Electron + React + Vite 桌面应用,生成 renderer、main、preload 三个顶层项目,主进程使用 Awilix。"
},
"empty-app": {
"name": "空应用",
@@ -445,7 +445,11 @@
"name": "空共享库",
"description": "在 packages/ 下创建空项目,稍后自行配置技术栈。"
}
- }
+ },
+ "groupSuccess": "已创建项目组 {{name}},包含 {{count}} 个项目",
+ "groupLocationHint": "使用输入的名称生成关联项目,统一使用工作区根目录的依赖配置。",
+ "installSkills": "安装开发 skills",
+ "skillsHint": "安装 One 用法和技术栈指导。业务 skills 由用户另行手动安装。"
},
"appMenu": {
"label": "One CLI 导航菜单"
@@ -470,7 +474,7 @@
"stack": "NestJS · TypeScript · Drizzle",
"feature1": "JWT 鉴权与用户业务模块",
"feature2": "Drizzle 数据访问与数据库迁移",
- "feature3": "Swagger 文档、健康检查与监控指标"
+ "feature3": "Swagger 文档、健康检查与请求日志"
},
"go-api": {
"description": "构建 Go API 服务,按 HTTP 接口、业务逻辑和数据访问分层组织代码。",
@@ -479,37 +483,37 @@
"feature2": "Gorm 数据访问与 SQL 迁移",
"feature3": "OpenAPI 文档、配置管理与日志"
},
- "astro-site": {
- "description": "通过静态页面构建官网、营销落地页和内容站点,适合以内容展示为主的项目。",
- "stack": "Astro · TypeScript · Tailwind CSS",
- "feature1": "基于文件的页面路由与共享布局",
- "feature2": "统一设计变量与 Tailwind 样式",
- "feature3": "类型检查、代码检查与格式化"
+ "nextjs-site": {
+ "description": "Next.js + React 静态导出,适合官网、营销页与内容站点",
+ "stack": "Next.js · React · TypeScript · Base UI",
+ "feature1": "静态导出与中英文页面",
+ "feature2": "Base UI 组件、主题与设计令牌",
+ "feature3": "SEO 元数据与静态托管"
},
- "starlight-docs": {
- "description": "快速搭建产品文档、开发者指南和团队知识库,专注于文档内容的编写。",
- "stack": "Astro · Starlight · Markdown / MDX",
- "feature1": "Markdown 与 MDX 文档页面",
- "feature2": "指南、参考文档与预设侧边栏",
- "feature3": "可定制的文档主题样式"
+ "fumadocs-docs": {
+ "description": "Next.js + Fumadocs + Base UI,支持中英文文档与静态搜索",
+ "stack": "Next.js · Fumadocs · Base UI · MDX",
+ "feature1": "Markdown / MDX 与中英文内容",
+ "feature2": "侧边栏、本页目录与代码高亮",
+ "feature3": "浏览器静态搜索与语言切换"
},
"nextjs-app": {
"description": "构建需要服务端渲染、API 路由和搜索引擎友好页面的 Web 应用。",
"stack": "Next.js · React · TypeScript",
"feature1": "App Router 页面与接口路由",
- "feature2": "shadcn/ui 组件与主题切换",
+ "feature2": "shadcn/ui (Base UI) 组件与主题切换",
"feature3": "SWR 数据请求与 Zustand 状态管理"
},
"react-spa": {
"description": "构建交互丰富的仪表盘、管理后台和浏览器端业务系统。",
"stack": "React · Vite · TypeScript",
"feature1": "客户端路由与错误边界",
- "feature2": "shadcn/ui 组件与明暗主题",
+ "feature2": "shadcn/ui (Base UI) 组件与明暗主题",
"feature3": "SWR 数据请求与 Zustand 状态管理"
},
"expo-mobile": {
"description": "基于 React Native 和 Expo,以同一项目开发 iOS 与 Android 移动应用。",
- "stack": "Expo · React Native · NativeWind",
+ "stack": "Expo · React Native · StyleSheet",
"feature1": "Expo Router 导航与标签页面",
"feature2": "SWR、Zustand 与 MMKV 本地存储",
"feature3": "原生模块通过 Expo 开发构建运行"
@@ -530,9 +534,9 @@
},
"electron-app": {
"description": "使用 Electron 主进程和 React 界面构建桌面应用,适合桌面工具与业务客户端。",
- "stack": "Electron · React · Vite · Inversify",
- "feature1": "主进程、渲染进程与 preload 分包",
- "feature2": "带类型的 IPC 桥接与依赖注入",
+ "stack": "Electron · React · Vite · Awilix",
+ "feature1": "顶层 apps、services、packages 项目组合",
+ "feature2": "带类型的 IPC 桥接与 Awilix 工厂函数",
"feature3": "日志、本地设置与更新器模块"
},
"empty-app": {
diff --git a/apps/dashboard/src/pages/ProjectTemplates.tsx b/apps/dashboard/src/pages/ProjectTemplates.tsx
index f70f68b0..4ac49dc1 100644
--- a/apps/dashboard/src/pages/ProjectTemplates.tsx
+++ b/apps/dashboard/src/pages/ProjectTemplates.tsx
@@ -27,8 +27,8 @@ const CATEGORIES = ["all", "frontend", "backend", "library"] as const;
const TEMPLATE_ICONS: Record = {
"nestjs-api": Server,
"go-api": Server,
- "astro-site": Globe,
- "starlight-docs": BookOpen,
+ "nextjs-site": Globe,
+ "fumadocs-docs": BookOpen,
"nextjs-app": Globe,
"react-spa": Braces,
"expo-mobile": Smartphone,
diff --git a/apps/docs/content/docs/en/add.md b/apps/docs/content/docs/en/add.md
index 8f439555..035ebb6d 100644
--- a/apps/docs/content/docs/en/add.md
+++ b/apps/docs/content/docs/en/add.md
@@ -28,6 +28,7 @@ one add [template-id] --name [options]
|---|---|
| `template-id` | Template ID, such as `nestjs-api`. Omit it for interactive selection |
| `-n, --name` | Project name; required in non-interactive mode |
+| `--skip-skills` | Skip development skills installation |
| `-y, --yes` | Non-interactive mode |
| `-o, --output ` | `json` / `yaml` / `text` |
@@ -61,6 +62,25 @@ These templates create `apps/web/`, `services/api/`, and `packages/shared/`, res
For Node or Go code, set the project's `toolchain` in `one.manifest.toml` to `node` or `go`. Node projects declare `packageManager` in the root `package.json` and need membership in the root package workspace; Go modules need membership in the root `go.work`. Define tasks in `package.json` / `Taskfile.yml`, then run `one init mise` to update task configuration. For other languages, keep `toolchain = "none"` and define your own tools and tasks in the root `mise.toml`. Configure the commands before using `one dev` / `one build`.
+## Add an Electron app
+
+```bash
+one add electron-app --name desktop --yes
+one run desktop:dev
+one run desktop:pack
+```
+
+This registers `desktop-renderer` in `apps/`, `desktop-main` in `services/`, and
+`desktop-preload` in `packages/`, plus the `desktop` project group. The main task
+waits for Vite before opening Electron. Packaging builds the group and assembles
+its outputs in main. Multiple desktop apps use their own derived package names;
+set a different `ELECTRON_RENDERER_PORT` for both main and renderer when running
+several apps concurrently.
+
+For composite templates, JSON output includes `projects[]` entries with `name`,
+`target_path`, and `toolchain`. `subproject_name` identifies the group and
+`target_path` is the workspace root. Ordinary templates keep the output below.
+
## Output
```json
@@ -148,3 +168,7 @@ Not sure which one to use? Read the [template decision tree](/en/docs/templates/
- `one add` updates task configuration and preserves existing `AGENTS.md` files and team edits
- Run `one dev -p ` for development and `one build -p ` to build
- `one add` generates project and workspace configuration; `one dev` prepares tools and dependencies automatically. Node dependencies are installed at the workspace root; Go dependencies follow the module or `go.work` build graph. After changing imports or module declarations, use `one exec -- go mod tidy` when repairs are needed
+
+## Development skills
+
+After generating the project, One installs missing common and stack skills in the workspace root. Electron installs the combined skills once after all three members are created. Existing skills and team edits are preserved; source conflicts produce a warning. Pass `--skip-skills` to skip. Dashboard offers the same choice. Failures preserve the project and return recovery warnings. See [skills](/en/docs/skills/).
diff --git a/apps/docs/content/docs/en/ai-native.md b/apps/docs/content/docs/en/ai-native.md
index ecb7d129..565e3c7f 100644
--- a/apps/docs/content/docs/en/ai-native.md
+++ b/apps/docs/content/docs/en/ai-native.md
@@ -62,8 +62,8 @@ One CLI errors use a single envelope:
"available_templates": [
"nestjs-api",
"go-api",
- "astro-site",
- "starlight-docs",
+ "nextjs-site",
+ "fumadocs-docs",
"nextjs-app",
"react-spa",
"expo-mobile",
@@ -149,3 +149,7 @@ After adding a real template, inspect its manifest entry:
one add nestjs-api --name api --yes -o json
cat one.manifest.toml
```
+
+## Optional business skills
+
+Development skills supplement the team-owned AGENTS.md. Business skills live in independent GitHub repositories and require manual installation. `one skills` transparently forwards to the upstream Skills CLI, whose raw output and flags do not follow One’s structured-output envelope. See [skills](/en/docs/skills/).
diff --git a/apps/docs/content/docs/en/cli-overview.md b/apps/docs/content/docs/en/cli-overview.md
index 649ec6c7..5e1643bd 100644
--- a/apps/docs/content/docs/en/cli-overview.md
+++ b/apps/docs/content/docs/en/cli-overview.md
@@ -16,6 +16,7 @@ description: Daily commands and advanced entry points.
| `one upgrade` | Update One CLI to the latest stable release |
| `one init mise` / `one init hooks` | Workspace tool configuration |
| `one run` | List and execute workspace tasks |
+| `one skills` | Forward commands to `npx skills` |
| `one templates` | List project templates |
| `one hk` | Run Git hook checks |
diff --git a/apps/docs/content/docs/en/create.md b/apps/docs/content/docs/en/create.md
index b1dc0c67..c62b6122 100644
--- a/apps/docs/content/docs/en/create.md
+++ b/apps/docs/content/docs/en/create.md
@@ -3,7 +3,7 @@ title: one create
description: Create a new One workspace root skeleton.
---
-`one create` creates an empty workspace. It does not ask for projects or deployment or modify local AI-tool settings. Add projects later with `one add`.
+`one create` creates an empty workspace. It does not ask for projects or deployment. It installs project-scoped development skills by default; global AI-tool settings are preserved. Add projects later with `one add`.
## Usage
@@ -17,6 +17,7 @@ one create [dir] [options]
|---|---|
| `dir` | Target directory. Use `.` for the current directory and its folder name. Supports new directories, empty directories, and repositories containing only valid Git metadata |
| `-n, --name ` | Advanced override for the name derived from the target folder |
+| `--skip-skills` | Skip development skills installation |
| `-y, --yes` | Non-interactive mode; uses defaults and requires an explicit `dir` |
| `-o, --output ` | `json` / `yaml` / `text`; default is TTY-aware auto detection |
@@ -42,6 +43,7 @@ one create my-app --yes
| Environment variables | Unbound | Bind Infisical when variables are needed; otherwise inherit the shell environment |
| Tool environment | mise | Creates root `mise.toml`; `one add` registers project tasks in that same file |
| Git checks | hk | Creates `.config/hk.pkl` and installs local Git launchers |
+| Agent skills | Enabled | Installs `one-cli` and `find-skills` in the workspace's `.agents/skills`; use `--skip-skills` to skip |
| Local development | `one dev` | Runs the development task graph through mise |
Creation and project addition prepare mise and automatically trust complete `mise.toml` files generated by One, so entering the new workspace does not require a separate trust command. Existing custom configuration keeps mise's trust policy. If no compatible runtime is installed, One may download its managed mise binary; project tools and dependencies are still installed when needed. A trust failure preserves the generated files and prints a recovery command.
@@ -126,8 +128,12 @@ New workspaces receive a single-language `AGENTS.md` matching the active One CLI
The source templates are editable Markdown files in `packages/cli/internal/modules/creation/templates/`: `AGENTS.en-US.md` and `AGENTS.zh-CN.md`. Rebuild the CLI after editing them to update future workspaces.
-The instructions require agents to run tests and project commands through `one run`, place complex TypeScript scripts in `scripts/*.mts` and register them as tasks, and manage environment variables through One. Publishing and operations credentials, such as Docker registry accounts, belong in global variables. When missing, agents direct users to the Dashboard’s Shared credentials page and then inject the configured variables with `one exec --global` inside the task.
+The instructions require agents to run tests and project commands through `one run`, place complex scripts written in the project's chosen language in `scripts/` and register them as tasks, and manage environment variables through One. The user chooses the stack for empty templates. Publishing and operations credentials, such as Docker registry accounts, belong in global variables. When missing, agents direct users to the Dashboard’s Shared credentials page and then inject the configured variables with `one exec --global` inside the task.
## Existing empty Git repositories
After cloning an empty repository or running `git init`, use `one create .`. One preserves branches, remotes, history, and Git configuration. Custom hooks are preserved with a warning; shared hooks in linked worktrees are not installed automatically. Repositories with files such as README or source code, or uncommitted deletions, are rejected with the specific conflict.
+
+## Development skills
+
+Creation installs `one-cli` and `find-skills` in `.agents/skills` under the workspace root, without global skill installation, and shows only One's concise progress. The generated `.gitignore` ignores the root `.one/` directory; installed skills and `skills-lock.json` remain versionable, and no duplicate skill source tree is generated. `one add` later installs the selected template’s stack skills. Business skills are maintained in separate GitHub repositories and installed manually. Skills installation failures preserve the workspace and appear in `warnings`, with a retry command. Node/npx may be prepared for skills even in an empty or Go workspace; this does not add a Node project or package manager. See [skills](/en/docs/skills/) for sources, lock files, and opt-out.
diff --git a/apps/docs/content/docs/en/manifest.md b/apps/docs/content/docs/en/manifest.md
index ed5772b4..30693e1b 100644
--- a/apps/docs/content/docs/en/manifest.md
+++ b/apps/docs/content/docs/en/manifest.md
@@ -48,6 +48,21 @@ Templates are used only when creating projects and are not recorded in the manif
Project paths cannot be absolute or escape the workspace. Unknown fields, unsupported versions, and invalid field values produce `MANIFEST_INVALID`. TOML syntax errors include the file path and source location.
+## Project groups
+
+Composite templates can add a named group of existing projects:
+
+```toml
+[groups.desktop]
+projects = ["desktop-renderer", "desktop-main", "desktop-preload"]
+```
+
+`groups..projects` must be a nonempty list of unique registered project names.
+Group names cannot collide with project names. Groups generate task aliases such as
+`desktop:dev` and `desktop:build`; they have no directory or environment binding.
+Member tasks use their own project environments. Update group membership when
+removing or renaming a project. `one init mise` regenerates aliases from this record.
+
## Environment conventions
Omit `[env.infisical]` to run with the existing process environment. The manifest never contains variable values or a variable-name registry.
diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json
index 4600454f..a6d5c951 100644
--- a/apps/docs/content/docs/en/meta.json
+++ b/apps/docs/content/docs/en/meta.json
@@ -9,6 +9,7 @@
"cli-overview",
"create",
"add",
+ "skills",
"env-vars",
"login",
"templates-cmd",
diff --git a/apps/docs/content/docs/en/skills.md b/apps/docs/content/docs/en/skills.md
new file mode 100644
index 00000000..47bb327d
--- /dev/null
+++ b/apps/docs/content/docs/en/skills.md
@@ -0,0 +1,100 @@
+---
+title: one skills
+description: Manage agent skills through the upstream Skills CLI and understand default template skills.
+---
+
+`one skills` is a thin wrapper around `npx skills@1.7.0`. It passes every argument unchanged in the current directory, preserves interactive input, stdout/stderr and exit codes, and prepares Node through mise when npx is unavailable. It does not load One/Infisical credentials or add Node projects to the workspace.
+
+```bash
+one skills --help
+one skills find react
+one skills add owner/repo --skill skill-name
+one skills list
+one skills update
+one skills remove skill-name
+```
+
+All subcommands and flags belong to [Skills CLI](https://github.com/vercel-labs/skills). One does not add a `sync` command or reinterpret `--agent`, `--global`, `--yes`, or future upstream flags. `one skills --help` shows upstream help; `one help skills` describes the wrapper. Native output is not wrapped in One JSON. The npm bootstrap prompt is accepted by npx; Skills CLI's own confirmations remain unchanged unless you pass `--yes`.
+
+## Automatic development skills
+
+`one create` installs `one-cli` and `find-skills` by default, providing One usage and skill discovery guidance. `one add` installs missing common and stack skills for the selected template after all project files and workspace configuration are generated. Electron installs the union once after all three member projects are ready. Automatic installations go into `.agents/skills` under the workspace root (the shared directory read by Codex, Cursor and other compatible agents), never the user's global skill directory, and show only One's concise progress.
+
+Use `--skip-skills` on either command to skip installation. Dashboard's project dialog offers the same choice. Default installation selects named skills and the universal agent explicitly, copies the files, and runs non-interactively; it does not install every skill in a source repository.
+
+```bash
+one create demo --skip-skills
+one add react-spa --name web --skip-skills
+```
+
+Every template gets the common skills `one-cli` and `find-skills`. Node templates also get `pnpm`:
+
+| Template | Additional skills |
+| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `nestjs-api` | `one-nestjs`, `nestjs-best-practices` |
+| `go-api`, `go-lib` | `one-go`, `golang-patterns`, `golang-testing` |
+| `nextjs-app`, `nextjs-site` | `one-web`, React best practices, composition patterns, `shadcn`; use installed Next documentation |
+| `fumadocs-docs` | The Next/React skills above plus `one-fumadocs` |
+| `react-spa` | `one-web`, React best practices, composition patterns, `shadcn`, `vite` |
+| `expo-mobile` | `one-expo`, `expo-overview`, `expo-router`, `expo-data-fetching`, `expo-dev-client`, `expo-upgrade`, `expo-project-structure` |
+| `ts-library` | `tsdown`, `vitest` |
+| `electron-app` | `one-web`, React best practices, composition patterns, `shadcn`, `vite`, `vitest`, `tsdown`, `one-electron` |
+| `empty-app`, `empty-service`, `empty-library` | Common skills only |
+
+Empty templates do not prescribe a language, framework, UI, database, or internal architecture; the user chooses the stack. Common skills guide the One workspace layout and workflow without requiring another template's stack. Once the stack is chosen, install matching development skills as needed with `one skills add`. The generated `AGENTS.md` follows the same rule: complex scripts use the project's chosen language without requiring Node or TypeScript.
+
+One's `one-cli` skill supplies outer and inner layout guidance plus stack adaptations. Follow existing project dependencies and team instructions: generated web templates use Base UI, existing applications may use Radix; Expo uses native styles and its existing Axios/SWR stack; Electron uses Awilix function factories. Installing an upstream skill does not authorize changing these choices.
+
+One maintains seven development skills:
+
+| Skill | Responsibility |
+| -------------- | ---------------------------------------------------------------------------------------------- |
+| `one-cli` | One commands, workspace layout, and project discovery; no prescribed stack for empty templates |
+| `one-nestjs` | NestJS modules, Drizzle, injected configuration, validation, logging, and health |
+| `one-go` | Go service/library layouts, explicit dependencies, Gin/Gorm/Viper/Zap, and resource ownership |
+| `one-expo` | Native styles, Expo Router, MMKV, development clients, and SWR native listeners |
+| `one-web` | Base UI, Axios/SWR/Zustand, and React SPA vs. Next.js SSR/static export |
+| `one-electron` | Three-process projects, Awilix, preload/IPC, Ubuntu setup, and packaging |
+| `one-fumadocs` | MDX, localized docs, navigation, and static search |
+
+These skills adapt One templates; upstream skills supply framework-wide best practices. `one-web` also covers the Web foundation of Electron renderers and Fumadocs; their specialized skills guide native processes and documentation content. Agents read only the skills and references needed for the current task. TypeScript libraries use upstream tool skills, and empty templates receive only common skills without stack adaptations. Existing projects can install the matching One skill manually from the workspace root; automatic installation adds missing names when a project is added.
+
+The curated sources are [Vercel](https://github.com/vercel-labs/agent-skills), [Skills CLI](https://github.com/vercel-labs/skills), [shadcn](https://github.com/shadcn-ui/ui), [Antfu](https://github.com/antfu/skills), [tsdown](https://github.com/rolldown/tsdown), [Expo](https://github.com/expo/skills), [ECC Go skills](https://github.com/affaan-m/ecc), and [NestJS community skills](https://github.com/kadajett/agent-nestjs-skills). Default sources are pinned to reviewed commits in One's catalog; user commands remain unrestricted upstream commands.
+
+## Files, conflicts, and recovery
+
+Commit installed `.agents/skills` files and upstream `skills-lock.json`. New workspaces ignore the root `.one/` directory, and installation no longer creates a duplicate source tree under `.one/skill-sources` or `.agents/skill-sources`. One's editable skill sources remain in `packages/agent-skills/skills` in the One repository, separate from runnable templates and business skills.
+
+Published CLI releases install One's skills from `1cli-team/one-cli/packages/agent-skills`, pinned to the release's full Git commit. The lock records that GitHub provenance, so restoration does not depend on local caches. Development and snapshot builds instead write only selected bundled skills into their final `.agents/skills` paths and have Skills CLI register `./.agents/skills` as the local source; this lets unpublished changes work without duplicate files or references to nonexistent remote commits. Commit these local copies too. If a development skill is deleted, restore it from Git or add the corresponding project through One again; a local source cannot download its own missing files.
+
+Existing installations and locks pointing to `.one/skill-sources` are preserved when adding projects. Keep their referenced sources until you explicitly migrate using `one skills add` with a published One source and commit. Review team edits before reinstalling, then check that no lock entries refer to the old directory before removing it or ignoring `.one/` in an older workspace. One does not delete legacy directories or rewrite provenance automatically.
+
+Skills are installed by template and read by task. Agents can use names and descriptions to select a skill, read its `SKILL.md` when activated, and open its references and scripts only when needed. Generated `AGENTS.md` describes this workflow rather than requiring every skill to be read at startup; exact loading behavior belongs to the agent.
+
+Automatic installation only adds missing names. It preserves existing skill directories, team changes, and different sources; source conflicts and invalid/unsupported locks produce warnings instead of overwrites. Explicit `one skills` commands retain upstream's update/overwrite behavior. Upstream owns its lock format; One does not maintain a second skills lock.
+
+Installation has a five-minute total timeout. Network/download/tool failures do not undo a generated workspace or project: creation succeeds with `warnings` containing diagnostics and a command to retry from the workspace root. Automatic installation shows only One's localized progress, hiding the upstream logo, generic tips, and installation UI. Failures retain their specific reason, and structured creation results stay parseable. Manual `one skills` commands still preserve all upstream output. Default commit pins remain in the lock; changing versions is an explicit source/ref decision rather than an automatic upgrade during project creation.
+
+## Business skills are manual
+
+Login/registration, billing, permissions, uploads, and other business skills are maintained in independent GitHub repositories, outside One CLI's source, templates, and release assets. Neither `one create` nor `one add` installs them.
+
+After a business repository is published, a user can choose a named skill from the workspace root, installing it in the project without `--global`:
+
+```bash
+one skills add owner/business-skills --skill one-auth
+```
+
+This is a placeholder repository/name example, not a bundled or already published skill. Installation makes instructions available to an agent; it does not implement login or provision backend services. The user must request the business feature separately.
+
+## Existing workspaces
+
+For existing projects, inspect the actual dependencies and choose the matching skills explicitly with `one skills add`. Do not select a retired template based on its name alone. For example, a React/Vite project can install:
+
+```bash
+one skills add vercel-labs/agent-skills --skill vercel-react-best-practices vercel-composition-patterns
+one skills add antfu/skills --skill vite
+one skills add shadcn-ui/ui --skill shadcn
+```
+
+In the One source repository, its maintained skills can be installed from `./packages/agent-skills`. Editing a canonical skill does not automatically overwrite an installed copy; reinstall explicitly when updating the copy.
diff --git a/apps/docs/content/docs/en/templates-cmd.md b/apps/docs/content/docs/en/templates-cmd.md
index ea3bfd2f..298075bb 100644
--- a/apps/docs/content/docs/en/templates-cmd.md
+++ b/apps/docs/content/docs/en/templates-cmd.md
@@ -57,8 +57,8 @@ The current registry has 13 templates:
|---|---|---|
| `nestjs-api` | backend | [->](/en/docs/templates/) |
| `go-api` | backend | [->](/en/docs/templates/) |
-| `astro-site` | frontend | [->](/en/docs/templates/) |
-| `starlight-docs` | frontend / docs | [->](/en/docs/templates/) |
+| `nextjs-site` | frontend | [->](/en/docs/templates/) |
+| `fumadocs-docs` | frontend / docs | [->](/en/docs/templates/) |
| `nextjs-app` | frontend | [->](/en/docs/templates/) |
| `react-spa` | frontend | [->](/en/docs/templates/) |
| `expo-mobile` | frontend / mobile | [->](/en/docs/templates/) |
diff --git a/apps/docs/content/docs/en/templates.md b/apps/docs/content/docs/en/templates.md
index 83f35c94..fb362cd1 100644
--- a/apps/docs/content/docs/en/templates.md
+++ b/apps/docs/content/docs/en/templates.md
@@ -15,9 +15,9 @@ Run [`one templates`](/en/docs/templates-cmd/) to list the templates in your ins
```text
Need a backend API -----------------------> nestjs-api / go-api
-Need a browser-facing web project --------> nextjs-app / react-spa / astro-site
+Need a browser-facing web project --------> nextjs-app / react-spa / nextjs-site
Need a reusable package ------------------> ts-library / go-lib
-Need a documentation site ----------------> starlight-docs
+Need a documentation site ----------------> fumadocs-docs
Need a mobile app ------------------------> expo-mobile
Need a desktop app -----------------------> electron-app
```
@@ -26,21 +26,21 @@ If unsure, ask one question: **how does the user consume this thing?** Browser -
## Full Comparison
-| ID | Category | Keywords | One-line fit | Details |
-|---|---|---|---|---|
-| `nestjs-api` | API | TypeScript, NestJS, REST | Default API template for TypeScript teams | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nestjs-api) |
-| `go-api` | API | Go, Gin, GORM | High-throughput / low-memory / mixed-language teams | - |
-| `nextjs-app` | Web | Next.js, SSR, React | Default consumer web or full-stack app | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nextjs-app) |
-| `react-spa` | Web | Vite, React, SPA | Console / internal app / no SEO | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/react-spa) |
-| `astro-site` | Web | Astro, static-first | Marketing or content site | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/astro-site) |
-| `starlight-docs` | Docs | Starlight, Astro | Documentation site or knowledge base | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/starlight-docs) |
-| `expo-mobile` | Mobile | Expo, React Native | Cross-platform iOS + Android | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/expo-mobile) |
-| `electron-app` | Desktop | Electron, React, Vite | Desktop app for macOS / Windows / Linux | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/electron-app) |
-| `ts-library` | Library | TS, strict semver | Reusable TypeScript package | - |
-| `go-lib` | Library | Go, module, package layout | Reusable Go module | - |
-| `empty-app` | App | No stack | Start with an empty application directory | - |
-| `empty-service` | API | No stack | Start with an empty service directory | - |
-| `empty-library` | Library | No stack | Start with an empty shared library directory | - |
+| ID | Category | Keywords | One-line fit | Details |
+| --------------- | -------- | ----------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------- |
+| `nestjs-api` | API | TypeScript, NestJS, REST | Default API template for TypeScript teams | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nestjs-api) |
+| `go-api` | API | Go, Gin, GORM | High-throughput / low-memory / mixed-language teams | - |
+| `nextjs-app` | Web | Next.js, SSR, React | Default consumer web or full-stack app | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nextjs-app) |
+| `react-spa` | Web | Vite, React, SPA | Console / internal app / no SEO | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/react-spa) |
+| `nextjs-site` | Web | Next.js, React, static export | Marketing or content site | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nextjs-site) |
+| `fumadocs-docs` | Docs | Fumadocs, Next.js, Base UI | Documentation site or knowledge base | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/fumadocs-docs) |
+| `expo-mobile` | Mobile | Expo, React Native | Cross-platform iOS + Android | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/expo-mobile) |
+| `electron-app` | Desktop | Electron, React, Vite | Desktop app for macOS / Windows / Linux | [Source](https://github.com/1cli-team/one-cli/tree/master/packages/templates/electron-app) |
+| `ts-library` | Library | TS, strict semver | Reusable TypeScript package | - |
+| `go-lib` | Library | Go, module, package layout | Reusable Go module | - |
+| `empty-app` | App | No stack | Start with an empty application directory | - |
+| `empty-service` | API | No stack | Start with an empty service directory | - |
+| `empty-library` | Library | No stack | Start with an empty shared library directory | - |
## Add The Template You Chose
@@ -79,11 +79,11 @@ Why: TypeScript across the stack lets `shared` be used by both API and web. Next
```bash
one add go-api --name api
-one add astro-site --name marketing
+one add nextjs-site --name marketing
one add react-spa --name console
```
-Why: Go handles traffic; Astro keeps the public site fast and SEO-friendly; React SPA works for an authenticated console.
+Why: Go handles traffic; Next.js static export serves the public site with SEO metadata; React SPA works for an authenticated console.
### Mobile + API
@@ -101,6 +101,17 @@ Run `one templates -o json` for full template metadata, or run `one add` interac
You can also pick one of the recommended combos above, get it running, and change course once you know more.
+## Static website and documentation templates
+
+`nextjs-site` uses Next.js, React, Tailwind CSS, and shadcn Base UI and exports `out/`.
+`fumadocs-docs` uses the Base UI implementation of Next.js + Fumadocs with English/Chinese MDX,
+sidebar navigation, a table of contents, syntax highlighting, and browser-side static search.
+Deploy its `out/` directory without a Node server.
+
+The retired IDs `astro-site` and `starlight-docs` now provide replacement guidance.
+Use `nextjs-site` and `fumadocs-docs` for new projects. Existing project directories,
+dependencies, and tasks stay as they are.
+
## Template dependencies and Electron workspaces
Node templates do not copy pre-generated lockfiles. Tasks such as `one dev` and
@@ -108,21 +119,38 @@ Node templates do not copy pre-generated lockfiles. Tasks such as `one dev` and
installation may create or update the root lockfile. Review and commit those changes.
For strict lockfile validation in CI, explicitly run `one mise exec -- pnpm install --frozen-lockfile`.
-`electron-app` requires a pnpm workspace. It remains one One project, while its main,
-UI, and preload packages join the root `pnpm-workspace.yaml` and share the root lockfile.
-Package names use the project name as their scope, for example `@desktop/electron`,
-`@desktop/ui`, and `@desktop/preload`, so multiple desktop apps can coexist.
-
+`electron-app` creates three top-level One projects. With `--name desktop`, they are
+`apps/desktop-renderer`, `services/desktop-main`, and `packages/desktop-preload`.
+Their npm package names are `desktop-renderer`, `desktop-main`, and `desktop-preload`.
+Directories preserve the supplied project name; npm names use kebab-case. All three
+join the root pnpm workspace and share its lockfile, without a nested workspace.
+
+The manifest records `[groups.desktop]` with these three members. Use
+`one run desktop:dev`, `one run desktop:build`, `one run desktop:test`, and
+`one run desktop:pack` to operate on the group. These aliases survive `one init mise`.
+Each member remains independently addressable, such as `one build -p desktop-main`.
+The main process uses Awilix function factories; the renderer uses React, Vite,
+and shadcn/ui backed by Base UI. Packaging copies renderer and preload outputs
+into the main project's build directory.
The template follows the root package-manager version, registry, and mirror settings.
Existing build-script policies are preserved; configurations without a policy receive
the bundled template defaults. Explicit denials of Electron's installation script
must be adjusted at the root. For concurrent desktop development, set a different
-`ELECTRON_RENDERER_PORT` in each project's environment.
+`ELECTRON_RENDERER_PORT` for both the renderer and main projects of each desktop app.
+
+Electron development checks sandbox permissions only when the OS identifies as Ubuntu
+(`ID=ubuntu`). On the first AppArmor user-namespace denial, it generates a profile for
+the installed Electron binary and prints commands for an administrator to review,
+install, and load it. Rerun development after setup; the script never elevates itself.
+A changed Electron binary path triggers another check. Linux preserves explicit displays,
+including SSH X11 forwarding and Xvfb; otherwise it discovers the current user's live
+Wayland / X11 desktop. Ambiguous displays require an explicit selection; headless systems
+receive guidance to use a graphical terminal, X11 forwarding, or Xvfb. macOS and Windows
+skip these checks. See the generated README for details.
These rules apply to newly generated projects. Existing Electron projects are not
automatically rewritten or migrated.
-
## Develop directly in a template directory
Bundled templates contain ordinary source files that you can run and debug in
@@ -139,8 +167,9 @@ go test ./...
go run ./cmd/server
```
-The Go API defaults to in-memory SQLite. Configure environment variables as
-described by the template when using PostgreSQL or other runtime settings.
+NestJS API and Go API start without database connections, JWT, login, or user CRUD. Drizzle ORM/Kit and Gorm are retained. Choose a database, install its driver, and explicitly add connections and migrations.
+
+Application templates retain Axios for HTTP, SWR for remote data and caching, and Zustand for shared UI state. Expo keeps MMKV and native development clients with React Native styles. Electron keeps Awilix function factories and an app-information-only preload API. Add business features through manually installed business skills or application code.
```sh
cd packages/templates/electron-app
@@ -169,13 +198,15 @@ lockfiles and build artifacts are excluded from the CLI bundle.
Files are copied verbatim by default. Only starters needing parameterization
include a `template.json` file, with these supported settings:
-| Setting | Purpose |
-| --- | --- |
-| `schemaVersion: 1` | Declare the descriptor version |
-| `go.modulePrefix` | Set the generated module path and rewrite its Go imports |
-| `node.scope`, `node.sourceFiles` | Rename internal Node packages, dependency keys, scripts and the scope in listed source files |
-| `text` | Replace example text in explicitly listed files |
-| `exclude` | Exclude files or directories used only for template development |
+| Setting | Purpose |
+| -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `schemaVersion: 1` | Declare the descriptor version |
+| `go.modulePrefix` | Set the generated module path and rewrite its Go imports |
+| `node.scope`, `node.sourceFiles` | Rename internal Node packages, dependency keys, scripts and the scope in listed source files |
+| `text` | Replace example text in explicitly listed files |
+| `exclude` | Exclude files or directories used only for template development |
+| `projects` | Composite components, each declaring `source`, name `suffix`, and `category` (`frontend`, `backend`, or `library`) |
+| `sharedFiles` | Template-relative files copied into every composite component |
Each `text` rule contains `files`, `from` and `value`. The supported values are
`projectName` and `projectNameKebabCase`. Optional `minMatches` defaults to 1;
@@ -196,8 +227,9 @@ mise run check
mise run check:templates
```
-`check:templates` installs the Electron template dependencies, builds the Go and
+`check:templates` installs the template dependencies, builds the Go and
Electron sources, checks generated formatting for every Node template, and
-builds two differently named Electron projects plus two Go projects in a
-temporary workspace. It requires network access and the corresponding
+builds two differently named Electron projects, two Go projects, and both new
+Next.js static sites in a temporary workspace. It checks bilingual pages and
+static documentation search. It requires network access and the corresponding
toolchains, and does not change the developer's global language preference.
diff --git a/apps/docs/content/docs/zh/add.md b/apps/docs/content/docs/zh/add.md
index f4652873..9fdf9543 100644
--- a/apps/docs/content/docs/zh/add.md
+++ b/apps/docs/content/docs/zh/add.md
@@ -28,6 +28,7 @@ one add [template-id] --name [options]
|---|---|
| `template-id` | 模板 ID(如 `nestjs-api`);不传走交互式选择 |
| `-n, --name` | 项目名(必填,非交互模式) |
+| `--skip-skills` | 跳过开发 skills 安装 |
| `-y, --yes` | 非交互模式 |
| `-o, --output ` | `json` / `yaml` / `text` |
@@ -57,6 +58,24 @@ one add empty-library --name shared --yes
如果使用 Node 或 Go,在 `one.manifest.toml` 中将项目的 `toolchain` 改为 `node` 或 `go`;Node 项目在根 `package.json` 中声明 `packageManager`,并补齐根工作区的包成员配置,Go 项目则将模块加入根 `go.work`。在 `package.json` / `Taskfile.yml` 中定义任务,然后运行 `one init mise` 更新任务配置。使用其他语言时,可以保留 `toolchain = "none"`,在根 `mise.toml` 中自行定义工具和任务。配置好命令后再使用 `one dev` / `one build`。
+## 添加 Electron 应用
+
+```bash
+one add electron-app --name desktop --yes
+one run desktop:dev
+one run desktop:pack
+```
+
+这会注册 `apps/desktop-renderer`、`services/desktop-main` 和
+`packages/desktop-preload` 三个项目,以及名为 `desktop` 的项目组。
+main 开发任务会等待 Vite 就绪再打开 Electron;打包任务会先构建整组项目,
+再将产物汇集到 main。多个应用使用各自派生的包名;同时开发多个应用时,
+为每个应用的 main 和 renderer 配置相同且与其他应用不同的 `ELECTRON_RENDERER_PORT`。
+
+组合模板的 JSON 输出额外包含 `projects[]`,每项有 `name`、`target_path` 和
+`toolchain`。`subproject_name` 为项目组名称,`target_path` 为工作区根目录。
+普通模板保持以下输出结构。
+
## 输出
```json
@@ -143,3 +162,7 @@ one add nestjs-api --name user-api --yes -o json | jq
- `one add` 同步任务配置,保留已有 `AGENTS.md` 和团队修改
- 下一步运行 `one dev -p ` 开发,使用 `one build -p ` 构建
- `one add` 只生成项目和工作区配置;`one dev` 会自动准备工具与应用依赖。JS/TS 在根目录统一安装,Go 按当前模块或 `go.work` 构建图准备依赖。修改 imports 或模块声明需要修复时,显式运行 `one exec -- go mod tidy`。
+
+## 开发 skills
+
+项目生成完成后,One 在工作区根目录安装缺少的基础和技术栈 skills。Electron 的三个成员全部创建完成后,只安装一次合并后的 skills。已有 skills 和团队修改会被保留,来源冲突会给出提示。使用 `--skip-skills` 跳过,Dashboard 也提供同样的选项。安装失败仍保留项目并返回恢复提示,详见 [skills](/zh/docs/skills/)。
diff --git a/apps/docs/content/docs/zh/ai-native.md b/apps/docs/content/docs/zh/ai-native.md
index 4a4cebd4..c1f54f00 100644
--- a/apps/docs/content/docs/zh/ai-native.md
+++ b/apps/docs/content/docs/zh/ai-native.md
@@ -62,8 +62,8 @@ One CLI 的错误使用统一 envelope:
"available_templates": [
"nestjs-api",
"go-api",
- "astro-site",
- "starlight-docs",
+ "nextjs-site",
+ "fumadocs-docs",
"nextjs-app",
"react-spa",
"expo-mobile",
@@ -149,3 +149,7 @@ one add api-fastify --name api --yes -o json
one add nestjs-api --name api --yes -o json
cat one.manifest.toml
```
+
+## 可选业务 skills
+
+开发 skills 补充团队维护的 AGENTS.md。业务 skills 在独立 GitHub 仓库维护,需要用户手动安装。`one skills` 透传上游 Skills CLI,其原始输出和参数不遵循 One 的结构化输出协议,详见 [skills](/zh/docs/skills/)。
diff --git a/apps/docs/content/docs/zh/cli-overview.md b/apps/docs/content/docs/zh/cli-overview.md
index 33dd5ba4..eb9916f0 100644
--- a/apps/docs/content/docs/zh/cli-overview.md
+++ b/apps/docs/content/docs/zh/cli-overview.md
@@ -16,6 +16,7 @@ description: One CLI 日常命令和高级入口。
| `one upgrade` | 将 One CLI 更新到最新稳定版 |
| `one init mise` / `one init hooks` | 工作区工具配置 |
| `one run` | 列出和执行工作区任务 |
+| `one skills` | 透传命令到 `npx skills` |
| `one templates` | 查看项目模板 |
| `one hk` | 执行 Git hooks 检查 |
diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md
index f7d82230..6ef1652d 100644
--- a/apps/docs/content/docs/zh/create.md
+++ b/apps/docs/content/docs/zh/create.md
@@ -3,7 +3,7 @@ title: one create
description: 起一个新的 one 工作区根骨架。
---
-`one create` 只创建空工作区:不问项目、不问部署,也不修改本机 AI 工具配置。需要项目时使用 `one add`。
+`one create` 只创建空工作区:不问项目、不问部署,默认安装项目级开发 skills,保留本机 AI 工具的全局配置。需要项目时使用 `one add`。
## 用法
@@ -17,6 +17,7 @@ one create [dir] [options]
|---|---|
| `dir` | 目标目录(位置参数)。传 `.` 在当前目录就地创建,自动使用当前文件夹名;支持新目录、空目录或只有有效 Git 元数据的仓库 |
| `-n, --name ` | 高级选项:覆盖自动使用的文件夹名称 |
+| `--skip-skills` | 跳过开发 skills 安装 |
| `-y, --yes` | 非交互模式:使用默认值;必须显式传 `dir` |
| `-o, --output ` | `json` / `yaml` / `text`(默认按 TTY 检测) |
@@ -136,4 +137,8 @@ one dev -p api
源码模板直接使用 Markdown,位于 `packages/cli/internal/modules/creation/templates/` 下的 `AGENTS.zh-CN.md` 和 `AGENTS.en-US.md`。修改后重新构建 CLI,即可应用到以后创建的工作区。
-指引要求 Agent 通过 `one run` 执行测试及项目命令,将复杂 TypeScript 脚本放在 `scripts/*.mts` 并登记为任务,统一通过 One 管理环境变量。Docker 镜像上传账号等发布、运维凭据放在全局变量中;缺少时提示用户到 Dashboard 的“共享凭据”页面配置,然后由任务内部通过 `one exec --global` 注入。
+指引要求 Agent 通过 `one run` 执行测试及项目命令,将复杂脚本按项目选择的语言放在 `scripts/` 并登记为任务,统一通过 One 管理环境变量。空模板的技术栈由用户选择。Docker 镜像上传账号等发布、运维凭据放在全局变量中;缺少时提示用户到 Dashboard 的“共享凭据”页面配置,然后由任务内部通过 `one exec --global` 注入。
+
+## 开发 skills
+
+创建工作区时默认在工作区根目录的 `.agents/skills` 中安装 `one-cli` 和 `find-skills`,不安装到用户全局目录,安装过程只显示 One 的简洁进度。生成的 `.gitignore` 默认忽略根目录 `.one/`,已安装的 skills 和 `skills-lock.json` 仍可提交,安装过程不生成重复的 skill 来源目录。之后 `one add` 再安装所选模板的技术栈 skills。业务 skills 在独立 GitHub 仓库维护,由用户手动安装。安装失败会保留工作区,并在 `warnings` 中给出重试命令。即使工作区为空或只有 Go 项目,也可能为 skills 准备 Node/npx,但不会因此添加 Node 项目或包管理器。使用 `--skip-skills` 跳过安装,来源及锁文件说明见 [skills](/zh/docs/skills/)。
diff --git a/apps/docs/content/docs/zh/manifest.md b/apps/docs/content/docs/zh/manifest.md
index 9eb02116..eff04157 100644
--- a/apps/docs/content/docs/zh/manifest.md
+++ b/apps/docs/content/docs/zh/manifest.md
@@ -48,6 +48,20 @@ toolchain = "go"
项目目录不能是绝对路径,也不能越出工作区。未知字段、不支持的版本或无效字段返回 `MANIFEST_INVALID`。TOML 语法错误会标明文件和位置。
+## 项目组
+
+组合模板可以将已注册的项目归为一组:
+
+```toml
+[groups.desktop]
+projects = ["desktop-renderer", "desktop-main", "desktop-preload"]
+```
+
+`groups..projects` 必须是非空列表,成员必须是已注册的项目名且不能重复。
+项目组名称不能与项目名称冲突。项目组生成 `desktop:dev`、`desktop:build`
+等任务别名,自身没有目录或环境绑定;成员任务各自使用所属项目的环境变量。
+删除或重命名成员时需同步更新项目组。`one init mise` 根据此记录重新生成别名。
+
## 环境变量约定
未配置 `[env.infisical]` 时,任务继承现有进程环境。Manifest 不保存变量值,也不登记变量名。
diff --git a/apps/docs/content/docs/zh/meta.json b/apps/docs/content/docs/zh/meta.json
index 3796224a..4cd96b41 100644
--- a/apps/docs/content/docs/zh/meta.json
+++ b/apps/docs/content/docs/zh/meta.json
@@ -9,6 +9,7 @@
"cli-overview",
"create",
"add",
+ "skills",
"env-vars",
"login",
"hk",
diff --git a/apps/docs/content/docs/zh/skills.md b/apps/docs/content/docs/zh/skills.md
new file mode 100644
index 00000000..6215f225
--- /dev/null
+++ b/apps/docs/content/docs/zh/skills.md
@@ -0,0 +1,100 @@
+---
+title: one skills
+description: 透传上游 Skills CLI 管理 agent skills,并说明各模板的默认开发 skills。
+---
+
+`one skills` 是 `npx skills@1.7.0` 的薄封装。在当前目录原样传递所有参数,保留交互输入、标准输出、错误输出和退出码。缺少 npx 时通过 mise 准备 Node,不读取 One/Infisical 凭据,也不会为工作区添加 Node 项目。
+
+```bash
+one skills --help
+one skills find react
+one skills add owner/repo --skill skill-name
+one skills list
+one skills update
+one skills remove skill-name
+```
+
+所有子命令和参数遵循 [Skills CLI](https://github.com/vercel-labs/skills)。One 不增加 `sync` 子命令,也不重新解释 `--agent`、`--global`、`--yes` 或未来新增的上游参数。`one skills --help` 显示上游帮助,`one help skills` 显示封装说明。原始输出不包装成 One JSON。npx 会接受 npm 的首次工具下载提示,Skills CLI 自身的确认仍保留,除非用户传入 `--yes`。
+
+## 自动安装开发 skills
+
+`one create` 默认安装 `one-cli` 和 `find-skills`,分别提供 One 用法与 skill 查找指导。`one add` 在项目文件和工作区配置生成完成后,为所选模板补齐缺少的基础和技术栈 skills。Electron 的三个成员全部就绪后,只安装一次合并后的 skills。自动安装都在工作区根目录的 `.agents/skills` 中,供 Codex、Cursor 等兼容 agent 读取,不安装到用户全局目录,并且只显示 One 的简洁进度。
+
+两个命令都可通过 `--skip-skills` 跳过安装,Dashboard 新建项目弹窗也提供同样的选项。默认安装明确选择 skill 名称和 universal agent,复制文件并非交互执行,不会安装来源仓库中的所有 skills。
+
+```bash
+one create demo --skip-skills
+one add react-spa --name web --skip-skills
+```
+
+所有模板都有基础 skills `one-cli` 和 `find-skills`,Node 模板另有 `pnpm`:
+
+| 模板 | 额外 skills |
+| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `nestjs-api` | `one-nestjs`、`nestjs-best-practices` |
+| `go-api`、`go-lib` | `one-go`、`golang-patterns`、`golang-testing` |
+| `nextjs-app`、`nextjs-site` | `one-web`、React 最佳实践、组合模式、`shadcn`;查阅项目已安装的 Next 文档 |
+| `fumadocs-docs` | 上述 Next/React skills,以及 `one-fumadocs` |
+| `react-spa` | `one-web`、React 最佳实践、组合模式、`shadcn`、`vite` |
+| `expo-mobile` | `one-expo`、`expo-overview`、`expo-router`、`expo-data-fetching`、`expo-dev-client`、`expo-upgrade`、`expo-project-structure` |
+| `ts-library` | `tsdown`、`vitest` |
+| `electron-app` | `one-web`、React 最佳实践、组合模式、`shadcn`、`vite`、`vitest`、`tsdown`、`one-electron` |
+| `empty-app`、`empty-service`、`empty-library` | 仅基础 skills |
+
+空模板不预设语言、框架、UI、数据库或项目内部架构,技术栈由用户自由选择。基础 skills 只指导 One 工作区目录与操作流程,不要求采用其他模板的技术栈。确定技术栈后,再按需通过 `one skills add` 安装匹配的开发 skills。默认生成的 `AGENTS.md` 也遵循这一规则,复杂脚本使用项目选择的语言,不强制引入 Node 或 TypeScript。
+
+`one-cli` skill 提供外层及项目内部目录规范,以及技术栈适配说明。项目依赖和团队约定优先:生成的 Web 模板使用 Base UI,已有应用可能使用 Radix;Expo 使用原生样式和已有 Axios/SWR;Electron 使用 Awilix 工厂函数。安装上游 skill 不代表授权替换这些选择。
+
+One 自行维护七个开发 skills:
+
+| Skill | 职责 |
+| -------------- | ------------------------------------------------------------------- |
+| `one-cli` | One 命令、工作区目录和项目发现;空模板不限定技术栈 |
+| `one-nestjs` | NestJS 模块、Drizzle、配置注入、验证、日志与健康检查 |
+| `one-go` | Go 服务和库的目录、显式依赖、Gin/Gorm/Viper/Zap 与资源生命周期 |
+| `one-expo` | 原生样式、Expo Router、MMKV、开发客户端与 SWR 原生监听 |
+| `one-web` | Base UI、Axios/SWR/Zustand,React SPA、Next.js SSR 与静态导出的区别 |
+| `one-electron` | 三进程项目、Awilix、preload/IPC、Ubuntu 引导与打包 |
+| `one-fumadocs` | MDX、多语言文档、导航与静态搜索 |
+
+这些技能提供 One 模板的适配规则,上游 skills 提供框架通用最佳实践。`one-web` 共用于 Web 应用、Electron renderer 和 Fumadocs 的 Web 基础栈;文档内容和原生进程分别由专属技能指导。Agent 只读取当前任务需要的技能与引用,不要求加载全部技能正文。TS 库沿用上游工具技能;空模板仅安装基础技能,不自动安装任何技术栈适配技能。已有项目可以在工作区根目录手动安装对应 One 技能,自动安装只在添加项目时补齐缺少的名称。
+
+默认来源包括 [Vercel](https://github.com/vercel-labs/agent-skills)、[Skills CLI](https://github.com/vercel-labs/skills)、[shadcn](https://github.com/shadcn-ui/ui)、[Antfu](https://github.com/antfu/skills)、[tsdown](https://github.com/rolldown/tsdown)、[Expo](https://github.com/expo/skills)、[ECC Go skills](https://github.com/affaan-m/ecc) 和 [NestJS 社区 skills](https://github.com/kadajett/agent-nestjs-skills)。One 的默认目录固定已审阅的来源提交;用户手动命令仍遵循上游,不限制来源。
+
+## 文件、冲突和恢复
+
+将已安装的 `.agents/skills` 文件和上游 `skills-lock.json` 一并提交。新工作区默认忽略根目录 `.one/`,安装过程不再生成 `.one/skill-sources` 或 `.agents/skill-sources` 中的重复来源。One 自己的 skill 源码仍在本仓库的 `packages/agent-skills/skills` 中维护,与可运行模板和业务 skills 分开。
+
+正式发布的 CLI 从 `1cli-team/one-cli/packages/agent-skills` 安装 One 自己的 skills,固定到该版本的完整 Git 提交。锁文件记录 GitHub 来源,恢复时不依赖本地缓存。开发版和快照版则只将所选的内置 skills 写入最终 `.agents/skills` 路径,再由 Skills CLI 将 `./.agents/skills` 登记为本地来源,避免未发布的修改指向不存在的远程提交,也不保留重复文件。这些本地副本同样需要提交;开发版 skill 被删除后,从 Git 恢复,或再次通过 One 添加对应项目补齐,本地来源无法下载自身缺失的文件。
+
+已有安装及指向 `.one/skill-sources` 的锁文件会在添加项目时保留。明确迁移前继续保留它们引用的来源;迁移时使用 `one skills add` 选择已发布的 One 来源和提交。重新安装前审阅团队修改,确认锁文件不再引用旧目录后再删除它,或在旧工作区中忽略 `.one/`。One 不会自动删除旧目录或改写来源记录。
+
+Skills 按模板安装、按任务读取。Agent 可以先根据名称与描述选择相关 skill,启用时读取其 `SKILL.md`,需要细节时再打开引用资料或脚本。生成的 `AGENTS.md` 会说明这一流程,不要求启动时阅读全部正文;具体加载行为由 agent 实现。
+
+自动安装只补齐缺少的名称,保留已有 skill 目录、团队修改和不同来源。来源冲突、无效或不受支持的锁文件会产生提示,不覆盖已有内容。用户明确执行 `one skills` 时仍遵循上游的更新和覆盖行为。锁文件格式由上游管理,One 不维护第二份 skills 锁文件。
+
+自动安装总超时为五分钟。网络、下载和工具运行失败不会撤销工作区或项目,创建操作仍成功,通过 `warnings` 返回诊断信息和在工作区根目录重试的命令。自动安装只显示 One 的本地化进度,上游 Logo、通用提示和安装界面会被收起;失败时保留具体原因,结构化创建结果仍可解析。手动执行 `one skills` 继续保留上游完整输出。默认来源提交保留在锁文件中,版本更新需要主动选择来源或 ref,添加项目不会自动升级已有 skills。
+
+## 业务 skills 由用户手动安装
+
+登录注册、支付、权限、上传等业务 skills 在独立 GitHub 仓库维护,不进入 One CLI 的源码、模板或发布资源。`one create` 和 `one add` 都不会自动安装。
+
+业务仓库发布后,用户可以在工作区根目录选择需要的 skill,安装到项目中,不添加 `--global`:
+
+```bash
+one skills add owner/business-skills --skill one-auth
+```
+
+这里的仓库及名称是占位示例,不表示已经内置或发布。安装只是让 agent 获得指导,不会直接实现登录或创建后端服务;业务功能需要用户另外提出开发需求。
+
+## 已有工作区
+
+已有项目先检查实际依赖,再通过 `one skills add` 主动选择匹配的 skills,不根据旧模板名称猜测技术栈。例如 React/Vite 项目可以安装:
+
+```bash
+one skills add vercel-labs/agent-skills --skill vercel-react-best-practices vercel-composition-patterns
+one skills add antfu/skills --skill vite
+one skills add shadcn-ui/ui --skill shadcn
+```
+
+在 One 源码仓库中,可以从 `./packages/agent-skills` 安装自行维护的 skills。修改维护源码不会自动覆盖已安装副本,需要更新副本时主动重新安装。
diff --git a/apps/docs/content/docs/zh/templates-cmd.md b/apps/docs/content/docs/zh/templates-cmd.md
index d24d3e75..7c83e887 100644
--- a/apps/docs/content/docs/zh/templates-cmd.md
+++ b/apps/docs/content/docs/zh/templates-cmd.md
@@ -57,8 +57,8 @@ one templates [-o ]
|---|---|---|
| `nestjs-api` | backend | [→](/zh/docs/templates/) |
| `go-api` | backend | [→](/zh/docs/templates/) |
-| `astro-site` | frontend | [→](/zh/docs/templates/) |
-| `starlight-docs` | frontend / docs | [→](/zh/docs/templates/) |
+| `nextjs-site` | frontend | [→](/zh/docs/templates/) |
+| `fumadocs-docs` | frontend / docs | [→](/zh/docs/templates/) |
| `nextjs-app` | frontend | [→](/zh/docs/templates/) |
| `react-spa` | frontend | [→](/zh/docs/templates/) |
| `expo-mobile` | frontend / mobile | [→](/zh/docs/templates/) |
diff --git a/apps/docs/content/docs/zh/templates.md b/apps/docs/content/docs/zh/templates.md
index 0f9652fc..7d0a9242 100644
--- a/apps/docs/content/docs/zh/templates.md
+++ b/apps/docs/content/docs/zh/templates.md
@@ -15,9 +15,9 @@ description: 13 个内置模板按用途分组的决策树。30 秒判断到底
```
要起一个后端 API ----------------→ nestjs-api / go-api
-要起一个前端 Web 项目 -------------→ nextjs-app / react-spa / astro-site
+要起一个前端 Web 项目 -------------→ nextjs-app / react-spa / nextjs-site
要写一个跨项目复用的库 ----------→ ts-library / go-lib
-要起一个文档站 -------------------→ starlight-docs
+要起一个文档站 -------------------→ fumadocs-docs
要起一个移动 app -----------------→ expo-mobile
要起一个桌面 app -----------------→ electron-app
```
@@ -26,21 +26,21 @@ description: 13 个内置模板按用途分组的决策树。30 秒判断到底
## 完整对比表
-| ID | 类别 | 关键词 | 一句话 | 详细 |
-|---|---|---|---|---|
-| `nestjs-api` | API | TypeScript, NestJS, REST | TS 团队默认 API 模板 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nestjs-api) |
-| `go-api` | API | Go, Gin, GORM | 高吞吐 / 低内存 / 团队混语言 | - |
-| `nextjs-app` | Web | Next.js, SSR, React | 通用 Web 应用 / C 端内容站首选 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nextjs-app) |
-| `react-spa` | Web | Vite, React, SPA | 控制台 / 内部应用 / 无 SEO | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/react-spa) |
-| `astro-site` | Web | Astro, 静态优先 | 营销页 / 内容站 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/astro-site) |
-| `starlight-docs` | Docs | Starlight, Astro | 文档站 / 知识库 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/starlight-docs) |
-| `expo-mobile` | Mobile | Expo, React Native | iOS + Android 跨平台 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/expo-mobile) |
-| `electron-app` | Desktop | Electron, React, Vite | 桌面 app(macOS / Windows / Linux) | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/electron-app) |
-| `ts-library` | Library | TS, 严格 semver | 跨项目复用的 TS 库 | - |
-| `go-lib` | Library | Go, module, package layout | 跨项目复用的 Go module | - |
-| `empty-app` | App | 无技术栈 | 从空应用目录开始 | - |
-| `empty-service` | API | 无技术栈 | 从空服务目录开始 | - |
-| `empty-library` | Library | 无技术栈 | 从空共享库目录开始 | - |
+| ID | 类别 | 关键词 | 一句话 | 详细 |
+| --------------- | ------- | -------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------- |
+| `nestjs-api` | API | TypeScript, NestJS, REST | TS 团队默认 API 模板 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nestjs-api) |
+| `go-api` | API | Go, Gin, GORM | 高吞吐 / 低内存 / 团队混语言 | - |
+| `nextjs-app` | Web | Next.js, SSR, React | 通用 Web 应用 / C 端内容站首选 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nextjs-app) |
+| `react-spa` | Web | Vite, React, SPA | 控制台 / 内部应用 / 无 SEO | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/react-spa) |
+| `nextjs-site` | Web | Next.js, React, 静态导出 | 营销页 / 内容站 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/nextjs-site) |
+| `fumadocs-docs` | Docs | Fumadocs, Next.js, Base UI | 文档站 / 知识库 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/fumadocs-docs) |
+| `expo-mobile` | Mobile | Expo, React Native | iOS + Android 跨平台 | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/expo-mobile) |
+| `electron-app` | Desktop | Electron, React, Vite | 桌面 app(macOS / Windows / Linux) | [源码](https://github.com/1cli-team/one-cli/tree/master/packages/templates/electron-app) |
+| `ts-library` | Library | TS, 严格 semver | 跨项目复用的 TS 库 | - |
+| `go-lib` | Library | Go, module, package layout | 跨项目复用的 Go module | - |
+| `empty-app` | App | 无技术栈 | 从空应用目录开始 | - |
+| `empty-service` | API | 无技术栈 | 从空服务目录开始 | - |
+| `empty-library` | Library | 无技术栈 | 从空共享库目录开始 | - |
## 选好之后怎么加
@@ -79,11 +79,11 @@ one add ts-library --name shared
```bash
one add go-api --name api
-one add astro-site --name marketing
+one add nextjs-site --name marketing
one add react-spa --name console
```
-为什么:Go API 顶住流量;Astro 静态化首页便于 SEO;React 控制台只给登录用户用,无 SEO 需求。
+为什么:Go API 顶住流量;Next.js 静态导出首页便于 SEO;React 控制台只给登录用户用,无 SEO 需求。
### 移动 + API
@@ -101,22 +101,48 @@ one add ts-library --name shared
或者直接选 [推荐组合](#推荐组合) 里的栈,先跑起来,跑不通再换。
+## 静态网站与文档模板
+
+`nextjs-site` 使用 Next.js、React、Tailwind CSS 和 shadcn Base UI,默认导出 `out/`。
+`fumadocs-docs` 使用 Next.js + Fumadocs 的 Base UI 实现,内置中英文 MDX、侧边栏、
+本页目录、代码高亮和浏览器静态搜索;同样部署 `out/`,无需 Node 服务。
+
+旧 ID `astro-site` 和 `starlight-docs` 已下架。新建项目分别使用 `nextjs-site` 和
+`fumadocs-docs`;旧 ID 会显示替代指引。已有项目的目录、依赖和任务保持原状。
+
## 模板依赖和 Electron 工作区
Node 模板不复制预生成的锁文件。`one dev`、`one build` 等任务共用依赖准备流程:
复用匹配的依赖,需要安装时允许生成或更新根锁文件。请审阅并提交锁文件变更。
需要严格校验锁文件的 CI,可显式执行 `one mise exec -- pnpm install --frozen-lockfile`。
-`electron-app` 需要 pnpm 工作区。它仍是一个 One 项目,内部的主进程、UI 和 preload
-包会自动加入根 `pnpm-workspace.yaml`,共享根锁文件。包名以项目名作为 scope,
-例如 `@desktop/electron`、`@desktop/ui`、`@desktop/preload`,可在同一仓库中添加多个桌面应用。
+`electron-app` 会创建三个顶层 One 项目。输入 `--name desktop` 时,目录分别为
+`apps/desktop-renderer`、`services/desktop-main` 和 `packages/desktop-preload`,
+npm 包名分别为 `desktop-renderer`、`desktop-main` 和 `desktop-preload`。
+目录保留用户输入的项目名,npm 包名转换为 kebab-case。三个项目共用根目录的
+pnpm workspace 和 lockfile,不再创建嵌套工作区。
+
+清单通过 `[groups.desktop]` 记录三个成员。使用 `one run desktop:dev`、
+`one run desktop:build`、`one run desktop:test` 和 `one run desktop:pack`
+操作整组项目;重新运行 `one init mise` 后这些任务仍会保留。
+每个成员仍可独立操作,例如 `one build -p desktop-main`。
+主进程使用 Awilix 函数工厂,renderer 使用 React、Vite 和基于 Base UI 的 shadcn/ui。
+打包时会将 renderer 和 preload 的构建产物复制到 main 的构建目录。
模板沿用根目录包管理器版本、registry 和镜像配置。已有安装脚本策略会保留;未配置时
使用内置模板默认规则。若显式禁用了 Electron 安装脚本,需要在根目录调整策略。
-多个桌面应用同时开发时,通过各项目环境中的 `ELECTRON_RENDERER_PORT` 配置不同端口。
+多个桌面应用同时开发时,为每组配置不同的 `ELECTRON_RENDERER_PORT`;
+同组 main 和 renderer 使用相同端口值。
-这些规则适用于新生成的项目,已有 Electron 项目不会自动改写目录或依赖配置。
+Electron 开发启动仅在系统标识为 Ubuntu(`ID=ubuntu`)时检查沙箱权限。
+首次遇到 AppArmor 用户命名空间限制,会生成当前 Electron 专用的规则文件,
+显示供管理员审阅、安装与加载的命令;配置后重新运行即可,脚本不会自行提权。
+Electron 可执行文件路径变化后会重新检查。Linux 会保留已有显示变量(包括
+SSH X11 转发和 Xvfb),缺失时自动识别当前用户可用的 Wayland / X11 桌面。
+多个候选无法确定时提示显式选择;无桌面时提示使用图形终端、X11 转发或 Xvfb。
+macOS 和 Windows 跳过这些检查。详细流程见生成项目的 README。
+这些规则适用于新生成的项目,已有 Electron 项目不会自动改写目录或依赖配置。
## 在模板目录直接开发
@@ -133,7 +159,9 @@ go test ./...
go run ./cmd/server
```
-Go API 默认使用内存 SQLite。需要 PostgreSQL 或其他运行配置时,按模板说明设置环境变量。
+NestJS API 和 Go API 默认不连接数据库,也不包含 JWT、登录或用户 CRUD。保留 Drizzle ORM/Kit 与 Gorm;选择数据库后再安装驱动并显式添加连接与迁移。
+
+应用模板保留 Axios、SWR、Zustand:Axios 负责 HTTP,SWR 管理远程数据和缓存,Zustand 只管理共享 UI。Expo 保留 MMKV 与原生开发客户端,使用 React Native 样式。Electron 保留 Awilix 函数工厂,preload 仅提供应用信息接口。业务功能通过手动安装的业务 skills 或项目代码扩展。
```sh
cd packages/templates/electron-app
@@ -159,13 +187,15 @@ pnpm --ignore-workspace run dev
默认按原始字节复制文件。只有需要参数化的模板才包含 `template.json`,目前支持:
-| 配置 | 用途 |
-| --- | --- |
-| `schemaVersion: 1` | 声明描述文件版本 |
-| `go.modulePrefix` | 生成 module 路径,并同步改写对应 Go import |
-| `node.scope`、`node.sourceFiles` | 修改内部 Node 包名、依赖键、scripts 和指定源码中的 scope |
-| `text` | 在明确列出的文件中替换示例文字 |
-| `exclude` | 排除模板开发专用的文件或目录 |
+| 配置 | 用途 |
+| -------------------------------- | -------------------------------------------------------------------------------------------------------- |
+| `schemaVersion: 1` | 声明描述文件版本 |
+| `go.modulePrefix` | 生成 module 路径,并同步改写对应 Go import |
+| `node.scope`、`node.sourceFiles` | 修改内部 Node 包名、依赖键、scripts 和指定源码中的 scope |
+| `text` | 在明确列出的文件中替换示例文字 |
+| `exclude` | 排除模板开发专用的文件或目录 |
+| `projects` | 组合模板的成员,每个成员声明 `source`、名称 `suffix` 和 `category`(`frontend`、`backend` 或 `library`) |
+| `sharedFiles` | 复制到组合模板每个成员的模板相对路径文件 |
`text` 的每条规则使用 `files`、`from`、`value`;`value` 仅支持
`projectName` 和 `projectNameKebabCase`。可选 `minMatches` 默认为 1,
@@ -184,6 +214,7 @@ mise run check
mise run check:templates
```
-`check:templates` 会安装 Electron 模板依赖,构建 Go/Electron 源码,检查全部
-Node 模板的生成格式,并在临时工作区构建两个不同名称的 Electron 项目和两个 Go 项目。
+`check:templates` 会安装模板依赖,构建 Go/Electron 源码与生成的 Next.js 静态站点,检查全部
+Node 模板的生成格式,并在临时工作区构建两个不同名称的 Electron 项目、两个 Go 项目与两种
+Next.js 静态站点,同时验证中英文页面和文档静态搜索。
该检查需要网络及对应工具链;它不会修改开发者的全局语言偏好。
diff --git a/apps/docs/content/tutorials/en/templates.mdx b/apps/docs/content/tutorials/en/templates.mdx
index 1cb24ecf..3069c2b0 100644
--- a/apps/docs/content/tutorials/en/templates.mdx
+++ b/apps/docs/content/tutorials/en/templates.mdx
@@ -13,7 +13,7 @@ Use the template ID with `one add`; you choose the project name separately. Run
| --- | --- |
| Backend API | `nestjs-api`, `go-api` |
| Web application | `nextjs-app`, `react-spa` |
-| Content and documentation | `astro-site`, `starlight-docs` |
+| Content and documentation | `nextjs-site`, `fumadocs-docs` |
| Desktop and mobile applications | `electron-app`, `expo-mobile` |
| Shared code | `ts-library`, `go-lib` |
| Choose a stack later | `empty-app`, `empty-service`, `empty-library` |
diff --git a/apps/docs/content/tutorials/zh/templates.mdx b/apps/docs/content/tutorials/zh/templates.mdx
index 56803692..138b1b36 100644
--- a/apps/docs/content/tutorials/zh/templates.mdx
+++ b/apps/docs/content/tutorials/zh/templates.mdx
@@ -13,7 +13,7 @@ description: 了解 One CLI 内置模板的用途、技术栈和默认目录,
| --- | --- |
| 后端 API | `nestjs-api`、`go-api` |
| Web 应用 | `nextjs-app`、`react-spa` |
-| 内容站与文档 | `astro-site`、`starlight-docs` |
+| 内容站与文档 | `nextjs-site`、`fumadocs-docs` |
| 桌面与移动应用 | `electron-app`、`expo-mobile` |
| 共享代码 | `ts-library`、`go-lib` |
| 暂时不选技术栈 | `empty-app`、`empty-service`、`empty-library` |
diff --git a/apps/docs/design/design.pen b/apps/docs/design/design.pen
index 3a51fd0f..25657333 100644
--- a/apps/docs/design/design.pen
+++ b/apps/docs/design/design.pen
@@ -3738,7 +3738,7 @@
"id": "Z8f9Lu",
"name": "tplB3Id",
"fill": "$foreground-primary",
- "content": "web-ssg-astro",
+ "content": "nextjs-site",
"fontFamily": "$font-mono",
"fontSize": 14,
"fontWeight": "500"
@@ -3750,7 +3750,7 @@
"fill": "$foreground-secondary",
"textGrowth": "fixed-width",
"width": "fill_container",
- "content": "Astro · static-first · client:* directives used sparingly · content schemas",
+ "content": "Next.js · React · static export · Tailwind CSS · shadcn Base UI",
"lineHeight": 1.5,
"fontFamily": "$font-body",
"fontSize": 13,
@@ -3834,7 +3834,7 @@
"id": "bVwQi",
"name": "tplC1Id",
"fill": "$foreground-primary",
- "content": "web-docs-starlight",
+ "content": "fumadocs-docs",
"fontFamily": "$font-mono",
"fontSize": 14,
"fontWeight": "500"
@@ -3846,7 +3846,7 @@
"fill": "$foreground-secondary",
"textGrowth": "fixed-width",
"width": "fill_container",
- "content": "Starlight + Astro · built-in components first · CSS variable overrides only",
+ "content": "Next.js + Fumadocs Base UI · MDX · English/Chinese · static search",
"lineHeight": 1.5,
"fontFamily": "$font-body",
"fontSize": 13,
diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs
index dd3fa20c..6a264226 100644
--- a/apps/docs/next.config.mjs
+++ b/apps/docs/next.config.mjs
@@ -10,7 +10,7 @@ const config = {
// managed Next.js runtime, including the Image Optimization API.
distDir: "dist",
outputFileTracingRoot: workspaceRoot,
- // 让生成的 URL 与 Starlight 的 trailing-slash 行为一致。
+ // 使用目录式 URL,便于静态托管与直接访问文档链接。
trailingSlash: true,
reactStrictMode: true,
async redirects() {
diff --git a/apps/docs/src/app/(home)/hero-workspace.ts b/apps/docs/src/app/(home)/hero-workspace.ts
index 99d3c36c..a32cafcd 100644
--- a/apps/docs/src/app/(home)/hero-workspace.ts
+++ b/apps/docs/src/app/(home)/hero-workspace.ts
@@ -149,8 +149,8 @@ const moduleConfigs: ModuleConfig[] = [
id: "docs",
labels: { zh: "Docs", en: "Docs" },
spotlight: {
- zh: { title: "Docs", subtitle: "Starlight 文档站" },
- en: { title: "Docs", subtitle: "Starlight docs site" },
+ zh: { title: "Docs", subtitle: "Fumadocs 文档站" },
+ en: { title: "Docs", subtitle: "Fumadocs documentation" },
},
x: 0.9,
y: -0.08,
diff --git a/mise.toml b/mise.toml
index f6404e4d..9f7c026e 100644
--- a/mise.toml
+++ b/mise.toml
@@ -98,7 +98,7 @@ description = "Run static checks and tests on the current platform"
depends = ["check:static", "check:test"]
[tasks."check:templates"]
-description = "Build runnable template sources and generated Go/Electron projects"
+description = "Build all generated starters and verify health, IPC, native listeners, and static search"
depends = ["sync-bundled", "sync-web"]
run = "go test -count=1 -timeout=20m -v ./packages/cli/internal/modules/creation -run ^TestRunnableTemplateSourcesAndProjects$"
env = { ONE_TEST_TEMPLATE_BUILDS = "1" }
diff --git a/packages/agent-skills/skills/one-cli/SKILL.md b/packages/agent-skills/skills/one-cli/SKILL.md
new file mode 100644
index 00000000..4daa97fd
--- /dev/null
+++ b/packages/agent-skills/skills/one-cli/SKILL.md
@@ -0,0 +1,55 @@
+---
+name: one-cli
+description: Develop and manage One CLI workspaces, discover installed commands and tasks, and apply project layouts within apps, services, and packages.
+---
+
+# One CLI
+
+Find the nearest `one.manifest.toml`; use its project names, paths, toolchains,
+and groups rather than assuming a single application. Read applicable
+`AGENTS.md` and project README files. Team instructions and the user's chosen
+stack take precedence over examples in installed skills.
+
+For `empty-app`, `empty-service`, and `empty-library`, the user defines the
+language, frameworks, UI, database, and other tooling. These templates only
+establish the One workspace location and workflow, not a default stack or
+internal architecture. Do not infer a stack from sibling projects or impose
+dependencies from another template or installed skill. Once a stack is chosen,
+adapt project tasks and relevant development skills to that choice.
+
+Discover commands with `one --help`, `one help --all`, and command-specific
+help. Discover templates with `one templates -o json` before `one add`.
+The installed CLI is the authority; do not invent flags or commands.
+
+Use `one run` to discover tasks and `one run -p ` for project
+checks, builds, tests, dependency preparation, and scripts. If a task is
+missing, define it in the project's package.json/Taskfile.yml or root
+mise.toml, synchronize with `one init mise`, and run it through One.
+Keep shared multi-step scripts in root `scripts/`; project scripts belong
+in the project. Follow the repository's script language convention.
+
+Use `-o json` when parsing One's own structured results; branch on error
+codes and context. `one skills`, `one mise`, and `one hk` forward native
+tool output and flags, so their output does not use One's JSON envelope.
+
+Manage runtime variables through One or Dashboard; application code reads
+the injected process environment. Do not introduce another .env source.
+Publishing credentials belong in Shared credentials, configured by the
+user, and are injected by a task using `one exec --global`. Never request
+or print their values.
+
+Read [project layouts](references/layouts.md) when adding or restructuring
+a project. Read [stack adaptations](references/stacks.md) when an upstream
+skill's examples differ from this project's dependencies or conventions.
+
+For deeper template-specific work, select the installed one-nestjs, one-go,
+one-expo, or one-web skill for that stack. Electron process boundaries use
+one-electron; Fumadocs content and search use one-fumadocs. Read only the skill
+and references needed for the current task. Their absence does not prescribe
+a stack for an empty/custom project; follow its actual dependencies and README.
+
+Business skills are optional instructions maintained in separate GitHub
+repositories. Install them only when the user requests installation, using
+`one skills add --skill ` from the workspace root. Install
+skills in the project's `.agents/skills`, without `--global`. Installing a
+skill does not authorize implementing a feature or provisioning an external service.
diff --git a/packages/agent-skills/skills/one-cli/agents/openai.yaml b/packages/agent-skills/skills/one-cli/agents/openai.yaml
new file mode 100644
index 00000000..e99914ac
--- /dev/null
+++ b/packages/agent-skills/skills/one-cli/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One CLI"
+ short_description: "Work with One workspaces and their project layouts"
diff --git a/packages/agent-skills/skills/one-cli/references/layouts.md b/packages/agent-skills/skills/one-cli/references/layouts.md
new file mode 100644
index 00000000..2c30c62a
--- /dev/null
+++ b/packages/agent-skills/skills/one-cli/references/layouts.md
@@ -0,0 +1,47 @@
+# Project layouts inside a One workspace
+
+The outer convention is `apps/` for runnable clients/sites, `services/` for
+backends/workers, and `packages/` for reusable libraries. Preserve existing
+project structure and public import paths. Add directories when they hold
+real code; do not generate empty architectural scaffolding.
+
+Empty templates leave the project's internal layout and stack to the user.
+The examples below apply only when the project uses that stack; they do not
+require an empty project to adopt it. Follow the selected framework's layout
+and the user's architecture while keeping the One workspace placement.
+
+- **Go services:** keep binaries under `cmd/` and private code under
+ `internal/`. Group handlers, application logic, persistence, and configuration
+ according to the existing service. Use constructor/function injection where
+ needed. Libraries should expose deliberate public packages; `pkg/` is not
+ required for every Go module. Module paths follow the project's actual
+ publishing/import identity. See the [official Go module layout guide](https://go.dev/doc/modules/layout).
+- **Node services:** organize substantial business domains into modules that
+ own their transport, application logic, and data access. Keep reusable
+ infrastructure separate. For NestJS, follow its modules/providers and
+ Drizzle when persistence is requested instead of transplanting Express directories.
+ The minimal starter does not pre-create domains, repositories, or user models.
+ See [NestJS modules](https://docs.nestjs.com/modules) and
+ [Node Best Practices](https://github.com/goldbergyoni/nodebestpractices).
+- **React SPA:** use `src/app` for composition/routing and `src/features/`
+ when a domain warrants colocated API code, components, hooks, and state.
+ Shared UI and utilities stay outside features. Avoid cross-feature cycles;
+ do not move small existing applications just to match an example.
+ See [Bulletproof React's layout reference](https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md).
+- **Next.js:** routing belongs in `app/` or `src/app/`; preserve the chosen
+ location. Keep route-specific code colocated, reusable business code in
+ features/modules, and shared UI outside the route tree. Static sites and
+ docs keep their static-export constraints.
+- **Expo:** keep Expo Router route entries in `app/` or `src/app/` and reusable
+ screens/components outside routing directories. Respect the committed
+ layout; the upstream project-structure skill is for new unstructured apps.
+- **TypeScript libraries:** keep public exports deliberate, implementation in
+ `src/`, and tests near the behavior they cover. Preserve tsdown output,
+ package exports, and the project's actual test convention.
+- **Electron:** a desktop project is a group with `-renderer` under
+ apps, `-main` under services, and `-preload` under packages.
+ Consult the installed `one-electron` skill for the process boundaries.
+
+Upstream examples may use Make, ESLint, npm, or a different DI style. Reuse
+One tasks, the workspace package manager, existing lint tooling, and existing
+functional factories rather than creating competing build systems.
diff --git a/packages/agent-skills/skills/one-cli/references/stacks.md b/packages/agent-skills/skills/one-cli/references/stacks.md
new file mode 100644
index 00000000..423361ba
--- /dev/null
+++ b/packages/agent-skills/skills/one-cli/references/stacks.md
@@ -0,0 +1,57 @@
+# Adapt skills to the installed stack
+
+Read the target project's package.json/go.mod, configuration, and task files
+before applying upstream advice. Root skills may serve several projects;
+choose references for the project being changed, not every installed skill.
+
+Empty templates have no prescribed stack. When dependencies are not yet
+defined, follow the user's choices rather than applying another template's
+defaults. The adaptations below describe projects already using those stacks.
+
+For template-specific work, select the installed adaptation skill:
+
+- `one-nestjs`: NestJS services, Drizzle, and service startup.
+- `one-go`: Go API services and public libraries.
+- `one-expo`: native styling, development clients, and mobile data/state lifecycle.
+- `one-web`: React SPA and Next.js; also Web UI in Electron and Fumadocs.
+- `one-electron`: desktop process boundaries and packaging.
+- `one-fumadocs`: documentation content, navigation, and static search.
+
+Read the relevant skill and its references only when needed. If it is not
+installed, inspect the project's README and existing implementation rather than
+assuming its absence changes the stack. The brief adaptations below also apply
+to existing workspaces that have only one-cli installed.
+
+- **Web UI:** inspect components.json to identify shadcn style and primitive
+ library. Generated web templates use Base UI; existing applications may
+ still use Radix. Base UI composition uses `render`; do not copy Radix's
+ `asChild` blindly. Generated templates use Base UI Toast, not Sonner.
+ Preserve Tailwind version, theme, translations, and accessible interactions.
+- **Next.js:** resolve `next` from the target subproject and consult its
+ `node_modules/next/dist/docs` version-matched documentation when available.
+ Do not rely on the retired next-best-practices package. Static-export
+ projects cannot adopt server-only runtime behavior without a product change.
+- **NestJS:** preserve Nest modules/providers, class-validator, Drizzle ORM/Kit,
+ and Jest. The starter has no database connection or driver. Add a driver,
+ schema, and migration tasks only after the user chooses persistence. An upstream ORM or logging
+ example does not authorize replacing the project's data or log stack.
+- **Expo:** the One template uses React Native styles, Axios/SWR, MMKV, and
+ development clients for native dependencies. Do not introduce NativeWind,
+ Tailwind, or replace the data stack merely because a skill prefers them.
+ Check Expo SDK compatibility before adding a native dependency.
+- **Go:** reuse current Gin/Gorm/Viper/Zap choices in services and the actual
+ module API in libraries. Do not introduce Makefiles or another check runner.
+- **Vite/Vitest/tsdown/pnpm:** use project-local versions and config; the root
+ workspace owns package-manager settings. Keep Oxlint/Oxfmt when present.
+ Do not install unrelated Vue/Nuxt/ESLint conventions from the same skill repo.
+
+Keep credentials used by One/Infisical separate from application account
+authentication. Business behavior is implemented only after the user requests
+that feature; the starter templates do not contain JWT, login, user CRUD, or default
+databases. Do not add these as prerequisite infrastructure.
+
+Use Axios as a thin HTTP client and SWR for remote data/cache ownership. Do not
+add token interceptors or duplicate SWR data in Zustand. Zustand is for shared
+UI state; use React state locally and create Next.js stores per Provider rather
+than as mutable server singletons. Expo's MMKV stores UI preferences, not a
+second remote cache. Retain the chosen dependencies when removing demo code.
diff --git a/packages/agent-skills/skills/one-electron/SKILL.md b/packages/agent-skills/skills/one-electron/SKILL.md
new file mode 100644
index 00000000..9ab53988
--- /dev/null
+++ b/packages/agent-skills/skills/one-electron/SKILL.md
@@ -0,0 +1,43 @@
+---
+name: one-electron
+description: Develop Electron applications generated by One CLI with separate renderer, main, and preload projects and Awilix function factories.
+---
+
+# One Electron
+
+Read the manifest group and all three package.json files before changing paths.
+For a project named `desktop`, the group contains:
+
+- `apps/desktop-renderer`: React, Vite, shadcn/Base UI, and Tailwind.
+- `services/desktop-main`: Electron lifecycle, native capabilities, Awilix,
+ tsdown, development launcher, and electron-builder packaging configuration.
+- `packages/desktop-preload`: the contextBridge API and its shared types/build.
+
+Use the user's actual project name in imports, package names, artifact paths,
+and group task references. Do not create a nested One workspace or move these
+projects under the main service. Discover tasks with `one run`; start a group
+with `one run :dev`.
+
+Keep main-process services as factory functions with explicit dependencies.
+Register them using the existing Awilix container and lifetime conventions;
+do not introduce decorators, class-based Inversify bindings, or a second DI
+container. Keep cleanup and window lifecycle ownership explicit.
+
+The renderer uses the typed preload API. Expose narrow contextBridge methods;
+keep Node access, filesystem operations, and privileged work in main.
+Update main handlers, preload contracts, and renderer callers together when
+changing IPC. Validate incoming arguments, sender window, main frame, and trusted URL in
+the privileged process. The starter exposes only `getAppInfo()`; add narrow
+methods when the user requests privileged features, never raw invoke/on APIs.
+
+Read existing dev/build scripts before changing startup dependencies. Preserve
+renderer/preload build ordering, relative production paths, and packaged assets.
+Production uses `loadFile`, relative Vite assets, and HashRouter. Keep local
+packaging separate from publishing; automatic updates require explicit setup.
+On Ubuntu, use the launcher's sandbox/AppArmor checks and desktop-session
+detection; do not apply Ubuntu setup on every Linux distribution or disable
+the sandbox as a default fix. Headless execution needs an intentional test setup.
+
+Verify affected process builds and meaningful IPC/lifecycle behavior through
+One tasks. Keep application account credentials separate from One CLI login;
+business skills and business features require the user's explicit request.
diff --git a/packages/agent-skills/skills/one-electron/agents/openai.yaml b/packages/agent-skills/skills/one-electron/agents/openai.yaml
new file mode 100644
index 00000000..26db5ea4
--- /dev/null
+++ b/packages/agent-skills/skills/one-electron/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Electron"
+ short_description: "Develop the three-project One Electron template"
diff --git a/packages/agent-skills/skills/one-expo/SKILL.md b/packages/agent-skills/skills/one-expo/SKILL.md
new file mode 100644
index 00000000..613ed15e
--- /dev/null
+++ b/packages/agent-skills/skills/one-expo/SKILL.md
@@ -0,0 +1,50 @@
+---
+name: one-expo
+description: Develop Expo mobile projects generated by One CLI with Expo Router, native styles, Axios/SWR, Zustand/MMKV, and native development clients.
+---
+
+# One Expo
+
+Read package.json, app.json, src/app/_layout.tsx, the One manifest, and the project
+README. The mobile app lives under apps/. Discover its tasks with
+`one run` and use One for development, checks, and builds. Follow the installed
+Expo SDK and user choices rather than version assumptions in upstream examples.
+
+The starter uses React Native styles, Expo Router, Axios, SWR, Zustand, MMKV,
+Nitro native modules, and an Expo development client. Its home, not-found screen,
+and theme switching are the starting surface. Do not introduce NativeWind or
+Tailwind, auth/token storage, user screens, or a new remote-data stack as default
+infrastructure. Business features and their skills require the user's request.
+
+Keep route entries in src/app/ and reusable components/hooks outside the route
+tree. Extend the existing Stack only when navigation needs another screen;
+add tabs or animation/gesture dependencies when the feature warrants them.
+Preserve safe-area handling, status bar appearance, and the system theme default.
+Respect light/dark/system preferences without forcing the device's appearance.
+
+Axios is a thin client using the public EXPO_PUBLIC_API_URL. Public Expo
+variables are bundled into the app and must not contain secrets. Let One inject
+build/runtime variables; .env.example describes keys rather than adding a second
+configuration loader. Do not copy token refresh or business-code interceptors
+from an upstream data-fetching example.
+
+SWR owns remote data and caching; use its scoped Map provider to initialize
+native focus/reconnect hooks. Keep initFocus and initReconnect cleanup functions.
+Online/visibility reads must be synchronous and must not register listeners.
+Maintain online state through the existing NetInfo listener, and revalidate on
+an offline-to-online or background-to-active transition. Do not add a second
+persisted remote cache or listeners inside every isOnline call. Web should use
+SWR's normal browser event handling rather than native lifecycle hooks.
+
+Zustand owns shared UI preferences; local component state stays in React and
+remote data stays in SWR. Persist preferences through the existing MMKV adapter.
+MMKV/Nitro need a native development client; Expo Go cannot validate them. Keep
+native and web behavior compatible, and avoid native-only side effects during
+web prerendering. Do not add AsyncStorage or SecureStore merely to duplicate the
+existing UI preference storage.
+
+Check SDK dependency compatibility with the installed Expo CLI's `install --check`
+through a project task. Verify relevant typechecks, tests, and web export; use a
+native development build for native changes. Listener tests should cover state
+transitions and cleanup. State clearly which platforms/devices were exercised;
+a passing web export does not prove iOS/Android runtime compatibility.
diff --git a/packages/agent-skills/skills/one-expo/agents/openai.yaml b/packages/agent-skills/skills/one-expo/agents/openai.yaml
new file mode 100644
index 00000000..0918abdf
--- /dev/null
+++ b/packages/agent-skills/skills/one-expo/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Expo"
+ short_description: "Develop One Expo apps with native styles and MMKV"
diff --git a/packages/agent-skills/skills/one-fumadocs/SKILL.md b/packages/agent-skills/skills/one-fumadocs/SKILL.md
new file mode 100644
index 00000000..b2b859ae
--- /dev/null
+++ b/packages/agent-skills/skills/one-fumadocs/SKILL.md
@@ -0,0 +1,29 @@
+---
+name: one-fumadocs
+description: Maintain Next.js and Fumadocs documentation projects in One workspaces, including MDX sources, localized navigation, static export, and search.
+---
+
+# One Fumadocs
+
+Inspect the project's package.json, source.config, content tree, app routes,
+and Next configuration. Resolve Next documentation from that project's
+installed version. Fumadocs Core, MDX, and UI can have different version lines.
+
+The One docs template uses `fumadocs-ui` aliased to `@fumadocs/base-ui`.
+Existing workspaces can use the regular UI package. Follow the installed
+provider, import paths, and components.json; do not switch UI primitives
+while editing content.
+
+Keep content and navigation synchronized in English and Chinese unless the
+user scopes the change to a language. Match each language's metadata, links,
+and routes. Use maintained source loaders and generated MDX types rather than
+building a second content pipeline or checking generated output into Git.
+
+For static-export projects, retain `output: 'export'`, generate required
+localized route parameters, and build static search data. Do not introduce
+a runtime search API or server-only feature that cannot work in the exported
+site. Resolve links and search URLs with the actual base path and locale.
+
+Run the project's checks/build using One tasks. Verify an exported page and
+the generated search data for both languages when routing/search changes.
+Business skills remain opt-in and live in separate repositories.
diff --git a/packages/agent-skills/skills/one-fumadocs/agents/openai.yaml b/packages/agent-skills/skills/one-fumadocs/agents/openai.yaml
new file mode 100644
index 00000000..7bee5392
--- /dev/null
+++ b/packages/agent-skills/skills/one-fumadocs/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Fumadocs"
+ short_description: "Maintain Next.js and Fumadocs documentation projects"
diff --git a/packages/agent-skills/skills/one-go/SKILL.md b/packages/agent-skills/skills/one-go/SKILL.md
new file mode 100644
index 00000000..0a2b6c3e
--- /dev/null
+++ b/packages/agent-skills/skills/one-go/SKILL.md
@@ -0,0 +1,52 @@
+---
+name: one-go
+description: Develop Go API and library projects in One CLI workspaces, applying service-specific Gin/Gorm/Viper/Zap conventions and deliberate public library APIs.
+---
+
+# One Go
+
+Read go.mod, Taskfile.yml, the One manifest, and the target project's entry points.
+Discover tasks with `one run` and use the project's One tasks for checks, builds,
+and tests. Respect the actual module identity and existing stack; an arbitrary
+Go project or an empty template does not need to adopt Gin or Gorm.
+
+## API services
+
+The generated go-api lives under services/, with cmd/server for the
+binary and internal/ for private implementation. It uses Gin, Gorm, Viper, Zap,
+and OpenAPI. Its initial routes are application information, database-independent
+health, and documentation. It has no JWT, login, user model, CRUD, database driver,
+default connection, or migration. Add domain packages only for requested behavior;
+small handlers need not grow a transport/service/repository hierarchy.
+
+Use functions and constructors with explicit dependencies. Keep assembly in
+internal/app and HTTP setup in internal/http; place persistence behind the
+business code that actually needs it. Avoid package globals for mutable service
+resources or a DI container that obscures resource ownership.
+
+Viper reads One-injected environment variables and optional project config.
+Preserve its existing nested-key mapping; do not add an independent dotenv
+loader or generate config files containing secrets. Keep Zap logging, request
+IDs, panic recovery, exact CORS origin matching, and graceful HTTP shutdown.
+Update api/openapi.yaml when real routes change.
+
+Gorm is retained without a driver. When the user requests persistence, install
+the driver for the selected database and pass its dialector to the existing
+internal/platform/database.Open entry point. Own and close the resulting
+connection explicitly, then inject it into the feature that needs it. Run
+migrations as explicit tasks. Do not open an implicit in-memory database or
+silently fall back when a configured connection fails.
+
+## Libraries
+
+The generated go-lib lives under packages/. Keep public exports small,
+with behavior tests near their package, and retain the project's module/import
+identity. Its pkg/ layout is an existing convention, not a requirement for every
+Go library. Do not introduce service dependencies, environment loading, Gin, or
+Gorm into a library that does not use them.
+
+For service routes, use httptest to verify externally observable status/body and
+middleware behavior. For library APIs, use table-driven cases where inputs vary.
+Run existing formatting, vet, tests, and build tasks; use the project's race
+checks when changing concurrent ownership. Authentication and user CRUD belong
+to requested business work, and business skills remain manually installed.
diff --git a/packages/agent-skills/skills/one-go/agents/openai.yaml b/packages/agent-skills/skills/one-go/agents/openai.yaml
new file mode 100644
index 00000000..4aff3ec3
--- /dev/null
+++ b/packages/agent-skills/skills/one-go/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Go"
+ short_description: "Develop One Go services and public libraries"
diff --git a/packages/agent-skills/skills/one-nestjs/SKILL.md b/packages/agent-skills/skills/one-nestjs/SKILL.md
new file mode 100644
index 00000000..eb00740c
--- /dev/null
+++ b/packages/agent-skills/skills/one-nestjs/SKILL.md
@@ -0,0 +1,45 @@
+---
+name: one-nestjs
+description: Develop NestJS API services generated by One CLI, preserving Drizzle, injected configuration, validation, logging, and the minimal database-free starter.
+---
+
+# One NestJS
+
+Read the target project's package.json, src/app.module.ts, src/main.ts, README,
+and One manifest before changing the service. Use the user's project name under
+services/, discover tasks with `one run`, and run project tasks through One.
+The actual project and user choices take precedence over template defaults.
+
+The starter has application information, a database-independent liveness check,
+and Swagger documentation. NestJS, class-validator/class-transformer, Drizzle
+ORM/Kit, Pino, and Jest remain part of its stack. It has no JWT, login, user CRUD,
+database driver, connection, schema, seed, or automatic migration. Add business
+features when requested; upstream examples do not make them prerequisites.
+
+Keep the composition in AppModule and bootstrap in main.ts. Create a feature
+module when a domain warrants it, with DTOs and providers colocated with its
+behavior. Do not add empty controller/service/repository layers for a small
+route. Reuse the global ValidationPipe and ordinary Nest HTTP exceptions rather
+than introducing another business-code response envelope or token interceptor.
+
+Read runtime configuration from One-injected process environment through the
+existing ConfigModule. Preserve ignoreEnvFile; .env.example documents keys but
+is not another loading mechanism. Validate new required values when the related
+feature is enabled. Keep liveness available without persistence and distinguish
+it from readiness if a requested feature needs dependency checks.
+
+When persistence is requested, follow the user's selected database and install
+its Drizzle-compatible driver. Add schema, connection ownership, shutdown cleanup,
+and Drizzle Kit configuration for that database. Run migration/seed work as
+explicit tasks, separate from HTTP startup. Do not default to PostgreSQL, SQLite,
+or PGlite, or add a fallback database on connection failure.
+
+Keep Pino's existing basic logging and the OpenAPI document accurate for actual
+routes and DTOs. Do not add log rotation, authentication schemes, or response
+wrappers unless the requested behavior needs them. One CLI credentials are
+separate from application authentication; business skills are installed manually.
+
+Verify typecheck, build, and relevant Jest tests through the project's One tasks.
+Use HTTP tests for changed route behavior, including validation and status codes.
+The starter's health test must still pass without application auth or database
+settings; extend it only when the feature changes that contract.
diff --git a/packages/agent-skills/skills/one-nestjs/agents/openai.yaml b/packages/agent-skills/skills/one-nestjs/agents/openai.yaml
new file mode 100644
index 00000000..7230656f
--- /dev/null
+++ b/packages/agent-skills/skills/one-nestjs/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One NestJS"
+ short_description: "Develop One NestJS API templates with Drizzle"
diff --git a/packages/agent-skills/skills/one-web/SKILL.md b/packages/agent-skills/skills/one-web/SKILL.md
new file mode 100644
index 00000000..d47d5455
--- /dev/null
+++ b/packages/agent-skills/skills/one-web/SKILL.md
@@ -0,0 +1,39 @@
+---
+name: one-web
+description: Develop React SPA and Next.js App/Site projects in One CLI workspaces, including shared Web UI in Electron renderers and Fumadocs, with Base UI and clear data/state ownership.
+---
+
+# One Web
+
+Read the target package.json, components.json, build config, and One manifest.
+Use the user's project name under apps/; discover tasks with `one run` and verify
+through the project's One tasks. Actual dependencies and user choices override
+template defaults. This skill does not prescribe a stack for empty templates or
+React Native apps.
+
+Generated Web projects use shadcn Base UI, Tailwind CSS, semantic design tokens,
+and Base UI Toast. Inspect components.json before composing UI: Base UI uses
+`render`, and Radix `asChild` examples cannot be copied mechanically. Existing
+applications may use Radix; preserve their actual primitive library. Add only
+components the requested screen needs and preserve keyboard/focus behavior,
+accessible labels, and light/dark styling. Reuse the mounted Toaster and its
+manager; do not add Sonner, another notification layer, or a toast-history store.
+
+Where Axios, SWR, and Zustand are installed, retain their distinct roles:
+Axios handles HTTP and its fetcher returns response data; SWR owns remote data,
+cache, and revalidation; Zustand owns shared UI state. Use React for local state.
+Do not duplicate SWR results in Zustand or invent business-code/token wrappers.
+Do not add these dependencies to static content projects merely for consistency.
+Authentication, user models, and business APIs are requested features, not
+starter infrastructure; business skills are installed manually.
+
+Read only the reference for the current runtime:
+
+- [React SPA](references/react-spa.md): Vite, routing, client state, and renderer adaptations.
+- [Next.js](references/nextjs.md): server/client boundaries, per-Provider stores, and static export.
+
+Electron renderer changes must also respect the installed one-electron guidance
+and typed preload contract; privileged capabilities belong in main. Fumadocs
+content, navigation, MDX, and search changes use one-fumadocs guidance when
+installed. Framework-specific skills complement this skill rather than replacing
+the project's One conventions.
diff --git a/packages/agent-skills/skills/one-web/agents/openai.yaml b/packages/agent-skills/skills/one-web/agents/openai.yaml
new file mode 100644
index 00000000..81aa5afc
--- /dev/null
+++ b/packages/agent-skills/skills/one-web/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "One Web"
+ short_description: "Develop One React and Next.js application templates"
diff --git a/packages/agent-skills/skills/one-web/references/nextjs.md b/packages/agent-skills/skills/one-web/references/nextjs.md
new file mode 100644
index 00000000..775f6d5a
--- /dev/null
+++ b/packages/agent-skills/skills/one-web/references/nextjs.md
@@ -0,0 +1,41 @@
+# Next.js runtime and static export
+
+Resolve Next from the target subproject and read its installed documentation
+under node_modules/next/dist/docs when available. Read next.config and the actual
+route/layout structure before applying upstream patterns. In the templates,
+route entries are under src/app/; keep route-specific code colocated and shared
+UI outside the route tree.
+
+## Application runtime
+
+nextjs-app supports a server runtime. Keep server data access and private values
+in server code; NEXT_PUBLIC_API_URL is public and only configures the browser's
+Axios client. Add client boundaries only where hooks, browser APIs, or user
+interaction need them. Shared UI stores must be created per Provider rather
+than as mutable module singletons reused by server requests. Keep initial server
+and client state compatible during hydration. Use the existing next-themes
+provider instead of duplicating theme state in Zustand.
+
+Client SWR requests should use the configured fetcher and stable feature keys.
+Do not add mock route handlers or authentication APIs as scaffolding for an
+unrelated page. Server-only variables are not browser API configuration.
+
+## Static sites and documentation
+
+nextjs-site and fumadocs-docs use static export. Keep localized en/zh routes,
+HTML language, navigation, theme, and SEO metadata consistent. NEXT_PUBLIC_SITE_URL
+is public site metadata configuration. Server components can run at build time;
+request-time cookies, server actions, runtime API handlers, and private dynamic
+data need a deliberately chosen server deployment rather than an incidental
+change to the output mode.
+
+Fumadocs' api/search route generates the static search index during the build;
+preserve it. It is part of the exported site's core behavior, even though it is
+located under an API route directory. Content/search work follows the installed
+one-fumadocs guidance.
+
+Run typecheck, lint/format, and production build tasks. Check exported localized
+pages and search after static content changes. When both Expo and Next projects
+share a workspace, use explicit React type imports in reusable Web components
+and verify against the target project's React types rather than a global React
+namespace that may resolve to Expo's version.
diff --git a/packages/agent-skills/skills/one-web/references/react-spa.md b/packages/agent-skills/skills/one-web/references/react-spa.md
new file mode 100644
index 00000000..1a3fcb84
--- /dev/null
+++ b/packages/agent-skills/skills/one-web/references/react-spa.md
@@ -0,0 +1,28 @@
+# React SPA and renderer runtime
+
+The react-spa template uses Vite, React Router, an Axios fetcher, SWRConfig,
+a Zustand UI theme store, semantic CSS tokens, and Base UI Toast. Keep routing
+in the existing composition and add features when real domains warrant them;
+a small screen does not need an empty architectural tree.
+
+Read Vite's public environment prefix and base path from the target config.
+Use only public values on the client; React SPA's optional API URL is VITE_API_URL.
+Keep the workspace package manager and existing Oxlint/Oxfmt configuration.
+After changing scripts, synchronize One task definitions when required.
+
+Use the existing shared UI theme store and provider, keep persisted preferences
+small, and avoid persisting server responses or introducing an auth store by
+copying a starter from another project. Keep error and not-found states usable
+with accessible recovery actions.
+
+Electron renderer uses its own Vite APP environment prefix, relative assets,
+and HashRouter for file-based production loading. Do not switch to BrowserRouter,
+absolute asset URLs, or a custom protocol just because the browser template uses
+them. Read native application information through window.electron.getAppInfo();
+keep Node/filesystem access outside the renderer and update preload types with
+main handlers when a requested feature needs another narrow method.
+
+Run the target project's typecheck, lint/format, and production build tasks.
+For Electron renderer changes, also verify the preload/main build dependencies
+and assembled production resources; Vite development success alone does not
+verify the packaged application.
diff --git a/packages/cli/internal/bootstrap/cli/dependencies.go b/packages/cli/internal/bootstrap/cli/dependencies.go
index 6ace0996..568c5bf1 100644
--- a/packages/cli/internal/bootstrap/cli/dependencies.go
+++ b/packages/cli/internal/bootstrap/cli/dependencies.go
@@ -12,7 +12,9 @@ import (
catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend"
creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation"
environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment"
+ skillsmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills"
tasksmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/tasks"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/prompt"
runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime"
"github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets"
)
@@ -97,6 +99,7 @@ func mustCreationService(
panic(err)
}
service.Runtime = miseruntime.Provider{}
+ service.Skills = skillsmodule.Installer{Run: (skillsmodule.Runner{Runtime: service.Runtime}).Run, Progress: prompt.ReportProgress}
return service
}
diff --git a/packages/cli/internal/bootstrap/cli/root.go b/packages/cli/internal/bootstrap/cli/root.go
index 1ec943d8..180a202f 100644
--- a/packages/cli/internal/bootstrap/cli/root.go
+++ b/packages/cli/internal/bootstrap/cli/root.go
@@ -21,6 +21,7 @@ import (
"github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution"
"github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace"
+ skillsmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills"
"github.com/torchstellar-team/one-cli/packages/cli/internal/modules/tasks"
cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors"
"github.com/torchstellar-team/one-cli/packages/cli/internal/platform/helpui"
@@ -40,6 +41,7 @@ import (
misecmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/mise"
runcmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/run"
servecmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/serve"
+ skillscmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/skills"
templatescmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/templates"
upgradecmd "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/cobra/upgrade"
)
@@ -57,6 +59,7 @@ func newRootCommand() *cobra.Command {
{localecmd.Command(), initcmd.Command(), upgradecmd.Command()},
createcmd.Commands(createcmd.Dependencies{Creation: deps.creation}),
misecmd.RuntimeCommands(deps.runtime),
+ skillscmd.Commands((skillsmodule.Runner{Runtime: deps.runtime}).Run),
hookscmd.Commands(deps.runtime),
envcmd.Commands(envcmd.Dependencies{Service: deps.environments}),
execcmd.Commands(deps.loaders, deps.runtime),
@@ -226,7 +229,7 @@ func Execute(version string, args []string) (resultErr error) {
// Execution leaves and previews must not start another background network check.
func shouldCheckUpdates(args []string) bool {
- if first, _ := firstPositional(args); strings.HasPrefix(first, "__") || first == "mise" || first == "hk" || first == "upgrade" {
+ if first, _ := firstPositional(args); strings.HasPrefix(first, "__") || first == "mise" || first == "hk" || first == "skills" || first == "upgrade" {
return false
}
for _, arg := range args {
@@ -379,6 +382,9 @@ func scanOutputValue(args []string) string {
if a == "--" {
break
}
+ if a == "skills" {
+ break // All following flags belong to the upstream Skills CLI.
+ }
switch {
case a == "-o" || a == "--output":
if i+1 < len(args) {
diff --git a/packages/cli/internal/bootstrap/cli/root_test.go b/packages/cli/internal/bootstrap/cli/root_test.go
index 98a0a8bd..6b66363f 100644
--- a/packages/cli/internal/bootstrap/cli/root_test.go
+++ b/packages/cli/internal/bootstrap/cli/root_test.go
@@ -26,6 +26,8 @@ func TestScanOutputValue(t *testing.T) {
{"first wins", []string{"-o", "json", "--output", "text"}, "json"},
{"flag mid-args", []string{"add", "nestjs-api", "-o", "json", "--name", "x"}, "json"},
{"unknown flag without value", []string{"-x", "templates"}, ""},
+ {"skills flags belong upstream", []string{"skills", "find", "x", "--output", "json"}, ""},
+ {"One output before skills", []string{"-o", "text", "skills", "list", "--output", "json"}, "text"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -161,7 +163,7 @@ func TestIsKnownSubcommand(t *testing.T) {
// Every command is assembled by the composition root.
for _, name := range []string{
"create", "templates", "add", "exec", "run",
- "env", "serve", "mise", "hk",
+ "env", "serve", "mise", "hk", "skills",
"login", "whoami", "logout", "locale", "init", "upgrade",
} {
if !isKnownSubcommand(name) {
@@ -173,7 +175,7 @@ func TestIsKnownSubcommand(t *testing.T) {
"dev", "build", "test", "lint", "update",
"doctor", "status", "unknown", "secrets", "skill", "prd", "design",
"docker", "infisical", "dotenv", "procs", "compose", "k8s",
- "plugins", "setup", "profile", "container", "deploy", "ci", "skills",
+ "plugins", "setup", "profile", "container", "deploy", "ci",
"",
} {
if isKnownSubcommand(name) {
diff --git a/packages/cli/internal/core/template/compat.go b/packages/cli/internal/core/template/compat.go
index d86dfb01..6618d242 100644
--- a/packages/cli/internal/core/template/compat.go
+++ b/packages/cli/internal/core/template/compat.go
@@ -8,7 +8,7 @@ package template
// Caller: `one add ` — after rendering the subproject, compare its
// `compat` map against the existing workspace selection. Surface any
// mismatch so the user knows e.g. "kustomize won't help your
-// starlight-docs subproject deploy".
+// fumadocs-docs subproject deploy".
//
// Semantics of compat[domain]:
//
diff --git a/packages/cli/internal/core/template/compat_test.go b/packages/cli/internal/core/template/compat_test.go
index 95d1864a..52d2e3bc 100644
--- a/packages/cli/internal/core/template/compat_test.go
+++ b/packages/cli/internal/core/template/compat_test.go
@@ -25,7 +25,7 @@ func TestCheckAllowedBackends_EmptySelection(t *testing.T) {
func TestCheckAllowedBackends_OptedOut(t *testing.T) {
tpl := Template{
- ID: "starlight-docs",
+ ID: "fumadocs-docs",
Compat: map[string][]string{"deploy": {"vercel", "s3"}},
}
// User explicitly skipped deploy at create time → "deploy" is not
@@ -62,7 +62,7 @@ func TestCheckAllowedBackends_Compatible(t *testing.T) {
func TestCheckAllowedBackends_Incompatible(t *testing.T) {
tpl := Template{
- ID: "starlight-docs",
+ ID: "fumadocs-docs",
Compat: map[string][]string{"deploy": {"vercel", "s3"}},
}
got := CheckAllowedBackends(tpl, map[string]string{"deploy": "deploy/k8s"}, "")
@@ -70,7 +70,7 @@ func TestCheckAllowedBackends_Incompatible(t *testing.T) {
t.Fatalf("expected 1 warning, got %d (%v)", len(got), got)
}
w := got[0]
- if w.Domain != "deploy" || w.SelectedID != "deploy/k8s" || w.TemplateID != "starlight-docs" {
+ if w.Domain != "deploy" || w.SelectedID != "deploy/k8s" || w.TemplateID != "fumadocs-docs" {
t.Errorf("warning fields wrong: %+v", w)
}
if w.SubprojectName != "" {
@@ -84,7 +84,7 @@ func TestCheckAllowedBackends_Incompatible(t *testing.T) {
func TestCheckAllowedBackends_WithSubprojectName(t *testing.T) {
tpl := Template{
- ID: "starlight-docs",
+ ID: "fumadocs-docs",
Compat: map[string][]string{"deploy": {"vercel"}},
}
got := CheckAllowedBackends(tpl, map[string]string{"deploy": "deploy/k8s"}, "my-docs")
diff --git a/packages/cli/internal/core/template/electron_template_test.go b/packages/cli/internal/core/template/electron_template_test.go
index 8e317d91..d78b2d13 100644
--- a/packages/cli/internal/core/template/electron_template_test.go
+++ b/packages/cli/internal/core/template/electron_template_test.go
@@ -15,10 +15,22 @@ func TestElectronSandboxProfileUsesInstalledBinaryWithoutDisablingSandbox(t *tes
t.Skip("node is not installed")
}
root := t.TempDir()
- if err := Render("electron-app", root, CommonVariables("desktop", "pnpm")); err != nil {
+ outputs, err := PrepareProjects("electron-app", CommonVariables("desktop", "pnpm"))
+ if err != nil {
t.Fatal(err)
}
- project := filepath.Join(root, "apps/electron")
+ for _, output := range outputs {
+ for name, raw := range output.Files {
+ file := filepath.Join(root, output.Name, filepath.FromSlash(name))
+ if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(file, raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ project := filepath.Join(root, "desktop-main")
module := filepath.Join(project, "node_modules/electron")
if err := os.MkdirAll(module, 0o755); err != nil {
t.Fatal(err)
@@ -28,7 +40,7 @@ func TestElectronSandboxProfileUsesInstalledBinaryWithoutDisablingSandbox(t *tes
if err := os.WriteFile(filepath.Join(module, "index.js"), []byte("module.exports = "+string(value)), 0o644); err != nil {
t.Fatal(err)
}
- cmd := exec.Command(node, "script/sandbox-profile.mjs")
+ cmd := exec.Command(node, "scripts/sandbox-profile.mjs")
cmd.Dir = project
out, err := cmd.CombinedOutput()
if err != nil {
@@ -43,7 +55,7 @@ func TestElectronSandboxProfileUsesInstalledBinaryWithoutDisablingSandbox(t *tes
t.Fatalf("profile contains shell commands: %s", out)
}
// Production/default development keeps Chromium's sandbox enabled.
- for _, rel := range []string{"package.json", "apps/electron/package.json", "apps/electron/src/index.ts", "apps/electron/src/windows/main.window.ts"} {
+ for _, rel := range []string{"desktop-main/package.json", "desktop-main/src/index.ts", "desktop-main/src/windows/main.window.ts"} {
raw, err := os.ReadFile(filepath.Join(root, rel))
if err != nil {
t.Fatal(err)
diff --git a/packages/cli/internal/core/template/node.go b/packages/cli/internal/core/template/node.go
index c0de8d62..8895b612 100644
--- a/packages/cli/internal/core/template/node.go
+++ b/packages/cli/internal/core/template/node.go
@@ -93,6 +93,10 @@ func fsPathPattern(p string) bool {
}
func rewriteNode(files map[string][]byte, spec NodeSpec, vars Variables) error {
+ return rewriteNodeProjects(files, spec, vars, nil)
+}
+
+func rewriteNodeProjects(files map[string][]byte, spec NodeSpec, vars Variables, projects []ProjectSpec) error {
targetScope := "@" + vars["projectNameKebabCase"]
if !scopeRE.MatchString(targetScope) {
return i18n.Errorf("template.spec_invalid", "projectNameKebabCase")
@@ -119,6 +123,11 @@ func rewriteNode(files map[string][]byte, spec NodeSpec, vars Variables) error {
return i18n.Errorf("template.spec_invalid", file+": name")
}
target = targetScope + strings.TrimPrefix(pkg.Name, spec.Scope)
+ for _, project := range projects {
+ if file == path.Join(project.Source, "package.json") {
+ target = vars["projectNameKebabCase"] + project.Suffix
+ }
+ }
}
names[pkg.Name] = target
}
@@ -186,7 +195,12 @@ func rewriteNode(files map[string][]byte, spec NodeSpec, vars Variables) error {
if !bytes.Contains(raw, []byte(from)) {
return i18n.Errorf("template.text_matches", file, from, 1, 0)
}
- files[file] = bytes.ReplaceAll(raw, []byte(from), []byte(targetScope+"/"))
+ files[file] = []byte(packageTokenRE.ReplaceAllStringFunc(string(raw), func(token string) string {
+ if target, ok := names[token]; ok {
+ return target
+ }
+ return token
+ }))
}
return nil
}
diff --git a/packages/cli/internal/core/template/projects.go b/packages/cli/internal/core/template/projects.go
new file mode 100644
index 00000000..49c3e8f9
--- /dev/null
+++ b/packages/cli/internal/core/template/projects.go
@@ -0,0 +1,81 @@
+package template
+
+import (
+ "io/fs"
+ "path"
+ "regexp"
+ "strings"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled"
+)
+
+var projectSuffixRE = regexp.MustCompile(`^-[a-z][a-z0-9-]*$`)
+
+type ProjectOutput struct {
+ Name string
+ Category Category
+ Files map[string][]byte
+}
+
+func templateSource(id string) (fs.FS, error) {
+ if err := validatePaths([]string{id}); err != nil {
+ return nil, err
+ }
+ return fs.Sub(bundled.TemplatesFS, path.Join(bundled.TemplatesRoot, id))
+}
+
+// ProjectLayouts exposes only the declarative component locations, without rendering.
+func ProjectLayouts(id string) ([]ProjectSpec, error) {
+ source, err := templateSource(id)
+ if err != nil {
+ return nil, err
+ }
+ spec, err := readSpec(source)
+ return spec.Projects, err
+}
+
+// PrepareProjects returns nil for ordinary starters. Composite starters are fully
+// validated and rendered in memory before creation writes any workspace files.
+func PrepareProjects(id string, vars Variables) ([]ProjectOutput, error) {
+ source, err := templateSource(id)
+ if err != nil {
+ return nil, err
+ }
+ spec, err := readSpec(source)
+ if err != nil || len(spec.Projects) == 0 {
+ return nil, err
+ }
+ files, err := prepare(source, vars)
+ if err != nil {
+ return nil, i18n.Errorf("template.prepare_failed", id, err)
+ }
+ return splitProjects(files, spec, vars)
+}
+
+func splitProjects(files map[string][]byte, spec Spec, vars Variables) ([]ProjectOutput, error) {
+ outputs := make([]ProjectOutput, 0, len(spec.Projects))
+ for _, project := range spec.Projects {
+ output := ProjectOutput{Name: vars["projectName"] + project.Suffix, Category: project.Category, Files: map[string][]byte{}}
+ for name, raw := range files {
+ if relative, ok := strings.CutPrefix(name, project.Source+"/"); ok {
+ output.Files[relative] = raw
+ }
+ }
+ if len(output.Files) == 0 {
+ return nil, i18n.Errorf("template.file_missing", project.Source)
+ }
+ for _, name := range spec.SharedFiles {
+ raw, ok := files[name]
+ if !ok {
+ return nil, i18n.Errorf("template.file_missing", name)
+ }
+ if _, exists := output.Files[name]; exists {
+ return nil, i18n.Errorf("template.file_collision", name)
+ }
+ output.Files[name] = raw
+ }
+ outputs = append(outputs, output)
+ }
+ return outputs, nil
+}
diff --git a/packages/cli/internal/core/template/projects_test.go b/packages/cli/internal/core/template/projects_test.go
new file mode 100644
index 00000000..27456df9
--- /dev/null
+++ b/packages/cli/internal/core/template/projects_test.go
@@ -0,0 +1,45 @@
+package template
+
+import (
+ "encoding/json"
+ "testing"
+ "testing/fstest"
+)
+
+func TestCompositeDescriptorRejectsUnsafeAndAmbiguousLocations(t *testing.T) {
+ base := ProjectSpec{Source: "apps/renderer", Suffix: "-renderer", Category: CategoryFrontend}
+ for _, test := range []struct {
+ name string
+ projects []ProjectSpec
+ shared []string
+ }{
+ {"traversal", []ProjectSpec{{Source: "../outside", Suffix: "-main", Category: CategoryBackend}}, nil},
+ {"duplicate source", []ProjectSpec{base, {Source: base.Source, Suffix: "-main", Category: CategoryBackend}}, nil},
+ {"overlap", []ProjectSpec{base, {Source: base.Source + "/nested", Suffix: "-main", Category: CategoryBackend}}, nil},
+ {"duplicate suffix", []ProjectSpec{base, {Source: "services/main", Suffix: base.Suffix, Category: CategoryBackend}}, nil},
+ {"invalid suffix", []ProjectSpec{{Source: "apps/renderer", Suffix: "/renderer", Category: CategoryFrontend}}, nil},
+ {"invalid category", []ProjectSpec{{Source: "apps/renderer", Suffix: "-renderer", Category: Category("desktop")}}, nil},
+ {"shared traversal", []ProjectSpec{base}, []string{"../README.md"}},
+ {"shared without projects", nil, []string{"README.md"}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ raw, _ := json.Marshal(Spec{SchemaVersion: 1, Projects: test.projects, SharedFiles: test.shared})
+ if _, err := readSpec(fstest.MapFS{"template.json": {Data: raw}}); err == nil {
+ t.Fatal("accepted invalid composite")
+ }
+ })
+ }
+}
+
+func TestCompositeSplitFailsOnMissingSourcesAndSharedCollisions(t *testing.T) {
+ spec := Spec{Projects: []ProjectSpec{{Source: "apps/renderer", Suffix: "-renderer", Category: CategoryFrontend}}, SharedFiles: []string{"README.md"}}
+ for _, files := range []map[string][]byte{
+ {"README.md": []byte("shared")},
+ {"apps/renderer/package.json": []byte(`{}`)},
+ {"README.md": []byte("shared"), "apps/renderer/README.md": []byte("existing")},
+ } {
+ if _, err := splitProjects(files, spec, CommonVariables("desktop", "pnpm")); err == nil {
+ t.Fatal("accepted incomplete or conflicting component")
+ }
+ }
+}
diff --git a/packages/cli/internal/core/template/render.go b/packages/cli/internal/core/template/render.go
index 733ba2f0..430605c5 100644
--- a/packages/cli/internal/core/template/render.go
+++ b/packages/cli/internal/core/template/render.go
@@ -120,7 +120,7 @@ func prepare(source fs.FS, vars Variables) (map[string][]byte, error) {
}
}
if spec.Node != nil {
- if err := rewriteNode(files, *spec.Node, vars); err != nil {
+ if err := rewriteNodeProjects(files, *spec.Node, vars, spec.Projects); err != nil {
return nil, err
}
}
diff --git a/packages/cli/internal/core/template/resolve.go b/packages/cli/internal/core/template/resolve.go
new file mode 100644
index 00000000..3da63931
--- /dev/null
+++ b/packages/cli/internal/core/template/resolve.go
@@ -0,0 +1,30 @@
+package template
+
+import (
+ cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+// Resolve keeps retired built-in IDs actionable without silently selecting a
+// different framework. An explicit registry entry always takes precedence.
+func (r *Registry) Resolve(id string) (*Template, error) {
+ ids := make([]string, 0, len(r.Templates))
+ for i := range r.Templates {
+ entry := &r.Templates[i]
+ if entry.ID == id {
+ return entry, nil
+ }
+ ids = append(ids, entry.ID)
+ }
+ replacement := map[string]string{"astro-site": "nextjs-site", "starlight-docs": "fumadocs-docs"}[id]
+ context := map[string]any{"requested_template": id, "available_templates": ids}
+ message := i18n.Tf("add.template_missing", id)
+ for _, entry := range r.Templates {
+ if replacement != "" && entry.ID == replacement {
+ context["replacement_template"] = replacement
+ message = i18n.Tf("template.retired", id, replacement)
+ break
+ }
+ }
+ return nil, cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND, message).WithContext(context)
+}
diff --git a/packages/cli/internal/core/template/resolve_test.go b/packages/cli/internal/core/template/resolve_test.go
new file mode 100644
index 00000000..bb0a6ab0
--- /dev/null
+++ b/packages/cli/internal/core/template/resolve_test.go
@@ -0,0 +1,31 @@
+package template
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+func TestResolveRetiredTemplates(t *testing.T) {
+ original := i18n.Active()
+ t.Cleanup(func() { _ = i18n.Init(original) })
+ r := &Registry{Templates: []Template{{ID: "nextjs-site"}, {ID: "fumadocs-docs"}}}
+ for _, locale := range []string{"zh-CN", "en-US"} {
+ _ = i18n.Init(locale)
+ for old, replacement := range map[string]string{"astro-site": "nextjs-site", "starlight-docs": "fumadocs-docs"} {
+ entry, err := r.Resolve(old)
+ if entry != nil || err == nil || !strings.Contains(err.Error(), i18n.Tf("template.retired", old, replacement)) {
+ t.Fatalf("Resolve(%s) = %v, %v", old, entry, err)
+ }
+ }
+ }
+ // External registries may still provide their own entry under the old ID.
+ r.Templates = append(r.Templates, Template{ID: "astro-site", Repo: "https://example.com/astro"})
+ if entry, err := r.Resolve("astro-site"); err != nil || entry.Repo != "https://example.com/astro" {
+ t.Fatalf("explicit entry was overridden: %v, %v", entry, err)
+ }
+ if _, err := r.Resolve("unknown"); err == nil || strings.Contains(err.Error(), "nextjs-site") {
+ t.Fatalf("unknown ID should retain ordinary missing-template error: %v", err)
+ }
+}
diff --git a/packages/cli/internal/core/template/spec.go b/packages/cli/internal/core/template/spec.go
index 6f57b32f..e50e67db 100644
--- a/packages/cli/internal/core/template/spec.go
+++ b/packages/cli/internal/core/template/spec.go
@@ -14,11 +14,20 @@ import (
// Spec describes a runnable starter. No scripts or expressions are evaluated.
type Spec struct {
- SchemaVersion int `json:"schemaVersion"`
- Go *GoSpec `json:"go,omitempty"`
- Node *NodeSpec `json:"node,omitempty"`
- Text []TextRule `json:"text,omitempty"`
- Exclude []string `json:"exclude,omitempty"`
+ SchemaVersion int `json:"schemaVersion"`
+ Go *GoSpec `json:"go,omitempty"`
+ Node *NodeSpec `json:"node,omitempty"`
+ Text []TextRule `json:"text,omitempty"`
+ Exclude []string `json:"exclude,omitempty"`
+ Projects []ProjectSpec `json:"projects,omitempty"`
+ SharedFiles []string `json:"sharedFiles,omitempty"`
+}
+
+// ProjectSpec maps a component of a composite starter to a top-level One project.
+type ProjectSpec struct {
+ Source string `json:"source"`
+ Suffix string `json:"suffix"`
+ Category Category `json:"category"`
}
type GoSpec struct {
ModulePrefix string `json:"modulePrefix"`
@@ -74,6 +83,33 @@ func readSpec(source fs.FS) (Spec, error) {
if err := validatePaths(spec.Exclude); err != nil {
return spec, err
}
+ if err := validatePaths(spec.SharedFiles); err != nil {
+ return spec, err
+ }
+ if len(spec.SharedFiles) > 0 && len(spec.Projects) == 0 {
+ return spec, i18n.Errorf("template.spec_invalid", "sharedFiles")
+ }
+ seenSources, seenSuffixes := map[string]bool{}, map[string]bool{}
+ for _, project := range spec.Projects {
+ if err := validatePaths([]string{project.Source}); err != nil {
+ return spec, err
+ }
+ if !projectSuffixRE.MatchString(project.Suffix) || seenSources[project.Source] || seenSuffixes[project.Suffix] {
+ return spec, i18n.Errorf("template.spec_invalid", "projects")
+ }
+ if _, ok := validCategories[project.Category]; !ok {
+ return spec, i18n.Errorf("template.spec_invalid", "projects.category")
+ }
+ for source := range seenSources {
+ if strings.HasPrefix(source, project.Source+"/") || strings.HasPrefix(project.Source, source+"/") {
+ return spec, i18n.Errorf("template.spec_invalid", "projects.source")
+ }
+ }
+ seenSources[project.Source], seenSuffixes[project.Suffix] = true, true
+ }
+ if len(spec.Projects) > 0 && spec.Go != nil {
+ return spec, i18n.Errorf("template.spec_invalid", "projects/go")
+ }
for _, rule := range spec.Text {
if rule.From == "" || len(rule.Files) == 0 || (rule.Value != "projectName" && rule.Value != "projectNameKebabCase") || (rule.MinMatches != nil && *rule.MinMatches < 1) {
return spec, i18n.Errorf("template.spec_invalid", "text")
diff --git a/packages/cli/internal/core/workspace/groups_test.go b/packages/cli/internal/core/workspace/groups_test.go
new file mode 100644
index 00000000..e9322f09
--- /dev/null
+++ b/packages/cli/internal/core/workspace/groups_test.go
@@ -0,0 +1,46 @@
+package workspace
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestManifestGroupsRoundTripAndPreserveComments(t *testing.T) {
+ raw := []byte("version = 2\n# keep workspace notes\n[projects.desktop-renderer]\npath='apps/desktop-renderer'\ntoolchain='node'\n[projects.desktop-main]\npath='services/desktop-main'\ntoolchain='node'\n[groups.desktop]\nprojects=['desktop-renderer','desktop-main']\n")
+ m, err := ParseManifest(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(m.Groups) != 1 || m.Groups[0].Name != "desktop" || len(m.Groups[0].Projects) != 2 {
+ t.Fatalf("groups: %#v", m.Groups)
+ }
+ after, err := MarshalManifest(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Contains(after, []byte("# keep workspace notes")) {
+ t.Fatal("lost comments")
+ }
+ again, err := ParseManifest(after)
+ if err != nil || len(again.Groups) != 1 {
+ t.Fatalf("round trip: %s %v", after, err)
+ }
+}
+
+func TestManifestRejectsInvalidGroups(t *testing.T) {
+ project := "version=2\n[projects.web]\npath='apps/web'\ntoolchain='node'\n"
+ for _, group := range []string{"[groups.web]\nprojects=['web']", "[groups.desktop]\nprojects=[]", "[groups.desktop]\nprojects=['missing']", "[groups.desktop]\nprojects=['web','web']", "[groups.'../desktop']\nprojects=['web']"} {
+ if _, err := ParseManifest([]byte(project + group + "\n")); err == nil {
+ t.Fatalf("accepted %s", group)
+ }
+ }
+ m, err := ParseManifest([]byte(project + "[groups.desktop]\nprojects=['web']\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.Projects = nil
+ if _, err = MarshalManifest(m); err == nil || !strings.Contains(err.Error(), "web") {
+ t.Fatalf("accepted orphan member: %v", err)
+ }
+}
diff --git a/packages/cli/internal/core/workspace/manifest.go b/packages/cli/internal/core/workspace/manifest.go
index de84e2b3..ab3826e1 100644
--- a/packages/cli/internal/core/workspace/manifest.go
+++ b/packages/cli/internal/core/workspace/manifest.go
@@ -34,6 +34,7 @@ type Manifest struct {
Workspace *ManifestWorkspace `json:"workspace,omitempty"`
Env *EnvironmentConfig `json:"env,omitempty"`
Projects []ManifestProject `json:"projects"`
+ Groups []ManifestGroup `json:"groups,omitempty"`
source []byte
}
@@ -56,11 +57,21 @@ type ManifestProject struct {
Toolchain string `json:"toolchain"`
}
+// ManifestGroup preserves a product's component membership across task refreshes.
+type ManifestGroup struct {
+ Name string `json:"name"`
+ Projects []string `json:"projects" toml:"projects"`
+}
+
type manifestDocument struct {
Version int `toml:"version"`
Workspace *ManifestWorkspace `toml:"workspace"`
Env *manifestEnvironment `toml:"env"`
Projects map[string]manifestProjectDocument `toml:"projects"`
+ Groups map[string]manifestGroupDocument `toml:"groups"`
+}
+type manifestGroupDocument struct {
+ Projects []string `toml:"projects"`
}
type manifestEnvironment struct {
Infisical *EnvironmentConfig `toml:"infisical"`
@@ -91,6 +102,10 @@ func ParseManifest(raw []byte) (*Manifest, error) {
for name, p := range doc.Projects {
m.Projects = append(m.Projects, ManifestProject{Name: name, RelativeDir: p.Path, Toolchain: p.Toolchain})
}
+ for name, group := range doc.Groups {
+ m.Groups = append(m.Groups, ManifestGroup{Name: name, Projects: group.Projects})
+ }
+ sort.Slice(m.Groups, func(i, j int) bool { return m.Groups[i].Name < m.Groups[j].Name })
sort.Slice(m.Projects, func(i, j int) bool { return m.Projects[i].RelativeDir < m.Projects[j].RelativeDir })
if err := ValidateManifest(m); err != nil {
return nil, err
@@ -140,6 +155,20 @@ func ValidateManifest(m *Manifest) error {
return invalid(i18n.Tf("manifest.toolchain_invalid", p.Name, p.Toolchain))
}
}
+ groupNames := map[string]bool{}
+ for _, group := range m.Groups {
+ if !IsValidProjectName(group.Name) || names[group.Name] || groupNames[group.Name] || len(group.Projects) == 0 {
+ return invalid(i18n.Tf("manifest.group_invalid", group.Name))
+ }
+ groupNames[group.Name] = true
+ members := map[string]bool{}
+ for _, name := range group.Projects {
+ if !names[name] || members[name] {
+ return invalid(i18n.Tf("manifest.group_member_invalid", group.Name, name))
+ }
+ members[name] = true
+ }
+ }
return nil
}
diff --git a/packages/cli/internal/core/workspace/manifest_write.go b/packages/cli/internal/core/workspace/manifest_write.go
index 1d27e129..b570934e 100644
--- a/packages/cli/internal/core/workspace/manifest_write.go
+++ b/packages/cli/internal/core/workspace/manifest_write.go
@@ -68,6 +68,15 @@ func MarshalManifest(m *Manifest) ([]byte, error) {
return nil, err
}
}
+ groups := append([]ManifestGroup(nil), m.Groups...)
+ sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
+ for _, group := range groups {
+ key, _ := toml.Marshal(map[string]int{group.Name: 0})
+ name := strings.TrimSpace(strings.SplitN(string(key), " = ", 2)[0])
+ if err := table("groups."+name, manifestGroupDocument{Projects: group.Projects}); err != nil {
+ return nil, err
+ }
+ }
if len(m.source) > 0 {
return configedit.UpdateTOML(m.source, out.Bytes())
}
diff --git a/packages/cli/internal/modules/creation/composite.go b/packages/cli/internal/modules/creation/composite.go
new file mode 100644
index 00000000..969e7cfa
--- /dev/null
+++ b/packages/cli/internal/modules/creation/composite.go
@@ -0,0 +1,112 @@
+package creation
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace"
+ cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+func materializeComposite(ctx context.Context, root string, in ProjectInput, files *fsutil.FilePlan, manifest *workspace.Manifest, outputs []template.ProjectOutput, manager string, miseEnabled bool) (result ProjectResult, err error) {
+ result = ProjectResult{Name: in.Name, TargetPath: root, TemplateID: in.Template.ID, Toolchain: string(in.Template.Toolchain), PackageManager: manager}
+ for _, project := range manifest.Projects {
+ if project.Name == in.Name {
+ return ProjectResult{}, cliErrors.New(cliErrors.TARGET_EXISTS, i18n.Tf("creation.group_exists", in.Name))
+ }
+ }
+ for _, group := range manifest.Groups {
+ if group.Name == in.Name {
+ return ProjectResult{}, cliErrors.New(cliErrors.TARGET_EXISTS, i18n.Tf("creation.group_exists", in.Name))
+ }
+ }
+ group := workspace.ManifestGroup{Name: in.Name}
+ // All destination checks precede publication; FilePlan also verifies snapshots.
+ for _, output := range outputs {
+ category, categoryErr := categoryDirFor(string(output.Category))
+ if categoryErr != nil {
+ return ProjectResult{}, categoryErr
+ }
+ rel := filepath.Join(category, output.Name)
+ dir := filepath.Join(root, rel)
+ if err := fsutil.SafeWritePath(root, dir); err != nil {
+ return ProjectResult{}, err
+ }
+ nonempty, err := dirNonEmpty(dir)
+ if err != nil {
+ return ProjectResult{}, err
+ }
+ if nonempty {
+ return ProjectResult{}, cliErrors.New(cliErrors.TARGET_EXISTS, i18n.Tf("creation.directory_exists", dir)).WithContext(map[string]any{"subproject_name": output.Name, "target_path": dir})
+ }
+ for _, current := range manifest.Projects {
+ if current.Name == output.Name || current.RelativeDir == filepath.ToSlash(rel) {
+ return ProjectResult{}, cliErrors.New(cliErrors.TARGET_EXISTS, i18n.Tf("creation.project_registered", output.Name))
+ }
+ }
+ for _, current := range manifest.Groups {
+ if current.Name == output.Name {
+ return ProjectResult{}, cliErrors.New(cliErrors.TARGET_EXISTS, i18n.Tf("creation.group_exists", output.Name))
+ }
+ }
+ group.Projects = append(group.Projects, output.Name)
+ result.Projects = append(result.Projects, ProjectResult{Name: output.Name, TargetPath: dir, TemplateID: in.Template.ID, Toolchain: string(in.Template.Toolchain), PackageManager: manager})
+ manifest.Projects = append(manifest.Projects, workspace.ManifestProject{Name: output.Name, RelativeDir: filepath.ToSlash(rel), Toolchain: string(in.Template.Toolchain)})
+ }
+ manifest.Groups = append(manifest.Groups, group)
+ missing := map[string]bool{}
+ defer func() {
+ if err == nil {
+ return
+ }
+ dirs := make([]string, 0, len(missing))
+ for dir := range missing {
+ dirs = append(dirs, dir)
+ }
+ sort.Slice(dirs, func(i, j int) bool { return len(dirs[i]) > len(dirs[j]) })
+ for _, dir := range dirs {
+ _ = os.Remove(dir)
+ }
+ }()
+ for i, output := range outputs {
+ rel, _ := filepath.Rel(root, result.Projects[i].TargetPath)
+ for name, raw := range output.Files {
+ dest := filepath.Join(rel, filepath.FromSlash(name))
+ for dir := filepath.Dir(filepath.Join(root, dest)); dir != root; dir = filepath.Dir(dir) {
+ if _, statErr := os.Lstat(dir); os.IsNotExist(statErr) {
+ missing[dir] = true
+ }
+ }
+ mode := os.FileMode(0o644)
+ if strings.HasSuffix(name, ".sh") {
+ mode = 0o755
+ }
+ if err = files.Set(dest, raw, mode); err != nil {
+ return ProjectResult{}, err
+ }
+ }
+ if in.Template.Toolchain == "node" {
+ packageName := template.CommonVariables(output.Name, manager)["projectNameKebabCase"]
+ if err = configureNodePackage(files, rel, packageName, manager); err != nil {
+ return ProjectResult{}, err
+ }
+ }
+ }
+ if err = planLanguages(files, manifest); err != nil {
+ return ProjectResult{}, err
+ }
+ if err = validateNodePackageNames(files, manifest); err != nil {
+ return ProjectResult{}, err
+ }
+ if err = finishProjectPlan(ctx, root, files, manifest, miseEnabled); err != nil {
+ return ProjectResult{}, err
+ }
+ result.Warnings = warningMessages(template.CheckAllowedBackends(*in.Template, workspace.SelectionForProject(manifest, nil), ""))
+ return result, nil
+}
diff --git a/packages/cli/internal/modules/creation/electron_test.go b/packages/cli/internal/modules/creation/electron_test.go
index 098978c8..9d863366 100644
--- a/packages/cli/internal/modules/creation/electron_test.go
+++ b/packages/cli/internal/modules/creation/electron_test.go
@@ -9,7 +9,9 @@ import (
"strings"
"testing"
+ "github.com/pelletier/go-toml/v2"
"github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/miseconfig"
"gopkg.in/yaml.v3"
)
@@ -19,7 +21,7 @@ func TestElectronProjectsShareRootWorkspace(t *testing.T) {
if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil {
t.Fatal(err)
}
- for _, name := range []string{"desktop", "studio"} {
+ for _, name := range []string{"desktop", "electron"} {
if err := addLanguageProject(t, s, root, "electron-app", name); err != nil {
t.Fatal(err)
}
@@ -28,125 +30,231 @@ func TestElectronProjectsShareRootWorkspace(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if len(manifest.Projects) != 2 {
- t.Fatalf("internal packages became One projects: %+v", manifest.Projects)
+ if len(manifest.Projects) != 6 || len(manifest.Groups) != 2 {
+ t.Fatalf("manifest: %+v", manifest)
}
var config struct {
Packages []string `yaml:"packages"`
AllowBuilds map[string]bool `yaml:"allowBuilds"`
}
- raw, err := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml"))
- if err != nil {
- t.Fatal(err)
- }
+ raw, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml"))
if err := yaml.Unmarshal(raw, &config); err != nil {
t.Fatal(err)
}
if !config.AllowBuilds["electron"] {
- t.Fatal("missing Electron installation policy")
+ t.Fatal("missing install policy")
}
- for _, name := range []string{"desktop", "studio"} {
- base := "apps/" + name
- for _, member := range []string{"apps/electron", "apps/ui", "packages/preload"} {
- want := base + "/" + member
+ for _, name := range []string{"desktop", "electron"} {
+ for _, part := range []struct{ dir, role string }{{"apps", "renderer"}, {"services", "main"}, {"packages", "preload"}} {
+ rel := part.dir + "/" + name + "-" + part.role
+ pkg, err := workspace.ReadPackageJSON(filepath.Join(root, rel))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pkg.Name != name+"-"+part.role {
+ t.Fatalf("package: %+v", pkg)
+ }
+ if part.role != "preload" && pkg.Dependencies[name+"-preload"] != "workspace:*" {
+ t.Fatalf("preload dependency: %+v", pkg)
+ }
+ raw, _ := os.ReadFile(filepath.Join(root, rel, "package.json"))
+ if strings.Contains(string(raw), `"workspaces"`) {
+ t.Fatal("nested workspace remained")
+ }
+ for _, file := range []string{"pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc"} {
+ if _, err := os.Stat(filepath.Join(root, rel, file)); !os.IsNotExist(err) {
+ t.Fatalf("project-owned %s", file)
+ }
+ }
+ if err := filepath.WalkDir(filepath.Join(root, rel), func(file string, entry os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ raw, err := os.ReadFile(file)
+ if err != nil {
+ return err
+ }
+ if strings.Contains(string(raw), "@one-template-electron/") || strings.Contains(string(raw), "{{projectName") || strings.HasSuffix(file, ".hbs") {
+ t.Errorf("unrendered reference: %s", file)
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(root, "apps", name)); !os.IsNotExist(err) {
+ t.Fatal("created nested project root")
+ }
+ }
+ // Verify regenerated group tasks and packaging edges, without a GUI or network.
+ plan, err := miseconfig.Build(root, miseconfig.Options{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(plan.Changes) != 0 {
+ t.Fatalf("not idempotent: %+v", plan.Changes)
+ }
+ var mise struct {
+ Tasks map[string]struct {
+ Depends []string `toml:"depends"`
+ } `toml:"tasks"`
+ }
+ raw, _ = os.ReadFile(filepath.Join(root, "mise.toml"))
+ if err := toml.Unmarshal(raw, &mise); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"desktop", "electron"} {
+ for _, op := range []string{"dev", "build", "test", "pack"} {
+ if len(mise.Tasks[name+":"+op].Depends) == 0 {
+ t.Fatalf("missing %s:%s", name, op)
+ }
+ }
+ for _, role := range []string{"renderer", "main", "preload"} {
+ want := "//:" + name + "-" + role + ":build"
found := false
- for _, pattern := range config.Packages {
- found = found || pattern == want
+ for _, dep := range mise.Tasks[name+"-main:pack"].Depends {
+ found = found || dep == want
}
if !found {
- t.Errorf("missing member %s in %s", want, raw)
+ t.Fatalf("missing pack dependency %s", want)
+ }
+ }
+ }
+}
+
+func TestElectronCompositeNamesAndPreflightRollback(t *testing.T) {
+ for _, name := range []string{"desktop", "electron", "Alpha_Desktop"} {
+ t.Run(name, func(t *testing.T) {
+ s := newCreationService(t)
+ root := filepath.Join(t.TempDir(), "workspace")
+ if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := addLanguageProject(t, s, root, "electron-app", name); err != nil {
+ t.Fatal(err)
}
- pkg, err := workspace.ReadPackageJSON(filepath.Join(root, want))
+ m, err := workspace.ReadManifest(root)
if err != nil {
t.Fatal(err)
}
- if pkg.Name != "@"+name+"/"+filepath.Base(member) {
- t.Fatalf("wrong package name: %+v", pkg)
+ if len(m.Groups) != 1 || m.Groups[0].Name != name {
+ t.Fatalf("group: %+v", m.Groups)
}
- if member != "packages/preload" && pkg.Dependencies["@"+name+"/preload"] != "workspace:*" {
- t.Fatalf("wrong dependency: %+v", pkg)
+ for _, p := range m.Projects {
+ if !strings.HasPrefix(p.Name, name+"-") {
+ t.Fatalf("lost input name: %+v", p)
+ }
}
- }
- for _, file := range []string{"pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc"} {
- if _, err := os.Stat(filepath.Join(root, base, file)); !os.IsNotExist(err) {
- t.Errorf("unexpected project-owned %s", file)
+ })
+ }
+ for _, conflict := range []string{"services/desktop-main/keep.txt", "pnpm-workspace.yaml", "mise.toml"} {
+ t.Run(conflict, func(t *testing.T) {
+ s := newCreationService(t)
+ root := filepath.Join(t.TempDir(), "workspace")
+ if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil {
+ t.Fatal(err)
}
- }
- if err := filepath.WalkDir(filepath.Join(root, base), func(path string, entry os.DirEntry, err error) error {
- if err != nil {
- return err
+ content := "user content"
+ if conflict == "pnpm-workspace.yaml" {
+ content = "packages: ['!services/*']\n"
}
- if entry.IsDir() {
- return nil
+ if conflict == "mise.toml" {
+ content = "# one:managed-v1 invalid\n"
}
- data, err := os.ReadFile(path)
- if err != nil {
- return err
+ dest := filepath.Join(root, conflict)
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ t.Fatal(err)
}
- if strings.Contains(string(data), "@app/") || strings.Contains(string(data), "{{projectName") || strings.HasSuffix(path, ".hbs") {
- t.Errorf("unrendered reference in %s", path)
+ if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
}
- return nil
- }); err != nil {
- t.Fatal(err)
- }
+ before, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml"))
+ if err := addLanguageProject(t, s, root, "electron-app", "desktop"); err == nil {
+ t.Fatal("accepted conflict")
+ }
+ after, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml"))
+ if string(before) != string(after) {
+ t.Fatal("changed manifest")
+ }
+ after, _ = os.ReadFile(dest)
+ if string(after) != content {
+ t.Fatal("changed user file")
+ }
+ for _, rel := range []string{"apps/desktop-renderer", "packages/desktop-preload"} {
+ if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) {
+ t.Fatalf("partial output: %s", rel)
+ }
+ }
+ })
}
+}
- // Exercise the generated filter scripts with pnpm and local-only packages.
- // Stub package commands avoid a GUI/network while preserving the real
- // package names, workspace links and parent orchestration scripts.
+func TestElectronNativePackageGraphIsIsolated(t *testing.T) {
pnpm, err := exec.LookPath("pnpm")
if err != nil {
t.Skip("pnpm is not installed")
}
- if _, err := exec.LookPath("node"); err != nil {
- t.Skip("node is not installed")
+ s := newCreationService(t)
+ root := filepath.Join(t.TempDir(), "workspace with spaces")
+ if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"desktop", "studio"} {
+ if err := addLanguageProject(t, s, root, "electron-app", name); err != nil {
+ t.Fatal(err)
+ }
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
t.Setenv("CI", "true")
- rewrite := func(rel string, internal bool) {
- file := filepath.Join(root, rel, "package.json")
- raw, err := os.ReadFile(file)
- if err != nil {
- t.Fatal(err)
- }
+ t.Setenv("npm_config_manage_package_manager_versions", "false")
+ m, err := workspace.ReadManifest(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range m.Projects {
+ file := filepath.Join(root, p.RelativeDir, "package.json")
+ raw, _ := os.ReadFile(file)
var pkg map[string]any
if err := json.Unmarshal(raw, &pkg); err != nil {
t.Fatal(err)
}
- delete(pkg, "packageManager")
- delete(pkg, "engines")
- delete(pkg, "devDependencies")
deps := map[string]any{}
- if current, ok := pkg["dependencies"].(map[string]any); ok {
- for key, value := range current {
- if strings.HasPrefix(key, "@desktop/") || strings.HasPrefix(key, "@studio/") {
- deps[key] = value
+ for _, field := range []string{"dependencies", "devDependencies"} {
+ if current, ok := pkg[field].(map[string]any); ok {
+ for key, value := range current {
+ if value == "workspace:*" {
+ deps[key] = value
+ }
}
}
}
+ delete(pkg, "devDependencies")
pkg["dependencies"] = deps
- if internal {
- command := `node -e "console.log('MEMBER:` + pkg["name"].(string) + `')"`
- pkg["scripts"] = map[string]string{"dev": command, "build": command}
- }
- out, err := json.MarshalIndent(pkg, "", " ")
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(file, out, 0o644); err != nil {
+ delete(pkg, "engines")
+ delete(pkg, "packageManager")
+ pkg["scripts"] = map[string]string{"build": `node -e "console.log('MEMBER:` + p.Name + `')"`}
+ raw, _ = json.MarshalIndent(pkg, "", " ")
+ if err := os.WriteFile(file, raw, 0o644); err != nil {
t.Fatal(err)
}
}
- rewrite(".", false)
- for _, name := range []string{"desktop", "studio"} {
- rewrite("apps/"+name, false)
- for _, member := range []string{"apps/electron", "apps/ui", "packages/preload"} {
- rewrite("apps/"+name+"/"+member, true)
- }
+ // This offline graph fixture has no external packages or manager downloads.
+ rootPackage := filepath.Join(root, "package.json")
+ raw, _ := os.ReadFile(rootPackage)
+ var pkg map[string]any
+ if err := json.Unmarshal(raw, &pkg); err != nil {
+ t.Fatal(err)
+ }
+ delete(pkg, "packageManager")
+ raw, _ = json.Marshal(pkg)
+ if err := os.WriteFile(rootPackage, raw, 0o644); err != nil {
+ t.Fatal(err)
}
install := exec.Command(pnpm, "install", "--offline", "--no-frozen-lockfile")
install.Dir = root
@@ -154,38 +262,25 @@ func TestElectronProjectsShareRootWorkspace(t *testing.T) {
t.Fatalf("install: %v\n%s", err, out)
}
for _, name := range []string{"desktop", "studio"} {
- for _, task := range []string{"dev", "build"} {
- cmd := exec.Command(pnpm, "run", task)
- cmd.Dir = filepath.Join(root, "apps", name)
- out, err := cmd.CombinedOutput()
- if err != nil {
- t.Fatalf("%s %s: %v\n%s", name, task, err, out)
- }
- text := string(out)
- preload := strings.Index(text, "MEMBER:@"+name+"/preload")
- for _, member := range []string{"electron", "ui"} {
- if index := strings.Index(text, "MEMBER:@"+name+"/"+member); preload < 0 || index <= preload {
- t.Fatalf("preload did not finish first: %s", out)
- }
- }
- other := "studio"
- if name == "studio" {
- other = "desktop"
- }
- if strings.Contains(text, "MEMBER:@"+other+"/") {
- t.Fatalf("started another application: %s", out)
- }
+ cmd := exec.Command(pnpm, "--filter", name+"-main...", "run", "build")
+ cmd.Dir = root
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("build: %v\n%s", err, out)
}
- }
- lock, err := os.ReadFile(filepath.Join(root, "pnpm-lock.yaml"))
- if err != nil {
- t.Fatal(err)
- }
- for _, name := range []string{"desktop", "studio"} {
- for _, member := range []string{"apps/electron", "apps/ui", "packages/preload"} {
- if !strings.Contains(string(lock), "apps/"+name+"/"+member+":") {
- t.Errorf("missing lockfile importer for %s/%s", name, member)
- }
+ text := string(out)
+ preload := strings.Index(text, "MEMBER:"+name+"-preload")
+ renderer := strings.Index(text, "MEMBER:"+name+"-renderer")
+ main := strings.Index(text, "MEMBER:"+name+"-main")
+ if preload < 0 || renderer <= preload || main <= renderer {
+ t.Fatalf("incorrect order: %s", out)
+ }
+ other := "desktop"
+ if name == other {
+ other = "studio"
+ }
+ if strings.Contains(text, "MEMBER:"+other+"-") {
+ t.Fatalf("started another group: %s", out)
}
}
}
@@ -215,20 +310,20 @@ func TestElectronPreservesRootSettings(t *testing.T) {
t.Fatal(err)
}
if cfg["minimumReleaseAge"] != 1440 || !strings.Contains(string(raw), "# user settings") {
- t.Fatalf("lost user config: %s", raw)
+ t.Fatalf("lost config: %s", raw)
}
if policy == "" && cfg["allowBuilds"].(map[string]any)["electron"] != true {
- t.Fatal("missing default build policy")
+ t.Fatal("missing default policy")
}
if strings.HasPrefix(policy, "allowBuilds") && cfg["allowBuilds"].(map[string]any)["electron"] != false {
- t.Fatal("overwrote explicit denial")
+ t.Fatal("overwrote denial")
}
if strings.HasPrefix(policy, "onlyBuilt") && cfg["allowBuilds"] != nil {
- t.Fatal("mixed old and new build policies")
+ t.Fatal("mixed policies")
}
after, _ := os.ReadFile(filepath.Join(root, ".npmrc"))
if string(after) != npmrc {
- t.Fatal("changed user registry")
+ t.Fatal("changed registry")
}
})
}
@@ -246,18 +341,20 @@ func TestElectronRejectsOtherManagerWithoutPartialWrites(t *testing.T) {
}
before, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml"))
if err := addLanguageProject(t, s, root, "electron-app", "desktop"); err == nil {
- t.Fatal("unsupported manager accepted")
+ t.Fatal("accepted manager")
}
after, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml"))
if string(before) != string(after) {
- t.Fatal("changed manifest on failure")
+ t.Fatal("changed manifest")
}
- if _, err := os.Stat(filepath.Join(root, "apps/desktop")); !os.IsNotExist(err) {
- t.Fatal("partial project left after failure")
+ for _, rel := range []string{"apps/desktop-renderer", "services/desktop-main", "packages/desktop-preload"} {
+ if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) {
+ t.Fatal("partial output")
+ }
}
after, _ = os.ReadFile(filepath.Join(root, "package.json"))
if string(after) != pkg {
- t.Fatal("overwrote root manager")
+ t.Fatal("changed manager")
}
}
@@ -272,13 +369,13 @@ func TestElectronRejectsNormalizedNameCollision(t *testing.T) {
}
before, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml"))
if err := addLanguageProject(t, s, root, "electron-app", "desktop-app"); err == nil {
- t.Fatal("duplicate normalized scope accepted")
+ t.Fatal("accepted duplicate package names")
}
after, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml"))
if string(before) != string(after) {
- t.Fatal("failed add changed workspace")
+ t.Fatal("changed workspace")
}
- if _, err := os.Stat(filepath.Join(root, "apps/desktop-app")); !os.IsNotExist(err) {
- t.Fatal("failed add left partial project")
+ if _, err := os.Stat(filepath.Join(root, "apps/desktop-app-renderer")); !os.IsNotExist(err) {
+ t.Fatal("partial project")
}
}
diff --git a/packages/cli/internal/modules/creation/package_format_test.go b/packages/cli/internal/modules/creation/package_format_test.go
index a6808c7c..1baf5040 100644
--- a/packages/cli/internal/modules/creation/package_format_test.go
+++ b/packages/cli/internal/modules/creation/package_format_test.go
@@ -73,21 +73,28 @@ func TestGeneratedNodeProjectsPassFormatting(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- cmd := exec.Command(formatter, "--check", ".")
- cmd.Dir = result.Project.TargetPath
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("generated %s formatting: %v\n%s", entry.ID, err, out)
+ projects := result.Project.Projects
+ if len(projects) == 0 {
+ projects = []ProjectResult{result.Project}
+ }
+ for _, project := range projects {
+ cmd := exec.Command(formatter, "--check", ".")
+ cmd.Dir = project.TargetPath
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("generated %s formatting: %v\n%s", project.Name, err, out)
+ }
+ b, err := os.ReadFile(filepath.Join(project.TargetPath, "package.json"))
+ var pkg struct{ Name string }
+ if err != nil || json.Unmarshal(b, &pkg) != nil || pkg.Name != project.Name {
+ t.Fatalf("generated package name: %s (%v)", b, err)
+ }
}
- cmd = exec.Command(formatter, "--check", "package.json")
+ cmd := exec.Command(formatter, "--check", "package.json")
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("workspace package formatting: %v\n%s", err, out)
}
- b, err := os.ReadFile(filepath.Join(result.Project.TargetPath, "package.json"))
- var pkg struct{ Name string }
- if err != nil || json.Unmarshal(b, &pkg) != nil || pkg.Name != "sample" {
- t.Fatalf("generated package name: %s (%v)", b, err)
- }
+
})
}
}
diff --git a/packages/cli/internal/modules/creation/project.go b/packages/cli/internal/modules/creation/project.go
index f1829d64..7e2f41f2 100644
--- a/packages/cli/internal/modules/creation/project.go
+++ b/packages/cli/internal/modules/creation/project.go
@@ -23,7 +23,8 @@ type ProjectInput struct {
Template *template.Template
// Name is the subproject name (validated for the IsValidProjectName
// regex by the caller).
- Name string
+ Name string
+ SkipSkills bool
}
// ProjectResult is the transport-neutral outcome of materialising a Template.
@@ -34,6 +35,7 @@ type ProjectResult struct {
Toolchain string
PackageManager string
Warnings []string
+ Projects []ProjectResult
}
// materializeProject renders the template into projectRoot, upserts the
@@ -76,6 +78,33 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput
}
entry := in.Template
+ templateLocalID, err := parseLocalTemplateID(entry.Repo)
+ if err != nil {
+ return ProjectResult{}, err
+ }
+
+ packageManager := defaultPackageManagerFor(string(entry.Toolchain))
+ if entry.Toolchain == "node" {
+ packageManager, err = nodePackageManager(files)
+ if err != nil {
+ return ProjectResult{}, err
+ }
+ }
+ vars := template.CommonVariables(in.Name, packageManager)
+ outputs, err := template.PrepareProjects(templateLocalID, vars)
+ if err != nil {
+ return ProjectResult{}, err
+ }
+ if len(outputs) > 0 {
+ return materializeComposite(ctx, projectRoot, in, files, manifest, outputs, packageManager, miseEnabled)
+ }
+
+ for _, group := range manifest.Groups {
+ if group.Name == in.Name {
+ return ProjectResult{}, cliErrors.New(cliErrors.TARGET_EXISTS, i18n.Tf("creation.group_exists", in.Name))
+ }
+ }
+
categoryDir, err := categoryDirFor(string(entry.Category))
if err != nil {
return ProjectResult{}, err
@@ -97,20 +126,6 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput
})
}
- templateLocalID, err := parseLocalTemplateID(entry.Repo)
- if err != nil {
- return ProjectResult{}, err
- }
-
- packageManager := defaultPackageManagerFor(string(entry.Toolchain))
- if entry.Toolchain == "node" {
- packageManager, err = nodePackageManager(files)
- if err != nil {
- return ProjectResult{}, err
- }
- }
- vars := template.CommonVariables(in.Name, packageManager)
-
if err := template.Render(templateLocalID, targetDir, vars); err != nil {
if createdFromScratch {
_ = os.RemoveAll(targetDir)
@@ -160,53 +175,53 @@ func materializeProject(ctx context.Context, projectRoot string, in ProjectInput
return ProjectResult{}, err
}
}
- if hk, err := files.Read(workspace.HooksConfigFilename); err != nil {
+ if err := finishProjectPlan(ctx, projectRoot, files, manifest, miseEnabled); err != nil {
return ProjectResult{}, err
+ }
+ registered = true
+
+ compatManifest, _ := workspace.ReadManifest(projectRoot)
+ compatSelection := workspace.SelectionForProject(compatManifest, nil)
+ warnings := template.CheckAllowedBackends(*entry, compatSelection, "")
+
+ return ProjectResult{
+ Name: in.Name, TargetPath: targetDir, TemplateID: entry.ID, Toolchain: string(entry.Toolchain),
+ PackageManager: manifestPM, Warnings: warningMessages(warnings),
+ }, nil
+}
+
+func finishProjectPlan(ctx context.Context, projectRoot string, files *fsutil.FilePlan, manifest *workspace.Manifest, miseEnabled bool) error {
+ if hk, err := files.Read(workspace.HooksConfigFilename); err != nil {
+ return err
} else if hk != nil {
if err := hooks.PlanFiles(files, manifest); err != nil {
- return ProjectResult{}, err
+ return err
}
}
manifestRaw, err := workspace.MarshalManifest(manifest)
if err != nil {
- return ProjectResult{}, err
+ return err
}
if err := files.Set(workspace.ManifestFilename, manifestRaw, 0o644); err != nil {
- return ProjectResult{}, err
+ return err
}
if miseEnabled {
plan, err := miseconfig.BuildWithFiles(projectRoot, miseconfig.Options{}, files.Overlay())
if err != nil {
- return ProjectResult{}, err
+ return err
}
for path, content := range plan.ReadInputs() {
if err := files.Expect(path, content); err != nil {
- return ProjectResult{}, err
+ return err
}
}
for _, change := range plan.Changes {
if err := files.Set(change.Path, []byte(change.After), 0o644); err != nil {
- return ProjectResult{}, err
+ return err
}
}
}
- if err := files.Apply(ctx); err != nil {
- return ProjectResult{}, err
- }
- registered = true
-
- compatManifest, _ := workspace.ReadManifest(projectRoot)
- compatSelection := workspace.SelectionForProject(compatManifest, nil)
- warnings := template.CheckAllowedBackends(*entry, compatSelection, "")
-
- return ProjectResult{
- Name: in.Name,
- TargetPath: targetDir,
- TemplateID: entry.ID,
- Toolchain: string(entry.Toolchain),
- PackageManager: manifestPM,
- Warnings: warningMessages(warnings),
- }, nil
+ return files.Apply(ctx)
}
// warningMessages flattens compat warnings to strings; empty / nil is
diff --git a/packages/cli/internal/modules/creation/runnable_templates_test.go b/packages/cli/internal/modules/creation/runnable_templates_test.go
index a320cf63..ac40f589 100644
--- a/packages/cli/internal/modules/creation/runnable_templates_test.go
+++ b/packages/cli/internal/modules/creation/runnable_templates_test.go
@@ -23,7 +23,7 @@ func TestRunnableTemplateSourcesAndProjects(t *testing.T) {
_, here, _, _ := runtime.Caller(0)
repo := filepath.Clean(filepath.Join(filepath.Dir(here), "../../../../.."))
sources := filepath.Join(repo, "packages/templates")
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
run := func(dir string, args ...string) {
t.Helper()
@@ -44,6 +44,7 @@ func TestRunnableTemplateSourcesAndProjects(t *testing.T) {
run(desktopSource, "pnpm", "install", "--no-frozen-lockfile")
run(desktopSource, "pnpm", "run", "format:check")
run(desktopSource, "pnpm", "run", "build")
+ run(desktopSource, "pnpm", "run", "test")
formatter := filepath.Join(desktopSource, "node_modules/.bin/oxfmt")
if runtime.GOOS == "windows" {
formatter += ".cmd"
@@ -68,22 +69,109 @@ func TestRunnableTemplateSourcesAndProjects(t *testing.T) {
if err := os.WriteFile(filepath.Join(root, "package.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(filepath.Join(root, "pnpm-workspace.yaml"), []byte("packages: []\nallowBuilds:\n electron: true\n electron-winstaller: false\n"), 0o644); err != nil {
+ policy := "packages: []\n" + pnpmWorkspaceContent[strings.Index(pnpmWorkspaceContent, "# Native"):]
+ if err := os.WriteFile(filepath.Join(root, "pnpm-workspace.yaml"), []byte(policy), 0o644); err != nil {
t.Fatal(err)
}
- for _, project := range []struct{ id, name string }{{"go-api", "api"}, {"go-lib", "shared"}, {"electron-app", "Alpha_Desktop"}, {"electron-app", "zulu-desktop"}} {
+ for _, project := range []struct{ id, name string }{{"go-api", "api"}, {"go-lib", "shared"}, {"electron-app", "Alpha_Desktop"}, {"electron-app", "zulu-desktop"}, {"nextjs-site", "website"}, {"fumadocs-docs", "docs"}, {"nestjs-api", "nest-api"}, {"react-spa", "web"}, {"nextjs-app", "app"}, {"expo-mobile", "mobile"}, {"ts-library", "utils"}, {"empty-app", "custom-app"}, {"empty-service", "custom-service"}, {"empty-library", "custom-library"}} {
if err := addLanguageProject(t, s, root, project.id, project.name); err != nil {
t.Fatal(err)
}
}
+ pages := map[string][]struct{ path, lang, text string }{
+ "website": {
+ {"en/index.html", "en-US", "Build something worth sharing"},
+ {"zh/index.html", "zh-CN", "让值得分享的想法成为网站"},
+ {"en/about/index.html", "en-US", "A simple place to tell your story"},
+ {"zh/about/index.html", "zh-CN", "在这里讲述你的故事"},
+ },
+ "docs": {
+ {"en/index.html", "en-US", "My Docs"},
+ {"zh/index.html", "zh-CN", "我的文档"},
+ {"en/docs/guide/index.html", "en-US", "Writing guide"},
+ {"zh/docs/guide/index.html", "zh-CN", "编写指南"},
+ },
+ }
+ for _, name := range []string{"website", "docs"} {
+ for _, excluded := range []string{"out", ".next", ".source", "node_modules", "pnpm-lock.yaml", "pnpm-workspace.yaml"} {
+ if _, err := os.Stat(filepath.Join(root, "apps", name, excluded)); !os.IsNotExist(err) {
+ t.Fatalf("template leaked %s into %s: %v", excluded, name, err)
+ }
+ }
+ }
run(root, "pnpm", "install", "--no-frozen-lockfile")
for _, dir := range []string{"services/api", "packages/shared"} {
run(filepath.Join(root, dir), "go", "test", "-mod=readonly", "./...")
}
- for _, dir := range []string{"apps/Alpha_Desktop", "apps/zulu-desktop"} {
+ // Check the complete application stack in one workspace, including the
+ // Expo/Next.js React type versions that previously conflicted.
+ for _, dir := range []string{"services/nest-api", "apps/web", "apps/app", "apps/mobile", "packages/utils"} {
+ run(filepath.Join(root, dir), "pnpm", "run", "check")
+ }
+ for _, dir := range []string{"services/nest-api", "apps/web", "apps/app", "packages/utils"} {
run(filepath.Join(root, dir), "pnpm", "run", "build")
- run(filepath.Join(root, dir), formatter, "--check", ".")
}
+ for _, dir := range []string{"services/nest-api", "apps/mobile", "packages/utils"} {
+ run(filepath.Join(root, dir), "pnpm", "run", "test")
+ }
+ run(filepath.Join(root, "services/nest-api"), "pnpm", "run", "test:e2e")
+ run(filepath.Join(root, "apps/mobile"), "pnpm", "exec", "expo", "install", "--check")
+ run(filepath.Join(root, "apps/mobile"), "pnpm", "exec", "expo", "export", "--platform", "web")
+ for _, project := range []struct{ name, pkg string }{{"Alpha_Desktop", "alpha-desktop"}, {"zulu-desktop", "zulu-desktop"}} {
+ run(root, "pnpm", "--filter", project.pkg+"-main...", "run", "build")
+ run(filepath.Join(root, "services", project.name+"-main"), "pnpm", "run", "test")
+ for _, member := range []struct{ dir, suffix string }{{"apps", "-renderer"}, {"services", "-main"}, {"packages", "-preload"}} {
+ run(filepath.Join(root, member.dir, project.name+member.suffix), formatter, "--check", ".")
+ }
+ }
+ for _, name := range []string{"website", "docs"} {
+ dir := filepath.Join(root, "apps", name)
+ run(dir, "pnpm", "run", "build")
+ run(dir, "pnpm", "run", "typecheck")
+ run(dir, "pnpm", "run", "lint")
+ for _, page := range pages[name] {
+ raw, err := os.ReadFile(filepath.Join(dir, "out", page.path))
+ if err != nil || !strings.Contains(string(raw), `lang="`+page.lang+`"`) || !strings.Contains(string(raw), page.text) {
+ t.Fatalf("%s/%s missing localized document: %v", name, page.path, err)
+ }
+ }
+ }
+ docs := filepath.Join(root, "apps", "docs")
+ for _, page := range []struct{ locale, search, toc string }{{"en", "Search", "On this page"}, {"zh", "搜索", "本页目录"}} {
+ raw, err := os.ReadFile(filepath.Join(docs, "out", page.locale, "docs", "index.html"))
+ if err != nil || !strings.Contains(string(raw), page.search) || !strings.Contains(string(raw), page.toc) || !strings.Contains(string(raw), "/"+page.locale+"/docs/guide") || strings.Contains(string(raw), "./guide.mdx") {
+ t.Fatalf("%s documentation UI or relative links invalid: %v", page.locale, err)
+ }
+ }
+ // Use the real browser search client against the exported index over HTTP.
+ // This catches missing exports, locale filters, and broken result URLs.
+ run(docs, "node", "--input-type=module", "--eval", `
+import assert from 'node:assert/strict';
+import { readFileSync, existsSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { staticClient } from 'fumadocs-core/search/client/orama-static';
+const index = readFileSync('out/api/search');
+const server = createServer((req, res) => {
+ res.setHeader('Content-Type', 'application/json');
+ res.end(index);
+});
+await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+try {
+ const from = 'http://127.0.0.1:' + server.address().port + '/api/search';
+ for (const [locale, query] of [['en', 'guide'], ['zh', '指南']]) {
+ const results = await staticClient({ from, locale }).search(query);
+ assert(results.length > 0, locale + ' search returned no results');
+ for (const result of results) {
+ assert(result.url.startsWith('/' + locale + '/docs'), result.url);
+ const path = result.url.split('#')[0].replace(/\/$/, '');
+ assert(existsSync('out' + path + '/index.html'), result.url);
+ }
+ assert.deepEqual(await staticClient({ from, locale }).search('zzzznonexistentzzzz'), []);
+ }
+} finally {
+ await new Promise(resolve => server.close(resolve));
+}
+`)
}
func packageManagerFromTestJSON(t *testing.T, raw []byte) string {
diff --git a/packages/cli/internal/modules/creation/service.go b/packages/cli/internal/modules/creation/service.go
index 0f1dd83c..3f9dba58 100644
--- a/packages/cli/internal/modules/creation/service.go
+++ b/packages/cli/internal/modules/creation/service.go
@@ -13,6 +13,7 @@ import (
environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment"
"github.com/torchstellar-team/one-cli/packages/cli/internal/modules/hooks"
"github.com/torchstellar-team/one-cli/packages/cli/internal/modules/miseconfig"
+ skillsmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills"
cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors"
"github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil"
"github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
@@ -21,10 +22,15 @@ import (
type Service struct {
Runtime runtimeport.Provider
+ Skills SkillInstaller
environments *environmentmodule.Service
observer WorkspaceObserver
}
+type SkillInstaller interface {
+ Install(context.Context, string, []skillsmodule.Selection) []string
+}
+
// WorkspaceObserver is the optional machine-local discovery hook invoked
// after a workspace has been created successfully. Discovery is auxiliary:
// a registry failure must never roll back an otherwise valid workspace.
@@ -50,6 +56,7 @@ type WorkspaceInput struct {
Name string
EnvBackend string
CreatedInPlace bool
+ SkipSkills bool
}
type WorkspaceResult struct {
@@ -63,6 +70,7 @@ type WorkspaceResult struct {
RegistryWarn error
MiseTrustWarn error
HooksWarn error
+ SkillsWarnings []string
}
// ValidateWorkspaceTarget performs the same final safety check used by
@@ -177,6 +185,9 @@ func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (Wo
if s.observer != nil {
result.RegistryWarn = s.observer(ctx, input.TargetDir, "create")
}
+ if !input.SkipSkills && s.Skills != nil {
+ result.SkillsWarnings = s.Skills.Install(ctx, input.TargetDir, skillsmodule.Defaults("", ""))
+ }
return result, nil
}
@@ -202,6 +213,10 @@ func (s *Service) AddProject(
project.Warnings = append(project.Warnings, i18n.Tf("creation.mise_trust_warning", trustErr))
}
}
+ if !input.SkipSkills && s.Skills != nil {
+ project.Warnings = append(project.Warnings, s.Skills.Install(ctx, projectRoot,
+ skillsmodule.Defaults(input.Template.ID, input.Template.Repo))...)
+ }
return AddProjectResult{
Project: project,
}, nil
diff --git a/packages/cli/internal/modules/creation/skills_test.go b/packages/cli/internal/modules/creation/skills_test.go
new file mode 100644
index 00000000..af73d00d
--- /dev/null
+++ b/packages/cli/internal/modules/creation/skills_test.go
@@ -0,0 +1,80 @@
+package creation
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace"
+ skillsmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills"
+)
+
+type installFunc func(context.Context, string, []skillsmodule.Selection) []string
+
+func (f installFunc) Install(ctx context.Context, root string, selections []skillsmodule.Selection) []string {
+ return f(ctx, root, selections)
+}
+
+func TestSkillsRunAfterCreationAndCompositeAndCanBeSkipped(t *testing.T) {
+ for _, skip := range []bool{false, true} {
+ service := newCreationService(t)
+ root := filepath.Join(t.TempDir(), "demo")
+ calls := 0
+ service.Skills = installFunc(func(_ context.Context, directory string, plan []skillsmodule.Selection) []string {
+ calls++
+ if directory != root {
+ t.Fatalf("skills target=%s want workspace %s", directory, root)
+ }
+ if calls == 1 {
+ var names []string
+ for _, selection := range plan {
+ names = append(names, selection.Names...)
+ }
+ if !reflect.DeepEqual(names, []string{"one-cli", "find-skills"}) {
+ t.Fatalf("creation must install usage and discovery skills: %v", plan)
+ }
+ }
+ manifest, err := workspace.ReadManifest(root)
+ if err != nil {
+ t.Fatal("skills ran before creation completed", err)
+ }
+ if calls == 2 {
+ if len(manifest.Projects) != 3 || len(manifest.Groups) != 1 {
+ t.Fatal("skills ran before the complete Electron group was published")
+ }
+ for _, project := range manifest.Projects {
+ if _, err := os.Stat(filepath.Join(root, project.RelativeDir, "package.json")); err != nil {
+ t.Fatal("skills ran before member files existed", err)
+ }
+ }
+ }
+ return []string{"installation unavailable; retry manually"}
+ })
+ created, err := service.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo", SkipSkills: skip})
+ if err != nil {
+ t.Fatal(err)
+ }
+ registry, err := template.Fetch(context.Background(), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ entry, err := registry.Resolve("electron-app")
+ if err != nil {
+ t.Fatal(err)
+ }
+ added, err := service.AddProject(context.Background(), root, ProjectInput{Template: entry, Name: "desktop", SkipSkills: skip})
+ if err != nil || len(added.Project.Projects) != 3 {
+ t.Fatalf("valid project failed because of skills: %+v %v", added, err)
+ }
+ if skip {
+ if calls != 0 || len(created.SkillsWarnings) != 0 {
+ t.Fatalf("skip called installer: %d %+v", calls, created)
+ }
+ } else if calls != 2 || len(created.SkillsWarnings) != 1 || len(added.Project.Warnings) == 0 {
+ t.Fatalf("auxiliary failure lost: calls=%d create=%+v add=%+v", calls, created, added)
+ }
+ }
+}
diff --git a/packages/cli/internal/modules/creation/templates/AGENTS.en-US.md b/packages/cli/internal/modules/creation/templates/AGENTS.en-US.md
index f06a4c4d..8d6755f2 100644
--- a/packages/cli/internal/modules/creation/templates/AGENTS.en-US.md
+++ b/packages/cli/internal/modules/creation/templates/AGENTS.en-US.md
@@ -7,19 +7,25 @@
- Read the relevant `README.md` and applicable `AGENTS.md` before changing a project.
- Consult `one --help` and command-specific `--help`; do not guess commands or flags. Before adding a project, discover templates with `one templates -o json`, then use `one add`.
+## Stack and project structure
+
+- `empty-app`, `empty-service`, and `empty-library` do not prescribe a stack. The user chooses the language, frameworks, UI, database, and development tools. Do not infer a required stack for an empty project from sibling projects or installed skills.
+- One places projects in `apps/`, `services/`, or `packages/`; organize each project's internal directories around the user's chosen stack and architecture. If the user has not chosen a stack, propose options based on the requirements rather than automatically applying another template's dependencies or layout.
+- For existing projects, follow their actual dependencies, configuration, and team instructions. Stack and layout examples in skills apply only to the corresponding projects. Configure the relevant toolchains, tasks, and development skills after a stack is chosen.
+
## Use one task entry point
- Tests, checks, builds, dependency preparation, and project scripts must run through `one run `. Select project tasks with `one run -p `.
- Inspect existing tasks with `one run` and reuse them first. Do not bypass this entry point by directly running commands such as `pnpm test`, `go test`, `node`, `docker`, or `mise run`; task definitions may call these native tools internally.
-- If a required task is missing, define it in the project's `package.json`, `Taskfile.yml`, or the root `mise.toml`. After changing project tasks, synchronize with `one init mise` when needed, then execute through `one run`.
-- One CLI help, task discovery, project creation, and variable management commands may be used directly without wrapping them in tasks.
+- If a required task is missing, define it in an applicable project task file (such as `package.json` or `Taskfile.yml`) or the root `mise.toml`. After changing project tasks, synchronize with `one init mise` when needed, then execute through `one run`.
+- One CLI help, task discovery, project creation, skills management, and variable management commands may be used directly without wrapping them in tasks.
- After making changes, use `one run` for the affected checks, tests, and necessary builds. Use task names from the actual configuration.
## Complex scripts
-- For operations with multiple steps, complex branches, or repeated use, write TypeScript in `scripts/.mts` instead of assembling long shell commands.
+- For operations with multiple steps, complex branches, or repeated use, write `scripts/.` in the project's chosen language instead of assembling long shell commands. TypeScript projects may use `.mts`; do not introduce another stack just for scripts.
- Put scripts shared across projects in the workspace root's `scripts/`; put project-specific scripts in that project's `scripts/`.
-- Configure the required Node version in mise and register the script as a task that calls `node scripts/.mts`. The agent still executes it through `one run`.
+- Configure the tools required by the chosen script language in mise and register the script as a task. The agent still executes it through `one run`.
- Read variables from the process environment. Do not write, expand, or print secret values in source code, command arguments, or logs.
## Environment variables
@@ -44,7 +50,11 @@ Then use `one run push-image`. The folder and variable names must match the user
## Automation and delivery
-- Use `-o json` when parsing One CLI results. Handle errors using `error.code` and `error.context`, not translated messages; treat child-process output as logs.
+- Consult installed workspace skills in `.agents/skills` for One usage, project layouts, and the target project's stack. The manifest and team instructions take precedence over upstream examples.
+- Choose relevant skills from their names and descriptions. Read the selected `SKILL.md` when the task needs it, then open references or scripts only as needed; do not read every installed skill in advance.
+- Install project-scoped skills from the workspace root into `.agents/skills`; do not use `--global` to install them in the user's global directory.
+- `one skills` forwards arguments to `npx skills`; its output and help belong to that tool. Business skills live in independent GitHub repositories and are installed only when the user requests them. Installing a skill does not authorize implementing a business feature.
+- Use `-o json` when parsing One's own structured results. Handle errors using `error.code` and `error.context`, not translated messages. `one skills`, `one mise`, and `one hk` preserve upstream output and flags without One's JSON envelope; treat child-process output as logs.
- Report what changed, which checks actually ran and their results, and which checks remain unrun.
This file is maintained by the team. Later `one add` operations and language changes do not rewrite existing content.
diff --git a/packages/cli/internal/modules/creation/templates/AGENTS.zh-CN.md b/packages/cli/internal/modules/creation/templates/AGENTS.zh-CN.md
index 418f67da..6b3034f0 100644
--- a/packages/cli/internal/modules/creation/templates/AGENTS.zh-CN.md
+++ b/packages/cli/internal/modules/creation/templates/AGENTS.zh-CN.md
@@ -7,19 +7,25 @@
- 修改项目之前,阅读相关的 `README.md` 和适用的 `AGENTS.md`。
- 使用 `one --help` 和具体命令的 `--help` 确认用法,不猜测命令或参数。添加项目时先用 `one templates -o json` 查询模板,再使用 `one add`。
+## 技术栈与项目结构
+
+- `empty-app`、`empty-service` 和 `empty-library` 不预设技术栈。语言、框架、UI、数据库及开发工具由用户选择;不要从工作区其他项目或已安装的 skills 推断空项目必须使用的技术栈。
+- One 约定项目位于 `apps/`、`services/` 或 `packages/`,项目内部目录按用户选择的技术栈与架构组织。用户尚未指定时,根据需求提供方案,不自动套用其他模板的依赖或目录。
+- 修改已有项目时,以该项目的实际依赖、配置和团队约定为准。Skills 中的技术栈和目录示例只适用于对应项目;确定技术栈后再配置相应工具链、任务和开发 skills。
+
## 统一执行入口
- 测试、检查、构建、依赖准备和项目脚本必须通过 `one run <任务>` 执行;项目任务使用 `one run <任务> -p <项目>`。
- 先用 `one run` 查看已有任务,优先复用。不直接运行 `pnpm test`、`go test`、`node`、`docker` 或 `mise run` 等命令绕过任务入口;任务内部可以调用这些原生工具。
-- 缺少需要的任务时,先在项目的 `package.json`、`Taskfile.yml` 或根 `mise.toml` 中定义。修改项目任务后,按需运行 `one init mise` 同步,再通过 `one run` 执行。
-- One CLI 的帮助、任务查询、项目创建和变量管理命令可以直接使用,无需包装成任务。
+- 缺少需要的任务时,先在适用的项目任务文件(如 `package.json`、`Taskfile.yml`)或根 `mise.toml` 中定义。修改项目任务后,按需运行 `one init mise` 同步,再通过 `one run` 执行。
+- One CLI 的帮助、任务查询、项目创建、skills 管理和变量管理命令可以直接使用,无需包装成任务。
- 修改完成后,通过 `one run` 执行受影响范围的检查、测试和必要构建;任务名以实际配置为准。
## 复杂脚本
-- 多步骤、复杂分支或反复使用的操作,可以编写为 `scripts/<名称>.mts`,用 TypeScript 表达,避免拼接长串 shell 命令。
+- 多步骤、复杂分支或反复使用的操作,可以编写为 `scripts/<名称>.<扩展名>`,使用项目已选用的语言,避免拼接长串 shell 命令。使用 TypeScript 时可采用 `.mts`;不要仅为脚本引入另一套技术栈。
- 跨项目脚本放在工作区根目录的 `scripts/`,仅服务于一个项目的脚本放在该项目的 `scripts/`。
-- 在 mise 中配置脚本需要的 Node 工具版本,把脚本注册为任务,由任务调用 `node scripts/<名称>.mts`,Agent 仍通过 `one run` 执行。
+- 在 mise 中配置所选脚本语言需要的工具版本,把脚本注册为任务;Agent 仍通过 `one run` 执行。
- 脚本从进程环境读取变量,不在代码、参数或日志中写入、展开或打印密钥值。
## 环境变量
@@ -44,7 +50,11 @@ run = "one exec --global --env dev --path /docker --keys REGISTRY_USER,REGISTRY_
## 自动化与交付
-- 需要解析 One CLI 结果时使用 `-o json`,根据 `error.code` 和 `error.context` 处理错误,不解析翻译后的消息;子进程输出按日志处理。
+- 使用 `.agents/skills` 中已安装的工作区 skills 了解 One 用法、项目目录结构和目标项目的技术栈。Manifest 和团队约定优先于上游示例。
+- 根据 skill 的名称与描述选择相关指导,任务需要时再阅读对应 `SKILL.md`,引用资料和脚本也按需读取,不预先阅读全部已安装 skills。
+- 在工作区根目录安装项目级 skills,保存到 `.agents/skills`,不使用 `--global` 安装到用户全局目录。
+- `one skills` 将参数透传给 `npx skills`,其输出和帮助遵循上游工具。业务 skills 在独立 GitHub 仓库维护,仅在用户主动要求时安装。安装 skill 不代表授权实现业务功能。
+- 需要解析 One 自身的结构化结果时使用 `-o json`,根据 `error.code` 和 `error.context` 处理错误,不解析翻译后的消息。`one skills`、`one mise` 和 `one hk` 保留上游输出与参数,不使用 One 的 JSON 包装;子进程输出按日志处理。
- 完成后说明改动内容、实际执行的验证及结果,并明确哪些验证尚未执行。
本文件由团队维护。后续 `one add` 和语言切换不会重写已有内容。
diff --git a/packages/cli/internal/modules/creation/workspace_content.go b/packages/cli/internal/modules/creation/workspace_content.go
index 3d456ef8..7b1a6527 100644
--- a/packages/cli/internal/modules/creation/workspace_content.go
+++ b/packages/cli/internal/modules/creation/workspace_content.go
@@ -23,7 +23,8 @@ allowBuilds:
unrs-resolver: true
`
-const gitignoreContent = `.one-run-*/
+const gitignoreContent = `/.one/
+.one-run-*/
# dependencies
node_modules
diff --git a/packages/cli/internal/modules/miseconfig/config.go b/packages/cli/internal/modules/miseconfig/config.go
index 25dbdf8d..d290f75c 100644
--- a/packages/cli/internal/modules/miseconfig/config.go
+++ b/packages/cli/internal/modules/miseconfig/config.go
@@ -297,6 +297,35 @@ func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan,
}
}
var ci Task
+ for _, group := range m.Groups {
+ operations := map[string][]string{}
+ var builds []string
+ for _, member := range group.Projects {
+ for name := range configs[member].Tasks {
+ operations[name] = append(operations[name], "//:"+member+":"+name)
+ }
+ if _, ok := configs[member].Tasks["build"]; ok {
+ builds = append(builds, "//:"+member+":build")
+ }
+ }
+ for name, targets := range operations {
+ rootConfig.Tasks[group.Name+":"+name] = Task{Depends: targets}
+ // Packaging consumes every component's output, including renderer assets.
+ if name == "pack" || name == "release" {
+ for _, member := range group.Projects {
+ key := member + ":" + name
+ if task, ok := rootConfig.Tasks[key]; ok {
+ for _, build := range builds {
+ if !slices.Contains(task.Depends, build) {
+ task.Depends = append(task.Depends, build)
+ }
+ }
+ rootConfig.Tasks[key] = task
+ }
+ }
+ }
+ }
+ }
for _, name := range []string{"check", "test", "build"} {
if _, ok := rootConfig.Tasks[name]; ok {
ci.Depends = append(ci.Depends, name)
diff --git a/packages/cli/internal/modules/skills/catalog.go b/packages/cli/internal/modules/skills/catalog.go
new file mode 100644
index 00000000..00dba527
--- /dev/null
+++ b/packages/cli/internal/modules/skills/catalog.go
@@ -0,0 +1,95 @@
+// Package skills installs curated development skills and runs the upstream
+// Skills CLI. Business skills are managed exclusively by explicit user commands.
+package skills
+
+import "strings"
+
+const CLIVersion = "1.7.0"
+
+const oneSource = "1cli-team/one-cli"
+
+// Release builds inject their full Git commit through ldflags. Development and
+// snapshot builds leave this empty so unpublished changes use local copies.
+var bundledSourceRef = ""
+
+// Selection uses the skill's frontmatter name, not its source directory name.
+// An empty Source selects One's usage/template skills for this CLI build.
+type Selection struct {
+ Source string
+ Names []string
+}
+
+const (
+ findSource = "vercel-labs/skills#3694740352eeef5cdd689af694c485f1ff62eec3"
+ reactSource = "vercel-labs/agent-skills#063bee94c3f4df8453406c830b0a7df0f2860278"
+ antfuSource = "antfu/skills#e53a142a2420e8cd812cfe9ed0484ab01bc856aa"
+ uiSource = "shadcn-ui/ui#295a1f114a138f23b5dfee0e0c6812394dfeb90c"
+ tsSource = "rolldown/tsdown#eb40c95efdf7f98d0aa7b0178a3b7322986bd419"
+ expoSource = "expo/skills#13ad8e05874195633b5c185f6947bb6400e228fc"
+ goSource = "affaan-m/ecc#ef648e01899ba3e8dc6371642deaaf64b4477775"
+ nestSource = "kadajett/agent-nestjs-skills#3986e0cede33958e000f959031cbee0cd83c2941"
+)
+
+// Defaults returns only development skills for known built-in templates.
+// Empty/custom templates get common workspace guidance without guessed stacks.
+func Defaults(templateID, repo string) []Selection {
+ plan := []Selection{{Names: []string{"one-cli"}}, {Source: findSource, Names: []string{"find-skills"}}}
+ if repo != "local:"+templateID {
+ return plan
+ }
+ node := Selection{Source: antfuSource, Names: []string{"pnpm"}}
+ web := []Selection{
+ {Source: reactSource, Names: []string{"vercel-react-best-practices", "vercel-composition-patterns"}},
+ {Source: uiSource, Names: []string{"shadcn"}},
+ }
+ switch templateID {
+ case "go-api", "go-lib":
+ plan[0].Names = append(plan[0].Names, "one-go")
+ plan = append(plan, Selection{Source: goSource, Names: []string{"golang-patterns", "golang-testing"}})
+ case "nestjs-api":
+ plan[0].Names = append(plan[0].Names, "one-nestjs")
+ plan = append(plan, node, Selection{Source: nestSource, Names: []string{"nestjs-best-practices"}})
+ case "nextjs-app", "nextjs-site", "fumadocs-docs":
+ plan[0].Names = append(plan[0].Names, "one-web")
+ plan = append(plan, node)
+ plan = append(plan, web...)
+ if templateID == "fumadocs-docs" {
+ plan[0].Names = append(plan[0].Names, "one-fumadocs")
+ }
+ case "react-spa":
+ plan[0].Names = append(plan[0].Names, "one-web")
+ node.Names = append(node.Names, "vite")
+ plan = append(plan, node)
+ plan = append(plan, web...)
+ case "expo-mobile":
+ plan[0].Names = append(plan[0].Names, "one-expo")
+ plan = append(plan, node, Selection{Source: expoSource, Names: []string{
+ "expo-overview", "expo-router", "expo-data-fetching", "expo-dev-client", "expo-upgrade", "expo-project-structure",
+ }})
+ case "ts-library":
+ node.Names = append(node.Names, "vitest")
+ plan = append(plan, node, Selection{Source: tsSource, Names: []string{"tsdown"}})
+ case "electron-app":
+ plan[0].Names = append(plan[0].Names, "one-electron", "one-web")
+ node.Names = append(node.Names, "vite", "vitest")
+ plan = append(plan, node, Selection{Source: tsSource, Names: []string{"tsdown"}})
+ plan = append(plan, web...)
+ }
+ return plan
+}
+
+func sourceRepo(source string) string {
+ repo, _, _ := strings.Cut(source, "#")
+ parts := strings.SplitN(repo, "/", 3)
+ if len(parts) == 3 {
+ return parts[0] + "/" + parts[1]
+ }
+ return repo
+}
+
+func bundledSource() string {
+ if bundledSourceRef == "" {
+ return "./.agents/skills"
+ }
+ return oneSource + "/packages/agent-skills#" + bundledSourceRef
+}
diff --git a/packages/cli/internal/modules/skills/catalog_test.go b/packages/cli/internal/modules/skills/catalog_test.go
new file mode 100644
index 00000000..d5e018e6
--- /dev/null
+++ b/packages/cli/internal/modules/skills/catalog_test.go
@@ -0,0 +1,43 @@
+package skills
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestTemplateAdaptationSkillsStayScoped(t *testing.T) {
+ for _, tt := range []struct {
+ template string
+ repo string
+ want []string
+ }{
+ {"nestjs-api", "local:nestjs-api", []string{"one-cli", "one-nestjs"}},
+ {"go-api", "local:go-api", []string{"one-cli", "one-go"}},
+ {"go-lib", "local:go-lib", []string{"one-cli", "one-go"}},
+ {"expo-mobile", "local:expo-mobile", []string{"one-cli", "one-expo"}},
+ {"react-spa", "local:react-spa", []string{"one-cli", "one-web"}},
+ {"nextjs-app", "local:nextjs-app", []string{"one-cli", "one-web"}},
+ {"nextjs-site", "local:nextjs-site", []string{"one-cli", "one-web"}},
+ {"fumadocs-docs", "local:fumadocs-docs", []string{"one-cli", "one-web", "one-fumadocs"}},
+ {"electron-app", "local:electron-app", []string{"one-cli", "one-electron", "one-web"}},
+ {"ts-library", "local:ts-library", []string{"one-cli"}},
+ {"empty-app", "local:empty-app", []string{"one-cli"}},
+ {"empty-service", "local:empty-service", []string{"one-cli"}},
+ {"empty-library", "local:empty-library", []string{"one-cli"}},
+ {"nextjs-app", "https://example.com/custom-next", []string{"one-cli"}},
+ {"custom", "local:custom", []string{"one-cli"}},
+ {"", "", []string{"one-cli"}},
+ } {
+ t.Run(tt.template+"/"+tt.repo, func(t *testing.T) {
+ var names []string
+ for _, selection := range Defaults(tt.template, tt.repo) {
+ if selection.Source == "" {
+ names = append(names, selection.Names...)
+ }
+ }
+ if !reflect.DeepEqual(names, tt.want) {
+ t.Fatalf("template adaptation skills = %v, want %v", names, tt.want)
+ }
+ })
+ }
+}
diff --git a/packages/cli/internal/modules/skills/install.go b/packages/cli/internal/modules/skills/install.go
new file mode 100644
index 00000000..9b02af84
--- /dev/null
+++ b/packages/cli/internal/modules/skills/install.go
@@ -0,0 +1,303 @@
+package skills
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled"
+)
+
+type RunFunc func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error
+
+// Installer adds missing skills only. Upstream owns skills-lock.json; One does
+// not implement a second skills registry or rewrite user-managed installations.
+type Installer struct {
+ Run RunFunc
+ Log io.Writer
+ Progress func(context.Context, string)
+ Timeout time.Duration
+}
+
+func (s Installer) Install(ctx context.Context, root string, selections []Selection) []string {
+ timeout := s.Timeout
+ if timeout <= 0 {
+ timeout = 5 * time.Minute
+ }
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ unlock, err := fsutil.WorkspaceLock(ctx, root, "skills")
+ if err != nil {
+ return []string{i18n.Tf("skills.install_warning", root, err, "one skills --help")}
+ }
+ defer unlock()
+ lock, err := readLock(root)
+ if err != nil {
+ return []string{i18n.Tf("skills.install_warning", root, err, "one skills list")}
+ }
+ log := s.Log
+ if log == nil {
+ log = io.Discard
+ }
+ seen := map[string]bool{}
+ var warnings []string
+ for _, selection := range selections {
+ var missing []string
+ for _, name := range selection.Names {
+ if seen[name] {
+ continue
+ }
+ seen[name] = true
+ entry, locked := lock[name]
+ if locked && !sameSource(entry, selection.Source) {
+ warnings = append(warnings, i18n.Tf("skills.source_conflict", name, entry.Source))
+ continue
+ }
+ exists, err := installed(root, name)
+ if err != nil {
+ warnings = append(warnings, i18n.Tf("skills.install_warning", name, err, "one skills list"))
+ continue
+ }
+ retryRegistration := false
+ if exists && !locked && selection.Source == "" && bundledSourceRef == "" {
+ // A failed development registration can leave the copied files
+ // without provenance. Retry only unchanged bundled copies so
+ // unregistered team instructions remain untouched.
+ retryRegistration, err = matchesBundledSkill(root, name)
+ if err != nil {
+ warnings = append(warnings, i18n.Tf("skills.install_warning", name, err, "one skills list"))
+ continue
+ }
+ }
+ if !exists || retryRegistration {
+ missing = append(missing, name)
+ }
+ }
+ if len(missing) == 0 {
+ continue
+ }
+ // Prevent upstream writes through a workspace-owned symlink. Existing
+ // individual skills were already skipped and remain untouched.
+ if err := fsutil.SafeWritePath(root, filepath.Join(root, ".agents", "skills")); err != nil {
+ warnings = append(warnings, i18n.Tf("skills.install_warning", strings.Join(missing, ", "), err, "one skills list"))
+ continue
+ }
+ if err := fsutil.SafeWritePath(root, filepath.Join(root, "skills-lock.json")); err != nil {
+ warnings = append(warnings, i18n.Tf("skills.install_warning", strings.Join(missing, ", "), err, "one skills list"))
+ continue
+ }
+ source := selection.Source
+ if source == "" {
+ source = bundledSource()
+ if bundledSourceRef == "" {
+ if err := writeBundled(ctx, root, missing); err != nil {
+ warnings = append(warnings, i18n.Tf("skills.install_warning", strings.Join(missing, ", "), err, "one skills --help"))
+ continue
+ }
+ }
+ }
+ args := []string{"add", source, "--skill"}
+ args = append(args, missing...)
+ args = append(args, "--agent", "universal", "--copy", "--yes")
+ recovery := recoveryCommand(source, missing)
+ message := i18n.Tf("skills.installing", strings.Join(missing, ", "))
+ if s.Progress != nil {
+ s.Progress(ctx, message)
+ } else {
+ fmt.Fprintln(log, message)
+ }
+ diagnostics := &diagnosticLog{}
+ if ctx.Err() != nil {
+ err = ctx.Err()
+ } else if s.Run == nil {
+ err = errors.New(i18n.T("skills.runner_required"))
+ } else {
+ err = s.Run(ctx, root, args, nil, diagnostics, diagnostics)
+ }
+ if ctx.Err() != nil {
+ err = ctx.Err()
+ } else if err != nil && diagnostics.text() != "" {
+ err = i18n.Errorf("skills.command_failed", platformprocess.ExitCode(err), err, diagnostics.text())
+ }
+ if err == nil {
+ for _, name := range missing {
+ if exists, checkErr := installed(root, name); checkErr != nil {
+ err = checkErr
+ break
+ } else if !exists {
+ err = i18n.Errorf("skills.install_missing", name)
+ break
+ }
+ }
+ }
+ if err != nil {
+ warnings = append(warnings, i18n.Tf("skills.install_warning", strings.Join(missing, ", "), err, recovery))
+ }
+ }
+ return warnings
+}
+
+type lockEntry struct {
+ Source string `json:"source"`
+ SourceType string `json:"sourceType"`
+}
+
+func readLock(root string) (map[string]lockEntry, error) {
+ path := filepath.Join(root, "skills-lock.json")
+ raw, err := os.ReadFile(path)
+ if errors.Is(err, fs.ErrNotExist) {
+ return map[string]lockEntry{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var lock struct {
+ Version int `json:"version"`
+ Skills map[string]lockEntry `json:"skills"`
+ }
+ if err := json.Unmarshal(raw, &lock); err != nil {
+ return nil, i18n.Errorf("skills.lock_invalid", path, err)
+ }
+ if lock.Version != 1 || lock.Skills == nil {
+ return nil, i18n.Errorf("skills.lock_unsupported", path)
+ }
+ return lock.Skills, nil
+}
+
+func sameSource(entry lockEntry, source string) bool {
+ if source == "" {
+ if entry.SourceType == "github" {
+ return entry.Source == oneSource
+ }
+ // Preserve old local-source locks and team edits when adding projects.
+ // Migration to a release source is an explicit upstream skills operation.
+ return entry.SourceType == "local" && (strings.HasPrefix(filepath.ToSlash(entry.Source), "./.one/skill-sources/") || entry.Source == "./packages/agent-skills" || entry.Source == "./.agents/skills")
+ }
+ return entry.SourceType == "github" && entry.Source == sourceRepo(source)
+}
+
+func installed(root, name string) (bool, error) {
+ for _, directory := range []string{".agents/skills", ".claude/skills", ".cursor/skills", ".codex/skills"} {
+ _, err := os.Lstat(filepath.Join(root, directory, name))
+ if err == nil {
+ return true, nil
+ }
+ if !errors.Is(err, fs.ErrNotExist) {
+ return false, err
+ }
+ }
+ return false, nil
+}
+
+func matchesBundledSkill(root, name string) (bool, error) {
+ skill, err := fs.Sub(bundled.SkillsFS, bundled.SkillsRoot+"/"+name)
+ if err != nil {
+ return false, err
+ }
+ matches := true
+ err = fs.WalkDir(skill, ".", func(relative string, entry fs.DirEntry, err error) error {
+ if err != nil || entry.IsDir() {
+ return err
+ }
+ original, err := fs.ReadFile(skill, relative)
+ if err != nil {
+ return err
+ }
+ current, err := os.ReadFile(filepath.Join(root, ".agents", "skills", name, filepath.FromSlash(relative)))
+ if errors.Is(err, fs.ErrNotExist) {
+ matches = false
+ return fs.SkipAll
+ }
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(current, original) {
+ matches = false
+ return fs.SkipAll
+ }
+ return nil
+ })
+ return matches, err
+}
+
+// Development builds write only selected skills into their final project paths.
+// Upstream registers these files as local sources without copying onto themselves.
+// Releases install from their published commit instead. Neither needs a second
+// source tree under .one or .agents/skill-sources.
+func writeBundled(ctx context.Context, root string, names []string) error {
+ assets, err := fs.Sub(bundled.SkillsFS, bundled.SkillsRoot)
+ if err != nil {
+ return err
+ }
+ plan := fsutil.NewFilePlan(root)
+ for _, name := range names {
+ skill, err := fs.Sub(assets, name)
+ if err != nil {
+ return err
+ }
+ if err := fs.WalkDir(skill, ".", func(relative string, entry fs.DirEntry, err error) error {
+ if err != nil || entry.IsDir() {
+ return err
+ }
+ raw, err := fs.ReadFile(skill, relative)
+ if err != nil {
+ return err
+ }
+ path := ".agents/skills/" + name + "/" + relative
+ before, err := plan.Read(path)
+ if err != nil {
+ return err
+ }
+ if before != nil && !bytes.Equal(before, raw) {
+ return i18n.Errorf("skills.source_modified", path)
+ }
+ return plan.Set(path, raw, 0o644)
+ }); err != nil {
+ return err
+ }
+ }
+ return plan.Apply(ctx)
+}
+
+func recoveryCommand(source string, names []string) string {
+ return "one skills add " + source + " --skill " + strings.Join(names, " ") + " --agent universal --copy"
+}
+
+// Capture automatic-install output without rendering the upstream UI. Retain a
+// bounded failure reason for result warnings. Manual skills commands use Runner
+// directly and still forward both streams unchanged.
+type diagnosticLog struct {
+ mu sync.Mutex
+ tail []byte
+}
+
+func (d *diagnosticLog) Write(raw []byte) (int, error) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.tail = append(d.tail, raw...)
+ if len(d.tail) > 4096 {
+ d.tail = append([]byte(nil), d.tail[len(d.tail)-4096:]...)
+ }
+ return len(raw), nil
+}
+
+var ansiControl = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
+
+func (d *diagnosticLog) text() string {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return strings.TrimSpace(ansiControl.ReplaceAllString(string(d.tail), ""))
+}
diff --git a/packages/cli/internal/modules/skills/install_test.go b/packages/cli/internal/modules/skills/install_test.go
new file mode 100644
index 00000000..2703a984
--- /dev/null
+++ b/packages/cli/internal/modules/skills/install_test.go
@@ -0,0 +1,370 @@
+package skills
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+func writeSkill(t *testing.T, root, name, content string) {
+ t.Helper()
+ dir := filepath.Join(root, ".agents", "skills", name)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func fakeInstall(t *testing.T, calls *[][]string) RunFunc {
+ return func(_ context.Context, root string, args []string, _ io.Reader, _, _ io.Writer) error {
+ *calls = append(*calls, append([]string(nil), args...))
+ lock, err := readLock(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for index := 3; index < len(args) && args[index] != "--agent"; index++ {
+ name := args[index]
+ if _, err := os.Stat(filepath.Join(root, ".agents", "skills", name, "SKILL.md")); os.IsNotExist(err) {
+ writeSkill(t, root, name, "original")
+ }
+ entry := lockEntry{Source: sourceRepo(args[1]), SourceType: "github"}
+ if strings.HasPrefix(args[1], "./") {
+ entry.Source, entry.SourceType = args[1], "local"
+ }
+ lock[name] = entry
+ }
+ raw, _ := json.Marshal(map[string]any{"version": 1, "skills": lock})
+ return os.WriteFile(filepath.Join(root, "skills-lock.json"), raw, 0o644)
+ }
+}
+
+func TestInstallIsIdempotentAndPreservesUserChanges(t *testing.T) {
+ root := t.TempDir()
+ var calls [][]string
+ installer := Installer{Run: fakeInstall(t, &calls)}
+ plan := Defaults("electron-app", "local:electron-app")
+ if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 0 {
+ t.Fatal(warnings)
+ }
+ firstCalls := len(calls)
+ if firstCalls != len(plan) {
+ t.Fatalf("calls=%d want %d", firstCalls, len(plan))
+ }
+ writeSkill(t, root, "one-cli", "team modifications")
+ lockBefore, _ := os.ReadFile(filepath.Join(root, "skills-lock.json"))
+ if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 0 || len(calls) != firstCalls {
+ t.Fatalf("repeat changed installations: %v %v", warnings, calls)
+ }
+ got, _ := os.ReadFile(filepath.Join(root, ".agents", "skills", "one-cli", "SKILL.md"))
+ lockAfter, _ := os.ReadFile(filepath.Join(root, "skills-lock.json"))
+ if string(got) != "team modifications" || string(lockAfter) != string(lockBefore) {
+ t.Fatal("repeat overwrote user content or lock")
+ }
+ for _, call := range calls {
+ if !reflect.DeepEqual(call[len(call)-4:], []string{"--agent", "universal", "--copy", "--yes"}) {
+ t.Fatalf("unexpected flags %q", call)
+ }
+ }
+}
+
+func TestBundledRegistrationRetriesAfterFailure(t *testing.T) {
+ for _, partial := range []bool{false, true} {
+ t.Run(map[bool]string{false: "unregistered", true: "partially registered"}[partial], func(t *testing.T) {
+ root := t.TempDir()
+ var calls, registrations [][]string
+ register := fakeInstall(t, ®istrations)
+ installer := Installer{Run: func(ctx context.Context, directory string, args []string, in io.Reader, out, stderr io.Writer) error {
+ calls = append(calls, append([]string(nil), args...))
+ if len(calls) == 1 {
+ if partial {
+ first := append(append([]string(nil), args[:4]...), args[len(args)-4:]...)
+ if err := register(ctx, directory, first, in, out, stderr); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return errors.New("registration interrupted")
+ }
+ return register(ctx, directory, args, in, out, stderr)
+ }}
+ plan := []Selection{{Names: []string{"one-cli", "one-electron"}}}
+ if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 1 {
+ t.Fatalf("failure was not reported: %v", warnings)
+ }
+ want := plan[0].Names
+ if partial {
+ writeSkill(t, root, "one-cli", "team modifications")
+ want = []string{"one-electron"}
+ }
+ if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 0 || len(calls) != 2 {
+ t.Fatalf("registration did not retry: warnings=%v calls=%v", warnings, calls)
+ }
+ if got := calls[1][3 : len(calls[1])-4]; !reflect.DeepEqual(got, want) {
+ t.Fatalf("retried skills=%v want %v", got, want)
+ }
+ lock, err := readLock(root)
+ if err != nil || len(lock) != 2 {
+ t.Fatalf("retry did not finish registration: %v %v", lock, err)
+ }
+ if partial {
+ content, err := os.ReadFile(filepath.Join(root, ".agents", "skills", "one-cli", "SKILL.md"))
+ if err != nil || string(content) != "team modifications" {
+ t.Fatalf("retry changed an installed skill: %q %v", content, err)
+ }
+ }
+ if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 0 || len(calls) != 2 {
+ t.Fatalf("successful retry was not idempotent: %v %v", warnings, calls)
+ }
+ })
+ }
+}
+
+func TestUnregisteredTeamSkillsArePreserved(t *testing.T) {
+ for _, file := range []string{"SKILL.md", "references/layouts.md"} {
+ t.Run(file, func(t *testing.T) {
+ root := t.TempDir()
+ plan := []Selection{{Names: []string{"one-cli"}}}
+ failed := Installer{Run: func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error {
+ return errors.New("registration interrupted")
+ }}
+ if warnings := failed.Install(context.Background(), root, plan); len(warnings) != 1 {
+ t.Fatal(warnings)
+ }
+ path := filepath.Join(root, ".agents", "skills", "one-cli", filepath.FromSlash(file))
+ if err := os.WriteFile(path, []byte("team modifications"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ var calls [][]string
+ warnings := (Installer{Run: fakeInstall(t, &calls)}).Install(context.Background(), root, plan)
+ content, err := os.ReadFile(path)
+ if len(warnings) != 0 || len(calls) != 0 || err != nil || string(content) != "team modifications" {
+ t.Fatalf("unregistered team skill changed: %v %v %q %v", warnings, calls, content, err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "skills-lock.json")); !os.IsNotExist(err) {
+ t.Fatalf("team skill was registered implicitly: %v", err)
+ }
+ })
+ }
+}
+
+func TestSourceConflictAndInvalidLockArePreserved(t *testing.T) {
+ for _, raw := range []string{`{"version":1,"skills":{"shadcn":{"source":"team/custom","sourceType":"github"}}}`, `{"version":99,"skills":{}}`, `invalid json`} {
+ root := t.TempDir()
+ lockPath := filepath.Join(root, "skills-lock.json")
+ _ = os.WriteFile(lockPath, []byte(raw), 0o644)
+ var calls [][]string
+ warnings := (Installer{Run: fakeInstall(t, &calls)}).Install(context.Background(), root, []Selection{{Source: uiSource, Names: []string{"shadcn"}}})
+ got, _ := os.ReadFile(lockPath)
+ if len(warnings) != 1 || len(calls) != 0 || string(got) != raw {
+ t.Fatalf("conflict mutated state: %v %q %q", warnings, calls, got)
+ }
+ }
+}
+
+func TestFailureAndTimeoutReturnRecoveryAndKeepWorkspace(t *testing.T) {
+ for _, timedOut := range []bool{false, true} {
+ root := t.TempDir()
+ marker := filepath.Join(root, "keep.txt")
+ _ = os.WriteFile(marker, []byte("project files"), 0o644)
+ installer := Installer{Timeout: 30 * time.Millisecond, Run: func(ctx context.Context, _ string, _ []string, _ io.Reader, _, _ io.Writer) error {
+ if timedOut {
+ <-ctx.Done()
+ return ctx.Err()
+ }
+ return errors.New("network unavailable")
+ }}
+ warnings := installer.Install(context.Background(), root, []Selection{{Source: goSource, Names: []string{"golang-patterns"}}})
+ got, _ := os.ReadFile(marker)
+ if len(warnings) != 1 || !strings.Contains(warnings[0], "one skills add "+goSource) || string(got) != "project files" {
+ t.Fatalf("recovery=%v marker=%q", warnings, got)
+ }
+ }
+}
+
+func TestAutomaticInstallHidesToolUIAndRetainsFailureReason(t *testing.T) {
+ t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) })
+ for _, locale := range []string{"en-US", "zh-CN"} {
+ t.Run(locale, func(t *testing.T) {
+ if err := i18n.Init(locale); err != nil {
+ t.Fatal(err)
+ }
+ for _, fail := range []bool{false, true} {
+ root := t.TempDir()
+ var log bytes.Buffer
+ var calls [][]string
+ install := fakeInstall(t, &calls)
+ installer := Installer{Log: &log, Run: func(ctx context.Context, directory string, args []string, in io.Reader, out, stderr io.Writer) error {
+ if _, err := io.WriteString(out, strings.Repeat("SKILLS UPSTREAM BANNER\n", 300)); err != nil {
+ return err
+ }
+ if _, err := io.WriteString(stderr, "\x1b[31mfatal: permission denied /tmp/skill-source\x1b[0m\n"); err != nil {
+ return err
+ }
+ if fail {
+ return errors.New("installation failed")
+ }
+ return install(ctx, directory, args, in, out, stderr)
+ }}
+ warnings := installer.Install(context.Background(), root, []Selection{{Names: []string{"one-cli"}}})
+ if got, want := log.String(), i18n.Tf("skills.installing", "one-cli")+"\n"; got != want {
+ t.Fatalf("automatic install leaked upstream UI: %q, want %q", got, want)
+ }
+ if !fail && len(warnings) != 0 {
+ t.Fatal(warnings)
+ }
+ if fail && (len(warnings) != 1 || !strings.Contains(warnings[0], "fatal: permission denied /tmp/skill-source") || !strings.Contains(warnings[0], "one skills add ") || strings.Contains(warnings[0], "\x1b")) {
+ t.Fatalf("failure lost its diagnosis or recovery command: %v", warnings)
+ }
+ }
+ })
+ }
+}
+
+func TestDevelopmentSkillsKeepOnlySelectedPortableCopies(t *testing.T) {
+ for _, names := range [][]string{
+ {"one-cli"},
+ {"one-cli", "one-nestjs"},
+ {"one-cli", "one-go"},
+ {"one-cli", "one-expo"},
+ {"one-cli", "one-web"},
+ {"one-cli", "one-electron", "one-web"},
+ {"one-cli", "one-web", "one-fumadocs"},
+ } {
+ t.Run(strings.Join(names, "+"), func(t *testing.T) {
+ root := t.TempDir()
+ var calls [][]string
+ warnings := (Installer{Run: fakeInstall(t, &calls)}).Install(context.Background(), root, []Selection{{Names: names}})
+ if len(warnings) != 0 || len(calls) != 1 || calls[0][1] != "./.agents/skills" {
+ t.Fatalf("local registration failed: %v %v", warnings, calls)
+ }
+ entries, err := os.ReadDir(filepath.Join(root, ".agents", "skills"))
+ if err != nil || len(entries) != len(names) {
+ t.Fatalf("unselected skills were written: %v %v", entries, err)
+ }
+ lock, err := readLock(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range names {
+ entry := lock[name]
+ if entry.SourceType != "local" || entry.Source != "./.agents/skills" {
+ t.Fatalf("nonportable source for %s: %+v", name, entry)
+ }
+ if raw, err := os.ReadFile(filepath.Join(root, entry.Source, name, "SKILL.md")); err != nil || !strings.Contains(string(raw), "name: "+name) {
+ t.Fatalf("source is not the installed skill: %s %v", name, err)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(root, ".one")); !os.IsNotExist(err) {
+ t.Fatalf("duplicate sources remain: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(root, ".agents", "skills", "one-cli", "references", "layouts.md")); err != nil {
+ t.Fatal("selected skill lost its references", err)
+ }
+ })
+ }
+}
+
+func TestLegacyBundledSourcesPreserveTeamFilesAndLock(t *testing.T) {
+ for _, source := range []string{"./.one/skill-sources/0123456789abcdef", "./packages/agent-skills", "./.agents/skills"} {
+ t.Run(source, func(t *testing.T) {
+ root := t.TempDir()
+ writeSkill(t, root, "one-cli", "team modifications")
+ raw, err := json.Marshal(map[string]any{"version": 1, "skills": map[string]any{"one-cli": map[string]any{"source": source, "sourceType": "local", "computedHash": "team-hash"}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ lockPath := filepath.Join(root, "skills-lock.json")
+ if err := os.WriteFile(lockPath, raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ var calls [][]string
+ installer := Installer{Run: fakeInstall(t, &calls)}
+ warnings := installer.Install(context.Background(), root, []Selection{{Names: []string{"one-cli"}}})
+ after, err := os.ReadFile(lockPath)
+ if len(warnings) != 0 || len(calls) != 0 || err != nil || !bytes.Equal(after, raw) {
+ t.Fatalf("existing installation changed: %v %v %s %v", warnings, calls, after, err)
+ }
+ warnings = installer.Install(context.Background(), root, []Selection{{Names: []string{"one-cli", "one-electron"}}})
+ content, err := os.ReadFile(filepath.Join(root, ".agents", "skills", "one-cli", "SKILL.md"))
+ if len(warnings) != 0 || len(calls) != 1 || !reflect.DeepEqual(calls[0][3:4], []string{"one-electron"}) || err != nil || string(content) != "team modifications" {
+ t.Fatalf("adding a skill overwrote team changes: %v %v %q %v", warnings, calls, content, err)
+ }
+ })
+ }
+}
+
+func TestReleaseSkillsUsePublishedCommitAndPreserveOlderSources(t *testing.T) {
+ previous := bundledSourceRef
+ bundledSourceRef = strings.Repeat("a", 40)
+ t.Cleanup(func() { bundledSourceRef = previous })
+ root := t.TempDir()
+ var calls [][]string
+ installer := Installer{Run: fakeInstall(t, &calls)}
+ plan := []Selection{{Names: []string{"one-cli", "one-electron"}}}
+ warnings := installer.Install(context.Background(), root, plan)
+ want := oneSource + "/packages/agent-skills#" + bundledSourceRef
+ if len(warnings) != 0 || len(calls) != 1 || calls[0][1] != want {
+ t.Fatalf("release did not use its published commit: %v %v", warnings, calls)
+ }
+ lock, err := readLock(root)
+ if err != nil || lock["one-cli"].Source != oneSource || lock["one-cli"].SourceType != "github" {
+ t.Fatalf("unexpected GitHub provenance: %v %v", lock, err)
+ }
+ writeSkill(t, root, "one-cli", "team changes")
+ warnings = installer.Install(context.Background(), root, plan)
+ if len(warnings) != 0 || len(calls) != 1 {
+ t.Fatalf("release reinstalled existing files: %v %v", warnings, calls)
+ }
+ // A release also accepts a development/legacy local source without forcing
+ // a provenance migration or overwriting the installed instructions.
+ legacyRoot := t.TempDir()
+ writeSkill(t, legacyRoot, "one-cli", "legacy team changes")
+ raw := []byte(`{"version":1,"skills":{"one-cli":{"source":"./.one/skill-sources/old","sourceType":"local","computedHash":"team"}}}`)
+ lockPath := filepath.Join(legacyRoot, "skills-lock.json")
+ if err := os.WriteFile(lockPath, raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ warnings = installer.Install(context.Background(), legacyRoot, []Selection{{Names: []string{"one-cli"}}})
+ after, err := os.ReadFile(lockPath)
+ if len(warnings) != 0 || len(calls) != 1 || err != nil || !bytes.Equal(raw, after) {
+ t.Fatalf("release migrated the legacy lock implicitly: %v %v %v", warnings, calls, err)
+ }
+}
+
+func TestDefaultsCoverRegistryWithoutBusinessSkills(t *testing.T) {
+ registry, err := template.Fetch(context.Background(), "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range registry.Templates {
+ plan := Defaults(entry.ID, entry.Repo)
+ if len(plan) < 2 || plan[0].Names[0] != "one-cli" || plan[1].Source != findSource || !reflect.DeepEqual(plan[1].Names, []string{"find-skills"}) {
+ t.Fatalf("missing common skills: %s %v", entry.ID, plan)
+ }
+ if entry.Toolchain != "none" && len(plan) <= 2 {
+ t.Fatalf("unmapped template: %s", entry.ID)
+ }
+ for _, selection := range plan {
+ for _, name := range selection.Names {
+ if strings.Contains(name, "auth") || strings.Contains(name, "business") {
+ t.Fatalf("unexpected automatic skill: %s", name)
+ }
+ }
+ }
+ }
+ if got := Defaults("react-spa", "https://example.com/custom"); len(got) != 2 {
+ t.Fatalf("custom template inherited built-in assumptions: %v", got)
+ }
+}
diff --git a/packages/cli/internal/modules/skills/runner.go b/packages/cli/internal/modules/skills/runner.go
new file mode 100644
index 00000000..6e26640f
--- /dev/null
+++ b/packages/cli/internal/modules/skills/runner.go
@@ -0,0 +1,52 @@
+package skills
+
+import (
+ "context"
+ "io"
+ "os"
+ "time"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process"
+ runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime"
+)
+
+// Runner prepares Node when absent, without adding a Node project or loading
+// One credentials. npx's --yes accepts npm's bootstrap prompt, not skills prompts.
+type Runner struct{ Runtime runtimeport.Provider }
+
+func (r Runner) Run(ctx context.Context, directory string, args []string, in io.Reader, out, errOut io.Writer) error {
+ env := os.Environ()
+ argv := append([]string{"npx", "--yes", "skills@" + CLIVersion}, args...)
+ prepared := runtimeport.Command{Directory: directory, Argv: argv, Env: env}
+ if path, err := platformprocess.LookPathIn("npx", directory, env); err == nil {
+ prepared.Argv[0] = path
+ } else {
+ if r.Runtime == nil {
+ return i18n.Errorf("skills.node_required", err)
+ }
+ prepared.Argv = append([]string{"exec", "node@24.21.0", "--"}, argv...)
+ var prepareErr error
+ prepared, prepareErr = r.Runtime.PrepareCLI(ctx, prepared)
+ if prepareErr != nil {
+ return i18n.Errorf("skills.prepare_failed", prepareErr)
+ }
+ }
+ child := platformprocess.CommandContext(ctx, prepared.Argv[0], prepared.Argv[1:]...)
+ child.Dir, child.Env = prepared.Directory, prepared.Env
+ child.Stdin, child.Stdout, child.Stderr = in, out, errOut
+ if in == nil {
+ platformprocess.CancelProcessTree(child)
+ } else {
+ // Keep interactive children in the foreground terminal group. A
+ // cancelled request still stops npm, node, git and their descendants.
+ child.Cancel = func() error {
+ if child.Process == nil {
+ return os.ErrProcessDone
+ }
+ return platformprocess.StopTree(child.Process)
+ }
+ child.WaitDelay = 5 * time.Second
+ }
+ return platformprocess.RunForwarded(ctx, child)
+}
diff --git a/packages/cli/internal/modules/skills/runner_test.go b/packages/cli/internal/modules/skills/runner_test.go
new file mode 100644
index 00000000..d4ab1538
--- /dev/null
+++ b/packages/cli/internal/modules/skills/runner_test.go
@@ -0,0 +1,95 @@
+package skills
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "os"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "testing"
+
+ platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process"
+ runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime"
+)
+
+type helperRuntime struct{ prepared runtimeport.Command }
+
+func (r *helperRuntime) Prepare(ctx context.Context, command runtimeport.Command) (runtimeport.Command, error) {
+ return r.PrepareCLI(ctx, command)
+}
+
+func (r *helperRuntime) PrepareCLI(_ context.Context, command runtimeport.Command) (runtimeport.Command, error) {
+ r.prepared = command
+ command.Argv = append([]string{os.Args[0], "-test.run=^TestSkillsHelperProcess$", "--"}, command.Argv...)
+ return command, nil
+}
+
+type helperResult struct {
+ Directory string
+ Args []string
+ Input string
+}
+
+func TestSkillsHelperProcess(t *testing.T) {
+ if os.Getenv("ONE_TEST_SKILLS_CHILD") != "1" {
+ return
+ }
+ index := 0
+ for index < len(os.Args) && os.Args[index] != "--" {
+ index++
+ }
+ dir, _ := os.Getwd()
+ in, _ := io.ReadAll(os.Stdin)
+ _ = json.NewEncoder(os.Stdout).Encode(helperResult{Directory: dir, Args: os.Args[index+1:], Input: string(in)})
+ _, _ = io.WriteString(os.Stderr, "upstream stderr\n")
+ os.Exit(37)
+}
+
+func TestRunnerPreparesMissingNodeWithoutChangingArguments(t *testing.T) {
+ for _, linked := range []bool{false, true} {
+ t.Run(map[bool]string{false: "directory", true: "directory alias"}[linked], func(t *testing.T) {
+ t.Setenv("PATH", "")
+ t.Setenv("ONE_TEST_SKILLS_CHILD", "1")
+ provider := &helperRuntime{}
+ root := t.TempDir()
+ if linked {
+ alias := filepath.Join(t.TempDir(), "workspace")
+ if err := os.Symlink(root, alias); err != nil {
+ if runtime.GOOS == "windows" {
+ t.Skipf("creating a directory symlink requires Windows privileges: %v", err)
+ }
+ t.Fatal(err)
+ }
+ root = alias
+ }
+ args := []string{"add", "owner/repo", "--skill", "a b", "", "$(literal)", "--global", "--yes"}
+ var out, stderr bytes.Buffer
+ err := (Runner{Runtime: provider}).Run(context.Background(), root, args, bytes.NewBufferString("input\n"), &out, &stderr)
+ if platformprocess.ExitCode(err) != 37 || stderr.String() != "upstream stderr\n" {
+ t.Fatalf("exit=%v stderr=%q", err, stderr.String())
+ }
+ var got helperResult
+ if err := json.Unmarshal(out.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ parentDirectory, err := os.Stat(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ childDirectory, err := os.Stat(got.Directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !os.SameFile(parentDirectory, childDirectory) {
+ t.Fatalf("child directory=%q want %q", got.Directory, root)
+ }
+ want := append([]string{"exec", "node@24.21.0", "--", "npx", "--yes", "skills@" + CLIVersion}, args...)
+ if provider.prepared.Directory != root || got.Input != "input\n" || !reflect.DeepEqual(got.Args, want) || !reflect.DeepEqual(provider.prepared.Argv, want) {
+ t.Fatalf("prepared directory=%q args=%q child=%+v", provider.prepared.Directory, provider.prepared.Argv, got)
+ }
+ })
+ }
+}
diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json
index 66e573df..6a471003 100644
--- a/packages/cli/internal/platform/i18n/locales/en-US.json
+++ b/packages/cli/internal/platform/i18n/locales/en-US.json
@@ -53,7 +53,7 @@
"error.BACKEND_VERB_NOT_SUPPORTED.message": "The current environment-variable source does not support this operation.",
"error.RUN_COMMAND_NOT_FOUND.message": "The command to run was not found.",
"add.short": "Add a project from a technology stack",
- "add.tip": "Add a project from a template, or start empty and configure a technology stack later.",
+ "add.tip": "Add a project from a template, or start empty and configure a technology stack later. Installs missing workspace and technology-stack skills after generation; use --skip-skills to skip. Business skills are installed manually.",
"add.success": "✓ Project added: %s",
"add.location": " Location: %s",
"add.stack": " Technology stack: %s (%s)",
@@ -74,7 +74,7 @@
"locale_from_env": " (from $LANG / $LC_*)",
"locale_path": "Preferences file: %s",
"create.short": "Create a workspace",
- "create.tip": "Create an empty workspace named after its target folder. Empty directories and empty Git repositories are supported. Run one add afterward to add a project.",
+ "create.tip": "Create an empty workspace named after its target folder. Empty directories and empty Git repositories are supported. Run one add afterward to add a project. Installs one-cli usage and find-skills discovery guidance in the workspace's .agents/skills by default; use --skip-skills to skip. Business skills are installed manually.",
"create.success": "✓ Workspace created: %s",
"create.location": " Location: %s",
"create.package_manager": " Package manager: %s",
@@ -502,10 +502,6 @@
"template.nestjs-api.description": "NestJS + TypeScript for APIs and backend services",
"template.go-api.name": "Go API service",
"template.go-api.description": "Gin + Gorm + Viper + Zap for APIs and backend services",
- "template.astro-site.name": "Astro static site",
- "template.astro-site.description": "Static site template for company, marketing, and content websites",
- "template.starlight-docs.name": "Starlight documentation",
- "template.starlight-docs.description": "Documentation project based on Astro + Starlight",
"template.nextjs-app.name": "Next.js application",
"template.nextjs-app.description": "Next.js application for SEO and fast initial page loads",
"template.react-spa.name": "React single-page application",
@@ -517,7 +513,7 @@
"template.go-lib.name": "Go library",
"template.go-lib.description": "Go library following golang-standards/project-layout, ready to publish and import",
"template.electron-app.name": "Electron desktop app",
- "template.electron-app.description": "Electron + React + Vite with inversify, electron-log, electron-store, and electron-updater",
+ "template.electron-app.description": "Electron + React + Vite with top-level renderer, main, and preload projects and Awilix factory functions",
"templates.headings": "CATEGORY\tID\tTOOLCHAIN\tDESCRIPTION",
"template.category.frontend": "Application",
"template.category.backend": "Service",
@@ -742,5 +738,31 @@
"upgrade.failed": "Could not update One CLI at %s: %w\nCheck the network and installation directory permissions, then retry `one upgrade`, or run: %s",
"upgrade.worker_invalid": "The update worker did not return a valid result. Retry `one upgrade`.",
"env.bind.revision_conflict": "Workspace configuration has changed. Refresh before binding again.",
- "env.bind.request_invalid": "Binding requires a configuration revision and exactly one of creation or an existing project."
+ "env.bind.request_invalid": "Binding requires a configuration revision and exactly one of creation or an existing project.",
+ "manifest.group_invalid": "Invalid project group: %s",
+ "manifest.group_member_invalid": "Project group %s contains a missing or duplicate project: %s",
+ "creation.group_exists": "Project or project group %s already exists. Choose another name.",
+ "add.group_success": "Project group %s created successfully.",
+ "add.group_projects": "Created projects:",
+ "add.group_next_steps": "Run the group task to start the desktop application:",
+ "add.group_member": " %s: %s",
+ "template.nextjs-site.name": "Next.js static site",
+ "template.nextjs-site.description": "Next.js + React static export for company, marketing, and content websites",
+ "template.fumadocs-docs.name": "Fumadocs documentation",
+ "template.fumadocs-docs.description": "Next.js + Fumadocs + Base UI with English/Chinese docs and static search",
+ "template.retired": "Template %s has been replaced. Use one add %s --name ; existing project tasks can still run.",
+ "skills.short": "Forward commands to the upstream Skills CLI",
+ "skills.tip": "Run npx skills@1.7.0 with all arguments unchanged in the current directory.\nOne prepares Node when it is missing and preserves upstream interaction, output,\nand exit codes. Use one skills --help for upstream help.\n\nBusiness skills are installed only on request from their GitHub repositories.\nOne does not add --global, --agent, or the Skills CLI --yes flag to manual commands.",
+ "skills.flag.skip": "Skip automatic installation of workspace and technology-stack skills",
+ "skills.node_required": "Node.js/npx is unavailable: %w. Install Node.js and retry the skills command.",
+ "skills.prepare_failed": "Could not prepare Node.js for Skills CLI: %w",
+ "skills.runner_required": "The Skills CLI runner is unavailable.",
+ "skills.install_missing": "Skills CLI finished without installing skill %s.",
+ "skills.install_warning": "Development skills %s could not be installed: %v. Generated project files are preserved. Retry from the workspace root: %s",
+ "skills.source_conflict": "Skill %s is already managed from %s; automatic installation preserves it. Use one skills list to review its source.",
+ "skills.lock_invalid": "Could not read skills lock file %s: %w. Repair it before installing skills.",
+ "skills.lock_unsupported": "Skills lock file %s has an unsupported format; automatic installation leaves it unchanged.",
+ "skills.source_modified": "Bundled skill file %s was modified; automatic installation preserves it.",
+ "skills.command_failed": "Skills CLI exited with code %d: %w\n%s",
+ "skills.installing": "[one] Installing development skills: %s"
}
diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json
index a31ff134..8abd2956 100644
--- a/packages/cli/internal/platform/i18n/locales/zh-CN.json
+++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json
@@ -53,7 +53,7 @@
"error.BACKEND_VERB_NOT_SUPPORTED.message": "当前环境变量来源不支持这个操作。",
"error.RUN_COMMAND_NOT_FOUND.message": "找不到要运行的命令。",
"add.short": "从技术栈添加项目",
- "add.tip": "从模板添加项目,也可选择空项目后自行配置技术栈。",
+ "add.tip": "从模板添加项目,也可选择空项目后自行配置技术栈。 生成完成后安装缺少的工作区基础和技术栈 skills,使用 --skip-skills 跳过。业务 skills 由用户手动安装。",
"add.success": "✓ 项目已添加:%s",
"add.location": " 位置:%s",
"add.stack": " 技术栈:%s(%s)",
@@ -74,7 +74,7 @@
"locale_from_env": "(来自 $LANG / $LC_*)",
"locale_path": "偏好设置文件:%s",
"create.short": "创建工作区",
- "create.tip": "创建空工作区,名称自动使用目标文件夹名。支持空目录或仅包含 Git 元数据的仓库。创建后运行 one add 添加项目。",
+ "create.tip": "创建空工作区,名称自动使用目标文件夹名。支持空目录或仅包含 Git 元数据的仓库。创建后运行 one add 添加项目。 默认在工作区的 .agents/skills 中安装 one-cli 用法指导和 find-skills 查找指导,使用 --skip-skills 跳过。业务 skills 由用户手动安装。",
"create.success": "✓ 工作区已创建:%s",
"create.location": " 位置:%s",
"create.package_manager": " 包管理器:%s",
@@ -502,10 +502,6 @@
"template.nestjs-api.description": "NestJS + TypeScript,适合 API 服务与业务后台",
"template.go-api.name": "Go API 服务",
"template.go-api.description": "Gin + Gorm + Viper + Zap,适合 API 服务与业务后台",
- "template.astro-site.name": "Astro 静态网站",
- "template.astro-site.description": "前端静态站模板,适合官网、营销站与内容站点",
- "template.starlight-docs.name": "Starlight 文档站点",
- "template.starlight-docs.description": "基于 Astro + Starlight 的文档项目模板",
"template.nextjs-app.name": "Next.js 全栈应用",
"template.nextjs-app.description": "Next.js 前端模板,适合 SEO 与首屏性能要求较高场景",
"template.react-spa.name": "React 单页应用",
@@ -517,7 +513,7 @@
"template.go-lib.name": "Go 通用库",
"template.go-lib.description": "Go 库模板,遵循 golang-standards/project-layout,适合发包与被 import 场景",
"template.electron-app.name": "Electron 桌面应用",
- "template.electron-app.description": "Electron + React + Vite 桌面应用模板,主进程基于 inversify,内置 electron-log/store/updater",
+ "template.electron-app.description": "Electron + React + Vite 桌面应用模板,生成 renderer、main、preload 三个顶层项目,主进程使用 Awilix 工厂函数",
"templates.headings": "分类\t标识\t工具链\t说明",
"template.category.frontend": "应用",
"template.category.backend": "服务",
@@ -742,5 +738,31 @@
"upgrade.failed": "无法更新 %s 处的 One CLI:%w\n请检查网络和安装目录权限后重试 `one upgrade`,或运行:%s",
"upgrade.worker_invalid": "更新进程未返回有效结果,请重试 `one upgrade`。",
"env.bind.revision_conflict": "工作区配置已发生变化,请刷新后重新绑定。",
- "env.bind.request_invalid": "绑定请求需要配置版本,并且只能选择创建或绑定已有项目。"
+ "env.bind.request_invalid": "绑定请求需要配置版本,并且只能选择创建或绑定已有项目。",
+ "manifest.group_invalid": "项目组无效:%s",
+ "manifest.group_member_invalid": "项目组 %s 包含不存在或重复的项目:%s",
+ "creation.group_exists": "项目或项目组 %s 已存在,请使用其他名称。",
+ "add.group_success": "项目组 %s 创建成功。",
+ "add.group_projects": "已创建的项目:",
+ "add.group_next_steps": "运行组合任务启动桌面应用:",
+ "add.group_member": " %s:%s",
+ "template.nextjs-site.name": "Next.js 静态网站",
+ "template.nextjs-site.description": "Next.js + React 静态导出,适合官网、营销页与内容站点",
+ "template.fumadocs-docs.name": "Fumadocs 文档站点",
+ "template.fumadocs-docs.description": "Next.js + Fumadocs + Base UI,支持中英文文档与静态搜索",
+ "template.retired": "模板 %s 已被替换。请使用 one add %s --name <项目名>;已有项目的任务仍可运行。",
+ "skills.short": "透传命令到上游 Skills CLI",
+ "skills.tip": "在当前目录运行 npx skills@1.7.0,所有参数原样传递。\n缺少 Node 时由 One 准备运行环境,并保留上游交互、输出和退出码。\n使用 one skills --help 查看上游帮助。\n\n业务 skills 仅由用户主动从其 GitHub 仓库安装。\n手动命令不会自动添加 --global、--agent 或 Skills CLI 的 --yes 参数。",
+ "skills.flag.skip": "跳过自动安装工作区基础和技术栈 skills",
+ "skills.node_required": "Node.js/npx 不可用:%w。请安装 Node.js 后重试 skills 命令。",
+ "skills.prepare_failed": "无法为 Skills CLI 准备 Node.js:%w",
+ "skills.runner_required": "Skills CLI 执行器不可用。",
+ "skills.install_missing": "Skills CLI 已退出,但未安装 skill %s。",
+ "skills.install_warning": "开发 skills %s 安装失败:%v。已生成的项目文件已保留。请在工作区根目录重试:%s",
+ "skills.source_conflict": "Skill %s 已由来源 %s 管理,自动安装会保留它。请使用 one skills list 检查其来源。",
+ "skills.lock_invalid": "无法读取 skills 锁文件 %s:%w。请修复后再安装 skills。",
+ "skills.lock_unsupported": "Skills 锁文件 %s 的格式不受支持,自动安装不会修改它。",
+ "skills.source_modified": "内置 skill 文件 %s 已被修改,自动安装会保留它。",
+ "skills.command_failed": "Skills CLI 退出码为 %d:%w\n%s",
+ "skills.installing": "[one] 正在安装开发 skills:%s"
}
diff --git a/packages/cli/internal/platform/prompt/spinner.go b/packages/cli/internal/platform/prompt/spinner.go
index fc66f176..a290ddb5 100644
--- a/packages/cli/internal/platform/prompt/spinner.go
+++ b/packages/cli/internal/platform/prompt/spinner.go
@@ -1,12 +1,16 @@
package prompt
import (
+ "context"
"fmt"
"io"
"os"
+ "sync"
"time"
"charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+ "golang.org/x/term"
"github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output"
)
@@ -30,20 +34,46 @@ const frameInterval = 80 * time.Millisecond
// program with progress; for sub-100 ms operations, don't wrap — the
// spinner flash is more distracting than the work.
func Spin(title string, action func() error) error {
+ return SpinContext(context.Background(), title, func(context.Context) error { return action() })
+}
+
+type spinnerContextKey struct{}
+
+type spinnerStatus struct {
+ mu sync.Mutex
+ title string
+}
+
+// SpinContext attaches a progress sink to this action's context, so its phase
+// updates replace the spinner title instead of interleaving terminal output.
+func SpinContext(ctx context.Context, title string, action func(context.Context) error) error {
if !output.IsTTY() {
- return action()
+ return action(ctx)
}
stop := make(chan struct{})
done := make(chan struct{})
+ status := &spinnerStatus{title: title}
+ ctx = context.WithValue(ctx, spinnerContextKey{}, status)
+
+ go renderSpinner(os.Stderr, status, stop, done)
+ defer func() {
+ close(stop)
+ <-done
+ }()
+ return action(ctx)
+}
- go renderSpinner(os.Stderr, title, stop, done)
-
- err := action()
-
- close(stop)
- <-done
- return err
+// ReportProgress updates the active action's spinner, or writes a normal status
+// line when no spinner is attached (structured CLI output or Dashboard calls).
+func ReportProgress(ctx context.Context, message string) {
+ if status, ok := ctx.Value(spinnerContextKey{}).(*spinnerStatus); ok {
+ status.mu.Lock()
+ status.title = message
+ status.mu.Unlock()
+ return
+ }
+ fmt.Fprintln(os.Stderr, message)
}
// renderSpinner writes spinner frames to w until stop is closed, then
@@ -55,7 +85,7 @@ func Spin(title string, action func() error) error {
// spinner frames into stdout breaks `... | jq` even in TTY mode
// - stderr is interactive-by-default — terminals show it inline, but
// redirected `2>` consumers don't see the carriage-return overwrites
-func renderSpinner(w io.Writer, title string, stop <-chan struct{}, done chan<- struct{}) {
+func renderSpinner(w io.Writer, status *spinnerStatus, stop <-chan struct{}, done chan<- struct{}) {
defer close(done)
// Use the terminal's cyan palette entry. Importing lipgloss/compat probes
@@ -69,7 +99,20 @@ func renderSpinner(w io.Writer, title string, stop <-chan struct{}, done chan<-
idx := 0
// Print initial frame so the spinner appears immediately.
render := func() {
- lipgloss.Fprintf(w, "\r%s %s ", frameStyle.Render(frames[idx]), title)
+ status.mu.Lock()
+ title := status.title
+ status.mu.Unlock()
+ // Clear the previous title, including when a new phase is shorter or
+ // contains wide characters, before drawing the next frame.
+ line := fmt.Sprintf("%s %s ", frameStyle.Render(frames[idx]), title)
+ if terminal, ok := w.(interface{ Fd() uintptr }); ok {
+ if columns, _, err := term.GetSize(int(terminal.Fd())); err == nil && columns > 0 {
+ // Leave the last column free to avoid terminal auto-wrap. ANSI
+ // truncation preserves styles and handles Chinese display width.
+ line = ansi.Truncate(line, columns-1, "…")
+ }
+ }
+ lipgloss.Fprintf(w, "\r\033[2K%s", line)
idx = (idx + 1) % len(frames)
}
render()
diff --git a/packages/cli/internal/platform/templatefiles/policy.go b/packages/cli/internal/platform/templatefiles/policy.go
index bf9df699..d97d923c 100644
--- a/packages/cli/internal/platform/templatefiles/policy.go
+++ b/packages/cli/internal/platform/templatefiles/policy.go
@@ -14,7 +14,7 @@ func Excluded(name string) bool {
for _, part := range strings.Split(name, "/") {
switch part {
case ".git", ".one", ".agents", "AGENTS.md", "CLAUDE.md", "SKILL.md",
- "node_modules", "dist", "build", "bin", "coverage", ".next", ".astro", ".expo", ".turbo", ".cache",
+ "node_modules", "dist", "out", "build", "bin", "coverage", ".next", ".source", ".astro", ".expo", ".turbo", ".cache",
"pnpm-lock.yaml", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "bun.lock", "bun.lockb",
"go.work", "go.work.sum", "coverage.out", ".DS_Store":
return true
diff --git a/packages/cli/internal/resources/bundled/bundled.go b/packages/cli/internal/resources/bundled/bundled.go
index 8f82f1e3..62f67c27 100644
--- a/packages/cli/internal/resources/bundled/bundled.go
+++ b/packages/cli/internal/resources/bundled/bundled.go
@@ -1,15 +1,15 @@
// Package bundled exposes the assets the CLI ships with: the template
// registry, the templates themselves,
-// and the built `one serve` web UI.
+// development skills, and the built `one serve` web UI.
//
// The files in this directory are physical copies of canonical sources
-// elsewhere in the monorepo (packages/templates/,
+// elsewhere in the monorepo (packages/templates/, packages/agent-skills/skills/,
// apps/dashboard/dist/). Go's embed directive cannot traverse upward
// with "../" and rejects symlinks ("cannot embed irregular file"), so
// the copies have to live inside this package directory.
//
// The whole tree is gitignored. Two tasks regenerate it:
-// - `mise run sync-bundled` — copy templates and registry.json.
+// - `mise run sync-bundled` — copy templates, registry.json, and skills.
// - `mise run sync-web` — pnpm install + vite build of
// apps/dashboard/ → _web/.
//
@@ -54,6 +54,14 @@ var TemplatesFS embed.FS
// template directory.
const TemplatesRoot = "_templates"
+// SkillsFS contains One's usage/layout and template adaptation skills, kept
+// separately from runnable templates. Business skills are never bundled.
+//
+//go:embed all:_skills
+var SkillsFS embed.FS
+
+const SkillsRoot = "_skills"
+
// WebDistFS is the built React UI for `one serve` (sources at apps/dashboard,
// built via `mise run build-web`, copied into this package by `mise run sync-web`).
// internal/transport/http walks this filesystem to serve index.html + hashed assets.
diff --git a/packages/cli/internal/resources/bundled/bundled_test.go b/packages/cli/internal/resources/bundled/bundled_test.go
index d9384905..b1b7e224 100644
--- a/packages/cli/internal/resources/bundled/bundled_test.go
+++ b/packages/cli/internal/resources/bundled/bundled_test.go
@@ -81,6 +81,23 @@ func TestRegistryBytes_MatchesOnDisk(t *testing.T) {
}
}
+func TestSkillsFS_MatchesOnDisk(t *testing.T) {
+ embedded := mustWalk(t, bundled.SkillsFS, bundled.SkillsRoot)
+ diskRoot := filepath.Join(repoRoot(t), "packages", "agent-skills", "skills")
+ disk := mustWalkOSFiltered(t, diskRoot, nil)
+ assertSameFiles(t, "skills", embedded, disk)
+ for _, name := range disk {
+ want, err := os.ReadFile(filepath.Join(diskRoot, filepath.FromSlash(name)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := fs.ReadFile(bundled.SkillsFS, bundled.SkillsRoot+"/"+name)
+ if err != nil || !bytes.Equal(got, want) {
+ t.Errorf("skill differs: %s: %v", name, err)
+ }
+ }
+}
+
// WebDistFS is special: its content is a build artefact (Vite bundle), not
// a copy of source files. Vite chunk hashes vary per machine, so we don't
// byte-compare against a fresh build like the template tests do.
diff --git a/packages/cli/internal/transport/cobra/add/cmd.go b/packages/cli/internal/transport/cobra/add/cmd.go
index 5d8d713f..a89a21f7 100644
--- a/packages/cli/internal/transport/cobra/add/cmd.go
+++ b/packages/cli/internal/transport/cobra/add/cmd.go
@@ -9,6 +9,7 @@
package addcmd
import (
+ "context"
"errors"
"fmt"
"io"
@@ -33,8 +34,9 @@ func buildContributions(service *creationmodule.Service) []*cobra.Command {
}
type addFlags struct {
- name string
- yes bool
+ name string
+ yes bool
+ skipSkills bool
}
func newAddCmd(service *creationmodule.Service) *cobra.Command {
@@ -54,6 +56,8 @@ func newAddCmd(service *creationmodule.Service) *cobra.Command {
}
cmd.Flags().StringVarP(&flags.name, "name", "n", "", i18n.T("add.flag.name"))
cmd.Flags().BoolVarP(&flags.yes, "yes", "y", false, i18n.T("add.flag.yes"))
+ cmd.Flags().BoolVar(&flags.skipSkills, "skip-skills", false, i18n.T("skills.flag.skip"))
+ i18n.MarkFlagUsage(cmd, "skip-skills", "skills.flag.skip")
i18n.MarkFlagUsage(cmd, "name", "add.flag.name")
i18n.MarkFlagUsage(cmd, "yes", "add.flag.yes")
i18n.MarkShort(cmd, "add.short")
@@ -90,18 +94,9 @@ func runAdd(cmd *cobra.Command, service *creationmodule.Service, positional stri
}
templateID = picked
}
- entry := findTemplate(registry.Templates, templateID)
- if entry == nil {
- ids := make([]string, 0, len(registry.Templates))
- for _, t := range registry.Templates {
- ids = append(ids, t.ID)
- }
- return cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND,
- i18n.Tf("add.template_missing", templateID)).
- WithContext(map[string]any{
- "requested_template": templateID,
- "available_templates": ids,
- })
+ entry, err := registry.Resolve(templateID)
+ if err != nil {
+ return err
}
name := strings.TrimSpace(flags.name)
@@ -137,13 +132,14 @@ func runAdd(cmd *cobra.Command, service *creationmodule.Service, positional stri
// Ordinary add deliberately leaves deployment unset. An explicit advanced
// flag retains the automation path that configures it immediately.
projectInput := creationmodule.ProjectInput{
- Template: entry,
- Name: name,
+ Template: entry,
+ Name: name,
+ SkipSkills: flags.skipSkills,
}
var result creationmodule.AddProjectResult
- if err := prompt.Spin(i18n.Tf("add.generating", entry.ID), func() error {
+ if err := prompt.SpinContext(cmd.Context(), i18n.Tf("add.generating", entry.ID), func(ctx context.Context) error {
var createErr error
- result, createErr = service.AddProject(cmd.Context(), projectRoot, projectInput)
+ result, createErr = service.AddProject(ctx, projectRoot, projectInput)
return createErr
}); err != nil {
return err
@@ -162,6 +158,7 @@ func runAdd(cmd *cobra.Command, service *creationmodule.Service, positional stri
Toolchain: project.Toolchain,
PackageManager: project.PackageManager,
Warnings: project.Warnings,
+ Projects: addedProjects(project.Projects),
})
return nil
@@ -177,7 +174,25 @@ type addResult struct {
// Warnings (v0.5+) carries one entry per template `compat` mismatch.
// Empty slice / nil is omitted from the JSON envelope so clean adds
// match the pre-v0.5 wire shape.
- Warnings []string `json:"warnings,omitempty"`
+ Warnings []string `json:"warnings,omitempty"`
+ Projects []addProject `json:"projects,omitempty"`
+}
+
+type addProject struct {
+ Name string `json:"name"`
+ TargetPath string `json:"target_path"`
+ Toolchain string `json:"toolchain"`
+}
+
+func addedProjects(projects []creationmodule.ProjectResult) []addProject {
+ if len(projects) == 0 {
+ return nil
+ }
+ result := make([]addProject, 0, len(projects))
+ for _, project := range projects {
+ result = append(result, addProject{Name: project.Name, TargetPath: project.TargetPath, Toolchain: project.Toolchain})
+ }
+ return result
}
// RenderTTY prints a friendly add-success summary.
@@ -185,6 +200,16 @@ func (r *addResult) RenderTTY(w io.Writer) {
if r == nil {
return
}
+ if len(r.Projects) > 0 {
+ fmt.Fprintf(w, i18n.T("add.group_success")+"\n", r.SubprojectName)
+ fmt.Fprintln(w, i18n.T("add.group_projects"))
+ for _, project := range r.Projects {
+ fmt.Fprintf(w, i18n.T("add.group_member")+"\n", project.Name, project.TargetPath)
+ }
+ fmt.Fprintln(w, i18n.T("add.group_next_steps"))
+ fmt.Fprintf(w, " one run %s:dev\n", r.SubprojectName)
+ return
+ }
fmt.Fprintf(w, i18n.T("add.success")+"\n", r.SubprojectName)
fmt.Fprintf(w, i18n.T("add.location")+"\n", r.TargetPath)
fmt.Fprintf(w, i18n.T("add.stack")+"\n", r.TemplateID, r.Toolchain)
@@ -200,15 +225,6 @@ func (r *addResult) RenderTTY(w io.Writer) {
fmt.Fprintf(w, " one dev -p %s\n", r.SubprojectName)
}
-func findTemplate(items []template.Template, id string) *template.Template {
- for i := range items {
- if items[i].ID == id {
- return &items[i]
- }
- }
- return nil
-}
-
type projectKind string
const (
diff --git a/packages/cli/internal/transport/cobra/add/cmd_test.go b/packages/cli/internal/transport/cobra/add/cmd_test.go
index 8ec335ca..a2a55e2f 100644
--- a/packages/cli/internal/transport/cobra/add/cmd_test.go
+++ b/packages/cli/internal/transport/cobra/add/cmd_test.go
@@ -1,6 +1,10 @@
package addcmd
import (
+ "bytes"
+ "encoding/json"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ "strings"
"testing"
"github.com/torchstellar-team/one-cli/packages/cli/internal/core/template"
@@ -40,3 +44,42 @@ func TestProjectKindForUsesDirectoryCategory(t *testing.T) {
})
}
}
+
+func TestCompositeAddResult(t *testing.T) {
+ original := i18n.Active()
+ t.Cleanup(func() { _ = i18n.Init(original) })
+ result := addResult{SubprojectName: "desktop", TargetPath: "/workspace", TemplateID: "electron-app", Toolchain: "node", Projects: []addProject{
+ {Name: "desktop-renderer", TargetPath: "/workspace/apps/desktop-renderer", Toolchain: "node"},
+ {Name: "desktop-main", TargetPath: "/workspace/services/desktop-main", Toolchain: "node"},
+ {Name: "desktop-preload", TargetPath: "/workspace/packages/desktop-preload", Toolchain: "node"},
+ }}
+ for _, locale := range []string{"zh-CN", "en-US"} {
+ _ = i18n.Init(locale)
+ var out bytes.Buffer
+ result.RenderTTY(&out)
+ for _, member := range result.Projects {
+ if !strings.Contains(out.String(), member.TargetPath) {
+ t.Fatal(out.String())
+ }
+ }
+ if !strings.Contains(out.String(), "one run desktop:dev") || strings.Contains(out.String(), "one dev -p desktop") {
+ t.Fatal(out.String())
+ }
+ if !strings.Contains(out.String(), i18n.Tf("add.group_success", "desktop")) {
+ t.Fatal(out.String())
+ }
+ }
+ raw, err := json.Marshal(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil || len(fields["projects"]) == 0 {
+ t.Fatalf("%s: %v", raw, err)
+ }
+ result.Projects = nil
+ raw, _ = json.Marshal(result)
+ if strings.Contains(string(raw), `"projects"`) {
+ t.Fatal("ordinary output changed")
+ }
+}
diff --git a/packages/cli/internal/transport/cobra/create/cmd.go b/packages/cli/internal/transport/cobra/create/cmd.go
index d4076c97..cf9313bb 100644
--- a/packages/cli/internal/transport/cobra/create/cmd.go
+++ b/packages/cli/internal/transport/cobra/create/cmd.go
@@ -33,8 +33,9 @@ var workspaceDefaultEnables = []string{
var canonicalDomainOrder = []string{"dev", "ci", "env"}
type createFlags struct {
- name string
- yes bool
+ name string
+ yes bool
+ skipSkills bool
}
func newCreateCmd(deps Dependencies) *cobra.Command {
@@ -54,6 +55,8 @@ func newCreateCmd(deps Dependencies) *cobra.Command {
}
cmd.Flags().StringVarP(&flags.name, "name", "n", "", i18n.T("create.flag.name"))
cmd.Flags().BoolVarP(&flags.yes, "yes", "y", false, i18n.T("create.flag.yes"))
+ cmd.Flags().BoolVar(&flags.skipSkills, "skip-skills", false, i18n.T("skills.flag.skip"))
+ i18n.MarkFlagUsage(cmd, "skip-skills", "skills.flag.skip")
i18n.MarkFlagUsage(cmd, "name", "create.flag.name")
i18n.MarkFlagUsage(cmd, "yes", "create.flag.yes")
helpui.MarkAdvanced(cmd, "name")
diff --git a/packages/cli/internal/transport/cobra/create/create.go b/packages/cli/internal/transport/cobra/create/create.go
index 2a1664ad..9b0f01c2 100644
--- a/packages/cli/internal/transport/cobra/create/create.go
+++ b/packages/cli/internal/transport/cobra/create/create.go
@@ -1,6 +1,7 @@
package createcmd
import (
+ "context"
"errors"
"os"
"path/filepath"
@@ -79,14 +80,15 @@ func runCreate(deps Dependencies, cmd *cobra.Command, rawDir string, flags *crea
enables := append([]string(nil), workspaceDefaultEnables...)
var result creationmodule.WorkspaceResult
- if err := prompt.Spin(i18n.T("create.generating"), func() error {
+ if err := prompt.SpinContext(cmd.Context(), i18n.T("create.generating"), func(ctx context.Context) error {
var createErr error
- result, createErr = deps.Creation.CreateWorkspace(cmd.Context(), creationmodule.WorkspaceInput{
+ result, createErr = deps.Creation.CreateWorkspace(ctx, creationmodule.WorkspaceInput{
TargetDir: targetDir,
DisplayPath: displayPath,
Name: projectName,
EnvBackend: selectedEnvironmentBackend(enables),
CreatedInPlace: useCurrentDir,
+ SkipSkills: flags.skipSkills,
})
return createErr
}); err != nil {
@@ -134,6 +136,7 @@ func runCreate(deps Dependencies, cmd *cobra.Command, rawDir string, flags *crea
if result.HooksWarn != nil {
payload.Warnings = append(payload.Warnings, i18n.Tf("create.hooks_warning", result.HooksWarn))
}
+ payload.Warnings = append(payload.Warnings, result.SkillsWarnings...)
output.Emit(&payload)
return nil
diff --git a/packages/cli/internal/transport/cobra/skills/cmd.go b/packages/cli/internal/transport/cobra/skills/cmd.go
new file mode 100644
index 00000000..27541d98
--- /dev/null
+++ b/packages/cli/internal/transport/cobra/skills/cmd.go
@@ -0,0 +1,26 @@
+package skillscmd
+
+import (
+ "os"
+
+ "github.com/spf13/cobra"
+ skillsmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+func Commands(run skillsmodule.RunFunc) []*cobra.Command {
+ cmd := &cobra.Command{
+ Use: "skills [args...]", DisableFlagParsing: true,
+ Example: " one skills find react\n one skills add owner/repo --skill skill-name\n one skills list\n one skills update",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ dir, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ return run(cmd.Context(), dir, args, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr())
+ },
+ }
+ i18n.MarkShort(cmd, "skills.short")
+ i18n.MarkLong(cmd, "skills.tip")
+ return []*cobra.Command{cmd}
+}
diff --git a/packages/cli/internal/transport/cobra/skills/cmd_test.go b/packages/cli/internal/transport/cobra/skills/cmd_test.go
new file mode 100644
index 00000000..b9692f74
--- /dev/null
+++ b/packages/cli/internal/transport/cobra/skills/cmd_test.go
@@ -0,0 +1,55 @@
+package skillscmd
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os"
+ "reflect"
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+ platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process"
+)
+
+func TestPassthroughPreservesArgumentsStreamsAndExit(t *testing.T) {
+ for _, args := range [][]string{nil, {"--help"}, {"add", "owner/repo", "--skill", "a b", "", "$(literal)", "--yes", "--global", "--agent", "codex"}, {"future-command", "--output", "json"}} {
+ var out, stderr bytes.Buffer
+ cwd, _ := os.Getwd()
+ wantExit := &platformprocess.ExitStatus{Code: 37}
+ called := false
+ root := &cobra.Command{Use: "one", SilenceErrors: true, SilenceUsage: true}
+ root.PersistentFlags().String("output", "", "")
+ root.AddCommand(Commands(func(_ context.Context, dir string, got []string, in io.Reader, stdout, errout io.Writer) error {
+ called = true
+ if dir != cwd || !reflect.DeepEqual(append([]string{}, got...), append([]string{}, args...)) {
+ t.Fatalf("forwarded %s %q; want %s %q", dir, got, cwd, args)
+ }
+ _, _ = io.Copy(stdout, in)
+ _, _ = io.WriteString(errout, "raw tool error\n")
+ return wantExit
+ })...)
+ root.SetIn(bytes.NewBufferString("interactive input\n"))
+ root.SetOut(&out)
+ root.SetErr(&stderr)
+ root.SetArgs(append([]string{"skills"}, args...))
+ err := root.Execute()
+ if !called || !errors.Is(err, wantExit) || out.String() != "interactive input\n" || stderr.String() != "raw tool error\n" {
+ t.Fatalf("passthrough: called=%v error=%v stdout=%q stderr=%q", called, err, out.String(), stderr.String())
+ }
+ }
+}
+
+func TestHelpRefreshesBothLanguages(t *testing.T) {
+ defer i18n.Init(i18n.DefaultLocale)
+ cmd := Commands(nil)[0]
+ for _, locale := range []string{"zh-CN", "en-US", "zh-CN"} {
+ _ = i18n.Init(locale)
+ i18n.RefreshTree(cmd)
+ if cmd.Short != i18n.T("skills.short") || cmd.Long != i18n.T("skills.tip") {
+ t.Fatalf("stale help after switching to %s", locale)
+ }
+ }
+}
diff --git a/packages/cli/internal/transport/http/handlers_project_create.go b/packages/cli/internal/transport/http/handlers_project_create.go
index cde08b20..88b5796d 100644
--- a/packages/cli/internal/transport/http/handlers_project_create.go
+++ b/packages/cli/internal/transport/http/handlers_project_create.go
@@ -19,6 +19,11 @@ type projectTemplate struct {
Category template.Category `json:"category"`
Directory string `json:"directory"`
Toolchain template.Toolchain `json:"toolchain"`
+ Projects []projectLocation `json:"projects,omitempty"`
+}
+type projectLocation struct {
+ Directory string `json:"directory"`
+ Suffix string `json:"suffix"`
}
func handleProjectTemplates() http.HandlerFunc {
@@ -33,9 +38,19 @@ func handleProjectTemplates() http.HandlerFunc {
template.CategoryFrontend: "apps", template.CategoryBackend: "services", template.CategoryLibrary: "packages",
}
for _, entry := range registry.Templates {
+ layouts, err := template.ProjectLayouts(strings.TrimPrefix(entry.Repo, template.LocalTemplatePrefix))
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ var locations []projectLocation
+ for _, layout := range layouts {
+ locations = append(locations, projectLocation{Directory: directories[layout.Category], Suffix: layout.Suffix})
+ }
templates = append(templates, projectTemplate{
ID: entry.ID, Name: entry.DisplayName(), Description: entry.DisplayDescription(),
Category: entry.Category, Directory: directories[entry.Category], Toolchain: entry.Toolchain,
+ Projects: locations,
})
}
writeJSON(w, http.StatusOK, map[string]any{"templates": templates})
@@ -45,13 +60,15 @@ func handleProjectTemplates() http.HandlerFunc {
type createProjectRequest struct {
Name string `json:"name"`
TemplateID string `json:"templateId"`
+ SkipSkills bool `json:"skipSkills,omitempty"`
}
type createProjectResponse struct {
- Name string `json:"name"`
- RelativeDir string `json:"relativeDir"`
- TemplateID string `json:"templateId"`
- Warnings []string `json:"warnings,omitempty"`
+ Name string `json:"name"`
+ RelativeDir string `json:"relativeDir"`
+ TemplateID string `json:"templateId"`
+ Warnings []string `json:"warnings,omitempty"`
+ Projects []createProjectResponse `json:"projects,omitempty"`
}
// The registry gateway resolves the selected Workspace; the request only
@@ -78,19 +95,13 @@ func handleCreateProject(opts MuxOpts) http.HandlerFunc {
writeServiceError(w, err)
return
}
- var selected *template.Template
- for i := range registry.Templates {
- if registry.Templates[i].ID == body.TemplateID {
- selected = ®istry.Templates[i]
- break
- }
- }
- if selected == nil {
- writeServiceError(w, cliErrors.New(cliErrors.TEMPLATE_NOT_FOUND, i18n.Tf("add.template_missing", body.TemplateID)))
+ selected, err := registry.Resolve(body.TemplateID)
+ if err != nil {
+ writeServiceError(w, err)
return
}
result, err := opts.CreationService.AddProject(r.Context(), opts.WorkspaceRoot, creationmodule.ProjectInput{
- Template: selected, Name: body.Name,
+ Template: selected, Name: body.Name, SkipSkills: body.SkipSkills,
})
if err != nil {
writeServiceError(w, err)
@@ -101,9 +112,18 @@ func handleCreateProject(opts MuxOpts) http.HandlerFunc {
writeServiceError(w, err)
return
}
- writeJSON(w, http.StatusCreated, createProjectResponse{
+ response := createProjectResponse{
Name: result.Project.Name, RelativeDir: filepath.ToSlash(relativeDir),
TemplateID: result.Project.TemplateID, Warnings: result.Project.Warnings,
- })
+ }
+ for _, project := range result.Project.Projects {
+ rel, err := filepath.Rel(opts.WorkspaceRoot, project.TargetPath)
+ if err != nil {
+ writeServiceError(w, err)
+ return
+ }
+ response.Projects = append(response.Projects, createProjectResponse{Name: project.Name, RelativeDir: filepath.ToSlash(rel), TemplateID: project.TemplateID})
+ }
+ writeJSON(w, http.StatusCreated, response)
}
}
diff --git a/packages/cli/internal/transport/http/handlers_project_create_test.go b/packages/cli/internal/transport/http/handlers_project_create_test.go
index 2ef2abe5..1674018e 100644
--- a/packages/cli/internal/transport/http/handlers_project_create_test.go
+++ b/packages/cli/internal/transport/http/handlers_project_create_test.go
@@ -155,6 +155,9 @@ func TestProjectTemplatesLocalized(t *testing.T) {
t.Fatal("no templates")
}
for _, entry := range result.Templates {
+ if entry.ID == "electron-app" && len(entry.Projects) != 3 {
+ t.Fatalf("missing composite locations: %#v", entry)
+ }
if entry.Directory == "" || entry.Name != i18n.T("template."+entry.ID+".name") {
t.Fatalf("template = %#v", entry)
}
@@ -205,3 +208,54 @@ func TestCreateEmptyProjectsInSelectedWorkspace(t *testing.T) {
}
}
}
+
+func TestCreateElectronGroupAndRejectMemberConflicts(t *testing.T) {
+ registry := newRegistryService(t)
+ root := seedRegistryWorkspace(t, "desktop", "Desktop", "")
+ selected := observeRegistryWorkspace(t, registry, root)
+ handler := newRegistryMux(t, root, registry)
+ endpoint := "/api/workspaces/" + selected.EntryID + "/projects"
+ response := registryRequest(t, handler, http.MethodPost, endpoint, strings.NewReader(`{"name":"desktop","templateId":"electron-app"}`))
+ if response.Code != http.StatusCreated {
+ t.Fatalf("create: %d %s", response.Code, response.Body.String())
+ }
+ var created createProjectResponse
+ if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil {
+ t.Fatal(err)
+ }
+ if created.Name != "desktop" || len(created.Projects) != 3 {
+ t.Fatalf("created = %#v", created)
+ }
+ for i, relative := range []string{"apps/desktop-renderer", "services/desktop-main", "packages/desktop-preload"} {
+ if created.Projects[i].RelativeDir != relative {
+ t.Fatalf("member = %#v", created.Projects[i])
+ }
+ if _, err := os.Stat(filepath.Join(root, relative, "package.json")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before := snapshotRepositoryTree(t, root)
+ for _, body := range []string{
+ `{"name":"desktop","templateId":"electron-app"}`,
+ `{"name":"desktop","templateId":"react-spa"}`,
+ `{"name":"desktop-main","templateId":"react-spa"}`,
+ } {
+ response = registryRequest(t, handler, http.MethodPost, endpoint, strings.NewReader(body))
+ if response.Code != http.StatusConflict {
+ t.Fatalf("duplicate: %d %s", response.Code, response.Body.String())
+ }
+ assertRepositoryUnchanged(t, root, before)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "services/studio-main"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "services/studio-main/keep.txt"), []byte("keep"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ before = snapshotRepositoryTree(t, root)
+ response = registryRequest(t, handler, http.MethodPost, endpoint, strings.NewReader(`{"name":"studio","templateId":"electron-app"}`))
+ if response.Code != http.StatusConflict {
+ t.Fatalf("member conflict: %d %s", response.Code, response.Body.String())
+ }
+ assertRepositoryUnchanged(t, root, before)
+}
diff --git a/packages/cli/testdata/reference/help/add.txt b/packages/cli/testdata/reference/help/add.txt
index 27e3ba39..6a5ab64a 100644
--- a/packages/cli/testdata/reference/help/add.txt
+++ b/packages/cli/testdata/reference/help/add.txt
@@ -12,9 +12,10 @@ EXAMPLES
one add empty-service --name api --yes
TIPS
-Add a project from a template, or start empty and configure a technology stack later.
+Add a project from a template, or start empty and configure a technology stack later. Installs missing workspace and technology-stack skills after generation; use --skip-skills to skip. Business skills are installed manually.
COMMON OPTIONS
-n, --name Project name
-o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped)
+ --skip-skills Skip automatic installation of workspace and technology-stack skills
-y, --yes Run non-interactively
diff --git a/packages/cli/testdata/reference/help/create.txt b/packages/cli/testdata/reference/help/create.txt
index 9146d939..9eadb3e2 100644
--- a/packages/cli/testdata/reference/help/create.txt
+++ b/packages/cli/testdata/reference/help/create.txt
@@ -11,10 +11,11 @@ EXAMPLES
one create demo --yes
TIPS
-Create an empty workspace named after its target folder. Empty directories and empty Git repositories are supported. Run one add afterward to add a project.
+Create an empty workspace named after its target folder. Empty directories and empty Git repositories are supported. Run one add afterward to add a project. Installs one-cli usage and find-skills discovery guidance in the workspace's .agents/skills by default; use --skip-skills to skip. Business skills are installed manually.
COMMON OPTIONS
-o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped)
+ --skip-skills Skip automatic installation of workspace and technology-stack skills
-y, --yes Use defaults without prompting
AUTOMATION AND ADVANCED OPTIONS
diff --git a/packages/cli/testdata/reference/help/skills.txt b/packages/cli/testdata/reference/help/skills.txt
new file mode 100644
index 00000000..27bbf72b
--- /dev/null
+++ b/packages/cli/testdata/reference/help/skills.txt
@@ -0,0 +1,23 @@
+
+DESCRIPTION
+Forward commands to the upstream Skills CLI
+
+USAGE
+ one skills [args...] [flags]
+
+EXAMPLES
+ one skills find react
+ one skills add owner/repo --skill skill-name
+ one skills list
+ one skills update
+
+TIPS
+Run npx skills@1.7.0 with all arguments unchanged in the current directory.
+One prepares Node when it is missing and preserves upstream interaction, output,
+and exit codes. Use one skills --help for upstream help.
+
+Business skills are installed only on request from their GitHub repositories.
+One does not add --global, --agent, or the Skills CLI --yes flag to manual commands.
+
+COMMON OPTIONS
+ -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped)
diff --git a/packages/cli/testdata/reference/templates.json b/packages/cli/testdata/reference/templates.json
index 70a5f6fa..f45010ff 100644
--- a/packages/cli/testdata/reference/templates.json
+++ b/packages/cli/testdata/reference/templates.json
@@ -35,13 +35,14 @@
},
{
"category": "frontend",
- "description": "前端静态站模板,适合官网、营销站与内容站点",
- "id": "astro-site",
- "name": "Astro 静态网站",
- "repo": "local:astro-site",
+ "description": "Next.js + React 静态导出,适合官网、营销页与内容站点",
+ "id": "nextjs-site",
+ "name": "Next.js 静态网站",
+ "repo": "local:nextjs-site",
"tags": [
"web",
- "astro",
+ "nextjs",
+ "react",
"static",
"frontend"
],
@@ -49,15 +50,15 @@
},
{
"category": "frontend",
- "description": "基于 Astro + Starlight 的文档项目模板",
- "id": "starlight-docs",
- "name": "Starlight 文档站点",
- "repo": "local:starlight-docs",
+ "description": "Next.js + Fumadocs + Base UI,支持中英文文档与静态搜索",
+ "id": "fumadocs-docs",
+ "name": "Fumadocs 文档站点",
+ "repo": "local:fumadocs-docs",
"tags": [
"web",
+ "nextjs",
+ "react",
"docs",
- "starlight",
- "astro",
"frontend"
],
"toolchain": "node"
@@ -137,7 +138,7 @@
},
{
"category": "frontend",
- "description": "Electron + React + Vite 桌面应用模板,主进程基于 inversify,内置 electron-log/store/updater",
+ "description": "Electron + React + Vite 桌面应用模板,生成 renderer、main、preload 三个顶层项目,主进程使用 Awilix 工厂函数",
"id": "electron-app",
"name": "Electron 桌面应用",
"repo": "local:electron-app",
diff --git a/packages/cli/tests/e2e/e2e_helpers_test.go b/packages/cli/tests/e2e/e2e_helpers_test.go
index 7c0d3559..55958e02 100644
--- a/packages/cli/tests/e2e/e2e_helpers_test.go
+++ b/packages/cli/tests/e2e/e2e_helpers_test.go
@@ -68,6 +68,11 @@ func runBinary(t *testing.T, args ...string) (stdout, stderr string, exitCode in
func runBinaryIn(t *testing.T, dir string, args ...string) (stdout, stderr string, exitCode int) {
t.Helper()
bin := binaryPath(t)
+ // Legacy scaffold/contract fixtures stay offline. Skills installation has
+ // dedicated integration tests; those launch the binary explicitly.
+ if len(args) > 0 && (args[0] == "create" || args[0] == "add") {
+ args = append(append([]string(nil), args...), "--skip-skills")
+ }
cmd := exec.Command(bin, args...)
if dir != "" {
cmd.Dir = dir
diff --git a/packages/cli/tests/e2e/electron_composite_test.go b/packages/cli/tests/e2e/electron_composite_test.go
new file mode 100644
index 00000000..162aa2ef
--- /dev/null
+++ b/packages/cli/tests/e2e/electron_composite_test.go
@@ -0,0 +1,109 @@
+package cli_test
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestE2E_ElectronCompositeNamingAndGroupPlans(t *testing.T) {
+ tmp := t.TempDir()
+ if runtime.GOOS != "windows" {
+ // Exercise the same directory-alias behavior as macOS's /var -> /private/var.
+ alias := filepath.Join(t.TempDir(), "linked-temp")
+ if err := os.Symlink(tmp, alias); err != nil {
+ t.Fatal(err)
+ }
+ tmp = alias
+ }
+ isolateHome(t, tmp)
+ root := bootstrapWorkspace(t, tmp, "workspace")
+ for _, name := range []string{"desktop", "electron"} {
+ stdout, stderr, code := runBinaryIn(t, root, "add", "electron-app", "--name", name, "-y", "-o", "json")
+ if code != 0 {
+ t.Fatalf("add: %d\n%s\n%s", code, stdout, stderr)
+ }
+ var result struct {
+ Name string `json:"subproject_name"`
+ Projects []struct {
+ Name string `json:"name"`
+ Path string `json:"target_path"`
+ } `json:"projects"`
+ }
+ if err := json.Unmarshal([]byte(stdout), &result); err != nil {
+ t.Fatal(err, stdout)
+ }
+ if result.Name != name || len(result.Projects) != 3 {
+ t.Fatal(stdout)
+ }
+ for i, location := range []struct{ directory, suffix string }{{"apps", "-renderer"}, {"services", "-main"}, {"packages", "-preload"}} {
+ member := result.Projects[i]
+ if member.Name != name+location.suffix {
+ t.Fatalf("member name=%q want %q", member.Name, name+location.suffix)
+ }
+ wantPath := filepath.Join(root, location.directory, member.Name)
+ wantDirectory, err := os.Stat(wantPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gotDirectory, err := os.Stat(member.Path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !os.SameFile(wantDirectory, gotDirectory) {
+ t.Fatalf("member path=%q want %q", member.Path, wantPath)
+ }
+ if _, err := os.Stat(filepath.Join(member.Path, "package.json")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(root, "apps", name)); !os.IsNotExist(err) {
+ t.Fatal("nested wrapper still exists")
+ }
+ }
+ for _, op := range []string{"dev", "build", "test", "pack"} {
+ stdout, stderr, code := runBinaryIn(t, root, "run", "desktop:"+op, "--dry-run", "-o", "json")
+ if code != 0 {
+ t.Fatalf("plan %s: %d\n%s\n%s", op, code, stdout, stderr)
+ }
+ var plan struct {
+ Tasks []struct {
+ Name string `json:"name"`
+ Project string `json:"project"`
+ } `json:"tasks"`
+ }
+ if err := json.Unmarshal([]byte(stdout), &plan); err != nil {
+ t.Fatal(err, stdout)
+ }
+ found := map[string]bool{}
+ for _, task := range plan.Tasks {
+ if strings.HasPrefix(task.Project, "electron-") {
+ t.Fatalf("group crossed into second app: %s", stdout)
+ }
+ found[task.Name] = true
+ }
+ for _, member := range []string{"desktop-renderer", "desktop-main", "desktop-preload"} {
+ task := "//:" + member + ":build"
+ if op == "dev" {
+ task = "//:" + member + ":dev"
+ }
+ if op == "test" && member == "desktop-main" {
+ task = "//:" + member + ":test"
+ }
+ if !found[task] {
+ t.Fatalf("missing %s in %s", task, stdout)
+ }
+ }
+ }
+ stdout, stderr, code := runBinaryIn(t, root, "init", "mise", "-o", "json")
+ if code != 0 {
+ t.Fatalf("refresh: %d\n%s\n%s", code, stdout, stderr)
+ }
+ stdout, stderr, code = runBinaryIn(t, root, "run", "desktop:pack", "--dry-run", "-o", "json")
+ if code != 0 {
+ t.Fatalf("refreshed group: %d\n%s\n%s", code, stdout, stderr)
+ }
+}
diff --git a/packages/cli/tests/e2e/skills_terminal_unix_test.go b/packages/cli/tests/e2e/skills_terminal_unix_test.go
new file mode 100644
index 00000000..ca77017a
--- /dev/null
+++ b/packages/cli/tests/e2e/skills_terminal_unix_test.go
@@ -0,0 +1,94 @@
+//go:build unix
+
+package cli_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n"
+)
+
+func TestE2E_AutomaticSkillsKeepLocalizedSpinnerClean(t *testing.T) {
+ t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) })
+ for _, locale := range []struct{ id, env string }{{"en-US", "en_US.UTF-8"}, {"zh-CN", "zh_CN.UTF-8"}} {
+ t.Run(locale.id, func(t *testing.T) {
+ parent := t.TempDir()
+ isolateHome(t, t.TempDir())
+ t.Setenv("LC_ALL", locale.env)
+ if err := i18n.Init(locale.id); err != nil {
+ t.Fatal(err)
+ }
+ tools := t.TempDir()
+ script := `#!/bin/sh
+set -eu
+printf 'SKILLS_UPSTREAM_BANNER\nTip: use --global\n'
+printf 'UPSTREAM_STDERR_NOTICE\n' >&2
+selecting=no
+for arg in "$@"; do
+ case "$arg" in
+ --skill) selecting=yes ;;
+ --agent) selecting=no ;;
+ *) if [ "$selecting" = yes ]; then
+ mkdir -p ".agents/skills/$arg"
+ printf 'fixture skill\n' > ".agents/skills/$arg/SKILL.md"
+ fi ;;
+ esac
+done
+sleep 0.3
+`
+ if err := os.WriteFile(filepath.Join(tools, "npx"), []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", tools+string(os.PathListSeparator)+os.Getenv("PATH"))
+ created := startTaskTerminal(t, parent, "create", "demo", "--yes")
+ created.wait(t, 0)
+ assertSkillsSpinner(t, created.out.String(), "one-cli")
+ assertSkillsSpinner(t, created.out.String(), "find-skills")
+ root := filepath.Join(parent, "demo")
+ added := startTaskTerminal(t, root, "add", "go-lib", "--name", "shared", "--yes")
+ added.wait(t, 0)
+ assertSkillsSpinner(t, added.out.String(), "golang-patterns, golang-testing")
+ mobile := startTaskTerminal(t, root, "add", "expo-mobile", "--name", "mobile", "--yes")
+ mobile.wait(t, 0)
+ assertSkillsSpinner(t, mobile.out.String(), "expo-overview")
+ truncated := false
+ for _, fragment := range strings.Split(mobile.out.String(), "\r") {
+ line := ansi.Strip(fragment)
+ characters := []rune(line)
+ if len(characters) == 0 || characters[0] < 0x2800 || characters[0] > 0x28ff {
+ continue
+ }
+ if ansi.StringWidth(line) >= 110 {
+ t.Fatalf("spinner would wrap in the 110-column terminal: %q", line)
+ }
+ truncated = truncated || strings.HasSuffix(line, "…")
+ }
+ if !truncated {
+ t.Fatalf("long skill names were not truncated: %q", mobile.out.String())
+ }
+ })
+ }
+}
+
+func assertSkillsSpinner(t *testing.T, got, names string) {
+ t.Helper()
+ for _, hidden := range []string{"SKILLS_UPSTREAM_BANNER", "Tip: use --global", "UPSTREAM_STDERR_NOTICE"} {
+ if strings.Contains(got, hidden) {
+ t.Fatalf("automatic install leaked upstream UI: %q", got)
+ }
+ }
+ message := i18n.Tf("skills.installing", names)
+ at := strings.Index(got, message)
+ if at < 0 {
+ t.Fatalf("missing localized skills progress %q: %q", message, got)
+ }
+ start := strings.LastIndex(got[:at], "\r")
+ if start < 0 || !strings.HasPrefix(got[start:at], "\r\x1b[2K") || strings.Contains(got[start:at], "\n") {
+ t.Fatalf("skills status did not replace the spinner on a cleared line: %q", got)
+ }
+}
diff --git a/packages/cli/tests/e2e/skills_test.go b/packages/cli/tests/e2e/skills_test.go
new file mode 100644
index 00000000..55ef0953
--- /dev/null
+++ b/packages/cli/tests/e2e/skills_test.go
@@ -0,0 +1,121 @@
+package cli_test
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestE2E_CreateInstallsCommonSkillsInWorkspace(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("POSIX fake npx; cross-platform runner is covered by helper-process tests")
+ }
+ parent := t.TempDir()
+ home := t.TempDir()
+ isolateHome(t, home)
+ globalPath := filepath.Join(home, ".agents", "skills", "one-cli", "SKILL.md")
+ if err := os.MkdirAll(filepath.Dir(globalPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ globalContent := []byte("user's global guidance")
+ if err := os.WriteFile(globalPath, globalContent, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ tools := t.TempDir()
+ tracePath := filepath.Join(tools, "trace")
+ t.Setenv("ONE_SKILLS_TEST_TRACE", tracePath)
+ script := `#!/bin/sh
+set -eu
+printf '%s\n' "$PWD" "$@" >> "$ONE_SKILLS_TEST_TRACE"
+printf 'SKILLS_UPSTREAM_BANNER\nTip: use --global\n'
+printf 'UPSTREAM_STDERR_NOTICE\n' >&2
+for arg in "$@"; do
+ case "$arg" in --global|-g) exit 17 ;; esac
+done
+mkdir -p ".agents/skills/$6"
+case "$6" in
+ one-cli) test "$4" = './.agents/skills'; test -s "$4/one-cli/SKILL.md" ;;
+ find-skills) printf 'discovery fixture\n' > .agents/skills/find-skills/SKILL.md ;;
+ *) exit 18 ;;
+esac
+`
+ if err := os.WriteFile(filepath.Join(tools, "npx"), []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", tools+string(os.PathListSeparator)+os.Getenv("PATH"))
+ root := filepath.Join(parent, "demo")
+ cmd := exec.Command(binaryPath(t), "create", root, "--yes", "-o", "json")
+ cmd.Dir = parent
+ var out, stderr bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &out, &stderr
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("create: %v stdout=%s stderr=%s", err, &out, &stderr)
+ }
+ mustParseJSON(t, out.String())
+ for _, hidden := range []string{"SKILLS_UPSTREAM_BANNER", "Tip: use --global", "UPSTREAM_STDERR_NOTICE"} {
+ if strings.Contains(out.String(), hidden) || strings.Contains(stderr.String(), hidden) {
+ t.Fatalf("automatic install leaked upstream UI: stdout=%s stderr=%s", &out, &stderr)
+ }
+ }
+ entries, err := os.ReadDir(filepath.Join(root, ".agents", "skills"))
+ if err != nil || len(entries) != 2 || entries[0].Name() != "find-skills" || entries[1].Name() != "one-cli" {
+ t.Fatalf("unexpected project skills: %v %v", entries, err)
+ }
+ localContent, err := os.ReadFile(filepath.Join(root, ".agents", "skills", "one-cli", "SKILL.md"))
+ if err != nil || len(localContent) == 0 || bytes.Equal(localContent, globalContent) {
+ t.Fatalf("global skill was used instead of a project installation: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(root, ".one")); !os.IsNotExist(err) {
+ t.Fatalf("create left a duplicate skill source directory: %v", err)
+ }
+ ignored := exec.Command("git", "check-ignore", "--no-index", "--stdin")
+ ignored.Dir = root
+ ignored.Stdin = strings.NewReader(".one/cache.json\n.agents/skills/one-cli/SKILL.md\nskills-lock.json\n")
+ if got, err := ignored.CombinedOutput(); err != nil || string(got) != ".one/cache.json\n" {
+ t.Fatalf("local state must be ignored while skills and their lock remain versionable: %q %v", got, err)
+ }
+ globalAfter, err := os.ReadFile(globalPath)
+ if err != nil || !bytes.Equal(globalAfter, globalContent) {
+ t.Fatalf("global guidance changed: %v", err)
+ }
+ trace, err := os.ReadFile(tracePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ canonical, err := filepath.EvalSymlinks(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Count(string(trace), canonical+"\n") != 2 || strings.Count(string(trace), "skills@1.7.0\n") != 2 || !strings.Contains(string(trace), "\nfind-skills\n") || !strings.Contains(string(trace), "\nvercel-labs/skills#") || strings.Contains(string(trace), "--global") {
+ t.Fatalf("common skills did not install from their sources in the project: %s", trace)
+ }
+}
+
+func TestE2E_SkillsPreservesUpstreamArgumentsHelpAndExitCode(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("POSIX fake npx; cross-platform runner is covered by helper-process tests")
+ }
+ root := t.TempDir()
+ isolateHome(t, t.TempDir())
+ tools := t.TempDir()
+ script := "#!/bin/sh\npwd\nprintf '%s\\n' \"$@\"\nprintf 'native stderr\\n' >&2\nexit 37\n"
+ if err := os.WriteFile(filepath.Join(tools, "npx"), []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("PATH", tools+string(os.PathListSeparator)+os.Getenv("PATH"))
+ canonical, err := filepath.EvalSymlinks(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{{"--help"}, {"add", "owner/repo", "--skill", "a b", "", "$(literal)", "--global", "--yes", "--agent", "codex", "--output", "json"}} {
+ out, stderr, code := runBinaryIn(t, root, append([]string{"skills"}, args...)...)
+ want := canonical + "\n--yes\nskills@1.7.0\n" + strings.Join(args, "\n") + "\n"
+ if code != 37 || out != want || stderr != "native stderr\n" {
+ t.Fatalf("upstream semantics changed: code=%d out=%q stderr=%q, want %q", code, out, stderr, want)
+ }
+ }
+}
diff --git a/packages/cli/tests/e2e/snapshot_e2e_add_test.go b/packages/cli/tests/e2e/snapshot_e2e_add_test.go
index 0416dfb7..a628ba27 100644
--- a/packages/cli/tests/e2e/snapshot_e2e_add_test.go
+++ b/packages/cli/tests/e2e/snapshot_e2e_add_test.go
@@ -79,12 +79,8 @@ func TestSnapshot_E2E_Add_DefersDeploymentDefaults(t *testing.T) {
t.Errorf("rendered router.go missing Swagger wiring %q:\n%s", want, router)
}
}
- appHandlerRaw, err := os.ReadFile(filepath.Join(svcDir, "internal", "http", "handlers", "app_handler.go"))
- if err != nil {
- t.Fatalf("read rendered app_handler.go: %v", err)
- }
- if !strings.Contains(string(appHandlerRaw), `c.Redirect(http.StatusTemporaryRedirect, "/api/docs/index.html")`) {
- t.Errorf("rendered app handler should redirect /api/docs to Swagger UI:\n%s", appHandlerRaw)
+ if !strings.Contains(router, `c.Redirect(http.StatusTemporaryRedirect, "/api/docs/index.html")`) {
+ t.Errorf("rendered router should redirect /api/docs to Swagger UI:\n%s", router)
}
openAPIRaw, err := os.ReadFile(filepath.Join(svcDir, "api", "openapi.yaml"))
if err != nil {
diff --git a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go b/packages/cli/tests/e2e/snapshot_e2e_ux_test.go
index f5124409..64ff7fbb 100644
--- a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go
+++ b/packages/cli/tests/e2e/snapshot_e2e_ux_test.go
@@ -67,12 +67,12 @@ func TestSnapshot_E2E_HelpDailyAndCompleteCatalogues(t *testing.T) {
if code != 0 || stderr != "" {
t.Fatalf("one help --all failed: exit=%d stderr=%q", code, stderr)
}
- for _, command := range []string{"create", "add", "env", "login", "logout", "whoami", "run", "exec", "templates", "serve", "init", "locale", "mise", "hk"} {
+ for _, command := range []string{"create", "add", "env", "login", "logout", "whoami", "run", "exec", "templates", "serve", "init", "locale", "mise", "hk", "skills"} {
if !strings.Contains(all, "\n "+command+" ") {
t.Errorf("complete help missing %q:\n%s", command, all)
}
}
- for _, command := range []string{"dev", "build", "test", "lint", "ci", "skills"} {
+ for _, command := range []string{"dev", "build", "test", "lint", "ci"} {
for _, help := range []string{daily, all} {
if strings.Contains(help, "\n "+command+" ") {
t.Errorf("help advertises task or removed command %q as built-in:\n%s", command, help)
diff --git a/packages/cli/tests/e2e/tasks_cache_test.go b/packages/cli/tests/e2e/tasks_cache_test.go
index be298860..56fb31c5 100644
--- a/packages/cli/tests/e2e/tasks_cache_test.go
+++ b/packages/cli/tests/e2e/tasks_cache_test.go
@@ -100,6 +100,13 @@ func TestE2E_GoTaskBuildRebuildsExecutableAndForwardsArguments(t *testing.T) {
taskBinary = strings.TrimSpace(string(out))
}
root := buildFixture(t, true)
+ // Own the fixture's VCS boundary rather than inheriting a parent repository
+ // or a sandbox's placeholder .git directory under the temporary root.
+ gitInit := exec.Command("git", "init", "--quiet")
+ gitInit.Dir = root
+ if err := gitInit.Run(); err != nil {
+ t.Fatal(err)
+ }
if err := os.Symlink(taskBinary, filepath.Join(root, "tools/task")); err != nil {
t.Fatal(err)
}
diff --git a/packages/cli/tools/sync-resources/main.go b/packages/cli/tools/sync-resources/main.go
index c3088aad..ee8f5c2f 100644
--- a/packages/cli/tools/sync-resources/main.go
+++ b/packages/cli/tools/sync-resources/main.go
@@ -97,7 +97,8 @@ func syncBundled(root string) error {
return err
}
- return nil
+ return replaceDir(root, filepath.Join(root, "packages", "agent-skills", "skills"),
+ filepath.Join(bundled, "_skills"), nil, false)
}
func syncWeb(root string) error {
diff --git a/packages/cli/tools/sync-resources/main_test.go b/packages/cli/tools/sync-resources/main_test.go
index 6a20ad96..1154a646 100644
--- a/packages/cli/tools/sync-resources/main_test.go
+++ b/packages/cli/tools/sync-resources/main_test.go
@@ -26,16 +26,17 @@ func TestSyncBundledCopiesCanonicalAssetsAndMapsModuleFiles(t *testing.T) {
writeTestFile(t, root, "skills/one-cli/SKILL.md", "one-cli skill")
writeTestFile(t, root, "skills/one-migrate/SKILL.md", "retired skill")
writeTestFile(t, root, "skills/one-cli/references/old.md", "retired reference")
+ writeTestFile(t, root, "packages/agent-skills/skills/one-cli/SKILL.md", "current skill")
if err := syncBundled(root); err != nil {
t.Fatalf("syncBundled: %v", err)
}
bundled := filepath.Join(root, "packages", "cli", "internal", "resources", "bundled")
- for _, rel := range []string{"registry.json", "_templates/go-api/main.go", "_templates/go-api/_go.mod"} {
+ for _, rel := range []string{"registry.json", "_templates/go-api/main.go", "_templates/go-api/_go.mod", "_skills/one-cli/SKILL.md"} {
if _, err := os.Stat(filepath.Join(bundled, filepath.FromSlash(rel))); err != nil {
t.Errorf("expected %s: %v", rel, err)
}
}
- for _, rel := range []string{"_templates/registry.json", "_templates/go-api/go.mod", "_skills"} {
+ for _, rel := range []string{"_templates/registry.json", "_templates/go-api/go.mod", "_skills/one-migrate", "_skills/one-cli/references/old.md"} {
if _, err := os.Stat(filepath.Join(bundled, filepath.FromSlash(rel))); !os.IsNotExist(err) {
t.Errorf("expected %s to be stripped, stat err=%v", rel, err)
}
diff --git a/packages/templates/astro-site/.env.example b/packages/templates/astro-site/.env.example
deleted file mode 100644
index 5ba7579b..00000000
--- a/packages/templates/astro-site/.env.example
+++ /dev/null
@@ -1,3 +0,0 @@
-# Add project-specific environment variables here when needed.
-# Example:
-# PUBLIC_API_BASE_URL="https://api.example.com"
diff --git a/packages/templates/astro-site/.gitignore b/packages/templates/astro-site/.gitignore
deleted file mode 100644
index e4cd4347..00000000
--- a/packages/templates/astro-site/.gitignore
+++ /dev/null
@@ -1,30 +0,0 @@
-# build output
-dist/
-
-# generated types
-.astro/
-
-# dependencies
-node_modules/
-
-# logs
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-
-# macOS-specific files
-.DS_Store
-
-# jetbrains setting folder
-.idea/
-
-# Local Environment Variables
-# -----------------
-# These files are for local development and should not be committed to the repository.
-# They may contain sensitive data.
-# Use .env.example as a template.
-.env
-*.local
-
-# Sentry Con
diff --git a/packages/templates/astro-site/README.md b/packages/templates/astro-site/README.md
deleted file mode 100644
index ac291060..00000000
--- a/packages/templates/astro-site/README.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# Web SSG Astro Template
-
-基于 Astro 的静态站点模板,适合官网、营销页和纯静态内容站点。模板保留了设计令牌、统一主题样式和轻量请求工具,适合作为 `one-cli` 的前端 SSG 起点。
-
-## 技术栈
-
-- Astro 7
-- TypeScript 6
-- Tailwind CSS v4
-- `@astrojs/check`
-- oxlint + oxfmt
-
-## 目录结构
-
-```text
-public/ # 静态资源
-src/
-├── api/ # 请求 key 与纯函数 API
-├── assets/ # 模板图片资源
-├── components/ # Astro 组件
-├── layouts/ # 页面布局
-├── pages/ # 文件路由
-├── styles/ # 样式入口与 design token
-└── utils/ # API / toast 等工具
-```
-
-## 快速开始
-
-```bash
-pnpm install
-pnpm dev
-```
-
-默认开发地址:`http://localhost:4321`
-
-## 常用命令
-
-| 命令 | 说明 |
-| ----------------- | ------------------------------------ |
-| `pnpm dev` | 启动开发服务器 |
-| `pnpm build` | 先执行 `astro check`,再构建生产产物 |
-| `pnpm preview` | 预览构建结果 |
-| `pnpm astro` | 直接调用 Astro CLI |
-| `pnpm lint` | 执行 oxlint |
-| `pnpm lint:fix` | 自动修复 lint 问题 |
-| `pnpm format` | 检查格式 |
-| `pnpm format:fix` | 自动格式化 |
-| `pnpm check` | 执行 lint + format |
-| `pnpm check:fix` | 执行 lint:fix + format:fix |
-
-## 请求层约定
-
-模板示例里的网络请求也统一采用 “`key + pure function`” 结构:
-
-```ts
-export const demoPostKey = "https://jsonplaceholder.typicode.com/posts/1";
-
-export function getDemoPost() {
- return api.get(demoPostKey);
-}
-```
-
-当前示例位于:
-
-- `src/api/demo.ts`
-- `src/utils/api.ts`
-- `src/utils/toast.ts`
-
-## 设计系统
-
-- `src/styles/global.css` 是样式入口,负责引入 Tailwind 与全局基础样式
-- `src/styles/tokens.css` 是唯一 design token 源,维护 Tailwind CSS v4 变量与动画
-- `src/layouts/Layout.astro` 和 `src/components/Welcome.astro` 展示了模板的默认页面组织方式
-
-## 说明
-
-这是模板仓库,不内置项目级 Git hooks、Biome 配置或发布工作流。工作区治理由 `one-cli` 提供,提交检查使用工作区的 hk 配置;版本管理和发布流程由项目按需配置。
-
-## TypeScript 版本
-
-此模板使用 TypeScript 6。`astro check` 依赖 TypeScript 的 JavaScript 编译器接口,
-当前检查工具尚不支持 TypeScript 7,因此不要单独升级到 TS7。
diff --git a/packages/templates/astro-site/astro.config.mjs b/packages/templates/astro-site/astro.config.mjs
deleted file mode 100644
index c92da047..00000000
--- a/packages/templates/astro-site/astro.config.mjs
+++ /dev/null
@@ -1,10 +0,0 @@
-// @ts-check
-import { defineConfig } from "astro/config";
-import tailwindcss from "@tailwindcss/vite";
-
-// https://astro.build/config
-export default defineConfig({
- vite: {
- plugins: [tailwindcss()],
- },
-});
diff --git a/packages/templates/astro-site/package.json b/packages/templates/astro-site/package.json
deleted file mode 100644
index dd772b82..00000000
--- a/packages/templates/astro-site/package.json
+++ /dev/null
@@ -1,42 +0,0 @@
-{
- "name": "astro-site-template",
- "version": "0.1.0",
- "type": "module",
- "scripts": {
- "dev": "astro dev",
- "build": "pnpm astro check && astro build",
- "preview": "astro preview",
- "astro": "astro",
- "lint": "oxlint .",
- "lint:fix": "oxlint --fix .",
- "format": "oxfmt --check .",
- "format:fix": "oxfmt --write .",
- "check": "pnpm run lint && pnpm run format",
- "check:fix": "pnpm run lint:fix && pnpm run format:fix"
- },
- "dependencies": {
- "@astrojs/check": "^0.9.10",
- "@tailwindcss/vite": "^4.3.3",
- "astro": "^7.3.5",
- "tailwindcss": "^4.3.3",
- "typescript": "^6.0.3"
- },
- "devDependencies": {
- "git-cz": "^4.9.0",
- "oxfmt": "^0.71.0",
- "oxlint": "^1.86.0"
- },
- "lint-staged": {
- "*.{js,jsx,ts,tsx,mjs,cjs}": [
- "oxlint --fix",
- "oxfmt --write --no-error-on-unmatched-pattern"
- ],
- "*.{json,md,css,scss,html,yml,yaml}": [
- "oxfmt --write --no-error-on-unmatched-pattern"
- ]
- },
- "engines": {
- "node": "^24.15.0 || >=26.0.0"
- },
- "packageManager": "pnpm@12.3.4"
-}
diff --git a/packages/templates/astro-site/public/favicon.svg b/packages/templates/astro-site/public/favicon.svg
deleted file mode 100644
index f157bd1c..00000000
--- a/packages/templates/astro-site/public/favicon.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
diff --git a/packages/templates/astro-site/public/onecli-logo-inverted.svg b/packages/templates/astro-site/public/onecli-logo-inverted.svg
deleted file mode 100644
index ee1d78fd..00000000
--- a/packages/templates/astro-site/public/onecli-logo-inverted.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
- One CLI
- One CLI horizontal wordmark — inverted for dark backgrounds
-
- ONE CLI
-
diff --git a/packages/templates/astro-site/public/onecli-logo.svg b/packages/templates/astro-site/public/onecli-logo.svg
deleted file mode 100644
index 6194d834..00000000
--- a/packages/templates/astro-site/public/onecli-logo.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
- One CLI
- One CLI horizontal wordmark — primary, torch
-
- ONE CLI
-
diff --git a/packages/templates/astro-site/src/api/demo.ts b/packages/templates/astro-site/src/api/demo.ts
deleted file mode 100644
index 347e59ff..00000000
--- a/packages/templates/astro-site/src/api/demo.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { api, type ApiResponse } from "../utils/api";
-
-export interface DemoPost {
- userId: number;
- id?: number;
- title: string;
- body: string;
-}
-
-export interface CreateDemoPostRequest {
- title: string;
- body: string;
- userId: number;
-}
-
-export const demoPostKey = "https://jsonplaceholder.typicode.com/posts/1";
-export const createDemoPostKey = "https://jsonplaceholder.typicode.com/posts";
-export const brokenDemoRequestKey = "https://nonexistent-api-endpoint-12345.com/test";
-
-export function getDemoPost(): Promise> {
- return api.get(demoPostKey);
-}
-
-export function createDemoPost(data: CreateDemoPostRequest): Promise> {
- return api.post(createDemoPostKey, data);
-}
-
-export function getBrokenDemoRequest(): Promise> {
- return api.get(brokenDemoRequestKey);
-}
diff --git a/packages/templates/astro-site/src/assets/astro.svg b/packages/templates/astro-site/src/assets/astro.svg
deleted file mode 100644
index 8cf8fb0c..00000000
--- a/packages/templates/astro-site/src/assets/astro.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/templates/astro-site/src/assets/background.svg b/packages/templates/astro-site/src/assets/background.svg
deleted file mode 100644
index 4b2be0ac..00000000
--- a/packages/templates/astro-site/src/assets/background.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/packages/templates/astro-site/src/components/ErrorBoundary.astro b/packages/templates/astro-site/src/components/ErrorBoundary.astro
deleted file mode 100644
index 4fda3409..00000000
--- a/packages/templates/astro-site/src/components/ErrorBoundary.astro
+++ /dev/null
@@ -1,146 +0,0 @@
----
-export interface Props {
- showDetails?: boolean;
-}
-
-const { showDetails = false } = Astro.props;
----
-
-
-
-
-
-
-
-
页面出现问题
-
抱歉,页面遇到了意外错误
-
-
-
-
-
-
-
-
发生了什么?
-
- 页面在运行过程中遇到了错误。我们已经自动记录了这个问题,开发团队会尽快修复。
-
-
-
- {showDetails && (
-
-
- 查看技术详情
-
-
- 错误详情将在这里显示
-
-
- )}
-
-
-
-
-
-
-
- 刷新页面
-
-
-
- 返回首页
-
-
-
- 报告问题
-
-
-
-
-
-
- 如果问题持续存在,请尝试清除浏览器缓存或联系支持团队
-
-
-
-
-
-
-
-
-
diff --git a/packages/templates/astro-site/src/components/Welcome.astro b/packages/templates/astro-site/src/components/Welcome.astro
deleted file mode 100644
index 47adcf56..00000000
--- a/packages/templates/astro-site/src/components/Welcome.astro
+++ /dev/null
@@ -1,72 +0,0 @@
----
-/**
- * Welcome screen — kept intentionally minimal for AI agents to extend.
- *
- * Pre-wired infrastructure (don't recreate; import from these paths):
- * - Layout: src/layouts/Layout.astro (mounts ErrorBoundary + toast container)
- * - Toast system: src/utils/toast.ts (showToast.success/info/warning/error, handleError)
- * - HTTP utils: src/utils/api.ts (fetch wrapper used by api/demo.ts)
- * - Demo API: src/api/demo.ts (getDemoPost, createDemoPost)
- * - Error boundary: src/components/ErrorBoundary.astro
- * - 404 page: src/pages/404.astro
- * - Design tokens: src/styles/tokens.css (Tailwind v4 @theme — primary / success / warning / error / secondary palettes)
- * - Global styles: src/styles/global.css
- *
- * Atomic design (advisory — physical folders NOT enforced):
- * - atoms → inline Tailwind utilities in .astro components (no UI lib here)
- * - molecules → src/components/ (compose atoms; e.g. ResourceLink below)
- * - organisms → src/components/sections/ (page-level blocks; create when needed)
- * - pages → src/pages/ (Astro file-based routes)
- *
- * Design tokens — DO use these utilities (they map to @theme in tokens.css):
- * - Primary: bg-primary-{50..900} / text-primary-{...} / border-primary-{...}
- * - Success: bg-success-{50..900} / text-success-{...}
- * - Warning: bg-warning-{50..900} / text-warning-{...}
- * - Error: bg-error-{50..900} / text-error-{...}
- * - Secondary: bg-secondary-{50..900} / text-secondary-{...} (used for surfaces / text)
- * DON'T hardcode hex / rgb in Tailwind classes.
- */
----
-
-
-
-
-
-
-
- Astro · SSG
-
-
- Welcome to One CLI
-
-
- 一个由 One CLI 生成的 Astro 静态站脚手架,已预置 Tailwind v4、设计令牌、Toast 与错误边界。
-
-
-
-
- Edit
- src/components/Welcome.astro
- and save to start.
-
-
-
-
-
diff --git a/packages/templates/astro-site/src/layouts/Layout.astro b/packages/templates/astro-site/src/layouts/Layout.astro
deleted file mode 100644
index f107be18..00000000
--- a/packages/templates/astro-site/src/layouts/Layout.astro
+++ /dev/null
@@ -1,39 +0,0 @@
----
-import '../styles/global.css';
-import ErrorBoundary from '../components/ErrorBoundary.astro';
-
-export interface Props {
- title?: string;
- description?: string;
-}
-
-const { title = 'Frontend SSG Webapp Template', description = 'A modern Astro template with comprehensive tooling' } = Astro.props;
----
-
-
-
-
-
-
-
-
-
- {title}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/templates/astro-site/src/pages/404.astro b/packages/templates/astro-site/src/pages/404.astro
deleted file mode 100644
index cd10c97a..00000000
--- a/packages/templates/astro-site/src/pages/404.astro
+++ /dev/null
@@ -1,171 +0,0 @@
----
-import Layout from '../layouts/Layout.astro';
----
-
-
-
-
-
-
-
-
-
- 404
-
-
-
-
-
-
-
-
-
-
-
-
-
- 页面未找到
-
-
- 抱歉,您访问的页面不存在
-
-
- 页面可能已被移动、删除,或者URL输入有误
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/templates/astro-site/src/pages/index.astro b/packages/templates/astro-site/src/pages/index.astro
deleted file mode 100644
index 4f3d2e27..00000000
--- a/packages/templates/astro-site/src/pages/index.astro
+++ /dev/null
@@ -1,8 +0,0 @@
----
-import Welcome from '../components/Welcome.astro';
-import Layout from '../layouts/Layout.astro';
----
-
-
-
-
diff --git a/packages/templates/astro-site/src/styles/global.css b/packages/templates/astro-site/src/styles/global.css
deleted file mode 100644
index 7f866579..00000000
--- a/packages/templates/astro-site/src/styles/global.css
+++ /dev/null
@@ -1,13 +0,0 @@
-@import "tailwindcss";
-@import "./tokens.css";
-
-@layer base {
- html,
- body {
- margin: 0;
- min-height: 100vh;
- padding: 0;
- width: 100%;
- font-family: var(--font-sans);
- }
-}
diff --git a/packages/templates/astro-site/src/styles/tokens.css b/packages/templates/astro-site/src/styles/tokens.css
deleted file mode 100644
index 403a7ae0..00000000
--- a/packages/templates/astro-site/src/styles/tokens.css
+++ /dev/null
@@ -1,96 +0,0 @@
-@theme {
- --color-primary-50: #fff7ed;
- --color-primary-100: #ffedd5;
- --color-primary-200: #fed7aa;
- --color-primary-300: #fdba74;
- --color-primary-400: #fb923c;
- --color-primary-500: #ea580c;
- --color-primary-600: #c2410c;
- --color-primary-700: #9a3412;
- --color-primary-800: #7c2d12;
- --color-primary-900: #431407;
- --color-primary-950: #200a02;
-
- --color-secondary-50: #f9fafb;
- --color-secondary-100: #f3f4f6;
- --color-secondary-200: #e5e7eb;
- --color-secondary-300: #d1d5db;
- --color-secondary-400: #9ca3af;
- --color-secondary-500: #6b7280;
- --color-secondary-600: #4b5563;
- --color-secondary-700: #374151;
- --color-secondary-800: #1f2937;
- --color-secondary-900: #111827;
- --color-secondary-950: #030712;
-
- --color-success-50: #f0fdf4;
- --color-success-100: #dcfce7;
- --color-success-200: #bbf7d0;
- --color-success-300: #86efac;
- --color-success-400: #4ade80;
- --color-success-500: #22c55e;
- --color-success-600: #16a34a;
- --color-success-700: #15803d;
- --color-success-800: #166534;
- --color-success-900: #14532d;
- --color-success-950: #052e16;
-
- --color-warning-50: #fffbeb;
- --color-warning-100: #fef3c7;
- --color-warning-200: #fde68a;
- --color-warning-300: #fcd34d;
- --color-warning-400: #fbbf24;
- --color-warning-500: #f59e0b;
- --color-warning-600: #d97706;
- --color-warning-700: #b45309;
- --color-warning-800: #92400e;
- --color-warning-900: #78350f;
- --color-warning-950: #451a03;
-
- --color-error-50: #fef2f2;
- --color-error-100: #fee2e2;
- --color-error-200: #fecaca;
- --color-error-300: #fca5a5;
- --color-error-400: #f87171;
- --color-error-500: #ef4444;
- --color-error-600: #dc2626;
- --color-error-700: #b91c1c;
- --color-error-800: #991b1b;
- --color-error-900: #7f1d1d;
- --color-error-950: #450a0a;
-
- --font-sans: "Inter", system-ui, sans-serif;
- --font-mono: "JetBrains Mono", Menlo, Monaco, monospace;
-
- --spacing-18: 4.5rem;
- --spacing-88: 22rem;
- --spacing-128: 32rem;
-
- --radius-4xl: 2rem;
-
- --animate-fade-in: fadeIn 0.5s ease-in-out;
- --animate-slide-up: slideUp 0.3s ease-out;
- --animate-bounce-slow: bounce 2s infinite;
-}
-
-@keyframes fadeIn {
- 0% {
- opacity: 0;
- }
-
- 100% {
- opacity: 1;
- }
-}
-
-@keyframes slideUp {
- 0% {
- opacity: 0;
- transform: translateY(10px);
- }
-
- 100% {
- opacity: 1;
- transform: translateY(0);
- }
-}
diff --git a/packages/templates/astro-site/src/utils/api.ts b/packages/templates/astro-site/src/utils/api.ts
deleted file mode 100644
index 700013e3..00000000
--- a/packages/templates/astro-site/src/utils/api.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-export interface ApiResponse {
- data: T;
- status: number;
- statusText: string;
-}
-
-export interface ApiError {
- message: string;
- status: number;
- statusText: string;
-}
-
-export class ApiException extends Error {
- public status: number;
- public statusText: string;
-
- constructor(message: string, status: number, statusText: string) {
- super(message);
- this.name = "ApiException";
- this.status = status;
- this.statusText = statusText;
- }
-}
-
-const DEFAULT_CONFIG = {
- headers: {
- "Content-Type": "application/json",
- },
-};
-
-async function handleResponse(response: Response): Promise> {
- const isJson = response.headers.get("content-type")?.includes("application/json");
- const data = isJson ? await response.json() : await response.text();
-
- if (!response.ok) {
- throw new ApiException(
- data?.message || `HTTP Error: ${response.status}`,
- response.status,
- response.statusText,
- );
- }
-
- return {
- data,
- status: response.status,
- statusText: response.statusText,
- };
-}
-
-export async function get(
- url: string,
- config: RequestInit = {},
-): Promise> {
- const response = await fetch(url, {
- ...DEFAULT_CONFIG,
- ...config,
- method: "GET",
- headers: {
- ...DEFAULT_CONFIG.headers,
- ...config.headers,
- },
- });
-
- return handleResponse(response);
-}
-
-export async function post(
- url: string,
- data?: unknown,
- config: RequestInit = {},
-): Promise> {
- const response = await fetch(url, {
- ...DEFAULT_CONFIG,
- ...config,
- method: "POST",
- headers: {
- ...DEFAULT_CONFIG.headers,
- ...config.headers,
- },
- body: data ? JSON.stringify(data) : undefined,
- });
-
- return handleResponse(response);
-}
-
-export async function put(
- url: string,
- data?: unknown,
- config: RequestInit = {},
-): Promise> {
- const response = await fetch(url, {
- ...DEFAULT_CONFIG,
- ...config,
- method: "PUT",
- headers: {
- ...DEFAULT_CONFIG.headers,
- ...config.headers,
- },
- body: data ? JSON.stringify(data) : undefined,
- });
-
- return handleResponse(response);
-}
-
-export async function del(
- url: string,
- config: RequestInit = {},
-): Promise> {
- const response = await fetch(url, {
- ...DEFAULT_CONFIG,
- ...config,
- method: "DELETE",
- headers: {
- ...DEFAULT_CONFIG.headers,
- ...config.headers,
- },
- });
-
- return handleResponse(response);
-}
-
-export const api = {
- get,
- post,
- put,
- delete: del,
-};
diff --git a/packages/templates/astro-site/src/utils/toast.ts b/packages/templates/astro-site/src/utils/toast.ts
deleted file mode 100644
index 363b94fa..00000000
--- a/packages/templates/astro-site/src/utils/toast.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-export type ToastType = "success" | "warning" | "error" | "info";
-
-export interface Toast {
- id: string;
- type: ToastType;
- title: string;
- message?: string;
- duration?: number;
- persistent?: boolean;
-}
-
-export interface ToastOptions {
- title: string;
- message?: string;
- duration?: number;
- persistent?: boolean;
-}
-
-class ToastManager {
- private toasts: Toast[] = [];
- private container: HTMLElement | null = null;
- private listeners: Array<(toasts: Toast[]) => void> = [];
-
- constructor() {
- if (typeof window !== "undefined") {
- this.initContainer();
- }
- }
-
- private initContainer() {
- // 检查是否已存在容器
- this.container = document.getElementById("toast-container");
- if (!this.container) {
- this.container = document.createElement("div");
- this.container.id = "toast-container";
- this.container.className = `
- fixed top-4 right-4 z-50 flex flex-col gap-3
- max-w-sm w-full pointer-events-none
- `.trim();
- document.body.appendChild(this.container);
- }
- }
-
- private generateId(): string {
- return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
- }
-
- private addToast(type: ToastType, options: ToastOptions): string {
- const id = this.generateId();
- const toast: Toast = {
- id,
- type,
- ...options,
- duration: options.duration ?? (type === "error" ? 0 : 5000), // 错误消息默认不自动消失
- };
-
- this.toasts.push(toast);
- this.render();
- this.notifyListeners();
-
- // 自动移除(如果不是持久化的)
- if (!toast.persistent && toast.duration && toast.duration > 0) {
- setTimeout(() => {
- this.remove(id);
- }, toast.duration);
- }
-
- return id;
- }
-
- private render() {
- if (!this.container) return;
-
- this.container.innerHTML = this.toasts.map((toast) => this.createToastHTML(toast)).join("");
-
- // 重新绑定关闭按钮事件
- this.toasts.forEach((toast) => {
- const closeBtn = document.getElementById(`close-${toast.id}`);
- if (closeBtn) {
- closeBtn.onclick = () => this.remove(toast.id);
- }
- });
- }
-
- private createToastHTML(toast: Toast): string {
- const { type, id, title, message } = toast;
-
- const typeStyles = {
- success: {
- container: "bg-success-50 border-success-200 text-success-800",
- icon: "✅",
- iconBg: "bg-success-100",
- },
- warning: {
- container: "bg-warning-50 border-warning-200 text-warning-800",
- icon: "⚠️",
- iconBg: "bg-warning-100",
- },
- error: {
- container: "bg-error-50 border-error-200 text-error-800",
- icon: "❌",
- iconBg: "bg-error-100",
- },
- info: {
- container: "bg-primary-50 border-primary-200 text-primary-800",
- icon: "ℹ️",
- iconBg: "bg-primary-100",
- },
- };
-
- const style = typeStyles[type];
-
- return `
-
- `;
- }
-
- private notifyListeners() {
- this.listeners.forEach((listener) => {
- listener([...this.toasts]);
- });
- }
-
- public success(options: ToastOptions): string {
- return this.addToast("success", options);
- }
-
- public warning(options: ToastOptions): string {
- return this.addToast("warning", options);
- }
-
- public error(options: ToastOptions): string {
- return this.addToast("error", {
- ...options,
- persistent: options.persistent ?? true, // 错误消息默认持久化
- });
- }
-
- public info(options: ToastOptions): string {
- return this.addToast("info", options);
- }
-
- public remove(id: string): boolean {
- const index = this.toasts.findIndex((toast) => toast.id === id);
- if (index > -1) {
- // 添加退出动画
- const toastElement = document.getElementById(`toast-${id}`);
- if (toastElement) {
- toastElement.style.transform = "translateX(100%)";
- toastElement.style.opacity = "0";
- setTimeout(() => {
- this.toasts.splice(index, 1);
- this.render();
- this.notifyListeners();
- }, 300);
- } else {
- this.toasts.splice(index, 1);
- this.render();
- this.notifyListeners();
- }
- return true;
- }
- return false;
- }
-
- public clear(): void {
- this.toasts = [];
- this.render();
- this.notifyListeners();
- }
-
- public getToasts(): Toast[] {
- return [...this.toasts];
- }
-
- public subscribe(listener: (toasts: Toast[]) => void): () => void {
- this.listeners.push(listener);
- return () => {
- const index = this.listeners.indexOf(listener);
- if (index > -1) {
- this.listeners.splice(index, 1);
- }
- };
- }
-}
-
-// 创建全局实例
-export const toast = new ToastManager();
-
-// 便捷方法
-export const showToast = {
- success: (title: string, message?: string) => toast.success({ title, message }),
- warning: (title: string, message?: string) => toast.warning({ title, message }),
- error: (title: string, message?: string) => toast.error({ title, message }),
- info: (title: string, message?: string) => toast.info({ title, message }),
-};
-
-// 错误处理辅助函数
-export const handleError = (error: unknown, context?: string) => {
- const errorMessage = error instanceof Error ? error.message : String(error);
- const title = context ? `${context} 失败` : "发生错误";
-
- console.error(title, error);
- toast.error({
- title,
- message: errorMessage,
- persistent: true,
- });
-};
diff --git a/packages/templates/astro-site/tsconfig.json b/packages/templates/astro-site/tsconfig.json
deleted file mode 100644
index 8bf91d3b..00000000
--- a/packages/templates/astro-site/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "astro/tsconfigs/strict",
- "include": [".astro/types.d.ts", "**/*"],
- "exclude": ["dist"]
-}
diff --git a/packages/templates/electron-app/README.md b/packages/templates/electron-app/README.md
index 72008bfb..bfe2bdcf 100644
--- a/packages/templates/electron-app/README.md
+++ b/packages/templates/electron-app/README.md
@@ -1,101 +1,76 @@
# OneTemplateElectron
-Electron + React + Vite,包含主进程、渲染进程和 preload 三个内部包。
-Electron + React + Vite, with separate main, renderer, and preload packages.
-
-## 工作区 / Workspace
-
-这是一个 One 项目,内部包共享仓库根目录的 `pnpm-workspace.yaml` 和
-`pnpm-lock.yaml`。包名使用 `@one-template-electron/` 前缀,支持在一个仓库中添加多个桌面应用。
-
-This is one One project. Its internal packages share the repository's root
-`pnpm-workspace.yaml` and `pnpm-lock.yaml`. Package names use the
-`@one-template-electron/` scope so multiple desktop apps can coexist.
-
-```text
-apps/electron/ Electron main process
-apps/ui/ React renderer
-packages/preload/ Preload bridge and shared IPC types
-```
-
-需要 pnpm 工作区。Node.js 和 pnpm 版本沿用仓库根目录配置。
-Requires a pnpm workspace. Node.js and pnpm versions follow the root configuration.
-
-## 开发和构建 / Development and builds
-
-从仓库根目录运行 / Run from the repository root:
+Electron · React · TypeScript · shadcn/ui (Base UI) · Tailwind CSS · Axios · SWR · Zustand · Awilix.
+
+## 中文
+
+- `apps/one-template-electron-renderer`:React UI;`services/one-template-electron-main`:主进程;`packages/one-template-electron-preload`:隔离桥接。
+- 生成后使用用户项目名,例如 `desktop-renderer`、`desktop-main`、`desktop-preload`。
+- `one run <项目名>:dev` 开发;`one run <项目名>:build` 构建。主进程的 `pack` / `release` 生成本地制品,不自动发布。
+- Awilix 只在 composition root 使用,业务通过函数工厂接收显式依赖。
+- preload 只暴露 `getAppInfo()`;主进程校验请求所属窗口、主 frame、URL 和参数。
+- 生产界面通过 `loadFile` 加载,Vite 使用相对资源路径,路由使用 HashRouter。
+- SWR 管理远程数据与应用信息,Zustand 保存 UI 主题。初始项目没有文件操作、自动更新或业务功能。
+- Ubuntu 开发启动时检查沙箱权限并自动识别 X11/Wayland 桌面;按提示安装 AppArmor profile,保留 Electron 沙箱。可运行主进程 `sandbox:profile` 生成配置。
+
+## English
+
+- `apps/one-template-electron-renderer`: React UI; `services/one-template-electron-main`: main process; `packages/one-template-electron-preload`: isolated bridge.
+- Generated member names use the project name, e.g. `desktop-renderer`, `desktop-main`, `desktop-preload`.
+- Develop with `one run :dev`; build with `one run :build`. Main's `pack` / `release` produce local artifacts without publishing.
+- Awilix is accessed only in the composition root. Function factories receive explicit dependencies.
+- Preload exposes only `getAppInfo()`. Main validates the window, main frame, URL, and arguments.
+- Production loads the renderer with `loadFile`, relative Vite asset URLs, and HashRouter.
+- SWR owns remote data and app information; Zustand stores the UI theme. No file operations, automatic updates, or business features are included.
+- On Ubuntu, development checks sandbox permissions and detects an X11/Wayland desktop. Follow the AppArmor profile instructions while keeping Electron's sandbox enabled. Main's `sandbox:profile` generates the profile.
+
+## Ubuntu 首次启动与桌面识别 / Ubuntu first launch and desktop detection
+
+`dev` 在启动前读取 `/etc/os-release`,仅在 `ID=ubuntu` 时检查 Ubuntu 沙箱权限。
+如 AppArmor 阻止当前 Electron 使用用户命名空间,会自动生成专用规则文件,
+并显示两条 `sudo` 安装与加载命令。请先审阅文件,再按提示执行并重新运行开发任务。
+脚本不会自动提权或修改系统策略。权限已配置时直接继续;升级 Electron 或移动
+工作区导致可执行文件路径变化时,重新检查并引导配置。已正确安装 SUID 沙箱的环境
+也可直接通过检查。
+
+Before launch, `dev` reads `/etc/os-release` and checks Ubuntu sandbox permissions only
+when `ID=ubuntu`. If AppArmor blocks the installed Electron binary's user namespaces,
+it generates a dedicated profile file and prints two `sudo` commands to install and
+load it. Review the file, follow the instructions, and rerun the development task.
+The script does not elevate privileges or change system policy. Configured permissions
+pass automatically; a changed binary path after an Electron upgrade or workspace move
+triggers another check and setup guidance. A correctly installed SUID sandbox also passes.
+
+仍可手动输出当前 Electron 的规则 / You can also print the current Electron profile manually:
```sh
-one dev -p OneTemplateElectron
-one build -p OneTemplateElectron
+one run OneTemplateElectron-main:sandbox:profile
```
-开发时会按需更新根锁文件;请将其提交 Git。已有锁文件在构建时严格校验。
-Development updates the root lockfile when needed; commit it to Git.
-Builds validate an existing lockfile without rewriting it.
-
-也可以在本项目目录运行 `pnpm run dev`、`pnpm run build`。
-You can also run `pnpm run dev` and `pnpm run build` from this project directory.
-
-先构建 preload,再运行主进程和 UI。多个桌面应用同时开发时,分别在各项目环境中设置
-`ELECTRON_RENDERER_PORT`(默认 `5173`),主进程和 Vite 会使用相同端口。
-
-Preload builds first, followed by the main process and UI. When developing multiple
-desktop apps at once, set a different `ELECTRON_RENDERER_PORT` in each project's
-environment (default `5173`). The main process and Vite use the same port.
-
-## 依赖 / Dependencies
-
-安装依赖使用仓库根目录配置,包括 registry、镜像和安装脚本策略。
-模板不会携带独立锁文件或覆盖根目录 `.npmrc`。Electron 需要允许运行安装脚本;
-已有策略中的显式禁用会保留,需要在根目录自行调整后重新安装。
-
-Dependency installation uses the root configuration, including the registry,
-mirrors, and build-script policy. The template does not carry a separate lockfile
-or override the root `.npmrc`. Electron needs its installation script enabled;
-existing explicit denials are preserved and must be adjusted at the root before reinstalling.
-
-新增内部包时,在本项目 `package.json` 的 `workspaces` 和根 `pnpm-workspace.yaml`
-中登记路径。
-
-When adding an internal package, register its path in this project's `package.json`
-`workspaces` and the root `pnpm-workspace.yaml`.
-
-## Ubuntu 沙箱启动错误 / Ubuntu sandbox startup errors
-
-如果启动时报 `The SUID sandbox helper binary was found, but is not configured correctly`,
-先检查系统的 AppArmor 用户命名空间限制。构建成功并不代表系统已允许 Electron 创建沙箱。
-
-If startup reports `The SUID sandbox helper binary was found, but is not configured correctly`,
-check the system's AppArmor user-namespace restrictions. A successful build does not
-mean that the operating system permits Electron to create its sandbox.
-
-在本项目目录生成当前 Electron 的规则 / Generate a profile for the installed Electron
-from this project directory:
-
-```sh
-node apps/electron/script/sandbox-profile.mjs > /tmp/one-electron.apparmor
-cat /tmp/one-electron.apparmor
-apparmor_parser --skip-kernel-load --skip-cache /tmp/one-electron.apparmor
-```
-
-规则只匹配当前 Electron 可执行文件的完整路径。管理员审核后,可安装到
-`/etc/apparmor.d/` 并使用 `apparmor_parser -r` 加载。此操作会持续允许该可执行文件使用
-用户命名空间,需管理员权限。升级 Electron 或移动工作区后,需要重新生成规则。
-
-The profile matches only the full path of the installed Electron executable. After
-review, an administrator can install it under `/etc/apparmor.d/` and load it with
-`apparmor_parser -r`. This persistently grants that executable access to user namespaces
-and requires administrator privileges. Regenerate the profile after upgrading Electron
-or moving the workspace.
-
-模板保持 Chromium 沙箱开启。`--no-sandbox` 只适合临时测试,会关闭所有进程的沙箱,
-不要写入正式启动或发布配置。
-
-The template keeps Chromium sandboxing enabled. `--no-sandbox` is for temporary testing
-and disables sandboxing for all processes; keep it out of normal startup and release configuration.
+Linux 开发启动保留已有 `DISPLAY`(包括 SSH X11 转发和 Xvfb)。仅配置
+`WAYLAND_DISPLAY` 时,会补齐当前用户的运行目录和 Wayland 会话类型;显示变量缺失时,
+先查询当前用户桌面会话,再检查活跃的 Wayland / X11 socket。自动识别 X11 时还需要
+当前用户的认证文件。发现多个候选且会话无法确定时,提示显式设置显示变量。
+通过 SSH 使用本机桌面时,窗口显示在该机器的桌面上。无桌面环境会给出明确提示,
+请使用图形桌面终端、SSH X11 转发,或在 CI 中使用 Xvfb。
+
+Linux development preserves an existing `DISPLAY`, including SSH X11 forwarding and
+Xvfb. With only `WAYLAND_DISPLAY` set, it fills the current user's runtime directory
+and Wayland session type. When display variables are missing, it queries the current
+user's desktop session, then looks for live Wayland / X11 sockets. X11 discovery also
+requires the current user's authentication file. Ambiguous displays require an explicit
+display variable. When using a local desktop from SSH, windows appear on that machine's
+desktop. A headless environment receives guidance to use a graphical terminal, SSH X11
+forwarding, or Xvfb in CI.
+
+macOS 和 Windows 跳过 Ubuntu 权限与 Linux 桌面检查。模板保留 Chromium 沙箱。
+启动提示沿用 One 的语言偏好;`auto` 按 `LC_ALL`、`LC_MESSAGES`、`LANG` 判断。
+
+macOS and Windows skip Ubuntu permission and Linux desktop checks. Chromium sandboxing
+remains enabled. Startup guidance follows One's language preference; `auto` uses
+`LC_ALL`, `LC_MESSAGES`, then `LANG`.
参考 / References:
-- [Ubuntu: user namespace restrictions](https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890)
-- [Electron: process sandboxing](https://www.electronjs.org/docs/latest/tutorial/sandbox)
+- [Ubuntu user namespace restrictions](https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890)
+- [Electron process sandboxing](https://www.electronjs.org/docs/latest/tutorial/sandbox)
diff --git a/packages/templates/electron-app/apps/electron/package.json b/packages/templates/electron-app/apps/electron/package.json
deleted file mode 100644
index 223d4cc2..00000000
--- a/packages/templates/electron-app/apps/electron/package.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "name": "@one-template-electron/electron",
- "version": "0.1.0",
- "description": "electron-template electron main process",
- "license": "MIT",
- "author": "",
- "type": "module",
- "main": "build/main/index.js",
- "scripts": {
- "dev": "cross-env NODE_ENV=development tsdown && cross-env NODE_ENV=development electron build/main/index.js",
- "build": "cross-env NODE_ENV=production tsdown",
- "sandbox:profile": "node script/sandbox-profile.mjs",
- "pack": "cross-env NODE_ENV=development node script/release.mjs",
- "release": "cross-env NODE_ENV=production node script/release.mjs"
- },
- "dependencies": {
- "@inversifyjs/binding-decorators": "^3.0.0",
- "@one-template-electron/preload": "workspace:*",
- "dayjs": "^1.11.23",
- "electron-devtools-installer": "^4.0.0",
- "electron-is-dev": "^3.0.1",
- "electron-log": "^5.4.4",
- "electron-store": "^11.0.2",
- "electron-updater": "^6.8.9",
- "inversify": "^8.2.3",
- "mime-types": "^3.0.2",
- "reflect-metadata": "^0.2.2"
- },
- "devDependencies": {
- "@types/mime-types": "^3.0.1",
- "@types/node": "^24.19.0",
- "cross-env": "^10.1.0",
- "electron": "^44.5.1",
- "electron-builder": "^26.15.3",
- "tsdown": "^0.23.0",
- "typescript": "^7.0.2"
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/script/release.mjs b/packages/templates/electron-app/apps/electron/script/release.mjs
deleted file mode 100644
index 5dc317a6..00000000
--- a/packages/templates/electron-app/apps/electron/script/release.mjs
+++ /dev/null
@@ -1,85 +0,0 @@
-import builder from "electron-builder";
-const Platform = builder.Platform;
-
-const nodeEnv = process.env.NODE_ENV;
-console.log("当前的环境是: ", nodeEnv);
-
-// Let's get that intellisense working
-/**
- * @type {import('electron-builder').Configuration}
- * @see https://www.electron.build/configuration/configuration
- */
-const options = {
- productName: process.env.APP_NAME,
- appId: process.env.APP_ID,
- copyright: process.env.APP_COPYRIGHT,
- artifactName: "${productName}-setup-${version}.${ext}",
- directories: {
- output: "./dist",
- },
- files: [
- {
- from: "./build",
- to: "./",
- filter: ["**/*"],
- },
- "./package.json",
- {
- from: "../../packages/preload/dist",
- to: "./preload",
- },
- ],
- win: {
- icon: "../assets/icon.ico",
- target: [
- {
- target: "nsis",
- },
- ],
- },
- dmg: {
- contents: [],
- },
- mac: {
- icon: "../icons/icon.icns",
- target: {
- target: "default",
- arch: ["x64", "arm64"],
- },
- },
- linux: {
- icon: "../build/icons",
- },
- nsis: {
- oneClick: false,
- allowElevation: true,
- allowToChangeInstallationDirectory: true,
- installerIcon: "",
- uninstallerIcon: "",
- installerHeaderIcon: "",
- createDesktopShortcut: true,
- createStartMenuShortcut: true,
- shortcutName: "",
- include: "",
- script: "",
- },
- publish: {
- provider: "github",
- repo: "repo",
- owner: "owner",
- releaseType: "draft",
- },
-};
-
-const target =
- process.env.NODE_ENV === "development"
- ? builder.DIR_TARGET
- : builder.DEFAULT_TARGET;
-try {
- await builder.build({
- targets: Platform.WINDOWS.createTarget(target),
- config: options,
- });
-} catch (e) {
- console.log(e);
-}
diff --git a/packages/templates/electron-app/apps/electron/src/app.ts b/packages/templates/electron-app/apps/electron/src/app.ts
deleted file mode 100644
index 492566bf..00000000
--- a/packages/templates/electron-app/apps/electron/src/app.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { app, BrowserWindow } from "electron";
-import { provide } from "@inversifyjs/binding-decorators";
-import { inject, injectable } from "inversify";
-import ProtocolService from "./core/protocol";
-import Router from "./core/router";
-import ElectronDevtools from "./vendor/ElectronDevtools";
-import ElectronUpdater from "./vendor/ElectronUpdater";
-import ElectronLogger from "./vendor/ElectronLogger";
-import MainWindow from "./windows/main.window";
-import "./controller";
-
-// 编排器:定义启动顺序
-// 1. 协议注册(whenReady 后尽早)
-// 2. IPC 路由(绑定所有控制器)
-// 3. Vendor init(日志、更新器、DevTools)
-// 4. 平台事件(activate / second-instance / quit)
-// 5. 业务窗口创建
-@injectable()
-@provide()
-export default class ElectronApp {
- constructor(
- @inject(MainWindow) private readonly mainWindow: MainWindow,
- @inject(ProtocolService) private readonly protocol: ProtocolService,
- @inject(Router) private readonly router: Router,
- @inject(ElectronUpdater) private readonly updater: ElectronUpdater,
- @inject(ElectronDevtools) private readonly devTools: ElectronDevtools,
- @inject(ElectronLogger) private readonly logger: ElectronLogger,
- ) {}
-
- async init(): Promise {
- this.protocol.create();
- this.router.init();
-
- await this.devTools.init();
- await this.updater.init();
-
- app.on("activate", () => {
- if (BrowserWindow.getAllWindows().length === 0) this.mainWindow.init();
- });
-
- this.mainWindow.init();
-
- this.logger.info("app ready");
- }
-
- // 第二实例启动时把焦点还给已有窗口
- secondInstance = (): void => {
- this.mainWindow.init();
- };
-}
diff --git a/packages/templates/electron-app/apps/electron/src/constants/index.ts b/packages/templates/electron-app/apps/electron/src/constants/index.ts
deleted file mode 100644
index c87ceb71..00000000
--- a/packages/templates/electron-app/apps/electron/src/constants/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// 自定义协议名:生产环境下通过这个协议加载渲染端静态资源
-export const DEFAULT_SCHEME = "myapp";
-
-// renderer dev server 地址(Vite 默认端口 5173)
-export const DEV_RENDERER_URL = `http://localhost:${process.env.ELECTRON_RENDERER_PORT || 5173}/`;
diff --git a/packages/templates/electron-app/apps/electron/src/controller/context-menu.controller.ts b/packages/templates/electron-app/apps/electron/src/controller/context-menu.controller.ts
deleted file mode 100644
index 1f6cd161..00000000
--- a/packages/templates/electron-app/apps/electron/src/controller/context-menu.controller.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { provide } from "@inversifyjs/binding-decorators";
-import {
- type IpcMainInvokeEvent,
- Menu,
- type MenuItem,
- type MenuItemConstructorOptions,
-} from "electron";
-import { injectable } from "inversify";
-import { handle } from "../core/decorators";
-import type { Controller } from "../types/core";
-import { IPC, type ContextMenuItem } from "../types/events";
-import { TYPES } from "../types/symbols";
-
-// 让渲染端在原生 context menu 里弹出选项
-// 返回用户点击的 key(取消返回 null)
-@injectable()
-@provide(TYPES.Controller)
-export default class ContextMenuController implements Controller {
- @handle(IPC.contextMenu.show)
- async show(
- _e: IpcMainInvokeEvent,
- items: ContextMenuItem[],
- ): Promise {
- return new Promise((resolve) => {
- let resolved = false;
- const template: Array = items.map(
- (item) => {
- if (item.type === "separator") return { type: "separator" as const };
- return {
- label: item.label,
- click: () => {
- resolved = true;
- resolve(item.key ?? null);
- },
- };
- },
- );
- const menu = Menu.buildFromTemplate(template);
- menu.popup({
- callback: () => {
- if (!resolved) resolve(null);
- },
- });
- });
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/controller/dialog.controller.ts b/packages/templates/electron-app/apps/electron/src/controller/dialog.controller.ts
deleted file mode 100644
index 7c2b752a..00000000
--- a/packages/templates/electron-app/apps/electron/src/controller/dialog.controller.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import fs from "node:fs/promises";
-import { provide } from "@inversifyjs/binding-decorators";
-import { dialog, type IpcMainInvokeEvent } from "electron";
-import { inject, injectable } from "inversify";
-import { handle } from "../core/decorators";
-import type { Controller } from "../types/core";
-import {
- IPC,
- type DialogOpenOptions,
- type DialogSaveOptions,
-} from "../types/events";
-import { TYPES } from "../types/symbols";
-import MainWindow from "../windows/main.window";
-
-// 通用文件选择 / 保存对话框
-// 约定以主窗口为 parent,避免多个独立对话框淹没用户
-@injectable()
-@provide(TYPES.Controller)
-export default class DialogController implements Controller {
- constructor(
- @inject(MainWindow)
- private readonly mainWindow: MainWindow,
- ) {}
-
- @handle(IPC.dialog.open)
- async open(
- _e: IpcMainInvokeEvent,
- options: DialogOpenOptions,
- ): Promise {
- const window = this.mainWindow.window;
- if (!window) throw new Error("main window not ready");
-
- const properties: Electron.OpenDialogOptions["properties"] =
- options.type === "directory" ? ["openDirectory"] : ["openFile"];
- if (options.multiple) properties.push("multiSelections");
-
- const result = await dialog.showOpenDialog(window, {
- properties,
- filters: options.filters,
- });
-
- if (result.canceled) return [];
-
- if (options.readContent && options.type === "file") {
- return Promise.all(result.filePaths.map((p) => fs.readFile(p, "utf-8")));
- }
- return result.filePaths;
- }
-
- @handle(IPC.dialog.save)
- async save(
- _e: IpcMainInvokeEvent,
- options: DialogSaveOptions,
- ): Promise {
- const window = this.mainWindow.window;
- if (!window) throw new Error("main window not ready");
-
- const result = await dialog.showSaveDialog(window, {
- defaultPath: options.defaultPath,
- filters: options.filters,
- });
- if (result.canceled || !result.filePath) return "";
-
- await fs.writeFile(result.filePath, options.content, "utf-8");
- return result.filePath;
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/controller/index.ts b/packages/templates/electron-app/apps/electron/src/controller/index.ts
deleted file mode 100644
index 0495ce65..00000000
--- a/packages/templates/electron-app/apps/electron/src/controller/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// 副作用导入:让 @provide(TYPES.Controller) 装饰器在容器加载前执行
-// 新增控制器时在这里追加一行
-import "./dialog.controller";
-import "./shell.controller";
-import "./context-menu.controller";
diff --git a/packages/templates/electron-app/apps/electron/src/controller/shell.controller.ts b/packages/templates/electron-app/apps/electron/src/controller/shell.controller.ts
deleted file mode 100644
index 9cb18843..00000000
--- a/packages/templates/electron-app/apps/electron/src/controller/shell.controller.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { provide } from "@inversifyjs/binding-decorators";
-import { type IpcMainInvokeEvent, shell } from "electron";
-import { injectable } from "inversify";
-import { handle } from "../core/decorators";
-import type { Controller } from "../types/core";
-import { IPC } from "../types/events";
-import { TYPES } from "../types/symbols";
-
-// 跨 URL / 文件路径的"打开"统一入口
-@injectable()
-@provide(TYPES.Controller)
-export default class ShellController implements Controller {
- @handle(IPC.shell.open)
- async open(_e: IpcMainInvokeEvent, target: string): Promise {
- if (/^https?:\/\//i.test(target)) {
- await shell.openExternal(target);
- } else {
- await shell.openPath(target);
- }
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/core/decorators.ts b/packages/templates/electron-app/apps/electron/src/core/decorators.ts
deleted file mode 100644
index cbb75861..00000000
--- a/packages/templates/electron-app/apps/electron/src/core/decorators.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import "reflect-metadata";
-import { IPC_EVENT_METADATA, IPC_METHOD_METADATA } from "../types/events";
-
-// @handle(channel) —— 把控制器方法标记为 ipcMain.handle 的 handler
-// 元数据写在 prototype 上,启动时由 registerControllerHandlers 统一扫描
-export const handle = (route: string) => {
- return (target: any, propertyName: string): void => {
- Reflect.defineMetadata(IPC_METHOD_METADATA, "handle", target, propertyName);
- Reflect.defineMetadata(IPC_EVENT_METADATA, route, target, propertyName);
- };
-};
-
-// @on(channel) —— ipcMain.on 版本,用于无返回值的"推送"类事件
-export const on = (route: string) => {
- return (target: any, propertyName: string): void => {
- Reflect.defineMetadata(IPC_METHOD_METADATA, "on", target, propertyName);
- Reflect.defineMetadata(IPC_EVENT_METADATA, route, target, propertyName);
- };
-};
diff --git a/packages/templates/electron-app/apps/electron/src/core/electronBinder.ts b/packages/templates/electron-app/apps/electron/src/core/electronBinder.ts
deleted file mode 100644
index 10741208..00000000
--- a/packages/templates/electron-app/apps/electron/src/core/electronBinder.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { ControllerHandlerBinder } from "./registerControllerHandlers";
-import { error, success } from "../utils/ipcResponse";
-import type ElectronLogger from "../vendor/ElectronLogger";
-
-export type IpcMainHandlers = {
- handle: (channel: string, listener: (...args: unknown[]) => unknown) => void;
- on: (channel: string, listener: (...args: unknown[]) => unknown) => void;
-};
-
-type LoggerLike = Pick;
-
-function isPromiseLike(value: unknown): value is PromiseLike {
- return typeof (value as PromiseLike)?.then === "function";
-}
-
-// 把扫描到的 handler 真正绑到 ipcMain 上
-// 所有处理器统一加 try/catch + 走 success/error 信封
-export function createElectronControllerBinder(
- ipc: IpcMainHandlers,
- logger: LoggerLike,
-): ControllerHandlerBinder {
- return ({ controller, handler, event, method }) => {
- if (method !== "on" && method !== "handle") return;
-
- ipc[method](event, async (...args: unknown[]) => {
- try {
- let result: unknown = handler.call(controller, ...args);
- if (isPromiseLike(result)) {
- result = await result;
- }
- return success(result);
- } catch (e: unknown) {
- logger.error(`process ipc [${event}] failed: `, e);
- if (e instanceof Error) return error(e.message);
- return error(String(e));
- }
- });
- };
-}
diff --git a/packages/templates/electron-app/apps/electron/src/core/protocol.ts b/packages/templates/electron-app/apps/electron/src/core/protocol.ts
deleted file mode 100644
index d9a200be..00000000
--- a/packages/templates/electron-app/apps/electron/src/core/protocol.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import path from "node:path";
-import { access, readFile } from "node:fs/promises";
-import { URL } from "node:url";
-import { provide } from "@inversifyjs/binding-decorators";
-import { protocol } from "electron";
-import isDev from "electron-is-dev";
-import { injectable } from "inversify";
-import mime from "mime-types";
-import { __dirname, defaultScheme } from "../utils";
-
-// 生产环境下把渲染端打包文件通过自定义协议提供给主窗口
-// 开发环境直接 loadURL("http://localhost:xxxx"),这里空转
-// SPA 路由 fallback:文件不存在返回 index.html
-@injectable()
-@provide()
-export default class ProtocolService {
- create(): void {
- if (isDev) return;
-
- protocol.handle(defaultScheme, async (req) => {
- const pathName = new URL(req.url).pathname;
- let filePath = path.join(__dirname, "../renderer", pathName);
- try {
- await access(filePath);
- } catch {
- filePath = path.join(__dirname, "../renderer/index.html");
- }
- const mimeType = mime.lookup(filePath);
- const data = await readFile(filePath);
- return new Response(new Uint8Array(data), {
- headers: { "Content-Type": mimeType || "text/html" },
- });
- });
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/core/registerControllerHandlers.ts b/packages/templates/electron-app/apps/electron/src/core/registerControllerHandlers.ts
deleted file mode 100644
index 84d93daa..00000000
--- a/packages/templates/electron-app/apps/electron/src/core/registerControllerHandlers.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import "reflect-metadata";
-import type { Controller } from "../types/core";
-import { IPC_EVENT_METADATA, IPC_METHOD_METADATA } from "../types/events";
-
-export interface ControllerHandlerRegistration {
- controller: Controller;
- propertyKey: string | symbol;
- handler: (...args: unknown[]) => unknown;
- event: string;
- method: string;
-}
-
-export type ControllerHandlerBinder = (
- registration: ControllerHandlerRegistration,
-) => void;
-
-// 扫描控制器原型上带 @handle/@on 元数据的方法,交给 binder 处理
-// 不直接操作 ipcMain —— binder 抽象了"如何绑定",便于单测时传假实现
-export function registerControllerHandlers(
- controllers: Controller[],
- binder: ControllerHandlerBinder,
-): void {
- for (const controller of controllers) {
- if (!controller) continue;
- const prototype = Object.getPrototypeOf(controller);
- if (!prototype) continue;
-
- for (const propertyKey of Reflect.ownKeys(prototype)) {
- if (propertyKey === "constructor") continue;
- const handler = Reflect.get(controller as object, propertyKey);
- if (typeof handler !== "function") continue;
-
- const event = Reflect.getMetadata(
- IPC_EVENT_METADATA,
- controller,
- propertyKey as string,
- );
- const method = Reflect.getMetadata(
- IPC_METHOD_METADATA,
- controller,
- propertyKey as string,
- );
-
- if (typeof event !== "string" || typeof method !== "string") continue;
-
- binder({
- controller,
- propertyKey,
- handler: handler as (...args: unknown[]) => unknown,
- event,
- method,
- });
- }
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/core/router.ts b/packages/templates/electron-app/apps/electron/src/core/router.ts
deleted file mode 100644
index 427818ad..00000000
--- a/packages/templates/electron-app/apps/electron/src/core/router.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { provide } from "@inversifyjs/binding-decorators";
-import { ipcMain } from "electron";
-import { inject, injectable, multiInject } from "inversify";
-import type { AppRouter, Controller } from "../types/core";
-import { TYPES } from "../types/symbols";
-import ElectronLogger from "../vendor/ElectronLogger";
-import { createElectronControllerBinder } from "./electronBinder";
-import { registerControllerHandlers } from "./registerControllerHandlers";
-
-// 收集所有 @provide(TYPES.Controller) 的控制器
-// 启动时调用 init() 一次性把它们绑定到 ipcMain
-@injectable()
-@provide()
-export default class Router implements AppRouter {
- constructor(
- @multiInject(TYPES.Controller)
- private readonly controllers: Controller[],
- @inject(ElectronLogger)
- private readonly logger: ElectronLogger,
- ) {}
-
- init(): void {
- const binder = createElectronControllerBinder(ipcMain, this.logger);
- registerControllerHandlers(this.controllers, binder);
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/core/window.ts b/packages/templates/electron-app/apps/electron/src/core/window.ts
deleted file mode 100644
index ad15299e..00000000
--- a/packages/templates/electron-app/apps/electron/src/core/window.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { BrowserWindow, type BrowserWindowConstructorOptions } from "electron";
-import isDev from "electron-is-dev";
-
-// 所有业务窗口继承这个基类
-// 职责:封装 BrowserWindow 生命周期 + 提供 send() 快捷方法
-// 子类只需要:设置 this.url、实现 init(),按需重写 readyToShow/windowClose 钩子
-export default class Window {
- window: BrowserWindow | null = null;
- options: BrowserWindowConstructorOptions;
- url: string = "";
-
- constructor(options: BrowserWindowConstructorOptions) {
- this.options = options;
- }
-
- create(): BrowserWindow {
- if (!this.url) throw new Error("url is required");
-
- const window = new BrowserWindow(this.options);
- void window.loadURL(this.url);
-
- window.once("ready-to-show", this.readyToShow);
- window.on("close", this.windowClose);
-
- return window;
- }
-
- readyToShow = () => {
- if (!this.window) return;
- this.window.show();
- if (isDev) this.window.webContents.openDevTools();
- };
-
- windowClose = () => {
- if (!this.window) return;
- this.window = null;
- };
-
- send(channel: string, ...args: unknown[]): void {
- if (!this.window) return;
- this.window.webContents.send(channel, ...args);
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/index.ts b/packages/templates/electron-app/apps/electron/src/index.ts
deleted file mode 100644
index 7aa4312c..00000000
--- a/packages/templates/electron-app/apps/electron/src/index.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import "reflect-metadata";
-import { buildProviderModule } from "@inversifyjs/binding-decorators";
-import { app, protocol } from "electron";
-import { Container } from "inversify";
-import ElectronApp from "./app";
-import { defaultScheme, noop } from "./utils";
-
-// 1. 容器:所有类默认单例
-const container = new Container({ defaultScope: "Singleton" });
-
-// 2. 单实例锁:重复启动时把焦点交给已有实例
-const gotTheLock = app.requestSingleInstanceLock();
-
-const start = async (): Promise => {
- if (!gotTheLock) {
- app.quit();
- return;
- }
-
- // 3. 自定义协议注册为特权 scheme(支持 fetch / cookies / service worker)
- // 必须在 app ready 之前调用
- protocol.registerSchemesAsPrivileged([
- { scheme: defaultScheme, privileges: { secure: true, standard: true } },
- ]);
-
- await app.whenReady();
-
- // 4. 加载所有 @provide 装饰的类到容器
- await container.loadAsync(buildProviderModule());
-
- // 5. 取编排器并启动
- const application = container.get(ElectronApp);
- await application.init();
-
- // 6. 平台惯例:macOS 关闭所有窗口不退出
- app.on("window-all-closed", noop);
- app.on("second-instance", application.secondInstance);
-};
-
-void start();
diff --git a/packages/templates/electron-app/apps/electron/src/types/core.ts b/packages/templates/electron-app/apps/electron/src/types/core.ts
deleted file mode 100644
index 93f768ab..00000000
--- a/packages/templates/electron-app/apps/electron/src/types/core.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-// 框架契约:所有控制器实现 Controller(空接口,只作类型标记)
-export interface Controller {}
-
-// Router 只暴露 init(),编排器启动时调用一次完成 IPC 绑定
-export interface AppRouter {
- init(): void;
-}
diff --git a/packages/templates/electron-app/apps/electron/src/types/events.ts b/packages/templates/electron-app/apps/electron/src/types/events.ts
deleted file mode 100644
index dff63b36..00000000
--- a/packages/templates/electron-app/apps/electron/src/types/events.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-// 元数据 key:装饰器写入,注册时读取。用 Symbol 避免和用户字段冲突
-export const IPC_EVENT_METADATA = Symbol.for("app:ipc-event");
-export const IPC_METHOD_METADATA = Symbol.for("app:ipc-method");
-
-// IPC 通道常量从 preload 包 re-export —— 主进程和 renderer 共用同一份源
-export { IPC, EVENT } from "@one-template-electron/preload/channels";
-export type {
- IpcResponse,
- InvokeMap,
- InvokeChannel,
- EventChannel,
- DialogOpenOptions,
- DialogSaveOptions,
- ContextMenuItem,
-} from "@one-template-electron/preload/channels";
diff --git a/packages/templates/electron-app/apps/electron/src/types/symbols.ts b/packages/templates/electron-app/apps/electron/src/types/symbols.ts
deleted file mode 100644
index b7291244..00000000
--- a/packages/templates/electron-app/apps/electron/src/types/symbols.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// DI 符号表:用 Symbol.for 保证跨模块唯一
-// 所有"多实例同类型"的注入点要在这里登记(比如 Controller 通过 multiInject 聚合)
-export const TYPES = {
- Controller: Symbol.for("Controller"),
-};
diff --git a/packages/templates/electron-app/apps/electron/src/utils/index.ts b/packages/templates/electron-app/apps/electron/src/utils/index.ts
deleted file mode 100644
index e15c7b54..00000000
--- a/packages/templates/electron-app/apps/electron/src/utils/index.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { app } from "electron";
-import { createRequire } from "node:module";
-import path from "node:path";
-import { fileURLToPath } from "node:url";
-import { DEFAULT_SCHEME } from "../constants";
-
-// ESM 下 __dirname 不存在,基于 import.meta.url 重建
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = path.dirname(__filename);
-
-// ESM 主进程要加载 CJS preload,需要 createRequire 才能 resolve workspace 包
-const requireFromHere = createRequire(import.meta.url);
-
-export const appName = app.getName();
-
-// 工作目录:所有持久化数据(日志、store、缓存)的根
-export const workspace = path.resolve(app.getPath("userData"));
-
-// 自定义协议名
-export const defaultScheme = DEFAULT_SCHEME;
-
-// 空函数占位
-export const noop = () => {};
-
-// preload 脚本绝对路径 —— 主窗口 webPreferences.preload 要指向这里
-// @one-template-electron/preload 包的 main 指向 dist/index.cjs,这里 resolve 出真实路径
-const preloadPackage = "@one-template-electron/preload";
-export const preloadPath = requireFromHere.resolve(preloadPackage);
-
-export { __dirname, __filename };
diff --git a/packages/templates/electron-app/apps/electron/src/utils/ipcResponse.ts b/packages/templates/electron-app/apps/electron/src/utils/ipcResponse.ts
deleted file mode 100644
index a8f4a9d5..00000000
--- a/packages/templates/electron-app/apps/electron/src/utils/ipcResponse.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-// 统一响应信封:所有 IPC 处理器返回值走这里
-// preload 侧 code === 0 解包 data;非 0 抛 Error(message)
-export interface IpcResponse {
- code: number;
- message: string;
- data: Record | null;
-}
-
-export function success(data: unknown): IpcResponse {
- return {
- code: 0,
- message: "success",
- data: (data ?? null) as Record | null,
- };
-}
-
-export function error(message = "fail"): IpcResponse {
- return { code: -1, message, data: null };
-}
diff --git a/packages/templates/electron-app/apps/electron/src/vendor/ElectronDevtools.ts b/packages/templates/electron-app/apps/electron/src/vendor/ElectronDevtools.ts
deleted file mode 100644
index cb777fc6..00000000
--- a/packages/templates/electron-app/apps/electron/src/vendor/ElectronDevtools.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { provide } from "@inversifyjs/binding-decorators";
-import install, {
- REACT_DEVELOPER_TOOLS,
- REDUX_DEVTOOLS,
-} from "electron-devtools-installer";
-import isDev from "electron-is-dev";
-import { inject, injectable } from "inversify";
-import ElectronLogger from "./ElectronLogger";
-
-// 开发时可选安装 React/Redux DevTools
-// 走 LOAD_DEVTOOLS 环境变量开关,避免每次启动都去下载
-@injectable()
-@provide()
-export default class ElectronDevtools {
- constructor(
- @inject(ElectronLogger)
- private readonly logger: ElectronLogger,
- ) {}
-
- async init(): Promise {
- if (!isDev) return;
- if (!process.env.LOAD_DEVTOOLS) return;
-
- try {
- this.logger.debug("Loading devtools");
- await install([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS]);
- this.logger.debug("Devtools loaded");
- } catch (err: unknown) {
- this.logger.error("Failed to load devtools", err);
- }
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/vendor/ElectronLogger.ts b/packages/templates/electron-app/apps/electron/src/vendor/ElectronLogger.ts
deleted file mode 100644
index 58a1c395..00000000
--- a/packages/templates/electron-app/apps/electron/src/vendor/ElectronLogger.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { mkdirSync } from "node:fs";
-import path from "node:path";
-import { provide } from "@inversifyjs/binding-decorators";
-import dayjs from "dayjs";
-import logger, { type Logger } from "electron-log";
-import { injectable } from "inversify";
-import { appName, workspace } from "../utils";
-
-// 日志:文件 + 控制台双通道
-// 文件按天切分,单文件 10MB 上限
-// Error 对象自动提取 stack,避免只打出 "[object Object]"
-@injectable()
-@provide()
-export default class ElectronLogger {
- logger: Logger;
-
- constructor() {
- const datetime = dayjs().format("YYYY-MM-DD");
- const logPath = path.resolve(workspace, `logs/${datetime}-${appName}.log`);
- mkdirSync(path.dirname(logPath), { recursive: true });
-
- logger.transports.file.level = "info";
- logger.transports.file.resolvePathFn = () => logPath;
- logger.transports.file.format =
- "[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}";
- logger.transports.file.maxSize = 1024 * 1024 * 10;
-
- logger.transports.console.level =
- process.env.NODE_ENV === "development" ? "debug" : "info";
- logger.transports.console.format = "{h}:{i}:{s} [{level}] {text}";
-
- this.logger = logger;
- }
-
- info(...args: unknown[]) {
- return this.logger.info(...this.normalizeArgs(args));
- }
- warn(...args: unknown[]) {
- return this.logger.warn(...this.normalizeArgs(args));
- }
- error(...args: unknown[]) {
- return this.logger.error(...this.normalizeArgs(args));
- }
- debug(...args: unknown[]) {
- return this.logger.debug(...this.normalizeArgs(args));
- }
-
- private normalizeArgs(args: unknown[]): unknown[] {
- return args.map((arg) => {
- if (arg instanceof Error) return arg.stack ?? arg.message;
- return arg;
- });
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/vendor/ElectronStore.ts b/packages/templates/electron-app/apps/electron/src/vendor/ElectronStore.ts
deleted file mode 100644
index 3cea841e..00000000
--- a/packages/templates/electron-app/apps/electron/src/vendor/ElectronStore.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { provide } from "@inversifyjs/binding-decorators";
-import Store from "electron-store";
-import { injectable } from "inversify";
-import { workspace } from "../utils";
-
-// 持久化小量结构化数据(窗口位置、用户偏好等)
-// 大数据别塞这里,走单独的存储层
-interface AppPersistentStore {
- mainBounds?: Electron.Rectangle;
- // 按需扩展
-}
-
-@injectable()
-@provide()
-export default class ElectronStore extends Store {
- constructor() {
- super({
- name: "window-state",
- cwd: workspace,
- defaults: {},
- });
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/vendor/ElectronUpdater.ts b/packages/templates/electron-app/apps/electron/src/vendor/ElectronUpdater.ts
deleted file mode 100644
index 0379d25d..00000000
--- a/packages/templates/electron-app/apps/electron/src/vendor/ElectronUpdater.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { provide } from "@inversifyjs/binding-decorators";
-import isDev from "electron-is-dev";
-import pkg from "electron-updater";
-import { inject, injectable } from "inversify";
-import ElectronLogger from "./ElectronLogger";
-import MainWindow from "../windows/main.window";
-import { EVENT } from "../types/events";
-
-const { autoUpdater } = pkg;
-
-// electron-updater 薄包装
-// 把更新状态事件转发到渲染端,UI 订阅 update:* 通道展示进度
-@injectable()
-@provide()
-export default class ElectronUpdater {
- constructor(
- @inject(ElectronLogger)
- private readonly logger: ElectronLogger,
- @inject(MainWindow)
- private readonly mainWindow: MainWindow,
- ) {}
-
- async init(
- options: { autoUpgrade?: boolean; allowBeta?: boolean } = {},
- ): Promise {
- autoUpdater.disableWebInstaller = true;
- autoUpdater.logger = this.logger.logger;
- autoUpdater.allowPrerelease = options.allowBeta ?? false;
- if (isDev) autoUpdater.forceDevUpdateConfig = true;
-
- autoUpdater.autoDownload = options.autoUpgrade ?? false;
-
- autoUpdater.on("checking-for-update", () =>
- this.mainWindow.send(EVENT.update.checking),
- );
- autoUpdater.on("update-available", () =>
- this.mainWindow.send(EVENT.update.available),
- );
- autoUpdater.on("update-not-available", () =>
- this.mainWindow.send(EVENT.update.notAvailable),
- );
- autoUpdater.on("download-progress", (progress) =>
- this.mainWindow.send(EVENT.update.downloadProgress, progress),
- );
- autoUpdater.on("update-downloaded", () =>
- this.mainWindow.send(EVENT.update.downloaded),
- );
-
- // 启动后延迟 60s 检查,避免抢占冷启动资源
- setTimeout(() => void this.check(), 60_000);
- }
-
- async check(): Promise {
- try {
- await autoUpdater.checkForUpdates();
- } catch (e) {
- this.logger.error("check for updates failed", e);
- }
- }
-
- startDownload(): Promise {
- return autoUpdater.downloadUpdate();
- }
-
- install(): void {
- autoUpdater.quitAndInstall();
- }
-
- setAllowBeta(allow: boolean): void {
- autoUpdater.allowPrerelease = allow;
- }
-}
diff --git a/packages/templates/electron-app/apps/electron/src/windows/main.window.ts b/packages/templates/electron-app/apps/electron/src/windows/main.window.ts
deleted file mode 100644
index 9ae84366..00000000
--- a/packages/templates/electron-app/apps/electron/src/windows/main.window.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { provide } from "@inversifyjs/binding-decorators";
-import isDev from "electron-is-dev";
-import { inject, injectable } from "inversify";
-import Window from "../core/window";
-import { DEV_RENDERER_URL } from "../constants";
-import { defaultScheme, preloadPath } from "../utils";
-import ElectronStore from "../vendor/ElectronStore";
-
-// 主窗口
-// 生产 URL 走自定义协议,开发 URL 走 Vite 本地服务
-// 关闭前把大小/位置存到 ElectronStore,下次启动恢复
-@injectable()
-@provide()
-export default class MainWindow extends Window {
- constructor(
- @inject(ElectronStore)
- private readonly store: ElectronStore,
- ) {
- const bounds = store.get("mainBounds");
- super({
- width: bounds?.width ?? 1100,
- height: bounds?.height ?? 680,
- x: bounds?.x,
- y: bounds?.y,
- show: false,
- autoHideMenuBar: true,
- webPreferences: {
- preload: preloadPath,
- contextIsolation: true,
- nodeIntegration: false,
- },
- });
-
- this.url = isDev ? DEV_RENDERER_URL : `${defaultScheme}://index.html/`;
- }
-
- init(): void {
- if (this.window) {
- this.window.show();
- return;
- }
- this.window = this.create();
- this.window.on("close", () => {
- if (!this.window) return;
- this.store.set("mainBounds", this.window.getBounds());
- });
- }
-}
diff --git a/packages/templates/electron-app/apps/ui/.gitignore b/packages/templates/electron-app/apps/one-template-electron-renderer/.gitignore
similarity index 91%
rename from packages/templates/electron-app/apps/ui/.gitignore
rename to packages/templates/electron-app/apps/one-template-electron-renderer/.gitignore
index 4108b33e..a547bf36 100644
--- a/packages/templates/electron-app/apps/ui/.gitignore
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/.gitignore
@@ -1,24 +1,24 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-node_modules
-dist
-dist-ssr
-*.local
-
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/templates/electron-app/apps/ui/components.json b/packages/templates/electron-app/apps/one-template-electron-renderer/components.json
similarity index 73%
rename from packages/templates/electron-app/apps/ui/components.json
rename to packages/templates/electron-app/apps/one-template-electron-renderer/components.json
index 285033d3..36b30ac5 100644
--- a/packages/templates/electron-app/apps/ui/components.json
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/components.json
@@ -1,6 +1,6 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
- "style": "new-york",
+ "style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
@@ -17,5 +17,9 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
- "iconLibrary": "lucide"
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
}
diff --git a/packages/templates/electron-app/apps/ui/index.html b/packages/templates/electron-app/apps/one-template-electron-renderer/index.html
similarity index 100%
rename from packages/templates/electron-app/apps/ui/index.html
rename to packages/templates/electron-app/apps/one-template-electron-renderer/index.html
diff --git a/packages/templates/electron-app/apps/ui/package.json b/packages/templates/electron-app/apps/one-template-electron-renderer/package.json
similarity index 68%
rename from packages/templates/electron-app/apps/ui/package.json
rename to packages/templates/electron-app/apps/one-template-electron-renderer/package.json
index 88e9ef06..bc351f29 100644
--- a/packages/templates/electron-app/apps/ui/package.json
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/package.json
@@ -1,5 +1,5 @@
{
- "name": "@one-template-electron/ui",
+ "name": "@one-template-electron/renderer",
"version": "0.1.0",
"private": true,
"description": "electron-template ui (renderer)",
@@ -8,20 +8,25 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "lint": "oxlint .",
+ "lint:fix": "oxlint --fix .",
+ "format": "oxfmt --check .",
+ "format:fix": "oxfmt --write .",
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run format",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
+ "@base-ui/react": "^1.8.0",
"@one-template-electron/preload": "workspace:*",
- "@radix-ui/react-slot": "^1.3.3",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "immer": "^11.1.18",
"lucide-react": "^1.49.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router-dom": "^7.18.4",
- "sonner": "^2.0.8",
+ "shadcn": "^4.21.0",
"swr": "^2.5.1",
"tailwind-merge": "^3.7.0",
"tw-animate-css": "^1.4.0",
@@ -33,6 +38,8 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
+ "oxfmt": "^0.71.0",
+ "oxlint": "^1.86.0",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.3.1"
diff --git a/packages/templates/electron-app/apps/ui/public/.gitkeep b/packages/templates/electron-app/apps/one-template-electron-renderer/public/.gitkeep
similarity index 100%
rename from packages/templates/electron-app/apps/ui/public/.gitkeep
rename to packages/templates/electron-app/apps/one-template-electron-renderer/public/.gitkeep
diff --git a/packages/templates/electron-app/apps/one-template-electron-renderer/src/App.tsx b/packages/templates/electron-app/apps/one-template-electron-renderer/src/App.tsx
new file mode 100644
index 00000000..64603549
--- /dev/null
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/src/App.tsx
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+import { Outlet } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { useAppStore } from "@/store/app-store";
+
+export default function App() {
+ const theme = useAppStore((state) => state.theme);
+ const toggleTheme = useAppStore((state) => state.toggleTheme);
+ useEffect(() => {
+ document.documentElement.classList.toggle("dark", theme === "dark");
+ document.documentElement.style.colorScheme = theme;
+ }, [theme]);
+ return (
+
+
+ One CLI · Electron
+
+ 切换主题 / Toggle theme
+
+
+
+
+
+
+ );
+}
diff --git a/packages/templates/electron-app/apps/ui/src/assets/.gitkeep b/packages/templates/electron-app/apps/one-template-electron-renderer/src/assets/.gitkeep
similarity index 100%
rename from packages/templates/electron-app/apps/ui/src/assets/.gitkeep
rename to packages/templates/electron-app/apps/one-template-electron-renderer/src/assets/.gitkeep
diff --git a/packages/templates/electron-app/apps/ui/src/components/.gitkeep b/packages/templates/electron-app/apps/one-template-electron-renderer/src/components/.gitkeep
similarity index 100%
rename from packages/templates/electron-app/apps/ui/src/components/.gitkeep
rename to packages/templates/electron-app/apps/one-template-electron-renderer/src/components/.gitkeep
diff --git a/packages/templates/electron-app/apps/one-template-electron-renderer/src/components/ui/button.tsx b/packages/templates/electron-app/apps/one-template-electron-renderer/src/components/ui/button.tsx
new file mode 100644
index 00000000..82f6ba2e
--- /dev/null
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/src/components/ui/button.tsx
@@ -0,0 +1,62 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ cn(
+ buttonVariants({ variant, size }),
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/packages/templates/electron-app/apps/one-template-electron-renderer/src/components/ui/toast.tsx b/packages/templates/electron-app/apps/one-template-electron-renderer/src/components/ui/toast.tsx
new file mode 100644
index 00000000..06a21a8e
--- /dev/null
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/src/components/ui/toast.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+import * as React from "react";
+import { Toast as ToastPrimitive } from "@base-ui/react/toast";
+import { cn } from "@/lib/utils";
+
+import { Button } from "@/components/ui/button";
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+ XIcon,
+} from "lucide-react";
+
+type ToastData = { className?: string };
+
+const toast = ToastPrimitive.createToastManager();
+
+function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
+ return ;
+}
+
+function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
+ return ;
+}
+
+function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
+ return (
+
+ cn(
+ "pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
+ return (
+
+ cn(
+ "group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ "[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
+ "h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
+ "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
+ "data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
+ "data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
+ "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
+ "data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
+ return (
+
+ cn(
+ "flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
+ return (
+
+ cn(
+ "text-sm font-medium",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastDescription({
+ className,
+ ...props
+}: ToastPrimitive.Description.Props) {
+ return (
+
+ cn(
+ "text-sm text-muted-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastAction({
+ className,
+ render = ,
+ ...props
+}: ToastPrimitive.Action.Props) {
+ return (
+
+ cn(
+ "shrink-0",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastClose({
+ className,
+ children,
+ render = ,
+ ...props
+}: ToastPrimitive.Close.Props) {
+ return (
+
+ cn(
+ "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function ToastIcon({ type }: { type: string | undefined }) {
+ let icon: React.ReactNode = null;
+
+ if (type === "success") {
+ icon = ;
+ }
+
+ if (type === "info") {
+ icon = ;
+ }
+
+ if (type === "warning") {
+ icon = ;
+ }
+
+ if (type === "error") {
+ icon = ;
+ }
+
+ if (type === "loading") {
+ icon = ;
+ }
+
+ if (!icon) {
+ return null;
+ }
+
+ return (
+
+ {icon}
+
+ );
+}
+
+function ToastList() {
+ const { toasts } = ToastPrimitive.useToastManager();
+
+ return toasts.map((toastItem) => (
+
+
+
+
+
+
+
+
+
+
+
+ ));
+}
+
+function Toaster({
+ children,
+ toastManager = toast,
+ ...props
+}: ToastPrimitive.Provider.Props) {
+ return (
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+const createToastManager = ToastPrimitive.createToastManager;
+const useToastManager = ToastPrimitive.useToastManager;
+
+export {
+ Toaster,
+ Toast,
+ ToastAction,
+ ToastClose,
+ ToastContent,
+ ToastDescription,
+ ToastPortal,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+ createToastManager,
+ toast,
+ useToastManager,
+};
diff --git a/packages/templates/electron-app/apps/one-template-electron-renderer/src/lib/http.ts b/packages/templates/electron-app/apps/one-template-electron-renderer/src/lib/http.ts
new file mode 100644
index 00000000..261ee937
--- /dev/null
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/src/lib/http.ts
@@ -0,0 +1,8 @@
+import axios from "axios";
+export const http = axios.create({
+ baseURL: import.meta.env.APP_API_URL,
+ timeout: 10000,
+});
+export async function fetcher(url: string): Promise {
+ return (await http.get(url)).data;
+}
diff --git a/packages/templates/electron-app/apps/ui/src/lib/utils.ts b/packages/templates/electron-app/apps/one-template-electron-renderer/src/lib/utils.ts
similarity index 100%
rename from packages/templates/electron-app/apps/ui/src/lib/utils.ts
rename to packages/templates/electron-app/apps/one-template-electron-renderer/src/lib/utils.ts
diff --git a/packages/templates/electron-app/apps/one-template-electron-renderer/src/main.tsx b/packages/templates/electron-app/apps/one-template-electron-renderer/src/main.tsx
new file mode 100644
index 00000000..6e97082d
--- /dev/null
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/src/main.tsx
@@ -0,0 +1,29 @@
+import { StrictMode } from "react";
+import ReactDOM from "react-dom/client";
+import { HashRouter, Route, Routes } from "react-router-dom";
+import { SWRConfig } from "swr";
+import App from "@/App";
+import { Toaster } from "@/components/ui/toast";
+import { fetcher } from "@/lib/http";
+import HomePage from "@/nodes/HomePage";
+import "@/styles/globals.css";
+
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+
+
+
+ }>
+ } />
+ 404 · 页面未找到 / Page not found
}
+ />
+
+
+
+
+
+ ,
+);
diff --git a/packages/templates/electron-app/apps/one-template-electron-renderer/src/nodes/HomePage/index.tsx b/packages/templates/electron-app/apps/one-template-electron-renderer/src/nodes/HomePage/index.tsx
new file mode 100644
index 00000000..c180a05c
--- /dev/null
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/src/nodes/HomePage/index.tsx
@@ -0,0 +1,25 @@
+import useSWR from "swr";
+import type { AppInfo } from "@one-template-electron/preload/channels";
+
+export default function HomePage() {
+ const { data, error } = useSWR(
+ window.electron ? "app.info" : null,
+ () => window.electron!.getAppInfo(),
+ );
+ return (
+
+ 欢迎 / Welcome
+
+ 从这里开始构建桌面应用。Start building your desktop application here.
+
+ {data && (
+
+ {data.name} · {data.version} · {data.platform}
+
+ )}
+ {error && (
+ 无法读取应用信息 / Could not load app information
+ )}
+
+ );
+}
diff --git a/packages/templates/electron-app/apps/one-template-electron-renderer/src/renderer.d.ts b/packages/templates/electron-app/apps/one-template-electron-renderer/src/renderer.d.ts
new file mode 100644
index 00000000..78e886e4
--- /dev/null
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/src/renderer.d.ts
@@ -0,0 +1,7 @@
+import type { ElectronAPI } from "@one-template-electron/preload";
+declare global {
+ interface Window {
+ electron?: ElectronAPI;
+ }
+}
+export {};
diff --git a/packages/templates/electron-app/apps/one-template-electron-renderer/src/store/app-store.ts b/packages/templates/electron-app/apps/one-template-electron-renderer/src/store/app-store.ts
new file mode 100644
index 00000000..06342745
--- /dev/null
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/src/store/app-store.ts
@@ -0,0 +1,23 @@
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+interface UIState {
+ theme: "light" | "dark";
+ toggleTheme: () => void;
+}
+export const useAppStore = create()(
+ persist(
+ (set) => ({
+ theme: window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light",
+ toggleTheme: () =>
+ set((state) => ({ theme: state.theme === "dark" ? "light" : "dark" })),
+ }),
+ {
+ name: "ui-theme-v1",
+ storage: createJSONStorage(() => localStorage),
+ partialize: (state) => ({ theme: state.theme }),
+ },
+ ),
+);
diff --git a/packages/templates/electron-app/apps/ui/src/styles/globals.css b/packages/templates/electron-app/apps/one-template-electron-renderer/src/styles/globals.css
similarity index 99%
rename from packages/templates/electron-app/apps/ui/src/styles/globals.css
rename to packages/templates/electron-app/apps/one-template-electron-renderer/src/styles/globals.css
index 50013cbe..8b1eb004 100644
--- a/packages/templates/electron-app/apps/ui/src/styles/globals.css
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/src/styles/globals.css
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
+@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
diff --git a/packages/templates/electron-app/apps/ui/src/vite-env.d.ts b/packages/templates/electron-app/apps/one-template-electron-renderer/src/vite-env.d.ts
similarity index 100%
rename from packages/templates/electron-app/apps/ui/src/vite-env.d.ts
rename to packages/templates/electron-app/apps/one-template-electron-renderer/src/vite-env.d.ts
diff --git a/packages/templates/electron-app/apps/ui/tsconfig.json b/packages/templates/electron-app/apps/one-template-electron-renderer/tsconfig.json
similarity index 100%
rename from packages/templates/electron-app/apps/ui/tsconfig.json
rename to packages/templates/electron-app/apps/one-template-electron-renderer/tsconfig.json
diff --git a/packages/templates/electron-app/apps/ui/tsconfig.node.json b/packages/templates/electron-app/apps/one-template-electron-renderer/tsconfig.node.json
similarity index 100%
rename from packages/templates/electron-app/apps/ui/tsconfig.node.json
rename to packages/templates/electron-app/apps/one-template-electron-renderer/tsconfig.node.json
diff --git a/packages/templates/electron-app/apps/ui/vite.config.ts b/packages/templates/electron-app/apps/one-template-electron-renderer/vite.config.ts
similarity index 83%
rename from packages/templates/electron-app/apps/ui/vite.config.ts
rename to packages/templates/electron-app/apps/one-template-electron-renderer/vite.config.ts
index b6f35943..3cb3952c 100644
--- a/packages/templates/electron-app/apps/ui/vite.config.ts
+++ b/packages/templates/electron-app/apps/one-template-electron-renderer/vite.config.ts
@@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
+ base: "./",
server: {
port: Number(process.env.ELECTRON_RENDERER_PORT || 5173),
strictPort: true,
@@ -14,10 +15,10 @@ export default defineConfig({
"@": path.resolve(import.meta.dirname, "./src"),
},
},
- envDir: "../../",
+ envDir: ".",
envPrefix: "APP",
build: {
- outDir: path.resolve(import.meta.dirname, "../electron/build/renderer"),
+ outDir: path.resolve(import.meta.dirname, "dist"),
emptyOutDir: true,
},
});
diff --git a/packages/templates/electron-app/apps/ui/public/onecli-logo-inverted.svg b/packages/templates/electron-app/apps/ui/public/onecli-logo-inverted.svg
deleted file mode 100644
index ee1d78fd..00000000
--- a/packages/templates/electron-app/apps/ui/public/onecli-logo-inverted.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
- One CLI
- One CLI horizontal wordmark — inverted for dark backgrounds
-
- ONE CLI
-
diff --git a/packages/templates/electron-app/apps/ui/public/onecli-logo.svg b/packages/templates/electron-app/apps/ui/public/onecli-logo.svg
deleted file mode 100644
index 6194d834..00000000
--- a/packages/templates/electron-app/apps/ui/public/onecli-logo.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
- One CLI
- One CLI horizontal wordmark — primary, torch
-
- ONE CLI
-
diff --git a/packages/templates/electron-app/apps/ui/src/App.tsx b/packages/templates/electron-app/apps/ui/src/App.tsx
deleted file mode 100644
index dda431b4..00000000
--- a/packages/templates/electron-app/apps/ui/src/App.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import React, { FC } from "react";
-import { Link, Outlet } from "react-router-dom";
-
-const App: FC = () => {
- return (
-
-
-
-
-
electron-template
-
- electron + react 19 + tailwind v4 + shadcn
-
-
-
-
- Home
-
-
- About
-
-
-
-
-
-
-
-
- );
-};
-
-export default App;
diff --git a/packages/templates/electron-app/apps/ui/src/components/ui/button.tsx b/packages/templates/electron-app/apps/ui/src/components/ui/button.tsx
deleted file mode 100644
index 83855833..00000000
--- a/packages/templates/electron-app/apps/ui/src/components/ui/button.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import * as React from "react";
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/lib/utils";
-
-const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
- {
- variants: {
- variant: {
- default:
- "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
- destructive:
- "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
- outline:
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
- secondary:
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
- ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default: "h-9 px-4 py-2 has-[>svg]:px-3",
- sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
- icon: "size-9",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- },
-);
-
-function Button({
- className,
- variant,
- size,
- asChild = false,
- ...props
-}: React.ComponentProps<"button"> &
- VariantProps & {
- asChild?: boolean;
- }) {
- const Comp = asChild ? Slot : "button";
- return (
-
- );
-}
-
-export { Button, buttonVariants };
diff --git a/packages/templates/electron-app/apps/ui/src/components/ui/sonner.tsx b/packages/templates/electron-app/apps/ui/src/components/ui/sonner.tsx
deleted file mode 100644
index 6cef0f5f..00000000
--- a/packages/templates/electron-app/apps/ui/src/components/ui/sonner.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Toaster as Sonner, type ToasterProps } from "sonner";
-
-// 简化版:不接 next-themes,由父层自行控制 className=dark
-// 需要夜间模式时把 theme 传进来或从全局 store 读
-const Toaster = (props: ToasterProps) => {
- return (
-
- );
-};
-
-export { Toaster };
diff --git a/packages/templates/electron-app/apps/ui/src/hooks/electron.ts b/packages/templates/electron-app/apps/ui/src/hooks/electron.ts
deleted file mode 100644
index 1a3219e2..00000000
--- a/packages/templates/electron-app/apps/ui/src/hooks/electron.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { ElectronAPI } from "@one-template-electron/preload";
-
-// 直接透传 preload 暴露的 API
-// preload 内部已解包信封并在失败时抛错,这里不再重复包装
-export default function useElectron(): ElectronAPI {
- return window.electron;
-}
diff --git a/packages/templates/electron-app/apps/ui/src/lib/http.ts b/packages/templates/electron-app/apps/ui/src/lib/http.ts
deleted file mode 100644
index d03697f1..00000000
--- a/packages/templates/electron-app/apps/ui/src/lib/http.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import axios, { type AxiosRequestConfig } from "axios";
-
-// axios 实例:统一 baseURL / timeout / interceptors 的地方
-// 业务代码从这里 import http,不要直接用 axios
-export const http = axios.create({
- timeout: 10_000,
-});
-
-// 给 swr 用的通用 fetcher
-// useSWR("/api/xxx", fetcher) 就能直接使用
-export const fetcher = async (
- url: string,
- config?: AxiosRequestConfig,
-): Promise => {
- const res = await http.get(url, config);
- return res.data;
-};
diff --git a/packages/templates/electron-app/apps/ui/src/main.tsx b/packages/templates/electron-app/apps/ui/src/main.tsx
deleted file mode 100644
index 976f4862..00000000
--- a/packages/templates/electron-app/apps/ui/src/main.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React, { StrictMode } from "react";
-import ReactDOM from "react-dom/client";
-import { BrowserRouter, Route, Routes } from "react-router-dom";
-import App from "@/App";
-import { Toaster } from "@/components/ui/sonner";
-import AboutPage from "@/nodes/AboutPage";
-import HomePage from "@/nodes/HomePage";
-import "@/styles/globals.css";
-
-ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
-
-
- }>
- } />
- } />
- } />
-
-
-
-
- ,
-);
diff --git a/packages/templates/electron-app/apps/ui/src/nodes/AboutPage/index.tsx b/packages/templates/electron-app/apps/ui/src/nodes/AboutPage/index.tsx
deleted file mode 100644
index da71af5e..00000000
--- a/packages/templates/electron-app/apps/ui/src/nodes/AboutPage/index.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React, { FC } from "react";
-
-const AboutPage: FC = () => {
- return (
-
- About
-
- Minimal electron + react template. Styled with tailwind v4 tokens.
-
-
- );
-};
-
-export default AboutPage;
diff --git a/packages/templates/electron-app/apps/ui/src/nodes/HomePage/index.tsx b/packages/templates/electron-app/apps/ui/src/nodes/HomePage/index.tsx
deleted file mode 100644
index 25449ce8..00000000
--- a/packages/templates/electron-app/apps/ui/src/nodes/HomePage/index.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * Welcome screen — kept intentionally minimal for AI agents to extend.
- *
- * Pre-wired infrastructure (don't recreate; import from these paths):
- * - IPC client: useElectron() in src/hooks/electron.ts
- * (electron.invoke / .send / .on; types from @one-template-electron/preload/channels)
- * - IPC channels: IPC.dialog.open / IPC.shell.open / IPC.contextMenu.show, etc.
- * (full list in @one-template-electron/preload/channels — DO NOT redefine)
- * - IPC events: EVENT.update.downloadProgress, etc. — subscribe via electron.on()
- * - Store: useAppStore (zustand + immer) in src/store/app-store.ts
- * - HTTP client: src/lib/http.ts (axios fetcher, e.g. for SWR with public APIs)
- * - Toast: sonner via src/components/ui/sonner.tsx (mounted by parent shell)
- * - Routing: react-router; routes defined alongside App.tsx
- * - Design tokens: src/styles/globals.css (CSS vars, light + dark via shadcn convention)
- *
- * Atomic design (advisory — physical folders NOT enforced):
- * - atoms → src/components/ui/ (shadcn primitives)
- * - molecules → src/components/ (compose atoms)
- * - organisms → src/nodes// (page-level blocks live here, alongside the page)
- * - pages → src/nodes//index.tsx
- *
- * Design tokens — DO use these utilities (they map to CSS vars in globals.css):
- * - Surface: bg-background / bg-card / bg-popover
- * - Text: text-foreground / text-muted-foreground / text-primary
- * - Border: border-border / border-input
- * - Accent: bg-primary / bg-secondary / bg-destructive
- * DON'T hardcode hex / rgb in Tailwind classes.
- */
-
-import { ArrowRight, BookOpen, CodeXml } from "lucide-react";
-import type { FC } from "react";
-
-const HomePage: FC = () => (
-
-
-
-
-
-
-
- Electron + React · Desktop
-
-
- Welcome to One CLI
-
-
- 一个由 One CLI 生成的 Electron 桌面端脚手架,已预置 shadcn/ui、Tailwind
- v4、IPC 桥与状态管理。
-
-
-
-
- Edit{" "}
-
- apps/ui/src/nodes/HomePage/index.tsx
- {" "}
- and save to start.
-
-
-
-
-);
-
-export default HomePage;
diff --git a/packages/templates/electron-app/apps/ui/src/renderer.d.ts b/packages/templates/electron-app/apps/ui/src/renderer.d.ts
deleted file mode 100644
index 1f0aebf2..00000000
--- a/packages/templates/electron-app/apps/ui/src/renderer.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// 把 @one-template-electron/preload 暴露的 ElectronAPI 注入到 window.electron 的类型上
-import "@one-template-electron/preload";
diff --git a/packages/templates/electron-app/apps/ui/src/store/app-store.ts b/packages/templates/electron-app/apps/ui/src/store/app-store.ts
deleted file mode 100644
index 7c0f3f35..00000000
--- a/packages/templates/electron-app/apps/ui/src/store/app-store.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { create } from "zustand";
-import { immer } from "zustand/middleware/immer";
-
-// 全局状态 store:zustand + immer
-// immer 让嵌套字段可以直接赋值(不用展开)
-interface AppState {
- count: number;
- theme: "light" | "dark";
-}
-
-interface AppActions {
- increase: (step?: number) => void;
- reset: () => void;
- toggleTheme: () => void;
-}
-
-export const useAppStore = create()(
- immer((set) => ({
- count: 0,
- theme: "light",
- increase: (step = 1) =>
- set((state) => {
- state.count += step;
- }),
- reset: () =>
- set((state) => {
- state.count = 0;
- }),
- toggleTheme: () =>
- set((state) => {
- state.theme = state.theme === "light" ? "dark" : "light";
- }),
- })),
-);
diff --git a/packages/templates/electron-app/apps/ui/src/store/index.ts b/packages/templates/electron-app/apps/ui/src/store/index.ts
deleted file mode 100644
index b31d7637..00000000
--- a/packages/templates/electron-app/apps/ui/src/store/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useAppStore } from "./app-store";
diff --git a/packages/templates/electron-app/package.json b/packages/templates/electron-app/package.json
index 535988be..b48dfbc5 100644
--- a/packages/templates/electron-app/package.json
+++ b/packages/templates/electron-app/package.json
@@ -7,16 +7,17 @@
"license": "MIT",
"author": "",
"workspaces": [
- "apps/electron",
- "apps/ui",
- "packages/preload"
+ "apps/one-template-electron-renderer",
+ "services/one-template-electron-main",
+ "packages/one-template-electron-preload"
],
"scripts": {
- "dev": "pnpm -F @one-template-electron/preload build && pnpm -r --parallel -F @one-template-electron/electron -F @one-template-electron/ui run dev",
- "build": "pnpm -F @one-template-electron/preload run build && pnpm -r --parallel -F @one-template-electron/electron -F @one-template-electron/ui run build",
- "pack": "pnpm -F @one-template-electron/electron run pack",
- "sandbox:profile": "pnpm --silent -F @one-template-electron/electron run sandbox:profile",
- "release": "pnpm run build && pnpm -F @one-template-electron/electron run release",
+ "dev": "pnpm -F @one-template-electron/preload build && pnpm -r --parallel -F @one-template-electron/main -F @one-template-electron/renderer -F @one-template-electron/preload run dev",
+ "build": "pnpm --filter @one-template-electron/main... run build",
+ "test": "pnpm -F @one-template-electron/preload run build && pnpm -F @one-template-electron/main run test",
+ "pack": "pnpm run build && pnpm -F @one-template-electron/main run pack",
+ "sandbox:profile": "pnpm --silent -F @one-template-electron/main run sandbox:profile",
+ "release": "pnpm run build && pnpm -F @one-template-electron/main run release",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"format": "oxfmt",
diff --git a/packages/templates/electron-app/packages/preload/.gitignore b/packages/templates/electron-app/packages/one-template-electron-preload/.gitignore
similarity index 83%
rename from packages/templates/electron-app/packages/preload/.gitignore
rename to packages/templates/electron-app/packages/one-template-electron-preload/.gitignore
index 6fc7bee6..1521c8b7 100644
--- a/packages/templates/electron-app/packages/preload/.gitignore
+++ b/packages/templates/electron-app/packages/one-template-electron-preload/.gitignore
@@ -1 +1 @@
-dist
+dist
diff --git a/packages/templates/electron-app/packages/preload/package.json b/packages/templates/electron-app/packages/one-template-electron-preload/package.json
similarity index 65%
rename from packages/templates/electron-app/packages/preload/package.json
rename to packages/templates/electron-app/packages/one-template-electron-preload/package.json
index e17956c0..1c221a8e 100644
--- a/packages/templates/electron-app/packages/preload/package.json
+++ b/packages/templates/electron-app/packages/one-template-electron-preload/package.json
@@ -20,10 +20,18 @@
},
"scripts": {
"dev": "tsdown --watch",
- "build": "tsdown"
+ "build": "tsdown",
+ "lint": "oxlint .",
+ "lint:fix": "oxlint --fix .",
+ "format": "oxfmt --check .",
+ "format:fix": "oxfmt --write .",
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run format",
+ "typecheck": "tsc --noEmit"
},
"devDependencies": {
"electron": "^44.5.1",
+ "oxfmt": "^0.71.0",
+ "oxlint": "^1.86.0",
"tsdown": "^0.23.0",
"typescript": "^7.0.2"
}
diff --git a/packages/templates/electron-app/packages/one-template-electron-preload/src/api.ts b/packages/templates/electron-app/packages/one-template-electron-preload/src/api.ts
new file mode 100644
index 00000000..938f24fa
--- /dev/null
+++ b/packages/templates/electron-app/packages/one-template-electron-preload/src/api.ts
@@ -0,0 +1,5 @@
+import type { AppInfo } from "./channels";
+
+export interface ElectronAPI {
+ getAppInfo: () => Promise;
+}
diff --git a/packages/templates/electron-app/packages/one-template-electron-preload/src/channels.ts b/packages/templates/electron-app/packages/one-template-electron-preload/src/channels.ts
new file mode 100644
index 00000000..486cd7b9
--- /dev/null
+++ b/packages/templates/electron-app/packages/one-template-electron-preload/src/channels.ts
@@ -0,0 +1,6 @@
+export const IPC = { appInfo: "app.info" } as const;
+export interface AppInfo {
+ name: string;
+ version: string;
+ platform: string;
+}
diff --git a/packages/templates/electron-app/packages/one-template-electron-preload/src/index.ts b/packages/templates/electron-app/packages/one-template-electron-preload/src/index.ts
new file mode 100644
index 00000000..eb9d7b2c
--- /dev/null
+++ b/packages/templates/electron-app/packages/one-template-electron-preload/src/index.ts
@@ -0,0 +1,8 @@
+import { contextBridge, ipcRenderer } from "electron";
+import type { ElectronAPI } from "./api";
+import { IPC } from "./channels";
+
+const api: ElectronAPI = { getAppInfo: () => ipcRenderer.invoke(IPC.appInfo) };
+contextBridge.exposeInMainWorld("electron", api);
+export type { ElectronAPI } from "./api";
+export type { AppInfo } from "./channels";
diff --git a/packages/templates/electron-app/packages/preload/tsconfig.json b/packages/templates/electron-app/packages/one-template-electron-preload/tsconfig.json
similarity index 100%
rename from packages/templates/electron-app/packages/preload/tsconfig.json
rename to packages/templates/electron-app/packages/one-template-electron-preload/tsconfig.json
diff --git a/packages/templates/electron-app/packages/preload/tsdown.config.ts b/packages/templates/electron-app/packages/one-template-electron-preload/tsdown.config.ts
similarity index 100%
rename from packages/templates/electron-app/packages/preload/tsdown.config.ts
rename to packages/templates/electron-app/packages/one-template-electron-preload/tsdown.config.ts
diff --git a/packages/templates/electron-app/packages/preload/src/api.ts b/packages/templates/electron-app/packages/preload/src/api.ts
deleted file mode 100644
index 67a53129..00000000
--- a/packages/templates/electron-app/packages/preload/src/api.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { InvokeChannel, InvokeMap, EventChannel } from "./channels";
-
-// 暴露到 renderer window.electron 上的 API 契约
-export interface ElectronAPI {
- // invoke:有返回值,preload 内部已解包信封,失败会抛错
- invoke(
- channel: K,
- ...args: InvokeMap[K]["args"]
- ): Promise;
-
- // on:订阅事件,返回 unsubscribe 函数
- on(
- channel: K,
- listener: (...args: unknown[]) => void,
- ): () => void;
-
- // once:订阅一次,返回 unsubscribe(提前取消也安全)
- once(
- channel: K,
- listener: (...args: unknown[]) => void,
- ): () => void;
-}
-
-declare global {
- interface Window {
- electron: ElectronAPI;
- }
-}
diff --git a/packages/templates/electron-app/packages/preload/src/channels.ts b/packages/templates/electron-app/packages/preload/src/channels.ts
deleted file mode 100644
index f67538ed..00000000
--- a/packages/templates/electron-app/packages/preload/src/channels.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-// IPC 通道:invoke(request/response)类型
-// 前后端共用同一份常量,拼写错误在编译期就能发现
-export const IPC = {
- dialog: {
- open: "dialog.open",
- save: "dialog.save",
- },
- shell: {
- open: "shell.open",
- },
- contextMenu: {
- show: "contextMenu.show",
- },
- update: {
- check: "update.check",
- startDownload: "update.startDownload",
- install: "update.install",
- },
-} as const;
-
-// 主进程主动推送到 renderer 的事件通道(无返回值)
-export const EVENT = {
- update: {
- checking: "update:checking",
- available: "update:available",
- notAvailable: "update:notAvailable",
- downloadProgress: "update:downloadProgress",
- downloaded: "update:downloaded",
- },
-} as const;
-
-// 统一响应信封:主进程所有 handler 都返回这个结构
-export interface IpcResponse {
- code: number;
- message: string;
- data: T | null;
-}
-
-// Dialog payload 契约
-export interface DialogOpenOptions {
- type: "file" | "directory";
- multiple?: boolean;
- readContent?: boolean;
- filters?: Electron.FileFilter[];
-}
-
-export interface DialogSaveOptions {
- defaultPath?: string;
- content: string;
- filters?: Electron.FileFilter[];
-}
-
-// ContextMenu payload 契约
-export interface ContextMenuItem {
- type?: "separator";
- key?: string;
- label?: string;
-}
-
-// 通道 → [参数, 返回值] 映射表,给 invoke 做类型推导
-export interface InvokeMap {
- [IPC.dialog.open]: { args: [DialogOpenOptions]; return: string[] };
- [IPC.dialog.save]: { args: [DialogSaveOptions]; return: string };
- [IPC.shell.open]: { args: [string]; return: void };
- [IPC.contextMenu.show]: { args: [ContextMenuItem[]]; return: string | null };
- [IPC.update.check]: { args: []; return: void };
- [IPC.update.startDownload]: { args: []; return: void };
- [IPC.update.install]: { args: []; return: void };
-}
-
-export type InvokeChannel = keyof InvokeMap;
-
-type EventNamespace = (typeof EVENT)[keyof typeof EVENT];
-export type EventChannel = EventNamespace[keyof EventNamespace];
diff --git a/packages/templates/electron-app/packages/preload/src/index.ts b/packages/templates/electron-app/packages/preload/src/index.ts
deleted file mode 100644
index c0c4453f..00000000
--- a/packages/templates/electron-app/packages/preload/src/index.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron";
-import type { ElectronAPI } from "./api";
-import type { IpcResponse } from "./channels";
-
-// 解包响应信封:code === 0 返回 data,否则抛 Error(message)
-// 上层调用方写普通的 try/catch 就能处理失败
-function unwrap(res: IpcResponse | T): T {
- if (
- res &&
- typeof res === "object" &&
- "code" in (res as object) &&
- "message" in (res as object)
- ) {
- const envelope = res as IpcResponse;
- if (envelope.code === 0) return envelope.data as T;
- throw new Error(envelope.message || "ipc error");
- }
- return res as T;
-}
-
-const api: ElectronAPI = {
- async invoke(channel, ...args) {
- const res = await ipcRenderer.invoke(channel as string, ...args);
- return unwrap(res);
- },
-
- on(channel, listener) {
- const wrapped = (_e: IpcRendererEvent, ...args: unknown[]) =>
- listener(...args);
- ipcRenderer.on(channel as string, wrapped);
- return () => ipcRenderer.removeListener(channel as string, wrapped);
- },
-
- once(channel, listener) {
- const wrapped = (_e: IpcRendererEvent, ...args: unknown[]) =>
- listener(...args);
- ipcRenderer.once(channel as string, wrapped);
- return () => ipcRenderer.removeListener(channel as string, wrapped);
- },
-};
-
-contextBridge.exposeInMainWorld("electron", api);
-
-export type { ElectronAPI } from "./api";
-export type {
- IpcResponse,
- InvokeChannel,
- InvokeMap,
- EventChannel,
- DialogOpenOptions,
- DialogSaveOptions,
- ContextMenuItem,
-} from "./channels";
-export { IPC, EVENT } from "./channels";
diff --git a/packages/templates/electron-app/pnpm-workspace.yaml b/packages/templates/electron-app/pnpm-workspace.yaml
index 6f9f797e..b7b56622 100644
--- a/packages/templates/electron-app/pnpm-workspace.yaml
+++ b/packages/templates/electron-app/pnpm-workspace.yaml
@@ -1,7 +1,7 @@
packages:
- - apps/electron
- - apps/ui
- - packages/preload
+ - services/one-template-electron-main
+ - apps/one-template-electron-renderer
+ - packages/one-template-electron-preload
allowBuilds:
electron: true
diff --git a/packages/templates/electron-app/apps/electron/.gitignore b/packages/templates/electron-app/services/one-template-electron-main/.gitignore
similarity index 87%
rename from packages/templates/electron-app/apps/electron/.gitignore
rename to packages/templates/electron-app/services/one-template-electron-main/.gitignore
index 63d7e650..f249c09e 100644
--- a/packages/templates/electron-app/apps/electron/.gitignore
+++ b/packages/templates/electron-app/services/one-template-electron-main/.gitignore
@@ -1,4 +1,4 @@
-.bin
-node_modules
-build
-dist
+.bin
+node_modules
+build
+dist
diff --git a/packages/templates/electron-app/apps/electron/assets/icon.ico b/packages/templates/electron-app/services/one-template-electron-main/assets/icon.ico
similarity index 100%
rename from packages/templates/electron-app/apps/electron/assets/icon.ico
rename to packages/templates/electron-app/services/one-template-electron-main/assets/icon.ico
diff --git a/packages/templates/electron-app/apps/electron/assets/icon.png b/packages/templates/electron-app/services/one-template-electron-main/assets/icon.png
similarity index 100%
rename from packages/templates/electron-app/apps/electron/assets/icon.png
rename to packages/templates/electron-app/services/one-template-electron-main/assets/icon.png
diff --git a/packages/templates/electron-app/services/one-template-electron-main/package.json b/packages/templates/electron-app/services/one-template-electron-main/package.json
new file mode 100644
index 00000000..1cb99e3d
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@one-template-electron/main",
+ "version": "0.1.0",
+ "description": "electron-template electron main process",
+ "license": "MIT",
+ "author": "",
+ "type": "module",
+ "main": "build/main/index.js",
+ "scripts": {
+ "dev": "node scripts/dev.mjs",
+ "build": "tsc --noEmit && cross-env NODE_ENV=production tsdown",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "sandbox:profile": "node scripts/sandbox-profile.mjs",
+ "pack": "cross-env NODE_ENV=development node scripts/release.mjs",
+ "release": "cross-env NODE_ENV=production node scripts/release.mjs",
+ "lint": "oxlint .",
+ "lint:fix": "oxlint --fix .",
+ "format": "oxfmt --check .",
+ "format:fix": "oxfmt --write .",
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run format"
+ },
+ "dependencies": {
+ "@one-template-electron/preload": "workspace:*",
+ "awilix": "^13.0.5",
+ "electron-log": "^5.4.4"
+ },
+ "devDependencies": {
+ "@one-template-electron/renderer": "workspace:*",
+ "@types/node": "^24.19.0",
+ "cross-env": "^10.1.0",
+ "electron": "^44.5.1",
+ "electron-builder": "^26.15.3",
+ "oxfmt": "^0.71.0",
+ "oxlint": "^1.86.0",
+ "tsdown": "^0.23.0",
+ "typescript": "^7.0.2",
+ "vitest": "^5.0.2"
+ }
+}
diff --git a/packages/templates/electron-app/services/one-template-electron-main/scripts/desktop-env.mjs b/packages/templates/electron-app/services/one-template-electron-main/scripts/desktop-env.mjs
new file mode 100644
index 00000000..997c628e
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/scripts/desktop-env.mjs
@@ -0,0 +1,160 @@
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs/promises";
+import { createConnection } from "node:net";
+import { homedir } from "node:os";
+import path from "node:path";
+import { messages } from "./dev-messages.mjs";
+
+function socketReady(socket) {
+ return new Promise((resolve) => {
+ const connection = createConnection(socket);
+ const finish = (ready) => {
+ connection.destroy();
+ resolve(ready);
+ };
+ connection.setTimeout(500, () => finish(false));
+ connection.once("error", () => finish(false));
+ connection.once("connect", () => finish(true));
+ });
+}
+
+export function readSessionEnvironment(env, runtime, run = spawnSync) {
+ // Query only the current user's service manager, then retain display keys.
+ const result = run("/usr/bin/systemctl", ["--user", "show-environment"], {
+ env: {
+ ...env,
+ XDG_RUNTIME_DIR: runtime,
+ DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtime}/bus`,
+ },
+ encoding: "utf8",
+ timeout: 2000,
+ maxBuffer: 1024 * 1024,
+ });
+ if (result.error || result.status !== 0) return {};
+ const values = {};
+ for (const line of result.stdout.split("\n")) {
+ const match = /^(DISPLAY|WAYLAND_DISPLAY|XAUTHORITY)=(.*)$/.exec(line);
+ // systemctl quotes unusual values. Ignore those and use socket discovery.
+ if (match && !/^["']/.test(match[2])) values[match[1]] = match[2];
+ }
+ return values;
+}
+
+export async function detectDesktopEnvironment({
+ env = process.env,
+ platform = process.platform,
+ uid = process.getuid?.(),
+ home = env.HOME || homedir(),
+ runtimeBase = "/run/user",
+ x11Directory = "/tmp/.X11-unix",
+ io = fs,
+ connect = socketReady,
+ sessionEnvironment = readSessionEnvironment,
+ text = messages["en-US"],
+ log = console.log,
+} = {}) {
+ const result = { ...env };
+ // Explicit displays include SSH forwarding and Xvfb. Preserve their settings.
+ if (platform !== "linux" || env.DISPLAY) return result;
+
+ const owned = async (file, kind, allowRoot = false) => {
+ try {
+ const info = await io.stat(file);
+ return (
+ (info.uid === uid || (allowRoot && info.uid === 0)) && info[kind]()
+ );
+ } catch {
+ return false;
+ }
+ };
+ const alive = async (file, allowRoot = false) =>
+ (await owned(file, "isSocket", allowRoot)) && (await connect(file));
+ const entries = async (directory, pattern) => {
+ try {
+ return (await io.readdir(directory))
+ .filter((name) => pattern.test(name))
+ .toSorted();
+ } catch {
+ return [];
+ }
+ };
+ const runtime = env.XDG_RUNTIME_DIR || path.join(runtimeBase, String(uid));
+ const validRuntime = await owned(runtime, "isDirectory");
+ if (!validRuntime && (env.XDG_RUNTIME_DIR || env.WAYLAND_DISPLAY))
+ throw new Error(text.runtime(runtime));
+
+ const desktop = validRuntime ? sessionEnvironment(env, runtime) : {};
+ const waylandPath = (display) =>
+ path.isAbsolute(display) ? display : path.join(runtime, display);
+ const useWayland = (display) => {
+ result.XDG_RUNTIME_DIR = runtime;
+ result.WAYLAND_DISPLAY = display;
+ result.XDG_SESSION_TYPE = "wayland";
+ if (!env.WAYLAND_DISPLAY) log(text.desktop("Wayland", display));
+ return result;
+ };
+ if (env.WAYLAND_DISPLAY) {
+ if (!(await alive(waylandPath(env.WAYLAND_DISPLAY))))
+ throw new Error(text.wayland(waylandPath(env.WAYLAND_DISPLAY)));
+ return useWayland(env.WAYLAND_DISPLAY);
+ }
+ if (validRuntime) {
+ if (
+ desktop.WAYLAND_DISPLAY &&
+ (await alive(waylandPath(desktop.WAYLAND_DISPLAY)))
+ )
+ return useWayland(desktop.WAYLAND_DISPLAY);
+ const displays = [];
+ for (const display of await entries(runtime, /^wayland-\d+$/)) {
+ if (await alive(waylandPath(display))) displays.push(display);
+ }
+ if (displays.length > 1) throw new Error(text.ambiguous("WAYLAND_DISPLAY"));
+ if (displays.length === 1) return useWayland(displays[0]);
+ }
+
+ // X11 requires the current user's authentication file as well as a socket.
+ const authorities = [
+ env.XAUTHORITY,
+ desktop.XAUTHORITY,
+ path.join(home, ".Xauthority"),
+ ].filter(Boolean);
+ let authority;
+ for (const file of authorities) {
+ if (await owned(file, "isFile")) {
+ try {
+ await io.access(file, fs.constants.R_OK);
+ authority = file;
+ break;
+ } catch {
+ // Keep looking for a readable session credential.
+ }
+ }
+ }
+ const x11Socket = (display) => {
+ const match = /^:(\d+)(?:\.\d+)?$/.exec(display || "");
+ return match ? path.join(x11Directory, `X${match[1]}`) : undefined;
+ };
+ if (authority) {
+ let display;
+ const preferred = x11Socket(desktop.DISPLAY);
+ if (preferred && (await alive(preferred, true))) display = desktop.DISPLAY;
+ else {
+ const displays = [];
+ for (const name of await entries(x11Directory, /^X\d+$/)) {
+ if (await alive(path.join(x11Directory, name), true))
+ displays.push(`:${name.slice(1)}`);
+ }
+ if (displays.length > 1) throw new Error(text.ambiguous("DISPLAY"));
+ [display] = displays;
+ }
+ if (display) {
+ result.DISPLAY = display;
+ result.XAUTHORITY = authority;
+ result.XDG_SESSION_TYPE = "x11";
+ if (validRuntime) result.XDG_RUNTIME_DIR = runtime;
+ log(text.desktop("X11", display));
+ return result;
+ }
+ }
+ throw new Error(text.headless);
+}
diff --git a/packages/templates/electron-app/services/one-template-electron-main/scripts/dev-messages.mjs b/packages/templates/electron-app/services/one-template-electron-main/scripts/dev-messages.mjs
new file mode 100644
index 00000000..585b8efa
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/scripts/dev-messages.mjs
@@ -0,0 +1,63 @@
+import { readFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import path from "node:path";
+
+// Follow One's existing preference and auto-locale order without changing it.
+export async function developmentMessages(env = process.env) {
+ let locale;
+ try {
+ const config =
+ env.XDG_CONFIG_HOME || path.join(env.HOME || homedir(), ".config");
+ ({ locale } = JSON.parse(
+ await readFile(path.join(config, "one/preferences.json"), "utf8"),
+ ));
+ } catch {
+ // Missing/invalid preferences use the same auto fallback as One.
+ }
+ if (locale !== "zh-CN" && locale !== "en-US") {
+ const systemLocale = env.LC_ALL || env.LC_MESSAGES || env.LANG || "";
+ locale = /^zh/i.test(systemLocale) ? "zh-CN" : "en-US";
+ }
+ return messages[locale];
+}
+
+export const messages = {
+ "en-US": {
+ sandbox: (binary, profile, commands) =>
+ `Ubuntu blocks Electron's sandbox permissions.\nElectron: ${binary}\nReview the generated AppArmor profile: ${profile}\nInstall it once with administrator privileges:\n\n${commands}\n\nThen rerun the development task. Repeat after Electron's binary path changes.`,
+ probe: (reason) => `Could not check Ubuntu sandbox permissions: ${reason}`,
+ userns:
+ "Ubuntu has disabled unprivileged user namespaces. Ask your administrator to configure Electron sandbox support; an AppArmor profile alone cannot enable it.",
+ runtime: (dir) =>
+ `The desktop runtime directory is unavailable or belongs to another user: ${dir}. Run from your desktop terminal or set XDG_RUNTIME_DIR for your own session.`,
+ wayland: (socket) =>
+ `The Wayland display is unavailable: ${socket}. Check WAYLAND_DISPLAY and XDG_RUNTIME_DIR in your desktop terminal.`,
+ ambiguous: (variable) =>
+ `Multiple desktop displays are available. Set ${variable} from the desktop session you want to use.`,
+ headless:
+ "No desktop display was found for the current user. Run from a graphical desktop terminal, forward an X11 display over SSH, or use Xvfb for headless tests.",
+ desktop: (type, display) =>
+ `Using the current user's local ${type} desktop (${display}). Electron windows will appear on that desktop.`,
+ renderer: (url) => `Renderer did not become ready at ${url}`,
+ build: (code) => `Main build exited with code ${code}`,
+ },
+ "zh-CN": {
+ sandbox: (binary, profile, commands) =>
+ `Ubuntu 限制了 Electron 的沙箱权限。\nElectron:${binary}\n请审阅已生成的 AppArmor 规则:${profile}\n首次使用时,通过管理员权限执行:\n\n${commands}\n\n完成后重新运行开发任务。Electron 可执行文件路径变化后,需要重新配置。`,
+ probe: (reason) => `无法检查 Ubuntu 沙箱权限:${reason}`,
+ userns:
+ "Ubuntu 已禁用非特权用户命名空间。请联系管理员配置 Electron 沙箱支持;仅安装 AppArmor 规则无法启用它。",
+ runtime: (dir) =>
+ `桌面运行目录不存在或属于其他用户:${dir}。请在桌面终端运行,或将 XDG_RUNTIME_DIR 设置为自己的会话目录。`,
+ wayland: (socket) =>
+ `Wayland 显示服务不可用:${socket}。请在桌面终端检查 WAYLAND_DISPLAY 和 XDG_RUNTIME_DIR。`,
+ ambiguous: (variable) =>
+ `发现多个桌面显示服务。请从目标桌面会话中获取并设置 ${variable}。`,
+ headless:
+ "未找到当前用户的桌面显示服务。请在图形桌面终端运行,通过 SSH 转发 X11 显示服务,或在无桌面的测试环境使用 Xvfb。",
+ desktop: (type, display) =>
+ `已识别当前用户的本机 ${type} 桌面(${display})。Electron 窗口会显示在该桌面上。`,
+ renderer: (url) => `Renderer 未能就绪:${url}`,
+ build: (code) => `主进程构建失败,退出码:${code}`,
+ },
+};
diff --git a/packages/templates/electron-app/services/one-template-electron-main/scripts/dev.mjs b/packages/templates/electron-app/services/one-template-electron-main/scripts/dev.mjs
new file mode 100644
index 00000000..8f274957
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/scripts/dev.mjs
@@ -0,0 +1,94 @@
+import { spawn } from "node:child_process";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { detectDesktopEnvironment } from "./desktop-env.mjs";
+import { developmentMessages, messages } from "./dev-messages.mjs";
+import { checkUbuntuSandbox } from "./ubuntu-sandbox.mjs";
+
+export async function waitForRenderer(
+ url,
+ { signal, timeout = 30_000, text = messages["en-US"] } = {},
+) {
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ signal?.throwIfAborted();
+ try {
+ const response = await fetch(url, {
+ signal: AbortSignal.any([
+ AbortSignal.timeout(1000),
+ ...(signal ? [signal] : []),
+ ]),
+ });
+ if (response.ok) return;
+ } catch {
+ signal?.throwIfAborted();
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ throw new Error(text.renderer(url));
+}
+
+async function start() {
+ const root = path.resolve(import.meta.dirname, "..");
+ const controller = new AbortController();
+ let child;
+ for (const signal of ["SIGINT", "SIGTERM"]) {
+ process.once(signal, () => {
+ controller.abort();
+ child?.kill(signal);
+ });
+ }
+ try {
+ const text = await developmentMessages();
+ const require = createRequire(import.meta.url);
+ const binary = require("electron");
+ await checkUbuntuSandbox({ binary, text });
+ const env = await detectDesktopEnvironment({ text });
+ controller.signal.throwIfAborted();
+ await new Promise((resolve, reject) => {
+ child = spawn(
+ process.platform === "win32" ? "pnpm.cmd" : "pnpm",
+ ["run", "build"],
+ {
+ cwd: root,
+ stdio: "inherit",
+ shell: process.platform === "win32",
+ },
+ );
+ child.once("error", reject);
+ child.once("exit", (code) =>
+ code === 0 ? resolve() : reject(new Error(text.build(code))),
+ );
+ });
+ const url = `http://localhost:${process.env.ELECTRON_RENDERER_PORT || 5173}/`;
+ await waitForRenderer(url, { signal: controller.signal, text });
+ child = spawn(
+ binary,
+ [path.join(root, "build/main/index.js"), ...process.argv.slice(2)],
+ {
+ cwd: root,
+ stdio: "inherit",
+ env: { ...env, NODE_ENV: "development" },
+ },
+ );
+ child.once("error", (error) => {
+ console.error(error.message);
+ process.exitCode = 1;
+ });
+ child.once("exit", (code) => {
+ process.exitCode = code ?? (controller.signal.aborted ? 0 : 1);
+ });
+ } catch (error) {
+ if (!controller.signal.aborted) {
+ console.error(error.message);
+ process.exitCode = 1;
+ }
+ }
+}
+
+if (
+ process.argv[1] &&
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
+)
+ await start();
diff --git a/packages/templates/electron-app/services/one-template-electron-main/scripts/release.mjs b/packages/templates/electron-app/services/one-template-electron-main/scripts/release.mjs
new file mode 100644
index 00000000..bff6f467
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/scripts/release.mjs
@@ -0,0 +1,43 @@
+import { createRequire } from "node:module";
+import path from "node:path";
+import builder from "electron-builder";
+import { prepareResources } from "./resources.mjs";
+
+const root = path.resolve(import.meta.dirname, "..");
+const require = createRequire(import.meta.url);
+const rendererDir = path.join(
+ path.dirname(require.resolve("@one-template-electron/renderer/package.json")),
+ "dist",
+);
+const preloadEntry = require.resolve("@one-template-electron/preload");
+const development = process.env.NODE_ENV === "development";
+
+await prepareResources({ mainDir: root, rendererDir, preloadEntry });
+
+/** @type {import('electron-builder').Configuration} */
+const config = {
+ productName: process.env.APP_NAME,
+ appId: process.env.APP_ID,
+ copyright: process.env.APP_COPYRIGHT,
+ artifactName: "${productName}-setup-${version}.${ext}",
+ directories: { output: "dist" },
+ files: ["build/**/*", "assets/**/*", "package.json"],
+ win: { icon: path.join(root, "assets/icon.ico"), target: ["nsis"] },
+ mac: { target: [{ target: "default", arch: ["x64", "arm64"] }] },
+ nsis: {
+ oneClick: false,
+ allowElevation: true,
+ allowToChangeInstallationDirectory: true,
+ createDesktopShortcut: true,
+ createStartMenuShortcut: true,
+ },
+};
+
+await builder.build({
+ projectDir: root,
+ targets: builder.Platform.current().createTarget(
+ development ? builder.DIR_TARGET : builder.DEFAULT_TARGET,
+ ),
+ publish: "never",
+ config,
+});
diff --git a/packages/templates/electron-app/services/one-template-electron-main/scripts/resources.mjs b/packages/templates/electron-app/services/one-template-electron-main/scripts/resources.mjs
new file mode 100644
index 00000000..1abc70f0
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/scripts/resources.mjs
@@ -0,0 +1,17 @@
+import { access, cp, mkdir, rm } from "node:fs/promises";
+import path from "node:path";
+
+// Every component builds in its own directory. Packaging assembles copies in main.
+export async function prepareResources({ mainDir, rendererDir, preloadEntry }) {
+ await Promise.all([
+ access(path.join(mainDir, "build/main/index.js")),
+ access(path.join(rendererDir, "index.html")),
+ access(preloadEntry),
+ ]);
+ const rendererTarget = path.join(mainDir, "build/renderer");
+ const preloadTarget = path.join(mainDir, "build/preload");
+ await rm(rendererTarget, { recursive: true, force: true });
+ await cp(rendererDir, rendererTarget, { recursive: true });
+ await mkdir(preloadTarget, { recursive: true });
+ await cp(preloadEntry, path.join(preloadTarget, "index.cjs"));
+}
diff --git a/packages/templates/electron-app/apps/electron/script/sandbox-profile.mjs b/packages/templates/electron-app/services/one-template-electron-main/scripts/sandbox-profile.mjs
similarity index 72%
rename from packages/templates/electron-app/apps/electron/script/sandbox-profile.mjs
rename to packages/templates/electron-app/services/one-template-electron-main/scripts/sandbox-profile.mjs
index a56a5828..e16d49b4 100644
--- a/packages/templates/electron-app/apps/electron/script/sandbox-profile.mjs
+++ b/packages/templates/electron-app/services/one-template-electron-main/scripts/sandbox-profile.mjs
@@ -5,9 +5,13 @@ import { fileURLToPath } from "node:url";
// Generate an application-specific user-namespace permission for Ubuntu.
// This command only prints a profile; installing it requires an administrator.
+export function sandboxProfileName(binary) {
+ return `one-electron-${createHash("sha256").update(binary).digest("hex").slice(0, 12)}`;
+}
+
export function sandboxProfile(binary) {
- const name = `one-electron-${createHash("sha256").update(binary).digest("hex").slice(0, 12)}`;
- const quoted = `"${binary.replace(/[\\"*?\[\]{}\n\r]/g, (char) => `\\${char}`)}"`;
+ const name = sandboxProfileName(binary);
+ const quoted = `"${binary.replace(/[\\"*?[\]{}\n\r]/g, (char) => `\\${char}`)}"`;
return `abi ,
include
diff --git a/packages/templates/electron-app/services/one-template-electron-main/scripts/ubuntu-sandbox.mjs b/packages/templates/electron-app/services/one-template-electron-main/scripts/ubuntu-sandbox.mjs
new file mode 100644
index 00000000..3b5777b4
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/scripts/ubuntu-sandbox.mjs
@@ -0,0 +1,120 @@
+import { spawnSync } from "node:child_process";
+import * as fs from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { messages } from "./dev-messages.mjs";
+import { sandboxProfile, sandboxProfileName } from "./sandbox-profile.mjs";
+
+export function isUbuntu(release) {
+ // Use ID, never ID_LIKE or a version guess. Do not evaluate shell content.
+ return /^ID=(?:ubuntu|"ubuntu"|'ubuntu')\s*$/m.test(release);
+}
+
+export function shellQuote(value) {
+ return `'${value.replaceAll("'", "'\\''")}'`;
+}
+
+// The probe runs under the actual Electron binary's AppArmor profile. Probing
+// unshare directly would test Node's permissions instead of Electron's.
+export function probeUserNamespace(binary, env, run = spawnSync) {
+ const source = `const { spawnSync } = require('node:child_process');
+const result = spawnSync('/usr/bin/unshare', ['--user', '--map-root-user', '--mount', '/usr/bin/true'], { encoding: 'utf8', timeout: 3000, env: { ...process.env, LC_ALL: 'C' } });
+console.log(JSON.stringify({ status: result.status, error: result.error?.message, stderr: result.stderr }));`;
+ const result = run(binary, ["--eval", source], {
+ env: { ...env, ELECTRON_RUN_AS_NODE: "1" },
+ encoding: "utf8",
+ timeout: 5000,
+ });
+ if (result.error || result.status !== 0)
+ throw new Error(
+ result.error?.message ||
+ result.stderr ||
+ `Electron exited with code ${result.status}`,
+ );
+ return JSON.parse(result.stdout.trim());
+}
+
+export async function checkUbuntuSandbox({
+ binary,
+ env = process.env,
+ platform = process.platform,
+ text = messages["en-US"],
+ io = fs,
+ probe = probeUserNamespace,
+ temporaryDirectory = tmpdir(),
+}) {
+ if (platform !== "linux") return;
+ let release;
+ try {
+ release = await io.readFile("/etc/os-release", "utf8");
+ } catch {
+ try {
+ release = await io.readFile("/usr/lib/os-release", "utf8");
+ } catch {
+ return;
+ }
+ }
+ if (!isUbuntu(release)) return;
+
+ // A correctly installed SUID helper also supports the Chromium sandbox.
+ try {
+ const helper = await io.stat(
+ path.join(path.dirname(binary), "chrome-sandbox"),
+ );
+ if (
+ helper.isFile() &&
+ helper.uid === 0 &&
+ (helper.mode & 0o7777) === 0o4755
+ )
+ return;
+ } catch {
+ // Prefer the application-specific user-namespace permission below.
+ }
+ const kernelSetting = async (name) => {
+ try {
+ return (await io.readFile(`/proc/sys/kernel/${name}`, "utf8")).trim();
+ } catch {
+ return undefined;
+ }
+ };
+ if ((await kernelSetting("unprivileged_userns_clone")) === "0")
+ throw new Error(text.userns);
+ if ((await kernelSetting("apparmor_restrict_unprivileged_userns")) !== "1")
+ return;
+
+ let result;
+ try {
+ result = probe(binary, env);
+ } catch (error) {
+ throw new Error(text.probe(error.message), { cause: error });
+ }
+ if (result.status === 0) return;
+ if (
+ result.error ||
+ !/Operation not permitted|Permission denied/.test(result.stderr || "")
+ )
+ throw new Error(
+ text.probe(
+ result.error ||
+ result.stderr ||
+ `unshare exited with code ${result.status}`,
+ ),
+ );
+
+ // Create a private, reviewable file. Never invoke sudo or change host policy.
+ const directory = await io.mkdtemp(
+ path.join(temporaryDirectory, "one-electron-apparmor-"),
+ );
+ const name = sandboxProfileName(binary);
+ const profile = path.join(directory, name);
+ await io.writeFile(profile, sandboxProfile(binary), {
+ mode: 0o600,
+ flag: "wx",
+ });
+ const target = `/etc/apparmor.d/${name}`;
+ const commands = [
+ `sudo install -o root -g root -m 644 -- ${shellQuote(profile)} ${shellQuote(target)}`,
+ `sudo apparmor_parser -r ${shellQuote(target)}`,
+ ].join("\n");
+ throw new Error(text.sandbox(binary, profile, commands));
+}
diff --git a/packages/templates/electron-app/services/one-template-electron-main/src/app.ts b/packages/templates/electron-app/services/one-template-electron-main/src/app.ts
new file mode 100644
index 00000000..104ed5a9
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/src/app.ts
@@ -0,0 +1,42 @@
+import { app } from "electron";
+import type { AppIPC } from "./ipc";
+import type { ElectronLogger } from "./vendor/ElectronLogger";
+import type { MainWindow } from "./windows/main.window";
+
+export function createElectronApp({
+ mainWindow,
+ ipc,
+ logger,
+}: {
+ mainWindow: MainWindow;
+ ipc: AppIPC;
+ logger: ElectronLogger;
+}) {
+ const show = () => {
+ void mainWindow.init().catch((error) => logger.error(error));
+ };
+ const closed = () => {
+ if (process.platform !== "darwin") app.quit();
+ };
+ let initialized = false;
+ const init = async () => {
+ if (initialized) return;
+ initialized = true;
+ ipc.init();
+ app.on("activate", show);
+ app.on("second-instance", show);
+ app.on("window-all-closed", closed);
+ await mainWindow.init();
+ logger.info("app ready");
+ };
+ const dispose = () => {
+ if (!initialized) return;
+ initialized = false;
+ ipc.dispose();
+ app.removeListener("activate", show);
+ app.removeListener("second-instance", show);
+ app.removeListener("window-all-closed", closed);
+ };
+ return { init, dispose };
+}
+export type ElectronApp = ReturnType;
diff --git a/packages/templates/electron-app/services/one-template-electron-main/src/constants/index.ts b/packages/templates/electron-app/services/one-template-electron-main/src/constants/index.ts
new file mode 100644
index 00000000..b492d0a1
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/src/constants/index.ts
@@ -0,0 +1 @@
+export const DEV_RENDERER_URL = `http://localhost:${process.env.ELECTRON_RENDERER_PORT || 5173}/`;
diff --git a/packages/templates/electron-app/services/one-template-electron-main/src/container.ts b/packages/templates/electron-app/services/one-template-electron-main/src/container.ts
new file mode 100644
index 00000000..13691311
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/src/container.ts
@@ -0,0 +1,31 @@
+import { asFunction, createContainer, InjectionMode } from "awilix";
+import { createElectronApp, type ElectronApp } from "./app";
+import { createAppIPC, type AppIPC } from "./ipc";
+import {
+ createElectronLogger,
+ type ElectronLogger,
+} from "./vendor/ElectronLogger";
+import { createMainWindow, type MainWindow } from "./windows/main.window";
+
+interface MainServices {
+ logger: ElectronLogger;
+ mainWindow: MainWindow;
+ ipc: AppIPC;
+ application: ElectronApp;
+}
+// Only the composition root accesses the container. Factories receive plain dependencies.
+export function createMainContainer() {
+ const container = createContainer({
+ injectionMode: InjectionMode.PROXY,
+ strict: true,
+ });
+ container.register({
+ logger: asFunction(createElectronLogger).singleton(),
+ mainWindow: asFunction(createMainWindow).singleton(),
+ ipc: asFunction(createAppIPC).singleton(),
+ application: asFunction(createElectronApp)
+ .singleton()
+ .disposer((application) => application.dispose()),
+ });
+ return container;
+}
diff --git a/packages/templates/electron-app/services/one-template-electron-main/src/index.ts b/packages/templates/electron-app/services/one-template-electron-main/src/index.ts
new file mode 100644
index 00000000..e7b7c470
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/src/index.ts
@@ -0,0 +1,24 @@
+import { app } from "electron";
+import { createMainContainer } from "./container";
+
+async function start() {
+ if (!app.requestSingleInstanceLock()) {
+ app.quit();
+ return;
+ }
+ await app.whenReady();
+ const container = createMainContainer();
+ const application = container.resolve("application");
+ app.once("will-quit", () => {
+ application.dispose();
+ void container.dispose();
+ });
+ try {
+ await application.init();
+ } catch (error) {
+ container.resolve("logger").error(error);
+ application.dispose();
+ app.exit(1);
+ }
+}
+void start();
diff --git a/packages/templates/electron-app/services/one-template-electron-main/src/ipc.ts b/packages/templates/electron-app/services/one-template-electron-main/src/ipc.ts
new file mode 100644
index 00000000..f311dbfa
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/src/ipc.ts
@@ -0,0 +1,29 @@
+import { app, ipcMain } from "electron";
+import { IPC, type AppInfo } from "./types/events";
+import type { MainWindow } from "./windows/main.window";
+
+export function createAppIPC({ mainWindow }: { mainWindow: MainWindow }) {
+ const init = () => {
+ ipcMain.handle(IPC.appInfo, (event, ...args: unknown[]): AppInfo => {
+ const contents = mainWindow.window?.webContents;
+ if (
+ !contents ||
+ event.sender !== contents ||
+ !event.senderFrame ||
+ event.senderFrame !== contents.mainFrame ||
+ !mainWindow.isTrustedURL(event.senderFrame.url) ||
+ args.length !== 0
+ ) {
+ throw new Error("Unauthorized IPC request");
+ }
+ return {
+ name: app.getName(),
+ version: app.getVersion(),
+ platform: process.platform,
+ };
+ });
+ };
+ const dispose = () => ipcMain.removeHandler(IPC.appInfo);
+ return { init, dispose };
+}
+export type AppIPC = ReturnType;
diff --git a/packages/templates/electron-app/services/one-template-electron-main/src/types/events.ts b/packages/templates/electron-app/services/one-template-electron-main/src/types/events.ts
new file mode 100644
index 00000000..cb98fefd
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/src/types/events.ts
@@ -0,0 +1,2 @@
+export { IPC } from "@one-template-electron/preload/channels";
+export type { AppInfo } from "@one-template-electron/preload/channels";
diff --git a/packages/templates/electron-app/services/one-template-electron-main/src/utils/index.ts b/packages/templates/electron-app/services/one-template-electron-main/src/utils/index.ts
new file mode 100644
index 00000000..0c238125
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/src/utils/index.ts
@@ -0,0 +1,16 @@
+import { app } from "electron";
+import { createRequire } from "node:module";
+import path from "node:path";
+
+const require = createRequire(import.meta.url);
+const preloadPackage = "@one-template-electron/preload";
+const rendererPackage = "@one-template-electron/renderer/package.json";
+export const preloadPath = app.isPackaged
+ ? path.resolve(import.meta.dirname, "../preload/index.cjs")
+ : require.resolve(preloadPackage);
+export const rendererPath = app.isPackaged
+ ? path.resolve(import.meta.dirname, "../renderer/index.html")
+ : path.join(
+ path.dirname(require.resolve(rendererPackage)),
+ "dist/index.html",
+ );
diff --git a/packages/templates/electron-app/services/one-template-electron-main/src/vendor/ElectronLogger.ts b/packages/templates/electron-app/services/one-template-electron-main/src/vendor/ElectronLogger.ts
new file mode 100644
index 00000000..82b69579
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/src/vendor/ElectronLogger.ts
@@ -0,0 +1,6 @@
+import log from "electron-log/main";
+
+export function createElectronLogger() {
+ return log;
+}
+export type ElectronLogger = ReturnType;
diff --git a/packages/templates/electron-app/services/one-template-electron-main/src/windows/main.window.ts b/packages/templates/electron-app/services/one-template-electron-main/src/windows/main.window.ts
new file mode 100644
index 00000000..5921c8a8
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/src/windows/main.window.ts
@@ -0,0 +1,56 @@
+import { BrowserWindow } from "electron";
+import { pathToFileURL } from "node:url";
+import { DEV_RENDERER_URL } from "../constants";
+import { preloadPath, rendererPath } from "../utils";
+
+export function createMainWindow() {
+ let window: BrowserWindow | null = null;
+ const development = process.env.NODE_ENV === "development";
+ const trustedURL = development
+ ? DEV_RENDERER_URL
+ : pathToFileURL(rendererPath).href;
+ const isTrustedURL = (url: string) => url.split("#")[0] === trustedURL;
+ const init = async (): Promise => {
+ if (window) {
+ if (window.isMinimized()) window.restore();
+ window.show();
+ window.focus();
+ return;
+ }
+ const current = new BrowserWindow({
+ width: 1100,
+ height: 680,
+ show: false,
+ autoHideMenuBar: true,
+ webPreferences: {
+ preload: preloadPath,
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ },
+ });
+ window = current;
+ current.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
+ current.webContents.on("will-navigate", (event) => event.preventDefault());
+ current.once("closed", () => {
+ if (window === current) window = null;
+ });
+ current.once("ready-to-show", () => current.show());
+ try {
+ if (development) await current.loadURL(DEV_RENDERER_URL);
+ else await current.loadFile(rendererPath);
+ } catch (error) {
+ if (!current.isDestroyed()) current.destroy();
+ if (window === current) window = null;
+ throw error;
+ }
+ };
+ return {
+ get window() {
+ return window;
+ },
+ init,
+ isTrustedURL,
+ };
+}
+export type MainWindow = ReturnType;
diff --git a/packages/templates/electron-app/services/one-template-electron-main/test/dev-environment.test.mjs b/packages/templates/electron-app/services/one-template-electron-main/test/dev-environment.test.mjs
new file mode 100644
index 00000000..07480b42
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/test/dev-environment.test.mjs
@@ -0,0 +1,444 @@
+import * as fs from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ detectDesktopEnvironment,
+ readSessionEnvironment,
+} from "../scripts/desktop-env.mjs";
+import { developmentMessages, messages } from "../scripts/dev-messages.mjs";
+import {
+ sandboxProfile,
+ sandboxProfileName,
+} from "../scripts/sandbox-profile.mjs";
+import {
+ checkUbuntuSandbox,
+ isUbuntu,
+ probeUserNamespace,
+ shellQuote,
+} from "../scripts/ubuntu-sandbox.mjs";
+
+const cleanup = [];
+afterEach(async () => {
+ for (const dispose of cleanup.splice(0).toReversed()) await dispose();
+});
+
+async function temporary() {
+ const root = await fs.mkdtemp(path.join(tmpdir(), "one-desktop-"));
+ cleanup.push(() => fs.rm(root, { recursive: true, force: true }));
+ return root;
+}
+
+function sandboxOptions({
+ release = 'ID="ubuntu"\nID_LIKE=debian',
+ restricted = "1",
+ clone = "1",
+ helper,
+} = {}) {
+ const settings = {
+ "/etc/os-release": release,
+ "/proc/sys/kernel/apparmor_restrict_unprivileged_userns": restricted,
+ "/proc/sys/kernel/unprivileged_userns_clone": clone,
+ };
+ return {
+ binary: "/workspace/node_modules/electron/dist/electron",
+ platform: "linux",
+ env: { LANG: "C" },
+ probe: vi.fn(() => ({ status: 0 })),
+ io: {
+ ...fs,
+ readFile: vi.fn(async (file) => {
+ if (!(file in settings)) throw new Error("missing");
+ return settings[file];
+ }),
+ stat: vi.fn(async () => {
+ if (!helper) throw new Error("missing");
+ return { isFile: () => true, uid: 0, mode: 0o104755, ...helper };
+ }),
+ mkdtemp: vi.fn(fs.mkdtemp),
+ },
+ };
+}
+
+describe("Ubuntu sandbox preflight", () => {
+ it("matches Ubuntu's ID rather than derivatives, descriptions, or shell expressions", () => {
+ for (const release of ["ID=ubuntu\n", 'ID="ubuntu"', "ID='ubuntu'"])
+ expect(isUbuntu(release)).toBe(true);
+ for (const release of [
+ 'ID=linuxmint\nID_LIKE="ubuntu debian"',
+ 'NAME="Ubuntu"\nID=debian',
+ "ID=$(echo ubuntu)",
+ "ID=ubuntu-test",
+ ])
+ expect(isUbuntu(release)).toBe(false);
+ });
+ it.each(["darwin", "win32"])(
+ "never accesses Linux configuration on %s",
+ async (platform) => {
+ const options = sandboxOptions();
+ await checkUbuntuSandbox({ ...options, platform });
+ expect(options.io.readFile).not.toHaveBeenCalled();
+ expect(options.probe).not.toHaveBeenCalled();
+ },
+ );
+ it("skips non-Ubuntu systems and kernels without the AppArmor restriction", async () => {
+ for (const settings of [
+ { release: "ID=fedora\nID_LIKE=ubuntu" },
+ { restricted: "0" },
+ { restricted: undefined },
+ ]) {
+ const options = sandboxOptions(settings);
+ if (settings.restricted === undefined && "restricted" in settings)
+ options.io.readFile.mockImplementation(async (file) => {
+ if (file === "/etc/os-release") return "ID=ubuntu";
+ throw new Error("missing");
+ });
+ await checkUbuntuSandbox(options);
+ expect(options.probe).not.toHaveBeenCalled();
+ expect(options.io.mkdtemp).not.toHaveBeenCalled();
+ }
+ });
+ it("accepts a correctly installed SUID helper but rejects user-owned or writable helpers", async () => {
+ const options = sandboxOptions({ helper: {} });
+ await checkUbuntuSandbox(options);
+ expect(options.probe).not.toHaveBeenCalled();
+ for (const helper of [
+ { uid: 1000 },
+ { mode: 0o104777 },
+ { mode: 0o100755 },
+ ]) {
+ const unsafe = sandboxOptions({ helper });
+ await checkUbuntuSandbox(unsafe);
+ expect(unsafe.probe).toHaveBeenCalledOnce();
+ }
+ });
+ it("checks actual permissions each time without generating profiles once configured", async () => {
+ const options = sandboxOptions();
+ await checkUbuntuSandbox(options);
+ await checkUbuntuSandbox(options);
+ expect(options.probe).toHaveBeenCalledTimes(2);
+ expect(options.probe).toHaveBeenCalledWith(options.binary, options.env);
+ expect(options.io.mkdtemp).not.toHaveBeenCalled();
+ });
+ it.each(["en-US", "zh-CN"])(
+ "prepares a private exact-binary profile and manual installation guidance in %s",
+ async (locale) => {
+ const root = await temporary();
+ const options = sandboxOptions();
+ options.binary = "/workspace/a 'quoted' [project]/electron";
+ options.probe.mockReturnValue({
+ status: 1,
+ stderr: "unshare: Operation not permitted",
+ });
+ let failure;
+ try {
+ await checkUbuntuSandbox({
+ ...options,
+ text: messages[locale],
+ temporaryDirectory: root,
+ });
+ } catch (error) {
+ failure = error;
+ }
+ const [directory] = await fs.readdir(root);
+ const profile = path.join(
+ root,
+ directory,
+ sandboxProfileName(options.binary),
+ );
+ expect(await fs.readFile(profile, "utf8")).toBe(
+ sandboxProfile(options.binary),
+ );
+ expect((await fs.stat(profile)).mode & 0o777).toBe(0o600);
+ expect(failure.message).toContain(profile);
+ expect(failure.message).toContain(
+ "sudo install -o root -g root -m 644 --",
+ );
+ expect(failure.message).toContain("sudo apparmor_parser -r");
+ expect(failure.message).toContain(
+ locale === "zh-CN" ? "重新运行开发任务" : "rerun the development task",
+ );
+ expect(failure.message).not.toContain("--no-sandbox");
+ expect(options.probe).toHaveBeenCalledOnce();
+ },
+ );
+ it("does not misdiagnose disabled namespaces or a failed probe as missing AppArmor configuration", async () => {
+ const disabled = sandboxOptions({ clone: "0" });
+ await expect(checkUbuntuSandbox(disabled)).rejects.toThrow(
+ "disabled unprivileged",
+ );
+ expect(disabled.probe).not.toHaveBeenCalled();
+ const options = sandboxOptions();
+ options.probe.mockReturnValue({ status: null, error: "unshare ENOENT" });
+ await expect(checkUbuntuSandbox(options)).rejects.toThrow("unshare ENOENT");
+ options.probe.mockImplementation(() => {
+ throw new Error("Electron ENOENT");
+ });
+ await expect(checkUbuntuSandbox(options)).rejects.toThrow(
+ "Electron ENOENT",
+ );
+ expect(options.io.mkdtemp).not.toHaveBeenCalled();
+ });
+ it("uses Electron's profile for the namespace probe without opening a GUI", () => {
+ const run = vi.fn(() => ({
+ status: 0,
+ stdout: '{"status":0}',
+ stderr: "",
+ }));
+ expect(
+ probeUserNamespace("/path/electron", { TEST: "preserved" }, run),
+ ).toEqual({ status: 0 });
+ const [binary, args, options] = run.mock.calls[0];
+ expect(binary).toBe("/path/electron");
+ expect(args[0]).toBe("--eval");
+ expect(args[1]).toContain("'/usr/bin/unshare'");
+ expect(args[1]).toContain("'--mount'");
+ expect(options.env).toEqual({
+ TEST: "preserved",
+ ELECTRON_RUN_AS_NODE: "1",
+ });
+ expect(options.shell).toBeUndefined();
+ expect(shellQuote("/tmp/a'b $HOME `command`")).toBe(
+ "'/tmp/a'\\''b $HOME `command`'",
+ );
+ });
+});
+
+async function desktop() {
+ const root = await temporary();
+ const uid = process.getuid?.() ?? 1000;
+ const runtimeBase = path.join(root, "users");
+ const runtime = path.join(runtimeBase, String(uid));
+ const home = path.join(root, "home");
+ const x11Directory = path.join(root, "x11");
+ for (const dir of [runtime, home, x11Directory])
+ await fs.mkdir(dir, { recursive: true });
+ const sockets = new Map();
+ const options = {
+ env: {},
+ platform: "linux",
+ uid,
+ home,
+ runtimeBase,
+ x11Directory,
+ sessionEnvironment: vi.fn(() => ({})),
+ log: vi.fn(),
+ io: {
+ ...fs,
+ stat: async (file) => {
+ const info = await fs.stat(file);
+ info.uid = uid;
+ if (sockets.has(file)) info.isSocket = () => true;
+ return info;
+ },
+ },
+ connect: vi.fn(async (file) => sockets.get(file) === true),
+ };
+ const socket = async (file, active = true) => {
+ await fs.writeFile(file, "socket fixture");
+ sockets.set(file, active);
+ };
+ return { options, runtime, home, x11Directory, socket };
+}
+
+describe("desktop discovery", () => {
+ it("queries only the current user's bus and retains only display variables", () => {
+ const run = vi.fn(() => ({
+ status: 0,
+ stdout:
+ "DISPLAY=:0\nWAYLAND_DISPLAY=wayland-0\nXAUTHORITY=/run/user/42/auth\nSECRET=private\n",
+ }));
+ expect(readSessionEnvironment({}, "/run/user/42", run)).toEqual({
+ DISPLAY: ":0",
+ WAYLAND_DISPLAY: "wayland-0",
+ XAUTHORITY: "/run/user/42/auth",
+ });
+ expect(run.mock.calls[0][2].env.DBUS_SESSION_BUS_ADDRESS).toBe(
+ "unix:path=/run/user/42/bus",
+ );
+ run.mockReturnValue({ status: 1 });
+ expect(readSessionEnvironment({}, "/run/user/42", run)).toEqual({});
+ });
+
+ it.each(["darwin", "win32"])(
+ "preserves all variables and skips discovery on %s",
+ async (platform) => {
+ const io = { stat: vi.fn() };
+ const env = { TEST: "preserved" };
+ expect(await detectDesktopEnvironment({ env, platform, io })).toEqual(
+ env,
+ );
+ expect(io.stat).not.toHaveBeenCalled();
+ },
+ );
+ it.each(["localhost:10.0", ":99", ":0"])(
+ "preserves explicit display %s including SSH forwarding and Xvfb",
+ async (DISPLAY) => {
+ const sessionEnvironment = vi.fn();
+ const env = {
+ DISPLAY,
+ XAUTHORITY: "/forwarded/auth",
+ XDG_SESSION_TYPE: "x11",
+ WAYLAND_DISPLAY: "stale-wayland",
+ SSH_CONNECTION: "remote",
+ OTHER: "value",
+ };
+ expect(
+ await detectDesktopEnvironment({
+ env,
+ platform: "linux",
+ sessionEnvironment,
+ }),
+ ).toEqual(env);
+ expect(sessionEnvironment).not.toHaveBeenCalled();
+ },
+ );
+ it("recovers a live current-user Wayland display and tells SSH users where windows appear", async () => {
+ const fixture = await desktop();
+ await fixture.socket(path.join(fixture.runtime, "wayland-3"));
+ fixture.options.env = { SSH_CONNECTION: "remote", OTHER: "preserved" };
+ const result = await detectDesktopEnvironment(fixture.options);
+ expect(result).toMatchObject({
+ WAYLAND_DISPLAY: "wayland-3",
+ XDG_RUNTIME_DIR: fixture.runtime,
+ XDG_SESSION_TYPE: "wayland",
+ OTHER: "preserved",
+ });
+ expect(fixture.options.env).not.toHaveProperty("WAYLAND_DISPLAY");
+ expect(fixture.options.log).toHaveBeenCalledWith(
+ expect.stringContaining("windows will appear on that desktop"),
+ );
+ });
+ it("fills runtime and session type for an explicit Wayland display", async () => {
+ const fixture = await desktop();
+ const socket = path.join(fixture.runtime, "wayland-2");
+ await fixture.socket(socket);
+ fixture.options.env = { WAYLAND_DISPLAY: socket };
+ expect(await detectDesktopEnvironment(fixture.options)).toMatchObject({
+ WAYLAND_DISPLAY: socket,
+ XDG_SESSION_TYPE: "wayland",
+ XDG_RUNTIME_DIR: fixture.runtime,
+ });
+ expect(fixture.options.log).not.toHaveBeenCalled();
+ });
+ it("chooses the service manager's live Wayland display when multiple sockets exist", async () => {
+ const fixture = await desktop();
+ for (const name of ["wayland-0", "wayland-1"])
+ await fixture.socket(path.join(fixture.runtime, name));
+ fixture.options.sessionEnvironment.mockReturnValue({
+ WAYLAND_DISPLAY: "wayland-1",
+ SECRET: "never copy",
+ });
+ const result = await detectDesktopEnvironment(fixture.options);
+ expect(result.WAYLAND_DISPLAY).toBe("wayland-1");
+ expect(result).not.toHaveProperty("SECRET");
+ fixture.options.sessionEnvironment.mockReturnValue({});
+ await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow(
+ "Set WAYLAND_DISPLAY",
+ );
+ });
+ it("ignores lock files, regular files, and stale display sockets", async () => {
+ const fixture = await desktop();
+ await fs.writeFile(path.join(fixture.runtime, "wayland-0"), "not a socket");
+ await fs.writeFile(path.join(fixture.runtime, "wayland-1.lock"), "lock");
+ await fixture.socket(path.join(fixture.runtime, "wayland-2"), false);
+ await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow(
+ "No desktop display",
+ );
+ fixture.options.env = { WAYLAND_DISPLAY: "wayland-0" };
+ await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow(
+ "Wayland display is unavailable",
+ );
+ });
+ it("rejects runtime directories and sockets owned by another user", async () => {
+ const fixture = await desktop();
+ await fixture.socket(path.join(fixture.runtime, "wayland-0"));
+ fixture.options.env = { XDG_RUNTIME_DIR: fixture.runtime };
+ fixture.options.uid++;
+ await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow(
+ "belongs to another user",
+ );
+ fixture.options.uid--;
+ const io = fixture.options.io;
+ fixture.options.io = {
+ ...io,
+ stat: async (file) => {
+ const info = await io.stat(file);
+ if (file.endsWith("wayland-0")) info.uid++;
+ return info;
+ },
+ };
+ await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow(
+ "No desktop display",
+ );
+ });
+ it("recovers X11 with the session's current-user authentication file", async () => {
+ const fixture = await desktop();
+ const auth = path.join(fixture.runtime, "session-auth");
+ await fs.writeFile(auth, "cookie");
+ await fixture.socket(path.join(fixture.x11Directory, "X2"));
+ fixture.options.sessionEnvironment.mockReturnValue({
+ DISPLAY: ":2.0",
+ XAUTHORITY: auth,
+ });
+ expect(await detectDesktopEnvironment(fixture.options)).toMatchObject({
+ DISPLAY: ":2.0",
+ XAUTHORITY: auth,
+ XDG_SESSION_TYPE: "x11",
+ });
+ });
+ it("uses a unique X11 socket with .Xauthority, and refuses ambiguous or unauthenticated displays", async () => {
+ const fixture = await desktop();
+ const auth = path.join(fixture.home, ".Xauthority");
+ await fixture.socket(path.join(fixture.x11Directory, "X7"));
+ await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow(
+ "No desktop display",
+ );
+ await fs.writeFile(auth, "cookie");
+ expect(await detectDesktopEnvironment(fixture.options)).toMatchObject({
+ DISPLAY: ":7",
+ XAUTHORITY: auth,
+ });
+ await fixture.socket(path.join(fixture.x11Directory, "X8"));
+ await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow(
+ "Set DISPLAY",
+ );
+ });
+});
+
+describe("development guidance languages", () => {
+ it("keeps both catalogs and parameter counts in sync", () => {
+ expect(Object.keys(messages["zh-CN"]).toSorted()).toEqual(
+ Object.keys(messages["en-US"]).toSorted(),
+ );
+ for (const key of Object.keys(messages["en-US"])) {
+ expect(typeof messages["zh-CN"][key]).toBe(typeof messages["en-US"][key]);
+ expect(messages["zh-CN"][key].length).toBeGreaterThan(0);
+ if (typeof messages["en-US"][key] === "function")
+ expect(messages["zh-CN"][key].length).toBe(
+ messages["en-US"][key].length,
+ );
+ }
+ });
+ it("reads One's existing language preference each time and honors the auto-locale order", async () => {
+ const config = await temporary();
+ await fs.mkdir(path.join(config, "one"));
+ const env = {
+ XDG_CONFIG_HOME: config,
+ LC_ALL: "zh_CN.UTF-8",
+ LC_MESSAGES: "en_US.UTF-8",
+ };
+ expect(await developmentMessages(env)).toBe(messages["zh-CN"]);
+ const file = path.join(config, "one/preferences.json");
+ await fs.writeFile(file, JSON.stringify({ locale: "en-US" }));
+ expect(await developmentMessages(env)).toBe(messages["en-US"]);
+ await fs.writeFile(file, JSON.stringify({ locale: "zh-CN" }));
+ expect(await developmentMessages({ ...env, LC_ALL: "C" })).toBe(
+ messages["zh-CN"],
+ );
+ await fs.writeFile(file, JSON.stringify({ locale: "auto" }));
+ expect(await developmentMessages({ ...env, LC_ALL: "C" })).toBe(
+ messages["en-US"],
+ );
+ });
+});
diff --git a/packages/templates/electron-app/services/one-template-electron-main/test/main.test.ts b/packages/templates/electron-app/services/one-template-electron-main/test/main.test.ts
new file mode 100644
index 00000000..d01b16de
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/test/main.test.ts
@@ -0,0 +1,144 @@
+import { EventEmitter } from "node:events";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { IpcMainInvokeEvent } from "electron";
+import { createMainWindow } from "../src/windows/main.window";
+import { createAppIPC } from "../src/ipc";
+import { createMainContainer } from "../src/container";
+import { IPC } from "../src/types/events";
+
+const mocks = vi.hoisted(() => ({
+ handles: new Map unknown>(),
+ removeHandler: vi.fn(),
+ expose: vi.fn(),
+ invoke: vi.fn(),
+ log: { info: vi.fn(), error: vi.fn() },
+}));
+vi.mock("../src/utils", () => ({
+ preloadPath: "/app/preload/index.cjs",
+ rendererPath: "/app with spaces/renderer/index.html",
+}));
+vi.mock("electron-log/main", () => ({ default: mocks.log }));
+vi.mock("electron", async () => {
+ const { EventEmitter } = await import("node:events");
+ return {
+ app: Object.assign(new EventEmitter(), {
+ getName: () => "desktop",
+ getVersion: () => "0.1.0",
+ quit: vi.fn(),
+ }),
+ ipcMain: {
+ handle: (channel: string, handler: (...args: unknown[]) => unknown) =>
+ mocks.handles.set(channel, handler),
+ removeHandler: mocks.removeHandler,
+ },
+ contextBridge: { exposeInMainWorld: mocks.expose },
+ ipcRenderer: { invoke: mocks.invoke },
+ BrowserWindow: class extends EventEmitter {
+ webContents = Object.assign(new EventEmitter(), {
+ mainFrame: { url: "file:///app%20with%20spaces/renderer/index.html" },
+ setWindowOpenHandler: vi.fn(),
+ });
+ loadURL = vi.fn(async () => {});
+ loadFile = vi.fn(async () => {});
+ show = vi.fn();
+ focus = vi.fn();
+ restore = vi.fn();
+ isMinimized = vi.fn(() => true);
+ isDestroyed = vi.fn(() => false);
+ destroy = vi.fn();
+ },
+ };
+});
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.handles.clear();
+ vi.stubEnv("NODE_ENV", "production");
+});
+
+describe("main window", () => {
+ it("loads the production HTML file, protects navigation, and reuses its window", async () => {
+ const service = createMainWindow();
+ await service.init();
+ const window = service.window!;
+ expect(window.loadFile).toHaveBeenCalledWith(
+ "/app with spaces/renderer/index.html",
+ );
+ expect(window.loadURL).not.toHaveBeenCalled();
+ const preventDefault = vi.fn();
+ (window.webContents as unknown as EventEmitter).emit("will-navigate", {
+ preventDefault,
+ });
+ expect(preventDefault).toHaveBeenCalledOnce();
+ const handler = vi.mocked(window.webContents.setWindowOpenHandler).mock
+ .calls[0][0];
+ expect(handler({} as never)).toEqual({ action: "deny" });
+ await service.init();
+ expect(window.restore).toHaveBeenCalledOnce();
+ expect(window.focus).toHaveBeenCalledOnce();
+ expect(service.window).toBe(window);
+ (window as unknown as EventEmitter).emit("closed");
+ expect(service.window).toBeNull();
+ });
+ it("loads Vite in development", async () => {
+ vi.stubEnv("NODE_ENV", "development");
+ const service = createMainWindow();
+ await service.init();
+ expect(service.window!.loadURL).toHaveBeenCalledWith(
+ "http://localhost:5173/",
+ );
+ expect(service.window!.loadFile).not.toHaveBeenCalled();
+ });
+});
+describe("isolated app information bridge", () => {
+ it("accepts only its window's trusted main frame and no arguments", async () => {
+ const mainWindow = createMainWindow();
+ await mainWindow.init();
+ const ipc = createAppIPC({ mainWindow });
+ ipc.init();
+ const contents = mainWindow.window!.webContents;
+ const event = {
+ sender: contents,
+ senderFrame: contents.mainFrame,
+ } as IpcMainInvokeEvent;
+ const handler = mocks.handles.get(IPC.appInfo)!;
+ expect(handler(event)).toEqual({
+ name: "desktop",
+ version: "0.1.0",
+ platform: process.platform,
+ });
+ for (const bad of [
+ { ...event, sender: {} },
+ { ...event, senderFrame: { url: contents.mainFrame.url } },
+ { ...event, senderFrame: null },
+ ])
+ expect(() => handler(bad)).toThrow("Unauthorized");
+ expect(() => handler(event, "extra")).toThrow("Unauthorized");
+ const frame = contents.mainFrame as unknown as { url: string };
+ frame.url = "https://untrusted.example/";
+ expect(() => handler(event)).toThrow("Unauthorized");
+ ipc.dispose();
+ expect(mocks.removeHandler).toHaveBeenCalledWith(IPC.appInfo);
+ });
+ it("exposes a fixed API without raw channel access", async () => {
+ const { createRequire } = await import("node:module");
+ const preloadPackage = "@one-template-electron/preload";
+ const entry = createRequire(import.meta.url).resolve(preloadPackage);
+ await import(entry.replace(/dist[/\\]index\.cjs$/, "src/index.ts"));
+ const [name, api] = mocks.expose.mock.calls[0];
+ expect(name).toBe("electron");
+ expect(Object.keys(api)).toEqual(["getAppInfo"]);
+ await api.getAppInfo();
+ expect(mocks.invoke).toHaveBeenCalledWith(IPC.appInfo);
+ });
+ it("disposes IPC and lifecycle hooks through Awilix", async () => {
+ const { app } = await import("electron");
+ const container = createMainContainer();
+ const application = container.resolve("application");
+ await application.init();
+ expect(app.listenerCount("activate")).toBe(1);
+ await container.dispose();
+ expect(app.listenerCount("activate")).toBe(0);
+ expect(app.listenerCount("second-instance")).toBe(0);
+ expect(mocks.removeHandler).toHaveBeenCalledWith(IPC.appInfo);
+ });
+});
diff --git a/packages/templates/electron-app/services/one-template-electron-main/test/scripts.test.mjs b/packages/templates/electron-app/services/one-template-electron-main/test/scripts.test.mjs
new file mode 100644
index 00000000..753ad555
--- /dev/null
+++ b/packages/templates/electron-app/services/one-template-electron-main/test/scripts.test.mjs
@@ -0,0 +1,117 @@
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:http";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { waitForRenderer } from "../scripts/dev.mjs";
+import { prepareResources } from "../scripts/resources.mjs";
+
+const cleanup = [];
+afterEach(async () => {
+ await Promise.all(cleanup.splice(0).map((dispose) => dispose()));
+});
+
+async function rendererServer() {
+ let ready = false;
+ let requests = 0;
+ const server = createServer((_request, response) => {
+ requests++;
+ response.writeHead(ready ? 200 : 503);
+ response.end();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ cleanup.push(() => new Promise((resolve) => server.close(resolve)));
+ return {
+ url: `http://127.0.0.1:${server.address().port}/`,
+ setReady: () => {
+ ready = true;
+ },
+ requests: () => requests,
+ };
+}
+
+describe("desktop development", () => {
+ it("waits through unavailable responses before launching main", async () => {
+ const server = await rendererServer();
+ const timer = setTimeout(server.setReady, 150);
+ try {
+ await waitForRenderer(server.url);
+ expect(server.requests()).toBeGreaterThan(1);
+ } finally {
+ clearTimeout(timer);
+ }
+ });
+ it("reports an unavailable renderer and honors cancellation", async () => {
+ const server = await rendererServer();
+ await expect(waitForRenderer(server.url, { timeout: 100 })).rejects.toThrow(
+ server.url,
+ );
+ const controller = new AbortController();
+ const pending = waitForRenderer(server.url, { signal: controller.signal });
+ controller.abort();
+ await expect(pending).rejects.toThrow();
+ });
+});
+
+describe("desktop packaging resources", () => {
+ async function fixture() {
+ const root = await mkdtemp(path.join(tmpdir(), "electron resources "));
+ cleanup.push(() => rm(root, { recursive: true, force: true }));
+ const mainDir = path.join(root, "services/desktop-main");
+ const rendererDir = path.join(root, "apps/desktop-renderer/dist");
+ const preloadEntry = path.join(
+ root,
+ "packages/desktop-preload/dist/index.cjs",
+ );
+ for (const dir of [
+ path.join(mainDir, "build/main"),
+ path.join(rendererDir, "assets"),
+ path.dirname(preloadEntry),
+ ]) {
+ await mkdir(dir, { recursive: true });
+ }
+ await writeFile(path.join(mainDir, "build/main/index.js"), "main");
+ await writeFile(path.join(rendererDir, "index.html"), "renderer");
+ await writeFile(
+ path.join(rendererDir, "assets/icon.bin"),
+ Buffer.from([0, 255, 12]),
+ );
+ await writeFile(preloadEntry, "bridge");
+ return { mainDir, rendererDir, preloadEntry };
+ }
+ it("assembles isolated copies and removes stale renderer assets", async () => {
+ const files = await fixture();
+ await prepareResources(files);
+ const stale = path.join(files.mainDir, "build/renderer/stale.js");
+ await writeFile(stale, "stale");
+ await prepareResources(files);
+ await expect(readFile(stale)).rejects.toThrow();
+ expect(
+ await readFile(
+ path.join(files.mainDir, "build/renderer/assets/icon.bin"),
+ ),
+ ).toEqual(Buffer.from([0, 255, 12]));
+ expect(
+ await readFile(
+ path.join(files.mainDir, "build/preload/index.cjs"),
+ "utf8",
+ ),
+ ).toBe("bridge");
+ expect(
+ await readFile(path.join(files.rendererDir, "index.html"), "utf8"),
+ ).toBe("renderer");
+ expect(await readFile(files.preloadEntry, "utf8")).toBe("bridge");
+ });
+ it("keeps the existing assembly when an input build is missing", async () => {
+ const files = await fixture();
+ await prepareResources(files);
+ await rm(files.preloadEntry);
+ await expect(prepareResources(files)).rejects.toThrow();
+ expect(
+ await readFile(
+ path.join(files.mainDir, "build/renderer/index.html"),
+ "utf8",
+ ),
+ ).toBe("renderer");
+ });
+});
diff --git a/packages/templates/electron-app/apps/electron/tsconfig.json b/packages/templates/electron-app/services/one-template-electron-main/tsconfig.json
similarity index 69%
rename from packages/templates/electron-app/apps/electron/tsconfig.json
rename to packages/templates/electron-app/services/one-template-electron-main/tsconfig.json
index 6f6bef3e..3eef0bbf 100644
--- a/packages/templates/electron-app/apps/electron/tsconfig.json
+++ b/packages/templates/electron-app/services/one-template-electron-main/tsconfig.json
@@ -4,15 +4,12 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
- "strictPropertyInitialization": false,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
- "experimentalDecorators": true,
- "emitDecoratorMetadata": true,
"forceConsistentCasingInFileNames": true,
"outDir": "build/main",
"types": ["node", "electron"]
},
- "include": ["./src/**/*"]
+ "include": ["./src/**/*", "./test/**/*"]
}
diff --git a/packages/templates/electron-app/apps/electron/tsdown.config.ts b/packages/templates/electron-app/services/one-template-electron-main/tsdown.config.ts
similarity index 68%
rename from packages/templates/electron-app/apps/electron/tsdown.config.ts
rename to packages/templates/electron-app/services/one-template-electron-main/tsdown.config.ts
index 939aa5a2..53dabc7d 100644
--- a/packages/templates/electron-app/apps/electron/tsdown.config.ts
+++ b/packages/templates/electron-app/services/one-template-electron-main/tsdown.config.ts
@@ -7,13 +7,7 @@ export default defineConfig({
platform: "node",
target: "node24",
fixedExtension: false,
- deps: {
- neverBundle: [
- "electron",
- "electron-updater",
- "electron-devtools-installer",
- ],
- },
+ deps: { neverBundle: ["electron"] },
dts: false,
clean: true,
sourcemap: true,
diff --git a/packages/templates/electron-app/template.json b/packages/templates/electron-app/template.json
index a623aa1c..e06257f7 100644
--- a/packages/templates/electron-app/template.json
+++ b/packages/templates/electron-app/template.json
@@ -3,11 +3,12 @@
"node": {
"scope": "@one-template-electron",
"sourceFiles": [
- "apps/electron/src/utils/index.ts",
- "apps/electron/src/types/events.ts",
- "apps/ui/src/nodes/HomePage/index.tsx",
- "apps/ui/src/renderer.d.ts",
- "apps/ui/src/hooks/electron.ts"
+ "services/one-template-electron-main/src/utils/index.ts",
+ "services/one-template-electron-main/src/types/events.ts",
+ "apps/one-template-electron-renderer/src/nodes/HomePage/index.tsx",
+ "apps/one-template-electron-renderer/src/renderer.d.ts",
+ "services/one-template-electron-main/scripts/release.mjs",
+ "services/one-template-electron-main/test/main.test.ts"
]
},
"text": [
@@ -22,5 +23,23 @@
"value": "projectNameKebabCase"
}
],
- "exclude": ["pnpm-workspace.yaml"]
+ "exclude": ["pnpm-workspace.yaml"],
+ "projects": [
+ {
+ "source": "apps/one-template-electron-renderer",
+ "suffix": "-renderer",
+ "category": "frontend"
+ },
+ {
+ "source": "services/one-template-electron-main",
+ "suffix": "-main",
+ "category": "backend"
+ },
+ {
+ "source": "packages/one-template-electron-preload",
+ "suffix": "-preload",
+ "category": "library"
+ }
+ ],
+ "sharedFiles": ["README.md", ".editorconfig"]
}
diff --git a/packages/templates/expo-mobile/.env.example b/packages/templates/expo-mobile/.env.example
index a56e82f1..f4d4c1fd 100644
--- a/packages/templates/expo-mobile/.env.example
+++ b/packages/templates/expo-mobile/.env.example
@@ -1,2 +1 @@
-# 不要直接修改本文件中的变量,需要创建 .local 文件修改环境变量
-EXPO_PUBLIC_API_URL="https://api.example.com/"
+EXPO_PUBLIC_API_URL=
diff --git a/packages/templates/expo-mobile/README.md b/packages/templates/expo-mobile/README.md
index b80a234d..dbbc7c2a 100644
--- a/packages/templates/expo-mobile/README.md
+++ b/packages/templates/expo-mobile/README.md
@@ -1,133 +1,21 @@
-# Mobile RN Expo Template
+# Expo Mobile
-基于 Expo Router 的 React Native 模板,适合快速起一个带路由、状态管理、SWR 请求缓存和 NativeWind 样式体系的移动端项目。
+Expo · Expo Router · React Native · TypeScript · Axios · SWR · Zustand · MMKV.
-依赖以 Expo SDK 57 的兼容版本表为准;React、React Native、Reanimated 和 Worklets 需要随 SDK 一起升级。
+## 中文
-## 项目架构
+- `pnpm android` / `pnpm ios`:构建原生开发客户端;`pnpm start`:启动开发服务器;`pnpm web`:浏览器开发。
+- MMKV 依赖 Nitro 原生模块,需要开发客户端,不能用 Expo Go 验证。
+- `pnpm check`、`pnpm test` 检查类型、格式与原生网络/前后台监听生命周期。
+- 只有首页、404 和主题切换。使用 React Native 样式,系统主题为默认值;MMKV 只保存 UI 偏好。
+- Axios 接收 `EXPO_PUBLIC_API_URL`。SWR 管理远程缓存,Zustand 管理共享 UI;没有 token、登录或自定义远程缓存。
+- 通过 `pnpm exec expo install --check` 检查 SDK 依赖兼容性,按需手动安装业务 skills。
-
+## English
-## 技术栈
-
-- Expo 57
-- Expo Router 57
-- React Native 0.86
-- React 19.2
-- TypeScript 7
-- NativeWind 4 + Tailwind CSS 3.4
-- SWR
-- Zustand
-- Axios
-- ahooks
-- React Native Reanimated 4 + Worklets
-- React Native Gesture Handler
-- react-native-mmkv 4 + Nitro Modules
-- oxlint + oxfmt
-
-## 目录结构
-
-```text
-scripts/
-└── reset-project.js # 重置示例页面
-src/
-├── api/ # 请求 key 与纯函数 API
-├── app/ # Expo Router 页面
-│ └── (tabs)/ # Tab 页面
-├── assets/ # 图片与字体资源
-├── components/ # 通用组件与 UI 片段
-├── constants/ # API / 颜色常量
-├── hooks/ # 业务 hooks 与 SWR 初始化
-├── lib/ # axios、mmkv、helper、auth
-├── store/ # Zustand stores
-└── types/ # 类型定义
-```
-
-## 快速开始
-
-使用 Node.js 24 LTS(24.15 或更新版本)和 pnpm 12.3.4。MMKV 使用原生模块,移动端请通过 development build 运行。
-
-```bash
-pnpm install
-pnpm start
-```
-
-其他常用入口:
-
-- `pnpm android`
-- `pnpm ios`
-- `pnpm web`
-
-## 依赖升级约定
-
-- `expo-*`、React 和 React Native 相关原生依赖采用 SDK 57 推荐的兼容组合,不单独追随各包的最新主版本。
-- 导航组件和主题从 `expo-router/js-tabs`、`expo-router/react-navigation` 导入,使用 Router 内置的导航实现。
-- NativeWind 4 的样式运行时依赖 Tailwind CSS 3,当前使用该系列最新的 3.4.19;NativeWind 5 仍处于预览阶段。
-- Expo 的 Babel preset 和 Jest preset 使用 Babel 7、Jest 29,保留这两个兼容系列的最新版本。
-- `react-test-renderer` 与 React 固定为相同版本。
-- 模板使用 TypeScript 7.0.2。SDK 57 官方默认版本仍为 TypeScript 6,因此通过 `expo.install.exclude` 明确保留此版本差异,避免 `expo install --fix` 改回默认版本。
-- `tsconfig.json` 显式加载 Node 和 Jest 全局类型,`src/types/styles.d.ts` 声明 CSS 导入,适配 TypeScript 7 的类型发现与副作用导入检查。
-- 使用 pnpm 更新依赖后运行 `pnpm dedupe`,合并兼容版本的原生模块,再执行下面的检查。
-- After updating dependencies with pnpm, run `pnpm dedupe` to deduplicate compatible native modules before running the checks below.
-- 升级后运行 `pnpm exec expo install --check`、`pnpm dlx expo-doctor`、`pnpm exec tsc --noEmit`、`pnpm exec expo export --platform web`,并在 Android/iOS development build 上验证原生功能。
-
-参考 [Expo 升级指南](https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/) 和 [NativeWind 安装说明](https://www.nativewind.dev/docs/getting-started/installation)。
-
-## 常用命令
-
-| 命令 | 说明 |
-| -------------------- | -------------------------- |
-| `pnpm start` | 启动 Expo 开发服务 |
-| `pnpm reset-project` | 重置模板示例页面 |
-| `pnpm android` | 运行 Android |
-| `pnpm ios` | 运行 iOS |
-| `pnpm web` | 启动 Web 预览 |
-| `pnpm test` | 运行 Jest |
-| `pnpm lint` | 执行 oxlint |
-| `pnpm lint:fix` | 自动修复 lint 问题 |
-| `pnpm format` | 检查格式 |
-| `pnpm format:fix` | 自动格式化 |
-| `pnpm check` | 执行 lint + format |
-| `pnpm check:fix` | 执行 lint:fix + format:fix |
-
-## 请求层约定
-
-RN 模板已经按 “`key + pure function`” 的形式组织请求模块:
-
-```ts
-export const commonPublicApiKey = "/common/public";
-
-export const commonPublicApi = async () => {
- const resp = await axiosPublic.post(commonPublicApiKey);
- return resp.data.data;
-};
-```
-
-页面或 hooks 中直接复用导出的 key 和请求函数:
-
-```tsx
-const { data, isLoading } = useSWR(commonPublicApiKey, commonPublicApi);
-```
-
-带参数的请求则使用闭包包装:
-
-```tsx
-const { data } = useSWR(user ? [commonAuthApiKey, user] : null, () => commonAuthApi({ user }));
-```
-
-当前示例可参考:
-
-- `src/api/common.ts`
-- `src/api/auth.ts`
-- `src/api/token.ts`
-
-## 状态与基础设施
-
-- `src/hooks/setup/swr.ts` 负责 SWR 全局配置
-- `src/lib/mmkv.ts` 提供本地持久化能力
-- `src/store/config.ts`、`src/store/session.ts`、`src/store/secure.ts` 分别承载普通状态、会话状态和敏感状态
-- `global.css` 与 `tailwind.config.js` 负责 NativeWind 样式基线
-
-## 说明
-
-这是模板仓库,不包含工作区级 CI、Docker、K8s 或 secrets 逻辑。这些内容由 `one-cli` 在根工作区统一生成和治理。
+- `pnpm android` / `pnpm ios`: build native development clients; `pnpm start`: start the development server; `pnpm web`: browser development.
+- MMKV requires Nitro native modules and a development client. Expo Go cannot validate it.
+- `pnpm check` and `pnpm test` validate types, formatting, and network/foreground listener lifecycles.
+- Includes only home, 404, and theme switching. Uses React Native styles, defaults to the system theme, and stores UI preferences in MMKV.
+- Axios reads `EXPO_PUBLIC_API_URL`. SWR owns remote caching; Zustand owns shared UI. No token, login, or custom remote cache is included.
+- Check SDK compatibility with `pnpm exec expo install --check`. Install business skills manually as needed.
diff --git a/packages/templates/expo-mobile/babel.config.js b/packages/templates/expo-mobile/babel.config.js
index 18767383..73ebf58e 100644
--- a/packages/templates/expo-mobile/babel.config.js
+++ b/packages/templates/expo-mobile/babel.config.js
@@ -1,6 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
- presets: [["babel-preset-expo", { jsxImportSource: "nativewind" }], "nativewind/babel"],
+ presets: ["babel-preset-expo"],
};
};
diff --git a/packages/templates/expo-mobile/global.css b/packages/templates/expo-mobile/global.css
deleted file mode 100644
index 2e266096..00000000
--- a/packages/templates/expo-mobile/global.css
+++ /dev/null
@@ -1,77 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
-
-:root {
- font-size: 100px;
-
- --foreground-rgb: 0, 0, 0;
- --background-start-rgb: 214, 219, 220;
- --background-end-rgb: 255, 255, 255;
-
- --btn-bgcolor: #9e61ff;
- --btn-bgcolor2: #619bff;
- --btn-bgcolor3: #6c52ee;
-
- --background-body: #00031c;
-
- --font2: Metropolitano;
-
- --brand1-1: #e8f3ff;
- --brand1-2: #bedaff;
- --brand1-3: #94bfff;
- --brand1-4: #6aa1ff;
- --brand1-5: #4080ff;
- --brand1-6: #165dff;
- --brand1-7: #0e42d2;
-
- --brand2-1: #e8f3ff1a;
- --brand2-2: #bedaff1a;
- --brand2-3: #94bfff1a;
- --brand2-4: #6aa1ff1a;
- --brand2-5: #4080ff1a;
- --brand2-6: #165dff1a;
- --brand2-7: #0e42d21a;
-
- --gray-1: #fff;
- --gray-2: #f7f8fa;
- --gray-3: #f2f3f7;
- --gray-4: #ebecf0;
- --gray-5: #e5e6ec;
- --gray-6: #dadbe1;
- --gray-7: #d0d1d9;
- --gray-8: #bbbac4;
- --gray-9: #868b9c;
- --gray-10: #4e5469;
- --gray-11: #13141d;
-
- --info-1: #ebf3ff;
- --info-2: #bedaff;
- --info-3: #94bfff;
- --info-5: #4080ff;
- --info-6: #165dff;
- --info-7: #0e42d2;
-
- --success-1: #e8ffea;
- --success-2: #aff0b5;
- --success-3: #7be188;
- --success-5: #23c343;
- --success-6: #00b42a;
- --success-7: #009a29;
-
- --warning-1: #fff7e8;
- --warning-2: #ffe4ba;
- --warning-3: #ffcf8b;
- --warning-5: #ff9a2e;
- --warning-6: #ff7d00;
- --warning-7: #d25f00;
-
- --error-1: #ffece8;
- --error-2: #ffece8;
- --error-3: #fbaca3;
- --error-5: #f76560;
- --error-6: #f53f3f;
- --error-7: #cb2634;
-
- --mask-1: #00000099;
-}
diff --git a/packages/templates/expo-mobile/metro.config.js b/packages/templates/expo-mobile/metro.config.js
index dd9eee35..1514e6ce 100644
--- a/packages/templates/expo-mobile/metro.config.js
+++ b/packages/templates/expo-mobile/metro.config.js
@@ -1,11 +1,7 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
-const { withNativeWind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
-module.exports = withNativeWind(config, {
- input: "./global.css",
- inlineRem: 100,
-});
+module.exports = config;
diff --git a/packages/templates/expo-mobile/nativewind-env.d.ts b/packages/templates/expo-mobile/nativewind-env.d.ts
deleted file mode 100644
index a13e3136..00000000
--- a/packages/templates/expo-mobile/nativewind-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///
diff --git a/packages/templates/expo-mobile/package.json b/packages/templates/expo-mobile/package.json
index a345707b..2a929c15 100644
--- a/packages/templates/expo-mobile/package.json
+++ b/packages/templates/expo-mobile/package.json
@@ -4,8 +4,7 @@
"private": true,
"main": "expo-router/entry",
"scripts": {
- "start": "cross-env DEBUG=nativewind expo start",
- "reset-project": "node ./scripts/reset-project.js",
+ "start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
@@ -15,52 +14,32 @@
"lint:fix": "oxlint --fix .",
"format": "oxfmt --check .",
"format:fix": "oxfmt --write .",
- "check": "pnpm run lint && pnpm run format",
- "check:fix": "pnpm run lint:fix && pnpm run format:fix"
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run format",
+ "check:fix": "pnpm run lint:fix && pnpm run format:fix",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/metro-runtime": "~57.0.16",
- "@expo/vector-icons": "^15.1.1",
- "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "12.0.1",
- "ahooks": "^3.10.0",
"axios": "^1.20.0",
- "dayjs": "^1.11.23",
"expo": "~57.0.26",
- "expo-blur": "~57.0.3",
"expo-constants": "~57.0.20",
"expo-dev-client": "~57.0.19",
"expo-font": "~57.0.4",
- "expo-haptics": "~57.0.3",
"expo-linking": "~57.0.11",
"expo-router": "~57.0.24",
- "expo-secure-store": "~57.0.4",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
- "expo-symbols": "~57.0.3",
"expo-system-ui": "~57.0.4",
- "expo-tracking-transparency": "~57.0.2",
- "expo-updates": "~57.0.24",
- "expo-web-browser": "~57.0.3",
- "immer": "^11.1.18",
- "lodash-es": "^4.18.1",
- "lru-cache": "^11.5.3",
- "nativewind": "^4.2.7",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
- "react-native-css-interop": "^0.2.7",
- "react-native-gesture-handler": "~2.32.0",
"react-native-mmkv": "^4.3.2",
"react-native-nitro-modules": "^0.37.1",
- "react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.2",
"react-native-web": "~0.21.3",
- "react-native-webview": "13.16.1",
- "react-native-worklets": "0.10.1",
"swr": "^2.5.1",
- "tailwindcss": "^3.4.19",
"zustand": "^5.0.15"
},
"devDependencies": {
@@ -68,30 +47,15 @@
"@react-native/jest-preset": "0.86.3",
"@react-native/metro-config": "0.86.3",
"@types/jest": "^29.5.14",
- "@types/lodash-es": "^4.17.12",
"@types/node": "^24.19.0",
"@types/react": "~19.2.18",
- "@types/react-test-renderer": "^19.3.0",
"babel-preset-expo": "~57.0.13",
- "cross-env": "^10.1.0",
- "globals": "^17.12.0",
"jest": "^29.7.0",
"jest-expo": "~57.0.5",
- "lint-staged": "^17.6.0",
"oxfmt": "^0.71.0",
"oxlint": "^1.86.0",
- "react-test-renderer": "19.2.3",
"typescript": "^7.0.2"
},
- "lint-staged": {
- "*.{js,jsx,ts,tsx,mjs,cjs}": [
- "oxlint --fix",
- "oxfmt --write --no-error-on-unmatched-pattern"
- ],
- "*.{json,md,css,scss,html,yml,yaml}": [
- "oxfmt --write --no-error-on-unmatched-pattern"
- ]
- },
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
diff --git a/packages/templates/expo-mobile/scripts/reset-project.js b/packages/templates/expo-mobile/scripts/reset-project.js
deleted file mode 100644
index 70724e60..00000000
--- a/packages/templates/expo-mobile/scripts/reset-project.js
+++ /dev/null
@@ -1,112 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script is used to reset the project to a blank state.
- * It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
- * You can remove the `reset-project` script from package.json and safely delete this file after running it.
- */
-
-const fs = require("fs");
-const path = require("path");
-const readline = require("readline");
-
-const root = process.cwd();
-const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
-const exampleDir = "app-example";
-const newAppDir = "app";
-const exampleDirPath = path.join(root, exampleDir);
-
-const indexContent = `import { Text, View } from "react-native";
-
-export default function Index() {
- return (
-
- Edit app/index.tsx to edit this screen.
-
- );
-}
-`;
-
-const layoutContent = `import { Stack } from "expo-router";
-
-export default function RootLayout() {
- return ;
-}
-`;
-
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-const moveDirectories = async (userInput) => {
- try {
- if (userInput === "y") {
- // Create the app-example directory
- await fs.promises.mkdir(exampleDirPath, { recursive: true });
- console.log(`📁 /${exampleDir} directory created.`);
- }
-
- // Move old directories to new app-example directory or delete them
- for (const dir of oldDirs) {
- const oldDirPath = path.join(root, dir);
- if (fs.existsSync(oldDirPath)) {
- if (userInput === "y") {
- const newDirPath = path.join(root, exampleDir, dir);
- await fs.promises.rename(oldDirPath, newDirPath);
- console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
- } else {
- await fs.promises.rm(oldDirPath, { recursive: true, force: true });
- console.log(`❌ /${dir} deleted.`);
- }
- } else {
- console.log(`➡️ /${dir} does not exist, skipping.`);
- }
- }
-
- // Create new /app directory
- const newAppDirPath = path.join(root, newAppDir);
- await fs.promises.mkdir(newAppDirPath, { recursive: true });
- console.log("\n📁 New /app directory created.");
-
- // Create index.tsx
- const indexPath = path.join(newAppDirPath, "index.tsx");
- await fs.promises.writeFile(indexPath, indexContent);
- console.log("📄 app/index.tsx created.");
-
- // Create _layout.tsx
- const layoutPath = path.join(newAppDirPath, "_layout.tsx");
- await fs.promises.writeFile(layoutPath, layoutContent);
- console.log("📄 app/_layout.tsx created.");
-
- console.log("\n✅ Project reset complete. Next steps:");
- console.log(
- `1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
- userInput === "y"
- ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
- : ""
- }`,
- );
- } catch (error) {
- console.error(`❌ Error during script execution: ${error.message}`);
- }
-};
-
-rl.question(
- "Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
- (answer) => {
- const userInput = answer.trim().toLowerCase() || "y";
- if (userInput === "y" || userInput === "n") {
- moveDirectories(userInput).finally(() => rl.close());
- } else {
- console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
- rl.close();
- }
- },
-);
diff --git a/packages/templates/expo-mobile/src/api/auth.ts b/packages/templates/expo-mobile/src/api/auth.ts
deleted file mode 100644
index 25dc8cf0..00000000
--- a/packages/templates/expo-mobile/src/api/auth.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { AppResponse } from "@/types";
-import { LoginParams } from "@/types/auth";
-import { axiosPublic } from "@/lib/axios";
-
-export const loginKey = "/auth/login";
-export const login = async (data: LoginParams) => {
- const res = await axiosPublic.post>(loginKey, data);
- return res.data.data;
-};
diff --git a/packages/templates/expo-mobile/src/api/common.ts b/packages/templates/expo-mobile/src/api/common.ts
deleted file mode 100644
index c0d26654..00000000
--- a/packages/templates/expo-mobile/src/api/common.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { axiosAuth, axiosPublic } from "@/lib/axios";
-import { AppResponse } from "@/types";
-import { CommonParams } from "@/types/common";
-
-export const commonAuthApiKey = "/common";
-export const commonAuthApi = async (data: CommonParams) => {
- const resp = await axiosAuth.post>(commonAuthApiKey, data);
- return resp.data.data;
-};
-
-export const commonPublicApiKey = "/common/public";
-export const commonPublicApi = async () => {
- const resp = await axiosPublic.post>(commonPublicApiKey);
- return resp.data.data;
-};
diff --git a/packages/templates/expo-mobile/src/api/index.ts b/packages/templates/expo-mobile/src/api/index.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/packages/templates/expo-mobile/src/api/token.ts b/packages/templates/expo-mobile/src/api/token.ts
deleted file mode 100644
index 6aca4680..00000000
--- a/packages/templates/expo-mobile/src/api/token.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useTokenStore } from "@/store/secure";
-import { AppResponse } from "@/types";
-import axios from "axios";
-
-export const refreshTokenKey = "/auth/refresh_token";
-export const refreshToken = async () => {
- const { token } = useTokenStore.getState();
- // 这里需要使用 axios 避免循环引用
- const res = await axios.post>(
- refreshTokenKey,
- {},
- {
- headers: {
- token,
- },
- },
- );
- if (!res.data.data) {
- throw new Error("No token data");
- }
- return res.data.data;
-};
diff --git a/packages/templates/expo-mobile/src/app/(tabs)/_layout.tsx b/packages/templates/expo-mobile/src/app/(tabs)/_layout.tsx
deleted file mode 100644
index d1a30424..00000000
--- a/packages/templates/expo-mobile/src/app/(tabs)/_layout.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Tabs } from "expo-router";
-import React from "react";
-import { Platform } from "react-native";
-
-import { HapticTab } from "@/components/HapticTab";
-import { IconSymbol } from "@/components/ui/IconSymbol";
-import TabBarBackground from "@/components/ui/TabBarBackground";
-
-export default function TabLayout() {
- return (
-
- ,
- }}
- />
- ,
- }}
- />
-
- );
-}
diff --git a/packages/templates/expo-mobile/src/app/(tabs)/explore.tsx b/packages/templates/expo-mobile/src/app/(tabs)/explore.tsx
deleted file mode 100644
index 70ad96ae..00000000
--- a/packages/templates/expo-mobile/src/app/(tabs)/explore.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from "react";
-import { View, Text } from "react-native";
-
-export default function TabTwoScreen() {
- return (
-
- REM 方案
-
- Hello
-
-
- px 方案
-
- Hello
-
-
- );
-}
diff --git a/packages/templates/expo-mobile/src/app/(tabs)/index.tsx b/packages/templates/expo-mobile/src/app/(tabs)/index.tsx
deleted file mode 100644
index 83a6e8d9..00000000
--- a/packages/templates/expo-mobile/src/app/(tabs)/index.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * Welcome screen — kept intentionally minimal for AI agents to extend.
- *
- * Pre-wired infrastructure (don't recreate; import from these paths):
- * - Theme: useConfigStore (src/store/config.ts) — toggles 'light' / 'dark'
- * - Theme color: useThemeColor (src/hooks/useThemeColor.ts) + src/constants/colors.ts
- * - Auth: useAuth (src/hooks/useAuth.ts) — userLogin(code) / userLogout()
- * - HTTP client: src/lib/axios.ts
- * - Storage: src/lib/mmkv.ts (synchronous, encrypted KV)
- * - SWR setup: src/hooks/setup/swr.ts
- * - API modules: src/api/{common,auth,token,index}.ts — `key + fetcher` shape
- * - Routing: expo-router (src/app/, file-based)
- * - Themed atoms: ThemedView / ThemedText (src/components/) — read theme color from tokens
- * - External link: ExternalLink (src/components/ExternalLink.tsx) — wraps expo-web-browser
- * - Design tokens: tailwind.config.js (NativeWind) — see palette below
- *
- * Atomic design (advisory — physical folders NOT enforced):
- * - atoms → src/components/{ThemedView,ThemedText,ExternalLink}.tsx + ui/
- * - molecules → src/components/ (compose atoms; e.g. ResourceLink below)
- * - organisms → src/app/(tabs)/.tsx (screens are organisms here)
- * - pages → src/app/ (expo-router routes)
- *
- * Design tokens — DO use NativeWind utilities (they map to CSS vars in tailwind.config.js):
- * - Brand: bg-brand1-{1..7} / text-brand1-{...} / border-brand1-{...}
- * bg-brand2-{1..7} / text-brand2-{...}
- * - Neutral: bg-gray-{1..11} / text-gray-{...} / border-gray-{...}
- * - Semantic: text-{info,success,warning,error}-{1,2,3,5,6,7} (text only for most)
- * DON'T hardcode hex / rgb in styles.
- */
-
-import React from "react";
-import { Pressable, View } from "react-native";
-import { ThemedText } from "@/components/ThemedText";
-import { ThemedView } from "@/components/ThemedView";
-import { ExternalLink } from "@/components/ExternalLink";
-
-export default function HomeScreen() {
- return (
-
-
-
- ONE CLI
-
-
-
-
- Welcome to One CLI
-
-
- EXPO · REACT NATIVE
-
-
- 一个由 One CLI 生成的 Expo 移动端脚手架,已预置 NativeWind、SWR、MMKV 与设计令牌。
-
-
-
- Edit src/app/(tabs)/index.tsx and save to start.
-
-
-
-
-
-
- 开始构建
-
-
-
-
-
- GitHub
-
-
-
-
- );
-}
diff --git a/packages/templates/expo-mobile/src/app/+not-found.tsx b/packages/templates/expo-mobile/src/app/+not-found.tsx
index f9692d08..eaf52db7 100644
--- a/packages/templates/expo-mobile/src/app/+not-found.tsx
+++ b/packages/templates/expo-mobile/src/app/+not-found.tsx
@@ -1,30 +1,19 @@
-import React from "react";
import { Link, Stack } from "expo-router";
-import { StyleSheet, View, Text } from "react-native";
+import { StyleSheet } from "react-native";
+import { ThemedText } from "@/components/ThemedText";
+import { ThemedView } from "@/components/ThemedView";
export default function NotFoundScreen() {
return (
-
-
-
- This screen doesn't exist.
-
- Go to home screen!
-
-
-
+
+
+ 页面不存在 / Page not found
+
+ 返回首页 / Back home
+
+
);
}
-
const styles = StyleSheet.create({
- container: {
- flex: 1,
- alignItems: "center",
- justifyContent: "center",
- padding: 20,
- },
- link: {
- marginTop: 15,
- paddingVertical: 15,
- },
+ container: { flex: 1, padding: 24, gap: 16, alignItems: "center", justifyContent: "center" },
});
diff --git a/packages/templates/expo-mobile/src/app/_layout.tsx b/packages/templates/expo-mobile/src/app/_layout.tsx
index 7022af04..fbb2faac 100644
--- a/packages/templates/expo-mobile/src/app/_layout.tsx
+++ b/packages/templates/expo-mobile/src/app/_layout.tsx
@@ -1,61 +1,30 @@
-import React from "react";
+import NetInfo from "@react-native-community/netinfo";
import { Stack } from "expo-router";
-import * as SplashScreen from "expo-splash-screen";
+import { DarkTheme, DefaultTheme, ThemeProvider } from "expo-router/react-navigation";
import { StatusBar } from "expo-status-bar";
-import { useEffect } from "react";
-import "react-native-reanimated";
-import { GestureHandlerRootView } from "react-native-gesture-handler";
-import { initialWindowMetrics, SafeAreaProvider } from "react-native-safe-area-context";
+import { AppState, Platform } from "react-native";
+import { SafeAreaProvider, initialWindowMetrics } from "react-native-safe-area-context";
import { SWRConfig } from "swr";
-import { useSetup } from "@/hooks/setup";
-import { StyleSheet } from "react-native";
-import "../../global.css";
-import { useColorScheme } from "nativewind";
-import { configSelector, useConfigStore } from "@/store/config";
-import { useShallow } from "zustand/react/shallow";
-import { DarkTheme, DefaultTheme, ThemeProvider } from "expo-router/react-navigation";
-
-// Prevent the splash screen from auto-hiding before asset loading is complete.
-SplashScreen.preventAutoHideAsync();
+import { createNativeSWRConfig } from "@/hooks/setup/swr";
+import { useColorScheme } from "@/hooks/useColorScheme";
+import { fetcher } from "@/lib/axios";
+const swrConfig = {
+ fetcher,
+ ...(Platform.OS === "web" ? {} : createNativeSWRConfig(AppState, NetInfo)),
+};
export default function RootLayout() {
- const { swrConfig, loaded, onLayoutRootView } = useSetup();
- const { theme } = useConfigStore(useShallow(configSelector));
- const { setColorScheme } = useColorScheme();
- setColorScheme(theme);
-
- useEffect(() => {
- if (loaded) {
- SplashScreen.hideAsync();
- }
- }, [loaded]);
-
- if (!loaded) {
- return null;
- }
-
+ const theme = useColorScheme();
return (
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
);
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- },
- backgroundImage: {
- ...StyleSheet.absoluteFill,
- },
-});
diff --git a/packages/templates/expo-mobile/src/app/index.tsx b/packages/templates/expo-mobile/src/app/index.tsx
new file mode 100644
index 00000000..5a1274dd
--- /dev/null
+++ b/packages/templates/expo-mobile/src/app/index.tsx
@@ -0,0 +1,31 @@
+import { Pressable, StyleSheet } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { ThemedText } from "@/components/ThemedText";
+import { ThemedView } from "@/components/ThemedView";
+import { useConfigStore } from "@/store/config";
+
+export default function HomeScreen() {
+ const theme = useConfigStore((state) => state.theme);
+ const setTheme = useConfigStore((state) => state.setTheme);
+ return (
+
+
+ 欢迎 / Welcome
+ 从这里开始构建你的应用。Start building your application here.
+
+ setTheme(theme === "system" ? "dark" : theme === "dark" ? "light" : "system")
+ }
+ >
+ 主题 / Theme: {theme}
+
+
+
+ );
+}
+const styles = StyleSheet.create({
+ screen: { flex: 1 },
+ content: { flex: 1, padding: 24, gap: 20, justifyContent: "center" },
+});
diff --git a/packages/templates/expo-mobile/src/assets/fonts/SpaceMono-Regular.ttf b/packages/templates/expo-mobile/src/assets/fonts/SpaceMono-Regular.ttf
deleted file mode 100644
index 28d7ff71..00000000
Binary files a/packages/templates/expo-mobile/src/assets/fonts/SpaceMono-Regular.ttf and /dev/null differ
diff --git a/packages/templates/expo-mobile/src/assets/images/partial-react-logo.png b/packages/templates/expo-mobile/src/assets/images/partial-react-logo.png
deleted file mode 100644
index 66fd9570..00000000
Binary files a/packages/templates/expo-mobile/src/assets/images/partial-react-logo.png and /dev/null differ
diff --git a/packages/templates/expo-mobile/src/assets/images/react-logo.png b/packages/templates/expo-mobile/src/assets/images/react-logo.png
deleted file mode 100644
index 9d72a9ff..00000000
Binary files a/packages/templates/expo-mobile/src/assets/images/react-logo.png and /dev/null differ
diff --git a/packages/templates/expo-mobile/src/assets/images/react-logo@2x.png b/packages/templates/expo-mobile/src/assets/images/react-logo@2x.png
deleted file mode 100644
index 2229b130..00000000
Binary files a/packages/templates/expo-mobile/src/assets/images/react-logo@2x.png and /dev/null differ
diff --git a/packages/templates/expo-mobile/src/assets/images/react-logo@3x.png b/packages/templates/expo-mobile/src/assets/images/react-logo@3x.png
deleted file mode 100644
index a99b2032..00000000
Binary files a/packages/templates/expo-mobile/src/assets/images/react-logo@3x.png and /dev/null differ
diff --git a/packages/templates/expo-mobile/src/components/Collapsible.tsx b/packages/templates/expo-mobile/src/components/Collapsible.tsx
deleted file mode 100644
index 5997989b..00000000
--- a/packages/templates/expo-mobile/src/components/Collapsible.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from "react";
-import { PropsWithChildren, useState } from "react";
-import { StyleSheet, TouchableOpacity } from "react-native";
-
-import { ThemedText } from "@/components/ThemedText";
-import { ThemedView } from "@/components/ThemedView";
-import { IconSymbol } from "@/components/ui/IconSymbol";
-import { Colors } from "@/constants/colors";
-import { useColorScheme } from "@/hooks/useColorScheme";
-
-export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
- const [isOpen, setIsOpen] = useState(false);
- const theme = useColorScheme() ?? "light";
-
- return (
-
- setIsOpen((value) => !value)}
- activeOpacity={0.8}
- >
-
-
- {title}
-
- {isOpen && {children} }
-
- );
-}
-
-const styles = StyleSheet.create({
- heading: {
- flexDirection: "row",
- alignItems: "center",
- gap: 6,
- },
- content: {
- marginTop: 6,
- marginLeft: 24,
- },
-});
diff --git a/packages/templates/expo-mobile/src/components/ExternalLink.tsx b/packages/templates/expo-mobile/src/components/ExternalLink.tsx
deleted file mode 100644
index 3fcd916f..00000000
--- a/packages/templates/expo-mobile/src/components/ExternalLink.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from "react";
-import { Link } from "expo-router";
-import { openBrowserAsync } from "expo-web-browser";
-import { type ComponentProps } from "react";
-import { Platform } from "react-native";
-
-type Props = Omit, "href"> & { href: string };
-
-export function ExternalLink({ href, ...rest }: Props) {
- return (
- {
- if (Platform.OS !== "web") {
- // Prevent the default behavior of linking to the default browser on native.
- event.preventDefault();
- // Open the link in an in-app browser.
- await openBrowserAsync(href);
- }
- }}
- />
- );
-}
diff --git a/packages/templates/expo-mobile/src/components/HapticTab.tsx b/packages/templates/expo-mobile/src/components/HapticTab.tsx
deleted file mode 100644
index b503ea72..00000000
--- a/packages/templates/expo-mobile/src/components/HapticTab.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from "react";
-import { BottomTabBarButtonProps } from "expo-router/js-tabs";
-import { PlatformPressable } from "expo-router/react-navigation";
-import * as Haptics from "expo-haptics";
-
-export function HapticTab(props: BottomTabBarButtonProps) {
- return (
- {
- if (process.env.EXPO_OS === "ios") {
- // Add a soft haptic feedback when pressing down on the tabs.
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- }
- props.onPressIn?.(ev);
- }}
- />
- );
-}
diff --git a/packages/templates/expo-mobile/src/components/HelloWave.tsx b/packages/templates/expo-mobile/src/components/HelloWave.tsx
deleted file mode 100644
index bb5a5ae4..00000000
--- a/packages/templates/expo-mobile/src/components/HelloWave.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React from "react";
-import { useEffect } from "react";
-import { StyleSheet } from "react-native";
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withTiming,
- withRepeat,
- withSequence,
-} from "react-native-reanimated";
-
-import { ThemedText } from "@/components/ThemedText";
-
-export function HelloWave() {
- const rotationAnimation = useSharedValue(0);
-
- useEffect(() => {
- rotationAnimation.value = withRepeat(
- withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
- 4, // Run the animation 4 times
- );
- }, []);
-
- const animatedStyle = useAnimatedStyle(() => ({
- transform: [{ rotate: `${rotationAnimation.value}deg` }],
- }));
-
- return (
-
- 👋
-
- );
-}
-
-const styles = StyleSheet.create({
- text: {
- fontSize: 28,
- lineHeight: 32,
- marginTop: -6,
- },
-});
diff --git a/packages/templates/expo-mobile/src/components/ThemedText.tsx b/packages/templates/expo-mobile/src/components/ThemedText.tsx
index b738db04..45cf7249 100644
--- a/packages/templates/expo-mobile/src/components/ThemedText.tsx
+++ b/packages/templates/expo-mobile/src/components/ThemedText.tsx
@@ -1,64 +1,15 @@
-import React from "react";
import { Text, type TextProps, StyleSheet } from "react-native";
-
import { useThemeColor } from "@/hooks/useThemeColor";
-export type ThemedTextProps = TextProps & {
- lightColor?: string;
- darkColor?: string;
- type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link";
- className?: string;
-};
-
export function ThemedText({
style,
- lightColor,
- darkColor,
type = "default",
- className,
- ...rest
-}: ThemedTextProps) {
- const color = useThemeColor({ light: lightColor, dark: darkColor }, "text");
-
- return (
-
- );
+ ...props
+}: TextProps & { type?: "default" | "title" }) {
+ const color = useThemeColor({}, "text");
+ return ;
}
-
const styles = StyleSheet.create({
- default: {
- fontSize: 16,
- lineHeight: 24,
- },
- defaultSemiBold: {
- fontSize: 16,
- lineHeight: 24,
- fontWeight: "600",
- },
- title: {
- fontSize: 32,
- fontWeight: "bold",
- lineHeight: 32,
- },
- subtitle: {
- fontSize: 20,
- fontWeight: "bold",
- },
- link: {
- lineHeight: 30,
- fontSize: 16,
- color: "#0a7ea4",
- },
+ default: { fontSize: 16, lineHeight: 24 },
+ title: { fontSize: 32, fontWeight: "bold", lineHeight: 40 },
});
diff --git a/packages/templates/expo-mobile/src/components/__tests__/ThemedText-test.tsx b/packages/templates/expo-mobile/src/components/__tests__/ThemedText-test.tsx
deleted file mode 100644
index e2630eb4..00000000
--- a/packages/templates/expo-mobile/src/components/__tests__/ThemedText-test.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import * as React from "react";
-import renderer from "react-test-renderer";
-import { ThemedText } from "../ThemedText";
-
-it(`renders correctly`, async () => {
- let component!: renderer.ReactTestRenderer;
- await renderer.act(async () => {
- component = renderer.create(Snapshot test! );
- });
-
- expect(component.toJSON()).toMatchSnapshot();
-});
diff --git a/packages/templates/expo-mobile/src/components/__tests__/__snapshots__/ThemedText-test.tsx.snap b/packages/templates/expo-mobile/src/components/__tests__/__snapshots__/ThemedText-test.tsx.snap
deleted file mode 100644
index b68e53e9..00000000
--- a/packages/templates/expo-mobile/src/components/__tests__/__snapshots__/ThemedText-test.tsx.snap
+++ /dev/null
@@ -1,24 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders correctly 1`] = `
-
- Snapshot test!
-
-`;
diff --git a/packages/templates/expo-mobile/src/components/ui/IconSymbol.ios.tsx b/packages/templates/expo-mobile/src/components/ui/IconSymbol.ios.tsx
deleted file mode 100644
index a78dcf33..00000000
--- a/packages/templates/expo-mobile/src/components/ui/IconSymbol.ios.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from "react";
-import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols";
-import { StyleProp, ViewStyle } from "react-native";
-
-export function IconSymbol({
- name,
- size = 24,
- color,
- style,
- weight = "regular",
-}: {
- name: SymbolViewProps["name"];
- size?: number;
- color: string;
- style?: StyleProp;
- weight?: SymbolWeight;
-}) {
- return (
-
- );
-}
diff --git a/packages/templates/expo-mobile/src/components/ui/IconSymbol.tsx b/packages/templates/expo-mobile/src/components/ui/IconSymbol.tsx
deleted file mode 100644
index 638f8efd..00000000
--- a/packages/templates/expo-mobile/src/components/ui/IconSymbol.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-// This file is a fallback for using MaterialIcons on Android and web.
-import React from "react";
-
-import MaterialIcons from "@expo/vector-icons/MaterialIcons";
-import { SymbolWeight } from "expo-symbols";
-import { OpaqueColorValue, StyleProp, ViewStyle } from "react-native";
-
-// Add your SFSymbol to MaterialIcons mappings here.
-const MAPPING = {
- // See MaterialIcons here: https://icons.expo.fyi
- // See SF Symbols in the SF Symbols app on Mac.
- "house.fill": "home",
- "paperplane.fill": "send",
- "chevron.left.forwardslash.chevron.right": "code",
- "chevron.right": "chevron-right",
-} satisfies Partial<
- Record<
- Extract,
- React.ComponentProps["name"]
- >
->;
-
-export type IconSymbolName = keyof typeof MAPPING;
-
-/**
- * An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage.
- *
- * Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons.
- */
-export function IconSymbol({
- name,
- size = 24,
- color,
-}: {
- name: IconSymbolName;
- size?: number;
- color: string | OpaqueColorValue;
- style?: StyleProp;
- weight?: SymbolWeight;
-}) {
- return ;
-}
diff --git a/packages/templates/expo-mobile/src/components/ui/TabBarBackground.ios.tsx b/packages/templates/expo-mobile/src/components/ui/TabBarBackground.ios.tsx
deleted file mode 100644
index 43bac334..00000000
--- a/packages/templates/expo-mobile/src/components/ui/TabBarBackground.ios.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from "react";
-import { useBottomTabBarHeight } from "expo-router/js-tabs";
-import { BlurView } from "expo-blur";
-import { StyleSheet } from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-
-export default function BlurTabBarBackground() {
- return (
-
- );
-}
-
-export function useBottomTabOverflow() {
- const tabHeight = useBottomTabBarHeight();
- const { bottom } = useSafeAreaInsets();
- return tabHeight - bottom;
-}
diff --git a/packages/templates/expo-mobile/src/components/ui/TabBarBackground.tsx b/packages/templates/expo-mobile/src/components/ui/TabBarBackground.tsx
deleted file mode 100644
index 70d1c3c0..00000000
--- a/packages/templates/expo-mobile/src/components/ui/TabBarBackground.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-// This is a shim for web and Android where the tab bar is generally opaque.
-export default undefined;
-
-export function useBottomTabOverflow() {
- return 0;
-}
diff --git a/packages/templates/expo-mobile/src/constants/api.ts b/packages/templates/expo-mobile/src/constants/api.ts
deleted file mode 100644
index 93f3df34..00000000
--- a/packages/templates/expo-mobile/src/constants/api.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const BASE_API_URL = process.env.EXPO_PUBLIC_API_URL || "";
diff --git a/packages/templates/expo-mobile/src/constants/colors.ts b/packages/templates/expo-mobile/src/constants/colors.ts
index 7b4a83c9..d617bfc4 100644
--- a/packages/templates/expo-mobile/src/constants/colors.ts
+++ b/packages/templates/expo-mobile/src/constants/colors.ts
@@ -1,26 +1,4 @@
-/**
- * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
- * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
- */
-
-const tintColorLight = "#ea580c";
-const tintColorDark = "#fb923c";
-
export const Colors = {
- light: {
- text: "#11181C",
- background: "#fff",
- tint: tintColorLight,
- icon: "#687076",
- tabIconDefault: "#687076",
- tabIconSelected: tintColorLight,
- },
- dark: {
- text: "#ECEDEE",
- background: "#151718",
- tint: tintColorDark,
- icon: "#9BA1A6",
- tabIconDefault: "#9BA1A6",
- tabIconSelected: tintColorDark,
- },
+ light: { text: "#11181c", background: "#ffffff" },
+ dark: { text: "#ecedee", background: "#151718" },
};
diff --git a/packages/templates/expo-mobile/src/hooks/setup/index.ts b/packages/templates/expo-mobile/src/hooks/setup/index.ts
deleted file mode 100644
index e5bbcc44..00000000
--- a/packages/templates/expo-mobile/src/hooks/setup/index.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import FontAwesome from "@expo/vector-icons/FontAwesome";
-import { useFonts } from "expo-font";
-import * as SplashScreen from "expo-splash-screen";
-import { useCallback, useEffect, useMemo, useState } from "react";
-
-import { swrConfig } from "./swr";
-
-export function useSetup() {
- const [layoutReady, setLayoutReady] = useState(false);
-
- // fonts
- const [fontsLoaded, fontsError] = useFonts({
- SpaceMono: require("@/assets/fonts/SpaceMono-Regular.ttf"),
- ...FontAwesome.font,
- });
-
- // loaded
- const loaded = useMemo(() => {
- return fontsLoaded;
- }, [fontsLoaded]);
-
- // error
- const error = useMemo(() => {
- return fontsError;
- }, [fontsError]);
-
- useEffect(() => {
- if (error) throw error;
- }, [error]);
-
- // splash
- const onLayoutRootView = useCallback(async () => {
- setLayoutReady(true);
- }, []);
-
- useEffect(() => {
- if (layoutReady && loaded) {
- SplashScreen.hideAsync();
- }
- }, [layoutReady, loaded]);
-
- return {
- swrConfig,
- loaded,
- error,
- onLayoutRootView,
- };
-}
diff --git a/packages/templates/expo-mobile/src/hooks/setup/swr.test.ts b/packages/templates/expo-mobile/src/hooks/setup/swr.test.ts
new file mode 100644
index 00000000..48220fa6
--- /dev/null
+++ b/packages/templates/expo-mobile/src/hooks/setup/swr.test.ts
@@ -0,0 +1,48 @@
+import { createNativeSWRConfig } from "./swr";
+
+test("online reads do not subscribe; reconnect listeners are cleaned up", () => {
+ let notify: (state: {
+ isConnected: boolean | null;
+ isInternetReachable: boolean | null;
+ }) => void = () => {};
+ const unsubscribe = jest.fn();
+ const subscribe = jest.fn((listener) => {
+ notify = listener;
+ return unsubscribe;
+ });
+ const config = createNativeSWRConfig(
+ { currentState: "active", addEventListener: jest.fn() },
+ { addEventListener: subscribe },
+ );
+ const reconnect = jest.fn();
+ const cleanup = config.initReconnect!(reconnect);
+ notify({ isConnected: false, isInternetReachable: false });
+ expect(config.isOnline!()).toBe(false);
+ expect(config.isOnline!()).toBe(false);
+ expect(subscribe).toHaveBeenCalledTimes(1);
+ notify({ isConnected: true, isInternetReachable: true });
+ expect(config.isOnline!()).toBe(true);
+ expect(reconnect).toHaveBeenCalledTimes(1);
+ cleanup!();
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
+});
+
+test("focus revalidates only when returning to foreground and releases the listener", () => {
+ let notify: (state: string) => void = () => {};
+ const remove = jest.fn();
+ const state = {
+ currentState: "background",
+ addEventListener: jest.fn((_event, listener) => {
+ notify = listener;
+ return { remove };
+ }),
+ };
+ const config = createNativeSWRConfig(state, { addEventListener: jest.fn() });
+ const focus = jest.fn();
+ const cleanup = config.initFocus!(focus);
+ notify("active");
+ notify("active");
+ expect(focus).toHaveBeenCalledTimes(1);
+ cleanup!();
+ expect(remove).toHaveBeenCalledTimes(1);
+});
diff --git a/packages/templates/expo-mobile/src/hooks/setup/swr.ts b/packages/templates/expo-mobile/src/hooks/setup/swr.ts
index cf1d89c4..0010079a 100644
--- a/packages/templates/expo-mobile/src/hooks/setup/swr.ts
+++ b/packages/templates/expo-mobile/src/hooks/setup/swr.ts
@@ -1,64 +1,36 @@
-import NetInfo from "@react-native-community/netinfo";
-import { AppState, AppStateStatus } from "react-native";
-import { SWRConfiguration } from "swr";
+import type { SWRConfiguration } from "swr";
-// import { storage } from "@/lib";
-import { isAppInactive, safeParse } from "@/lib/helper";
-
-const storage = new Map();
-
-export function localStorageProvider() {
- const appCache = storage.get("app-cache");
- // 性能问题
- const cache = safeParse(appCache) || [];
- const map = new Map(cache);
-
- const subscription = AppState.addEventListener("change", (state) => {
- if (isAppInactive(state)) {
- // TODO: 性能问题
- const appCache = JSON.stringify(Array.from(map.entries()));
- storage.set("app-cache", appCache);
- }
- subscription.remove();
- });
+type AppStateSource = {
+ currentState: string | null;
+ addEventListener: (event: "change", listener: (state: string) => void) => { remove: () => void };
+};
+type NetworkState = { isConnected: boolean | null; isInternetReachable: boolean | null };
+type NetworkSource = { addEventListener: (listener: (state: NetworkState) => void) => () => void };
- return map as Map;
+export function createNativeSWRConfig(
+ appState: AppStateSource,
+ network: NetworkSource,
+): SWRConfiguration {
+ let online = true;
+ return {
+ // SWR owns this cache and initializes/cleans up its native listeners.
+ provider: () => new Map(),
+ isOnline: () => online,
+ isVisible: () => appState.currentState === "active",
+ initFocus(callback) {
+ let previous = appState.currentState;
+ const subscription = appState.addEventListener("change", (next) => {
+ if (previous !== "active" && next === "active") callback();
+ previous = next;
+ });
+ return () => subscription.remove();
+ },
+ initReconnect(callback) {
+ return network.addEventListener((state) => {
+ const next = state.isConnected !== false && state.isInternetReachable !== false;
+ if (!online && next) callback();
+ online = next;
+ });
+ },
+ };
}
-
-export const swrConfig: SWRConfiguration = {
- provider: localStorageProvider,
- isOnline() {
- let isConnected: boolean | null = null;
- NetInfo.addEventListener((state) => {
- isConnected = state.isConnected;
- });
- return !!isConnected;
- },
- isVisible() {
- return AppState.currentState === "active";
- },
- initFocus(callback) {
- let appState = AppState.currentState;
- const onAppStateChange = (nextAppState: AppStateStatus) => {
- if (isAppInactive(appState) && nextAppState === "active") {
- callback();
- }
- appState = nextAppState;
- };
- const subscription = AppState.addEventListener("change", onAppStateChange);
-
- return () => {
- subscription.remove();
- };
- },
- initReconnect(callback) {
- const unsubscribe = NetInfo.addEventListener((state) => {
- if (state.isConnected) {
- callback();
- }
- });
- return () => {
- unsubscribe();
- };
- },
-};
diff --git a/packages/templates/expo-mobile/src/hooks/useAuth.ts b/packages/templates/expo-mobile/src/hooks/useAuth.ts
deleted file mode 100644
index 47d54b45..00000000
--- a/packages/templates/expo-mobile/src/hooks/useAuth.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { configSelector, useConfigStore } from "@/store/config";
-import { tokenSelector, useTokenStore } from "@/store/secure";
-import { useShallow } from "zustand/react/shallow";
-import useSWRMutation from "swr/mutation";
-import { login, loginKey } from "@/api/auth";
-import { LoginParams } from "@/types/auth";
-import { useMemoizedFn } from "ahooks";
-
-export function useAuth() {
- const { isLogin, setIsLogin } = useConfigStore(useShallow(configSelector));
- const { setToken } = useTokenStore(useShallow(tokenSelector));
-
- // login
- const { trigger } = useSWRMutation(loginKey, (key, { arg }: { arg: LoginParams }) => {
- return login(arg);
- });
-
- const userLogin = useMemoizedFn(async (code: string) => {
- try {
- const token = await trigger({ code });
- setIsLogin(true);
- setToken(token);
- } catch (error) {
- console.error(error);
- }
- });
-
- const userLogout = useMemoizedFn(() => {
- setIsLogin(false);
- setToken(null);
- });
-
- return {
- isLogin,
- userLogin,
- userLogout,
- };
-}
diff --git a/packages/templates/expo-mobile/src/hooks/useColorScheme.ts b/packages/templates/expo-mobile/src/hooks/useColorScheme.ts
index c0dfac3d..188783cb 100644
--- a/packages/templates/expo-mobile/src/hooks/useColorScheme.ts
+++ b/packages/templates/expo-mobile/src/hooks/useColorScheme.ts
@@ -1,5 +1,8 @@
-import { useColorScheme as useNativeColorScheme } from "react-native";
+import { useColorScheme as useSystemColorScheme } from "react-native";
+import { useConfigStore } from "@/store/config";
export function useColorScheme() {
- return useNativeColorScheme() === "dark" ? "dark" : "light";
+ const systemTheme = useSystemColorScheme();
+ const theme = useConfigStore((state) => state.theme);
+ return theme === "system" ? (systemTheme === "dark" ? "dark" : "light") : theme;
}
diff --git a/packages/templates/expo-mobile/src/lib/auth/jwt.ts b/packages/templates/expo-mobile/src/lib/auth/jwt.ts
deleted file mode 100644
index 868e68fa..00000000
--- a/packages/templates/expo-mobile/src/lib/auth/jwt.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Buffer } from "buffer";
-import { unstable_batchedUpdates } from "react-native";
-
-import { refreshToken } from "@/api/token";
-import { useTokenStore } from "@/store/secure";
-
-interface JwtPayload {
- exp: number;
- iat: number;
- aud: string;
- iss: string;
- sub: string;
- auth_time: number;
-}
-
-// 读取 token
-export const getAuthToken = async (): Promise => {
- try {
- const { token } = useTokenStore.getState();
- if (token && isJwtExpiringSoon(token)) {
- try {
- const token = await refreshToken();
- if (!token) {
- throw new Error("No refresh token data");
- }
- await setAuthToken(token);
- } catch (error) {
- console.error("Failed to refresh token:", error);
- await removeAuthToken();
- }
- }
- return token;
- } catch (error) {
- console.error("Error retrieving token:", error);
- return null;
- }
-};
-
-// 保存 token
-export const setAuthToken = async (token: string): Promise => {
- try {
- unstable_batchedUpdates(() => {
- useTokenStore.getState().setToken(token);
- });
- } catch (error) {
- console.error("Error saving token:", error);
- }
-};
-
-// 移除 token
-export const removeAuthToken = async (): Promise => {
- try {
- unstable_batchedUpdates(() => {
- useTokenStore.getState().setToken(null);
- });
- } catch (error) {
- console.error("Error removing token:", error);
- }
-};
-
-// 解析 JWT payload
-export const parseJwtPayload = (token: string): JwtPayload => {
- try {
- const base64Url = token.split(".")[1];
- const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
- const jsonPayload = Buffer.from(base64, "base64").toString("utf8");
- return JSON.parse(jsonPayload);
- } catch (error) {
- console.error("Error parsing JWT payload:", error);
- throw new Error("Invalid token format");
- }
-};
-
-// 检查 JWT 是否未过期
-export const hasJwtNotExpired = (token: string): boolean => {
- try {
- const payload = parseJwtPayload(token);
- if (payload.exp) {
- const currentTime = Math.floor(Date.now() / 1000);
- if (currentTime >= payload.exp) {
- return false;
- }
- }
- return true;
- } catch (error) {
- console.error("Error checking token expiration:", error);
- return false;
- }
-};
-
-// 检查 JWT 是否即将过期
-const isJwtExpiringSoon = (token: string): boolean => {
- try {
- const payload = parseJwtPayload(token);
- if (payload.exp) {
- const currentTime = Math.floor(Date.now() / 1000);
- return payload.exp - currentTime > 0 && payload.exp - currentTime < 60 * 5;
- }
- return true; // 如果没有 exp 字段,假设即将过期
- } catch (error) {
- console.error("Error checking if token is expiring soon:", error);
- return false;
- }
-};
diff --git a/packages/templates/expo-mobile/src/lib/axios.ts b/packages/templates/expo-mobile/src/lib/axios.ts
index 9620a277..af3c08ff 100644
--- a/packages/templates/expo-mobile/src/lib/axios.ts
+++ b/packages/templates/expo-mobile/src/lib/axios.ts
@@ -1,80 +1,6 @@
import axios from "axios";
-import { unstable_batchedUpdates } from "react-native";
-import { AxiosRespError, TokenExpiredError } from "./error";
-
-import { getAuthToken, hasJwtNotExpired } from "./auth/jwt";
-import { useSessionStore } from "@/store/session";
-import { BASE_API_URL } from "@/constants/api";
-
-export const axiosPublic = axios.create({
- baseURL: `${BASE_API_URL}/api/v1`,
-});
-
-axiosPublic.interceptors.request.use(
- async (config) => {
- return config;
- },
- (error) => {
- return Promise.reject(error);
- },
-);
-
-axiosPublic.interceptors.response.use(
- (response) => {
- if (response?.data?.code !== 0) {
- const error = new AxiosRespError(response.data.msg || "Unknown error", response);
- return Promise.reject(error);
- }
- return response;
- },
- (error) => {
- return Promise.reject(error);
- },
-);
-
-export const axiosAuth = axios.create({
- baseURL: `${BASE_API_URL}/api/v1`,
-});
-
-axiosAuth.interceptors.request.use(
- async (config) => {
- try {
- const token = await getAuthToken();
- if (token && hasJwtNotExpired(token)) {
- config.headers.Authorization = `Bearer ${token}`;
- } else {
- unstable_batchedUpdates(() => {
- useSessionStore.getState().setLoginModalOpen(true);
- });
- if (token && !hasJwtNotExpired(token)) {
- return Promise.reject(new TokenExpiredError("Authentication has expired"));
- } else {
- return Promise.reject(new Error("Authentication is invalid"));
- }
- }
- return config;
- } catch (error) {
- return Promise.reject(error);
- }
- },
- (error) => {
- return Promise.reject(error);
- },
-);
-
-axiosAuth.interceptors.response.use(
- (response) => {
- if (response?.data?.code !== 0) {
- const error = new AxiosRespError(response.data.msg || "Unknown error", response);
- return Promise.reject(error);
- }
- return response;
- },
- (error) => {
- if (error.response?.status === 401) {
- // signOut().catch()
- }
- return Promise.reject(error);
- },
-);
+export const http = axios.create({ baseURL: process.env.EXPO_PUBLIC_API_URL, timeout: 10000 });
+export async function fetcher(url: string): Promise {
+ return (await http.get(url)).data;
+}
diff --git a/packages/templates/expo-mobile/src/lib/error.ts b/packages/templates/expo-mobile/src/lib/error.ts
deleted file mode 100644
index 07f93603..00000000
--- a/packages/templates/expo-mobile/src/lib/error.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { AxiosResponse } from "axios";
-
-export class AxiosRespError extends Error {
- response?: AxiosResponse;
-
- constructor(message: string, response?: AxiosResponse) {
- super(message);
- this.response = response;
- // 维护原型链
- Object.setPrototypeOf(this, AxiosRespError.prototype);
- }
-}
-
-export class TokenExpiredError extends Error {
- constructor(message: string) {
- super(message);
- this.name = "TokenExpiredError";
- }
-}
diff --git a/packages/templates/expo-mobile/src/lib/helper.ts b/packages/templates/expo-mobile/src/lib/helper.ts
deleted file mode 100644
index 88e1d924..00000000
--- a/packages/templates/expo-mobile/src/lib/helper.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export function isAppInactive(state: string) {
- return state.match(/inactive|background/);
-}
-
-export function safeParse(jsonStr?: string | null) {
- if (!jsonStr) {
- return null;
- }
- try {
- return JSON.parse(jsonStr);
- } catch (e: unknown) {
- console.error(e);
- return null;
- }
-}
diff --git a/packages/templates/expo-mobile/src/lib/index.ts b/packages/templates/expo-mobile/src/lib/index.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/packages/templates/expo-mobile/src/lib/mmkv.ts b/packages/templates/expo-mobile/src/lib/mmkv.ts
index 336757e3..1eb12a74 100644
--- a/packages/templates/expo-mobile/src/lib/mmkv.ts
+++ b/packages/templates/expo-mobile/src/lib/mmkv.ts
@@ -1,3 +1,11 @@
import { createMMKV } from "react-native-mmkv";
+import type { StateStorage } from "zustand/middleware";
-export const storage = createMMKV();
+const storage = createMMKV({ id: "ui-preferences" });
+export const preferenceStorage: StateStorage = {
+ getItem: (name) => storage.getString(name) ?? null,
+ setItem: (name, value) => storage.set(name, value),
+ removeItem: (name) => {
+ storage.remove(name);
+ },
+};
diff --git a/packages/templates/expo-mobile/src/store/config.ts b/packages/templates/expo-mobile/src/store/config.ts
index 07360a35..bba243b1 100644
--- a/packages/templates/expo-mobile/src/store/config.ts
+++ b/packages/templates/expo-mobile/src/store/config.ts
@@ -1,38 +1,22 @@
-import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
+import { preferenceStorage } from "@/lib/mmkv";
-// 需要持久化的 store
-export interface GlobalConfigState {
- // 登录相关
- isLogin: boolean;
- setIsLogin: (isLogin: boolean) => void;
-
- // 主题
- theme: "light" | "dark";
- setTheme: (theme: "light" | "dark") => void;
+type Theme = "system" | "light" | "dark";
+interface ConfigState {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
}
-
-export const useConfigStore = create()(
+export const useConfigStore = create()(
persist(
(set) => ({
- isLogin: false,
- setIsLogin: (isLogin) => set({ isLogin }),
-
- theme: "light",
+ theme: "system",
setTheme: (theme) => set({ theme }),
}),
{
- name: "config-storage",
- storage: createJSONStorage(() => AsyncStorage),
+ name: "ui-preferences-v1",
+ storage: createJSONStorage(() => preferenceStorage),
+ partialize: (state) => ({ theme: state.theme }),
},
),
);
-
-export const configSelector = (state: GlobalConfigState) => ({
- isLogin: state.isLogin,
- setIsLogin: state.setIsLogin,
-
- theme: state.theme,
- setTheme: state.setTheme,
-});
diff --git a/packages/templates/expo-mobile/src/store/index.ts b/packages/templates/expo-mobile/src/store/index.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/packages/templates/expo-mobile/src/store/secure.ts b/packages/templates/expo-mobile/src/store/secure.ts
deleted file mode 100644
index 33afb4a7..00000000
--- a/packages/templates/expo-mobile/src/store/secure.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { create } from "zustand";
-import { createJSONStorage, persist } from "zustand/middleware";
-import * as SecureStore from "expo-secure-store";
-
-export interface TokenState {
- token: string | null;
- setToken: (token: string | null) => void;
-}
-
-export const useTokenStore = create()(
- persist(
- (set) => ({
- token: null,
- setToken: (token) =>
- set(() => {
- return { token };
- }),
- }),
- {
- name: "token-storage",
- storage: createJSONStorage(() => ({
- getItem: (key) => {
- return SecureStore.getItemAsync(key);
- },
- setItem: async (key, value) => {
- await SecureStore.setItemAsync(key, value);
- },
- removeItem: async (key) => {
- await SecureStore.deleteItemAsync(key);
- },
- })),
- },
- ),
-);
-
-export const tokenSelector = (state: TokenState) => ({
- token: state.token,
- setToken: state.setToken,
-});
diff --git a/packages/templates/expo-mobile/src/store/session.ts b/packages/templates/expo-mobile/src/store/session.ts
deleted file mode 100644
index 2dbe6370..00000000
--- a/packages/templates/expo-mobile/src/store/session.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { create } from "zustand";
-
-// 不需要持久化的 store
-export interface GlobalSessionState {
- // 登录弹窗
- loginModalOpen: boolean;
- setLoginModalOpen: (open: boolean) => void;
-}
-
-export const useSessionStore = create((set) => ({
- loginModalOpen: false,
- setLoginModalOpen: (open) => set({ loginModalOpen: open }),
-}));
-
-export const sessionSelector = (state: GlobalSessionState) => ({
- loginModalOpen: state.loginModalOpen,
- setLoginModalOpen: state.setLoginModalOpen,
-});
diff --git a/packages/templates/expo-mobile/src/types/auth.ts b/packages/templates/expo-mobile/src/types/auth.ts
deleted file mode 100644
index 0de9833d..00000000
--- a/packages/templates/expo-mobile/src/types/auth.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface LoginParams {
- code: string;
-}
diff --git a/packages/templates/expo-mobile/src/types/common.ts b/packages/templates/expo-mobile/src/types/common.ts
deleted file mode 100644
index 40f9dac6..00000000
--- a/packages/templates/expo-mobile/src/types/common.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export interface CommonParams {
- user: string;
-}
diff --git a/packages/templates/expo-mobile/src/types/index.ts b/packages/templates/expo-mobile/src/types/index.ts
deleted file mode 100644
index 089d2e61..00000000
--- a/packages/templates/expo-mobile/src/types/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface AppResponse {
- code: number;
- message: string;
- data: T;
-}
diff --git a/packages/templates/expo-mobile/src/types/styles.d.ts b/packages/templates/expo-mobile/src/types/styles.d.ts
deleted file mode 100644
index cbe652db..00000000
--- a/packages/templates/expo-mobile/src/types/styles.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-declare module "*.css";
diff --git a/packages/templates/expo-mobile/tailwind.config.js b/packages/templates/expo-mobile/tailwind.config.js
deleted file mode 100644
index 13cdc197..00000000
--- a/packages/templates/expo-mobile/tailwind.config.js
+++ /dev/null
@@ -1,182 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-module.exports = {
- darkMode: "class",
- content: ["./src/app/**/*.{js,jsx,ts,tsx}"],
- presets: [require("nativewind/preset")],
- theme: {
- extend: {
- backgroundImage: {},
- backgroundColor: {
- brand1: {
- 1: "var(--brand1-1)",
- 2: "var(--brand1-2)",
- 3: "var(--brand1-3)",
- 4: "var(--brand1-4)",
- 5: "var(--brand1-5)",
- 6: "var(--brand1-6)",
- 7: "var(--brand1-7)",
- },
- brand2: {
- 1: "var(--brand2-1)",
- 2: "var(--brand2-2)",
- 3: "var(--brand2-3)",
- 4: "var(--brand2-4)",
- 5: "var(--brand2-5)",
- 6: "var(--brand2-6)",
- 7: "var(--brand2-7)",
- },
- gray: {
- 1: "var(--gray-1)",
- 2: "var(--gray-2)",
- 3: "var(--gray-3)",
- 4: "var(--gray-4)",
- 5: "var(--gray-5)",
- 6: "var(--gray-6)",
- 7: "var(--gray-7)",
- 8: "var(--gray-8)",
- 9: "var(--gray-9)",
- 10: "var(--gray-10)",
- 11: "var(--gray-11)",
- },
- },
- textColor: {
- brand1: {
- 1: "var(--brand1-1)",
- 2: "var(--brand1-2)",
- 3: "var(--brand1-3)",
- 4: "var(--brand1-4)",
- 5: "var(--brand1-5)",
- 6: "var(--brand1-6)",
- 7: "var(--brand1-7)",
- },
- brand2: {
- 1: "var(--brand2-1)",
- 2: "var(--brand2-2)",
- 3: "var(--brand2-3)",
- 4: "var(--brand2-4)",
- 5: "var(--brand2-5)",
- 6: "var(--brand2-6)",
- 7: "var(--brand2-7)",
- },
- gray: {
- 1: "var(--gray-1)",
- 2: "var(--gray-2)",
- 3: "var(--gray-3)",
- 4: "var(--gray-4)",
- 5: "var(--gray-5)",
- 6: "var(--gray-6)",
- 7: "var(--gray-7)",
- 8: "var(--gray-8)",
- 9: "var(--gray-9)",
- 10: "var(--gray-10)",
- 11: "var(--gray-11)",
- },
- error: {
- 1: "var(--error-1)",
- 2: "var(--error-2)",
- 3: "var(--error-3)",
- 5: "var(--error-5)",
- 6: "var(--error-6)",
- 7: "var(--error-7)",
- },
- },
- borderRadius: {},
- borderColor: {
- brand1: {
- 1: "var(--brand1-1)",
- 2: "var(--brand1-2)",
- 3: "var(--brand1-3)",
- 4: "var(--brand1-4)",
- 5: "var(--brand1-5)",
- 6: "var(--brand1-6)",
- 7: "var(--brand1-7)",
- },
- gray: {
- 1: "var(--gray-1)",
- 2: "var(--gray-2)",
- 3: "var(--gray-3)",
- 4: "var(--gray-4)",
- 5: "var(--gray-5)",
- 6: "var(--gray-6)",
- 7: "var(--gray-7)",
- 8: "var(--gray-8)",
- 9: "var(--gray-9)",
- 10: "var(--gray-10)",
- 11: "var(--gray-11)",
- },
- },
- colors: {
- brand1: {
- 1: "hsl(var(--brand1-1))",
- 2: "hsl(var(--brand1-2))",
- 3: "hsl(var(--brand1-3))",
- 4: "hsl(var(--brand1-4))",
- 5: "hsl(var(--brand1-5))",
- 6: "hsl(var(--brand1-6))",
- 7: "hsl(var(--brand1-7))",
- },
- brand2: {
- 1: "hsl(var(--brand2-1))",
- 2: "hsl(var(--brand2-2))",
- 3: "hsl(var(--brand2-3))",
- 4: "hsl(var(--brand2-4))",
- 5: "hsl(var(--brand2-5))",
- 6: "hsl(var(--brand2-6))",
- 7: "hsl(var(--brand2-7))",
- },
- gray: {
- 1: "hsl(var(--gray-1))",
- 2: "hsl(var(--gray-2))",
- 3: "hsl(var(--gray-3))",
- 4: "hsl(var(--gray-4))",
- 5: "hsl(var(--gray-5))",
- 6: "hsl(var(--gray-6))",
- 7: "hsl(var(--gray-7))",
- 8: "hsl(var(--gray-8))",
- 9: "hsl(var(--gray-9))",
- 10: "hsl(var(--gray-10))",
- 11: "hsl(var(--gray-11))",
- },
- mask: {
- 1: "hsl(var(--mask-1))",
- },
- info: {
- 1: "hsl(var(--info-1))",
- 2: "hsl(var(--info-2))",
- 3: "hsl(var(--info-3))",
- 5: "hsl(var(--info-5))",
- 6: "hsl(var(--info-6))",
- 7: "hsl(var(--info-7))",
- },
- success: {
- 1: "hsl(var(--success-1))",
- 2: "hsl(var(--success-2))",
- 3: "hsl(var(--success-3))",
- 5: "hsl(var(--success-5))",
- 6: "hsl(var(--success-6))",
- 7: "hsl(var(--success-7))",
- },
- warning: {
- 1: "hsl(var(--warning-1))",
- 2: "hsl(var(--warning-2))",
- 3: "hsl(var(--warning-3))",
- 5: "hsl(var(--warning-5))",
- 6: "hsl(var(--warning-6))",
- 7: "hsl(var(--warning-7))",
- },
- error: {
- 1: "hsl(var(--error-1))",
- 2: "hsl(var(--error-2))",
- 3: "hsl(var(--error-3))",
- 5: "hsl(var(--error-5))",
- 6: "hsl(var(--error-6))",
- 7: "hsl(var(--error-7))",
- },
- },
- keyframes: {},
- animation: {},
- boxShadow: {},
- },
- },
- plugins: [],
-};
diff --git a/packages/templates/expo-mobile/tsconfig.json b/packages/templates/expo-mobile/tsconfig.json
index 3db7b043..f876864b 100644
--- a/packages/templates/expo-mobile/tsconfig.json
+++ b/packages/templates/expo-mobile/tsconfig.json
@@ -8,5 +8,5 @@
"@/*": ["./src/*"]
}
},
- "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
diff --git a/packages/templates/fumadocs-docs/.editorconfig b/packages/templates/fumadocs-docs/.editorconfig
new file mode 100644
index 00000000..bb0754ff
--- /dev/null
+++ b/packages/templates/fumadocs-docs/.editorconfig
@@ -0,0 +1,32 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# All files
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+# JavaScript, TypeScript, JSON, CSS files
+[*.{js,jsx,ts,tsx,json,css,scss,sass}]
+indent_style = tab
+indent_size = 4
+
+# Markdown files
+[*.md]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = false
+
+# YAML files
+[*.{yml,yaml}]
+indent_style = space
+indent_size = 2
+
+# Package files
+[package.json]
+indent_style = tab
+indent_size = 2
\ No newline at end of file
diff --git a/packages/templates/fumadocs-docs/.gitignore b/packages/templates/fumadocs-docs/.gitignore
new file mode 100644
index 00000000..eccd9a34
--- /dev/null
+++ b/packages/templates/fumadocs-docs/.gitignore
@@ -0,0 +1,57 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# CI/CD temporary directories
+deployment-package/
+/artifacts/
+*.artifact/
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# Local Environment Variables
+# -----------------
+# These files are for local development and should not be committed to the repository.
+# They may contain sensitive data.
+# Use .env.example as a template.
+.env
+*.local
+
+# Sentry Config File
+.env.sentry-build-plugin
+
+# Generated content and dependencies
+.source/
+node_modules/
+pnpm-lock.yaml
+*.tsbuildinfo
+
+# Agent metadata generated by next dev
+AGENTS.md
+CLAUDE.md
diff --git a/packages/templates/fumadocs-docs/README.md b/packages/templates/fumadocs-docs/README.md
new file mode 100644
index 00000000..f840f98c
--- /dev/null
+++ b/packages/templates/fumadocs-docs/README.md
@@ -0,0 +1,54 @@
+# Fumadocs 文档站 / Fumadocs documentation
+
+Next.js 16 + React 19 + TypeScript 7 + Tailwind CSS 4 + Fumadocs Base UI。
+English/Chinese documentation with local Markdown/MDX and static browser search.
+
+## 创建与开发 / Create and develop
+
+```sh
+one add fumadocs-docs --name docs --yes
+one dev -p docs
+one build -p docs
+```
+
+项目直接位于 `apps/<用户项目名>`,共用工作区根目录的 pnpm 配置。
+The project lives directly in `apps/` and uses the workspace root pnpm configuration.
+
+## 内容与语言 / Content and languages
+
+在 `content/docs/en/` 和 `content/docs/zh/` 编写文档,使用相同文件名让语言切换打开对应页面。
+Edit matching files in `content/docs/en/` and `content/docs/zh/`.
+`meta.json` 控制侧边栏顺序,正文标题生成目录,`source.config.ts` 配置内容与代码高亮。
+Use `meta.json` for navigation order and `source.config.ts` for content and syntax highlighting.
+
+文档 UI 使用 `fumadocs-ui` 到 `@fumadocs/base-ui` 的 npm alias。
+The `fumadocs-ui` npm alias installs `@fumadocs/base-ui`; custom components use shadcn `base-nova`.
+搜索通过静态 `GET /api/search` 导出索引,并在浏览器中按当前语言搜索。
+The static `GET /api/search` exports the index; search runs in the browser and filters by locale.
+
+## 构建与部署 / Build and deploy
+
+```sh
+pnpm run check
+pnpm run build
+pnpm run preview
+```
+
+`next build` 将站点导出到 `out/`,`preview` / `start` 使用静态文件服务器。
+`next build` exports the site to `out/`; `preview` / `start` serve those static files.
+可部署到 Nginx、对象存储或其他静态托管平台。
+Deploy to Nginx, object storage, or another static hosting provider.
+需要运行时服务端功能时,选择 `nextjs-app` 或调整 Next.js 输出模式。
+Choose `nextjs-app` or change the output mode when your site needs a runtime server.
+
+## 模板源码开发 / Template source development
+
+```sh
+pnpm install
+pnpm run dev
+```
+
+源码目录的 `pnpm-workspace.yaml` 隔离父工作区,生成项目时会排除它。
+The source-only `pnpm-workspace.yaml` isolates the parent workspace and is excluded when generating projects.
+生成的内容、依赖、锁文件和构建产物不包含在模板中。
+Generated content, dependencies, lockfiles, and build outputs are excluded from the template.
diff --git a/packages/templates/fumadocs-docs/components.json b/packages/templates/fumadocs-docs/components.json
new file mode 100644
index 00000000..dd4f3312
--- /dev/null
+++ b/packages/templates/fumadocs-docs/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-nova",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/packages/templates/fumadocs-docs/content/docs/en/guide.mdx b/packages/templates/fumadocs-docs/content/docs/en/guide.mdx
new file mode 100644
index 00000000..ac3ba7ac
--- /dev/null
+++ b/packages/templates/fumadocs-docs/content/docs/en/guide.mdx
@@ -0,0 +1,18 @@
+---
+title: Writing guide
+description: Organize pages and publish your docs.
+---
+
+## Content structure
+
+Add Markdown or MDX files in `content/docs/en/`. Use the same file names in
+`content/docs/zh/` so the language selector opens the corresponding page.
+
+## Navigation
+
+Set page order in `meta.json`. Headings create the table of contents automatically.
+
+## Publish
+
+Run `pnpm build`, then deploy `out/` to your static hosting provider. Search uses
+the exported index and runs in the browser.
diff --git a/packages/templates/fumadocs-docs/content/docs/en/index.mdx b/packages/templates/fumadocs-docs/content/docs/en/index.mdx
new file mode 100644
index 00000000..43f9d893
--- /dev/null
+++ b/packages/templates/fumadocs-docs/content/docs/en/index.mdx
@@ -0,0 +1,23 @@
+---
+title: Getting started
+description: Welcome to your documentation site.
+---
+
+## Your first page
+
+Edit `content/docs/en/index.mdx` to introduce your product. Create a matching page
+under `content/docs/zh/` for Chinese readers.
+
+## Write documentation
+
+Use Markdown for content and MDX for interactive React components.
+
+```tsx
+import { Button } from "@/components/ui/button";
+
+export function Example() {
+ return Get started ;
+}
+```
+
+See [the guide](/en/docs/guide/) for content organization and publishing.
diff --git a/packages/templates/fumadocs-docs/content/docs/en/meta.json b/packages/templates/fumadocs-docs/content/docs/en/meta.json
new file mode 100644
index 00000000..d1a0cc05
--- /dev/null
+++ b/packages/templates/fumadocs-docs/content/docs/en/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "Documentation",
+ "pages": ["index", "guide"]
+}
diff --git a/packages/templates/fumadocs-docs/content/docs/zh/guide.mdx b/packages/templates/fumadocs-docs/content/docs/zh/guide.mdx
new file mode 100644
index 00000000..1702190e
--- /dev/null
+++ b/packages/templates/fumadocs-docs/content/docs/zh/guide.mdx
@@ -0,0 +1,17 @@
+---
+title: 编写指南
+description: 组织页面并发布文档。
+---
+
+## 内容结构
+
+在 `content/docs/zh/` 中添加 Markdown 或 MDX 文件。在 `content/docs/en/` 中使用
+相同的文件名,让语言切换器能够打开对应页面。
+
+## 导航
+
+在 `meta.json` 中设置页面顺序。正文标题会自动生成本页目录。
+
+## 发布
+
+运行 `pnpm build`,将 `out/` 部署到静态托管平台。搜索使用导出的索引,在浏览器中运行。
diff --git a/packages/templates/fumadocs-docs/content/docs/zh/index.mdx b/packages/templates/fumadocs-docs/content/docs/zh/index.mdx
new file mode 100644
index 00000000..e80e648d
--- /dev/null
+++ b/packages/templates/fumadocs-docs/content/docs/zh/index.mdx
@@ -0,0 +1,23 @@
+---
+title: 快速开始
+description: 欢迎使用你的文档站点。
+---
+
+## 第一篇文档
+
+修改 `content/docs/zh/index.mdx`,介绍你的产品。在 `content/docs/en/` 中添加
+对应的英文页面。
+
+## 编写文档
+
+使用 Markdown 编写正文,使用 MDX 插入交互式 React 组件。
+
+```tsx
+import { Button } from "@/components/ui/button";
+
+export function Example() {
+ return 开始使用 ;
+}
+```
+
+查看[编写指南](/zh/docs/guide/),了解内容组织和发布方式。
diff --git a/packages/templates/fumadocs-docs/content/docs/zh/meta.json b/packages/templates/fumadocs-docs/content/docs/zh/meta.json
new file mode 100644
index 00000000..7661efbe
--- /dev/null
+++ b/packages/templates/fumadocs-docs/content/docs/zh/meta.json
@@ -0,0 +1,4 @@
+{
+ "title": "文档",
+ "pages": ["index", "guide"]
+}
diff --git a/packages/templates/fumadocs-docs/next-env.d.ts b/packages/templates/fumadocs-docs/next-env.d.ts
new file mode 100644
index 00000000..ce4e94a6
--- /dev/null
+++ b/packages/templates/fumadocs-docs/next-env.d.ts
@@ -0,0 +1,7 @@
+///
+///
+import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/templates/fumadocs-docs/next.config.mjs b/packages/templates/fumadocs-docs/next.config.mjs
new file mode 100644
index 00000000..e533d220
--- /dev/null
+++ b/packages/templates/fumadocs-docs/next.config.mjs
@@ -0,0 +1,12 @@
+import { createMDX } from "fumadocs-mdx/next";
+const withMDX = createMDX();
+
+/** @type {import("next").NextConfig} */
+const config = {
+ output: "export",
+ experimental: { globalNotFound: true },
+ trailingSlash: true,
+ images: { unoptimized: true },
+};
+
+export default withMDX(config);
diff --git a/packages/templates/fumadocs-docs/package.json b/packages/templates/fumadocs-docs/package.json
new file mode 100644
index 00000000..24080e4f
--- /dev/null
+++ b/packages/templates/fumadocs-docs/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "fumadocs-docs-template",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "serve out",
+ "lint": "oxlint .",
+ "lint:fix": "oxlint --fix .",
+ "format": "oxfmt --check .",
+ "format:fix": "oxfmt --write .",
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run format",
+ "check:fix": "pnpm run lint:fix && pnpm run format:fix",
+ "preview": "serve out",
+ "typecheck": "fumadocs-mdx && tsc --noEmit",
+ "postinstall": "fumadocs-mdx"
+ },
+ "dependencies": {
+ "@base-ui/react": "^1.8.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "fumadocs-core": "16.15.15",
+ "fumadocs-mdx": "^15.4.5",
+ "fumadocs-ui": "npm:@fumadocs/base-ui@16.15.15",
+ "lucide-react": "^1.49.0",
+ "next": "^16.3.7",
+ "next-themes": "^0.4.6",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "shadcn": "^4.21.0",
+ "tailwind-merge": "^3.7.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.3.3",
+ "@types/mdx": "^2.0.13",
+ "@types/node": "^24.19.0",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "git-cz": "^4.9.0",
+ "lint-staged": "^17.6.0",
+ "oxfmt": "^0.71.0",
+ "oxlint": "^1.86.0",
+ "postcss": "^8.5.28",
+ "serve": "^14.2.5",
+ "tailwindcss": "^4.3.3",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^7.0.2"
+ },
+ "lint-staged": {
+ "*.{js,jsx,ts,tsx,mjs,cjs}": [
+ "oxlint --fix",
+ "oxfmt --write --no-error-on-unmatched-pattern"
+ ],
+ "*.{json,md,css,scss,html,yml,yaml}": [
+ "oxfmt --write --no-error-on-unmatched-pattern"
+ ]
+ },
+ "engines": {
+ "node": "^24.15.0 || >=26.0.0"
+ },
+ "packageManager": "pnpm@12.3.4"
+}
diff --git a/packages/templates/fumadocs-docs/pnpm-workspace.yaml b/packages/templates/fumadocs-docs/pnpm-workspace.yaml
new file mode 100644
index 00000000..2dfb2499
--- /dev/null
+++ b/packages/templates/fumadocs-docs/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+packages: []
+allowBuilds:
+ esbuild: true
diff --git a/packages/templates/fumadocs-docs/postcss.config.mjs b/packages/templates/fumadocs-docs/postcss.config.mjs
new file mode 100644
index 00000000..c7bcb4b1
--- /dev/null
+++ b/packages/templates/fumadocs-docs/postcss.config.mjs
@@ -0,0 +1,5 @@
+const config = {
+ plugins: ["@tailwindcss/postcss"],
+};
+
+export default config;
diff --git a/packages/templates/fumadocs-docs/source.config.ts b/packages/templates/fumadocs-docs/source.config.ts
new file mode 100644
index 00000000..97fbcd45
--- /dev/null
+++ b/packages/templates/fumadocs-docs/source.config.ts
@@ -0,0 +1,5 @@
+import { defineConfig, defineDocs } from "fumadocs-mdx/config";
+export const docs = defineDocs({ dir: "content/docs" });
+export default defineConfig({
+ mdxOptions: { rehypeCodeOptions: { themes: { light: "github-light", dark: "github-dark" } } },
+});
diff --git a/packages/templates/fumadocs-docs/src/app/(index)/layout.tsx b/packages/templates/fumadocs-docs/src/app/(index)/layout.tsx
new file mode 100644
index 00000000..cc586d09
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/app/(index)/layout.tsx
@@ -0,0 +1,10 @@
+import type { ReactNode } from "react";
+import type { Metadata } from "next";
+import { SiteDocument } from "@/components/site-document";
+export const metadata: Metadata = {
+ title: "My Docs / 我的文档",
+ description: "Choose your language / 选择语言",
+};
+export default function Layout({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/packages/templates/fumadocs-docs/src/app/(index)/page.tsx b/packages/templates/fumadocs-docs/src/app/(index)/page.tsx
new file mode 100644
index 00000000..1971f4ab
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/app/(index)/page.tsx
@@ -0,0 +1,17 @@
+import Link from "next/link";
+export default function Page() {
+ return (
+
+ My Docs / 我的文档
+ Choose your language / 选择语言
+
+
+ English
+
+
+ 中文
+
+
+
+ );
+}
diff --git a/packages/templates/fumadocs-docs/src/app/[lang]/docs/[[...slug]]/page.tsx b/packages/templates/fumadocs-docs/src/app/[lang]/docs/[[...slug]]/page.tsx
new file mode 100644
index 00000000..a4944177
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/app/[lang]/docs/[[...slug]]/page.tsx
@@ -0,0 +1,41 @@
+import { notFound } from "next/navigation";
+import { DocsPage, DocsBody, DocsTitle, DocsDescription } from "fumadocs-ui/page";
+import { source } from "@/lib/source";
+import { getMDXComponents } from "@/components/mdx";
+import { isLocale } from "@/lib/i18n";
+
+export const dynamicParams = false;
+export function generateStaticParams() {
+ return source.generateParams("slug", "lang");
+}
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string; slug?: string[] }>;
+}) {
+ const { lang, slug } = await params;
+ if (!isLocale(lang)) notFound();
+ const page = source.getPage(slug, lang);
+ if (!page) notFound();
+ return { title: page.data.title, description: page.data.description };
+}
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ lang: string; slug?: string[] }>;
+}) {
+ const { lang, slug } = await params;
+ if (!isLocale(lang)) notFound();
+ const page = source.getPage(slug, lang);
+ if (!page) notFound();
+ const MDX = page.data.body;
+ return (
+
+ {page.data.title}
+ {page.data.description}
+
+
+
+
+ );
+}
diff --git a/packages/templates/fumadocs-docs/src/app/[lang]/docs/layout.tsx b/packages/templates/fumadocs-docs/src/app/[lang]/docs/layout.tsx
new file mode 100644
index 00000000..0d2e5104
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/app/[lang]/docs/layout.tsx
@@ -0,0 +1,28 @@
+import type { ReactNode } from "react";
+import { notFound } from "next/navigation";
+import { DocsLayout } from "fumadocs-ui/layouts/docs";
+import { source } from "@/lib/source";
+import { isLocale, messages, i18n } from "@/lib/i18n";
+export default async function Layout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ lang: string }>;
+}) {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/templates/fumadocs-docs/src/app/[lang]/layout.tsx b/packages/templates/fumadocs-docs/src/app/[lang]/layout.tsx
new file mode 100644
index 00000000..98ec30f6
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/app/[lang]/layout.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { SiteDocument } from "@/components/site-document";
+import { locales, isLocale, messages } from "@/lib/i18n";
+export const dynamicParams = false;
+export function generateStaticParams() {
+ return locales.map((lang) => ({ lang }));
+}
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}): Promise {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ return {
+ title: { default: messages[lang].title, template: `%s | ${messages[lang].title}` },
+ description: messages[lang].description,
+ };
+}
+export default async function Layout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ lang: string }>;
+}) {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ return {children} ;
+}
diff --git a/packages/templates/fumadocs-docs/src/app/[lang]/page.tsx b/packages/templates/fumadocs-docs/src/app/[lang]/page.tsx
new file mode 100644
index 00000000..68258421
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/app/[lang]/page.tsx
@@ -0,0 +1,20 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { messages, isLocale } from "@/lib/i18n";
+export default async function Page({ params }: { params: Promise<{ lang: string }> }) {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ const text = messages[lang];
+ return (
+
+ {text.title}
+ {text.description}
+
+ {text.read}
+
+
+ );
+}
diff --git a/packages/templates/fumadocs-docs/src/app/api/search/route.ts b/packages/templates/fumadocs-docs/src/app/api/search/route.ts
new file mode 100644
index 00000000..b6136a3f
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/app/api/search/route.ts
@@ -0,0 +1,5 @@
+import { createFromSource } from "fumadocs-core/search/server";
+import { source } from "@/lib/source";
+export const dynamic = "force-static";
+export const revalidate = false;
+export const { staticGET: GET } = createFromSource(source);
diff --git a/packages/templates/fumadocs-docs/src/app/global-not-found.tsx b/packages/templates/fumadocs-docs/src/app/global-not-found.tsx
new file mode 100644
index 00000000..043cc791
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/app/global-not-found.tsx
@@ -0,0 +1,13 @@
+import "./globals.css";
+export default function NotFound() {
+ return (
+
+
+ Page not found / 页面不存在
+
+ Back to home / 返回首页
+
+
+
+ );
+}
diff --git a/packages/templates/fumadocs-docs/src/app/globals.css b/packages/templates/fumadocs-docs/src/app/globals.css
new file mode 100644
index 00000000..804e119f
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/app/globals.css
@@ -0,0 +1,143 @@
+@import "tailwindcss";
+@import "fumadocs-ui/css/neutral.css";
+@import "fumadocs-ui/css/preset.css";
+@source "../../node_modules/fumadocs-ui/dist/**/*.js";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+@import "../styles/tokens.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: ui-sans-serif, system-ui, sans-serif;
+ --font-mono: ui-monospace, monospace;
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-success-surface: var(--success-surface);
+ --color-success-border: var(--success-border);
+ --color-success-foreground: var(--success-foreground);
+ --color-info-surface: var(--info-surface);
+ --color-info-border: var(--info-border);
+ --color-info-foreground: var(--info-foreground);
+ --color-warning-surface: var(--warning-surface);
+ --color-warning-border: var(--warning-border);
+ --color-warning-foreground: var(--warning-foreground);
+ --color-error-surface: var(--error-surface);
+ --color-error-border: var(--error-border);
+ --color-error-foreground: var(--error-foreground);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Toast 自定义样式 */
+@layer components {
+ /* 强制应用自定义颜色 */
+ [data-slot="toast"] {
+ background: var(--toast-bg, var(--popover)) !important;
+ border: 1px solid var(--toast-border, var(--border)) !important;
+ color: var(--toast-text, var(--popover-foreground)) !important;
+ }
+
+ [data-slot="toast"][data-type="success"] {
+ --toast-bg: var(--success-surface);
+ --toast-border: var(--success-border);
+ --toast-text: var(--success-foreground);
+ }
+
+ [data-slot="toast"][data-type="error"] {
+ --toast-bg: var(--error-surface);
+ --toast-border: var(--error-border);
+ --toast-text: var(--error-foreground);
+ }
+
+ [data-slot="toast"][data-type="warning"] {
+ --toast-bg: var(--warning-surface);
+ --toast-border: var(--warning-border);
+ --toast-text: var(--warning-foreground);
+ }
+
+ [data-slot="toast"][data-type="info"] {
+ --toast-bg: var(--info-surface);
+ --toast-border: var(--info-border);
+ --toast-text: var(--info-foreground);
+ }
+
+ /* Toast 标题和描述颜色 */
+ [data-slot="toast"] [data-slot="toast-title"] {
+ color: var(--toast-text, var(--popover-foreground)) !important;
+ font-weight: 600;
+ }
+
+ [data-slot="toast"] [data-slot="toast-description"] {
+ color: var(--toast-text, var(--popover-foreground)) !important;
+ opacity: 0.8;
+ }
+
+ /* 动作按钮样式 */
+ [data-slot="toast"] [data-slot="toast-action"] {
+ background: var(--toast-bg, var(--popover)) !important;
+ color: var(--toast-text, var(--popover-foreground)) !important;
+ border: 1px solid var(--toast-border, var(--border)) !important;
+ }
+
+ [data-slot="toast"] [data-slot="toast-action"]:hover {
+ opacity: 0.9;
+ }
+}
+
+@theme inline {
+ --color-fd-background: var(--background);
+ --color-fd-foreground: var(--foreground);
+ --color-fd-primary: var(--primary);
+ --color-fd-primary-foreground: var(--primary-foreground);
+ --color-fd-card: var(--card);
+ --color-fd-card-foreground: var(--card-foreground);
+ --color-fd-popover: var(--popover);
+ --color-fd-popover-foreground: var(--popover-foreground);
+ --color-fd-muted: var(--muted);
+ --color-fd-muted-foreground: var(--muted-foreground);
+ --color-fd-border: var(--border);
+ --color-fd-accent: var(--accent);
+ --color-fd-accent-foreground: var(--accent-foreground);
+}
diff --git a/packages/templates/fumadocs-docs/src/components/mdx.tsx b/packages/templates/fumadocs-docs/src/components/mdx.tsx
new file mode 100644
index 00000000..a4102fe7
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/components/mdx.tsx
@@ -0,0 +1,5 @@
+import defaultMdxComponents from "fumadocs-ui/mdx";
+import type { MDXComponents } from "mdx/types";
+export function getMDXComponents(components?: MDXComponents) {
+ return { ...defaultMdxComponents, ...components } satisfies MDXComponents;
+}
diff --git a/packages/templates/fumadocs-docs/src/components/providers.tsx b/packages/templates/fumadocs-docs/src/components/providers.tsx
new file mode 100644
index 00000000..6b4edbb2
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/components/providers.tsx
@@ -0,0 +1,27 @@
+"use client";
+import type { ReactNode } from "react";
+import { RootProvider } from "fumadocs-ui/provider/next";
+import { Toaster } from "@/components/ui/toast";
+import { StaticSearchDialog } from "@/components/search-dialog";
+import { translations, type Locale } from "@/lib/i18n";
+
+export function Providers({ locale, children }: { locale: Locale; children: ReactNode }) {
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/packages/templates/fumadocs-docs/src/components/search-dialog.tsx b/packages/templates/fumadocs-docs/src/components/search-dialog.tsx
new file mode 100644
index 00000000..8d973e90
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/components/search-dialog.tsx
@@ -0,0 +1,37 @@
+"use client";
+import { useDocsSearch } from "fumadocs-core/search/client";
+import { staticClient } from "fumadocs-core/search/client/orama-static";
+import { useI18n } from "fumadocs-ui/contexts/i18n";
+import {
+ SearchDialog,
+ SearchDialogClose,
+ SearchDialogContent,
+ SearchDialogFooter,
+ SearchDialogHeader,
+ SearchDialogIcon,
+ SearchDialogInput,
+ SearchDialogList,
+ SearchDialogOverlay,
+ type SharedProps,
+} from "fumadocs-ui/components/dialog/search";
+
+export function StaticSearchDialog(props: SharedProps) {
+ const { locale } = useI18n();
+ const { search, setSearch, query } = useDocsSearch({
+ client: staticClient({ locale, from: "/api/search" }),
+ });
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/templates/fumadocs-docs/src/components/site-document.tsx b/packages/templates/fumadocs-docs/src/components/site-document.tsx
new file mode 100644
index 00000000..c0431dce
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/components/site-document.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from "react";
+import { Providers } from "@/components/providers";
+import { htmlLang, type Locale } from "@/lib/i18n";
+import "@/app/globals.css";
+export function SiteDocument({ locale, children }: { locale: Locale; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/templates/fumadocs-docs/src/components/ui/button.tsx b/packages/templates/fumadocs-docs/src/components/ui/button.tsx
new file mode 100644
index 00000000..0be1f23b
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/components/ui/button.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ cn(
+ buttonVariants({ variant, size }),
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/packages/templates/fumadocs-docs/src/components/ui/toast.tsx b/packages/templates/fumadocs-docs/src/components/ui/toast.tsx
new file mode 100644
index 00000000..7666dc47
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/components/ui/toast.tsx
@@ -0,0 +1,246 @@
+"use client";
+
+import * as React from "react";
+import { Toast as ToastPrimitive } from "@base-ui/react/toast";
+import { cn } from "@/lib/utils";
+
+import { Button } from "@/components/ui/button";
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+ XIcon,
+} from "lucide-react";
+
+type ToastData = { className?: string };
+
+const toast = ToastPrimitive.createToastManager();
+
+function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
+ return ;
+}
+
+function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
+ return ;
+}
+
+function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
+ return (
+
+ cn(
+ "pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
+ return (
+
+ cn(
+ "group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ "[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
+ "h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
+ "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
+ "data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
+ "data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
+ "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
+ "data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
+ return (
+
+ cn(
+ "flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
+ return (
+
+ cn("text-sm font-medium", typeof className === "function" ? className(state) : className)
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) {
+ return (
+
+ cn(
+ "text-sm text-muted-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastAction({
+ className,
+ render = ,
+ ...props
+}: ToastPrimitive.Action.Props) {
+ return (
+
+ cn("shrink-0", typeof className === "function" ? className(state) : className)
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastClose({
+ className,
+ children,
+ render = ,
+ ...props
+}: ToastPrimitive.Close.Props) {
+ return (
+
+ cn(
+ "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function ToastIcon({ type }: { type: string | undefined }) {
+ let icon: React.ReactNode = null;
+
+ if (type === "success") {
+ icon = ;
+ }
+
+ if (type === "info") {
+ icon = ;
+ }
+
+ if (type === "warning") {
+ icon = ;
+ }
+
+ if (type === "error") {
+ icon = ;
+ }
+
+ if (type === "loading") {
+ icon = ;
+ }
+
+ if (!icon) {
+ return null;
+ }
+
+ return (
+
+ {icon}
+
+ );
+}
+
+function ToastList() {
+ const { toasts } = ToastPrimitive.useToastManager();
+
+ return toasts.map((toastItem) => (
+
+
+
+
+
+
+
+
+
+
+
+ ));
+}
+
+function Toaster({ children, toastManager = toast, ...props }: ToastPrimitive.Provider.Props) {
+ return (
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+const createToastManager = ToastPrimitive.createToastManager;
+const useToastManager = ToastPrimitive.useToastManager;
+
+export {
+ Toaster,
+ Toast,
+ ToastAction,
+ ToastClose,
+ ToastContent,
+ ToastDescription,
+ ToastPortal,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+ createToastManager,
+ toast,
+ useToastManager,
+};
diff --git a/packages/templates/fumadocs-docs/src/lib/i18n.ts b/packages/templates/fumadocs-docs/src/lib/i18n.ts
new file mode 100644
index 00000000..c0bc53ea
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/lib/i18n.ts
@@ -0,0 +1,150 @@
+import { defineI18n } from "fumadocs-core/i18n";
+export const locales = ["en", "zh"] as const;
+export type Locale = (typeof locales)[number];
+export const htmlLang = { en: "en-US", zh: "zh-CN" };
+export function isLocale(value: string): value is Locale {
+ return locales.includes(value as Locale);
+}
+export const i18n = defineI18n({
+ languages: [...locales],
+ defaultLanguage: "en",
+ hideLocale: "never",
+ parser: "dir",
+});
+export const messages = {
+ en: {
+ title: "My Docs",
+ description: "A home for your product documentation.",
+ read: "Read the docs",
+ home: "Home",
+ docs: "Documentation",
+ choose: "Choose your language",
+ },
+ zh: {
+ title: "我的文档",
+ description: "为你的产品文档提供一个清晰易用的网站。",
+ read: "阅读文档",
+ home: "首页",
+ docs: "文档",
+ choose: "选择语言",
+ },
+};
+// Fumadocs 16 uses contextual translation keys, including accessibility labels.
+export const translations = {
+ en: {
+ "Ask AI(AI chat button)": "Ask AI",
+ "Back to Home(404 not found page)": "Back to Home",
+ "Choose a language(language switcher)": "Choose a language",
+ "Choose a language(language switcher)(aria-label)": "Choose a language",
+ "Close Banner(banner)(aria-label)": "Close Banner",
+ "Close Search(search dialog)(aria-label)": "Close Search",
+ "Close Sidebar(aria-label)": "Close Sidebar",
+ "Close Sidebar(sidebar)(aria-label)": "Close Sidebar",
+ "Collapse Sidebar(sidebar)(aria-label)": "Collapse Sidebar",
+ "Copied Anchor Link(heading anchor)(aria-label)": "Copied Anchor Link",
+ "Copied Link(accordion)(aria-label)": "Copied Link",
+ "Copied Markdown(page actions)": "Copied Markdown",
+ "Copied Text(code block)(aria-label)": "Copied Text",
+ "Copy Anchor Link(heading anchor)(aria-label)": "Copy Anchor Link",
+ "Copy Link(accordion)(aria-label)": "Copy Link",
+ "Copy Markdown(page actions)": "Copy Markdown",
+ "Copy Text(code block)(aria-label)": "Copy Text",
+ "Dark(theme switcher)(aria-label)": "Dark",
+ "Default(type table)": "Default",
+ "Edit on GitHub(edit page)": "Edit on GitHub",
+ "Hide Sidebar(sidebar)": "Hide Sidebar",
+ "Last updated on(page footer)": "Last updated on",
+ "Layout Tab(layout tab trigger)": "Layout Tab",
+ "Light(theme switcher)(aria-label)": "Light",
+ "Next Page(pagination)": "Next Page",
+ "No Headings(table of contents)": "No Headings",
+ "No results found(search dialog)": "No results found",
+ "On this page(table of contents)": "On this page",
+ "Open Search(search trigger)(aria-label)": "Open Search",
+ "Open Sidebar(aria-label)": "Open Sidebar",
+ "Open Sidebar(sidebar)(aria-label)": "Open Sidebar",
+ "Open in ChatGPT(page actions)": "Open in ChatGPT",
+ "Open in Claude(page actions)": "Open in Claude",
+ "Open in Cursor(page actions)": "Open in Cursor",
+ "Open in GitHub(page actions)": "Open in GitHub",
+ "Open in Scira AI(page actions)": "Open in Scira AI",
+ "Open(page actions)": "Open",
+ "Page Not Found(404 not found page)": "Page Not Found",
+ "Parameters(type table)": "Parameters",
+ "Previous Page(pagination)": "Previous Page",
+ "Prop(type table)": "Prop",
+ "Read {url}, I want to ask questions about it.(page actions)":
+ "Read {url}, I want to ask questions about it.",
+ "Returns(type table)": "Returns",
+ "Search(search dialog)": "Search",
+ "Search(search trigger)": "Search",
+ "Show Sidebar(sidebar)": "Show Sidebar",
+ "System(theme switcher)(aria-label)": "System",
+ "Table of Contents(inline table of contents)": "Table of Contents",
+ "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.(404 not found page)":
+ "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.",
+ "Toggle Menu(home layout header)(aria-label)": "Toggle Menu",
+ "Toggle Theme(theme switcher)(aria-label)": "Toggle Theme",
+ "Type(type table)": "Type",
+ "View as Markdown(page actions)": "View as Markdown",
+ displayName: "English",
+ },
+ zh: {
+ "Ask AI(AI chat button)": "询问 AI",
+ "Back to Home(404 not found page)": "返回首页",
+ "Choose a language(language switcher)": "选择语言",
+ "Choose a language(language switcher)(aria-label)": "选择语言",
+ "Close Banner(banner)(aria-label)": "关闭横幅",
+ "Close Search(search dialog)(aria-label)": "关闭搜索",
+ "Close Sidebar(aria-label)": "关闭侧边栏",
+ "Close Sidebar(sidebar)(aria-label)": "关闭侧边栏",
+ "Collapse Sidebar(sidebar)(aria-label)": "收起侧边栏",
+ "Copied Anchor Link(heading anchor)(aria-label)": "已复制标题链接",
+ "Copied Link(accordion)(aria-label)": "已复制链接",
+ "Copied Markdown(page actions)": "已复制 Markdown",
+ "Copied Text(code block)(aria-label)": "已复制文本",
+ "Copy Anchor Link(heading anchor)(aria-label)": "复制标题链接",
+ "Copy Link(accordion)(aria-label)": "复制链接",
+ "Copy Markdown(page actions)": "复制 Markdown",
+ "Copy Text(code block)(aria-label)": "复制文本",
+ "Dark(theme switcher)(aria-label)": "深色",
+ "Default(type table)": "默认值",
+ "Edit on GitHub(edit page)": "在 GitHub 上编辑",
+ "Hide Sidebar(sidebar)": "隐藏侧边栏",
+ "Last updated on(page footer)": "最后更新于",
+ "Layout Tab(layout tab trigger)": "布局标签",
+ "Light(theme switcher)(aria-label)": "浅色",
+ "Next Page(pagination)": "下一页",
+ "No Headings(table of contents)": "没有标题",
+ "No results found(search dialog)": "没有找到结果",
+ "On this page(table of contents)": "本页目录",
+ "Open Search(search trigger)(aria-label)": "打开搜索",
+ "Open Sidebar(aria-label)": "打开侧边栏",
+ "Open Sidebar(sidebar)(aria-label)": "打开侧边栏",
+ "Open in ChatGPT(page actions)": "在 ChatGPT 中打开",
+ "Open in Claude(page actions)": "在 Claude 中打开",
+ "Open in Cursor(page actions)": "在 Cursor 中打开",
+ "Open in GitHub(page actions)": "在 GitHub 中打开",
+ "Open in Scira AI(page actions)": "在 Scira AI 中打开",
+ "Open(page actions)": "打开",
+ "Page Not Found(404 not found page)": "页面未找到",
+ "Parameters(type table)": "参数",
+ "Previous Page(pagination)": "上一页",
+ "Prop(type table)": "属性",
+ "Read {url}, I want to ask questions about it.(page actions)":
+ "请阅读 {url},我想询问相关问题。",
+ "Returns(type table)": "返回值",
+ "Search(search dialog)": "搜索",
+ "Search(search trigger)": "搜索",
+ "Show Sidebar(sidebar)": "显示侧边栏",
+ "System(theme switcher)(aria-label)": "跟随系统",
+ "Table of Contents(inline table of contents)": "目录",
+ "The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.(404 not found page)":
+ "你要访问的页面可能已被删除、重命名或暂时无法使用。",
+ "Toggle Menu(home layout header)(aria-label)": "切换菜单",
+ "Toggle Theme(theme switcher)(aria-label)": "切换主题",
+ "Type(type table)": "类型",
+ "View as Markdown(page actions)": "查看 Markdown",
+ displayName: "中文",
+ },
+};
diff --git a/packages/templates/fumadocs-docs/src/lib/source.ts b/packages/templates/fumadocs-docs/src/lib/source.ts
new file mode 100644
index 00000000..48f744e4
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/lib/source.ts
@@ -0,0 +1,4 @@
+import { docs } from "../../.source/server";
+import { loader } from "fumadocs-core/source";
+import { i18n } from "@/lib/i18n";
+export const source = loader({ baseUrl: "/docs", source: docs.toFumadocsSource(), i18n });
diff --git a/packages/templates/fumadocs-docs/src/lib/utils.ts b/packages/templates/fumadocs-docs/src/lib/utils.ts
new file mode 100644
index 00000000..ac680b30
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/packages/templates/fumadocs-docs/src/styles/tokens.css b/packages/templates/fumadocs-docs/src/styles/tokens.css
new file mode 100644
index 00000000..e86fe74d
--- /dev/null
+++ b/packages/templates/fumadocs-docs/src/styles/tokens.css
@@ -0,0 +1,93 @@
+:root {
+ --radius: 0.75rem;
+ --background: oklch(0.98 0.01 240);
+ --foreground: oklch(0.08 0.02 240);
+ --card: oklch(0.98 0.01 240);
+ --card-foreground: oklch(0.08 0.02 240);
+ --popover: oklch(0.98 0.01 240);
+ --popover-foreground: oklch(0.08 0.02 240);
+ --primary: oklch(0.65 0.21 42);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.94 0.02 240);
+ --secondary-foreground: oklch(0.08 0.02 240);
+ --muted: oklch(0.94 0.02 240);
+ --muted-foreground: oklch(0.45 0.015 240);
+ --accent: oklch(0.94 0.02 240);
+ --accent-foreground: oklch(0.08 0.02 240);
+ --destructive: oklch(0.55 0.22 25);
+ --border: oklch(0.89 0.02 240);
+ --input: oklch(0.89 0.02 240);
+ --ring: oklch(0.65 0.21 42);
+ --success-surface: rgb(240 253 244);
+ --success-border: rgb(34 197 94);
+ --success-foreground: rgb(22 101 52);
+ --info-surface: rgb(239 246 255);
+ --info-border: rgb(59 130 246);
+ --info-foreground: rgb(30 58 138);
+ --warning-surface: rgb(255 251 235);
+ --warning-border: rgb(245 158 11);
+ --warning-foreground: rgb(146 64 14);
+ --error-surface: rgb(254 242 242);
+ --error-border: rgb(239 68 68);
+ --error-foreground: rgb(127 29 29);
+ --chart-1: oklch(0.6 0.2 260);
+ --chart-2: oklch(0.7 0.15 220);
+ --chart-3: oklch(0.6 0.2 190);
+ --chart-4: oklch(0.55 0.22 150);
+ --chart-5: oklch(0.7 0.2 50);
+ --sidebar: oklch(0.96 0.015 240);
+ --sidebar-foreground: oklch(0.08 0.02 240);
+ --sidebar-primary: oklch(0.65 0.21 42);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.92 0.02 240);
+ --sidebar-accent-foreground: oklch(0.08 0.02 240);
+ --sidebar-border: oklch(0.89 0.02 240);
+ --sidebar-ring: oklch(0.65 0.21 42);
+}
+
+.dark {
+ --radius: 0.75rem;
+ --background: oklch(0.08 0.02 240);
+ --foreground: oklch(0.98 0.01 240);
+ --card: oklch(0.12 0.03 240);
+ --card-foreground: oklch(0.98 0.01 240);
+ --popover: oklch(0.12 0.03 240);
+ --popover-foreground: oklch(0.98 0.01 240);
+ --primary: oklch(0.65 0.21 42);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.15 0.03 240);
+ --secondary-foreground: oklch(0.98 0.01 240);
+ --muted: oklch(0.15 0.03 240);
+ --muted-foreground: oklch(0.6 0.02 240);
+ --accent: oklch(0.15 0.03 240);
+ --accent-foreground: oklch(0.98 0.01 240);
+ --destructive: oklch(0.6 0.2 25);
+ --border: oklch(0.2 0.03 240);
+ --input: oklch(0.2 0.03 240);
+ --ring: oklch(0.65 0.21 42);
+ --success-surface: rgb(5 46 22);
+ --success-border: rgb(34 197 94);
+ --success-foreground: rgb(187 247 208);
+ --info-surface: rgb(23 37 84);
+ --info-border: rgb(59 130 246);
+ --info-foreground: rgb(191 219 254);
+ --warning-surface: rgb(69 26 3);
+ --warning-border: rgb(245 158 11);
+ --warning-foreground: rgb(254 215 170);
+ --error-surface: rgb(69 10 10);
+ --error-border: rgb(239 68 68);
+ --error-foreground: rgb(254 202 202);
+ --chart-1: oklch(0.5 0.22 260);
+ --chart-2: oklch(0.6 0.18 220);
+ --chart-3: oklch(0.5 0.2 190);
+ --chart-4: oklch(0.5 0.22 150);
+ --chart-5: oklch(0.6 0.2 50);
+ --sidebar: oklch(0.12 0.03 240);
+ --sidebar-foreground: oklch(0.98 0.01 240);
+ --sidebar-primary: oklch(0.65 0.21 42);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.2 0.03 240);
+ --sidebar-accent-foreground: oklch(0.98 0.01 240);
+ --sidebar-border: oklch(0.2 0.03 240);
+ --sidebar-ring: oklch(0.65 0.21 42);
+}
diff --git a/packages/templates/fumadocs-docs/template.json b/packages/templates/fumadocs-docs/template.json
new file mode 100644
index 00000000..0d417b1e
--- /dev/null
+++ b/packages/templates/fumadocs-docs/template.json
@@ -0,0 +1,4 @@
+{
+ "schemaVersion": 1,
+ "exclude": [".source", "pnpm-workspace.yaml"]
+}
diff --git a/packages/templates/fumadocs-docs/tsconfig.json b/packages/templates/fumadocs-docs/tsconfig.json
new file mode 100644
index 00000000..a368696a
--- /dev/null
+++ b/packages/templates/fumadocs-docs/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/templates/go-api/README.md b/packages/templates/go-api/README.md
index 809d412b..76ce6a50 100644
--- a/packages/templates/go-api/README.md
+++ b/packages/templates/go-api/README.md
@@ -1,50 +1,17 @@
# one-template-go-api
-Gin + Gorm + Viper + Zap API service generated by One CLI.
+Gin · Gorm · Viper · Zap · OpenAPI · Taskfile.
-Requires Go 1.27 or newer. Use the latest patch release for your Go version.
+## 中文
-## Commands
+- `task run`:启动服务(默认端口 3000)。`task build`、`task check`、`task test`:构建、检查、测试。
+- 提供 `GET /`、`GET /health`、`/api/docs`,没有登录、用户 CRUD 或默认数据库。
+- 配置支持 `configs/config.yaml` 和环境变量,参见 `configs/config.example.yaml`。One CLI 注入的环境变量由 Viper 读取。
+- Gorm 已保留。选择驱动后,显式调用 `database.Open(dialector)` 并将连接传给自己的业务代码;迁移单独执行。初始 HTTP 服务不创建连接。
-```bash
-go mod tidy
-task run
-task check
-task build
-```
+## English
-## API docs
-
-Swagger UI is available at `http://localhost:8080/api/docs` when the
-service is running. The UI reads the OpenAPI source at
-`/api/openapi.yaml`; update `api/openapi.yaml` whenever routes or
-response shapes change.
-
-## Environment
-
-Viper reads `configs/config.yaml` and process environment variables. Use `one env set ` to configure Infisical variables, then run project tasks through `one run` or commands through `one exec`.
-
-使用 `one env set ` 配置 Infisical 变量,通过 `one run` 执行项目任务或通过 `one exec` 执行命令时自动注入。
-
-```bash
-DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/demo?sslmode=disable
-JWT_SECRET=your-jwt-secret-here
-CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
-```
-
-## Layout
-
-```text
-cmd/server/ # executable entrypoint
-internal/app/ # application wiring
-internal/http/ # Gin routes, handlers, middleware, responses
-internal/domain/ # domain models
-internal/repository/ # Gorm repositories
-internal/service/ # business services
-internal/platform/ # infrastructure adapters
-api/ # OpenAPI spec
-configs/ # config examples
-migrations/ # database SQL migrations
-scripts/ # operational scripts
-Taskfile.yml # go-task tasks
-```
+- `task run`: start on port 3000 by default. `task build`, `task check`, `task test`: build, check, test.
+- Exposes `GET /`, `GET /health`, and `/api/docs`, with no login, user CRUD, or default database.
+- Configuration reads `configs/config.yaml` and environment variables; see `configs/config.example.yaml`. Viper reads variables injected by One CLI.
+- Gorm is retained. Choose a driver, explicitly call `database.Open(dialector)`, and pass the connection to your business code. Run migrations separately. The initial HTTP service does not open a connection.
diff --git a/packages/templates/go-api/api/openapi.yaml b/packages/templates/go-api/api/openapi.yaml
index 8d55f42c..3dd87199 100644
--- a/packages/templates/go-api/api/openapi.yaml
+++ b/packages/templates/go-api/api/openapi.yaml
@@ -1,48 +1,31 @@
openapi: 3.0.3
info:
- title: Go API Service
+ title: Go API
version: 0.1.0
+ description: Application information and health / 服务信息与健康检查
paths:
/:
get:
- summary: Service metadata
+ summary: Application information / 服务信息
responses:
"200":
- description: Service metadata
+ description: Application name and version
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name: { type: string }
+ version: { type: string }
/health:
get:
- summary: Health check
+ summary: Liveness check / 存活检查
responses:
"200":
- description: Healthy
- "503":
- description: Dependency unavailable
- /auth/login:
- post:
- summary: Login
- responses:
- "200":
- description: JWT token
- "401":
- description: Invalid credentials
- /api/users:
- get:
- summary: List users
- security:
- - bearerAuth: []
- responses:
- "200":
- description: Users
- post:
- summary: Create user
- security:
- - bearerAuth: []
- responses:
- "201":
- description: Created user
-components:
- securitySchemes:
- bearerAuth:
- type: http
- scheme: bearer
- bearerFormat: JWT
+ description: Service is running
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status: { type: string, enum: [ok] }
diff --git a/packages/templates/go-api/cmd/server/main.go b/packages/templates/go-api/cmd/server/main.go
index 72fdafd2..f629cbcc 100644
--- a/packages/templates/go-api/cmd/server/main.go
+++ b/packages/templates/go-api/cmd/server/main.go
@@ -23,10 +23,7 @@ func main() {
_ = log.Sync()
}()
- application, err := app.New(cfg, log)
- if err != nil {
- log.Fatal("failed to initialize application", zap.Error(err))
- }
+ application := app.New(cfg, log)
server := &http.Server{
Addr: ":" + cfg.Port,
diff --git a/packages/templates/go-api/configs/config.example.yaml b/packages/templates/go-api/configs/config.example.yaml
index ac5888d2..0e6784fc 100644
--- a/packages/templates/go-api/configs/config.example.yaml
+++ b/packages/templates/go-api/configs/config.example.yaml
@@ -1,36 +1,7 @@
-# 这个文件提供配置 schema 和本地开发默认值,committed 到仓库。
-#
-# 生产敏感值不写在这里 —— 改用:
-#
-# one env set DATABASE_URL=postgres://prod/db -p api
-# one env set JWT_SECRET=real-secret -p api
-#
-# Viper 在运行时通过 AutomaticEnv + SetEnvKeyReplacer(".", "_") 把环境
-# 变量按嵌套路径覆盖到对应字段:
-#
-# env var DATABASE_URL → viper.Get("database.url")
-# env var JWT_SECRET → viper.Get("jwt.secret")
-# env var APP_ENV → viper.Get("app.env")
-#
-# 即"嵌套路径 a.b.c ↔ env var A_B_C"。所以服务无须依赖一个生成的
-# config.yaml,env 注入与文件覆盖会自动归一。
-
app:
name: go-api
env: development
-
-port: 3000
-
-# Database — 留空时使用内存 SQLite(开发兜底,数据不持久化)。需要
-# Postgres 时取消注释下面那行,或用 `one env set DATABASE_URL=...`。
-database:
- url: ""
- # url: postgresql://postgres:postgres@127.0.0.1:5432/demo?sslmode=disable
-
-jwt:
- secret: your-jwt-secret-here
- expires_in: 720h
-
+port: "3000"
cors:
allowed_origins:
- http://localhost:3000
diff --git a/packages/templates/go-api/go.mod b/packages/templates/go-api/go.mod
index c5bfc967..7fbfbfe0 100644
--- a/packages/templates/go-api/go.mod
+++ b/packages/templates/go-api/go.mod
@@ -4,32 +4,23 @@ go 1.27.0
require (
github.com/gin-gonic/gin v1.12.0
- github.com/glebarez/sqlite v1.11.0
- github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
- github.com/prometheus/client_golang v1.24.1
github.com/spf13/viper v1.21.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
go.uber.org/zap v1.28.0
- golang.org/x/crypto v0.57.0
- gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
- github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.4 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
- github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
- github.com/dustin/go-humanize v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.2 // indirect
- github.com/glebarez/go-sqlite v1.23.0 // indirect
github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
@@ -46,10 +37,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.11.2 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
- github.com/jackc/pgpassfile v1.0.0 // indirect
- github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
- github.com/jackc/pgx/v5 v5.11.0 // indirect
- github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -58,15 +45,9 @@ require (
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
- github.com/prometheus/client_model v0.6.3 // indirect
- github.com/prometheus/common v0.72.0 // indirect
- github.com/prometheus/procfs v0.22.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.63.0 // indirect
- github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@@ -79,6 +60,7 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.31.0 // indirect
+ golang.org/x/crypto v0.57.0 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
@@ -86,8 +68,4 @@ require (
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.50.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
- modernc.org/libc v1.77.1 // indirect
- modernc.org/mathutil v1.7.1 // indirect
- modernc.org/memory v1.12.1 // indirect
- modernc.org/sqlite v1.60.1 // indirect
)
diff --git a/packages/templates/go-api/go.sum b/packages/templates/go-api/go.sum
index 5f691cf6..1489a6a1 100644
--- a/packages/templates/go-api/go.sum
+++ b/packages/templates/go-api/go.sum
@@ -1,22 +1,16 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
-github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI8=
github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
-github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
-github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
-github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
@@ -29,10 +23,6 @@ github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo
github.com/gin-contrib/sse v1.1.2/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
-github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI=
-github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI=
-github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
-github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
@@ -74,41 +64,23 @@ github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
-github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
-github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
-github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
-github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
-github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
-github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
-github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
-github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
-github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
-github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
-github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -120,29 +92,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
-github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
-github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
-github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
-github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
-github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
-github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
-github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
-github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A=
github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
@@ -160,7 +118,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -190,8 +147,6 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
-go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
-go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/arch v0.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk=
@@ -241,37 +196,7 @@ google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
-gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
-modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
-modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
-modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
-modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
-modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
-modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
-modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
-modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
-modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
-modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
-modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
-modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
-modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
-modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
-modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
-modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
-modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
-modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
-modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
-modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
-modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
-modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
-modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
-modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/packages/templates/go-api/internal/app/app.go b/packages/templates/go-api/internal/app/app.go
index abab9eee..e3d629bb 100644
--- a/packages/templates/go-api/internal/app/app.go
+++ b/packages/templates/go-api/internal/app/app.go
@@ -1,56 +1,15 @@
package app
import (
- "go.uber.org/zap"
- "gorm.io/gorm"
-
"github.com/example/one-template-go-api/internal/config"
apihttp "github.com/example/one-template-go-api/internal/http"
- "github.com/example/one-template-go-api/internal/http/handlers"
- "github.com/example/one-template-go-api/internal/platform/jwt"
- "github.com/example/one-template-go-api/internal/platform/postgres"
- "github.com/example/one-template-go-api/internal/repository"
- "github.com/example/one-template-go-api/internal/service"
+ "go.uber.org/zap"
)
-type App struct {
- cfg config.Config
- log *zap.Logger
- db *gorm.DB
- router *apihttp.Router
-}
-
-func New(cfg config.Config, log *zap.Logger) (*App, error) {
- db, err := postgres.Open(cfg.DatabaseURL, log)
- if err != nil {
- return nil, err
- }
-
- jwtManager := jwt.NewManager(cfg.JWTSecret, cfg.JWTExpiresIn)
- userRepo := repository.NewUserRepository(db)
- userService := service.NewUserService(userRepo)
- authService := service.NewAuthService(userRepo, jwtManager)
+type App struct{ router *apihttp.Router }
- handlerSet := handlers.Set{
- App: handlers.NewAppHandler(cfg),
- Health: handlers.NewHealthHandler(db),
- Auth: handlers.NewAuthHandler(authService),
- Users: handlers.NewUserHandler(userService),
- }
-
- return &App{
- cfg: cfg,
- log: log,
- db: db,
- router: apihttp.NewRouter(apihttp.Dependencies{
- Config: cfg,
- Logger: log,
- JWT: jwtManager,
- Handler: handlerSet,
- }),
- }, nil
+func New(cfg config.Config, log *zap.Logger) *App {
+ return &App{router: apihttp.NewRouter(cfg, log)}
}
-func (a *App) Router() *apihttp.Router {
- return a.router
-}
+func (a *App) Router() *apihttp.Router { return a.router }
diff --git a/packages/templates/go-api/internal/config/config.go b/packages/templates/go-api/internal/config/config.go
index bc86321a..f53c1991 100644
--- a/packages/templates/go-api/internal/config/config.go
+++ b/packages/templates/go-api/internal/config/config.go
@@ -1,24 +1,7 @@
-// Package config 加载服务配置。
-//
-// 配置加载策略(One CLI 推荐):
-// 1. configs/config.yaml 提供 schema 与本地开发默认值(committed)。
-// 2. 环境变量按 SetEnvKeyReplacer 规则覆盖文件值:
-// viper.Get("database.url") ← env var DATABASE_URL
-// viper.Get("jwt.secret") ← env var JWT_SECRET
-// viper.Get("app.env") ← env var APP_ENV
-// (嵌套路径 a.b.c ↔ env var A_B_C)
-// 3. 通过 `one env set DATABASE_URL=...` 设置的 secret 会在
-// `one run` / `one dev` 启动时被自动注入为环境变量,
-// 于是服务无须手写 config 文件就能拿到生产值 —— 嵌套覆盖
-// 全部由 viper AutomaticEnv 在运行时完成。
-//
-// 因此**不要**让任何工具去生成 configs/config.yaml;它就是默认值
-// 来源 + schema 文档。生产敏感值改用 `one env set =...`。
package config
import (
"strings"
- "time"
"github.com/spf13/viper"
)
@@ -27,9 +10,6 @@ type Config struct {
AppName string
AppEnv string
Port string
- DatabaseURL string
- JWTSecret string
- JWTExpiresIn time.Duration
AllowedOrigins []string
}
@@ -45,12 +25,6 @@ func Load() Config {
v.SetDefault("app.name", "go-api")
v.SetDefault("app.env", "development")
v.SetDefault("port", "3000")
- // database.url 默认留空 — 启动时回退到内存 SQLite(dev fallback)。
- // 需要真实 Postgres 时 `one env set DATABASE_URL=...` 或在
- // configs/config.yaml 里设。
- v.SetDefault("database.url", "")
- v.SetDefault("jwt.secret", "change-me")
- v.SetDefault("jwt.expires_in", "720h")
v.SetDefault("cors.allowed_origins", []string{
"http://localhost:3000",
"http://localhost:5173",
@@ -62,26 +36,10 @@ func Load() Config {
AppName: v.GetString("app.name"),
AppEnv: v.GetString("app.env"),
Port: v.GetString("port"),
- DatabaseURL: v.GetString("database.url"),
- JWTSecret: v.GetString("jwt.secret"),
- JWTExpiresIn: getDuration(v, "jwt.expires_in", 30*24*time.Hour),
AllowedOrigins: getStringSlice(v, "cors.allowed_origins"),
}
}
-func getDuration(v *viper.Viper, key string, fallback time.Duration) time.Duration {
- value := strings.TrimSpace(v.GetString(key))
- if value == "" {
- return fallback
- }
-
- parsed, err := time.ParseDuration(value)
- if err != nil {
- return fallback
- }
- return parsed
-}
-
func getStringSlice(v *viper.Viper, key string) []string {
value := v.Get(key)
switch typed := value.(type) {
diff --git a/packages/templates/go-api/internal/domain/user.go b/packages/templates/go-api/internal/domain/user.go
deleted file mode 100644
index 5aafbbb0..00000000
--- a/packages/templates/go-api/internal/domain/user.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package domain
-
-import "time"
-
-// User 的 ID 是 app 层生成的 UUID(google/uuid),存储为 text。不写
-// `type:uuid` 让 Gorm 按 dialect 选 text-affinity 默认——Postgres 用
-// text 列,SQLite 同样 text 亲和——便于同一份 schema 在两种 driver 间
-// portable。
-type User struct {
- ID string `gorm:"primaryKey;size:36" json:"id"`
- Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
- Name string `gorm:"size:120;not null" json:"name"`
- PasswordHash string `gorm:"size:255;not null" json:"-"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
-}
diff --git a/packages/templates/go-api/internal/http/handlers/app_handler.go b/packages/templates/go-api/internal/http/handlers/app_handler.go
deleted file mode 100644
index 2bb3bba2..00000000
--- a/packages/templates/go-api/internal/http/handlers/app_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package handlers
-
-import (
- "net/http"
-
- "github.com/gin-gonic/gin"
-
- "github.com/example/one-template-go-api/internal/config"
- "github.com/example/one-template-go-api/internal/http/response"
-)
-
-type AppHandler struct {
- cfg config.Config
-}
-
-func NewAppHandler(cfg config.Config) *AppHandler {
- return &AppHandler{cfg: cfg}
-}
-
-func (h *AppHandler) Info(c *gin.Context) {
- response.OK(c, gin.H{
- "name": h.cfg.AppName,
- "env": h.cfg.AppEnv,
- })
-}
-
-func (h *AppHandler) Docs(c *gin.Context) {
- c.Redirect(http.StatusTemporaryRedirect, "/api/docs/index.html")
-}
diff --git a/packages/templates/go-api/internal/http/handlers/auth_handler.go b/packages/templates/go-api/internal/http/handlers/auth_handler.go
deleted file mode 100644
index c01dcbac..00000000
--- a/packages/templates/go-api/internal/http/handlers/auth_handler.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package handlers
-
-import (
- "errors"
- "net/http"
-
- "github.com/gin-gonic/gin"
-
- "github.com/example/one-template-go-api/internal/http/response"
- "github.com/example/one-template-go-api/internal/service"
-)
-
-type AuthHandler struct {
- auth *service.AuthService
-}
-
-type loginRequest struct {
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required"`
-}
-
-func NewAuthHandler(auth *service.AuthService) *AuthHandler {
- return &AuthHandler{auth: auth}
-}
-
-func (h *AuthHandler) Login(c *gin.Context) {
- var req loginRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.Error(c, http.StatusBadRequest, 40000, "invalid request body")
- return
- }
-
- token, err := h.auth.Login(c.Request.Context(), service.LoginInput{
- Email: req.Email,
- Password: req.Password,
- })
- if errors.Is(err, service.ErrInvalidCredentials) {
- response.Error(c, http.StatusUnauthorized, 40103, "invalid credentials")
- return
- }
- if err != nil {
- response.Error(c, http.StatusInternalServerError, 50001, "login failed")
- return
- }
-
- response.OK(c, token)
-}
diff --git a/packages/templates/go-api/internal/http/handlers/handlers.go b/packages/templates/go-api/internal/http/handlers/handlers.go
deleted file mode 100644
index e7bc1ddd..00000000
--- a/packages/templates/go-api/internal/http/handlers/handlers.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package handlers
-
-type Set struct {
- App *AppHandler
- Health *HealthHandler
- Auth *AuthHandler
- Users *UserHandler
-}
diff --git a/packages/templates/go-api/internal/http/handlers/health_handler.go b/packages/templates/go-api/internal/http/handlers/health_handler.go
deleted file mode 100644
index 09bd4fd5..00000000
--- a/packages/templates/go-api/internal/http/handlers/health_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package handlers
-
-import (
- "net/http"
-
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-
- "github.com/example/one-template-go-api/internal/http/response"
-)
-
-type HealthHandler struct {
- db *gorm.DB
-}
-
-func NewHealthHandler(db *gorm.DB) *HealthHandler {
- return &HealthHandler{db: db}
-}
-
-func (h *HealthHandler) Check(c *gin.Context) {
- sqlDB, err := h.db.DB()
- if err != nil {
- response.Error(c, http.StatusServiceUnavailable, 50300, "database unavailable")
- return
- }
-
- if err := sqlDB.PingContext(c.Request.Context()); err != nil {
- response.Error(c, http.StatusServiceUnavailable, 50301, "database unavailable")
- return
- }
-
- response.OK(c, gin.H{"status": "ok", "driver": h.db.Dialector.Name()})
-}
diff --git a/packages/templates/go-api/internal/http/handlers/user_handler.go b/packages/templates/go-api/internal/http/handlers/user_handler.go
deleted file mode 100644
index 20aca29e..00000000
--- a/packages/templates/go-api/internal/http/handlers/user_handler.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package handlers
-
-import (
- "errors"
- "net/http"
- "strconv"
-
- "github.com/gin-gonic/gin"
-
- "github.com/example/one-template-go-api/internal/http/response"
- "github.com/example/one-template-go-api/internal/service"
-)
-
-type UserHandler struct {
- users *service.UserService
-}
-
-type createUserRequest struct {
- Email string `json:"email" binding:"required,email"`
- Name string `json:"name" binding:"required"`
- Password string `json:"password" binding:"required,min=8"`
-}
-
-func NewUserHandler(users *service.UserService) *UserHandler {
- return &UserHandler{users: users}
-}
-
-func (h *UserHandler) List(c *gin.Context) {
- limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
- offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
-
- users, err := h.users.List(c.Request.Context(), limit, offset)
- if err != nil {
- response.Error(c, http.StatusInternalServerError, 50002, "failed to list users")
- return
- }
-
- response.OK(c, users)
-}
-
-func (h *UserHandler) Create(c *gin.Context) {
- var req createUserRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.Error(c, http.StatusBadRequest, 40000, "invalid request body")
- return
- }
-
- user, err := h.users.Create(c.Request.Context(), service.CreateUserInput{
- Email: req.Email,
- Name: req.Name,
- Password: req.Password,
- })
- if errors.Is(err, service.ErrInvalidInput) {
- response.Error(c, http.StatusBadRequest, 40001, "invalid user input")
- return
- }
- if errors.Is(err, service.ErrEmailAlreadyInUse) {
- response.Error(c, http.StatusConflict, 40900, "email already in use")
- return
- }
- if err != nil {
- response.Error(c, http.StatusInternalServerError, 50003, "failed to create user")
- return
- }
-
- response.Created(c, user)
-}
diff --git a/packages/templates/go-api/internal/http/middleware/auth.go b/packages/templates/go-api/internal/http/middleware/auth.go
deleted file mode 100644
index 5e631660..00000000
--- a/packages/templates/go-api/internal/http/middleware/auth.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package middleware
-
-import (
- "net/http"
- "strings"
-
- "github.com/gin-gonic/gin"
-
- "github.com/example/one-template-go-api/internal/http/response"
- "github.com/example/one-template-go-api/internal/platform/jwt"
-)
-
-const CurrentUserIDKey = "current_user_id"
-
-func Auth(manager *jwt.Manager) gin.HandlerFunc {
- return func(c *gin.Context) {
- header := c.GetHeader("Authorization")
- if header == "" {
- response.Error(c, http.StatusUnauthorized, 40100, "missing authorization header")
- return
- }
-
- tokenValue := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
- if tokenValue == "" || tokenValue == header {
- response.Error(c, http.StatusUnauthorized, 40101, "invalid authorization header")
- return
- }
-
- claims, err := manager.Verify(tokenValue)
- if err != nil {
- response.Error(c, http.StatusUnauthorized, 40102, "invalid token")
- return
- }
-
- c.Set(CurrentUserIDKey, claims.UserID)
- c.Next()
- }
-}
diff --git a/packages/templates/go-api/internal/http/middleware/cors.go b/packages/templates/go-api/internal/http/middleware/cors.go
index b5919f86..6bf698ee 100644
--- a/packages/templates/go-api/internal/http/middleware/cors.go
+++ b/packages/templates/go-api/internal/http/middleware/cors.go
@@ -2,7 +2,6 @@ package middleware
import (
"net/http"
- "strings"
"github.com/gin-gonic/gin"
@@ -18,12 +17,11 @@ func CORS(cfg config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
- if _, ok := allowed[origin]; ok || strings.Contains(origin, "localhost") {
+ if _, ok := allowed[origin]; ok {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
}
}
- c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
diff --git a/packages/templates/go-api/internal/http/middleware/metrics.go b/packages/templates/go-api/internal/http/middleware/metrics.go
deleted file mode 100644
index afe74016..00000000
--- a/packages/templates/go-api/internal/http/middleware/metrics.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package middleware
-
-import (
- "strconv"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/prometheus/client_golang/prometheus"
- "github.com/prometheus/client_golang/prometheus/promauto"
-)
-
-var (
- requestsTotal = promauto.NewCounterVec(
- prometheus.CounterOpts{
- Name: "http_requests_total",
- Help: "Total HTTP requests by method, route, and status.",
- },
- []string{"method", "route", "status"},
- )
- requestDuration = promauto.NewHistogramVec(
- prometheus.HistogramOpts{
- Name: "http_request_duration_seconds",
- Help: "HTTP request duration by method, route, and status.",
- Buckets: prometheus.DefBuckets,
- },
- []string{"method", "route", "status"},
- )
-)
-
-func Metrics() gin.HandlerFunc {
- return func(c *gin.Context) {
- start := time.Now()
- c.Next()
-
- route := c.FullPath()
- if route == "" {
- route = c.Request.URL.Path
- }
- status := strconv.Itoa(c.Writer.Status())
- labels := prometheus.Labels{
- "method": c.Request.Method,
- "route": route,
- "status": status,
- }
- requestsTotal.With(labels).Inc()
- requestDuration.With(labels).Observe(time.Since(start).Seconds())
- }
-}
diff --git a/packages/templates/go-api/internal/http/middleware/recovery.go b/packages/templates/go-api/internal/http/middleware/recovery.go
index 5a2daa21..33fb527d 100644
--- a/packages/templates/go-api/internal/http/middleware/recovery.go
+++ b/packages/templates/go-api/internal/http/middleware/recovery.go
@@ -5,8 +5,6 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
-
- "github.com/example/one-template-go-api/internal/http/response"
)
func Recovery(log *zap.Logger) gin.HandlerFunc {
@@ -14,7 +12,7 @@ func Recovery(log *zap.Logger) gin.HandlerFunc {
defer func() {
if recovered := recover(); recovered != nil {
log.Error("panic recovered", zap.Any("panic", recovered), zap.String("request_id", requestID(c)))
- response.Error(c, http.StatusInternalServerError, 50000, "internal server error")
+ c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
}()
c.Next()
diff --git a/packages/templates/go-api/internal/http/response/response.go b/packages/templates/go-api/internal/http/response/response.go
deleted file mode 100644
index 3ae8d01c..00000000
--- a/packages/templates/go-api/internal/http/response/response.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package response
-
-import (
- "net/http"
-
- "github.com/gin-gonic/gin"
-)
-
-type Body struct {
- Code int `json:"code"`
- Message string `json:"message"`
- Data any `json:"data,omitempty"`
-}
-
-func OK(c *gin.Context, data any) {
- c.JSON(http.StatusOK, Body{
- Code: 0,
- Message: "success",
- Data: data,
- })
-}
-
-func Created(c *gin.Context, data any) {
- c.JSON(http.StatusCreated, Body{
- Code: 0,
- Message: "success",
- Data: data,
- })
-}
-
-func Error(c *gin.Context, status int, code int, message string) {
- c.AbortWithStatusJSON(status, Body{
- Code: code,
- Message: message,
- })
-}
diff --git a/packages/templates/go-api/internal/http/router.go b/packages/templates/go-api/internal/http/router.go
index 2de1a3b8..dfe276b7 100644
--- a/packages/templates/go-api/internal/http/router.go
+++ b/packages/templates/go-api/internal/http/router.go
@@ -1,61 +1,27 @@
package http
import (
+ "github.com/example/one-template-go-api/internal/config"
+ "github.com/example/one-template-go-api/internal/http/middleware"
"github.com/gin-gonic/gin"
- "github.com/prometheus/client_golang/prometheus/promhttp"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"go.uber.org/zap"
-
- "github.com/example/one-template-go-api/internal/config"
- "github.com/example/one-template-go-api/internal/http/handlers"
- "github.com/example/one-template-go-api/internal/http/middleware"
- "github.com/example/one-template-go-api/internal/platform/jwt"
+ "net/http"
)
-type Router struct {
- *gin.Engine
-}
-
-type Dependencies struct {
- Config config.Config
- Logger *zap.Logger
- JWT *jwt.Manager
- Handler handlers.Set
-}
+type Router struct{ *gin.Engine }
-func NewRouter(deps Dependencies) *Router {
- if deps.Config.AppEnv == "production" {
+func NewRouter(cfg config.Config, log *zap.Logger) *Router {
+ if cfg.AppEnv == "production" {
gin.SetMode(gin.ReleaseMode)
}
-
engine := gin.New()
- engine.Use(
- middleware.RequestID(),
- middleware.Recovery(deps.Logger),
- middleware.Logger(deps.Logger),
- middleware.Metrics(),
- middleware.CORS(deps.Config),
- )
-
- engine.GET("/", deps.Handler.App.Info)
- engine.GET("/health", deps.Handler.Health.Check)
- engine.GET("/metrics", gin.WrapH(promhttp.Handler()))
- engine.GET("/api/docs", deps.Handler.App.Docs)
+ engine.Use(middleware.RequestID(), middleware.Recovery(log), middleware.Logger(log), middleware.CORS(cfg))
+ engine.GET("/", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"name": cfg.AppName, "version": "0.1.0"}) })
+ engine.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
+ engine.GET("/api/docs", func(c *gin.Context) { c.Redirect(http.StatusTemporaryRedirect, "/api/docs/index.html") })
engine.StaticFile("/api/openapi.yaml", "api/openapi.yaml")
- engine.GET("/api/docs/*any", ginSwagger.WrapHandler(
- swaggerFiles.Handler,
- ginSwagger.URL("/api/openapi.yaml"),
- ))
-
- engine.POST("/auth/login", deps.Handler.Auth.Login)
-
- api := engine.Group("/api")
- api.Use(middleware.Auth(deps.JWT))
- {
- api.GET("/users", deps.Handler.Users.List)
- api.POST("/users", deps.Handler.Users.Create)
- }
-
+ engine.GET("/api/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/api/openapi.yaml")))
return &Router{Engine: engine}
}
diff --git a/packages/templates/go-api/internal/http/router_test.go b/packages/templates/go-api/internal/http/router_test.go
new file mode 100644
index 00000000..02b85402
--- /dev/null
+++ b/packages/templates/go-api/internal/http/router_test.go
@@ -0,0 +1,47 @@
+package http
+
+import (
+ "encoding/json"
+ "github.com/example/one-template-go-api/internal/config"
+ "go.uber.org/zap"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestStarterRoutes(t *testing.T) {
+ router := NewRouter(config.Config{AppName: "starter", AppEnv: "production", AllowedOrigins: []string{"http://localhost:5173"}}, zap.NewNop())
+ for _, tt := range []struct {
+ method string
+ path string
+ status int
+ }{{http.MethodGet, "/", 200}, {http.MethodGet, "/health", 200}, {http.MethodPost, "/auth/login", 404}, {http.MethodGet, "/api/users", 404}} {
+ t.Run(tt.path, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, httptest.NewRequest(tt.method, tt.path, nil))
+ if recorder.Code != tt.status {
+ t.Fatalf("status = %d, want %d", recorder.Code, tt.status)
+ }
+ if tt.path == "/health" {
+ var body map[string]any
+ if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ if body["status"] != "ok" {
+ t.Fatalf("health = %v", body)
+ }
+ }
+ })
+ }
+ for _, tt := range []struct{ origin, allowed string }{{"http://localhost:5173", "http://localhost:5173"}, {"https://localhost.example.com", ""}} {
+ t.Run(tt.origin, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodOptions, "/health", nil)
+ request.Header.Set("Origin", tt.origin)
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+ if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != tt.allowed {
+ t.Fatalf("origin = %q, want %q", got, tt.allowed)
+ }
+ })
+ }
+}
diff --git a/packages/templates/go-api/internal/platform/database/database.go b/packages/templates/go-api/internal/platform/database/database.go
new file mode 100644
index 00000000..0e8a4f6d
--- /dev/null
+++ b/packages/templates/go-api/internal/platform/database/database.go
@@ -0,0 +1,16 @@
+// Package database provides an explicit Gorm connection entry point.
+// Install a driver and supply its dialector when adding persistence.
+// The HTTP starter does not call Open or run migrations.
+package database
+
+import (
+ "errors"
+ "gorm.io/gorm"
+)
+
+func Open(dialector gorm.Dialector, options ...gorm.Option) (*gorm.DB, error) {
+ if dialector == nil {
+ return nil, errors.New("database dialector is required")
+ }
+ return gorm.Open(dialector, options...)
+}
diff --git a/packages/templates/go-api/internal/platform/jwt/jwt.go b/packages/templates/go-api/internal/platform/jwt/jwt.go
deleted file mode 100644
index 625f0212..00000000
--- a/packages/templates/go-api/internal/platform/jwt/jwt.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package jwt
-
-import (
- "errors"
- "time"
-
- "github.com/golang-jwt/jwt/v5"
-)
-
-type Manager struct {
- secret []byte
- expiresIn time.Duration
-}
-
-type Claims struct {
- UserID string `json:"user_id"`
- jwt.RegisteredClaims
-}
-
-func NewManager(secret string, expiresIn time.Duration) *Manager {
- return &Manager{
- secret: []byte(secret),
- expiresIn: expiresIn,
- }
-}
-
-func (m *Manager) Sign(userID string) (string, error) {
- now := time.Now()
- claims := Claims{
- UserID: userID,
- RegisteredClaims: jwt.RegisteredClaims{
- Subject: userID,
- IssuedAt: jwt.NewNumericDate(now),
- ExpiresAt: jwt.NewNumericDate(now.Add(m.expiresIn)),
- },
- }
-
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- return token.SignedString(m.secret)
-}
-
-func (m *Manager) Verify(tokenValue string) (*Claims, error) {
- claims := &Claims{}
- token, err := jwt.ParseWithClaims(tokenValue, claims, func(token *jwt.Token) (any, error) {
- if token.Method != jwt.SigningMethodHS256 {
- return nil, errors.New("unexpected signing method")
- }
- return m.secret, nil
- })
- if err != nil {
- return nil, err
- }
- if !token.Valid {
- return nil, errors.New("invalid token")
- }
- return claims, nil
-}
diff --git a/packages/templates/go-api/internal/platform/postgres/postgres.go b/packages/templates/go-api/internal/platform/postgres/postgres.go
deleted file mode 100644
index c3bace4f..00000000
--- a/packages/templates/go-api/internal/platform/postgres/postgres.go
+++ /dev/null
@@ -1,78 +0,0 @@
-// Package postgres opens the application's Gorm DB handle. The package
-// name is historical — when DATABASE_URL is empty it actually returns
-// an in-memory SQLite (pure-Go via modernc) so the first `one dev`
-// boots successfully without a Postgres server. Set DATABASE_URL to
-// switch to the real driver.
-package postgres
-
-import (
- "strings"
- "time"
-
- "github.com/glebarez/sqlite"
- "go.uber.org/zap"
- "gorm.io/driver/postgres"
- "gorm.io/gorm"
- gormlogger "gorm.io/gorm/logger"
-
- "github.com/example/one-template-go-api/internal/domain"
-)
-
-// Open returns a configured *gorm.DB. When databaseURL is empty, falls
-// back to in-memory SQLite (no daemon, no Docker, no extra install).
-// Gorm's dialect abstraction handles the schema for both drivers; the
-// same AutoMigrate produces compatible tables on either side. The
-// returned handle is never nil, so downstream callers (repositories,
-// handlers) work uniformly without nil-checks.
-func Open(databaseURL string, log *zap.Logger) (*gorm.DB, error) {
- if strings.TrimSpace(databaseURL) == "" {
- return openSQLite(log)
- }
- return openPostgres(databaseURL, log)
-}
-
-func openPostgres(databaseURL string, log *zap.Logger) (*gorm.DB, error) {
- db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
- Logger: gormlogger.Default.LogMode(gormlogger.Warn),
- })
- if err != nil {
- return nil, err
- }
-
- sqlDB, err := db.DB()
- if err != nil {
- return nil, err
- }
- sqlDB.SetMaxOpenConns(20)
- sqlDB.SetMaxIdleConns(10)
- sqlDB.SetConnMaxLifetime(time.Hour)
-
- if err := db.AutoMigrate(&domain.User{}); err != nil {
- return nil, err
- }
-
- log.Info("database connected", zap.String("driver", "postgres"))
- return db, nil
-}
-
-func openSQLite(log *zap.Logger) (*gorm.DB, error) {
- log.Warn(
- "DATABASE_URL 未设置,使用内存 SQLite 作为开发兜底。" +
- "数据不持久化(每次重启都清空);需要持久化时 " +
- "`one env set DATABASE_URL=postgres://... -p `",
- )
-
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
- Logger: gormlogger.Default.LogMode(gormlogger.Warn),
- })
- if err != nil {
- return nil, err
- }
-
- if err := db.AutoMigrate(&domain.User{}); err != nil {
- return nil, err
- }
-
- log.Info("database connected", zap.String("driver", "sqlite-memory"))
- return db, nil
-}
diff --git a/packages/templates/go-api/internal/repository/user_repository.go b/packages/templates/go-api/internal/repository/user_repository.go
deleted file mode 100644
index 1878d424..00000000
--- a/packages/templates/go-api/internal/repository/user_repository.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package repository
-
-import (
- "context"
- "errors"
-
- "gorm.io/gorm"
-
- "github.com/example/one-template-go-api/internal/domain"
-)
-
-var ErrNotFound = errors.New("resource not found")
-
-type UserRepository struct {
- db *gorm.DB
-}
-
-func NewUserRepository(db *gorm.DB) *UserRepository {
- return &UserRepository{db: db}
-}
-
-func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
- return r.db.WithContext(ctx).Create(user).Error
-}
-
-func (r *UserRepository) List(ctx context.Context, limit int, offset int) ([]domain.User, error) {
- users := []domain.User{}
- err := r.db.WithContext(ctx).
- Order("created_at desc").
- Limit(limit).
- Offset(offset).
- Find(&users).
- Error
- return users, err
-}
-
-func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
- var user domain.User
- err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return nil, ErrNotFound
- }
- if err != nil {
- return nil, err
- }
- return &user, nil
-}
-
-func (r *UserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
- var user domain.User
- err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return nil, ErrNotFound
- }
- if err != nil {
- return nil, err
- }
- return &user, nil
-}
diff --git a/packages/templates/go-api/internal/service/auth_service.go b/packages/templates/go-api/internal/service/auth_service.go
deleted file mode 100644
index b5656d8a..00000000
--- a/packages/templates/go-api/internal/service/auth_service.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "strings"
-
- "golang.org/x/crypto/bcrypt"
-
- "github.com/example/one-template-go-api/internal/domain"
- "github.com/example/one-template-go-api/internal/platform/jwt"
- "github.com/example/one-template-go-api/internal/repository"
-)
-
-type TokenSigner interface {
- Sign(userID string) (string, error)
-}
-
-type AuthService struct {
- users UserStore
- jwt TokenSigner
-}
-
-type LoginInput struct {
- Email string
- Password string
-}
-
-type AuthToken struct {
- AccessToken string `json:"access_token"`
- TokenType string `json:"token_type"`
- User *domain.User `json:"user"`
-}
-
-func NewAuthService(users UserStore, manager *jwt.Manager) *AuthService {
- return &AuthService{users: users, jwt: manager}
-}
-
-func (s *AuthService) Login(ctx context.Context, input LoginInput) (*AuthToken, error) {
- email := strings.TrimSpace(strings.ToLower(input.Email))
- if email == "" || input.Password == "" {
- return nil, ErrInvalidCredentials
- }
-
- user, err := s.users.FindByEmail(ctx, email)
- if errors.Is(err, repository.ErrNotFound) {
- return nil, ErrInvalidCredentials
- }
- if err != nil {
- return nil, err
- }
- if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(input.Password)); err != nil {
- return nil, ErrInvalidCredentials
- }
-
- token, err := s.jwt.Sign(user.ID)
- if err != nil {
- return nil, err
- }
-
- return &AuthToken{
- AccessToken: token,
- TokenType: "Bearer",
- User: user,
- }, nil
-}
diff --git a/packages/templates/go-api/internal/service/user_service.go b/packages/templates/go-api/internal/service/user_service.go
deleted file mode 100644
index e7b73502..00000000
--- a/packages/templates/go-api/internal/service/user_service.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "strings"
-
- "github.com/google/uuid"
- "golang.org/x/crypto/bcrypt"
-
- "github.com/example/one-template-go-api/internal/domain"
- "github.com/example/one-template-go-api/internal/repository"
-)
-
-var (
- ErrInvalidInput = errors.New("invalid input")
- ErrInvalidCredentials = errors.New("invalid credentials")
- ErrEmailAlreadyInUse = errors.New("email already in use")
-)
-
-type UserStore interface {
- Create(ctx context.Context, user *domain.User) error
- List(ctx context.Context, limit int, offset int) ([]domain.User, error)
- FindByEmail(ctx context.Context, email string) (*domain.User, error)
- FindByID(ctx context.Context, id string) (*domain.User, error)
-}
-
-type UserService struct {
- users UserStore
-}
-
-type CreateUserInput struct {
- Email string
- Name string
- Password string
-}
-
-func NewUserService(users UserStore) *UserService {
- return &UserService{users: users}
-}
-
-func (s *UserService) Create(ctx context.Context, input CreateUserInput) (*domain.User, error) {
- email := strings.TrimSpace(strings.ToLower(input.Email))
- name := strings.TrimSpace(input.Name)
- if email == "" || name == "" || len(input.Password) < 8 {
- return nil, ErrInvalidInput
- }
-
- _, err := s.users.FindByEmail(ctx, email)
- if err == nil {
- return nil, ErrEmailAlreadyInUse
- }
- if !errors.Is(err, repository.ErrNotFound) {
- return nil, err
- }
-
- hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
- if err != nil {
- return nil, err
- }
-
- user := &domain.User{
- ID: uuid.NewString(),
- Email: email,
- Name: name,
- PasswordHash: string(hash),
- }
- if err := s.users.Create(ctx, user); err != nil {
- return nil, err
- }
- return user, nil
-}
-
-func (s *UserService) List(ctx context.Context, limit int, offset int) ([]domain.User, error) {
- if limit <= 0 || limit > 100 {
- limit = 20
- }
- if offset < 0 {
- offset = 0
- }
- return s.users.List(ctx, limit, offset)
-}
diff --git a/packages/templates/go-api/migrations/001_create_users.sql b/packages/templates/go-api/migrations/001_create_users.sql
deleted file mode 100644
index cb05b487..00000000
--- a/packages/templates/go-api/migrations/001_create_users.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-CREATE TABLE IF NOT EXISTS users (
- id uuid PRIMARY KEY,
- email varchar(255) NOT NULL UNIQUE,
- name varchar(120) NOT NULL,
- password_hash varchar(255) NOT NULL,
- created_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now()
-);
diff --git a/packages/templates/go-api/template.json b/packages/templates/go-api/template.json
index 6dee9d4e..e307a22a 100644
--- a/packages/templates/go-api/template.json
+++ b/packages/templates/go-api/template.json
@@ -5,9 +5,7 @@
},
"text": [
{
- "files": [
- "README.md"
- ],
+ "files": ["README.md"],
"from": "one-template-go-api",
"value": "projectNameKebabCase"
}
diff --git a/packages/templates/go-lib/template.json b/packages/templates/go-lib/template.json
index 62143f5a..14ccd9dc 100644
--- a/packages/templates/go-lib/template.json
+++ b/packages/templates/go-lib/template.json
@@ -5,16 +5,12 @@
},
"text": [
{
- "files": [
- "README.md"
- ],
+ "files": ["README.md"],
"from": "one-template-go-lib",
"value": "projectNameKebabCase"
},
{
- "files": [
- "pkg/greeter/greeter.go"
- ],
+ "files": ["pkg/greeter/greeter.go"],
"from": "one-template-go-lib",
"value": "projectNameKebabCase"
}
diff --git a/packages/templates/nestjs-api/.env.example b/packages/templates/nestjs-api/.env.example
index ca8305b5..c6bd2fe9 100644
--- a/packages/templates/nestjs-api/.env.example
+++ b/packages/templates/nestjs-api/.env.example
@@ -1,12 +1,3 @@
-# Server
+# Inject with One CLI / 通过 One CLI 注入
PORT=3000
-
-# Database (可选 — 留空时启动跳过 DB 初始化,业务调用 DB 才报错)
-# DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/demo
-
-# JWT (开发可选 — 不设会用一次性随机 secret;生产环境必须设置)
-# JWT_SECRET=your-jwt-secret-here
-JWT_EXPIRES_IN=30d
-
-# CORS
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
diff --git a/packages/templates/nestjs-api/CHANGELOG.md b/packages/templates/nestjs-api/CHANGELOG.md
deleted file mode 100644
index 73e33593..00000000
--- a/packages/templates/nestjs-api/CHANGELOG.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# frontend-ssr-webapp-template
-
-## 0.1.0
-
-### Patch Changes
-
-- 26fa9a3: cicd 优化
-- 6ca55d3: artifact 支持, actions cache 支持, dockerfile 多阶段构建,dev container
-- 6ca55d3: add git-cz
-- Manual release via GitHub Actions - patch version bump
-
-## 0.1.6
-
-### Patch Changes
-
-- Manual release via GitHub Actions - patch version bump
-
-## 0.1.5
-
-### Patch Changes
-
-- Manual release via GitHub Actions - patch version bump
-
-## 0.1.4
-
-### Patch Changes
-
-- [#13](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/pull/13) [`8df38d8`](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/commit/8df38d892a06f26ea4ca74715c738fbc7302bcde) Thanks [@y2hlbg](https://github.com/y2hlbg)! - 修改 release.yml
-
-## 0.1.3
-
-### Patch Changes
-
-- [#10](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/pull/10) [`6d9e8da`](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/commit/6d9e8dacfeae36de69804ff81591d32fbc049f59) Thanks [@y2hlbg](https://github.com/y2hlbg)! - 优化.env.example
-
-- [#10](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/pull/10) [`6d9e8da`](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/commit/6d9e8dacfeae36de69804ff81591d32fbc049f59) Thanks [@y2hlbg](https://github.com/y2hlbg)! - add monitoring setup
-
-- [#11](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/pull/11) [`d60ef8b`](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/commit/d60ef8b811aacdd33ed8e01dc86741c8866ca9b1) Thanks [@y2hlbg](https://github.com/y2hlbg)! - cicd 部署打 tag 脚本交友
-
-- [#10](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/pull/10) [`6d9e8da`](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/commit/6d9e8dacfeae36de69804ff81591d32fbc049f59) Thanks [@y2hlbg](https://github.com/y2hlbg)! - add md to docs
-
-## 0.1.2
-
-### Patch Changes
-
-- [#6](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/pull/6) [`69b20e2`](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/commit/69b20e2f318fb031319364d692edc4e7f2788ee5) Thanks [@y2hlbg](https://github.com/y2hlbg)! - ci/cd 流程完善,changeset 检查,commitlint 完善
-
-- [#7](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/pull/7) [`c4389c2`](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/commit/c4389c2452c64e948ed3f1ef99c36e2598a1dfbd) Thanks [@y2hlbg](https://github.com/y2hlbg)! - 修改 package.json 中的 release:commit 命令,不验证
-
-## 0.1.1
-
-### Patch Changes
-
-- [`cafc5c8`](https://github.com/DreamRoom2025/frontend-ssr-webapp-template/commit/cafc5c8700e7f144b6ded7f70e845ed8310901f8) Thanks [@y2hlbg](https://github.com/y2hlbg)! - Add Changeset support with Husky and Biome integration
- - Added @changesets/cli and @changesets/changelog-github
- - Configured changeset with GitHub changelog support
- - Updated Husky pre-commit hook to check for changesets
- - Added GitHub Actions workflow for automated releases
- - Added comprehensive documentation
diff --git a/packages/templates/nestjs-api/README.md b/packages/templates/nestjs-api/README.md
index 3da78570..f7c1c780 100644
--- a/packages/templates/nestjs-api/README.md
+++ b/packages/templates/nestjs-api/README.md
@@ -1,451 +1,23 @@
-# Nest Server 模板项目
+# NestJS API
-项目主要包含一个 Nest 工程模板,并包含一些常用的模块和工具函数。
+NestJS · TypeScript · Drizzle ORM/Kit · class-validator · Swagger · Pino · Jest.
-## 架构总览
+## 中文
-```mermaid
-graph TB
- subgraph Client["客户端"]
- Browser["Browser / API Client"]
- end
+初始项目只提供 `GET /`、`GET /health` 和 `/api/docs`,无需数据库或认证配置即可运行。
- subgraph Docker["Docker 部署层"]
- subgraph NestApp["NestJS Application"]
+- `pnpm start:dev`:开发;`pnpm build` / `pnpm start:prod`:构建与启动。
+- `pnpm check`、`pnpm test`、`pnpm test:e2e`:检查与测试。
+- 使用 One CLI 注入 `PORT`、`ALLOWED_ORIGINS`,参见 `.env.example`。
+- 已保留 Drizzle。需要数据持久化时,先选择并安装驱动,再编写 schema、连接和 Drizzle Kit 配置。迁移由独立任务显式执行。
+- 业务功能与认证通过手动安装业务 skills 或项目代码添加。
- subgraph Entry["入口层"]
- Main["main.ts 应用引导"]
- Swagger["Swagger UI /api/docs"]
- end
+## English
- subgraph Middleware["全局中间件 & 管道"]
- VP["ValidationPipe 参数校验"]
- AuthGuard["AuthGuard JWT 认证守卫"]
- RI["ResponseInterceptor 响应格式化"]
- MI["MetricsInterceptor 指标采集"]
- LI["LoggingInterceptor 请求日志"]
- EF["HttpExceptionFilter 异常过滤器"]
- end
+The starter exposes only `GET /`, `GET /health`, and `/api/docs`. It starts without database or authentication settings.
- subgraph Modules["业务模块层"]
- AuthMod["Auth Module 登录 / Token 刷新"]
- UserMod["User Module 用户 CRUD"]
- CommonMod["Common Module 公共接口"]
- HealthMod["Health Module 健康检查"]
- MetricsMod["Metrics Module Prometheus 指标"]
- end
-
- subgraph DAO["数据访问层"]
- Repo["UserRepository 数据仓储"]
- Entity["User Entity 领域实体"]
- end
-
- subgraph Infra["基础设施层"]
- Drizzle["DrizzleService ORM 服务"]
- Config["ConfigModule 配置管理"]
- Logger["Pino Logger 日志系统"]
- JWT["JwtModule JWT 签发/验证"]
- end
-
- end
- end
-
- subgraph External["外部服务"]
- PG[("PostgreSQL 15 数据库")]
- Prom["Prometheus 监控采集"]
- end
-
- Browser -->|"HTTP Request"| Main
- Main --> Swagger
-
- Main --> VP --> AuthGuard --> RI --> MI --> LI --> EF
-
- EF --> AuthMod
- EF --> UserMod
- EF --> CommonMod
- EF --> HealthMod
- EF --> MetricsMod
-
- AuthMod --> JWT
- UserMod --> Repo
- Repo --> Entity
- Repo --> Drizzle
-
- HealthMod --> Drizzle
- MetricsMod --> Prom
-
- Drizzle --> PG
- Config -.->|"环境变量"| Drizzle
- Config -.->|"JWT_SECRET"| JWT
- Logger -.->|"日志文件"| LogFiles["logs/ info / warn / error / debug"]
-```
-
-## 请求处理流程
-
-```mermaid
-sequenceDiagram
- participant C as Client
- participant VP as ValidationPipe
- participant AG as AuthGuard
- participant RI as ResponseInterceptor
- participant MI as MetricsInterceptor
- participant LI as LoggingInterceptor
- participant Ctrl as Controller
- participant Svc as Service
- participant Repo as Repository
- participant DB as PostgreSQL
-
- C->>VP: HTTP Request
- VP->>VP: 参数校验 (class-validator)
- VP->>AG: 校验通过
- AG->>AG: 检查 @Public() 装饰器
- AG->>AG: 验证 JWT Token
- AG->>RI: 认证通过
- RI->>MI: 进入拦截器链
- MI->>LI: 记录开始时间
- LI->>Ctrl: 记录请求日志
- Ctrl->>Svc: 调用业务逻辑
- Svc->>Repo: 数据操作
- Repo->>DB: SQL 查询
- DB-->>Repo: 查询结果
- Repo-->>Svc: 返回数据
- Svc-->>Ctrl: 返回结果
- Ctrl-->>LI: 原始响应
- LI-->>MI: 记录响应日志
- MI-->>RI: 记录请求耗时
- RI-->>C: { code: 0, message: "success", data: ... }
-```
-
-## 模块依赖关系
-
-```mermaid
-graph LR
- App["AppModule"] --> Config["ConfigModule (Global)"]
- App --> Drizzle["DrizzleModule (Global)"]
- App --> JWT["JwtModule (Global)"]
- App --> Prom["PrometheusModule"]
- App --> Auth["AuthModule"]
- App --> User["UserModule"]
- App --> Common["CommonModule"]
- App --> Health["HealthModule"]
- App --> Metrics["MetricsModule"]
-
- Auth -->|"APP_GUARD"| AuthGuard["AuthGuard"]
- Auth --> JWT
- User --> UserRepo["UserRepository"]
- UserRepo --> Drizzle
- Health --> Drizzle
- Metrics --> Prom
-
- style App fill:#e1f5fe
- style Config fill:#fff9c4
- style Drizzle fill:#fff9c4
- style JWT fill:#fff9c4
-```
-
-## 项目结构
-
-```bash
-nest-template/
-├── Dockerfile # 多阶段构建,PM2 生产运行
-├── docker-compose.yml # 本地编排 (app + postgres)
-├── .env.example # 变量名参考;运行时由 Infisical 注入 / Variable reference; injected from Infisical at runtime
-├── drizzle.config.ts # Drizzle Kit 迁移配置
-├── drizzle/ # 数据库迁移文件 & seed
-├── package.json # 项目依赖与脚本
-├── tsconfig.json # TypeScript 配置
-├── nest-cli.json # NestJS CLI 配置
-└── src/
- ├── main.ts # 应用入口:全局管道、守卫、拦截器、过滤器
- ├── app.module.ts # 根模块:整合各个功能模块
- ├── app.controller.ts # 根路由:API 信息
- │
- ├── core/ # 核心层:框架级别的公共组件
- │ ├── config/
- │ │ └── configuration.ts # 配置对象 (jwt / server / database / app)
- │ ├── constants/
- │ │ └── error-code.ts # 业务错误码枚举
- │ ├── decorators/
- │ │ ├── public.decorators.ts # @Public() 公共路由装饰器
- │ │ └── current-user.decorator.ts # @CurrentUser() 用户提取装饰器
- │ ├── exceptions/
- │ │ └── business.exception.ts # 自定义业务异常
- │ ├── filters/
- │ │ └── http-exception.filter.ts # 全局异常过滤器
- │ └── interceptors/
- │ ├── response.interceptor.ts # 响应格式统一 {code, message, data}
- │ ├── metrics.interceptor.ts # Prometheus 指标采集
- │ └── logging.interceptor.ts # 请求/响应日志 (敏感字段脱敏)
- │
- ├── dao/ # 数据访问层
- │ ├── entities/
- │ │ └── user.entity.ts # 用户领域实体
- │ └── repositories/
- │ └── user.repository.ts # 用户数据仓储
- │
- ├── modules/ # 业务模块层
- │ ├── auth/ # 认证模块
- │ │ ├── auth.module.ts
- │ │ ├── auth.controller.ts # POST /login, /refresh
- │ │ ├── auth.service.ts # Token 签发/验证
- │ │ ├── auth.guard.ts # JWT 全局守卫
- │ │ └── dto/
- │ ├── user/ # 用户模块
- │ │ ├── user.module.ts
- │ │ ├── user.controller.ts # GET /users, POST /users
- │ │ ├── user.service.ts
- │ │ └── dto/
- │ ├── common/ # 公共接口模块
- │ │ ├── common.module.ts
- │ │ ├── common.controller.ts
- │ │ └── common.service.ts
- │ ├── health/ # 健康检查模块
- │ │ ├── health.module.ts
- │ │ ├── health.controller.ts # GET /health
- │ │ └── health.service.ts
- │ └── metrics/ # 指标模块
- │ └── metrics.module.ts # Prometheus 注册
- │
- ├── service/ # 基础设施层
- │ └── drizzle/
- │ ├── drizzle.module.ts # 全局数据库模块
- │ ├── drizzle.service.ts # 数据库连接 & 健康检查
- │ └── schema.ts # Drizzle 表定义
- │
- └── shared/ # 共享工具层
- ├── dto/
- │ └── pagination.dto.ts # 分页 DTO
- └── utils/
- ├── index.ts
- └── logger.ts # Pino 多目标日志 (按级别/日期切分)
-```
-
-## 技术栈
-
-| 类别 | 技术 | 说明 |
-| -------- | ----------------- | --------------------------------- |
-| 框架 | NestJS 12 | Node.js 企业级框架 |
-| 语言 | TypeScript 6.0 | 类型安全 |
-| 数据库 | PostgreSQL 15 | 关系型数据库 |
-| ORM | Drizzle ORM | 类型安全的 SQL 查询构建器 |
-| 认证 | JWT | 无状态令牌认证 |
-| 文档 | Swagger / OpenAPI | 自动生成 API 文档 |
-| 监控 | Prometheus | 指标采集 (请求计数、耗时) |
-| 日志 | Pino | 高性能 JSON 日志,按级别/日期切分 |
-| 校验 | class-validator | DTO 参数校验 |
-| 部署 | Docker + PM2 | 多阶段构建,进程管理 |
-| 代码质量 | oxlint + oxfmt | 快速 lint 和格式化 |
-| 版本管理 | pnpm + Git | 依赖锁定与源码版本控制 |
-
-## 项目启动
-
-```bash
-# 启动 PostgreSQL
-docker compose up -d postgres
-
-# 安装依赖
-pnpm install
-
-# 开发环境同步数据库
-pnpm db:push
-
-# 初始化数据库迁移
-pnpm db:generate
-
-# 执行迁移
-pnpm db:migrate
-
-# 初始化默认数据
-pnpm db:seed
-
-# 开发环境启动 (带热更新)
-pnpm run start:dev
-
-# 生产环境启动
-pnpm run start
-```
-
-## 环境变量
-
-```bash
-PORT=3000
-DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/demo
-JWT_SECRET=your-jwt-secret-here
-JWT_EXPIRES_IN=30d
-ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
-```
-
-## API 接口
-
-| 方法 | 路径 | 说明 | 认证 |
-| ------ | ----------------------- | --------------- | -------- |
-| `GET` | `/` | API 信息 | 公开 |
-| `GET` | `/api/docs` | Swagger 文档 | 公开 |
-| `POST` | `/api/v1/auth/login` | 登录获取 Token | 公开 |
-| `POST` | `/api/v1/auth/refresh` | 刷新 Token | 公开 |
-| `GET` | `/api/v1/users` | 用户列表 (分页) | 需要认证 |
-| `POST` | `/api/v1/users` | 创建用户 | 需要认证 |
-| `POST` | `/api/v1/common` | 受保护接口示例 | 需要认证 |
-| `POST` | `/api/v1/common/public` | 公共接口示例 | 公开 |
-| `GET` | `/health` | 健康检查 | 公开 |
-| `GET` | `/metrics` | Prometheus 指标 | 公开 |
-
-## 示例接口
-
-### 登录
-
-```bash
-curl -X POST http://localhost:3001/api/v1/auth/login \
- -H "Content-Type: application/json" \
- -d '{"code": "123456"}'
-```
-
-```json
-{
- "code": 0,
- "message": "success",
- "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
-}
-```
-
-### 获取用户列表
-
-```bash
-curl -X GET "http://localhost:3001/api/v1/users?page=1&pageSize=20" \
- -H "Authorization: Bearer "
-```
-
-### 健康检查
-
-```bash
-curl -X GET http://localhost:3001/health
-```
-
-```json
-{
- "status": "ok",
- "timestamp": "2026-02-08T12:00:00.000Z",
- "uptime": "1234s",
- "environment": "production",
- "version": "0.1.0",
- "memory": { "used": "123MB", "total": "256MB" },
- "database": { "status": "connected" }
-}
-```
-
-## 集成说明
-
-### 认证
-
-默认集成 JWT 认证,全局启用 `AuthGuard`。使用 `@Public()` 装饰器标记公共接口,使用 `@CurrentUser()` 获取当前用户。
-
-```ts
-import { Public } from '@/core/decorators/public.decorators';
-import { CurrentUser } from '@/core/decorators/current-user.decorator';
-
-@Public()
-@Get('open')
-getPublicData() { ... }
-
-@Get('profile')
-getProfile(@CurrentUser() user: JwtPayload) { ... }
-```
-
-### 数据库
-
-使用 Drizzle ORM 进行类型安全的数据库操作。
-默认物理表名采用复数命名,例如 `users`。
-
-```ts
-import { DrizzleService } from "@/service/drizzle/drizzle.service";
-import { users } from "@/service/drizzle/schema";
-
-@Injectable()
-export class UserRepository {
- constructor(private readonly drizzle: DrizzleService) {}
-
- async findAll() {
- return this.drizzle.db.select().from(users);
- }
-}
-```
-
-### 响应格式
-
-所有接口统一返回格式(`/metrics` 和 `/health` 除外):
-
-```json
-{ "code": 0, "message": "success", "data": "..." }
-```
-
-异常响应:
-
-```json
-{
- "code": 10001,
- "message": "错误描述",
- "data": null,
- "timestamp": "...",
- "path": "/api/v1/..."
-}
-```
-
-### 业务异常
-
-```ts
-import { BusinessException } from "@/core/exceptions";
-import { ErrorCode } from "@/core/constants";
-
-throw new BusinessException(ErrorCode.USER_NOT_FOUND);
-```
-
-### 日志
-
-使用 Pino 进行日志管理,按级别和日期自动切分日志文件,敏感字段自动脱敏。
-
-```ts
-import { logger } from "@/shared/utils";
-
-logger.info("这是一条普通日志");
-logger.error({ err }, "发生错误");
-```
-
-### 数据库命令
-
-```bash
-pnpm db:generate # 生成迁移文件
-pnpm db:migrate # 执行迁移
-pnpm db:push # 推送 schema (开发环境)
-pnpm db:studio # 打开 Drizzle Studio
-pnpm db:seed # 初始化数据
-```
-
-## 测试
-
-```bash
-pnpm test # 运行所有测试
-pnpm test:cov # 带覆盖率
-pnpm test:watch # 监听模式
-pnpm test:e2e # 端到端测试
-```
-
-## Docker 部署
-
-```bash
-# 构建镜像
-pnpm docker:build
-
-# 启动全部服务 (app + postgres)
-docker compose up -d
-
-# 仅启动应用
-docker compose up -d app
-```
-
-### TypeScript 兼容性
-
-模板使用 TypeScript 6.0、Nest 12、CLI 12 和 Schematics 12。
-TypeScript 配置采用 NodeNext 模块解析和相对路径映射;构建输出仍为 `dist/main.js`。
-运行 `pnpm typecheck` 检查应用、测试和 Drizzle 脚本的类型。
-测试脚本启用 Jest 的 VM Modules 支持,用于加载 Nest 12 的 ESM 包。
-当前 ts-jest 依赖 TypeScript 6 提供的 JavaScript 编译器 API,因此本模板保留在 TypeScript 6。
+- `pnpm start:dev`: development; `pnpm build` / `pnpm start:prod`: build and run.
+- `pnpm check`, `pnpm test`, `pnpm test:e2e`: validation.
+- Inject `PORT` and `ALLOWED_ORIGINS` with One CLI; see `.env.example`.
+- Drizzle is retained. Choose and install a driver before adding your schema, connection, and Drizzle Kit configuration. Run migrations as explicit, separate tasks.
+- Add business features and authentication through manually installed business skills or application code.
diff --git a/packages/templates/nestjs-api/drizzle.config.ts b/packages/templates/nestjs-api/drizzle.config.ts
deleted file mode 100644
index 84082e64..00000000
--- a/packages/templates/nestjs-api/drizzle.config.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { defineConfig } from "drizzle-kit";
-
-export default defineConfig({
- dialect: "postgresql",
- schema: "./src/service/drizzle/schema.ts",
- out: "./drizzle/migrations",
- dbCredentials: {
- url: process.env.DATABASE_URL!,
- },
- verbose: true,
- strict: true,
-});
diff --git a/packages/templates/nestjs-api/drizzle/seed.ts b/packages/templates/nestjs-api/drizzle/seed.ts
deleted file mode 100644
index be6542d5..00000000
--- a/packages/templates/nestjs-api/drizzle/seed.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-///
-import { drizzle } from "drizzle-orm/node-postgres";
-import { Pool } from "pg";
-import { users } from "../src/service/drizzle/schema";
-
-async function main() {
- const pool = new Pool({
- connectionString: process.env.DATABASE_URL,
- });
-
- const db = drizzle(pool);
-
- // 创建默认账户
- const [defaultUser] = await db
- .insert(users)
- .values({
- name: "default-user",
- })
- .returning();
-
- console.log("Default user created:", defaultUser);
-
- await pool.end();
-}
-
-main().catch((e) => {
- console.error(e);
- process.exit(1);
-});
diff --git a/packages/templates/nestjs-api/images/architecture.png b/packages/templates/nestjs-api/images/architecture.png
deleted file mode 100644
index 26bd090e..00000000
Binary files a/packages/templates/nestjs-api/images/architecture.png and /dev/null differ
diff --git a/packages/templates/nestjs-api/package.json b/packages/templates/nestjs-api/package.json
index 529f4bd3..5b61327a 100644
--- a/packages/templates/nestjs-api/package.json
+++ b/packages/templates/nestjs-api/package.json
@@ -19,34 +19,21 @@
"test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config ./test/jest-e2e.json",
"lint": "oxlint .",
"format": "oxfmt --check .",
- "db:generate": "drizzle-kit generate",
- "db:migrate": "drizzle-kit migrate",
- "db:push": "drizzle-kit push",
- "db:studio": "drizzle-kit studio",
- "db:seed": "ts-node drizzle/seed.ts",
"lint:fix": "oxlint --fix .",
"format:fix": "oxfmt --write .",
"check": "pnpm run typecheck && pnpm run lint && pnpm run format",
"check:fix": "pnpm run lint:fix && pnpm run format:fix"
},
"dependencies": {
- "@electric-sql/pglite": "^0.5.8",
"@nestjs/common": "^12.1.1",
"@nestjs/config": "^12.0.1",
"@nestjs/core": "^12.1.1",
- "@nestjs/jwt": "^12.0.2",
"@nestjs/platform-express": "^12.1.1",
"@nestjs/swagger": "^12.0.2",
- "@willsoto/nestjs-prometheus": "^6.1.1",
- "axios": "^1.20.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"drizzle-orm": "^0.45.3",
- "pg": "^8.23.0",
"pino": "^10.3.1",
- "pino-pretty": "^13.1.3",
- "pino-roll": "^4.0.0",
- "prom-client": "^15.1.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"swagger-ui-express": "^5.0.1"
@@ -60,9 +47,7 @@
"@types/node": "^24.19.0",
"@types/supertest": "^7.2.1",
"drizzle-kit": "^0.31.11",
- "git-cz": "^4.9.0",
"jest": "^30.5.2",
- "lint-staged": "^17.6.0",
"oxfmt": "^0.71.0",
"oxlint": "^1.86.0",
"source-map-support": "^0.5.21",
@@ -73,15 +58,6 @@
"tsconfig-paths": "^4.2.0",
"typescript": "^6.0.3"
},
- "lint-staged": {
- "*.{js,jsx,ts,tsx,mjs,cjs}": [
- "oxlint --fix",
- "oxfmt --write --no-error-on-unmatched-pattern"
- ],
- "*.{json,md,css,scss,html,yml,yaml}": [
- "oxfmt --write --no-error-on-unmatched-pattern"
- ]
- },
"jest": {
"collectCoverageFrom": [
"**/*.(t|j)s"
diff --git a/packages/templates/nestjs-api/scripts/start.sh b/packages/templates/nestjs-api/scripts/start.sh
index e44b692e..038f0874 100644
--- a/packages/templates/nestjs-api/scripts/start.sh
+++ b/packages/templates/nestjs-api/scripts/start.sh
@@ -1,7 +1,3 @@
-#!/bin/sh
-
-# 执行 Drizzle 数据库迁移
-npx drizzle-kit migrate
-
-# 启动应用
-pm2-runtime dist/main.js
+#!/usr/bin/env sh
+set -eu
+exec node dist/main.js
diff --git a/packages/templates/nestjs-api/src/app.controller.spec.ts b/packages/templates/nestjs-api/src/app.controller.spec.ts
new file mode 100644
index 00000000..c28b0b98
--- /dev/null
+++ b/packages/templates/nestjs-api/src/app.controller.spec.ts
@@ -0,0 +1,9 @@
+import { AppController } from "./app.controller";
+
+describe("AppController", () => {
+ it("reports a live process", () => {
+ const result = new AppController().health();
+ expect(result.status).toBe("ok");
+ expect(result.uptime).toBeGreaterThanOrEqual(0);
+ });
+});
diff --git a/packages/templates/nestjs-api/src/app.controller.ts b/packages/templates/nestjs-api/src/app.controller.ts
index 98c658a8..daea655d 100644
--- a/packages/templates/nestjs-api/src/app.controller.ts
+++ b/packages/templates/nestjs-api/src/app.controller.ts
@@ -1,55 +1,18 @@
import { Controller, Get } from "@nestjs/common";
-import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
-import { Public } from "@/core/decorators/public.decorators";
+import { ApiOperation, ApiTags } from "@nestjs/swagger";
-@ApiTags("app")
+@ApiTags("Application")
@Controller()
export class AppController {
- @ApiOperation({ summary: "Get API information" })
- @ApiResponse({
- status: 200,
- description: "API information retrieved successfully",
- schema: {
- type: "object",
- properties: {
- code: { type: "number", example: 0 },
- message: { type: "string", example: "success" },
- data: {
- type: "object",
- properties: {
- name: { type: "string", example: "NestJS Template API" },
- version: { type: "string", example: "0.1.0" },
- description: {
- type: "string",
- example:
- "A NestJS REST API template with authentication and validation",
- },
- endpoints: {
- type: "object",
- properties: {
- auth: { type: "string", example: "/api/v1/auth" },
- common: { type: "string", example: "/api/v1/common" },
- health: { type: "string", example: "/api/v1" },
- },
- },
- },
- },
- },
- },
- })
- @Public()
@Get()
- getRoot() {
- return {
- name: "NestJS Template API",
- version: "0.1.0",
- description:
- "A NestJS REST API template with authentication and validation",
- endpoints: {
- auth: "/api/v1/auth",
- common: "/api/v1/common",
- health: "/api/v1",
- },
- };
+ @ApiOperation({ summary: "Application information / 服务信息" })
+ info() {
+ return { name: "nestjs-api", version: "0.1.0" };
+ }
+
+ @Get("health")
+ @ApiOperation({ summary: "Liveness check / 存活检查" })
+ health() {
+ return { status: "ok", uptime: process.uptime() };
}
}
diff --git a/packages/templates/nestjs-api/src/app.module.ts b/packages/templates/nestjs-api/src/app.module.ts
index eb3597f8..37366685 100644
--- a/packages/templates/nestjs-api/src/app.module.ts
+++ b/packages/templates/nestjs-api/src/app.module.ts
@@ -1,58 +1,9 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
-import { APP_FILTER, APP_INTERCEPTOR } from "@nestjs/core";
-import { PrometheusModule } from "@willsoto/nestjs-prometheus";
-import * as configurations from "@/core/config/configuration";
-import { HttpExceptionFilter } from "@/core/filters/http-exception.filter";
-import { LoggingInterceptor } from "@/core/interceptors/logging.interceptor";
-import { MetricsInterceptor } from "@/core/interceptors/metrics.interceptor";
-import { ResponseInterceptor } from "@/core/interceptors/response.interceptor";
-import { AuthModule } from "@/modules/auth/auth.module";
-import { CommonModule } from "@/modules/common/common.module";
-import { HealthModule } from "@/modules/health/health.module";
-import { MetricsModule } from "@/modules/metrics/metrics.module";
-import { UserModule } from "@/modules/user/user.module";
-import { DrizzleModule } from "@/service/drizzle/drizzle.module";
import { AppController } from "./app.controller";
@Module({
- imports: [
- ConfigModule.forRoot({
- isGlobal: true,
- ignoreEnvFile: true,
- load: Object.values(configurations),
- }),
- PrometheusModule.register({
- path: "/metrics",
- defaultLabels: {
- app: "nest-app",
- },
- }),
- DrizzleModule,
- HealthModule,
- MetricsModule,
- AuthModule,
- CommonModule,
- UserModule,
- ],
+ imports: [ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true })],
controllers: [AppController],
- providers: [
- {
- provide: APP_INTERCEPTOR,
- useClass: ResponseInterceptor,
- },
- {
- provide: APP_INTERCEPTOR,
- useClass: MetricsInterceptor,
- },
- {
- provide: APP_INTERCEPTOR,
- useClass: LoggingInterceptor,
- },
- {
- provide: APP_FILTER,
- useClass: HttpExceptionFilter,
- },
- ],
})
export class AppModule {}
diff --git a/packages/templates/nestjs-api/src/core/config/configuration.ts b/packages/templates/nestjs-api/src/core/config/configuration.ts
deleted file mode 100644
index dca669d8..00000000
--- a/packages/templates/nestjs-api/src/core/config/configuration.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-// 配置加载策略(One CLI 推荐):
-// 1. 用 @nestjs/config 的 registerAs 把 process.env.* 映射成嵌套
-// namespace(jwt / server / database / app),ConfigService 消费。
-// 2. 通过 `one env set =` 设置的 secret 会在 `one run` /
-// `one dev` 启动时被自动注入为环境变量,无须额外文件渲染。
-//
-// one env set DATABASE_URL=postgres://prod/db -p api-nest
-// one env set JWT_SECRET=real-secret -p api-nest
-//
-// ConfigService 里:
-// config.get('database.url') ← process.env.DATABASE_URL
-// config.get('jwt.secret') ← process.env.JWT_SECRET
-//
-// 3. 默认值在下方各 registerAs 工厂里兜底;生产环境缺关键 secret
-// 仍直接抛错(NODE_ENV=production),非生产环境则生成一次性随机
-// fallback 以便首跑即可启动。
-//
-// 因此**不要**用模板渲染 / 文件生成方式补 config,运行时 env 已经覆盖。
-import { Logger } from "@nestjs/common";
-import { registerAs } from "@nestjs/config";
-import { randomBytes } from "node:crypto";
-
-const configLogger = new Logger("Config");
-
-export const jwtConfig = registerAs("jwt", () => {
- let secret = process.env.JWT_SECRET;
- if (!secret) {
- if (process.env.NODE_ENV === "production") {
- throw new Error(
- "JWT_SECRET environment variable is required in production",
- );
- }
- secret = randomBytes(32).toString("hex");
- configLogger.warn(
- "JWT_SECRET 未设置;已生成一次性随机 secret(每次启动都会变,仅用于本地开发)。" +
- " 跑 `one env set JWT_SECRET=` 设置稳定值。",
- );
- }
- return {
- secret,
- expiresIn: process.env.JWT_EXPIRES_IN || "30d",
- };
-});
-
-export const serverConfig = registerAs("server", () => ({
- port: parseInt(process.env.PORT || "3000", 10),
-}));
-
-export const databaseConfig = registerAs("database", () => ({
- url: process.env.DATABASE_URL,
-}));
-
-export const appConfig = registerAs("app", () => ({
- responseInterceptorExcludePaths: [
- "stream",
- "yungouos/callback",
- "/metrics",
- "/health",
- ],
- authGuardExcludePaths: ["/metrics", "/health"],
-}));
diff --git a/packages/templates/nestjs-api/src/core/constants/error-code.ts b/packages/templates/nestjs-api/src/core/constants/error-code.ts
deleted file mode 100644
index d47e1754..00000000
--- a/packages/templates/nestjs-api/src/core/constants/error-code.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-export enum ErrorCode {
- SUCCESS = 0,
-
- // 通用错误 (100000-100999)
- INTERNAL_SERVER_ERROR = 100000,
- INVALID_PARAMS = 100001,
- UNAUTHORIZED = 100002,
- FORBIDDEN = 100003,
- NOT_FOUND = 100004,
- METHOD_NOT_ALLOWED = 100005,
- RATE_LIMITED = 100006,
-
- // 认证授权相关错误 (200000-200999)
- AUTH_TOKEN_MISSING = 200000,
- AUTH_TOKEN_INVALID = 200001,
- AUTH_TOKEN_EXPIRED = 200002,
- AUTH_REFRESH_TOKEN_INVALID = 200003,
- AUTH_LOGIN_FAILED = 200004,
- AUTH_PASSWORD_INCORRECT = 200005,
- AUTH_ACCOUNT_DISABLED = 200006,
- AUTH_ACCOUNT_LOCKED = 200007,
-
- // 用户相关错误 (300000-300999)
- USER_NOT_FOUND = 300000,
- USER_ALREADY_EXISTS = 300001,
- USER_EMAIL_ALREADY_EXISTS = 300002,
- USER_NAME_ALREADY_EXISTS = 300003,
- USER_PROFILE_UPDATE_FAILED = 300004,
- USER_PASSWORD_UPDATE_FAILED = 300005,
- USER_PERMISSION_DENIED = 300006,
-
- // 业务逻辑错误 (400000-499999)
- BUSINESS_RULE_VIOLATION = 400000,
- RESOURCE_CONFLICT = 400001,
- OPERATION_NOT_ALLOWED = 400002,
- QUOTA_EXCEEDED = 400003,
-
- // 数据库相关错误 (500000-500999)
- DATABASE_CONNECTION_ERROR = 500000,
- DATABASE_QUERY_FAILED = 500001,
- DATABASE_CONSTRAINT_VIOLATION = 500002,
- DATABASE_TIMEOUT = 500003,
-
- // 外部服务错误 (600000-600999)
- EXTERNAL_SERVICE_UNAVAILABLE = 600000,
- EXTERNAL_SERVICE_TIMEOUT = 600001,
- EXTERNAL_SERVICE_ERROR = 600002,
-}
-
-export const ErrorMessages: Record = {
- [ErrorCode.SUCCESS]: "操作成功",
-
- // 通用错误
- [ErrorCode.INTERNAL_SERVER_ERROR]: "服务器内部错误",
- [ErrorCode.INVALID_PARAMS]: "参数无效",
- [ErrorCode.UNAUTHORIZED]: "未授权访问",
- [ErrorCode.FORBIDDEN]: "禁止访问",
- [ErrorCode.NOT_FOUND]: "资源未找到",
- [ErrorCode.METHOD_NOT_ALLOWED]: "方法不被允许",
- [ErrorCode.RATE_LIMITED]: "请求频率限制",
-
- // 认证授权相关错误
- [ErrorCode.AUTH_TOKEN_MISSING]: "访问令牌缺失",
- [ErrorCode.AUTH_TOKEN_INVALID]: "访问令牌无效",
- [ErrorCode.AUTH_TOKEN_EXPIRED]: "访问令牌已过期",
- [ErrorCode.AUTH_REFRESH_TOKEN_INVALID]: "刷新令牌无效",
- [ErrorCode.AUTH_LOGIN_FAILED]: "登录失败",
- [ErrorCode.AUTH_PASSWORD_INCORRECT]: "密码错误",
- [ErrorCode.AUTH_ACCOUNT_DISABLED]: "账户已禁用",
- [ErrorCode.AUTH_ACCOUNT_LOCKED]: "账户已锁定",
-
- // 用户相关错误
- [ErrorCode.USER_NOT_FOUND]: "用户不存在",
- [ErrorCode.USER_ALREADY_EXISTS]: "用户已存在",
- [ErrorCode.USER_EMAIL_ALREADY_EXISTS]: "邮箱已被使用",
- [ErrorCode.USER_NAME_ALREADY_EXISTS]: "用户名已被使用",
- [ErrorCode.USER_PROFILE_UPDATE_FAILED]: "用户资料更新失败",
- [ErrorCode.USER_PASSWORD_UPDATE_FAILED]: "密码更新失败",
- [ErrorCode.USER_PERMISSION_DENIED]: "用户权限不足",
-
- // 业务逻辑错误
- [ErrorCode.BUSINESS_RULE_VIOLATION]: "业务规则违反",
- [ErrorCode.RESOURCE_CONFLICT]: "资源冲突",
- [ErrorCode.OPERATION_NOT_ALLOWED]: "操作不被允许",
- [ErrorCode.QUOTA_EXCEEDED]: "配额超限",
-
- // 数据库相关错误
- [ErrorCode.DATABASE_CONNECTION_ERROR]: "数据库连接错误",
- [ErrorCode.DATABASE_QUERY_FAILED]: "数据库查询失败",
- [ErrorCode.DATABASE_CONSTRAINT_VIOLATION]: "数据库约束违反",
- [ErrorCode.DATABASE_TIMEOUT]: "数据库操作超时",
-
- // 外部服务错误
- [ErrorCode.EXTERNAL_SERVICE_UNAVAILABLE]: "外部服务不可用",
- [ErrorCode.EXTERNAL_SERVICE_TIMEOUT]: "外部服务超时",
- [ErrorCode.EXTERNAL_SERVICE_ERROR]: "外部服务错误",
-};
-
-export function getErrorMessage(code: ErrorCode): string {
- return ErrorMessages[code] || ErrorMessages[ErrorCode.INTERNAL_SERVER_ERROR];
-}
diff --git a/packages/templates/nestjs-api/src/core/constants/index.ts b/packages/templates/nestjs-api/src/core/constants/index.ts
deleted file mode 100644
index c0247c82..00000000
--- a/packages/templates/nestjs-api/src/core/constants/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { ErrorCode, ErrorMessages, getErrorMessage } from "./error-code";
diff --git a/packages/templates/nestjs-api/src/core/decorators/current-user.decorator.ts b/packages/templates/nestjs-api/src/core/decorators/current-user.decorator.ts
deleted file mode 100644
index 0266a412..00000000
--- a/packages/templates/nestjs-api/src/core/decorators/current-user.decorator.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { createParamDecorator, type ExecutionContext } from "@nestjs/common";
-
-export const CurrentUser = createParamDecorator(
- (data: string | undefined, ctx: ExecutionContext) => {
- const request = ctx.switchToHttp().getRequest();
- const user = request.user;
- return data ? user?.[data] : user;
- },
-);
diff --git a/packages/templates/nestjs-api/src/core/decorators/public.decorators.ts b/packages/templates/nestjs-api/src/core/decorators/public.decorators.ts
deleted file mode 100644
index 4bcd7511..00000000
--- a/packages/templates/nestjs-api/src/core/decorators/public.decorators.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// 创建路由访问权限标记:该文件定义了一个名为 @Public() 的装饰器,用于标记哪些路由端点可以被公开访问,无需身份验证或授权
-import { SetMetadata } from "@nestjs/common";
-
-/**
- * 这段代码使用 NestJS 的 SetMetadata 函数创建了一个装饰器,该装饰器会在路由处理程序上设置名为 'isPublic' 的元数据,并赋值为 true。
- * 与守卫配合使用: 通常在项目中,这个装饰器会与一个全局认证守卫(例如 JwtAuthGuard)配合使用。守卫会检查路由处理程序上的元数据,如果发现 isPublic 为 true,则允许请求通过而不进行身份验证检查。
- */
-export const IS_PUBLIC_KEY = "isPublic";
-export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
-
-/**
- * 示例
- *
- * import { Controller, Get } from '@nestjs/common';
- * import { Public } from '../core/decorators/public.decorators';
- * @Controller('api')
- * export class SomeController {
- * // 这个路由需要验证身份
- * @Get('protected')
- * getProtectedData() {
- * return { message: '这是受保护的数据' };
- * }
-
- * // 这个路由不需要验证身份,任何人都可以访问
- * @Public()
- * @Get('public')
- * getPublicData() {
- * return { message: '这是公开数据' };
- * }
-* }
- */
diff --git a/packages/templates/nestjs-api/src/core/exceptions/business.exception.spec.ts b/packages/templates/nestjs-api/src/core/exceptions/business.exception.spec.ts
deleted file mode 100644
index 578d3e0b..00000000
--- a/packages/templates/nestjs-api/src/core/exceptions/business.exception.spec.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import { HttpStatus } from "@nestjs/common";
-import { ErrorCode, getErrorMessage } from "../constants/error-code";
-import { BusinessException } from "./business.exception";
-
-describe("BusinessException", () => {
- describe("constructor", () => {
- it("should create with errorCode, message and httpStatus", () => {
- const ex = new BusinessException(
- ErrorCode.INVALID_PARAMS,
- "custom message",
- HttpStatus.UNPROCESSABLE_ENTITY,
- );
- expect(ex.getStatus()).toBe(HttpStatus.UNPROCESSABLE_ENTITY);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.INVALID_PARAMS);
- expect(response.message).toBe("custom message");
- });
-
- it("should use default message from getErrorMessage when message is not provided", () => {
- const ex = new BusinessException(ErrorCode.INVALID_PARAMS);
- const response = ex.getResponse() as any;
- expect(response.message).toBe(getErrorMessage(ErrorCode.INVALID_PARAMS));
- });
-
- it("should default to HttpStatus.BAD_REQUEST when httpStatus is not provided", () => {
- const ex = new BusinessException(ErrorCode.INVALID_PARAMS);
- expect(ex.getStatus()).toBe(HttpStatus.BAD_REQUEST);
- });
- });
-
- describe("static factory methods", () => {
- it("unauthorized() should create with UNAUTHORIZED status", () => {
- const ex = BusinessException.unauthorized();
- expect(ex.getStatus()).toBe(HttpStatus.UNAUTHORIZED);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.UNAUTHORIZED);
- });
-
- it("forbidden() should create with FORBIDDEN status", () => {
- const ex = BusinessException.forbidden();
- expect(ex.getStatus()).toBe(HttpStatus.FORBIDDEN);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.FORBIDDEN);
- });
-
- it("notFound() should create with NOT_FOUND status", () => {
- const ex = BusinessException.notFound();
- expect(ex.getStatus()).toBe(HttpStatus.NOT_FOUND);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.NOT_FOUND);
- });
-
- it("badRequest() should create with BAD_REQUEST status", () => {
- const ex = BusinessException.badRequest();
- expect(ex.getStatus()).toBe(HttpStatus.BAD_REQUEST);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.INVALID_PARAMS);
- });
-
- it("internalServerError() should create with INTERNAL_SERVER_ERROR status", () => {
- const ex = BusinessException.internalServerError();
- expect(ex.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
- });
-
- it("tooManyRequests() should create with TOO_MANY_REQUESTS status", () => {
- const ex = BusinessException.tooManyRequests();
- expect(ex.getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.RATE_LIMITED);
- });
- });
-
- describe("auth static methods", () => {
- it("authTokenMissing() should use AUTH_TOKEN_MISSING code", () => {
- const ex = BusinessException.authTokenMissing();
- expect(ex.getStatus()).toBe(HttpStatus.UNAUTHORIZED);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.AUTH_TOKEN_MISSING);
- });
-
- it("authTokenInvalid() should use AUTH_TOKEN_INVALID code", () => {
- const ex = BusinessException.authTokenInvalid();
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.AUTH_TOKEN_INVALID);
- });
-
- it("authTokenExpired() should use AUTH_TOKEN_EXPIRED code", () => {
- const ex = BusinessException.authTokenExpired();
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.AUTH_TOKEN_EXPIRED);
- });
-
- it("authLoginFailed() should use AUTH_LOGIN_FAILED code", () => {
- const ex = BusinessException.authLoginFailed();
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.AUTH_LOGIN_FAILED);
- });
- });
-
- describe("user static methods", () => {
- it("userNotFound() should use NOT_FOUND status", () => {
- const ex = BusinessException.userNotFound();
- expect(ex.getStatus()).toBe(HttpStatus.NOT_FOUND);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.USER_NOT_FOUND);
- });
-
- it("userAlreadyExists() should use BAD_REQUEST status", () => {
- const ex = BusinessException.userAlreadyExists();
- expect(ex.getStatus()).toBe(HttpStatus.BAD_REQUEST);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.USER_ALREADY_EXISTS);
- });
-
- it("userPermissionDenied() should use FORBIDDEN status", () => {
- const ex = BusinessException.userPermissionDenied();
- expect(ex.getStatus()).toBe(HttpStatus.FORBIDDEN);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.USER_PERMISSION_DENIED);
- });
- });
-
- describe("business static methods", () => {
- it("resourceConflict() should use CONFLICT status", () => {
- const ex = BusinessException.resourceConflict();
- expect(ex.getStatus()).toBe(HttpStatus.CONFLICT);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.RESOURCE_CONFLICT);
- });
-
- it("quotaExceeded() should use PAYLOAD_TOO_LARGE status", () => {
- const ex = BusinessException.quotaExceeded();
- expect(ex.getStatus()).toBe(HttpStatus.PAYLOAD_TOO_LARGE);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.QUOTA_EXCEEDED);
- });
-
- it("operationNotAllowed() should use FORBIDDEN status", () => {
- const ex = BusinessException.operationNotAllowed();
- expect(ex.getStatus()).toBe(HttpStatus.FORBIDDEN);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.OPERATION_NOT_ALLOWED);
- });
-
- it("businessRuleViolation() should use BAD_REQUEST status", () => {
- const ex = BusinessException.businessRuleViolation();
- expect(ex.getStatus()).toBe(HttpStatus.BAD_REQUEST);
- const response = ex.getResponse() as any;
- expect(response.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
- });
- });
-
- describe("custom messages", () => {
- it("should accept custom message in factory methods", () => {
- const ex = BusinessException.unauthorized(
- ErrorCode.UNAUTHORIZED,
- "custom auth error",
- );
- const response = ex.getResponse() as any;
- expect(response.message).toBe("custom auth error");
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/core/exceptions/business.exception.ts b/packages/templates/nestjs-api/src/core/exceptions/business.exception.ts
deleted file mode 100644
index 2b3638f0..00000000
--- a/packages/templates/nestjs-api/src/core/exceptions/business.exception.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { HttpException, HttpStatus } from "@nestjs/common";
-import { ErrorCode, getErrorMessage } from "../constants/error-code";
-
-export class BusinessException extends HttpException {
- constructor(
- errorCode: ErrorCode,
- message?: string,
- httpStatus: HttpStatus = HttpStatus.BAD_REQUEST,
- ) {
- const errorMessage = message || getErrorMessage(errorCode);
-
- super(
- {
- code: errorCode,
- message: errorMessage,
- },
- httpStatus,
- );
- }
-
- static unauthorized(
- errorCode: ErrorCode = ErrorCode.UNAUTHORIZED,
- message?: string,
- ) {
- return new BusinessException(errorCode, message, HttpStatus.UNAUTHORIZED);
- }
-
- static forbidden(
- errorCode: ErrorCode = ErrorCode.FORBIDDEN,
- message?: string,
- ) {
- return new BusinessException(errorCode, message, HttpStatus.FORBIDDEN);
- }
-
- static notFound(
- errorCode: ErrorCode = ErrorCode.NOT_FOUND,
- message?: string,
- ) {
- return new BusinessException(errorCode, message, HttpStatus.NOT_FOUND);
- }
-
- static badRequest(
- errorCode: ErrorCode = ErrorCode.INVALID_PARAMS,
- message?: string,
- ) {
- return new BusinessException(errorCode, message, HttpStatus.BAD_REQUEST);
- }
-
- static internalServerError(
- errorCode: ErrorCode = ErrorCode.INTERNAL_SERVER_ERROR,
- message?: string,
- ) {
- return new BusinessException(
- errorCode,
- message,
- HttpStatus.INTERNAL_SERVER_ERROR,
- );
- }
-
- static tooManyRequests(
- errorCode: ErrorCode = ErrorCode.RATE_LIMITED,
- message?: string,
- ) {
- return new BusinessException(
- errorCode,
- message,
- HttpStatus.TOO_MANY_REQUESTS,
- );
- }
-
- // 认证相关静态方法
- static authTokenMissing(message?: string) {
- return BusinessException.unauthorized(
- ErrorCode.AUTH_TOKEN_MISSING,
- message,
- );
- }
-
- static authTokenInvalid(message?: string) {
- return BusinessException.unauthorized(
- ErrorCode.AUTH_TOKEN_INVALID,
- message,
- );
- }
-
- static authTokenExpired(message?: string) {
- return BusinessException.unauthorized(
- ErrorCode.AUTH_TOKEN_EXPIRED,
- message,
- );
- }
-
- static authLoginFailed(message?: string) {
- return BusinessException.unauthorized(ErrorCode.AUTH_LOGIN_FAILED, message);
- }
-
- // 用户相关静态方法
- static userNotFound(message?: string) {
- return BusinessException.notFound(ErrorCode.USER_NOT_FOUND, message);
- }
-
- static userAlreadyExists(message?: string) {
- return BusinessException.badRequest(ErrorCode.USER_ALREADY_EXISTS, message);
- }
-
- static userNameAlreadyExists(message?: string) {
- return BusinessException.badRequest(
- ErrorCode.USER_NAME_ALREADY_EXISTS,
- message,
- );
- }
-
- static userEmailAlreadyExists(message?: string) {
- return BusinessException.badRequest(
- ErrorCode.USER_EMAIL_ALREADY_EXISTS,
- message,
- );
- }
-
- static userPermissionDenied(message?: string) {
- return BusinessException.forbidden(
- ErrorCode.USER_PERMISSION_DENIED,
- message,
- );
- }
-
- // 业务逻辑相关静态方法
- static businessRuleViolation(message?: string) {
- return BusinessException.badRequest(
- ErrorCode.BUSINESS_RULE_VIOLATION,
- message,
- );
- }
-
- static resourceConflict(message?: string) {
- return new BusinessException(
- ErrorCode.RESOURCE_CONFLICT,
- message,
- HttpStatus.CONFLICT,
- );
- }
-
- static operationNotAllowed(message?: string) {
- return BusinessException.forbidden(
- ErrorCode.OPERATION_NOT_ALLOWED,
- message,
- );
- }
-
- static quotaExceeded(message?: string) {
- return new BusinessException(
- ErrorCode.QUOTA_EXCEEDED,
- message,
- HttpStatus.PAYLOAD_TOO_LARGE,
- );
- }
-}
diff --git a/packages/templates/nestjs-api/src/core/exceptions/index.ts b/packages/templates/nestjs-api/src/core/exceptions/index.ts
deleted file mode 100644
index f53caf01..00000000
--- a/packages/templates/nestjs-api/src/core/exceptions/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { BusinessException } from "./business.exception";
diff --git a/packages/templates/nestjs-api/src/core/filters/http-exception.filter.spec.ts b/packages/templates/nestjs-api/src/core/filters/http-exception.filter.spec.ts
deleted file mode 100644
index 118d2093..00000000
--- a/packages/templates/nestjs-api/src/core/filters/http-exception.filter.spec.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { HttpException, HttpStatus } from "@nestjs/common";
-import type { ArgumentsHost } from "@nestjs/common";
-import { ErrorCode } from "../constants/error-code";
-import { BusinessException } from "../exceptions/business.exception";
-import { HttpExceptionFilter } from "./http-exception.filter";
-
-describe("HttpExceptionFilter", () => {
- let filter: HttpExceptionFilter;
- let mockResponse: any;
- let mockRequest: any;
- let mockHost: ArgumentsHost;
-
- beforeEach(() => {
- filter = new HttpExceptionFilter();
- mockResponse = {
- status: jest.fn().mockReturnThis(),
- json: jest.fn(),
- };
- mockRequest = {
- path: "/test",
- method: "GET",
- get: jest.fn().mockReturnValue("test-agent"),
- ip: "127.0.0.1",
- };
- mockHost = {
- switchToHttp: () => ({
- getResponse: () => mockResponse,
- getRequest: () => mockRequest,
- }),
- } as unknown as ArgumentsHost;
- });
-
- it("should handle BusinessException", () => {
- const exception = BusinessException.notFound(
- ErrorCode.USER_NOT_FOUND,
- "user not found",
- );
-
- filter.catch(exception, mockHost);
-
- expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
- const responseBody = mockResponse.json.mock.calls[0][0];
- expect(responseBody.code).toBe(ErrorCode.USER_NOT_FOUND);
- expect(responseBody.message).toBe("user not found");
- expect(responseBody.data).toBeNull();
- expect(responseBody.path).toBe("/test");
- expect(responseBody.timestamp).toBeDefined();
- });
-
- it("should handle HttpException with object response", () => {
- const exception = new HttpException(
- { message: "bad request", statusCode: 400 },
- HttpStatus.BAD_REQUEST,
- );
-
- filter.catch(exception, mockHost);
-
- expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
- const responseBody = mockResponse.json.mock.calls[0][0];
- expect(responseBody.message).toBe("bad request");
- expect(responseBody.code).toBe(ErrorCode.INVALID_PARAMS);
- });
-
- it("should handle HttpException with validation error array", () => {
- const exception = new HttpException(
- { message: ["field1 is required", "field2 must be string"] },
- HttpStatus.BAD_REQUEST,
- );
-
- filter.catch(exception, mockHost);
-
- const responseBody = mockResponse.json.mock.calls[0][0];
- expect(responseBody.message).toBe(
- "field1 is required, field2 must be string",
- );
- });
-
- it("should handle HttpException with string response", () => {
- const exception = new HttpException("Not Found", HttpStatus.NOT_FOUND);
-
- filter.catch(exception, mockHost);
-
- expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
- const responseBody = mockResponse.json.mock.calls[0][0];
- expect(responseBody.message).toBe("Not Found");
- expect(responseBody.code).toBe(ErrorCode.NOT_FOUND);
- });
-
- it("should handle generic Error", () => {
- const exception = new Error("something went wrong");
-
- filter.catch(exception, mockHost);
-
- expect(mockResponse.status).toHaveBeenCalledWith(
- HttpStatus.INTERNAL_SERVER_ERROR,
- );
- const responseBody = mockResponse.json.mock.calls[0][0];
- expect(responseBody.message).toBe("something went wrong");
- expect(responseBody.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
- });
-
- it("should handle unknown exception", () => {
- filter.catch("unknown error", mockHost);
-
- expect(mockResponse.status).toHaveBeenCalledWith(
- HttpStatus.INTERNAL_SERVER_ERROR,
- );
- const responseBody = mockResponse.json.mock.calls[0][0];
- expect(responseBody.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR);
- });
-
- it("should include timestamp and path in every response", () => {
- const exception = new HttpException("test", HttpStatus.BAD_REQUEST);
-
- filter.catch(exception, mockHost);
-
- const responseBody = mockResponse.json.mock.calls[0][0];
- expect(responseBody.timestamp).toBeDefined();
- expect(responseBody.path).toBe("/test");
- expect(responseBody.data).toBeNull();
- });
-
- it("should map HttpStatus to correct ErrorCode", () => {
- const cases = [
- { status: HttpStatus.UNAUTHORIZED, code: ErrorCode.UNAUTHORIZED },
- { status: HttpStatus.FORBIDDEN, code: ErrorCode.FORBIDDEN },
- { status: HttpStatus.TOO_MANY_REQUESTS, code: ErrorCode.RATE_LIMITED },
- ];
-
- for (const { status, code } of cases) {
- const exception = new HttpException("test", status);
- filter.catch(exception, mockHost);
- const responseBody = mockResponse.json.mock.calls.at(-1)[0];
- expect(responseBody.code).toBe(code);
- }
- });
-});
diff --git a/packages/templates/nestjs-api/src/core/filters/http-exception.filter.ts b/packages/templates/nestjs-api/src/core/filters/http-exception.filter.ts
deleted file mode 100644
index 0529b192..00000000
--- a/packages/templates/nestjs-api/src/core/filters/http-exception.filter.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import {
- type ArgumentsHost,
- Catch,
- type ExceptionFilter,
- HttpException,
- HttpStatus,
- Logger,
-} from "@nestjs/common";
-import type { Request, Response } from "express";
-import { ErrorCode, getErrorMessage } from "../constants/error-code";
-import { BusinessException } from "../exceptions/business.exception";
-
-interface ErrorResponse {
- code: number;
- message: string;
- data: null;
- timestamp: string;
- path: string;
-}
-
-@Catch()
-export class HttpExceptionFilter implements ExceptionFilter {
- private readonly logger = new Logger(HttpExceptionFilter.name);
-
- catch(exception: unknown, host: ArgumentsHost) {
- const ctx = host.switchToHttp();
- const response = ctx.getResponse();
- const request = ctx.getRequest();
-
- let status = HttpStatus.INTERNAL_SERVER_ERROR;
- let message = getErrorMessage(ErrorCode.INTERNAL_SERVER_ERROR);
- let code = ErrorCode.INTERNAL_SERVER_ERROR;
-
- // 处理 BusinessException
- if (exception instanceof BusinessException) {
- const exceptionResponse = exception.getResponse() as any;
- status = exception.getStatus();
- code = exceptionResponse.code;
- message = exceptionResponse.message;
- }
- // 处理通用 HttpException
- else if (exception instanceof HttpException) {
- const exceptionResponse = exception.getResponse();
- status = exception.getStatus();
-
- if (typeof exceptionResponse === "object") {
- const responseData = exceptionResponse as any;
- message = responseData.message || this.getDefaultHttpMessage(status);
- code = responseData.code || this.getDefaultErrorCode(status);
-
- // 处理验证错误数组
- if (Array.isArray(responseData.message)) {
- message = responseData.message.join(", ");
- }
- } else {
- message = exceptionResponse as string;
- code = this.getDefaultErrorCode(status);
- }
- }
- // 处理其他错误
- else if (exception instanceof Error) {
- message = exception.message;
- this.logger.error(
- `Unhandled exception: ${exception.message}`,
- exception.stack,
- );
- }
-
- // 记录错误日志
- this.logError(exception, request, status, code);
-
- const errorResponse: ErrorResponse = {
- code,
- message,
- data: null,
- timestamp: new Date().toISOString(),
- path: request.path,
- };
-
- response.status(status).json(errorResponse);
- }
-
- private getDefaultErrorCode(httpStatus: HttpStatus): ErrorCode {
- switch (httpStatus) {
- case HttpStatus.BAD_REQUEST:
- return ErrorCode.INVALID_PARAMS;
- case HttpStatus.UNAUTHORIZED:
- return ErrorCode.UNAUTHORIZED;
- case HttpStatus.FORBIDDEN:
- return ErrorCode.FORBIDDEN;
- case HttpStatus.NOT_FOUND:
- return ErrorCode.NOT_FOUND;
- case HttpStatus.METHOD_NOT_ALLOWED:
- return ErrorCode.METHOD_NOT_ALLOWED;
- case HttpStatus.TOO_MANY_REQUESTS:
- return ErrorCode.RATE_LIMITED;
- default:
- return ErrorCode.INTERNAL_SERVER_ERROR;
- }
- }
-
- private getDefaultHttpMessage(httpStatus: HttpStatus): string {
- const errorCode = this.getDefaultErrorCode(httpStatus);
- return getErrorMessage(errorCode);
- }
-
- private logError(
- exception: unknown,
- request: Request,
- status: HttpStatus,
- code: ErrorCode,
- ) {
- const message =
- exception instanceof Error ? exception.message : "Unknown error";
- const stack = exception instanceof Error ? exception.stack : "";
-
- const logData = {
- method: request.method,
- path: request.path,
- userAgent: request.get("user-agent"),
- ip: request.ip,
- status,
- code,
- message,
- };
-
- if (status >= 500) {
- this.logger.error("Internal server error", { ...logData, stack });
- } else if (status >= 400) {
- this.logger.warn("Client error", logData);
- }
- }
-}
diff --git a/packages/templates/nestjs-api/src/core/interceptors/logging.interceptor.spec.ts b/packages/templates/nestjs-api/src/core/interceptors/logging.interceptor.spec.ts
deleted file mode 100644
index 5ccbda77..00000000
--- a/packages/templates/nestjs-api/src/core/interceptors/logging.interceptor.spec.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-jest.mock("@/shared/utils", () => ({
- logger: {
- info: jest.fn(),
- error: jest.fn(),
- warn: jest.fn(),
- debug: jest.fn(),
- },
-}));
-
-import type { CallHandler, ExecutionContext } from "@nestjs/common";
-import { of } from "rxjs";
-import { lastValueFrom } from "rxjs";
-import { logger } from "@/shared/utils";
-import { LoggingInterceptor } from "./logging.interceptor";
-
-describe("LoggingInterceptor", () => {
- let interceptor: LoggingInterceptor;
-
- beforeEach(() => {
- interceptor = new LoggingInterceptor();
- jest.clearAllMocks();
- });
-
- function createMockContext(
- method: string,
- url: string,
- query: any = {},
- body: any = {},
- statusCode = 200,
- ): ExecutionContext {
- return {
- switchToHttp: () => ({
- getRequest: () => ({ method, url, query, body }),
- getResponse: () => ({ statusCode }),
- }),
- } as unknown as ExecutionContext;
- }
-
- function createMockCallHandler(data: any): CallHandler {
- return { handle: () => of(data) };
- }
-
- it("should log request info on intercept", async () => {
- const context = createMockContext(
- "GET",
- "/api/v1/users",
- { page: "1" },
- {},
- );
- const handler = createMockCallHandler("result");
-
- await lastValueFrom(interceptor.intercept(context, handler));
-
- expect(logger.info).toHaveBeenCalledWith(
- expect.stringContaining("GET /api/v1/users"),
- );
- expect(logger.info).toHaveBeenCalledWith(
- expect.stringContaining('"page":"1"'),
- );
- });
-
- it("should log response status code", async () => {
- const context = createMockContext(
- "POST",
- "/api/v1/users",
- {},
- { name: "Alice" },
- 201,
- );
- const handler = createMockCallHandler("result");
-
- await lastValueFrom(interceptor.intercept(context, handler));
-
- expect(logger.info).toHaveBeenCalledWith(
- expect.stringContaining("Status: 201"),
- );
- });
-
- it("should pass through the handler result", async () => {
- const context = createMockContext("GET", "/test");
- const handler = createMockCallHandler({ data: "test" });
-
- const result = await lastValueFrom(interceptor.intercept(context, handler));
-
- expect(result).toEqual({ data: "test" });
- });
-
- it("should sanitize sensitive fields in body", async () => {
- const context = createMockContext(
- "POST",
- "/api/v1/auth/login",
- {},
- {
- username: "alice",
- password: "secret123",
- token: "jwt-token",
- },
- );
- const handler = createMockCallHandler("ok");
-
- await lastValueFrom(interceptor.intercept(context, handler));
-
- const logCall = (logger.info as jest.Mock).mock.calls[0][0];
- expect(logCall).toContain('"username":"alice"');
- expect(logCall).toContain('"password":"***"');
- expect(logCall).toContain('"token":"***"');
- expect(logCall).not.toContain("secret123");
- expect(logCall).not.toContain("jwt-token");
- });
-
- it("should sanitize sensitive fields in query", async () => {
- const context = createMockContext("GET", "/api/v1/test", {
- secret: "my-key",
- page: "1",
- });
- const handler = createMockCallHandler("ok");
-
- await lastValueFrom(interceptor.intercept(context, handler));
-
- const logCall = (logger.info as jest.Mock).mock.calls[0][0];
- expect(logCall).toContain('"secret":"***"');
- expect(logCall).toContain('"page":"1"');
- expect(logCall).not.toContain("my-key");
- });
-});
diff --git a/packages/templates/nestjs-api/src/core/interceptors/logging.interceptor.ts b/packages/templates/nestjs-api/src/core/interceptors/logging.interceptor.ts
deleted file mode 100644
index c475bf82..00000000
--- a/packages/templates/nestjs-api/src/core/interceptors/logging.interceptor.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import {
- type CallHandler,
- type ExecutionContext,
- Injectable,
- type NestInterceptor,
-} from "@nestjs/common";
-import type { Observable } from "rxjs";
-import { tap } from "rxjs/operators";
-import { logger } from "@/shared/utils";
-
-const SENSITIVE_FIELDS = [
- "password",
- "token",
- "secret",
- "authorization",
- "credit_card",
- "ssn",
-];
-
-function sanitize(obj: any): any {
- if (!obj || typeof obj !== "object") return obj;
- const sanitized: Record = {};
- for (const [key, value] of Object.entries(obj)) {
- if (SENSITIVE_FIELDS.includes(key.toLowerCase())) {
- sanitized[key] = "***";
- } else {
- sanitized[key] = value;
- }
- }
- return sanitized;
-}
-
-@Injectable()
-export class LoggingInterceptor implements NestInterceptor {
- intercept(context: ExecutionContext, next: CallHandler): Observable {
- const req = context.switchToHttp().getRequest();
- const { method, url, body, query } = req;
-
- logger.info(
- `${method} ${url} - Query: ${JSON.stringify(sanitize(query))} - Body: ${JSON.stringify(sanitize(body))}`,
- );
-
- return next.handle().pipe(
- tap(() => {
- const res = context.switchToHttp().getResponse();
- logger.info(`${method} ${url} - Status: ${res.statusCode}`);
- }),
- );
- }
-}
diff --git a/packages/templates/nestjs-api/src/core/interceptors/metrics.interceptor.ts b/packages/templates/nestjs-api/src/core/interceptors/metrics.interceptor.ts
deleted file mode 100644
index 1ff22207..00000000
--- a/packages/templates/nestjs-api/src/core/interceptors/metrics.interceptor.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {
- CallHandler,
- ExecutionContext,
- Injectable,
- NestInterceptor,
-} from "@nestjs/common";
-import { InjectMetric } from "@willsoto/nestjs-prometheus";
-import { Counter, Histogram } from "prom-client";
-import { Observable, throwError } from "rxjs";
-import { catchError, tap } from "rxjs/operators";
-
-@Injectable()
-export class MetricsInterceptor implements NestInterceptor {
- constructor(
- @InjectMetric("http_requests_total") public counter: Counter,
- @InjectMetric("http_request_duration_seconds")
- public histogram: Histogram,
- ) {}
-
- intercept(context: ExecutionContext, next: CallHandler): Observable {
- const req = context.switchToHttp().getRequest();
- const res = context.switchToHttp().getResponse();
-
- const method = req.method;
- const route = req.route?.path || req.url;
-
- const timer = this.histogram.startTimer({ method, route });
-
- return next.handle().pipe(
- tap(() => {
- const status = res.statusCode.toString();
- this.counter.inc({ method, route, status });
- timer({ method, route, status });
- }),
- catchError((error) => {
- const status = error.status?.toString() || "500";
- this.counter.inc({ method, route, status });
- timer({ method, route, status });
- return throwError(() => error);
- }),
- );
- }
-}
diff --git a/packages/templates/nestjs-api/src/core/interceptors/response.interceptor.spec.ts b/packages/templates/nestjs-api/src/core/interceptors/response.interceptor.spec.ts
deleted file mode 100644
index 5d841459..00000000
--- a/packages/templates/nestjs-api/src/core/interceptors/response.interceptor.spec.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import type { CallHandler, ExecutionContext } from "@nestjs/common";
-import { ConfigService } from "@nestjs/config";
-import { Test } from "@nestjs/testing";
-import { lastValueFrom, of } from "rxjs";
-import { ResponseInterceptor } from "./response.interceptor";
-
-describe("ResponseInterceptor", () => {
- let interceptor: ResponseInterceptor;
- let configService: ConfigService;
-
- beforeEach(async () => {
- const module = await Test.createTestingModule({
- providers: [
- ResponseInterceptor,
- {
- provide: ConfigService,
- useValue: {
- get: jest.fn((key: string) => {
- if (key === "app.responseInterceptorExcludePaths") {
- return ["/metrics", "/health", "stream"];
- }
- return undefined;
- }),
- },
- },
- ],
- }).compile();
-
- interceptor = module.get(ResponseInterceptor);
- configService = module.get(ConfigService);
- });
-
- function createMockContext(path: string): ExecutionContext {
- return {
- switchToHttp: () => ({
- getRequest: () => ({ path }),
- }),
- } as unknown as ExecutionContext;
- }
-
- function createMockCallHandler(data: any): CallHandler {
- return { handle: () => of(data) };
- }
-
- it("should wrap response data in standard format", async () => {
- const context = createMockContext("/api/v1/users");
- const handler = createMockCallHandler({ id: "1", name: "Alice" });
-
- const result = await lastValueFrom(interceptor.intercept(context, handler));
-
- expect(result).toEqual({
- code: 0,
- message: "success",
- data: { id: "1", name: "Alice" },
- });
- });
-
- it("should pass through for exact exclude path", async () => {
- const context = createMockContext("/metrics");
- const testData = { metric: "value" };
- const handler = createMockCallHandler(testData);
-
- const result = await lastValueFrom(interceptor.intercept(context, handler));
-
- expect(result).toEqual(testData);
- });
-
- it("should pass through for substring exclude path", async () => {
- const context = createMockContext("/api/v1/stream/data");
- const testData = "raw stream data";
- const handler = createMockCallHandler(testData);
-
- const result = await lastValueFrom(interceptor.intercept(context, handler));
-
- expect(result).toBe(testData);
- });
-
- it("should use empty array when config returns undefined", async () => {
- jest.spyOn(configService, "get").mockReturnValue(undefined);
-
- const context = createMockContext("/metrics");
- const handler = createMockCallHandler("data");
-
- const result = await lastValueFrom(interceptor.intercept(context, handler));
-
- expect(result).toEqual({
- code: 0,
- message: "success",
- data: "data",
- });
- });
-
- it("should wrap null data", async () => {
- const context = createMockContext("/api/v1/test");
- const handler = createMockCallHandler(null);
-
- const result = await lastValueFrom(interceptor.intercept(context, handler));
-
- expect(result).toEqual({
- code: 0,
- message: "success",
- data: null,
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/core/interceptors/response.interceptor.ts b/packages/templates/nestjs-api/src/core/interceptors/response.interceptor.ts
deleted file mode 100644
index 5e422556..00000000
--- a/packages/templates/nestjs-api/src/core/interceptors/response.interceptor.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-// 统一接口响应格式拦截器,将响应数据包装为 { code, message, data } 结构。
-// 通过配置 app.responseInterceptorExcludePaths 可排除特定路径(如 stream、metrics 等)。
-import {
- type CallHandler,
- type ExecutionContext,
- Injectable,
- type NestInterceptor,
-} from "@nestjs/common";
-import { ConfigService } from "@nestjs/config";
-import type { Observable } from "rxjs";
-import { map } from "rxjs/operators";
-
-@Injectable()
-export class ResponseInterceptor implements NestInterceptor {
- constructor(private readonly configService: ConfigService) {}
-
- intercept(context: ExecutionContext, next: CallHandler): Observable {
- const request = context.switchToHttp().getRequest();
- const excludePaths =
- this.configService.get("app.responseInterceptorExcludePaths") ||
- [];
-
- const shouldExclude = excludePaths.some((excludePath) => {
- if (excludePath.startsWith("/")) {
- return request.path === excludePath;
- }
- // 按路径段匹配,避免 "stream" 意外匹配 "/upstream"
- return request.path.split("/").includes(excludePath);
- });
-
- if (shouldExclude) {
- return next.handle();
- }
-
- return next.handle().pipe(
- map((data) => ({
- code: 0,
- message: "success",
- data,
- })),
- );
- }
-}
diff --git a/packages/templates/nestjs-api/src/dao/entities/user.entity.ts b/packages/templates/nestjs-api/src/dao/entities/user.entity.ts
deleted file mode 100644
index 459bd636..00000000
--- a/packages/templates/nestjs-api/src/dao/entities/user.entity.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-// 定义核心领域模型,这里我们用一个简单的 User 实体来表示业务对象。
-export class User {
- constructor(
- public readonly id: string,
- public name: string,
- ) {}
-}
diff --git a/packages/templates/nestjs-api/src/dao/repositories/user.repository.spec.ts b/packages/templates/nestjs-api/src/dao/repositories/user.repository.spec.ts
deleted file mode 100644
index 4bece0cb..00000000
--- a/packages/templates/nestjs-api/src/dao/repositories/user.repository.spec.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { Test } from "@nestjs/testing";
-import { User } from "../entities/user.entity";
-import { DrizzleService } from "../../service/drizzle/drizzle.service";
-import { UserRepository } from "./user.repository";
-
-describe("UserRepository", () => {
- let repository: UserRepository;
- let drizzleService: any;
-
- // 模拟 Drizzle 链式查询
- let mockFrom: jest.Mock;
- let mockSelect: jest.Mock;
- let mockInsert: jest.Mock;
- let mockValues: jest.Mock;
- let mockReturning: jest.Mock;
- let mockLimit: jest.Mock;
- let mockOffset: jest.Mock;
-
- beforeEach(async () => {
- mockOffset = jest.fn();
- mockLimit = jest.fn().mockReturnValue({ offset: mockOffset });
- mockFrom = jest.fn().mockReturnValue({ limit: mockLimit });
- mockSelect = jest.fn().mockReturnValue({ from: mockFrom });
- mockReturning = jest.fn();
- mockValues = jest.fn().mockReturnValue({ returning: mockReturning });
- mockInsert = jest.fn().mockReturnValue({ values: mockValues });
-
- drizzleService = {
- db: {
- select: mockSelect,
- insert: mockInsert,
- },
- };
-
- const module = await Test.createTestingModule({
- providers: [
- UserRepository,
- { provide: DrizzleService, useValue: drizzleService },
- ],
- }).compile();
-
- repository = module.get(UserRepository);
- });
-
- describe("findAll", () => {
- it("should return paginated User entities", async () => {
- // Mock for data query (select().from().limit().offset())
- mockOffset.mockResolvedValue([
- { id: "1", name: "Alice", created: new Date(), updated: new Date() },
- { id: "2", name: "Bob", created: new Date(), updated: new Date() },
- ]);
-
- // Mock for count query - second call to select().from()
- const mockCountFrom = jest.fn().mockResolvedValue([{ total: 5 }]);
- const mockCountSelect = jest
- .fn()
- .mockReturnValue({ from: mockCountFrom });
-
- // Override select to return different chains for data vs count queries
- let selectCallCount = 0;
- drizzleService.db.select = jest
- .fn()
- .mockImplementation((...args: any[]) => {
- selectCallCount++;
- if (selectCallCount === 1) {
- return { from: mockFrom };
- }
- return mockCountSelect(...args);
- });
-
- const result = await repository.findAll(1, 20);
-
- expect(result.items).toHaveLength(2);
- expect(result.items[0]).toBeInstanceOf(User);
- expect(result.items[0].name).toBe("Alice");
- expect(result.total).toBe(5);
- });
-
- it("should return empty items when no users exist", async () => {
- mockOffset.mockResolvedValue([]);
- const mockCountFrom = jest.fn().mockResolvedValue([{ total: 0 }]);
-
- let selectCallCount = 0;
- drizzleService.db.select = jest
- .fn()
- .mockImplementation((...args: any[]) => {
- selectCallCount++;
- if (selectCallCount === 1) {
- return { from: mockFrom };
- }
- return { from: mockCountFrom };
- });
-
- const result = await repository.findAll(1, 20);
-
- expect(result.items).toEqual([]);
- expect(result.total).toBe(0);
- });
- });
-
- describe("create", () => {
- it("should create and return a User entity", async () => {
- mockReturning.mockResolvedValue([
- {
- id: "3",
- name: "Charlie",
- created: new Date(),
- updated: new Date(),
- },
- ]);
-
- const result = await repository.create("Charlie");
-
- expect(result).toBeInstanceOf(User);
- expect(result.id).toBe("3");
- expect(result.name).toBe("Charlie");
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/dao/repositories/user.repository.ts b/packages/templates/nestjs-api/src/dao/repositories/user.repository.ts
deleted file mode 100644
index d1b50f80..00000000
--- a/packages/templates/nestjs-api/src/dao/repositories/user.repository.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// 封装对数据库的操作,依赖于 DrizzleService(在基础设施层已封装)。该模块负责从数据库中查询、创建 User 实体。
-import { Injectable } from "@nestjs/common";
-import { count } from "drizzle-orm";
-import { DrizzleService } from "../../service/drizzle/drizzle.service";
-import { users } from "../../service/drizzle/schema";
-import { User } from "../entities/user.entity";
-
-@Injectable()
-export class UserRepository {
- constructor(private readonly drizzle: DrizzleService) {}
-
- async findAll(
- page: number,
- pageSize: number,
- ): Promise<{ items: User[]; total: number }> {
- const offset = (page - 1) * pageSize;
-
- const [result, [{ total }]] = await Promise.all([
- this.drizzle.db.select().from(users).limit(pageSize).offset(offset),
- this.drizzle.db.select({ total: count() }).from(users),
- ]);
-
- return {
- items: result.map((row) => new User(row.id, row.name)),
- total,
- };
- }
-
- async create(name: string): Promise {
- const [row] = await this.drizzle.db
- .insert(users)
- .values({ name })
- .returning();
- return new User(row.id, row.name);
- }
-}
diff --git a/packages/templates/nestjs-api/src/logger.ts b/packages/templates/nestjs-api/src/logger.ts
new file mode 100644
index 00000000..87e7e1cb
--- /dev/null
+++ b/packages/templates/nestjs-api/src/logger.ts
@@ -0,0 +1,18 @@
+import type { LoggerService } from "@nestjs/common";
+import pino from "pino";
+
+const output = pino();
+export const logger: LoggerService = {
+ log: (message: unknown, ...context: unknown[]) =>
+ output.info({ context }, String(message)),
+ error: (message: unknown, ...context: unknown[]) =>
+ output.error({ context }, String(message)),
+ warn: (message: unknown, ...context: unknown[]) =>
+ output.warn({ context }, String(message)),
+ debug: (message: unknown, ...context: unknown[]) =>
+ output.debug({ context }, String(message)),
+ verbose: (message: unknown, ...context: unknown[]) =>
+ output.trace({ context }, String(message)),
+ fatal: (message: unknown, ...context: unknown[]) =>
+ output.fatal({ context }, String(message)),
+};
diff --git a/packages/templates/nestjs-api/src/main.ts b/packages/templates/nestjs-api/src/main.ts
index ca8d88e0..acbd6b0a 100644
--- a/packages/templates/nestjs-api/src/main.ts
+++ b/packages/templates/nestjs-api/src/main.ts
@@ -1,86 +1,32 @@
-import { Logger, ValidationPipe } from "@nestjs/common";
+import "reflect-metadata";
+import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
+import { logger } from "./logger";
async function bootstrap() {
- // Create the application instance
- const app = await NestFactory.create(AppModule, {
- logger: ["error", "warn", "log", "debug", "verbose"],
- });
-
- const logger = new Logger("Bootstrap");
- const port = process.env.PORT || 3000;
-
- // Enable CORS with custom configuration
- const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",");
- if (!allowedOrigins) {
- logger.warn(
- "ALLOWED_ORIGINS not set, CORS will allow all origins. Do NOT use in production!",
- );
- }
- app.enableCors({
- origin: allowedOrigins || "*",
- methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
- credentials: true,
- });
-
- // Set global prefix for all routes except /metrics and /health
- app.setGlobalPrefix("api/v1", {
- exclude: ["/metrics", "/health"],
- });
-
- // Global validation pipe
- app.useGlobalPipes(
- new ValidationPipe({
- whitelist: true,
- transform: true,
- forbidNonWhitelisted: true,
- transformOptions: {
- enableImplicitConversion: true,
- },
- }),
- );
-
- // Setup Swagger documentation
+ const app = await NestFactory.create(AppModule, { logger });
+ const origins = (
+ process.env.ALLOWED_ORIGINS ?? "http://localhost:3000,http://localhost:5173"
+ )
+ .split(",")
+ .map((origin) => origin.trim())
+ .filter(Boolean);
+ app.enableCors({ origin: origins });
+ app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
+ app.enableShutdownHooks();
const config = new DocumentBuilder()
- .setTitle("NestJS Template API")
- .setDescription(
- "A NestJS REST API template with authentication and validation",
- )
+ .setTitle("NestJS API")
+ .setDescription("Application information and health / 服务信息与健康检查")
.setVersion("0.1.0")
- .addBearerAuth(
- {
- type: "http",
- scheme: "bearer",
- bearerFormat: "JWT",
- name: "JWT",
- description: "Enter JWT token",
- in: "header",
- },
- "JWT-auth", // This name here is important for matching up with @ApiBearerAuth() in your controller!
- )
- .addTag("auth", "Authentication endpoints")
- .addTag("common", "Common endpoints")
- .addTag("users", "User management endpoints")
.build();
-
- const document = SwaggerModule.createDocument(app, config);
- SwaggerModule.setup("api/docs", app, document, {
- swaggerOptions: {
- persistAuthorization: true,
- },
- });
-
- // Enable graceful shutdown hooks (triggers onModuleDestroy, onApplicationShutdown, etc.)
- app.enableShutdownHooks();
-
- // Start the server
- await app.listen(port);
- logger.log(`Application is running on: http://localhost:${port}`);
+ SwaggerModule.setup(
+ "api/docs",
+ app,
+ SwaggerModule.createDocument(app, config),
+ );
+ await app.listen(Number(process.env.PORT ?? 3000));
}
-bootstrap().catch((err) => {
- new Logger("Bootstrap").error("Failed to start application", err);
- process.exit(1);
-});
+void bootstrap();
diff --git a/packages/templates/nestjs-api/src/modules/auth/auth.controller.spec.ts b/packages/templates/nestjs-api/src/modules/auth/auth.controller.spec.ts
deleted file mode 100644
index bf7723a0..00000000
--- a/packages/templates/nestjs-api/src/modules/auth/auth.controller.spec.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Test } from "@nestjs/testing";
-import { AuthController } from "./auth.controller";
-import { AuthService } from "./auth.service";
-
-describe("AuthController", () => {
- let controller: AuthController;
- let authService: AuthService;
-
- beforeEach(async () => {
- const module = await Test.createTestingModule({
- controllers: [AuthController],
- providers: [
- {
- provide: AuthService,
- useValue: {
- getAccessToken: jest.fn().mockResolvedValue("mock-access-token"),
- refreshAccessToken: jest
- .fn()
- .mockResolvedValue("mock-refreshed-token"),
- },
- },
- ],
- }).compile();
-
- controller = module.get(AuthController);
- authService = module.get(AuthService);
- });
-
- describe("login", () => {
- it("should call authService.getAccessToken with code and return token", async () => {
- const result = await controller.login({ code: "auth-code-123" });
-
- expect(authService.getAccessToken).toHaveBeenCalledWith("auth-code-123");
- expect(result).toBe("mock-access-token");
- });
- });
-
- describe("refresh", () => {
- it("should call authService.refreshAccessToken with token", async () => {
- const result = await controller.refresh({ token: "old-token" });
-
- expect(authService.refreshAccessToken).toHaveBeenCalledWith("old-token");
- expect(result).toBe("mock-refreshed-token");
- });
-
- it("should propagate errors from authService", async () => {
- jest
- .spyOn(authService, "refreshAccessToken")
- .mockRejectedValue(new Error("token expired"));
-
- await expect(controller.refresh({ token: "bad-token" })).rejects.toThrow(
- "token expired",
- );
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/modules/auth/auth.controller.ts b/packages/templates/nestjs-api/src/modules/auth/auth.controller.ts
deleted file mode 100644
index e3248e0e..00000000
--- a/packages/templates/nestjs-api/src/modules/auth/auth.controller.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Body, Controller, Post } from "@nestjs/common";
-import {
- ApiBearerAuth,
- ApiBody,
- ApiOperation,
- ApiResponse,
- ApiTags,
-} from "@nestjs/swagger";
-import { Public } from "@/core/decorators/public.decorators";
-import { AuthService } from "./auth.service";
-import { LoginDto } from "./dto/login.dto";
-import { RefreshDto } from "./dto/refresh.dto";
-
-@ApiTags("auth")
-@Controller("auth")
-export class AuthController {
- constructor(private readonly authService: AuthService) {}
-
- @ApiOperation({ summary: "User login" })
- @ApiBody({ type: LoginDto })
- @ApiResponse({
- status: 200,
- description: "Login successful",
- schema: {
- type: "object",
- properties: {
- code: { type: "number", example: 0 },
- message: { type: "string", example: "success" },
- data: {
- type: "string",
- example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
- },
- },
- },
- })
- @ApiResponse({ status: 400, description: "Bad request" })
- @Public()
- @Post("login")
- async login(@Body() body: LoginDto) {
- const { code } = body;
-
- return this.authService.getAccessToken(code);
- }
-
- @ApiOperation({ summary: "Refresh access token" })
- @ApiBody({ type: RefreshDto })
- @ApiResponse({
- status: 200,
- description: "Token refreshed successfully",
- schema: {
- type: "object",
- properties: {
- code: { type: "number", example: 0 },
- message: { type: "string", example: "success" },
- data: {
- type: "string",
- example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
- },
- },
- },
- })
- @ApiResponse({ status: 401, description: "Unauthorized" })
- @ApiBearerAuth("JWT-auth")
- @Post("refresh")
- async refresh(@Body() body: RefreshDto) {
- const { token } = body;
-
- return this.authService.refreshAccessToken(token);
- }
-}
diff --git a/packages/templates/nestjs-api/src/modules/auth/auth.guard.spec.ts b/packages/templates/nestjs-api/src/modules/auth/auth.guard.spec.ts
deleted file mode 100644
index 16e6620e..00000000
--- a/packages/templates/nestjs-api/src/modules/auth/auth.guard.spec.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import { UnauthorizedException } from "@nestjs/common";
-import { ConfigService } from "@nestjs/config";
-import { Reflector } from "@nestjs/core";
-import { JwtService } from "@nestjs/jwt";
-import { Test } from "@nestjs/testing";
-import { AuthGuard } from "./auth.guard";
-
-describe("AuthGuard", () => {
- let guard: AuthGuard;
- let jwtService: JwtService;
- let reflector: Reflector;
- let configService: ConfigService;
-
- beforeEach(async () => {
- const module = await Test.createTestingModule({
- providers: [
- AuthGuard,
- {
- provide: JwtService,
- useValue: {
- verifyAsync: jest.fn().mockResolvedValue({ userId: "test-user" }),
- },
- },
- {
- provide: Reflector,
- useValue: {
- getAllAndOverride: jest.fn().mockReturnValue(false),
- },
- },
- {
- provide: ConfigService,
- useValue: {
- get: jest.fn((key: string) => {
- if (key === "app.authGuardExcludePaths")
- return ["/metrics", "/health"];
- if (key === "jwt.secret") return "test-secret";
- return undefined;
- }),
- },
- },
- ],
- }).compile();
-
- guard = module.get(AuthGuard);
- jwtService = module.get(JwtService);
- reflector = module.get(Reflector);
- configService = module.get(ConfigService);
- });
-
- function createMockContext(path: string, authorization?: string) {
- const request: any = { path, headers: {} };
- if (authorization) {
- request.headers.authorization = authorization;
- }
- return {
- switchToHttp: () => ({ getRequest: () => request }),
- getHandler: () => jest.fn(),
- getClass: () => jest.fn(),
- } as any;
- }
-
- it("should allow access for exclude paths", async () => {
- const context = createMockContext("/metrics");
-
- const result = await guard.canActivate(context);
-
- expect(result).toBe(true);
- });
-
- it("should allow access when @Public() decorator is present", async () => {
- jest.spyOn(reflector, "getAllAndOverride").mockReturnValue(true);
- const context = createMockContext("/api/v1/auth/login");
-
- const result = await guard.canActivate(context);
-
- expect(result).toBe(true);
- });
-
- it("should throw UnauthorizedException when no token is present", async () => {
- const context = createMockContext("/api/v1/users");
-
- await expect(guard.canActivate(context)).rejects.toThrow(
- UnauthorizedException,
- );
- });
-
- it("should throw UnauthorizedException when Authorization header has no Bearer prefix", async () => {
- const context = createMockContext("/api/v1/users", "Basic some-token");
-
- await expect(guard.canActivate(context)).rejects.toThrow(
- UnauthorizedException,
- );
- });
-
- it("should throw UnauthorizedException when token verification fails", async () => {
- jest
- .spyOn(jwtService, "verifyAsync")
- .mockRejectedValue(new Error("invalid"));
- const context = createMockContext("/api/v1/users", "Bearer invalid-token");
-
- await expect(guard.canActivate(context)).rejects.toThrow(
- UnauthorizedException,
- );
- });
-
- it("should allow access and set request.user when token is valid", async () => {
- const context = createMockContext("/api/v1/users", "Bearer valid-token");
- const request = context.switchToHttp().getRequest();
-
- const result = await guard.canActivate(context);
-
- expect(result).toBe(true);
- expect(request.user).toEqual({ userId: "test-user" });
- expect(jwtService.verifyAsync).toHaveBeenCalledWith("valid-token", {
- secret: "test-secret",
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/modules/auth/auth.guard.ts b/packages/templates/nestjs-api/src/modules/auth/auth.guard.ts
deleted file mode 100644
index b3306512..00000000
--- a/packages/templates/nestjs-api/src/modules/auth/auth.guard.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import {
- type CanActivate,
- type ExecutionContext,
- Injectable,
- UnauthorizedException,
-} from "@nestjs/common";
-import { ConfigService } from "@nestjs/config";
-import { Reflector } from "@nestjs/core";
-import { JwtService } from "@nestjs/jwt";
-import type { Request } from "express";
-import { IS_PUBLIC_KEY } from "@/core/decorators/public.decorators";
-
-@Injectable()
-export class AuthGuard implements CanActivate {
- constructor(
- private jwtService: JwtService,
- private reflector: Reflector,
- private configService: ConfigService,
- ) {}
-
- async canActivate(context: ExecutionContext): Promise {
- const request = context.switchToHttp().getRequest();
- const excludePaths =
- this.configService.get("app.authGuardExcludePaths") || [];
-
- // Skip authentication for configured exclude paths (use request.path to ignore query strings)
- if (excludePaths.includes(request.path)) {
- return true;
- }
-
- const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
- context.getHandler(),
- context.getClass(),
- ]);
- if (isPublic) {
- return true;
- }
- const token = this.extractTokenFromHeader(request);
- if (!token) {
- throw new UnauthorizedException();
- }
- try {
- const payload = await this.jwtService.verifyAsync(token, {
- secret: this.configService.get("jwt.secret"),
- });
- request["user"] = payload;
- } catch {
- throw new UnauthorizedException();
- }
- return true;
- }
-
- private extractTokenFromHeader(request: Request): string | undefined {
- const [type, token] = request.headers.authorization?.split(" ") ?? [];
- return type === "Bearer" ? token : undefined;
- }
-}
diff --git a/packages/templates/nestjs-api/src/modules/auth/auth.module.ts b/packages/templates/nestjs-api/src/modules/auth/auth.module.ts
deleted file mode 100644
index 7712be0a..00000000
--- a/packages/templates/nestjs-api/src/modules/auth/auth.module.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Module } from "@nestjs/common";
-import { ConfigService } from "@nestjs/config";
-import { APP_GUARD } from "@nestjs/core";
-import { JwtModule } from "@nestjs/jwt";
-import { AuthController } from "./auth.controller";
-import { AuthGuard } from "./auth.guard";
-import { AuthService } from "./auth.service";
-
-@Module({
- imports: [
- JwtModule.registerAsync({
- useFactory: (configService: ConfigService) => ({
- global: true,
- secret: configService.get("jwt.secret"),
- signOptions: { expiresIn: configService.get("jwt.expiresIn") },
- }),
- inject: [ConfigService],
- }),
- ],
- controllers: [AuthController],
- providers: [
- AuthService,
- {
- provide: APP_GUARD,
- useClass: AuthGuard,
- },
- ],
- exports: [AuthService],
-})
-export class AuthModule {}
diff --git a/packages/templates/nestjs-api/src/modules/auth/auth.service.spec.ts b/packages/templates/nestjs-api/src/modules/auth/auth.service.spec.ts
deleted file mode 100644
index 927ec96f..00000000
--- a/packages/templates/nestjs-api/src/modules/auth/auth.service.spec.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { UnauthorizedException } from "@nestjs/common";
-import { JwtService } from "@nestjs/jwt";
-import { Test } from "@nestjs/testing";
-import { AuthService } from "./auth.service";
-
-describe("AuthService", () => {
- let authService: AuthService;
- let jwtService: JwtService;
-
- beforeEach(async () => {
- const module = await Test.createTestingModule({
- providers: [
- AuthService,
- {
- provide: JwtService,
- useValue: {
- sign: jest.fn().mockReturnValue("mock-token"),
- verify: jest.fn().mockReturnValue({ userId: "test-user" }),
- },
- },
- ],
- }).compile();
-
- authService = module.get(AuthService);
- jwtService = module.get(JwtService);
- });
-
- describe("getAccessToken", () => {
- it("should sign a token with userId from code", async () => {
- const result = await authService.getAccessToken("auth-code-123");
-
- expect(jwtService.sign).toHaveBeenCalledWith({ userId: "auth-code-123" });
- expect(result).toBe("mock-token");
- });
- });
-
- describe("refreshAccessToken", () => {
- it("should verify with ignoreExpiration and re-sign with clean payload", async () => {
- jest.spyOn(jwtService, "verify").mockReturnValue({
- userId: "test-user",
- iat: 1000,
- exp: 2000,
- } as any);
-
- const result = await authService.refreshAccessToken("old-token");
-
- expect(jwtService.verify).toHaveBeenCalledWith("old-token", {
- ignoreExpiration: true,
- });
- expect(jwtService.sign).toHaveBeenCalledWith({ userId: "test-user" });
- expect(result).toBe("mock-token");
- });
-
- it("should throw UnauthorizedException when verify throws", async () => {
- jest.spyOn(jwtService, "verify").mockImplementation(() => {
- throw new Error("invalid token");
- });
-
- await expect(authService.refreshAccessToken("bad-token")).rejects.toThrow(
- UnauthorizedException,
- );
- });
- });
-
- describe("verifyAccessToken", () => {
- it("should verify and return the payload", () => {
- const result = authService.verifyAccessToken("valid-token");
-
- expect(jwtService.verify).toHaveBeenCalledWith("valid-token");
- expect(result).toEqual({ userId: "test-user" });
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/modules/auth/auth.service.ts b/packages/templates/nestjs-api/src/modules/auth/auth.service.ts
deleted file mode 100644
index 57f17737..00000000
--- a/packages/templates/nestjs-api/src/modules/auth/auth.service.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Injectable, UnauthorizedException } from "@nestjs/common";
-import { JwtService } from "@nestjs/jwt";
-
-@Injectable()
-export class AuthService {
- constructor(private readonly jwtService: JwtService) {}
-
- async getAccessToken(code: string) {
- const payload = { userId: code };
- return this.jwtService.sign(payload);
- }
-
- async refreshAccessToken(token: string) {
- try {
- const payload = this.jwtService.verify(token, { ignoreExpiration: true });
- // 只保留业务字段,去除 JWT 元数据(iat、exp 等),重新签发
- const { iat, exp, nbf, jti, ...cleanPayload } = payload;
- return this.jwtService.sign(cleanPayload);
- } catch {
- throw new UnauthorizedException("Invalid token");
- }
- }
-
- verifyAccessToken(token: string) {
- return this.jwtService.verify(token);
- }
-}
diff --git a/packages/templates/nestjs-api/src/modules/auth/dto/login.dto.ts b/packages/templates/nestjs-api/src/modules/auth/dto/login.dto.ts
deleted file mode 100644
index 3b38dbb4..00000000
--- a/packages/templates/nestjs-api/src/modules/auth/dto/login.dto.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { ApiProperty } from "@nestjs/swagger";
-import { IsNotEmpty, IsString } from "class-validator";
-
-export class LoginDto {
- @ApiProperty({
- description: "Authorization code for login",
- example: "auth_code_123",
- })
- @IsString()
- @IsNotEmpty()
- code!: string;
-}
diff --git a/packages/templates/nestjs-api/src/modules/auth/dto/refresh.dto.ts b/packages/templates/nestjs-api/src/modules/auth/dto/refresh.dto.ts
deleted file mode 100644
index c3f54100..00000000
--- a/packages/templates/nestjs-api/src/modules/auth/dto/refresh.dto.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { ApiProperty } from "@nestjs/swagger";
-import { IsNotEmpty, IsString } from "class-validator";
-
-export class RefreshDto {
- @ApiProperty({
- description: "JWT refresh token",
- example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
- })
- @IsString()
- @IsNotEmpty()
- token!: string;
-}
diff --git a/packages/templates/nestjs-api/src/modules/common/common.controller.spec.ts b/packages/templates/nestjs-api/src/modules/common/common.controller.spec.ts
deleted file mode 100644
index 3770c0a3..00000000
--- a/packages/templates/nestjs-api/src/modules/common/common.controller.spec.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-jest.mock("@/shared/utils", () => ({
- logger: {
- info: jest.fn(),
- error: jest.fn(),
- warn: jest.fn(),
- debug: jest.fn(),
- },
-}));
-
-import { Test } from "@nestjs/testing";
-import { CommonController } from "./common.controller";
-import { CommonService } from "./common.service";
-
-describe("CommonController", () => {
- let controller: CommonController;
- let commonService: CommonService;
-
- beforeEach(async () => {
- const module = await Test.createTestingModule({
- controllers: [CommonController],
- providers: [
- {
- provide: CommonService,
- useValue: {
- getHello: jest.fn().mockReturnValue("Hello Alice!"),
- },
- },
- ],
- }).compile();
-
- controller = module.get(CommonController);
- commonService = module.get(CommonService);
- });
-
- describe("getHello", () => {
- it("should call commonService.getHello with user", () => {
- const result = controller.getHello({ user: "Alice" });
-
- expect(commonService.getHello).toHaveBeenCalledWith("Alice");
- expect(result).toBe("Hello Alice!");
- });
- });
-
- describe("test", () => {
- it('should return "this endpoint is public"', () => {
- expect(controller.test()).toBe("this endpoint is public");
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/modules/common/common.controller.ts b/packages/templates/nestjs-api/src/modules/common/common.controller.ts
deleted file mode 100644
index 9ab11dac..00000000
--- a/packages/templates/nestjs-api/src/modules/common/common.controller.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { Body, Controller, HttpCode, Post } from "@nestjs/common";
-import {
- ApiBearerAuth,
- ApiBody,
- ApiOperation,
- ApiResponse,
- ApiTags,
-} from "@nestjs/swagger";
-import { Public } from "@/core/decorators/public.decorators";
-import { logger } from "@/shared/utils";
-import { CommonService } from "./common.service";
-import { HelloDto } from "./dto/hello.dto";
-
-@ApiTags("common")
-@Controller("common")
-export class CommonController {
- constructor(private readonly commonService: CommonService) {}
-
- @ApiOperation({ summary: "Get personalized greeting" })
- @ApiBody({ type: HelloDto })
- @ApiResponse({
- status: 200,
- description: "Greeting message retrieved successfully",
- schema: {
- type: "object",
- properties: {
- code: { type: "number", example: 0 },
- message: { type: "string", example: "success" },
- data: { type: "string", example: "Hello john_doe!" },
- },
- },
- })
- @ApiResponse({ status: 401, description: "Unauthorized" })
- @ApiBearerAuth("JWT-auth")
- @Post()
- @HttpCode(200)
- getHello(@Body() body: HelloDto): string {
- const { user } = body;
-
- logger.info("这是一条 Pino 普通日志");
-
- return this.commonService.getHello(user);
- }
-
- @ApiOperation({ summary: "Public test endpoint" })
- @ApiResponse({
- status: 200,
- description: "Public endpoint test",
- schema: {
- type: "object",
- properties: {
- code: { type: "number", example: 0 },
- message: { type: "string", example: "success" },
- data: { type: "string", example: "this endpoint is public" },
- },
- },
- })
- @Post("public")
- @HttpCode(200)
- @Public()
- test() {
- return "this endpoint is public";
- }
-}
diff --git a/packages/templates/nestjs-api/src/modules/common/common.module.ts b/packages/templates/nestjs-api/src/modules/common/common.module.ts
deleted file mode 100644
index 65e942c4..00000000
--- a/packages/templates/nestjs-api/src/modules/common/common.module.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { Module } from "@nestjs/common";
-import { CommonController } from "./common.controller";
-import { CommonService } from "./common.service";
-
-@Module({
- controllers: [CommonController],
- providers: [CommonService],
-})
-export class CommonModule {}
diff --git a/packages/templates/nestjs-api/src/modules/common/common.service.spec.ts b/packages/templates/nestjs-api/src/modules/common/common.service.spec.ts
deleted file mode 100644
index db09cb46..00000000
--- a/packages/templates/nestjs-api/src/modules/common/common.service.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { CommonService } from "./common.service";
-
-describe("CommonService", () => {
- let service: CommonService;
-
- beforeEach(() => {
- service = new CommonService();
- });
-
- describe("getHello", () => {
- it('should return "Hello World!" when called with "World"', () => {
- expect(service.getHello("World")).toBe("Hello World!");
- });
-
- it('should return "Hello !" when called with empty string', () => {
- expect(service.getHello("")).toBe("Hello !");
- });
-
- it("should correctly interpolate the user parameter", () => {
- expect(service.getHello("Alice")).toBe("Hello Alice!");
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/modules/common/common.service.ts b/packages/templates/nestjs-api/src/modules/common/common.service.ts
deleted file mode 100644
index 49a8fb80..00000000
--- a/packages/templates/nestjs-api/src/modules/common/common.service.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Injectable } from "@nestjs/common";
-
-@Injectable()
-export class CommonService {
- getHello(user: string): string {
- return `Hello ${user}!`;
- }
-}
diff --git a/packages/templates/nestjs-api/src/modules/common/dto/hello.dto.ts b/packages/templates/nestjs-api/src/modules/common/dto/hello.dto.ts
deleted file mode 100644
index 986558f5..00000000
--- a/packages/templates/nestjs-api/src/modules/common/dto/hello.dto.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { ApiProperty } from "@nestjs/swagger";
-import { IsNotEmpty, IsString } from "class-validator";
-
-export class HelloDto {
- @ApiProperty({
- description: "User name for greeting",
- example: "john_doe",
- })
- @IsString()
- @IsNotEmpty()
- user!: string;
-}
diff --git a/packages/templates/nestjs-api/src/modules/health/health.controller.spec.ts b/packages/templates/nestjs-api/src/modules/health/health.controller.spec.ts
deleted file mode 100644
index 46669dba..00000000
--- a/packages/templates/nestjs-api/src/modules/health/health.controller.spec.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Test } from "@nestjs/testing";
-import { HealthController } from "./health.controller";
-import { HealthService } from "./health.service";
-
-describe("HealthController", () => {
- let controller: HealthController;
- let healthService: HealthService;
-
- const mockHealthStatus = {
- status: "ok",
- timestamp: "2024-01-15T00:00:00.000Z",
- uptime: "100s",
- environment: "test",
- version: "0.1.0",
- memory: { used: "50MB", total: "100MB" },
- database: { status: "connected" },
- };
-
- beforeEach(async () => {
- const module = await Test.createTestingModule({
- controllers: [HealthController],
- providers: [
- {
- provide: HealthService,
- useValue: {
- getHealthStatus: jest.fn().mockResolvedValue(mockHealthStatus),
- },
- },
- ],
- }).compile();
-
- controller = module.get(HealthController);
- healthService = module.get(HealthService);
- });
-
- describe("getHealth", () => {
- it("should call healthService.getHealthStatus and return result", async () => {
- const result = await controller.getHealth();
-
- expect(healthService.getHealthStatus).toHaveBeenCalled();
- expect(result).toEqual(mockHealthStatus);
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/modules/health/health.controller.ts b/packages/templates/nestjs-api/src/modules/health/health.controller.ts
deleted file mode 100644
index 13571d6f..00000000
--- a/packages/templates/nestjs-api/src/modules/health/health.controller.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Controller, Get } from "@nestjs/common";
-import { HealthService } from "./health.service";
-
-@Controller()
-export class HealthController {
- constructor(private readonly healthService: HealthService) {}
-
- @Get("health")
- getHealth() {
- return this.healthService.getHealthStatus();
- }
-}
diff --git a/packages/templates/nestjs-api/src/modules/health/health.module.ts b/packages/templates/nestjs-api/src/modules/health/health.module.ts
deleted file mode 100644
index bf948349..00000000
--- a/packages/templates/nestjs-api/src/modules/health/health.module.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { Module } from "@nestjs/common";
-import { HealthController } from "./health.controller";
-import { HealthService } from "./health.service";
-
-@Module({
- controllers: [HealthController],
- providers: [HealthService],
-})
-export class HealthModule {}
diff --git a/packages/templates/nestjs-api/src/modules/health/health.service.spec.ts b/packages/templates/nestjs-api/src/modules/health/health.service.spec.ts
deleted file mode 100644
index 14a6c750..00000000
--- a/packages/templates/nestjs-api/src/modules/health/health.service.spec.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { HealthService } from "./health.service";
-
-describe("HealthService", () => {
- let service: HealthService;
- let mockDrizzle: any;
-
- beforeEach(() => {
- mockDrizzle = {
- isConfigured: jest.fn().mockReturnValue(true),
- isHealthy: jest.fn().mockResolvedValue(true),
- };
- service = new HealthService(mockDrizzle);
- });
-
- describe("getHealthStatus", () => {
- it('should return status "ok" when database is healthy', async () => {
- const result = await service.getHealthStatus();
- expect(result.status).toBe("ok");
- expect(result.database.status).toBe("connected");
- });
-
- it('should return status "degraded" when database is unhealthy', async () => {
- mockDrizzle.isHealthy.mockResolvedValue(false);
- const result = await service.getHealthStatus();
- expect(result.status).toBe("degraded");
- expect(result.database.status).toBe("disconnected");
- });
-
- it("should include a valid ISO timestamp", async () => {
- const result = await service.getHealthStatus();
- expect(() => new Date(result.timestamp)).not.toThrow();
- expect(new Date(result.timestamp).toISOString()).toBe(result.timestamp);
- });
-
- it('should include uptime ending with "s"', async () => {
- const result = await service.getHealthStatus();
- expect(result.uptime).toMatch(/^\d+s$/);
- });
-
- it('should include memory.used and memory.total ending with "MB"', async () => {
- const result = await service.getHealthStatus();
- expect(result.memory.used).toMatch(/^\d+MB$/);
- expect(result.memory.total).toMatch(/^\d+MB$/);
- });
-
- it("should return environment from NODE_ENV", async () => {
- const result = await service.getHealthStatus();
- expect(result.environment).toBeDefined();
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/modules/health/health.service.ts b/packages/templates/nestjs-api/src/modules/health/health.service.ts
deleted file mode 100644
index a73177bf..00000000
--- a/packages/templates/nestjs-api/src/modules/health/health.service.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Injectable } from "@nestjs/common";
-import { DrizzleService } from "@/service/drizzle/drizzle.service";
-
-@Injectable()
-export class HealthService {
- constructor(private readonly drizzle: DrizzleService) {}
-
- async getHealthStatus() {
- const dbConfigured = this.drizzle.isConfigured();
- const dbHealthy = await this.drizzle.isHealthy();
- const uptime = process.uptime();
-
- // PGlite fallback (dbConfigured=false) is always healthy as long as
- // the process is up. Real Postgres can degrade when DATABASE_URL is
- // set but the server is unreachable.
- const status = dbHealthy ? "ok" : "degraded";
-
- return {
- status,
- timestamp: new Date().toISOString(),
- uptime: `${Math.floor(uptime)}s`,
- environment: process.env.NODE_ENV || "development",
- version: process.env.npm_package_version || "0.1.0",
- memory: {
- used: `${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`,
- total: `${Math.round(process.memoryUsage().heapTotal / 1024 / 1024)}MB`,
- },
- database: {
- driver: dbConfigured ? "postgres" : "pglite",
- status: dbHealthy ? "connected" : "disconnected",
- },
- };
- }
-}
diff --git a/packages/templates/nestjs-api/src/modules/metrics/metrics.module.ts b/packages/templates/nestjs-api/src/modules/metrics/metrics.module.ts
deleted file mode 100644
index ab466295..00000000
--- a/packages/templates/nestjs-api/src/modules/metrics/metrics.module.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Module } from "@nestjs/common";
-import {
- makeCounterProvider,
- makeHistogramProvider,
-} from "@willsoto/nestjs-prometheus";
-
-const httpRequestsCounter = makeCounterProvider({
- name: "http_requests_total",
- help: "Total number of HTTP requests",
- labelNames: ["method", "route", "status"],
-});
-
-const httpRequestDurationHistogram = makeHistogramProvider({
- name: "http_request_duration_seconds",
- help: "Duration of HTTP requests in seconds",
- labelNames: ["method", "route", "status"],
- buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
-});
-
-const appInfoCounter = makeCounterProvider({
- name: "app_info",
- help: "Application information",
- labelNames: ["version", "environment"],
-});
-
-@Module({
- providers: [
- httpRequestsCounter,
- httpRequestDurationHistogram,
- appInfoCounter,
- ],
- exports: [httpRequestsCounter, httpRequestDurationHistogram, appInfoCounter],
-})
-export class MetricsModule {}
diff --git a/packages/templates/nestjs-api/src/modules/user/dto/create-user.dto.ts b/packages/templates/nestjs-api/src/modules/user/dto/create-user.dto.ts
deleted file mode 100644
index c86fe2e1..00000000
--- a/packages/templates/nestjs-api/src/modules/user/dto/create-user.dto.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-// DTO 的作用:用于数据校验、转换和格式化,保障接口数据的一致性和安全性。
-// 目录结构调整:建议在每个业务模块内部增加 dto 文件夹,和其他层(controllers、services、domain、repositories)并列,既保持了分层架构的清晰,又便于维护和扩展。
-
-import { ApiProperty } from "@nestjs/swagger";
-import { IsString, Length } from "class-validator";
-
-export class CreateUserDto {
- @ApiProperty({
- description: "User name",
- example: "john_doe",
- minLength: 2,
- maxLength: 50,
- })
- @IsString()
- @Length(2, 50)
- name!: string;
-}
diff --git a/packages/templates/nestjs-api/src/modules/user/user.controller.spec.ts b/packages/templates/nestjs-api/src/modules/user/user.controller.spec.ts
deleted file mode 100644
index 589e8245..00000000
--- a/packages/templates/nestjs-api/src/modules/user/user.controller.spec.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Test } from "@nestjs/testing";
-import { User } from "@/dao/entities/user.entity";
-import { PaginatedResult } from "@/shared/dto/pagination.dto";
-import { UserController } from "./user.controller";
-import { UserService } from "./user.service";
-
-describe("UserController", () => {
- let controller: UserController;
- let userService: UserService;
-
- const mockPaginatedResult = new PaginatedResult(
- [new User("1", "Alice"), new User("2", "Bob")],
- 2,
- 1,
- 20,
- );
-
- beforeEach(async () => {
- const module = await Test.createTestingModule({
- controllers: [UserController],
- providers: [
- {
- provide: UserService,
- useValue: {
- getAllUsers: jest.fn().mockResolvedValue(mockPaginatedResult),
- createUser: jest.fn().mockResolvedValue(new User("3", "Charlie")),
- },
- },
- ],
- }).compile();
-
- controller = module.get(UserController);
- userService = module.get(UserService);
- });
-
- describe("getUsers", () => {
- it("should return paginated users", async () => {
- const result = await controller.getUsers({ page: 1, pageSize: 20 });
-
- expect(userService.getAllUsers).toHaveBeenCalledWith(1, 20);
- expect(result.items).toHaveLength(2);
- expect(result.total).toBe(2);
- });
- });
-
- describe("createUser", () => {
- it("should create user with dto name", async () => {
- const result = await controller.createUser({ name: "Charlie" } as any);
-
- expect(userService.createUser).toHaveBeenCalledWith("Charlie");
- expect(result.name).toBe("Charlie");
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/modules/user/user.controller.ts b/packages/templates/nestjs-api/src/modules/user/user.controller.ts
deleted file mode 100644
index f6f3be08..00000000
--- a/packages/templates/nestjs-api/src/modules/user/user.controller.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Body, Controller, Get, Post, Query } from "@nestjs/common";
-import {
- ApiBearerAuth,
- ApiBody,
- ApiOperation,
- ApiQuery,
- ApiResponse,
- ApiTags,
-} from "@nestjs/swagger";
-import type { User } from "@/dao/entities/user.entity";
-import { PaginatedResult, PaginationDto } from "@/shared/dto/pagination.dto";
-import { CreateUserDto } from "./dto/create-user.dto";
-import { UserService } from "./user.service";
-
-@ApiTags("users")
-@ApiBearerAuth("JWT-auth")
-@Controller("users")
-export class UserController {
- constructor(private readonly userService: UserService) {}
-
- @ApiOperation({ summary: "Get paginated user list" })
- @ApiQuery({ name: "page", required: false, example: 1 })
- @ApiQuery({ name: "pageSize", required: false, example: 20 })
- @ApiResponse({ status: 200, description: "User list retrieved successfully" })
- @Get()
- async getUsers(
- @Query() query: PaginationDto,
- ): Promise> {
- return this.userService.getAllUsers(query.page, query.pageSize);
- }
-
- @ApiOperation({ summary: "Create a new user" })
- @ApiBody({ type: CreateUserDto })
- @ApiResponse({ status: 201, description: "User created successfully" })
- @ApiResponse({ status: 400, description: "Bad request" })
- @Post()
- async createUser(@Body() createUserDto: CreateUserDto): Promise {
- return this.userService.createUser(createUserDto.name);
- }
-}
diff --git a/packages/templates/nestjs-api/src/modules/user/user.module.ts b/packages/templates/nestjs-api/src/modules/user/user.module.ts
deleted file mode 100644
index 823c8764..00000000
--- a/packages/templates/nestjs-api/src/modules/user/user.module.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-// 为了让 NestJS 能够识别并加载以上组件,需要将各层模块组合到一个模块中,并在根模块中引入。
-// 在根模块中引入 UserModule,以便 NestJS 能够加载 UserModule 中的组件。
-/**
- import { Module } from '@nestjs/common';
- import { UserModule } from './modules/user/user.module';
- // 其它模块,如 AuthModule、CoreModule 等
-
- @Module({
- imports: [UserModule],
- })
- export class AppModule {}
- */
-import { Module } from "@nestjs/common";
-import { UserController } from "./user.controller";
-import { UserService } from "./user.service";
-import { UserRepository } from "../../dao/repositories/user.repository";
-
-@Module({
- controllers: [UserController],
- providers: [UserService, UserRepository],
-})
-export class UserModule {}
diff --git a/packages/templates/nestjs-api/src/modules/user/user.service.spec.ts b/packages/templates/nestjs-api/src/modules/user/user.service.spec.ts
deleted file mode 100644
index c4ffdd6b..00000000
--- a/packages/templates/nestjs-api/src/modules/user/user.service.spec.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { User } from "@/dao/entities/user.entity";
-import { UserService } from "./user.service";
-
-describe("UserService", () => {
- let service: UserService;
- let mockUserRepository: any;
-
- beforeEach(() => {
- mockUserRepository = {
- findAll: jest.fn().mockResolvedValue({
- items: [new User("1", "Alice"), new User("2", "Bob")],
- total: 2,
- }),
- create: jest.fn().mockResolvedValue(new User("3", "Charlie")),
- };
-
- service = new UserService(mockUserRepository);
- });
-
- describe("getAllUsers", () => {
- it("should return paginated users from repository", async () => {
- const result = await service.getAllUsers(1, 20);
-
- expect(mockUserRepository.findAll).toHaveBeenCalledWith(1, 20);
- expect(result.items).toHaveLength(2);
- expect(result.items[0].name).toBe("Alice");
- expect(result.total).toBe(2);
- expect(result.page).toBe(1);
- expect(result.pageSize).toBe(20);
- expect(result.totalPages).toBe(1);
- });
-
- it("should return empty result when no users exist", async () => {
- mockUserRepository.findAll.mockResolvedValue({ items: [], total: 0 });
-
- const result = await service.getAllUsers(1, 20);
-
- expect(result.items).toEqual([]);
- expect(result.total).toBe(0);
- });
- });
-
- describe("createUser", () => {
- it("should create user via repository", async () => {
- const result = await service.createUser("Charlie");
-
- expect(mockUserRepository.create).toHaveBeenCalledWith("Charlie");
- expect(result.name).toBe("Charlie");
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/modules/user/user.service.ts b/packages/templates/nestjs-api/src/modules/user/user.service.ts
deleted file mode 100644
index eae4c3b4..00000000
--- a/packages/templates/nestjs-api/src/modules/user/user.service.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-// 负责业务流程的编排,调用 Repository 完成具体数据操作。这里的 UserService 对外提供获取用户列表和创建用户的业务逻辑。
-import { Injectable } from "@nestjs/common";
-import type { User } from "@/dao/entities/user.entity";
-// 不能用 `import type`:UserRepository 是构造函数注入的 DI token,
-// NestJS 依靠 emitDecoratorMetadata 反射出参数类型;type-only import
-// 会被 TS 编译时擦除,运行时反射拿到 Function,触发
-// UnknownDependenciesException。
-import { UserRepository } from "@/dao/repositories/user.repository";
-import { PaginatedResult } from "@/shared/dto/pagination.dto";
-
-@Injectable()
-export class UserService {
- constructor(private readonly userRepository: UserRepository) {}
-
- async getAllUsers(
- page: number,
- pageSize: number,
- ): Promise> {
- const { items, total } = await this.userRepository.findAll(page, pageSize);
- return new PaginatedResult(items, total, page, pageSize);
- }
-
- async createUser(name: string): Promise {
- return this.userRepository.create(name);
- }
-}
diff --git a/packages/templates/nestjs-api/src/service/drizzle/drizzle.module.ts b/packages/templates/nestjs-api/src/service/drizzle/drizzle.module.ts
deleted file mode 100644
index 900553a6..00000000
--- a/packages/templates/nestjs-api/src/service/drizzle/drizzle.module.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { Global, Module } from "@nestjs/common";
-import { DrizzleService } from "./drizzle.service";
-
-@Global()
-@Module({
- providers: [DrizzleService],
- exports: [DrizzleService],
-})
-export class DrizzleModule {}
diff --git a/packages/templates/nestjs-api/src/service/drizzle/drizzle.service.spec.ts b/packages/templates/nestjs-api/src/service/drizzle/drizzle.service.spec.ts
deleted file mode 100644
index ea304b29..00000000
--- a/packages/templates/nestjs-api/src/service/drizzle/drizzle.service.spec.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { DrizzleService } from "./drizzle.service";
-
-describe("DrizzleService", () => {
- const originalUrl = process.env.DATABASE_URL;
-
- afterEach(() => {
- if (originalUrl === undefined) {
- delete process.env.DATABASE_URL;
- } else {
- process.env.DATABASE_URL = originalUrl;
- }
- });
-
- describe("when DATABASE_URL is empty (PGlite fallback)", () => {
- beforeEach(() => {
- delete process.env.DATABASE_URL;
- });
-
- it("constructs without throwing and exposes a usable db handle", async () => {
- const svc = new DrizzleService();
- expect(svc.db).toBeDefined();
- expect(svc.isConfigured()).toBe(false);
- await svc.onModuleDestroy();
- });
-
- it("onModuleInit bootstraps the schema in-memory", async () => {
- const svc = new DrizzleService();
- await expect(svc.onModuleInit()).resolves.toBeUndefined();
- await svc.onModuleDestroy();
- });
-
- it("isHealthy returns true once PGlite is ready", async () => {
- const svc = new DrizzleService();
- await expect(svc.isHealthy()).resolves.toBe(true);
- await svc.onModuleDestroy();
- });
- });
-
- describe("when DATABASE_URL is set", () => {
- beforeEach(() => {
- // Non-reachable host — Pool construction is lazy (no TCP). Tests
- // here exercise the configured branch without dialing.
- process.env.DATABASE_URL = "postgresql://test:test@127.0.0.1:1/test";
- });
-
- it("constructs the Postgres driver and marks itself configured", async () => {
- const svc = new DrizzleService();
- expect(svc.db).toBeDefined();
- expect(svc.isConfigured()).toBe(true);
- await svc.onModuleDestroy();
- });
- });
-
- describe("when DATABASE_URL is whitespace", () => {
- beforeEach(() => {
- process.env.DATABASE_URL = " ";
- });
-
- it("is treated as empty and falls back to PGlite", async () => {
- const svc = new DrizzleService();
- expect(svc.isConfigured()).toBe(false);
- await svc.onModuleDestroy();
- });
- });
-});
diff --git a/packages/templates/nestjs-api/src/service/drizzle/drizzle.service.ts b/packages/templates/nestjs-api/src/service/drizzle/drizzle.service.ts
deleted file mode 100644
index 7c882ed6..00000000
--- a/packages/templates/nestjs-api/src/service/drizzle/drizzle.service.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import {
- Injectable,
- Logger,
- type OnModuleDestroy,
- type OnModuleInit,
-} from "@nestjs/common";
-import { PGlite } from "@electric-sql/pglite";
-import { sql } from "drizzle-orm";
-import {
- drizzle as drizzleNodePg,
- type NodePgDatabase,
-} from "drizzle-orm/node-postgres";
-import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
-import { Pool } from "pg";
-import * as schema from "./schema";
-
-/**
- * DrizzleService picks a driver based on DATABASE_URL.
- *
- * - DATABASE_URL set: connect to real Postgres via pg.Pool.
- * - DATABASE_URL empty: spin up an in-memory PGlite (Postgres compiled
- * to WASM, runs in-process — no daemon, no Docker, no extra install).
- * Schema is bootstrapped via plain CREATE TABLE IF NOT EXISTS so the
- * first `one dev` succeeds and endpoints actually work, just with
- * ephemeral data.
- *
- * Repositories always read `this.drizzle.db`. The public API is a
- * single field; the dual-driver branching is private.
- */
-@Injectable()
-export class DrizzleService implements OnModuleInit, OnModuleDestroy {
- private readonly logger = new Logger(DrizzleService.name);
- private readonly mode: "postgres" | "pglite";
- private readonly pool?: Pool;
- private readonly pglite?: PGlite;
- public readonly db: NodePgDatabase;
-
- constructor() {
- const url = process.env.DATABASE_URL?.trim();
- if (url) {
- this.mode = "postgres";
- this.pool = new Pool({ connectionString: url });
- this.db = drizzleNodePg(this.pool, { schema });
- } else {
- this.mode = "pglite";
- this.pglite = new PGlite();
- // PgliteDatabase shares the query-builder API with NodePgDatabase;
- // we cast so repositories type-check against a single shape.
- this.db = drizzlePglite(this.pglite, {
- schema,
- }) as unknown as NodePgDatabase;
- this.logger.warn(
- "DATABASE_URL 未设置,使用内存 PGlite 作为开发兜底。" +
- "数据不持久化(每次重启都清空);需要持久化时" +
- "`one env set DATABASE_URL=postgres://... -p `",
- );
- }
- }
-
- async onModuleInit() {
- if (this.mode === "pglite") {
- await this.bootstrapPgliteSchema();
- this.logger.log("PGlite (in-memory) ready");
- return;
- }
- try {
- const client = await this.pool!.connect();
- client.release();
- this.logger.log("Database connection established");
- } catch (error) {
- this.logger.error(
- "Failed to connect to database",
- (error as Error).stack,
- );
- throw error;
- }
- }
-
- async onModuleDestroy() {
- if (this.mode === "pglite") {
- await this.pglite!.close();
- return;
- }
- await this.pool!.end();
- this.logger.log("Database connection closed");
- }
-
- async isHealthy(): Promise {
- if (this.mode === "pglite") return true;
- try {
- const client = await this.pool!.connect();
- client.release();
- return true;
- } catch {
- return false;
- }
- }
-
- /** isConfigured returns true when a real DATABASE_URL was set. */
- isConfigured(): boolean {
- return this.mode === "postgres";
- }
-
- /**
- * bootstrapPgliteSchema applies the schema DDL to a fresh in-memory
- * PGlite instance. We hand-write the DDL here because drizzle-kit
- * migrations aren't generated for this template. Keep this block in
- * sync with `schema.ts` whenever you add a table.
- */
- private async bootstrapPgliteSchema(): Promise {
- await this.db.execute(sql`
- CREATE TABLE IF NOT EXISTS "users" (
- "id" text PRIMARY KEY,
- "name" varchar(255) NOT NULL UNIQUE,
- "created" timestamp(3) DEFAULT now() NOT NULL,
- "updated" timestamp(3) NOT NULL
- )
- `);
- }
-}
diff --git a/packages/templates/nestjs-api/src/service/drizzle/schema.ts b/packages/templates/nestjs-api/src/service/drizzle/schema.ts
deleted file mode 100644
index e18fdc9d..00000000
--- a/packages/templates/nestjs-api/src/service/drizzle/schema.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { pgTable, text, varchar, timestamp } from "drizzle-orm/pg-core";
-
-// users 表定义
-export const users = pgTable("users", {
- id: text("id")
- .primaryKey()
- .$defaultFn(() => randomUUID()),
- name: varchar("name", { length: 255 }).unique().notNull(),
- created: timestamp("created", { precision: 3 }).defaultNow().notNull(),
- updated: timestamp("updated", { precision: 3 })
- .notNull()
- .$onUpdate(() => new Date()),
-});
-
-// 推断类型
-export type UserSelect = typeof users.$inferSelect;
-export type UserInsert = typeof users.$inferInsert;
diff --git a/packages/templates/nestjs-api/src/shared/dto/pagination.dto.ts b/packages/templates/nestjs-api/src/shared/dto/pagination.dto.ts
deleted file mode 100644
index edc6efcf..00000000
--- a/packages/templates/nestjs-api/src/shared/dto/pagination.dto.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { ApiProperty } from "@nestjs/swagger";
-import { IsInt, IsOptional, Max, Min } from "class-validator";
-
-export class PaginationDto {
- @ApiProperty({ description: "Page number", example: 1, required: false })
- @IsOptional()
- @IsInt()
- @Min(1)
- page: number = 1;
-
- @ApiProperty({ description: "Items per page", example: 20, required: false })
- @IsOptional()
- @IsInt()
- @Min(1)
- @Max(100)
- pageSize: number = 20;
-}
-
-export class PaginatedResult {
- items: T[];
- total: number;
- page: number;
- pageSize: number;
- totalPages: number;
-
- constructor(items: T[], total: number, page: number, pageSize: number) {
- this.items = items;
- this.total = total;
- this.page = page;
- this.pageSize = pageSize;
- this.totalPages = Math.ceil(total / pageSize);
- }
-}
diff --git a/packages/templates/nestjs-api/src/shared/interfaces/.gitkeep b/packages/templates/nestjs-api/src/shared/interfaces/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/packages/templates/nestjs-api/src/shared/types/.gitkeep b/packages/templates/nestjs-api/src/shared/types/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/packages/templates/nestjs-api/src/shared/utils/index.spec.ts b/packages/templates/nestjs-api/src/shared/utils/index.spec.ts
deleted file mode 100644
index 5bb8f994..00000000
--- a/packages/templates/nestjs-api/src/shared/utils/index.spec.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { safeParseJSON } from "./index";
-
-describe("safeParseJSON", () => {
- it("should parse valid JSON string", () => {
- const result = safeParseJSON('{"name":"Alice","age":30}');
- expect(result).toEqual({ name: "Alice", age: 30 });
- });
-
- it("should return {} for invalid JSON string", () => {
- const result = safeParseJSON("not valid json");
- expect(result).toEqual({});
- });
-
- it("should return {} for undefined input", () => {
- const result = safeParseJSON(undefined);
- expect(result).toEqual({});
- });
-
- it("should return {} for empty string", () => {
- const result = safeParseJSON("");
- expect(result).toEqual({});
- });
-
- it("should parse JSON array", () => {
- const result = safeParseJSON("[1,2,3]");
- expect(result).toEqual([1, 2, 3]);
- });
-});
diff --git a/packages/templates/nestjs-api/src/shared/utils/index.ts b/packages/templates/nestjs-api/src/shared/utils/index.ts
deleted file mode 100644
index 35a1d33e..00000000
--- a/packages/templates/nestjs-api/src/shared/utils/index.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-// safeParseJSON
-export function safeParseJSON(str?: string): any {
- if (!str) {
- return {};
- }
- try {
- return JSON.parse(str);
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- } catch (e) {
- return {};
- }
-}
-
-export { logger, logInfo, logError, logWarn, logDebug } from "./logger";
diff --git a/packages/templates/nestjs-api/src/shared/utils/logger.ts b/packages/templates/nestjs-api/src/shared/utils/logger.ts
deleted file mode 100644
index b41016a8..00000000
--- a/packages/templates/nestjs-api/src/shared/utils/logger.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import pino from "pino";
-import { join } from "path";
-
-const logsDir = join(process.cwd(), "logs");
-
-const transport = pino.transport({
- targets: [
- {
- // 控制台输出,使用 pino-pretty 美化
- target: "pino-pretty",
- options: {
- colorize: true,
- levelFirst: true,
- translateTime: "yyyy-mm-dd HH:MM:ss",
- messageFormat: "{level} {msg}",
- },
- },
- {
- // 错误日志 - 按日期切分,保留 30 天
- target: "pino-roll",
- level: "error",
- options: {
- file: join(logsDir, "error", "error"),
- frequency: "daily",
- dateFormat: "yyyy-MM-dd",
- mkdir: true,
- limit: { count: 30 },
- },
- },
- {
- // 警告日志 - 按日期切分,保留 30 天
- target: "pino-roll",
- level: "warn",
- options: {
- file: join(logsDir, "warn", "warn"),
- frequency: "daily",
- dateFormat: "yyyy-MM-dd",
- mkdir: true,
- limit: { count: 30 },
- },
- },
- {
- // 信息日志 - 按日期切分,保留 30 天
- target: "pino-roll",
- level: "info",
- options: {
- file: join(logsDir, "info", "info"),
- frequency: "daily",
- dateFormat: "yyyy-MM-dd",
- mkdir: true,
- limit: { count: 30 },
- },
- },
- {
- // 调试日志 - 按日期切分,保留 7 天
- target: "pino-roll",
- level: "debug",
- options: {
- file: join(logsDir, "debug", "debug"),
- frequency: "daily",
- dateFormat: "yyyy-MM-dd",
- mkdir: true,
- limit: { count: 7 },
- },
- },
- ],
-});
-
-export const logger = pino(
- {
- level: process.env.NODE_ENV === "production" ? "info" : "debug",
- },
- transport,
-);
-
-// 导出常用的日志方法
-export const logInfo = (msg: string, ...args: any[]) =>
- logger.info(msg, ...args);
-export const logError = (msg: string, ...args: any[]) =>
- logger.error(msg, ...args);
-export const logWarn = (msg: string, ...args: any[]) =>
- logger.warn(msg, ...args);
-export const logDebug = (msg: string, ...args: any[]) =>
- logger.debug(msg, ...args);
diff --git a/packages/templates/nestjs-api/test/app.e2e-spec.ts b/packages/templates/nestjs-api/test/app.e2e-spec.ts
index 5236a3cf..3a3f9495 100644
--- a/packages/templates/nestjs-api/test/app.e2e-spec.ts
+++ b/packages/templates/nestjs-api/test/app.e2e-spec.ts
@@ -1,31 +1,41 @@
import type { INestApplication } from "@nestjs/common";
-import { Test, type TestingModule } from "@nestjs/testing";
+import { Test } from "@nestjs/testing";
import request from "supertest";
-import { AppModule } from "./../src/app.module";
+import { AppModule } from "../src/app.module";
-describe("AppController (e2e)", () => {
+describe("Starter API", () => {
let app: INestApplication;
-
- beforeEach(async () => {
- const moduleFixture: TestingModule = await Test.createTestingModule({
+ beforeAll(async () => {
+ const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
-
- app = moduleFixture.createNestApplication();
+ app = module.createNestApplication();
await app.init();
});
-
- afterEach(async () => {
+ afterAll(async () => {
await app.close();
});
-
- it("/ (GET)", () => {
- return request(app.getHttpServer())
+ it("starts without database or authentication configuration", async () => {
+ await request(app.getHttpServer())
.get("/")
.expect(200)
- .expect((res) => {
- expect(res.body.data).toHaveProperty("name", "NestJS Template API");
- expect(res.body.data).toHaveProperty("version");
- });
+ .expect({ name: "nestjs-api", version: "0.1.0" });
+ const response = await request(app.getHttpServer())
+ .get("/health")
+ .expect(200);
+ expect(response.body.status).toBe("ok");
+ expect(response.body).not.toHaveProperty("database");
+ });
+ it("does not include login", async () => {
+ await request(app.getHttpServer())
+ .post("/auth/login")
+ .send({ code: "demo" })
+ .expect(404);
});
+ it.each(["/users", "/api/v1/users"])(
+ "does not include business routes: %s",
+ async (path) => {
+ await request(app.getHttpServer()).get(path).expect(404);
+ },
+ );
});
diff --git a/packages/templates/nestjs-api/tsconfig.build.json b/packages/templates/nestjs-api/tsconfig.build.json
index 68297a12..bcea9b8e 100644
--- a/packages/templates/nestjs-api/tsconfig.build.json
+++ b/packages/templates/nestjs-api/tsconfig.build.json
@@ -5,12 +5,5 @@
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo",
"types": ["node"]
},
- "exclude": [
- "node_modules",
- "test",
- "dist",
- "**/*spec.ts",
- "drizzle.config.ts",
- "drizzle/**"
- ]
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
diff --git a/packages/templates/nextjs-app/.env.example b/packages/templates/nextjs-app/.env.example
index ae514530..afb3e747 100644
--- a/packages/templates/nextjs-app/.env.example
+++ b/packages/templates/nextjs-app/.env.example
@@ -1,3 +1 @@
-# Add project-specific environment variables here when needed.
-# Example:
-# NEXT_PUBLIC_API_BASE_URL="https://api.example.com"
+NEXT_PUBLIC_API_URL=
diff --git a/packages/templates/nextjs-app/README.md b/packages/templates/nextjs-app/README.md
index af16c9ef..cd68f882 100644
--- a/packages/templates/nextjs-app/README.md
+++ b/packages/templates/nextjs-app/README.md
@@ -1,85 +1,21 @@
-# Web SSR Next Template
+# Next.js App
-基于 Next.js App Router 的 SSR 模板,适合需要服务端渲染、API Route、主题切换和基础交互组件的 Web 应用。模板当前已经统一到 `shadcn/ui + Tailwind CSS v4 + SWR + Zustand` 这一套前端基线。
+React · TypeScript · shadcn/ui (Base UI) · Tailwind CSS · Axios · SWR · Zustand.
-## 技术栈
+## 中文
-- Next.js 16
-- React 19
-- TypeScript 7
-- shadcn/ui + Radix UI
-- Tailwind CSS v4
-- next-themes
-- SWR
-- Zustand
-- Axios
-- sonner
-- oxlint + oxfmt
+- `pnpm dev`、`pnpm build`、`pnpm check`:开发、构建、检查。
+- 起始页面包含主题切换和 Base UI Toast,没有认证或业务 API。
+- `src/lib/http.ts` 提供 Axios 实例和原始 JSON fetcher;通过 `.env.example` 中的公开 API 地址连接后端。
+- SWR 管理远程数据与缓存;Zustand 只管理共享 UI 状态,组件局部状态使用 React。
+- Next.js 的 store 必须按 Provider 创建;服务端数据使用服务端函数,私密环境变量不能传入客户端。
+- 按需用 shadcn 添加 Base UI 组件,手动安装业务 skills。
-## 目录结构
+## English
-```text
-src/
-├── api/ # 请求 key 与纯函数 API
-├── app/ # App Router 页面与 route handlers
-│ └── api/hello/route.ts
-├── components/ # 页面组件与 shadcn/ui 组件
-├── lib/
-│ ├── http.ts # Axios 客户端
-│ ├── server-data.ts # 服务端数据获取封装
-│ └── utils.ts # cn 等工具函数
-└── stores/ # Zustand store
-```
-
-## 快速开始
-
-```bash
-pnpm install
-pnpm dev
-```
-
-默认开发地址:`http://localhost:3000`
-
-## 常用命令
-
-| 命令 | 说明 |
-| ----------------- | -------------------------- |
-| `pnpm dev` | 启动开发服务器 |
-| `pnpm build` | 构建生产产物 |
-| `pnpm start` | 启动生产服务 |
-| `pnpm lint` | 执行 oxlint |
-| `pnpm lint:fix` | 自动修复 lint 问题 |
-| `pnpm format` | 检查格式 |
-| `pnpm format:fix` | 自动格式化 |
-| `pnpm check` | 执行 lint + format |
-| `pnpm check:fix` | 执行 lint:fix + format:fix |
-
-## 请求层约定
-
-模板中的请求模块统一采用 “`key + pure function`” 形式,避免在页面和组件里内联 endpoint。
-
-```ts
-export const helloKey = "/api/hello";
-
-export function getHello() {
- return http.get(helloKey);
-}
-```
-
-当前示例位于:
-
-- `src/api/hello.ts`
-- `src/lib/server-data.ts`
-
-## UI 与主题
-
-- `src/components/ui/*` 提供 shadcn/ui 组件
-- `src/components/theme-provider.tsx` 与 `theme-toggle.tsx` 管理主题切换
-- `src/app/globals.css` 是样式入口,负责 `@theme inline` 映射与全局样式层
-- `src/styles/tokens.css` 是 design token 源,维护 light / dark 与状态语义变量
-- `src/components/ui/sonner.tsx` 在 `layout.tsx` 中挂载 ` `,业务侧用 `import { toast } from "sonner"` 调用
-- `src/app/global-error.tsx` 提供全局错误兜底
-
-## 说明
-
-这是模板仓库,不内置项目级 Biome、额外 docs 目录或发布流水线。工作区治理由 `one-cli` 提供;版本管理和发布流程由项目按需配置。
+- `pnpm dev`, `pnpm build`, `pnpm check`: develop, build, validate.
+- The starter includes theme switching and Base UI Toast, with no authentication or business API.
+- `src/lib/http.ts` exports an Axios client and a raw JSON fetcher. Configure the public API URL from `.env.example`.
+- SWR owns remote data and caching. Zustand owns shared UI state; use React for local state.
+- In Next.js, create stores per Provider. Fetch server data with server functions and keep private environment variables off the client.
+- Add Base UI components with shadcn as needed. Install business skills manually.
diff --git a/packages/templates/nextjs-app/components.json b/packages/templates/nextjs-app/components.json
index c354f0d0..dd4f3312 100644
--- a/packages/templates/nextjs-app/components.json
+++ b/packages/templates/nextjs-app/components.json
@@ -1,12 +1,12 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
- "style": "new-york",
+ "style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
- "baseColor": "slate",
+ "baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
@@ -17,5 +17,9 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
- "iconLibrary": "lucide"
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
}
diff --git a/packages/templates/nextjs-app/package.json b/packages/templates/nextjs-app/package.json
index 7c557acb..c6330b90 100644
--- a/packages/templates/nextjs-app/package.json
+++ b/packages/templates/nextjs-app/package.json
@@ -10,14 +10,12 @@
"lint:fix": "oxlint --fix .",
"format": "oxfmt --check .",
"format:fix": "oxfmt --write .",
- "check": "pnpm run lint && pnpm run format",
- "check:fix": "pnpm run lint:fix && pnpm run format:fix"
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run format",
+ "check:fix": "pnpm run lint:fix && pnpm run format:fix",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
- "@radix-ui/react-avatar": "^1.2.6",
- "@radix-ui/react-dropdown-menu": "^2.1.24",
- "@radix-ui/react-label": "^2.1.15",
- "@radix-ui/react-slot": "^1.3.3",
+ "@base-ui/react": "^1.8.0",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -26,7 +24,7 @@
"next-themes": "^0.4.6",
"react": "^19.3.0",
"react-dom": "^19.3.0",
- "sonner": "^2.0.8",
+ "shadcn": "^4.21.0",
"swr": "^2.5.1",
"tailwind-merge": "^3.7.0",
"zustand": "^5.0.15"
@@ -36,8 +34,6 @@
"@types/node": "^24.19.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
- "git-cz": "^4.9.0",
- "lint-staged": "^17.6.0",
"oxfmt": "^0.71.0",
"oxlint": "^1.86.0",
"postcss": "^8.5.28",
@@ -45,15 +41,6 @@
"tw-animate-css": "^1.4.0",
"typescript": "^7.0.2"
},
- "lint-staged": {
- "*.{js,jsx,ts,tsx,mjs,cjs}": [
- "oxlint --fix",
- "oxfmt --write --no-error-on-unmatched-pattern"
- ],
- "*.{json,md,css,scss,html,yml,yaml}": [
- "oxfmt --write --no-error-on-unmatched-pattern"
- ]
- },
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
diff --git a/packages/templates/nextjs-app/public/file.svg b/packages/templates/nextjs-app/public/file.svg
deleted file mode 100644
index 004145cd..00000000
--- a/packages/templates/nextjs-app/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/templates/nextjs-app/public/globe.svg b/packages/templates/nextjs-app/public/globe.svg
deleted file mode 100644
index 567f17b0..00000000
--- a/packages/templates/nextjs-app/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/templates/nextjs-app/public/next.svg b/packages/templates/nextjs-app/public/next.svg
deleted file mode 100644
index 5174b28c..00000000
--- a/packages/templates/nextjs-app/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/templates/nextjs-app/public/onecli-logo-inverted.svg b/packages/templates/nextjs-app/public/onecli-logo-inverted.svg
deleted file mode 100644
index ee1d78fd..00000000
--- a/packages/templates/nextjs-app/public/onecli-logo-inverted.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
- One CLI
- One CLI horizontal wordmark — inverted for dark backgrounds
-
- ONE CLI
-
diff --git a/packages/templates/nextjs-app/public/onecli-logo.svg b/packages/templates/nextjs-app/public/onecli-logo.svg
deleted file mode 100644
index 6194d834..00000000
--- a/packages/templates/nextjs-app/public/onecli-logo.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
- One CLI
- One CLI horizontal wordmark — primary, torch
-
- ONE CLI
-
diff --git a/packages/templates/nextjs-app/public/vercel.svg b/packages/templates/nextjs-app/public/vercel.svg
deleted file mode 100644
index 77053960..00000000
--- a/packages/templates/nextjs-app/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/templates/nextjs-app/public/window.svg b/packages/templates/nextjs-app/public/window.svg
deleted file mode 100644
index b2b2a44f..00000000
--- a/packages/templates/nextjs-app/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/templates/nextjs-app/src/api/hello.ts b/packages/templates/nextjs-app/src/api/hello.ts
deleted file mode 100644
index 1cf8fe7b..00000000
--- a/packages/templates/nextjs-app/src/api/hello.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import http from "@/lib/http";
-
-export interface HelloResponse {
- message: string;
- timestamp: string;
- serverInfo: {
- nodeVersion: string;
- platform: string;
- };
-}
-
-export const helloKey = "/api/hello";
-export const helloErrorKey = `${helloKey}?error=true`;
-
-export function getHello(): Promise {
- return http.get(helloKey);
-}
-
-export function getHelloWithError(): Promise {
- return http.get(helloErrorKey);
-}
diff --git a/packages/templates/nextjs-app/src/app/api/hello/route.ts b/packages/templates/nextjs-app/src/app/api/hello/route.ts
deleted file mode 100644
index 3317aed8..00000000
--- a/packages/templates/nextjs-app/src/app/api/hello/route.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { type NextRequest, NextResponse } from "next/server";
-
-export async function GET(request: NextRequest) {
- const { searchParams } = new URL(request.url);
- const showError = searchParams.get("error") === "true";
-
- // Simulate a delay
- await new Promise((resolve) => setTimeout(resolve, 500));
-
- if (showError) {
- // Return a business error response
- return NextResponse.json({
- code: "01001", // Business domain 01, error 001
- msg: "模拟的业务错误:参数验证失败",
- data: null,
- });
- }
-
- // Return a success response with server data structure
- return NextResponse.json({
- code: "00000", // Success code
- msg: "请求成功",
- data: {
- message: "Hello from Client Side API!",
- timestamp: new Date().toISOString(),
- serverInfo: {
- nodeVersion: process.version,
- platform: process.platform,
- },
- },
- });
-}
diff --git a/packages/templates/nextjs-app/src/app/globals.css b/packages/templates/nextjs-app/src/app/globals.css
index 5288089c..b7030465 100644
--- a/packages/templates/nextjs-app/src/app/globals.css
+++ b/packages/templates/nextjs-app/src/app/globals.css
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
+@import "shadcn/tailwind.css";
@import "../styles/tokens.css";
@custom-variant dark (&:is(.dark *));
@@ -7,8 +8,6 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -50,32 +49,10 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
- --animate-accordion-down: accordion-down 0.2s ease-out;
- --animate-accordion-up: accordion-up 0.2s ease-out;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
-
- @keyframes accordion-down {
- from {
- height: 0;
- }
-
- to {
- height: var(--radix-accordion-content-height);
- }
- }
-
- @keyframes accordion-up {
- from {
- height: var(--radix-accordion-content-height);
- }
-
- to {
- height: 0;
- }
- }
}
@layer base {
@@ -90,63 +67,56 @@
/* Toast 自定义样式 */
@layer components {
- /* 确保 toast 样式优先级 */
- [data-sonner-toaster] {
- --normal-bg: transparent;
- --normal-border: transparent;
- --normal-text: inherit;
- }
-
/* 强制应用自定义颜色 */
- [data-sonner-toast] {
- background: var(--toast-bg) !important;
- border: 1px solid var(--toast-border) !important;
- color: var(--toast-text) !important;
+ [data-slot="toast"] {
+ background: var(--toast-bg, var(--popover)) !important;
+ border: 1px solid var(--toast-border, var(--border)) !important;
+ color: var(--toast-text, var(--popover-foreground)) !important;
}
- [data-sonner-toast][data-type="success"] {
+ [data-slot="toast"][data-type="success"] {
--toast-bg: var(--success-surface);
--toast-border: var(--success-border);
--toast-text: var(--success-foreground);
}
- [data-sonner-toast][data-type="error"] {
+ [data-slot="toast"][data-type="error"] {
--toast-bg: var(--error-surface);
--toast-border: var(--error-border);
--toast-text: var(--error-foreground);
}
- [data-sonner-toast][data-type="warning"] {
+ [data-slot="toast"][data-type="warning"] {
--toast-bg: var(--warning-surface);
--toast-border: var(--warning-border);
--toast-text: var(--warning-foreground);
}
- [data-sonner-toast][data-type="info"] {
+ [data-slot="toast"][data-type="info"] {
--toast-bg: var(--info-surface);
--toast-border: var(--info-border);
--toast-text: var(--info-foreground);
}
/* Toast 标题和描述颜色 */
- [data-sonner-toast] [data-title] {
- color: var(--toast-text) !important;
+ [data-slot="toast"] [data-slot="toast-title"] {
+ color: var(--toast-text, var(--popover-foreground)) !important;
font-weight: 600;
}
- [data-sonner-toast] [data-description] {
- color: var(--toast-text) !important;
+ [data-slot="toast"] [data-slot="toast-description"] {
+ color: var(--toast-text, var(--popover-foreground)) !important;
opacity: 0.8;
}
/* 动作按钮样式 */
- [data-sonner-toast] [data-button] {
- background: var(--toast-border) !important;
- color: white !important;
- border: none !important;
+ [data-slot="toast"] [data-slot="toast-action"] {
+ background: var(--toast-bg, var(--popover)) !important;
+ color: var(--toast-text, var(--popover-foreground)) !important;
+ border: 1px solid var(--toast-border, var(--border)) !important;
}
- [data-sonner-toast] [data-button]:hover {
+ [data-slot="toast"] [data-slot="toast-action"]:hover {
opacity: 0.9;
}
}
diff --git a/packages/templates/nextjs-app/src/app/layout.tsx b/packages/templates/nextjs-app/src/app/layout.tsx
index d7194073..40fcb512 100644
--- a/packages/templates/nextjs-app/src/app/layout.tsx
+++ b/packages/templates/nextjs-app/src/app/layout.tsx
@@ -1,44 +1,17 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import type { ReactNode } from "react";
+import { Providers } from "@/components/providers";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
-import { ThemeProvider } from "@/components/theme-provider";
-import { Toaster } from "@/components/ui/sonner";
-
-export async function generateMetadata(): Promise {
- return {
- title: "Next.js toC App Template",
- description: "A template for building modern toC applications.",
- };
-}
-
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
+export const metadata: Metadata = {
+ title: "One CLI · Next.js",
+ description: "Next.js application starter / Next.js 应用模板",
+};
+export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
-
- {children}
-
-
+
+ {children}
);
diff --git a/packages/templates/nextjs-app/src/app/page.tsx b/packages/templates/nextjs-app/src/app/page.tsx
index 66e41d53..195de6e6 100644
--- a/packages/templates/nextjs-app/src/app/page.tsx
+++ b/packages/templates/nextjs-app/src/app/page.tsx
@@ -1,83 +1,34 @@
-/**
- * Welcome screen — kept intentionally minimal for AI agents to extend.
- *
- * Pre-wired infrastructure (don't recreate; import from these paths):
- * - Theme: src/components/theme-provider.tsx + theme-toggle.tsx (next-themes)
- * - Toast: sonner via src/components/ui/sonner.tsx (mounted in layout.tsx)
- * - HTTP client: src/lib/http.ts (axios instance)
- * - Server data: src/lib/server-data.ts (use in async server components)
- * - API conventions: src/api/.ts — `key + pure function`, e.g. src/api/hello.ts
- * - API routes: src/app/api//route.ts
- * - Store helper: createStore() in src/lib/utils.ts (zustand + devtools)
- * - Global error: src/app/global-error.tsx
- * - Design tokens: src/styles/tokens.css (CSS vars, light + dark)
- *
- * Atomic design (advisory — physical folders NOT enforced):
- * - atoms → src/components/ui/ (shadcn primitives)
- * - molecules → src/components/ (compose atoms; e.g. ResourceLink below)
- * - organisms → src/components/sections/ (page-level blocks; create when needed)
- * - pages → src/app/ (App Router segments; this file is the home page)
- *
- * Design tokens — DO use these utilities (they map to CSS vars in tokens.css):
- * - Surface: bg-background / bg-card / bg-popover
- * - Text: text-foreground / text-muted-foreground / text-primary
- * - Border: border-border / border-input
- * - Accent: bg-primary / bg-secondary / bg-destructive
- * DON'T hardcode hex / rgb in Tailwind classes.
- */
+"use client";
-import { ArrowRight, BookOpen, Code2 } from "lucide-react";
+import { useEffect } from "react";
+import { ThemeToggle } from "@/components/theme-toggle";
+import { Button } from "@/components/ui/button";
+import { useUI } from "@/stores/ui";
export default function HomePage() {
+ const locale = useUI((state) => state.locale);
+ const toggleLocale = useUI((state) => state.toggleLocale);
+ useEffect(() => {
+ document.documentElement.lang = locale === "zh" ? "zh-CN" : "en-US";
+ }, [locale]);
return (
-
-
-
-
+
+
+ One CLI · Next.js
+
+
+ {locale === "en" ? "中文" : "English"}
+
+
-
-
-
- Next.js · App Router
-
-
- Welcome to One CLI
-
-
- 一个由 One CLI 生成的 Next.js 脚手架,已预置 shadcn/ui、Tailwind v4、SWR 与设计令牌。
-
-
-
-
- Edit{" "}
-
- src/app/page.tsx
- {" "}
- and save to start.
-
-
-
-
+
+ {locale === "en" ? "Welcome" : "欢迎"}
+
+ {locale === "en" ? "Start building your application here." : "从这里开始构建你的应用。"}
+
+
+ {locale === "en" ? "Documentation" : "文档"}
+
);
}
diff --git a/packages/templates/nextjs-app/src/components/providers.tsx b/packages/templates/nextjs-app/src/components/providers.tsx
new file mode 100644
index 00000000..7be2e484
--- /dev/null
+++ b/packages/templates/nextjs-app/src/components/providers.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import type { PropsWithChildren } from "react";
+import { SWRConfig } from "swr";
+import { ThemeProvider } from "@/components/theme-provider";
+import { Toaster } from "@/components/ui/toast";
+import { UIProvider } from "@/stores/ui";
+import { fetcher } from "@/lib/http";
+
+export function Providers({ children }: PropsWithChildren) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/packages/templates/nextjs-app/src/components/theme-toggle.tsx b/packages/templates/nextjs-app/src/components/theme-toggle.tsx
index 199e62b9..4fa54870 100644
--- a/packages/templates/nextjs-app/src/components/theme-toggle.tsx
+++ b/packages/templates/nextjs-app/src/components/theme-toggle.tsx
@@ -16,17 +16,15 @@ export function ThemeToggle() {
return (
-
-
-
-
- Toggle theme
-
+ }>
+
+
+ 切换主题 / Toggle theme
- setTheme("light")}>Light
- setTheme("dark")}>Dark
- setTheme("system")}>System
+ setTheme("light")}>浅色 / Light
+ setTheme("dark")}>深色 / Dark
+ setTheme("system")}>系统 / System
);
diff --git a/packages/templates/nextjs-app/src/components/ui/avatar.tsx b/packages/templates/nextjs-app/src/components/ui/avatar.tsx
deleted file mode 100644
index ea032dd6..00000000
--- a/packages/templates/nextjs-app/src/components/ui/avatar.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-"use client";
-
-import * as AvatarPrimitive from "@radix-ui/react-avatar";
-import * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-const Avatar = React.forwardRef<
- React.ElementRef
,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-Avatar.displayName = AvatarPrimitive.Root.displayName;
-
-const AvatarImage = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-AvatarImage.displayName = AvatarPrimitive.Image.displayName;
-
-const AvatarFallback = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
-
-export { Avatar, AvatarImage, AvatarFallback };
diff --git a/packages/templates/nextjs-app/src/components/ui/badge.tsx b/packages/templates/nextjs-app/src/components/ui/badge.tsx
deleted file mode 100644
index 70a07811..00000000
--- a/packages/templates/nextjs-app/src/components/ui/badge.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-const badgeVariants = cva(
- "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
- {
- variants: {
- variant: {
- default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
- secondary:
- "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
- destructive:
- "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
- outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-function Badge({
- className,
- variant,
- asChild = false,
- ...props
-}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "span";
-
- return (
-
- );
-}
-
-export { Badge, badgeVariants };
diff --git a/packages/templates/nextjs-app/src/components/ui/button.tsx b/packages/templates/nextjs-app/src/components/ui/button.tsx
index eeb3167e..0be1f23b 100644
--- a/packages/templates/nextjs-app/src/components/ui/button.tsx
+++ b/packages/templates/nextjs-app/src/components/ui/button.tsx
@@ -1,26 +1,37 @@
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-import * as React from "react";
+"use client";
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/90",
- destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
- outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
- secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
- ghost: "hover:bg-accent hover:text-accent-foreground",
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default: "h-10 px-4 py-2",
- sm: "h-9 rounded-md px-3",
- lg: "h-11 rounded-md px-8",
- icon: "h-10 w-10",
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
},
},
defaultVariants: {
@@ -30,19 +41,24 @@ const buttonVariants = cva(
},
);
-export interface ButtonProps
- extends React.ButtonHTMLAttributes, VariantProps {
- asChild?: boolean;
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ cn(
+ buttonVariants({ variant, size }),
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
}
-const Button = React.forwardRef(
- ({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
- return (
-
- );
- },
-);
-Button.displayName = "Button";
-
export { Button, buttonVariants };
diff --git a/packages/templates/nextjs-app/src/components/ui/card.tsx b/packages/templates/nextjs-app/src/components/ui/card.tsx
deleted file mode 100644
index 4a22652f..00000000
--- a/packages/templates/nextjs-app/src/components/ui/card.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-const Card = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-Card.displayName = "Card";
-
-const CardHeader = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-CardHeader.displayName = "CardHeader";
-
-const CardTitle = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-CardTitle.displayName = "CardTitle";
-
-const CardDescription = React.forwardRef<
- HTMLParagraphElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-));
-CardDescription.displayName = "CardDescription";
-
-const CardContent = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-CardContent.displayName = "CardContent";
-
-const CardFooter = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-CardFooter.displayName = "CardFooter";
-
-export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/packages/templates/nextjs-app/src/components/ui/dropdown-menu.tsx b/packages/templates/nextjs-app/src/components/ui/dropdown-menu.tsx
index a3462cc7..726bc74f 100644
--- a/packages/templates/nextjs-app/src/components/ui/dropdown-menu.tsx
+++ b/packages/templates/nextjs-app/src/components/ui/dropdown-menu.tsx
@@ -1,188 +1,277 @@
"use client";
-import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
-import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
-
+import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "@/lib/utils";
-const DropdownMenu = DropdownMenuPrimitive.Root;
-
-const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
-
-const DropdownMenuGroup = DropdownMenuPrimitive.Group;
-
-const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
-
-const DropdownMenuSub = DropdownMenuPrimitive.Sub;
-
-const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
-
-const DropdownMenuSubTrigger = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & {
- inset?: boolean;
- }
->(({ className, inset, children, ...props }, ref) => (
-
- {children}
-
-
-));
-DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
-
-const DropdownMenuSubContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
-
-const DropdownMenuContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, sideOffset = 4, ...props }, ref) => (
-
- ;
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return ;
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return ;
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick) {
+ return (
+
+
+
+ cn(
+ "cn-menu-target cn-menu-translucent z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+
+
+ );
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return ;
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ cn(
+ "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ cn(
+ "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ cn(
+ "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ >
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ cn(
+ "cn-menu-target cn-menu-translucent w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
sideOffset={sideOffset}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ cn(
+ "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ checked={checked}
+ {...props}
+ >
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return ;
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ cn(
+ "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ >
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
+ return (
+
+ cn(
+ "-mx-1 my-1 h-px bg-border",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
-
-));
-DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
-
-const DropdownMenuItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & {
- inset?: boolean;
- }
->(({ className, inset, ...props }, ref) => (
-
-));
-DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
-
-const DropdownMenuCheckboxItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, checked, ...props }, ref) => (
-
-
-
-
-
-
- {children}
-
-));
-DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
-
-const DropdownMenuRadioItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
-
-
-
-
- {children}
-
-));
-DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
-
-const DropdownMenuLabel = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & {
- inset?: boolean;
- }
->(({ className, inset, ...props }, ref) => (
-
-));
-DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
-
-const DropdownMenuSeparator = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
-
-const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => {
- return (
-
);
-};
-DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+}
export {
DropdownMenu,
+ DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
DropdownMenuRadioItem,
- DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
- DropdownMenuGroup,
- DropdownMenuPortal,
DropdownMenuSub,
- DropdownMenuSubContent,
DropdownMenuSubTrigger,
- DropdownMenuRadioGroup,
+ DropdownMenuSubContent,
};
diff --git a/packages/templates/nextjs-app/src/components/ui/input.tsx b/packages/templates/nextjs-app/src/components/ui/input.tsx
deleted file mode 100644
index ba2fbccd..00000000
--- a/packages/templates/nextjs-app/src/components/ui/input.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-export interface InputProps extends React.InputHTMLAttributes {}
-
-const Input = React.forwardRef(
- ({ className, type, ...props }, ref) => {
- return (
-
- );
- },
-);
-Input.displayName = "Input";
-
-export { Input };
diff --git a/packages/templates/nextjs-app/src/components/ui/label.tsx b/packages/templates/nextjs-app/src/components/ui/label.tsx
deleted file mode 100644
index aa56d4e6..00000000
--- a/packages/templates/nextjs-app/src/components/ui/label.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-"use client";
-
-import * as LabelPrimitive from "@radix-ui/react-label";
-import { cva, type VariantProps } from "class-variance-authority";
-import * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-const labelVariants = cva(
- "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
-);
-
-const Label = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & VariantProps
->(({ className, ...props }, ref) => (
-
-));
-Label.displayName = LabelPrimitive.Root.displayName;
-
-export { Label };
diff --git a/packages/templates/nextjs-app/src/components/ui/skeleton.tsx b/packages/templates/nextjs-app/src/components/ui/skeleton.tsx
deleted file mode 100644
index e8d58c09..00000000
--- a/packages/templates/nextjs-app/src/components/ui/skeleton.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import type * as React from "react";
-
-import { cn } from "@/lib/utils";
-
-function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-export { Skeleton };
diff --git a/packages/templates/nextjs-app/src/components/ui/sonner.tsx b/packages/templates/nextjs-app/src/components/ui/sonner.tsx
deleted file mode 100644
index b08c2e88..00000000
--- a/packages/templates/nextjs-app/src/components/ui/sonner.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-"use client";
-
-import { useTheme } from "next-themes";
-import { Toaster as Sonner, type ToasterProps } from "sonner";
-
-const Toaster = ({ ...props }: ToasterProps) => {
- const { theme = "system" } = useTheme();
-
- return (
-
- );
-};
-
-export { Toaster };
diff --git a/packages/templates/nextjs-app/src/components/ui/toast.tsx b/packages/templates/nextjs-app/src/components/ui/toast.tsx
new file mode 100644
index 00000000..7666dc47
--- /dev/null
+++ b/packages/templates/nextjs-app/src/components/ui/toast.tsx
@@ -0,0 +1,246 @@
+"use client";
+
+import * as React from "react";
+import { Toast as ToastPrimitive } from "@base-ui/react/toast";
+import { cn } from "@/lib/utils";
+
+import { Button } from "@/components/ui/button";
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+ XIcon,
+} from "lucide-react";
+
+type ToastData = { className?: string };
+
+const toast = ToastPrimitive.createToastManager();
+
+function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
+ return ;
+}
+
+function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
+ return ;
+}
+
+function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
+ return (
+
+ cn(
+ "pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
+ return (
+
+ cn(
+ "group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ "[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
+ "h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
+ "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
+ "data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
+ "data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
+ "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
+ "data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
+ return (
+
+ cn(
+ "flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
+ return (
+
+ cn("text-sm font-medium", typeof className === "function" ? className(state) : className)
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) {
+ return (
+
+ cn(
+ "text-sm text-muted-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastAction({
+ className,
+ render = ,
+ ...props
+}: ToastPrimitive.Action.Props) {
+ return (
+
+ cn("shrink-0", typeof className === "function" ? className(state) : className)
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastClose({
+ className,
+ children,
+ render = ,
+ ...props
+}: ToastPrimitive.Close.Props) {
+ return (
+
+ cn(
+ "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function ToastIcon({ type }: { type: string | undefined }) {
+ let icon: React.ReactNode = null;
+
+ if (type === "success") {
+ icon = ;
+ }
+
+ if (type === "info") {
+ icon = ;
+ }
+
+ if (type === "warning") {
+ icon = ;
+ }
+
+ if (type === "error") {
+ icon = ;
+ }
+
+ if (type === "loading") {
+ icon = ;
+ }
+
+ if (!icon) {
+ return null;
+ }
+
+ return (
+
+ {icon}
+
+ );
+}
+
+function ToastList() {
+ const { toasts } = ToastPrimitive.useToastManager();
+
+ return toasts.map((toastItem) => (
+
+
+
+
+
+
+
+
+
+
+
+ ));
+}
+
+function Toaster({ children, toastManager = toast, ...props }: ToastPrimitive.Provider.Props) {
+ return (
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+const createToastManager = ToastPrimitive.createToastManager;
+const useToastManager = ToastPrimitive.useToastManager;
+
+export {
+ Toaster,
+ Toast,
+ ToastAction,
+ ToastClose,
+ ToastContent,
+ ToastDescription,
+ ToastPortal,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+ createToastManager,
+ toast,
+ useToastManager,
+};
diff --git a/packages/templates/nextjs-app/src/lib/http.ts b/packages/templates/nextjs-app/src/lib/http.ts
index 36829b31..8067b2e9 100644
--- a/packages/templates/nextjs-app/src/lib/http.ts
+++ b/packages/templates/nextjs-app/src/lib/http.ts
@@ -1,64 +1,6 @@
-import axios, { type AxiosInstance, type AxiosResponse } from "axios";
+import axios from "axios";
-// --- 1. 定义 API 响应和错误结构 ---
-
-// API 响应的基础结构
-interface BaseResponse {
- code: string;
- msg: string;
- data: T;
+export const http = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, timeout: 10000 });
+export async function fetcher(url: string): Promise {
+ return (await http.get(url)).data;
}
-
-// 自定义业务错误类
-export class ApiError extends Error {
- public readonly code: string;
- public readonly data: unknown;
-
- constructor(code: string, message: string, data: unknown = null) {
- super(message);
- this.name = "ApiError";
- this.code = code;
- this.data = data;
- }
-}
-
-// --- 2. 创建和配置 Axios 实例 ---
-
-const http: AxiosInstance = axios.create({
- // baseURL: process.env.NEXT_PUBLIC_API_URL, // 如果有统一的 API 地址
- timeout: 10000, // 请求超时时间
- headers: {
- "Content-Type": "application/json",
- },
-});
-
-// --- 3. 设置响应拦截器 ---
-
-http.interceptors.response.use(
- (response: AxiosResponse>) => {
- const { code, msg, data } = response.data;
- // 假设 '00000' 是成功的业务代码
- if (code === "00000") {
- // 直接返回业务数据
- return data as any;
- }
-
- // 如果是业务错误,则抛出 ApiError
- return Promise.reject(new ApiError(code, msg, data as any));
- },
- (error) => {
- // 处理网络层面的错误 (e.g., 404, 500)
- // 你可以在这里添加更复杂的逻辑,例如根据状态码跳转页面
- if (error.response) {
- console.error("API Error Response:", error.response);
- } else if (error.request) {
- console.error("API Error Request:", error.request);
- } else {
- console.error("API Error Message:", error.message);
- }
- // 抛出原始的 axios 错误,或者一个更友好的错误对象
- return Promise.reject(error);
- },
-);
-
-export default http;
diff --git a/packages/templates/nextjs-app/src/lib/server-data.ts b/packages/templates/nextjs-app/src/lib/server-data.ts
deleted file mode 100644
index 8e3c8784..00000000
--- a/packages/templates/nextjs-app/src/lib/server-data.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-// 服务端数据获取函数
-// 这些函数只在服务器端运行,支持 SSR
-import type { HelloResponse } from "@/api/hello";
-
-export async function getHelloData(): Promise {
- // 模拟 API 调用延迟
- await new Promise((resolve) => setTimeout(resolve, 100));
-
- return {
- message: "Hello from Server Side Rendering!",
- timestamp: new Date().toISOString(),
- serverInfo: {
- nodeVersion: process.version,
- platform: process.platform,
- },
- };
-}
-
-export async function getUserData(userId?: string) {
- // 模拟用户数据获取
- await new Promise((resolve) => setTimeout(resolve, 200));
-
- return {
- id: userId || "1",
- name: "张三",
- email: "zhangsan@example.com",
- avatar: "https://github.com/shadcn.png",
- createdAt: new Date().toISOString(),
- preferences: {
- theme: "system",
- language: "zh-CN",
- },
- };
-}
-
-// 模拟可能失败的 API 调用
-export async function getDataWithError(shouldError: boolean = false) {
- await new Promise((resolve) => setTimeout(resolve, 150));
-
- if (shouldError) {
- throw new Error("模拟的服务器错误");
- }
-
- return {
- success: true,
- data: "成功获取的数据",
- timestamp: new Date().toISOString(),
- };
-}
diff --git a/packages/templates/nextjs-app/src/lib/utils.ts b/packages/templates/nextjs-app/src/lib/utils.ts
index 7dea028f..3200be28 100644
--- a/packages/templates/nextjs-app/src/lib/utils.ts
+++ b/packages/templates/nextjs-app/src/lib/utils.ts
@@ -1,16 +1,6 @@
-import { type ClassValue, clsx } from "clsx";
+import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
-import type { StateCreator } from "zustand";
-import { create } from "zustand";
-import { devtools } from "zustand/middleware";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-
-export const createStore = (storeCreator: StateCreator, name: string) => {
- if (process.env.NODE_ENV === "development") {
- return create(devtools(storeCreator, { name }));
- }
- return create(storeCreator);
-};
diff --git a/packages/templates/nextjs-app/src/stores/ui.tsx b/packages/templates/nextjs-app/src/stores/ui.tsx
new file mode 100644
index 00000000..8774aa28
--- /dev/null
+++ b/packages/templates/nextjs-app/src/stores/ui.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { createContext, useContext, useState, type PropsWithChildren } from "react";
+import { createStore, useStore } from "zustand";
+
+type UIState = { locale: "en" | "zh"; toggleLocale: () => void };
+function createUIStore() {
+ return createStore()((set) => ({
+ locale: "en",
+ toggleLocale: () => set((state) => ({ locale: state.locale === "en" ? "zh" : "en" })),
+ }));
+}
+const UIContext = createContext | null>(null);
+export function UIProvider({ children }: PropsWithChildren) {
+ const [store] = useState(createUIStore);
+ return {children} ;
+}
+export function useUI(selector: (state: UIState) => T) {
+ const store = useContext(UIContext);
+ if (!store) throw new Error("UIProvider is required");
+ return useStore(store, selector);
+}
diff --git a/packages/templates/nextjs-site/.editorconfig b/packages/templates/nextjs-site/.editorconfig
new file mode 100644
index 00000000..bb0754ff
--- /dev/null
+++ b/packages/templates/nextjs-site/.editorconfig
@@ -0,0 +1,32 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# All files
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+# JavaScript, TypeScript, JSON, CSS files
+[*.{js,jsx,ts,tsx,json,css,scss,sass}]
+indent_style = tab
+indent_size = 4
+
+# Markdown files
+[*.md]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = false
+
+# YAML files
+[*.{yml,yaml}]
+indent_style = space
+indent_size = 2
+
+# Package files
+[package.json]
+indent_style = tab
+indent_size = 2
\ No newline at end of file
diff --git a/packages/templates/nextjs-site/.gitignore b/packages/templates/nextjs-site/.gitignore
new file mode 100644
index 00000000..eccd9a34
--- /dev/null
+++ b/packages/templates/nextjs-site/.gitignore
@@ -0,0 +1,57 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# CI/CD temporary directories
+deployment-package/
+/artifacts/
+*.artifact/
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# Local Environment Variables
+# -----------------
+# These files are for local development and should not be committed to the repository.
+# They may contain sensitive data.
+# Use .env.example as a template.
+.env
+*.local
+
+# Sentry Config File
+.env.sentry-build-plugin
+
+# Generated content and dependencies
+.source/
+node_modules/
+pnpm-lock.yaml
+*.tsbuildinfo
+
+# Agent metadata generated by next dev
+AGENTS.md
+CLAUDE.md
diff --git a/packages/templates/nextjs-site/README.md b/packages/templates/nextjs-site/README.md
new file mode 100644
index 00000000..c89b307c
--- /dev/null
+++ b/packages/templates/nextjs-site/README.md
@@ -0,0 +1,51 @@
+# Next.js 静态网站 / Next.js static website
+
+Next.js 16 + React 19 + TypeScript 7 + Tailwind CSS 4 + shadcn Base UI。
+A static website with English/Chinese pages, themes, and Base UI Toast.
+
+## 创建与开发 / Create and develop
+
+```sh
+one add nextjs-site --name website --yes
+one dev -p website
+one build -p website
+```
+
+项目直接位于 `apps/<用户项目名>`,共用工作区根目录的 pnpm 配置。
+The project lives directly in `apps/` and uses the workspace root pnpm configuration.
+
+## 内容与语言 / Content and languages
+
+页面位于 `src/app/[lang]/`,包含首页与关于页面;译文集中在 `src/lib/i18n.ts`。
+Pages in `src/app/[lang]/` include home and about; translations live in `src/lib/i18n.ts`.
+`src/styles/tokens.css` 提供设计令牌,组件使用 shadcn `base-nova` 与 Base UI Toast。
+Design tokens live in `src/styles/tokens.css`; components use shadcn `base-nova` and Base UI Toast.
+部署前设置 `NEXT_PUBLIC_SITE_URL`,供 SEO 元数据、sitemap 与 robots 使用。
+Set `NEXT_PUBLIC_SITE_URL` before building to configure metadata, sitemap, and robots.
+
+## 构建与部署 / Build and deploy
+
+```sh
+pnpm run check
+pnpm run build
+pnpm run preview
+```
+
+`next build` 将站点导出到 `out/`,`preview` / `start` 使用静态文件服务器。
+`next build` exports the site to `out/`; `preview` / `start` serve those static files.
+可部署到 Nginx、对象存储或其他静态托管平台。
+Deploy to Nginx, object storage, or another static hosting provider.
+需要运行时服务端功能时,选择 `nextjs-app` 或调整 Next.js 输出模式。
+Choose `nextjs-app` or change the output mode when your site needs a runtime server.
+
+## 模板源码开发 / Template source development
+
+```sh
+pnpm install
+pnpm run dev
+```
+
+源码目录的 `pnpm-workspace.yaml` 隔离父工作区,生成项目时会排除它。
+The source-only `pnpm-workspace.yaml` isolates the parent workspace and is excluded when generating projects.
+生成的内容、依赖、锁文件和构建产物不包含在模板中。
+Generated content, dependencies, lockfiles, and build outputs are excluded from the template.
diff --git a/packages/templates/nextjs-site/components.json b/packages/templates/nextjs-site/components.json
new file mode 100644
index 00000000..dd4f3312
--- /dev/null
+++ b/packages/templates/nextjs-site/components.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-nova",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
+}
diff --git a/packages/templates/nextjs-site/next-env.d.ts b/packages/templates/nextjs-site/next-env.d.ts
new file mode 100644
index 00000000..ce4e94a6
--- /dev/null
+++ b/packages/templates/nextjs-site/next-env.d.ts
@@ -0,0 +1,7 @@
+///
+///
+import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/templates/nextjs-site/next.config.mjs b/packages/templates/nextjs-site/next.config.mjs
new file mode 100644
index 00000000..0d7a231d
--- /dev/null
+++ b/packages/templates/nextjs-site/next.config.mjs
@@ -0,0 +1,9 @@
+/** @type {import("next").NextConfig} */
+const config = {
+ output: "export",
+ experimental: { globalNotFound: true },
+ trailingSlash: true,
+ images: { unoptimized: true },
+};
+
+export default config;
diff --git a/packages/templates/nextjs-site/package.json b/packages/templates/nextjs-site/package.json
new file mode 100644
index 00000000..a3263fc7
--- /dev/null
+++ b/packages/templates/nextjs-site/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "nextjs-site-template",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "serve out",
+ "lint": "oxlint .",
+ "lint:fix": "oxlint --fix .",
+ "format": "oxfmt --check .",
+ "format:fix": "oxfmt --write .",
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run format",
+ "check:fix": "pnpm run lint:fix && pnpm run format:fix",
+ "preview": "serve out",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@base-ui/react": "^1.8.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^1.49.0",
+ "next": "^16.3.7",
+ "next-themes": "^0.4.6",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "shadcn": "^4.21.0",
+ "tailwind-merge": "^3.7.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.3.3",
+ "@types/node": "^24.19.0",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "git-cz": "^4.9.0",
+ "lint-staged": "^17.6.0",
+ "oxfmt": "^0.71.0",
+ "oxlint": "^1.86.0",
+ "postcss": "^8.5.28",
+ "serve": "^14.2.5",
+ "tailwindcss": "^4.3.3",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^7.0.2"
+ },
+ "lint-staged": {
+ "*.{js,jsx,ts,tsx,mjs,cjs}": [
+ "oxlint --fix",
+ "oxfmt --write --no-error-on-unmatched-pattern"
+ ],
+ "*.{json,md,css,scss,html,yml,yaml}": [
+ "oxfmt --write --no-error-on-unmatched-pattern"
+ ]
+ },
+ "engines": {
+ "node": "^24.15.0 || >=26.0.0"
+ },
+ "packageManager": "pnpm@12.3.4"
+}
diff --git a/packages/templates/nextjs-site/pnpm-workspace.yaml b/packages/templates/nextjs-site/pnpm-workspace.yaml
new file mode 100644
index 00000000..3334c0e4
--- /dev/null
+++ b/packages/templates/nextjs-site/pnpm-workspace.yaml
@@ -0,0 +1 @@
+packages: []
diff --git a/packages/templates/nextjs-site/postcss.config.mjs b/packages/templates/nextjs-site/postcss.config.mjs
new file mode 100644
index 00000000..c7bcb4b1
--- /dev/null
+++ b/packages/templates/nextjs-site/postcss.config.mjs
@@ -0,0 +1,5 @@
+const config = {
+ plugins: ["@tailwindcss/postcss"],
+};
+
+export default config;
diff --git a/packages/templates/nextjs-site/src/app/(index)/layout.tsx b/packages/templates/nextjs-site/src/app/(index)/layout.tsx
new file mode 100644
index 00000000..1e3146fd
--- /dev/null
+++ b/packages/templates/nextjs-site/src/app/(index)/layout.tsx
@@ -0,0 +1,11 @@
+import type { ReactNode } from "react";
+import type { Metadata } from "next";
+import { SiteDocument } from "@/components/site-document";
+
+export const metadata: Metadata = {
+ title: "My Website / 我的网站",
+ description: "Choose your language / 选择语言",
+};
+export default function Layout({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/packages/templates/nextjs-site/src/app/(index)/page.tsx b/packages/templates/nextjs-site/src/app/(index)/page.tsx
new file mode 100644
index 00000000..79a0d266
--- /dev/null
+++ b/packages/templates/nextjs-site/src/app/(index)/page.tsx
@@ -0,0 +1,17 @@
+import Link from "next/link";
+export default function Page() {
+ return (
+
+ My Website / 我的网站
+ Choose your language / 选择语言
+
+
+ English
+
+
+ 中文
+
+
+
+ );
+}
diff --git a/packages/templates/nextjs-site/src/app/[lang]/about/page.tsx b/packages/templates/nextjs-site/src/app/[lang]/about/page.tsx
new file mode 100644
index 00000000..12335458
--- /dev/null
+++ b/packages/templates/nextjs-site/src/app/[lang]/about/page.tsx
@@ -0,0 +1,30 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { isLocale, messages } from "@/lib/i18n";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}): Promise {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ return {
+ title: messages[lang].about,
+ description: messages[lang].aboutDescription,
+ alternates: {
+ canonical: `/${lang}/about/`,
+ languages: { "en-US": "/en/about/", "zh-CN": "/zh/about/" },
+ },
+ };
+}
+export default async function Page({ params }: { params: Promise<{ lang: string }> }) {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ return (
+
+ {messages[lang].aboutTitle}
+ {messages[lang].aboutDescription}
+
+ );
+}
diff --git a/packages/templates/nextjs-site/src/app/[lang]/layout.tsx b/packages/templates/nextjs-site/src/app/[lang]/layout.tsx
new file mode 100644
index 00000000..95455600
--- /dev/null
+++ b/packages/templates/nextjs-site/src/app/[lang]/layout.tsx
@@ -0,0 +1,52 @@
+import type { ReactNode } from "react";
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { SiteDocument } from "@/components/site-document";
+import { ThemeToggle } from "@/components/theme-toggle";
+import { locales, isLocale, messages } from "@/lib/i18n";
+
+export const dynamicParams = false;
+export function generateStaticParams() {
+ return locales.map((lang) => ({ lang }));
+}
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}): Promise {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ return {
+ metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com"),
+ title: { default: messages[lang].name, template: `%s | ${messages[lang].name}` },
+ description: messages[lang].description,
+ };
+}
+export default async function Layout({
+ params,
+ children,
+}: {
+ params: Promise<{ lang: string }>;
+ children: ReactNode;
+}) {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ const text = messages[lang];
+ return (
+
+
+
+ {text.name}
+
+
+ {text.home}
+ {text.about}
+ {lang === "en" ? "中文" : "English"}
+
+
+
+ {children}
+
+ );
+}
diff --git a/packages/templates/nextjs-site/src/app/[lang]/page.tsx b/packages/templates/nextjs-site/src/app/[lang]/page.tsx
new file mode 100644
index 00000000..ca0f900c
--- /dev/null
+++ b/packages/templates/nextjs-site/src/app/[lang]/page.tsx
@@ -0,0 +1,45 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { ArrowRight } from "lucide-react";
+import { isLocale, messages } from "@/lib/i18n";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ lang: string }>;
+}): Promise {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ return {
+ title: messages[lang].title,
+ description: messages[lang].description,
+ alternates: {
+ canonical: `/${lang}/`,
+ languages: { "en-US": "/en/", "zh-CN": "/zh/" },
+ },
+ };
+}
+
+export default async function Page({ params }: { params: Promise<{ lang: string }> }) {
+ const { lang } = await params;
+ if (!isLocale(lang)) notFound();
+ const text = messages[lang];
+ return (
+
+
+ {text.title}
+ {text.description}
+
+
+
+ );
+}
diff --git a/packages/templates/nextjs-site/src/app/global-not-found.tsx b/packages/templates/nextjs-site/src/app/global-not-found.tsx
new file mode 100644
index 00000000..043cc791
--- /dev/null
+++ b/packages/templates/nextjs-site/src/app/global-not-found.tsx
@@ -0,0 +1,13 @@
+import "./globals.css";
+export default function NotFound() {
+ return (
+
+
+ Page not found / 页面不存在
+
+ Back to home / 返回首页
+
+
+
+ );
+}
diff --git a/packages/templates/nextjs-site/src/app/globals.css b/packages/templates/nextjs-site/src/app/globals.css
new file mode 100644
index 00000000..5266d179
--- /dev/null
+++ b/packages/templates/nextjs-site/src/app/globals.css
@@ -0,0 +1,124 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+@import "../styles/tokens.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: ui-sans-serif, system-ui, sans-serif;
+ --font-mono: ui-monospace, monospace;
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-success-surface: var(--success-surface);
+ --color-success-border: var(--success-border);
+ --color-success-foreground: var(--success-foreground);
+ --color-info-surface: var(--info-surface);
+ --color-info-border: var(--info-border);
+ --color-info-foreground: var(--info-foreground);
+ --color-warning-surface: var(--warning-surface);
+ --color-warning-border: var(--warning-border);
+ --color-warning-foreground: var(--warning-foreground);
+ --color-error-surface: var(--error-surface);
+ --color-error-border: var(--error-border);
+ --color-error-foreground: var(--error-foreground);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Toast 自定义样式 */
+@layer components {
+ /* 强制应用自定义颜色 */
+ [data-slot="toast"] {
+ background: var(--toast-bg, var(--popover)) !important;
+ border: 1px solid var(--toast-border, var(--border)) !important;
+ color: var(--toast-text, var(--popover-foreground)) !important;
+ }
+
+ [data-slot="toast"][data-type="success"] {
+ --toast-bg: var(--success-surface);
+ --toast-border: var(--success-border);
+ --toast-text: var(--success-foreground);
+ }
+
+ [data-slot="toast"][data-type="error"] {
+ --toast-bg: var(--error-surface);
+ --toast-border: var(--error-border);
+ --toast-text: var(--error-foreground);
+ }
+
+ [data-slot="toast"][data-type="warning"] {
+ --toast-bg: var(--warning-surface);
+ --toast-border: var(--warning-border);
+ --toast-text: var(--warning-foreground);
+ }
+
+ [data-slot="toast"][data-type="info"] {
+ --toast-bg: var(--info-surface);
+ --toast-border: var(--info-border);
+ --toast-text: var(--info-foreground);
+ }
+
+ /* Toast 标题和描述颜色 */
+ [data-slot="toast"] [data-slot="toast-title"] {
+ color: var(--toast-text, var(--popover-foreground)) !important;
+ font-weight: 600;
+ }
+
+ [data-slot="toast"] [data-slot="toast-description"] {
+ color: var(--toast-text, var(--popover-foreground)) !important;
+ opacity: 0.8;
+ }
+
+ /* 动作按钮样式 */
+ [data-slot="toast"] [data-slot="toast-action"] {
+ background: var(--toast-bg, var(--popover)) !important;
+ color: var(--toast-text, var(--popover-foreground)) !important;
+ border: 1px solid var(--toast-border, var(--border)) !important;
+ }
+
+ [data-slot="toast"] [data-slot="toast-action"]:hover {
+ opacity: 0.9;
+ }
+}
diff --git a/packages/templates/nextjs-site/src/app/robots.ts b/packages/templates/nextjs-site/src/app/robots.ts
new file mode 100644
index 00000000..3d06f973
--- /dev/null
+++ b/packages/templates/nextjs-site/src/app/robots.ts
@@ -0,0 +1,6 @@
+import type { MetadataRoute } from "next";
+export const dynamic = "force-static";
+export default function robots(): MetadataRoute.Robots {
+ const base = (process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com").replace(/\/$/, "");
+ return { rules: { userAgent: "*", allow: "/" }, sitemap: `${base}/sitemap.xml` };
+}
diff --git a/packages/templates/nextjs-site/src/app/sitemap.ts b/packages/templates/nextjs-site/src/app/sitemap.ts
new file mode 100644
index 00000000..13d8917a
--- /dev/null
+++ b/packages/templates/nextjs-site/src/app/sitemap.ts
@@ -0,0 +1,9 @@
+import type { MetadataRoute } from "next";
+import { locales } from "@/lib/i18n";
+export const dynamic = "force-static";
+export default function sitemap(): MetadataRoute.Sitemap {
+ const base = (process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com").replace(/\/$/, "");
+ return locales.flatMap((lang) =>
+ ["", "about/"].map((path) => ({ url: `${base}/${lang}/${path}` })),
+ );
+}
diff --git a/packages/templates/nextjs-site/src/components/site-document.tsx b/packages/templates/nextjs-site/src/components/site-document.tsx
new file mode 100644
index 00000000..eec4b9e6
--- /dev/null
+++ b/packages/templates/nextjs-site/src/components/site-document.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from "react";
+import { ThemeProvider } from "@/components/theme-provider";
+import { Toaster } from "@/components/ui/toast";
+import { htmlLang, type Locale } from "@/lib/i18n";
+import "@/app/globals.css";
+
+export function SiteDocument({ locale, children }: { locale: Locale; children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/packages/templates/nextjs-site/src/components/theme-provider.tsx b/packages/templates/nextjs-site/src/components/theme-provider.tsx
new file mode 100644
index 00000000..bfadc0e6
--- /dev/null
+++ b/packages/templates/nextjs-site/src/components/theme-provider.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import type { ThemeProviderProps } from "next-themes";
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children} ;
+}
diff --git a/packages/templates/nextjs-site/src/components/theme-toggle.tsx b/packages/templates/nextjs-site/src/components/theme-toggle.tsx
new file mode 100644
index 00000000..e7d38539
--- /dev/null
+++ b/packages/templates/nextjs-site/src/components/theme-toggle.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { messages, type Locale } from "@/lib/i18n";
+
+export function ThemeToggle({ locale }: { locale: Locale }) {
+ const { setTheme } = useTheme();
+ const text = messages[locale];
+ return (
+
+ }
+ >
+
+
+
+
+ setTheme("light")}>{text.light}
+ setTheme("dark")}>{text.dark}
+ setTheme("system")}>{text.system}
+
+
+ );
+}
diff --git a/packages/templates/nextjs-site/src/components/ui/button.tsx b/packages/templates/nextjs-site/src/components/ui/button.tsx
new file mode 100644
index 00000000..0be1f23b
--- /dev/null
+++ b/packages/templates/nextjs-site/src/components/ui/button.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ cn(
+ buttonVariants({ variant, size }),
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/packages/templates/nextjs-site/src/components/ui/dropdown-menu.tsx b/packages/templates/nextjs-site/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..726bc74f
--- /dev/null
+++ b/packages/templates/nextjs-site/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,277 @@
+"use client";
+
+import * as React from "react";
+import { Menu as MenuPrimitive } from "@base-ui/react/menu";
+import { cn } from "@/lib/utils";
+
+import { CheckIcon, ChevronRightIcon } from "lucide-react";
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return ;
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return ;
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return ;
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick) {
+ return (
+
+
+
+ cn(
+ "cn-menu-target cn-menu-translucent z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+
+
+ );
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return ;
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ cn(
+ "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ cn(
+ "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ cn(
+ "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ >
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ cn(
+ "cn-menu-target cn-menu-translucent w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
+ sideOffset={sideOffset}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ cn(
+ "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ checked={checked}
+ {...props}
+ >
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return ;
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+
+ cn(
+ "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ >
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
+ return (
+
+ cn(
+ "-mx-1 my-1 h-px bg-border",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/packages/templates/nextjs-site/src/components/ui/toast.tsx b/packages/templates/nextjs-site/src/components/ui/toast.tsx
new file mode 100644
index 00000000..7666dc47
--- /dev/null
+++ b/packages/templates/nextjs-site/src/components/ui/toast.tsx
@@ -0,0 +1,246 @@
+"use client";
+
+import * as React from "react";
+import { Toast as ToastPrimitive } from "@base-ui/react/toast";
+import { cn } from "@/lib/utils";
+
+import { Button } from "@/components/ui/button";
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+ XIcon,
+} from "lucide-react";
+
+type ToastData = { className?: string };
+
+const toast = ToastPrimitive.createToastManager();
+
+function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
+ return ;
+}
+
+function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
+ return ;
+}
+
+function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
+ return (
+
+ cn(
+ "pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
+ return (
+
+ cn(
+ "group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ "[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
+ "h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
+ "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
+ "data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
+ "data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
+ "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
+ "data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
+ return (
+
+ cn(
+ "flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
+ return (
+
+ cn("text-sm font-medium", typeof className === "function" ? className(state) : className)
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) {
+ return (
+
+ cn(
+ "text-sm text-muted-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastAction({
+ className,
+ render = ,
+ ...props
+}: ToastPrimitive.Action.Props) {
+ return (
+
+ cn("shrink-0", typeof className === "function" ? className(state) : className)
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastClose({
+ className,
+ children,
+ render = ,
+ ...props
+}: ToastPrimitive.Close.Props) {
+ return (
+
+ cn(
+ "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function ToastIcon({ type }: { type: string | undefined }) {
+ let icon: React.ReactNode = null;
+
+ if (type === "success") {
+ icon = ;
+ }
+
+ if (type === "info") {
+ icon = ;
+ }
+
+ if (type === "warning") {
+ icon = ;
+ }
+
+ if (type === "error") {
+ icon = ;
+ }
+
+ if (type === "loading") {
+ icon = ;
+ }
+
+ if (!icon) {
+ return null;
+ }
+
+ return (
+
+ {icon}
+
+ );
+}
+
+function ToastList() {
+ const { toasts } = ToastPrimitive.useToastManager();
+
+ return toasts.map((toastItem) => (
+
+
+
+
+
+
+
+
+
+
+
+ ));
+}
+
+function Toaster({ children, toastManager = toast, ...props }: ToastPrimitive.Provider.Props) {
+ return (
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+const createToastManager = ToastPrimitive.createToastManager;
+const useToastManager = ToastPrimitive.useToastManager;
+
+export {
+ Toaster,
+ Toast,
+ ToastAction,
+ ToastClose,
+ ToastContent,
+ ToastDescription,
+ ToastPortal,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+ createToastManager,
+ toast,
+ useToastManager,
+};
diff --git a/packages/templates/nextjs-site/src/lib/i18n.ts b/packages/templates/nextjs-site/src/lib/i18n.ts
new file mode 100644
index 00000000..d975e13f
--- /dev/null
+++ b/packages/templates/nextjs-site/src/lib/i18n.ts
@@ -0,0 +1,40 @@
+export const locales = ["en", "zh"] as const;
+export type Locale = (typeof locales)[number];
+export const htmlLang = { en: "en-US", zh: "zh-CN" };
+export function isLocale(value: string): value is Locale {
+ return locales.includes(value as Locale);
+}
+export const messages = {
+ en: {
+ name: "My Website",
+ title: "Build something worth sharing",
+ description: "A React website ready for your product, company, or next idea.",
+ home: "Home",
+ about: "About",
+ start: "Explore the site",
+ theme: "Change theme",
+ light: "Light",
+ dark: "Dark",
+ system: "System",
+ aboutTitle: "A simple place to tell your story",
+ aboutDescription: "Add your company story, product details, or latest work here.",
+ back: "Back to home",
+ notFound: "Page not found",
+ },
+ zh: {
+ name: "我的网站",
+ title: "让值得分享的想法成为网站",
+ description: "一个为产品、公司和新想法准备好的 React 网站。",
+ home: "首页",
+ about: "关于",
+ start: "探索网站",
+ theme: "切换主题",
+ light: "浅色",
+ dark: "深色",
+ system: "跟随系统",
+ aboutTitle: "在这里讲述你的故事",
+ aboutDescription: "添加公司介绍、产品信息或最新作品。",
+ back: "返回首页",
+ notFound: "页面不存在",
+ },
+};
diff --git a/packages/templates/nextjs-site/src/lib/utils.ts b/packages/templates/nextjs-site/src/lib/utils.ts
new file mode 100644
index 00000000..ac680b30
--- /dev/null
+++ b/packages/templates/nextjs-site/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/packages/templates/nextjs-site/src/styles/tokens.css b/packages/templates/nextjs-site/src/styles/tokens.css
new file mode 100644
index 00000000..e86fe74d
--- /dev/null
+++ b/packages/templates/nextjs-site/src/styles/tokens.css
@@ -0,0 +1,93 @@
+:root {
+ --radius: 0.75rem;
+ --background: oklch(0.98 0.01 240);
+ --foreground: oklch(0.08 0.02 240);
+ --card: oklch(0.98 0.01 240);
+ --card-foreground: oklch(0.08 0.02 240);
+ --popover: oklch(0.98 0.01 240);
+ --popover-foreground: oklch(0.08 0.02 240);
+ --primary: oklch(0.65 0.21 42);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.94 0.02 240);
+ --secondary-foreground: oklch(0.08 0.02 240);
+ --muted: oklch(0.94 0.02 240);
+ --muted-foreground: oklch(0.45 0.015 240);
+ --accent: oklch(0.94 0.02 240);
+ --accent-foreground: oklch(0.08 0.02 240);
+ --destructive: oklch(0.55 0.22 25);
+ --border: oklch(0.89 0.02 240);
+ --input: oklch(0.89 0.02 240);
+ --ring: oklch(0.65 0.21 42);
+ --success-surface: rgb(240 253 244);
+ --success-border: rgb(34 197 94);
+ --success-foreground: rgb(22 101 52);
+ --info-surface: rgb(239 246 255);
+ --info-border: rgb(59 130 246);
+ --info-foreground: rgb(30 58 138);
+ --warning-surface: rgb(255 251 235);
+ --warning-border: rgb(245 158 11);
+ --warning-foreground: rgb(146 64 14);
+ --error-surface: rgb(254 242 242);
+ --error-border: rgb(239 68 68);
+ --error-foreground: rgb(127 29 29);
+ --chart-1: oklch(0.6 0.2 260);
+ --chart-2: oklch(0.7 0.15 220);
+ --chart-3: oklch(0.6 0.2 190);
+ --chart-4: oklch(0.55 0.22 150);
+ --chart-5: oklch(0.7 0.2 50);
+ --sidebar: oklch(0.96 0.015 240);
+ --sidebar-foreground: oklch(0.08 0.02 240);
+ --sidebar-primary: oklch(0.65 0.21 42);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.92 0.02 240);
+ --sidebar-accent-foreground: oklch(0.08 0.02 240);
+ --sidebar-border: oklch(0.89 0.02 240);
+ --sidebar-ring: oklch(0.65 0.21 42);
+}
+
+.dark {
+ --radius: 0.75rem;
+ --background: oklch(0.08 0.02 240);
+ --foreground: oklch(0.98 0.01 240);
+ --card: oklch(0.12 0.03 240);
+ --card-foreground: oklch(0.98 0.01 240);
+ --popover: oklch(0.12 0.03 240);
+ --popover-foreground: oklch(0.98 0.01 240);
+ --primary: oklch(0.65 0.21 42);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.15 0.03 240);
+ --secondary-foreground: oklch(0.98 0.01 240);
+ --muted: oklch(0.15 0.03 240);
+ --muted-foreground: oklch(0.6 0.02 240);
+ --accent: oklch(0.15 0.03 240);
+ --accent-foreground: oklch(0.98 0.01 240);
+ --destructive: oklch(0.6 0.2 25);
+ --border: oklch(0.2 0.03 240);
+ --input: oklch(0.2 0.03 240);
+ --ring: oklch(0.65 0.21 42);
+ --success-surface: rgb(5 46 22);
+ --success-border: rgb(34 197 94);
+ --success-foreground: rgb(187 247 208);
+ --info-surface: rgb(23 37 84);
+ --info-border: rgb(59 130 246);
+ --info-foreground: rgb(191 219 254);
+ --warning-surface: rgb(69 26 3);
+ --warning-border: rgb(245 158 11);
+ --warning-foreground: rgb(254 215 170);
+ --error-surface: rgb(69 10 10);
+ --error-border: rgb(239 68 68);
+ --error-foreground: rgb(254 202 202);
+ --chart-1: oklch(0.5 0.22 260);
+ --chart-2: oklch(0.6 0.18 220);
+ --chart-3: oklch(0.5 0.2 190);
+ --chart-4: oklch(0.5 0.22 150);
+ --chart-5: oklch(0.6 0.2 50);
+ --sidebar: oklch(0.12 0.03 240);
+ --sidebar-foreground: oklch(0.98 0.01 240);
+ --sidebar-primary: oklch(0.65 0.21 42);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.2 0.03 240);
+ --sidebar-accent-foreground: oklch(0.98 0.01 240);
+ --sidebar-border: oklch(0.2 0.03 240);
+ --sidebar-ring: oklch(0.65 0.21 42);
+}
diff --git a/packages/templates/nextjs-site/template.json b/packages/templates/nextjs-site/template.json
new file mode 100644
index 00000000..0d417b1e
--- /dev/null
+++ b/packages/templates/nextjs-site/template.json
@@ -0,0 +1,4 @@
+{
+ "schemaVersion": 1,
+ "exclude": [".source", "pnpm-workspace.yaml"]
+}
diff --git a/packages/templates/nextjs-site/tsconfig.json b/packages/templates/nextjs-site/tsconfig.json
new file mode 100644
index 00000000..a368696a
--- /dev/null
+++ b/packages/templates/nextjs-site/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/templates/react-spa/.env.example b/packages/templates/react-spa/.env.example
index 8d526614..292a14c0 100644
--- a/packages/templates/react-spa/.env.example
+++ b/packages/templates/react-spa/.env.example
@@ -1,4 +1 @@
-# 应用配置
-VITE_APP_NAME=Modern Frontend CSR WebApp Template
-VITE_APP_VERSION=0.1.0
-VITE_ENVIRONMENT=development
+VITE_API_URL=
diff --git a/packages/templates/react-spa/README.md b/packages/templates/react-spa/README.md
index 1782e5ed..8eecc726 100644
--- a/packages/templates/react-spa/README.md
+++ b/packages/templates/react-spa/README.md
@@ -1,104 +1,21 @@
-# Web CSR React Template
+# React SPA
-面向浏览器端单页应用的 React 模板,当前基线已经切到 `shadcn/ui + Tailwind CSS v4 + CSS variables`,并保留了 SWR、Zustand、Axios、主题切换和错误兜底这些常用能力。
+React · TypeScript · shadcn/ui (Base UI) · Tailwind CSS · Axios · SWR · Zustand.
-## 技术栈
+## 中文
-- React 19
-- TypeScript 7
-- Vite 8
-- shadcn/ui + Radix UI
-- Tailwind CSS v4
-- SWR
-- Zustand
-- Axios
-- sonner
-- oxlint + oxfmt
+- `pnpm dev`、`pnpm build`、`pnpm check`:开发、构建、检查。
+- 起始页面包含主题切换和 Base UI Toast,没有认证或业务 API。
+- `src/lib/http.ts` 提供 Axios 实例和原始 JSON fetcher;通过 `.env.example` 中的公开 API 地址连接后端。
+- SWR 管理远程数据与缓存;Zustand 只管理共享 UI 状态,组件局部状态使用 React。
+- Next.js 的 store 必须按 Provider 创建;服务端数据使用服务端函数,私密环境变量不能传入客户端。
+- 按需用 shadcn 添加 Base UI 组件,手动安装业务 skills。
-## 目录结构
+## English
-```text
-src/
-├── api/ # 请求 key 与纯函数 API
-├── components/
-│ ├── ErrorBoundary.tsx
-│ └── ui/ # shadcn/ui 基础组件
-├── hooks/ # 组合 hooks(如 toast)
-├── lib/
-│ ├── http.ts # Axios 客户端
-│ ├── toast.ts # sonner 封装
-│ ├── utils.ts # cn 等工具函数
-│ └── stores/ # Zustand stores
-├── pages/ # 页面组件
-├── providers/ # Theme / SWR Provider
-├── router/ # 路由配置
-├── styles/ # reset、Tailwind、设计令牌
-├── types/ # 类型定义
-├── App.tsx
-└── main.tsx
-```
-
-## 快速开始
-
-```bash
-pnpm install
-pnpm dev
-```
-
-默认开发地址:`http://localhost:5173`
-
-## 常用命令
-
-| 命令 | 说明 |
-| ----------------- | -------------------------- |
-| `pnpm dev` | 启动开发服务器 |
-| `pnpm build` | 构建生产产物 |
-| `pnpm preview` | 预览构建结果 |
-| `pnpm lint` | 执行 oxlint |
-| `pnpm lint:fix` | 自动修复 lint 问题 |
-| `pnpm format` | 检查格式 |
-| `pnpm format:fix` | 自动格式化 |
-| `pnpm check` | 执行 lint + format |
-| `pnpm check:fix` | 执行 lint:fix + format:fix |
-
-## 请求层约定
-
-模板里的请求模块统一采用 “`key + pure function`” 形式,便于 SWR 复用和测试。当前示例接口是本地 mock,但组织方式和真实请求保持一致。
-
-```ts
-export const demoKey = "/api/demo";
-
-export async function getDemo() {
- return {
- message: "SWR cache 已同步完成",
- timestamp: new Date().toLocaleString("zh-CN"),
- count: Math.floor(Math.random() * 100),
- status: "success",
- };
-}
-```
-
-页面消费时直接复用导出的 key 和请求函数:
-
-```tsx
-const { data, isLoading } = useSWR(demoKey, getDemo);
-```
-
-当前示例可参考:
-
-- `src/api/demo.ts`
-- `src/api/auth.ts`
-- `src/api/users.ts`
-
-## UI 与主题
-
-- `src/components/ui/*` 提供模板内置的 shadcn/ui 组件
-- `src/providers/ThemeProvider.tsx` 负责同步 light / dark 主题
-- `src/styles/index.css` 是样式入口,负责串联 reset、tokens 与 Tailwind
-- `src/styles/tokens.css` 是唯一 design token 源
-- `src/styles/tailwind.css` 负责 Tailwind v4 的 `@theme inline` 映射与全局样式层
-- `src/lib/toast.ts` 封装 `sonner`,避免页面直接依赖第三方通知 API
-
-## 说明
-
-这是模板仓库,不内置项目级 CI、Git hooks、Docker Compose 或发布流程。工作区治理由 `one-cli` 提供,提交检查使用工作区的 hk 配置;版本管理和发布流程由项目按需配置。
+- `pnpm dev`, `pnpm build`, `pnpm check`: develop, build, validate.
+- The starter includes theme switching and Base UI Toast, with no authentication or business API.
+- `src/lib/http.ts` exports an Axios client and a raw JSON fetcher. Configure the public API URL from `.env.example`.
+- SWR owns remote data and caching. Zustand owns shared UI state; use React for local state.
+- In Next.js, create stores per Provider. Fetch server data with server functions and keep private environment variables off the client.
+- Add Base UI components with shadcn as needed. Install business skills manually.
diff --git a/packages/templates/react-spa/components.json b/packages/templates/react-spa/components.json
index 5e782a0a..1de35e7d 100644
--- a/packages/templates/react-spa/components.json
+++ b/packages/templates/react-spa/components.json
@@ -1,12 +1,12 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
- "style": "new-york",
+ "style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/tailwind.css",
- "baseColor": "slate",
+ "baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
@@ -17,5 +17,9 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
- "iconLibrary": "lucide"
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "menuColor": "default",
+ "menuAccent": "subtle",
+ "registries": {}
}
diff --git a/packages/templates/react-spa/index.html b/packages/templates/react-spa/index.html
index ef80c79b..4d97bc13 100644
--- a/packages/templates/react-spa/index.html
+++ b/packages/templates/react-spa/index.html
@@ -2,9 +2,8 @@
-
- Vite + React + TS
+ One CLI · React
diff --git a/packages/templates/react-spa/package.json b/packages/templates/react-spa/package.json
index 3897115d..c259b572 100644
--- a/packages/templates/react-spa/package.json
+++ b/packages/templates/react-spa/package.json
@@ -11,12 +11,12 @@
"lint:fix": "oxlint --fix .",
"format": "oxfmt --check .",
"format:fix": "oxfmt --write .",
- "check": "pnpm run lint && pnpm run format",
- "check:fix": "pnpm run lint:fix && pnpm run format:fix"
+ "check": "pnpm run typecheck && pnpm run lint && pnpm run format",
+ "check:fix": "pnpm run lint:fix && pnpm run format:fix",
+ "typecheck": "tsc -b"
},
"dependencies": {
- "@radix-ui/react-label": "^2.1.15",
- "@radix-ui/react-slot": "^1.3.3",
+ "@base-ui/react": "^1.8.0",
"@tailwindcss/vite": "^4.3.3",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
@@ -25,10 +25,11 @@
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router-dom": "^7.18.4",
- "sonner": "^2.0.8",
+ "shadcn": "^4.21.0",
"swr": "^2.5.1",
"tailwind-merge": "^3.7.0",
"tailwindcss": "^4.3.3",
+ "tw-animate-css": "^1.4.0",
"zustand": "^5.0.15"
},
"devDependencies": {
@@ -36,24 +37,11 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
- "cross-env": "^10.1.0",
- "git-cz": "^4.9.0",
- "globals": "^17.12.0",
- "lint-staged": "^17.6.0",
"oxfmt": "^0.71.0",
"oxlint": "^1.86.0",
"typescript": "^7.0.2",
"vite": "^8.3.1"
},
- "lint-staged": {
- "*.{js,jsx,ts,tsx,mjs,cjs}": [
- "oxlint --fix",
- "oxfmt --write --no-error-on-unmatched-pattern"
- ],
- "*.{json,md,css,scss,html,yml,yaml}": [
- "oxfmt --write --no-error-on-unmatched-pattern"
- ]
- },
"engines": {
"node": "^24.15.0 || >=26.0.0"
},
diff --git a/packages/templates/react-spa/public/onecli-logo-inverted.svg b/packages/templates/react-spa/public/onecli-logo-inverted.svg
deleted file mode 100644
index ee1d78fd..00000000
--- a/packages/templates/react-spa/public/onecli-logo-inverted.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
- One CLI
- One CLI horizontal wordmark — inverted for dark backgrounds
-
- ONE CLI
-
diff --git a/packages/templates/react-spa/public/onecli-logo.svg b/packages/templates/react-spa/public/onecli-logo.svg
deleted file mode 100644
index 6194d834..00000000
--- a/packages/templates/react-spa/public/onecli-logo.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
- One CLI
- One CLI horizontal wordmark — primary, torch
-
- ONE CLI
-
diff --git a/packages/templates/react-spa/public/vite.svg b/packages/templates/react-spa/public/vite.svg
deleted file mode 100644
index e7b8dfb1..00000000
--- a/packages/templates/react-spa/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/templates/react-spa/src/App.tsx b/packages/templates/react-spa/src/App.tsx
index 8f00064e..69bb9704 100644
--- a/packages/templates/react-spa/src/App.tsx
+++ b/packages/templates/react-spa/src/App.tsx
@@ -1,53 +1,22 @@
-import { MoonStar, SunMedium } from "lucide-react";
-import type React from "react";
-import { Badge } from "@/components/ui/badge";
+import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useThemeStore } from "@/lib/stores/theme";
import { AppRoutes } from "@/router/routes";
-export const App: React.FC = () => {
- const { mode, toggle } = useThemeStore();
-
+export function App() {
+ const mode = useThemeStore((state) => state.mode);
+ const toggle = useThemeStore((state) => state.toggle);
return (
);
-};
+}
diff --git a/packages/templates/react-spa/src/api/auth.ts b/packages/templates/react-spa/src/api/auth.ts
deleted file mode 100644
index 7525f13d..00000000
--- a/packages/templates/react-spa/src/api/auth.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { httpClient } from "@/lib/http";
-import type { ApiResponse } from "@/types/api";
-import type { User } from "@/api/users";
-
-export interface LoginRequest {
- email: string;
- password: string;
-}
-
-export interface LoginResponse {
- token: string;
- user: User;
-}
-
-export const loginKey = "/auth/login";
-
-export function login(data: LoginRequest): Promise> {
- return httpClient.post(loginKey, data);
-}
-
-export const logoutKey = "/auth/logout";
-
-export function logout(): Promise> {
- return httpClient.post(logoutKey);
-}
-
-export const refreshTokenKey = "/auth/refresh";
-
-export function refreshToken(): Promise> {
- return httpClient.post(refreshTokenKey);
-}
-
-export const currentUserKey = "/auth/me";
-
-export function getCurrentUser(): Promise> {
- return httpClient.get(currentUserKey);
-}
diff --git a/packages/templates/react-spa/src/api/demo.ts b/packages/templates/react-spa/src/api/demo.ts
deleted file mode 100644
index 297bdc09..00000000
--- a/packages/templates/react-spa/src/api/demo.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-export interface DemoResponse {
- message: string;
- timestamp: string;
- count: number;
- status: string;
-}
-
-export const demoKey = "/api/demo";
-
-export async function getDemo(): Promise {
- await new Promise((resolve) => setTimeout(resolve, 900));
-
- return {
- message: "SWR cache 已同步完成",
- timestamp: new Date().toLocaleString("zh-CN"),
- count: Math.floor(Math.random() * 100),
- status: "success",
- };
-}
diff --git a/packages/templates/react-spa/src/api/users.ts b/packages/templates/react-spa/src/api/users.ts
deleted file mode 100644
index 75f220ef..00000000
--- a/packages/templates/react-spa/src/api/users.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { httpClient } from "@/lib/http";
-import type { ApiResponse, PaginationParams, PaginationResponse } from "@/types/api";
-
-export interface User {
- id: number;
- name: string;
- email: string;
- avatar?: string;
- createdAt: string;
-}
-
-export interface CreateUserRequest {
- name: string;
- email: string;
-}
-
-export const usersKey = "/users";
-
-export function getUsers(params: PaginationParams): Promise>> {
- return httpClient.get(usersKey, { params });
-}
-
-export function getUserKey(id: number) {
- return `${usersKey}/${id}`;
-}
-
-export function getUserById(id: number): Promise> {
- return httpClient.get(getUserKey(id));
-}
-
-export function createUser(data: CreateUserRequest): Promise> {
- return httpClient.post(usersKey, data);
-}
-
-export function updateUser(
- id: number,
- data: Partial,
-): Promise> {
- return httpClient.put(getUserKey(id), data);
-}
-
-export function deleteUser(id: number): Promise> {
- return httpClient.delete(getUserKey(id));
-}
diff --git a/packages/templates/react-spa/src/assets/react.svg b/packages/templates/react-spa/src/assets/react.svg
deleted file mode 100644
index 6c87de9b..00000000
--- a/packages/templates/react-spa/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/packages/templates/react-spa/src/components/ErrorBoundary.tsx b/packages/templates/react-spa/src/components/ErrorBoundary.tsx
index a6bdc8fc..69ba1961 100644
--- a/packages/templates/react-spa/src/components/ErrorBoundary.tsx
+++ b/packages/templates/react-spa/src/components/ErrorBoundary.tsx
@@ -1,151 +1,21 @@
-import { AlertTriangle, Home, RotateCcw } from "lucide-react";
-import React from "react";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Component, type PropsWithChildren } from "react";
-interface ErrorBoundaryState {
- hasError: boolean;
- error?: Error;
- errorId?: string;
-}
-
-interface ErrorBoundaryProps {
- children: React.ReactNode;
- fallback?: React.ComponentType<{ error?: Error; resetError: () => void }>;
- onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
-}
-
-class ErrorBoundaryClass extends React.Component {
- constructor(props: ErrorBoundaryProps) {
- super(props);
- this.state = { hasError: false };
- }
-
- static getDerivedStateFromError(error: Error): ErrorBoundaryState {
- // 生成错误 ID
- const errorId = Math.random().toString(36).substring(2, 15);
- return {
- hasError: true,
- error,
- errorId,
- };
+export class ErrorBoundary extends Component {
+ state = { failed: false };
+ static getDerivedStateFromError() {
+ return { failed: true };
}
-
- componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
- // 调用自定义错误处理
- this.props.onError?.(error, errorInfo);
-
- // 开发环境下打印详细错误信息
- console.group("🚨 ErrorBoundary caught an error");
- console.error("Error:", error);
- console.error("Component Stack:", errorInfo.componentStack);
- console.groupEnd();
+ componentDidCatch(error: Error) {
+ console.error(error);
}
-
- handleReset = () => {
- this.setState({ hasError: false, error: undefined, errorId: undefined });
- };
-
render() {
- if (this.state.hasError) {
- // 如果提供了自定义 fallback 组件
- if (this.props.fallback) {
- const FallbackComponent = this.props.fallback;
- return ;
- }
-
- // 默认错误 UI
- return (
-
- );
- }
-
- return this.props.children;
+ return this.state.failed ? (
+
+
页面出现错误 / Something went wrong
+
window.location.reload()}>重新加载 / Reload
+
+ ) : (
+ this.props.children
+ );
}
}
-
-// 默认错误回退组件
-const DefaultErrorFallback: React.FC<{
- error?: Error;
- resetError: () => void;
- errorId?: string;
-}> = ({ error, resetError, errorId }) => {
- const isDev = !import.meta.env.PROD;
-
- return (
-
-
-
-
-
- 应用程序遇到了一个错误
-
- 很抱歉,应用程序遇到了一个意外错误。问题已经被本地错误边界捕获,你可以重试或返回首页。
-
-
-
-
- {errorId ? (
-
- 错误 ID:
- {errorId}
-
- ) : null}
-
- {isDev && error ? (
-
-
开发模式错误详情
-
- {error.message}
- {error.stack ? `\n\nStack trace:\n${error.stack}` : ""}
-
-
- ) : null}
-
-
-
-
- 重新加载
-
- {
- window.location.href = "/";
- }}
- >
-
- 返回首页
-
-
-
-
-
- );
-};
-
-export const ErrorBoundary: React.FC = ({ children, ...props }) => {
- return {children} ;
-};
-
-// HOC 版本的 ErrorBoundary
-export const withErrorBoundary = (
- Component: React.ComponentType
,
- fallback?: React.ComponentType<{ error?: Error; resetError: () => void }>,
-) => {
- const WrappedComponent = (props: P) => (
-
-
-
- );
-
- WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`;
- return WrappedComponent;
-};
-
-export default ErrorBoundary;
diff --git a/packages/templates/react-spa/src/components/ui/alert.tsx b/packages/templates/react-spa/src/components/ui/alert.tsx
deleted file mode 100644
index 59b219cd..00000000
--- a/packages/templates/react-spa/src/components/ui/alert.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { cva, type VariantProps } from "class-variance-authority";
-import * as React from "react";
-import { cn } from "@/lib/utils";
-
-const alertVariants = cva(
- "relative w-full rounded-2xl border px-4 py-3 text-sm grid gap-1.5 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-current [&>svg+div]:translate-y-[-2px] [&>svg~*]:pl-7",
- {
- variants: {
- variant: {
- default: "border-border/80 bg-background/80 text-foreground",
- destructive:
- "border-destructive/30 bg-destructive/8 text-destructive [&>svg]:text-destructive",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-const Alert = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes & VariantProps
->(({ className, variant, ...props }, ref) => (
-
-));
-Alert.displayName = "Alert";
-
-const AlertTitle = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-AlertTitle.displayName = "AlertTitle";
-
-const AlertDescription = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-AlertDescription.displayName = "AlertDescription";
-
-export { Alert, AlertTitle, AlertDescription };
diff --git a/packages/templates/react-spa/src/components/ui/badge.tsx b/packages/templates/react-spa/src/components/ui/badge.tsx
deleted file mode 100644
index a080bc8f..00000000
--- a/packages/templates/react-spa/src/components/ui/badge.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { cva, type VariantProps } from "class-variance-authority";
-import type * as React from "react";
-import { cn } from "@/lib/utils";
-
-const badgeVariants = cva(
- "inline-flex items-center justify-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition-[color,background-color,border-color]",
- {
- variants: {
- variant: {
- default: "border-transparent bg-primary text-primary-foreground",
- secondary: "border-transparent bg-secondary text-secondary-foreground",
- destructive: "border-transparent bg-destructive text-destructive-foreground",
- outline: "border-border/80 bg-background/70 text-foreground",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-function Badge({
- className,
- variant,
- ...props
-}: React.ComponentProps<"span"> & VariantProps) {
- return ;
-}
-
-export { Badge, badgeVariants };
diff --git a/packages/templates/react-spa/src/components/ui/button.tsx b/packages/templates/react-spa/src/components/ui/button.tsx
index 3edade1f..eceaf035 100644
--- a/packages/templates/react-spa/src/components/ui/button.tsx
+++ b/packages/templates/react-spa/src/components/ui/button.tsx
@@ -1,26 +1,35 @@
-import { Slot } from "@radix-ui/react-slot";
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
-import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-[background-color,color,box-shadow,border-color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 shrink-0",
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
- destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
+ default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
- "border border-input bg-background/90 shadow-sm hover:bg-accent hover:text-accent-foreground",
- secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/85",
- ghost: "hover:bg-accent hover:text-accent-foreground",
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default: "h-10 px-4 py-2",
- sm: "h-9 rounded-lg px-3",
- lg: "h-11 rounded-xl px-6",
- icon: "h-10 w-10",
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
},
},
defaultVariants: {
@@ -30,19 +39,24 @@ const buttonVariants = cva(
},
);
-export interface ButtonProps
- extends React.ButtonHTMLAttributes, VariantProps {
- asChild?: boolean;
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps) {
+ return (
+
+ cn(
+ buttonVariants({ variant, size }),
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
}
-const Button = React.forwardRef(
- ({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
- return (
-
- );
- },
-);
-Button.displayName = "Button";
-
export { Button, buttonVariants };
diff --git a/packages/templates/react-spa/src/components/ui/card.tsx b/packages/templates/react-spa/src/components/ui/card.tsx
deleted file mode 100644
index 57176844..00000000
--- a/packages/templates/react-spa/src/components/ui/card.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import * as React from "react";
-import { cn } from "@/lib/utils";
-
-const Card = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-Card.displayName = "Card";
-
-const CardHeader = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-CardHeader.displayName = "CardHeader";
-
-const CardTitle = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-CardTitle.displayName = "CardTitle";
-
-const CardDescription = React.forwardRef<
- HTMLParagraphElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-));
-CardDescription.displayName = "CardDescription";
-
-const CardContent = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-CardContent.displayName = "CardContent";
-
-const CardFooter = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
-);
-CardFooter.displayName = "CardFooter";
-
-export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/packages/templates/react-spa/src/components/ui/input.tsx b/packages/templates/react-spa/src/components/ui/input.tsx
deleted file mode 100644
index a48fac84..00000000
--- a/packages/templates/react-spa/src/components/ui/input.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import * as React from "react";
-import { cn } from "@/lib/utils";
-
-export interface InputProps extends React.InputHTMLAttributes {}
-
-const Input = React.forwardRef(
- ({ className, type, ...props }, ref) => {
- return (
-
- );
- },
-);
-Input.displayName = "Input";
-
-export { Input };
diff --git a/packages/templates/react-spa/src/components/ui/label.tsx b/packages/templates/react-spa/src/components/ui/label.tsx
deleted file mode 100644
index 3562a1de..00000000
--- a/packages/templates/react-spa/src/components/ui/label.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as LabelPrimitive from "@radix-ui/react-label";
-import * as React from "react";
-import { cn } from "@/lib/utils";
-
-const Label = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-Label.displayName = LabelPrimitive.Root.displayName;
-
-export { Label };
diff --git a/packages/templates/react-spa/src/components/ui/sonner.tsx b/packages/templates/react-spa/src/components/ui/sonner.tsx
deleted file mode 100644
index 72ff7175..00000000
--- a/packages/templates/react-spa/src/components/ui/sonner.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Toaster as Sonner, type ToasterProps } from "sonner";
-import { useThemeStore } from "@/lib/stores/theme";
-
-export const Toaster = (props: ToasterProps) => {
- const { mode } = useThemeStore();
-
- return (
-
- );
-};
diff --git a/packages/templates/react-spa/src/components/ui/toast.tsx b/packages/templates/react-spa/src/components/ui/toast.tsx
new file mode 100644
index 00000000..7666dc47
--- /dev/null
+++ b/packages/templates/react-spa/src/components/ui/toast.tsx
@@ -0,0 +1,246 @@
+"use client";
+
+import * as React from "react";
+import { Toast as ToastPrimitive } from "@base-ui/react/toast";
+import { cn } from "@/lib/utils";
+
+import { Button } from "@/components/ui/button";
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+ XIcon,
+} from "lucide-react";
+
+type ToastData = { className?: string };
+
+const toast = ToastPrimitive.createToastManager();
+
+function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
+ return ;
+}
+
+function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
+ return ;
+}
+
+function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
+ return (
+
+ cn(
+ "pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
+ return (
+
+ cn(
+ "group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
+ "[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
+ "h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
+ "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
+ "data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
+ "data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
+ "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
+ "data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
+ "data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
+ return (
+
+ cn(
+ "flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
+ return (
+
+ cn("text-sm font-medium", typeof className === "function" ? className(state) : className)
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastDescription({ className, ...props }: ToastPrimitive.Description.Props) {
+ return (
+
+ cn(
+ "text-sm text-muted-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastAction({
+ className,
+ render = ,
+ ...props
+}: ToastPrimitive.Action.Props) {
+ return (
+
+ cn("shrink-0", typeof className === "function" ? className(state) : className)
+ }
+ {...props}
+ />
+ );
+}
+
+function ToastClose({
+ className,
+ children,
+ render = ,
+ ...props
+}: ToastPrimitive.Close.Props) {
+ return (
+
+ cn(
+ "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
+ typeof className === "function" ? className(state) : className,
+ )
+ }
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function ToastIcon({ type }: { type: string | undefined }) {
+ let icon: React.ReactNode = null;
+
+ if (type === "success") {
+ icon = ;
+ }
+
+ if (type === "info") {
+ icon = ;
+ }
+
+ if (type === "warning") {
+ icon = ;
+ }
+
+ if (type === "error") {
+ icon = ;
+ }
+
+ if (type === "loading") {
+ icon = ;
+ }
+
+ if (!icon) {
+ return null;
+ }
+
+ return (
+
+ {icon}
+
+ );
+}
+
+function ToastList() {
+ const { toasts } = ToastPrimitive.useToastManager();
+
+ return toasts.map((toastItem) => (
+
+
+
+
+
+
+
+
+
+
+
+ ));
+}
+
+function Toaster({ children, toastManager = toast, ...props }: ToastPrimitive.Provider.Props) {
+ return (
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+const createToastManager = ToastPrimitive.createToastManager;
+const useToastManager = ToastPrimitive.useToastManager;
+
+export {
+ Toaster,
+ Toast,
+ ToastAction,
+ ToastClose,
+ ToastContent,
+ ToastDescription,
+ ToastPortal,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+ createToastManager,
+ toast,
+ useToastManager,
+};
diff --git a/packages/templates/react-spa/src/hooks/useToast.ts b/packages/templates/react-spa/src/hooks/useToast.ts
deleted file mode 100644
index 0260a576..00000000
--- a/packages/templates/react-spa/src/hooks/useToast.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import Toast from "@/lib/toast";
-
-export const useToast = () => Toast;
diff --git a/packages/templates/react-spa/src/lib/app-info.ts b/packages/templates/react-spa/src/lib/app-info.ts
deleted file mode 100644
index dbffe6c5..00000000
--- a/packages/templates/react-spa/src/lib/app-info.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * 应用信息工具类
- * 提供应用配置和环境信息
- */
-
-export const appInfo = {
- name: import.meta.env.VITE_APP_NAME || "Modern Frontend CSR WebApp Template",
- version: import.meta.env.VITE_APP_VERSION || "0.1.0",
- environment: import.meta.env.VITE_ENVIRONMENT || "development",
- isDev: import.meta.env.DEV,
- isProd: import.meta.env.PROD,
- isTest: import.meta.env.MODE === "test",
-} as const;
-
-/**
- * 获取应用完整版本信息
- */
-export const getVersionInfo = () => ({
- name: appInfo.name,
- version: appInfo.version,
- environment: appInfo.environment,
- buildTime: new Date().toISOString(),
- userAgent: navigator.userAgent,
-});
-
-/**
- * 检查是否为开发环境
- */
-export const isDevelopment = () => appInfo.isDev;
-
-/**
- * 检查是否为生产环境
- */
-export const isProduction = () => appInfo.isProd;
-
-/**
- * 获取环境标识字符串
- */
-export const getEnvironmentLabel = () => {
- if (appInfo.isDev) return "🔧 开发环境";
- if (appInfo.isProd) return "🚀 生产环境";
- return "🧪 测试环境";
-};
diff --git a/packages/templates/react-spa/src/lib/http.ts b/packages/templates/react-spa/src/lib/http.ts
index 75ad03b5..c8ba437c 100644
--- a/packages/templates/react-spa/src/lib/http.ts
+++ b/packages/templates/react-spa/src/lib/http.ts
@@ -1,114 +1,6 @@
-import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios";
-import { type ApiError, type ApiResponse, ErrorCode } from "@/types/api";
+import axios from "axios";
-class HttpClient {
- private instance: AxiosInstance;
-
- constructor(baseURL = "/api") {
- this.instance = axios.create({
- baseURL,
- timeout: 10000,
- headers: {
- "Content-Type": "application/json",
- },
- });
-
- this.setupInterceptors();
- }
-
- private setupInterceptors() {
- this.instance.interceptors.request.use(
- (config) => {
- const token = localStorage.getItem("token");
- if (token) {
- config.headers.Authorization = `Bearer ${token}`;
- }
- return config;
- },
- (error) => Promise.reject(error),
- );
-
- this.instance.interceptors.response.use(
- (response: AxiosResponse) => {
- const { data } = response;
-
- if (data.success && data.code === ErrorCode.SUCCESS) {
- return response;
- }
-
- const error: ApiError = {
- code: data.code,
- message: data.message,
- details: data,
- };
-
- return Promise.reject(error);
- },
- (error) => {
- if (error.response) {
- const apiError: ApiError = {
- code: error.response.status,
- message: error.response.data?.message || error.message,
- details: error.response.data,
- };
- return Promise.reject(apiError);
- }
-
- if (error.request) {
- const networkError: ApiError = {
- code: ErrorCode.NETWORK_ERROR,
- message: "网络请求失败,请检查网络连接",
- details: error,
- };
- return Promise.reject(networkError);
- }
-
- return Promise.reject({
- code: ErrorCode.INTERNAL_ERROR,
- message: error.message,
- details: error,
- });
- },
- );
- }
-
- async get(url: string, config?: AxiosRequestConfig): Promise> {
- const response = await this.instance.get(url, config);
- return response.data;
- }
-
- async post(
- url: string,
- data?: unknown,
- config?: AxiosRequestConfig,
- ): Promise> {
- const response = await this.instance.post(url, data, config);
- return response.data;
- }
-
- async put(
- url: string,
- data?: unknown,
- config?: AxiosRequestConfig,
- ): Promise> {
- const response = await this.instance.put(url, data, config);
- return response.data;
- }
-
- async delete(url: string, config?: AxiosRequestConfig): Promise> {
- const response = await this.instance.delete(url, config);
- return response.data;
- }
-
- async patch(
- url: string,
- data?: unknown,
- config?: AxiosRequestConfig,
- ): Promise> {
- const response = await this.instance.patch(url, data, config);
- return response.data;
- }
+export const http = axios.create({ baseURL: import.meta.env.VITE_API_URL, timeout: 10000 });
+export async function fetcher(url: string): Promise {
+ return (await http.get(url)).data;
}
-
-export const httpClient = new HttpClient();
-export default httpClient;
diff --git a/packages/templates/react-spa/src/lib/index.ts b/packages/templates/react-spa/src/lib/index.ts
deleted file mode 100644
index e5c22990..00000000
--- a/packages/templates/react-spa/src/lib/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export * from "./http";
-export * from "./stores";
-export * from "./utils";
diff --git a/packages/templates/react-spa/src/lib/stores/index.ts b/packages/templates/react-spa/src/lib/stores/index.ts
deleted file mode 100644
index 3797aeae..00000000
--- a/packages/templates/react-spa/src/lib/stores/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./theme";
diff --git a/packages/templates/react-spa/src/lib/stores/theme.ts b/packages/templates/react-spa/src/lib/stores/theme.ts
index ec6fb8a6..279fff42 100644
--- a/packages/templates/react-spa/src/lib/stores/theme.ts
+++ b/packages/templates/react-spa/src/lib/stores/theme.ts
@@ -1,34 +1,21 @@
-import { createStore } from "@/lib/utils";
-
-export type ThemeMode = "light" | "dark";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+type ThemeMode = "light" | "dark";
interface ThemeState {
mode: ThemeMode;
- setMode: (m: ThemeMode) => void;
toggle: () => void;
}
-
-const THEME_KEY = "app_theme_mode";
-
-const getInitial = (): ThemeMode => {
- const cached = localStorage.getItem(THEME_KEY) as ThemeMode | null;
- if (cached) return cached;
- // 跟随系统
- return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
-};
-
-export const useThemeStore = createStore(
- (set, get) => ({
- mode: getInitial(),
- setMode: (m) => {
- localStorage.setItem(THEME_KEY, m);
- set({ mode: m });
- },
- toggle: () => {
- const next = get().mode === "light" ? "dark" : "light";
- localStorage.setItem(THEME_KEY, next);
- set({ mode: next });
+export const useThemeStore = create()(
+ persist(
+ (set) => ({
+ mode: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
+ toggle: () => set((state) => ({ mode: state.mode === "dark" ? "light" : "dark" })),
+ }),
+ {
+ name: "ui-theme-v1",
+ storage: createJSONStorage(() => localStorage),
+ partialize: (state) => ({ mode: state.mode }),
},
- }),
- "themeStore",
+ ),
);
diff --git a/packages/templates/react-spa/src/lib/stores/toast.ts b/packages/templates/react-spa/src/lib/stores/toast.ts
deleted file mode 100644
index 82723d65..00000000
--- a/packages/templates/react-spa/src/lib/stores/toast.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-import Toast, { type ToastOptions, type ToastType } from "../toast";
-import { createStore } from "../utils";
-
-// Toast历史记录接口
-export interface ToastHistoryItem {
- id: string;
- type: ToastType;
- content: string;
- timestamp: number;
- options?: ToastOptions;
-}
-
-// Toast状态接口
-interface ToastState {
- /** Toast历史记录 */
- history: ToastHistoryItem[];
- /** 当前活跃的Toast数量 */
- activeCount: number;
- /** 是否启用Toast历史记录 */
- enableHistory: boolean;
- /** 最大历史记录数量 */
- maxHistoryCount: number;
-
- // 显示方法
- success: (content: string, options?: ToastOptions) => string;
- info: (content: string, options?: ToastOptions) => string;
- warning: (content: string, options?: ToastOptions) => string;
- error: (content: string, options?: ToastOptions) => string;
- loading: (content: string, options?: ToastOptions) => string;
-
- // 便捷方法
- successAction: (action: string, options?: ToastOptions) => string;
- errorAction: (action: string, reason?: string, options?: ToastOptions) => string;
- loadingAction: (action: string, options?: ToastOptions) => string;
- copySuccess: (options?: ToastOptions) => string;
- networkError: (options?: ToastOptions) => string;
- permissionDenied: (options?: ToastOptions) => string;
-
- // 管理方法
- update: (
- key: string,
- content: string,
- type?: ToastType,
- options?: Omit,
- ) => void;
- destroy: (key?: string) => void;
- clearHistory: () => void;
- setEnableHistory: (enable: boolean) => void;
- setMaxHistoryCount: (count: number) => void;
- getHistoryByType: (type: ToastType) => ToastHistoryItem[];
- getTodayHistory: () => ToastHistoryItem[];
-}
-
-// 生成唯一ID
-const generateId = () => Math.random().toString(36).substring(2, 15);
-
-// 添加到历史记录
-const addToHistory = (
- get: () => ToastState,
- set: (partial: Partial) => void,
- type: ToastType,
- content: string,
- options?: ToastOptions,
-) => {
- const state = get();
- if (!state.enableHistory) return;
-
- const historyItem: ToastHistoryItem = {
- id: generateId(),
- type,
- content,
- timestamp: Date.now(),
- options,
- };
-
- const newHistory = [historyItem, ...state.history].slice(0, state.maxHistoryCount);
-
- set({ history: newHistory });
-};
-
-/**
- * Toast状态管理Store
- */
-export const useToastStore = createStore(
- (set, get) => ({
- history: [],
- activeCount: 0,
- enableHistory: true,
- maxHistoryCount: 100,
-
- success: (content: string, options?: ToastOptions) => {
- const id = options?.key || generateId();
- const finalOptions = { ...options, key: id };
-
- addToHistory(get, set, "success", content, finalOptions);
- set({ activeCount: get().activeCount + 1 });
-
- Toast.success(content, finalOptions);
- return id;
- },
-
- info: (content: string, options?: ToastOptions) => {
- const id = options?.key || generateId();
- const finalOptions = { ...options, key: id };
-
- addToHistory(get, set, "info", content, finalOptions);
- set({ activeCount: get().activeCount + 1 });
-
- Toast.info(content, finalOptions);
- return id;
- },
-
- warning: (content: string, options?: ToastOptions) => {
- const id = options?.key || generateId();
- const finalOptions = { ...options, key: id };
-
- addToHistory(get, set, "warning", content, finalOptions);
- set({ activeCount: get().activeCount + 1 });
-
- Toast.warning(content, finalOptions);
- return id;
- },
-
- error: (content: string, options?: ToastOptions) => {
- const id = options?.key || generateId();
- const finalOptions = { ...options, key: id };
-
- addToHistory(get, set, "error", content, finalOptions);
- set({ activeCount: get().activeCount + 1 });
-
- Toast.error(content, finalOptions);
- return id;
- },
-
- loading: (content: string, options?: ToastOptions) => {
- const id = options?.key || generateId();
- const finalOptions = { ...options, key: id };
-
- addToHistory(get, set, "loading", content, finalOptions);
- set({ activeCount: get().activeCount + 1 });
-
- Toast.loading(content, finalOptions);
- return id;
- },
-
- successAction: (action: string, options?: ToastOptions) => {
- return get().success(`${action}成功!`, options);
- },
-
- errorAction: (action: string, reason?: string, options?: ToastOptions) => {
- const content = reason ? `${action}失败:${reason}` : `${action}失败!`;
- return get().error(content, options);
- },
-
- loadingAction: (action: string, options?: ToastOptions) => {
- return get().loading(`${action}中...`, options);
- },
-
- copySuccess: (options?: ToastOptions) => {
- return get().success("复制成功!", { duration: 2, ...options });
- },
-
- networkError: (options?: ToastOptions) => {
- return get().error("网络连接失败,请检查网络设置", {
- duration: 5,
- ...options,
- });
- },
-
- permissionDenied: (options?: ToastOptions) => {
- return get().warning("权限不足,请联系管理员", {
- duration: 4,
- ...options,
- });
- },
-
- update: (
- key: string,
- content: string,
- type: ToastType = "info",
- options?: Omit,
- ) => {
- Toast.update(key, content, type, options);
- },
-
- destroy: (key?: string) => {
- if (key) {
- set({ activeCount: Math.max(0, get().activeCount - 1) });
- } else {
- set({ activeCount: 0 });
- }
- Toast.destroy(key);
- },
-
- clearHistory: () => {
- set({ history: [] });
- },
-
- setEnableHistory: (enable: boolean) => {
- set({ enableHistory: enable });
- },
-
- setMaxHistoryCount: (count: number) => {
- set({ maxHistoryCount: Math.max(1, count) });
- const { history } = get();
- if (history.length > count) {
- set({ history: history.slice(0, count) });
- }
- },
-
- getHistoryByType: (type: ToastType) => {
- return get().history.filter((item) => item.type === type);
- },
-
- getTodayHistory: () => {
- const today = new Date();
- today.setHours(0, 0, 0, 0);
- const todayTimestamp = today.getTime();
-
- return get().history.filter((item) => item.timestamp >= todayTimestamp);
- },
- }),
- "toastStore",
-);
diff --git a/packages/templates/react-spa/src/lib/toast.ts b/packages/templates/react-spa/src/lib/toast.ts
deleted file mode 100644
index 7809dd6e..00000000
--- a/packages/templates/react-spa/src/lib/toast.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-import { toast as sonnerToast } from "sonner";
-
-// Toast提示类型
-export type ToastType = "success" | "info" | "warning" | "error" | "loading";
-
-// Toast配置选项
-export interface ToastOptions {
- /** 显示时长(秒),0表示不自动关闭,默认3秒 */
- duration?: number;
- /** 自定义key,用于更新或关闭特定消息 */
- key?: string;
- /** 自定义样式类名 */
- className?: string;
- /** 附加说明 */
- description?: string;
- /** 点击动作 */
- onClick?: () => void;
-}
-
-const DEFAULT_DURATION = 3;
-const generateToastId = () => Math.random().toString(36).substring(2, 15);
-
-const toDuration = (duration?: number) => {
- if (duration === 0) return Number.POSITIVE_INFINITY;
- return (duration ?? DEFAULT_DURATION) * 1000;
-};
-
-const resolveToastId = (options?: ToastOptions) => options?.key ?? generateToastId();
-
-const toToastOptions = (options?: ToastOptions, key?: string) => ({
- id: key ?? options?.key,
- duration: toDuration(options?.duration),
- className: options?.className,
- description: options?.description,
- action: options?.onClick
- ? {
- label: "查看",
- onClick: options.onClick,
- }
- : undefined,
-});
-
-/**
- * Toast提示工具对象
- */
-const Toast = {
- /**
- * 成功提示
- * @param content 提示内容
- * @param options 配置选项
- * @returns 返回用于关闭的函数
- */
- success(content: string, options?: ToastOptions) {
- const key = resolveToastId(options);
- sonnerToast.success(content, toToastOptions(options, key));
- return key;
- },
-
- /**
- * 信息提示
- * @param content 提示内容
- * @param options 配置选项
- * @returns 返回用于关闭的函数
- */
- info(content: string, options?: ToastOptions) {
- const key = resolveToastId(options);
- sonnerToast.info(content, toToastOptions(options, key));
- return key;
- },
-
- /**
- * 警告提示
- * @param content 提示内容
- * @param options 配置选项
- * @returns 返回用于关闭的函数
- */
- warning(content: string, options?: ToastOptions) {
- const key = resolveToastId(options);
- sonnerToast.warning(
- content,
- toToastOptions({ ...options, duration: options?.duration ?? 4 }, key),
- );
- return key;
- },
-
- /**
- * 错误提示
- * @param content 提示内容
- * @param options 配置选项
- * @returns 返回用于关闭的函数
- */
- error(content: string, options?: ToastOptions) {
- const key = resolveToastId(options);
- sonnerToast.error(
- content,
- toToastOptions({ ...options, duration: options?.duration ?? 5 }, key),
- );
- return key;
- },
-
- /**
- * 加载提示
- * @param content 提示内容
- * @param options 配置选项
- * @returns 返回用于关闭的函数
- */
- loading(content: string, options?: ToastOptions) {
- const key = resolveToastId(options);
- sonnerToast.loading(
- content,
- toToastOptions({ ...options, duration: options?.duration ?? 0 }, key),
- );
- return key;
- },
-
- /**
- * 更新指定key的消息
- * @param key 消息key
- * @param content 新内容
- * @param type 消息类型
- * @param options 配置选项
- */
- update(
- key: string,
- content: string,
- type: ToastType = "info",
- options?: Omit,
- ) {
- const config = toToastOptions(options, key);
-
- switch (type) {
- case "success":
- return sonnerToast.success(content, config);
- case "warning":
- return sonnerToast.warning(content, config);
- case "error":
- return sonnerToast.error(content, config);
- case "loading":
- return sonnerToast.loading(content, toToastOptions({ ...options, duration: 0 }, key));
- default:
- return sonnerToast.info(content, config);
- }
- },
-
- /**
- * 销毁指定key的消息
- * @param key 消息key,不传则销毁所有
- */
- destroy(key?: string) {
- if (key) {
- sonnerToast.dismiss(key);
- } else {
- sonnerToast.dismiss();
- }
- },
-
- /**
- * 显示操作成功的标准提示
- * @param action 操作名称,如'保存'、'删除'等
- * @param options 配置选项
- */
- successAction(action: string, options?: ToastOptions) {
- return Toast.success(`${action}成功!`, options);
- },
-
- /**
- * 显示操作失败的标准提示
- * @param action 操作名称,如'保存'、'删除'等
- * @param reason 失败原因,可选
- * @param options 配置选项
- */
- errorAction(action: string, reason?: string, options?: ToastOptions) {
- const content = reason ? `${action}失败:${reason}` : `${action}失败!`;
- return Toast.error(content, options);
- },
-
- /**
- * 显示加载中的标准提示
- * @param action 操作名称,如'保存'、'加载'等
- * @param options 配置选项
- * @returns 返回用于关闭的函数
- */
- loadingAction(action: string, options?: ToastOptions) {
- return Toast.loading(`${action}中...`, options);
- },
-
- /**
- * 显示复制成功提示
- * @param options 配置选项
- */
- copySuccess(options?: ToastOptions) {
- return Toast.success("复制成功!", { duration: 2, ...options });
- },
-
- /**
- * 显示网络错误提示
- * @param options 配置选项
- */
- networkError(options?: ToastOptions) {
- return Toast.error("网络连接失败,请检查网络设置", {
- duration: 5,
- ...options,
- });
- },
-
- /**
- * 显示权限不足提示
- * @param options 配置选项
- */
- permissionDenied(options?: ToastOptions) {
- return Toast.warning("权限不足,请联系管理员", {
- duration: 4,
- ...options,
- });
- },
-};
-
-export default Toast;
-
-// 导出常用方法的简写
-export const toast = Toast;
-export const { success, info, warning, error, loading } = Toast;
diff --git a/packages/templates/react-spa/src/lib/utils.ts b/packages/templates/react-spa/src/lib/utils.ts
index 64063292..3200be28 100644
--- a/packages/templates/react-spa/src/lib/utils.ts
+++ b/packages/templates/react-spa/src/lib/utils.ts
@@ -1,42 +1,5 @@
-import { type ClassValue, clsx } from "clsx";
+import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
-import type { StateCreator } from "zustand";
-import { create } from "zustand";
-import { devtools } from "zustand/middleware";
-
-export const createStore =