From c2715af316de7b838b2d9bf342302ce458ede862 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 6 Feb 2026 22:50:18 +0000 Subject: [PATCH 1/9] feat(core-app): FrankenPHP + Wails v3 native desktop app Single 53MB binary embedding PHP 8.4 ZTS runtime, Laravel 12, Livewire 4, and Octane worker mode inside a Wails v3 native desktop window. Co-Authored-By: Claude Opus 4.6 --- Taskfile.yml | 61 + cmd/core-app/Taskfile.yml | 37 + cmd/core-app/app_service.go | 48 + cmd/core-app/embed.go | 52 + cmd/core-app/env.go | 166 + cmd/core-app/go.mod | 67 + cmd/core-app/go.sum | 185 + cmd/core-app/handler.go | 137 + cmd/core-app/icons/appicon.png | Bin 0 -> 76 bytes cmd/core-app/icons/icons.go | 24 + cmd/core-app/icons/tray-dark.png | Bin 0 -> 76 bytes cmd/core-app/icons/tray-light.png | Bin 0 -> 76 bytes cmd/core-app/icons/tray-template.png | Bin 0 -> 76 bytes cmd/core-app/laravel/.env.example | 13 + cmd/core-app/laravel/.gitignore | 5 + cmd/core-app/laravel/app/Livewire/Counter.php | 27 + cmd/core-app/laravel/artisan | 21 + cmd/core-app/laravel/bootstrap/app.php | 19 + cmd/core-app/laravel/composer.json | 29 + cmd/core-app/laravel/composer.lock | 6149 +++++++++++++++++ cmd/core-app/laravel/config/app.php | 19 + cmd/core-app/laravel/config/cache.php | 21 + cmd/core-app/laravel/config/database.php | 25 + cmd/core-app/laravel/config/logging.php | 25 + cmd/core-app/laravel/config/session.php | 22 + cmd/core-app/laravel/config/view.php | 10 + cmd/core-app/laravel/public/index.php | 19 + .../views/components/layout.blade.php | 107 + .../views/livewire/counter.blade.php | 8 + .../laravel/resources/views/welcome.blade.php | 40 + cmd/core-app/laravel/routes/web.php | 9 + cmd/core-app/main.go | 102 + cmd/core-app/native_bridge.go | 96 + go.work | 11 + 34 files changed, 7554 insertions(+) create mode 100644 cmd/core-app/Taskfile.yml create mode 100644 cmd/core-app/app_service.go create mode 100644 cmd/core-app/embed.go create mode 100644 cmd/core-app/env.go create mode 100644 cmd/core-app/go.mod create mode 100644 cmd/core-app/go.sum create mode 100644 cmd/core-app/handler.go create mode 100644 cmd/core-app/icons/appicon.png create mode 100644 cmd/core-app/icons/icons.go create mode 100644 cmd/core-app/icons/tray-dark.png create mode 100644 cmd/core-app/icons/tray-light.png create mode 100644 cmd/core-app/icons/tray-template.png create mode 100644 cmd/core-app/laravel/.env.example create mode 100644 cmd/core-app/laravel/.gitignore create mode 100644 cmd/core-app/laravel/app/Livewire/Counter.php create mode 100644 cmd/core-app/laravel/artisan create mode 100644 cmd/core-app/laravel/bootstrap/app.php create mode 100644 cmd/core-app/laravel/composer.json create mode 100644 cmd/core-app/laravel/composer.lock create mode 100644 cmd/core-app/laravel/config/app.php create mode 100644 cmd/core-app/laravel/config/cache.php create mode 100644 cmd/core-app/laravel/config/database.php create mode 100644 cmd/core-app/laravel/config/logging.php create mode 100644 cmd/core-app/laravel/config/session.php create mode 100644 cmd/core-app/laravel/config/view.php create mode 100644 cmd/core-app/laravel/public/index.php create mode 100644 cmd/core-app/laravel/resources/views/components/layout.blade.php create mode 100644 cmd/core-app/laravel/resources/views/livewire/counter.blade.php create mode 100644 cmd/core-app/laravel/resources/views/welcome.blade.php create mode 100644 cmd/core-app/laravel/routes/web.php create mode 100644 cmd/core-app/main.go create mode 100644 cmd/core-app/native_bridge.go create mode 100644 go.work diff --git a/Taskfile.yml b/Taskfile.yml index 1e267461..02384851 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -140,6 +140,67 @@ tasks: cmds: - go run ./internal/tools/i18n-validate ./... + # --- Core IDE (Wails v3) --- + ide:dev: + desc: "Run Core IDE in Wails dev mode" + dir: cmd/core-ide + cmds: + - cd frontend && npm install && npm run build + - wails3 dev + + ide:build: + desc: "Build Core IDE production binary" + dir: cmd/core-ide + cmds: + - cd frontend && npm install && npm run build + - wails3 build + + ide:frontend: + desc: "Build Core IDE frontend only" + dir: cmd/core-ide/frontend + cmds: + - npm install + - npm run build + + # --- Core App (FrankenPHP + Wails v3) --- + app:setup: + desc: "Install PHP-ZTS build dependency for Core App" + cmds: + - brew tap shivammathur/php 2>/dev/null || true + - brew install shivammathur/php/php@8.4-zts + + app:composer: + desc: "Install Laravel dependencies for Core App" + dir: cmd/core-app/laravel + cmds: + - composer install --no-dev --optimize-autoloader --no-interaction + + app:build: + desc: "Build Core App (FrankenPHP + Laravel desktop binary)" + dir: cmd/core-app + env: + CGO_ENABLED: "1" + CGO_CFLAGS: + sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes + CGO_LDFLAGS: + sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)" + cmds: + - go build -tags nowatcher -o ../../bin/core-app . + + app:dev: + desc: "Build and run Core App" + dir: cmd/core-app + env: + CGO_ENABLED: "1" + CGO_CFLAGS: + sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes + CGO_LDFLAGS: + sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)" + DYLD_LIBRARY_PATH: "/opt/homebrew/opt/php@8.4-zts/lib" + cmds: + - go build -tags nowatcher -o ../../bin/core-app . + - ../../bin/core-app + # --- Multi-repo (when in workspace) --- dev:health: desc: "Check health of all repos" diff --git a/cmd/core-app/Taskfile.yml b/cmd/core-app/Taskfile.yml new file mode 100644 index 00000000..5f3fc0d4 --- /dev/null +++ b/cmd/core-app/Taskfile.yml @@ -0,0 +1,37 @@ +version: '3' + +vars: + PHP_CONFIG: /opt/homebrew/opt/php@8.4-zts/bin/php-config + CGO_CFLAGS: + sh: "{{.PHP_CONFIG}} --includes" + CGO_LDFLAGS: + sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $({{.PHP_CONFIG}} --ldflags) $({{.PHP_CONFIG}} --libs)" + +tasks: + setup: + desc: "Install PHP-ZTS build dependency" + cmds: + - brew tap shivammathur/php 2>/dev/null || true + - brew install shivammathur/php/php@8.4-zts + + build: + desc: "Build core-app binary" + env: + CGO_ENABLED: "1" + CGO_CFLAGS: "{{.CGO_CFLAGS}}" + CGO_LDFLAGS: "{{.CGO_LDFLAGS}}" + cmds: + - go build -tags nowatcher -o ../../bin/core-app . + + dev: + desc: "Build and run core-app" + deps: [build] + env: + DYLD_LIBRARY_PATH: "/opt/homebrew/opt/php@8.4-zts/lib" + cmds: + - ../../bin/core-app + + clean: + desc: "Remove build artifacts" + cmds: + - rm -f ../../bin/core-app diff --git a/cmd/core-app/app_service.go b/cmd/core-app/app_service.go new file mode 100644 index 00000000..30226c8f --- /dev/null +++ b/cmd/core-app/app_service.go @@ -0,0 +1,48 @@ +package main + +import ( + "github.com/wailsapp/wails/v3/pkg/application" +) + +// AppService provides native desktop capabilities to the Wails frontend. +// These methods are callable via window.go.main.AppService.{Method}() +// from any JavaScript/webview context. +type AppService struct { + app *application.App + env *AppEnvironment +} + +func NewAppService(env *AppEnvironment) *AppService { + return &AppService{env: env} +} + +// ServiceStartup is called by Wails when the application starts. +func (s *AppService) ServiceStartup(app *application.App) { + s.app = app +} + +// GetVersion returns the application version. +func (s *AppService) GetVersion() string { + return "0.1.0" +} + +// GetDataDir returns the persistent data directory path. +func (s *AppService) GetDataDir() string { + return s.env.DataDir +} + +// GetDatabasePath returns the SQLite database file path. +func (s *AppService) GetDatabasePath() string { + return s.env.DatabasePath +} + +// ShowWindow shows and focuses the main application window. +func (s *AppService) ShowWindow(name string) { + if s.app == nil { + return + } + if w, ok := s.app.Window.Get(name); ok { + w.Show() + w.Focus() + } +} diff --git a/cmd/core-app/embed.go b/cmd/core-app/embed.go new file mode 100644 index 00000000..97d57f4c --- /dev/null +++ b/cmd/core-app/embed.go @@ -0,0 +1,52 @@ +package main + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +//go:embed all:laravel +var laravelFiles embed.FS + +// extractLaravel copies the embedded Laravel app to a temporary directory. +// FrankenPHP needs real filesystem paths — it cannot serve from embed.FS. +// Returns the path to the extracted Laravel root. +func extractLaravel() (string, error) { + tmpDir, err := os.MkdirTemp("", "core-app-laravel-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + err = fs.WalkDir(laravelFiles, "laravel", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel("laravel", path) + if err != nil { + return err + } + targetPath := filepath.Join(tmpDir, relPath) + + if d.IsDir() { + return os.MkdirAll(targetPath, 0o755) + } + + data, err := laravelFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("read embedded %s: %w", path, err) + } + + return os.WriteFile(targetPath, data, 0o644) + }) + + if err != nil { + os.RemoveAll(tmpDir) + return "", fmt.Errorf("extract Laravel: %w", err) + } + + return tmpDir, nil +} diff --git a/cmd/core-app/env.go b/cmd/core-app/env.go new file mode 100644 index 00000000..5fbde0bf --- /dev/null +++ b/cmd/core-app/env.go @@ -0,0 +1,166 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "log" + "os" + "path/filepath" + "runtime" +) + +// AppEnvironment holds the resolved paths for the running application. +type AppEnvironment struct { + // DataDir is the persistent data directory (survives app updates). + DataDir string + // LaravelRoot is the extracted Laravel app in the temp directory. + LaravelRoot string + // DatabasePath is the full path to the SQLite database file. + DatabasePath string +} + +// PrepareEnvironment creates data directories, generates .env, and symlinks +// storage so Laravel can write to persistent locations. +func PrepareEnvironment(laravelRoot string) (*AppEnvironment, error) { + dataDir, err := resolveDataDir() + if err != nil { + return nil, fmt.Errorf("resolve data dir: %w", err) + } + + env := &AppEnvironment{ + DataDir: dataDir, + LaravelRoot: laravelRoot, + DatabasePath: filepath.Join(dataDir, "core-app.sqlite"), + } + + // Create persistent directories + dirs := []string{ + dataDir, + filepath.Join(dataDir, "storage", "app"), + filepath.Join(dataDir, "storage", "framework", "cache", "data"), + filepath.Join(dataDir, "storage", "framework", "sessions"), + filepath.Join(dataDir, "storage", "framework", "views"), + filepath.Join(dataDir, "storage", "logs"), + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create dir %s: %w", dir, err) + } + } + + // Create empty SQLite database if it doesn't exist + if _, err := os.Stat(env.DatabasePath); os.IsNotExist(err) { + if err := os.WriteFile(env.DatabasePath, nil, 0o644); err != nil { + return nil, fmt.Errorf("create database: %w", err) + } + log.Printf("Created new database: %s", env.DatabasePath) + } + + // Replace the extracted storage/ with a symlink to the persistent one + extractedStorage := filepath.Join(laravelRoot, "storage") + os.RemoveAll(extractedStorage) + persistentStorage := filepath.Join(dataDir, "storage") + if err := os.Symlink(persistentStorage, extractedStorage); err != nil { + return nil, fmt.Errorf("symlink storage: %w", err) + } + + // Generate .env file with resolved paths + if err := writeEnvFile(laravelRoot, env); err != nil { + return nil, fmt.Errorf("write .env: %w", err) + } + + return env, nil +} + +// resolveDataDir returns the OS-appropriate persistent data directory. +func resolveDataDir() (string, error) { + var base string + switch runtime.GOOS { + case "darwin": + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, "Library", "Application Support", "core-app") + case "linux": + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + base = filepath.Join(xdg, "core-app") + } else { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".local", "share", "core-app") + } + default: + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".core-app") + } + return base, nil +} + +// writeEnvFile generates the Laravel .env with resolved runtime paths. +func writeEnvFile(laravelRoot string, env *AppEnvironment) error { + appKey, err := loadOrGenerateAppKey(env.DataDir) + if err != nil { + return fmt.Errorf("app key: %w", err) + } + + content := fmt.Sprintf(`APP_NAME="Core App" +APP_ENV=production +APP_KEY=%s +APP_DEBUG=false +APP_URL=http://localhost + +DB_CONNECTION=sqlite +DB_DATABASE="%s" + +CACHE_STORE=file +SESSION_DRIVER=file +LOG_CHANNEL=single +LOG_LEVEL=warning +`, appKey, env.DatabasePath) + + return os.WriteFile(filepath.Join(laravelRoot, ".env"), []byte(content), 0o644) +} + +// loadOrGenerateAppKey loads an existing APP_KEY from the data dir, +// or generates a new one and persists it. +func loadOrGenerateAppKey(dataDir string) (string, error) { + keyFile := filepath.Join(dataDir, ".app-key") + + data, err := os.ReadFile(keyFile) + if err == nil && len(data) > 0 { + return string(data), nil + } + + // Generate a new 32-byte key + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return "", fmt.Errorf("generate key: %w", err) + } + appKey := "base64:" + base64.StdEncoding.EncodeToString(key) + + if err := os.WriteFile(keyFile, []byte(appKey), 0o600); err != nil { + return "", fmt.Errorf("save key: %w", err) + } + + log.Printf("Generated new APP_KEY (saved to %s)", keyFile) + return appKey, nil +} + +// appendEnv appends a key=value pair to the Laravel .env file. +func appendEnv(laravelRoot, key, value string) error { + envFile := filepath.Join(laravelRoot, ".env") + f, err := os.OpenFile(envFile, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = fmt.Fprintf(f, "%s=\"%s\"\n", key, value) + return err +} diff --git a/cmd/core-app/go.mod b/cmd/core-app/go.mod new file mode 100644 index 00000000..31efbdcb --- /dev/null +++ b/cmd/core-app/go.mod @@ -0,0 +1,67 @@ +module github.com/host-uk/core/cmd/core-app + +go 1.25.5 + +require ( + github.com/dunglas/frankenphp v1.5.0 + github.com/wailsapp/wails/v3 v3.0.0-alpha.64 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/adrg/xdg v0.5.3 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dolthub/maphash v0.1.0 // indirect + github.com/ebitengine/purego v0.9.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/gammazero/deque v1.0.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.7.0 // indirect + github.com/go-git/go-git/v5 v5.16.4 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/lmittmann/tint v1.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/maypok86/otter v1.2.4 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.21.1 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.63.0 // indirect + github.com/prometheus/procfs v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.52.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect + github.com/wailsapp/go-webview2 v1.0.23 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) + +replace github.com/host-uk/core => ../.. diff --git a/cmd/core-app/go.sum b/cmd/core-app/go.sum new file mode 100644 index 00000000..5c59f369 --- /dev/null +++ b/cmd/core-app/go.sum @@ -0,0 +1,185 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= +github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= +github.com/dunglas/frankenphp v1.5.0 h1:mrkJNe2gxlqYijGSpYIVbbRYxjYw2bmgAeDFqwREEk4= +github.com/dunglas/frankenphp v1.5.0/go.mod h1:tU9EirkVR0EuIr69IT1XBjSE6YfQY88tZlgkAvLPdOw= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34= +github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= +github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= +github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= +github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc= +github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= +github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= +github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= +github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= +github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= +github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= +github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= +github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs= +github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/core-app/handler.go b/cmd/core-app/handler.go new file mode 100644 index 00000000..0ad3d78a --- /dev/null +++ b/cmd/core-app/handler.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/dunglas/frankenphp" +) + +// PHPHandler implements http.Handler by delegating to FrankenPHP. +// It resolves URLs to files (like Caddy's try_files) before passing +// requests to the PHP runtime. +type PHPHandler struct { + docRoot string + laravelRoot string +} + +// NewPHPHandler extracts the embedded Laravel app, prepares the environment, +// initialises FrankenPHP with worker mode, and returns the handler. +func NewPHPHandler() (*PHPHandler, *AppEnvironment, func(), error) { + // Extract embedded Laravel to temp directory + laravelRoot, err := extractLaravel() + if err != nil { + return nil, nil, nil, fmt.Errorf("extract Laravel: %w", err) + } + + // Prepare persistent environment + env, err := PrepareEnvironment(laravelRoot) + if err != nil { + os.RemoveAll(laravelRoot) + return nil, nil, nil, fmt.Errorf("prepare environment: %w", err) + } + + docRoot := filepath.Join(laravelRoot, "public") + + log.Printf("Laravel root: %s", laravelRoot) + log.Printf("Document root: %s", docRoot) + log.Printf("Data directory: %s", env.DataDir) + log.Printf("Database: %s", env.DatabasePath) + + // Try Octane worker mode first, fall back to standard mode. + // Worker mode keeps Laravel booted in memory — sub-ms response times. + workerScript := filepath.Join(laravelRoot, "vendor", "laravel", "octane", "bin", "frankenphp-worker.php") + workerEnv := map[string]string{ + "APP_BASE_PATH": laravelRoot, + "FRANKENPHP_WORKER": "1", + } + + workerMode := false + if _, err := os.Stat(workerScript); err == nil { + if err := frankenphp.Init( + frankenphp.WithNumThreads(4), + frankenphp.WithWorkers("laravel", workerScript, 2, workerEnv, nil), + frankenphp.WithPhpIni(map[string]string{ + "display_errors": "Off", + "opcache.enable": "1", + }), + ); err != nil { + log.Printf("Worker mode init failed (%v), falling back to standard mode", err) + } else { + workerMode = true + } + } + + if !workerMode { + if err := frankenphp.Init( + frankenphp.WithNumThreads(4), + frankenphp.WithPhpIni(map[string]string{ + "display_errors": "Off", + "opcache.enable": "1", + }), + ); err != nil { + os.RemoveAll(laravelRoot) + return nil, nil, nil, fmt.Errorf("init FrankenPHP: %w", err) + } + } + + if workerMode { + log.Println("FrankenPHP initialised (Octane worker mode, 2 workers)") + } else { + log.Println("FrankenPHP initialised (standard mode, 4 threads)") + } + + cleanup := func() { + frankenphp.Shutdown() + os.RemoveAll(laravelRoot) + } + + handler := &PHPHandler{ + docRoot: docRoot, + laravelRoot: laravelRoot, + } + + return handler, env, cleanup, nil +} + +func (h *PHPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + urlPath := r.URL.Path + filePath := filepath.Join(h.docRoot, filepath.Clean(urlPath)) + + info, err := os.Stat(filePath) + if err == nil && info.IsDir() { + // Directory → try index.php inside it + urlPath = strings.TrimRight(urlPath, "/") + "/index.php" + } else if err != nil && !strings.HasSuffix(urlPath, ".php") { + // File not found and not a .php request → front controller + urlPath = "/index.php" + } + + // Serve static assets directly (CSS, JS, images) + if !strings.HasSuffix(urlPath, ".php") { + staticPath := filepath.Join(h.docRoot, filepath.Clean(urlPath)) + if info, err := os.Stat(staticPath); err == nil && !info.IsDir() { + http.ServeFile(w, r, staticPath) + return + } + } + + // Route to FrankenPHP + r.URL.Path = urlPath + + req, err := frankenphp.NewRequestWithContext(r, + frankenphp.WithRequestDocumentRoot(h.docRoot, false), + ) + if err != nil { + http.Error(w, fmt.Sprintf("FrankenPHP request error: %v", err), http.StatusInternalServerError) + return + } + + if err := frankenphp.ServeHTTP(w, req); err != nil { + http.Error(w, fmt.Sprintf("FrankenPHP serve error: %v", err), http.StatusInternalServerError) + } +} diff --git a/cmd/core-app/icons/appicon.png b/cmd/core-app/icons/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..53adbd595d3e69cce3545aafe98f348b5eb4a3be GIT binary patch literal 76 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fqMj~}Ar*6y|NQ@N&n&>e@c%zE Z1IHhxf6EIyW&))dJYD@<);T3K0RVYV6kz}W literal 0 HcmV?d00001 diff --git a/cmd/core-app/icons/icons.go b/cmd/core-app/icons/icons.go new file mode 100644 index 00000000..d1305e19 --- /dev/null +++ b/cmd/core-app/icons/icons.go @@ -0,0 +1,24 @@ +// Package icons provides embedded icon assets for the Core App. +package icons + +import _ "embed" + +// TrayTemplate is the template icon for macOS systray (22x22 PNG, black on transparent). +// +//go:embed tray-template.png +var TrayTemplate []byte + +// TrayLight is the light mode icon for Windows/Linux systray. +// +//go:embed tray-light.png +var TrayLight []byte + +// TrayDark is the dark mode icon for Windows/Linux systray. +// +//go:embed tray-dark.png +var TrayDark []byte + +// AppIcon is the main application icon. +// +//go:embed appicon.png +var AppIcon []byte diff --git a/cmd/core-app/icons/tray-dark.png b/cmd/core-app/icons/tray-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..53adbd595d3e69cce3545aafe98f348b5eb4a3be GIT binary patch literal 76 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fqMj~}Ar*6y|NQ@N&n&>e@c%zE Z1IHhxf6EIyW&))dJYD@<);T3K0RVYV6kz}W literal 0 HcmV?d00001 diff --git a/cmd/core-app/icons/tray-light.png b/cmd/core-app/icons/tray-light.png new file mode 100644 index 0000000000000000000000000000000000000000..53adbd595d3e69cce3545aafe98f348b5eb4a3be GIT binary patch literal 76 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fqMj~}Ar*6y|NQ@N&n&>e@c%zE Z1IHhxf6EIyW&))dJYD@<);T3K0RVYV6kz}W literal 0 HcmV?d00001 diff --git a/cmd/core-app/icons/tray-template.png b/cmd/core-app/icons/tray-template.png new file mode 100644 index 0000000000000000000000000000000000000000..53adbd595d3e69cce3545aafe98f348b5eb4a3be GIT binary patch literal 76 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fqMj~}Ar*6y|NQ@N&n&>e@c%zE Z1IHhxf6EIyW&))dJYD@<);T3K0RVYV6kz}W literal 0 HcmV?d00001 diff --git a/cmd/core-app/laravel/.env.example b/cmd/core-app/laravel/.env.example new file mode 100644 index 00000000..99fd7612 --- /dev/null +++ b/cmd/core-app/laravel/.env.example @@ -0,0 +1,13 @@ +APP_NAME="Core App" +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=http://localhost + +DB_CONNECTION=sqlite +DB_DATABASE=/tmp/core-app/database.sqlite + +CACHE_STORE=file +SESSION_DRIVER=file +LOG_CHANNEL=single +LOG_LEVEL=warning diff --git a/cmd/core-app/laravel/.gitignore b/cmd/core-app/laravel/.gitignore new file mode 100644 index 00000000..aeec7040 --- /dev/null +++ b/cmd/core-app/laravel/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +/node_modules/ +/.env +/bootstrap/cache/*.php +/storage/*.key diff --git a/cmd/core-app/laravel/app/Livewire/Counter.php b/cmd/core-app/laravel/app/Livewire/Counter.php new file mode 100644 index 00000000..71f5890f --- /dev/null +++ b/cmd/core-app/laravel/app/Livewire/Counter.php @@ -0,0 +1,27 @@ +count++; + } + + public function decrement(): void + { + $this->count--; + } + + public function render() + { + return view('livewire.counter'); + } +} diff --git a/cmd/core-app/laravel/artisan b/cmd/core-app/laravel/artisan new file mode 100644 index 00000000..20cd96fd --- /dev/null +++ b/cmd/core-app/laravel/artisan @@ -0,0 +1,21 @@ +#!/usr/bin/env php +make(Illuminate\Contracts\Console\Kernel::class); + +$status = $kernel->handle( + $input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +$kernel->terminate($input, $status); + +exit($status); diff --git a/cmd/core-app/laravel/bootstrap/app.php b/cmd/core-app/laravel/bootstrap/app.php new file mode 100644 index 00000000..66615824 --- /dev/null +++ b/cmd/core-app/laravel/bootstrap/app.php @@ -0,0 +1,19 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + ) + ->withMiddleware(function (Middleware $middleware) { + // + }) + ->withExceptions(function (Exceptions $exceptions) { + // + }) + ->create(); diff --git a/cmd/core-app/laravel/composer.json b/cmd/core-app/laravel/composer.json new file mode 100644 index 00000000..03e625d4 --- /dev/null +++ b/cmd/core-app/laravel/composer.json @@ -0,0 +1,29 @@ +{ + "name": "host-uk/core-app", + "description": "Embedded Laravel application for Core App desktop", + "license": "EUPL-1.2", + "type": "project", + "require": { + "php": "^8.4", + "laravel/framework": "^12.0", + "laravel/octane": "^2.0", + "livewire/livewire": "^4.0" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true, + "scripts": { + "post-autoload-dump": [ + "@php artisan package:discover --ansi" + ] + } +} diff --git a/cmd/core-app/laravel/composer.lock b/cmd/core-app/laravel/composer.lock new file mode 100644 index 00000000..fe0f78d4 --- /dev/null +++ b/cmd/core-app/laravel/composer.lock @@ -0,0 +1,6149 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "42a832df69a90e5b95166df60047b7c3", + "packages": [ + { + "name": "brick/math", + "version": "0.14.6", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", + "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.6" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-05T07:59:58+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:27:06+00:00" + }, + { + "name": "laminas/laminas-diactoros", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-diactoros.git", + "reference": "60c182916b2749480895601649563970f3f12ec4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/60c182916b2749480895601649563970f3f12ec4", + "reference": "60c182916b2749480895601649563970f3f12ec4", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "conflict": { + "amphp/amp": "<2.6.4" + }, + "provide": { + "psr/http-factory-implementation": "^1.0", + "psr/http-message-implementation": "^1.1 || ^2.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-dom": "*", + "ext-gd": "*", + "ext-libxml": "*", + "http-interop/http-factory-tests": "^2.2.0", + "laminas/laminas-coding-standard": "~3.1.0", + "php-http/psr7-integration-tests": "^1.4.0", + "phpunit/phpunit": "^10.5.36", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13" + }, + "type": "library", + "extra": { + "laminas": { + "module": "Laminas\\Diactoros", + "config-provider": "Laminas\\Diactoros\\ConfigProvider" + } + }, + "autoload": { + "files": [ + "src/functions/create_uploaded_file.php", + "src/functions/marshal_headers_from_sapi.php", + "src/functions/marshal_method_from_sapi.php", + "src/functions/marshal_protocol_version_from_sapi.php", + "src/functions/normalize_server.php", + "src/functions/normalize_uploaded_files.php", + "src/functions/parse_cookie_header.php" + ], + "psr-4": { + "Laminas\\Diactoros\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "PSR HTTP Message implementations", + "homepage": "https://laminas.dev", + "keywords": [ + "http", + "laminas", + "psr", + "psr-17", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-diactoros/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-diactoros/issues", + "rss": "https://github.com/laminas/laminas-diactoros/releases.atom", + "source": "https://github.com/laminas/laminas-diactoros" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-12T15:31:36+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.50.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "174ffed91d794a35a541a5eb7c3785a02a34aaba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/174ffed91d794a35a541a5eb7c3785a02a34aaba", + "reference": "174ffed91d794a35a541a5eb7c3785a02a34aaba", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.7", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-04T18:34:13+00:00" + }, + { + "name": "laravel/octane", + "version": "v2.13.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/octane.git", + "reference": "c343716659c280a7613a0c10d3241215512355ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/octane/zipball/c343716659c280a7613a0c10d3241215512355ee", + "reference": "c343716659c280a7613a0c10d3241215512355ee", + "shasum": "" + }, + "require": { + "laminas/laminas-diactoros": "^3.0", + "laravel/framework": "^10.10.1|^11.0|^12.0", + "laravel/prompts": "^0.1.24|^0.2.0|^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "nesbot/carbon": "^2.66.0|^3.0", + "php": "^8.1.0", + "symfony/console": "^6.0|^7.0", + "symfony/psr-http-message-bridge": "^2.2.0|^6.4|^7.0" + }, + "conflict": { + "spiral/roadrunner": "<2023.1.0", + "spiral/roadrunner-cli": "<2.6.0", + "spiral/roadrunner-http": "<3.3.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.6.1", + "inertiajs/inertia-laravel": "^1.3.2|^2.0", + "laravel/scout": "^10.2.1", + "laravel/socialite": "^5.6.1", + "livewire/livewire": "^2.12.3|^3.0", + "mockery/mockery": "^1.5.1", + "nunomaduro/collision": "^6.4.0|^7.5.2|^8.0", + "orchestra/testbench": "^8.21|^9.0|^10.0", + "phpstan/phpstan": "^2.1.7", + "phpunit/phpunit": "^10.4|^11.5", + "spiral/roadrunner-cli": "^2.6.0", + "spiral/roadrunner-http": "^3.3.0" + }, + "bin": [ + "bin/roadrunner-worker", + "bin/swoole-server" + ], + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Octane": "Laravel\\Octane\\Facades\\Octane" + }, + "providers": [ + "Laravel\\Octane\\OctaneServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Octane\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Supercharge your Laravel application's performance.", + "keywords": [ + "frankenphp", + "laravel", + "octane", + "roadrunner", + "swoole" + ], + "support": { + "issues": "https://github.com/laravel/octane/issues", + "source": "https://github.com/laravel/octane" + }, + "time": "2026-01-22T17:24:46+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.12", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "4861ded9003b7f8a158176a0b7666f74ee761be8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/4861ded9003b7f8a158176a0b7666f74ee761be8", + "reference": "4861ded9003b7f8a158176a0b7666f74ee761be8", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.12" + }, + "time": "2026-02-03T06:57:26+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.9", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", + "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-02-03T06:55:34+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2025-11-26T21:48:24+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" + }, + "time": "2026-01-23T15:38:47+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", + "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-14T17:24:56+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-01-15T06:54:53+00:00" + }, + { + "name": "livewire/livewire", + "version": "v4.1.3", + "source": { + "type": "git", + "url": "https://github.com/livewire/livewire.git", + "reference": "69c871cb15fb95f10cda5acd1ee7e63cd3c494c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/livewire/zipball/69c871cb15fb95f10cda5acd1ee7e63cd3c494c8", + "reference": "69c871cb15fb95f10cda5acd1ee7e63cd3c494c8", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "laravel/prompts": "^0.1.24|^0.2|^0.3", + "league/mime-type-detection": "^1.9", + "php": "^8.1", + "symfony/console": "^6.0|^7.0", + "symfony/http-kernel": "^6.2|^7.0" + }, + "require-dev": { + "calebporzio/sushi": "^2.1", + "laravel/framework": "^10.15.0|^11.0|^12.0", + "mockery/mockery": "^1.3.1", + "orchestra/testbench": "^8.21.0|^9.0|^10.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0", + "phpunit/phpunit": "^10.4|^11.5", + "psy/psysh": "^0.11.22|^0.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Livewire": "Livewire\\Livewire" + }, + "providers": [ + "Livewire\\LivewireServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Livewire\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "A front-end framework for Laravel.", + "support": { + "issues": "https://github.com/livewire/livewire/issues", + "source": "https://github.com/livewire/livewire/tree/v4.1.3" + }, + "funding": [ + { + "url": "https://github.com/livewire", + "type": "github" + } + ], + "time": "2026-02-06T12:19:55+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "f438fcc98f92babee98381d399c65336f3a3827f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", + "reference": "f438fcc98f92babee98381d399c65336f3a3827f", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-01-29T09:26:29+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.3", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/tester": "^2.5.2", + "phpstan/phpstan-nette": "^2.0@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.3" + }, + "time": "2025-10-30T22:57:59+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.2", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/tester": "^2.5", + "phpstan/phpstan": "^2.0@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.2" + }, + "time": "2026-02-03T17:21:09+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.3.3", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.3.6" + }, + "require-dev": { + "illuminate/console": "^11.46.1", + "laravel/pint": "^1.25.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Its like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2025-11-20T02:34:59+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-11-12T15:46:48+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-13T11:36:38+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-30T14:17:19+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-20T16:42:42+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", + "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T11:45:55+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:07:59+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", + "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T16:16:02+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", + "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-28T10:33:42+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", + "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-08T08:25:11+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", + "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T08:59:58+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "608476f4604102976d687c483ac63a79ba18cc97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:07:59+00:00" + }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/929ffe10bbfbb92e711ac3818d416f9daffee067", + "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:30:35+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", + "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-12T12:19:02+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "758b372d6882506821ed666032e43020c4f57194" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", + "reference": "758b372d6882506821ed666032e43020c4f57194", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-12T12:37:40+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-13T13:06:50+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", + "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:30:35+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", + "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-01T22:13:48+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/cmd/core-app/laravel/config/app.php b/cmd/core-app/laravel/config/app.php new file mode 100644 index 00000000..7944ae49 --- /dev/null +++ b/cmd/core-app/laravel/config/app.php @@ -0,0 +1,19 @@ + env('APP_NAME', 'Core App'), + 'env' => env('APP_ENV', 'production'), + 'debug' => (bool) env('APP_DEBUG', false), + 'url' => env('APP_URL', 'http://localhost'), + 'timezone' => 'UTC', + 'locale' => 'en', + 'fallback_locale' => 'en', + 'faker_locale' => 'en_GB', + 'cipher' => 'AES-256-CBC', + 'key' => env('APP_KEY'), + 'maintenance' => [ + 'driver' => 'file', + ], +]; diff --git a/cmd/core-app/laravel/config/cache.php b/cmd/core-app/laravel/config/cache.php new file mode 100644 index 00000000..d2106ca7 --- /dev/null +++ b/cmd/core-app/laravel/config/cache.php @@ -0,0 +1,21 @@ + env('CACHE_STORE', 'file'), + + 'stores' => [ + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + ], + + 'prefix' => env('CACHE_PREFIX', 'core_app_cache_'), +]; diff --git a/cmd/core-app/laravel/config/database.php b/cmd/core-app/laravel/config/database.php new file mode 100644 index 00000000..0dd2ae25 --- /dev/null +++ b/cmd/core-app/laravel/config/database.php @@ -0,0 +1,25 @@ + 'sqlite', + + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => true, + 'busy_timeout' => 5000, + 'journal_mode' => 'wal', + 'synchronous' => 'normal', + ], + ], + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], +]; diff --git a/cmd/core-app/laravel/config/logging.php b/cmd/core-app/laravel/config/logging.php new file mode 100644 index 00000000..0b50ef7f --- /dev/null +++ b/cmd/core-app/laravel/config/logging.php @@ -0,0 +1,25 @@ + env('LOG_CHANNEL', 'single'), + + 'channels' => [ + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'warning'), + 'replace_placeholders' => true, + ], + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => Monolog\Handler\StreamHandler::class, + 'with' => [ + 'stream' => 'php://stderr', + ], + 'processors' => [Monolog\Processor\PsrLogMessageProcessor::class], + ], + ], +]; diff --git a/cmd/core-app/laravel/config/session.php b/cmd/core-app/laravel/config/session.php new file mode 100644 index 00000000..1d69e945 --- /dev/null +++ b/cmd/core-app/laravel/config/session.php @@ -0,0 +1,22 @@ + env('SESSION_DRIVER', 'file'), + 'lifetime' => env('SESSION_LIFETIME', 120), + 'expire_on_close' => true, + 'encrypt' => false, + 'files' => storage_path('framework/sessions'), + 'connection' => env('SESSION_CONNECTION'), + 'table' => 'sessions', + 'store' => env('SESSION_STORE'), + 'lottery' => [2, 100], + 'cookie' => env('SESSION_COOKIE', 'core_app_session'), + 'path' => '/', + 'domain' => null, + 'secure' => false, + 'http_only' => true, + 'same_site' => 'lax', + 'partitioned' => false, +]; diff --git a/cmd/core-app/laravel/config/view.php b/cmd/core-app/laravel/config/view.php new file mode 100644 index 00000000..c839c6ff --- /dev/null +++ b/cmd/core-app/laravel/config/view.php @@ -0,0 +1,10 @@ + [ + resource_path('views'), + ], + 'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))), +]; diff --git a/cmd/core-app/laravel/public/index.php b/cmd/core-app/laravel/public/index.php new file mode 100644 index 00000000..d55a3b2c --- /dev/null +++ b/cmd/core-app/laravel/public/index.php @@ -0,0 +1,19 @@ +handleRequest(Request::capture()); diff --git a/cmd/core-app/laravel/resources/views/components/layout.blade.php b/cmd/core-app/laravel/resources/views/components/layout.blade.php new file mode 100644 index 00000000..acabb0db --- /dev/null +++ b/cmd/core-app/laravel/resources/views/components/layout.blade.php @@ -0,0 +1,107 @@ + + + + + + Core App + + @livewireStyles + + + {{ $slot }} + @livewireScripts + + diff --git a/cmd/core-app/laravel/resources/views/livewire/counter.blade.php b/cmd/core-app/laravel/resources/views/livewire/counter.blade.php new file mode 100644 index 00000000..b000570a --- /dev/null +++ b/cmd/core-app/laravel/resources/views/livewire/counter.blade.php @@ -0,0 +1,8 @@ +
+
{{ $count }}
+
+ + +
+

Livewire {{ \Livewire\Livewire::VERSION }} · Server-rendered, no page reload

+
diff --git a/cmd/core-app/laravel/resources/views/welcome.blade.php b/cmd/core-app/laravel/resources/views/welcome.blade.php new file mode 100644 index 00000000..47186d3d --- /dev/null +++ b/cmd/core-app/laravel/resources/views/welcome.blade.php @@ -0,0 +1,40 @@ + +
+

Core App

+

Laravel {{ app()->version() }} running inside a native desktop window

+ +
+
+
PHP
+
{{ PHP_VERSION }}
+
+
+
Thread Safety
+
{{ PHP_ZTS ? 'ZTS (Yes)' : 'NTS (No)' }}
+
+
+
SAPI
+
{{ php_sapi_name() }}
+
+
+
Platform
+
{{ PHP_OS }} {{ php_uname('m') }}
+
+
+
Database
+
SQLite {{ \SQLite3::version()['versionString'] }}
+
+
+
Mode
+
{{ env('FRANKENPHP_WORKER') ? 'Octane Worker' : 'Standard' }}
+
+
+ +
Single Binary · No Server · No Config
+
+ +
+

Livewire Reactivity Test

+ +
+
diff --git a/cmd/core-app/laravel/routes/web.php b/cmd/core-app/laravel/routes/web.php new file mode 100644 index 00000000..7bceeafd --- /dev/null +++ b/cmd/core-app/laravel/routes/web.php @@ -0,0 +1,9 @@ + Date: Fri, 6 Feb 2026 22:56:44 +0000 Subject: [PATCH 2/9] feat(core-app): add auto-migration and session/cache tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppServiceProvider runs migrate --force on first request. Sessions and cache tables created automatically in SQLite. Removed synthetic HTTP migration approach in favour of pure PHP service provider — cleaner, works with Octane workers. Co-Authored-By: Claude Opus 4.6 --- cmd/core-app/env.go | 1 + .../app/Providers/AppServiceProvider.php | 28 +++++++++++++++++ cmd/core-app/laravel/bootstrap/app.php | 1 + cmd/core-app/laravel/bootstrap/providers.php | 7 +++++ ...001_01_01_000000_create_sessions_table.php | 27 ++++++++++++++++ .../0001_01_01_000001_create_cache_table.php | 31 +++++++++++++++++++ 6 files changed, 95 insertions(+) create mode 100644 cmd/core-app/laravel/app/Providers/AppServiceProvider.php create mode 100644 cmd/core-app/laravel/bootstrap/providers.php create mode 100644 cmd/core-app/laravel/database/migrations/0001_01_01_000000_create_sessions_table.php create mode 100644 cmd/core-app/laravel/database/migrations/0001_01_01_000001_create_cache_table.php diff --git a/cmd/core-app/env.go b/cmd/core-app/env.go index 5fbde0bf..6249285c 100644 --- a/cmd/core-app/env.go +++ b/cmd/core-app/env.go @@ -123,6 +123,7 @@ CACHE_STORE=file SESSION_DRIVER=file LOG_CHANNEL=single LOG_LEVEL=warning + `, appKey, env.DatabasePath) return os.WriteFile(filepath.Join(laravelRoot, ".env"), []byte(content), 0o644) diff --git a/cmd/core-app/laravel/app/Providers/AppServiceProvider.php b/cmd/core-app/laravel/app/Providers/AppServiceProvider.php new file mode 100644 index 00000000..e8f107ac --- /dev/null +++ b/cmd/core-app/laravel/app/Providers/AppServiceProvider.php @@ -0,0 +1,28 @@ + true, + '--no-interaction' => true, + ]); + } catch (Throwable) { + // Silently skip — DB might not exist yet (e.g. during + // composer operations or first extraction). + } + } +} diff --git a/cmd/core-app/laravel/bootstrap/app.php b/cmd/core-app/laravel/bootstrap/app.php index 66615824..ba8f1fff 100644 --- a/cmd/core-app/laravel/bootstrap/app.php +++ b/cmd/core-app/laravel/bootstrap/app.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', ) ->withMiddleware(function (Middleware $middleware) { // diff --git a/cmd/core-app/laravel/bootstrap/providers.php b/cmd/core-app/laravel/bootstrap/providers.php new file mode 100644 index 00000000..84c7d4de --- /dev/null +++ b/cmd/core-app/laravel/bootstrap/providers.php @@ -0,0 +1,7 @@ +string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/cmd/core-app/laravel/database/migrations/0001_01_01_000001_create_cache_table.php b/cmd/core-app/laravel/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 00000000..266e00a9 --- /dev/null +++ b/cmd/core-app/laravel/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,31 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cache_locks'); + Schema::dropIfExists('cache'); + } +}; From d2916db6403a55087e582bfbb67d656e5f8816f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 13:25:06 +0000 Subject: [PATCH 3/9] feat: add Woodpecker CI pipeline and workspace improvements (#1) Co-authored-by: Claude Co-committed-by: Claude --- .forgejo/workflows/security-scan.yml | 50 ++ .gitleaks.toml | 10 + .woodpecker.yml | 21 + Taskfile.yml | 27 +- cmd/bugseti/go.mod | 5 + cmd/bugseti/go.sum | 10 +- cmd/bugseti/main.go | 2 + cmd/bugseti/workspace.go | 268 ++++++++ docs/examples/build-cpp.yaml | 83 +++ go.mod | 1 + go.sum | 2 + internal/cmd/forge/cmd_config.go | 106 ++++ internal/cmd/forge/cmd_forge.go | 53 ++ internal/cmd/forge/cmd_issues.go | 200 ++++++ internal/cmd/forge/cmd_labels.go | 120 ++++ internal/cmd/forge/cmd_migrate.go | 121 ++++ internal/cmd/forge/cmd_orgs.go | 66 ++ internal/cmd/forge/cmd_prs.go | 98 +++ internal/cmd/forge/cmd_repos.go | 94 +++ internal/cmd/forge/cmd_status.go | 63 ++ internal/cmd/forge/cmd_sync.go | 334 ++++++++++ internal/cmd/forge/helpers.go | 33 + internal/cmd/php/detect_test.go | 3 + internal/cmd/pkgcmd/cmd_pkg.go | 1 + internal/cmd/pkgcmd/cmd_remove.go | 144 +++++ internal/cmd/pkgcmd/cmd_remove_test.go | 92 +++ internal/cmd/workspace/cmd_agent.go | 288 +++++++++ internal/cmd/workspace/cmd_agent_test.go | 79 +++ internal/cmd/workspace/cmd_task.go | 466 ++++++++++++++ internal/cmd/workspace/cmd_task_test.go | 109 ++++ internal/cmd/workspace/cmd_workspace.go | 2 + internal/variants/full.go | 2 + pkg/build/build.go | 2 + pkg/build/buildcmd/cmd_project.go | 2 + pkg/build/builders/cpp.go | 253 ++++++++ pkg/build/builders/cpp_test.go | 149 +++++ pkg/build/discovery.go | 7 + pkg/build/discovery_test.go | 21 + pkg/build/testdata/cpp-project/CMakeLists.txt | 2 + pkg/cli/app.go | 34 +- pkg/forge/client.go | 37 ++ pkg/forge/config.go | 92 +++ pkg/forge/issues.go | 119 ++++ pkg/forge/labels.go | 60 ++ pkg/forge/meta.go | 144 +++++ pkg/forge/orgs.go | 51 ++ pkg/forge/repos.go | 96 +++ pkg/forge/webhooks.go | 41 ++ pkg/i18n/locales/en_GB.json | 16 +- pkg/io/datanode/client.go | 573 ++++++++++++++++++ pkg/io/datanode/client_test.go | 352 +++++++++++ pkg/release/config_test.go | 3 + pkg/release/release_test.go | 3 + 53 files changed, 4998 insertions(+), 12 deletions(-) create mode 100644 .forgejo/workflows/security-scan.yml create mode 100644 .gitleaks.toml create mode 100644 .woodpecker.yml create mode 100644 cmd/bugseti/workspace.go create mode 100644 docs/examples/build-cpp.yaml create mode 100644 internal/cmd/forge/cmd_config.go create mode 100644 internal/cmd/forge/cmd_forge.go create mode 100644 internal/cmd/forge/cmd_issues.go create mode 100644 internal/cmd/forge/cmd_labels.go create mode 100644 internal/cmd/forge/cmd_migrate.go create mode 100644 internal/cmd/forge/cmd_orgs.go create mode 100644 internal/cmd/forge/cmd_prs.go create mode 100644 internal/cmd/forge/cmd_repos.go create mode 100644 internal/cmd/forge/cmd_status.go create mode 100644 internal/cmd/forge/cmd_sync.go create mode 100644 internal/cmd/forge/helpers.go create mode 100644 internal/cmd/pkgcmd/cmd_remove.go create mode 100644 internal/cmd/pkgcmd/cmd_remove_test.go create mode 100644 internal/cmd/workspace/cmd_agent.go create mode 100644 internal/cmd/workspace/cmd_agent_test.go create mode 100644 internal/cmd/workspace/cmd_task.go create mode 100644 internal/cmd/workspace/cmd_task_test.go create mode 100644 pkg/build/builders/cpp.go create mode 100644 pkg/build/builders/cpp_test.go create mode 100644 pkg/build/testdata/cpp-project/CMakeLists.txt create mode 100644 pkg/forge/client.go create mode 100644 pkg/forge/config.go create mode 100644 pkg/forge/issues.go create mode 100644 pkg/forge/labels.go create mode 100644 pkg/forge/meta.go create mode 100644 pkg/forge/orgs.go create mode 100644 pkg/forge/repos.go create mode 100644 pkg/forge/webhooks.go create mode 100644 pkg/io/datanode/client.go create mode 100644 pkg/io/datanode/client_test.go diff --git a/.forgejo/workflows/security-scan.yml b/.forgejo/workflows/security-scan.yml new file mode 100644 index 00000000..7544d94a --- /dev/null +++ b/.forgejo/workflows/security-scan.yml @@ -0,0 +1,50 @@ +# Sovereign security scanning — no cloud dependencies +# Replaces: GitHub Dependabot, CodeQL, Advanced Security +# PCI DSS: Req 6.3.2 (code review), Req 11.3 (vulnerability scanning) + +name: Security Scan + +on: + push: + branches: [main, dev, 'feat/*'] + pull_request: + branches: [main] + +jobs: + govulncheck: + name: Go Vulnerability Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Run govulncheck + run: govulncheck ./... + + gitleaks: + name: Secret Detection + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install gitleaks + run: | + GITLEAKS_VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | jq -r '.tag_name' | tr -d 'v') + curl -sL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar xz -C /usr/local/bin gitleaks + - name: Scan for secrets + run: gitleaks detect --source . --no-banner + + trivy: + name: Dependency & Config Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Trivy + run: | + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin + - name: Filesystem scan + run: trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 . diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..893d7184 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,10 @@ +# Gitleaks configuration for host-uk/core +# Test fixtures contain private keys for cryptographic testing — not real secrets. + +[allowlist] + description = "Test fixture allowlist" + paths = [ + '''pkg/crypt/pgp/pgp_test\.go''', + '''pkg/crypt/rsa/rsa_test\.go''', + '''pkg/crypt/openpgp/test_util\.go''', + ] diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 00000000..7e1e7b29 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,21 @@ +when: + - event: [push, pull_request, manual] + +steps: + - name: build + image: golang:1.25-bookworm + commands: + - go version + - go mod download + - >- + go build + -ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=ci + -X github.com/host-uk/core/pkg/cli.BuildCommit=${CI_COMMIT_SHA:0:7} + -X github.com/host-uk/core/pkg/cli.BuildDate=$(date -u +%Y%m%d)" + -o ./bin/core . + - ./bin/core --version + + - name: test + image: golang:1.25-bookworm + commands: + - go test -short -count=1 -timeout 120s ./... diff --git a/Taskfile.yml b/Taskfile.yml index 1e267461..d3ceddb7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,14 +1,33 @@ version: '3' vars: - VERSION: - sh: git describe --tags --exact-match 2>/dev/null || echo "dev" - # Base ldflags for version injection - LDFLAGS_BASE: "-X github.com/host-uk/core/pkg/cli.AppVersion={{.VERSION}}" + # SemVer 2.0.0 build variables + SEMVER_TAG: + sh: git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0" + SEMVER_VERSION: + sh: echo "{{.SEMVER_TAG}}" | sed 's/^v//' + SEMVER_COMMITS: + sh: git rev-list {{.SEMVER_TAG}}..HEAD --count 2>/dev/null || echo "0" + SEMVER_COMMIT: + sh: git rev-parse --short HEAD 2>/dev/null || echo "unknown" + SEMVER_DATE: + sh: date -u +%Y%m%d + SEMVER_PRERELEASE: + sh: '[ "{{.SEMVER_COMMITS}}" = "0" ] && echo "" || echo "dev.{{.SEMVER_COMMITS}}"' + # ldflags + PKG: "github.com/host-uk/core/pkg/cli" + LDFLAGS_BASE: >- + -X {{.PKG}}.AppVersion={{.SEMVER_VERSION}} + -X {{.PKG}}.BuildCommit={{.SEMVER_COMMIT}} + -X {{.PKG}}.BuildDate={{.SEMVER_DATE}} + -X {{.PKG}}.BuildPreRelease={{.SEMVER_PRERELEASE}} # Development build: includes debug info LDFLAGS: "{{.LDFLAGS_BASE}}" # Release build: strips debug info and symbol table for smaller binary LDFLAGS_RELEASE: "-s -w {{.LDFLAGS_BASE}}" + # Compat alias + VERSION: + sh: git describe --tags --exact-match 2>/dev/null || echo "dev" tasks: # --- CLI Management --- diff --git a/cmd/bugseti/go.mod b/cmd/bugseti/go.mod index 99cabc6a..8d363e94 100644 --- a/cmd/bugseti/go.mod +++ b/cmd/bugseti/go.mod @@ -3,11 +3,15 @@ module github.com/host-uk/core/cmd/bugseti go 1.25.5 require ( + github.com/Snider/Borg v0.2.0 + github.com/host-uk/core v0.0.0 github.com/host-uk/core/internal/bugseti v0.0.0 github.com/host-uk/core/internal/bugseti/updater v0.0.0 github.com/wailsapp/wails/v3 v3.0.0-alpha.64 ) +replace github.com/host-uk/core => ../.. + replace github.com/host-uk/core/internal/bugseti => ../../internal/bugseti replace github.com/host-uk/core/internal/bugseti/updater => ../../internal/bugseti/updater @@ -16,6 +20,7 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/Snider/Enchantrix v0.0.2 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect diff --git a/cmd/bugseti/go.sum b/cmd/bugseti/go.sum index 0e3453c2..35a32441 100644 --- a/cmd/bugseti/go.sum +++ b/cmd/bugseti/go.sum @@ -5,6 +5,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ= +github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY= +github.com/Snider/Enchantrix v0.0.2 h1:ExZQiBhfS/p/AHFTKhY80TOd+BXZjK95EzByAEgwvjs= +github.com/Snider/Enchantrix v0.0.2/go.mod h1:CtFcLAvnDT1KcuF1JBb/DJj0KplY8jHryO06KzQ1hsQ= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -20,8 +24,9 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -86,8 +91,9 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/cmd/bugseti/main.go b/cmd/bugseti/main.go index 4e23dbab..458f53a7 100644 --- a/cmd/bugseti/main.go +++ b/cmd/bugseti/main.go @@ -44,6 +44,7 @@ func main() { seederService := bugseti.NewSeederService(configService) submitService := bugseti.NewSubmitService(configService, notifyService, statsService) versionService := bugseti.NewVersionService() + workspaceService := NewWorkspaceService(configService) // Initialize update service updateService, err := updater.NewService(configService) @@ -64,6 +65,7 @@ func main() { application.NewService(seederService), application.NewService(submitService), application.NewService(versionService), + application.NewService(workspaceService), application.NewService(trayService), } diff --git a/cmd/bugseti/workspace.go b/cmd/bugseti/workspace.go new file mode 100644 index 00000000..df2c02b8 --- /dev/null +++ b/cmd/bugseti/workspace.go @@ -0,0 +1,268 @@ +// Package main provides the BugSETI system tray application. +package main + +import ( + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/Snider/Borg/pkg/tim" + "github.com/host-uk/core/internal/bugseti" + "github.com/host-uk/core/pkg/io/datanode" +) + +// WorkspaceService manages DataNode-backed workspaces for issues. +// Each issue gets a sandboxed in-memory filesystem that can be +// snapshotted, packaged as a TIM container, or shipped as a crash report. +type WorkspaceService struct { + config *bugseti.ConfigService + workspaces map[string]*Workspace // issue ID → workspace + mu sync.RWMutex +} + +// Workspace tracks a DataNode-backed workspace for an issue. +type Workspace struct { + Issue *bugseti.Issue `json:"issue"` + Medium *datanode.Medium + DiskPath string `json:"diskPath"` + CreatedAt time.Time `json:"createdAt"` + Snapshots int `json:"snapshots"` +} + +// CrashReport contains a packaged workspace state for debugging. +type CrashReport struct { + IssueID string `json:"issueId"` + Repo string `json:"repo"` + Number int `json:"number"` + Title string `json:"title"` + Error string `json:"error"` + Timestamp time.Time `json:"timestamp"` + Data []byte `json:"data"` // tar snapshot + Files int `json:"files"` + Size int64 `json:"size"` +} + +// NewWorkspaceService creates a new WorkspaceService. +func NewWorkspaceService(config *bugseti.ConfigService) *WorkspaceService { + return &WorkspaceService{ + config: config, + workspaces: make(map[string]*Workspace), + } +} + +// ServiceName returns the service name for Wails. +func (w *WorkspaceService) ServiceName() string { + return "WorkspaceService" +} + +// Capture loads a filesystem workspace into a DataNode Medium. +// Call this after git clone to create the in-memory snapshot. +func (w *WorkspaceService) Capture(issue *bugseti.Issue, diskPath string) error { + if issue == nil { + return fmt.Errorf("issue is nil") + } + + m := datanode.New() + + // Walk the filesystem and load all files into the DataNode + err := filepath.WalkDir(diskPath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // skip errors + } + + // Get relative path + rel, err := filepath.Rel(diskPath, path) + if err != nil { + return nil + } + if rel == "." { + return nil + } + + // Skip .git internals (keep .git marker but not the pack files) + if rel == ".git" { + return fs.SkipDir + } + + if d.IsDir() { + return m.EnsureDir(rel) + } + + // Skip large files (>1MB) to keep DataNode lightweight + info, err := d.Info() + if err != nil || info.Size() > 1<<20 { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return nil + } + return m.Write(rel, string(content)) + }) + if err != nil { + return fmt.Errorf("failed to capture workspace: %w", err) + } + + w.mu.Lock() + w.workspaces[issue.ID] = &Workspace{ + Issue: issue, + Medium: m, + DiskPath: diskPath, + CreatedAt: time.Now(), + } + w.mu.Unlock() + + log.Printf("Captured workspace for issue #%d (%s)", issue.Number, issue.Repo) + return nil +} + +// GetMedium returns the DataNode Medium for an issue's workspace. +func (w *WorkspaceService) GetMedium(issueID string) *datanode.Medium { + w.mu.RLock() + defer w.mu.RUnlock() + + ws := w.workspaces[issueID] + if ws == nil { + return nil + } + return ws.Medium +} + +// Snapshot takes a tar snapshot of the workspace. +func (w *WorkspaceService) Snapshot(issueID string) ([]byte, error) { + w.mu.Lock() + defer w.mu.Unlock() + + ws := w.workspaces[issueID] + if ws == nil { + return nil, fmt.Errorf("workspace not found: %s", issueID) + } + + data, err := ws.Medium.Snapshot() + if err != nil { + return nil, fmt.Errorf("snapshot failed: %w", err) + } + + ws.Snapshots++ + return data, nil +} + +// PackageCrashReport captures the current workspace state as a crash report. +// Re-reads from disk to get the latest state (including git changes). +func (w *WorkspaceService) PackageCrashReport(issue *bugseti.Issue, errMsg string) (*CrashReport, error) { + if issue == nil { + return nil, fmt.Errorf("issue is nil") + } + + w.mu.RLock() + ws := w.workspaces[issue.ID] + w.mu.RUnlock() + + var diskPath string + if ws != nil { + diskPath = ws.DiskPath + } else { + // Try to find the workspace on disk + baseDir := w.config.GetWorkspaceDir() + if baseDir == "" { + baseDir = filepath.Join(os.TempDir(), "bugseti") + } + diskPath = filepath.Join(baseDir, sanitizeForPath(issue.Repo), fmt.Sprintf("issue-%d", issue.Number)) + } + + // Re-capture from disk to get latest state + if err := w.Capture(issue, diskPath); err != nil { + return nil, fmt.Errorf("capture failed: %w", err) + } + + // Snapshot the captured workspace + data, err := w.Snapshot(issue.ID) + if err != nil { + return nil, fmt.Errorf("snapshot failed: %w", err) + } + + return &CrashReport{ + IssueID: issue.ID, + Repo: issue.Repo, + Number: issue.Number, + Title: issue.Title, + Error: errMsg, + Timestamp: time.Now(), + Data: data, + Size: int64(len(data)), + }, nil +} + +// PackageTIM wraps the workspace as a TIM container (runc-compatible bundle). +// The resulting TIM can be executed via runc or encrypted to .stim for transit. +func (w *WorkspaceService) PackageTIM(issueID string) (*tim.TerminalIsolationMatrix, error) { + w.mu.RLock() + ws := w.workspaces[issueID] + w.mu.RUnlock() + + if ws == nil { + return nil, fmt.Errorf("workspace not found: %s", issueID) + } + + dn := ws.Medium.DataNode() + return tim.FromDataNode(dn) +} + +// SaveCrashReport writes a crash report to the data directory. +func (w *WorkspaceService) SaveCrashReport(report *CrashReport) (string, error) { + dataDir := w.config.GetDataDir() + if dataDir == "" { + dataDir = filepath.Join(os.TempDir(), "bugseti") + } + + crashDir := filepath.Join(dataDir, "crash-reports") + if err := os.MkdirAll(crashDir, 0755); err != nil { + return "", fmt.Errorf("failed to create crash dir: %w", err) + } + + filename := fmt.Sprintf("crash-%s-issue-%d-%s.tar", + sanitizeForPath(report.Repo), + report.Number, + report.Timestamp.Format("20060102-150405"), + ) + path := filepath.Join(crashDir, filename) + + if err := os.WriteFile(path, report.Data, 0644); err != nil { + return "", fmt.Errorf("failed to write crash report: %w", err) + } + + log.Printf("Crash report saved: %s (%d bytes)", path, report.Size) + return path, nil +} + +// Release removes a workspace from memory. +func (w *WorkspaceService) Release(issueID string) { + w.mu.Lock() + delete(w.workspaces, issueID) + w.mu.Unlock() +} + +// ActiveWorkspaces returns the count of active workspaces. +func (w *WorkspaceService) ActiveWorkspaces() int { + w.mu.RLock() + defer w.mu.RUnlock() + return len(w.workspaces) +} + +// sanitizeForPath converts owner/repo to a safe directory name. +func sanitizeForPath(s string) string { + result := make([]byte, 0, len(s)) + for _, c := range s { + if c == '/' || c == '\\' || c == ':' { + result = append(result, '-') + } else { + result = append(result, byte(c)) + } + } + return string(result) +} diff --git a/docs/examples/build-cpp.yaml b/docs/examples/build-cpp.yaml new file mode 100644 index 00000000..3cee8562 --- /dev/null +++ b/docs/examples/build-cpp.yaml @@ -0,0 +1,83 @@ +# Example: C++ Build Configuration +# CMake + Conan 2 project using host-uk/build system + +version: 1 + +project: + name: my-cpp-project + type: cpp + description: "A C++ application" + +cpp: + standard: 17 + build_type: Release + static: false + + # Conan package manager + conan: + version: "2.21.0" + requires: + - zlib/1.3.1 + - boost/1.85.0 + - openssl/3.2.0 + tool_requires: + - cmake/3.31.9 + options: + boost/*:without_test: true + registry: + url: http://forge.snider.dev:4000/api/packages/host-uk/conan + remote: conan_build + + # CMake settings + cmake: + minimum_version: "3.16" + variables: + USE_CCACHE: "ON" + presets: + - conan-release + - conan-debug + + # Optional project-specific build options + options: + testnet: false + +# Cross-compilation targets +targets: + - os: linux + arch: x86_64 + profile: gcc-linux-x86_64 + - os: linux + arch: arm64 + profile: gcc-linux-armv8 + - os: darwin + arch: arm64 + profile: apple-clang-armv8 + - os: darwin + arch: x86_64 + profile: apple-clang-x86_64 + - os: windows + arch: x86_64 + profile: msvc-194-x86_64 + +# Packaging +package: + generators: + - TGZ + - ZIP + vendor: host-uk + contact: developers@lethean.io + website: https://lt.hn + +# Docker output +docker: + dockerfile: .core/build/docker/Dockerfile + platforms: + - linux/amd64 + - linux/arm64 + tags: + - latest + - "{{.Version}}" + build_args: + BUILD_THREADS: auto + BUILD_STATIC: "0" + BUILD_TYPE: Release diff --git a/go.mod b/go.mod index ea9b957e..7324523e 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( require ( aead.dev/minisign v0.3.0 // indirect cloud.google.com/go v0.123.0 // indirect + codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/42wim/httpsig v1.2.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect diff --git a/go.sum b/go.sum index 58a940c4..5846a463 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI= +codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= diff --git a/internal/cmd/forge/cmd_config.go b/internal/cmd/forge/cmd_config.go new file mode 100644 index 00000000..7dd55542 --- /dev/null +++ b/internal/cmd/forge/cmd_config.go @@ -0,0 +1,106 @@ +package forge + +import ( + "fmt" + + "github.com/host-uk/core/pkg/cli" + fg "github.com/host-uk/core/pkg/forge" +) + +// Config command flags. +var ( + configURL string + configToken string + configTest bool +) + +// addConfigCommand adds the 'config' subcommand for Forgejo connection setup. +func addConfigCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "config", + Short: "Configure Forgejo connection", + Long: "Set the Forgejo instance URL and API token, or test the current connection.", + RunE: func(cmd *cli.Command, args []string) error { + return runConfig() + }, + } + + cmd.Flags().StringVar(&configURL, "url", "", "Forgejo instance URL") + cmd.Flags().StringVar(&configToken, "token", "", "Forgejo API token") + cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection") + + parent.AddCommand(cmd) +} + +func runConfig() error { + // If setting values, save them first + if configURL != "" || configToken != "" { + if err := fg.SaveConfig(configURL, configToken); err != nil { + return err + } + + if configURL != "" { + cli.Success(fmt.Sprintf("Forgejo URL set to %s", configURL)) + } + if configToken != "" { + cli.Success("Forgejo token saved") + } + } + + // If testing, verify the connection + if configTest { + return runConfigTest() + } + + // If no flags, show current config + if configURL == "" && configToken == "" && !configTest { + return showConfig() + } + + return nil +} + +func showConfig() error { + url, token, err := fg.ResolveConfig("", "") + if err != nil { + return err + } + + cli.Blank() + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url)) + + if token != "" { + masked := token + if len(token) >= 8 { + masked = token[:4] + "..." + token[len(token)-4:] + } + cli.Print(" %s %s\n", dimStyle.Render("Token:"), valueStyle.Render(masked)) + } else { + cli.Print(" %s %s\n", dimStyle.Render("Token:"), warningStyle.Render("not set")) + } + + cli.Blank() + + return nil +} + +func runConfigTest() error { + client, err := fg.NewFromConfig(configURL, configToken) + if err != nil { + return err + } + + user, _, err := client.API().GetMyUserInfo() + if err != nil { + cli.Error("Connection failed") + return cli.WrapVerb(err, "connect to", "Forgejo") + } + + cli.Blank() + cli.Success(fmt.Sprintf("Connected to %s", client.URL())) + cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName)) + cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email)) + cli.Blank() + + return nil +} diff --git a/internal/cmd/forge/cmd_forge.go b/internal/cmd/forge/cmd_forge.go new file mode 100644 index 00000000..62aa33ea --- /dev/null +++ b/internal/cmd/forge/cmd_forge.go @@ -0,0 +1,53 @@ +// Package forge provides CLI commands for managing a Forgejo instance. +// +// Commands: +// - config: Configure Forgejo connection (URL, token) +// - status: Show instance status and version +// - repos: List repositories +// - issues: List and create issues +// - prs: List pull requests +// - migrate: Migrate repos from external services +// - sync: Sync GitHub repos to Forgejo upstream branches +// - orgs: List organisations +// - labels: List and create labels +package forge + +import ( + "github.com/host-uk/core/pkg/cli" +) + +func init() { + cli.RegisterCommands(AddForgeCommands) +} + +// Style aliases from shared package. +var ( + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + warningStyle = cli.WarningStyle + dimStyle = cli.DimStyle + valueStyle = cli.ValueStyle + repoStyle = cli.RepoStyle + numberStyle = cli.NumberStyle + infoStyle = cli.InfoStyle +) + +// AddForgeCommands registers the 'forge' command and all subcommands. +func AddForgeCommands(root *cli.Command) { + forgeCmd := &cli.Command{ + Use: "forge", + Short: "Forgejo instance management", + Long: "Manage repositories, issues, pull requests, and organisations on your Forgejo instance.", + } + root.AddCommand(forgeCmd) + + addConfigCommand(forgeCmd) + addStatusCommand(forgeCmd) + addReposCommand(forgeCmd) + addIssuesCommand(forgeCmd) + addPRsCommand(forgeCmd) + addMigrateCommand(forgeCmd) + addSyncCommand(forgeCmd) + addOrgsCommand(forgeCmd) + addLabelsCommand(forgeCmd) +} diff --git a/internal/cmd/forge/cmd_issues.go b/internal/cmd/forge/cmd_issues.go new file mode 100644 index 00000000..b66ef7ce --- /dev/null +++ b/internal/cmd/forge/cmd_issues.go @@ -0,0 +1,200 @@ +package forge + +import ( + "fmt" + "strings" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/cli" + fg "github.com/host-uk/core/pkg/forge" +) + +// Issues command flags. +var ( + issuesState string + issuesTitle string + issuesBody string +) + +// addIssuesCommand adds the 'issues' subcommand for listing and creating issues. +func addIssuesCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "issues [owner/repo]", + Short: "List and manage issues", + Long: "List issues for a repository, or list all open issues across all your repos.", + Args: cli.MaximumNArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + if len(args) == 0 { + return runListAllIssues() + } + + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + + // If title is set, create an issue instead + if issuesTitle != "" { + return runCreateIssue(owner, repo) + } + + return runListIssues(owner, repo) + }, + } + + cmd.Flags().StringVar(&issuesState, "state", "open", "Filter by state (open, closed, all)") + cmd.Flags().StringVar(&issuesTitle, "title", "", "Create issue with this title") + cmd.Flags().StringVar(&issuesBody, "body", "", "Issue body (used with --title)") + + parent.AddCommand(cmd) +} + +func runListAllIssues() error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Collect all repos: user repos + all org repos, deduplicated + seen := make(map[string]bool) + var allRepos []*forgejo.Repository + + userRepos, err := client.ListUserRepos() + if err == nil { + for _, r := range userRepos { + if !seen[r.FullName] { + seen[r.FullName] = true + allRepos = append(allRepos, r) + } + } + } + + orgs, err := client.ListMyOrgs() + if err != nil { + return err + } + + for _, org := range orgs { + repos, err := client.ListOrgRepos(org.UserName) + if err != nil { + continue + } + for _, r := range repos { + if !seen[r.FullName] { + seen[r.FullName] = true + allRepos = append(allRepos, r) + } + } + } + + total := 0 + cli.Blank() + + for _, repo := range allRepos { + if repo.OpenIssues == 0 { + continue + } + + owner, name := repo.Owner.UserName, repo.Name + issues, err := client.ListIssues(owner, name, fg.ListIssuesOpts{ + State: issuesState, + }) + if err != nil || len(issues) == 0 { + continue + } + + cli.Print(" %s %s\n", repoStyle.Render(repo.FullName), dimStyle.Render(fmt.Sprintf("(%d)", len(issues)))) + for _, issue := range issues { + printForgeIssue(issue) + } + cli.Blank() + total += len(issues) + } + + if total == 0 { + cli.Text(fmt.Sprintf("No %s issues found.", issuesState)) + } else { + cli.Print(" %s\n", dimStyle.Render(fmt.Sprintf("%d %s issues total", total, issuesState))) + } + cli.Blank() + + return nil +} + +func runListIssues(owner, repo string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + issues, err := client.ListIssues(owner, repo, fg.ListIssuesOpts{ + State: issuesState, + }) + if err != nil { + return err + } + + if len(issues) == 0 { + cli.Text(fmt.Sprintf("No %s issues in %s/%s.", issuesState, owner, repo)) + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d %s issues in %s/%s", len(issues), issuesState, owner, repo)) + + for _, issue := range issues { + printForgeIssue(issue) + } + + return nil +} + +func runCreateIssue(owner, repo string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + issue, err := client.CreateIssue(owner, repo, forgejo.CreateIssueOption{ + Title: issuesTitle, + Body: issuesBody, + }) + if err != nil { + return err + } + + cli.Blank() + cli.Success(fmt.Sprintf("Created issue #%d: %s", issue.Index, issue.Title)) + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(issue.HTMLURL)) + cli.Blank() + + return nil +} + +func printForgeIssue(issue *forgejo.Issue) { + num := numberStyle.Render(fmt.Sprintf("#%d", issue.Index)) + title := valueStyle.Render(cli.Truncate(issue.Title, 60)) + + line := fmt.Sprintf(" %s %s", num, title) + + // Add labels + if len(issue.Labels) > 0 { + var labels []string + for _, l := range issue.Labels { + labels = append(labels, l.Name) + } + line += " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]") + } + + // Add assignees + if len(issue.Assignees) > 0 { + var assignees []string + for _, a := range issue.Assignees { + assignees = append(assignees, "@"+a.UserName) + } + line += " " + infoStyle.Render(strings.Join(assignees, ", ")) + } + + cli.Text(line) +} diff --git a/internal/cmd/forge/cmd_labels.go b/internal/cmd/forge/cmd_labels.go new file mode 100644 index 00000000..ada96c5c --- /dev/null +++ b/internal/cmd/forge/cmd_labels.go @@ -0,0 +1,120 @@ +package forge + +import ( + "fmt" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/cli" + fg "github.com/host-uk/core/pkg/forge" +) + +// Labels command flags. +var ( + labelsCreate string + labelsColor string + labelsRepo string +) + +// addLabelsCommand adds the 'labels' subcommand for listing and creating labels. +func addLabelsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "labels ", + Short: "List and manage labels", + Long: `List labels from an organisation's repos, or create a new label. + +Labels are listed from the first repo in the organisation. Use --repo to target a specific repo. + +Examples: + core forge labels Private-Host-UK + core forge labels Private-Host-UK --create "feature" --color "00aabb" + core forge labels Private-Host-UK --repo Enchantrix`, + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + if labelsCreate != "" { + return runCreateLabel(args[0]) + } + return runListLabels(args[0]) + }, + } + + cmd.Flags().StringVar(&labelsCreate, "create", "", "Create a label with this name") + cmd.Flags().StringVar(&labelsColor, "color", "0075ca", "Label colour (hex, e.g. 00aabb)") + cmd.Flags().StringVar(&labelsRepo, "repo", "", "Target a specific repo (default: first org repo)") + + parent.AddCommand(cmd) +} + +func runListLabels(org string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + var labels []*forgejo.Label + if labelsRepo != "" { + labels, err = client.ListRepoLabels(org, labelsRepo) + } else { + labels, err = client.ListOrgLabels(org) + } + if err != nil { + return err + } + + if len(labels) == 0 { + cli.Text("No labels found.") + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d labels", len(labels))) + + table := cli.NewTable("Name", "Color", "Description") + + for _, l := range labels { + table.AddRow( + warningStyle.Render(l.Name), + dimStyle.Render("#"+l.Color), + cli.Truncate(l.Description, 50), + ) + } + + table.Render() + + return nil +} + +func runCreateLabel(org string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Determine target repo + repo := labelsRepo + if repo == "" { + repos, err := client.ListOrgRepos(org) + if err != nil { + return err + } + if len(repos) == 0 { + return cli.Err("no repos in org %s to create label on", org) + } + repo = repos[0].Name + org = repos[0].Owner.UserName + } + + label, err := client.CreateRepoLabel(org, repo, forgejo.CreateLabelOption{ + Name: labelsCreate, + Color: "#" + labelsColor, + }) + if err != nil { + return err + } + + cli.Blank() + cli.Success(fmt.Sprintf("Created label %q on %s/%s", label.Name, org, repo)) + cli.Blank() + + return nil +} diff --git a/internal/cmd/forge/cmd_migrate.go b/internal/cmd/forge/cmd_migrate.go new file mode 100644 index 00000000..a37e1a6b --- /dev/null +++ b/internal/cmd/forge/cmd_migrate.go @@ -0,0 +1,121 @@ +package forge + +import ( + "fmt" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/cli" + fg "github.com/host-uk/core/pkg/forge" +) + +// Migrate command flags. +var ( + migrateOrg string + migrateService string + migrateToken string + migrateMirror bool +) + +// addMigrateCommand adds the 'migrate' subcommand for importing repos from external services. +func addMigrateCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "migrate ", + Short: "Migrate a repo from an external service", + Long: `Migrate a repository from GitHub, GitLab, Gitea, or other services into Forgejo. + +Unlike a simple mirror, migration imports issues, labels, pull requests, releases, and more. + +Examples: + core forge migrate https://github.com/owner/repo --org MyOrg --service github + core forge migrate https://gitea.example.com/owner/repo --service gitea --token TOKEN`, + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + return runMigrate(args[0]) + }, + } + + cmd.Flags().StringVar(&migrateOrg, "org", "", "Forgejo organisation to migrate into (default: your user account)") + cmd.Flags().StringVar(&migrateService, "service", "github", "Source service type (github, gitlab, gitea, forgejo, gogs, git)") + cmd.Flags().StringVar(&migrateToken, "token", "", "Auth token for the source service") + cmd.Flags().BoolVar(&migrateMirror, "mirror", false, "Set up as a mirror (periodic sync)") + + parent.AddCommand(cmd) +} + +func runMigrate(cloneURL string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Determine target owner on Forgejo + targetOwner := migrateOrg + if targetOwner == "" { + user, _, err := client.API().GetMyUserInfo() + if err != nil { + return cli.WrapVerb(err, "get", "current user") + } + targetOwner = user.UserName + } + + // Extract repo name from clone URL + repoName := extractRepoName(cloneURL) + if repoName == "" { + return cli.Err("could not extract repo name from URL: %s", cloneURL) + } + + // Map service flag to SDK type + service := mapServiceType(migrateService) + + cli.Print(" Migrating %s -> %s/%s on Forgejo...\n", cloneURL, targetOwner, repoName) + + opts := forgejo.MigrateRepoOption{ + RepoName: repoName, + RepoOwner: targetOwner, + CloneAddr: cloneURL, + Service: service, + Mirror: migrateMirror, + AuthToken: migrateToken, + Issues: true, + Labels: true, + PullRequests: true, + Releases: true, + Milestones: true, + Wiki: true, + Description: "Migrated from " + cloneURL, + } + + repo, err := client.MigrateRepo(opts) + if err != nil { + return err + } + + cli.Blank() + cli.Success(fmt.Sprintf("Migration complete: %s", repo.FullName)) + cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(repo.HTMLURL)) + cli.Print(" %s %s\n", dimStyle.Render("Clone:"), valueStyle.Render(repo.CloneURL)) + if migrateMirror { + cli.Print(" %s %s\n", dimStyle.Render("Type:"), dimStyle.Render("mirror (periodic sync)")) + } + cli.Blank() + + return nil +} + +func mapServiceType(s string) forgejo.GitServiceType { + switch s { + case "github": + return forgejo.GitServiceGithub + case "gitlab": + return forgejo.GitServiceGitlab + case "gitea": + return forgejo.GitServiceGitea + case "forgejo": + return forgejo.GitServiceForgejo + case "gogs": + return forgejo.GitServiceGogs + default: + return forgejo.GitServicePlain + } +} diff --git a/internal/cmd/forge/cmd_orgs.go b/internal/cmd/forge/cmd_orgs.go new file mode 100644 index 00000000..d33bc74e --- /dev/null +++ b/internal/cmd/forge/cmd_orgs.go @@ -0,0 +1,66 @@ +package forge + +import ( + "fmt" + + "github.com/host-uk/core/pkg/cli" + fg "github.com/host-uk/core/pkg/forge" +) + +// addOrgsCommand adds the 'orgs' subcommand for listing organisations. +func addOrgsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "orgs", + Short: "List organisations", + Long: "List all organisations the authenticated user belongs to.", + RunE: func(cmd *cli.Command, args []string) error { + return runOrgs() + }, + } + + parent.AddCommand(cmd) +} + +func runOrgs() error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + orgs, err := client.ListMyOrgs() + if err != nil { + return err + } + + if len(orgs) == 0 { + cli.Text("No organisations found.") + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d organisations", len(orgs))) + + table := cli.NewTable("Name", "Visibility", "Description") + + for _, org := range orgs { + visibility := successStyle.Render(org.Visibility) + if org.Visibility == "private" { + visibility = warningStyle.Render(org.Visibility) + } + + desc := cli.Truncate(org.Description, 50) + if desc == "" { + desc = dimStyle.Render("-") + } + + table.AddRow( + repoStyle.Render(org.UserName), + visibility, + desc, + ) + } + + table.Render() + + return nil +} diff --git a/internal/cmd/forge/cmd_prs.go b/internal/cmd/forge/cmd_prs.go new file mode 100644 index 00000000..3be19519 --- /dev/null +++ b/internal/cmd/forge/cmd_prs.go @@ -0,0 +1,98 @@ +package forge + +import ( + "fmt" + "strings" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/cli" + fg "github.com/host-uk/core/pkg/forge" +) + +// PRs command flags. +var ( + prsState string +) + +// addPRsCommand adds the 'prs' subcommand for listing pull requests. +func addPRsCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "prs ", + Short: "List pull requests", + Long: "List pull requests for a repository.", + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + owner, repo, err := splitOwnerRepo(args[0]) + if err != nil { + return err + } + return runListPRs(owner, repo) + }, + } + + cmd.Flags().StringVar(&prsState, "state", "open", "Filter by state (open, closed, all)") + + parent.AddCommand(cmd) +} + +func runListPRs(owner, repo string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + prs, err := client.ListPullRequests(owner, repo, prsState) + if err != nil { + return err + } + + if len(prs) == 0 { + cli.Text(fmt.Sprintf("No %s pull requests in %s/%s.", prsState, owner, repo)) + return nil + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d %s pull requests in %s/%s", len(prs), prsState, owner, repo)) + + for _, pr := range prs { + printForgePR(pr) + } + + return nil +} + +func printForgePR(pr *forgejo.PullRequest) { + num := numberStyle.Render(fmt.Sprintf("#%d", pr.Index)) + title := valueStyle.Render(cli.Truncate(pr.Title, 50)) + + var author string + if pr.Poster != nil { + author = infoStyle.Render("@" + pr.Poster.UserName) + } + + // Branch info + branch := dimStyle.Render(pr.Head.Ref + " -> " + pr.Base.Ref) + + // Merge status + var status string + if pr.HasMerged { + status = successStyle.Render("merged") + } else if pr.State == forgejo.StateClosed { + status = errorStyle.Render("closed") + } else { + status = warningStyle.Render("open") + } + + // Labels + var labelStr string + if len(pr.Labels) > 0 { + var labels []string + for _, l := range pr.Labels { + labels = append(labels, l.Name) + } + labelStr = " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]") + } + + cli.Print(" %s %s %s %s %s%s\n", num, title, author, status, branch, labelStr) +} diff --git a/internal/cmd/forge/cmd_repos.go b/internal/cmd/forge/cmd_repos.go new file mode 100644 index 00000000..5b0ffc7c --- /dev/null +++ b/internal/cmd/forge/cmd_repos.go @@ -0,0 +1,94 @@ +package forge + +import ( + "fmt" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/cli" + fg "github.com/host-uk/core/pkg/forge" +) + +// Repos command flags. +var ( + reposOrg string + reposMirrors bool +) + +// addReposCommand adds the 'repos' subcommand for listing repositories. +func addReposCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "repos", + Short: "List repositories", + Long: "List repositories from your Forgejo instance, optionally filtered by organisation or mirror status.", + RunE: func(cmd *cli.Command, args []string) error { + return runRepos() + }, + } + + cmd.Flags().StringVar(&reposOrg, "org", "", "Filter by organisation") + cmd.Flags().BoolVar(&reposMirrors, "mirrors", false, "Show only mirror repositories") + + parent.AddCommand(cmd) +} + +func runRepos() error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + var repos []*forgejo.Repository + if reposOrg != "" { + repos, err = client.ListOrgRepos(reposOrg) + } else { + repos, err = client.ListUserRepos() + } + if err != nil { + return err + } + + // Filter mirrors if requested + if reposMirrors { + var filtered []*forgejo.Repository + for _, r := range repos { + if r.Mirror { + filtered = append(filtered, r) + } + } + repos = filtered + } + + if len(repos) == 0 { + cli.Text("No repositories found.") + return nil + } + + // Build table + table := cli.NewTable("Name", "Type", "Visibility", "Stars") + + for _, r := range repos { + repoType := "source" + if r.Mirror { + repoType = "mirror" + } + + visibility := successStyle.Render("public") + if r.Private { + visibility = warningStyle.Render("private") + } + + table.AddRow( + repoStyle.Render(r.FullName), + dimStyle.Render(repoType), + visibility, + fmt.Sprintf("%d", r.Stars), + ) + } + + cli.Blank() + cli.Print(" %s\n\n", fmt.Sprintf("%d repositories", len(repos))) + table.Render() + + return nil +} diff --git a/internal/cmd/forge/cmd_status.go b/internal/cmd/forge/cmd_status.go new file mode 100644 index 00000000..83619508 --- /dev/null +++ b/internal/cmd/forge/cmd_status.go @@ -0,0 +1,63 @@ +package forge + +import ( + "fmt" + + "github.com/host-uk/core/pkg/cli" + fg "github.com/host-uk/core/pkg/forge" +) + +// addStatusCommand adds the 'status' subcommand for instance info. +func addStatusCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "status", + Short: "Show Forgejo instance status", + Long: "Display Forgejo instance version, authenticated user, and summary counts.", + RunE: func(cmd *cli.Command, args []string) error { + return runStatus() + }, + } + + parent.AddCommand(cmd) +} + +func runStatus() error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Get server version + ver, _, err := client.API().ServerVersion() + if err != nil { + return cli.WrapVerb(err, "get", "server version") + } + + // Get authenticated user + user, _, err := client.API().GetMyUserInfo() + if err != nil { + return cli.WrapVerb(err, "get", "user info") + } + + // Get org count + orgs, err := client.ListMyOrgs() + if err != nil { + return cli.WrapVerb(err, "list", "organisations") + } + + // Get repo count + repos, err := client.ListUserRepos() + if err != nil { + return cli.WrapVerb(err, "list", "repositories") + } + + cli.Blank() + cli.Print(" %s %s\n", dimStyle.Render("Instance:"), valueStyle.Render(client.URL())) + cli.Print(" %s %s\n", dimStyle.Render("Version:"), valueStyle.Render(ver)) + cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName)) + cli.Print(" %s %s\n", dimStyle.Render("Orgs:"), numberStyle.Render(fmt.Sprintf("%d", len(orgs)))) + cli.Print(" %s %s\n", dimStyle.Render("Repos:"), numberStyle.Render(fmt.Sprintf("%d", len(repos)))) + cli.Blank() + + return nil +} diff --git a/internal/cmd/forge/cmd_sync.go b/internal/cmd/forge/cmd_sync.go new file mode 100644 index 00000000..93fc12a8 --- /dev/null +++ b/internal/cmd/forge/cmd_sync.go @@ -0,0 +1,334 @@ +package forge + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/cli" + fg "github.com/host-uk/core/pkg/forge" +) + +// Sync command flags. +var ( + syncOrg string + syncBasePath string + syncSetup bool +) + +// addSyncCommand adds the 'sync' subcommand for syncing GitHub repos to Forgejo upstream branches. +func addSyncCommand(parent *cli.Command) { + cmd := &cli.Command{ + Use: "sync [owner/repo...]", + Short: "Sync GitHub repos to Forgejo upstream branches", + Long: `Push local GitHub content to Forgejo as 'upstream' branches. + +Each repo gets: + - An 'upstream' branch tracking the GitHub default branch + - A 'main' branch (default) for private tasks, processes, and AI workflows + +Use --setup on first run to create the Forgejo repos and configure remotes. +Without --setup, updates existing upstream branches from local clones.`, + Args: cli.MinimumNArgs(0), + RunE: func(cmd *cli.Command, args []string) error { + return runSync(args) + }, + } + + cmd.Flags().StringVar(&syncOrg, "org", "Host-UK", "Forgejo organisation") + cmd.Flags().StringVar(&syncBasePath, "base-path", "~/Code/host-uk", "Base path for local repo clones") + cmd.Flags().BoolVar(&syncSetup, "setup", false, "Initial setup: create repos, configure remotes, push upstream branches") + + parent.AddCommand(cmd) +} + +// syncRepoEntry holds info for a repo to sync. +type syncRepoEntry struct { + name string + localPath string + defaultBranch string +} + +func runSync(args []string) error { + client, err := fg.NewFromConfig("", "") + if err != nil { + return err + } + + // Expand base path + basePath := syncBasePath + if strings.HasPrefix(basePath, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to resolve home directory: %w", err) + } + basePath = filepath.Join(home, basePath[2:]) + } + + // Build repo list: either from args or from the Forgejo org + repos, err := buildSyncRepoList(client, args, basePath) + if err != nil { + return err + } + + if len(repos) == 0 { + cli.Text("No repos to sync.") + return nil + } + + forgeURL := client.URL() + + if syncSetup { + return runSyncSetup(client, repos, forgeURL) + } + + return runSyncUpdate(repos, forgeURL) +} + +func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syncRepoEntry, error) { + var repos []syncRepoEntry + + if len(args) > 0 { + for _, arg := range args { + name := arg + if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 { + name = parts[1] + } + localPath := filepath.Join(basePath, name) + branch := syncDetectDefaultBranch(localPath) + repos = append(repos, syncRepoEntry{ + name: name, + localPath: localPath, + defaultBranch: branch, + }) + } + } else { + orgRepos, err := client.ListOrgRepos(syncOrg) + if err != nil { + return nil, err + } + for _, r := range orgRepos { + localPath := filepath.Join(basePath, r.Name) + branch := syncDetectDefaultBranch(localPath) + repos = append(repos, syncRepoEntry{ + name: r.Name, + localPath: localPath, + defaultBranch: branch, + }) + } + } + + return repos, nil +} + +func runSyncSetup(client *fg.Client, repos []syncRepoEntry, forgeURL string) error { + cli.Blank() + cli.Print(" Setting up %d repos in %s with upstream branches...\n\n", len(repos), syncOrg) + + var succeeded, failed int + + for _, repo := range repos { + cli.Print(" %s %s\n", dimStyle.Render(">>"), repoStyle.Render(repo.name)) + + // Step 1: Delete existing repo if it exists + cli.Print(" Deleting existing repo... ") + err := client.DeleteRepo(syncOrg, repo.name) + if err != nil { + cli.Print("%s (may not exist)\n", dimStyle.Render("skipped")) + } else { + cli.Print("%s\n", successStyle.Render("done")) + } + + // Step 2: Create empty repo + cli.Print(" Creating repo... ") + _, err = client.CreateOrgRepo(syncOrg, forgejo.CreateRepoOption{ + Name: repo.name, + AutoInit: false, + DefaultBranch: "main", + }) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + cli.Print("%s\n", successStyle.Render("done")) + + // Step 3: Add forge remote to local clone + cli.Print(" Configuring remote... ") + remoteURL := fmt.Sprintf("%s/%s/%s.git", forgeURL, syncOrg, repo.name) + err = syncConfigureForgeRemote(repo.localPath, remoteURL) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + cli.Print("%s\n", successStyle.Render("done")) + + // Step 4: Push default branch as 'upstream' to Forgejo + cli.Print(" Pushing %s -> upstream... ", repo.defaultBranch) + err = syncPushUpstream(repo.localPath, repo.defaultBranch) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + cli.Print("%s\n", successStyle.Render("done")) + + // Step 5: Create 'main' branch from 'upstream' on Forgejo + cli.Print(" Creating main branch... ") + err = syncCreateMainFromUpstream(client, syncOrg, repo.name) + if err != nil { + if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "409") { + cli.Print("%s\n", dimStyle.Render("exists")) + } else { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + } else { + cli.Print("%s\n", successStyle.Render("done")) + } + + // Step 6: Set default branch to 'main' + cli.Print(" Setting default branch... ") + _, _, err = client.API().EditRepo(syncOrg, repo.name, forgejo.EditRepoOption{ + DefaultBranch: strPtr("main"), + }) + if err != nil { + cli.Print("%s\n", warningStyle.Render(err.Error())) + } else { + cli.Print("%s\n", successStyle.Render("main")) + } + + succeeded++ + cli.Blank() + } + + cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d repos set up", succeeded))) + if failed > 0 { + cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + cli.Blank() + + return nil +} + +func runSyncUpdate(repos []syncRepoEntry, forgeURL string) error { + cli.Blank() + cli.Print(" Syncing %d repos to %s upstream branches...\n\n", len(repos), syncOrg) + + var succeeded, failed int + + for _, repo := range repos { + cli.Print(" %s -> upstream ", repoStyle.Render(repo.name)) + + // Ensure remote exists + remoteURL := fmt.Sprintf("%s/%s/%s.git", forgeURL, syncOrg, repo.name) + _ = syncConfigureForgeRemote(repo.localPath, remoteURL) + + // Fetch latest from GitHub (origin) + err := syncGitFetch(repo.localPath, "origin") + if err != nil { + cli.Print("%s\n", errorStyle.Render("fetch failed: "+err.Error())) + failed++ + continue + } + + // Push to Forgejo upstream branch + err = syncPushUpstream(repo.localPath, repo.defaultBranch) + if err != nil { + cli.Print("%s\n", errorStyle.Render(err.Error())) + failed++ + continue + } + + cli.Print("%s\n", successStyle.Render("ok")) + succeeded++ + } + + cli.Blank() + cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d synced", succeeded))) + if failed > 0 { + cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + cli.Blank() + + return nil +} + +func syncDetectDefaultBranch(path string) string { + out, err := exec.Command("git", "-C", path, "symbolic-ref", "refs/remotes/origin/HEAD").Output() + if err == nil { + ref := strings.TrimSpace(string(out)) + if parts := strings.Split(ref, "/"); len(parts) > 0 { + return parts[len(parts)-1] + } + } + + out, err = exec.Command("git", "-C", path, "branch", "--show-current").Output() + if err == nil { + branch := strings.TrimSpace(string(out)) + if branch != "" { + return branch + } + } + + return "main" +} + +func syncConfigureForgeRemote(localPath, remoteURL string) error { + out, err := exec.Command("git", "-C", localPath, "remote", "get-url", "forge").Output() + if err == nil { + existing := strings.TrimSpace(string(out)) + if existing != remoteURL { + cmd := exec.Command("git", "-C", localPath, "remote", "set-url", "forge", remoteURL) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to update remote: %w", err) + } + } + return nil + } + + cmd := exec.Command("git", "-C", localPath, "remote", "add", "forge", remoteURL) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to add remote: %w", err) + } + + return nil +} + +func syncPushUpstream(localPath, defaultBranch string) error { + refspec := fmt.Sprintf("refs/remotes/origin/%s:refs/heads/upstream", defaultBranch) + cmd := exec.Command("git", "-C", localPath, "push", "--force", "forge", refspec) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + + return nil +} + +func syncGitFetch(localPath, remote string) error { + cmd := exec.Command("git", "-C", localPath, "fetch", remote) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + return nil +} + +func syncCreateMainFromUpstream(client *fg.Client, org, repo string) error { + _, _, err := client.API().CreateBranch(org, repo, forgejo.CreateBranchOption{ + BranchName: "main", + OldBranchName: "upstream", + }) + if err != nil { + return fmt.Errorf("create branch: %w", err) + } + + return nil +} diff --git a/internal/cmd/forge/helpers.go b/internal/cmd/forge/helpers.go new file mode 100644 index 00000000..6d5cf9c3 --- /dev/null +++ b/internal/cmd/forge/helpers.go @@ -0,0 +1,33 @@ +package forge + +import ( + "path" + "strings" + + "github.com/host-uk/core/pkg/cli" +) + +// splitOwnerRepo splits "owner/repo" into its parts. +func splitOwnerRepo(s string) (string, string, error) { + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", cli.Err("expected format: owner/repo (got %q)", s) + } + return parts[0], parts[1], nil +} + +// strPtr returns a pointer to the given string. +func strPtr(s string) *string { return &s } + +// extractRepoName extracts a repository name from a clone URL. +// e.g. "https://github.com/owner/repo.git" -> "repo" +func extractRepoName(cloneURL string) string { + // Get the last path segment + name := path.Base(cloneURL) + // Strip .git suffix + name = strings.TrimSuffix(name, ".git") + if name == "" || name == "." || name == "/" { + return "" + } + return name +} diff --git a/internal/cmd/php/detect_test.go b/internal/cmd/php/detect_test.go index 6460a83e..9b72f843 100644 --- a/internal/cmd/php/detect_test.go +++ b/internal/cmd/php/detect_test.go @@ -178,6 +178,9 @@ return [ }) t.Run("project with octane but unreadable config file", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read any file") + } dir := t.TempDir() // Create composer.json with laravel/octane diff --git a/internal/cmd/pkgcmd/cmd_pkg.go b/internal/cmd/pkgcmd/cmd_pkg.go index 5f6da91e..284f1635 100644 --- a/internal/cmd/pkgcmd/cmd_pkg.go +++ b/internal/cmd/pkgcmd/cmd_pkg.go @@ -35,4 +35,5 @@ func AddPkgCommands(root *cobra.Command) { addPkgListCommand(pkgCmd) addPkgUpdateCommand(pkgCmd) addPkgOutdatedCommand(pkgCmd) + addPkgRemoveCommand(pkgCmd) } diff --git a/internal/cmd/pkgcmd/cmd_remove.go b/internal/cmd/pkgcmd/cmd_remove.go new file mode 100644 index 00000000..00dd8135 --- /dev/null +++ b/internal/cmd/pkgcmd/cmd_remove.go @@ -0,0 +1,144 @@ +// cmd_remove.go implements the 'pkg remove' command with safety checks. +// +// Before removing a package, it verifies: +// 1. No uncommitted changes exist +// 2. No unpushed branches exist +// This prevents accidental data loss from agents or tools that might +// attempt to remove packages without cleaning up first. +package pkgcmd + +import ( + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/i18n" + coreio "github.com/host-uk/core/pkg/io" + "github.com/host-uk/core/pkg/repos" + "github.com/spf13/cobra" +) + +var removeForce bool + +func addPkgRemoveCommand(parent *cobra.Command) { + removeCmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a package (with safety checks)", + Long: `Removes a package directory after verifying it has no uncommitted +changes or unpushed branches. Use --force to skip safety checks.`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.New(i18n.T("cmd.pkg.error.repo_required")) + } + return runPkgRemove(args[0], removeForce) + }, + } + + removeCmd.Flags().BoolVar(&removeForce, "force", false, "Skip safety checks (dangerous)") + + parent.AddCommand(removeCmd) +} + +func runPkgRemove(name string, force bool) error { + // Find package path via registry + regPath, err := repos.FindRegistry(coreio.Local) + if err != nil { + return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml")) + } + + reg, err := repos.LoadRegistry(coreio.Local, regPath) + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err) + } + + basePath := reg.BasePath + if basePath == "" { + basePath = "." + } + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(regPath), basePath) + } + + repoPath := filepath.Join(basePath, name) + + if !coreio.Local.IsDir(filepath.Join(repoPath, ".git")) { + return fmt.Errorf("package %s is not installed at %s", name, repoPath) + } + + if !force { + blocked, reasons := checkRepoSafety(repoPath) + if blocked { + fmt.Printf("%s Cannot remove %s:\n", errorStyle.Render("Blocked:"), repoNameStyle.Render(name)) + for _, r := range reasons { + fmt.Printf(" %s %s\n", errorStyle.Render("·"), r) + } + fmt.Printf("\nResolve the issues above or use --force to override.\n") + return errors.New("package has unresolved changes") + } + } + + // Remove the directory + fmt.Printf("%s %s... ", dimStyle.Render("Removing"), repoNameStyle.Render(name)) + + if err := coreio.Local.DeleteAll(repoPath); err != nil { + fmt.Printf("%s\n", errorStyle.Render("x "+err.Error())) + return err + } + + fmt.Printf("%s\n", successStyle.Render("ok")) + return nil +} + +// checkRepoSafety checks a git repo for uncommitted changes and unpushed branches. +func checkRepoSafety(repoPath string) (blocked bool, reasons []string) { + // Check for uncommitted changes (staged, unstaged, untracked) + cmd := exec.Command("git", "-C", repoPath, "status", "--porcelain") + output, err := cmd.Output() + if err == nil && strings.TrimSpace(string(output)) != "" { + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + blocked = true + reasons = append(reasons, fmt.Sprintf("has %d uncommitted changes", len(lines))) + } + + // Check for unpushed commits on current branch + cmd = exec.Command("git", "-C", repoPath, "log", "--oneline", "@{u}..HEAD") + output, err = cmd.Output() + if err == nil && strings.TrimSpace(string(output)) != "" { + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + blocked = true + reasons = append(reasons, fmt.Sprintf("has %d unpushed commits on current branch", len(lines))) + } + + // Check all local branches for unpushed work + cmd = exec.Command("git", "-C", repoPath, "branch", "--no-merged", "origin/HEAD") + output, _ = cmd.Output() + if trimmed := strings.TrimSpace(string(output)); trimmed != "" { + branches := strings.Split(trimmed, "\n") + var unmerged []string + for _, b := range branches { + b = strings.TrimSpace(b) + b = strings.TrimPrefix(b, "* ") + if b != "" { + unmerged = append(unmerged, b) + } + } + if len(unmerged) > 0 { + blocked = true + reasons = append(reasons, fmt.Sprintf("has %d unmerged branches: %s", + len(unmerged), strings.Join(unmerged, ", "))) + } + } + + // Check for stashed changes + cmd = exec.Command("git", "-C", repoPath, "stash", "list") + output, err = cmd.Output() + if err == nil && strings.TrimSpace(string(output)) != "" { + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + blocked = true + reasons = append(reasons, fmt.Sprintf("has %d stashed entries", len(lines))) + } + + return blocked, reasons +} diff --git a/internal/cmd/pkgcmd/cmd_remove_test.go b/internal/cmd/pkgcmd/cmd_remove_test.go new file mode 100644 index 00000000..442a08e5 --- /dev/null +++ b/internal/cmd/pkgcmd/cmd_remove_test.go @@ -0,0 +1,92 @@ +package pkgcmd + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestRepo(t *testing.T, dir, name string) string { + t.Helper() + repoPath := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(repoPath, 0755)) + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "commit", "--allow-empty", "-m", "initial"}, + } + for _, c := range cmds { + cmd := exec.Command(c[0], c[1:]...) + cmd.Dir = repoPath + out, err := cmd.CombinedOutput() + require.NoError(t, err, "cmd %v failed: %s", c, string(out)) + } + return repoPath +} + +func TestCheckRepoSafety_Clean(t *testing.T) { + tmp := t.TempDir() + repoPath := setupTestRepo(t, tmp, "clean-repo") + + blocked, reasons := checkRepoSafety(repoPath) + assert.False(t, blocked) + assert.Empty(t, reasons) +} + +func TestCheckRepoSafety_UncommittedChanges(t *testing.T) { + tmp := t.TempDir() + repoPath := setupTestRepo(t, tmp, "dirty-repo") + + require.NoError(t, os.WriteFile(filepath.Join(repoPath, "new.txt"), []byte("data"), 0644)) + + blocked, reasons := checkRepoSafety(repoPath) + assert.True(t, blocked) + assert.NotEmpty(t, reasons) + assert.Contains(t, reasons[0], "uncommitted changes") +} + +func TestCheckRepoSafety_Stash(t *testing.T) { + tmp := t.TempDir() + repoPath := setupTestRepo(t, tmp, "stash-repo") + + // Create a file, add, stash + require.NoError(t, os.WriteFile(filepath.Join(repoPath, "stash.txt"), []byte("data"), 0644)) + cmd := exec.Command("git", "add", ".") + cmd.Dir = repoPath + require.NoError(t, cmd.Run()) + + cmd = exec.Command("git", "stash") + cmd.Dir = repoPath + require.NoError(t, cmd.Run()) + + blocked, reasons := checkRepoSafety(repoPath) + assert.True(t, blocked) + found := false + for _, r := range reasons { + if assert.ObjectsAreEqual("stashed", "") || len(r) > 0 { + if contains(r, "stash") { + found = true + } + } + } + assert.True(t, found, "expected stash warning in reasons: %v", reasons) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/cmd/workspace/cmd_agent.go b/internal/cmd/workspace/cmd_agent.go new file mode 100644 index 00000000..84a64cf1 --- /dev/null +++ b/internal/cmd/workspace/cmd_agent.go @@ -0,0 +1,288 @@ +// cmd_agent.go manages persistent agent context within task workspaces. +// +// Each agent gets a directory at: +// .core/workspace/p{epic}/i{issue}/agents/{provider}/{agent-name}/ +// +// This directory persists across invocations, allowing agents to build +// understanding over time — QA agents accumulate findings, reviewers +// track patterns, implementors record decisions. +// +// Layout: +// +// agents/ +// ├── claude-opus/implementor/ +// │ ├── memory.md # Persistent notes, decisions, context +// │ └── artifacts/ # Generated artifacts (reports, diffs, etc.) +// ├── claude-opus/qa/ +// │ ├── memory.md +// │ └── artifacts/ +// └── gemini/reviewer/ +// └── memory.md +package workspace + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/host-uk/core/pkg/cli" + coreio "github.com/host-uk/core/pkg/io" + "github.com/spf13/cobra" +) + +var ( + agentProvider string + agentName string +) + +func addAgentCommands(parent *cobra.Command) { + agentCmd := &cobra.Command{ + Use: "agent", + Short: "Manage persistent agent context within task workspaces", + } + + initCmd := &cobra.Command{ + Use: "init ", + Short: "Initialize an agent's context directory in the task workspace", + Long: `Creates agents/{provider}/{agent-name}/ with memory.md and artifacts/ +directory. The agent can read/write memory.md across invocations to +build understanding over time.`, + Args: cobra.ExactArgs(1), + RunE: runAgentInit, + } + initCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") + initCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") + _ = initCmd.MarkFlagRequired("epic") + _ = initCmd.MarkFlagRequired("issue") + + agentListCmd := &cobra.Command{ + Use: "list", + Short: "List agents in a task workspace", + RunE: runAgentList, + } + agentListCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") + agentListCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") + _ = agentListCmd.MarkFlagRequired("epic") + _ = agentListCmd.MarkFlagRequired("issue") + + pathCmd := &cobra.Command{ + Use: "path ", + Short: "Print the agent's context directory path", + Args: cobra.ExactArgs(1), + RunE: runAgentPath, + } + pathCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") + pathCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") + _ = pathCmd.MarkFlagRequired("epic") + _ = pathCmd.MarkFlagRequired("issue") + + agentCmd.AddCommand(initCmd, agentListCmd, pathCmd) + parent.AddCommand(agentCmd) +} + +// agentContextPath returns the path for an agent's context directory. +func agentContextPath(wsPath, provider, name string) string { + return filepath.Join(wsPath, "agents", provider, name) +} + +// parseAgentID splits "provider/agent-name" into parts. +func parseAgentID(id string) (provider, name string, err error) { + parts := strings.SplitN(id, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("agent ID must be provider/agent-name (e.g. claude-opus/qa)") + } + return parts[0], parts[1], nil +} + +// AgentManifest tracks agent metadata for a task workspace. +type AgentManifest struct { + Provider string `json:"provider"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastSeen time.Time `json:"last_seen"` +} + +func runAgentInit(cmd *cobra.Command, args []string) error { + provider, name, err := parseAgentID(args[0]) + if err != nil { + return err + } + + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + wsPath := taskWorkspacePath(root, taskEpic, taskIssue) + if !coreio.Local.IsDir(wsPath) { + return cli.Err("task workspace does not exist: p%d/i%d — create it first with `core workspace task create`", taskEpic, taskIssue) + } + + agentDir := agentContextPath(wsPath, provider, name) + + if coreio.Local.IsDir(agentDir) { + // Update last_seen + updateAgentManifest(agentDir, provider, name) + cli.Print("Agent %s/%s already initialized at p%d/i%d\n", + cli.ValueStyle.Render(provider), cli.ValueStyle.Render(name), taskEpic, taskIssue) + cli.Print("Path: %s\n", cli.DimStyle.Render(agentDir)) + return nil + } + + // Create directory structure + if err := coreio.Local.EnsureDir(agentDir); err != nil { + return fmt.Errorf("failed to create agent directory: %w", err) + } + if err := coreio.Local.EnsureDir(filepath.Join(agentDir, "artifacts")); err != nil { + return fmt.Errorf("failed to create artifacts directory: %w", err) + } + + // Create initial memory.md + memoryContent := fmt.Sprintf(`# %s/%s — Issue #%d (EPIC #%d) + +## Context +- **Task workspace:** p%d/i%d +- **Initialized:** %s + +## Notes + + +`, provider, name, taskIssue, taskEpic, taskEpic, taskIssue, time.Now().Format(time.RFC3339)) + + if err := coreio.Local.Write(filepath.Join(agentDir, "memory.md"), memoryContent); err != nil { + return fmt.Errorf("failed to create memory.md: %w", err) + } + + // Write manifest + updateAgentManifest(agentDir, provider, name) + + cli.Print("%s Agent %s/%s initialized at p%d/i%d\n", + cli.SuccessStyle.Render("Done:"), + cli.ValueStyle.Render(provider), cli.ValueStyle.Render(name), + taskEpic, taskIssue) + cli.Print("Memory: %s\n", cli.DimStyle.Render(filepath.Join(agentDir, "memory.md"))) + + return nil +} + +func runAgentList(cmd *cobra.Command, args []string) error { + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + wsPath := taskWorkspacePath(root, taskEpic, taskIssue) + agentsDir := filepath.Join(wsPath, "agents") + + if !coreio.Local.IsDir(agentsDir) { + cli.Println("No agents in this workspace.") + return nil + } + + providers, err := coreio.Local.List(agentsDir) + if err != nil { + return fmt.Errorf("failed to list agents: %w", err) + } + + found := false + for _, providerEntry := range providers { + if !providerEntry.IsDir() { + continue + } + providerDir := filepath.Join(agentsDir, providerEntry.Name()) + agents, err := coreio.Local.List(providerDir) + if err != nil { + continue + } + + for _, agentEntry := range agents { + if !agentEntry.IsDir() { + continue + } + found = true + agentDir := filepath.Join(providerDir, agentEntry.Name()) + + // Read manifest for last_seen + lastSeen := "" + manifestPath := filepath.Join(agentDir, "manifest.json") + if data, err := coreio.Local.Read(manifestPath); err == nil { + var m AgentManifest + if json.Unmarshal([]byte(data), &m) == nil { + lastSeen = m.LastSeen.Format("2006-01-02 15:04") + } + } + + // Check if memory has content beyond the template + memorySize := "" + if content, err := coreio.Local.Read(filepath.Join(agentDir, "memory.md")); err == nil { + lines := len(strings.Split(content, "\n")) + memorySize = fmt.Sprintf("%d lines", lines) + } + + cli.Print(" %s/%s %s", + cli.ValueStyle.Render(providerEntry.Name()), + cli.ValueStyle.Render(agentEntry.Name()), + cli.DimStyle.Render(memorySize)) + if lastSeen != "" { + cli.Print(" last: %s", cli.DimStyle.Render(lastSeen)) + } + cli.Print("\n") + } + } + + if !found { + cli.Println("No agents in this workspace.") + } + + return nil +} + +func runAgentPath(cmd *cobra.Command, args []string) error { + provider, name, err := parseAgentID(args[0]) + if err != nil { + return err + } + + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + wsPath := taskWorkspacePath(root, taskEpic, taskIssue) + agentDir := agentContextPath(wsPath, provider, name) + + if !coreio.Local.IsDir(agentDir) { + return cli.Err("agent %s/%s not initialized — run `core workspace agent init %s/%s`", provider, name, provider, name) + } + + // Print just the path (useful for scripting: cd $(core workspace agent path ...)) + cli.Text(agentDir) + return nil +} + +func updateAgentManifest(agentDir, provider, name string) { + now := time.Now() + manifest := AgentManifest{ + Provider: provider, + Name: name, + CreatedAt: now, + LastSeen: now, + } + + // Try to preserve created_at from existing manifest + manifestPath := filepath.Join(agentDir, "manifest.json") + if data, err := coreio.Local.Read(manifestPath); err == nil { + var existing AgentManifest + if json.Unmarshal([]byte(data), &existing) == nil { + manifest.CreatedAt = existing.CreatedAt + } + } + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return + } + _ = coreio.Local.Write(manifestPath, string(data)) +} diff --git a/internal/cmd/workspace/cmd_agent_test.go b/internal/cmd/workspace/cmd_agent_test.go new file mode 100644 index 00000000..e414cb05 --- /dev/null +++ b/internal/cmd/workspace/cmd_agent_test.go @@ -0,0 +1,79 @@ +package workspace + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseAgentID_Good(t *testing.T) { + provider, name, err := parseAgentID("claude-opus/qa") + require.NoError(t, err) + assert.Equal(t, "claude-opus", provider) + assert.Equal(t, "qa", name) +} + +func TestParseAgentID_Bad(t *testing.T) { + tests := []string{ + "noslash", + "/missing-provider", + "missing-name/", + "", + } + for _, id := range tests { + _, _, err := parseAgentID(id) + assert.Error(t, err, "expected error for: %q", id) + } +} + +func TestAgentContextPath(t *testing.T) { + path := agentContextPath("/ws/p101/i343", "claude-opus", "qa") + assert.Equal(t, "/ws/p101/i343/agents/claude-opus/qa", path) +} + +func TestUpdateAgentManifest_Good(t *testing.T) { + tmp := t.TempDir() + agentDir := filepath.Join(tmp, "agents", "test-provider", "test-agent") + require.NoError(t, os.MkdirAll(agentDir, 0755)) + + updateAgentManifest(agentDir, "test-provider", "test-agent") + + data, err := os.ReadFile(filepath.Join(agentDir, "manifest.json")) + require.NoError(t, err) + + var m AgentManifest + require.NoError(t, json.Unmarshal(data, &m)) + assert.Equal(t, "test-provider", m.Provider) + assert.Equal(t, "test-agent", m.Name) + assert.False(t, m.CreatedAt.IsZero()) + assert.False(t, m.LastSeen.IsZero()) +} + +func TestUpdateAgentManifest_PreservesCreatedAt(t *testing.T) { + tmp := t.TempDir() + agentDir := filepath.Join(tmp, "agents", "p", "a") + require.NoError(t, os.MkdirAll(agentDir, 0755)) + + // First call sets created_at + updateAgentManifest(agentDir, "p", "a") + + data, err := os.ReadFile(filepath.Join(agentDir, "manifest.json")) + require.NoError(t, err) + var first AgentManifest + require.NoError(t, json.Unmarshal(data, &first)) + + // Second call should preserve created_at + updateAgentManifest(agentDir, "p", "a") + + data, err = os.ReadFile(filepath.Join(agentDir, "manifest.json")) + require.NoError(t, err) + var second AgentManifest + require.NoError(t, json.Unmarshal(data, &second)) + + assert.Equal(t, first.CreatedAt, second.CreatedAt) + assert.True(t, second.LastSeen.After(first.CreatedAt) || second.LastSeen.Equal(first.CreatedAt)) +} diff --git a/internal/cmd/workspace/cmd_task.go b/internal/cmd/workspace/cmd_task.go new file mode 100644 index 00000000..fcb0b831 --- /dev/null +++ b/internal/cmd/workspace/cmd_task.go @@ -0,0 +1,466 @@ +// cmd_task.go implements task workspace isolation using git worktrees. +// +// Each task gets an isolated workspace at .core/workspace/p{epic}/i{issue}/ +// containing git worktrees of required repos. This prevents agents from +// writing to the implementor's working tree. +// +// Safety checks enforce that workspaces cannot be removed if they contain +// uncommitted changes or unpushed branches. +package workspace + +import ( + "context" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/host-uk/core/pkg/cli" + coreio "github.com/host-uk/core/pkg/io" + "github.com/host-uk/core/pkg/repos" + "github.com/spf13/cobra" +) + +var ( + taskEpic int + taskIssue int + taskRepos []string + taskForce bool + taskBranch string +) + +func addTaskCommands(parent *cobra.Command) { + taskCmd := &cobra.Command{ + Use: "task", + Short: "Manage isolated task workspaces for agents", + } + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create an isolated task workspace with git worktrees", + Long: `Creates a workspace at .core/workspace/p{epic}/i{issue}/ with git +worktrees for each specified repo. Each worktree gets a fresh branch +(issue/{id} by default) so agents work in isolation.`, + RunE: runTaskCreate, + } + createCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") + createCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") + createCmd.Flags().StringSliceVar(&taskRepos, "repo", nil, "Repos to include (default: all from registry)") + createCmd.Flags().StringVar(&taskBranch, "branch", "", "Branch name (default: issue/{issue})") + _ = createCmd.MarkFlagRequired("epic") + _ = createCmd.MarkFlagRequired("issue") + + removeCmd := &cobra.Command{ + Use: "remove", + Short: "Remove a task workspace (with safety checks)", + Long: `Removes a task workspace after checking for uncommitted changes and +unpushed branches. Use --force to skip safety checks.`, + RunE: runTaskRemove, + } + removeCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") + removeCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") + removeCmd.Flags().BoolVar(&taskForce, "force", false, "Skip safety checks") + _ = removeCmd.MarkFlagRequired("epic") + _ = removeCmd.MarkFlagRequired("issue") + + listCmd := &cobra.Command{ + Use: "list", + Short: "List all task workspaces", + RunE: runTaskList, + } + + statusCmd := &cobra.Command{ + Use: "status", + Short: "Show status of a task workspace", + RunE: runTaskStatus, + } + statusCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") + statusCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") + _ = statusCmd.MarkFlagRequired("epic") + _ = statusCmd.MarkFlagRequired("issue") + + addAgentCommands(taskCmd) + + taskCmd.AddCommand(createCmd, removeCmd, listCmd, statusCmd) + parent.AddCommand(taskCmd) +} + +// taskWorkspacePath returns the path for a task workspace. +func taskWorkspacePath(root string, epic, issue int) string { + return filepath.Join(root, ".core", "workspace", fmt.Sprintf("p%d", epic), fmt.Sprintf("i%d", issue)) +} + +func runTaskCreate(cmd *cobra.Command, args []string) error { + ctx := context.Background() + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace — run from workspace root or a package directory") + } + + wsPath := taskWorkspacePath(root, taskEpic, taskIssue) + + if coreio.Local.IsDir(wsPath) { + return cli.Err("task workspace already exists: %s", wsPath) + } + + branch := taskBranch + if branch == "" { + branch = fmt.Sprintf("issue/%d", taskIssue) + } + + // Determine repos to include + repoNames := taskRepos + if len(repoNames) == 0 { + repoNames, err = registryRepoNames(root) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } + + if len(repoNames) == 0 { + return cli.Err("no repos specified and no registry found") + } + + // Resolve package paths + config, _ := LoadConfig(root) + pkgDir := "./packages" + if config != nil && config.PackagesDir != "" { + pkgDir = config.PackagesDir + } + if !filepath.IsAbs(pkgDir) { + pkgDir = filepath.Join(root, pkgDir) + } + + if err := coreio.Local.EnsureDir(wsPath); err != nil { + return fmt.Errorf("failed to create workspace directory: %w", err) + } + + cli.Print("Creating task workspace: %s\n", cli.ValueStyle.Render(fmt.Sprintf("p%d/i%d", taskEpic, taskIssue))) + cli.Print("Branch: %s\n", cli.ValueStyle.Render(branch)) + cli.Print("Path: %s\n\n", cli.DimStyle.Render(wsPath)) + + var created, skipped int + for _, repoName := range repoNames { + repoPath := filepath.Join(pkgDir, repoName) + if !coreio.Local.IsDir(filepath.Join(repoPath, ".git")) { + cli.Print(" %s %s (not cloned, skipping)\n", cli.DimStyle.Render("·"), repoName) + skipped++ + continue + } + + worktreePath := filepath.Join(wsPath, repoName) + cli.Print(" %s %s... ", cli.DimStyle.Render("·"), repoName) + + if err := createWorktree(ctx, repoPath, worktreePath, branch); err != nil { + cli.Print("%s\n", cli.ErrorStyle.Render("x "+err.Error())) + skipped++ + continue + } + + cli.Print("%s\n", cli.SuccessStyle.Render("ok")) + created++ + } + + cli.Print("\n%s %d worktrees created", cli.SuccessStyle.Render("Done:"), created) + if skipped > 0 { + cli.Print(", %d skipped", skipped) + } + cli.Print("\n") + + return nil +} + +func runTaskRemove(cmd *cobra.Command, args []string) error { + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + wsPath := taskWorkspacePath(root, taskEpic, taskIssue) + if !coreio.Local.IsDir(wsPath) { + return cli.Err("task workspace does not exist: p%d/i%d", taskEpic, taskIssue) + } + + if !taskForce { + dirty, reasons := checkWorkspaceSafety(wsPath) + if dirty { + cli.Print("%s Cannot remove workspace p%d/i%d:\n", cli.ErrorStyle.Render("Blocked:"), taskEpic, taskIssue) + for _, r := range reasons { + cli.Print(" %s %s\n", cli.ErrorStyle.Render("·"), r) + } + cli.Print("\nUse --force to override or resolve the issues first.\n") + return errors.New("workspace has unresolved changes") + } + } + + // Remove worktrees first (so git knows they're gone) + entries, err := coreio.Local.List(wsPath) + if err != nil { + return fmt.Errorf("failed to list workspace: %w", err) + } + + config, _ := LoadConfig(root) + pkgDir := "./packages" + if config != nil && config.PackagesDir != "" { + pkgDir = config.PackagesDir + } + if !filepath.IsAbs(pkgDir) { + pkgDir = filepath.Join(root, pkgDir) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + worktreePath := filepath.Join(wsPath, entry.Name()) + repoPath := filepath.Join(pkgDir, entry.Name()) + + // Remove worktree from git + if coreio.Local.IsDir(filepath.Join(repoPath, ".git")) { + removeWorktree(repoPath, worktreePath) + } + } + + // Remove the workspace directory + if err := coreio.Local.DeleteAll(wsPath); err != nil { + return fmt.Errorf("failed to remove workspace directory: %w", err) + } + + // Clean up empty parent (p{epic}/) if it's now empty + epicDir := filepath.Dir(wsPath) + if entries, err := coreio.Local.List(epicDir); err == nil && len(entries) == 0 { + coreio.Local.DeleteAll(epicDir) + } + + cli.Print("%s Removed workspace p%d/i%d\n", cli.SuccessStyle.Render("Done:"), taskEpic, taskIssue) + return nil +} + +func runTaskList(cmd *cobra.Command, args []string) error { + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + wsRoot := filepath.Join(root, ".core", "workspace") + if !coreio.Local.IsDir(wsRoot) { + cli.Println("No task workspaces found.") + return nil + } + + epics, err := coreio.Local.List(wsRoot) + if err != nil { + return fmt.Errorf("failed to list workspaces: %w", err) + } + + found := false + for _, epicEntry := range epics { + if !epicEntry.IsDir() || !strings.HasPrefix(epicEntry.Name(), "p") { + continue + } + epicDir := filepath.Join(wsRoot, epicEntry.Name()) + issues, err := coreio.Local.List(epicDir) + if err != nil { + continue + } + for _, issueEntry := range issues { + if !issueEntry.IsDir() || !strings.HasPrefix(issueEntry.Name(), "i") { + continue + } + found = true + wsPath := filepath.Join(epicDir, issueEntry.Name()) + + // Count worktrees + entries, _ := coreio.Local.List(wsPath) + dirCount := 0 + for _, e := range entries { + if e.IsDir() { + dirCount++ + } + } + + // Check safety + dirty, _ := checkWorkspaceSafety(wsPath) + status := cli.SuccessStyle.Render("clean") + if dirty { + status = cli.ErrorStyle.Render("dirty") + } + + cli.Print(" %s/%s %d repos %s\n", + epicEntry.Name(), issueEntry.Name(), + dirCount, status) + } + } + + if !found { + cli.Println("No task workspaces found.") + } + + return nil +} + +func runTaskStatus(cmd *cobra.Command, args []string) error { + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + wsPath := taskWorkspacePath(root, taskEpic, taskIssue) + if !coreio.Local.IsDir(wsPath) { + return cli.Err("task workspace does not exist: p%d/i%d", taskEpic, taskIssue) + } + + cli.Print("Workspace: %s\n", cli.ValueStyle.Render(fmt.Sprintf("p%d/i%d", taskEpic, taskIssue))) + cli.Print("Path: %s\n\n", cli.DimStyle.Render(wsPath)) + + entries, err := coreio.Local.List(wsPath) + if err != nil { + return fmt.Errorf("failed to list workspace: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + worktreePath := filepath.Join(wsPath, entry.Name()) + + // Get branch + branch := gitOutput(worktreePath, "rev-parse", "--abbrev-ref", "HEAD") + branch = strings.TrimSpace(branch) + + // Get status + status := gitOutput(worktreePath, "status", "--porcelain") + statusLabel := cli.SuccessStyle.Render("clean") + if strings.TrimSpace(status) != "" { + lines := len(strings.Split(strings.TrimSpace(status), "\n")) + statusLabel = cli.ErrorStyle.Render(fmt.Sprintf("%d changes", lines)) + } + + // Get unpushed + unpushed := gitOutput(worktreePath, "log", "--oneline", "@{u}..HEAD") + unpushedLabel := "" + if trimmed := strings.TrimSpace(unpushed); trimmed != "" { + count := len(strings.Split(trimmed, "\n")) + unpushedLabel = cli.WarningStyle.Render(fmt.Sprintf(" %d unpushed", count)) + } + + cli.Print(" %s %s %s%s\n", + cli.RepoStyle.Render(entry.Name()), + cli.DimStyle.Render(branch), + statusLabel, + unpushedLabel) + } + + return nil +} + +// createWorktree adds a git worktree at worktreePath for the given branch. +func createWorktree(ctx context.Context, repoPath, worktreePath, branch string) error { + // Check if branch exists on remote first + cmd := exec.CommandContext(ctx, "git", "worktree", "add", "-b", branch, worktreePath) + cmd.Dir = repoPath + output, err := cmd.CombinedOutput() + if err != nil { + errStr := strings.TrimSpace(string(output)) + // If branch already exists, try without -b + if strings.Contains(errStr, "already exists") { + cmd = exec.CommandContext(ctx, "git", "worktree", "add", worktreePath, branch) + cmd.Dir = repoPath + output, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + return nil + } + return fmt.Errorf("%s", errStr) + } + return nil +} + +// removeWorktree removes a git worktree. +func removeWorktree(repoPath, worktreePath string) { + cmd := exec.Command("git", "worktree", "remove", worktreePath) + cmd.Dir = repoPath + _ = cmd.Run() + + // Prune stale worktrees + cmd = exec.Command("git", "worktree", "prune") + cmd.Dir = repoPath + _ = cmd.Run() +} + +// checkWorkspaceSafety checks all worktrees in a workspace for uncommitted/unpushed changes. +func checkWorkspaceSafety(wsPath string) (dirty bool, reasons []string) { + entries, err := coreio.Local.List(wsPath) + if err != nil { + return false, nil + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + worktreePath := filepath.Join(wsPath, entry.Name()) + + // Check for uncommitted changes + status := gitOutput(worktreePath, "status", "--porcelain") + if strings.TrimSpace(status) != "" { + dirty = true + reasons = append(reasons, fmt.Sprintf("%s: has uncommitted changes", entry.Name())) + } + + // Check for unpushed commits + unpushed := gitOutput(worktreePath, "log", "--oneline", "@{u}..HEAD") + if strings.TrimSpace(unpushed) != "" { + dirty = true + count := len(strings.Split(strings.TrimSpace(unpushed), "\n")) + reasons = append(reasons, fmt.Sprintf("%s: %d unpushed commits", entry.Name(), count)) + } + } + + return dirty, reasons +} + +// gitOutput runs a git command and returns stdout. +func gitOutput(dir string, args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, _ := cmd.Output() + return string(out) +} + +// registryRepoNames returns repo names from the workspace registry. +func registryRepoNames(root string) ([]string, error) { + // Try to find repos.yaml + regPath, err := repos.FindRegistry(coreio.Local) + if err != nil { + return nil, err + } + + reg, err := repos.LoadRegistry(coreio.Local, regPath) + if err != nil { + return nil, err + } + + var names []string + for _, repo := range reg.List() { + // Only include cloneable repos + if repo.Clone != nil && !*repo.Clone { + continue + } + // Skip meta repos + if repo.Type == "meta" { + continue + } + names = append(names, repo.Name) + } + + return names, nil +} + +// epicBranchName returns the branch name for an EPIC. +func epicBranchName(epicID int) string { + return "epic/" + strconv.Itoa(epicID) +} diff --git a/internal/cmd/workspace/cmd_task_test.go b/internal/cmd/workspace/cmd_task_test.go new file mode 100644 index 00000000..63404705 --- /dev/null +++ b/internal/cmd/workspace/cmd_task_test.go @@ -0,0 +1,109 @@ +package workspace + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestRepo(t *testing.T, dir, name string) string { + t.Helper() + repoPath := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(repoPath, 0755)) + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "commit", "--allow-empty", "-m", "initial"}, + } + for _, c := range cmds { + cmd := exec.Command(c[0], c[1:]...) + cmd.Dir = repoPath + out, err := cmd.CombinedOutput() + require.NoError(t, err, "cmd %v failed: %s", c, string(out)) + } + return repoPath +} + +func TestTaskWorkspacePath(t *testing.T) { + path := taskWorkspacePath("/home/user/Code/host-uk", 101, 343) + assert.Equal(t, "/home/user/Code/host-uk/.core/workspace/p101/i343", path) +} + +func TestCreateWorktree_Good(t *testing.T) { + tmp := t.TempDir() + repoPath := setupTestRepo(t, tmp, "test-repo") + worktreePath := filepath.Join(tmp, "workspace", "test-repo") + + err := createWorktree(t.Context(), repoPath, worktreePath, "issue/123") + require.NoError(t, err) + + // Verify worktree exists + assert.DirExists(t, worktreePath) + assert.FileExists(t, filepath.Join(worktreePath, ".git")) + + // Verify branch + branch := gitOutput(worktreePath, "rev-parse", "--abbrev-ref", "HEAD") + assert.Equal(t, "issue/123", trimNL(branch)) +} + +func TestCreateWorktree_BranchExists(t *testing.T) { + tmp := t.TempDir() + repoPath := setupTestRepo(t, tmp, "test-repo") + + // Create branch first + cmd := exec.Command("git", "branch", "issue/456") + cmd.Dir = repoPath + require.NoError(t, cmd.Run()) + + worktreePath := filepath.Join(tmp, "workspace", "test-repo") + err := createWorktree(t.Context(), repoPath, worktreePath, "issue/456") + require.NoError(t, err) + + assert.DirExists(t, worktreePath) +} + +func TestCheckWorkspaceSafety_Clean(t *testing.T) { + tmp := t.TempDir() + wsPath := filepath.Join(tmp, "workspace") + require.NoError(t, os.MkdirAll(wsPath, 0755)) + + repoPath := setupTestRepo(t, tmp, "origin-repo") + worktreePath := filepath.Join(wsPath, "origin-repo") + require.NoError(t, createWorktree(t.Context(), repoPath, worktreePath, "test-branch")) + + dirty, reasons := checkWorkspaceSafety(wsPath) + assert.False(t, dirty) + assert.Empty(t, reasons) +} + +func TestCheckWorkspaceSafety_Dirty(t *testing.T) { + tmp := t.TempDir() + wsPath := filepath.Join(tmp, "workspace") + require.NoError(t, os.MkdirAll(wsPath, 0755)) + + repoPath := setupTestRepo(t, tmp, "origin-repo") + worktreePath := filepath.Join(wsPath, "origin-repo") + require.NoError(t, createWorktree(t.Context(), repoPath, worktreePath, "test-branch")) + + // Create uncommitted file + require.NoError(t, os.WriteFile(filepath.Join(worktreePath, "dirty.txt"), []byte("dirty"), 0644)) + + dirty, reasons := checkWorkspaceSafety(wsPath) + assert.True(t, dirty) + assert.Contains(t, reasons[0], "uncommitted changes") +} + +func TestEpicBranchName(t *testing.T) { + assert.Equal(t, "epic/101", epicBranchName(101)) + assert.Equal(t, "epic/42", epicBranchName(42)) +} + +func trimNL(s string) string { + return s[:len(s)-1] +} diff --git a/internal/cmd/workspace/cmd_workspace.go b/internal/cmd/workspace/cmd_workspace.go index 204efe1c..c90bf63c 100644 --- a/internal/cmd/workspace/cmd_workspace.go +++ b/internal/cmd/workspace/cmd_workspace.go @@ -21,6 +21,8 @@ func AddWorkspaceCommands(root *cobra.Command) { RunE: runWorkspaceActive, }) + addTaskCommands(wsCmd) + root.AddCommand(wsCmd) } diff --git a/internal/variants/full.go b/internal/variants/full.go index f80e34f7..1fb33c3e 100644 --- a/internal/variants/full.go +++ b/internal/variants/full.go @@ -21,6 +21,7 @@ // - qa: Quality assurance workflows // - monitor: Security monitoring aggregation // - gitea: Gitea instance management (repos, issues, PRs, mirrors) +// - forge: Forgejo instance management (repos, issues, PRs, migration, orgs, labels) // - unifi: UniFi network management (sites, devices, clients) package variants @@ -36,6 +37,7 @@ import ( _ "github.com/host-uk/core/internal/cmd/dev" _ "github.com/host-uk/core/internal/cmd/docs" _ "github.com/host-uk/core/internal/cmd/doctor" + _ "github.com/host-uk/core/internal/cmd/forge" _ "github.com/host-uk/core/internal/cmd/gitcmd" _ "github.com/host-uk/core/internal/cmd/gitea" _ "github.com/host-uk/core/internal/cmd/go" diff --git a/pkg/build/build.go b/pkg/build/build.go index 86f660eb..8d686079 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -22,6 +22,8 @@ const ( ProjectTypeNode ProjectType = "node" // ProjectTypePHP indicates a PHP/Laravel project with composer.json. ProjectTypePHP ProjectType = "php" + // ProjectTypeCPP indicates a C++ project with CMakeLists.txt. + ProjectTypeCPP ProjectType = "cpp" // ProjectTypeDocker indicates a Docker-based project with Dockerfile. ProjectTypeDocker ProjectType = "docker" // ProjectTypeLinuxKit indicates a LinuxKit VM configuration. diff --git a/pkg/build/buildcmd/cmd_project.go b/pkg/build/buildcmd/cmd_project.go index 25a09dd8..e13b9ea9 100644 --- a/pkg/build/buildcmd/cmd_project.go +++ b/pkg/build/buildcmd/cmd_project.go @@ -380,6 +380,8 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) { return builders.NewLinuxKitBuilder(), nil case build.ProjectTypeTaskfile: return builders.NewTaskfileBuilder(), nil + case build.ProjectTypeCPP: + return builders.NewCPPBuilder(), nil case build.ProjectTypeNode: return nil, fmt.Errorf("%s", i18n.T("cmd.build.error.node_not_implemented")) case build.ProjectTypePHP: diff --git a/pkg/build/builders/cpp.go b/pkg/build/builders/cpp.go new file mode 100644 index 00000000..f5cf6f44 --- /dev/null +++ b/pkg/build/builders/cpp.go @@ -0,0 +1,253 @@ +// Package builders provides build implementations for different project types. +package builders + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" +) + +// CPPBuilder implements the Builder interface for C++ projects using CMake + Conan. +// It wraps the Makefile-based build system from the .core/build submodule. +type CPPBuilder struct{} + +// NewCPPBuilder creates a new CPPBuilder instance. +func NewCPPBuilder() *CPPBuilder { + return &CPPBuilder{} +} + +// Name returns the builder's identifier. +func (b *CPPBuilder) Name() string { + return "cpp" +} + +// Detect checks if this builder can handle the project in the given directory. +func (b *CPPBuilder) Detect(fs io.Medium, dir string) (bool, error) { + return build.IsCPPProject(fs, dir), nil +} + +// Build compiles the C++ project using Make targets. +// The build flow is: make configure → make build → make package. +// Cross-compilation is handled via Conan profiles specified in .core/build.yaml. +func (b *CPPBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { + if cfg == nil { + return nil, fmt.Errorf("builders.CPPBuilder.Build: config is nil") + } + + // Validate make is available + if err := b.validateMake(); err != nil { + return nil, err + } + + // For C++ projects, the Makefile handles everything. + // We don't iterate per-target like Go — the Makefile's configure + build + // produces binaries for the host platform, and cross-compilation uses + // named Conan profiles (e.g., make gcc-linux-armv8). + if len(targets) == 0 { + // Default to host platform + targets = []build.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}} + } + + var artifacts []build.Artifact + + for _, target := range targets { + built, err := b.buildTarget(ctx, cfg, target) + if err != nil { + return artifacts, fmt.Errorf("builders.CPPBuilder.Build: %w", err) + } + artifacts = append(artifacts, built...) + } + + return artifacts, nil +} + +// buildTarget compiles for a single target platform. +func (b *CPPBuilder) buildTarget(ctx context.Context, cfg *build.Config, target build.Target) ([]build.Artifact, error) { + // Determine if this is a cross-compile or host build + isHostBuild := target.OS == runtime.GOOS && target.Arch == runtime.GOARCH + + if isHostBuild { + return b.buildHost(ctx, cfg, target) + } + + return b.buildCross(ctx, cfg, target) +} + +// buildHost runs the standard make configure → make build → make package flow. +func (b *CPPBuilder) buildHost(ctx context.Context, cfg *build.Config, target build.Target) ([]build.Artifact, error) { + fmt.Printf("Building C++ project for %s/%s (host)\n", target.OS, target.Arch) + + // Step 1: Configure (runs conan install + cmake configure) + if err := b.runMake(ctx, cfg.ProjectDir, "configure"); err != nil { + return nil, fmt.Errorf("configure failed: %w", err) + } + + // Step 2: Build + if err := b.runMake(ctx, cfg.ProjectDir, "build"); err != nil { + return nil, fmt.Errorf("build failed: %w", err) + } + + // Step 3: Package + if err := b.runMake(ctx, cfg.ProjectDir, "package"); err != nil { + return nil, fmt.Errorf("package failed: %w", err) + } + + // Discover artifacts from build/packages/ + return b.findArtifacts(cfg.FS, cfg.ProjectDir, target) +} + +// buildCross runs a cross-compilation using a Conan profile name. +// The Makefile supports profile targets like: make gcc-linux-armv8 +func (b *CPPBuilder) buildCross(ctx context.Context, cfg *build.Config, target build.Target) ([]build.Artifact, error) { + // Map target to a Conan profile name + profile := b.targetToProfile(target) + if profile == "" { + return nil, fmt.Errorf("no Conan profile mapped for target %s/%s", target.OS, target.Arch) + } + + fmt.Printf("Building C++ project for %s/%s (cross: %s)\n", target.OS, target.Arch, profile) + + // The Makefile exposes each profile as a top-level target + if err := b.runMake(ctx, cfg.ProjectDir, profile); err != nil { + return nil, fmt.Errorf("cross-compile for %s failed: %w", profile, err) + } + + return b.findArtifacts(cfg.FS, cfg.ProjectDir, target) +} + +// runMake executes a make target in the project directory. +func (b *CPPBuilder) runMake(ctx context.Context, projectDir string, target string) error { + cmd := exec.CommandContext(ctx, "make", target) + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + if err := cmd.Run(); err != nil { + return fmt.Errorf("make %s: %w", target, err) + } + return nil +} + +// findArtifacts searches for built packages in build/packages/. +func (b *CPPBuilder) findArtifacts(fs io.Medium, projectDir string, target build.Target) ([]build.Artifact, error) { + packagesDir := filepath.Join(projectDir, "build", "packages") + + if !fs.IsDir(packagesDir) { + // Fall back to searching build/release/src/ for raw binaries + return b.findBinaries(fs, projectDir, target) + } + + entries, err := fs.List(packagesDir) + if err != nil { + return nil, fmt.Errorf("failed to list packages directory: %w", err) + } + + var artifacts []build.Artifact + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + // Skip checksum files and hidden files + if strings.HasSuffix(name, ".sha256") || strings.HasPrefix(name, ".") { + continue + } + + artifacts = append(artifacts, build.Artifact{ + Path: filepath.Join(packagesDir, name), + OS: target.OS, + Arch: target.Arch, + }) + } + + return artifacts, nil +} + +// findBinaries searches for compiled binaries in build/release/src/. +func (b *CPPBuilder) findBinaries(fs io.Medium, projectDir string, target build.Target) ([]build.Artifact, error) { + binDir := filepath.Join(projectDir, "build", "release", "src") + + if !fs.IsDir(binDir) { + return nil, fmt.Errorf("no build output found in %s", binDir) + } + + entries, err := fs.List(binDir) + if err != nil { + return nil, fmt.Errorf("failed to list build directory: %w", err) + } + + var artifacts []build.Artifact + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + // Skip non-executable files (libraries, cmake files, etc.) + if strings.HasSuffix(name, ".a") || strings.HasSuffix(name, ".o") || + strings.HasSuffix(name, ".cmake") || strings.HasPrefix(name, ".") { + continue + } + + fullPath := filepath.Join(binDir, name) + + // On Unix, check if file is executable + if target.OS != "windows" { + info, err := os.Stat(fullPath) + if err != nil { + continue + } + if info.Mode()&0111 == 0 { + continue + } + } + + artifacts = append(artifacts, build.Artifact{ + Path: fullPath, + OS: target.OS, + Arch: target.Arch, + }) + } + + return artifacts, nil +} + +// targetToProfile maps a build target to a Conan cross-compilation profile name. +// Profile names match those in .core/build/cmake/profiles/. +func (b *CPPBuilder) targetToProfile(target build.Target) string { + key := target.OS + "/" + target.Arch + profiles := map[string]string{ + "linux/amd64": "gcc-linux-x86_64", + "linux/x86_64": "gcc-linux-x86_64", + "linux/arm64": "gcc-linux-armv8", + "linux/armv8": "gcc-linux-armv8", + "darwin/arm64": "apple-clang-armv8", + "darwin/armv8": "apple-clang-armv8", + "darwin/amd64": "apple-clang-x86_64", + "darwin/x86_64": "apple-clang-x86_64", + "windows/amd64": "msvc-194-x86_64", + "windows/x86_64": "msvc-194-x86_64", + } + + return profiles[key] +} + +// validateMake checks if make is available. +func (b *CPPBuilder) validateMake() error { + if _, err := exec.LookPath("make"); err != nil { + return fmt.Errorf("cpp: make not found. Install build-essential (Linux) or Xcode Command Line Tools (macOS)") + } + return nil +} + +// Ensure CPPBuilder implements the Builder interface. +var _ build.Builder = (*CPPBuilder)(nil) diff --git a/pkg/build/builders/cpp_test.go b/pkg/build/builders/cpp_test.go new file mode 100644 index 00000000..f78c16c7 --- /dev/null +++ b/pkg/build/builders/cpp_test.go @@ -0,0 +1,149 @@ +package builders + +import ( + "os" + "path/filepath" + "testing" + + "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCPPBuilder_Name_Good(t *testing.T) { + builder := NewCPPBuilder() + assert.Equal(t, "cpp", builder.Name()) +} + +func TestCPPBuilder_Detect_Good(t *testing.T) { + fs := io.Local + + t.Run("detects C++ project with CMakeLists.txt", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "CMakeLists.txt"), []byte("cmake_minimum_required(VERSION 3.16)"), 0644) + require.NoError(t, err) + + builder := NewCPPBuilder() + detected, err := builder.Detect(fs, dir) + assert.NoError(t, err) + assert.True(t, detected) + }) + + t.Run("returns false for non-C++ project", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + require.NoError(t, err) + + builder := NewCPPBuilder() + detected, err := builder.Detect(fs, dir) + assert.NoError(t, err) + assert.False(t, detected) + }) + + t.Run("returns false for empty directory", func(t *testing.T) { + dir := t.TempDir() + + builder := NewCPPBuilder() + detected, err := builder.Detect(fs, dir) + assert.NoError(t, err) + assert.False(t, detected) + }) +} + +func TestCPPBuilder_Build_Bad(t *testing.T) { + t.Run("returns error for nil config", func(t *testing.T) { + builder := NewCPPBuilder() + artifacts, err := builder.Build(nil, nil, []build.Target{{OS: "linux", Arch: "amd64"}}) + assert.Error(t, err) + assert.Nil(t, artifacts) + assert.Contains(t, err.Error(), "config is nil") + }) +} + +func TestCPPBuilder_TargetToProfile_Good(t *testing.T) { + builder := NewCPPBuilder() + + tests := []struct { + os, arch string + expected string + }{ + {"linux", "amd64", "gcc-linux-x86_64"}, + {"linux", "x86_64", "gcc-linux-x86_64"}, + {"linux", "arm64", "gcc-linux-armv8"}, + {"darwin", "arm64", "apple-clang-armv8"}, + {"darwin", "amd64", "apple-clang-x86_64"}, + {"windows", "amd64", "msvc-194-x86_64"}, + } + + for _, tt := range tests { + t.Run(tt.os+"/"+tt.arch, func(t *testing.T) { + profile := builder.targetToProfile(build.Target{OS: tt.os, Arch: tt.arch}) + assert.Equal(t, tt.expected, profile) + }) + } +} + +func TestCPPBuilder_TargetToProfile_Bad(t *testing.T) { + builder := NewCPPBuilder() + + t.Run("returns empty for unknown target", func(t *testing.T) { + profile := builder.targetToProfile(build.Target{OS: "plan9", Arch: "mips"}) + assert.Empty(t, profile) + }) +} + +func TestCPPBuilder_FindArtifacts_Good(t *testing.T) { + fs := io.Local + + t.Run("finds packages in build/packages", func(t *testing.T) { + dir := t.TempDir() + packagesDir := filepath.Join(dir, "build", "packages") + require.NoError(t, os.MkdirAll(packagesDir, 0755)) + + // Create mock package files + require.NoError(t, os.WriteFile(filepath.Join(packagesDir, "test-1.0-linux-x86_64.tar.xz"), []byte("pkg"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(packagesDir, "test-1.0-linux-x86_64.tar.xz.sha256"), []byte("checksum"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(packagesDir, "test-1.0-linux-x86_64.rpm"), []byte("rpm"), 0644)) + + builder := NewCPPBuilder() + target := build.Target{OS: "linux", Arch: "amd64"} + artifacts, err := builder.findArtifacts(fs, dir, target) + require.NoError(t, err) + + // Should find tar.xz and rpm but not sha256 + assert.Len(t, artifacts, 2) + for _, a := range artifacts { + assert.Equal(t, "linux", a.OS) + assert.Equal(t, "amd64", a.Arch) + assert.False(t, filepath.Ext(a.Path) == ".sha256") + } + }) + + t.Run("falls back to binaries in build/release/src", func(t *testing.T) { + dir := t.TempDir() + binDir := filepath.Join(dir, "build", "release", "src") + require.NoError(t, os.MkdirAll(binDir, 0755)) + + // Create mock binary (executable) + binPath := filepath.Join(binDir, "test-daemon") + require.NoError(t, os.WriteFile(binPath, []byte("binary"), 0755)) + + // Create a library (should be skipped) + require.NoError(t, os.WriteFile(filepath.Join(binDir, "libcrypto.a"), []byte("lib"), 0644)) + + builder := NewCPPBuilder() + target := build.Target{OS: "linux", Arch: "amd64"} + artifacts, err := builder.findArtifacts(fs, dir, target) + require.NoError(t, err) + + // Should find the executable but not the library + assert.Len(t, artifacts, 1) + assert.Contains(t, artifacts[0].Path, "test-daemon") + }) +} + +func TestCPPBuilder_Interface_Good(t *testing.T) { + var _ build.Builder = (*CPPBuilder)(nil) + var _ build.Builder = NewCPPBuilder() +} diff --git a/pkg/build/discovery.go b/pkg/build/discovery.go index ea4ee121..209c2cf8 100644 --- a/pkg/build/discovery.go +++ b/pkg/build/discovery.go @@ -13,6 +13,7 @@ const ( markerWails = "wails.json" markerNodePackage = "package.json" markerComposer = "composer.json" + markerCMake = "CMakeLists.txt" ) // projectMarker maps a marker file to its project type. @@ -28,6 +29,7 @@ var markers = []projectMarker{ {markerGoMod, ProjectTypeGo}, {markerNodePackage, ProjectTypeNode}, {markerComposer, ProjectTypePHP}, + {markerCMake, ProjectTypeCPP}, } // Discover detects project types in the given directory by checking for marker files. @@ -83,6 +85,11 @@ func IsPHPProject(fs io.Medium, dir string) bool { return fileExists(fs, filepath.Join(dir, markerComposer)) } +// IsCPPProject checks if the directory contains a C++ project. +func IsCPPProject(fs io.Medium, dir string) bool { + return fileExists(fs, filepath.Join(dir, markerCMake)) +} + // fileExists checks if a file exists and is not a directory. func fileExists(fs io.Medium, path string) bool { return fs.IsFile(path) diff --git a/pkg/build/discovery_test.go b/pkg/build/discovery_test.go index 414b1a33..11b4cc60 100644 --- a/pkg/build/discovery_test.go +++ b/pkg/build/discovery_test.go @@ -52,6 +52,13 @@ func TestDiscover_Good(t *testing.T) { assert.Equal(t, []ProjectType{ProjectTypePHP}, types) }) + t.Run("detects C++ project", func(t *testing.T) { + dir := setupTestDir(t, "CMakeLists.txt") + types, err := Discover(fs, dir) + assert.NoError(t, err) + assert.Equal(t, []ProjectType{ProjectTypeCPP}, types) + }) + t.Run("detects multiple project types", func(t *testing.T) { dir := setupTestDir(t, "go.mod", "package.json") types, err := Discover(fs, dir) @@ -155,6 +162,19 @@ func TestIsNodeProject_Good(t *testing.T) { }) } +func TestIsCPPProject_Good(t *testing.T) { + fs := io.Local + t.Run("true with CMakeLists.txt", func(t *testing.T) { + dir := setupTestDir(t, "CMakeLists.txt") + assert.True(t, IsCPPProject(fs, dir)) + }) + + t.Run("false without CMakeLists.txt", func(t *testing.T) { + dir := t.TempDir() + assert.False(t, IsCPPProject(fs, dir)) + }) +} + func TestIsPHPProject_Good(t *testing.T) { fs := io.Local t.Run("true with composer.json", func(t *testing.T) { @@ -209,6 +229,7 @@ func TestDiscover_Testdata(t *testing.T) { {"wails-project", "wails-project", []ProjectType{ProjectTypeWails, ProjectTypeGo}}, {"node-project", "node-project", []ProjectType{ProjectTypeNode}}, {"php-project", "php-project", []ProjectType{ProjectTypePHP}}, + {"cpp-project", "cpp-project", []ProjectType{ProjectTypeCPP}}, {"multi-project", "multi-project", []ProjectType{ProjectTypeGo, ProjectTypeNode}}, {"empty-project", "empty-project", []ProjectType{}}, } diff --git a/pkg/build/testdata/cpp-project/CMakeLists.txt b/pkg/build/testdata/cpp-project/CMakeLists.txt new file mode 100644 index 00000000..f6ba2c76 --- /dev/null +++ b/pkg/build/testdata/cpp-project/CMakeLists.txt @@ -0,0 +1,2 @@ +cmake_minimum_required(VERSION 3.16) +project(TestCPP) diff --git a/pkg/cli/app.go b/pkg/cli/app.go index e904b178..a9f60546 100644 --- a/pkg/cli/app.go +++ b/pkg/cli/app.go @@ -17,10 +17,36 @@ const ( AppName = "core" ) -// AppVersion is set at build time via ldflags: +// Build-time variables set via ldflags (SemVer 2.0.0): // -// go build -ldflags="-X github.com/host-uk/core/pkg/cli.AppVersion=v1.0.0" -var AppVersion = "dev" +// go build -ldflags="-X github.com/host-uk/core/pkg/cli.AppVersion=1.2.0 \ +// -X github.com/host-uk/core/pkg/cli.BuildCommit=df94c24 \ +// -X github.com/host-uk/core/pkg/cli.BuildDate=2026-02-06 \ +// -X github.com/host-uk/core/pkg/cli.BuildPreRelease=dev.8" +var ( + AppVersion = "0.0.0" + BuildCommit = "unknown" + BuildDate = "unknown" + BuildPreRelease = "" +) + +// SemVer returns the full SemVer 2.0.0 version string. +// - Release: 1.2.0 +// - Pre-release: 1.2.0-dev.8 +// - Full: 1.2.0-dev.8+df94c24.20260206 +func SemVer() string { + v := AppVersion + if BuildPreRelease != "" { + v += "-" + BuildPreRelease + } + if BuildCommit != "unknown" { + v += "+" + BuildCommit + if BuildDate != "unknown" { + v += "." + BuildDate + } + } + return v +} // Main initialises and runs the CLI application. // This is the main entry point for the CLI. @@ -38,7 +64,7 @@ func Main() { // Initialise CLI runtime with services if err := Init(Options{ AppName: AppName, - Version: AppVersion, + Version: SemVer(), Services: []framework.Option{ framework.WithName("i18n", NewI18nService(I18nOptions{})), framework.WithName("log", NewLogService(log.Options{ diff --git a/pkg/forge/client.go b/pkg/forge/client.go new file mode 100644 index 00000000..601d1cfe --- /dev/null +++ b/pkg/forge/client.go @@ -0,0 +1,37 @@ +// Package forge provides a thin wrapper around the Forgejo Go SDK +// for managing repositories, issues, and pull requests on a Forgejo instance. +// +// Authentication is resolved from config file, environment variables, or flag overrides: +// +// 1. ~/.core/config.yaml keys: forge.token, forge.url +// 2. FORGE_TOKEN + FORGE_URL environment variables (override config file) +// 3. Flag overrides via core forge config --url/--token (highest priority) +package forge + +import ( + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/log" +) + +// Client wraps the Forgejo SDK client with config-based auth. +type Client struct { + api *forgejo.Client + url string +} + +// New creates a new Forgejo API client for the given URL and token. +func New(url, token string) (*Client, error) { + api, err := forgejo.NewClient(url, forgejo.SetToken(token)) + if err != nil { + return nil, log.E("forge.New", "failed to create client", err) + } + + return &Client{api: api, url: url}, nil +} + +// API exposes the underlying SDK client for direct access. +func (c *Client) API() *forgejo.Client { return c.api } + +// URL returns the Forgejo instance URL. +func (c *Client) URL() string { return c.url } diff --git a/pkg/forge/config.go b/pkg/forge/config.go new file mode 100644 index 00000000..e641001e --- /dev/null +++ b/pkg/forge/config.go @@ -0,0 +1,92 @@ +package forge + +import ( + "os" + + "github.com/host-uk/core/pkg/config" + "github.com/host-uk/core/pkg/log" +) + +const ( + // ConfigKeyURL is the config key for the Forgejo instance URL. + ConfigKeyURL = "forge.url" + // ConfigKeyToken is the config key for the Forgejo API token. + ConfigKeyToken = "forge.token" + + // DefaultURL is the default Forgejo instance URL. + DefaultURL = "http://localhost:4000" +) + +// NewFromConfig creates a Forgejo client using the standard config resolution: +// +// 1. ~/.core/config.yaml keys: forge.token, forge.url +// 2. FORGE_TOKEN + FORGE_URL environment variables (override config file) +// 3. Provided flag overrides (highest priority; pass empty to skip) +func NewFromConfig(flagURL, flagToken string) (*Client, error) { + url, token, err := ResolveConfig(flagURL, flagToken) + if err != nil { + return nil, err + } + + if token == "" { + return nil, log.E("forge.NewFromConfig", "no API token configured (set FORGE_TOKEN or run: core forge config --token TOKEN)", nil) + } + + return New(url, token) +} + +// ResolveConfig resolves the Forgejo URL and token from all config sources. +// Flag values take highest priority, then env vars, then config file. +func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { + // Start with config file values + cfg, cfgErr := config.New() + if cfgErr == nil { + _ = cfg.Get(ConfigKeyURL, &url) + _ = cfg.Get(ConfigKeyToken, &token) + } + + // Overlay environment variables + if envURL := os.Getenv("FORGE_URL"); envURL != "" { + url = envURL + } + if envToken := os.Getenv("FORGE_TOKEN"); envToken != "" { + token = envToken + } + + // Overlay flag values (highest priority) + if flagURL != "" { + url = flagURL + } + if flagToken != "" { + token = flagToken + } + + // Default URL if nothing configured + if url == "" { + url = DefaultURL + } + + return url, token, nil +} + +// SaveConfig persists the Forgejo URL and/or token to the config file. +func SaveConfig(url, token string) error { + cfg, err := config.New() + if err != nil { + return log.E("forge.SaveConfig", "failed to load config", err) + } + + if url != "" { + if err := cfg.Set(ConfigKeyURL, url); err != nil { + return log.E("forge.SaveConfig", "failed to save URL", err) + } + } + + if token != "" { + if err := cfg.Set(ConfigKeyToken, token); err != nil { + return log.E("forge.SaveConfig", "failed to save token", err) + } + } + + return nil +} diff --git a/pkg/forge/issues.go b/pkg/forge/issues.go new file mode 100644 index 00000000..80c60997 --- /dev/null +++ b/pkg/forge/issues.go @@ -0,0 +1,119 @@ +package forge + +import ( + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/log" +) + +// ListIssuesOpts configures issue listing. +type ListIssuesOpts struct { + State string // "open", "closed", "all" + Page int + Limit int +} + +// ListIssues returns issues for the given repository. +func (c *Client) ListIssues(owner, repo string, opts ListIssuesOpts) ([]*forgejo.Issue, error) { + state := forgejo.StateOpen + switch opts.State { + case "closed": + state = forgejo.StateClosed + case "all": + state = forgejo.StateAll + } + + limit := opts.Limit + if limit == 0 { + limit = 50 + } + + page := opts.Page + if page == 0 { + page = 1 + } + + issues, _, err := c.api.ListRepoIssues(owner, repo, forgejo.ListIssueOption{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: limit}, + State: state, + Type: forgejo.IssueTypeIssue, + }) + if err != nil { + return nil, log.E("forge.ListIssues", "failed to list issues", err) + } + + return issues, nil +} + +// GetIssue returns a single issue by number. +func (c *Client) GetIssue(owner, repo string, number int64) (*forgejo.Issue, error) { + issue, _, err := c.api.GetIssue(owner, repo, number) + if err != nil { + return nil, log.E("forge.GetIssue", "failed to get issue", err) + } + + return issue, nil +} + +// CreateIssue creates a new issue in the given repository. +func (c *Client) CreateIssue(owner, repo string, opts forgejo.CreateIssueOption) (*forgejo.Issue, error) { + issue, _, err := c.api.CreateIssue(owner, repo, opts) + if err != nil { + return nil, log.E("forge.CreateIssue", "failed to create issue", err) + } + + return issue, nil +} + +// EditIssue edits an existing issue. +func (c *Client) EditIssue(owner, repo string, number int64, opts forgejo.EditIssueOption) (*forgejo.Issue, error) { + issue, _, err := c.api.EditIssue(owner, repo, number, opts) + if err != nil { + return nil, log.E("forge.EditIssue", "failed to edit issue", err) + } + + return issue, nil +} + +// ListPullRequests returns pull requests for the given repository. +func (c *Client) ListPullRequests(owner, repo string, state string) ([]*forgejo.PullRequest, error) { + st := forgejo.StateOpen + switch state { + case "closed": + st = forgejo.StateClosed + case "all": + st = forgejo.StateAll + } + + var all []*forgejo.PullRequest + page := 1 + + for { + prs, resp, err := c.api.ListRepoPullRequests(owner, repo, forgejo.ListPullRequestsOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, + State: st, + }) + if err != nil { + return nil, log.E("forge.ListPullRequests", "failed to list pull requests", err) + } + + all = append(all, prs...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + +// GetPullRequest returns a single pull request by number. +func (c *Client) GetPullRequest(owner, repo string, number int64) (*forgejo.PullRequest, error) { + pr, _, err := c.api.GetPullRequest(owner, repo, number) + if err != nil { + return nil, log.E("forge.GetPullRequest", "failed to get pull request", err) + } + + return pr, nil +} diff --git a/pkg/forge/labels.go b/pkg/forge/labels.go new file mode 100644 index 00000000..89f2de28 --- /dev/null +++ b/pkg/forge/labels.go @@ -0,0 +1,60 @@ +package forge + +import ( + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/log" +) + +// ListOrgLabels returns all labels for repos in the given organisation. +// Note: The Forgejo SDK does not have a dedicated org-level labels endpoint. +// This lists labels from the first repo found, which works when orgs use shared label sets. +// For org-wide label management, use ListRepoLabels with a specific repo. +func (c *Client) ListOrgLabels(org string) ([]*forgejo.Label, error) { + // Forgejo doesn't expose org-level labels via SDK — list repos and aggregate unique labels. + repos, err := c.ListOrgRepos(org) + if err != nil { + return nil, err + } + + if len(repos) == 0 { + return nil, nil + } + + // Use the first repo's labels as representative of the org's label set. + return c.ListRepoLabels(repos[0].Owner.UserName, repos[0].Name) +} + +// ListRepoLabels returns all labels for a repository. +func (c *Client) ListRepoLabels(owner, repo string) ([]*forgejo.Label, error) { + var all []*forgejo.Label + page := 1 + + for { + labels, resp, err := c.api.ListRepoLabels(owner, repo, forgejo.ListLabelsOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, log.E("forge.ListRepoLabels", "failed to list repo labels", err) + } + + all = append(all, labels...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + +// CreateRepoLabel creates a label on a repository. +func (c *Client) CreateRepoLabel(owner, repo string, opts forgejo.CreateLabelOption) (*forgejo.Label, error) { + label, _, err := c.api.CreateLabel(owner, repo, opts) + if err != nil { + return nil, log.E("forge.CreateRepoLabel", "failed to create repo label", err) + } + + return label, nil +} diff --git a/pkg/forge/meta.go b/pkg/forge/meta.go new file mode 100644 index 00000000..642f6766 --- /dev/null +++ b/pkg/forge/meta.go @@ -0,0 +1,144 @@ +package forge + +import ( + "time" + + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/log" +) + +// PRMeta holds structural signals from a pull request, +// used by the pipeline MetaReader for AI-driven workflows. +type PRMeta struct { + Number int64 + Title string + State string + Author string + Branch string + BaseBranch string + Labels []string + Assignees []string + IsMerged bool + CreatedAt time.Time + UpdatedAt time.Time + CommentCount int +} + +// Comment represents a comment with metadata. +type Comment struct { + ID int64 + Author string + Body string + CreatedAt time.Time + UpdatedAt time.Time +} + +const commentPageSize = 50 + +// GetPRMeta returns structural signals for a pull request. +// This is the Forgejo side of the dual MetaReader described in the pipeline design. +func (c *Client) GetPRMeta(owner, repo string, pr int64) (*PRMeta, error) { + pull, _, err := c.api.GetPullRequest(owner, repo, pr) + if err != nil { + return nil, log.E("forge.GetPRMeta", "failed to get PR metadata", err) + } + + meta := &PRMeta{ + Number: pull.Index, + Title: pull.Title, + State: string(pull.State), + Branch: pull.Head.Ref, + BaseBranch: pull.Base.Ref, + IsMerged: pull.HasMerged, + } + + if pull.Created != nil { + meta.CreatedAt = *pull.Created + } + if pull.Updated != nil { + meta.UpdatedAt = *pull.Updated + } + + if pull.Poster != nil { + meta.Author = pull.Poster.UserName + } + + for _, label := range pull.Labels { + meta.Labels = append(meta.Labels, label.Name) + } + + for _, assignee := range pull.Assignees { + meta.Assignees = append(meta.Assignees, assignee.UserName) + } + + // Fetch comment count from the issue side (PRs are issues in Forgejo). + // Paginate to get an accurate count. + count := 0 + page := 1 + for { + comments, _, listErr := c.api.ListIssueComments(owner, repo, pr, forgejo.ListIssueCommentOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: commentPageSize}, + }) + if listErr != nil { + break + } + count += len(comments) + if len(comments) < commentPageSize { + break + } + page++ + } + meta.CommentCount = count + + return meta, nil +} + +// GetCommentBodies returns all comment bodies for a pull request. +func (c *Client) GetCommentBodies(owner, repo string, pr int64) ([]Comment, error) { + var comments []Comment + page := 1 + + for { + raw, _, err := c.api.ListIssueComments(owner, repo, pr, forgejo.ListIssueCommentOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: commentPageSize}, + }) + if err != nil { + return nil, log.E("forge.GetCommentBodies", "failed to get PR comments", err) + } + + if len(raw) == 0 { + break + } + + for _, rc := range raw { + comment := Comment{ + ID: rc.ID, + Body: rc.Body, + CreatedAt: rc.Created, + UpdatedAt: rc.Updated, + } + if rc.Poster != nil { + comment.Author = rc.Poster.UserName + } + comments = append(comments, comment) + } + + if len(raw) < commentPageSize { + break + } + page++ + } + + return comments, nil +} + +// GetIssueBody returns the body text of an issue. +func (c *Client) GetIssueBody(owner, repo string, issue int64) (string, error) { + iss, _, err := c.api.GetIssue(owner, repo, issue) + if err != nil { + return "", log.E("forge.GetIssueBody", "failed to get issue body", err) + } + + return iss.Body, nil +} diff --git a/pkg/forge/orgs.go b/pkg/forge/orgs.go new file mode 100644 index 00000000..0c559d16 --- /dev/null +++ b/pkg/forge/orgs.go @@ -0,0 +1,51 @@ +package forge + +import ( + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/log" +) + +// ListMyOrgs returns all organisations for the authenticated user. +func (c *Client) ListMyOrgs() ([]*forgejo.Organization, error) { + var all []*forgejo.Organization + page := 1 + + for { + orgs, resp, err := c.api.ListMyOrgs(forgejo.ListOrgsOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, log.E("forge.ListMyOrgs", "failed to list orgs", err) + } + + all = append(all, orgs...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + +// GetOrg returns a single organisation by name. +func (c *Client) GetOrg(name string) (*forgejo.Organization, error) { + org, _, err := c.api.GetOrg(name) + if err != nil { + return nil, log.E("forge.GetOrg", "failed to get org", err) + } + + return org, nil +} + +// CreateOrg creates a new organisation. +func (c *Client) CreateOrg(opts forgejo.CreateOrgOption) (*forgejo.Organization, error) { + org, _, err := c.api.CreateOrg(opts) + if err != nil { + return nil, log.E("forge.CreateOrg", "failed to create org", err) + } + + return org, nil +} diff --git a/pkg/forge/repos.go b/pkg/forge/repos.go new file mode 100644 index 00000000..62f6b741 --- /dev/null +++ b/pkg/forge/repos.go @@ -0,0 +1,96 @@ +package forge + +import ( + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/log" +) + +// ListOrgRepos returns all repositories for the given organisation. +func (c *Client) ListOrgRepos(org string) ([]*forgejo.Repository, error) { + var all []*forgejo.Repository + page := 1 + + for { + repos, resp, err := c.api.ListOrgRepos(org, forgejo.ListOrgReposOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, log.E("forge.ListOrgRepos", "failed to list org repos", err) + } + + all = append(all, repos...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + +// ListUserRepos returns all repositories for the authenticated user. +func (c *Client) ListUserRepos() ([]*forgejo.Repository, error) { + var all []*forgejo.Repository + page := 1 + + for { + repos, resp, err := c.api.ListMyRepos(forgejo.ListReposOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, log.E("forge.ListUserRepos", "failed to list user repos", err) + } + + all = append(all, repos...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + +// GetRepo returns a single repository by owner and name. +func (c *Client) GetRepo(owner, name string) (*forgejo.Repository, error) { + repo, _, err := c.api.GetRepo(owner, name) + if err != nil { + return nil, log.E("forge.GetRepo", "failed to get repo", err) + } + + return repo, nil +} + +// CreateOrgRepo creates a new empty repository under an organisation. +func (c *Client) CreateOrgRepo(org string, opts forgejo.CreateRepoOption) (*forgejo.Repository, error) { + repo, _, err := c.api.CreateOrgRepo(org, opts) + if err != nil { + return nil, log.E("forge.CreateOrgRepo", "failed to create org repo", err) + } + + return repo, nil +} + +// DeleteRepo deletes a repository from Forgejo. +func (c *Client) DeleteRepo(owner, name string) error { + _, err := c.api.DeleteRepo(owner, name) + if err != nil { + return log.E("forge.DeleteRepo", "failed to delete repo", err) + } + + return nil +} + +// MigrateRepo migrates a repository from an external service using the Forgejo migration API. +// Unlike CreateMirror, this supports importing issues, labels, PRs, and more. +func (c *Client) MigrateRepo(opts forgejo.MigrateRepoOption) (*forgejo.Repository, error) { + repo, _, err := c.api.MigrateRepo(opts) + if err != nil { + return nil, log.E("forge.MigrateRepo", "failed to migrate repo", err) + } + + return repo, nil +} diff --git a/pkg/forge/webhooks.go b/pkg/forge/webhooks.go new file mode 100644 index 00000000..a2c49bd1 --- /dev/null +++ b/pkg/forge/webhooks.go @@ -0,0 +1,41 @@ +package forge + +import ( + forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/host-uk/core/pkg/log" +) + +// CreateRepoWebhook creates a webhook on a repository. +func (c *Client) CreateRepoWebhook(owner, repo string, opts forgejo.CreateHookOption) (*forgejo.Hook, error) { + hook, _, err := c.api.CreateRepoHook(owner, repo, opts) + if err != nil { + return nil, log.E("forge.CreateRepoWebhook", "failed to create repo webhook", err) + } + + return hook, nil +} + +// ListRepoWebhooks returns all webhooks for a repository. +func (c *Client) ListRepoWebhooks(owner, repo string) ([]*forgejo.Hook, error) { + var all []*forgejo.Hook + page := 1 + + for { + hooks, resp, err := c.api.ListRepoHooks(owner, repo, forgejo.ListHooksOptions{ + ListOptions: forgejo.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, log.E("forge.ListRepoWebhooks", "failed to list repo webhooks", err) + } + + all = append(all, hooks...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index 4f6d8f4f..b901d3de 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -282,6 +282,9 @@ "vm.status.short": "Show development VM status", "no_changes": "No uncommitted changes found.", "no_git_repos": "No git repositories found.", + "modified": "{{.Count}} modified", + "staged": "{{.Count}} staged", + "untracked": "{{.Count}} untracked", "confirm_claude_commit": "Have Claude commit these repos?", "health.short": "Quick health check across all repos", "health.long": "Shows a summary of repository health across all repos in the workspace.", @@ -298,6 +301,12 @@ "status.clean": "clean", "commit.short": "Claude-assisted commits across repos", "push.short": "Push commits across all repos", + "push.long": "Push commits to remote across all repos in the workspace.", + "push.flag.force": "Push without confirmation", + "push.all_up_to_date": "All repos are up to date.", + "push.confirm_push": "Push {{.Commits}} commit(s) across {{.Repos}} repo(s)?", + "push.done_pushed": "Pushed {{.Count}} repo(s)", + "push.pull_and_retry": "Pull and retry push?", "push.diverged": "branch has diverged from remote", "push.diverged_help": "Some repos have diverged (local and remote have different commits).", "push.uncommitted_changes_commit": "You have uncommitted changes. Commit with Claude first?", @@ -731,7 +740,9 @@ "succeeded": "{{.Count}} succeeded", "failed": "{{.Count}} failed", "skipped": "{{.Count}} skipped", - "passed": "{{.Count}} passed" + "passed": "{{.Count}} passed", + "commits": "{{.Count}} commit(s) ahead", + "repos_unpushed": "{{.Count}} repo(s) with unpushed commits" } }, "error": { @@ -748,6 +759,7 @@ }, "cli": { "pass": "PASS", - "fail": "FAIL" + "fail": "FAIL", + "aborted": "Aborted." } } diff --git a/pkg/io/datanode/client.go b/pkg/io/datanode/client.go new file mode 100644 index 00000000..8a385a55 --- /dev/null +++ b/pkg/io/datanode/client.go @@ -0,0 +1,573 @@ +// Package datanode provides an in-memory io.Medium backed by Borg's DataNode. +// +// DataNode is an in-memory fs.FS that serializes to tar. Wrapping it as a +// Medium lets any code that works with io.Medium transparently operate on +// an in-memory filesystem that can be snapshotted, shipped as a crash report, +// or wrapped in a TIM container for runc execution. +package datanode + +import ( + goio "io" + "io/fs" + "os" + "path" + "sort" + "strings" + "sync" + "time" + + "github.com/Snider/Borg/pkg/datanode" + coreerr "github.com/host-uk/core/pkg/framework/core" +) + +// Medium is an in-memory storage backend backed by a Borg DataNode. +// All paths are relative (no leading slash). Thread-safe via RWMutex. +type Medium struct { + dn *datanode.DataNode + dirs map[string]bool // explicit directory tracking + mu sync.RWMutex +} + +// New creates a new empty DataNode Medium. +func New() *Medium { + return &Medium{ + dn: datanode.New(), + dirs: make(map[string]bool), + } +} + +// FromTar creates a Medium from a tarball, restoring all files. +func FromTar(data []byte) (*Medium, error) { + dn, err := datanode.FromTar(data) + if err != nil { + return nil, coreerr.E("datanode.FromTar", "failed to restore", err) + } + return &Medium{ + dn: dn, + dirs: make(map[string]bool), + }, nil +} + +// Snapshot serializes the entire filesystem to a tarball. +// Use this for crash reports, workspace packaging, or TIM creation. +func (m *Medium) Snapshot() ([]byte, error) { + m.mu.RLock() + defer m.mu.RUnlock() + data, err := m.dn.ToTar() + if err != nil { + return nil, coreerr.E("datanode.Snapshot", "tar failed", err) + } + return data, nil +} + +// Restore replaces the filesystem contents from a tarball. +func (m *Medium) Restore(data []byte) error { + dn, err := datanode.FromTar(data) + if err != nil { + return coreerr.E("datanode.Restore", "tar failed", err) + } + m.mu.Lock() + defer m.mu.Unlock() + m.dn = dn + m.dirs = make(map[string]bool) + return nil +} + +// DataNode returns the underlying Borg DataNode. +// Use this to wrap the filesystem in a TIM container. +func (m *Medium) DataNode() *datanode.DataNode { + m.mu.RLock() + defer m.mu.RUnlock() + return m.dn +} + +// clean normalizes a path: strips leading slash, cleans traversal. +func clean(p string) string { + p = strings.TrimPrefix(p, "/") + p = path.Clean(p) + if p == "." { + return "" + } + return p +} + +// --- io.Medium interface --- + +func (m *Medium) Read(p string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p = clean(p) + f, err := m.dn.Open(p) + if err != nil { + return "", coreerr.E("datanode.Read", "not found: "+p, os.ErrNotExist) + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return "", coreerr.E("datanode.Read", "stat failed: "+p, err) + } + if info.IsDir() { + return "", coreerr.E("datanode.Read", "is a directory: "+p, os.ErrInvalid) + } + + data, err := goio.ReadAll(f) + if err != nil { + return "", coreerr.E("datanode.Read", "read failed: "+p, err) + } + return string(data), nil +} + +func (m *Medium) Write(p, content string) error { + m.mu.Lock() + defer m.mu.Unlock() + + p = clean(p) + if p == "" { + return coreerr.E("datanode.Write", "empty path", os.ErrInvalid) + } + m.dn.AddData(p, []byte(content)) + + // ensure parent dirs are tracked + m.ensureDirsLocked(path.Dir(p)) + return nil +} + +func (m *Medium) EnsureDir(p string) error { + m.mu.Lock() + defer m.mu.Unlock() + + p = clean(p) + if p == "" { + return nil + } + m.ensureDirsLocked(p) + return nil +} + +// ensureDirsLocked marks a directory and all ancestors as existing. +// Caller must hold m.mu. +func (m *Medium) ensureDirsLocked(p string) { + for p != "" && p != "." { + m.dirs[p] = true + p = path.Dir(p) + if p == "." { + break + } + } +} + +func (m *Medium) IsFile(p string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + p = clean(p) + info, err := m.dn.Stat(p) + return err == nil && !info.IsDir() +} + +func (m *Medium) FileGet(p string) (string, error) { + return m.Read(p) +} + +func (m *Medium) FileSet(p, content string) error { + return m.Write(p, content) +} + +func (m *Medium) Delete(p string) error { + m.mu.Lock() + defer m.mu.Unlock() + + p = clean(p) + if p == "" { + return coreerr.E("datanode.Delete", "cannot delete root", os.ErrPermission) + } + + // Check if it's a file in the DataNode + info, err := m.dn.Stat(p) + if err != nil { + // Check explicit dirs + if m.dirs[p] { + // Check if dir is empty + if m.hasPrefixLocked(p + "/") { + return coreerr.E("datanode.Delete", "directory not empty: "+p, os.ErrExist) + } + delete(m.dirs, p) + return nil + } + return coreerr.E("datanode.Delete", "not found: "+p, os.ErrNotExist) + } + + if info.IsDir() { + if m.hasPrefixLocked(p + "/") { + return coreerr.E("datanode.Delete", "directory not empty: "+p, os.ErrExist) + } + delete(m.dirs, p) + return nil + } + + // Remove the file by creating a new DataNode without it + m.removeFileLocked(p) + return nil +} + +func (m *Medium) DeleteAll(p string) error { + m.mu.Lock() + defer m.mu.Unlock() + + p = clean(p) + if p == "" { + return coreerr.E("datanode.DeleteAll", "cannot delete root", os.ErrPermission) + } + + prefix := p + "/" + found := false + + // Check if p itself is a file + info, err := m.dn.Stat(p) + if err == nil && !info.IsDir() { + m.removeFileLocked(p) + found = true + } + + // Remove all files under prefix + entries, _ := m.collectAllLocked() + for _, name := range entries { + if name == p || strings.HasPrefix(name, prefix) { + m.removeFileLocked(name) + found = true + } + } + + // Remove explicit dirs under prefix + for d := range m.dirs { + if d == p || strings.HasPrefix(d, prefix) { + delete(m.dirs, d) + found = true + } + } + + if !found { + return coreerr.E("datanode.DeleteAll", "not found: "+p, os.ErrNotExist) + } + return nil +} + +func (m *Medium) Rename(oldPath, newPath string) error { + m.mu.Lock() + defer m.mu.Unlock() + + oldPath = clean(oldPath) + newPath = clean(newPath) + + // Check if source is a file + info, err := m.dn.Stat(oldPath) + if err != nil { + return coreerr.E("datanode.Rename", "not found: "+oldPath, os.ErrNotExist) + } + + if !info.IsDir() { + // Read old, write new, delete old + f, err := m.dn.Open(oldPath) + if err != nil { + return coreerr.E("datanode.Rename", "open failed: "+oldPath, err) + } + data, err := goio.ReadAll(f) + f.Close() + if err != nil { + return coreerr.E("datanode.Rename", "read failed: "+oldPath, err) + } + m.dn.AddData(newPath, data) + m.ensureDirsLocked(path.Dir(newPath)) + m.removeFileLocked(oldPath) + return nil + } + + // Directory rename: move all files under oldPath to newPath + oldPrefix := oldPath + "/" + newPrefix := newPath + "/" + + entries, _ := m.collectAllLocked() + for _, name := range entries { + if strings.HasPrefix(name, oldPrefix) { + newName := newPrefix + strings.TrimPrefix(name, oldPrefix) + f, err := m.dn.Open(name) + if err != nil { + continue + } + data, _ := goio.ReadAll(f) + f.Close() + m.dn.AddData(newName, data) + m.removeFileLocked(name) + } + } + + // Move explicit dirs + dirsToMove := make(map[string]string) + for d := range m.dirs { + if d == oldPath || strings.HasPrefix(d, oldPrefix) { + newD := newPath + strings.TrimPrefix(d, oldPath) + dirsToMove[d] = newD + } + } + for old, nw := range dirsToMove { + delete(m.dirs, old) + m.dirs[nw] = true + } + + return nil +} + +func (m *Medium) List(p string) ([]fs.DirEntry, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p = clean(p) + + entries, err := m.dn.ReadDir(p) + if err != nil { + // Check explicit dirs + if p == "" || m.dirs[p] { + return []fs.DirEntry{}, nil + } + return nil, coreerr.E("datanode.List", "not found: "+p, os.ErrNotExist) + } + + // Also include explicit subdirectories not discovered via files + prefix := p + if prefix != "" { + prefix += "/" + } + seen := make(map[string]bool) + for _, e := range entries { + seen[e.Name()] = true + } + + for d := range m.dirs { + if !strings.HasPrefix(d, prefix) { + continue + } + rest := strings.TrimPrefix(d, prefix) + if rest == "" { + continue + } + first := strings.SplitN(rest, "/", 2)[0] + if !seen[first] { + seen[first] = true + entries = append(entries, &dirEntry{name: first}) + } + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + return entries, nil +} + +func (m *Medium) Stat(p string) (fs.FileInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p = clean(p) + if p == "" { + return &fileInfo{name: ".", isDir: true, mode: fs.ModeDir | 0755}, nil + } + + info, err := m.dn.Stat(p) + if err == nil { + return info, nil + } + + if m.dirs[p] { + return &fileInfo{name: path.Base(p), isDir: true, mode: fs.ModeDir | 0755}, nil + } + return nil, coreerr.E("datanode.Stat", "not found: "+p, os.ErrNotExist) +} + +func (m *Medium) Open(p string) (fs.File, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p = clean(p) + return m.dn.Open(p) +} + +func (m *Medium) Create(p string) (goio.WriteCloser, error) { + p = clean(p) + if p == "" { + return nil, coreerr.E("datanode.Create", "empty path", os.ErrInvalid) + } + return &writeCloser{m: m, path: p}, nil +} + +func (m *Medium) Append(p string) (goio.WriteCloser, error) { + p = clean(p) + if p == "" { + return nil, coreerr.E("datanode.Append", "empty path", os.ErrInvalid) + } + + // Read existing content + var existing []byte + m.mu.RLock() + f, err := m.dn.Open(p) + if err == nil { + existing, _ = goio.ReadAll(f) + f.Close() + } + m.mu.RUnlock() + + return &writeCloser{m: m, path: p, buf: existing}, nil +} + +func (m *Medium) ReadStream(p string) (goio.ReadCloser, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p = clean(p) + f, err := m.dn.Open(p) + if err != nil { + return nil, coreerr.E("datanode.ReadStream", "not found: "+p, os.ErrNotExist) + } + return f.(goio.ReadCloser), nil +} + +func (m *Medium) WriteStream(p string) (goio.WriteCloser, error) { + return m.Create(p) +} + +func (m *Medium) Exists(p string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + p = clean(p) + if p == "" { + return true // root always exists + } + _, err := m.dn.Stat(p) + if err == nil { + return true + } + return m.dirs[p] +} + +func (m *Medium) IsDir(p string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + p = clean(p) + if p == "" { + return true + } + info, err := m.dn.Stat(p) + if err == nil { + return info.IsDir() + } + return m.dirs[p] +} + +// --- internal helpers --- + +// hasPrefixLocked checks if any file path starts with prefix. Caller holds lock. +func (m *Medium) hasPrefixLocked(prefix string) bool { + entries, _ := m.collectAllLocked() + for _, name := range entries { + if strings.HasPrefix(name, prefix) { + return true + } + } + for d := range m.dirs { + if strings.HasPrefix(d, prefix) { + return true + } + } + return false +} + +// collectAllLocked returns all file paths in the DataNode. Caller holds lock. +func (m *Medium) collectAllLocked() ([]string, error) { + var names []string + err := fs.WalkDir(m.dn, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if !d.IsDir() { + names = append(names, p) + } + return nil + }) + return names, err +} + +// removeFileLocked removes a single file by rebuilding the DataNode. +// This is necessary because Borg's DataNode doesn't expose a Remove method. +// Caller must hold m.mu write lock. +func (m *Medium) removeFileLocked(target string) { + entries, _ := m.collectAllLocked() + newDN := datanode.New() + for _, name := range entries { + if name == target { + continue + } + f, err := m.dn.Open(name) + if err != nil { + continue + } + data, err := goio.ReadAll(f) + f.Close() + if err != nil { + continue + } + newDN.AddData(name, data) + } + m.dn = newDN +} + +// --- writeCloser buffers writes and flushes to DataNode on Close --- + +type writeCloser struct { + m *Medium + path string + buf []byte +} + +func (w *writeCloser) Write(p []byte) (int, error) { + w.buf = append(w.buf, p...) + return len(p), nil +} + +func (w *writeCloser) Close() error { + w.m.mu.Lock() + defer w.m.mu.Unlock() + + w.m.dn.AddData(w.path, w.buf) + w.m.ensureDirsLocked(path.Dir(w.path)) + return nil +} + +// --- fs types for explicit directories --- + +type dirEntry struct { + name string +} + +func (d *dirEntry) Name() string { return d.name } +func (d *dirEntry) IsDir() bool { return true } +func (d *dirEntry) Type() fs.FileMode { return fs.ModeDir } +func (d *dirEntry) Info() (fs.FileInfo, error) { return &fileInfo{name: d.name, isDir: true, mode: fs.ModeDir | 0755}, nil } + +type fileInfo struct { + name string + size int64 + mode fs.FileMode + modTime time.Time + isDir bool +} + +func (fi *fileInfo) Name() string { return fi.name } +func (fi *fileInfo) Size() int64 { return fi.size } +func (fi *fileInfo) Mode() fs.FileMode { return fi.mode } +func (fi *fileInfo) ModTime() time.Time { return fi.modTime } +func (fi *fileInfo) IsDir() bool { return fi.isDir } +func (fi *fileInfo) Sys() any { return nil } diff --git a/pkg/io/datanode/client_test.go b/pkg/io/datanode/client_test.go new file mode 100644 index 00000000..70ed2cc8 --- /dev/null +++ b/pkg/io/datanode/client_test.go @@ -0,0 +1,352 @@ +package datanode + +import ( + "io" + "testing" + + coreio "github.com/host-uk/core/pkg/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Compile-time check: Medium implements io.Medium. +var _ coreio.Medium = (*Medium)(nil) + +func TestReadWrite_Good(t *testing.T) { + m := New() + + err := m.Write("hello.txt", "world") + require.NoError(t, err) + + got, err := m.Read("hello.txt") + require.NoError(t, err) + assert.Equal(t, "world", got) +} + +func TestReadWrite_Bad(t *testing.T) { + m := New() + + _, err := m.Read("missing.txt") + assert.Error(t, err) + + err = m.Write("", "content") + assert.Error(t, err) +} + +func TestNestedPaths_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("a/b/c/deep.txt", "deep")) + + got, err := m.Read("a/b/c/deep.txt") + require.NoError(t, err) + assert.Equal(t, "deep", got) + + assert.True(t, m.IsDir("a")) + assert.True(t, m.IsDir("a/b")) + assert.True(t, m.IsDir("a/b/c")) +} + +func TestLeadingSlash_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("/leading/file.txt", "stripped")) + got, err := m.Read("leading/file.txt") + require.NoError(t, err) + assert.Equal(t, "stripped", got) + + got, err = m.Read("/leading/file.txt") + require.NoError(t, err) + assert.Equal(t, "stripped", got) +} + +func TestIsFile_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("file.go", "package main")) + + assert.True(t, m.IsFile("file.go")) + assert.False(t, m.IsFile("missing.go")) + assert.False(t, m.IsFile("")) // empty path +} + +func TestEnsureDir_Good(t *testing.T) { + m := New() + + require.NoError(t, m.EnsureDir("foo/bar/baz")) + + assert.True(t, m.IsDir("foo")) + assert.True(t, m.IsDir("foo/bar")) + assert.True(t, m.IsDir("foo/bar/baz")) + assert.True(t, m.Exists("foo/bar/baz")) +} + +func TestDelete_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("delete-me.txt", "bye")) + assert.True(t, m.Exists("delete-me.txt")) + + require.NoError(t, m.Delete("delete-me.txt")) + assert.False(t, m.Exists("delete-me.txt")) +} + +func TestDelete_Bad(t *testing.T) { + m := New() + + // Delete non-existent + assert.Error(t, m.Delete("ghost.txt")) + + // Delete non-empty dir + require.NoError(t, m.Write("dir/file.txt", "content")) + assert.Error(t, m.Delete("dir")) +} + +func TestDeleteAll_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("tree/a.txt", "a")) + require.NoError(t, m.Write("tree/sub/b.txt", "b")) + require.NoError(t, m.Write("keep.txt", "keep")) + + require.NoError(t, m.DeleteAll("tree")) + + assert.False(t, m.Exists("tree/a.txt")) + assert.False(t, m.Exists("tree/sub/b.txt")) + assert.True(t, m.Exists("keep.txt")) +} + +func TestRename_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("old.txt", "content")) + require.NoError(t, m.Rename("old.txt", "new.txt")) + + assert.False(t, m.Exists("old.txt")) + got, err := m.Read("new.txt") + require.NoError(t, err) + assert.Equal(t, "content", got) +} + +func TestRenameDir_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("src/a.go", "package a")) + require.NoError(t, m.Write("src/sub/b.go", "package b")) + + require.NoError(t, m.Rename("src", "dst")) + + assert.False(t, m.Exists("src/a.go")) + + got, err := m.Read("dst/a.go") + require.NoError(t, err) + assert.Equal(t, "package a", got) + + got, err = m.Read("dst/sub/b.go") + require.NoError(t, err) + assert.Equal(t, "package b", got) +} + +func TestList_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("root.txt", "r")) + require.NoError(t, m.Write("pkg/a.go", "a")) + require.NoError(t, m.Write("pkg/b.go", "b")) + require.NoError(t, m.Write("pkg/sub/c.go", "c")) + + entries, err := m.List("") + require.NoError(t, err) + + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name() + } + assert.Contains(t, names, "root.txt") + assert.Contains(t, names, "pkg") + + entries, err = m.List("pkg") + require.NoError(t, err) + names = make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name() + } + assert.Contains(t, names, "a.go") + assert.Contains(t, names, "b.go") + assert.Contains(t, names, "sub") +} + +func TestStat_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("stat.txt", "hello")) + + info, err := m.Stat("stat.txt") + require.NoError(t, err) + assert.Equal(t, int64(5), info.Size()) + assert.False(t, info.IsDir()) + + // Root stat + info, err = m.Stat("") + require.NoError(t, err) + assert.True(t, info.IsDir()) +} + +func TestOpen_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("open.txt", "opened")) + + f, err := m.Open("open.txt") + require.NoError(t, err) + defer f.Close() + + data, err := io.ReadAll(f) + require.NoError(t, err) + assert.Equal(t, "opened", string(data)) +} + +func TestCreateAppend_Good(t *testing.T) { + m := New() + + // Create + w, err := m.Create("new.txt") + require.NoError(t, err) + w.Write([]byte("hello")) + w.Close() + + got, err := m.Read("new.txt") + require.NoError(t, err) + assert.Equal(t, "hello", got) + + // Append + w, err = m.Append("new.txt") + require.NoError(t, err) + w.Write([]byte(" world")) + w.Close() + + got, err = m.Read("new.txt") + require.NoError(t, err) + assert.Equal(t, "hello world", got) +} + +func TestStreams_Good(t *testing.T) { + m := New() + + // WriteStream + ws, err := m.WriteStream("stream.txt") + require.NoError(t, err) + ws.Write([]byte("streamed")) + ws.Close() + + // ReadStream + rs, err := m.ReadStream("stream.txt") + require.NoError(t, err) + data, err := io.ReadAll(rs) + require.NoError(t, err) + assert.Equal(t, "streamed", string(data)) + rs.Close() +} + +func TestFileGetFileSet_Good(t *testing.T) { + m := New() + + require.NoError(t, m.FileSet("alias.txt", "via set")) + + got, err := m.FileGet("alias.txt") + require.NoError(t, err) + assert.Equal(t, "via set", got) +} + +func TestSnapshotRestore_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("a.txt", "alpha")) + require.NoError(t, m.Write("b/c.txt", "charlie")) + + snap, err := m.Snapshot() + require.NoError(t, err) + assert.NotEmpty(t, snap) + + // Restore into a new Medium + m2, err := FromTar(snap) + require.NoError(t, err) + + got, err := m2.Read("a.txt") + require.NoError(t, err) + assert.Equal(t, "alpha", got) + + got, err = m2.Read("b/c.txt") + require.NoError(t, err) + assert.Equal(t, "charlie", got) +} + +func TestRestore_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("original.txt", "before")) + + snap, err := m.Snapshot() + require.NoError(t, err) + + // Modify + require.NoError(t, m.Write("original.txt", "after")) + require.NoError(t, m.Write("extra.txt", "extra")) + + // Restore to snapshot + require.NoError(t, m.Restore(snap)) + + got, err := m.Read("original.txt") + require.NoError(t, err) + assert.Equal(t, "before", got) + + assert.False(t, m.Exists("extra.txt")) +} + +func TestDataNode_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("test.txt", "borg")) + + dn := m.DataNode() + assert.NotNil(t, dn) + + // Verify we can use the DataNode directly + f, err := dn.Open("test.txt") + require.NoError(t, err) + defer f.Close() + + data, err := io.ReadAll(f) + require.NoError(t, err) + assert.Equal(t, "borg", string(data)) +} + +func TestOverwrite_Good(t *testing.T) { + m := New() + + require.NoError(t, m.Write("file.txt", "v1")) + require.NoError(t, m.Write("file.txt", "v2")) + + got, err := m.Read("file.txt") + require.NoError(t, err) + assert.Equal(t, "v2", got) +} + +func TestExists_Good(t *testing.T) { + m := New() + + assert.True(t, m.Exists("")) // root + assert.False(t, m.Exists("x")) + + require.NoError(t, m.Write("x", "y")) + assert.True(t, m.Exists("x")) +} + +func TestReadDir_Ugly(t *testing.T) { + m := New() + + // Read from a file path (not a dir) should return empty or error + require.NoError(t, m.Write("file.txt", "content")) + _, err := m.Read("file.txt") + require.NoError(t, err) +} diff --git a/pkg/release/config_test.go b/pkg/release/config_test.go index 7af80e97..59d47e8f 100644 --- a/pkg/release/config_test.go +++ b/pkg/release/config_test.go @@ -327,6 +327,9 @@ func TestWriteConfig_Bad(t *testing.T) { }) t.Run("returns error when directory creation fails", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can create directories anywhere") + } // Use a path that doesn't exist and can't be created cfg := DefaultConfig() err := WriteConfig(io.Local, cfg, "/nonexistent/path/that/cannot/be/created") diff --git a/pkg/release/release_test.go b/pkg/release/release_test.go index d768e929..a0dce08d 100644 --- a/pkg/release/release_test.go +++ b/pkg/release/release_test.go @@ -141,6 +141,9 @@ func TestFindArtifacts_Bad(t *testing.T) { }) t.Run("returns error when dist directory is unreadable", func(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read any directory") + } dir := t.TempDir() distDir := filepath.Join(dir, "dist") require.NoError(t, os.MkdirAll(distDir, 0755)) From f602587522e68c62b8082b3525a8f9833030515a Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 8 Feb 2026 15:17:12 +0000 Subject: [PATCH 4/9] updates --- .../src/app/jellyfin/jellyfin.component.ts | 187 ++++++ cmd/community/index.html | 602 ++++++++++++++++++ cmd/core-app/CODEX_PROMPT.md | 100 +++ cmd/core-app/laravel/database/database.sqlite | Bin 0 -> 45056 bytes cmd/core-ide/build_service.go | 71 +++ cmd/core-ide/chat_service.go | 135 ++++ cmd/core-ide/frontend/angular.json | 91 +++ cmd/core-ide/frontend/package.json | 41 ++ .../frontend/src/app/app.component.ts | 18 + cmd/core-ide/frontend/src/app/app.config.ts | 9 + cmd/core-ide/frontend/src/app/app.routes.ts | 25 + .../frontend/src/app/build/build.component.ts | 184 ++++++ .../frontend/src/app/chat/chat.component.ts | 242 +++++++ .../src/app/dashboard/dashboard.component.ts | 163 +++++ .../src/app/jellyfin/jellyfin.component.ts | 175 +++++ .../frontend/src/app/main/main.component.ts | 119 ++++ .../src/app/settings/settings.component.ts | 105 +++ .../frontend/src/app/shared/wails.service.ts | 133 ++++ .../frontend/src/app/shared/ws.service.ts | 89 +++ .../frontend/src/app/tray/tray.component.ts | 124 ++++ cmd/core-ide/frontend/src/index.html | 13 + cmd/core-ide/frontend/src/main.ts | 6 + cmd/core-ide/frontend/src/styles.scss | 247 +++++++ cmd/core-ide/frontend/tsconfig.app.json | 13 + cmd/core-ide/frontend/tsconfig.json | 35 + cmd/core-ide/go.mod | 57 ++ cmd/core-ide/go.sum | 165 +++++ cmd/core-ide/icons/appicon.png | Bin 0 -> 76 bytes cmd/core-ide/icons/icons.go | 25 + cmd/core-ide/icons/tray-dark.png | Bin 0 -> 76 bytes cmd/core-ide/icons/tray-light.png | Bin 0 -> 76 bytes cmd/core-ide/icons/tray-template.png | Bin 0 -> 76 bytes cmd/core-ide/ide_service.go | 102 +++ cmd/core-ide/main.go | 151 +++++ github-projects-recovery.md | 403 ++++++++++++ pkg/mcp/ide/bridge.go | 182 ++++++ pkg/mcp/ide/bridge_test.go | 237 +++++++ pkg/mcp/ide/config.go | 48 ++ pkg/mcp/ide/ide.go | 57 ++ pkg/mcp/ide/tools_build.go | 109 ++++ pkg/mcp/ide/tools_chat.go | 191 ++++++ pkg/mcp/ide/tools_dashboard.go | 127 ++++ pkg/mcp/subsystem.go | 32 + pkg/mcp/subsystem_test.go | 114 ++++ 44 files changed, 4927 insertions(+) create mode 100644 cmd/bugseti/frontend/src/app/jellyfin/jellyfin.component.ts create mode 100644 cmd/community/index.html create mode 100644 cmd/core-app/CODEX_PROMPT.md create mode 100644 cmd/core-app/laravel/database/database.sqlite create mode 100644 cmd/core-ide/build_service.go create mode 100644 cmd/core-ide/chat_service.go create mode 100644 cmd/core-ide/frontend/angular.json create mode 100644 cmd/core-ide/frontend/package.json create mode 100644 cmd/core-ide/frontend/src/app/app.component.ts create mode 100644 cmd/core-ide/frontend/src/app/app.config.ts create mode 100644 cmd/core-ide/frontend/src/app/app.routes.ts create mode 100644 cmd/core-ide/frontend/src/app/build/build.component.ts create mode 100644 cmd/core-ide/frontend/src/app/chat/chat.component.ts create mode 100644 cmd/core-ide/frontend/src/app/dashboard/dashboard.component.ts create mode 100644 cmd/core-ide/frontend/src/app/jellyfin/jellyfin.component.ts create mode 100644 cmd/core-ide/frontend/src/app/main/main.component.ts create mode 100644 cmd/core-ide/frontend/src/app/settings/settings.component.ts create mode 100644 cmd/core-ide/frontend/src/app/shared/wails.service.ts create mode 100644 cmd/core-ide/frontend/src/app/shared/ws.service.ts create mode 100644 cmd/core-ide/frontend/src/app/tray/tray.component.ts create mode 100644 cmd/core-ide/frontend/src/index.html create mode 100644 cmd/core-ide/frontend/src/main.ts create mode 100644 cmd/core-ide/frontend/src/styles.scss create mode 100644 cmd/core-ide/frontend/tsconfig.app.json create mode 100644 cmd/core-ide/frontend/tsconfig.json create mode 100644 cmd/core-ide/go.mod create mode 100644 cmd/core-ide/go.sum create mode 100644 cmd/core-ide/icons/appicon.png create mode 100644 cmd/core-ide/icons/icons.go create mode 100644 cmd/core-ide/icons/tray-dark.png create mode 100644 cmd/core-ide/icons/tray-light.png create mode 100644 cmd/core-ide/icons/tray-template.png create mode 100644 cmd/core-ide/ide_service.go create mode 100644 cmd/core-ide/main.go create mode 100644 github-projects-recovery.md create mode 100644 pkg/mcp/ide/bridge.go create mode 100644 pkg/mcp/ide/bridge_test.go create mode 100644 pkg/mcp/ide/config.go create mode 100644 pkg/mcp/ide/ide.go create mode 100644 pkg/mcp/ide/tools_build.go create mode 100644 pkg/mcp/ide/tools_chat.go create mode 100644 pkg/mcp/ide/tools_dashboard.go create mode 100644 pkg/mcp/subsystem.go create mode 100644 pkg/mcp/subsystem_test.go diff --git a/cmd/bugseti/frontend/src/app/jellyfin/jellyfin.component.ts b/cmd/bugseti/frontend/src/app/jellyfin/jellyfin.component.ts new file mode 100644 index 00000000..0f7c8382 --- /dev/null +++ b/cmd/bugseti/frontend/src/app/jellyfin/jellyfin.component.ts @@ -0,0 +1,187 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; + +type Mode = 'web' | 'stream'; + +@Component({ + selector: 'app-jellyfin', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+
+
+

Jellyfin Player

+

Quick embed for media.lthn.ai or any Jellyfin host.

+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+ +
+ +

Set Item ID and API key to build stream URL.

+
+
+ `, + styles: [` + .jellyfin { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + padding: var(--spacing-md); + height: 100%; + overflow: auto; + background: var(--bg-secondary); + } + + .jellyfin__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + } + + .jellyfin__header h1 { + margin-bottom: var(--spacing-xs); + } + + .mode-switch { + display: flex; + gap: var(--spacing-xs); + } + + .mode-switch .btn.is-active { + border-color: var(--accent-primary); + color: var(--accent-primary); + } + + .jellyfin__config { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + } + + .stream-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--spacing-sm); + } + + .actions { + display: flex; + gap: var(--spacing-sm); + } + + .jellyfin__viewer { + flex: 1; + min-height: 420px; + padding: 0; + overflow: hidden; + } + + .jellyfin-frame, + .jellyfin-video { + border: 0; + width: 100%; + height: 100%; + min-height: 420px; + background: #000; + } + + .stream-hint { + padding: var(--spacing-md); + margin: 0; + } + `] +}) +export class JellyfinComponent { + mode: Mode = 'web'; + loaded = false; + + serverUrl = 'https://media.lthn.ai'; + itemId = ''; + apiKey = ''; + mediaSourceId = ''; + + safeWebUrl: SafeResourceUrl = this.sanitizer.bypassSecurityTrustResourceUrl('https://media.lthn.ai/web/index.html'); + streamUrl = ''; + + constructor(private sanitizer: DomSanitizer) {} + + load(): void { + const base = this.normalizeBase(this.serverUrl); + this.safeWebUrl = this.sanitizer.bypassSecurityTrustResourceUrl(`${base}/web/index.html`); + this.streamUrl = this.buildStreamUrl(base); + this.loaded = true; + } + + reset(): void { + this.loaded = false; + this.itemId = ''; + this.apiKey = ''; + this.mediaSourceId = ''; + this.streamUrl = ''; + } + + private normalizeBase(value: string): string { + const raw = value.trim() || 'https://media.lthn.ai'; + const withProtocol = raw.startsWith('http://') || raw.startsWith('https://') ? raw : `https://${raw}`; + return withProtocol.replace(/\/+$/, ''); + } + + private buildStreamUrl(base: string): string { + if (!this.itemId.trim() || !this.apiKey.trim()) { + return ''; + } + + const url = new URL(`${base}/Videos/${encodeURIComponent(this.itemId.trim())}/stream`); + url.searchParams.set('api_key', this.apiKey.trim()); + url.searchParams.set('static', 'true'); + if (this.mediaSourceId.trim()) { + url.searchParams.set('MediaSourceId', this.mediaSourceId.trim()); + } + return url.toString(); + } +} diff --git a/cmd/community/index.html b/cmd/community/index.html new file mode 100644 index 00000000..9da43fd2 --- /dev/null +++ b/cmd/community/index.html @@ -0,0 +1,602 @@ + + + + + + Lethean Community — Build Trust Through Code + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ + BugSETI by Lethean.io +
+ + +

+ Build trust
+ through code +

+ + +

+ An open source community where every commit, review, and pull request + builds your reputation. Like SETI@home, but for fixing real bugs in real projects. +

+ + +
+
+
+
+ + + + ~ +
+
+
$ bugseti start
+
⠋ Fetching issues from 42 OSS repos...
+
✓ 7 beginner-friendly issues queued
+
✓ AI context prepared for each issue
+
Ready. Fix bugs. Build trust.
+
+
+
+
+ + + +
+
+ + + + +
+
+ +
+

How it works

+

From install to impact

+

BugSETI runs in your system tray. It finds issues, prepares context, and gets out of your way. You write code. The community remembers.

+
+ +
+ +
+
+ 1 +

Install & connect

+
+

Download BugSETI, connect your GitHub account. That's your identity in the Lethean Community — one account, everywhere.

+
+ $ gh auth login
+ $ bugseti init +
+
+ + +
+
+ 2 +

Pick an issue

+
+

BugSETI scans OSS repos for beginner-friendly issues. AI prepares context — the relevant files, similar past fixes, project conventions.

+
+ 7 issues ready
+ Context seeded +
+
+ + +
+
+ 3 +

Fix & earn trust

+
+

Submit your PR. Every merged fix, every review, every contribution — it all counts. Your track record becomes your reputation.

+
+ PR #247 merged
+ Trust updated +
+
+
+
+
+ + + + +
+
+ + +
+
+

The app

+

A workbench in your tray

+

BugSETI lives in your system tray on macOS, Linux, and Windows. It quietly fetches issues, seeds AI context, and presents a clean workbench when you're ready to code.

+
+
+ + Priority queue — issues ranked by your skills and interests +
+
+ + AI context seeding — relevant files and patterns, ready to go +
+
+ + One-click PR submission — fork, branch, commit, push +
+
+ + Stats tracking — streaks, repos contributed, PRs merged +
+
+
+
+
+ +
+
+ + + + BugSETI — Workbench +
+
+ +
+
+ lodash/lodash#5821 + good first issue +
+

Fix _.merge not handling Symbol properties

+
+ ⭐ 58.2k + JavaScript + Context ready +
+
+ +
+
+ vuejs/core#9214 + bug +
+

Teleport target not updating on HMR

+
+ ⭐ 44.7k + TypeScript + Seeding... +
+
+ +
+ 7 issues queued + ♫ dapp.fm playing +
+
+
+
+
+
+ + +
+
+
+
+
+
+ +
+
+

dapp.fm

+

Built into BugSETI

+
+
+ +
+
+
+
+

It Feels So Good (Amnesia Mix)

+

The Conductor & The Cowboy

+
+ 3:42 +
+
+
+
+
+

Zero-trust DRM · Artists keep 95–100% · ChaCha20-Poly1305

+
+
+
+
+

Built in

+

Music while you merge

+

dapp.fm is a free music player built into BugSETI. Zero-trust DRM where the password is the license. Artists keep almost everything. No middlemen, no platform fees.

+

The player is a working implementation of the Lethean protocol RFCs — encrypted, decentralised, and yours. Code, listen, contribute.

+ + Try the demo + + +
+
+ +
+
+ + + + +
+
+ +
+

Ecosystem

+

One identity, everywhere

+

Your GitHub is your Lethean identity. One name across Web2, Web3, Handshake DNS, blockchain — verified by what you've actually done.

+
+ +
+ +
+
Protocol
+

Lethean Network

+

Privacy-first blockchain. Consent-gated networking via the UEPS protocol. Data sovereignty cryptographically enforced.

+ lt.hn → +
+ + +
+
Identity
+

lthn/ everywhere

+

Handshake TLD, .io, .ai, .community, .eth, .tron — one name that resolves across every namespace. Your DID, decentralised.

+ hns.to → +
+ + +
+
Foundation
+

EUPL-1.2

+

Every line is open source under the European Union Public License. 23 languages, no jurisdiction loopholes. Code stays open, forever.

+ host.uk.com/oss → +
+ + +
+
Coming
+

lthn.ai

+

Open source EUPL-1.2 models up to 70B parameters. High quality, embeddable transformers for the community.

+ Coming soon +
+ + +
+
Music
+

dapp.fm

+

All-in-one publishing platform. Zero-trust DRM. Artists keep 95–100%. Built on Borg encryption and LTHN rolling keys.

+ demo.dapp.fm → +
+ + +
+
Services
+

Host UK

+

Infrastructure and services brand of the Lethean Community. Privacy-first hosting, analytics, trust verification, notifications.

+ host.uk.com → +
+
+ +
+
+ + + + +
+ +
+ +
+ +

Get started

+

Join the community

+

Install BugSETI. Connect your GitHub. Start contributing. Every bug you fix makes open source better — and builds a trust record that's cryptographically yours.

+ + + + + +
+
+ # or build from source
+ $ git clone https://github.com/host-uk/core
+ $ cd core && go build ./cmd/bugseti +
+
+ +
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/cmd/core-app/CODEX_PROMPT.md b/cmd/core-app/CODEX_PROMPT.md new file mode 100644 index 00000000..7dbfbf27 --- /dev/null +++ b/cmd/core-app/CODEX_PROMPT.md @@ -0,0 +1,100 @@ +# Codex Task: Core App — FrankenPHP Native Desktop App + +## Context + +You are working on `cmd/core-app/` inside the `host-uk/core` Go monorepo. This is a **working** native desktop application that embeds the PHP runtime (FrankenPHP) inside a Wails v3 window. A single 53MB binary runs Laravel 12 with Livewire 4, Octane worker mode, and SQLite — no Docker, no php-fpm, no nginx, no external dependencies. + +**It already builds and runs.** Your job is to refine, not rebuild. + +## Architecture + +``` +Wails v3 WebView (native window) + | + | AssetOptions.Handler → http.Handler + v +FrankenPHP (CGO, PHP 8.4 ZTS runtime) + | + | ServeHTTP() → Laravel public/index.php + v +Laravel 12 (Octane worker mode, 2 workers) + ├── Livewire 4 (server-rendered reactivity) + ├── SQLite (~/Library/Application Support/core-app/) + └── Native Bridge (localhost HTTP API for PHP→Go calls) +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `main.go` | Wails app entry, system tray, window config | +| `handler.go` | PHPHandler — FrankenPHP init, Octane worker mode, try_files URL resolution | +| `embed.go` | `//go:embed all:laravel` + extraction to temp dir | +| `env.go` | Persistent data dir, .env generation, APP_KEY management | +| `app_service.go` | Wails service bindings (version, data dir, window management) | +| `native_bridge.go` | PHP→Go HTTP bridge on localhost (random port) | +| `laravel/` | Full Laravel 12 skeleton (vendor excluded from git, built via `composer install`) | + +## Build Requirements + +- **PHP 8.4 ZTS**: `brew install shivammathur/php/php@8.4-zts` +- **Go 1.25+** with CGO enabled +- **Build tags**: `-tags nowatcher` (FrankenPHP's watcher needs libwatcher-c, skip it) +- **ZTS php-config**: Must use `/opt/homebrew/opt/php@8.4-zts/bin/php-config` (NOT the default php-config which may point to non-ZTS PHP) + +```bash +# Install Laravel deps (one-time) +cd laravel && composer install --no-dev --optimize-autoloader + +# Build +ZTS_PHP_CONFIG=/opt/homebrew/opt/php@8.4-zts/bin/php-config +CGO_ENABLED=1 \ +CGO_CFLAGS="$($ZTS_PHP_CONFIG --includes)" \ +CGO_LDFLAGS="-L/opt/homebrew/opt/php@8.4-zts/lib $($ZTS_PHP_CONFIG --ldflags) $($ZTS_PHP_CONFIG --libs)" \ +go build -tags nowatcher -o ../../bin/core-app . +``` + +## Known Patterns & Gotchas + +1. **FrankenPHP can't serve from embed.FS** — must extract to temp dir, symlink `storage/` to persistent data dir +2. **WithWorkers API (v1.5.0)**: `WithWorkers(name, fileName string, num int, env map[string]string, watch []string)` — 5 positional args, NOT variadic +3. **Worker mode needs Octane**: Workers point at `vendor/laravel/octane/bin/frankenphp-worker.php` with `APP_BASE_PATH` and `FRANKENPHP_WORKER=1` env vars +4. **Paths with spaces**: macOS `~/Library/Application Support/` has a space — ALL .env values with paths MUST be quoted +5. **URL resolution**: FrankenPHP doesn't auto-resolve `/` → `/index.php` — the Go handler implements try_files logic +6. **Auto-migration**: `AppServiceProvider::boot()` runs `migrate --force` wrapped in try/catch (must not fail during composer operations) +7. **Vendor dir**: Excluded from git (`.gitignore`), built at dev time via `composer install`, embedded by `//go:embed all:laravel` at build time + +## Coding Standards + +- **UK English**: colour, organisation, centre +- **PHP**: `declare(strict_types=1)` in every file, full type hints, PSR-12 via Pint +- **Go**: Standard Go conventions, error wrapping with `fmt.Errorf("context: %w", err)` +- **License**: EUPL-1.2 +- **Testing**: Pest syntax for PHP (not PHPUnit) + +## Tasks for Codex + +### Priority 1: Code Quality +- [ ] Review all Go files for error handling consistency +- [ ] Ensure handler.go's try_files logic handles edge cases (double slashes, encoded paths, path traversal) +- [ ] Add Go tests for PHPHandler URL resolution (unit tests, no FrankenPHP needed) +- [ ] Add Go tests for env.go (resolveDataDir, writeEnvFile, loadOrGenerateAppKey) + +### Priority 2: Laravel Polish +- [ ] Add `config/octane.php` with FrankenPHP server config +- [ ] Update welcome view to show migration status (table count from SQLite) +- [ ] Add a second Livewire component (e.g., todo list) to prove full CRUD with SQLite +- [ ] Add proper error page views (404, 500) styled to match the dark theme + +### Priority 3: Build Hardening +- [ ] Verify the Taskfile.yml tasks work end-to-end (`task app:setup && task app:composer && task app:build`) +- [ ] Add `.gitignore` entries for build artifacts (`bin/core-app`, temp dirs) +- [ ] Ensure `go.work` and `go.mod` are consistent + +## CRITICAL WARNINGS + +- **DO NOT push to GitHub** — GitHub remotes have been removed deliberately. The host-uk org is flagged. +- **DO NOT add GitHub as a remote** — Forge (forge.lthn.ai / git.lthn.ai) is the source of truth. +- **DO NOT modify files outside `cmd/core-app/`** — This is a workspace module, keep changes scoped. +- **DO NOT remove the `-tags nowatcher` build flag** — It will fail without libwatcher-c. +- **DO NOT change the PHP-ZTS path** — It must be the ZTS variant, not the default Homebrew PHP. diff --git a/cmd/core-app/laravel/database/database.sqlite b/cmd/core-app/laravel/database/database.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..e265e8de7498d3668f706b7cd996a93c220687bf GIT binary patch literal 45056 zcmeI(yKmD#9Kdng4RM<^4H6QbD!FclT1AiwF)$!eN`^`W@f1`H)-^d=)5LCVr?e6b zDGLjK2>%QVe*pgi0}~5e9ws(@NK8o9*GihR@8#d;&XLYepFZC76KTH=qpnEoo7!c~ zFtoe2t!bK}-c|KZx3Wr<(jE1wXVT{-4ek1;pS8JPTBRCl^{u*D`!V;W_OVu(eK7m2 z8qQuluOi10KmY**5I_I{1P~~IK!0h*v=$fjlX@byJF+EOyVC1~t-UyttgJt2u03no z&(`j3Hf<-9fnIn(({YWH!7&x{cByvY) zEAB^rS479QYIoea>)xuAt&K%p^MkfLiVr$!iQtJ|5)Svh%n5m`nVo9CZkm>5>HYVU z=!W^zXGC;SEQg}gla8IpBNg5x#D5ROoi1B~<7~(6znC(u`FZ_=J{+d>ywlH8ez4NRgOm2#XHKxR<#cwb+34?kqTP-Z z$)6hP=&+#JkwG#VTybrmQ#qc$GM59#e1z7(Ve~gI`0R#|0009ILKmY**5I_I{{{NpjfB*sr zAb0tg_000IagfB*srAbH@ literal 0 HcmV?d00001 diff --git a/cmd/core-ide/build_service.go b/cmd/core-ide/build_service.go new file mode 100644 index 00000000..cf793209 --- /dev/null +++ b/cmd/core-ide/build_service.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "log" + "time" + + "github.com/host-uk/core/pkg/mcp/ide" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// BuildService provides build monitoring bindings for the frontend. +type BuildService struct { + ideSub *ide.Subsystem +} + +// NewBuildService creates a new BuildService. +func NewBuildService(ideSub *ide.Subsystem) *BuildService { + return &BuildService{ideSub: ideSub} +} + +// ServiceName returns the service name for Wails. +func (s *BuildService) ServiceName() string { return "BuildService" } + +// ServiceStartup is called when the Wails application starts. +func (s *BuildService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + log.Println("BuildService started") + return nil +} + +// ServiceShutdown is called when the Wails application shuts down. +func (s *BuildService) ServiceShutdown() error { + log.Println("BuildService shutdown") + return nil +} + +// BuildDTO is a build for the frontend. +type BuildDTO struct { + ID string `json:"id"` + Repo string `json:"repo"` + Branch string `json:"branch"` + Status string `json:"status"` + Duration string `json:"duration,omitempty"` + StartedAt time.Time `json:"startedAt"` +} + +// GetBuilds returns recent builds. +func (s *BuildService) GetBuilds(repo string) []BuildDTO { + bridge := s.ideSub.Bridge() + if bridge == nil { + return []BuildDTO{} + } + _ = bridge.Send(ide.BridgeMessage{ + Type: "build_list", + Data: map[string]any{"repo": repo}, + }) + return []BuildDTO{} +} + +// GetBuildLogs returns log output for a specific build. +func (s *BuildService) GetBuildLogs(buildID string) []string { + bridge := s.ideSub.Bridge() + if bridge == nil { + return []string{} + } + _ = bridge.Send(ide.BridgeMessage{ + Type: "build_logs", + Data: map[string]any{"buildId": buildID}, + }) + return []string{} +} diff --git a/cmd/core-ide/chat_service.go b/cmd/core-ide/chat_service.go new file mode 100644 index 00000000..e6576261 --- /dev/null +++ b/cmd/core-ide/chat_service.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "log" + "time" + + "github.com/host-uk/core/pkg/mcp/ide" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// ChatService provides chat bindings for the frontend. +type ChatService struct { + ideSub *ide.Subsystem +} + +// NewChatService creates a new ChatService. +func NewChatService(ideSub *ide.Subsystem) *ChatService { + return &ChatService{ideSub: ideSub} +} + +// ServiceName returns the service name for Wails. +func (s *ChatService) ServiceName() string { return "ChatService" } + +// ServiceStartup is called when the Wails application starts. +func (s *ChatService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + log.Println("ChatService started") + return nil +} + +// ServiceShutdown is called when the Wails application shuts down. +func (s *ChatService) ServiceShutdown() error { + log.Println("ChatService shutdown") + return nil +} + +// ChatMessageDTO is a message for the frontend. +type ChatMessageDTO struct { + Role string `json:"role"` + Content string `json:"content"` + Timestamp time.Time `json:"timestamp"` +} + +// SessionDTO is a session for the frontend. +type SessionDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` +} + +// PlanStepDTO is a plan step for the frontend. +type PlanStepDTO struct { + Name string `json:"name"` + Status string `json:"status"` +} + +// PlanDTO is a plan for the frontend. +type PlanDTO struct { + SessionID string `json:"sessionId"` + Status string `json:"status"` + Steps []PlanStepDTO `json:"steps"` +} + +// SendMessage sends a message to an agent session via the bridge. +func (s *ChatService) SendMessage(sessionID string, message string) (bool, error) { + bridge := s.ideSub.Bridge() + if bridge == nil { + return false, nil + } + err := bridge.Send(ide.BridgeMessage{ + Type: "chat_send", + Channel: "chat:" + sessionID, + SessionID: sessionID, + Data: message, + }) + return err == nil, err +} + +// GetHistory retrieves message history for a session. +func (s *ChatService) GetHistory(sessionID string) []ChatMessageDTO { + bridge := s.ideSub.Bridge() + if bridge == nil { + return []ChatMessageDTO{} + } + _ = bridge.Send(ide.BridgeMessage{ + Type: "chat_history", + SessionID: sessionID, + }) + return []ChatMessageDTO{} +} + +// ListSessions returns active agent sessions. +func (s *ChatService) ListSessions() []SessionDTO { + bridge := s.ideSub.Bridge() + if bridge == nil { + return []SessionDTO{} + } + _ = bridge.Send(ide.BridgeMessage{Type: "session_list"}) + return []SessionDTO{} +} + +// CreateSession creates a new agent session. +func (s *ChatService) CreateSession(name string) SessionDTO { + bridge := s.ideSub.Bridge() + if bridge == nil { + return SessionDTO{Name: name, Status: "offline"} + } + _ = bridge.Send(ide.BridgeMessage{ + Type: "session_create", + Data: map[string]any{"name": name}, + }) + return SessionDTO{ + Name: name, + Status: "creating", + CreatedAt: time.Now(), + } +} + +// GetPlanStatus returns the plan status for a session. +func (s *ChatService) GetPlanStatus(sessionID string) PlanDTO { + bridge := s.ideSub.Bridge() + if bridge == nil { + return PlanDTO{SessionID: sessionID, Status: "offline"} + } + _ = bridge.Send(ide.BridgeMessage{ + Type: "plan_status", + SessionID: sessionID, + }) + return PlanDTO{ + SessionID: sessionID, + Status: "unknown", + Steps: []PlanStepDTO{}, + } +} diff --git a/cmd/core-ide/frontend/angular.json b/cmd/core-ide/frontend/angular.json new file mode 100644 index 00000000..638b167a --- /dev/null +++ b/cmd/core-ide/frontend/angular.json @@ -0,0 +1,91 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "core-ide": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss", + "standalone": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/core-ide", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "core-ide:build:production" + }, + "development": { + "buildTarget": "core-ide:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": ["zone.js", "zone.js/testing"], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/cmd/core-ide/frontend/package.json b/cmd/core-ide/frontend/package.json new file mode 100644 index 00000000..e575d95a --- /dev/null +++ b/cmd/core-ide/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "core-ide", + "version": "0.1.0", + "private": true, + "scripts": { + "ng": "ng", + "start": "ng serve", + "dev": "ng serve --configuration development", + "build": "ng build --configuration production", + "build:dev": "ng build --configuration development", + "watch": "ng build --watch --configuration development", + "test": "ng test", + "lint": "ng lint" + }, + "dependencies": { + "@angular/animations": "^19.1.0", + "@angular/common": "^19.1.0", + "@angular/compiler": "^19.1.0", + "@angular/core": "^19.1.0", + "@angular/forms": "^19.1.0", + "@angular/platform-browser": "^19.1.0", + "@angular/platform-browser-dynamic": "^19.1.0", + "@angular/router": "^19.1.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^19.1.0", + "@angular/cli": "^21.1.2", + "@angular/compiler-cli": "^19.1.0", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.5.2" + } +} diff --git a/cmd/core-ide/frontend/src/app/app.component.ts b/cmd/core-ide/frontend/src/app/app.component.ts new file mode 100644 index 00000000..d26c6dc5 --- /dev/null +++ b/cmd/core-ide/frontend/src/app/app.component.ts @@ -0,0 +1,18 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: '', + styles: [` + :host { + display: block; + height: 100%; + } + `] +}) +export class AppComponent { + title = 'Core IDE'; +} diff --git a/cmd/core-ide/frontend/src/app/app.config.ts b/cmd/core-ide/frontend/src/app/app.config.ts new file mode 100644 index 00000000..628370af --- /dev/null +++ b/cmd/core-ide/frontend/src/app/app.config.ts @@ -0,0 +1,9 @@ +import { ApplicationConfig } from '@angular/core'; +import { provideRouter, withHashLocation } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes, withHashLocation()) + ] +}; diff --git a/cmd/core-ide/frontend/src/app/app.routes.ts b/cmd/core-ide/frontend/src/app/app.routes.ts new file mode 100644 index 00000000..e8d803cb --- /dev/null +++ b/cmd/core-ide/frontend/src/app/app.routes.ts @@ -0,0 +1,25 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { + path: '', + redirectTo: 'tray', + pathMatch: 'full' + }, + { + path: 'tray', + loadComponent: () => import('./tray/tray.component').then(m => m.TrayComponent) + }, + { + path: 'main', + loadComponent: () => import('./main/main.component').then(m => m.MainComponent) + }, + { + path: 'settings', + loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) + }, + { + path: 'jellyfin', + loadComponent: () => import('./jellyfin/jellyfin.component').then(m => m.JellyfinComponent) + } +]; diff --git a/cmd/core-ide/frontend/src/app/build/build.component.ts b/cmd/core-ide/frontend/src/app/build/build.component.ts new file mode 100644 index 00000000..ea3fecec --- /dev/null +++ b/cmd/core-ide/frontend/src/app/build/build.component.ts @@ -0,0 +1,184 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { WailsService, Build } from '@shared/wails.service'; +import { WebSocketService, WSMessage } from '@shared/ws.service'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-build', + standalone: true, + imports: [CommonModule], + template: ` +
+
+

Builds

+ +
+ +
+
+
+
+ {{ build.repo }} + {{ build.branch }} +
+ {{ build.status }} +
+ +
+ {{ build.startedAt | date:'medium' }} + · {{ build.duration }} +
+ +
+
{{ logs.join('\\n') }}
+

No logs available

+
+
+ +
+ No builds found. Builds will appear here from Forgejo CI. +
+
+
+ `, + styles: [` + .builds { + padding: var(--spacing-md); + } + + .builds__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); + } + + .builds__list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + } + + .build-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--spacing-md); + cursor: pointer; + transition: border-color 0.15s; + + &:hover { + border-color: var(--text-muted); + } + } + + .build-card__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-xs); + } + + .build-card__info { + display: flex; + gap: var(--spacing-sm); + align-items: center; + } + + .build-card__repo { + font-weight: 600; + } + + .build-card__branch { + font-size: 12px; + } + + .build-card__meta { + font-size: 12px; + } + + .build-card__logs { + margin-top: var(--spacing-md); + border-top: 1px solid var(--border-color); + padding-top: var(--spacing-md); + } + + .build-card__logs pre { + font-size: 12px; + max-height: 300px; + overflow-y: auto; + } + + .builds__empty { + text-align: center; + padding: var(--spacing-xl); + } + `] +}) +export class BuildComponent implements OnInit, OnDestroy { + builds: Build[] = []; + expandedId = ''; + logs: string[] = []; + + private sub: Subscription | null = null; + + constructor( + private wails: WailsService, + private wsService: WebSocketService + ) {} + + ngOnInit(): void { + this.refresh(); + this.wsService.connect(); + this.sub = this.wsService.subscribe('build:status').subscribe( + (msg: WSMessage) => { + if (msg.data && typeof msg.data === 'object') { + const update = msg.data as Build; + const idx = this.builds.findIndex(b => b.id === update.id); + if (idx >= 0) { + this.builds[idx] = { ...this.builds[idx], ...update }; + } else { + this.builds.unshift(update); + } + } + } + ); + } + + ngOnDestroy(): void { + this.sub?.unsubscribe(); + } + + async refresh(): Promise { + this.builds = await this.wails.getBuilds(); + } + + async toggle(buildId: string): Promise { + if (this.expandedId === buildId) { + this.expandedId = ''; + this.logs = []; + return; + } + this.expandedId = buildId; + this.logs = await this.wails.getBuildLogs(buildId); + } + + trackBuild(_: number, build: Build): string { + return build.id; + } + + statusBadge(status: string): string { + switch (status) { + case 'success': return 'badge--success'; + case 'running': return 'badge--info'; + case 'failed': return 'badge--danger'; + default: return 'badge--warning'; + } + } +} diff --git a/cmd/core-ide/frontend/src/app/chat/chat.component.ts b/cmd/core-ide/frontend/src/app/chat/chat.component.ts new file mode 100644 index 00000000..ac6ca837 --- /dev/null +++ b/cmd/core-ide/frontend/src/app/chat/chat.component.ts @@ -0,0 +1,242 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { WailsService, ChatMessage, Session, PlanStatus } from '@shared/wails.service'; +import { WebSocketService, WSMessage } from '@shared/ws.service'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-chat', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+
+
+ + +
+
+ +
+
+
+
{{ msg.role }}
+
{{ msg.content }}
+
+
+ No messages yet. Start a conversation with an agent. +
+
+ +
+

Plan: {{ plan.status }}

+
    +
  • + {{ step.name }} + {{ step.status }} +
  • +
+
+
+ +
+ + +
+
+ `, + styles: [` + .chat { + display: flex; + flex-direction: column; + height: 100%; + } + + .chat__header { + padding: var(--spacing-sm) var(--spacing-md); + border-bottom: 1px solid var(--border-color); + } + + .chat__session-picker { + display: flex; + gap: var(--spacing-sm); + align-items: center; + } + + .chat__session-picker select { + flex: 1; + } + + .chat__body { + flex: 1; + display: flex; + overflow: hidden; + } + + .chat__messages { + flex: 1; + overflow-y: auto; + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + } + + .chat__msg { + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + max-width: 80%; + } + + .chat__msg--user { + align-self: flex-end; + background: rgba(57, 208, 216, 0.12); + border: 1px solid rgba(57, 208, 216, 0.2); + } + + .chat__msg--agent { + align-self: flex-start; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + } + + .chat__msg-role { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 2px; + } + + .chat__msg-content { + white-space: pre-wrap; + word-break: break-word; + } + + .chat__empty { + margin: auto; + text-align: center; + } + + .chat__plan { + width: 260px; + border-left: 1px solid var(--border-color); + padding: var(--spacing-md); + overflow-y: auto; + } + + .chat__plan ul { + list-style: none; + margin-top: var(--spacing-sm); + } + + .chat__plan li { + padding: var(--spacing-xs) 0; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + } + + .chat__input { + padding: var(--spacing-sm) var(--spacing-md); + border-top: 1px solid var(--border-color); + display: flex; + gap: var(--spacing-sm); + align-items: flex-end; + } + + .chat__input textarea { + flex: 1; + resize: none; + } + `] +}) +export class ChatComponent implements OnInit, OnDestroy { + sessions: Session[] = []; + activeSessionId = ''; + messages: ChatMessage[] = []; + plan: PlanStatus = { sessionId: '', status: '', steps: [] }; + draft = ''; + + private sub: Subscription | null = null; + + constructor( + private wails: WailsService, + private wsService: WebSocketService + ) {} + + ngOnInit(): void { + this.loadSessions(); + this.wsService.connect(); + } + + ngOnDestroy(): void { + this.sub?.unsubscribe(); + } + + async loadSessions(): Promise { + this.sessions = await this.wails.listSessions(); + if (this.sessions.length > 0 && !this.activeSessionId) { + this.activeSessionId = this.sessions[0].id; + this.onSessionChange(); + } + } + + async onSessionChange(): Promise { + if (!this.activeSessionId) return; + + // Unsubscribe from previous channel + this.sub?.unsubscribe(); + + // Load history and plan + this.messages = await this.wails.getHistory(this.activeSessionId); + this.plan = await this.wails.getPlanStatus(this.activeSessionId); + + // Subscribe to live updates + this.sub = this.wsService.subscribe(`chat:${this.activeSessionId}`).subscribe( + (msg: WSMessage) => { + if (msg.data && typeof msg.data === 'object') { + this.messages.push(msg.data as ChatMessage); + } + } + ); + } + + async sendMessage(event?: KeyboardEvent): Promise { + if (event) { + if (event.shiftKey) return; // Allow shift+enter for newlines + event.preventDefault(); + } + const text = this.draft.trim(); + if (!text || !this.activeSessionId) return; + + // Optimistic UI update + this.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString() }); + this.draft = ''; + + await this.wails.sendMessage(this.activeSessionId, text); + } + + async createSession(): Promise { + const name = `Session ${this.sessions.length + 1}`; + const session = await this.wails.createSession(name); + this.sessions.push(session); + this.activeSessionId = session.id; + this.onSessionChange(); + } +} diff --git a/cmd/core-ide/frontend/src/app/dashboard/dashboard.component.ts b/cmd/core-ide/frontend/src/app/dashboard/dashboard.component.ts new file mode 100644 index 00000000..32f4a90d --- /dev/null +++ b/cmd/core-ide/frontend/src/app/dashboard/dashboard.component.ts @@ -0,0 +1,163 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { WailsService, DashboardData } from '@shared/wails.service'; +import { WebSocketService, WSMessage } from '@shared/ws.service'; +import { Subscription } from 'rxjs'; + +interface ActivityItem { + type: string; + message: string; + timestamp: string; +} + +@Component({ + selector: 'app-dashboard', + standalone: true, + imports: [CommonModule], + template: ` +
+

Dashboard

+ +
+
+
+ {{ data.connection.bridgeConnected ? 'Online' : 'Offline' }} +
+
Bridge Status
+
+
+
{{ data.connection.wsClients }}
+
WS Clients
+
+
+
{{ data.connection.wsChannels }}
+
Active Channels
+
+
+
0
+
Agent Sessions
+
+
+ +
+

Activity Feed

+
+
+ {{ item.type }} + {{ item.message }} + {{ item.timestamp | date:'shortTime' }} +
+
+ No recent activity. Events will stream here in real-time. +
+
+
+
+ `, + styles: [` + .dashboard { + padding: var(--spacing-md); + } + + .dashboard__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--spacing-md); + margin: var(--spacing-md) 0; + } + + .stat-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + text-align: center; + } + + .stat-card__value { + font-size: 28px; + font-weight: 700; + color: var(--accent-primary); + } + + .stat-card__label { + font-size: 13px; + color: var(--text-muted); + margin-top: var(--spacing-xs); + } + + .dashboard__activity { + margin-top: var(--spacing-lg); + } + + .activity-feed { + margin-top: var(--spacing-sm); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + max-height: 400px; + overflow-y: auto; + } + + .activity-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + border-bottom: 1px solid var(--border-color); + font-size: 13px; + + &:last-child { + border-bottom: none; + } + } + + .activity-item__msg { + flex: 1; + } + + .activity-item__time { + font-size: 12px; + white-space: nowrap; + } + `] +}) +export class DashboardComponent implements OnInit, OnDestroy { + data: DashboardData = { + connection: { bridgeConnected: false, laravelUrl: '', wsClients: 0, wsChannels: 0 } + }; + activity: ActivityItem[] = []; + + private sub: Subscription | null = null; + private pollTimer: ReturnType | null = null; + + constructor( + private wails: WailsService, + private wsService: WebSocketService + ) {} + + ngOnInit(): void { + this.refresh(); + this.pollTimer = setInterval(() => this.refresh(), 10000); + + this.wsService.connect(); + this.sub = this.wsService.subscribe('dashboard:activity').subscribe( + (msg: WSMessage) => { + if (msg.data && typeof msg.data === 'object') { + this.activity.unshift(msg.data as ActivityItem); + if (this.activity.length > 100) { + this.activity.pop(); + } + } + } + ); + } + + ngOnDestroy(): void { + this.sub?.unsubscribe(); + if (this.pollTimer) clearInterval(this.pollTimer); + } + + async refresh(): Promise { + this.data = await this.wails.getDashboard(); + } +} diff --git a/cmd/core-ide/frontend/src/app/jellyfin/jellyfin.component.ts b/cmd/core-ide/frontend/src/app/jellyfin/jellyfin.component.ts new file mode 100644 index 00000000..29242321 --- /dev/null +++ b/cmd/core-ide/frontend/src/app/jellyfin/jellyfin.component.ts @@ -0,0 +1,175 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; + +type Mode = 'web' | 'stream'; + +@Component({ + selector: 'app-jellyfin', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+
+
+

Jellyfin Player

+

Embedded media access for Host UK workflows.

+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+ +
+ +

Set Item ID and API key to build stream URL.

+
+
+ `, + styles: [` + .jellyfin { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + padding: var(--spacing-md); + min-height: 100%; + background: var(--bg-primary); + } + + .jellyfin__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + } + + .mode-switch { + display: flex; + gap: var(--spacing-xs); + } + + .mode-switch .btn.is-active { + border-color: var(--accent-primary); + color: var(--accent-primary); + } + + .stream-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: var(--spacing-sm); + } + + .actions { + display: flex; + gap: var(--spacing-sm); + } + + .viewer { + padding: 0; + overflow: hidden; + min-height: 520px; + } + + .jellyfin-frame, + .jellyfin-video { + border: 0; + width: 100%; + height: 100%; + min-height: 520px; + background: #000; + } + + .stream-hint { + padding: var(--spacing-md); + margin: 0; + } + `] +}) +export class JellyfinComponent { + mode: Mode = 'web'; + loaded = false; + + serverUrl = 'https://media.lthn.ai'; + itemId = ''; + apiKey = ''; + mediaSourceId = ''; + + safeWebUrl: SafeResourceUrl = this.sanitizer.bypassSecurityTrustResourceUrl('https://media.lthn.ai/web/index.html'); + streamUrl = ''; + + constructor(private sanitizer: DomSanitizer) {} + + load(): void { + const base = this.normalizeBase(this.serverUrl); + this.safeWebUrl = this.sanitizer.bypassSecurityTrustResourceUrl(`${base}/web/index.html`); + this.streamUrl = this.buildStreamUrl(base); + this.loaded = true; + } + + reset(): void { + this.loaded = false; + this.itemId = ''; + this.apiKey = ''; + this.mediaSourceId = ''; + this.streamUrl = ''; + } + + private normalizeBase(value: string): string { + const raw = value.trim() || 'https://media.lthn.ai'; + const withProtocol = raw.startsWith('http://') || raw.startsWith('https://') ? raw : `https://${raw}`; + return withProtocol.replace(/\/+$/, ''); + } + + private buildStreamUrl(base: string): string { + if (!this.itemId.trim() || !this.apiKey.trim()) { + return ''; + } + + const url = new URL(`${base}/Videos/${encodeURIComponent(this.itemId.trim())}/stream`); + url.searchParams.set('api_key', this.apiKey.trim()); + url.searchParams.set('static', 'true'); + if (this.mediaSourceId.trim()) { + url.searchParams.set('MediaSourceId', this.mediaSourceId.trim()); + } + return url.toString(); + } +} diff --git a/cmd/core-ide/frontend/src/app/main/main.component.ts b/cmd/core-ide/frontend/src/app/main/main.component.ts new file mode 100644 index 00000000..6c6e7030 --- /dev/null +++ b/cmd/core-ide/frontend/src/app/main/main.component.ts @@ -0,0 +1,119 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { ChatComponent } from '../chat/chat.component'; +import { BuildComponent } from '../build/build.component'; +import { DashboardComponent } from '../dashboard/dashboard.component'; +import { JellyfinComponent } from '../jellyfin/jellyfin.component'; + +type Panel = 'chat' | 'build' | 'dashboard' | 'jellyfin'; + +@Component({ + selector: 'app-main', + standalone: true, + imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet, ChatComponent, BuildComponent, DashboardComponent, JellyfinComponent], + template: ` +
+ + +
+ + + + +
+
+ `, + styles: [` + .ide { + display: flex; + height: 100vh; + overflow: hidden; + } + + .ide__sidebar { + width: var(--sidebar-width); + background: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + padding: var(--spacing-md) 0; + flex-shrink: 0; + } + + .ide__logo { + padding: 0 var(--spacing-md); + font-size: 16px; + font-weight: 700; + color: var(--accent-primary); + margin-bottom: var(--spacing-lg); + } + + .ide__nav { + list-style: none; + flex: 1; + } + + .ide__nav-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + color: var(--text-secondary); + transition: all 0.15s; + border-left: 3px solid transparent; + + &:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + } + + &.active { + color: var(--accent-primary); + background: rgba(57, 208, 216, 0.08); + border-left-color: var(--accent-primary); + } + } + + .ide__nav-icon { + font-size: 16px; + width: 20px; + text-align: center; + } + + .ide__nav-footer { + padding: var(--spacing-sm) var(--spacing-md); + font-size: 12px; + } + + .ide__content { + flex: 1; + overflow: auto; + } + `] +}) +export class MainComponent { + activePanel: Panel = 'dashboard'; + + navItems: { id: Panel; label: string; icon: string }[] = [ + { id: 'dashboard', label: 'Dashboard', icon: '\u25A6' }, + { id: 'chat', label: 'Chat', icon: '\u2709' }, + { id: 'build', label: 'Builds', icon: '\u2699' }, + { id: 'jellyfin', label: 'Jellyfin', icon: '\u25B6' }, + ]; +} diff --git a/cmd/core-ide/frontend/src/app/settings/settings.component.ts b/cmd/core-ide/frontend/src/app/settings/settings.component.ts new file mode 100644 index 00000000..b91418b4 --- /dev/null +++ b/cmd/core-ide/frontend/src/app/settings/settings.component.ts @@ -0,0 +1,105 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'app-settings', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+

Settings

+ +
+

Connection

+
+ + +
+
+ + +
+
+ +
+

Appearance

+
+ + +
+
+ +
+ +
+
+ `, + styles: [` + .settings { + padding: var(--spacing-lg); + max-width: 500px; + } + + .settings__section { + margin-top: var(--spacing-lg); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--border-color); + + &:first-of-type { + margin-top: var(--spacing-md); + padding-top: 0; + border-top: none; + } + } + + .settings__actions { + margin-top: var(--spacing-lg); + } + `] +}) +export class SettingsComponent implements OnInit { + laravelUrl = 'ws://localhost:9876/ws'; + workspaceRoot = '.'; + theme = 'dark'; + + ngOnInit(): void { + // Settings will be loaded from the Go backend + const saved = localStorage.getItem('ide-settings'); + if (saved) { + try { + const parsed = JSON.parse(saved); + this.laravelUrl = parsed.laravelUrl ?? this.laravelUrl; + this.workspaceRoot = parsed.workspaceRoot ?? this.workspaceRoot; + this.theme = parsed.theme ?? this.theme; + } catch { + // Ignore parse errors + } + } + } + + save(): void { + localStorage.setItem('ide-settings', JSON.stringify({ + laravelUrl: this.laravelUrl, + workspaceRoot: this.workspaceRoot, + theme: this.theme, + })); + + if (this.theme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + } +} diff --git a/cmd/core-ide/frontend/src/app/shared/wails.service.ts b/cmd/core-ide/frontend/src/app/shared/wails.service.ts new file mode 100644 index 00000000..2da65e97 --- /dev/null +++ b/cmd/core-ide/frontend/src/app/shared/wails.service.ts @@ -0,0 +1,133 @@ +import { Injectable } from '@angular/core'; + +// Type-safe wrapper for Wails v3 Go service bindings. +// At runtime, `window.go.main.{ServiceName}.{Method}()` returns a Promise. + +interface WailsGo { + main: { + IDEService: { + GetConnectionStatus(): Promise; + GetDashboard(): Promise; + ShowWindow(name: string): Promise; + }; + ChatService: { + SendMessage(sessionId: string, message: string): Promise; + GetHistory(sessionId: string): Promise; + ListSessions(): Promise; + CreateSession(name: string): Promise; + GetPlanStatus(sessionId: string): Promise; + }; + BuildService: { + GetBuilds(repo: string): Promise; + GetBuildLogs(buildId: string): Promise; + }; + }; +} + +export interface ConnectionStatus { + bridgeConnected: boolean; + laravelUrl: string; + wsClients: number; + wsChannels: number; +} + +export interface DashboardData { + connection: ConnectionStatus; +} + +export interface ChatMessage { + role: string; + content: string; + timestamp: string; +} + +export interface Session { + id: string; + name: string; + status: string; + createdAt: string; +} + +export interface PlanStatus { + sessionId: string; + status: string; + steps: PlanStep[]; +} + +export interface PlanStep { + name: string; + status: string; +} + +export interface Build { + id: string; + repo: string; + branch: string; + status: string; + duration?: string; + startedAt: string; +} + +declare global { + interface Window { + go: WailsGo; + } +} + +@Injectable({ providedIn: 'root' }) +export class WailsService { + private get ide() { return window.go?.main?.IDEService; } + private get chat() { return window.go?.main?.ChatService; } + private get build() { return window.go?.main?.BuildService; } + + // IDE + getConnectionStatus(): Promise { + return this.ide?.GetConnectionStatus() ?? Promise.resolve({ + bridgeConnected: false, laravelUrl: '', wsClients: 0, wsChannels: 0 + }); + } + + getDashboard(): Promise { + return this.ide?.GetDashboard() ?? Promise.resolve({ + connection: { bridgeConnected: false, laravelUrl: '', wsClients: 0, wsChannels: 0 } + }); + } + + showWindow(name: string): Promise { + return this.ide?.ShowWindow(name) ?? Promise.resolve(); + } + + // Chat + sendMessage(sessionId: string, message: string): Promise { + return this.chat?.SendMessage(sessionId, message) ?? Promise.resolve(false); + } + + getHistory(sessionId: string): Promise { + return this.chat?.GetHistory(sessionId) ?? Promise.resolve([]); + } + + listSessions(): Promise { + return this.chat?.ListSessions() ?? Promise.resolve([]); + } + + createSession(name: string): Promise { + return this.chat?.CreateSession(name) ?? Promise.resolve({ + id: '', name, status: 'offline', createdAt: '' + }); + } + + getPlanStatus(sessionId: string): Promise { + return this.chat?.GetPlanStatus(sessionId) ?? Promise.resolve({ + sessionId, status: 'offline', steps: [] + }); + } + + // Build + getBuilds(repo: string = ''): Promise { + return this.build?.GetBuilds(repo) ?? Promise.resolve([]); + } + + getBuildLogs(buildId: string): Promise { + return this.build?.GetBuildLogs(buildId) ?? Promise.resolve([]); + } +} diff --git a/cmd/core-ide/frontend/src/app/shared/ws.service.ts b/cmd/core-ide/frontend/src/app/shared/ws.service.ts new file mode 100644 index 00000000..a6d55c99 --- /dev/null +++ b/cmd/core-ide/frontend/src/app/shared/ws.service.ts @@ -0,0 +1,89 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Subject, Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +export interface WSMessage { + type: string; + channel?: string; + processId?: string; + data?: unknown; + timestamp: string; +} + +@Injectable({ providedIn: 'root' }) +export class WebSocketService implements OnDestroy { + private ws: WebSocket | null = null; + private messages$ = new Subject(); + private reconnectTimer: ReturnType | null = null; + private url = 'ws://127.0.0.1:9877/ws'; + private connected = false; + + connect(url?: string): void { + if (url) this.url = url; + this.doConnect(); + } + + private doConnect(): void { + if (this.ws) { + this.ws.close(); + } + + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + this.connected = true; + console.log('[WS] Connected'); + }; + + this.ws.onmessage = (event: MessageEvent) => { + try { + const msg: WSMessage = JSON.parse(event.data); + this.messages$.next(msg); + } catch { + console.warn('[WS] Failed to parse message'); + } + }; + + this.ws.onclose = () => { + this.connected = false; + console.log('[WS] Disconnected, reconnecting in 3s...'); + this.reconnectTimer = setTimeout(() => this.doConnect(), 3000); + }; + + this.ws.onerror = () => { + this.ws?.close(); + }; + } + + subscribe(channel: string): Observable { + // Send subscribe command to hub + this.send({ type: 'subscribe', data: channel, timestamp: new Date().toISOString() }); + return this.messages$.pipe( + filter(msg => msg.channel === channel) + ); + } + + unsubscribe(channel: string): void { + this.send({ type: 'unsubscribe', data: channel, timestamp: new Date().toISOString() }); + } + + send(msg: WSMessage): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } + + get isConnected(): boolean { + return this.connected; + } + + get allMessages$(): Observable { + return this.messages$.asObservable(); + } + + ngOnDestroy(): void { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.ws?.close(); + this.messages$.complete(); + } +} diff --git a/cmd/core-ide/frontend/src/app/tray/tray.component.ts b/cmd/core-ide/frontend/src/app/tray/tray.component.ts new file mode 100644 index 00000000..5911a0de --- /dev/null +++ b/cmd/core-ide/frontend/src/app/tray/tray.component.ts @@ -0,0 +1,124 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { WailsService, ConnectionStatus } from '@shared/wails.service'; + +@Component({ + selector: 'app-tray', + standalone: true, + imports: [CommonModule], + template: ` +
+
+

Core IDE

+ + {{ status.bridgeConnected ? 'Online' : 'Offline' }} + +
+ +
+
+ {{ status.wsClients }} + WS Clients +
+
+ {{ status.wsChannels }} + Channels +
+
+ +
+ + +
+ + +
+ `, + styles: [` + .tray { + padding: var(--spacing-md); + height: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + } + + .tray__header { + display: flex; + justify-content: space-between; + align-items: center; + } + + .tray__stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-sm); + } + + .stat { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--spacing-sm) var(--spacing-md); + text-align: center; + } + + .stat__value { + display: block; + font-size: 24px; + font-weight: 600; + color: var(--accent-primary); + } + + .stat__label { + font-size: 12px; + color: var(--text-muted); + } + + .tray__actions { + display: flex; + gap: var(--spacing-sm); + } + + .tray__actions .btn { + flex: 1; + } + + .tray__footer { + margin-top: auto; + font-size: 12px; + text-align: center; + } + `] +}) +export class TrayComponent implements OnInit { + status: ConnectionStatus = { + bridgeConnected: false, + laravelUrl: '', + wsClients: 0, + wsChannels: 0 + }; + + private pollTimer: ReturnType | null = null; + + constructor(private wails: WailsService) {} + + ngOnInit(): void { + this.refresh(); + this.pollTimer = setInterval(() => this.refresh(), 5000); + } + + async refresh(): Promise { + this.status = await this.wails.getConnectionStatus(); + } + + openMain(): void { + this.wails.showWindow('main'); + } + + openSettings(): void { + this.wails.showWindow('settings'); + } +} diff --git a/cmd/core-ide/frontend/src/index.html b/cmd/core-ide/frontend/src/index.html new file mode 100644 index 00000000..f56693ea --- /dev/null +++ b/cmd/core-ide/frontend/src/index.html @@ -0,0 +1,13 @@ + + + + + Core IDE + + + + + + + + diff --git a/cmd/core-ide/frontend/src/main.ts b/cmd/core-ide/frontend/src/main.ts new file mode 100644 index 00000000..35b00f34 --- /dev/null +++ b/cmd/core-ide/frontend/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/cmd/core-ide/frontend/src/styles.scss b/cmd/core-ide/frontend/src/styles.scss new file mode 100644 index 00000000..a8dda351 --- /dev/null +++ b/cmd/core-ide/frontend/src/styles.scss @@ -0,0 +1,247 @@ +// Core IDE Global Styles + +:root { + // Dark theme (default) — IDE accent: teal/cyan + --bg-primary: #161b22; + --bg-secondary: #0d1117; + --bg-tertiary: #21262d; + --bg-sidebar: #131820; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --text-muted: #6e7681; + --border-color: #30363d; + --accent-primary: #39d0d8; + --accent-secondary: #58a6ff; + --accent-success: #3fb950; + --accent-warning: #d29922; + --accent-danger: #f85149; + + // Spacing + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + // Border radius + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 12px; + + // Font + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; + + // IDE-specific + --sidebar-width: 240px; + --chat-input-height: 80px; +} + +// Reset +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + width: 100%; +} + +body { + font-family: var(--font-family); + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); + background-color: var(--bg-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +// Typography +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.25; + margin-bottom: var(--spacing-sm); +} + +h1 { font-size: 24px; } +h2 { font-size: 20px; } +h3 { font-size: 16px; } +h4 { font-size: 14px; } + +a { + color: var(--accent-secondary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +code, pre { + font-family: var(--font-mono); + font-size: 13px; +} + +code { + padding: 2px 6px; + background-color: var(--bg-tertiary); + border-radius: var(--radius-sm); +} + +pre { + padding: var(--spacing-md); + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow-x: auto; +} + +// Scrollbar styling +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; + + &:hover { + background: var(--text-muted); + } +} + +// Buttons +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-xs); + padding: var(--spacing-sm) var(--spacing-md); + font-size: 14px; + font-weight: 500; + line-height: 1; + border: 1px solid transparent; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &--primary { + background-color: var(--accent-primary); + color: #0d1117; + + &:hover:not(:disabled) { + opacity: 0.9; + } + } + + &--secondary { + background-color: var(--bg-tertiary); + border-color: var(--border-color); + color: var(--text-primary); + + &:hover:not(:disabled) { + background-color: var(--bg-secondary); + } + } + + &--danger { + background-color: var(--accent-danger); + color: white; + } + + &--ghost { + background: transparent; + color: var(--text-secondary); + + &:hover:not(:disabled) { + color: var(--text-primary); + background-color: var(--bg-tertiary); + } + } +} + +// Forms +.form-group { + margin-bottom: var(--spacing-md); +} + +.form-label { + display: block; + margin-bottom: var(--spacing-xs); + font-weight: 500; + color: var(--text-primary); +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + font-size: 14px; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-primary); + + &:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(57, 208, 216, 0.15); + } + + &::placeholder { + color: var(--text-muted); + } +} + +// Badges +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + font-size: 12px; + font-weight: 500; + border-radius: 999px; + + &--success { + background-color: rgba(63, 185, 80, 0.15); + color: var(--accent-success); + } + + &--warning { + background-color: rgba(210, 153, 34, 0.15); + color: var(--accent-warning); + } + + &--danger { + background-color: rgba(248, 81, 73, 0.15); + color: var(--accent-danger); + } + + &--info { + background-color: rgba(57, 208, 216, 0.15); + color: var(--accent-primary); + } +} + +// Utility classes +.text-muted { color: var(--text-muted); } +.text-success { color: var(--accent-success); } +.text-danger { color: var(--accent-danger); } +.text-warning { color: var(--accent-warning); } +.mono { font-family: var(--font-mono); } diff --git a/cmd/core-ide/frontend/tsconfig.app.json b/cmd/core-ide/frontend/tsconfig.app.json new file mode 100644 index 00000000..7d7c716d --- /dev/null +++ b/cmd/core-ide/frontend/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/cmd/core-ide/frontend/tsconfig.json b/cmd/core-ide/frontend/tsconfig.json new file mode 100644 index 00000000..62eaf438 --- /dev/null +++ b/cmd/core-ide/frontend/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "lib": [ + "ES2022", + "dom" + ], + "paths": { + "@app/*": ["src/app/*"], + "@shared/*": ["src/app/shared/*"] + } + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/cmd/core-ide/go.mod b/cmd/core-ide/go.mod new file mode 100644 index 00000000..626ea74d --- /dev/null +++ b/cmd/core-ide/go.mod @@ -0,0 +1,57 @@ +module github.com/host-uk/core/cmd/core-ide + +go 1.25.5 + +require ( + github.com/host-uk/core v0.0.0 + github.com/wailsapp/wails/v3 v3.0.0-alpha.64 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/adrg/xdg v0.5.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/ebitengine/purego v0.9.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.7.0 // indirect + github.com/go-git/go-git/v5 v5.16.4 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/lmittmann/tint v1.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modelcontextprotocol/go-sdk v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.52.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect + github.com/wailsapp/go-webview2 v1.0.23 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) + +replace github.com/host-uk/core => ../.. diff --git a/cmd/core-ide/go.sum b/cmd/core-ide/go.sum new file mode 100644 index 00000000..685ffc65 --- /dev/null +++ b/cmd/core-ide/go.sum @@ -0,0 +1,165 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= +github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= +github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= +github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= +github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= +github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= +github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= +github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs= +github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/core-ide/icons/appicon.png b/cmd/core-ide/icons/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..53adbd595d3e69cce3545aafe98f348b5eb4a3be GIT binary patch literal 76 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fqMj~}Ar*6y|NQ@N&n&>e@c%zE Z1IHhxf6EIyW&))dJYD@<);T3K0RVYV6kz}W literal 0 HcmV?d00001 diff --git a/cmd/core-ide/icons/icons.go b/cmd/core-ide/icons/icons.go new file mode 100644 index 00000000..72fb175c --- /dev/null +++ b/cmd/core-ide/icons/icons.go @@ -0,0 +1,25 @@ +// Package icons provides embedded icon assets for the Core IDE application. +package icons + +import _ "embed" + +// TrayTemplate is the template icon for macOS systray (22x22 PNG, black on transparent). +// Template icons automatically adapt to light/dark mode on macOS. +// +//go:embed tray-template.png +var TrayTemplate []byte + +// TrayLight is the light mode icon for Windows/Linux systray. +// +//go:embed tray-light.png +var TrayLight []byte + +// TrayDark is the dark mode icon for Windows/Linux systray. +// +//go:embed tray-dark.png +var TrayDark []byte + +// AppIcon is the main application icon. +// +//go:embed appicon.png +var AppIcon []byte diff --git a/cmd/core-ide/icons/tray-dark.png b/cmd/core-ide/icons/tray-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..53adbd595d3e69cce3545aafe98f348b5eb4a3be GIT binary patch literal 76 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fqMj~}Ar*6y|NQ@N&n&>e@c%zE Z1IHhxf6EIyW&))dJYD@<);T3K0RVYV6kz}W literal 0 HcmV?d00001 diff --git a/cmd/core-ide/icons/tray-light.png b/cmd/core-ide/icons/tray-light.png new file mode 100644 index 0000000000000000000000000000000000000000..53adbd595d3e69cce3545aafe98f348b5eb4a3be GIT binary patch literal 76 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fqMj~}Ar*6y|NQ@N&n&>e@c%zE Z1IHhxf6EIyW&))dJYD@<);T3K0RVYV6kz}W literal 0 HcmV?d00001 diff --git a/cmd/core-ide/icons/tray-template.png b/cmd/core-ide/icons/tray-template.png new file mode 100644 index 0000000000000000000000000000000000000000..53adbd595d3e69cce3545aafe98f348b5eb4a3be GIT binary patch literal 76 zcmeAS@N?(olHy`uVBq!ia0vp^Vj#@H1|*Mc$*~4fqMj~}Ar*6y|NQ@N&n&>e@c%zE Z1IHhxf6EIyW&))dJYD@<);T3K0RVYV6kz}W literal 0 HcmV?d00001 diff --git a/cmd/core-ide/ide_service.go b/cmd/core-ide/ide_service.go new file mode 100644 index 00000000..fca137ca --- /dev/null +++ b/cmd/core-ide/ide_service.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "log" + "net/http" + + "github.com/host-uk/core/pkg/mcp/ide" + "github.com/host-uk/core/pkg/ws" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// IDEService provides core IDE bindings for the frontend. +type IDEService struct { + app *application.App + ideSub *ide.Subsystem + hub *ws.Hub +} + +// NewIDEService creates a new IDEService. +func NewIDEService(ideSub *ide.Subsystem, hub *ws.Hub) *IDEService { + return &IDEService{ideSub: ideSub, hub: hub} +} + +// ServiceName returns the service name for Wails. +func (s *IDEService) ServiceName() string { return "IDEService" } + +// ServiceStartup is called when the Wails application starts. +func (s *IDEService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + // Start WebSocket HTTP server for the Angular frontend + go s.startWSServer() + log.Println("IDEService started") + return nil +} + +// ServiceShutdown is called when the Wails application shuts down. +func (s *IDEService) ServiceShutdown() error { + log.Println("IDEService shutdown") + return nil +} + +// ConnectionStatus represents the IDE bridge connection state. +type ConnectionStatus struct { + BridgeConnected bool `json:"bridgeConnected"` + LaravelURL string `json:"laravelUrl"` + WSClients int `json:"wsClients"` + WSChannels int `json:"wsChannels"` +} + +// GetConnectionStatus returns the current bridge and WebSocket status. +func (s *IDEService) GetConnectionStatus() ConnectionStatus { + connected := false + if s.ideSub.Bridge() != nil { + connected = s.ideSub.Bridge().Connected() + } + + stats := s.hub.Stats() + return ConnectionStatus{ + BridgeConnected: connected, + WSClients: stats.Clients, + WSChannels: stats.Channels, + } +} + +// DashboardData aggregates data for the dashboard view. +type DashboardData struct { + Connection ConnectionStatus `json:"connection"` +} + +// GetDashboard returns aggregated dashboard data. +func (s *IDEService) GetDashboard() DashboardData { + return DashboardData{ + Connection: s.GetConnectionStatus(), + } +} + +// ShowWindow shows a named window. +func (s *IDEService) ShowWindow(name string) { + if s.app == nil { + return + } + if w, ok := s.app.Window.Get(name); ok { + w.Show() + w.Focus() + } +} + +// startWSServer starts the WebSocket HTTP server for the Angular frontend. +func (s *IDEService) startWSServer() { + mux := http.NewServeMux() + mux.HandleFunc("/ws", s.hub.HandleWebSocket) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + }) + + addr := "127.0.0.1:9877" + log.Printf("IDE WebSocket server listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Printf("IDE WebSocket server error: %v", err) + } +} diff --git a/cmd/core-ide/main.go b/cmd/core-ide/main.go new file mode 100644 index 00000000..f9efb9fe --- /dev/null +++ b/cmd/core-ide/main.go @@ -0,0 +1,151 @@ +// Package main provides the Core IDE desktop application. +// Core IDE connects to the Laravel core-agentic backend via MCP bridge, +// providing a chat interface for AI agent sessions, build monitoring, +// and a system dashboard. +package main + +import ( + "context" + "embed" + "io/fs" + "log" + "runtime" + + "github.com/host-uk/core/cmd/core-ide/icons" + "github.com/host-uk/core/pkg/mcp/ide" + "github.com/host-uk/core/pkg/ws" + "github.com/wailsapp/wails/v3/pkg/application" +) + +//go:embed all:frontend/dist/core-ide/browser +var assets embed.FS + +func main() { + staticAssets, err := fs.Sub(assets, "frontend/dist/core-ide/browser") + if err != nil { + log.Fatal(err) + } + + // Create shared WebSocket hub for real-time streaming + hub := ws.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + // Create IDE subsystem (bridge to Laravel core-agentic) + ideSub := ide.New(hub) + ideSub.StartBridge(ctx) + + // Create Wails services + ideService := NewIDEService(ideSub, hub) + chatService := NewChatService(ideSub) + buildService := NewBuildService(ideSub) + + app := application.New(application.Options{ + Name: "Core IDE", + Description: "Host UK Platform IDE - AI Agent Sessions, Build Monitoring & Dashboard", + Services: []application.Service{ + application.NewService(ideService), + application.NewService(chatService), + application.NewService(buildService), + }, + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(staticAssets), + }, + Mac: application.MacOptions{ + ActivationPolicy: application.ActivationPolicyAccessory, + }, + }) + + ideService.app = app + + setupSystemTray(app, ideService) + + log.Println("Starting Core IDE...") + log.Println(" - System tray active") + log.Println(" - Bridge connecting to Laravel core-agentic...") + + if err := app.Run(); err != nil { + log.Fatal(err) + } + + cancel() +} + +// setupSystemTray configures the system tray icon, menu, and windows. +func setupSystemTray(app *application.App, ideService *IDEService) { + systray := app.SystemTray.New() + systray.SetTooltip("Core IDE") + + if runtime.GOOS == "darwin" { + systray.SetTemplateIcon(icons.TrayTemplate) + } else { + systray.SetDarkModeIcon(icons.TrayDark) + systray.SetIcon(icons.TrayLight) + } + + // Tray panel window + trayWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "tray-panel", + Title: "Core IDE", + Width: 400, + Height: 500, + URL: "/tray", + Hidden: true, + Frameless: true, + BackgroundColour: application.NewRGB(22, 27, 34), + }) + systray.AttachWindow(trayWindow).WindowOffset(5) + + // Main IDE window + app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "main", + Title: "Core IDE", + Width: 1400, + Height: 900, + URL: "/main", + Hidden: true, + BackgroundColour: application.NewRGB(22, 27, 34), + }) + + // Settings window + app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "settings", + Title: "Core IDE Settings", + Width: 600, + Height: 500, + URL: "/settings", + Hidden: true, + BackgroundColour: application.NewRGB(22, 27, 34), + }) + + // Tray menu + trayMenu := app.Menu.New() + + statusItem := trayMenu.Add("Status: Connecting...") + statusItem.SetEnabled(false) + + trayMenu.AddSeparator() + + trayMenu.Add("Open IDE").OnClick(func(ctx *application.Context) { + if w, ok := app.Window.Get("main"); ok { + w.Show() + w.Focus() + } + }) + + trayMenu.Add("Settings...").OnClick(func(ctx *application.Context) { + if w, ok := app.Window.Get("settings"); ok { + w.Show() + w.Focus() + } + }) + + trayMenu.AddSeparator() + + trayMenu.Add("Quit Core IDE").OnClick(func(ctx *application.Context) { + app.Quit() + }) + + systray.SetMenu(trayMenu) +} diff --git a/github-projects-recovery.md b/github-projects-recovery.md new file mode 100644 index 00000000..5ead7321 --- /dev/null +++ b/github-projects-recovery.md @@ -0,0 +1,403 @@ +# GitHub Projects Recovery — host-uk org + +> Recovered 2026-02-08 from flagged GitHub org before potential data loss. +> Projects 1 (Core.Framework) was empty. Projects 2, 3, 4 captured below. + +--- + +## Project 2: Workstation (43 items) + +> Agentic task queue — issues labelled agent:ready across all host-uk repos. + +| # | Title | Issue | +|---|-------|-------| +| 1 | feat: add workspace.yaml support for unified package commands | #38 | +| 2 | feat: add core setup command for GitHub repo configuration | #45 | +| 3 | docs sync ignores packages_dir from workspace.yaml | #46 | +| 4 | feat: add core qa command area for CI/workflow monitoring | #47 | +| 5 | feat: add core security command to expose Dependabot and code scanning alerts | #48 | +| 6 | feat: add core monitor to aggregate free tier scanner results | #49 | +| 7 | feat: add core qa issues for intelligent issue triage | #61 | +| 8 | feat: add core qa review for PR review status | #62 | +| 9 | feat: add core qa health for aggregate CI health | #63 | +| 10 | feat(dev): add safe git operations for AI agents | #53 | +| 11 | docs(mcp): Document MCP server setup and usage | #125 | +| 12 | feat: Implement persistent MCP server in daemon mode | #118 | +| 13 | chore(io): Migrate pkg/agentic to Medium abstraction | #104 | +| 14 | feat: Evolve pkg/io from Medium abstraction to io.Node (Borg + Enchantrix) | #101 | +| 15 | Add streaming API to pkg/io/local for large file handling | #224 | +| 16 | feat(hooks): Add core ai hook for async test running | #262 | +| 17 | feat(ai): Add core ai spawn for parallel agent tasks | #260 | +| 18 | feat(ai): Add core ai cost for budget tracking | #261 | +| 19 | feat(ai): Add core ai session for session management | #259 | +| 20 | feat(test): Add smart test detection to core test | #258 | +| 21 | feat(test): Add core test --watch continuous testing mode | #257 | +| 22 | feat(collect): Add core collect dispatch event hook system | #256 | +| 23 | feat(collect): Add core collect process command | #255 | +| 24 | feat(collect): Add core collect excavate command | #254 | +| 25 | feat(collect): Add core collect papers command | #253 | +| 26 | feat(collect): Add core collect bitcointalk command | #251 | +| 27 | feat(collect): Add core collect market command | #252 | +| 28 | feat(collect): Add core collect github command | #250 | +| 29 | epic(security): workspace isolation and authorisation hardening | #31 | +| 30 | epic(security): SQL query validation and execution safety | #32 | +| 31 | epic(fix): namespace and import corrections | #33 | +| 32 | epic(chore): configuration and documentation standardisation | #34 | +| 33 | Epic: Webhook Security Hardening | #27 | +| 34 | Epic: API Performance Optimisation | #28 | +| 35 | Epic: MCP API Hardening | #29 | +| 36 | Epic: API Test Coverage | #30 | +| 37 | Epic: Security Hardening | #104 | +| 38 | Epic: Input Validation & Sanitisation | #105 | +| 39 | Epic: Test Coverage | #106 | +| 40 | Epic: Error Handling & Observability | #107 | +| 41 | Epic: Performance Optimisation | #108 | +| 42 | Epic: Code Quality & Architecture | #109 | +| 43 | Epic: Documentation | #110 | + +--- + +## Project 4: Core.GO & Core.CLI (97 items) + +> Go framework and CLI development — host-uk/core repo. Filter by lang:go label. + +| # | Title | Issue | +|---|-------|-------| +| 1 | feat: add workspace.yaml support for unified package commands | #38 | +| 2 | feat: add core setup command for GitHub repo configuration | #45 | +| 3 | docs sync ignores packages_dir from workspace.yaml | #46 | +| 4 | feat: add core qa command area for CI/workflow monitoring | #47 | +| 5 | feat: add core security command to expose Dependabot and code scanning alerts | #48 | +| 6 | feat: add core monitor to aggregate free tier scanner results | #49 | +| 7 | feat(crypt): Implement standalone pkg/crypt with modern cryptographic primitives | #168 | +| 8 | feat(cli): Implement build variants for reduced attack surface | #171 | +| 9 | feat(config): Implement standalone pkg/config with layered configuration | #167 | +| 10 | feat(io): Fix pkg/io import and add symlink-safe path validation | #169 | +| 11 | feat(plugin): Consolidate pkg/module into pkg/plugin with GitHub installation | #170 | +| 12 | feat(help): Implement full-text search | #139 | +| 13 | feat(help): Implement Catalog and Topic types | #138 | +| 14 | feat(help): Implement markdown parsing and section extraction | #137 | +| 15 | feat(help): Remove Wails dependencies from pkg/help | #134 | +| 16 | feat(help): Add CLI help command | #136 | +| 17 | docs(help): Create help content for core CLI | #135 | +| 18 | feat(help): Implement display-agnostic help system for CLI and GUI | #133 | +| 19 | chore(log): Remove deprecated pkg/errors package | #131 | +| 20 | feat(log): Add combined log-and-return error helpers | #129 | +| 21 | chore(log): Create pkg/errors deprecation alias | #128 | +| 22 | feat(log): Unify pkg/errors and pkg/log into single logging package | #127 | +| 23 | feat(mcp): Add TCP transport | #126 | +| 24 | docs(mcp): Document MCP server setup and usage | #125 | +| 25 | feat(mcp): Add MCP command for manual server control | #124 | +| 26 | feat(mcp): Create MCPService for framework integration | #122 | +| 27 | feat(mcp): Add health check integration | #123 | +| 28 | chore(log): Migrate pkg/errors imports to pkg/log | #130 | +| 29 | feat(mcp): Add connection management and graceful draining | #121 | +| 30 | feat(mcp): Add daemon mode detection and auto-start | #119 | +| 31 | feat(mcp): Add Unix socket transport | #120 | +| 32 | feat: Implement persistent MCP server in daemon mode | #118 | +| 33 | chore(io): Migrate internal/cmd/setup to Medium abstraction | #116 | +| 34 | chore(io): Migrate internal/cmd/docs to Medium abstraction | #113 | +| 35 | chore(io): Migrate remaining internal/cmd/* to Medium abstraction | #117 | +| 36 | chore(io): Migrate internal/cmd/dev to Medium abstraction | #114 | +| 37 | chore(io): Migrate internal/cmd/sdk to Medium abstraction | #115 | +| 38 | chore(io): Migrate internal/cmd/php to Medium abstraction | #112 | +| 39 | feat(log): Add error creation functions to pkg/log | #132 | +| 40 | chore(io): Migrate pkg/cache to Medium abstraction | #111 | +| 41 | chore(io): Migrate pkg/devops to Medium abstraction | #110 | +| 42 | chore(io): Migrate pkg/cli to Medium abstraction | #107 | +| 43 | chore(io): Migrate pkg/build to Medium abstraction | #109 | +| 44 | chore(io): Migrate pkg/container to Medium abstraction | #105 | +| 45 | chore(io): Migrate pkg/repos to Medium abstraction | #108 | +| 46 | feat(io): Migrate pkg/mcp to use Medium abstraction | #103 | +| 47 | chore(io): Migrate pkg/release to Medium abstraction | #106 | +| 48 | chore(io): Migrate pkg/agentic to Medium abstraction | #104 | +| 49 | feat(io): Extend Medium interface with missing operations | #102 | +| 50 | fix(php): core php ci improvements needed | #92 | +| 51 | CLI Output: Color contrast audit and terminal adaptation | #99 | +| 52 | feat: Evolve pkg/io from Medium abstraction to io.Node (Borg + Enchantrix) | #101 | +| 53 | Documentation: Improve Accessibility | #89 | +| 54 | Web UI: Audit Angular App Accessibility | #88 | +| 55 | Add configuration documentation to README | #236 | +| 56 | Add Architecture Decision Records (ADRs) | #237 | +| 57 | Add user documentation: user guide, FAQ, troubleshooting guide | #235 | +| 58 | Add CHANGELOG.md to track version changes | #234 | +| 59 | Add CONTRIBUTING.md with contribution guidelines | #233 | +| 60 | Create centralized configuration service to reduce code duplication | #232 | +| 61 | Update README.md to reflect actual configuration management implementation | #231 | +| 62 | Centralize user-facing error strings in i18n translation files | #230 | +| 63 | Log all errors at handling point with contextual information | #229 | +| 64 | Implement panic recovery mechanism with graceful shutdown | #228 | +| 65 | Standardize on cli.Error for user-facing errors, deprecate cli.Fatal | #227 | +| 66 | Add linker flags (-s -w) to reduce binary size | #226 | +| 67 | Use background goroutines for long-running operations to prevent UI blocking | #225 | +| 68 | Add streaming API to pkg/io/local for large file handling | #224 | +| 69 | Fix Go environment to run govulncheck for dependency scanning | #223 | +| 70 | Sanitize user input in execInContainer to prevent injection | #222 | +| 71 | Configure branch coverage measurement in test tooling | #220 | +| 72 | Remove StrictHostKeyChecking=no from SSH commands | #221 | +| 73 | Implement authentication and authorization features described in README | #217 | +| 74 | Add tests for edge cases, error paths, and integration scenarios | #219 | +| 75 | Increase test coverage for low-coverage packages (cli, internal/cmd/dev) | #218 | +| 76 | Introduce typed messaging system for IPC (replace interface{}) | #216 | +| 77 | Refactor Core struct to smaller, focused components (ServiceManager, MessageBus, LifecycleManager) | #215 | +| 78 | Implement structured logging (JSON format) | #212 | +| 79 | Implement log retention policy | #214 | +| 80 | Add logging for security events (authentication, access) | #213 | +| 81 | feat(setup): add .core/setup.yaml for dev environment bootstrapping | #211 | +| 82 | audit: Documentation completeness and quality | #192 | +| 83 | audit: API design and consistency | #191 | +| 84 | [Audit] Concurrency and Race Condition Analysis | #197 | +| 85 | feat(hooks): Add core ai hook for async test running | #262 | +| 86 | feat(ai): Add core ai spawn for parallel agent tasks | #260 | +| 87 | feat(ai): Add core ai cost for budget tracking | #261 | +| 88 | feat(ai): Add core ai session for session management | #259 | +| 89 | feat(test): Add smart test detection to core test | #258 | +| 90 | feat(test): Add core test --watch continuous testing mode | #257 | +| 91 | feat(collect): Add core collect dispatch event hook system | #256 | +| 92 | feat(collect): Add core collect process command | #255 | +| 93 | feat(collect): Add core collect excavate command | #254 | +| 94 | feat(collect): Add core collect bitcointalk command | #251 | +| 95 | feat(collect): Add core collect papers command | #253 | +| 96 | feat(collect): Add core collect market command | #252 | +| 97 | feat(collect): Add core collect github command | #250 | + +--- + +## Project 3: Core.PHP (195 items) + +> Laravel/PHP ecosystem — all core-* packages. Filter by lang:php label. + +| # | Title | Issue | +|---|-------|-------| +| 1 | Dependency: Consider adding security scanning to CI pipeline | #31 | +| 2 | Concurrency: Sanitiser preset registration not thread-safe | #32 | +| 3 | Documentation: Missing SECURITY.md with vulnerability reporting process | #30 | +| 4 | Error Handling: ResilientSession redirect loop potential | #28 | +| 5 | Configuration: ConfigValue encryption may cause issues during APP_KEY rotation | #25 | +| 6 | Testing: Missing test coverage for critical security components | #23 | +| 7 | Security: HadesEncrypt embeds hardcoded public key | #21 | +| 8 | Security: SafeWebhookUrl DNS rebinding vulnerability | #17 | +| 9 | Performance: selectRaw queries may have missing indexes | #19 | +| 10 | Core Bouncer: Request Whitelisting System | #14 | +| 11 | Security: ManagesTokens trait stores tokens in memory without protection | #18 | +| 12 | Trees: Consolidate subscriber monthly command from Commerce module | #12 | +| 13 | Trees: Webhook/API for TFTF confirmation | #13 | +| 14 | CSRF token not automatically attached in bootstrap.js | #17 | +| 15 | Missing exception handling configuration in bootstrap/app.php | #15 | +| 16 | CI workflow only runs on main branch but repo uses dev as main | #14 | +| 17 | Minimal test coverage for a best-practices template | #16 | +| 18 | Missing declare(strict_types=1) in PHP files violates coding standards | #12 | +| 19 | Dependencies using dev-main branches instead of stable versions | #13 | +| 20 | Security: No HTTPS enforcement in production | #11 | +| 21 | Security: SESSION_ENCRYPT=false in .env.example is insecure default | #8 | +| 22 | Security: No rate limiting configured for any routes | #10 | +| 23 | Security: Missing security headers middleware by default | #9 | +| 24 | Security: ActivityLog query vulnerable to SQL wildcard injection | #20 | +| 25 | Missing: Rate limiting not applied to Livewire component methods | #17 | +| 26 | Missing: Log redaction patterns incomplete for common sensitive data | #16 | +| 27 | Code Quality: Livewire components duplicate checkHadesAccess() method | #19 | +| 28 | Error Handling: RemoteServerManager writeFile() has command injection via base64 | #15 | +| 29 | Missing: phpseclib3 not in composer.json dependencies | #18 | +| 30 | Performance: Query logging enabled unconditionally in local environment | #12 | +| 31 | Testing: Test suite does not verify Hades authorization enforcement | #11 | +| 32 | Error Handling: LogReaderService silently fails on file operations | #10 | +| 33 | Security: Telescope hides insufficient request headers in production | #14 | +| 34 | Security: IP validation missing for Server model | #13 | +| 35 | Security: Hades cookie has 1-year expiry with no rotation | #8 | +| 36 | Security: DevController authorize() method undefined | #7 | +| 37 | Security: Missing HADES_TOKEN configuration in .env.example | #9 | +| 38 | Security: Missing workspace authorization check when creating Server records | #6 | +| 39 | Security: SQL injection vulnerability in Database query tool - stacked query bypass | #4 | +| 40 | Security: Server SSH connection test uses StrictHostKeyChecking=no | #5 | +| 41 | Missing: Webhook endpoint URL scheme validation | #19 | +| 42 | Missing: Tests for WebhookSecretRotationService grace period edge cases | #20 | +| 43 | Performance: ApiUsageDaily recordFromUsage performs multiple queries | #18 | +| 44 | Security: API key scopes exposed in 403 error responses | #17 | +| 45 | Missing: Webhook delivery retry job lacks idempotency key | #15 | +| 46 | Configuration: No environment variable validation for API config | #16 | +| 47 | Error Handling: MCP registry YAML files read without validation | #14 | +| 48 | Missing: Index on webhook_deliveries for needsDelivery scope | #12 | +| 49 | Code Quality: WebhookSignature generateSecret uses Str::random instead of cryptographic RNG | #13 | +| 50 | Error Handling: recordUsage() called synchronously on every request | #10 | +| 51 | Security: Rate limit sliding window stores individual timestamps - memory growth concern | #9 | +| 52 | Security: WebhookSecretController lacks authorization checks | #11 | +| 53 | Security: Webhook secret visible in API response after rotation | #7 | +| 54 | Missing: Tests for MCP API Controller tool execution | #8 | +| 55 | Performance: API key lookup requires loading all candidates with matching prefix | #6 | +| 56 | Security: Webhook URL SSRF vulnerability - no validation of internal/private network URLs | #4 | +| 57 | Security: MCP tool execution uses proc_open without output sanitization | #5 | +| 58 | Missing tests for Social API controllers | #2 | +| 59 | Verify ProductApiController implementation | #3 | +| 60 | Session data stored without encryption (SESSION_ENCRYPT=false) | #18 | +| 61 | Mass assignment vulnerability in ContentEditor save method | #17 | +| 62 | AdminPageSearchProvider returns hardcoded URLs without auth checking | #16 | +| 63 | Missing rate limiting on sensitive admin operations | #14 | +| 64 | XSS risk in GlobalSearch component's JSON encoding | #13 | +| 65 | Missing validation for sortField parameter allows SQL injection | #10 | +| 66 | Missing test coverage for critical admin operations | #11 | +| 67 | Cache flush in Platform.php may cause service disruption | #12 | +| 68 | Missing CSRF protection for Livewire file uploads | #9 | +| 69 | N+1 query risk in ContentManager computed properties | #8 | +| 70 | Missing route authentication middleware on admin routes | #7 | +| 71 | Missing authorization check on Dashboard and Console components | #4 | +| 72 | SQL injection risk via LIKE wildcards in search queries | #5 | +| 73 | Bug: CheckMcpQuota middleware checks wrong attribute name | #22 | +| 74 | Security: DataRedactor does not handle object properties | #21 | +| 75 | Performance: QueryDatabase tool fetches all results before truncation | #20 | +| 76 | Documentation: Missing env validation for sensitive configuration | #23 | +| 77 | Security: McpAuditLog hash chain has race condition in transaction | #18 | +| 78 | Configuration: Missing MCP config file with database and security settings | #17 | +| 79 | Security: ApiKeyManager Livewire component missing CSRF and rate limiting | #19 | +| 80 | Error Handling: QueryExecutionService swallows timeout configuration errors | #16 | +| 81 | Security: SqlQueryValidator whitelist regex may allow SQL injection via JOINs | #15 | +| 82 | Test Coverage: Missing tests for critical security components | #14 | +| 83 | Security: McpApiController namespace mismatch and missing authorization | #11 | +| 84 | Security: AuditLogService export method has no authorization check | #13 | +| 85 | Bug: UpgradePlan tool imports RequiresWorkspaceContext from wrong namespace | #10 | +| 86 | Security: McpAuthenticate accepts API key in query string | #8 | +| 87 | Performance: AuditLogService hash chain verification loads entire log table | #12 | +| 88 | Bug: CircuitBreaker imports wrong namespace for CircuitOpenException | #9 | +| 89 | Security: ListTables tool uses MySQL-specific SHOW TABLES query | #7 | +| 90 | Security: ListTables tool exposes all database tables without authorization | #6 | +| 91 | Security: CreateCoupon tool missing strict_types declaration | #4 | +| 92 | Multi-server federation for MCP | #3 | +| 93 | Security: CreateCoupon tool missing workspace context/authorization | #5 | +| 94 | WebSocket support for real-time MCP updates | #2 | +| 95 | Incomplete account deletion may leave orphaned data | #13 | +| 96 | Error handling gap: Webhook secret returned in creation response | #14 | +| 97 | Missing environment validation for sensitive configuration | #18 | +| 98 | Potential timing attack in invitation token verification | #17 | +| 99 | Race condition in workspace default switching | #11 | +| 100 | Missing test coverage for TotpService TOTP verification | #12 | +| 101 | Missing authorisation check in EntitlementApiController::summary | #10 | +| 102 | Missing rate limiting on sensitive entitlement API endpoints | #9 | +| 103 | Security: Hardcoded test credentials in DemoTestUserSeeder | #7 | +| 104 | Security: SQL injection-like pattern in search query | #8 | +| 105 | Complete UserStatsService TODO items | #2 | +| 106 | Security: SSRF protection missing DNS rebinding defence in webhook dispatch job | #6 | +| 107 | Refund::markAsSucceeded not wrapped in transaction with payment update | #28 | +| 108 | Missing strict_types in Refund model | #30 | +| 109 | CreditNoteService::autoApplyCredits lacks transaction wrapper | #27 | +| 110 | Fail-open VAT validation could allow tax evasion | #25 | +| 111 | Missing strict_types in CreditNote model | #29 | +| 112 | Missing tests for CommerceController API endpoints | #26 | +| 113 | API controller returns raw exception messages to clients | #22 | +| 114 | Missing rate limiting on Commerce API endpoints | #23 | +| 115 | ProcessDunning console command lacks mutex/locking for concurrent runs | #24 | +| 116 | Race condition in CreditNote::recordUsage without row locking | #21 | +| 117 | Missing strict_types in PaymentMethodService.php | #20 | +| 118 | Missing strict_types in CreditNoteService.php | #19 | +| 119 | Missing tests for UsageBillingService | #16 | +| 120 | Missing strict_types in RefundService.php | #18 | +| 121 | Missing return type declarations in CreditNote model scopes | #14 | +| 122 | Missing tests for PaymentMethodService | #17 | +| 123 | MySQL-specific raw SQL breaks database portability | #13 | +| 124 | Missing strict_types declaration in UsageBillingService.php | #11 | +| 125 | Weak random number generation in CreditNote reference number | #12 | +| 126 | Missing tests for CreditNoteService | #15 | +| 127 | Missing tests for critical fraud detection paths | #9 | +| 128 | Missing strict_types declaration in TaxService.php | #10 | +| 129 | Missing index validation and SQL injection protection in Coupon scopes | #6 | +| 130 | Missing database transaction in referral payout commission assignment | #8 | +| 131 | Potential N+1 query in StripeGateway::createCheckoutSession | #7 | +| 132 | Race condition in Order number generation | #5 | +| 133 | Missing strict type declaration in SubscriptionService.php | #3 | +| 134 | Warehouse & Fulfillment System | #2 | +| 135 | Race condition in Invoice number generation | #4 | +| 136 | [Audit] Architecture Patterns | #50 | +| 137 | [Audit] Database Query Optimization | #48 | +| 138 | [Audit] Error Handling and Recovery | #51 | +| 139 | [Audit] Concurrency and Race Condition Analysis | #47 | +| 140 | audit: API design and consistency | #44 | +| 141 | audit: Performance bottlenecks and optimization | #43 | +| 142 | [Audit] Multi-Tenancy Security | #23 | +| 143 | fix(composer): simplify dependencies for hello world setup | #21 | +| 144 | [Audit] Database Query Optimization | #23 | +| 145 | audit: Test coverage and quality | #42 | +| 146 | audit: Code complexity and maintainability | #41 | +| 147 | audit: Authentication and authorization flows | #38 | +| 148 | audit: Dependency vulnerabilities and supply chain | #39 | +| 149 | [Audit] Database Query Optimization | #22 | +| 150 | audit: OWASP Top 10 security review | #36 | +| 151 | audit: Input validation and sanitization | #37 | +| 152 | security(mcp): ContentTools.php accepts workspace as request parameter enabling cross-tenant access | #29 | +| 153 | quality(mcp): standardise tool schema and request input patterns to match MCP spec | #30 | +| 154 | epic(security): workspace isolation and authorisation hardening | #31 | +| 155 | epic(security): SQL query validation and execution safety | #32 | +| 156 | epic(fix): namespace and import corrections | #33 | +| 157 | epic(chore): configuration and documentation standardisation | #34 | +| 158 | Epic: Webhook Security Hardening | #27 | +| 159 | Epic: API Performance Optimisation | #28 | +| 160 | Epic: MCP API Hardening | #29 | +| 161 | Epic: API Test Coverage | #30 | +| 162 | security(trees): fix race condition in PlantTreeWithTFTF job | #77 | +| 163 | security(auth): replace LthnHash with bcrypt for password hashing | #78 | +| 164 | security(helpers): fix SSRF in File.php via unvalidated Http::get | #79 | +| 165 | security(input): sanitise route parameters in Sanitiser middleware | #80 | +| 166 | security(trees): validate $model parameter in TreeStatsController | #81 | +| 167 | security(tests): remove hardcoded API token from test file | #82 | +| 168 | quality(bouncer): move env() call to config file in BouncerMiddleware | #83 | +| 169 | security(api): prevent upstream body leakage in BuildsResponse | #84 | +| 170 | security(auth): add session configuration file | #85 | +| 171 | quality(logging): add correlation IDs to request logging | #86 | +| 172 | security(logging): prevent PII leakage in LogsActivity trait | #87 | +| 173 | performance(queries): fix N+1 queries in ConfigResolver, AdminMenuRegistry, activity feed, SeoScoreTrend | #88 | +| 174 | performance(queries): replace ::all() with chunking/cursors | #89 | +| 175 | security(bouncer): review overly permissive bypass patterns | #90 | +| 176 | performance(http): add caching headers middleware | #91 | +| 177 | quality(scanner): refactor ModuleScanner namespace detection | #92 | +| 178 | security(input): extend superglobal sanitisation to cookies and server vars | #93 | +| 179 | docs(arch): add architecture diagram | #94 | +| 180 | docs(decisions): add Architecture Decision Records | #95 | +| 181 | docs(changelog): create formal changelog | #96 | +| 182 | docs(guide): add user guide, FAQ, and troubleshooting | #97 | +| 183 | quality(tenant): fix BelongsToWorkspace trait location discrepancy | #98 | +| 184 | quality(errors): implement custom exception hierarchy | #99 | +| 185 | quality(registry): reduce code duplication in ModuleRegistry | #100 | +| 186 | test(unit): add unit tests for src/ classes | #101 | +| 187 | test(security): add security-specific test suite | #102 | +| 188 | test(integration): add integration tests | #103 | +| 189 | Epic: Performance Optimisation | #108 | +| 190 | Epic: Code Quality & Architecture | #109 | +| 191 | Epic: Documentation | #110 | +| 192 | Epic: Input Validation & Sanitisation | #105 | +| 193 | Epic: Security Hardening | #104 | +| 194 | Epic: Test Coverage | #106 | +| 195 | Epic: Error Handling & Observability | #107 | + +--- + +## Summary + +| Project | Items | Focus | +|---------|-------|-------| +| #1 Core.Framework | 0 (empty) | 10,000ft architectural decisions | +| #2 Workstation | 43 | Agentic task queue, cross-repo | +| #3 Core.PHP | 195 | Laravel/PHP security, quality, tests | +| #4 Core.GO & Core.CLI | 97 | Go framework, CLI, MCP, io abstraction | +| **Total** | **335** | | + +### Categories at a glance + +**Core.PHP (#3)** — Dominated by security findings and audit results: +- ~60 security vulnerabilities (SQL injection, SSRF, XSS, auth bypass, race conditions) +- ~30 missing strict_types / coding standards +- ~25 missing test coverage +- ~15 performance issues (N+1 queries, missing indexes) +- ~10 epics grouping related work +- ~10 audit tasks +- Misc: docs, config, quality + +**Core.GO (#4)** — Feature development and refactoring: +- ~15 io/Medium abstraction migrations +- ~10 MCP server features (transports, daemon, health) +- ~10 help system features +- ~8 log/error unification +- ~8 collect commands (data gathering) +- ~7 ai/test commands +- ~7 documentation/config audit +- Misc: security hardening, accessibility + +**Workstation (#2)** — Subset of #3 and #4 tagged for agentic execution: +- Features ready for AI agent implementation +- Epics spanning both Go and PHP diff --git a/pkg/mcp/ide/bridge.go b/pkg/mcp/ide/bridge.go new file mode 100644 index 00000000..e0d6f3a8 --- /dev/null +++ b/pkg/mcp/ide/bridge.go @@ -0,0 +1,182 @@ +package ide + +import ( + "context" + "encoding/json" + "fmt" + "log" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/host-uk/core/pkg/ws" +) + +// BridgeMessage is the wire format between the IDE and Laravel. +type BridgeMessage struct { + Type string `json:"type"` + Channel string `json:"channel,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Data any `json:"data,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// Bridge maintains a WebSocket connection to the Laravel core-agentic +// backend and forwards responses to a local ws.Hub. +type Bridge struct { + cfg Config + hub *ws.Hub + conn *websocket.Conn + + mu sync.Mutex + connected bool + cancel context.CancelFunc +} + +// NewBridge creates a bridge that will connect to the Laravel backend and +// forward incoming messages to the provided ws.Hub channels. +func NewBridge(hub *ws.Hub, cfg Config) *Bridge { + return &Bridge{cfg: cfg, hub: hub} +} + +// Start begins the connection loop in a background goroutine. +// Call Shutdown to stop it. +func (b *Bridge) Start(ctx context.Context) { + ctx, b.cancel = context.WithCancel(ctx) + go b.connectLoop(ctx) +} + +// Shutdown cleanly closes the bridge. +func (b *Bridge) Shutdown() { + if b.cancel != nil { + b.cancel() + } + b.mu.Lock() + defer b.mu.Unlock() + if b.conn != nil { + b.conn.Close() + b.conn = nil + } + b.connected = false +} + +// Connected reports whether the bridge has an active connection. +func (b *Bridge) Connected() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.connected +} + +// Send sends a message to the Laravel backend. +func (b *Bridge) Send(msg BridgeMessage) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.conn == nil { + return fmt.Errorf("bridge: not connected") + } + msg.Timestamp = time.Now() + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("bridge: marshal failed: %w", err) + } + return b.conn.WriteMessage(websocket.TextMessage, data) +} + +// connectLoop reconnects to Laravel with exponential backoff. +func (b *Bridge) connectLoop(ctx context.Context) { + delay := b.cfg.ReconnectInterval + for { + select { + case <-ctx.Done(): + return + default: + } + + if err := b.dial(ctx); err != nil { + log.Printf("ide bridge: connect failed: %v", err) + select { + case <-ctx.Done(): + return + case <-time.After(delay): + } + delay = min(delay*2, b.cfg.MaxReconnectInterval) + continue + } + + // Reset backoff on successful connection + delay = b.cfg.ReconnectInterval + b.readLoop(ctx) + } +} + +func (b *Bridge) dial(ctx context.Context) error { + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + } + conn, _, err := dialer.DialContext(ctx, b.cfg.LaravelWSURL, nil) + if err != nil { + return err + } + + b.mu.Lock() + b.conn = conn + b.connected = true + b.mu.Unlock() + + log.Printf("ide bridge: connected to %s", b.cfg.LaravelWSURL) + return nil +} + +func (b *Bridge) readLoop(ctx context.Context) { + defer func() { + b.mu.Lock() + if b.conn != nil { + b.conn.Close() + } + b.connected = false + b.mu.Unlock() + }() + + for { + select { + case <-ctx.Done(): + return + default: + } + + _, data, err := b.conn.ReadMessage() + if err != nil { + log.Printf("ide bridge: read error: %v", err) + return + } + + var msg BridgeMessage + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("ide bridge: unmarshal error: %v", err) + continue + } + + b.dispatch(msg) + } +} + +// dispatch routes an incoming message to the appropriate ws.Hub channel. +func (b *Bridge) dispatch(msg BridgeMessage) { + if b.hub == nil { + return + } + + wsMsg := ws.Message{ + Type: ws.TypeEvent, + Data: msg.Data, + } + + channel := msg.Channel + if channel == "" { + channel = "ide:" + msg.Type + } + + if err := b.hub.SendToChannel(channel, wsMsg); err != nil { + log.Printf("ide bridge: dispatch to %s failed: %v", channel, err) + } +} diff --git a/pkg/mcp/ide/bridge_test.go b/pkg/mcp/ide/bridge_test.go new file mode 100644 index 00000000..faae4dbc --- /dev/null +++ b/pkg/mcp/ide/bridge_test.go @@ -0,0 +1,237 @@ +package ide + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/host-uk/core/pkg/ws" +) + +var testUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// echoServer creates a test WebSocket server that echoes messages back. +func echoServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := testUpgrader.Upgrade(w, r, nil) + if err != nil { + t.Logf("upgrade error: %v", err) + return + } + defer conn.Close() + for { + mt, data, err := conn.ReadMessage() + if err != nil { + break + } + if err := conn.WriteMessage(mt, data); err != nil { + break + } + } + })) +} + +func wsURL(ts *httptest.Server) string { + return "ws" + strings.TrimPrefix(ts.URL, "http") +} + +func TestBridge_Good_ConnectAndSend(t *testing.T) { + ts := echoServer(t) + defer ts.Close() + + hub := ws.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + cfg := DefaultConfig() + cfg.LaravelWSURL = wsURL(ts) + cfg.ReconnectInterval = 100 * time.Millisecond + + bridge := NewBridge(hub, cfg) + bridge.Start(ctx) + + // Wait for connection + deadline := time.Now().Add(2 * time.Second) + for !bridge.Connected() && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + if !bridge.Connected() { + t.Fatal("bridge did not connect within timeout") + } + + err := bridge.Send(BridgeMessage{ + Type: "test", + Data: "hello", + }) + if err != nil { + t.Fatalf("Send() failed: %v", err) + } +} + +func TestBridge_Good_Shutdown(t *testing.T) { + ts := echoServer(t) + defer ts.Close() + + hub := ws.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + cfg := DefaultConfig() + cfg.LaravelWSURL = wsURL(ts) + cfg.ReconnectInterval = 100 * time.Millisecond + + bridge := NewBridge(hub, cfg) + bridge.Start(ctx) + + deadline := time.Now().Add(2 * time.Second) + for !bridge.Connected() && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + + bridge.Shutdown() + if bridge.Connected() { + t.Error("bridge should be disconnected after Shutdown") + } +} + +func TestBridge_Bad_SendWithoutConnection(t *testing.T) { + hub := ws.NewHub() + cfg := DefaultConfig() + bridge := NewBridge(hub, cfg) + + err := bridge.Send(BridgeMessage{Type: "test"}) + if err == nil { + t.Error("expected error when sending without connection") + } +} + +func TestBridge_Good_MessageDispatch(t *testing.T) { + // Server that sends a message to the bridge on connect. + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := testUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + msg := BridgeMessage{ + Type: "chat_response", + Channel: "chat:session-1", + Data: "hello from laravel", + } + data, _ := json.Marshal(msg) + conn.WriteMessage(websocket.TextMessage, data) + + // Keep connection open + for { + _, _, err := conn.ReadMessage() + if err != nil { + break + } + } + })) + defer ts.Close() + + hub := ws.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + cfg := DefaultConfig() + cfg.LaravelWSURL = wsURL(ts) + cfg.ReconnectInterval = 100 * time.Millisecond + + bridge := NewBridge(hub, cfg) + bridge.Start(ctx) + + deadline := time.Now().Add(2 * time.Second) + for !bridge.Connected() && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + if !bridge.Connected() { + t.Fatal("bridge did not connect within timeout") + } + + // Give time for the dispatched message to be processed. + time.Sleep(200 * time.Millisecond) + + // Verify hub stats — the message was dispatched (even without subscribers). + // This confirms the dispatch path ran without error. +} + +func TestBridge_Good_Reconnect(t *testing.T) { + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + conn, err := testUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + // Close immediately on first connection to force reconnect + if callCount == 1 { + conn.Close() + return + } + defer conn.Close() + for { + _, _, err := conn.ReadMessage() + if err != nil { + break + } + } + })) + defer ts.Close() + + hub := ws.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + cfg := DefaultConfig() + cfg.LaravelWSURL = wsURL(ts) + cfg.ReconnectInterval = 100 * time.Millisecond + cfg.MaxReconnectInterval = 200 * time.Millisecond + + bridge := NewBridge(hub, cfg) + bridge.Start(ctx) + + // Wait long enough for a reconnect cycle + deadline := time.Now().Add(3 * time.Second) + for !bridge.Connected() && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + } + if !bridge.Connected() { + t.Fatal("bridge did not reconnect within timeout") + } + if callCount < 2 { + t.Errorf("expected at least 2 connection attempts, got %d", callCount) + } +} + +func TestSubsystem_Good_Name(t *testing.T) { + sub := New(nil) + if sub.Name() != "ide" { + t.Errorf("expected name 'ide', got %q", sub.Name()) + } +} + +func TestSubsystem_Good_NilHub(t *testing.T) { + sub := New(nil) + if sub.Bridge() != nil { + t.Error("expected nil bridge when hub is nil") + } + // Shutdown should not panic + if err := sub.Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown with nil bridge failed: %v", err) + } +} diff --git a/pkg/mcp/ide/config.go b/pkg/mcp/ide/config.go new file mode 100644 index 00000000..d501c090 --- /dev/null +++ b/pkg/mcp/ide/config.go @@ -0,0 +1,48 @@ +// Package ide provides an MCP subsystem that bridges the desktop IDE to +// a Laravel core-agentic backend over WebSocket. +package ide + +import "time" + +// Config holds connection and workspace settings for the IDE subsystem. +type Config struct { + // LaravelWSURL is the WebSocket endpoint for the Laravel core-agentic backend. + LaravelWSURL string + + // WorkspaceRoot is the local path used as the default workspace context. + WorkspaceRoot string + + // ReconnectInterval controls how long to wait between reconnect attempts. + ReconnectInterval time.Duration + + // MaxReconnectInterval caps exponential backoff for reconnection. + MaxReconnectInterval time.Duration +} + +// DefaultConfig returns sensible defaults for local development. +func DefaultConfig() Config { + return Config{ + LaravelWSURL: "ws://localhost:9876/ws", + WorkspaceRoot: ".", + ReconnectInterval: 2 * time.Second, + MaxReconnectInterval: 30 * time.Second, + } +} + +// Option configures the IDE subsystem. +type Option func(*Config) + +// WithLaravelURL sets the Laravel WebSocket endpoint. +func WithLaravelURL(url string) Option { + return func(c *Config) { c.LaravelWSURL = url } +} + +// WithWorkspaceRoot sets the workspace root directory. +func WithWorkspaceRoot(root string) Option { + return func(c *Config) { c.WorkspaceRoot = root } +} + +// WithReconnectInterval sets the base reconnect interval. +func WithReconnectInterval(d time.Duration) Option { + return func(c *Config) { c.ReconnectInterval = d } +} diff --git a/pkg/mcp/ide/ide.go b/pkg/mcp/ide/ide.go new file mode 100644 index 00000000..f44b91a2 --- /dev/null +++ b/pkg/mcp/ide/ide.go @@ -0,0 +1,57 @@ +package ide + +import ( + "context" + + "github.com/host-uk/core/pkg/ws" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Subsystem implements mcp.Subsystem and mcp.SubsystemWithShutdown for the IDE. +type Subsystem struct { + cfg Config + bridge *Bridge + hub *ws.Hub +} + +// New creates an IDE subsystem. The ws.Hub is used for real-time forwarding; +// pass nil if headless (tools still work but real-time streaming is disabled). +func New(hub *ws.Hub, opts ...Option) *Subsystem { + cfg := DefaultConfig() + for _, opt := range opts { + opt(&cfg) + } + var bridge *Bridge + if hub != nil { + bridge = NewBridge(hub, cfg) + } + return &Subsystem{cfg: cfg, bridge: bridge, hub: hub} +} + +// Name implements mcp.Subsystem. +func (s *Subsystem) Name() string { return "ide" } + +// RegisterTools implements mcp.Subsystem. +func (s *Subsystem) RegisterTools(server *mcp.Server) { + s.registerChatTools(server) + s.registerBuildTools(server) + s.registerDashboardTools(server) +} + +// Shutdown implements mcp.SubsystemWithShutdown. +func (s *Subsystem) Shutdown(_ context.Context) error { + if s.bridge != nil { + s.bridge.Shutdown() + } + return nil +} + +// Bridge returns the Laravel WebSocket bridge (may be nil in headless mode). +func (s *Subsystem) Bridge() *Bridge { return s.bridge } + +// StartBridge begins the background connection to the Laravel backend. +func (s *Subsystem) StartBridge(ctx context.Context) { + if s.bridge != nil { + s.bridge.Start(ctx) + } +} diff --git a/pkg/mcp/ide/tools_build.go b/pkg/mcp/ide/tools_build.go new file mode 100644 index 00000000..4d258832 --- /dev/null +++ b/pkg/mcp/ide/tools_build.go @@ -0,0 +1,109 @@ +package ide + +import ( + "context" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Build tool input/output types. + +// BuildStatusInput is the input for ide_build_status. +type BuildStatusInput struct { + BuildID string `json:"buildId"` +} + +// BuildInfo represents a single build. +type BuildInfo struct { + ID string `json:"id"` + Repo string `json:"repo"` + Branch string `json:"branch"` + Status string `json:"status"` + Duration string `json:"duration,omitempty"` + StartedAt time.Time `json:"startedAt"` +} + +// BuildStatusOutput is the output for ide_build_status. +type BuildStatusOutput struct { + Build BuildInfo `json:"build"` +} + +// BuildListInput is the input for ide_build_list. +type BuildListInput struct { + Repo string `json:"repo,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// BuildListOutput is the output for ide_build_list. +type BuildListOutput struct { + Builds []BuildInfo `json:"builds"` +} + +// BuildLogsInput is the input for ide_build_logs. +type BuildLogsInput struct { + BuildID string `json:"buildId"` + Tail int `json:"tail,omitempty"` +} + +// BuildLogsOutput is the output for ide_build_logs. +type BuildLogsOutput struct { + BuildID string `json:"buildId"` + Lines []string `json:"lines"` +} + +func (s *Subsystem) registerBuildTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_build_status", + Description: "Get the status of a specific build", + }, s.buildStatus) + + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_build_list", + Description: "List recent builds, optionally filtered by repository", + }, s.buildList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_build_logs", + Description: "Retrieve log output for a build", + }, s.buildLogs) +} + +func (s *Subsystem) buildStatus(_ context.Context, _ *mcp.CallToolRequest, input BuildStatusInput) (*mcp.CallToolResult, BuildStatusOutput, error) { + if s.bridge == nil { + return nil, BuildStatusOutput{}, fmt.Errorf("bridge not available") + } + _ = s.bridge.Send(BridgeMessage{ + Type: "build_status", + Data: map[string]any{"buildId": input.BuildID}, + }) + return nil, BuildStatusOutput{ + Build: BuildInfo{ID: input.BuildID, Status: "unknown"}, + }, nil +} + +func (s *Subsystem) buildList(_ context.Context, _ *mcp.CallToolRequest, input BuildListInput) (*mcp.CallToolResult, BuildListOutput, error) { + if s.bridge == nil { + return nil, BuildListOutput{}, fmt.Errorf("bridge not available") + } + _ = s.bridge.Send(BridgeMessage{ + Type: "build_list", + Data: map[string]any{"repo": input.Repo, "limit": input.Limit}, + }) + return nil, BuildListOutput{Builds: []BuildInfo{}}, nil +} + +func (s *Subsystem) buildLogs(_ context.Context, _ *mcp.CallToolRequest, input BuildLogsInput) (*mcp.CallToolResult, BuildLogsOutput, error) { + if s.bridge == nil { + return nil, BuildLogsOutput{}, fmt.Errorf("bridge not available") + } + _ = s.bridge.Send(BridgeMessage{ + Type: "build_logs", + Data: map[string]any{"buildId": input.BuildID, "tail": input.Tail}, + }) + return nil, BuildLogsOutput{ + BuildID: input.BuildID, + Lines: []string{}, + }, nil +} diff --git a/pkg/mcp/ide/tools_chat.go b/pkg/mcp/ide/tools_chat.go new file mode 100644 index 00000000..8a00477e --- /dev/null +++ b/pkg/mcp/ide/tools_chat.go @@ -0,0 +1,191 @@ +package ide + +import ( + "context" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Chat tool input/output types. + +// ChatSendInput is the input for ide_chat_send. +type ChatSendInput struct { + SessionID string `json:"sessionId"` + Message string `json:"message"` +} + +// ChatSendOutput is the output for ide_chat_send. +type ChatSendOutput struct { + Sent bool `json:"sent"` + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"timestamp"` +} + +// ChatHistoryInput is the input for ide_chat_history. +type ChatHistoryInput struct { + SessionID string `json:"sessionId"` + Limit int `json:"limit,omitempty"` +} + +// ChatMessage represents a single message in history. +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` + Timestamp time.Time `json:"timestamp"` +} + +// ChatHistoryOutput is the output for ide_chat_history. +type ChatHistoryOutput struct { + SessionID string `json:"sessionId"` + Messages []ChatMessage `json:"messages"` +} + +// SessionListInput is the input for ide_session_list. +type SessionListInput struct{} + +// Session represents an agent session. +type Session struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` +} + +// SessionListOutput is the output for ide_session_list. +type SessionListOutput struct { + Sessions []Session `json:"sessions"` +} + +// SessionCreateInput is the input for ide_session_create. +type SessionCreateInput struct { + Name string `json:"name"` +} + +// SessionCreateOutput is the output for ide_session_create. +type SessionCreateOutput struct { + Session Session `json:"session"` +} + +// PlanStatusInput is the input for ide_plan_status. +type PlanStatusInput struct { + SessionID string `json:"sessionId"` +} + +// PlanStep is a single step in an agent plan. +type PlanStep struct { + Name string `json:"name"` + Status string `json:"status"` +} + +// PlanStatusOutput is the output for ide_plan_status. +type PlanStatusOutput struct { + SessionID string `json:"sessionId"` + Status string `json:"status"` + Steps []PlanStep `json:"steps"` +} + +func (s *Subsystem) registerChatTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_chat_send", + Description: "Send a message to an agent chat session", + }, s.chatSend) + + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_chat_history", + Description: "Retrieve message history for a chat session", + }, s.chatHistory) + + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_session_list", + Description: "List active agent sessions", + }, s.sessionList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_session_create", + Description: "Create a new agent session", + }, s.sessionCreate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_plan_status", + Description: "Get the current plan status for a session", + }, s.planStatus) +} + +func (s *Subsystem) chatSend(_ context.Context, _ *mcp.CallToolRequest, input ChatSendInput) (*mcp.CallToolResult, ChatSendOutput, error) { + if s.bridge == nil { + return nil, ChatSendOutput{}, fmt.Errorf("bridge not available") + } + err := s.bridge.Send(BridgeMessage{ + Type: "chat_send", + Channel: "chat:" + input.SessionID, + SessionID: input.SessionID, + Data: input.Message, + }) + if err != nil { + return nil, ChatSendOutput{}, fmt.Errorf("failed to send message: %w", err) + } + return nil, ChatSendOutput{ + Sent: true, + SessionID: input.SessionID, + Timestamp: time.Now(), + }, nil +} + +func (s *Subsystem) chatHistory(_ context.Context, _ *mcp.CallToolRequest, input ChatHistoryInput) (*mcp.CallToolResult, ChatHistoryOutput, error) { + if s.bridge == nil { + return nil, ChatHistoryOutput{}, fmt.Errorf("bridge not available") + } + // Request history via bridge; for now return placeholder indicating the + // request was forwarded. Real data arrives via WebSocket subscription. + _ = s.bridge.Send(BridgeMessage{ + Type: "chat_history", + SessionID: input.SessionID, + Data: map[string]any{"limit": input.Limit}, + }) + return nil, ChatHistoryOutput{ + SessionID: input.SessionID, + Messages: []ChatMessage{}, + }, nil +} + +func (s *Subsystem) sessionList(_ context.Context, _ *mcp.CallToolRequest, _ SessionListInput) (*mcp.CallToolResult, SessionListOutput, error) { + if s.bridge == nil { + return nil, SessionListOutput{}, fmt.Errorf("bridge not available") + } + _ = s.bridge.Send(BridgeMessage{Type: "session_list"}) + return nil, SessionListOutput{Sessions: []Session{}}, nil +} + +func (s *Subsystem) sessionCreate(_ context.Context, _ *mcp.CallToolRequest, input SessionCreateInput) (*mcp.CallToolResult, SessionCreateOutput, error) { + if s.bridge == nil { + return nil, SessionCreateOutput{}, fmt.Errorf("bridge not available") + } + _ = s.bridge.Send(BridgeMessage{ + Type: "session_create", + Data: map[string]any{"name": input.Name}, + }) + return nil, SessionCreateOutput{ + Session: Session{ + Name: input.Name, + Status: "creating", + CreatedAt: time.Now(), + }, + }, nil +} + +func (s *Subsystem) planStatus(_ context.Context, _ *mcp.CallToolRequest, input PlanStatusInput) (*mcp.CallToolResult, PlanStatusOutput, error) { + if s.bridge == nil { + return nil, PlanStatusOutput{}, fmt.Errorf("bridge not available") + } + _ = s.bridge.Send(BridgeMessage{ + Type: "plan_status", + SessionID: input.SessionID, + }) + return nil, PlanStatusOutput{ + SessionID: input.SessionID, + Status: "unknown", + Steps: []PlanStep{}, + }, nil +} diff --git a/pkg/mcp/ide/tools_dashboard.go b/pkg/mcp/ide/tools_dashboard.go new file mode 100644 index 00000000..a84e4911 --- /dev/null +++ b/pkg/mcp/ide/tools_dashboard.go @@ -0,0 +1,127 @@ +package ide + +import ( + "context" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Dashboard tool input/output types. + +// DashboardOverviewInput is the input for ide_dashboard_overview. +type DashboardOverviewInput struct{} + +// DashboardOverview contains high-level platform stats. +type DashboardOverview struct { + Repos int `json:"repos"` + Services int `json:"services"` + ActiveSessions int `json:"activeSessions"` + RecentBuilds int `json:"recentBuilds"` + BridgeOnline bool `json:"bridgeOnline"` +} + +// DashboardOverviewOutput is the output for ide_dashboard_overview. +type DashboardOverviewOutput struct { + Overview DashboardOverview `json:"overview"` +} + +// DashboardActivityInput is the input for ide_dashboard_activity. +type DashboardActivityInput struct { + Limit int `json:"limit,omitempty"` +} + +// ActivityEvent represents a single activity feed item. +type ActivityEvent struct { + Type string `json:"type"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// DashboardActivityOutput is the output for ide_dashboard_activity. +type DashboardActivityOutput struct { + Events []ActivityEvent `json:"events"` +} + +// DashboardMetricsInput is the input for ide_dashboard_metrics. +type DashboardMetricsInput struct { + Period string `json:"period,omitempty"` // "1h", "24h", "7d" +} + +// DashboardMetrics contains aggregate metrics. +type DashboardMetrics struct { + BuildsTotal int `json:"buildsTotal"` + BuildsSuccess int `json:"buildsSuccess"` + BuildsFailed int `json:"buildsFailed"` + AvgBuildTime string `json:"avgBuildTime"` + AgentSessions int `json:"agentSessions"` + MessagesTotal int `json:"messagesTotal"` + SuccessRate float64 `json:"successRate"` +} + +// DashboardMetricsOutput is the output for ide_dashboard_metrics. +type DashboardMetricsOutput struct { + Period string `json:"period"` + Metrics DashboardMetrics `json:"metrics"` +} + +func (s *Subsystem) registerDashboardTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_dashboard_overview", + Description: "Get a high-level overview of the platform (repos, services, sessions, builds)", + }, s.dashboardOverview) + + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_dashboard_activity", + Description: "Get the recent activity feed", + }, s.dashboardActivity) + + mcp.AddTool(server, &mcp.Tool{ + Name: "ide_dashboard_metrics", + Description: "Get aggregate build and agent metrics for a time period", + }, s.dashboardMetrics) +} + +func (s *Subsystem) dashboardOverview(_ context.Context, _ *mcp.CallToolRequest, _ DashboardOverviewInput) (*mcp.CallToolResult, DashboardOverviewOutput, error) { + connected := s.bridge != nil && s.bridge.Connected() + + if s.bridge != nil { + _ = s.bridge.Send(BridgeMessage{Type: "dashboard_overview"}) + } + + return nil, DashboardOverviewOutput{ + Overview: DashboardOverview{ + BridgeOnline: connected, + }, + }, nil +} + +func (s *Subsystem) dashboardActivity(_ context.Context, _ *mcp.CallToolRequest, input DashboardActivityInput) (*mcp.CallToolResult, DashboardActivityOutput, error) { + if s.bridge == nil { + return nil, DashboardActivityOutput{}, fmt.Errorf("bridge not available") + } + _ = s.bridge.Send(BridgeMessage{ + Type: "dashboard_activity", + Data: map[string]any{"limit": input.Limit}, + }) + return nil, DashboardActivityOutput{Events: []ActivityEvent{}}, nil +} + +func (s *Subsystem) dashboardMetrics(_ context.Context, _ *mcp.CallToolRequest, input DashboardMetricsInput) (*mcp.CallToolResult, DashboardMetricsOutput, error) { + if s.bridge == nil { + return nil, DashboardMetricsOutput{}, fmt.Errorf("bridge not available") + } + period := input.Period + if period == "" { + period = "24h" + } + _ = s.bridge.Send(BridgeMessage{ + Type: "dashboard_metrics", + Data: map[string]any{"period": period}, + }) + return nil, DashboardMetricsOutput{ + Period: period, + Metrics: DashboardMetrics{}, + }, nil +} diff --git a/pkg/mcp/subsystem.go b/pkg/mcp/subsystem.go new file mode 100644 index 00000000..56bd6f74 --- /dev/null +++ b/pkg/mcp/subsystem.go @@ -0,0 +1,32 @@ +package mcp + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Subsystem registers additional MCP tools at startup. +// Implementations should be safe to call concurrently. +type Subsystem interface { + // Name returns a human-readable identifier for logging. + Name() string + + // RegisterTools adds tools to the MCP server during initialisation. + RegisterTools(server *mcp.Server) +} + +// SubsystemWithShutdown extends Subsystem with graceful cleanup. +type SubsystemWithShutdown interface { + Subsystem + Shutdown(ctx context.Context) error +} + +// WithSubsystem registers a subsystem whose tools will be added +// after the built-in tools during New(). +func WithSubsystem(sub Subsystem) Option { + return func(s *Service) error { + s.subsystems = append(s.subsystems, sub) + return nil + } +} diff --git a/pkg/mcp/subsystem_test.go b/pkg/mcp/subsystem_test.go new file mode 100644 index 00000000..5e823f75 --- /dev/null +++ b/pkg/mcp/subsystem_test.go @@ -0,0 +1,114 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// stubSubsystem is a minimal Subsystem for testing. +type stubSubsystem struct { + name string + toolsRegistered bool +} + +func (s *stubSubsystem) Name() string { return s.name } + +func (s *stubSubsystem) RegisterTools(server *mcp.Server) { + s.toolsRegistered = true +} + +// shutdownSubsystem tracks Shutdown calls. +type shutdownSubsystem struct { + stubSubsystem + shutdownCalled bool + shutdownErr error +} + +func (s *shutdownSubsystem) Shutdown(_ context.Context) error { + s.shutdownCalled = true + return s.shutdownErr +} + +func TestWithSubsystem_Good_Registration(t *testing.T) { + sub := &stubSubsystem{name: "test-sub"} + svc, err := New(WithSubsystem(sub)) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + if len(svc.Subsystems()) != 1 { + t.Fatalf("expected 1 subsystem, got %d", len(svc.Subsystems())) + } + if svc.Subsystems()[0].Name() != "test-sub" { + t.Errorf("expected name 'test-sub', got %q", svc.Subsystems()[0].Name()) + } +} + +func TestWithSubsystem_Good_ToolsRegistered(t *testing.T) { + sub := &stubSubsystem{name: "tools-sub"} + _, err := New(WithSubsystem(sub)) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if !sub.toolsRegistered { + t.Error("expected RegisterTools to have been called") + } +} + +func TestWithSubsystem_Good_MultipleSubsystems(t *testing.T) { + sub1 := &stubSubsystem{name: "sub-1"} + sub2 := &stubSubsystem{name: "sub-2"} + svc, err := New(WithSubsystem(sub1), WithSubsystem(sub2)) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if len(svc.Subsystems()) != 2 { + t.Fatalf("expected 2 subsystems, got %d", len(svc.Subsystems())) + } + if !sub1.toolsRegistered || !sub2.toolsRegistered { + t.Error("expected all subsystems to have RegisterTools called") + } +} + +func TestSubsystemShutdown_Good(t *testing.T) { + sub := &shutdownSubsystem{stubSubsystem: stubSubsystem{name: "shutdown-sub"}} + svc, err := New(WithSubsystem(sub)) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if err := svc.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown() failed: %v", err) + } + if !sub.shutdownCalled { + t.Error("expected Shutdown to have been called") + } +} + +func TestSubsystemShutdown_Bad_Error(t *testing.T) { + sub := &shutdownSubsystem{ + stubSubsystem: stubSubsystem{name: "fail-sub"}, + shutdownErr: context.DeadlineExceeded, + } + svc, err := New(WithSubsystem(sub)) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + err = svc.Shutdown(context.Background()) + if err == nil { + t.Fatal("expected error from Shutdown") + } +} + +func TestSubsystemShutdown_Good_NoShutdownInterface(t *testing.T) { + // A plain Subsystem (without Shutdown) should not cause errors. + sub := &stubSubsystem{name: "plain-sub"} + svc, err := New(WithSubsystem(sub)) + if err != nil { + t.Fatalf("New() failed: %v", err) + } + if err := svc.Shutdown(context.Background()); err != nil { + t.Fatalf("Shutdown() should succeed for non-shutdown subsystem: %v", err) + } +} From 40c4c5ef687d767c2cbaf0e9c5a9882944bac469 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 8 Feb 2026 15:17:18 +0000 Subject: [PATCH 5/9] updates --- cmd/bugseti/frontend/src/app/app.routes.ts | 4 ++++ cmd/bugseti/frontend/src/app/tray/tray.component.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/cmd/bugseti/frontend/src/app/app.routes.ts b/cmd/bugseti/frontend/src/app/app.routes.ts index 8367d07a..76725edb 100644 --- a/cmd/bugseti/frontend/src/app/app.routes.ts +++ b/cmd/bugseti/frontend/src/app/app.routes.ts @@ -21,5 +21,9 @@ export const routes: Routes = [ { path: 'onboarding', loadComponent: () => import('./onboarding/onboarding.component').then(m => m.OnboardingComponent) + }, + { + path: 'jellyfin', + loadComponent: () => import('./jellyfin/jellyfin.component').then(m => m.JellyfinComponent) } ]; diff --git a/cmd/bugseti/frontend/src/app/tray/tray.component.ts b/cmd/bugseti/frontend/src/app/tray/tray.component.ts index 4a7ebec8..f6232e90 100644 --- a/cmd/bugseti/frontend/src/app/tray/tray.component.ts +++ b/cmd/bugseti/frontend/src/app/tray/tray.component.ts @@ -66,6 +66,9 @@ interface TrayStatus {