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 00000000..e265e8de Binary files /dev/null and b/cmd/core-app/laravel/database/database.sqlite differ 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 00000000..53adbd59 Binary files /dev/null and b/cmd/core-ide/icons/appicon.png differ 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 00000000..53adbd59 Binary files /dev/null and b/cmd/core-ide/icons/tray-dark.png differ diff --git a/cmd/core-ide/icons/tray-light.png b/cmd/core-ide/icons/tray-light.png new file mode 100644 index 00000000..53adbd59 Binary files /dev/null and b/cmd/core-ide/icons/tray-light.png differ diff --git a/cmd/core-ide/icons/tray-template.png b/cmd/core-ide/icons/tray-template.png new file mode 100644 index 00000000..53adbd59 Binary files /dev/null and b/cmd/core-ide/icons/tray-template.png differ 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) + } +}