refactor: turn the repo into a two-plugin marketplace
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.
The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.
tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.
test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
# Unified Server Scaffold Template
|
||||
|
||||
Generate the core unified server infrastructure in `internal/common/server/`. This replaces the standalone `RunHTTPServer` / `RunGRPCServer` functions with a composable `server.New(...).Run(ctx)` pattern that supports multiple transports with explicit shutdown ordering.
|
||||
|
||||
Created once per project. Individual transports (`WithWatermillRouter`) can be added later.
|
||||
|
||||
## Placeholders
|
||||
|
||||
- `{{module_common}}` — Go module path to `internal/common` (e.g., `github.com/example/myproject/internal/common`)
|
||||
|
||||
## File 1: `internal/common/server/server.go`
|
||||
|
||||
```go
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
components map[string]component
|
||||
startOrder []string
|
||||
shutdownSteps []ShutdownStep
|
||||
}
|
||||
|
||||
type component struct {
|
||||
name string
|
||||
start func(ctx context.Context) error
|
||||
stop func(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Option func(*Server)
|
||||
|
||||
func New(opts ...Option) *Server {
|
||||
s := &Server{
|
||||
components: make(map[string]component),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) addComponent(name string, c component) {
|
||||
if _, exists := s.components[name]; exists {
|
||||
panic("duplicate component name: " + name)
|
||||
}
|
||||
s.components[name] = c
|
||||
s.startOrder = append(s.startOrder, name)
|
||||
}
|
||||
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errCh := make(chan error, len(s.components))
|
||||
for _, name := range s.startOrder {
|
||||
c := s.components[name]
|
||||
go func(c component) {
|
||||
logrus.WithField("component", c.name).Info("Starting")
|
||||
if err := c.start(ctx); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logrus.Info("Shutdown signal received")
|
||||
case err := <-errCh:
|
||||
logrus.WithError(err).Error("Component failed, initiating shutdown")
|
||||
}
|
||||
|
||||
s.executeShutdown()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) executeShutdown() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stopped := map[string]bool{}
|
||||
|
||||
for _, step := range s.shutdownSteps {
|
||||
if step.fn != nil {
|
||||
logrus.Info("Running shutdown func")
|
||||
if err := step.fn(shutdownCtx); err != nil {
|
||||
logrus.WithError(err).Error("Shutdown func failed")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, name := range step.componentNames {
|
||||
c, ok := s.components[name]
|
||||
if !ok {
|
||||
logrus.WithField("component", name).Warn("Unknown component in OnShutdown")
|
||||
continue
|
||||
}
|
||||
stopped[name] = true
|
||||
wg.Add(1)
|
||||
go func(c component) {
|
||||
defer wg.Done()
|
||||
logrus.WithField("component", c.name).Info("Stopping")
|
||||
if err := c.stop(shutdownCtx); err != nil {
|
||||
logrus.WithError(err).WithField("component", c.name).Error("Stop failed")
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Safety net: stop any components not mentioned in OnShutdown
|
||||
var wg sync.WaitGroup
|
||||
for name, c := range s.components {
|
||||
if stopped[name] {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(c component) {
|
||||
defer wg.Done()
|
||||
logrus.WithField("component", c.name).Warn("Stopping (not in OnShutdown — add it)")
|
||||
if err := c.stop(shutdownCtx); err != nil {
|
||||
logrus.WithError(err).WithField("component", c.name).Error("Stop failed")
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
```
|
||||
|
||||
## File 2: `internal/common/server/shutdown.go`
|
||||
|
||||
```go
|
||||
package server
|
||||
|
||||
import "context"
|
||||
|
||||
// ShutdownStep is one step in the shutdown sequence.
|
||||
type ShutdownStep struct {
|
||||
componentNames []string
|
||||
fn func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Stop creates a shutdown step that stops named components.
|
||||
// Multiple names in one call = parallel shutdown within the step.
|
||||
func Stop(names ...string) ShutdownStep {
|
||||
return ShutdownStep{componentNames: names}
|
||||
}
|
||||
|
||||
// StopFunc creates a shutdown step that runs an arbitrary cleanup function.
|
||||
func StopFunc(fn func()) ShutdownStep {
|
||||
return ShutdownStep{
|
||||
fn: func(ctx context.Context) error {
|
||||
fn()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// StopFuncWithErr creates a shutdown step with error return.
|
||||
func StopFuncWithErr(fn func(ctx context.Context) error) ShutdownStep {
|
||||
return ShutdownStep{fn: fn}
|
||||
}
|
||||
|
||||
// OnShutdown declares the shutdown sequence.
|
||||
// Steps execute top-to-bottom. Each step completes before the next starts.
|
||||
// Components not mentioned are stopped last with a warning.
|
||||
func OnShutdown(steps ...ShutdownStep) Option {
|
||||
return func(s *Server) {
|
||||
s.shutdownSteps = steps
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## File 3: `internal/common/server/http.go` (replace existing)
|
||||
|
||||
```go
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"{{module_common}}/auth"
|
||||
"{{module_common}}/logs"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func WithHTTPHandler(name string, createHandler func(chi.Router) http.Handler) Option {
|
||||
return func(s *Server) {
|
||||
addr := ":" + os.Getenv("PORT")
|
||||
srv := &http.Server{Addr: addr}
|
||||
|
||||
s.addComponent(name, component{
|
||||
name: name,
|
||||
start: func(ctx context.Context) error {
|
||||
apiRouter := chi.NewRouter()
|
||||
setMiddlewares(apiRouter)
|
||||
rootRouter := chi.NewRouter()
|
||||
rootRouter.Mount("/api", createHandler(apiRouter))
|
||||
srv.Handler = rootRouter
|
||||
|
||||
logrus.WithField("addr", addr).Info("Starting HTTP server")
|
||||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
stop: func(ctx context.Context) error {
|
||||
return srv.Shutdown(ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// setMiddlewares, addAuthMiddleware, addCorsMiddleware — same as existing
|
||||
```
|
||||
|
||||
## File 4: `internal/common/server/grpc.go` (replace existing)
|
||||
|
||||
```go
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"{{module_common}}/logs"
|
||||
grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
|
||||
grpc_logrus "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus"
|
||||
grpc_ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags"
|
||||
"github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
func WithGRPCServer(name string, registerServer func(*grpc.Server)) Option {
|
||||
return func(s *Server) {
|
||||
logrusEntry := logrus.NewEntry(logrus.StandardLogger())
|
||||
|
||||
grpcSrv := grpc.NewServer(
|
||||
grpc_middleware.WithUnaryServerChain(
|
||||
grpc_ctxtags.UnaryServerInterceptor(grpc_ctxtags.WithFieldExtractor(grpc_ctxtags.CodeGenRequestFieldExtractor)),
|
||||
grpc_logrus.UnaryServerInterceptor(logrusEntry),
|
||||
),
|
||||
grpc_middleware.WithStreamServerChain(
|
||||
grpc_ctxtags.StreamServerInterceptor(grpc_ctxtags.WithFieldExtractor(grpc_ctxtags.CodeGenRequestFieldExtractor)),
|
||||
grpc_logrus.StreamServerInterceptor(logrusEntry),
|
||||
),
|
||||
)
|
||||
registerServer(grpcSrv)
|
||||
|
||||
port := os.Getenv("GRPC_PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
addr := ":" + port
|
||||
|
||||
s.addComponent(name, component{
|
||||
name: name,
|
||||
start: func(ctx context.Context) error {
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logrus.WithField("addr", addr).Info("Starting gRPC server")
|
||||
return grpcSrv.Serve(lis)
|
||||
},
|
||||
stop: func(ctx context.Context) error {
|
||||
grpcSrv.GracefulStop()
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Post-Creation Instructions
|
||||
|
||||
After creating the unified server:
|
||||
|
||||
1. Remove or replace the old `RunHTTPServer` / `RunGRPCServer` standalone functions
|
||||
2. Update all `main.go` files to use `server.New(...).Run(ctx)` with `OnShutdown`
|
||||
3. Add `/threedotslabs scaffold watermill_router` to add Watermill support
|
||||
4. Every component MUST appear in `OnShutdown` — the safety net logs warnings for forgotten ones
|
||||
Reference in New Issue
Block a user