Merge branch 'new' into codex/bugseti-mcp

This commit is contained in:
Snider 2026-02-08 23:15:35 +00:00
commit 58a052bd17
158 changed files with 20611 additions and 534 deletions

View file

@ -0,0 +1,50 @@
# Sovereign security scanning — no cloud dependencies
# Replaces: GitHub Dependabot, CodeQL, Advanced Security
# PCI DSS: Req 6.3.2 (code review), Req 11.3 (vulnerability scanning)
name: Security Scan
on:
push:
branches: [main, dev, 'feat/*']
pull_request:
branches: [main]
jobs:
govulncheck:
name: Go Vulnerability Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
gitleaks:
name: Secret Detection
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install gitleaks
run: |
GITLEAKS_VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | jq -r '.tag_name' | tr -d 'v')
curl -sL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar xz -C /usr/local/bin gitleaks
- name: Scan for secrets
run: gitleaks detect --source . --no-banner
trivy:
name: Dependency & Config Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
- name: Filesystem scan
run: trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 .

3
.gitignore vendored
View file

@ -16,7 +16,8 @@ bin/
dist/
tasks
/core
/i18n-validate
cmd/bugseti/bugseti
patch_cov.*
go.work.sum

10
.gitleaks.toml Normal file
View file

@ -0,0 +1,10 @@
# Gitleaks configuration for host-uk/core
# Test fixtures contain private keys for cryptographic testing — not real secrets.
[allowlist]
description = "Test fixture allowlist"
paths = [
'''pkg/crypt/pgp/pgp_test\.go''',
'''pkg/crypt/rsa/rsa_test\.go''',
'''pkg/crypt/openpgp/test_util\.go''',
]

52
.woodpecker/bugseti.yml Normal file
View file

@ -0,0 +1,52 @@
when:
- event: tag
ref: "refs/tags/bugseti-v*"
- event: push
branch: main
path: "cmd/bugseti/**"
steps:
- name: frontend
image: node:22-bookworm
commands:
- cd cmd/bugseti/frontend
- npm ci --prefer-offline
- npm run build
- name: build-linux
image: golang:1.25-bookworm
environment:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: amd64
commands:
- apt-get update -qq && apt-get install -y -qq libgtk-3-dev libwebkit2gtk-4.1-dev > /dev/null 2>&1
- cd cmd/bugseti
- go build -tags production -trimpath -buildvcs=false -ldflags="-w -s" -o ../../bin/bugseti
depends_on: [frontend]
- name: package
image: alpine:3.21
commands:
- cd bin
- tar czf bugseti-linux-amd64.tar.gz bugseti
- sha256sum bugseti-linux-amd64.tar.gz > bugseti-linux-amd64.tar.gz.sha256
- echo "=== Package ==="
- ls -lh bugseti-linux-amd64.*
- cat bugseti-linux-amd64.tar.gz.sha256
depends_on: [build-linux]
- name: release
image: plugins/gitea-release
settings:
api_key:
from_secret: forgejo_token
base_url: https://forge.lthn.ai
files:
- bin/bugseti-linux-amd64.tar.gz
- bin/bugseti-linux-amd64.tar.gz.sha256
title: ${CI_COMMIT_TAG}
note: "BugSETI ${CI_COMMIT_TAG} — Linux amd64 build"
when:
- event: tag
depends_on: [package]

21
.woodpecker/core.yml Normal file
View file

@ -0,0 +1,21 @@
when:
- event: [push, pull_request, manual]
steps:
- name: build
image: golang:1.25-bookworm
commands:
- go version
- go mod download
- >-
go build
-ldflags "-X github.com/host-uk/core/pkg/cli.AppVersion=ci
-X github.com/host-uk/core/pkg/cli.BuildCommit=${CI_COMMIT_SHA:0:7}
-X github.com/host-uk/core/pkg/cli.BuildDate=$(date -u +%Y%m%d)"
-o ./bin/core .
- ./bin/core --version
- name: test
image: golang:1.25-bookworm
commands:
- go test -short -count=1 -timeout 120s ./...

View file

@ -1,14 +1,33 @@
version: '3'
vars:
VERSION:
sh: git describe --tags --exact-match 2>/dev/null || echo "dev"
# Base ldflags for version injection
LDFLAGS_BASE: "-X github.com/host-uk/core/pkg/cli.AppVersion={{.VERSION}}"
# SemVer 2.0.0 build variables
SEMVER_TAG:
sh: git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0"
SEMVER_VERSION:
sh: echo "{{.SEMVER_TAG}}" | sed 's/^v//'
SEMVER_COMMITS:
sh: git rev-list {{.SEMVER_TAG}}..HEAD --count 2>/dev/null || echo "0"
SEMVER_COMMIT:
sh: git rev-parse --short HEAD 2>/dev/null || echo "unknown"
SEMVER_DATE:
sh: date -u +%Y%m%d
SEMVER_PRERELEASE:
sh: '[ "{{.SEMVER_COMMITS}}" = "0" ] && echo "" || echo "dev.{{.SEMVER_COMMITS}}"'
# ldflags
PKG: "github.com/host-uk/core/pkg/cli"
LDFLAGS_BASE: >-
-X {{.PKG}}.AppVersion={{.SEMVER_VERSION}}
-X {{.PKG}}.BuildCommit={{.SEMVER_COMMIT}}
-X {{.PKG}}.BuildDate={{.SEMVER_DATE}}
-X {{.PKG}}.BuildPreRelease={{.SEMVER_PRERELEASE}}
# Development build: includes debug info
LDFLAGS: "{{.LDFLAGS_BASE}}"
# Release build: strips debug info and symbol table for smaller binary
LDFLAGS_RELEASE: "-s -w {{.LDFLAGS_BASE}}"
# Compat alias
VERSION:
sh: git describe --tags --exact-match 2>/dev/null || echo "dev"
tasks:
# --- CLI Management ---
@ -140,6 +159,67 @@ tasks:
cmds:
- go run ./internal/tools/i18n-validate ./...
# --- Core IDE (Wails v3) ---
ide:dev:
desc: "Run Core IDE in Wails dev mode"
dir: cmd/core-ide
cmds:
- cd frontend && npm install && npm run build
- wails3 dev
ide:build:
desc: "Build Core IDE production binary"
dir: cmd/core-ide
cmds:
- cd frontend && npm install && npm run build
- wails3 build
ide:frontend:
desc: "Build Core IDE frontend only"
dir: cmd/core-ide/frontend
cmds:
- npm install
- npm run build
# --- Core App (FrankenPHP + Wails v3) ---
app:setup:
desc: "Install PHP-ZTS build dependency for Core App"
cmds:
- brew tap shivammathur/php 2>/dev/null || true
- brew install shivammathur/php/php@8.4-zts
app:composer:
desc: "Install Laravel dependencies for Core App"
dir: cmd/core-app/laravel
cmds:
- composer install --no-dev --optimize-autoloader --no-interaction
app:build:
desc: "Build Core App (FrankenPHP + Laravel desktop binary)"
dir: cmd/core-app
env:
CGO_ENABLED: "1"
CGO_CFLAGS:
sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes
CGO_LDFLAGS:
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)"
cmds:
- go build -tags nowatcher -o ../../bin/core-app .
app:dev:
desc: "Build and run Core App"
dir: cmd/core-app
env:
CGO_ENABLED: "1"
CGO_CFLAGS:
sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes
CGO_LDFLAGS:
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)"
DYLD_LIBRARY_PATH: "/opt/homebrew/opt/php@8.4-zts/lib"
cmds:
- go build -tags nowatcher -o ../../bin/core-app .
- ../../bin/core-app
# --- Multi-repo (when in workspace) ---
dev:health:
desc: "Check health of all repos"

View file

@ -21,5 +21,9 @@ export const routes: Routes = [
{
path: 'onboarding',
loadComponent: () => import('./onboarding/onboarding.component').then(m => m.OnboardingComponent)
},
{
path: 'jellyfin',
loadComponent: () => import('./jellyfin/jellyfin.component').then(m => m.JellyfinComponent)
}
];

View file

@ -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: `
<div class="jellyfin">
<header class="jellyfin__header">
<div>
<h1>Jellyfin Player</h1>
<p class="text-muted">Quick embed for media.lthn.ai or any Jellyfin host.</p>
</div>
<div class="mode-switch">
<button class="btn btn--secondary" [class.is-active]="mode === 'web'" (click)="mode = 'web'">Web</button>
<button class="btn btn--secondary" [class.is-active]="mode === 'stream'" (click)="mode = 'stream'">Stream</button>
</div>
</header>
<div class="card jellyfin__config">
<div class="form-group">
<label class="form-label">Jellyfin Server URL</label>
<input class="form-input" [(ngModel)]="serverUrl" placeholder="https://media.lthn.ai" />
</div>
<div *ngIf="mode === 'stream'" class="stream-grid">
<div class="form-group">
<label class="form-label">Item ID</label>
<input class="form-input" [(ngModel)]="itemId" placeholder="Jellyfin library item ID" />
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input class="form-input" [(ngModel)]="apiKey" placeholder="Jellyfin API key" />
</div>
<div class="form-group">
<label class="form-label">Media Source ID (optional)</label>
<input class="form-input" [(ngModel)]="mediaSourceId" placeholder="Source ID for multi-source items" />
</div>
</div>
<div class="actions">
<button class="btn btn--primary" (click)="load()">Load Player</button>
<button class="btn btn--secondary" (click)="reset()">Reset</button>
</div>
</div>
<div class="card jellyfin__viewer" *ngIf="loaded && mode === 'web'">
<iframe
class="jellyfin-frame"
title="Jellyfin Web"
[src]="safeWebUrl"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>
</div>
<div class="card jellyfin__viewer" *ngIf="loaded && mode === 'stream'">
<video class="jellyfin-video" controls [src]="streamUrl"></video>
<p class="text-muted stream-hint" *ngIf="!streamUrl">Set Item ID and API key to build stream URL.</p>
</div>
</div>
`,
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();
}
}

View file

@ -66,6 +66,9 @@ interface TrayStatus {
</section>
<footer class="tray-footer">
<button class="btn btn--secondary btn--sm" (click)="openJellyfin()">
Jellyfin
</button>
<button class="btn btn--secondary btn--sm" (click)="toggleRunning()">
{{ status.running ? 'Pause' : 'Start' }}
</button>
@ -293,4 +296,8 @@ export class TrayComponent implements OnInit, OnDestroy {
});
}
}
openJellyfin() {
window.location.assign('/jellyfin');
}
}

View file

@ -3,11 +3,15 @@ module github.com/host-uk/core/cmd/bugseti
go 1.25.5
require (
github.com/Snider/Borg v0.2.0
github.com/host-uk/core v0.0.0
github.com/host-uk/core/internal/bugseti v0.0.0
github.com/host-uk/core/internal/bugseti/updater v0.0.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
)
replace github.com/host-uk/core => ../..
replace github.com/host-uk/core/internal/bugseti => ../../internal/bugseti
replace github.com/host-uk/core/internal/bugseti/updater => ../../internal/bugseti/updater
@ -16,6 +20,7 @@ require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/Snider/Enchantrix v0.0.2 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect

View file

@ -5,6 +5,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY=
github.com/Snider/Enchantrix v0.0.2 h1:ExZQiBhfS/p/AHFTKhY80TOd+BXZjK95EzByAEgwvjs=
github.com/Snider/Enchantrix v0.0.2/go.mod h1:CtFcLAvnDT1KcuF1JBb/DJj0KplY8jHryO06KzQ1hsQ=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@ -20,8 +24,9 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
@ -86,8 +91,9 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=

View file

@ -44,6 +44,7 @@ func main() {
seederService := bugseti.NewSeederService(configService)
submitService := bugseti.NewSubmitService(configService, notifyService, statsService)
versionService := bugseti.NewVersionService()
workspaceService := NewWorkspaceService(configService)
// Initialize update service
updateService, err := updater.NewService(configService)
@ -64,6 +65,7 @@ func main() {
application.NewService(seederService),
application.NewService(submitService),
application.NewService(versionService),
application.NewService(workspaceService),
application.NewService(trayService),
}

268
cmd/bugseti/workspace.go Normal file
View file

@ -0,0 +1,268 @@
// Package main provides the BugSETI system tray application.
package main
import (
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/Snider/Borg/pkg/tim"
"github.com/host-uk/core/internal/bugseti"
"github.com/host-uk/core/pkg/io/datanode"
)
// WorkspaceService manages DataNode-backed workspaces for issues.
// Each issue gets a sandboxed in-memory filesystem that can be
// snapshotted, packaged as a TIM container, or shipped as a crash report.
type WorkspaceService struct {
config *bugseti.ConfigService
workspaces map[string]*Workspace // issue ID → workspace
mu sync.RWMutex
}
// Workspace tracks a DataNode-backed workspace for an issue.
type Workspace struct {
Issue *bugseti.Issue `json:"issue"`
Medium *datanode.Medium
DiskPath string `json:"diskPath"`
CreatedAt time.Time `json:"createdAt"`
Snapshots int `json:"snapshots"`
}
// CrashReport contains a packaged workspace state for debugging.
type CrashReport struct {
IssueID string `json:"issueId"`
Repo string `json:"repo"`
Number int `json:"number"`
Title string `json:"title"`
Error string `json:"error"`
Timestamp time.Time `json:"timestamp"`
Data []byte `json:"data"` // tar snapshot
Files int `json:"files"`
Size int64 `json:"size"`
}
// NewWorkspaceService creates a new WorkspaceService.
func NewWorkspaceService(config *bugseti.ConfigService) *WorkspaceService {
return &WorkspaceService{
config: config,
workspaces: make(map[string]*Workspace),
}
}
// ServiceName returns the service name for Wails.
func (w *WorkspaceService) ServiceName() string {
return "WorkspaceService"
}
// Capture loads a filesystem workspace into a DataNode Medium.
// Call this after git clone to create the in-memory snapshot.
func (w *WorkspaceService) Capture(issue *bugseti.Issue, diskPath string) error {
if issue == nil {
return fmt.Errorf("issue is nil")
}
m := datanode.New()
// Walk the filesystem and load all files into the DataNode
err := filepath.WalkDir(diskPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip errors
}
// Get relative path
rel, err := filepath.Rel(diskPath, path)
if err != nil {
return nil
}
if rel == "." {
return nil
}
// Skip .git internals (keep .git marker but not the pack files)
if rel == ".git" {
return fs.SkipDir
}
if d.IsDir() {
return m.EnsureDir(rel)
}
// Skip large files (>1MB) to keep DataNode lightweight
info, err := d.Info()
if err != nil || info.Size() > 1<<20 {
return nil
}
content, err := os.ReadFile(path)
if err != nil {
return nil
}
return m.Write(rel, string(content))
})
if err != nil {
return fmt.Errorf("failed to capture workspace: %w", err)
}
w.mu.Lock()
w.workspaces[issue.ID] = &Workspace{
Issue: issue,
Medium: m,
DiskPath: diskPath,
CreatedAt: time.Now(),
}
w.mu.Unlock()
log.Printf("Captured workspace for issue #%d (%s)", issue.Number, issue.Repo)
return nil
}
// GetMedium returns the DataNode Medium for an issue's workspace.
func (w *WorkspaceService) GetMedium(issueID string) *datanode.Medium {
w.mu.RLock()
defer w.mu.RUnlock()
ws := w.workspaces[issueID]
if ws == nil {
return nil
}
return ws.Medium
}
// Snapshot takes a tar snapshot of the workspace.
func (w *WorkspaceService) Snapshot(issueID string) ([]byte, error) {
w.mu.Lock()
defer w.mu.Unlock()
ws := w.workspaces[issueID]
if ws == nil {
return nil, fmt.Errorf("workspace not found: %s", issueID)
}
data, err := ws.Medium.Snapshot()
if err != nil {
return nil, fmt.Errorf("snapshot failed: %w", err)
}
ws.Snapshots++
return data, nil
}
// PackageCrashReport captures the current workspace state as a crash report.
// Re-reads from disk to get the latest state (including git changes).
func (w *WorkspaceService) PackageCrashReport(issue *bugseti.Issue, errMsg string) (*CrashReport, error) {
if issue == nil {
return nil, fmt.Errorf("issue is nil")
}
w.mu.RLock()
ws := w.workspaces[issue.ID]
w.mu.RUnlock()
var diskPath string
if ws != nil {
diskPath = ws.DiskPath
} else {
// Try to find the workspace on disk
baseDir := w.config.GetWorkspaceDir()
if baseDir == "" {
baseDir = filepath.Join(os.TempDir(), "bugseti")
}
diskPath = filepath.Join(baseDir, sanitizeForPath(issue.Repo), fmt.Sprintf("issue-%d", issue.Number))
}
// Re-capture from disk to get latest state
if err := w.Capture(issue, diskPath); err != nil {
return nil, fmt.Errorf("capture failed: %w", err)
}
// Snapshot the captured workspace
data, err := w.Snapshot(issue.ID)
if err != nil {
return nil, fmt.Errorf("snapshot failed: %w", err)
}
return &CrashReport{
IssueID: issue.ID,
Repo: issue.Repo,
Number: issue.Number,
Title: issue.Title,
Error: errMsg,
Timestamp: time.Now(),
Data: data,
Size: int64(len(data)),
}, nil
}
// PackageTIM wraps the workspace as a TIM container (runc-compatible bundle).
// The resulting TIM can be executed via runc or encrypted to .stim for transit.
func (w *WorkspaceService) PackageTIM(issueID string) (*tim.TerminalIsolationMatrix, error) {
w.mu.RLock()
ws := w.workspaces[issueID]
w.mu.RUnlock()
if ws == nil {
return nil, fmt.Errorf("workspace not found: %s", issueID)
}
dn := ws.Medium.DataNode()
return tim.FromDataNode(dn)
}
// SaveCrashReport writes a crash report to the data directory.
func (w *WorkspaceService) SaveCrashReport(report *CrashReport) (string, error) {
dataDir := w.config.GetDataDir()
if dataDir == "" {
dataDir = filepath.Join(os.TempDir(), "bugseti")
}
crashDir := filepath.Join(dataDir, "crash-reports")
if err := os.MkdirAll(crashDir, 0755); err != nil {
return "", fmt.Errorf("failed to create crash dir: %w", err)
}
filename := fmt.Sprintf("crash-%s-issue-%d-%s.tar",
sanitizeForPath(report.Repo),
report.Number,
report.Timestamp.Format("20060102-150405"),
)
path := filepath.Join(crashDir, filename)
if err := os.WriteFile(path, report.Data, 0644); err != nil {
return "", fmt.Errorf("failed to write crash report: %w", err)
}
log.Printf("Crash report saved: %s (%d bytes)", path, report.Size)
return path, nil
}
// Release removes a workspace from memory.
func (w *WorkspaceService) Release(issueID string) {
w.mu.Lock()
delete(w.workspaces, issueID)
w.mu.Unlock()
}
// ActiveWorkspaces returns the count of active workspaces.
func (w *WorkspaceService) ActiveWorkspaces() int {
w.mu.RLock()
defer w.mu.RUnlock()
return len(w.workspaces)
}
// sanitizeForPath converts owner/repo to a safe directory name.
func sanitizeForPath(s string) string {
result := make([]byte, 0, len(s))
for _, c := range s {
if c == '/' || c == '\\' || c == ':' {
result = append(result, '-')
} else {
result = append(result, byte(c))
}
}
return string(result)
}

602
cmd/community/index.html Normal file
View file

@ -0,0 +1,602 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lethean Community — Build Trust Through Code</title>
<meta name="description" content="An open source community where developers earn functional trust by fixing real bugs. BugSETI by Lethean.io — SETI@home for code.">
<link rel="canonical" href="https://lthn.community">
<!-- Open Graph -->
<meta property="og:title" content="Lethean Community — Build Trust Through Code">
<meta property="og:description" content="An open source community where developers earn functional trust by fixing real bugs.">
<meta property="og:url" content="https://lthn.community">
<meta property="og:type" content="website">
<!-- Tailwind CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
lethean: {
950: '#070a0f',
900: '#0d1117',
800: '#161b22',
700: '#21262d',
600: '#30363d',
500: '#484f58',
400: '#8b949e',
300: '#c9d1d9',
200: '#e6edf3',
},
cyan: {
400: '#40c1c5',
500: '#2da8ac',
},
blue: {
400: '#58a6ff',
500: '#4A90E2',
600: '#357ABD',
},
},
fontFamily: {
display: ['"DM Sans"', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
}
}
}
</script>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,300;1,9..40,400&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
body {
font-family: 'DM Sans', system-ui, sans-serif;
background: #070a0f;
color: #c9d1d9;
}
/* Grain overlay */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 50;
pointer-events: none;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Cursor glow */
.glow-cursor {
position: fixed;
width: 600px;
height: 600px;
border-radius: 50%;
pointer-events: none;
z-index: 1;
background: radial-gradient(circle, rgba(64,193,197,0.06) 0%, transparent 70%);
transform: translate(-50%, -50%);
transition: opacity 0.3s;
}
/* Typing cursor blink */
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.cursor-blink::after {
content: '▊';
animation: blink 1s infinite;
color: #40c1c5;
margin-left: 2px;
}
/* Fade-in on scroll */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-up {
opacity: 0;
animation: fadeUp 0.6s ease-out forwards;
}
.fade-up-d1 { animation-delay: 0.1s; }
.fade-up-d2 { animation-delay: 0.2s; }
.fade-up-d3 { animation-delay: 0.3s; }
.fade-up-d4 { animation-delay: 0.4s; }
.fade-up-d5 { animation-delay: 0.5s; }
.fade-up-d6 { animation-delay: 0.6s; }
/* Terminal-style section divider */
.terminal-line::before {
content: '$ ';
color: #40c1c5;
font-family: 'JetBrains Mono', monospace;
}
/* Gradient border effect */
.gradient-border {
position: relative;
}
.gradient-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, #40c1c5, #4A90E2, #40c1c5);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
/* Soft glow for hero text */
.text-glow {
text-shadow: 0 0 80px rgba(64,193,197,0.3), 0 0 32px rgba(64,193,197,0.1);
}
/* Stats counter animation */
@keyframes countUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Link hover effect */
.link-underline {
position: relative;
}
.link-underline::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 1px;
background: #40c1c5;
transition: width 0.3s ease;
}
.link-underline:hover::after {
width: 100%;
}
</style>
</head>
<body class="antialiased relative overflow-x-hidden">
<!-- Cursor glow follower -->
<div class="glow-cursor hidden lg:block" id="glowCursor"></div>
<!-- ─────────────────────────────────────────────── -->
<!-- NAV -->
<!-- ─────────────────────────────────────────────── -->
<nav class="fixed top-0 inset-x-0 z-40 backdrop-blur-xl bg-lethean-950/80 border-b border-lethean-600/30">
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<a href="/" class="flex items-center gap-2.5 group">
<span class="text-cyan-400 font-mono text-sm font-medium tracking-tight">lthn</span>
<span class="text-lethean-500 font-mono text-xs">/</span>
<span class="text-lethean-300 text-sm font-medium">community</span>
</a>
<div class="flex items-center gap-6 text-sm">
<a href="#how-it-works" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">How it works</a>
<a href="#ecosystem" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">Ecosystem</a>
<a href="https://github.com/host-uk/core" target="_blank" rel="noopener" class="text-lethean-400 hover:text-lethean-200 transition-colors link-underline">GitHub</a>
<a href="#join" class="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-md bg-cyan-400/10 text-cyan-400 border border-cyan-400/20 hover:bg-cyan-400/20 hover:border-cyan-400/30 transition-all text-sm font-medium">
Get BugSETI
</a>
</div>
</div>
</nav>
<!-- ─────────────────────────────────────────────── -->
<!-- HERO -->
<!-- ─────────────────────────────────────────────── -->
<section class="relative min-h-screen flex items-center justify-center pt-14">
<!-- Background grid -->
<div class="absolute inset-0" style="background-image: linear-gradient(rgba(48,54,61,0.15) 1px, transparent 1px), linear-gradient(90deg, rgba(48,54,61,0.15) 1px, transparent 1px); background-size: 64px 64px;"></div>
<!-- Radial fade -->
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_20%,#070a0f_70%)]"></div>
<div class="relative z-10 max-w-4xl mx-auto px-6 text-center">
<!-- Badge -->
<div class="fade-up inline-flex items-center gap-2 px-3 py-1 rounded-full bg-lethean-800/80 border border-lethean-600/40 text-xs font-mono text-lethean-400 mb-8">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-pulse"></span>
BugSETI by Lethean.io
</div>
<!-- Headline -->
<h1 class="fade-up fade-up-d1 text-5xl sm:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.08] mb-6">
<span class="text-lethean-200">Build trust</span><br>
<span class="text-glow text-cyan-400">through code</span>
</h1>
<!-- Subheadline -->
<p class="fade-up fade-up-d2 text-lg sm:text-xl text-lethean-400 max-w-2xl mx-auto mb-10 leading-relaxed">
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.
</p>
<!-- Terminal preview -->
<div class="fade-up fade-up-d3 max-w-lg mx-auto mb-10">
<div class="gradient-border rounded-lg overflow-hidden">
<div class="bg-lethean-900 rounded-lg">
<div class="flex items-center gap-1.5 px-4 py-2.5 border-b border-lethean-700/50">
<span class="w-2.5 h-2.5 rounded-full bg-lethean-600/60"></span>
<span class="w-2.5 h-2.5 rounded-full bg-lethean-600/60"></span>
<span class="w-2.5 h-2.5 rounded-full bg-lethean-600/60"></span>
<span class="ml-3 text-xs font-mono text-lethean-500">~</span>
</div>
<div class="px-4 py-4 text-left font-mono text-sm leading-relaxed">
<div class="text-lethean-400"><span class="text-cyan-400">$</span> bugseti start</div>
<div class="text-lethean-500 mt-1">⠋ Fetching issues from 42 OSS repos...</div>
<div class="text-green-400/80 mt-1">✓ 7 beginner-friendly issues queued</div>
<div class="text-green-400/80">✓ AI context prepared for each issue</div>
<div class="text-lethean-300 mt-1">Ready. Fix bugs. Build trust. <span class="cursor-blink"></span></div>
</div>
</div>
</div>
</div>
<!-- CTAs -->
<div class="fade-up fade-up-d4 flex flex-col sm:flex-row items-center justify-center gap-3">
<a href="#join" class="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-cyan-400 text-lethean-950 font-semibold text-sm hover:bg-cyan-400/90 transition-all shadow-lg shadow-cyan-400/10">
Download BugSETI
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
</a>
<a href="https://github.com/host-uk/core" target="_blank" rel="noopener" class="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-lethean-600/50 text-lethean-300 font-medium text-sm hover:bg-lethean-800/50 hover:border-lethean-500/50 transition-all">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
View Source
</a>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- HOW IT WORKS -->
<!-- ─────────────────────────────────────────────── -->
<section id="how-it-works" class="relative py-32">
<div class="max-w-5xl mx-auto px-6">
<div class="text-center mb-20">
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">How it works</p>
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">From install to impact</h2>
<p class="text-lethean-400 max-w-xl mx-auto">BugSETI runs in your system tray. It finds issues, prepares context, and gets out of your way. You write code. The community remembers.</p>
</div>
<div class="grid md:grid-cols-3 gap-6">
<!-- Step 1 -->
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
<div class="flex items-center gap-3 mb-4">
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">1</span>
<h3 class="text-lethean-200 font-semibold">Install & connect</h3>
</div>
<p class="text-sm text-lethean-400 leading-relaxed mb-4">Download BugSETI, connect your GitHub account. That's your identity in the Lethean Community — one account, everywhere.</p>
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
<span class="text-cyan-400/70">$</span> gh auth login<br>
<span class="text-cyan-400/70">$</span> bugseti init
</div>
</div>
<!-- Step 2 -->
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
<div class="flex items-center gap-3 mb-4">
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">2</span>
<h3 class="text-lethean-200 font-semibold">Pick an issue</h3>
</div>
<p class="text-sm text-lethean-400 leading-relaxed mb-4">BugSETI scans OSS repos for beginner-friendly issues. AI prepares context — the relevant files, similar past fixes, project conventions.</p>
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
<span class="text-green-400/70"></span> 7 issues ready<br>
<span class="text-green-400/70"></span> Context seeded
</div>
</div>
<!-- Step 3 -->
<div class="group relative p-6 rounded-xl bg-lethean-900/50 border border-lethean-700/30 hover:border-cyan-400/20 transition-all duration-300">
<div class="flex items-center gap-3 mb-4">
<span class="flex items-center justify-center w-8 h-8 rounded-md bg-cyan-400/10 text-cyan-400 font-mono text-sm font-bold">3</span>
<h3 class="text-lethean-200 font-semibold">Fix & earn trust</h3>
</div>
<p class="text-sm text-lethean-400 leading-relaxed mb-4">Submit your PR. Every merged fix, every review, every contribution — it all counts. Your track record becomes your reputation.</p>
<div class="font-mono text-xs text-lethean-500 bg-lethean-800/50 rounded-md px-3 py-2">
<span class="text-green-400/70"></span> PR #247 merged<br>
<span class="text-cyan-400/70"></span> Trust updated
</div>
</div>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- WHAT YOU GET -->
<!-- ─────────────────────────────────────────────── -->
<section class="relative py-24">
<div class="max-w-5xl mx-auto px-6">
<!-- BugSETI features -->
<div class="grid lg:grid-cols-2 gap-16 items-center mb-32">
<div>
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">The app</p>
<h2 class="text-3xl font-bold text-lethean-200 mb-4">A workbench in your tray</h2>
<p class="text-lethean-400 leading-relaxed mb-6">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.</p>
<div class="space-y-3 text-sm">
<div class="flex items-start gap-3">
<span class="text-cyan-400 mt-0.5 font-mono text-xs"></span>
<span class="text-lethean-300">Priority queue — issues ranked by your skills and interests</span>
</div>
<div class="flex items-start gap-3">
<span class="text-cyan-400 mt-0.5 font-mono text-xs"></span>
<span class="text-lethean-300">AI context seeding — relevant files and patterns, ready to go</span>
</div>
<div class="flex items-start gap-3">
<span class="text-cyan-400 mt-0.5 font-mono text-xs"></span>
<span class="text-lethean-300">One-click PR submission — fork, branch, commit, push</span>
</div>
<div class="flex items-start gap-3">
<span class="text-cyan-400 mt-0.5 font-mono text-xs"></span>
<span class="text-lethean-300">Stats tracking — streaks, repos contributed, PRs merged</span>
</div>
</div>
</div>
<div class="gradient-border rounded-xl overflow-hidden">
<div class="bg-lethean-900 rounded-xl p-1">
<!-- Mock app UI -->
<div class="bg-lethean-800 rounded-lg overflow-hidden">
<div class="flex items-center gap-1.5 px-3 py-2 bg-lethean-900/80 border-b border-lethean-700/30">
<span class="w-2 h-2 rounded-full bg-red-400/40"></span>
<span class="w-2 h-2 rounded-full bg-yellow-400/40"></span>
<span class="w-2 h-2 rounded-full bg-green-400/40"></span>
<span class="ml-2 text-[10px] font-mono text-lethean-500">BugSETI — Workbench</span>
</div>
<div class="p-4 space-y-3">
<!-- Mock issue card -->
<div class="p-3 rounded-md bg-lethean-900/60 border border-lethean-700/20">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-mono text-cyan-400/80">lodash/lodash#5821</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-green-400/10 text-green-400/80 border border-green-400/20">good first issue</span>
</div>
<p class="text-xs text-lethean-300 mb-2">Fix _.merge not handling Symbol properties</p>
<div class="flex items-center gap-3 text-[10px] text-lethean-500">
<span>⭐ 58.2k</span>
<span>JavaScript</span>
<span>Context ready</span>
</div>
</div>
<!-- Mock issue card 2 -->
<div class="p-3 rounded-md bg-lethean-900/30 border border-lethean-700/10">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-mono text-lethean-500">vuejs/core#9214</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-blue-400/10 text-blue-400/70 border border-blue-400/15">bug</span>
</div>
<p class="text-xs text-lethean-400 mb-2">Teleport target not updating on HMR</p>
<div class="flex items-center gap-3 text-[10px] text-lethean-500">
<span>⭐ 44.7k</span>
<span>TypeScript</span>
<span>Seeding...</span>
</div>
</div>
<!-- Status bar -->
<div class="flex items-center justify-between pt-2 border-t border-lethean-700/20 text-[10px] font-mono text-lethean-500">
<span>7 issues queued</span>
<span class="text-cyan-400/60">♫ dapp.fm playing</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- dapp.fm teaser -->
<div class="grid lg:grid-cols-2 gap-16 items-center">
<div class="order-2 lg:order-1">
<div class="gradient-border rounded-xl overflow-hidden">
<div class="bg-lethean-900 rounded-xl p-6">
<div class="flex items-center gap-4 mb-4">
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-cyan-400/20 to-blue-500/20 flex items-center justify-center text-cyan-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/></svg>
</div>
<div>
<p class="text-lethean-200 font-semibold">dapp.fm</p>
<p class="text-xs text-lethean-500">Built into BugSETI</p>
</div>
</div>
<!-- Mini player mock -->
<div class="bg-lethean-800/60 rounded-lg p-4 border border-lethean-700/20">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-md bg-gradient-to-br from-purple-500/30 to-cyan-400/30 flex items-center justify-center text-xs text-lethean-400"></div>
<div class="flex-1 min-w-0">
<p class="text-xs text-lethean-200 truncate">It Feels So Good (Amnesia Mix)</p>
<p class="text-[10px] text-lethean-500">The Conductor & The Cowboy</p>
</div>
<span class="text-[10px] font-mono text-lethean-500">3:42</span>
</div>
<div class="h-1 bg-lethean-700/50 rounded-full overflow-hidden">
<div class="h-full w-2/3 bg-gradient-to-r from-cyan-400/60 to-cyan-400/30 rounded-full"></div>
</div>
</div>
<p class="text-[10px] text-lethean-500 mt-3 font-mono">Zero-trust DRM · Artists keep 95100% · ChaCha20-Poly1305</p>
</div>
</div>
</div>
<div class="order-1 lg:order-2">
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Built in</p>
<h2 class="text-3xl font-bold text-lethean-200 mb-4">Music while you merge</h2>
<p class="text-lethean-400 leading-relaxed mb-6">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.</p>
<p class="text-sm text-lethean-400 leading-relaxed">The player is a working implementation of the Lethean protocol RFCs — encrypted, decentralised, and yours. Code, listen, contribute.</p>
<a href="https://demo.dapp.fm" target="_blank" rel="noopener" class="inline-flex items-center gap-1.5 mt-4 text-sm text-cyan-400 hover:text-cyan-400/80 transition-colors link-underline">
Try the demo
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- ECOSYSTEM -->
<!-- ─────────────────────────────────────────────── -->
<section id="ecosystem" class="relative py-32">
<div class="max-w-5xl mx-auto px-6">
<div class="text-center mb-16">
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Ecosystem</p>
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">One identity, everywhere</h2>
<p class="text-lethean-400 max-w-xl mx-auto">Your GitHub is your Lethean identity. One name across Web2, Web3, Handshake DNS, blockchain — verified by what you've actually done.</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Card: Lethean Protocol -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Protocol</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">Lethean Network</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Privacy-first blockchain. Consent-gated networking via the UEPS protocol. Data sovereignty cryptographically enforced.</p>
<a href="https://lt.hn" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">lt.hn →</a>
</div>
<!-- Card: Handshake DNS -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Identity</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">lthn/ everywhere</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Handshake TLD, .io, .ai, .community, .eth, .tron — one name that resolves across every namespace. Your DID, decentralised.</p>
<a href="https://hns.to" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">hns.to →</a>
</div>
<!-- Card: Open Source -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Foundation</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">EUPL-1.2</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Every line is open source under the European Union Public License. 23 languages, no jurisdiction loopholes. Code stays open, forever.</p>
<a href="https://host.uk.com/oss" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">host.uk.com/oss →</a>
</div>
<!-- Card: AI Models -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Coming</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">lthn.ai</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Open source EUPL-1.2 models up to 70B parameters. High quality, embeddable transformers for the community.</p>
<span class="inline-flex items-center gap-1 mt-3 text-xs text-lethean-500">Coming soon</span>
</div>
<!-- Card: dapp.fm -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Music</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">dapp.fm</h3>
<p class="text-sm text-lethean-400 leading-relaxed">All-in-one publishing platform. Zero-trust DRM. Artists keep 95100%. Built on Borg encryption and LTHN rolling keys.</p>
<a href="https://demo.dapp.fm" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">demo.dapp.fm →</a>
</div>
<!-- Card: Host UK -->
<div class="p-5 rounded-xl bg-lethean-900/40 border border-lethean-700/20 hover:border-lethean-600/40 transition-all group">
<div class="text-cyan-400/60 mb-3 font-mono text-xs">Services</div>
<h3 class="text-lethean-200 font-semibold mb-2 group-hover:text-cyan-400 transition-colors">Host UK</h3>
<p class="text-sm text-lethean-400 leading-relaxed">Infrastructure and services brand of the Lethean Community. Privacy-first hosting, analytics, trust verification, notifications.</p>
<a href="https://host.uk.com" target="_blank" rel="noopener" class="inline-flex items-center gap-1 mt-3 text-xs text-cyan-400/60 hover:text-cyan-400 transition-colors">host.uk.com →</a>
</div>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- JOIN / DOWNLOAD -->
<!-- ─────────────────────────────────────────────── -->
<section id="join" class="relative py-32">
<!-- Subtle gradient bg -->
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-cyan-400/[0.02] to-transparent"></div>
<div class="relative max-w-3xl mx-auto px-6 text-center">
<p class="font-mono text-xs text-cyan-400 tracking-widest uppercase mb-3">Get started</p>
<h2 class="text-3xl sm:text-4xl font-bold text-lethean-200 mb-4">Join the community</h2>
<p class="text-lethean-400 max-w-lg mx-auto mb-10">Install BugSETI. Connect your GitHub. Start contributing. Every bug you fix makes open source better — and builds a trust record that's cryptographically yours.</p>
<!-- Download buttons -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-3 mb-12">
<a href="https://github.com/host-uk/core/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<span class="text-lg">🐧</span> Linux
</a>
<a href="https://github.com/host-uk/core/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<span class="text-lg">🍎</span> macOS
</a>
<a href="https://github.com/host-uk/core/releases" target="_blank" rel="noopener" class="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg bg-lethean-800 border border-lethean-600/40 text-lethean-200 font-medium text-sm hover:bg-lethean-700 hover:border-lethean-500/50 transition-all">
<span class="text-lg">🪟</span> Windows
</a>
</div>
<!-- Or just the terminal way -->
<div class="gradient-border rounded-lg overflow-hidden max-w-md mx-auto">
<div class="bg-lethean-900 rounded-lg px-5 py-3 font-mono text-sm text-left">
<span class="text-lethean-500"># or build from source</span><br>
<span class="text-cyan-400">$</span> <span class="text-lethean-300">git clone https://github.com/host-uk/core</span><br>
<span class="text-cyan-400">$</span> <span class="text-lethean-300">cd core && go build ./cmd/bugseti</span>
</div>
</div>
</div>
</section>
<!-- ─────────────────────────────────────────────── -->
<!-- FOOTER -->
<!-- ─────────────────────────────────────────────── -->
<footer class="border-t border-lethean-700/20 py-12">
<div class="max-w-5xl mx-auto px-6">
<div class="flex flex-col md:flex-row items-center justify-between gap-6">
<div class="flex items-center gap-2">
<span class="font-mono text-sm text-cyan-400">lthn</span>
<span class="text-lethean-600 font-mono text-xs">/</span>
<span class="text-lethean-400 text-sm">community</span>
</div>
<div class="flex items-center gap-6 text-xs text-lethean-500">
<a href="https://github.com/host-uk" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">GitHub</a>
<a href="https://discord.com/invite/lethean-lthn-379876792003067906" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">Discord</a>
<a href="https://lethean.io" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">Lethean.io</a>
<a href="https://host.uk.com" target="_blank" rel="noopener" class="hover:text-lethean-300 transition-colors">Host UK</a>
</div>
<div class="text-xs text-lethean-600 font-mono">
EUPL-1.2 · Viva La OpenSource
</div>
</div>
</div>
</footer>
<!-- ─────────────────────────────────────────────── -->
<!-- JS: Cursor glow + scroll animations -->
<!-- ─────────────────────────────────────────────── -->
<script>
// Cursor glow follower
const glow = document.getElementById('glowCursor');
if (glow && window.matchMedia('(pointer: fine)').matches) {
document.addEventListener('mousemove', (e) => {
glow.style.left = e.clientX + 'px';
glow.style.top = e.clientY + 'px';
});
}
// Intersection Observer for fade-in sections
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('fade-up');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
// Observe all section headings and cards
document.querySelectorAll('section:not(:first-of-type) h2, section:not(:first-of-type) .grid > div').forEach(el => {
el.style.opacity = '0';
el.style.transform = 'translateY(24px)';
observer.observe(el);
});
</script>
</body>
</html>

View file

@ -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.

37
cmd/core-app/Taskfile.yml Normal file
View file

@ -0,0 +1,37 @@
version: '3'
vars:
PHP_CONFIG: /opt/homebrew/opt/php@8.4-zts/bin/php-config
CGO_CFLAGS:
sh: "{{.PHP_CONFIG}} --includes"
CGO_LDFLAGS:
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $({{.PHP_CONFIG}} --ldflags) $({{.PHP_CONFIG}} --libs)"
tasks:
setup:
desc: "Install PHP-ZTS build dependency"
cmds:
- brew tap shivammathur/php 2>/dev/null || true
- brew install shivammathur/php/php@8.4-zts
build:
desc: "Build core-app binary"
env:
CGO_ENABLED: "1"
CGO_CFLAGS: "{{.CGO_CFLAGS}}"
CGO_LDFLAGS: "{{.CGO_LDFLAGS}}"
cmds:
- go build -tags nowatcher -o ../../bin/core-app .
dev:
desc: "Build and run core-app"
deps: [build]
env:
DYLD_LIBRARY_PATH: "/opt/homebrew/opt/php@8.4-zts/lib"
cmds:
- ../../bin/core-app
clean:
desc: "Remove build artifacts"
cmds:
- rm -f ../../bin/core-app

View file

@ -0,0 +1,48 @@
package main
import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// AppService provides native desktop capabilities to the Wails frontend.
// These methods are callable via window.go.main.AppService.{Method}()
// from any JavaScript/webview context.
type AppService struct {
app *application.App
env *AppEnvironment
}
func NewAppService(env *AppEnvironment) *AppService {
return &AppService{env: env}
}
// ServiceStartup is called by Wails when the application starts.
func (s *AppService) ServiceStartup(app *application.App) {
s.app = app
}
// GetVersion returns the application version.
func (s *AppService) GetVersion() string {
return "0.1.0"
}
// GetDataDir returns the persistent data directory path.
func (s *AppService) GetDataDir() string {
return s.env.DataDir
}
// GetDatabasePath returns the SQLite database file path.
func (s *AppService) GetDatabasePath() string {
return s.env.DatabasePath
}
// ShowWindow shows and focuses the main application window.
func (s *AppService) ShowWindow(name string) {
if s.app == nil {
return
}
if w, ok := s.app.Window.Get(name); ok {
w.Show()
w.Focus()
}
}

52
cmd/core-app/embed.go Normal file
View file

@ -0,0 +1,52 @@
package main
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
)
//go:embed all:laravel
var laravelFiles embed.FS
// extractLaravel copies the embedded Laravel app to a temporary directory.
// FrankenPHP needs real filesystem paths — it cannot serve from embed.FS.
// Returns the path to the extracted Laravel root.
func extractLaravel() (string, error) {
tmpDir, err := os.MkdirTemp("", "core-app-laravel-*")
if err != nil {
return "", fmt.Errorf("create temp dir: %w", err)
}
err = fs.WalkDir(laravelFiles, "laravel", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel("laravel", path)
if err != nil {
return err
}
targetPath := filepath.Join(tmpDir, relPath)
if d.IsDir() {
return os.MkdirAll(targetPath, 0o755)
}
data, err := laravelFiles.ReadFile(path)
if err != nil {
return fmt.Errorf("read embedded %s: %w", path, err)
}
return os.WriteFile(targetPath, data, 0o644)
})
if err != nil {
os.RemoveAll(tmpDir)
return "", fmt.Errorf("extract Laravel: %w", err)
}
return tmpDir, nil
}

167
cmd/core-app/env.go Normal file
View file

@ -0,0 +1,167 @@
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
)
// AppEnvironment holds the resolved paths for the running application.
type AppEnvironment struct {
// DataDir is the persistent data directory (survives app updates).
DataDir string
// LaravelRoot is the extracted Laravel app in the temp directory.
LaravelRoot string
// DatabasePath is the full path to the SQLite database file.
DatabasePath string
}
// PrepareEnvironment creates data directories, generates .env, and symlinks
// storage so Laravel can write to persistent locations.
func PrepareEnvironment(laravelRoot string) (*AppEnvironment, error) {
dataDir, err := resolveDataDir()
if err != nil {
return nil, fmt.Errorf("resolve data dir: %w", err)
}
env := &AppEnvironment{
DataDir: dataDir,
LaravelRoot: laravelRoot,
DatabasePath: filepath.Join(dataDir, "core-app.sqlite"),
}
// Create persistent directories
dirs := []string{
dataDir,
filepath.Join(dataDir, "storage", "app"),
filepath.Join(dataDir, "storage", "framework", "cache", "data"),
filepath.Join(dataDir, "storage", "framework", "sessions"),
filepath.Join(dataDir, "storage", "framework", "views"),
filepath.Join(dataDir, "storage", "logs"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create dir %s: %w", dir, err)
}
}
// Create empty SQLite database if it doesn't exist
if _, err := os.Stat(env.DatabasePath); os.IsNotExist(err) {
if err := os.WriteFile(env.DatabasePath, nil, 0o644); err != nil {
return nil, fmt.Errorf("create database: %w", err)
}
log.Printf("Created new database: %s", env.DatabasePath)
}
// Replace the extracted storage/ with a symlink to the persistent one
extractedStorage := filepath.Join(laravelRoot, "storage")
os.RemoveAll(extractedStorage)
persistentStorage := filepath.Join(dataDir, "storage")
if err := os.Symlink(persistentStorage, extractedStorage); err != nil {
return nil, fmt.Errorf("symlink storage: %w", err)
}
// Generate .env file with resolved paths
if err := writeEnvFile(laravelRoot, env); err != nil {
return nil, fmt.Errorf("write .env: %w", err)
}
return env, nil
}
// resolveDataDir returns the OS-appropriate persistent data directory.
func resolveDataDir() (string, error) {
var base string
switch runtime.GOOS {
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, "Library", "Application Support", "core-app")
case "linux":
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
base = filepath.Join(xdg, "core-app")
} else {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, ".local", "share", "core-app")
}
default:
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, ".core-app")
}
return base, nil
}
// writeEnvFile generates the Laravel .env with resolved runtime paths.
func writeEnvFile(laravelRoot string, env *AppEnvironment) error {
appKey, err := loadOrGenerateAppKey(env.DataDir)
if err != nil {
return fmt.Errorf("app key: %w", err)
}
content := fmt.Sprintf(`APP_NAME="Core App"
APP_ENV=production
APP_KEY=%s
APP_DEBUG=false
APP_URL=http://localhost
DB_CONNECTION=sqlite
DB_DATABASE="%s"
CACHE_STORE=file
SESSION_DRIVER=file
LOG_CHANNEL=single
LOG_LEVEL=warning
`, appKey, env.DatabasePath)
return os.WriteFile(filepath.Join(laravelRoot, ".env"), []byte(content), 0o644)
}
// loadOrGenerateAppKey loads an existing APP_KEY from the data dir,
// or generates a new one and persists it.
func loadOrGenerateAppKey(dataDir string) (string, error) {
keyFile := filepath.Join(dataDir, ".app-key")
data, err := os.ReadFile(keyFile)
if err == nil && len(data) > 0 {
return string(data), nil
}
// Generate a new 32-byte key
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return "", fmt.Errorf("generate key: %w", err)
}
appKey := "base64:" + base64.StdEncoding.EncodeToString(key)
if err := os.WriteFile(keyFile, []byte(appKey), 0o600); err != nil {
return "", fmt.Errorf("save key: %w", err)
}
log.Printf("Generated new APP_KEY (saved to %s)", keyFile)
return appKey, nil
}
// appendEnv appends a key=value pair to the Laravel .env file.
func appendEnv(laravelRoot, key, value string) error {
envFile := filepath.Join(laravelRoot, ".env")
f, err := os.OpenFile(envFile, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintf(f, "%s=\"%s\"\n", key, value)
return err
}

67
cmd/core-app/go.mod Normal file
View file

@ -0,0 +1,67 @@
module github.com/host-uk/core/cmd/core-app
go 1.25.5
require (
github.com/dunglas/frankenphp v1.5.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gammazero/deque v1.0.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/maypok86/otter v1.2.4 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
replace github.com/host-uk/core => ../..

185
cmd/core-app/go.sum Normal file
View file

@ -0,0 +1,185 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/dunglas/frankenphp v1.5.0 h1:mrkJNe2gxlqYijGSpYIVbbRYxjYw2bmgAeDFqwREEk4=
github.com/dunglas/frankenphp v1.5.0/go.mod h1:tU9EirkVR0EuIr69IT1XBjSE6YfQY88tZlgkAvLPdOw=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34=
github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

137
cmd/core-app/handler.go Normal file
View file

@ -0,0 +1,137 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/dunglas/frankenphp"
)
// PHPHandler implements http.Handler by delegating to FrankenPHP.
// It resolves URLs to files (like Caddy's try_files) before passing
// requests to the PHP runtime.
type PHPHandler struct {
docRoot string
laravelRoot string
}
// NewPHPHandler extracts the embedded Laravel app, prepares the environment,
// initialises FrankenPHP with worker mode, and returns the handler.
func NewPHPHandler() (*PHPHandler, *AppEnvironment, func(), error) {
// Extract embedded Laravel to temp directory
laravelRoot, err := extractLaravel()
if err != nil {
return nil, nil, nil, fmt.Errorf("extract Laravel: %w", err)
}
// Prepare persistent environment
env, err := PrepareEnvironment(laravelRoot)
if err != nil {
os.RemoveAll(laravelRoot)
return nil, nil, nil, fmt.Errorf("prepare environment: %w", err)
}
docRoot := filepath.Join(laravelRoot, "public")
log.Printf("Laravel root: %s", laravelRoot)
log.Printf("Document root: %s", docRoot)
log.Printf("Data directory: %s", env.DataDir)
log.Printf("Database: %s", env.DatabasePath)
// Try Octane worker mode first, fall back to standard mode.
// Worker mode keeps Laravel booted in memory — sub-ms response times.
workerScript := filepath.Join(laravelRoot, "vendor", "laravel", "octane", "bin", "frankenphp-worker.php")
workerEnv := map[string]string{
"APP_BASE_PATH": laravelRoot,
"FRANKENPHP_WORKER": "1",
}
workerMode := false
if _, err := os.Stat(workerScript); err == nil {
if err := frankenphp.Init(
frankenphp.WithNumThreads(4),
frankenphp.WithWorkers("laravel", workerScript, 2, workerEnv, nil),
frankenphp.WithPhpIni(map[string]string{
"display_errors": "Off",
"opcache.enable": "1",
}),
); err != nil {
log.Printf("Worker mode init failed (%v), falling back to standard mode", err)
} else {
workerMode = true
}
}
if !workerMode {
if err := frankenphp.Init(
frankenphp.WithNumThreads(4),
frankenphp.WithPhpIni(map[string]string{
"display_errors": "Off",
"opcache.enable": "1",
}),
); err != nil {
os.RemoveAll(laravelRoot)
return nil, nil, nil, fmt.Errorf("init FrankenPHP: %w", err)
}
}
if workerMode {
log.Println("FrankenPHP initialised (Octane worker mode, 2 workers)")
} else {
log.Println("FrankenPHP initialised (standard mode, 4 threads)")
}
cleanup := func() {
frankenphp.Shutdown()
os.RemoveAll(laravelRoot)
}
handler := &PHPHandler{
docRoot: docRoot,
laravelRoot: laravelRoot,
}
return handler, env, cleanup, nil
}
func (h *PHPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
filePath := filepath.Join(h.docRoot, filepath.Clean(urlPath))
info, err := os.Stat(filePath)
if err == nil && info.IsDir() {
// Directory → try index.php inside it
urlPath = strings.TrimRight(urlPath, "/") + "/index.php"
} else if err != nil && !strings.HasSuffix(urlPath, ".php") {
// File not found and not a .php request → front controller
urlPath = "/index.php"
}
// Serve static assets directly (CSS, JS, images)
if !strings.HasSuffix(urlPath, ".php") {
staticPath := filepath.Join(h.docRoot, filepath.Clean(urlPath))
if info, err := os.Stat(staticPath); err == nil && !info.IsDir() {
http.ServeFile(w, r, staticPath)
return
}
}
// Route to FrankenPHP
r.URL.Path = urlPath
req, err := frankenphp.NewRequestWithContext(r,
frankenphp.WithRequestDocumentRoot(h.docRoot, false),
)
if err != nil {
http.Error(w, fmt.Sprintf("FrankenPHP request error: %v", err), http.StatusInternalServerError)
return
}
if err := frankenphp.ServeHTTP(w, req); err != nil {
http.Error(w, fmt.Sprintf("FrankenPHP serve error: %v", err), http.StatusInternalServerError)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

View file

@ -0,0 +1,24 @@
// Package icons provides embedded icon assets for the Core App.
package icons
import _ "embed"
// TrayTemplate is the template icon for macOS systray (22x22 PNG, black on transparent).
//
//go:embed tray-template.png
var TrayTemplate []byte
// TrayLight is the light mode icon for Windows/Linux systray.
//
//go:embed tray-light.png
var TrayLight []byte
// TrayDark is the dark mode icon for Windows/Linux systray.
//
//go:embed tray-dark.png
var TrayDark []byte
// AppIcon is the main application icon.
//
//go:embed appicon.png
var AppIcon []byte

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

View file

@ -0,0 +1,13 @@
APP_NAME="Core App"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
DB_CONNECTION=sqlite
DB_DATABASE=/tmp/core-app/database.sqlite
CACHE_STORE=file
SESSION_DRIVER=file
LOG_CHANNEL=single
LOG_LEVEL=warning

5
cmd/core-app/laravel/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/vendor/
/node_modules/
/.env
/bootstrap/cache/*.php
/storage/*.key

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use Livewire\Component;
class Counter extends Component
{
public int $count = 0;
public function increment(): void
{
$this->count++;
}
public function decrement(): void
{
$this->count--;
}
public function render()
{
return view('livewire.counter');
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\ServiceProvider;
use Throwable;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Auto-migrate on first boot. Single-user desktop app with
// SQLite — safe to run on every startup. The --force flag
// is required in production, --no-interaction prevents prompts.
try {
Artisan::call('migrate', [
'--force' => true,
'--no-interaction' => true,
]);
} catch (Throwable) {
// Silently skip — DB might not exist yet (e.g. during
// composer operations or first extraction).
}
}
}

View file

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
define('LARAVEL_START', microtime(true));
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
$kernel->terminate($input, $status);
exit($status);

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})
->create();

View file

@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
return [
App\Providers\AppServiceProvider::class,
];

View file

@ -0,0 +1,29 @@
{
"name": "host-uk/core-app",
"description": "Embedded Laravel application for Core App desktop",
"license": "EUPL-1.2",
"type": "project",
"require": {
"php": "^8.4",
"laravel/framework": "^12.0",
"laravel/octane": "^2.0",
"livewire/livewire": "^4.0"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"@php artisan package:discover --ansi"
]
}
}

6149
cmd/core-app/laravel/composer.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
return [
'name' => env('APP_NAME', 'Core App'),
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false),
'url' => env('APP_URL', 'http://localhost'),
'timezone' => 'UTC',
'locale' => 'en',
'fallback_locale' => 'en',
'faker_locale' => 'en_GB',
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'maintenance' => [
'driver' => 'file',
],
];

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
return [
'default' => env('CACHE_STORE', 'file'),
'stores' => [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'array' => [
'driver' => 'array',
'serialize' => false,
],
],
'prefix' => env('CACHE_PREFIX', 'core_app_cache_'),
];

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
return [
'default' => 'sqlite',
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => true,
'busy_timeout' => 5000,
'journal_mode' => 'wal',
'synchronous' => 'normal',
],
],
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
];

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
return [
'default' => env('LOG_CHANNEL', 'single'),
'channels' => [
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'warning'),
'replace_placeholders' => true,
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => Monolog\Handler\StreamHandler::class,
'with' => [
'stream' => 'php://stderr',
],
'processors' => [Monolog\Processor\PsrLogMessageProcessor::class],
],
],
];

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
return [
'driver' => env('SESSION_DRIVER', 'file'),
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => true,
'encrypt' => false,
'files' => storage_path('framework/sessions'),
'connection' => env('SESSION_CONNECTION'),
'table' => 'sessions',
'store' => env('SESSION_STORE'),
'lottery' => [2, 100],
'cookie' => env('SESSION_COOKIE', 'core_app_session'),
'path' => '/',
'domain' => null,
'secure' => false,
'http_only' => true,
'same_site' => 'lax',
'partitioned' => false,
];

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
return [
'paths' => [
resource_path('views'),
],
'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))),
];

Binary file not shown.

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
public function down(): void
{
Schema::dropIfExists('sessions');
}
};

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
public function down(): void
{
Schema::dropIfExists('cache_locks');
Schema::dropIfExists('cache');
}
};

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
(require_once __DIR__.'/../bootstrap/app.php')
->handleRequest(Request::capture());

View file

@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Core App</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0d1117;
color: #e6edf3;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 32px;
padding: 32px;
}
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 12px;
padding: 48px;
text-align: center;
max-width: 600px;
width: 100%;
}
h1 { font-size: 32px; margin-bottom: 8px; }
h2 { font-size: 20px; margin-bottom: 16px; color: #8b949e; font-weight: 400; }
.accent { color: #39d0d8; }
.subtitle { color: #8b949e; font-size: 16px; margin-bottom: 24px; }
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 24px;
text-align: left;
}
.info-item {
background: #21262d;
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px 16px;
}
.info-item__label { font-size: 11px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
.info-item__value { font-size: 14px; margin-top: 4px; font-family: monospace; }
.badge {
display: inline-block;
background: #238636;
color: #fff;
border-radius: 12px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
margin-top: 20px;
}
.counter { text-align: center; }
.counter__display {
font-size: 72px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #39d0d8;
line-height: 1;
margin-bottom: 24px;
}
.counter__controls {
display: flex;
gap: 16px;
justify-content: center;
}
.counter__hint {
margin-top: 16px;
font-size: 12px;
color: #8b949e;
}
.btn {
appearance: none;
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px 32px;
font-size: 20px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn:active { transform: scale(0.96); }
.btn--primary {
background: #238636;
color: #fff;
border-color: #2ea043;
}
.btn--primary:hover { background: #2ea043; }
.btn--secondary {
background: #21262d;
color: #e6edf3;
}
.btn--secondary:hover { background: #30363d; }
</style>
@livewireStyles
</head>
<body>
{{ $slot }}
@livewireScripts
</body>
</html>

View file

@ -0,0 +1,8 @@
<div class="counter">
<div class="counter__display">{{ $count }}</div>
<div class="counter__controls">
<button wire:click="decrement" class="btn btn--secondary">&minus;</button>
<button wire:click="increment" class="btn btn--primary">+</button>
</div>
<p class="counter__hint">Livewire {{ \Livewire\Livewire::VERSION }} &middot; Server-rendered, no page reload</p>
</div>

View file

@ -0,0 +1,40 @@
<x-layout>
<div class="card">
<h1><span class="accent">Core App</span></h1>
<p class="subtitle">Laravel {{ app()->version() }} running inside a native desktop window</p>
<div class="info-grid">
<div class="info-item">
<div class="info-item__label">PHP</div>
<div class="info-item__value">{{ PHP_VERSION }}</div>
</div>
<div class="info-item">
<div class="info-item__label">Thread Safety</div>
<div class="info-item__value">{{ PHP_ZTS ? 'ZTS (Yes)' : 'NTS (No)' }}</div>
</div>
<div class="info-item">
<div class="info-item__label">SAPI</div>
<div class="info-item__value">{{ php_sapi_name() }}</div>
</div>
<div class="info-item">
<div class="info-item__label">Platform</div>
<div class="info-item__value">{{ PHP_OS }} {{ php_uname('m') }}</div>
</div>
<div class="info-item">
<div class="info-item__label">Database</div>
<div class="info-item__value">SQLite {{ \SQLite3::version()['versionString'] }}</div>
</div>
<div class="info-item">
<div class="info-item__label">Mode</div>
<div class="info-item__value">{{ env('FRANKENPHP_WORKER') ? 'Octane Worker' : 'Standard' }}</div>
</div>
</div>
<div class="badge">Single Binary &middot; No Server &middot; No Config</div>
</div>
<div class="card">
<h2>Livewire Reactivity Test</h2>
<livewire:counter />
</div>
</x-layout>

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});

102
cmd/core-app/main.go Normal file
View file

@ -0,0 +1,102 @@
// Package main provides the Core App — a native desktop application
// embedding Laravel via FrankenPHP inside a Wails v3 window.
//
// A single Go binary that boots the PHP runtime, extracts the embedded
// Laravel application, and serves it through FrankenPHP's ServeHTTP into
// a native webview via Wails v3's AssetOptions.Handler.
package main
import (
"context"
"log"
"runtime"
"github.com/host-uk/core/cmd/core-app/icons"
"github.com/wailsapp/wails/v3/pkg/application"
)
func main() {
// Set up PHP handler (extracts Laravel, prepares env, inits FrankenPHP).
handler, env, cleanup, err := NewPHPHandler()
if err != nil {
log.Fatalf("Failed to initialise PHP handler: %v", err)
}
defer cleanup()
// Create the app service and native bridge.
appService := NewAppService(env)
bridge, err := NewNativeBridge(appService)
if err != nil {
log.Fatalf("Failed to start native bridge: %v", err)
}
defer bridge.Shutdown(context.Background())
// Inject the bridge URL into the Laravel .env so PHP can call Go.
if err := appendEnv(handler.laravelRoot, "NATIVE_BRIDGE_URL", bridge.URL()); err != nil {
log.Printf("Warning: couldn't inject bridge URL into .env: %v", err)
}
app := application.New(application.Options{
Name: "Core App",
Description: "Host UK Native App — Laravel powered by FrankenPHP",
Services: []application.Service{
application.NewService(appService),
},
Assets: application.AssetOptions{
Handler: handler,
},
Mac: application.MacOptions{
ActivationPolicy: application.ActivationPolicyAccessory,
},
})
appService.app = app
setupSystemTray(app)
// Main application window
app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "main",
Title: "Core App",
Width: 1200,
Height: 800,
URL: "/",
BackgroundColour: application.NewRGB(13, 17, 23),
})
log.Println("Starting Core App...")
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
// setupSystemTray configures the system tray icon and menu.
func setupSystemTray(app *application.App) {
systray := app.SystemTray.New()
systray.SetTooltip("Core App")
if runtime.GOOS == "darwin" {
systray.SetTemplateIcon(icons.TrayTemplate)
} else {
systray.SetDarkModeIcon(icons.TrayDark)
systray.SetIcon(icons.TrayLight)
}
trayMenu := app.Menu.New()
trayMenu.Add("Open Core App").OnClick(func(ctx *application.Context) {
if w, ok := app.Window.Get("main"); ok {
w.Show()
w.Focus()
}
})
trayMenu.AddSeparator()
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
app.Quit()
})
systray.SetMenu(trayMenu)
}

View file

@ -0,0 +1,96 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
)
// NativeBridge provides a localhost HTTP API that PHP code can call
// to access native desktop capabilities (file dialogs, notifications, etc.).
//
// Livewire renders server-side in PHP, so it can't call Wails bindings
// (window.go.*) directly. Instead, PHP makes HTTP requests to this bridge.
// The bridge port is injected into Laravel's .env as NATIVE_BRIDGE_URL.
type NativeBridge struct {
server *http.Server
port int
app *AppService
}
// NewNativeBridge creates and starts the bridge on a random available port.
func NewNativeBridge(appService *AppService) (*NativeBridge, error) {
mux := http.NewServeMux()
bridge := &NativeBridge{app: appService}
// Register bridge endpoints
mux.HandleFunc("POST /bridge/version", bridge.handleVersion)
mux.HandleFunc("POST /bridge/data-dir", bridge.handleDataDir)
mux.HandleFunc("POST /bridge/show-window", bridge.handleShowWindow)
mux.HandleFunc("GET /bridge/health", bridge.handleHealth)
// Listen on a random available port (localhost only)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("listen: %w", err)
}
bridge.port = listener.Addr().(*net.TCPAddr).Port
bridge.server = &http.Server{Handler: mux}
go func() {
if err := bridge.server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("Native bridge error: %v", err)
}
}()
log.Printf("Native bridge listening on http://127.0.0.1:%d", bridge.port)
return bridge, nil
}
// Port returns the port the bridge is listening on.
func (b *NativeBridge) Port() int {
return b.port
}
// URL returns the full base URL of the bridge.
func (b *NativeBridge) URL() string {
return fmt.Sprintf("http://127.0.0.1:%d", b.port)
}
// Shutdown gracefully stops the bridge server.
func (b *NativeBridge) Shutdown(ctx context.Context) error {
return b.server.Shutdown(ctx)
}
func (b *NativeBridge) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"})
}
func (b *NativeBridge) handleVersion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"version": b.app.GetVersion()})
}
func (b *NativeBridge) handleDataDir(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"path": b.app.GetDataDir()})
}
func (b *NativeBridge) handleShowWindow(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
b.app.ShowWindow(req.Name)
writeJSON(w, map[string]string{"status": "ok"})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}

View file

@ -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{}
}

View file

@ -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{},
}
}

View file

@ -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": []
}
}
}
}
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,18 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: '<router-outlet></router-outlet>',
styles: [`
:host {
display: block;
height: 100%;
}
`]
})
export class AppComponent {
title = 'Core IDE';
}

View file

@ -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())
]
};

View file

@ -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)
}
];

View file

@ -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: `
<div class="builds">
<div class="builds__header">
<h2>Builds</h2>
<button class="btn btn--secondary" (click)="refresh()">Refresh</button>
</div>
<div class="builds__list">
<div
*ngFor="let build of builds; trackBy: trackBuild"
class="build-card"
[class.build-card--expanded]="expandedId === build.id"
(click)="toggle(build.id)"
>
<div class="build-card__header">
<div class="build-card__info">
<span class="build-card__repo">{{ build.repo }}</span>
<span class="build-card__branch text-muted">{{ build.branch }}</span>
</div>
<span class="badge" [class]="statusBadge(build.status)">{{ build.status }}</span>
</div>
<div class="build-card__meta text-muted">
{{ build.startedAt | date:'medium' }}
<span *ngIf="build.duration"> &middot; {{ build.duration }}</span>
</div>
<div *ngIf="expandedId === build.id" class="build-card__logs">
<pre *ngIf="logs.length > 0">{{ logs.join('\\n') }}</pre>
<p *ngIf="logs.length === 0" class="text-muted">No logs available</p>
</div>
</div>
<div *ngIf="builds.length === 0" class="builds__empty text-muted">
No builds found. Builds will appear here from Forgejo CI.
</div>
</div>
</div>
`,
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<void> {
this.builds = await this.wails.getBuilds();
}
async toggle(buildId: string): Promise<void> {
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';
}
}
}

View file

@ -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: `
<div class="chat">
<div class="chat__header">
<div class="chat__session-picker">
<select class="form-select" [(ngModel)]="activeSessionId" (ngModelChange)="onSessionChange()">
<option *ngFor="let s of sessions" [value]="s.id">{{ s.name }} ({{ s.status }})</option>
</select>
<button class="btn btn--ghost" (click)="createSession()">+ New</button>
</div>
</div>
<div class="chat__body">
<div class="chat__messages">
<div
*ngFor="let msg of messages"
class="chat__msg"
[class.chat__msg--user]="msg.role === 'user'"
[class.chat__msg--agent]="msg.role === 'agent'"
>
<div class="chat__msg-role">{{ msg.role }}</div>
<div class="chat__msg-content">{{ msg.content }}</div>
</div>
<div *ngIf="messages.length === 0" class="chat__empty text-muted">
No messages yet. Start a conversation with an agent.
</div>
</div>
<div *ngIf="plan.steps.length > 0" class="chat__plan">
<h4>Plan: {{ plan.status }}</h4>
<ul>
<li *ngFor="let step of plan.steps" [class]="'plan-step plan-step--' + step.status">
{{ step.name }}
<span class="badge badge--info">{{ step.status }}</span>
</li>
</ul>
</div>
</div>
<div class="chat__input">
<textarea
class="form-textarea"
[(ngModel)]="draft"
(keydown.enter)="sendMessage($event)"
placeholder="Type a message... (Enter to send)"
rows="2"
></textarea>
<button class="btn btn--primary" (click)="sendMessage()" [disabled]="!draft.trim()">Send</button>
</div>
</div>
`,
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<void> {
this.sessions = await this.wails.listSessions();
if (this.sessions.length > 0 && !this.activeSessionId) {
this.activeSessionId = this.sessions[0].id;
this.onSessionChange();
}
}
async onSessionChange(): Promise<void> {
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<void> {
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<void> {
const name = `Session ${this.sessions.length + 1}`;
const session = await this.wails.createSession(name);
this.sessions.push(session);
this.activeSessionId = session.id;
this.onSessionChange();
}
}

View file

@ -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: `
<div class="dashboard">
<h2>Dashboard</h2>
<div class="dashboard__grid">
<div class="stat-card">
<div class="stat-card__value" [class.text-success]="data.connection.bridgeConnected">
{{ data.connection.bridgeConnected ? 'Online' : 'Offline' }}
</div>
<div class="stat-card__label">Bridge Status</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ data.connection.wsClients }}</div>
<div class="stat-card__label">WS Clients</div>
</div>
<div class="stat-card">
<div class="stat-card__value">{{ data.connection.wsChannels }}</div>
<div class="stat-card__label">Active Channels</div>
</div>
<div class="stat-card">
<div class="stat-card__value">0</div>
<div class="stat-card__label">Agent Sessions</div>
</div>
</div>
<div class="dashboard__activity">
<h3>Activity Feed</h3>
<div class="activity-feed">
<div *ngFor="let item of activity" class="activity-item">
<span class="activity-item__badge badge badge--info">{{ item.type }}</span>
<span class="activity-item__msg">{{ item.message }}</span>
<span class="activity-item__time text-muted">{{ item.timestamp | date:'shortTime' }}</span>
</div>
<div *ngIf="activity.length === 0" class="text-muted" style="text-align: center; padding: var(--spacing-lg);">
No recent activity. Events will stream here in real-time.
</div>
</div>
</div>
</div>
`,
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<typeof setInterval> | 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<void> {
this.data = await this.wails.getDashboard();
}
}

View file

@ -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: `
<div class="jellyfin">
<header class="jellyfin__header">
<div>
<h2>Jellyfin Player</h2>
<p class="text-muted">Embedded media access for Host UK workflows.</p>
</div>
<div class="mode-switch">
<button class="btn btn--secondary" [class.is-active]="mode === 'web'" (click)="mode = 'web'">Web</button>
<button class="btn btn--secondary" [class.is-active]="mode === 'stream'" (click)="mode = 'stream'">Stream</button>
</div>
</header>
<section class="card">
<div class="form-group">
<label class="form-label">Jellyfin Server URL</label>
<input class="form-input" [(ngModel)]="serverUrl" placeholder="https://media.lthn.ai" />
</div>
<div *ngIf="mode === 'stream'" class="stream-grid">
<div class="form-group">
<label class="form-label">Item ID</label>
<input class="form-input" [(ngModel)]="itemId" placeholder="Jellyfin library item ID" />
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input class="form-input" [(ngModel)]="apiKey" placeholder="Jellyfin API key" />
</div>
<div class="form-group">
<label class="form-label">Media Source ID (optional)</label>
<input class="form-input" [(ngModel)]="mediaSourceId" placeholder="Source ID for multi-source items" />
</div>
</div>
<div class="actions">
<button class="btn btn--primary" (click)="load()">Load Player</button>
<button class="btn btn--secondary" (click)="reset()">Reset</button>
</div>
</section>
<section class="card viewer" *ngIf="loaded && mode === 'web'">
<iframe
class="jellyfin-frame"
title="Jellyfin Web"
[src]="safeWebUrl"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>
</section>
<section class="card viewer" *ngIf="loaded && mode === 'stream'">
<video class="jellyfin-video" controls [src]="streamUrl"></video>
<p class="text-muted stream-hint" *ngIf="!streamUrl">Set Item ID and API key to build stream URL.</p>
</section>
</div>
`,
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();
}
}

View file

@ -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: `
<div class="ide">
<nav class="ide__sidebar">
<div class="ide__logo">Core IDE</div>
<ul class="ide__nav">
<li
*ngFor="let item of navItems"
class="ide__nav-item"
[class.active]="activePanel === item.id"
(click)="activePanel = item.id"
>
<span class="ide__nav-icon">{{ item.icon }}</span>
<span class="ide__nav-label">{{ item.label }}</span>
</li>
</ul>
<div class="ide__nav-footer text-muted">v0.1.0</div>
</nav>
<main class="ide__content">
<app-chat *ngIf="activePanel === 'chat'" />
<app-build *ngIf="activePanel === 'build'" />
<app-dashboard *ngIf="activePanel === 'dashboard'" />
<app-jellyfin *ngIf="activePanel === 'jellyfin'" />
</main>
</div>
`,
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' },
];
}

View file

@ -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: `
<div class="settings">
<h2>Settings</h2>
<div class="settings__section">
<h3>Connection</h3>
<div class="form-group">
<label class="form-label">Laravel WebSocket URL</label>
<input
class="form-input"
[(ngModel)]="laravelUrl"
placeholder="ws://localhost:9876/ws"
/>
</div>
<div class="form-group">
<label class="form-label">Workspace Root</label>
<input
class="form-input"
[(ngModel)]="workspaceRoot"
placeholder="/path/to/workspace"
/>
</div>
</div>
<div class="settings__section">
<h3>Appearance</h3>
<div class="form-group">
<label class="form-label">Theme</label>
<select class="form-select" [(ngModel)]="theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
</div>
<div class="settings__actions">
<button class="btn btn--primary" (click)="save()">Save Settings</button>
</div>
</div>
`,
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');
}
}
}

View file

@ -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<ConnectionStatus>;
GetDashboard(): Promise<DashboardData>;
ShowWindow(name: string): Promise<void>;
};
ChatService: {
SendMessage(sessionId: string, message: string): Promise<boolean>;
GetHistory(sessionId: string): Promise<ChatMessage[]>;
ListSessions(): Promise<Session[]>;
CreateSession(name: string): Promise<Session>;
GetPlanStatus(sessionId: string): Promise<PlanStatus>;
};
BuildService: {
GetBuilds(repo: string): Promise<Build[]>;
GetBuildLogs(buildId: string): Promise<string[]>;
};
};
}
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<ConnectionStatus> {
return this.ide?.GetConnectionStatus() ?? Promise.resolve({
bridgeConnected: false, laravelUrl: '', wsClients: 0, wsChannels: 0
});
}
getDashboard(): Promise<DashboardData> {
return this.ide?.GetDashboard() ?? Promise.resolve({
connection: { bridgeConnected: false, laravelUrl: '', wsClients: 0, wsChannels: 0 }
});
}
showWindow(name: string): Promise<void> {
return this.ide?.ShowWindow(name) ?? Promise.resolve();
}
// Chat
sendMessage(sessionId: string, message: string): Promise<boolean> {
return this.chat?.SendMessage(sessionId, message) ?? Promise.resolve(false);
}
getHistory(sessionId: string): Promise<ChatMessage[]> {
return this.chat?.GetHistory(sessionId) ?? Promise.resolve([]);
}
listSessions(): Promise<Session[]> {
return this.chat?.ListSessions() ?? Promise.resolve([]);
}
createSession(name: string): Promise<Session> {
return this.chat?.CreateSession(name) ?? Promise.resolve({
id: '', name, status: 'offline', createdAt: ''
});
}
getPlanStatus(sessionId: string): Promise<PlanStatus> {
return this.chat?.GetPlanStatus(sessionId) ?? Promise.resolve({
sessionId, status: 'offline', steps: []
});
}
// Build
getBuilds(repo: string = ''): Promise<Build[]> {
return this.build?.GetBuilds(repo) ?? Promise.resolve([]);
}
getBuildLogs(buildId: string): Promise<string[]> {
return this.build?.GetBuildLogs(buildId) ?? Promise.resolve([]);
}
}

View file

@ -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<WSMessage>();
private reconnectTimer: ReturnType<typeof setTimeout> | 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<WSMessage> {
// 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<WSMessage> {
return this.messages$.asObservable();
}
ngOnDestroy(): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
this.messages$.complete();
}
}

View file

@ -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: `
<div class="tray">
<div class="tray__header">
<h3>Core IDE</h3>
<span class="badge" [class]="status.bridgeConnected ? 'badge--success' : 'badge--danger'">
{{ status.bridgeConnected ? 'Online' : 'Offline' }}
</span>
</div>
<div class="tray__stats">
<div class="stat">
<span class="stat__value">{{ status.wsClients }}</span>
<span class="stat__label">WS Clients</span>
</div>
<div class="stat">
<span class="stat__value">{{ status.wsChannels }}</span>
<span class="stat__label">Channels</span>
</div>
</div>
<div class="tray__actions">
<button class="btn btn--primary" (click)="openMain()">Open IDE</button>
<button class="btn btn--secondary" (click)="openSettings()">Settings</button>
</div>
<div class="tray__footer text-muted">
Laravel bridge: {{ status.bridgeConnected ? 'connected' : 'disconnected' }}
</div>
</div>
`,
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<typeof setInterval> | null = null;
constructor(private wails: WailsService) {}
ngOnInit(): void {
this.refresh();
this.pollTimer = setInterval(() => this.refresh(), 5000);
}
async refresh(): Promise<void> {
this.status = await this.wails.getConnectionStatus();
}
openMain(): void {
this.wails.showWindow('main');
}
openSettings(): void {
this.wails.showWindow('settings');
}
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Core IDE</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

View file

@ -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));

View file

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

View file

@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View file

@ -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
}
}

57
cmd/core-ide/go.mod Normal file
View file

@ -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 => ../..

165
cmd/core-ide/go.sum Normal file
View file

@ -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=

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 B

102
cmd/core-ide/ide_service.go Normal file
View file

@ -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)
}
}

151
cmd/core-ide/main.go Normal file
View file

@ -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)
}

View file

@ -0,0 +1,9 @@
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod main.go ./
RUN go build -trimpath -ldflags="-w -s" -o /vanity-import .
FROM alpine:3.21
COPY --from=build /vanity-import /vanity-import
EXPOSE 8080
ENTRYPOINT ["/vanity-import"]

3
cmd/vanity-import/go.mod Normal file
View file

@ -0,0 +1,3 @@
module dappco.re/vanity-import
go 1.25.6

104
cmd/vanity-import/main.go Normal file
View file

@ -0,0 +1,104 @@
// Package main provides a Go vanity import server for dappco.re.
//
// When a Go tool requests ?go-get=1, this server responds with HTML
// containing <meta name="go-import"> tags that map dappco.re module
// paths to their Git repositories on forge.lthn.ai.
//
// For browser requests (no ?go-get=1), it redirects to the Forgejo
// repository web UI.
package main
import (
"fmt"
"log"
"net/http"
"os"
"strings"
)
var modules = map[string]string{
"core": "host-uk/core",
"build": "host-uk/build",
}
const (
forgeBase = "https://forge.lthn.ai"
vanityHost = "dappco.re"
defaultAddr = ":8080"
)
func main() {
addr := os.Getenv("ADDR")
if addr == "" {
addr = defaultAddr
}
// Allow overriding forge base URL
forge := os.Getenv("FORGE_URL")
if forge == "" {
forge = forgeBase
}
// Parse additional modules from VANITY_MODULES env (format: "mod1=owner/repo,mod2=owner/repo")
if extra := os.Getenv("VANITY_MODULES"); extra != "" {
for _, entry := range strings.Split(extra, ",") {
parts := strings.SplitN(strings.TrimSpace(entry), "=", 2)
if len(parts) == 2 {
modules[parts[0]] = parts[1]
}
}
}
http.HandleFunc("/", handler(forge))
log.Printf("vanity-import listening on %s (%d modules)", addr, len(modules))
for mod, repo := range modules {
log.Printf(" %s/%s → %s/%s.git", vanityHost, mod, forge, repo)
}
log.Fatal(http.ListenAndServe(addr, nil))
}
func handler(forge string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Extract the first path segment as the module name
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
// Root request — redirect to forge org page
http.Redirect(w, r, forge+"/host-uk", http.StatusFound)
return
}
// Module is the first path segment (e.g., "core" from "/core/pkg/mcp")
mod := strings.SplitN(path, "/", 2)[0]
repo, ok := modules[mod]
if !ok {
http.NotFound(w, r)
return
}
// If go-get=1, serve the vanity import HTML
if r.URL.Query().Get("go-get") == "1" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<meta name="go-import" content="%s/%s git %s/%s.git">
<meta name="go-source" content="%s/%s %s/%s %s/%s/src/branch/main{/dir} %s/%s/src/branch/main{/dir}/{file}#L{line}">
<meta http-equiv="refresh" content="0; url=%s/%s">
</head>
<body>
Redirecting to <a href="%s/%s">%s/%s</a>...
</body>
</html>
`, vanityHost, mod, forge, repo,
vanityHost, mod, forge, repo, forge, repo, forge, repo,
forge, repo,
forge, repo, forge, repo)
return
}
// Browser request — redirect to Forgejo
http.Redirect(w, r, forge+"/"+repo, http.StatusFound)
}
}

View file

@ -0,0 +1,83 @@
# Example: C++ Build Configuration
# CMake + Conan 2 project using host-uk/build system
version: 1
project:
name: my-cpp-project
type: cpp
description: "A C++ application"
cpp:
standard: 17
build_type: Release
static: false
# Conan package manager
conan:
version: "2.21.0"
requires:
- zlib/1.3.1
- boost/1.85.0
- openssl/3.2.0
tool_requires:
- cmake/3.31.9
options:
boost/*:without_test: true
registry:
url: http://forge.snider.dev:4000/api/packages/host-uk/conan
remote: conan_build
# CMake settings
cmake:
minimum_version: "3.16"
variables:
USE_CCACHE: "ON"
presets:
- conan-release
- conan-debug
# Optional project-specific build options
options:
testnet: false
# Cross-compilation targets
targets:
- os: linux
arch: x86_64
profile: gcc-linux-x86_64
- os: linux
arch: arm64
profile: gcc-linux-armv8
- os: darwin
arch: arm64
profile: apple-clang-armv8
- os: darwin
arch: x86_64
profile: apple-clang-x86_64
- os: windows
arch: x86_64
profile: msvc-194-x86_64
# Packaging
package:
generators:
- TGZ
- ZIP
vendor: host-uk
contact: developers@lethean.io
website: https://lt.hn
# Docker output
docker:
dockerfile: .core/build/docker/Dockerfile
platforms:
- linux/amd64
- linux/arm64
tags:
- latest
- "{{.Version}}"
build_args:
BUILD_THREADS: auto
BUILD_STATIC: "0"
BUILD_TYPE: Release

403
github-projects-recovery.md Normal file
View file

@ -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

1
go.mod
View file

@ -31,6 +31,7 @@ require (
require (
aead.dev/minisign v0.3.0 // indirect
cloud.google.com/go v0.123.0 // indirect
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect

2
go.sum
View file

@ -5,6 +5,8 @@ cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=

11
go.work Normal file
View file

@ -0,0 +1,11 @@
go 1.25.5
use (
.
./cmd/bugseti
./cmd/core-app
./cmd/core-ide
./internal/bugseti
./internal/bugseti/updater
./internal/core-ide
)

View file

@ -0,0 +1,86 @@
package forge
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
fg "github.com/host-uk/core/pkg/forge"
)
// Auth command flags.
var (
authURL string
authToken string
)
// addAuthCommand adds the 'auth' subcommand for authentication status and login.
func addAuthCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "auth",
Short: "Show authentication status",
Long: "Show the current Forgejo authentication status, or log in with a new token.",
RunE: func(cmd *cli.Command, args []string) error {
return runAuth()
},
}
cmd.Flags().StringVar(&authURL, "url", "", "Forgejo instance URL")
cmd.Flags().StringVar(&authToken, "token", "", "API token (create at <url>/user/settings/applications)")
parent.AddCommand(cmd)
}
func runAuth() error {
// If credentials provided, save them first
if authURL != "" || authToken != "" {
if err := fg.SaveConfig(authURL, authToken); err != nil {
return err
}
if authURL != "" {
cli.Success(fmt.Sprintf("URL set to %s", authURL))
}
if authToken != "" {
cli.Success("Token saved")
}
}
// Always show current auth status
url, token, err := fg.ResolveConfig(authURL, authToken)
if err != nil {
return err
}
if token == "" {
cli.Blank()
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url))
cli.Print(" %s %s\n", dimStyle.Render("Auth:"), warningStyle.Render("not authenticated"))
cli.Print(" %s %s\n", dimStyle.Render("Hint:"), dimStyle.Render(fmt.Sprintf("core forge auth --token TOKEN (create at %s/user/settings/applications)", url)))
cli.Blank()
return nil
}
client, err := fg.NewFromConfig(authURL, authToken)
if err != nil {
return err
}
user, _, err := client.API().GetMyUserInfo()
if err != nil {
cli.Blank()
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url))
cli.Print(" %s %s\n", dimStyle.Render("Auth:"), errorStyle.Render("token invalid or expired"))
cli.Blank()
return nil
}
cli.Blank()
cli.Success(fmt.Sprintf("Authenticated to %s", client.URL()))
cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName))
cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email))
if user.IsAdmin {
cli.Print(" %s %s\n", dimStyle.Render("Role:"), infoStyle.Render("admin"))
}
cli.Blank()
return nil
}

View file

@ -0,0 +1,106 @@
package forge
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
fg "github.com/host-uk/core/pkg/forge"
)
// Config command flags.
var (
configURL string
configToken string
configTest bool
)
// addConfigCommand adds the 'config' subcommand for Forgejo connection setup.
func addConfigCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "config",
Short: "Configure Forgejo connection",
Long: "Set the Forgejo instance URL and API token, or test the current connection.",
RunE: func(cmd *cli.Command, args []string) error {
return runConfig()
},
}
cmd.Flags().StringVar(&configURL, "url", "", "Forgejo instance URL")
cmd.Flags().StringVar(&configToken, "token", "", "Forgejo API token")
cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection")
parent.AddCommand(cmd)
}
func runConfig() error {
// If setting values, save them first
if configURL != "" || configToken != "" {
if err := fg.SaveConfig(configURL, configToken); err != nil {
return err
}
if configURL != "" {
cli.Success(fmt.Sprintf("Forgejo URL set to %s", configURL))
}
if configToken != "" {
cli.Success("Forgejo token saved")
}
}
// If testing, verify the connection
if configTest {
return runConfigTest()
}
// If no flags, show current config
if configURL == "" && configToken == "" && !configTest {
return showConfig()
}
return nil
}
func showConfig() error {
url, token, err := fg.ResolveConfig("", "")
if err != nil {
return err
}
cli.Blank()
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url))
if token != "" {
masked := token
if len(token) >= 8 {
masked = token[:4] + "..." + token[len(token)-4:]
}
cli.Print(" %s %s\n", dimStyle.Render("Token:"), valueStyle.Render(masked))
} else {
cli.Print(" %s %s\n", dimStyle.Render("Token:"), warningStyle.Render("not set"))
}
cli.Blank()
return nil
}
func runConfigTest() error {
client, err := fg.NewFromConfig(configURL, configToken)
if err != nil {
return err
}
user, _, err := client.API().GetMyUserInfo()
if err != nil {
cli.Error("Connection failed")
return cli.WrapVerb(err, "connect to", "Forgejo")
}
cli.Blank()
cli.Success(fmt.Sprintf("Connected to %s", client.URL()))
cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName))
cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email))
cli.Blank()
return nil
}

View file

@ -0,0 +1,53 @@
// Package forge provides CLI commands for managing a Forgejo instance.
//
// Commands:
// - config: Configure Forgejo connection (URL, token)
// - status: Show instance status and version
// - repos: List repositories
// - issues: List and create issues
// - prs: List pull requests
// - migrate: Migrate repos from external services
// - sync: Sync GitHub repos to Forgejo upstream branches
// - orgs: List organisations
// - labels: List and create labels
package forge
import (
"github.com/host-uk/core/pkg/cli"
)
func init() {
cli.RegisterCommands(AddForgeCommands)
}
// Style aliases from shared package.
var (
successStyle = cli.SuccessStyle
errorStyle = cli.ErrorStyle
warningStyle = cli.WarningStyle
dimStyle = cli.DimStyle
valueStyle = cli.ValueStyle
repoStyle = cli.RepoStyle
numberStyle = cli.NumberStyle
infoStyle = cli.InfoStyle
)
// AddForgeCommands registers the 'forge' command and all subcommands.
func AddForgeCommands(root *cli.Command) {
forgeCmd := &cli.Command{
Use: "forge",
Short: "Forgejo instance management",
Long: "Manage repositories, issues, pull requests, and organisations on your Forgejo instance.",
}
root.AddCommand(forgeCmd)
addConfigCommand(forgeCmd)
addStatusCommand(forgeCmd)
addReposCommand(forgeCmd)
addIssuesCommand(forgeCmd)
addPRsCommand(forgeCmd)
addMigrateCommand(forgeCmd)
addSyncCommand(forgeCmd)
addOrgsCommand(forgeCmd)
addLabelsCommand(forgeCmd)
}

View file

@ -0,0 +1,200 @@
package forge
import (
"fmt"
"strings"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/host-uk/core/pkg/cli"
fg "github.com/host-uk/core/pkg/forge"
)
// Issues command flags.
var (
issuesState string
issuesTitle string
issuesBody string
)
// addIssuesCommand adds the 'issues' subcommand for listing and creating issues.
func addIssuesCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "issues [owner/repo]",
Short: "List and manage issues",
Long: "List issues for a repository, or list all open issues across all your repos.",
Args: cli.MaximumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
if len(args) == 0 {
return runListAllIssues()
}
owner, repo, err := splitOwnerRepo(args[0])
if err != nil {
return err
}
// If title is set, create an issue instead
if issuesTitle != "" {
return runCreateIssue(owner, repo)
}
return runListIssues(owner, repo)
},
}
cmd.Flags().StringVar(&issuesState, "state", "open", "Filter by state (open, closed, all)")
cmd.Flags().StringVar(&issuesTitle, "title", "", "Create issue with this title")
cmd.Flags().StringVar(&issuesBody, "body", "", "Issue body (used with --title)")
parent.AddCommand(cmd)
}
func runListAllIssues() error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
// Collect all repos: user repos + all org repos, deduplicated
seen := make(map[string]bool)
var allRepos []*forgejo.Repository
userRepos, err := client.ListUserRepos()
if err == nil {
for _, r := range userRepos {
if !seen[r.FullName] {
seen[r.FullName] = true
allRepos = append(allRepos, r)
}
}
}
orgs, err := client.ListMyOrgs()
if err != nil {
return err
}
for _, org := range orgs {
repos, err := client.ListOrgRepos(org.UserName)
if err != nil {
continue
}
for _, r := range repos {
if !seen[r.FullName] {
seen[r.FullName] = true
allRepos = append(allRepos, r)
}
}
}
total := 0
cli.Blank()
for _, repo := range allRepos {
if repo.OpenIssues == 0 {
continue
}
owner, name := repo.Owner.UserName, repo.Name
issues, err := client.ListIssues(owner, name, fg.ListIssuesOpts{
State: issuesState,
})
if err != nil || len(issues) == 0 {
continue
}
cli.Print(" %s %s\n", repoStyle.Render(repo.FullName), dimStyle.Render(fmt.Sprintf("(%d)", len(issues))))
for _, issue := range issues {
printForgeIssue(issue)
}
cli.Blank()
total += len(issues)
}
if total == 0 {
cli.Text(fmt.Sprintf("No %s issues found.", issuesState))
} else {
cli.Print(" %s\n", dimStyle.Render(fmt.Sprintf("%d %s issues total", total, issuesState)))
}
cli.Blank()
return nil
}
func runListIssues(owner, repo string) error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
issues, err := client.ListIssues(owner, repo, fg.ListIssuesOpts{
State: issuesState,
})
if err != nil {
return err
}
if len(issues) == 0 {
cli.Text(fmt.Sprintf("No %s issues in %s/%s.", issuesState, owner, repo))
return nil
}
cli.Blank()
cli.Print(" %s\n\n", fmt.Sprintf("%d %s issues in %s/%s", len(issues), issuesState, owner, repo))
for _, issue := range issues {
printForgeIssue(issue)
}
return nil
}
func runCreateIssue(owner, repo string) error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
issue, err := client.CreateIssue(owner, repo, forgejo.CreateIssueOption{
Title: issuesTitle,
Body: issuesBody,
})
if err != nil {
return err
}
cli.Blank()
cli.Success(fmt.Sprintf("Created issue #%d: %s", issue.Index, issue.Title))
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(issue.HTMLURL))
cli.Blank()
return nil
}
func printForgeIssue(issue *forgejo.Issue) {
num := numberStyle.Render(fmt.Sprintf("#%d", issue.Index))
title := valueStyle.Render(cli.Truncate(issue.Title, 60))
line := fmt.Sprintf(" %s %s", num, title)
// Add labels
if len(issue.Labels) > 0 {
var labels []string
for _, l := range issue.Labels {
labels = append(labels, l.Name)
}
line += " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]")
}
// Add assignees
if len(issue.Assignees) > 0 {
var assignees []string
for _, a := range issue.Assignees {
assignees = append(assignees, "@"+a.UserName)
}
line += " " + infoStyle.Render(strings.Join(assignees, ", "))
}
cli.Text(line)
}

View file

@ -0,0 +1,120 @@
package forge
import (
"fmt"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/host-uk/core/pkg/cli"
fg "github.com/host-uk/core/pkg/forge"
)
// Labels command flags.
var (
labelsCreate string
labelsColor string
labelsRepo string
)
// addLabelsCommand adds the 'labels' subcommand for listing and creating labels.
func addLabelsCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "labels <org>",
Short: "List and manage labels",
Long: `List labels from an organisation's repos, or create a new label.
Labels are listed from the first repo in the organisation. Use --repo to target a specific repo.
Examples:
core forge labels Private-Host-UK
core forge labels Private-Host-UK --create "feature" --color "00aabb"
core forge labels Private-Host-UK --repo Enchantrix`,
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
if labelsCreate != "" {
return runCreateLabel(args[0])
}
return runListLabels(args[0])
},
}
cmd.Flags().StringVar(&labelsCreate, "create", "", "Create a label with this name")
cmd.Flags().StringVar(&labelsColor, "color", "0075ca", "Label colour (hex, e.g. 00aabb)")
cmd.Flags().StringVar(&labelsRepo, "repo", "", "Target a specific repo (default: first org repo)")
parent.AddCommand(cmd)
}
func runListLabels(org string) error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
var labels []*forgejo.Label
if labelsRepo != "" {
labels, err = client.ListRepoLabels(org, labelsRepo)
} else {
labels, err = client.ListOrgLabels(org)
}
if err != nil {
return err
}
if len(labels) == 0 {
cli.Text("No labels found.")
return nil
}
cli.Blank()
cli.Print(" %s\n\n", fmt.Sprintf("%d labels", len(labels)))
table := cli.NewTable("Name", "Color", "Description")
for _, l := range labels {
table.AddRow(
warningStyle.Render(l.Name),
dimStyle.Render("#"+l.Color),
cli.Truncate(l.Description, 50),
)
}
table.Render()
return nil
}
func runCreateLabel(org string) error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
// Determine target repo
repo := labelsRepo
if repo == "" {
repos, err := client.ListOrgRepos(org)
if err != nil {
return err
}
if len(repos) == 0 {
return cli.Err("no repos in org %s to create label on", org)
}
repo = repos[0].Name
org = repos[0].Owner.UserName
}
label, err := client.CreateRepoLabel(org, repo, forgejo.CreateLabelOption{
Name: labelsCreate,
Color: "#" + labelsColor,
})
if err != nil {
return err
}
cli.Blank()
cli.Success(fmt.Sprintf("Created label %q on %s/%s", label.Name, org, repo))
cli.Blank()
return nil
}

View file

@ -0,0 +1,121 @@
package forge
import (
"fmt"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/host-uk/core/pkg/cli"
fg "github.com/host-uk/core/pkg/forge"
)
// Migrate command flags.
var (
migrateOrg string
migrateService string
migrateToken string
migrateMirror bool
)
// addMigrateCommand adds the 'migrate' subcommand for importing repos from external services.
func addMigrateCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "migrate <clone-url>",
Short: "Migrate a repo from an external service",
Long: `Migrate a repository from GitHub, GitLab, Gitea, or other services into Forgejo.
Unlike a simple mirror, migration imports issues, labels, pull requests, releases, and more.
Examples:
core forge migrate https://github.com/owner/repo --org MyOrg --service github
core forge migrate https://gitea.example.com/owner/repo --service gitea --token TOKEN`,
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runMigrate(args[0])
},
}
cmd.Flags().StringVar(&migrateOrg, "org", "", "Forgejo organisation to migrate into (default: your user account)")
cmd.Flags().StringVar(&migrateService, "service", "github", "Source service type (github, gitlab, gitea, forgejo, gogs, git)")
cmd.Flags().StringVar(&migrateToken, "token", "", "Auth token for the source service")
cmd.Flags().BoolVar(&migrateMirror, "mirror", false, "Set up as a mirror (periodic sync)")
parent.AddCommand(cmd)
}
func runMigrate(cloneURL string) error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
// Determine target owner on Forgejo
targetOwner := migrateOrg
if targetOwner == "" {
user, _, err := client.API().GetMyUserInfo()
if err != nil {
return cli.WrapVerb(err, "get", "current user")
}
targetOwner = user.UserName
}
// Extract repo name from clone URL
repoName := extractRepoName(cloneURL)
if repoName == "" {
return cli.Err("could not extract repo name from URL: %s", cloneURL)
}
// Map service flag to SDK type
service := mapServiceType(migrateService)
cli.Print(" Migrating %s -> %s/%s on Forgejo...\n", cloneURL, targetOwner, repoName)
opts := forgejo.MigrateRepoOption{
RepoName: repoName,
RepoOwner: targetOwner,
CloneAddr: cloneURL,
Service: service,
Mirror: migrateMirror,
AuthToken: migrateToken,
Issues: true,
Labels: true,
PullRequests: true,
Releases: true,
Milestones: true,
Wiki: true,
Description: "Migrated from " + cloneURL,
}
repo, err := client.MigrateRepo(opts)
if err != nil {
return err
}
cli.Blank()
cli.Success(fmt.Sprintf("Migration complete: %s", repo.FullName))
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(repo.HTMLURL))
cli.Print(" %s %s\n", dimStyle.Render("Clone:"), valueStyle.Render(repo.CloneURL))
if migrateMirror {
cli.Print(" %s %s\n", dimStyle.Render("Type:"), dimStyle.Render("mirror (periodic sync)"))
}
cli.Blank()
return nil
}
func mapServiceType(s string) forgejo.GitServiceType {
switch s {
case "github":
return forgejo.GitServiceGithub
case "gitlab":
return forgejo.GitServiceGitlab
case "gitea":
return forgejo.GitServiceGitea
case "forgejo":
return forgejo.GitServiceForgejo
case "gogs":
return forgejo.GitServiceGogs
default:
return forgejo.GitServicePlain
}
}

View file

@ -0,0 +1,66 @@
package forge
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
fg "github.com/host-uk/core/pkg/forge"
)
// addOrgsCommand adds the 'orgs' subcommand for listing organisations.
func addOrgsCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "orgs",
Short: "List organisations",
Long: "List all organisations the authenticated user belongs to.",
RunE: func(cmd *cli.Command, args []string) error {
return runOrgs()
},
}
parent.AddCommand(cmd)
}
func runOrgs() error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
orgs, err := client.ListMyOrgs()
if err != nil {
return err
}
if len(orgs) == 0 {
cli.Text("No organisations found.")
return nil
}
cli.Blank()
cli.Print(" %s\n\n", fmt.Sprintf("%d organisations", len(orgs)))
table := cli.NewTable("Name", "Visibility", "Description")
for _, org := range orgs {
visibility := successStyle.Render(org.Visibility)
if org.Visibility == "private" {
visibility = warningStyle.Render(org.Visibility)
}
desc := cli.Truncate(org.Description, 50)
if desc == "" {
desc = dimStyle.Render("-")
}
table.AddRow(
repoStyle.Render(org.UserName),
visibility,
desc,
)
}
table.Render()
return nil
}

View file

@ -0,0 +1,98 @@
package forge
import (
"fmt"
"strings"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/host-uk/core/pkg/cli"
fg "github.com/host-uk/core/pkg/forge"
)
// PRs command flags.
var (
prsState string
)
// addPRsCommand adds the 'prs' subcommand for listing pull requests.
func addPRsCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "prs <owner/repo>",
Short: "List pull requests",
Long: "List pull requests for a repository.",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
owner, repo, err := splitOwnerRepo(args[0])
if err != nil {
return err
}
return runListPRs(owner, repo)
},
}
cmd.Flags().StringVar(&prsState, "state", "open", "Filter by state (open, closed, all)")
parent.AddCommand(cmd)
}
func runListPRs(owner, repo string) error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
prs, err := client.ListPullRequests(owner, repo, prsState)
if err != nil {
return err
}
if len(prs) == 0 {
cli.Text(fmt.Sprintf("No %s pull requests in %s/%s.", prsState, owner, repo))
return nil
}
cli.Blank()
cli.Print(" %s\n\n", fmt.Sprintf("%d %s pull requests in %s/%s", len(prs), prsState, owner, repo))
for _, pr := range prs {
printForgePR(pr)
}
return nil
}
func printForgePR(pr *forgejo.PullRequest) {
num := numberStyle.Render(fmt.Sprintf("#%d", pr.Index))
title := valueStyle.Render(cli.Truncate(pr.Title, 50))
var author string
if pr.Poster != nil {
author = infoStyle.Render("@" + pr.Poster.UserName)
}
// Branch info
branch := dimStyle.Render(pr.Head.Ref + " -> " + pr.Base.Ref)
// Merge status
var status string
if pr.HasMerged {
status = successStyle.Render("merged")
} else if pr.State == forgejo.StateClosed {
status = errorStyle.Render("closed")
} else {
status = warningStyle.Render("open")
}
// Labels
var labelStr string
if len(pr.Labels) > 0 {
var labels []string
for _, l := range pr.Labels {
labels = append(labels, l.Name)
}
labelStr = " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]")
}
cli.Print(" %s %s %s %s %s%s\n", num, title, author, status, branch, labelStr)
}

View file

@ -0,0 +1,94 @@
package forge
import (
"fmt"
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
"github.com/host-uk/core/pkg/cli"
fg "github.com/host-uk/core/pkg/forge"
)
// Repos command flags.
var (
reposOrg string
reposMirrors bool
)
// addReposCommand adds the 'repos' subcommand for listing repositories.
func addReposCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "repos",
Short: "List repositories",
Long: "List repositories from your Forgejo instance, optionally filtered by organisation or mirror status.",
RunE: func(cmd *cli.Command, args []string) error {
return runRepos()
},
}
cmd.Flags().StringVar(&reposOrg, "org", "", "Filter by organisation")
cmd.Flags().BoolVar(&reposMirrors, "mirrors", false, "Show only mirror repositories")
parent.AddCommand(cmd)
}
func runRepos() error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
var repos []*forgejo.Repository
if reposOrg != "" {
repos, err = client.ListOrgRepos(reposOrg)
} else {
repos, err = client.ListUserRepos()
}
if err != nil {
return err
}
// Filter mirrors if requested
if reposMirrors {
var filtered []*forgejo.Repository
for _, r := range repos {
if r.Mirror {
filtered = append(filtered, r)
}
}
repos = filtered
}
if len(repos) == 0 {
cli.Text("No repositories found.")
return nil
}
// Build table
table := cli.NewTable("Name", "Type", "Visibility", "Stars")
for _, r := range repos {
repoType := "source"
if r.Mirror {
repoType = "mirror"
}
visibility := successStyle.Render("public")
if r.Private {
visibility = warningStyle.Render("private")
}
table.AddRow(
repoStyle.Render(r.FullName),
dimStyle.Render(repoType),
visibility,
fmt.Sprintf("%d", r.Stars),
)
}
cli.Blank()
cli.Print(" %s\n\n", fmt.Sprintf("%d repositories", len(repos)))
table.Render()
return nil
}

View file

@ -0,0 +1,63 @@
package forge
import (
"fmt"
"github.com/host-uk/core/pkg/cli"
fg "github.com/host-uk/core/pkg/forge"
)
// addStatusCommand adds the 'status' subcommand for instance info.
func addStatusCommand(parent *cli.Command) {
cmd := &cli.Command{
Use: "status",
Short: "Show Forgejo instance status",
Long: "Display Forgejo instance version, authenticated user, and summary counts.",
RunE: func(cmd *cli.Command, args []string) error {
return runStatus()
},
}
parent.AddCommand(cmd)
}
func runStatus() error {
client, err := fg.NewFromConfig("", "")
if err != nil {
return err
}
// Get server version
ver, _, err := client.API().ServerVersion()
if err != nil {
return cli.WrapVerb(err, "get", "server version")
}
// Get authenticated user
user, _, err := client.API().GetMyUserInfo()
if err != nil {
return cli.WrapVerb(err, "get", "user info")
}
// Get org count
orgs, err := client.ListMyOrgs()
if err != nil {
return cli.WrapVerb(err, "list", "organisations")
}
// Get repo count
repos, err := client.ListUserRepos()
if err != nil {
return cli.WrapVerb(err, "list", "repositories")
}
cli.Blank()
cli.Print(" %s %s\n", dimStyle.Render("Instance:"), valueStyle.Render(client.URL()))
cli.Print(" %s %s\n", dimStyle.Render("Version:"), valueStyle.Render(ver))
cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName))
cli.Print(" %s %s\n", dimStyle.Render("Orgs:"), numberStyle.Render(fmt.Sprintf("%d", len(orgs))))
cli.Print(" %s %s\n", dimStyle.Render("Repos:"), numberStyle.Render(fmt.Sprintf("%d", len(repos))))
cli.Blank()
return nil
}

Some files were not shown because too many files have changed in this diff Show more