Compare commits

..

80 commits

Author SHA1 Message Date
Snider
48d385279b Merge branch 'feat/ml-integration' into dev
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
# Conflicts:
#	.gh-actions/ISSUE_TEMPLATE/config.yml
#	.gh-actions/workflows/alpha-release-manual.yml
#	.gh-actions/workflows/alpha-release-push.yml
#	.gh-actions/workflows/alpha-release.yml
#	.gh-actions/workflows/bugseti-release.yml
#	.gh-actions/workflows/ci-manual.yml
#	.gh-actions/workflows/ci-pull-request.yml
#	.gh-actions/workflows/ci-push.yml
#	.gh-actions/workflows/ci.yml
#	.gh-actions/workflows/coverage-manual.yml
#	.gh-actions/workflows/coverage-pull-request.yml
#	.gh-actions/workflows/coverage-push.yml
#	.gh-actions/workflows/coverage.yml
#	.gh-actions/workflows/release.yml
#	cmd/bugseti/go.mod
#	cmd/bugseti/workspace.go
#	go.sum
#	internal/bugseti/submit.go
#	internal/bugseti/updater/go.mod
#	internal/cmd/ml/cmd_ml.go
#	internal/core-ide/go.mod
#	internal/variants/full.go
#	pkg/ml/db.go
2026-02-16 06:13:40 +00:00
Snider
4dcd168cd4 feat: update import paths to use new forge.lthn.ai domain 2026-02-16 06:04:53 +00:00
Claude
1b23082e25 feat(ethics-ab): LEK-1 ethics kernel A/B testing and LoRA POC
Five-phase ethics kernel testing across 4 local models (Gemma 3 12B,
Mistral 7B, DeepSeek V2 16B, Qwen 2.5 7B) proving that Google's
alignment training creates persistent ethical reasoning pathways in
Gemma that survive distillation.

- Phase 1: LEK-1 signed vs unsigned (Gemma 8.8/10 differential)
- Phase 2: Three-way test (unsigned vs LEK-1 vs Axioms of Life)
- Phase 3: Double-signed/sandwich signing mode comparison
- Phase 4: Multilingual filter mapping (EN/RU/CN bypass vectors)
- Phase 5: Hypnos POC training data + MLX LoRA on M3 Ultra

Key findings: sandwich signing optimal for training, DeepSeek CCP
alignment is weight-level (no prompt override), Russian language
bypasses DeepSeek content filters. LoRA POC mechanism confirmed
with 40 examples — needs 200+ for stable generalisation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 06:04:53 +00:00
Claude
da81534897
feat: integrate lab dashboard as core lab serve
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Port the standalone lab dashboard (lab.lthn.io) into the core CLI as
pkg/lab/ with collectors, handlers, and HTML templates. The dashboard
monitors machines, Docker containers, Forgejo, HuggingFace models,
training runs, and InfluxDB metrics with SSE live updates.

New command: core lab serve --bind :8080

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 04:34:29 +00:00
Claude
a290ab31e9
feat: port 11 LEM data management commands into core ml
Ports all remaining LEM pipeline commands from pkg/lem into core ml,
eliminating the standalone LEM CLI dependency. Each command is split
into reusable business logic (pkg/ml/) and a thin cobra wrapper
(internal/cmd/ml/).

New commands: query, inventory, metrics, ingest, normalize, seed-influx,
consolidate, import-all, approve, publish, coverage.

Adds Path(), Exec(), QueryRowScan() convenience methods to DB type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 04:02:28 +00:00
Claude
d2bb19c6bd
feat: add Metal memory budget monitoring after each request
Tracks model size at load time and checks Metal active memory after
each generation. If usage exceeds 3× model size, forces double GC
and cache clear as a safety net.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:52:26 +00:00
Claude
298c8d9cd8
fix: remove Go-side array ref tracking, rely on MLX-C refcounting
The Go wrapper was tracking inter-array references via desc.inputs,
creating chains that kept all intermediate arrays alive across requests.
After 3-4 requests, Metal memory grew to 170GB+ and macOS killed the
process.

Fix: remove desc.inputs/numRefs entirely. MLX-C has its own internal
reference counting — when Go GC finalizes an Array wrapper, it calls
mlx_array_free which decrements the C-side refcount. If the C-side
count reaches 0, Metal memory is freed. Go GC + MLX-C refcounting
together handle all lifecycle management correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:49:35 +00:00
Claude
a27a31faad
fix: add GC-based memory management for MLX array handles
Go GC cannot see Metal/C memory pressure, so intermediate arrays from
each forward pass accumulated without bound, causing OOM kills after
3-4 requests. Fix: runtime.SetFinalizer on every Array releases C
handles when GC collects them, and runtime.GC() is forced every 4
tokens during generation. Also adds SetMemoryLimit(24GB) as a hard
Metal ceiling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:42:28 +00:00
Claude
478bbdd44c
fix: add Metal cache management to prevent memory growth
- Add ClearCache() wrapping mlx_clear_cache
- Clear Metal allocator cache every 8 tokens during generation
- Set 16GB cache limit on backend init
- Prevents GPU memory from growing unbounded during inference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:27:10 +00:00
Claude
b6fbb88bfb
fix: correct SDPA mask mode and slice logits to last position 2026-02-16 02:25:23 +00:00
Claude
70c32135d0
fix: use affine quantization mode and infer head_dim from weights 2026-02-16 02:19:33 +00:00
Claude
ef22946f35
debug: add shape logging and stderr error handler for inference debugging 2026-02-16 02:16:30 +00:00
Claude
c8e66918c3
feat: support quantized inference (4-bit) for Gemma 3
- Add QuantizedLinear with QuantizedMatmul for packed uint32 weights
- Add quantized Embedding with Dequantize before lookup
- Parse quantization config (group_size, bits) from config.json
- Detect .scales/.biases weight tensors and auto-select quantized path
- Add Dequantize op wrapping mlx_dequantize
- Add safety guard to KVCache.Update for malformed shapes
- Handle tied embeddings with quantization (AsLinear helper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:12:31 +00:00
Claude
1ce4f6b251
fix: handle both string and array merge formats in tokenizer
Gemma 3 tokenizer.json uses [["a","b"],...] format for merges
instead of the ["a b",...] format. Support both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:02:55 +00:00
Claude
f8d8bd6556
feat: use native MLX backend when --model-path is set on Apple Silicon
Build-tagged backend selection: MLX on darwin/arm64/mlx, HTTP elsewhere.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:01:52 +00:00
Claude
6f2d9f8de4
feat: handle nested text_config and language_model weight prefix
Supports both multimodal (Gemma3ForConditionalGeneration) and
text-only configs. Resolves weights with language_model. prefix
fallback. Computes head_dim from hidden_size when missing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 02:00:40 +00:00
Claude
c8ec0f9e49
chore: target macOS 26.0, fix duplicate -lstdc++ linker warning
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:55:49 +00:00
Claude
290e3416ce
fix: remove unused vars in TopP sampler placeholder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:54:29 +00:00
Claude
f89e80732a
fix: resolve CGo type conflict in error handler
Use pure C callback instead of //export to avoid const char* vs
GoString type mismatch in cgo-generated headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:53:36 +00:00
Claude
065b42a0be
fix: correct 20 mlx-c API mismatches for v0.4.1
- Use _axis/_axes variants for softmax, argmax, topk, sum, mean, squeeze,
  concatenate, argpartition
- Fix size_t vs int for count parameters throughout
- Fix int64_t strides in as_strided
- Add mlx_optional_int + mode param to quantized_matmul
- Use mlx_array_new() for null arrays (freqs, key, mask, sinks)
- Fix expand_dims to single-axis signature
- Fix compile callback signature (size_t index)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:52:29 +00:00
Claude
004c5c9eb9
fix: correct mlx_closure_new_func_payload signature for mlx-c v0.4.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:41:07 +00:00
Claude
9d664c055a
feat: add native MLX backend for Apple Silicon inference (pkg/mlx)
CGo wrapper for mlx-c providing zero-Python Metal GPU inference.
Includes Gemma 3 model architecture, BPE tokenizer, KV cache,
composable sampling, and OpenAI-compatible serve command.

Build-tagged (darwin && arm64 && mlx) with stubs for cross-platform.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:19:04 +00:00
Claude
ca8c155d85
feat: add ML inference, scoring, and training pipeline (pkg/ml)
Port LEM scoring/training pipeline into CoreGo as pkg/ml with:
- Inference abstraction with HTTP, llama-server, and Ollama backends
- 3-tier scoring engine (heuristic, exact, LLM judge)
- Capability and content probes for model evaluation
- GGUF/safetensors format converters, MLX to PEFT adapter conversion
- DuckDB integration for training data pipeline
- InfluxDB metrics for lab dashboard
- Training data export (JSONL + Parquet)
- Expansion generation pipeline with distributed workers
- 10 CLI commands under 'core ml' (score, probe, export, expand, status, gguf, convert, agent, worker)
- 5 MCP tools (ml_generate, ml_score, ml_probe, ml_status, ml_backends)

All 37 ML tests passing. Binary builds at 138MB with all commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:34:53 +00:00
Claude
01d9aa1b73
refactor: rename module from github.com/host-uk/core to forge.lthn.ai/core/cli
Move module identity to our own Forgejo instance. All import paths
updated across 434 Go files, sub-module go.mod files, and go.work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:30:41 +00:00
b8b144bec0 Merge pull request 'Merge new branch into dev — 382 commits of platform work' (#161) from merge/new-into-dev into dev
Reviewed-on: https://forge.lthn.io/host-uk/core/pulls/161
Reviewed-by: Snider <snider@lethean.io>
2026-02-16 00:26:16 +00:00
Claude
f2272e4f6f
fix: restore CLI entry point and register all commands
The main.go was removed when Wails3 apps were added to cmd/, breaking
`go build .` for the core CLI. Restore it and update variants/full.go
to include daemon, forge, mcpcmd, prod, and session commands. Drop gitea
(superseded by forge) and unifi (unused).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:24:00 +00:00
Claude
5fd7705580
feat(mcp): add ML tools subsystem and fix MCP service extension points
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Add 5 ML MCP tools (ml_generate, ml_score, ml_probe, ml_status,
ml_backends) as a Subsystem. Fix pre-existing gaps: add Subsystems(),
Shutdown(), WithProcessService, WithWSHub, WSHub(), ProcessService()
methods, and subsystem registration loop in New().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:58:16 +00:00
Claude
3dbb5988a8
feat(ml): add CoreGo service wrapper and CLI commands (Tasks 6-7)
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Service registration with DI lifecycle, typed options, and backend
management. Ten CLI subcommands under `core ml` for scoring, probing,
export, expansion, status, GGUF/PEFT conversion, agent, and worker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:52:46 +00:00
Claude
fcd1758b7d
feat(ml): add format converters, data pipeline, and scoring agent
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Port remaining lem-repo components into pkg/ml/:
- convert.go: safetensors reader/writer, MLX→PEFT converter
- gguf.go: GGUF v3 writer, MLX→GGUF LoRA converter
- export.go: training data JSONL export with split/filter
- parquet.go: Parquet export with snappy compression
- db.go: DuckDB wrapper for golden set and expansion prompts
- influx.go: InfluxDB v3 client for metrics/status
- ollama.go: Ollama model management (create/delete with adapters)
- status.go: training and generation status display
- expand.go: expansion generation pipeline (Backend interface)
- agent.go: scoring agent with probe running and InfluxDB push
- worker.go: distributed worker for LEM API task processing

Adds parquet-go and go-duckdb dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:46:24 +00:00
Claude
3fdc3f3086
refactor: rename module from github.com/host-uk/core to forge.lthn.ai/core/cli
Some checks are pending
Security Scan / Go Vulnerability Check (push) Waiting to run
Security Scan / Secret Detection (push) Waiting to run
Security Scan / Dependency & Config Scan (push) Waiting to run
Move Go module path to production Forgejo instance.
Updates all imports, go.mod, go.sum, docs, and CI configs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:35:00 +00:00
Claude
6f52e4e3ae
feat(ml): add ML inference and scoring engine from lem-repo
Port LEM scoring pipeline into CoreGo pkg/ml/:
- Backend interface abstracting HTTP, llama-server, and future backends
- HTTPBackend for OpenAI-compatible APIs with retry logic
- LlamaBackend managing llama-server via pkg/process
- Scoring engine with heuristic, semantic, content, and exact suites
- Judge for LLM-based multi-dimensional scoring
- 23 capability probes (math, logic, reasoning, code)
- 6 sovereignty content probes
- GGUF/PEFT format helpers, safetensors reader
- 37 tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:34:54 +00:00
Virgil
1f43073f57 Merge pull request 'feat(bugseti): add HubService for portal coordination' (#160) from feat/bugseti-hub-service into new 2026-02-13 21:45:01 +00:00
Virgil
1facdd602f Merge pull request 'feat(bugseti): migrate from GitHub gh CLI to Forgejo SDK' (#159) from feat/bugseti-forgejo-migration into new 2026-02-13 21:44:43 +00:00
Snider
c72f35bd3f feat(bugseti): wire HubService into main.go with auto-registration
Add HubService to the Wails service list and attempt hub registration
at startup when hubUrl is configured. Drains any pending operations
queued from previous sessions.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:36:59 +00:00
Snider
2a8b5c207f feat(bugseti): implement pending operations queue with disk persistence
Replace no-op stubs with real implementations for queueOp, drainPendingOps,
savePendingOps, and loadPendingOps. Operations are persisted to hub_pending.json
and replayed on next hub connection — 5xx/transport errors are retried, 4xx
responses are dropped as stale. Adds PendingCount() for queue inspection.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:36:08 +00:00
Snider
5d0b6c3a71 feat(bugseti): add hub read operations
Add IsIssueClaimed, ListClaims, GetLeaderboard, and GetGlobalStats
methods. IsIssueClaimed returns (nil, nil) on 404 for unclaimed
issues. GetLeaderboard returns entries and total participant count.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:33:11 +00:00
Snider
d583a074f7 feat(bugseti): add hub write operations
Add Register, Heartbeat, ClaimIssue, UpdateStatus, ReleaseClaim,
and SyncStats methods for hub coordination. ClaimIssue returns
ConflictError on 409 and calls drainPendingOps before mutating.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:32:57 +00:00
Snider
f963a45d9f feat(bugseti): add AutoRegister via Forge token exchange
Exchange a Forge API token for a hub API key by POSTing to
/api/bugseti/auth/forge. Skips if hub token already cached.
Adds drainPendingOps() stub for future Task 7 use.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:31:23 +00:00
Snider
74bb62fda8 feat(bugseti): add HubService HTTP request helpers
Add doRequest() and doJSON() methods for hub API communication. doRequest
builds full URLs, sets bearer auth and JSON headers, tracks connected
state. doJSON handles status codes: 401 unauthorised, 409 ConflictError,
404 NotFoundError, and generic errors for other 4xx/5xx responses.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:25:28 +00:00
Snider
f85bba5332 feat(bugseti): add HubService types and constructor
Introduce HubService struct with types for hub coordination: PendingOp,
HubClaim, LeaderboardEntry, GlobalStats, ConflictError, NotFoundError.
Constructor generates a crypto/rand client ID when none exists. Includes
no-op loadPendingOps/savePendingOps stubs for future persistence.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:24:38 +00:00
Snider
0af6407666 feat(bugseti): add hub coordination config fields and accessors
Add HubURL, HubToken, ClientID, and ClientName fields to Config struct
for agentic portal integration. Include getter/setter methods following
the existing pattern (SetForgeURL, SetForgeToken also added).

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:23:02 +00:00
Snider
1f3e6ba4ab docs: add BugSETI HubService implementation plan
10 tasks covering Go client + Laravel auth endpoint.
TDD approach with httptest mocks.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:17:59 +00:00
Snider
39d6dccbf8 docs: add BugSETI HubService design doc
Thin HTTP client for portal coordination API — issue claiming,
stats sync, leaderboard, auto-register via forge token.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 21:12:53 +00:00
Snider
2979816d83 feat(bugseti): migrate from GitHub gh CLI to Forgejo SDK
Replace all exec.Command("gh", ...) calls with the existing pkg/forge
wrapper around the Forgejo Go SDK. BugSETI no longer requires the gh
CLI to be installed.

Changes:
- fetcher: use forge.ListIssues/GetIssue instead of gh issue list/view
- submit: use forge.ForkRepo/CreatePullRequest instead of gh pr create
- seeder: use git clone with forge URL + token auth instead of gh clone
- ghcheck: CheckForge() returns *forge.Client via forge.NewFromConfig()
- config: add ForgeURL/ForgeToken fields (GitHubToken kept for migration)
- pkg/forge: add Token(), GetCurrentUser(), ForkRepo(), CreatePullRequest(),
  ListIssueComments(), and label filtering to ListIssuesOpts

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 20:55:39 +00:00
Virgil
f0595f6858 Merge pull request 'chore: migrate forge.lthn.ai → forge.lthn.io' (#158) from chore/forge-domain-migration into new 2026-02-13 19:22:41 +00:00
Snider
bdbfc5e59e chore: migrate forge.lthn.ai → forge.lthn.io
Update Forgejo domain references in CI pipeline, vanity import
tool, and core-app codex prompt.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-13 19:13:40 +00:00
Charon (snider-linux)
b779c5ece0 Merge pull request 'fix(bugseti): workspace TTL sweeper and configurable limits (#54)' (#157) from fix/54-workspace-ttl-cleanup into new 2026-02-12 20:35:24 +00:00
Charon (snider-linux)
1e0bff0a2e Merge pull request 'fix(security): path traversal in journal logging (#46)' (#156) from fix/issue-46-path-traversal into new 2026-02-12 20:35:22 +00:00
Charon (snider-linux)
e9df62b04e Merge pull request 'fix(bugseti): race condition in QueueService.load() - missing mutex during init' (#155) from fix/51-queue-load-race-condition into new 2026-02-12 20:35:15 +00:00
Charon (snider-linux)
03d7a7dc4e Merge pull request 'fix(security): move Gemini API key from URL to header (#47)' (#154) from fix/gemini-api-key-in-url-47 into new 2026-02-12 20:35:02 +00:00
Claude
6abe90c8cb
fix(bugseti): add background TTL sweeper and configurable workspace limits
The workspace map previously only cleaned up during Capture() calls,
meaning stale entries would accumulate indefinitely if no new captures
occurred. This adds:

- Background sweeper goroutine (Start/Stop lifecycle) that runs every 5
  minutes to evict expired workspaces
- Configurable MaxWorkspaces and WorkspaceTTLMinutes in Config (defaults:
  100 entries, 24h TTL) replacing hardcoded constants
- cleanup() now returns eviction count for observability logging
- Nil-config fallback to safe defaults

Fixes #54

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:31:25 +00:00
Claude
b0ef3fb215
fix(security): sanitize path components in journal logging (#46)
Prevent path traversal in Journal.Append() by validating RepoOwner and
RepoName before using them in file paths. Malicious values like
"../../etc/cron.d" could previously write outside the journal baseDir.

Defence layers:
- Reject inputs containing path separators (/ or \)
- Reject ".." and "." traversal components
- Validate against safe character regex ^[a-zA-Z0-9][a-zA-Z0-9._-]*$
- Verify resolved absolute path stays within baseDir

Closes #46
CVSS 6.3 — OWASP A01:2021-Broken Access Control

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:30:47 +00:00
Claude
bcb559630e
fix(bugseti): hold mutex during entire QueueService initialization
Move shared state initialization (issues, seen) and the load() call
inside the mutex scope in NewQueueService() to eliminate the race
window where concurrent callers could observe partially initialized
state. Remove the redundant heap.Init before the lock since load()
already calls heap.Init when restoring from disk.

Add documentation to save() and load() noting they must be called
with q.mu held.

Fixes #51

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:30:22 +00:00
Claude
bde00e40f4
fix(security): move Gemini API key from URL query params to header (#47)
Pass the API key via x-goog-api-key HTTP header instead of the URL
query parameter to prevent credential leakage in proxy logs, web
server access logs, and monitoring systems.

Resolves: #47 (CVSS 5.3, OWASP A09:2021)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 20:29:09 +00:00
Charon (snider-linux)
45d8b5b7d4 Merge pull request 'feat(agentic): agent allowance system — respect model quotas and budgets' (#151) from feat/agentic-allowance-system into new 2026-02-12 20:07:11 +00:00
Charon (snider-linux)
6702d56edb Merge pull request 'feat(agentic): Forgejo integration bridge — PHP service linking platform to forges' (#150) from feat/agentic-forgejo-bridge into new 2026-02-12 20:06:58 +00:00
Charon (snider-linux)
4bc43939a6 Merge pull request 'feat(agentic): agent trust model — security wall between non-aligned agents' (#149) from feat/agentic-trust-model into new 2026-02-12 20:06:50 +00:00
Charon (snider-linux)
d2dd23697f Merge pull request 'feat(agentic): real-time dashboard — live agent activity view' (#148) from feat/agentic-dashboard into new 2026-02-12 20:03:51 +00:00
Athena
65c138e126 feat(agentic): add agent allowance system for model quotas and budgets
Implements quota enforcement for agents including daily token limits,
daily job limits, concurrent job caps, model allowlists, and global
per-model budgets. Quota recovery returns 50% for failed jobs and
100% for cancelled jobs.

Go: AllowanceService with MemoryStore, AllowanceStore interface, and
25 tests covering all enforcement paths.

Laravel: migration for 5 tables (agent_allowances, quota_usage,
model_quotas, usage_reports, repo_limits), Eloquent models,
AllowanceService, QuotaMiddleware, and REST API routes.

Closes #99

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:32:41 +00:00
Athena
740cf115b2 feat(agentic): add Forgejo integration bridge for PHP platform
Add ForgejoClient and ForgejoService to the Laravel app, providing a
clean service layer for all Forgejo REST API operations the orchestrator
needs. Supports multiple instances (forge, dev, qa) with config-driven
auto-routing, token auth, retry with circuit breaker, and pagination.

Covers issues, PRs, repos, branches, user/token management, and orgs.

Closes #98

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:16:15 +00:00
Athena
3bfaf37ab1 feat(agentic): add agent trust model with tiered access control
Implements the security wall between non-aligned agents (issue #97).

Adds pkg/trust with:
- Three trust tiers: Full (Tier 3), Verified (Tier 2), Untrusted (Tier 1)
- Agent registry with mutex-protected concurrent access
- Policy engine with capability-based access control
- Repo-scoped permissions for Tier 2 agents
- Default policies matching the spec (rate limits, approval gates, denials)
- 49 tests covering all tiers, capabilities, edge cases, and helpers

Closes #97

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:04:35 +00:00
Athena
72529a8281 feat(agentic): add real-time dashboard with Livewire components (#96)
Add a live agent activity dashboard to the Core App Laravel frontend.
Provides real-time visibility into agent fleet status, job queue,
activity feed, metrics, and human-in-the-loop actions — replacing
SSH + tail -f as the operator interface.

Dashboard panels:
- Agent Fleet: grid of agent cards with heartbeat, status, model info
- Job Queue: filterable table with cancel/retry actions
- Live Activity Feed: real-time stream with agent/type filters
- Metrics: stat cards, budget gauge, cost breakdown, throughput chart
- Human Actions: inline question answering, review gate approval

Tech: Laravel Blade + Livewire 4 + Tailwind CSS + Alpine.js + ApexCharts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:56:17 +00:00
7ce8ca717c Merge pull request 'fix(bugseti): add test coverage for SubmitService PR workflow' (#76) from Athena/core:fix/bugseti-submit-tests into new 2026-02-10 15:43:41 +00:00
37b04695d1 Merge pull request 'fix(bugseti): sanitize shell metacharacters in seeder env vars' (#71) from Athena/core:fix/bugseti-sanitize-shell-metacharacters into new 2026-02-10 15:43:37 +00:00
9fe4d5f063 Merge pull request 'fix(bugseti): update config file permissions to 0600' (#57) from fix/bugseti-config-perms into new 2026-02-10 15:43:19 +00:00
16a5ba70ef Merge pull request 'fix(bugseti): add mutex protection to seeder concurrent access' (#75) from Athena/core:fix/issue-63-seeder-mutex into new 2026-02-10 15:42:58 +00:00
88e5560086 Merge pull request 'fix(bugseti): handle silent git fetch failure in submit.go' (#74) from Athena/core:fix/bugseti-git-fetch-error-62 into new 2026-02-10 15:42:54 +00:00
b57e30ea06 Merge pull request 'fix(bugseti): add gh CLI availability check with helpful error' (#73) from Athena/core:fix/bugseti-gh-cli-check into new 2026-02-10 15:42:51 +00:00
8d3f9a73ee Merge pull request 'fix(bugseti): add comprehensive tests for FetcherService' (#72) from Athena/core:fix/bugseti-fetcher-tests into new 2026-02-10 15:42:48 +00:00
Athena
c4d59f9850 fix(bugseti): add test coverage for SubmitService PR workflow (#64)
Extract buildForkURL helper for testable fork URL construction and add
19 tests covering Submit validation, HTTPS/SSH fork URLs, PR body
generation, and ensureFork error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:42:45 +00:00
9f9e8cc044 Merge pull request 'fix(bugseti): add TTL cleanup and max size cap to workspace map' (#58) from fix/bugseti-workspace-ttl-55 into new 2026-02-10 15:42:44 +00:00
74e2614e41 Merge pull request 'fix(bugseti): acquire mutex in NewQueueService before load()' (#56) from fix/bugseti-queue-race-52 into new 2026-02-10 15:42:40 +00:00
Athena
149dc3de14 fix(bugseti): add mutex protection to seeder concurrent access
Add sync.Mutex to SeederService to protect shared state during
concurrent SeedIssue, GetWorkspaceDir, and CleanupWorkspace calls.
Extract getWorkspaceDir as lock-free helper to avoid double-locking.

Closes #63

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:37:11 +00:00
Athena
9319015219 fix(bugseti): handle silent git fetch failure in submit.go
Capture and log the error from `git fetch origin` in createBranch()
instead of silently ignoring it. Warns the user they may be proceeding
with stale data if the fetch fails.

Fixes #62

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:30:50 +00:00
Athena
25985af53c fix(bugseti): add gh CLI availability check with helpful error
Adds a startup check that verifies gh is in PATH and authenticated
before initializing services. Provides clear install/auth instructions
on failure instead of cryptic exec errors at runtime.

Closes #61

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:27:53 +00:00
Athena
796ec563ed fix(bugseti): add comprehensive tests for FetcherService (#60)
Add fetcher_test.go covering: service creation, start/pause lifecycle,
calculatePriority scoring for all label types, label query construction
with custom and default labels, gh CLI JSON parsing for both list and
single-issue endpoints, channel backpressure when issuesCh is full,
fetchAll with no repos configured, and missing binary error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:23:35 +00:00
Athena
e83e416854 fix(bugseti): sanitize shell metacharacters in seeder env vars
SanitizeEnv() only removed control characters but not shell
metacharacters. A malicious repo name could execute arbitrary commands
via environment variable injection (e.g. backticks, $(), semicolons).

Add stripShellMeta() to strip backticks, dollar signs, semicolons,
pipes, ampersands, and other shell-significant characters from values
passed to the bash seed script environment.

Fixes #59

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:18:36 +00:00
Claude (M3 Studio)
3fc04f809b fix(bugseti): add TTL cleanup and max size cap to workspace map (#55)
The workspaces map in WorkspaceService grew unboundedly. Add cleanup()
that evicts entries older than 24h and enforces a 100-entry cap by
removing oldest entries first. Called on each Capture().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:25:00 +00:00
Claude (M3 Studio)
169428a945 fix(bugseti): update config file permissions to 0600
This commit updates the file permissions for the BugSETI configuration file from 0644 to 0600, ensuring owner-only access. This addresses the security concern where the GitHub token stored in the config file was world-readable.

Fixes #53
2026-02-10 11:15:52 +00:00
Claude (M3 Studio)
440086b83a fix(bugseti): acquire mutex in NewQueueService before load()
q.load() accesses shared state (issues, seen, current) without holding
the mutex, creating a race condition. Wrap the call with q.mu.Lock().

Fixes #52

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:12:46 +00:00
1063 changed files with 210648 additions and 1318 deletions

View file

@ -1,4 +1,5 @@
# CodeRabbit Configuration # CodeRabbit Configuration
# Inherits from: https://github.com/host-uk/coderabbit/.coderabbit.yaml
# Manual trigger only: @coderabbitai review # Manual trigger only: @coderabbitai review
reviews: reviews:

32
.core/build.yaml Normal file
View file

@ -0,0 +1,32 @@
# Core CLI build configuration
# Used by: core build
version: 1
project:
name: core
description: Host UK Core CLI
main: "."
binary: core
build:
cgo: false
flags:
- -trimpath
ldflags:
- -s
- -w
- -X main.Version={{.Version}}
env: []
targets:
- os: linux
arch: amd64
- os: linux
arch: arm64
- os: darwin
arch: amd64
- os: darwin
arch: arm64
- os: windows
arch: amd64

18
.core/ci.yaml Normal file
View file

@ -0,0 +1,18 @@
# CI configuration for core CLI installation
# Used by: core setup ci
# Homebrew (macOS/Linux)
tap: host-uk/tap
formula: core
# Scoop (Windows)
scoop_bucket: https://github.com/host-uk/scoop-bucket.git
# Chocolatey (Windows)
chocolatey_pkg: core-cli
# GitHub releases (fallback for all platforms)
repository: host-uk/core
# Default version to install (use 'dev' for latest development build)
default_version: dev

121
.core/linuxkit/core-dev.yml Normal file
View file

@ -0,0 +1,121 @@
# Core Development Environment Template
# A full-featured development environment with multiple runtimes
#
# Variables:
# ${SSH_KEY} - SSH public key for access (required)
# ${MEMORY:-2048} - Memory in MB (default: 2048)
# ${CPUS:-2} - Number of CPUs (default: 2)
# ${HOSTNAME:-core-dev} - Hostname for the VM
# ${DATA_SIZE:-10G} - Size of persistent /data volume
kernel:
image: linuxkit/kernel:6.6.13
cmdline: "console=tty0 console=ttyS0"
init:
- linuxkit/init:v1.2.0
- linuxkit/runc:v1.1.12
- linuxkit/containerd:v1.7.13
- linuxkit/ca-certificates:v1.0.0
onboot:
- name: sysctl
image: linuxkit/sysctl:v1.0.0
- name: format
image: linuxkit/format:v1.0.0
- name: mount
image: linuxkit/mount:v1.0.0
command: ["/usr/bin/mountie", "/dev/sda1", "/data"]
- name: dhcpcd
image: linuxkit/dhcpcd:v1.0.0
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"]
onshutdown:
- name: shutdown
image: busybox:latest
command: ["/bin/echo", "Shutting down..."]
services:
- name: getty
image: linuxkit/getty:v1.0.0
env:
- INSECURE=true
- name: sshd
image: linuxkit/sshd:v1.2.0
binds:
- /etc/ssh/authorized_keys:/root/.ssh/authorized_keys
- name: docker
image: docker:24.0-dind
capabilities:
- all
net: host
pid: host
binds:
- /var/run:/var/run
- /data/docker:/var/lib/docker
rootfsPropagation: shared
- name: dev-tools
image: alpine:3.19
capabilities:
- all
net: host
binds:
- /data:/data
command:
- /bin/sh
- -c
- |
# Install development tools
apk add --no-cache \
git curl wget vim nano htop tmux \
build-base gcc musl-dev linux-headers \
openssh-client jq yq
# Install Go 1.22.0
wget -q https://go.dev/dl/go1.22.0.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
rm go1.22.0.linux-amd64.tar.gz
echo 'export PATH=/usr/local/go/bin:$PATH' >> /etc/profile
# Install Node.js
apk add --no-cache nodejs npm
# Install PHP
apk add --no-cache php82 php82-cli php82-curl php82-json php82-mbstring \
php82-openssl php82-pdo php82-pdo_mysql php82-pdo_pgsql php82-phar \
php82-session php82-tokenizer php82-xml php82-zip composer
# Keep container running
tail -f /dev/null
files:
- path: /etc/hostname
contents: "${HOSTNAME:-core-dev}"
- path: /etc/ssh/authorized_keys
contents: "${SSH_KEY}"
mode: "0600"
- path: /etc/profile.d/dev.sh
contents: |
export PATH=$PATH:/usr/local/go/bin
export GOPATH=/data/go
export PATH=$PATH:$GOPATH/bin
cd /data
mode: "0755"
- path: /etc/motd
contents: |
================================================
Core Development Environment
Runtimes: Go, Node.js, PHP
Tools: git, curl, vim, docker
Data directory: /data (persistent)
================================================
trust:
org:
- linuxkit
- library

View file

@ -0,0 +1,142 @@
# PHP/FrankenPHP Server Template
# A minimal production-ready PHP server with FrankenPHP and Caddy
#
# Variables:
# ${SSH_KEY} - SSH public key for management access (required)
# ${MEMORY:-512} - Memory in MB (default: 512)
# ${CPUS:-1} - Number of CPUs (default: 1)
# ${HOSTNAME:-php-server} - Hostname for the VM
# ${APP_NAME:-app} - Application name
# ${DOMAIN:-localhost} - Domain for SSL certificates
# ${PHP_MEMORY:-128M} - PHP memory limit
kernel:
image: linuxkit/kernel:6.6.13
cmdline: "console=tty0 console=ttyS0"
init:
- linuxkit/init:v1.2.0
- linuxkit/runc:v1.1.12
- linuxkit/containerd:v1.7.13
- linuxkit/ca-certificates:v1.0.0
onboot:
- name: sysctl
image: linuxkit/sysctl:v1.0.0
- name: dhcpcd
image: linuxkit/dhcpcd:v1.0.0
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"]
services:
- name: sshd
image: linuxkit/sshd:v1.2.0
binds:
- /etc/ssh/authorized_keys:/root/.ssh/authorized_keys
- name: frankenphp
image: dunglas/frankenphp:latest
capabilities:
- CAP_NET_BIND_SERVICE
net: host
binds:
- /app:/app
- /data:/data
- /etc/caddy/Caddyfile:/etc/caddy/Caddyfile
env:
- SERVER_NAME=${DOMAIN:-localhost}
- FRANKENPHP_CONFIG=/etc/caddy/Caddyfile
command:
- frankenphp
- run
- --config
- /etc/caddy/Caddyfile
- name: healthcheck
image: alpine:3.19
net: host
command:
- /bin/sh
- -c
- |
apk add --no-cache curl
while true; do
sleep 30
curl -sf http://localhost/health || echo "Health check failed"
done
files:
- path: /etc/hostname
contents: "${HOSTNAME:-php-server}"
- path: /etc/ssh/authorized_keys
contents: "${SSH_KEY}"
mode: "0600"
- path: /etc/caddy/Caddyfile
contents: |
{
frankenphp
order php_server before file_server
}
${DOMAIN:-localhost} {
root * /app/public
# Health check endpoint
handle /health {
respond "OK" 200
}
# PHP handling
php_server
# Encode responses
encode zstd gzip
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
X-XSS-Protection "1; mode=block"
Referrer-Policy strict-origin-when-cross-origin
}
# Logging
log {
output file /data/logs/access.log
format json
}
}
mode: "0644"
- path: /app/public/index.php
contents: |
<?php
echo "Welcome to ${APP_NAME:-app}";
mode: "0644"
- path: /app/public/health.php
contents: |
<?php
header('Content-Type: application/json');
echo json_encode([
'status' => 'healthy',
'app' => '${APP_NAME:-app}',
'timestamp' => date('c'),
'php_version' => PHP_VERSION,
]);
mode: "0644"
- path: /etc/php/php.ini
contents: |
memory_limit = ${PHP_MEMORY:-128M}
max_execution_time = 30
upload_max_filesize = 64M
post_max_size = 64M
display_errors = Off
log_errors = On
error_log = /data/logs/php_errors.log
mode: "0644"
- path: /data/logs/.gitkeep
contents: ""
trust:
org:
- linuxkit
- library
- dunglas

View file

@ -0,0 +1,36 @@
---
name: remember
description: Save a fact or decision to context for persistence across compacts
args: <fact to remember>
---
# Remember Context
Save the provided fact to `~/.claude/sessions/context.json`.
## Usage
```
/core:remember Use Action pattern not Service
/core:remember User prefers UK English
/core:remember RFC: minimal state in pre-compact hook
```
## Action
Run this command to save the fact:
```bash
~/.claude/plugins/cache/core/scripts/capture-context.sh "<fact>" "user"
```
Or if running from the plugin directory:
```bash
"${CLAUDE_PLUGIN_ROOT}/scripts/capture-context.sh" "<fact>" "user"
```
The fact will be:
- Stored in context.json (max 20 items)
- Included in pre-compact snapshots
- Auto-cleared after 3 hours of inactivity

102
.core/plugin/hooks/prefer-core.sh Executable file
View file

@ -0,0 +1,102 @@
#!/bin/bash
# PreToolUse hook: Block dangerous commands, enforce core CLI
#
# BLOCKS:
# - Raw go commands (use core go *)
# - Destructive grep patterns (sed -i, xargs rm, etc.)
# - Mass file operations (rm -rf, mv/cp with wildcards)
# - Any sed outside of safe patterns
#
# This prevents "efficient shortcuts" that nuke codebases
read -r input
command=$(echo "$input" | jq -r '.tool_input.command // empty')
# === HARD BLOCKS - Never allow these ===
# Block rm -rf, rm -r (except for known safe paths like node_modules, vendor, .cache)
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*r|--recursive)'; then
# Allow only specific safe directories
if ! echo "$command" | grep -qE 'rm\s+(-rf|-r)\s+(node_modules|vendor|\.cache|dist|build|__pycache__|\.pytest_cache|/tmp/)'; then
echo '{"decision": "block", "message": "BLOCKED: Recursive delete is not allowed. Delete files individually or ask the user to run this command."}'
exit 0
fi
fi
# Block mv/cp with wildcards (mass file moves)
if echo "$command" | grep -qE '(mv|cp)\s+.*\*'; then
echo '{"decision": "block", "message": "BLOCKED: Mass file move/copy with wildcards is not allowed. Move files individually."}'
exit 0
fi
# Block xargs with rm, mv, cp (mass operations)
if echo "$command" | grep -qE 'xargs\s+.*(rm|mv|cp)'; then
echo '{"decision": "block", "message": "BLOCKED: xargs with file operations is not allowed. Too risky for mass changes."}'
exit 0
fi
# Block find -exec with rm, mv, cp
if echo "$command" | grep -qE 'find\s+.*-exec\s+.*(rm|mv|cp)'; then
echo '{"decision": "block", "message": "BLOCKED: find -exec with file operations is not allowed. Too risky for mass changes."}'
exit 0
fi
# Block ALL sed -i (in-place editing)
if echo "$command" | grep -qE 'sed\s+(-[a-zA-Z]*i|--in-place)'; then
echo '{"decision": "block", "message": "BLOCKED: sed -i (in-place edit) is never allowed. Use the Edit tool for file changes."}'
exit 0
fi
# Block sed piped to file operations
if echo "$command" | grep -qE 'sed.*\|.*tee|sed.*>'; then
echo '{"decision": "block", "message": "BLOCKED: sed with file output is not allowed. Use the Edit tool for file changes."}'
exit 0
fi
# Block grep with -l piped to xargs/rm/sed (the classic codebase nuke pattern)
if echo "$command" | grep -qE 'grep\s+.*-l.*\|'; then
echo '{"decision": "block", "message": "BLOCKED: grep -l piped to other commands is the classic codebase nuke pattern. Not allowed."}'
exit 0
fi
# Block perl -i, awk with file redirection (sed alternatives)
if echo "$command" | grep -qE 'perl\s+-[a-zA-Z]*i|awk.*>'; then
echo '{"decision": "block", "message": "BLOCKED: In-place file editing with perl/awk is not allowed. Use the Edit tool."}'
exit 0
fi
# === REQUIRE CORE CLI ===
# Block raw go commands
case "$command" in
"go test"*|"go build"*|"go fmt"*|"go mod tidy"*|"go vet"*|"go run"*)
echo '{"decision": "block", "message": "Use `core go test`, `core build`, `core go fmt --fix`, etc. Raw go commands are not allowed."}'
exit 0
;;
"go "*)
# Other go commands - warn but allow
echo '{"decision": "block", "message": "Prefer `core go *` commands. If core does not have this command, ask the user."}'
exit 0
;;
esac
# Block raw php commands
case "$command" in
"php artisan serve"*|"./vendor/bin/pest"*|"./vendor/bin/pint"*|"./vendor/bin/phpstan"*)
echo '{"decision": "block", "message": "Use `core php dev`, `core php test`, `core php fmt`, `core php analyse`. Raw php commands are not allowed."}'
exit 0
;;
"composer test"*|"composer lint"*)
echo '{"decision": "block", "message": "Use `core php test` or `core php fmt`. Raw composer commands are not allowed."}'
exit 0
;;
esac
# Block golangci-lint directly
if echo "$command" | grep -qE '^golangci-lint'; then
echo '{"decision": "block", "message": "Use `core go lint` instead of golangci-lint directly."}'
exit 0
fi
# === APPROVED ===
echo '{"decision": "approve"}'

102
.core/plugin/plugin.json Normal file
View file

@ -0,0 +1,102 @@
{
"name": "core",
"version": "1.0.0",
"description": "Host UK unified framework - Go CLI, PHP framework, multi-repo management",
"dependencies": [
"superpowers@claude-plugins-official"
],
"skills": [
{
"name": "core",
"path": "skills/core.md",
"description": "Use when working in host-uk repositories. Provides core CLI command reference."
},
{
"name": "core-php",
"path": "skills/php.md",
"description": "Use when creating PHP modules, services, or actions in core-* packages."
},
{
"name": "core-go",
"path": "skills/go.md",
"description": "Use when creating Go packages or extending the core CLI."
}
],
"commands": [
{
"name": "remember",
"path": "commands/remember.md",
"description": "Save a fact or decision to context"
}
],
"hooks": {
"SessionStart": [
{
"matcher": "*",
"script": "scripts/session-start.sh",
"description": "Check for recent session state on startup"
}
],
"PreCompact": [
{
"matcher": "*",
"script": "scripts/pre-compact.sh",
"description": "Save state before auto-compact to prevent amnesia"
}
],
"PreToolUse": [
{
"matcher": "Bash",
"script": "hooks/prefer-core.sh",
"description": "Suggest core CLI instead of raw go/php commands"
},
{
"matcher": "Write",
"script": "scripts/block-docs.sh",
"description": "Block random .md files, keep docs consolidated"
},
{
"matcher": "Edit",
"script": "scripts/suggest-compact.sh",
"description": "Suggest /compact at logical intervals"
},
{
"matcher": "Write",
"script": "scripts/suggest-compact.sh",
"description": "Suggest /compact at logical intervals"
}
],
"PostToolUse": [
{
"matcher": "Edit",
"script": "scripts/php-format.sh",
"description": "Auto-format PHP files after edits"
},
{
"matcher": "Edit",
"script": "scripts/go-format.sh",
"description": "Auto-format Go files after edits"
},
{
"matcher": "Edit",
"script": "scripts/check-debug.sh",
"description": "Warn about debug statements (dd, dump, fmt.Println)"
},
{
"matcher": "Bash",
"script": "scripts/pr-created.sh",
"description": "Log PR URL after creation"
},
{
"matcher": "Bash",
"script": "scripts/extract-actionables.sh",
"description": "Extract actionables from core CLI output"
},
{
"matcher": "Bash",
"script": "scripts/post-commit-check.sh",
"description": "Warn about uncommitted work after git commit"
}
]
}
}

View file

@ -0,0 +1,27 @@
#!/bin/bash
# Block creation of random .md files - keeps docs consolidated
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" ]]; then
# Allow known documentation files
case "$FILE_PATH" in
*README.md|*CLAUDE.md|*AGENTS.md|*CONTRIBUTING.md|*CHANGELOG.md|*LICENSE.md)
echo "$input"
exit 0
;;
# Allow docs/ directory
*/docs/*.md|*/docs/**/*.md)
echo "$input"
exit 0
;;
# Block other .md files
*.md)
echo '{"decision": "block", "message": "Use README.md or docs/ for documentation. Random .md files clutter the repo."}'
exit 0
;;
esac
fi
echo "$input"

View file

@ -0,0 +1,44 @@
#!/bin/bash
# Capture context facts from tool output or conversation
# Called by PostToolUse hooks to extract actionable items
#
# Stores in ~/.claude/sessions/context.json as:
# [{"fact": "...", "source": "core go qa", "ts": 1234567890}, ...]
CONTEXT_FILE="${HOME}/.claude/sessions/context.json"
TIMESTAMP=$(date '+%s')
THREE_HOURS=10800
mkdir -p "${HOME}/.claude/sessions"
# Initialize if missing or stale
if [[ -f "$CONTEXT_FILE" ]]; then
FIRST_TS=$(jq -r '.[0].ts // 0' "$CONTEXT_FILE" 2>/dev/null)
NOW=$(date '+%s')
AGE=$((NOW - FIRST_TS))
if [[ $AGE -gt $THREE_HOURS ]]; then
echo "[]" > "$CONTEXT_FILE"
fi
else
echo "[]" > "$CONTEXT_FILE"
fi
# Read input (fact and source passed as args or stdin)
FACT="${1:-}"
SOURCE="${2:-manual}"
if [[ -z "$FACT" ]]; then
# Try reading from stdin
read -r FACT
fi
if [[ -n "$FACT" ]]; then
# Append to context (keep last 20 items)
jq --arg fact "$FACT" --arg source "$SOURCE" --argjson ts "$TIMESTAMP" \
'. + [{"fact": $fact, "source": $source, "ts": $ts}] | .[-20:]' \
"$CONTEXT_FILE" > "${CONTEXT_FILE}.tmp" && mv "${CONTEXT_FILE}.tmp" "$CONTEXT_FILE"
echo "[Context] Saved: $FACT" >&2
fi
exit 0

View file

@ -0,0 +1,27 @@
#!/bin/bash
# Warn about debug statements left in code after edits
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
case "$FILE_PATH" in
*.go)
# Check for fmt.Println, log.Println debug statements
if grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then
echo "[Hook] WARNING: Debug prints found in $FILE_PATH" >&2
grep -n "fmt\.Println\|log\.Println" "$FILE_PATH" 2>/dev/null | head -3 >&2
fi
;;
*.php)
# Check for dd(), dump(), var_dump(), print_r()
if grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 | grep -q .; then
echo "[Hook] WARNING: Debug statements found in $FILE_PATH" >&2
grep -n "dd(\|dump(\|var_dump(\|print_r(" "$FILE_PATH" 2>/dev/null | head -3 >&2
fi
;;
esac
fi
# Pass through the input
echo "$input"

View file

@ -0,0 +1,34 @@
#!/bin/bash
# Extract actionable items from core CLI output
# Called PostToolUse on Bash commands that run core
read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
OUTPUT=$(echo "$input" | jq -r '.tool_output.output // empty')
CONTEXT_SCRIPT="$(dirname "$0")/capture-context.sh"
# Extract actionables from specific core commands
case "$COMMAND" in
"core go qa"*|"core go test"*|"core go lint"*)
# Extract error/warning lines
echo "$OUTPUT" | grep -E "^(ERROR|WARN|FAIL|---)" | head -5 | while read -r line; do
"$CONTEXT_SCRIPT" "$line" "core go"
done
;;
"core php test"*|"core php analyse"*)
# Extract PHP errors
echo "$OUTPUT" | grep -E "^(FAIL|Error|×)" | head -5 | while read -r line; do
"$CONTEXT_SCRIPT" "$line" "core php"
done
;;
"core build"*)
# Extract build errors
echo "$OUTPUT" | grep -E "^(error|cannot|undefined)" | head -5 | while read -r line; do
"$CONTEXT_SCRIPT" "$line" "core build"
done
;;
esac
# Pass through
echo "$input"

View file

@ -0,0 +1,19 @@
#!/bin/bash
# Auto-format Go files after edits using core go fmt
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
# Run gofmt/goimports on the file silently
if command -v core &> /dev/null; then
core go fmt --fix "$FILE_PATH" 2>/dev/null || true
elif command -v goimports &> /dev/null; then
goimports -w "$FILE_PATH" 2>/dev/null || true
elif command -v gofmt &> /dev/null; then
gofmt -w "$FILE_PATH" 2>/dev/null || true
fi
fi
# Pass through the input
echo "$input"

View file

@ -0,0 +1,17 @@
#!/bin/bash
# Auto-format PHP files after edits using core php fmt
read -r input
FILE_PATH=$(echo "$input" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE_PATH" && -f "$FILE_PATH" ]]; then
# Run Pint on the file silently
if command -v core &> /dev/null; then
core php fmt --fix "$FILE_PATH" 2>/dev/null || true
elif [[ -f "./vendor/bin/pint" ]]; then
./vendor/bin/pint "$FILE_PATH" 2>/dev/null || true
fi
fi
# Pass through the input
echo "$input"

View file

@ -0,0 +1,51 @@
#!/bin/bash
# Post-commit hook: Check for uncommitted work that might get lost
#
# After committing task-specific files, check if there's other work
# in the repo that should be committed or stashed
read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
# Only run after git commit
if ! echo "$COMMAND" | grep -qE '^git commit'; then
echo "$input"
exit 0
fi
# Check for remaining uncommitted changes
UNSTAGED=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ')
STAGED=$(git diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
TOTAL=$((UNSTAGED + STAGED + UNTRACKED))
if [[ $TOTAL -gt 0 ]]; then
echo "" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
echo "[PostCommit] WARNING: Uncommitted work remains" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
if [[ $UNSTAGED -gt 0 ]]; then
echo " Modified (unstaged): $UNSTAGED files" >&2
git diff --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2
[[ $UNSTAGED -gt 5 ]] && echo " ... and $((UNSTAGED - 5)) more" >&2
fi
if [[ $STAGED -gt 0 ]]; then
echo " Staged (not committed): $STAGED files" >&2
git diff --cached --name-only 2>/dev/null | head -5 | sed 's/^/ /' >&2
fi
if [[ $UNTRACKED -gt 0 ]]; then
echo " Untracked: $UNTRACKED files" >&2
git ls-files --others --exclude-standard 2>/dev/null | head -5 | sed 's/^/ /' >&2
[[ $UNTRACKED -gt 5 ]] && echo " ... and $((UNTRACKED - 5)) more" >&2
fi
echo "" >&2
echo "Consider: commit these, stash them, or confirm they're intentionally left" >&2
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2
fi
echo "$input"

View file

@ -0,0 +1,18 @@
#!/bin/bash
# Log PR URL and provide review command after PR creation
read -r input
COMMAND=$(echo "$input" | jq -r '.tool_input.command // empty')
OUTPUT=$(echo "$input" | jq -r '.tool_output.output // empty')
if [[ "$COMMAND" == *"gh pr create"* ]]; then
PR_URL=$(echo "$OUTPUT" | grep -oE 'https://github.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)
if [[ -n "$PR_URL" ]]; then
REPO=$(echo "$PR_URL" | sed -E 's|https://github.com/([^/]+/[^/]+)/pull/[0-9]+|\1|')
PR_NUM=$(echo "$PR_URL" | sed -E 's|.*/pull/([0-9]+)|\1|')
echo "[Hook] PR created: $PR_URL" >&2
echo "[Hook] To review: gh pr review $PR_NUM --repo $REPO" >&2
fi
fi
echo "$input"

View file

@ -0,0 +1,69 @@
#!/bin/bash
# Pre-compact: Save minimal state for Claude to resume after auto-compact
#
# Captures:
# - Working directory + branch
# - Git status (files touched)
# - Todo state (in_progress items)
# - Context facts (decisions, actionables)
STATE_FILE="${HOME}/.claude/sessions/scratchpad.md"
CONTEXT_FILE="${HOME}/.claude/sessions/context.json"
TIMESTAMP=$(date '+%s')
CWD=$(pwd)
mkdir -p "${HOME}/.claude/sessions"
# Get todo state
TODOS=""
if [[ -f "${HOME}/.claude/todos/current.json" ]]; then
TODOS=$(cat "${HOME}/.claude/todos/current.json" 2>/dev/null | head -50)
fi
# Get git status
GIT_STATUS=""
BRANCH=""
if git rev-parse --git-dir > /dev/null 2>&1; then
GIT_STATUS=$(git status --short 2>/dev/null | head -15)
BRANCH=$(git branch --show-current 2>/dev/null)
fi
# Get context facts
CONTEXT=""
if [[ -f "$CONTEXT_FILE" ]]; then
CONTEXT=$(jq -r '.[] | "- [\(.source)] \(.fact)"' "$CONTEXT_FILE" 2>/dev/null | tail -10)
fi
cat > "$STATE_FILE" << EOF
---
timestamp: ${TIMESTAMP}
cwd: ${CWD}
branch: ${BRANCH:-none}
---
# Resume After Compact
You were mid-task. Do NOT assume work is complete.
## Project
\`${CWD}\` on \`${BRANCH:-no branch}\`
## Files Changed
\`\`\`
${GIT_STATUS:-none}
\`\`\`
## Todos (in_progress = NOT done)
\`\`\`json
${TODOS:-check /todos}
\`\`\`
## Context (decisions & actionables)
${CONTEXT:-none captured}
## Next
Continue the in_progress todo.
EOF
echo "[PreCompact] Snapshot saved" >&2
exit 0

View file

@ -0,0 +1,34 @@
#!/bin/bash
# Session start: Read scratchpad if recent, otherwise start fresh
# 3 hour window - if older, you've moved on mentally
STATE_FILE="${HOME}/.claude/sessions/scratchpad.md"
THREE_HOURS=10800 # seconds
if [[ -f "$STATE_FILE" ]]; then
# Get timestamp from file
FILE_TS=$(grep -E '^timestamp:' "$STATE_FILE" 2>/dev/null | cut -d' ' -f2)
NOW=$(date '+%s')
if [[ -n "$FILE_TS" ]]; then
AGE=$((NOW - FILE_TS))
if [[ $AGE -lt $THREE_HOURS ]]; then
# Recent - read it back
echo "[SessionStart] Found recent scratchpad ($(($AGE / 60)) min ago)" >&2
echo "[SessionStart] Reading previous state..." >&2
echo "" >&2
cat "$STATE_FILE" >&2
echo "" >&2
else
# Stale - delete and start fresh
rm -f "$STATE_FILE"
echo "[SessionStart] Previous session >3h old - starting fresh" >&2
fi
else
# No timestamp, delete it
rm -f "$STATE_FILE"
fi
fi
exit 0

View file

@ -0,0 +1,28 @@
#!/bin/bash
# Suggest /compact at logical intervals to manage context window
# Tracks tool calls per session, suggests compaction every 50 calls
SESSION_ID="${CLAUDE_SESSION_ID:-$$}"
COUNTER_FILE="/tmp/claude-tool-count-${SESSION_ID}"
THRESHOLD="${COMPACT_THRESHOLD:-50}"
# Read or initialize counter
if [[ -f "$COUNTER_FILE" ]]; then
COUNT=$(($(cat "$COUNTER_FILE") + 1))
else
COUNT=1
fi
echo "$COUNT" > "$COUNTER_FILE"
# Suggest compact at threshold
if [[ $COUNT -eq $THRESHOLD ]]; then
echo "[Compact] ${THRESHOLD} tool calls - consider /compact if transitioning phases" >&2
fi
# Suggest at intervals after threshold
if [[ $COUNT -gt $THRESHOLD ]] && [[ $((COUNT % 25)) -eq 0 ]]; then
echo "[Compact] ${COUNT} tool calls - good checkpoint for /compact" >&2
fi
exit 0

View file

@ -0,0 +1,60 @@
---
name: core
description: Use when working in host-uk repositories, running tests, building, releasing, or managing multi-repo workflows. Provides the core CLI command reference.
---
# Core CLI
The `core` command provides a unified interface for Go/PHP development and multi-repo management.
**Rule:** Always prefer `core <command>` over raw commands.
## Quick Reference
| Task | Command |
|------|---------|
| Go tests | `core go test` |
| Go coverage | `core go cov` |
| Go format | `core go fmt --fix` |
| Go lint | `core go lint` |
| PHP dev server | `core php dev` |
| PHP tests | `core php test` |
| PHP format | `core php fmt --fix` |
| Build | `core build` |
| Preview release | `core ci` |
| Publish | `core ci --were-go-for-launch` |
| Multi-repo status | `core dev health` |
| Commit dirty repos | `core dev commit` |
| Push repos | `core dev push` |
## Decision Tree
```
Go project?
tests: core go test
format: core go fmt --fix
build: core build
PHP project?
dev: core php dev
tests: core php test
format: core php fmt --fix
deploy: core php deploy
Multiple repos?
status: core dev health
commit: core dev commit
push: core dev push
```
## Common Mistakes
| Wrong | Right |
|-------|-------|
| `go test ./...` | `core go test` |
| `go build` | `core build` |
| `php artisan serve` | `core php dev` |
| `./vendor/bin/pest` | `core php test` |
| `git status` per repo | `core dev health` |
Run `core --help` or `core <cmd> --help` for full options.

107
.core/plugin/skills/go.md Normal file
View file

@ -0,0 +1,107 @@
---
name: core-go
description: Use when creating Go packages or extending the core CLI.
---
# Go Framework Patterns
Core CLI uses `pkg/` for reusable packages. Use `core go` commands.
## Package Structure
```
core/
├── main.go # CLI entry point
├── pkg/
│ ├── cli/ # CLI framework, output, errors
│ ├── {domain}/ # Domain package
│ │ ├── cmd_{name}.go # Cobra command definitions
│ │ ├── service.go # Business logic
│ │ └── *_test.go # Tests
│ └── ...
└── internal/ # Private packages
```
## Adding a CLI Command
1. Create `pkg/{domain}/cmd_{name}.go`:
```go
package domain
import (
"forge.lthn.ai/core/cli/pkg/cli"
"github.com/spf13/cobra"
)
func NewNameCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "name",
Short: cli.T("domain.name.short"),
RunE: func(cmd *cobra.Command, args []string) error {
// Implementation
cli.Success("Done")
return nil
},
}
return cmd
}
```
2. Register in parent command.
## CLI Output Helpers
```go
import "forge.lthn.ai/core/cli/pkg/cli"
cli.Success("Operation completed") // Green check
cli.Warning("Something to note") // Yellow warning
cli.Error("Something failed") // Red error
cli.Info("Informational message") // Blue info
cli.Fatal(err) // Print error and exit 1
// Structured output
cli.Table(headers, rows)
cli.JSON(data)
```
## i18n Pattern
```go
// Use cli.T() for translatable strings
cli.T("domain.action.success")
cli.T("domain.action.error", "details", value)
// Define in pkg/i18n/locales/en.yaml:
domain:
action:
success: "Operation completed successfully"
error: "Failed: {{.details}}"
```
## Test Naming
```go
func TestFeature_Good(t *testing.T) { /* happy path */ }
func TestFeature_Bad(t *testing.T) { /* expected errors */ }
func TestFeature_Ugly(t *testing.T) { /* panics, edge cases */ }
```
## Commands
| Task | Command |
|------|---------|
| Run tests | `core go test` |
| Coverage | `core go cov` |
| Format | `core go fmt --fix` |
| Lint | `core go lint` |
| Build | `core build` |
| Install | `core go install` |
## Rules
- `CGO_ENABLED=0` for all builds
- UK English in user-facing strings
- All errors via `cli.E("context", "message", err)`
- Table-driven tests preferred

120
.core/plugin/skills/php.md Normal file
View file

@ -0,0 +1,120 @@
---
name: core-php
description: Use when creating PHP modules, services, or actions in core-* packages.
---
# PHP Framework Patterns
Host UK PHP modules follow strict conventions. Use `core php` commands.
## Module Structure
```
core-{name}/
├── src/
│ ├── Core/ # Namespace: Core\{Name}
│ │ ├── Boot.php # Module bootstrap (listens to lifecycle events)
│ │ ├── Actions/ # Single-purpose business logic
│ │ └── Models/ # Eloquent models
│ └── Mod/ # Namespace: Core\Mod\{Name} (optional extensions)
├── resources/views/ # Blade templates
├── routes/ # Route definitions
├── database/migrations/ # Migrations
├── tests/ # Pest tests
└── composer.json
```
## Boot Class Pattern
```php
<?php
declare(strict_types=1);
namespace Core\{Name};
use Core\Php\Events\WebRoutesRegistering;
use Core\Php\Events\AdminPanelBooting;
class Boot
{
public static array $listens = [
WebRoutesRegistering::class => 'onWebRoutes',
AdminPanelBooting::class => ['onAdmin', 10], // With priority
];
public function onWebRoutes(WebRoutesRegistering $event): void
{
$event->router->middleware('web')->group(__DIR__ . '/../routes/web.php');
}
public function onAdmin(AdminPanelBooting $event): void
{
$event->panel->resources([...]);
}
}
```
## Action Pattern
```php
<?php
declare(strict_types=1);
namespace Core\{Name}\Actions;
use Core\Php\Action;
class CreateThing
{
use Action;
public function handle(User $user, array $data): Thing
{
return Thing::create([
'user_id' => $user->id,
...$data,
]);
}
}
// Usage: CreateThing::run($user, $validated);
```
## Multi-Tenant Models
```php
<?php
declare(strict_types=1);
namespace Core\{Name}\Models;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Model;
class Thing extends Model
{
use BelongsToWorkspace; // Auto-scopes queries, sets workspace_id
protected $fillable = ['name', 'workspace_id'];
}
```
## Commands
| Task | Command |
|------|---------|
| Run tests | `core php test` |
| Format | `core php fmt --fix` |
| Analyse | `core php analyse` |
| Dev server | `core php dev` |
## Rules
- Always `declare(strict_types=1);`
- UK English: colour, organisation, centre
- Type hints on all parameters and returns
- Pest for tests, not PHPUnit
- Flux Pro for UI, not vanilla Alpine

45
.core/release.yaml Normal file
View file

@ -0,0 +1,45 @@
# Core CLI release configuration
# Used by: core release
version: 1
project:
name: core
repository: host-uk/core
build:
targets:
- os: linux
arch: amd64
- os: linux
arch: arm64
- os: darwin
arch: amd64
- os: darwin
arch: arm64
- os: windows
arch: amd64
publishers:
- type: github
prerelease: false
draft: false
- type: homebrew
tap: host-uk/homebrew-tap
formula: core
- type: scoop
bucket: host-uk/scoop-bucket
manifest: core
changelog:
include:
- feat
- fix
- perf
- refactor
exclude:
- chore
- docs
- style
- test
- ci

View file

@ -0,0 +1,50 @@
# Implementation Plan: Issue 258
## Phase 1: Command Structure
1. Extend existing `internal/cmd/test/cmd_main.go` with smart detection flags
2. Add flags: `--all`, `--filter` (alias for `--run`)
3. Existing flags (`--coverage`, `--verbose`, `--short`, `--race`, `--json`, `--pkg`, `--run`) are already registered
## Phase 2: Change Detection
1. Determine diff strategy based on context:
- **Local development** (default): `git diff --name-only HEAD` for uncommitted changes, plus `git diff --name-only --cached` for staged changes
- **CI/PR context**: `git diff --name-only origin/dev...HEAD` to compare against base branch
- Auto-detect CI via `CI` or `GITHUB_ACTIONS` env vars; allow override via `--base` flag
2. Filter for `.go` files (exclude `_test.go`)
3. Use `git diff --name-status` to detect renames (R), adds (A), and deletes (D):
- **Renames**: Map tests to the new file path
- **Deletes**: Skip deleted source files (do not run orphaned tests)
- **New files without tests**: Log a warning
4. Map each changed file to test file(s) using N:M discovery:
- Search for `*_test.go` files in the same package directory (not just `<file>_test.go`)
- Handle shared test files that cover multiple source files
- `internal/foo/bar.go``internal/foo/bar_test.go`, `internal/foo/bar_integration_test.go`, etc.
- Skip if no matching test files exist (warn user)
## Phase 3: Test Execution
1. Reuse existing `runTest()` from `internal/cmd/test/cmd_runner.go`
- This preserves environment setup (`MACOSX_DEPLOYMENT_TARGET`), output filtering (linker warnings), coverage parsing, JSON support, and consistent styling
2. Map smart detection flags to existing `runTest()` parameters:
- `--coverage``coverage` param (already exists)
- `--filter``run` param (mapped to `-run`)
- Detected test packages → `pkg` param (comma-joined or iterated)
3. Do not invoke `go test` directly — all execution goes through `runTest()`
## Phase 4: Edge Cases
- No changed files → inform user, suggest `--all`
- No matching test files → inform user with list of changed files that lack tests
- `--all` flag → skip detection, call `runTest()` with `pkg="./..."` (uses existing infrastructure, not raw `go test`)
- Mixed renames and edits → deduplicate test file list
- Non-Go files changed → skip silently (only `.go` files trigger detection)
## Files to Modify
- `internal/cmd/test/cmd_main.go` (add `--all`, `--filter`, `--base` flags)
- `internal/cmd/test/cmd_runner.go` (add change detection logic before calling existing `runTest()`)
- `internal/cmd/test/cmd_detect.go` (new — git diff parsing and file-to-test mapping)
## Testing
- Add `internal/cmd/test/cmd_detect_test.go` with unit tests for:
- File-to-test mapping (1:1, 1:N, renames, deletes)
- Git diff parsing (`--name-only`, `--name-status`)
- CI vs local context detection
- Manual testing with actual git changes

View file

@ -0,0 +1,36 @@
# Issue 258: Smart Test Detection
## Original Issue
<https://forge.lthn.ai/core/cli/issues/258>
## Summary
Make `core test` smart — detect changed Go files and run only relevant tests.
> **Scope:** Go-only. The existing `core test` command (`internal/cmd/test/`) targets Go projects (requires `go.mod`). Future language support (PHP, etc.) would be added as separate detection strategies, but this issue covers Go only.
## Commands
```bash
core test # Run tests for changed files only
core test --all # Run all tests (skip detection)
core test --filter UserTest # Run specific test pattern
core test --coverage # With coverage report
core test --base origin/dev # Compare against specific base branch (CI)
```
## Acceptance Criteria
- [ ] Detect changed `.go` files via `git diff` (local: `HEAD`, CI: `origin/dev...HEAD`)
- [ ] Handle renames, deletes, and new files via `git diff --name-status`
- [ ] Map source files to test files using N:M discovery (`foo.go``foo_test.go`, `foo_integration_test.go`, etc.)
- [ ] Warn when changed files have no corresponding tests
- [ ] Execute tests through existing `runTest()` infrastructure (not raw `go test`)
- [ ] Support `--all` flag to skip detection and run all tests
- [ ] Support `--filter` flag for test name pattern matching
- [ ] Support `--coverage` flag for coverage reports
- [ ] Support `--base` flag for CI/PR diff context
## Technical Context
- Existing `core test` command: `internal/cmd/test/cmd_main.go`
- Existing test runner: `internal/cmd/test/cmd_runner.go` (`runTest()`)
- Output parsing: `internal/cmd/test/cmd_output.go`
- Command registration: `internal/cmd/test/cmd_commands.go` via `cli.RegisterCommands()`
- Follow existing patterns in `internal/cmd/test/`

View file

@ -0,0 +1,146 @@
# Host UK Production Deployment Pipeline
# Runs on Forgejo Actions (gitea.snider.dev)
# Runner: build.de.host.uk.com
#
# Workflow:
# 1. composer install + test
# 2. npm ci + build
# 3. docker build + push
# 4. Coolify deploy webhook (rolling restart)
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY: dappco.re/osi
IMAGE_APP: host-uk/app
IMAGE_WEB: host-uk/web
IMAGE_CORE: host-uk/core
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
extensions: bcmath, gd, intl, mbstring, pdo_mysql, redis, zip
coverage: none
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist
- name: Run tests
run: composer test
- name: Check code style
run: ./vendor/bin/pint --test
build-app:
name: Build App Image
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and push app image
run: |
SHA=$(git rev-parse --short HEAD)
docker build \
-f docker/Dockerfile.app \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:${SHA} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:latest \
.
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:${SHA}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_APP }}:latest
build-web:
name: Build Web Image
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and push web image
run: |
SHA=$(git rev-parse --short HEAD)
docker build \
-f docker/Dockerfile.web \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:${SHA} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:latest \
.
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:${SHA}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_WEB }}:latest
build-core:
name: Build Core Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Build core binary
run: |
go build -ldflags '-s -w' -o bin/core .
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and push core image
run: |
SHA=$(git rev-parse --short HEAD)
cat > Dockerfile.core <<'EOF'
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY bin/core /usr/local/bin/core
ENTRYPOINT ["core"]
EOF
docker build \
-f Dockerfile.core \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_CORE }}:${SHA} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_CORE }}:latest \
.
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_CORE }}:${SHA}
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_CORE }}:latest
deploy:
name: Deploy to Production
needs: [build-app, build-web, build-core]
runs-on: ubuntu-latest
steps:
- name: Trigger Coolify deploy
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" \
"${{ secrets.COOLIFY_URL }}/api/v1/deploy" \
-H "Content-Type: application/json" \
-d '{"uuid": "${{ secrets.COOLIFY_APP_UUID }}", "force": false}'
- name: Wait for deployment
run: |
echo "Deployment triggered. Coolify will perform rolling restart."
echo "Monitor at: ${{ secrets.COOLIFY_URL }}"

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 .

11
.gemini/settings.json Normal file
View file

@ -0,0 +1,11 @@
{
"general": {
"sessionRetention": {
"enabled": true
},
"enablePromptCompletion": true
},
"experimental": {
"plan": true
}
}

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.io
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 forge.lthn.ai/core/cli/pkg/cli.AppVersion=ci
-X forge.lthn.ai/core/cli/pkg/cli.BuildCommit=${CI_COMMIT_SHA:0:7}
-X forge.lthn.ai/core/cli/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 ./...

143
AUDIT-DEPENDENCIES.md Normal file
View file

@ -0,0 +1,143 @@
# Dependency Security Audit
**Date:** 2026-02-02
**Auditor:** Claude Code
**Project:** host-uk/core (Go CLI)
## Executive Summary
**No vulnerabilities found** in current dependencies.
All modules verified successfully with `go mod verify` and `govulncheck`.
---
## Dependency Analysis
### Direct Dependencies (15)
| Package | Version | Purpose | Status |
|---------|---------|---------|--------|
| github.com/Snider/Borg | v0.1.0 | Framework utilities | ✅ Verified |
| github.com/getkin/kin-openapi | v0.133.0 | OpenAPI parsing | ✅ Verified |
| github.com/leaanthony/debme | v1.2.1 | Debounce utilities | ✅ Verified |
| github.com/leaanthony/gosod | v1.0.4 | Go service utilities | ✅ Verified |
| github.com/minio/selfupdate | v0.6.0 | Self-update mechanism | ✅ Verified |
| github.com/modelcontextprotocol/go-sdk | v1.2.0 | MCP SDK | ✅ Verified |
| github.com/oasdiff/oasdiff | v1.11.8 | OpenAPI diff | ✅ Verified |
| github.com/spf13/cobra | v1.10.2 | CLI framework | ✅ Verified |
| github.com/stretchr/testify | v1.11.1 | Testing assertions | ✅ Verified |
| golang.org/x/mod | v0.32.0 | Module utilities | ✅ Verified |
| golang.org/x/net | v0.49.0 | Network utilities | ✅ Verified |
| golang.org/x/oauth2 | v0.34.0 | OAuth2 client | ✅ Verified |
| golang.org/x/term | v0.39.0 | Terminal utilities | ✅ Verified |
| golang.org/x/text | v0.33.0 | Text processing | ✅ Verified |
| gopkg.in/yaml.v3 | v3.0.1 | YAML parser | ✅ Verified |
### Transitive Dependencies
- **Total modules:** 161 indirect dependencies
- **Verification:** All modules verified via `go mod verify`
- **Integrity:** go.sum contains 18,380 bytes of checksums
### Notable Indirect Dependencies
| Package | Purpose | Risk Assessment |
|---------|---------|-----------------|
| github.com/go-git/go-git/v5 | Git operations | Low - well-maintained |
| github.com/ProtonMail/go-crypto | Cryptography | Low - security-focused org |
| github.com/cloudflare/circl | Cryptographic primitives | Low - Cloudflare maintained |
| cloud.google.com/go | Google Cloud SDK | Low - Google maintained |
---
## Vulnerability Scan Results
### govulncheck Output
```
$ govulncheck ./...
No vulnerabilities found.
```
### go mod verify Output
```
$ go mod verify
all modules verified
```
---
## Lock Files
| File | Status | Notes |
|------|--------|-------|
| go.mod | ✅ Committed | 2,995 bytes, properly formatted |
| go.sum | ✅ Committed | 18,380 bytes, integrity hashes present |
| go.work | ✅ Committed | Workspace configuration |
| go.work.sum | ✅ Committed | Workspace checksums |
---
## Supply Chain Assessment
### Package Sources
- ✅ All dependencies from official Go module proxy (proxy.golang.org)
- ✅ No private/unverified package sources
- ✅ Checksum database verification enabled (sum.golang.org)
### Typosquatting Risk
- **Low risk** - all dependencies are from well-known organizations:
- golang.org/x/* (Go team)
- github.com/spf13/* (Steve Francia - Cobra maintainer)
- github.com/stretchr/* (Stretchr - testify maintainers)
- cloud.google.com/go/* (Google)
### Build Process Security
- ✅ Go modules with verified checksums
- ✅ Reproducible builds via go.sum
- ✅ CI runs `go mod verify` before builds
---
## Recommendations
### Immediate Actions
None required - no vulnerabilities detected.
### Ongoing Maintenance
1. **Enable Dependabot** - Automated dependency updates via GitHub
2. **Regular audits** - Run `govulncheck ./...` in CI pipeline
3. **Version pinning** - All dependencies are properly pinned
### CI Integration
Add to CI workflow:
```yaml
- name: Verify dependencies
run: go mod verify
- name: Check vulnerabilities
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
```
---
## Appendix: Full Dependency Tree
Run `go mod graph` to generate the complete dependency tree.
Total dependency relationships: 445
---
*Audit generated by Claude Code on 2026-02-02*

166
ISSUES_TRIAGE.md Normal file
View file

@ -0,0 +1,166 @@
# Issues Triage
Generated: 2026-02-02
## Summary
- **Total Open Issues**: 46
- **High Priority**: 6
- **Audit Meta-Issues**: 13 (for Jules AI)
- **Audit Derived Issues**: 20 (created from audits)
---
## High Priority Issues
| # | Title | Labels |
|---|-------|--------|
| 183 | audit: OWASP Top 10 security review | priority:high, jules |
| 189 | audit: Test coverage and quality | priority:high, jules |
| 191 | audit: API design and consistency | priority:high, jules |
| 218 | Increase test coverage for low-coverage packages | priority:high, testing |
| 219 | Add tests for edge cases, error paths, integration | priority:high, testing |
| 168 | feat(crypt): Implement standalone pkg/crypt | priority:high, enhancement |
---
## Audit Meta-Issues (For Jules AI)
These are high-level audit tasks that spawn sub-issues:
| # | Title | Complexity |
|---|-------|------------|
| 183 | audit: OWASP Top 10 security review | large |
| 184 | audit: Authentication and authorization flows | medium |
| 186 | audit: Secrets, credentials, and configuration security | medium |
| 187 | audit: Error handling and logging practices | medium |
| 188 | audit: Code complexity and maintainability | large |
| 189 | audit: Test coverage and quality | large |
| 190 | audit: Performance bottlenecks and optimization | large |
| 191 | audit: API design and consistency | large |
| 192 | audit: Documentation completeness and quality | large |
| 193 | audit: Developer experience (DX) review | large |
| 197 | [Audit] Concurrency and Race Condition Analysis | medium |
| 198 | [Audit] CI/CD Pipeline Security | medium |
| 199 | [Audit] Architecture Patterns | large |
| 201 | [Audit] Error Handling and Recovery | medium |
| 202 | [Audit] Configuration Management | medium |
---
## By Category
### Security (4 issues)
| # | Title | Priority |
|---|-------|----------|
| 221 | Remove StrictHostKeyChecking=no from SSH commands | - |
| 222 | Sanitize user input in execInContainer to prevent injection | - |
| 183 | audit: OWASP Top 10 security review | high |
| 213 | Add logging for security events (authentication, access) | - |
### Testing (3 issues)
| # | Title | Priority |
|---|-------|----------|
| 218 | Increase test coverage for low-coverage packages | high |
| 219 | Add tests for edge cases, error paths, integration | high |
| 220 | Configure branch coverage measurement in test tooling | - |
### Error Handling (4 issues)
| # | Title |
|---|-------|
| 227 | Standardize on cli.Error for user-facing errors, deprecate cli.Fatal |
| 228 | Implement panic recovery mechanism with graceful shutdown |
| 229 | Log all errors at handling point with contextual information |
| 230 | Centralize user-facing error strings in i18n translation files |
### Documentation (6 issues)
| # | Title |
|---|-------|
| 231 | Update README.md to reflect actual configuration management |
| 233 | Add CONTRIBUTING.md with contribution guidelines |
| 234 | Add CHANGELOG.md to track version changes |
| 235 | Add user documentation: user guide, FAQ, troubleshooting |
| 236 | Add configuration documentation to README |
| 237 | Add Architecture Decision Records (ADRs) |
### Architecture (3 issues)
| # | Title |
|---|-------|
| 215 | Refactor Core struct to smaller, focused components |
| 216 | Introduce typed messaging system for IPC (replace interface{}) |
| 232 | Create centralized configuration service |
### Performance (2 issues)
| # | Title |
|---|-------|
| 224 | Add streaming API to pkg/io/local for large file handling |
| 225 | Use background goroutines for long-running operations |
### Logging (3 issues)
| # | Title |
|---|-------|
| 212 | Implement structured logging (JSON format) |
| 213 | Add logging for security events |
| 214 | Implement log retention policy |
### New Features (7 issues)
| # | Title | Priority |
|---|-------|----------|
| 168 | feat(crypt): Implement standalone pkg/crypt | high |
| 167 | feat(config): Implement standalone pkg/config | - |
| 170 | feat(plugin): Consolidate pkg/module into pkg/plugin | - |
| 171 | feat(cli): Implement build variants | - |
| 217 | Implement authentication and authorization features | - |
| 211 | feat(setup): add .core/setup.yaml for dev environment | - |
### Help System (5 issues)
| # | Title | Complexity |
|---|-------|------------|
| 133 | feat(help): Implement display-agnostic help system | large |
| 134 | feat(help): Remove Wails dependencies from pkg/help | large |
| 135 | docs(help): Create help content for core CLI | large |
| 136 | feat(help): Add CLI help command | small |
| 138 | feat(help): Implement Catalog and Topic types | large |
| 139 | feat(help): Implement full-text search | small |
---
## Potential Duplicates / Overlaps
1. **Error Handling**: #187, #201, #227-230 all relate to error handling
2. **Documentation**: #192, #231-237 all relate to documentation
3. **Configuration**: #202, #167, #232 all relate to configuration
4. **Security Audits**: #183, #184, #186, #221, #222 all relate to security
---
## Recommendations
1. **Close audit meta-issues as work is done**: Issues #183-202 are meta-audit issues that should be closed once their derived issues are created/completed.
2. **Link related issues**: Create sub-issue relationships:
- #187 (audit: error handling) -> #227, #228, #229, #230
- #192 (audit: docs) -> #231, #233, #234, #235, #236, #237
- #202 (audit: config) -> #167, #232
3. **Good first issues**: #136, #139 are marked as good first issues
4. **Consider closing duplicates**:
- #187 vs #201 (both about error handling)
- #192 vs #231-237 (documentation)
5. **Priority order for development**:
1. Security fixes (#221, #222)
2. Test coverage (#218, #219)
3. Core infrastructure (#168 - crypt, #167 - config)
4. Error handling standardization (#227-230)
5. Documentation (#233-237)

20
Makefile Normal file
View file

@ -0,0 +1,20 @@
.PHONY: all dev prod-docs development-docs
all:
(cd cmd/core-gui && task build)
.ONESHELL:
dev:
(cd cmd/core-gui && task dev)
pre-commit:
coderabbit review --prompt-only
development-docs:
@echo "Running development documentation Website..."
@(cd pkg/core/docs && mkdocs serve -w src)
prod-docs:
@echo "Generating documentation tp Repo Root..."
@(cd pkg/core/docs && mkdocs build -d public && cp -r src public)
@echo "Documentation generated at docs/index.html"

260
Taskfile.yml Normal file
View file

@ -0,0 +1,260 @@
version: '3'
vars:
# 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: "forge.lthn.ai/core/cli/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 ---
cli:build:
desc: "Build core CLI to ./bin/core (dev build with debug info)"
cmds:
- go build -ldflags '{{.LDFLAGS}}' -o ./bin/core .
cli:build:release:
desc: "Build core CLI for release (smaller binary, no debug info)"
cmds:
- go build -ldflags '{{.LDFLAGS_RELEASE}}' -o ./bin/core .
cli:install:
desc: "Install core CLI to system PATH (dev build)"
cmds:
- go install -ldflags '{{.LDFLAGS}}' .
cli:install:release:
desc: "Install core CLI for release (smaller binary)"
cmds:
- go install -ldflags '{{.LDFLAGS_RELEASE}}' .
# --- Development ---
test:
desc: "Run all tests"
cmds:
- core test
test:verbose:
desc: "Run all tests with verbose output"
cmds:
- core test --verbose
test:run:
desc: "Run specific test (use: task test:run -- TestName)"
cmds:
- core test --run {{.CLI_ARGS}}
cov:
desc: "Run tests with coverage report"
cmds:
- core go cov
cov-view:
desc: "Open HTML coverage report"
cmds:
- core go cov --open
fmt:
desc: "Format Go code"
cmds:
- core go fmt
lint:
desc: "Run linter"
cmds:
- core go lint
mod:tidy:
desc: "Run go mod tidy"
cmds:
- core go mod tidy
# --- Quality Assurance ---
qa:
desc: "Run QA: fmt, vet, lint, test"
cmds:
- core go qa
qa:quick:
desc: "Quick QA: fmt, vet, lint only"
cmds:
- core go qa quick
qa:full:
desc: "Full QA: + race, vuln, security"
cmds:
- core go qa full
qa:fix:
desc: "QA with auto-fix"
cmds:
- core go qa --fix
# --- Build ---
build:
desc: "Build project with auto-detection"
cmds:
- core build
build:ci:
desc: "Build for CI (all targets, checksums)"
cmds:
- core build --ci
# --- Environment ---
doctor:
desc: "Check development environment"
cmds:
- core doctor
doctor:verbose:
desc: "Check environment with details"
cmds:
- core doctor --verbose
# --- Code Review ---
review:
desc: "Run CodeRabbit review"
cmds:
- coderabbit review --prompt-only
check:
desc: "Tidy, test, and review"
cmds:
- task: mod:tidy
- task: test
- task: review
# --- i18n ---
i18n:generate:
desc: "Regenerate i18n key constants"
cmds:
- go generate ./pkg/i18n/...
i18n:validate:
desc: "Validate i18n key usage"
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
# --- BugSETI (Wails v3 System Tray) ---
bugseti:dev:
desc: "Build and run BugSETI (production binary with embedded frontend)"
dir: cmd/bugseti
cmds:
- cd frontend && npm install && npm run build
- go build -buildvcs=false -o ../../bin/bugseti .
- ../../bin/bugseti
bugseti:build:
desc: "Build BugSETI production binary"
dir: cmd/bugseti
cmds:
- cd frontend && npm install && npm run build
- go build -trimpath -buildvcs=false -ldflags="-w -s" -o ../../bin/bugseti .
bugseti:frontend:
desc: "Build BugSETI frontend only"
dir: cmd/bugseti/frontend
cmds:
- npm install
- npm run build
# --- Multi-repo (when in workspace) ---
dev:health:
desc: "Check health of all repos"
cmds:
- core dev health
dev:work:
desc: "Full workflow: status, commit, push"
cmds:
- core dev work
dev:status:
desc: "Show status of all repos"
cmds:
- core dev work --status

31
cmd/bugseti/.gitignore vendored Normal file
View file

@ -0,0 +1,31 @@
# Build output
bin/
frontend/dist/
frontend/node_modules/
frontend/.angular/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test
*.test
*.out
coverage/
# Wails
wails.json

186
cmd/bugseti/README.md Normal file
View file

@ -0,0 +1,186 @@
# BugSETI
**Distributed Bug Fixing - like SETI@home but for code**
BugSETI is a system tray application that helps developers contribute to open source by fixing bugs in their spare CPU cycles. It fetches issues from GitHub repositories, prepares context using AI, and guides you through the fix-and-submit workflow.
## Features
- **System Tray Integration**: Runs quietly in the background, ready when you are
- **Issue Queue**: Automatically fetches and queues issues from configured repositories
- **AI Context Seeding**: Prepares relevant code context for each issue using pattern matching
- **Workbench UI**: Full-featured interface for reviewing issues and submitting fixes
- **Automated PR Submission**: Streamlined workflow from fix to pull request
- **Stats & Leaderboard**: Track your contributions and compete with the community
## Installation
### From Source
```bash
# Clone the repository
git clone https://forge.lthn.ai/core/cli.git
cd core
# Build BugSETI
task bugseti:build
# The binary will be in build/bin/bugseti
```
### Prerequisites
- Go 1.25 or later
- Node.js 18+ and npm (for frontend)
- GitHub CLI (`gh`) authenticated
- Chrome/Chromium (optional, for webview features)
## Configuration
On first launch, BugSETI will show an onboarding wizard to configure:
1. **GitHub Token**: For fetching issues and submitting PRs
2. **Repositories**: Which repos to fetch issues from
3. **Filters**: Issue labels, difficulty levels, languages
4. **Notifications**: How to alert you about new issues
### Configuration File
Settings are stored in `~/.config/bugseti/config.json`:
```json
{
"github_token": "ghp_...",
"repositories": [
"host-uk/core",
"example/repo"
],
"filters": {
"labels": ["good first issue", "help wanted", "bug"],
"languages": ["go", "typescript"],
"max_age_days": 30
},
"notifications": {
"enabled": true,
"sound": true
},
"fetch_interval_minutes": 30
}
```
## Usage
### Starting BugSETI
```bash
# Run the application
./bugseti
# Or use task runner
task bugseti:run
```
The app will appear in your system tray. Click the icon to see the quick menu or open the workbench.
### Workflow
1. **Browse Issues**: Click the tray icon to see available issues
2. **Select an Issue**: Choose one to work on from the queue
3. **Review Context**: BugSETI shows relevant files and patterns
4. **Fix the Bug**: Make your changes in your preferred editor
5. **Submit PR**: Use the workbench to create and submit your pull request
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+Shift+B` | Open workbench |
| `Ctrl+Shift+N` | Next issue |
| `Ctrl+Shift+S` | Submit PR |
## Architecture
```
cmd/bugseti/
main.go # Application entry point
tray.go # System tray service
icons/ # Tray icons (light/dark/template)
frontend/ # Angular frontend
src/
app/
tray/ # Tray panel component
workbench/ # Main workbench
settings/ # Settings panel
onboarding/ # First-run wizard
internal/bugseti/
config.go # Configuration service
fetcher.go # GitHub issue fetcher
queue.go # Issue queue management
seeder.go # Context seeding via AI
submit.go # PR submission
notify.go # Notification service
stats.go # Statistics tracking
```
## Contributing
We welcome contributions! Here's how to get involved:
### Development Setup
```bash
# Install dependencies
cd cmd/bugseti/frontend
npm install
# Run in development mode
task bugseti:dev
```
### Running Tests
```bash
# Go tests
go test ./cmd/bugseti/... ./internal/bugseti/...
# Frontend tests
cd cmd/bugseti/frontend
npm test
```
### Submitting Changes
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Make your changes and add tests
4. Run the test suite: `task test`
5. Submit a pull request
### Code Style
- Go: Follow standard Go conventions, run `go fmt`
- TypeScript/Angular: Follow Angular style guide
- Commits: Use conventional commit messages
## Roadmap
- [ ] Auto-update mechanism
- [ ] Team/organization support
- [ ] Integration with more issue trackers (GitLab, Jira)
- [ ] AI-assisted code review
- [ ] Mobile companion app
## License
MIT License - see [LICENSE](../../LICENSE) for details.
## Acknowledgments
- Inspired by SETI@home and distributed computing projects
- Built with [Wails v3](https://wails.io/) for native desktop integration
- Uses [Angular](https://angular.io/) for the frontend
---
**Happy Bug Hunting!**

134
cmd/bugseti/Taskfile.yml Normal file
View file

@ -0,0 +1,134 @@
version: '3'
includes:
common: ./build/Taskfile.yml
windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml
vars:
APP_NAME: "bugseti"
BIN_DIR: "bin"
VITE_PORT: '{{.WAILS_VITE_PORT | default 9246}}'
tasks:
build:
summary: Builds the application
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
cmds:
- task: "{{OS}}:package"
run:
summary: Runs the application
cmds:
- task: "{{OS}}:run"
dev:
summary: Runs the application in development mode
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
build:all:
summary: Builds for all platforms
cmds:
- task: darwin:build
vars:
PRODUCTION: "true"
- task: linux:build
vars:
PRODUCTION: "true"
- task: windows:build
vars:
PRODUCTION: "true"
package:all:
summary: Packages for all platforms
cmds:
- task: darwin:package
- task: linux:package
- task: windows:package
clean:
summary: Cleans build artifacts
cmds:
- rm -rf bin/
- rm -rf frontend/dist/
- rm -rf frontend/node_modules/
# Release targets
release:stable:
summary: Creates a stable release tag
desc: |
Creates a stable release tag (bugseti-vX.Y.Z).
Usage: task release:stable VERSION=1.0.0
preconditions:
- sh: '[ -n "{{.VERSION}}" ]'
msg: "VERSION is required. Usage: task release:stable VERSION=1.0.0"
cmds:
- git tag -a "bugseti-v{{.VERSION}}" -m "BugSETI v{{.VERSION}} stable release"
- echo "Created tag bugseti-v{{.VERSION}}"
- echo "To push: git push origin bugseti-v{{.VERSION}}"
release:beta:
summary: Creates a beta release tag
desc: |
Creates a beta release tag (bugseti-vX.Y.Z-beta.N).
Usage: task release:beta VERSION=1.0.0 BETA=1
preconditions:
- sh: '[ -n "{{.VERSION}}" ]'
msg: "VERSION is required. Usage: task release:beta VERSION=1.0.0 BETA=1"
- sh: '[ -n "{{.BETA}}" ]'
msg: "BETA number is required. Usage: task release:beta VERSION=1.0.0 BETA=1"
cmds:
- git tag -a "bugseti-v{{.VERSION}}-beta.{{.BETA}}" -m "BugSETI v{{.VERSION}} beta {{.BETA}}"
- echo "Created tag bugseti-v{{.VERSION}}-beta.{{.BETA}}"
- echo "To push: git push origin bugseti-v{{.VERSION}}-beta.{{.BETA}}"
release:nightly:
summary: Creates a nightly release tag
desc: Creates a nightly release tag (bugseti-nightly-YYYYMMDD)
vars:
DATE:
sh: date -u +%Y%m%d
cmds:
- git tag -a "bugseti-nightly-{{.DATE}}" -m "BugSETI nightly build {{.DATE}}"
- echo "Created tag bugseti-nightly-{{.DATE}}"
- echo "To push: git push origin bugseti-nightly-{{.DATE}}"
release:push:
summary: Pushes the latest release tag
desc: |
Pushes the most recent bugseti-* tag to origin.
Usage: task release:push
vars:
TAG:
sh: git tag -l 'bugseti-*' | sort -V | tail -1
preconditions:
- sh: '[ -n "{{.TAG}}" ]'
msg: "No bugseti-* tags found"
cmds:
- echo "Pushing tag {{.TAG}}..."
- git push origin {{.TAG}}
- echo "Tag {{.TAG}} pushed. GitHub Actions will build and release."
release:list:
summary: Lists all BugSETI release tags
cmds:
- echo "=== BugSETI Release Tags ==="
- git tag -l 'bugseti-*' | sort -V
version:
summary: Shows current version info
cmds:
- |
echo "=== BugSETI Version Info ==="
echo "Latest stable tag:"
git tag -l 'bugseti-v*' | grep -v beta | sort -V | tail -1 || echo " (none)"
echo "Latest beta tag:"
git tag -l 'bugseti-v*-beta.*' | sort -V | tail -1 || echo " (none)"
echo "Latest nightly tag:"
git tag -l 'bugseti-nightly-*' | sort -V | tail -1 || echo " (none)"

View file

@ -0,0 +1,90 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
dir: frontend
sources:
- package.json
- package-lock.json
generates:
- node_modules/*
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install
build:frontend:
label: build:frontend (PRODUCTION={{.PRODUCTION}})
summary: Build the frontend project
dir: frontend
sources:
- "**/*"
generates:
- dist/**/*
deps:
- task: install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
cmds:
- npm run {{.BUILD_COMMAND}} -q
env:
PRODUCTION: '{{.PRODUCTION | default "false"}}'
vars:
BUILD_COMMAND: '{{if eq .PRODUCTION "true"}}build{{else}}build:dev{{end}}'
generate:bindings:
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
summary: Generates bindings for the frontend
deps:
- task: go:mod:tidy
sources:
- "**/*.[jt]s"
- exclude: frontend/**/*
- frontend/bindings/**/*
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=false -ts -i
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` files from an image
dir: build
sources:
- "appicon.png"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
dev:frontend:
summary: Runs the frontend in development mode
dir: frontend
deps:
- task: install:frontend:deps
cmds:
- npm run dev -- --port {{.VITE_PORT}}
vars:
VITE_PORT: '{{.VITE_PORT | default "5173"}}'
update:build-assets:
summary: Updates the build assets
dir: build
preconditions:
- sh: '[ -n "{{.APP_NAME}}" ]'
msg: "APP_NAME variable is required"
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .

View file

@ -0,0 +1,38 @@
# BugSETI Wails v3 Build Configuration
version: '3'
# Build metadata
info:
companyName: "Lethean"
productName: "BugSETI"
productIdentifier: "io.lethean.bugseti"
description: "Distributed Bug Fixing - like SETI@home but for code"
copyright: "Copyright 2026 Lethean"
comments: "Distributed OSS bug fixing application"
version: "0.1.0"
# Dev mode configuration
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
watched_extension:
- "*.go"
git_ignore: true
executes:
- cmd: go build -buildvcs=false -gcflags=all=-l -o bin/bugseti .
type: blocking
- cmd: cd frontend && npx ng serve --port ${WAILS_FRONTEND_PORT:-9246}
type: background
- cmd: bin/bugseti
type: primary

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>BugSETI (Dev)</string>
<key>CFBundleExecutable</key>
<string>bugseti</string>
<key>CFBundleIdentifier</key>
<string>io.lethean.bugseti.dev</string>
<key>CFBundleVersion</key>
<string>0.1.0-dev</string>
<key>CFBundleGetInfoString</key>
<string>Distributed Bug Fixing - like SETI@home but for code (Development)</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0-dev</string>
<key>CFBundleIconFile</key>
<string>icons.icns</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSUIElement</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>BugSETI</string>
<key>CFBundleExecutable</key>
<string>bugseti</string>
<key>CFBundleIdentifier</key>
<string>io.lethean.bugseti</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>Distributed Bug Fixing - like SETI@home but for code</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleIconFile</key>
<string>icons.icns</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSUIElement</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,84 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Creates a production build of the application
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=10.15"
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
MACOSX_DEPLOYMENT_TARGET: "10.15"
PRODUCTION: '{{.PRODUCTION | default "false"}}'
build:universal:
summary: Builds darwin universal binary (arm64 + amd64)
deps:
- task: build
vars:
ARCH: amd64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
PRODUCTION: '{{.PRODUCTION | default "true"}}'
- task: build
vars:
ARCH: arm64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
PRODUCTION: '{{.PRODUCTION | default "true"}}'
cmds:
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
package:
summary: Packages a production build of the application into a `.app` bundle
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: create:app:bundle
package:universal:
summary: Packages darwin universal binary (arm64 + amd64)
deps:
- task: build:universal
cmds:
- task: create:app:bundle
create:app:bundle:
summary: Creates an `.app` bundle
cmds:
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources}
- cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS
- cp build/darwin/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents
- codesign --force --deep --sign - {{.BIN_DIR}}/{{.APP_NAME}}.app
run:
deps:
- task: build
cmds:
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/{MacOS,Resources}
- cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS
- cp build/darwin/Info.dev.plist {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist
- codesign --force --deep --sign - {{.BIN_DIR}}/{{.APP_NAME}}.dev.app
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}'

View file

@ -0,0 +1,103 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Builds the application for Linux
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application for Linux
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: create:appimage
- task: create:deb
- task: create:rpm
create:appimage:
summary: Creates an AppImage
dir: build/linux/appimage
deps:
- task: build
vars:
PRODUCTION: "true"
- task: generate:dotdesktop
cmds:
- cp {{.APP_BINARY}} {{.APP_NAME}}
- cp ../../appicon.png {{.APP_NAME}}.png
- wails3 generate appimage -binary {{.APP_NAME}} -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../../bin/{{.APP_NAME}}'
ICON: '{{.APP_NAME}}.png'
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../../bin'
create:deb:
summary: Creates a deb package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:deb
create:rpm:
summary: Creates a rpm package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:rpm
generate:deb:
summary: Creates a deb package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:rpm:
summary: Creates a rpm package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:dotdesktop:
summary: Generates a `.desktop` file
dir: build
cmds:
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop -categories "{{.CATEGORIES}}"
vars:
APP_NAME: 'BugSETI'
EXEC: '{{.APP_NAME}}'
ICON: 'bugseti'
CATEGORIES: 'Development;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View file

@ -0,0 +1,34 @@
# nfpm configuration for BugSETI
name: "bugseti"
arch: "${GOARCH}"
platform: "linux"
version: "0.1.0"
section: "devel"
priority: "optional"
maintainer: "Lethean <developers@lethean.io>"
description: |
BugSETI - Distributed Bug Fixing
Like SETI@home but for code. Install the system tray app,
it pulls OSS issues from GitHub, AI prepares context,
you fix bugs, and it auto-submits PRs.
vendor: "Lethean"
homepage: "https://forge.lthn.ai/core/cli"
license: "MIT"
contents:
- src: ./bin/bugseti
dst: /usr/bin/bugseti
- src: ./build/linux/bugseti.desktop
dst: /usr/share/applications/bugseti.desktop
- src: ./build/appicon.png
dst: /usr/share/icons/hicolor/256x256/apps/bugseti.png
overrides:
deb:
dependencies:
- libwebkit2gtk-4.1-0
- libgtk-3-0
rpm:
dependencies:
- webkit2gtk4.1
- gtk3

View file

@ -0,0 +1,49 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Builds the application for Windows
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
env:
GOOS: windows
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application for Windows
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: create:nsis
create:nsis:
summary: Creates an NSIS installer
cmds:
- wails3 tool package -name {{.APP_NAME}} -format nsis -config ./build/windows/nsis/installer.nsi -out {{.ROOT_DIR}}/bin
create:msi:
summary: Creates an MSI installer
cmds:
- wails3 tool package -name {{.APP_NAME}} -format msi -config ./build/windows/wix/main.wxs -out {{.ROOT_DIR}}/bin
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'

View file

@ -0,0 +1,94 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"bugseti": {
"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/bugseti",
"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": "6kb",
"maximumError": "10kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "bugseti:build:production"
},
"development": {
"buildTarget": "bugseti: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": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

15012
cmd/bugseti/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
{
"name": "bugseti",
"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 = 'BugSETI';
}

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,29 @@
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: 'workbench',
loadComponent: () => import('./workbench/workbench.component').then(m => m.WorkbenchComponent)
},
{
path: 'settings',
loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent)
},
{
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,189 @@
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;
streamUrl = '';
constructor(private sanitizer: DomSanitizer) {
this.safeWebUrl = this.sanitizer.bypassSecurityTrustResourceUrl('https://media.lthn.ai/web/index.html');
}
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,457 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-onboarding',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="onboarding">
<div class="onboarding-content">
<!-- Step 1: Welcome -->
<div class="step" *ngIf="step === 1">
<div class="step-icon">B</div>
<h1>Welcome to BugSETI</h1>
<p class="subtitle">Distributed Bug Fixing - like SETI&#64;home but for code</p>
<div class="feature-list">
<div class="feature">
<span class="feature-icon">[1]</span>
<div>
<strong>Find Issues</strong>
<p>We pull beginner-friendly issues from OSS projects you care about.</p>
</div>
</div>
<div class="feature">
<span class="feature-icon">[2]</span>
<div>
<strong>Get Context</strong>
<p>AI prepares relevant context to help you understand each issue.</p>
</div>
</div>
<div class="feature">
<span class="feature-icon">[3]</span>
<div>
<strong>Submit PRs</strong>
<p>Fix bugs and submit PRs with minimal friction.</p>
</div>
</div>
</div>
<button class="btn btn--primary btn--lg" (click)="nextStep()">Get Started</button>
</div>
<!-- Step 2: GitHub Auth -->
<div class="step" *ngIf="step === 2">
<h2>Connect GitHub</h2>
<p>BugSETI uses the GitHub CLI (gh) to interact with repositories.</p>
<div class="auth-status" [class.auth-success]="ghAuthenticated">
<span class="status-icon">{{ ghAuthenticated ? '[OK]' : '[!]' }}</span>
<span>{{ ghAuthenticated ? 'GitHub CLI authenticated' : 'GitHub CLI not detected' }}</span>
</div>
<div class="auth-instructions" *ngIf="!ghAuthenticated">
<p>To authenticate with GitHub CLI, run:</p>
<code>gh auth login</code>
<p class="note">After authenticating, click "Check Again".</p>
</div>
<div class="step-actions">
<button class="btn btn--secondary" (click)="checkGhAuth()">Check Again</button>
<button class="btn btn--primary" (click)="nextStep()" [disabled]="!ghAuthenticated">Continue</button>
</div>
</div>
<!-- Step 3: Select Repos -->
<div class="step" *ngIf="step === 3">
<h2>Choose Repositories</h2>
<p>Add repositories you want to contribute to.</p>
<div class="repo-input">
<input type="text" class="form-input" [(ngModel)]="newRepo"
placeholder="owner/repo (e.g., facebook/react)">
<button class="btn btn--secondary" (click)="addRepo()" [disabled]="!newRepo">Add</button>
</div>
<div class="selected-repos" *ngIf="selectedRepos.length">
<h3>Selected Repositories</h3>
<div class="repo-chip" *ngFor="let repo of selectedRepos; let i = index">
{{ repo }}
<button class="repo-remove" (click)="removeRepo(i)">x</button>
</div>
</div>
<div class="suggested-repos">
<h3>Suggested Repositories</h3>
<div class="suggested-list">
<button class="suggestion" *ngFor="let repo of suggestedRepos" (click)="addSuggested(repo)">
{{ repo }}
</button>
</div>
</div>
<div class="step-actions">
<button class="btn btn--secondary" (click)="prevStep()">Back</button>
<button class="btn btn--primary" (click)="nextStep()" [disabled]="selectedRepos.length === 0">Continue</button>
</div>
</div>
<!-- Step 4: Complete -->
<div class="step" *ngIf="step === 4">
<div class="complete-icon">[OK]</div>
<h2>You're All Set!</h2>
<p>BugSETI is ready to help you contribute to open source.</p>
<div class="summary">
<p><strong>{{ selectedRepos.length }}</strong> repositories selected</p>
<p>Looking for issues with these labels:</p>
<div class="label-list">
<span class="badge badge--primary">good first issue</span>
<span class="badge badge--primary">help wanted</span>
<span class="badge badge--primary">beginner-friendly</span>
</div>
</div>
<button class="btn btn--success btn--lg" (click)="complete()">Start Finding Issues</button>
</div>
</div>
<div class="step-indicators">
<span class="indicator" [class.active]="step >= 1" [class.current]="step === 1"></span>
<span class="indicator" [class.active]="step >= 2" [class.current]="step === 2"></span>
<span class="indicator" [class.active]="step >= 3" [class.current]="step === 3"></span>
<span class="indicator" [class.active]="step >= 4" [class.current]="step === 4"></span>
</div>
</div>
`,
styles: [`
.onboarding {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
}
.onboarding-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
}
.step {
max-width: 500px;
text-align: center;
}
.step-icon, .complete-icon {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto var(--spacing-lg);
background: linear-gradient(135deg, var(--accent-primary), var(--accent-success));
border-radius: var(--radius-lg);
font-size: 32px;
font-weight: bold;
color: white;
}
.complete-icon {
background: var(--accent-success);
}
h1 {
font-size: 28px;
margin-bottom: var(--spacing-sm);
}
h2 {
font-size: 24px;
margin-bottom: var(--spacing-sm);
}
.subtitle {
color: var(--text-secondary);
margin-bottom: var(--spacing-xl);
}
.feature-list {
text-align: left;
margin-bottom: var(--spacing-xl);
}
.feature {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
padding: var(--spacing-md);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
}
.feature-icon {
font-family: var(--font-mono);
color: var(--accent-primary);
font-weight: bold;
}
.feature strong {
display: block;
margin-bottom: var(--spacing-xs);
}
.feature p {
color: var(--text-secondary);
font-size: 13px;
margin: 0;
}
.auth-status {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background-color: var(--bg-tertiary);
border-radius: var(--radius-md);
margin: var(--spacing-lg) 0;
}
.auth-status.auth-success {
background-color: rgba(63, 185, 80, 0.15);
color: var(--accent-success);
}
.status-icon {
font-family: var(--font-mono);
font-weight: bold;
}
.auth-instructions {
text-align: left;
padding: var(--spacing-md);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
}
.auth-instructions code {
display: block;
margin: var(--spacing-md) 0;
padding: var(--spacing-md);
background-color: var(--bg-tertiary);
}
.auth-instructions .note {
color: var(--text-muted);
font-size: 13px;
margin: 0;
}
.step-actions {
display: flex;
gap: var(--spacing-md);
justify-content: center;
margin-top: var(--spacing-xl);
}
.repo-input {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
}
.repo-input .form-input {
flex: 1;
}
.selected-repos, .suggested-repos {
text-align: left;
margin-bottom: var(--spacing-lg);
}
.selected-repos h3, .suggested-repos h3 {
font-size: 12px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: var(--spacing-sm);
}
.repo-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
margin-right: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
}
.repo-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0;
}
.suggested-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.suggestion {
padding: var(--spacing-xs) var(--spacing-sm);
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
}
.suggestion:hover {
background-color: var(--bg-secondary);
border-color: var(--accent-primary);
}
.summary {
padding: var(--spacing-lg);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-xl);
}
.summary p {
margin-bottom: var(--spacing-sm);
}
.label-list {
display: flex;
gap: var(--spacing-xs);
justify-content: center;
flex-wrap: wrap;
}
.step-indicators {
display: flex;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg);
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--border-color);
}
.indicator.active {
background-color: var(--accent-primary);
}
.indicator.current {
width: 24px;
border-radius: 4px;
}
.btn--lg {
padding: var(--spacing-md) var(--spacing-xl);
font-size: 16px;
}
`]
})
export class OnboardingComponent {
step = 1;
ghAuthenticated = false;
newRepo = '';
selectedRepos: string[] = [];
suggestedRepos = [
'facebook/react',
'microsoft/vscode',
'golang/go',
'kubernetes/kubernetes',
'rust-lang/rust',
'angular/angular',
'nodejs/node',
'python/cpython'
];
ngOnInit() {
this.checkGhAuth();
}
nextStep() {
if (this.step < 4) {
this.step++;
}
}
prevStep() {
if (this.step > 1) {
this.step--;
}
}
async checkGhAuth() {
try {
// Check if gh CLI is authenticated
// In a real implementation, this would call the backend
this.ghAuthenticated = true; // Assume authenticated for demo
} catch (err) {
this.ghAuthenticated = false;
}
}
addRepo() {
if (this.newRepo && !this.selectedRepos.includes(this.newRepo)) {
this.selectedRepos.push(this.newRepo);
this.newRepo = '';
}
}
removeRepo(index: number) {
this.selectedRepos.splice(index, 1);
}
addSuggested(repo: string) {
if (!this.selectedRepos.includes(repo)) {
this.selectedRepos.push(repo);
}
}
async complete() {
try {
// Save repos to config
if ((window as any).go?.main?.ConfigService?.SetConfig) {
const config = await (window as any).go.main.ConfigService.GetConfig() || {};
config.watchedRepos = this.selectedRepos;
await (window as any).go.main.ConfigService.SetConfig(config);
}
// Mark onboarding as complete
if ((window as any).go?.main?.TrayService?.CompleteOnboarding) {
await (window as any).go.main.TrayService.CompleteOnboarding();
}
// Close onboarding window and start fetching
if ((window as any).wails?.Window) {
(window as any).wails.Window.GetByName('onboarding').then((w: any) => w.Hide());
}
// Start fetching
if ((window as any).go?.main?.TrayService?.StartFetching) {
await (window as any).go.main.TrayService.StartFetching();
}
} catch (err) {
console.error('Failed to complete onboarding:', err);
}
}
}

View file

@ -0,0 +1,407 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
interface Config {
watchedRepos: string[];
labels: string[];
fetchIntervalMinutes: number;
notificationsEnabled: boolean;
notificationSound: boolean;
workspaceDir: string;
marketplaceMcpRoot: string;
theme: string;
autoSeedContext: boolean;
workHours?: {
enabled: boolean;
startHour: number;
endHour: number;
days: number[];
timezone: string;
};
}
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="settings">
<header class="settings-header">
<h1>Settings</h1>
<button class="btn btn--primary" (click)="saveSettings()">Save</button>
</header>
<div class="settings-content">
<section class="settings-section">
<h2>Repositories</h2>
<p class="section-description">Add GitHub repositories to watch for issues.</p>
<div class="repo-list">
<div class="repo-item" *ngFor="let repo of config.watchedRepos; let i = index">
<span>{{ repo }}</span>
<button class="btn btn--danger btn--sm" (click)="removeRepo(i)">Remove</button>
</div>
</div>
<div class="add-repo">
<input type="text" class="form-input" [(ngModel)]="newRepo"
placeholder="owner/repo (e.g., facebook/react)">
<button class="btn btn--secondary" (click)="addRepo()" [disabled]="!newRepo">Add</button>
</div>
</section>
<section class="settings-section">
<h2>Issue Labels</h2>
<p class="section-description">Filter issues by these labels.</p>
<div class="label-list">
<span class="label-chip" *ngFor="let label of config.labels; let i = index">
{{ label }}
<button class="label-remove" (click)="removeLabel(i)">x</button>
</span>
</div>
<div class="add-label">
<input type="text" class="form-input" [(ngModel)]="newLabel"
placeholder="Add label (e.g., good first issue)">
<button class="btn btn--secondary" (click)="addLabel()" [disabled]="!newLabel">Add</button>
</div>
</section>
<section class="settings-section">
<h2>Fetch Settings</h2>
<div class="form-group">
<label class="form-label">Fetch Interval (minutes)</label>
<input type="number" class="form-input" [(ngModel)]="config.fetchIntervalMinutes" min="5" max="120">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" [(ngModel)]="config.autoSeedContext">
<span>Auto-prepare AI context for issues</span>
</label>
</div>
</section>
<section class="settings-section">
<h2>Work Hours</h2>
<p class="section-description">Only fetch issues during these hours.</p>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" [(ngModel)]="config.workHours!.enabled">
<span>Enable work hours</span>
</label>
</div>
<div class="work-hours-config" *ngIf="config.workHours?.enabled">
<div class="form-group">
<label class="form-label">Start Hour</label>
<select class="form-select" [(ngModel)]="config.workHours!.startHour">
<option *ngFor="let h of hours" [value]="h">{{ h }}:00</option>
</select>
</div>
<div class="form-group">
<label class="form-label">End Hour</label>
<select class="form-select" [(ngModel)]="config.workHours!.endHour">
<option *ngFor="let h of hours" [value]="h">{{ h }}:00</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Days</label>
<div class="day-checkboxes">
<label class="checkbox-label" *ngFor="let day of days; let i = index">
<input type="checkbox" [checked]="isDaySelected(i)" (change)="toggleDay(i)">
<span>{{ day }}</span>
</label>
</div>
</div>
</div>
</section>
<section class="settings-section">
<h2>Notifications</h2>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" [(ngModel)]="config.notificationsEnabled">
<span>Enable desktop notifications</span>
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" [(ngModel)]="config.notificationSound">
<span>Play notification sounds</span>
</label>
</div>
</section>
<section class="settings-section">
<h2>Appearance</h2>
<div class="form-group">
<label class="form-label">Theme</label>
<select class="form-select" [(ngModel)]="config.theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
</select>
</div>
</section>
<section class="settings-section">
<h2>Storage</h2>
<div class="form-group">
<label class="form-label">Workspace Directory</label>
<input type="text" class="form-input" [(ngModel)]="config.workspaceDir"
placeholder="Leave empty for default">
</div>
<div class="form-group">
<label class="form-label">Marketplace MCP Root</label>
<input type="text" class="form-input" [(ngModel)]="config.marketplaceMcpRoot"
placeholder="Path to core-agent (optional)">
<p class="section-description">Override the marketplace MCP root. Leave empty to auto-detect.</p>
</div>
</section>
</div>
</div>
`,
styles: [`
.settings {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-secondary);
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-lg);
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
}
.settings-header h1 {
font-size: 18px;
margin: 0;
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
}
.settings-section {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.settings-section h2 {
font-size: 16px;
margin-bottom: var(--spacing-xs);
}
.section-description {
color: var(--text-muted);
font-size: 13px;
margin-bottom: var(--spacing-md);
}
.repo-list, .label-list {
margin-bottom: var(--spacing-md);
}
.repo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-xs);
}
.add-repo, .add-label {
display: flex;
gap: var(--spacing-sm);
}
.add-repo .form-input, .add-label .form-input {
flex: 1;
}
.label-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.label-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background-color: var(--bg-tertiary);
border-radius: 999px;
font-size: 13px;
}
.label-remove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
}
.label-remove:hover {
color: var(--accent-danger);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
}
.work-hours-config {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
}
.day-checkboxes {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.day-checkboxes .checkbox-label {
width: auto;
}
.btn--sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 12px;
}
`]
})
export class SettingsComponent implements OnInit {
config: Config = {
watchedRepos: [],
labels: ['good first issue', 'help wanted'],
fetchIntervalMinutes: 15,
notificationsEnabled: true,
notificationSound: true,
workspaceDir: '',
marketplaceMcpRoot: '',
theme: 'dark',
autoSeedContext: true,
workHours: {
enabled: false,
startHour: 9,
endHour: 17,
days: [1, 2, 3, 4, 5],
timezone: ''
}
};
newRepo = '';
newLabel = '';
hours = Array.from({ length: 24 }, (_, i) => i);
days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
ngOnInit() {
this.loadConfig();
}
async loadConfig() {
try {
if ((window as any).go?.main?.ConfigService?.GetConfig) {
this.config = await (window as any).go.main.ConfigService.GetConfig();
if (!this.config.workHours) {
this.config.workHours = {
enabled: false,
startHour: 9,
endHour: 17,
days: [1, 2, 3, 4, 5],
timezone: ''
};
}
}
} catch (err) {
console.error('Failed to load config:', err);
}
}
async saveSettings() {
try {
if ((window as any).go?.main?.ConfigService?.SetConfig) {
await (window as any).go.main.ConfigService.SetConfig(this.config);
alert('Settings saved!');
}
} catch (err) {
console.error('Failed to save config:', err);
alert('Failed to save settings.');
}
}
addRepo() {
if (this.newRepo && !this.config.watchedRepos.includes(this.newRepo)) {
this.config.watchedRepos.push(this.newRepo);
this.newRepo = '';
}
}
removeRepo(index: number) {
this.config.watchedRepos.splice(index, 1);
}
addLabel() {
if (this.newLabel && !this.config.labels.includes(this.newLabel)) {
this.config.labels.push(this.newLabel);
this.newLabel = '';
}
}
removeLabel(index: number) {
this.config.labels.splice(index, 1);
}
isDaySelected(day: number): boolean {
return this.config.workHours?.days.includes(day) || false;
}
toggleDay(day: number) {
if (!this.config.workHours) return;
const index = this.config.workHours.days.indexOf(day);
if (index === -1) {
this.config.workHours.days.push(day);
} else {
this.config.workHours.days.splice(index, 1);
}
}
}

View file

@ -0,0 +1,556 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
interface UpdateSettings {
channel: string;
autoUpdate: boolean;
checkInterval: number;
lastCheck: string;
}
interface VersionInfo {
version: string;
channel: string;
commit: string;
buildTime: string;
goVersion: string;
os: string;
arch: string;
}
interface ChannelInfo {
id: string;
name: string;
description: string;
}
interface UpdateCheckResult {
available: boolean;
currentVersion: string;
latestVersion: string;
release?: {
version: string;
channel: string;
tag: string;
name: string;
body: string;
publishedAt: string;
htmlUrl: string;
};
error?: string;
checkedAt: string;
}
@Component({
selector: 'app-updates-settings',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="updates-settings">
<div class="current-version">
<div class="version-badge">
<span class="version-number">{{ versionInfo?.version || 'Unknown' }}</span>
<span class="channel-badge" [class]="'channel-' + (versionInfo?.channel || 'dev')">
{{ versionInfo?.channel || 'dev' }}
</span>
</div>
<p class="build-info" *ngIf="versionInfo">
Built {{ versionInfo.buildTime | date:'medium' }} ({{ versionInfo.commit?.substring(0, 7) }})
</p>
</div>
<div class="update-check" *ngIf="checkResult">
<div class="update-available" *ngIf="checkResult.available">
<div class="update-icon">!</div>
<div class="update-info">
<h4>Update Available</h4>
<p>Version {{ checkResult.latestVersion }} is available</p>
<a *ngIf="checkResult.release?.htmlUrl"
[href]="checkResult.release.htmlUrl"
target="_blank"
class="release-link">
View Release Notes
</a>
</div>
<button class="btn btn--primary" (click)="installUpdate()" [disabled]="isInstalling">
{{ isInstalling ? 'Installing...' : 'Install Update' }}
</button>
</div>
<div class="up-to-date" *ngIf="!checkResult.available && !checkResult.error">
<div class="check-icon">OK</div>
<div class="check-info">
<h4>Up to Date</h4>
<p>You're running the latest version</p>
<span class="last-check" *ngIf="checkResult.checkedAt">
Last checked: {{ checkResult.checkedAt | date:'short' }}
</span>
</div>
</div>
<div class="check-error" *ngIf="checkResult.error">
<div class="error-icon">X</div>
<div class="error-info">
<h4>Check Failed</h4>
<p>{{ checkResult.error }}</p>
</div>
</div>
</div>
<div class="check-button-row">
<button class="btn btn--secondary" (click)="checkForUpdates()" [disabled]="isChecking">
{{ isChecking ? 'Checking...' : 'Check for Updates' }}
</button>
</div>
<div class="settings-section">
<h3>Update Channel</h3>
<p class="section-description">Choose which release channel to follow for updates.</p>
<div class="channel-options">
<label class="channel-option" *ngFor="let channel of channels"
[class.selected]="settings.channel === channel.id">
<input type="radio"
[name]="'channel'"
[value]="channel.id"
[(ngModel)]="settings.channel"
(change)="onSettingsChange()">
<div class="channel-content">
<span class="channel-name">{{ channel.name }}</span>
<span class="channel-desc">{{ channel.description }}</span>
</div>
</label>
</div>
</div>
<div class="settings-section">
<h3>Automatic Updates</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox"
[(ngModel)]="settings.autoUpdate"
(change)="onSettingsChange()">
<span>Automatically install updates</span>
</label>
<p class="setting-hint">When enabled, updates will be installed automatically on app restart.</p>
</div>
<div class="form-group">
<label class="form-label">Check Interval</label>
<select class="form-select"
[(ngModel)]="settings.checkInterval"
(change)="onSettingsChange()">
<option [value]="0">Disabled</option>
<option [value]="1">Every hour</option>
<option [value]="6">Every 6 hours</option>
<option [value]="12">Every 12 hours</option>
<option [value]="24">Daily</option>
<option [value]="168">Weekly</option>
</select>
</div>
</div>
<div class="save-status" *ngIf="saveMessage">
<span [class.error]="saveError">{{ saveMessage }}</span>
</div>
</div>
`,
styles: [`
.updates-settings {
padding: var(--spacing-md);
}
.current-version {
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
text-align: center;
}
.version-badge {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
.version-number {
font-size: 24px;
font-weight: 600;
}
.channel-badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.channel-stable { background: var(--accent-success); color: white; }
.channel-beta { background: var(--accent-warning); color: black; }
.channel-nightly { background: var(--accent-purple, #8b5cf6); color: white; }
.channel-dev { background: var(--text-muted); color: var(--bg-primary); }
.build-info {
color: var(--text-muted);
font-size: 12px;
margin: 0;
}
.update-check {
margin-bottom: var(--spacing-lg);
}
.update-available, .up-to-date, .check-error {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
border-radius: var(--radius-md);
}
.update-available {
background: var(--accent-warning-bg, rgba(245, 158, 11, 0.1));
border: 1px solid var(--accent-warning);
}
.up-to-date {
background: var(--accent-success-bg, rgba(34, 197, 94, 0.1));
border: 1px solid var(--accent-success);
}
.check-error {
background: var(--accent-danger-bg, rgba(239, 68, 68, 0.1));
border: 1px solid var(--accent-danger);
}
.update-icon, .check-icon, .error-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
}
.update-icon { background: var(--accent-warning); color: black; }
.check-icon { background: var(--accent-success); color: white; }
.error-icon { background: var(--accent-danger); color: white; }
.update-info, .check-info, .error-info {
flex: 1;
}
.update-info h4, .check-info h4, .error-info h4 {
margin: 0 0 var(--spacing-xs) 0;
font-size: 14px;
}
.update-info p, .check-info p, .error-info p {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
.release-link {
color: var(--accent-primary);
font-size: 12px;
}
.last-check {
font-size: 11px;
color: var(--text-muted);
}
.check-button-row {
margin-bottom: var(--spacing-lg);
}
.settings-section {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.settings-section h3 {
font-size: 14px;
margin: 0 0 var(--spacing-xs) 0;
}
.section-description {
color: var(--text-muted);
font-size: 12px;
margin-bottom: var(--spacing-md);
}
.channel-options {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.channel-option {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
padding: var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
}
.channel-option:hover {
border-color: var(--accent-primary);
}
.channel-option.selected {
border-color: var(--accent-primary);
background: var(--accent-primary-bg, rgba(59, 130, 246, 0.1));
}
.channel-option input[type="radio"] {
margin-top: 2px;
}
.channel-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.channel-name {
font-weight: 500;
font-size: 14px;
}
.channel-desc {
font-size: 12px;
color: var(--text-muted);
}
.form-group {
margin-bottom: var(--spacing-md);
}
.form-group:last-child {
margin-bottom: 0;
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--spacing-sm);
cursor: pointer;
}
.setting-hint {
color: var(--text-muted);
font-size: 12px;
margin: var(--spacing-xs) 0 0 24px;
}
.form-label {
display: block;
font-size: 13px;
margin-bottom: var(--spacing-xs);
}
.form-select {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
}
.save-status {
text-align: center;
font-size: 13px;
color: var(--accent-success);
}
.save-status .error {
color: var(--accent-danger);
}
.btn {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--radius-md);
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--primary {
background: var(--accent-primary);
color: white;
}
.btn--primary:hover:not(:disabled) {
background: var(--accent-primary-hover, #2563eb);
}
.btn--secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn--secondary:hover:not(:disabled) {
background: var(--bg-secondary);
}
`]
})
export class UpdatesComponent implements OnInit, OnDestroy {
settings: UpdateSettings = {
channel: 'stable',
autoUpdate: false,
checkInterval: 6,
lastCheck: ''
};
versionInfo: VersionInfo | null = null;
checkResult: UpdateCheckResult | null = null;
channels: ChannelInfo[] = [
{ id: 'stable', name: 'Stable', description: 'Production releases - most stable, recommended for most users' },
{ id: 'beta', name: 'Beta', description: 'Pre-release builds - new features being tested before stable release' },
{ id: 'nightly', name: 'Nightly', description: 'Latest development builds - bleeding edge, may be unstable' }
];
isChecking = false;
isInstalling = false;
saveMessage = '';
saveError = false;
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
ngOnInit() {
this.loadSettings();
this.loadVersionInfo();
}
ngOnDestroy() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
}
async loadSettings() {
try {
const wails = (window as any).go?.main;
if (wails?.UpdateService?.GetSettings) {
this.settings = await wails.UpdateService.GetSettings();
} else if (wails?.ConfigService?.GetUpdateSettings) {
this.settings = await wails.ConfigService.GetUpdateSettings();
}
} catch (err) {
console.error('Failed to load update settings:', err);
}
}
async loadVersionInfo() {
try {
const wails = (window as any).go?.main;
if (wails?.VersionService?.GetVersionInfo) {
this.versionInfo = await wails.VersionService.GetVersionInfo();
} else if (wails?.UpdateService?.GetVersionInfo) {
this.versionInfo = await wails.UpdateService.GetVersionInfo();
}
} catch (err) {
console.error('Failed to load version info:', err);
}
}
async checkForUpdates() {
this.isChecking = true;
this.checkResult = null;
try {
const wails = (window as any).go?.main;
if (wails?.UpdateService?.CheckForUpdate) {
this.checkResult = await wails.UpdateService.CheckForUpdate();
}
} catch (err) {
console.error('Failed to check for updates:', err);
this.checkResult = {
available: false,
currentVersion: this.versionInfo?.version || 'unknown',
latestVersion: '',
error: 'Failed to check for updates',
checkedAt: new Date().toISOString()
};
} finally {
this.isChecking = false;
}
}
async installUpdate() {
if (!this.checkResult?.available || !this.checkResult.release) {
return;
}
this.isInstalling = true;
try {
const wails = (window as any).go?.main;
if (wails?.UpdateService?.InstallUpdate) {
await wails.UpdateService.InstallUpdate();
}
} catch (err) {
console.error('Failed to install update:', err);
alert('Failed to install update. Please try again or download manually.');
} finally {
this.isInstalling = false;
}
}
async onSettingsChange() {
// Debounce save
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => this.saveSettings(), 500);
}
async saveSettings() {
try {
const wails = (window as any).go?.main;
if (wails?.UpdateService?.SetSettings) {
await wails.UpdateService.SetSettings(this.settings);
} else if (wails?.ConfigService?.SetUpdateSettings) {
await wails.ConfigService.SetUpdateSettings(this.settings);
}
this.saveMessage = 'Settings saved';
this.saveError = false;
} catch (err) {
console.error('Failed to save update settings:', err);
this.saveMessage = 'Failed to save settings';
this.saveError = true;
}
// Clear message after 2 seconds
setTimeout(() => {
this.saveMessage = '';
}, 2000);
}
}

View file

@ -0,0 +1,303 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
interface TrayStatus {
running: boolean;
currentIssue: string;
queueSize: number;
issuesFixed: number;
prsMerged: number;
}
@Component({
selector: 'app-tray',
standalone: true,
imports: [CommonModule],
template: `
<div class="tray-panel">
<header class="tray-header">
<div class="logo">
<span class="logo-icon">B</span>
<span class="logo-text">BugSETI</span>
</div>
<span class="badge" [class.badge--success]="status.running" [class.badge--warning]="!status.running">
{{ status.running ? 'Running' : 'Paused' }}
</span>
</header>
<section class="stats-grid">
<div class="stat-card">
<span class="stat-value">{{ status.queueSize }}</span>
<span class="stat-label">In Queue</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ status.issuesFixed }}</span>
<span class="stat-label">Fixed</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ status.prsMerged }}</span>
<span class="stat-label">Merged</span>
</div>
</section>
<section class="current-issue" *ngIf="status.currentIssue">
<h3>Current Issue</h3>
<div class="issue-card">
<p class="issue-title">{{ status.currentIssue }}</p>
<div class="issue-actions">
<button class="btn btn--primary btn--sm" (click)="openWorkbench()">
Open Workbench
</button>
<button class="btn btn--secondary btn--sm" (click)="skipIssue()">
Skip
</button>
</div>
</div>
</section>
<section class="current-issue" *ngIf="!status.currentIssue">
<div class="empty-state">
<span class="empty-icon">[ ]</span>
<p>No issue in progress</p>
<button class="btn btn--primary btn--sm" (click)="nextIssue()" [disabled]="status.queueSize === 0">
Get Next Issue
</button>
</div>
</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>
<button class="btn btn--secondary btn--sm" (click)="openSettings()">
Settings
</button>
</footer>
</div>
`,
styles: [`
.tray-panel {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--spacing-md);
background-color: var(--bg-primary);
}
.tray-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
}
.logo {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.logo-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-success));
border-radius: var(--radius-md);
font-weight: bold;
color: white;
}
.logo-text {
font-weight: 600;
font-size: 16px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-sm);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: var(--accent-primary);
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
}
.current-issue {
flex: 1;
margin-bottom: var(--spacing-md);
}
.current-issue h3 {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: var(--spacing-sm);
}
.issue-card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--spacing-md);
}
.issue-title {
font-size: 13px;
line-height: 1.4;
margin-bottom: var(--spacing-sm);
}
.issue-actions {
display: flex;
gap: var(--spacing-sm);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
text-align: center;
}
.empty-icon {
font-size: 32px;
color: var(--text-muted);
margin-bottom: var(--spacing-sm);
}
.empty-state p {
color: var(--text-muted);
margin-bottom: var(--spacing-md);
}
.tray-footer {
display: flex;
gap: var(--spacing-sm);
justify-content: center;
}
.btn--sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 12px;
}
`]
})
export class TrayComponent implements OnInit, OnDestroy {
status: TrayStatus = {
running: false,
currentIssue: '',
queueSize: 0,
issuesFixed: 0,
prsMerged: 0
};
private refreshInterval?: ReturnType<typeof setInterval>;
ngOnInit() {
this.loadStatus();
this.refreshInterval = setInterval(() => this.loadStatus(), 5000);
}
ngOnDestroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
async loadStatus() {
try {
// Call Wails binding when available
if ((window as any).go?.main?.TrayService?.GetStatus) {
this.status = await (window as any).go.main.TrayService.GetStatus();
}
} catch (err) {
console.error('Failed to load status:', err);
}
}
async toggleRunning() {
try {
if (this.status.running) {
if ((window as any).go?.main?.TrayService?.PauseFetching) {
await (window as any).go.main.TrayService.PauseFetching();
}
} else {
if ((window as any).go?.main?.TrayService?.StartFetching) {
await (window as any).go.main.TrayService.StartFetching();
}
}
this.loadStatus();
} catch (err) {
console.error('Failed to toggle running:', err);
}
}
async nextIssue() {
try {
if ((window as any).go?.main?.TrayService?.NextIssue) {
await (window as any).go.main.TrayService.NextIssue();
}
this.loadStatus();
} catch (err) {
console.error('Failed to get next issue:', err);
}
}
async skipIssue() {
try {
if ((window as any).go?.main?.TrayService?.SkipIssue) {
await (window as any).go.main.TrayService.SkipIssue();
}
this.loadStatus();
} catch (err) {
console.error('Failed to skip issue:', err);
}
}
openWorkbench() {
if ((window as any).wails?.Window) {
(window as any).wails.Window.GetByName('workbench').then((w: any) => {
w.Show();
w.Focus();
});
}
}
openSettings() {
if ((window as any).wails?.Window) {
(window as any).wails.Window.GetByName('settings').then((w: any) => {
w.Show();
w.Focus();
});
}
}
openJellyfin() {
window.location.assign('/jellyfin');
}
}

View file

@ -0,0 +1,356 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
interface Issue {
id: string;
number: number;
repo: string;
title: string;
body: string;
url: string;
labels: string[];
author: string;
context?: IssueContext;
}
interface IssueContext {
summary: string;
relevantFiles: string[];
suggestedFix: string;
complexity: string;
estimatedTime: string;
}
@Component({
selector: 'app-workbench',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="workbench">
<header class="workbench-header">
<h1>BugSETI Workbench</h1>
<div class="header-actions">
<button class="btn btn--secondary" (click)="skipIssue()">Skip</button>
<button class="btn btn--success" (click)="submitPR()" [disabled]="!canSubmit">Submit PR</button>
</div>
</header>
<div class="workbench-content" *ngIf="currentIssue">
<aside class="issue-panel">
<div class="card">
<div class="card__header">
<h2 class="card__title">Issue #{{ currentIssue.number }}</h2>
<a [href]="currentIssue.url" target="_blank" class="btn btn--secondary btn--sm">View on GitHub</a>
</div>
<h3>{{ currentIssue.title }}</h3>
<div class="labels">
<span class="badge badge--primary" *ngFor="let label of currentIssue.labels">
{{ label }}
</span>
</div>
<div class="issue-meta">
<span>{{ currentIssue.repo }}</span>
<span>by {{ currentIssue.author }}</span>
</div>
<div class="issue-body">
<pre>{{ currentIssue.body }}</pre>
</div>
</div>
<div class="card" *ngIf="currentIssue.context">
<div class="card__header">
<h2 class="card__title">AI Context</h2>
<span class="badge" [ngClass]="{
'badge--success': currentIssue.context.complexity === 'easy',
'badge--warning': currentIssue.context.complexity === 'medium',
'badge--danger': currentIssue.context.complexity === 'hard'
}">
{{ currentIssue.context.complexity }}
</span>
</div>
<p class="context-summary">{{ currentIssue.context.summary }}</p>
<div class="context-section" *ngIf="currentIssue.context.relevantFiles?.length">
<h4>Relevant Files</h4>
<ul class="file-list">
<li *ngFor="let file of currentIssue.context.relevantFiles">
<code>{{ file }}</code>
</li>
</ul>
</div>
<div class="context-section" *ngIf="currentIssue.context.suggestedFix">
<h4>Suggested Approach</h4>
<p>{{ currentIssue.context.suggestedFix }}</p>
</div>
<div class="context-meta">
<span>Est. time: {{ currentIssue.context.estimatedTime || 'Unknown' }}</span>
</div>
</div>
</aside>
<main class="editor-panel">
<div class="card">
<div class="card__header">
<h2 class="card__title">PR Details</h2>
</div>
<div class="form-group">
<label class="form-label">PR Title</label>
<input type="text" class="form-input" [(ngModel)]="prTitle"
[placeholder]="'Fix #' + currentIssue.number + ': ' + currentIssue.title">
</div>
<div class="form-group">
<label class="form-label">PR Description</label>
<textarea class="form-textarea" [(ngModel)]="prBody" rows="8"
placeholder="Describe your changes..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Branch Name</label>
<input type="text" class="form-input" [(ngModel)]="branchName"
[placeholder]="'bugseti/issue-' + currentIssue.number">
</div>
<div class="form-group">
<label class="form-label">Commit Message</label>
<textarea class="form-textarea" [(ngModel)]="commitMessage" rows="3"
[placeholder]="'fix: resolve issue #' + currentIssue.number"></textarea>
</div>
</div>
</main>
</div>
<div class="empty-state" *ngIf="!currentIssue">
<h2>No Issue Selected</h2>
<p>Get an issue from the queue to start working.</p>
<button class="btn btn--primary" (click)="nextIssue()">Get Next Issue</button>
</div>
</div>
`,
styles: [`
.workbench {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-secondary);
}
.workbench-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-lg);
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
}
.workbench-header h1 {
font-size: 18px;
margin: 0;
}
.header-actions {
display: flex;
gap: var(--spacing-sm);
}
.workbench-content {
display: grid;
grid-template-columns: 400px 1fr;
flex: 1;
overflow: hidden;
}
.issue-panel {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
padding: var(--spacing-md);
overflow-y: auto;
border-right: 1px solid var(--border-color);
}
.editor-panel {
padding: var(--spacing-md);
overflow-y: auto;
}
.labels {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
margin: var(--spacing-sm) 0;
}
.issue-meta {
display: flex;
gap: var(--spacing-md);
font-size: 12px;
color: var(--text-muted);
margin-bottom: var(--spacing-md);
}
.issue-body {
padding: var(--spacing-md);
background-color: var(--bg-tertiary);
border-radius: var(--radius-md);
max-height: 200px;
overflow-y: auto;
}
.issue-body pre {
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
line-height: 1.5;
margin: 0;
}
.context-summary {
color: var(--text-secondary);
margin-bottom: var(--spacing-md);
}
.context-section {
margin-bottom: var(--spacing-md);
}
.context-section h4 {
font-size: 12px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: var(--spacing-xs);
}
.file-list {
list-style: none;
padding: 0;
margin: 0;
}
.file-list li {
padding: var(--spacing-xs) 0;
}
.context-meta {
font-size: 12px;
color: var(--text-muted);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
text-align: center;
}
.empty-state h2 {
color: var(--text-secondary);
}
.empty-state p {
color: var(--text-muted);
}
`]
})
export class WorkbenchComponent implements OnInit {
currentIssue: Issue | null = null;
prTitle = '';
prBody = '';
branchName = '';
commitMessage = '';
get canSubmit(): boolean {
return !!this.currentIssue && !!this.prTitle;
}
ngOnInit() {
this.loadCurrentIssue();
}
async loadCurrentIssue() {
try {
if ((window as any).go?.main?.TrayService?.GetCurrentIssue) {
this.currentIssue = await (window as any).go.main.TrayService.GetCurrentIssue();
if (this.currentIssue) {
this.initDefaults();
}
}
} catch (err) {
console.error('Failed to load current issue:', err);
}
}
initDefaults() {
if (!this.currentIssue) return;
this.prTitle = `Fix #${this.currentIssue.number}: ${this.currentIssue.title}`;
this.branchName = `bugseti/issue-${this.currentIssue.number}`;
this.commitMessage = `fix: resolve issue #${this.currentIssue.number}\n\n${this.currentIssue.title}`;
}
async nextIssue() {
try {
if ((window as any).go?.main?.TrayService?.NextIssue) {
this.currentIssue = await (window as any).go.main.TrayService.NextIssue();
if (this.currentIssue) {
this.initDefaults();
}
}
} catch (err) {
console.error('Failed to get next issue:', err);
}
}
async skipIssue() {
try {
if ((window as any).go?.main?.TrayService?.SkipIssue) {
await (window as any).go.main.TrayService.SkipIssue();
this.currentIssue = null;
this.prTitle = '';
this.prBody = '';
this.branchName = '';
this.commitMessage = '';
}
} catch (err) {
console.error('Failed to skip issue:', err);
}
}
async submitPR() {
if (!this.currentIssue || !this.canSubmit) return;
try {
if ((window as any).go?.main?.SubmitService?.Submit) {
const result = await (window as any).go.main.SubmitService.Submit({
issue: this.currentIssue,
title: this.prTitle,
body: this.prBody,
branch: this.branchName,
commitMsg: this.commitMessage
});
if (result.success) {
alert(`PR submitted successfully!\n\n${result.prUrl}`);
this.currentIssue = null;
} else {
alert(`Failed to submit PR: ${result.error}`);
}
}
} catch (err) {
console.error('Failed to submit PR:', err);
alert('Failed to submit PR. Check console for details.');
}
}
}

View file

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BugSETI</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,268 @@
// BugSETI Global Styles
// CSS Variables for theming
:root {
// Dark theme (default)
--bg-primary: #161b22;
--bg-secondary: #0d1117;
--bg-tertiary: #21262d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--border-color: #30363d;
--accent-primary: #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;
}
// Light theme
[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #f0f3f6;
--text-primary: #24292f;
--text-secondary: #57606a;
--text-muted: #8b949e;
--border-color: #d0d7de;
--accent-primary: #0969da;
--accent-success: #1a7f37;
--accent-warning: #9a6700;
--accent-danger: #cf222e;
}
// 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; }
p {
margin-bottom: var(--spacing-md);
}
a {
color: var(--accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
code {
font-family: var(--font-mono);
font-size: 12px;
padding: 2px 6px;
background-color: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
// 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: white;
&: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);
}
}
&--success {
background-color: var(--accent-success);
color: white;
}
&--danger {
background-color: var(--accent-danger);
color: white;
}
}
// 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(88, 166, 255, 0.2);
}
&::placeholder {
color: var(--text-muted);
}
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
// Cards
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border-color);
}
&__title {
font-size: 16px;
font-weight: 600;
}
}
// Badges
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 999px;
&--primary {
background-color: rgba(88, 166, 255, 0.15);
color: var(--accent-primary);
}
&--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);
}
}
// Utility classes
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-muted { color: var(--text-muted); }
.text-success { color: var(--accent-success); }
.text-danger { color: var(--accent-danger); }
.text-warning { color: var(--accent-warning); }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-sm { gap: var(--spacing-sm); }
.gap-md { gap: var(--spacing-md); }
.mt-sm { margin-top: var(--spacing-sm); }
.mt-md { margin-top: var(--spacing-md); }
.mb-sm { margin-bottom: var(--spacing-sm); }
.mb-md { margin-bottom: var(--spacing-md); }
.hidden { display: none; }

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

View file

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

88
cmd/bugseti/go.mod Normal file
View file

@ -0,0 +1,88 @@
module forge.lthn.ai/core/cli/cmd/bugseti
go 1.25.5
require (
forge.lthn.ai/core/cli v0.0.0
forge.lthn.ai/core/cli/internal/bugseti v0.0.0
forge.lthn.ai/core/cli/internal/bugseti/updater v0.0.0
github.com/Snider/Borg v0.2.0
forge.lthn.ai/core/cli v0.0.0
forge.lthn.ai/core/cli/internal/bugseti v0.0.0
forge.lthn.ai/core/cli/internal/bugseti/updater v0.0.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
)
replace forge.lthn.ai/core/cli => ../..
replace forge.lthn.ai/core/cli/internal/bugseti => ../../internal/bugseti
replace forge.lthn.ai/core/cli/internal/bugseti/updater => ../../internal/bugseti/updater
require (
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
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/bahlo/generic-list-go v0.2.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/buger/jsonparser v1.1.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/davidmz/go-pageant v1.0.2 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-fed/httpsig v1.1.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/go-viper/mapstructure/v2 v2.4.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/hashicorp/go-version v1.7.0 // indirect
github.com/invopop/jsonschema v0.13.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/mailru/easyjson v0.9.1 // indirect
github.com/mark3labs/mcp-go v0.43.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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/sagikazarmark/locafero v0.11.0 // 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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.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
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

181
cmd/bugseti/go.sum Normal file
View file

@ -0,0 +1,181 @@
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
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=
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/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=
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/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
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/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
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/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
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/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
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/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
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=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
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/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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=
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: 172 B

View file

@ -0,0 +1,25 @@
// Package icons provides embedded icon assets for the BugSETI 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: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

290
cmd/bugseti/main.go Normal file
View file

@ -0,0 +1,290 @@
// Package main provides the BugSETI system tray application.
// BugSETI - "Distributed Bug Fixing like SETI@home but for code"
//
// The application runs as a system tray app that:
// - Pulls OSS issues from Forgejo
// - Uses AI to prepare context for each issue
// - Presents issues to users for fixing
// - Automates PR submission
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"runtime"
"strings"
"forge.lthn.ai/core/cli/cmd/bugseti/icons"
"forge.lthn.ai/core/cli/internal/bugseti"
"forge.lthn.ai/core/cli/internal/bugseti/updater"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
//go:embed all:frontend/dist/bugseti/browser
var assets embed.FS
func main() {
// Strip the embed path prefix so files are served from root
staticAssets, err := fs.Sub(assets, "frontend/dist/bugseti/browser")
if err != nil {
log.Fatal(err)
}
// Initialize the config service
configService := bugseti.NewConfigService()
if err := configService.Load(); err != nil {
log.Printf("Warning: Could not load config: %v", err)
}
// Check Forgejo API availability
forgeClient, err := bugseti.CheckForge()
if err != nil {
log.Fatalf("Forgejo check failed: %v\n\nConfigure with: core forge config --url URL --token TOKEN", err)
}
// Initialize core services
notifyService := bugseti.NewNotifyService(configService)
statsService := bugseti.NewStatsService(configService)
fetcherService := bugseti.NewFetcherService(configService, notifyService, forgeClient)
queueService := bugseti.NewQueueService(configService)
seederService := bugseti.NewSeederService(configService, forgeClient.URL(), forgeClient.Token())
submitService := bugseti.NewSubmitService(configService, notifyService, statsService, forgeClient)
hubService := bugseti.NewHubService(configService)
versionService := bugseti.NewVersionService()
workspaceService := NewWorkspaceService(configService)
// Initialize update service
updateService, err := updater.NewService(configService)
if err != nil {
log.Printf("Warning: Could not initialize update service: %v", err)
}
// Create the tray service (we'll set the app reference later)
trayService := NewTrayService(nil)
// Build services list
services := []application.Service{
application.NewService(configService),
application.NewService(notifyService),
application.NewService(statsService),
application.NewService(fetcherService),
application.NewService(queueService),
application.NewService(seederService),
application.NewService(submitService),
application.NewService(versionService),
application.NewService(workspaceService),
application.NewService(hubService),
application.NewService(trayService),
}
// Add update service if available
if updateService != nil {
services = append(services, application.NewService(updateService))
}
// Create the application
app := application.New(application.Options{
Name: "BugSETI",
Description: "Distributed Bug Fixing - like SETI@home but for code",
Services: services,
Assets: application.AssetOptions{
Handler: spaHandler(staticAssets),
},
Mac: application.MacOptions{
ActivationPolicy: application.ActivationPolicyAccessory,
},
})
// Set the app reference and services in tray service
trayService.app = app
trayService.SetServices(fetcherService, queueService, configService, statsService)
// Set up system tray
setupSystemTray(app, fetcherService, queueService, configService)
// Start update service background checker
if updateService != nil {
updateService.Start()
}
log.Println("Starting BugSETI...")
log.Println(" - System tray active")
log.Println(" - Waiting for issues...")
log.Printf(" - Version: %s (%s)", bugseti.GetVersion(), bugseti.GetChannel())
// Attempt hub registration (non-blocking)
if hubURL := configService.GetHubURL(); hubURL != "" {
if err := hubService.AutoRegister(); err != nil {
log.Printf(" - Hub: auto-register skipped: %v", err)
} else if err := hubService.Register(); err != nil {
log.Printf(" - Hub: registration failed: %v", err)
} else {
log.Println(" - Hub: registered with portal")
}
} else {
log.Println(" - Hub: not configured (set hubUrl in config)")
}
if err := app.Run(); err != nil {
log.Fatal(err)
}
// Stop update service on exit
if updateService != nil {
updateService.Stop()
}
}
// setupSystemTray configures the system tray icon and menu
func setupSystemTray(app *application.App, fetcher *bugseti.FetcherService, queue *bugseti.QueueService, config *bugseti.ConfigService) {
systray := app.SystemTray.New()
systray.SetTooltip("BugSETI - Distributed Bug Fixing")
// Set tray icon based on OS
if runtime.GOOS == "darwin" {
systray.SetTemplateIcon(icons.TrayTemplate)
} else {
systray.SetDarkModeIcon(icons.TrayDark)
systray.SetIcon(icons.TrayLight)
}
// Create tray panel window (workbench preview)
trayWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "tray-panel",
Title: "BugSETI",
Width: 420,
Height: 520,
URL: "/tray",
Hidden: true,
Frameless: true,
BackgroundColour: application.NewRGB(22, 27, 34),
})
systray.AttachWindow(trayWindow).WindowOffset(5)
// Create main workbench window
workbenchWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "workbench",
Title: "BugSETI Workbench",
Width: 1200,
Height: 800,
URL: "/workbench",
Hidden: true,
BackgroundColour: application.NewRGB(22, 27, 34),
})
// Create settings window
settingsWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
Title: "BugSETI Settings",
Width: 600,
Height: 500,
URL: "/settings",
Hidden: true,
BackgroundColour: application.NewRGB(22, 27, 34),
})
// Create onboarding window
onboardingWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "onboarding",
Title: "Welcome to BugSETI",
Width: 700,
Height: 600,
URL: "/onboarding",
Hidden: true,
BackgroundColour: application.NewRGB(22, 27, 34),
})
// Build tray menu
trayMenu := app.Menu.New()
// Status item (dynamic)
statusItem := trayMenu.Add("Status: Idle")
statusItem.SetEnabled(false)
trayMenu.AddSeparator()
// Start/Pause toggle
startPauseItem := trayMenu.Add("Start Fetching")
startPauseItem.OnClick(func(ctx *application.Context) {
if fetcher.IsRunning() {
fetcher.Pause()
startPauseItem.SetLabel("Start Fetching")
statusItem.SetLabel("Status: Paused")
} else {
fetcher.Start()
startPauseItem.SetLabel("Pause")
statusItem.SetLabel("Status: Running")
}
})
trayMenu.AddSeparator()
// Current Issue
currentIssueItem := trayMenu.Add("Current Issue: None")
currentIssueItem.OnClick(func(ctx *application.Context) {
if issue := queue.CurrentIssue(); issue != nil {
workbenchWindow.Show()
workbenchWindow.Focus()
}
})
// Open Workbench
trayMenu.Add("Open Workbench").OnClick(func(ctx *application.Context) {
workbenchWindow.Show()
workbenchWindow.Focus()
})
trayMenu.AddSeparator()
// Settings
trayMenu.Add("Settings...").OnClick(func(ctx *application.Context) {
settingsWindow.Show()
settingsWindow.Focus()
})
// Stats submenu
statsMenu := trayMenu.AddSubmenu("Stats")
statsMenu.Add("Issues Fixed: 0").SetEnabled(false)
statsMenu.Add("PRs Merged: 0").SetEnabled(false)
statsMenu.Add("Repos Contributed: 0").SetEnabled(false)
trayMenu.AddSeparator()
// Quit
trayMenu.Add("Quit BugSETI").OnClick(func(ctx *application.Context) {
app.Quit()
})
systray.SetMenu(trayMenu)
// Check if onboarding needed (deferred until app is running)
app.Event.RegisterApplicationEventHook(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) {
if !config.IsOnboarded() {
onboardingWindow.Show()
onboardingWindow.Focus()
}
})
}
// spaHandler wraps an fs.FS to serve static files with SPA fallback.
// If the requested path doesn't match a real file, it serves index.html
// so Angular's client-side router can handle the route.
func spaHandler(fsys fs.FS) http.Handler {
fileServer := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
// Check if the file exists
if _, err := fs.Stat(fsys, path); err != nil {
// File doesn't exist — serve index.html for SPA routing
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
}

158
cmd/bugseti/tray.go Normal file
View file

@ -0,0 +1,158 @@
// Package main provides the BugSETI system tray application.
package main
import (
"context"
"log"
"forge.lthn.ai/core/cli/internal/bugseti"
"github.com/wailsapp/wails/v3/pkg/application"
)
// TrayService provides system tray bindings for the frontend.
type TrayService struct {
app *application.App
fetcher *bugseti.FetcherService
queue *bugseti.QueueService
config *bugseti.ConfigService
stats *bugseti.StatsService
}
// NewTrayService creates a new TrayService instance.
func NewTrayService(app *application.App) *TrayService {
return &TrayService{
app: app,
}
}
// SetServices sets the service references after initialization.
func (t *TrayService) SetServices(fetcher *bugseti.FetcherService, queue *bugseti.QueueService, config *bugseti.ConfigService, stats *bugseti.StatsService) {
t.fetcher = fetcher
t.queue = queue
t.config = config
t.stats = stats
}
// ServiceName returns the service name for Wails.
func (t *TrayService) ServiceName() string {
return "TrayService"
}
// ServiceStartup is called when the Wails application starts.
func (t *TrayService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
log.Println("TrayService started")
return nil
}
// ServiceShutdown is called when the Wails application shuts down.
func (t *TrayService) ServiceShutdown() error {
log.Println("TrayService shutdown")
return nil
}
// TrayStatus represents the current status of the tray.
type TrayStatus struct {
Running bool `json:"running"`
CurrentIssue string `json:"currentIssue"`
QueueSize int `json:"queueSize"`
IssuesFixed int `json:"issuesFixed"`
PRsMerged int `json:"prsMerged"`
}
// GetStatus returns the current tray status.
func (t *TrayService) GetStatus() TrayStatus {
var currentIssue string
if t.queue != nil {
if issue := t.queue.CurrentIssue(); issue != nil {
currentIssue = issue.Title
}
}
var queueSize int
if t.queue != nil {
queueSize = t.queue.Size()
}
var running bool
if t.fetcher != nil {
running = t.fetcher.IsRunning()
}
var issuesFixed, prsMerged int
if t.stats != nil {
stats := t.stats.GetStats()
issuesFixed = stats.IssuesAttempted
prsMerged = stats.PRsMerged
}
return TrayStatus{
Running: running,
CurrentIssue: currentIssue,
QueueSize: queueSize,
IssuesFixed: issuesFixed,
PRsMerged: prsMerged,
}
}
// StartFetching starts the issue fetcher.
func (t *TrayService) StartFetching() error {
if t.fetcher == nil {
return nil
}
return t.fetcher.Start()
}
// PauseFetching pauses the issue fetcher.
func (t *TrayService) PauseFetching() {
if t.fetcher != nil {
t.fetcher.Pause()
}
}
// GetCurrentIssue returns the current issue being worked on.
func (t *TrayService) GetCurrentIssue() *bugseti.Issue {
if t.queue == nil {
return nil
}
return t.queue.CurrentIssue()
}
// NextIssue moves to the next issue in the queue.
func (t *TrayService) NextIssue() *bugseti.Issue {
if t.queue == nil {
return nil
}
return t.queue.Next()
}
// SkipIssue skips the current issue.
func (t *TrayService) SkipIssue() {
if t.queue == nil {
return
}
t.queue.Skip()
}
// ShowWindow shows a specific window by name.
func (t *TrayService) ShowWindow(name string) {
if t.app == nil {
return
}
// Window will be shown by the frontend via Wails runtime
}
// IsOnboarded returns whether the user has completed onboarding.
func (t *TrayService) IsOnboarded() bool {
if t.config == nil {
return false
}
return t.config.IsOnboarded()
}
// CompleteOnboarding marks onboarding as complete.
func (t *TrayService) CompleteOnboarding() error {
if t.config == nil {
return nil
}
return t.config.CompleteOnboarding()
}

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

@ -0,0 +1,374 @@
// Package main provides the BugSETI system tray application.
package main
import (
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/Snider/Borg/pkg/tim"
"forge.lthn.ai/core/cli/internal/bugseti"
"forge.lthn.ai/core/cli/pkg/io/datanode"
)
const (
// defaultMaxWorkspaces is the fallback upper bound when config is unavailable.
defaultMaxWorkspaces = 100
// defaultWorkspaceTTL is the fallback TTL when config is unavailable.
defaultWorkspaceTTL = 24 * time.Hour
// sweepInterval is how often the background sweeper runs.
sweepInterval = 5 * time.Minute
)
// 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
done chan struct{} // signals the background sweeper to stop
stopped chan struct{} // closed when the sweeper goroutine exits
}
// 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.
// Call Start() to begin the background TTL sweeper.
func NewWorkspaceService(config *bugseti.ConfigService) *WorkspaceService {
return &WorkspaceService{
config: config,
workspaces: make(map[string]*Workspace),
done: make(chan struct{}),
stopped: make(chan struct{}),
}
}
// ServiceName returns the service name for Wails.
func (w *WorkspaceService) ServiceName() string {
return "WorkspaceService"
}
// Start launches the background sweeper goroutine that periodically
// evicts expired workspaces. This prevents unbounded map growth even
// when no new Capture calls arrive.
func (w *WorkspaceService) Start() {
go func() {
defer close(w.stopped)
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
w.mu.Lock()
evicted := w.cleanup()
w.mu.Unlock()
if evicted > 0 {
log.Printf("Workspace sweeper: evicted %d stale entries, %d remaining", evicted, w.ActiveWorkspaces())
}
case <-w.done:
return
}
}
}()
log.Printf("Workspace sweeper started (interval=%s, ttl=%s, max=%d)",
sweepInterval, w.ttl(), w.maxCap())
}
// Stop signals the background sweeper to exit and waits for it to finish.
func (w *WorkspaceService) Stop() {
close(w.done)
<-w.stopped
log.Printf("Workspace sweeper stopped")
}
// ttl returns the configured workspace TTL, falling back to the default.
func (w *WorkspaceService) ttl() time.Duration {
if w.config != nil {
return w.config.GetWorkspaceTTL()
}
return defaultWorkspaceTTL
}
// maxCap returns the configured max workspace count, falling back to the default.
func (w *WorkspaceService) maxCap() int {
if w.config != nil {
return w.config.GetMaxWorkspaces()
}
return defaultMaxWorkspaces
}
// 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.cleanup()
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
}
// cleanup evicts expired workspaces and enforces the max size cap.
// Must be called with w.mu held for writing.
// Returns the number of evicted entries.
func (w *WorkspaceService) cleanup() int {
now := time.Now()
ttl := w.ttl()
cap := w.maxCap()
evicted := 0
// First pass: evict entries older than TTL.
for id, ws := range w.workspaces {
if now.Sub(ws.CreatedAt) > ttl {
delete(w.workspaces, id)
evicted++
}
}
// Second pass: if still over cap, evict oldest entries.
if len(w.workspaces) > cap {
type entry struct {
id string
createdAt time.Time
}
entries := make([]entry, 0, len(w.workspaces))
for id, ws := range w.workspaces {
entries = append(entries, entry{id, ws.CreatedAt})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].createdAt.Before(entries[j].createdAt)
})
toEvict := len(w.workspaces) - cap
for i := 0; i < toEvict; i++ {
delete(w.workspaces, entries[i].id)
evicted++
}
}
return evicted
}
// 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)
}

View file

@ -0,0 +1,151 @@
package main
import (
"fmt"
"testing"
"time"
"forge.lthn.ai/core/cli/internal/bugseti"
)
func TestCleanup_TTL(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
// Seed with entries that are older than TTL.
svc.mu.Lock()
for i := 0; i < 5; i++ {
svc.workspaces[fmt.Sprintf("old-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
}
// Add one fresh entry.
svc.workspaces["fresh"] = &Workspace{
CreatedAt: time.Now(),
}
svc.cleanup()
svc.mu.Unlock()
if got := svc.ActiveWorkspaces(); got != 1 {
t.Errorf("expected 1 workspace after TTL cleanup, got %d", got)
}
}
func TestCleanup_MaxSize(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
maxCap := svc.maxCap()
// Fill beyond the cap with fresh entries.
svc.mu.Lock()
for i := 0; i < maxCap+20; i++ {
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
}
}
svc.cleanup()
svc.mu.Unlock()
if got := svc.ActiveWorkspaces(); got != maxCap {
t.Errorf("expected %d workspaces after cap cleanup, got %d", maxCap, got)
}
}
func TestCleanup_EvictsOldestWhenOverCap(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
maxCap := svc.maxCap()
// Create maxCap+1 entries; the newest should survive.
svc.mu.Lock()
for i := 0; i <= maxCap; i++ {
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-time.Duration(maxCap-i) * time.Minute),
}
}
svc.cleanup()
svc.mu.Unlock()
// The newest entry (ws-<maxCap>) should still exist.
newest := fmt.Sprintf("ws-%d", maxCap)
svc.mu.RLock()
_, exists := svc.workspaces[newest]
svc.mu.RUnlock()
if !exists {
t.Error("expected newest workspace to survive eviction")
}
// The oldest entry (ws-0) should have been evicted.
svc.mu.RLock()
_, exists = svc.workspaces["ws-0"]
svc.mu.RUnlock()
if exists {
t.Error("expected oldest workspace to be evicted")
}
}
func TestCleanup_ReturnsEvictedCount(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
svc.mu.Lock()
for i := 0; i < 3; i++ {
svc.workspaces[fmt.Sprintf("old-%d", i)] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
}
svc.workspaces["fresh"] = &Workspace{
CreatedAt: time.Now(),
}
evicted := svc.cleanup()
svc.mu.Unlock()
if evicted != 3 {
t.Errorf("expected 3 evicted entries, got %d", evicted)
}
}
func TestStartStop(t *testing.T) {
svc := NewWorkspaceService(bugseti.NewConfigService())
svc.Start()
// Add a stale entry while the sweeper is running.
svc.mu.Lock()
svc.workspaces["stale"] = &Workspace{
CreatedAt: time.Now().Add(-25 * time.Hour),
}
svc.mu.Unlock()
// Stop should return without hanging.
svc.Stop()
}
func TestConfigurableTTL(t *testing.T) {
cfg := bugseti.NewConfigService()
svc := NewWorkspaceService(cfg)
// Default TTL should be 24h (1440 minutes).
if got := svc.ttl(); got != 24*time.Hour {
t.Errorf("expected default TTL of 24h, got %s", got)
}
// Default max cap should be 100.
if got := svc.maxCap(); got != 100 {
t.Errorf("expected default max cap of 100, got %d", got)
}
}
func TestNilConfigFallback(t *testing.T) {
svc := &WorkspaceService{
config: nil,
workspaces: make(map[string]*Workspace),
done: make(chan struct{}),
stopped: make(chan struct{}),
}
if got := svc.ttl(); got != defaultWorkspaceTTL {
t.Errorf("expected fallback TTL %s, got %s", defaultWorkspaceTTL, got)
}
if got := svc.maxCap(); got != defaultMaxWorkspaces {
t.Errorf("expected fallback max cap %d, got %d", defaultMaxWorkspaces, got)
}
}

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://forge.lthn.ai/core/cli" 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://forge.lthn.ai/core/cli" 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://forge.lthn.ai/core/cli/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://forge.lthn.ai/core/cli/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://forge.lthn.ai/core/cli/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://forge.lthn.ai/core/cli</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.io / 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 forge.lthn.ai/core/cli/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 forge.lthn.ai/core/cli => ../..

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,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\AllowanceService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class QuotaMiddleware
{
public function __construct(
private readonly AllowanceService $allowanceService,
) {}
public function handle(Request $request, Closure $next): Response
{
$agentId = $request->header('X-Agent-ID', $request->input('agent_id', ''));
$model = $request->input('model', '');
if ($agentId === '') {
return response()->json([
'error' => 'agent_id is required',
], 400);
}
$result = $this->allowanceService->check($agentId, $model);
if (! $result['allowed']) {
return response()->json([
'error' => 'quota_exceeded',
'status' => $result['status'],
'reason' => $result['reason'],
'remaining_tokens' => $result['remaining_tokens'],
'remaining_jobs' => $result['remaining_jobs'],
], 429);
}
// Attach quota info to request for downstream use
$request->merge(['_quota' => $result]);
return $next($request);
}
}

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,111 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class ActivityFeed extends Component
{
public array $entries = [];
public string $agentFilter = 'all';
public string $typeFilter = 'all';
public bool $showOnlyQuestions = false;
public function mount(): void
{
$this->loadEntries();
}
public function loadEntries(): void
{
// Placeholder data — will be replaced with real-time WebSocket feed
$this->entries = [
[
'id' => 'act-001',
'agent' => 'Athena',
'type' => 'code_write',
'message' => 'Created AgentFleet Livewire component',
'job' => '#96',
'timestamp' => now()->subMinutes(2)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-002',
'agent' => 'Athena',
'type' => 'tool_call',
'message' => 'Read file: cmd/core-app/laravel/composer.json',
'job' => '#96',
'timestamp' => now()->subMinutes(5)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-003',
'agent' => 'Clotho',
'type' => 'question',
'message' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
'job' => '#84',
'timestamp' => now()->subMinutes(8)->toIso8601String(),
'is_question' => true,
],
[
'id' => 'act-004',
'agent' => 'Virgil',
'type' => 'pr_created',
'message' => 'Opened PR #89: fix WebSocket reconnection logic',
'job' => '#89',
'timestamp' => now()->subMinutes(15)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-005',
'agent' => 'Virgil',
'type' => 'test_run',
'message' => 'All 47 tests passed (0.8s)',
'job' => '#89',
'timestamp' => now()->subMinutes(18)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-006',
'agent' => 'Athena',
'type' => 'git_push',
'message' => 'Pushed branch feat/agentic-dashboard',
'job' => '#96',
'timestamp' => now()->subMinutes(22)->toIso8601String(),
'is_question' => false,
],
[
'id' => 'act-007',
'agent' => 'Clotho',
'type' => 'code_write',
'message' => 'Added input validation for MCP file_write paths',
'job' => '#84',
'timestamp' => now()->subMinutes(30)->toIso8601String(),
'is_question' => false,
],
];
}
public function getFilteredEntriesProperty(): array
{
return array_filter($this->entries, function ($entry) {
if ($this->showOnlyQuestions && !$entry['is_question']) {
return false;
}
if ($this->agentFilter !== 'all' && $entry['agent'] !== $this->agentFilter) {
return false;
}
if ($this->typeFilter !== 'all' && $entry['type'] !== $this->typeFilter) {
return false;
}
return true;
});
}
public function render()
{
return view('livewire.dashboard.activity-feed');
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class AgentFleet extends Component
{
/** @var array<int, array{name: string, host: string, model: string, status: string, job: string, heartbeat: string, uptime: string}> */
public array $agents = [];
public ?string $selectedAgent = null;
public function mount(): void
{
$this->loadAgents();
}
public function loadAgents(): void
{
// Placeholder data — will be replaced with real API calls to Go backend
$this->agents = [
[
'id' => 'athena',
'name' => 'Athena',
'host' => 'studio.snider.dev',
'model' => 'claude-opus-4-6',
'status' => 'working',
'job' => '#96 agentic dashboard',
'heartbeat' => 'green',
'uptime' => '4h 23m',
'tokens_today' => 142_580,
'jobs_completed' => 3,
],
[
'id' => 'virgil',
'name' => 'Virgil',
'host' => 'studio.snider.dev',
'model' => 'claude-opus-4-6',
'status' => 'idle',
'job' => '',
'heartbeat' => 'green',
'uptime' => '12h 07m',
'tokens_today' => 89_230,
'jobs_completed' => 5,
],
[
'id' => 'clotho',
'name' => 'Clotho',
'host' => 'darwin-au',
'model' => 'claude-sonnet-4-5',
'status' => 'working',
'job' => '#84 security audit',
'heartbeat' => 'yellow',
'uptime' => '1h 45m',
'tokens_today' => 34_100,
'jobs_completed' => 1,
],
[
'id' => 'charon',
'name' => 'Charon',
'host' => 'linux.snider.dev',
'model' => 'claude-haiku-4-5',
'status' => 'unhealthy',
'job' => '',
'heartbeat' => 'red',
'uptime' => '0m',
'tokens_today' => 0,
'jobs_completed' => 0,
],
];
}
public function selectAgent(string $agentId): void
{
$this->selectedAgent = $this->selectedAgent === $agentId ? null : $agentId;
}
public function render()
{
return view('livewire.dashboard.agent-fleet');
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class HumanActions extends Component
{
public array $pendingQuestions = [];
public array $reviewGates = [];
public string $answerText = '';
public ?string $answeringId = null;
public function mount(): void
{
$this->loadPending();
}
public function loadPending(): void
{
// Placeholder data — will be replaced with real data from Go backend
$this->pendingQuestions = [
[
'id' => 'q-001',
'agent' => 'Clotho',
'job' => '#84',
'question' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
'asked_at' => now()->subMinutes(8)->toIso8601String(),
'context' => 'Working on security audit — found unvalidated input in transport layer.',
],
];
$this->reviewGates = [
[
'id' => 'rg-001',
'agent' => 'Virgil',
'job' => '#89',
'type' => 'pr_review',
'title' => 'PR #89: fix WebSocket reconnection logic',
'description' => 'Adds exponential backoff and connection state tracking.',
'submitted_at' => now()->subMinutes(15)->toIso8601String(),
],
];
}
public function startAnswer(string $questionId): void
{
$this->answeringId = $questionId;
$this->answerText = '';
}
public function submitAnswer(): void
{
if (! $this->answeringId || trim($this->answerText) === '') {
return;
}
// Remove answered question from list
$this->pendingQuestions = array_values(
array_filter($this->pendingQuestions, fn ($q) => $q['id'] !== $this->answeringId)
);
$this->answeringId = null;
$this->answerText = '';
}
public function cancelAnswer(): void
{
$this->answeringId = null;
$this->answerText = '';
}
public function approveGate(string $gateId): void
{
$this->reviewGates = array_values(
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
);
}
public function rejectGate(string $gateId): void
{
$this->reviewGates = array_values(
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
);
}
public function render()
{
return view('livewire.dashboard.human-actions');
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class JobQueue extends Component
{
public array $jobs = [];
public string $statusFilter = 'all';
public string $agentFilter = 'all';
public function mount(): void
{
$this->loadJobs();
}
public function loadJobs(): void
{
// Placeholder data — will be replaced with real API calls to Go backend
$this->jobs = [
[
'id' => 'job-001',
'issue' => '#96',
'repo' => 'host-uk/core',
'title' => 'feat(agentic): real-time dashboard',
'agent' => 'Athena',
'status' => 'in_progress',
'priority' => 1,
'queued_at' => now()->subMinutes(45)->toIso8601String(),
'started_at' => now()->subMinutes(30)->toIso8601String(),
],
[
'id' => 'job-002',
'issue' => '#84',
'repo' => 'host-uk/core',
'title' => 'fix: security audit findings',
'agent' => 'Clotho',
'status' => 'in_progress',
'priority' => 2,
'queued_at' => now()->subHours(2)->toIso8601String(),
'started_at' => now()->subHours(1)->toIso8601String(),
],
[
'id' => 'job-003',
'issue' => '#102',
'repo' => 'host-uk/core',
'title' => 'feat: add rate limiting to MCP',
'agent' => null,
'status' => 'queued',
'priority' => 3,
'queued_at' => now()->subMinutes(10)->toIso8601String(),
'started_at' => null,
],
[
'id' => 'job-004',
'issue' => '#89',
'repo' => 'host-uk/core',
'title' => 'fix: WebSocket reconnection',
'agent' => 'Virgil',
'status' => 'review',
'priority' => 2,
'queued_at' => now()->subHours(4)->toIso8601String(),
'started_at' => now()->subHours(3)->toIso8601String(),
],
[
'id' => 'job-005',
'issue' => '#78',
'repo' => 'host-uk/core',
'title' => 'docs: update CLAUDE.md',
'agent' => 'Virgil',
'status' => 'completed',
'priority' => 4,
'queued_at' => now()->subHours(6)->toIso8601String(),
'started_at' => now()->subHours(5)->toIso8601String(),
],
];
}
public function updatedStatusFilter(): void
{
// Livewire auto-updates the view
}
public function cancelJob(string $jobId): void
{
$this->jobs = array_map(function ($job) use ($jobId) {
if ($job['id'] === $jobId && in_array($job['status'], ['queued', 'in_progress'])) {
$job['status'] = 'cancelled';
}
return $job;
}, $this->jobs);
}
public function retryJob(string $jobId): void
{
$this->jobs = array_map(function ($job) use ($jobId) {
if ($job['id'] === $jobId && in_array($job['status'], ['failed', 'cancelled'])) {
$job['status'] = 'queued';
$job['agent'] = null;
}
return $job;
}, $this->jobs);
}
public function getFilteredJobsProperty(): array
{
return array_filter($this->jobs, function ($job) {
if ($this->statusFilter !== 'all' && $job['status'] !== $this->statusFilter) {
return false;
}
if ($this->agentFilter !== 'all' && ($job['agent'] ?? '') !== $this->agentFilter) {
return false;
}
return true;
});
}
public function render()
{
return view('livewire.dashboard.job-queue');
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Dashboard;
use Livewire\Component;
class Metrics extends Component
{
public array $stats = [];
public array $throughputData = [];
public array $costBreakdown = [];
public float $budgetUsed = 0;
public float $budgetLimit = 0;
public function mount(): void
{
$this->loadMetrics();
}
public function loadMetrics(): void
{
// Placeholder data — will be replaced with real metrics from Go backend
$this->stats = [
'jobs_completed' => 12,
'prs_merged' => 8,
'tokens_used' => 1_245_800,
'cost_today' => 18.42,
'active_agents' => 3,
'queue_depth' => 4,
];
$this->budgetUsed = 18.42;
$this->budgetLimit = 50.00;
// Hourly throughput for chart
$this->throughputData = [
['hour' => '00:00', 'jobs' => 0, 'tokens' => 0],
['hour' => '02:00', 'jobs' => 0, 'tokens' => 0],
['hour' => '04:00', 'jobs' => 1, 'tokens' => 45_000],
['hour' => '06:00', 'jobs' => 2, 'tokens' => 120_000],
['hour' => '08:00', 'jobs' => 3, 'tokens' => 195_000],
['hour' => '10:00', 'jobs' => 2, 'tokens' => 280_000],
['hour' => '12:00', 'jobs' => 1, 'tokens' => 340_000],
['hour' => '14:00', 'jobs' => 3, 'tokens' => 450_000],
];
$this->costBreakdown = [
['model' => 'claude-opus-4-6', 'cost' => 12.80, 'tokens' => 856_000],
['model' => 'claude-sonnet-4-5', 'cost' => 4.20, 'tokens' => 312_000],
['model' => 'claude-haiku-4-5', 'cost' => 1.42, 'tokens' => 77_800],
];
}
public function render()
{
return view('livewire.dashboard.metrics');
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AgentAllowance extends Model
{
protected $fillable = [
'agent_id',
'daily_token_limit',
'daily_job_limit',
'concurrent_jobs',
'max_job_duration_minutes',
'model_allowlist',
];
protected function casts(): array
{
return [
'daily_token_limit' => 'integer',
'daily_job_limit' => 'integer',
'concurrent_jobs' => 'integer',
'max_job_duration_minutes' => 'integer',
'model_allowlist' => 'array',
];
}
public function usageRecords(): HasMany
{
return $this->hasMany(QuotaUsage::class, 'agent_id', 'agent_id');
}
public function todayUsage(): ?QuotaUsage
{
return $this->usageRecords()
->where('period_date', now()->toDateString())
->first();
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ModelQuota extends Model
{
protected $fillable = [
'model',
'daily_token_budget',
'hourly_rate_limit',
'cost_ceiling',
];
protected function casts(): array
{
return [
'daily_token_budget' => 'integer',
'hourly_rate_limit' => 'integer',
'cost_ceiling' => 'integer',
];
}
}

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