2026-01-28 19:14:06 +00:00
|
|
|
package php
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"io"
|
|
|
|
|
"os"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
2026-01-31 11:39:19 +00:00
|
|
|
|
|
|
|
|
"github.com/host-uk/core/pkg/cli"
|
2026-01-28 19:14:06 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Options configures the development server.
|
|
|
|
|
type Options struct {
|
|
|
|
|
// Dir is the Laravel project directory.
|
|
|
|
|
Dir string
|
|
|
|
|
|
|
|
|
|
// Services specifies which services to start.
|
|
|
|
|
// If empty, services are auto-detected.
|
|
|
|
|
Services []DetectedService
|
|
|
|
|
|
|
|
|
|
// NoVite disables the Vite dev server.
|
|
|
|
|
NoVite bool
|
|
|
|
|
|
|
|
|
|
// NoHorizon disables Laravel Horizon.
|
|
|
|
|
NoHorizon bool
|
|
|
|
|
|
|
|
|
|
// NoReverb disables Laravel Reverb.
|
|
|
|
|
NoReverb bool
|
|
|
|
|
|
|
|
|
|
// NoRedis disables the Redis server.
|
|
|
|
|
NoRedis bool
|
|
|
|
|
|
|
|
|
|
// HTTPS enables HTTPS with mkcert certificates.
|
|
|
|
|
HTTPS bool
|
|
|
|
|
|
|
|
|
|
// Domain is the domain for SSL certificates.
|
|
|
|
|
// Defaults to APP_URL from .env or "localhost".
|
|
|
|
|
Domain string
|
|
|
|
|
|
|
|
|
|
// Ports for each service
|
|
|
|
|
FrankenPHPPort int
|
|
|
|
|
HTTPSPort int
|
|
|
|
|
VitePort int
|
|
|
|
|
ReverbPort int
|
|
|
|
|
RedisPort int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DevServer manages all development services.
|
|
|
|
|
type DevServer struct {
|
|
|
|
|
opts Options
|
|
|
|
|
services []Service
|
|
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
running bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewDevServer creates a new development server manager.
|
|
|
|
|
func NewDevServer(opts Options) *DevServer {
|
|
|
|
|
return &DevServer{
|
|
|
|
|
opts: opts,
|
|
|
|
|
services: make([]Service, 0),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start starts all detected/configured services.
|
|
|
|
|
func (d *DevServer) Start(ctx context.Context, opts Options) error {
|
|
|
|
|
d.mu.Lock()
|
|
|
|
|
defer d.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if d.running {
|
2026-01-31 11:39:19 +00:00
|
|
|
return cli.Err("dev server is already running")
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Merge options
|
|
|
|
|
if opts.Dir != "" {
|
|
|
|
|
d.opts.Dir = opts.Dir
|
|
|
|
|
}
|
|
|
|
|
if d.opts.Dir == "" {
|
|
|
|
|
cwd, err := os.Getwd()
|
|
|
|
|
if err != nil {
|
2026-01-31 11:39:19 +00:00
|
|
|
return cli.WrapVerb(err, "get", "working directory")
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
d.opts.Dir = cwd
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify this is a Laravel project
|
|
|
|
|
if !IsLaravelProject(d.opts.Dir) {
|
2026-01-31 11:39:19 +00:00
|
|
|
return cli.Err("not a Laravel project: %s", d.opts.Dir)
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create cancellable context
|
|
|
|
|
d.ctx, d.cancel = context.WithCancel(ctx)
|
|
|
|
|
|
|
|
|
|
// Detect or use provided services
|
|
|
|
|
services := opts.Services
|
|
|
|
|
if len(services) == 0 {
|
|
|
|
|
services = DetectServices(d.opts.Dir)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filter out disabled services
|
|
|
|
|
services = d.filterServices(services, opts)
|
|
|
|
|
|
|
|
|
|
// Setup SSL if HTTPS is enabled
|
|
|
|
|
var certFile, keyFile string
|
|
|
|
|
if opts.HTTPS {
|
|
|
|
|
domain := opts.Domain
|
|
|
|
|
if domain == "" {
|
|
|
|
|
// Try to get domain from APP_URL
|
|
|
|
|
appURL := GetLaravelAppURL(d.opts.Dir)
|
|
|
|
|
if appURL != "" {
|
|
|
|
|
domain = ExtractDomainFromURL(appURL)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if domain == "" {
|
|
|
|
|
domain = "localhost"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var err error
|
|
|
|
|
certFile, keyFile, err = SetupSSLIfNeeded(domain, SSLOptions{})
|
|
|
|
|
if err != nil {
|
2026-01-31 11:39:19 +00:00
|
|
|
return cli.WrapVerb(err, "setup", "SSL")
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create services
|
|
|
|
|
d.services = make([]Service, 0)
|
|
|
|
|
|
|
|
|
|
for _, svc := range services {
|
|
|
|
|
var service Service
|
|
|
|
|
|
|
|
|
|
switch svc {
|
|
|
|
|
case ServiceFrankenPHP:
|
|
|
|
|
port := opts.FrankenPHPPort
|
|
|
|
|
if port == 0 {
|
|
|
|
|
port = 8000
|
|
|
|
|
}
|
|
|
|
|
httpsPort := opts.HTTPSPort
|
|
|
|
|
if httpsPort == 0 {
|
|
|
|
|
httpsPort = 443
|
|
|
|
|
}
|
|
|
|
|
service = NewFrankenPHPService(d.opts.Dir, FrankenPHPOptions{
|
|
|
|
|
Port: port,
|
|
|
|
|
HTTPSPort: httpsPort,
|
|
|
|
|
HTTPS: opts.HTTPS,
|
|
|
|
|
CertFile: certFile,
|
|
|
|
|
KeyFile: keyFile,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case ServiceVite:
|
|
|
|
|
port := opts.VitePort
|
|
|
|
|
if port == 0 {
|
|
|
|
|
port = 5173
|
|
|
|
|
}
|
|
|
|
|
service = NewViteService(d.opts.Dir, ViteOptions{
|
|
|
|
|
Port: port,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case ServiceHorizon:
|
|
|
|
|
service = NewHorizonService(d.opts.Dir)
|
|
|
|
|
|
|
|
|
|
case ServiceReverb:
|
|
|
|
|
port := opts.ReverbPort
|
|
|
|
|
if port == 0 {
|
|
|
|
|
port = 8080
|
|
|
|
|
}
|
|
|
|
|
service = NewReverbService(d.opts.Dir, ReverbOptions{
|
|
|
|
|
Port: port,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
case ServiceRedis:
|
|
|
|
|
port := opts.RedisPort
|
|
|
|
|
if port == 0 {
|
|
|
|
|
port = 6379
|
|
|
|
|
}
|
|
|
|
|
service = NewRedisService(d.opts.Dir, RedisOptions{
|
|
|
|
|
Port: port,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if service != nil {
|
|
|
|
|
d.services = append(d.services, service)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start all services
|
|
|
|
|
var startErrors []error
|
|
|
|
|
for _, svc := range d.services {
|
|
|
|
|
if err := svc.Start(d.ctx); err != nil {
|
2026-01-31 11:39:19 +00:00
|
|
|
startErrors = append(startErrors, cli.Err("%s: %v", svc.Name(), err))
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(startErrors) > 0 {
|
|
|
|
|
// Stop any services that did start
|
|
|
|
|
for _, svc := range d.services {
|
feat: infrastructure packages and lint cleanup (#281)
* ci: consolidate duplicate workflows and merge CodeQL configs
Remove 17 duplicate workflow files that were split copies of the
combined originals. Each family (CI, CodeQL, Coverage, PR Build,
Alpha Release) had the same job duplicated across separate
push/pull_request/schedule/manual trigger files.
Merge codeql.yml and codescan.yml into a single codeql.yml with
a language matrix covering go, javascript-typescript, python,
and actions — matching the previous default setup coverage.
Remaining workflows (one per family):
- ci.yml (push + PR + manual)
- codeql.yml (push + PR + schedule, all languages)
- coverage.yml (push + PR + manual)
- alpha-release.yml (push + manual)
- pr-build.yml (PR + manual)
- release.yml (tag push)
- agent-verify.yml, auto-label.yml, auto-project.yml
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add collect, config, crypt, plugin packages and fix all lint issues
Add four new infrastructure packages with CLI commands:
- pkg/config: layered configuration (defaults → file → env → flags)
- pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums)
- pkg/plugin: plugin system with GitHub-based install/update/remove
- pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate)
Fix all golangci-lint issues across the entire codebase (~100 errcheck,
staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that
`core go qa` passes with 0 issues.
Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:34:43 +00:00
|
|
|
_ = svc.Stop()
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
2026-01-31 11:39:19 +00:00
|
|
|
return cli.Err("failed to start services: %v", startErrors)
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
d.running = true
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// filterServices removes disabled services from the list.
|
|
|
|
|
func (d *DevServer) filterServices(services []DetectedService, opts Options) []DetectedService {
|
|
|
|
|
filtered := make([]DetectedService, 0)
|
|
|
|
|
|
|
|
|
|
for _, svc := range services {
|
|
|
|
|
switch svc {
|
|
|
|
|
case ServiceVite:
|
|
|
|
|
if !opts.NoVite {
|
|
|
|
|
filtered = append(filtered, svc)
|
|
|
|
|
}
|
|
|
|
|
case ServiceHorizon:
|
|
|
|
|
if !opts.NoHorizon {
|
|
|
|
|
filtered = append(filtered, svc)
|
|
|
|
|
}
|
|
|
|
|
case ServiceReverb:
|
|
|
|
|
if !opts.NoReverb {
|
|
|
|
|
filtered = append(filtered, svc)
|
|
|
|
|
}
|
|
|
|
|
case ServiceRedis:
|
|
|
|
|
if !opts.NoRedis {
|
|
|
|
|
filtered = append(filtered, svc)
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
filtered = append(filtered, svc)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return filtered
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop stops all services gracefully.
|
|
|
|
|
func (d *DevServer) Stop() error {
|
|
|
|
|
d.mu.Lock()
|
|
|
|
|
defer d.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if !d.running {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cancel context first
|
|
|
|
|
if d.cancel != nil {
|
|
|
|
|
d.cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop all services in reverse order
|
|
|
|
|
var stopErrors []error
|
|
|
|
|
for i := len(d.services) - 1; i >= 0; i-- {
|
|
|
|
|
svc := d.services[i]
|
|
|
|
|
if err := svc.Stop(); err != nil {
|
2026-01-31 11:39:19 +00:00
|
|
|
stopErrors = append(stopErrors, cli.Err("%s: %v", svc.Name(), err))
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
d.running = false
|
|
|
|
|
|
|
|
|
|
if len(stopErrors) > 0 {
|
2026-01-31 11:39:19 +00:00
|
|
|
return cli.Err("errors stopping services: %v", stopErrors)
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Logs returns a reader for the specified service's logs.
|
|
|
|
|
// If service is empty, returns unified logs from all services.
|
|
|
|
|
func (d *DevServer) Logs(service string, follow bool) (io.ReadCloser, error) {
|
|
|
|
|
d.mu.RLock()
|
|
|
|
|
defer d.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
if service == "" {
|
|
|
|
|
// Return unified logs
|
|
|
|
|
return d.unifiedLogs(follow)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find specific service
|
|
|
|
|
for _, svc := range d.services {
|
|
|
|
|
if svc.Name() == service {
|
|
|
|
|
return svc.Logs(follow)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 11:39:19 +00:00
|
|
|
return nil, cli.Err("service not found: %s", service)
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// unifiedLogs creates a reader that combines logs from all services.
|
|
|
|
|
func (d *DevServer) unifiedLogs(follow bool) (io.ReadCloser, error) {
|
|
|
|
|
readers := make([]io.ReadCloser, 0)
|
|
|
|
|
|
|
|
|
|
for _, svc := range d.services {
|
|
|
|
|
reader, err := svc.Logs(follow)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// Close any readers we already opened
|
|
|
|
|
for _, r := range readers {
|
feat: infrastructure packages and lint cleanup (#281)
* ci: consolidate duplicate workflows and merge CodeQL configs
Remove 17 duplicate workflow files that were split copies of the
combined originals. Each family (CI, CodeQL, Coverage, PR Build,
Alpha Release) had the same job duplicated across separate
push/pull_request/schedule/manual trigger files.
Merge codeql.yml and codescan.yml into a single codeql.yml with
a language matrix covering go, javascript-typescript, python,
and actions — matching the previous default setup coverage.
Remaining workflows (one per family):
- ci.yml (push + PR + manual)
- codeql.yml (push + PR + schedule, all languages)
- coverage.yml (push + PR + manual)
- alpha-release.yml (push + manual)
- pr-build.yml (PR + manual)
- release.yml (tag push)
- agent-verify.yml, auto-label.yml, auto-project.yml
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add collect, config, crypt, plugin packages and fix all lint issues
Add four new infrastructure packages with CLI commands:
- pkg/config: layered configuration (defaults → file → env → flags)
- pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums)
- pkg/plugin: plugin system with GitHub-based install/update/remove
- pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate)
Fix all golangci-lint issues across the entire codebase (~100 errcheck,
staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that
`core go qa` passes with 0 issues.
Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:34:43 +00:00
|
|
|
_ = r.Close()
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
2026-01-31 11:39:19 +00:00
|
|
|
return nil, cli.Err("failed to get logs for %s: %v", svc.Name(), err)
|
2026-01-28 19:14:06 +00:00
|
|
|
}
|
|
|
|
|
readers = append(readers, reader)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return newMultiServiceReader(d.services, readers, follow), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Status returns the status of all services.
|
|
|
|
|
func (d *DevServer) Status() []ServiceStatus {
|
|
|
|
|
d.mu.RLock()
|
|
|
|
|
defer d.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
statuses := make([]ServiceStatus, 0, len(d.services))
|
|
|
|
|
for _, svc := range d.services {
|
|
|
|
|
statuses = append(statuses, svc.Status())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return statuses
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IsRunning returns true if the dev server is running.
|
|
|
|
|
func (d *DevServer) IsRunning() bool {
|
|
|
|
|
d.mu.RLock()
|
|
|
|
|
defer d.mu.RUnlock()
|
|
|
|
|
return d.running
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Services returns the list of managed services.
|
|
|
|
|
func (d *DevServer) Services() []Service {
|
|
|
|
|
d.mu.RLock()
|
|
|
|
|
defer d.mu.RUnlock()
|
|
|
|
|
return d.services
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// multiServiceReader combines multiple service log readers.
|
|
|
|
|
type multiServiceReader struct {
|
|
|
|
|
services []Service
|
|
|
|
|
readers []io.ReadCloser
|
|
|
|
|
follow bool
|
|
|
|
|
closed bool
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newMultiServiceReader(services []Service, readers []io.ReadCloser, follow bool) *multiServiceReader {
|
|
|
|
|
return &multiServiceReader{
|
|
|
|
|
services: services,
|
|
|
|
|
readers: readers,
|
|
|
|
|
follow: follow,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *multiServiceReader) Read(p []byte) (n int, err error) {
|
|
|
|
|
m.mu.RLock()
|
|
|
|
|
if m.closed {
|
|
|
|
|
m.mu.RUnlock()
|
|
|
|
|
return 0, io.EOF
|
|
|
|
|
}
|
|
|
|
|
m.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
// Round-robin read from all readers
|
|
|
|
|
for i, reader := range m.readers {
|
|
|
|
|
buf := make([]byte, len(p))
|
|
|
|
|
n, err := reader.Read(buf)
|
|
|
|
|
if n > 0 {
|
|
|
|
|
// Prefix with service name
|
2026-01-31 11:39:19 +00:00
|
|
|
prefix := cli.Sprintf("[%s] ", m.services[i].Name())
|
2026-01-28 19:14:06 +00:00
|
|
|
copy(p, prefix)
|
|
|
|
|
copy(p[len(prefix):], buf[:n])
|
|
|
|
|
return n + len(prefix), nil
|
|
|
|
|
}
|
|
|
|
|
if err != nil && err != io.EOF {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if m.follow {
|
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
|
return 0, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 0, io.EOF
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *multiServiceReader) Close() error {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
m.closed = true
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
var closeErr error
|
|
|
|
|
for _, reader := range m.readers {
|
|
|
|
|
if err := reader.Close(); err != nil && closeErr == nil {
|
|
|
|
|
closeErr = err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return closeErr
|
|
|
|
|
}
|