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>/, '<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; ---- - - - - - - 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. - */ ---- - -
-
- One CLI - -
- - 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输入有误 -

-
- - - - - -
-
- - - 回到首页 - -
- - - -
- - -
-
-
-
- - - - -
-
检查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 ` -
-
-
- ${style.icon} -
-
-
${title}
- ${message ? `
${message}
` : ""} -
- -
-
- `; - } - - 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 + +
+
+ +
+
+ ); +} 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 = ; +} +``` + +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 / 选择语言

+ +
+ ); +} 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 = + - -
- - 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 - 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 = -
-
+
+ One CLI · React +
- -
+
- -
-
-

CSR WebApp Template powered by shadcn/ui, Tailwind CSS v4 and Vite.

-

© {new Date().getFullYear()} Template

-
-
); -}; +} 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

+ +
+ ) : ( + 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} - -
- - -
-
-
-
- ); -}; - -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 =