Compare commits
484 commits
8441658bc4
...
173905403d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173905403d | ||
|
|
a3f0040215 | ||
| 27d5e2e100 | |||
| 0729b3a672 | |||
|
|
484af25447 | ||
| 6f84531bd3 | |||
|
|
50da0adcb7 | ||
|
|
c5bc97de19 | ||
|
|
045f8fc110 | ||
|
|
c5689c3e83 | ||
|
|
1d4ec55d05 | ||
|
|
e6ada25bd8 | ||
|
|
f76bf0f0c0 | ||
|
|
b1f76ce7db | ||
|
|
f416f7e529 | ||
|
|
d92d097a7f | ||
|
|
7fc1571f93 | ||
|
|
4d3e54c81a | ||
|
|
f4303fada2 | ||
|
|
97ffe91cde | ||
|
|
e4467dd977 | ||
|
|
828a5c0853 | ||
|
|
a0435a84ea | ||
|
|
c25e1a633c | ||
|
|
8ee0c4bc4e | ||
|
|
5ff4b8a2eb | ||
|
|
23b82482f2 | ||
|
|
ca4c8208f7 | ||
|
|
177ce27e43 | ||
|
|
a567568bbf | ||
|
|
7a92fe0040 | ||
|
|
a6456e2c6d | ||
|
|
21d5f5f6df | ||
|
|
ab7ef525be | ||
|
|
a89acfa412 | ||
|
|
a38ce051b6 | ||
|
|
b3568f57e6 | ||
|
|
348e39e297 | ||
|
|
69a0cd631a | ||
|
|
1e2e8e9c11 | ||
|
|
fd0188d808 | ||
|
|
518da273f6 | ||
|
|
b512d7192d | ||
|
|
68065df140 | ||
|
|
794cff9409 | ||
|
|
2e271d2e17 | ||
|
|
7259d86208 | ||
|
|
f174650f37 | ||
|
|
f4ff0495f6 | ||
|
|
8852dff8de | ||
|
|
903fd79454 | ||
|
|
69b236e5c7 | ||
|
|
251ce85f20 | ||
|
|
ded85b081f | ||
|
|
fd8360cda8 | ||
|
|
a3892209c3 | ||
|
|
3af5ce687c | ||
|
|
d5771ed817 | ||
| 3473d5729e | |||
|
|
e0619280fb | ||
|
|
886c67e560 | ||
|
|
15c3c96fbb | ||
|
|
294f7814ed | ||
|
|
63cbe74932 | ||
|
|
b72ac61698 | ||
|
|
ac587b0c53 | ||
|
|
82cb877121 | ||
|
|
eaed083f9d | ||
|
|
199e8f0294 | ||
|
|
ac19c682b4 | ||
|
|
d9f3b726f2 | ||
|
|
03a9640f05 | ||
|
|
c535c5ad2f | ||
|
|
76c731ffb9 | ||
|
|
849695fe39 | ||
|
|
1b2866fc17 | ||
|
|
a668c5ab5a | ||
|
|
0fb9de600d | ||
|
|
d32c51d816 | ||
| 77adc533fc | |||
| 6010931500 | |||
|
|
c494c7b998 | ||
|
|
dd017288e7 | ||
|
|
5c3b70a1eb | ||
|
|
686b876749 | ||
|
|
44c154056f | ||
|
|
9fd9db8181 | ||
|
|
295f0fca85 | ||
|
|
dac61fc428 | ||
|
|
170da456ae | ||
|
|
8066e6cfd7 | ||
|
|
18c9f973db | ||
|
|
8e4ab36aa4 | ||
|
|
cbb0f9045c | ||
|
|
5d38a65bf1 | ||
|
|
1c029e044b | ||
|
|
1cf6449602 | ||
|
|
f855c45fc8 | ||
|
|
6064c198eb | ||
|
|
a3147895c4 | ||
|
|
8225812fd2 | ||
|
|
315b4fc052 | ||
|
|
aba0698c24 | ||
|
|
6a92682c20 | ||
|
|
704f8327de | ||
|
|
2d48f2d335 | ||
|
|
34b4ec2178 | ||
|
|
4e3a1a436a | ||
|
|
7065889926 | ||
|
|
389ce9972d | ||
|
|
ecfa5d744f | ||
|
|
03a6e04b17 | ||
|
|
28331e2e5f | ||
|
|
1940b92893 | ||
|
|
53a833d55a | ||
|
|
288431dde2 | ||
|
|
bbdfc3a3e6 | ||
|
|
93248b848f | ||
|
|
a519e326fd | ||
|
|
33f92306be | ||
|
|
7d134f9d0c | ||
|
|
a852ab7ff8 | ||
|
|
a673647f89 | ||
|
|
229d256561 | ||
|
|
d44ca11e39 | ||
|
|
843315165c | ||
|
|
eb8e927fef | ||
|
|
9b8522640e | ||
|
|
15d5aa0fbd | ||
|
|
44e74128c4 | ||
|
|
eeca300765 | ||
|
|
0a553dcf6e | ||
|
|
0bdb3d0a3e | ||
|
|
0fc16305d9 | ||
|
|
95261a92ff | ||
|
|
68247f8205 | ||
|
|
4b8d4895a6 | ||
|
|
14b297e058 | ||
|
|
5d22b3adf0 | ||
|
|
49da5b10c3 | ||
|
|
cdd97441e9 | ||
|
|
309dcaf0b4 | ||
|
|
b74f8264d3 | ||
|
|
c13b6b8b02 | ||
|
|
e5c82dab5e | ||
|
|
1b861494f1 | ||
|
|
c0fd80a2b6 | ||
|
|
9d3fc213ae | ||
|
|
bfc2332d02 | ||
|
|
4e10c7f38e | ||
|
|
1f0c67cae9 | ||
|
|
dfd7c3ab2d | ||
|
|
c122e89f40 | ||
|
|
dd25cff835 | ||
|
|
e0cc9526ea | ||
|
|
bdbcc4acfd | ||
|
|
89dd2b8eb5 | ||
|
|
48511478b8 | ||
|
|
4debdc1449 | ||
|
|
4a1600e9be | ||
|
|
6d65b70e0c | ||
|
|
feff6f7a09 | ||
|
|
8f4b58fa27 | ||
|
|
ad828a6663 | ||
|
|
d34053e21d | ||
|
|
bb74f87e30 | ||
|
|
070f0c7c71 | ||
|
|
6edb650340 | ||
|
|
dfc684a8fa | ||
|
|
a2ddf37df7 | ||
|
|
a169558102 | ||
|
|
38db43bbfb | ||
|
|
f3c178a9c6 | ||
|
|
5447884d7f | ||
|
|
6af2acd56b | ||
|
|
15e9c85995 | ||
|
|
5cd7c4d420 | ||
|
|
571627d0aa | ||
|
|
bd8617c3a5 | ||
|
|
5dd581c3bf | ||
|
|
4a1eaa9b68 | ||
|
|
9f4007c409 | ||
|
|
d83de64728 | ||
|
|
2b32633b7c | ||
|
|
10ea31e586 | ||
|
|
700f084cae | ||
|
|
3a1ed975ae | ||
|
|
3993d0583e | ||
|
|
768a8dfcc7 | ||
|
|
1d73209e89 | ||
|
|
febdb1ba92 | ||
|
|
cf63e0d2f7 | ||
|
|
799507881f | ||
|
|
1979510fd7 | ||
|
|
fac62b87fe | ||
|
|
c4730f7a26 | ||
|
|
748ff4df7d | ||
|
|
022c1f08b1 | ||
|
|
57057e64d1 | ||
|
|
e1923095e1 | ||
|
|
ecc161b725 | ||
|
|
12fe1cff4e | ||
|
|
89461d12eb | ||
|
|
1dffdd3a1b | ||
|
|
786223257b | ||
|
|
4a690d49f0 | ||
|
|
666a0c38a6 | ||
|
|
d4d26a6ba2 | ||
|
|
588687ae9a | ||
|
|
be8b2e7677 | ||
|
|
05e85448b4 | ||
|
|
552feb9d45 | ||
|
|
d0cdd89e4d | ||
|
|
8f369000ad | ||
|
|
bc3d6dd269 | ||
|
|
440333d31c | ||
|
|
5109895486 | ||
|
|
bbeed2c1dc | ||
|
|
65d1015616 | ||
|
|
7741360bd5 | ||
|
|
40fd53dfc1 | ||
|
|
abdb479b5f | ||
|
|
6dd9647861 | ||
|
|
acec997d18 | ||
|
|
d0b80d311a | ||
|
|
174d097a3a | ||
|
|
e8fb36c8d1 | ||
|
|
7ccfa92c7e | ||
|
|
4f7869d982 | ||
|
|
ad05899e9b | ||
|
|
9901de2350 | ||
|
|
e4848f187a | ||
|
|
f2bc75c6f7 | ||
|
|
1185ec8058 | ||
|
|
c54b28249c | ||
|
|
8553ba2a1c | ||
|
|
ee4e3b40fd | ||
|
|
cac1b38cce | ||
|
|
7c25034261 | ||
|
|
90531c148d | ||
|
|
42215b1979 | ||
|
|
b806f4f11c | ||
|
|
18f68ef907 | ||
|
|
5fdf5876ff | ||
|
|
51c313373c | ||
|
|
08226cb365 | ||
|
|
82599e0a55 | ||
|
|
0f40b99ba0 | ||
|
|
192769ea69 | ||
|
|
d4afcc2918 | ||
|
|
a89ff8ca91 | ||
|
|
85aad44a0d | ||
|
|
eba9c950bb | ||
|
|
80f23ba9b9 | ||
|
|
4cd0cf14a3 | ||
|
|
f2bc912ebe | ||
|
|
32a3613a3a | ||
|
|
8c914a99cc | ||
|
|
bbd793bd51 | ||
|
|
ee21150eac | ||
|
|
6f4b2a21da | ||
|
|
dfb634d933 | ||
|
|
9563ca9ad7 | ||
|
|
6a5f5a1e6c | ||
|
|
b6fbb781d5 | ||
|
|
820256141a | ||
|
|
23bf7835af | ||
|
|
cabaa0cc49 | ||
|
|
a1afc612f9 | ||
|
|
92a7298216 | ||
|
|
f03cf409a3 | ||
|
|
6e2dbcb6d7 | ||
|
|
4164caa6f7 | ||
|
|
4513ba62bb | ||
|
|
b5430b933e | ||
|
|
15bc295fd4 | ||
|
|
95d49eb453 | ||
|
|
8ab25e46d6 | ||
|
|
1911a88c6f | ||
|
|
2899347aed | ||
|
|
5806d63956 | ||
|
|
fe8801628f | ||
|
|
5e2765fd5f | ||
|
|
22952d9b2c | ||
|
|
e5e6908416 | ||
|
|
a443669e33 | ||
|
|
24c234d798 | ||
|
|
1b220e8f44 | ||
|
|
f55ca297a0 | ||
|
|
a794f6b55f | ||
|
|
ba7fa42522 | ||
|
|
18da0122a1 | ||
|
|
cfd0bec5e1 | ||
|
|
961a4251ad | ||
|
|
44cf868b88 | ||
|
|
4a98d9baf2 | ||
|
|
46c094242e | ||
|
|
b9dbf1e099 | ||
|
|
255705874c | ||
|
|
b782fa7fe3 | ||
|
|
23bedb999d | ||
|
|
e7922e1976 | ||
|
|
68704af131 | ||
|
|
045533a674 | ||
|
|
5f696fd252 | ||
|
|
eeb31221f4 | ||
|
|
06ff6ab4a5 | ||
|
|
0edf04b915 | ||
|
|
e497ddcaf5 | ||
|
|
c62d088ccb | ||
|
|
3d9955e144 | ||
|
|
6322377d6e | ||
|
|
0cda07179f | ||
|
|
691908aa6c | ||
|
|
4e5a361035 | ||
|
|
dbe5393695 | ||
|
|
e4ee8d2328 | ||
|
|
ffae35cb73 | ||
|
|
1d18339a97 | ||
|
|
8592ef3e62 | ||
|
|
9547f9bff3 | ||
|
|
77b705ba27 | ||
|
|
2e8613175d | ||
|
|
f64394eaef | ||
|
|
6de9a4a94f | ||
|
|
ce0e04df9f | ||
|
|
15c374a029 | ||
|
|
89a789d170 | ||
|
|
f8ec6a57a7 | ||
|
|
bb7afaf3ea | ||
|
|
940122085c | ||
|
|
4014fd2dc3 | ||
|
|
50dbeac9bd | ||
|
|
469a234bfb | ||
|
|
5e05be6ba4 | ||
|
|
6b3e61732a | ||
|
|
b291ec0e62 | ||
|
|
9ba35ebff4 | ||
|
|
1adebbdba1 | ||
|
|
40451538d6 | ||
|
|
e081869ba2 | ||
|
|
dfbefeb826 | ||
|
|
71115d87bf | ||
|
|
6fb2713112 | ||
|
|
d6fb905dba | ||
|
|
bd665e0892 | ||
|
|
48bee731b4 | ||
|
|
68f2f658f4 | ||
|
|
3caa394e15 | ||
|
|
549e0c6035 | ||
|
|
234bfd17f8 | ||
|
|
f49155745a | ||
|
|
95a980bea1 | ||
|
|
0b69df6e93 | ||
|
|
8c4b526ef4 | ||
|
|
dc7f22acfb | ||
|
|
2aa3c49896 | ||
|
|
cf2a4db2d9 | ||
|
|
a61971d2b5 | ||
|
|
5be349c062 | ||
|
|
2af3a7fd2e | ||
|
|
3c44ba9a11 | ||
|
|
56f6a32be7 | ||
|
|
81e593f310 | ||
|
|
78b7273f83 | ||
|
|
a3ee94461a | ||
|
|
02c1a94b72 | ||
|
|
96b72aff52 | ||
|
|
144d5c787c | ||
|
|
025d9597a3 | ||
|
|
8e55f585f3 | ||
|
|
342fd2e962 | ||
|
|
c0fd6ebe5f | ||
|
|
564dfb160b | ||
|
|
04fc222982 | ||
|
|
8379695332 | ||
|
|
f56d043c8d | ||
|
|
2a870a5d7d | ||
|
|
e999f390ac | ||
|
|
82eb5ec054 | ||
|
|
bc1900a639 | ||
|
|
e0b1615905 | ||
|
|
c51b71ce27 | ||
|
|
00f6cdef92 | ||
|
|
0d13f408d1 | ||
|
|
fcfdba6cef | ||
|
|
b66af1e183 | ||
|
|
fa405487d6 | ||
|
|
8c7ad6822f | ||
|
|
d602504a56 | ||
|
|
fbc31b28c3 | ||
|
|
c1681ff672 | ||
|
|
1d3a050b62 | ||
|
|
ddcddd73e9 | ||
|
|
a3e1de3f77 | ||
|
|
68b84445ca | ||
|
|
6cf3e745c9 | ||
|
|
fb0fc447ef | ||
|
|
e6d1bd22d2 | ||
|
|
413c7b823a | ||
|
|
e52542835a | ||
|
|
aadd286ee7 | ||
|
|
fd8a4a03fc | ||
|
|
8bfb0c65ab | ||
|
|
8992328175 | ||
|
|
f709b4fb81 | ||
|
|
144cda8181 | ||
|
|
42781f075d | ||
|
|
8b63fa8ecc | ||
|
|
8941fd3431 | ||
|
|
ec6eca99d8 | ||
|
|
3db61841bd | ||
|
|
b053206e95 | ||
|
|
d73ed87485 | ||
|
|
cb7bd792f0 | ||
|
|
9d6ffaf219 | ||
|
|
aaa7739749 | ||
|
|
2851d772a7 | ||
|
|
0d7333849c | ||
|
|
32a9822305 | ||
|
|
954498ca82 | ||
|
|
ea6d045327 | ||
|
|
e91481c285 | ||
|
|
dd9c0a8ca5 | ||
|
|
7e8ff413d0 | ||
|
|
804142320d | ||
|
|
f574cae2a8 | ||
|
|
12779ef67c | ||
|
|
8c550d2360 | ||
|
|
2b68a26a1b | ||
|
|
df7ff9f128 | ||
|
|
cbd41e6837 | ||
|
|
547c65f264 | ||
|
|
f47e8211fb | ||
|
|
c58bc3e344 | ||
|
|
13b0efda16 | ||
|
|
9b678f21a0 | ||
|
|
21357e1260 | ||
|
|
f9ed8bab2e | ||
|
|
04e70d9cda | ||
|
|
61bdb820e8 | ||
|
|
f2bbb71875 | ||
|
|
eefc914f52 | ||
|
|
efd952dab6 | ||
|
|
19bb1e8fa3 | ||
|
|
d9c7a8ac7b | ||
|
|
e8f479f65c | ||
|
|
c36e4f72a3 | ||
|
|
7789f422e0 | ||
|
|
e4b47f9d29 | ||
|
|
0ef41ef642 | ||
|
|
03727e18bb | ||
|
|
3423e48682 | ||
|
|
c409f25de4 | ||
|
|
74cc00ec24 | ||
|
|
a422c18c0e | ||
|
|
aeb852c5e5 | ||
|
|
1afa35d3c6 | ||
|
|
0072650fd9 | ||
|
|
fa8e5334a5 | ||
|
|
29683c1ce7 | ||
|
|
d52f1080a5 | ||
|
|
ebc67ed727 | ||
|
|
13f7e29894 | ||
|
|
ba88455efb | ||
|
|
af9fd33b2a | ||
|
|
d731afc298 | ||
|
|
d3031d6b73 | ||
|
|
f407d04eef | ||
|
|
a4971fe0df | ||
|
|
36dc56f789 | ||
|
|
1cdd050520 | ||
|
|
15d803c0e7 | ||
|
|
17ca111a1c | ||
|
|
25b73fa79e | ||
|
|
ebbe01c427 | ||
|
|
a0088a34a8 | ||
|
|
e813c1f07e | ||
|
|
11b47cb07f | ||
|
|
75d4057fe0 | ||
|
|
10277c6094 | ||
|
|
b02b57e6fb | ||
|
|
0c5e0c6435 | ||
|
|
31d29711c0 |
796 changed files with 50271 additions and 82350 deletions
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"enabledPlugins": {
|
||||
"core@core-claude": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
# CodeRabbit Configuration
|
||||
# Inherits from: https://github.com/host-uk/coderabbit/.coderabbit.yaml
|
||||
# Manual trigger only: @coderabbitai review
|
||||
|
||||
reviews:
|
||||
auto_review:
|
||||
enabled: false
|
||||
review_status: false
|
||||
|
||||
path_instructions:
|
||||
|
|
|
|||
32
.core/build.yaml
Normal file
32
.core/build.yaml
Normal 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
18
.core/ci.yaml
Normal 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://https://forge.lthn.ai/core/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
|
||||
|
|
@ -30,7 +30,7 @@ core/
|
|||
package domain
|
||||
|
||||
import (
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ func NewNameCmd() *cobra.Command {
|
|||
## CLI Output Helpers
|
||||
|
||||
```go
|
||||
import "github.com/host-uk/core/pkg/cli"
|
||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
cli.Success("Operation completed") // Green check
|
||||
cli.Warning("Something to note") // Yellow warning
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
# Core CLI release configuration
|
||||
# Used by: core release
|
||||
|
||||
version: 1
|
||||
|
||||
project:
|
||||
name: myapp
|
||||
repository: owner/repo
|
||||
name: core
|
||||
repository: host-uk/core
|
||||
|
||||
build:
|
||||
targets:
|
||||
|
|
@ -21,12 +24,19 @@ 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
|
||||
50
.core/task/issue/258/plan.md
Normal file
50
.core/task/issue/258/plan.md
Normal 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
|
||||
36
.core/task/issue/258/spec.md
Normal file
36
.core/task/issue/258/spec.md
Normal 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/`
|
||||
146
.forgejo/workflows/deploy.yml
Normal file
146
.forgejo/workflows/deploy.yml
Normal 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 }}"
|
||||
50
.forgejo/workflows/security-scan.yml
Normal file
50
.forgejo/workflows/security-scan.yml
Normal 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
11
.gemini/settings.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"general": {
|
||||
"sessionRetention": {
|
||||
"enabled": true
|
||||
},
|
||||
"enablePromptCompletion": true
|
||||
},
|
||||
"experimental": {
|
||||
"plan": true
|
||||
}
|
||||
}
|
||||
23
.gitattributes
vendored
Normal file
23
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Normalize all text files to LF
|
||||
* text=auto eol=lf
|
||||
|
||||
# Ensure shell scripts use LF
|
||||
*.sh text eol=lf
|
||||
|
||||
# Ensure Go files use LF
|
||||
*.go text eol=lf
|
||||
|
||||
# Ensure JSON/YAML use LF
|
||||
*.json text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
|
||||
# Binary files
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
4
.githooks/pre-commit
Executable file
4
.githooks/pre-commit
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
exec core go qa full --fix
|
||||
24
.github/dependabot.yml
vendored
24
.github/dependabot.yml
vendored
|
|
@ -1,24 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "type:dependencies"
|
||||
- "priority:low"
|
||||
commit-message:
|
||||
prefix: "deps(go):"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "type:dependencies"
|
||||
- "priority:low"
|
||||
commit-message:
|
||||
prefix: "deps(actions):"
|
||||
|
||||
133
.github/workflows/agent-verify.yml
vendored
133
.github/workflows/agent-verify.yml
vendored
|
|
@ -1,133 +0,0 @@
|
|||
name: Agent Verification Workflow
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
# When work is claimed, track the implementer
|
||||
track-implementer:
|
||||
if: github.event.label.name == 'agent:wip'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Record implementer
|
||||
run: |
|
||||
echo "Implementer: ${{ github.actor }}"
|
||||
# Could store in issue body or external system
|
||||
|
||||
# When work is submitted for review, add to verification queue
|
||||
request-verification:
|
||||
if: github.event.label.name == 'agent:review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add to Workstation for verification
|
||||
uses: actions/add-to-project@v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/host-uk/projects/2
|
||||
github-token: ${{ secrets.PROJECT_TOKEN }}
|
||||
|
||||
- name: Comment verification needed
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const implementer = context.payload.sender.login;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `## 🔍 Verification Required\n\nWork submitted by @${implementer}.\n\n**Rule:** A different agent must verify this work.\n\nTo verify:\n1. Review the implementation\n2. Run tests if applicable\n3. Add \`verified\` or \`verify-failed\` label\n\n_Self-verification is not allowed._`
|
||||
});
|
||||
|
||||
# Block self-verification
|
||||
check-verification:
|
||||
if: github.event.label.name == 'verified' || github.event.label.name == 'verify-failed'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get issue details
|
||||
id: issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
|
||||
// Check timeline for who added agent:wip
|
||||
const timeline = await github.rest.issues.listEventsForTimeline({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const wipEvent = timeline.data.find(e =>
|
||||
e.event === 'labeled' && e.label?.name === 'agent:wip'
|
||||
);
|
||||
|
||||
const implementer = wipEvent?.actor?.login || 'unknown';
|
||||
const verifier = context.payload.sender.login;
|
||||
|
||||
console.log(`Implementer: ${implementer}`);
|
||||
console.log(`Verifier: ${verifier}`);
|
||||
|
||||
if (implementer === verifier) {
|
||||
core.setFailed(`Self-verification not allowed. ${verifier} cannot verify their own work.`);
|
||||
}
|
||||
|
||||
return { implementer, verifier };
|
||||
|
||||
- name: Record verification
|
||||
if: success()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const label = context.payload.label.name;
|
||||
const verifier = context.payload.sender.login;
|
||||
const status = label === 'verified' ? '✅ Verified' : '❌ Failed';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `## ${status}\n\nVerified by @${verifier}`
|
||||
});
|
||||
|
||||
// Remove agent:review label
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'agent:review'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('agent:review label not present');
|
||||
}
|
||||
|
||||
# If verification failed, reset for rework
|
||||
handle-failure:
|
||||
if: github.event.label.name == 'verify-failed'
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-verification
|
||||
steps:
|
||||
- name: Reset for rework
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// Remove verify-failed after processing
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'verify-failed'
|
||||
});
|
||||
|
||||
// Add back to ready queue
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['agent:ready']
|
||||
});
|
||||
30
.github/workflows/auto-project.yml
vendored
30
.github/workflows/auto-project.yml
vendored
|
|
@ -1,30 +0,0 @@
|
|||
name: Auto-add to Project
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled]
|
||||
|
||||
jobs:
|
||||
add-to-project:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add to Workstation (agentic label)
|
||||
if: contains(github.event.issue.labels.*.name, 'agentic')
|
||||
uses: actions/add-to-project@v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/host-uk/projects/2
|
||||
github-token: ${{ secrets.PROJECT_TOKEN }}
|
||||
|
||||
- name: Add to Core.GO (lang:go label)
|
||||
if: contains(github.event.issue.labels.*.name, 'lang:go')
|
||||
uses: actions/add-to-project@v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/host-uk/projects/4
|
||||
github-token: ${{ secrets.PROJECT_TOKEN }}
|
||||
|
||||
- name: Add to Core.Framework (scope:arch label)
|
||||
if: contains(github.event.issue.labels.*.name, 'scope:arch')
|
||||
uses: actions/add-to-project@v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/host-uk/projects/1
|
||||
github-token: ${{ secrets.PROJECT_TOKEN }}
|
||||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
|
|
@ -1,24 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 1.22
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod tidy
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
36
.github/workflows/codeql.yml
vendored
36
.github/workflows/codeql.yml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
pull_request:
|
||||
branches: [dev, main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: go
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:go"
|
||||
|
||||
36
.github/workflows/codescan.yml
vendored
36
.github/workflows/codescan.yml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
name: "Code Scanning"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["dev"]
|
||||
pull_request:
|
||||
branches: ["dev"]
|
||||
schedule:
|
||||
- cron: "0 2 * * 1-5"
|
||||
|
||||
jobs:
|
||||
CodeQL:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Initialize CodeQL"
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: go,javascript,typescript
|
||||
|
||||
- name: "Autobuild"
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: "Perform CodeQL Analysis"
|
||||
uses: github/codeql-action/analyze@v3
|
||||
40
.github/workflows/coverage.yml
vendored
40
.github/workflows/coverage.yml
vendored
|
|
@ -1,40 +0,0 @@
|
|||
name: Go Test Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
pull_request:
|
||||
branches: [dev, main]
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.work'
|
||||
|
||||
- name: Setup Task
|
||||
uses: arduino/setup-task@v1
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- name: Run coverage
|
||||
run: task cov
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.txt
|
||||
93
.github/workflows/dev-release.yml
vendored
93
.github/workflows/dev-release.yml
vendored
|
|
@ -1,93 +0,0 @@
|
|||
name: Dev Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
check-latest: true
|
||||
|
||||
- name: Build CLI
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: '0'
|
||||
run: |
|
||||
EXT=""
|
||||
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
|
||||
go build -trimpath -ldflags="-s -w" -o core-${GOOS}-${GOARCH}${EXT} ./cmd/core
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: core-*
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List artifacts
|
||||
run: ls -la artifacts/
|
||||
|
||||
- name: Delete existing dev release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release delete dev -y || true
|
||||
|
||||
- name: Delete existing dev tag
|
||||
run: git push origin :refs/tags/dev || true
|
||||
|
||||
- name: Create dev release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
gh release create dev \
|
||||
--title "Development Build" \
|
||||
--notes "Latest development build from the dev branch.
|
||||
|
||||
**Commit:** ${COMMIT_SHA}
|
||||
**Built:** $(date -u +'%Y-%m-%d %H:%M:%S UTC')
|
||||
|
||||
This is a pre-release for testing. Use tagged releases for production." \
|
||||
--prerelease \
|
||||
--target dev \
|
||||
artifacts/*
|
||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
|
|
@ -1,36 +0,0 @@
|
|||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
check-latest: true
|
||||
- name: Set up Node (for GUI builds if needed)
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install Wails (optional)
|
||||
run: |
|
||||
go install github.com/wailsapp/wails/v3/cmd/wails3@latest || true
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -13,7 +13,12 @@ coverage.html
|
|||
*.cache
|
||||
/coverage.txt
|
||||
bin/
|
||||
dist/
|
||||
tasks
|
||||
/cli
|
||||
/core
|
||||
/i18n-validate
|
||||
.angular/
|
||||
|
||||
|
||||
patch_cov.*
|
||||
go.work.sum
|
||||
|
|
|
|||
10
.gitleaks.toml
Normal file
10
.gitleaks.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Gitleaks configuration for host-uk/core
|
||||
# Test fixtures contain private keys for cryptographic testing — not real secrets.
|
||||
|
||||
[allowlist]
|
||||
description = "Test fixture allowlist"
|
||||
paths = [
|
||||
'''pkg/crypt/pgp/pgp_test\.go''',
|
||||
'''pkg/crypt/rsa/rsa_test\.go''',
|
||||
'''pkg/crypt/openpgp/test_util\.go''',
|
||||
]
|
||||
52
.woodpecker/bugseti.yml
Normal file
52
.woodpecker/bugseti.yml
Normal 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
21
.woodpecker/core.yml
Normal 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
143
AUDIT-DEPENDENCIES.md
Normal 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*
|
||||
67
CLAUDE.md
67
CLAUDE.md
|
|
@ -38,7 +38,7 @@ Run a single test: `go test -run TestName ./...`
|
|||
### Core Framework (`core.go`, `interfaces.go`)
|
||||
|
||||
The `Core` struct is the central application container managing:
|
||||
- **Services**: Named service registry with type-safe retrieval via `ServiceFor[T]()` and `MustServiceFor[T]()`
|
||||
- **Services**: Named service registry with type-safe retrieval via `ServiceFor[T]()`
|
||||
- **Actions/IPC**: Message-passing system where services communicate via `ACTION(msg Message)` and register handlers via `RegisterAction()`
|
||||
- **Lifecycle**: Services implementing `Startable` (OnStartup) and/or `Stoppable` (OnShutdown) interfaces are automatically called during app lifecycle
|
||||
|
||||
|
|
@ -97,6 +97,69 @@ Tests use `_Good`, `_Bad`, `_Ugly` suffix pattern:
|
|||
Uses Go 1.25 workspaces. The workspace includes:
|
||||
- Root module (Core framework)
|
||||
- `cmd/core-gui` (Wails GUI application)
|
||||
- `cmd/bugseti` (BugSETI system tray app - distributed bug fixing)
|
||||
- `cmd/examples/*` (Example applications)
|
||||
|
||||
After adding modules: `go work sync`
|
||||
After adding modules: `go work sync`
|
||||
|
||||
## Additional Packages
|
||||
|
||||
### pkg/ws (WebSocket Hub)
|
||||
|
||||
Real-time streaming via WebSocket connections. Implements a hub pattern for managing connections and channel-based subscriptions.
|
||||
|
||||
```go
|
||||
hub := ws.NewHub()
|
||||
go hub.Run(ctx)
|
||||
|
||||
// Register HTTP handler
|
||||
http.HandleFunc("/ws", hub.Handler())
|
||||
|
||||
// Send process output to subscribers
|
||||
hub.SendProcessOutput(processID, "output line")
|
||||
```
|
||||
|
||||
Message types: `process_output`, `process_status`, `event`, `error`, `ping/pong`, `subscribe/unsubscribe`
|
||||
|
||||
### pkg/webview (Browser Automation)
|
||||
|
||||
Chrome DevTools Protocol (CDP) client for browser automation, testing, and scraping.
|
||||
|
||||
```go
|
||||
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
|
||||
defer wv.Close()
|
||||
|
||||
wv.Navigate("https://example.com")
|
||||
wv.Click("#submit-button")
|
||||
wv.Type("#input", "text")
|
||||
screenshot, _ := wv.Screenshot()
|
||||
```
|
||||
|
||||
Features: Navigation, DOM queries, console capture, screenshots, JavaScript evaluation, Angular helpers
|
||||
|
||||
### pkg/mcp (MCP Server)
|
||||
|
||||
Model Context Protocol server with tools for:
|
||||
- **File operations**: file_read, file_write, file_edit, file_delete, file_rename, file_exists, dir_list, dir_create
|
||||
- **RAG**: rag_query, rag_ingest, rag_collections (Qdrant + Ollama)
|
||||
- **Metrics**: metrics_record, metrics_query (JSONL storage)
|
||||
- **Language detection**: lang_detect, lang_list
|
||||
- **Process management**: process_start, process_stop, process_kill, process_list, process_output, process_input
|
||||
- **WebSocket**: ws_start, ws_info
|
||||
- **Webview/CDP**: webview_connect, webview_navigate, webview_click, webview_type, webview_query, webview_console, webview_eval, webview_screenshot, webview_wait, webview_disconnect
|
||||
|
||||
Run server: `core mcp serve` (stdio) or `MCP_ADDR=:9000 core mcp serve` (TCP)
|
||||
|
||||
## BugSETI Application
|
||||
|
||||
System tray application for distributed bug fixing - "like SETI@home but for code".
|
||||
|
||||
Features:
|
||||
- Fetches OSS issues from GitHub
|
||||
- AI-powered context preparation via seeder
|
||||
- Issue queue management
|
||||
- Automated PR submission
|
||||
- Stats tracking and leaderboard
|
||||
|
||||
Build: `task bugseti:build`
|
||||
Run: `task bugseti:dev`
|
||||
166
ISSUES_TRIAGE.md
Normal file
166
ISSUES_TRIAGE.md
Normal 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)
|
||||
225
README.md
225
README.md
|
|
@ -1,9 +1,14 @@
|
|||
# Core
|
||||
|
||||
[](https://codecov.io/gh/host-uk/core)
|
||||
[](https://forge.lthn.ai/core/cli/actions/workflows/coverage.yml)
|
||||
[](https://forge.lthn.ai/core/cli/actions/workflows/codescan.yml)
|
||||
[](https://go.dev/)
|
||||
[](https://opensource.org/licenses/EUPL-1.2)
|
||||
|
||||
Core is a Web3 Framework, written in Go using Wails.io to replace Electron and the bloat of browsers that, at their core, still live in their mum's basement.
|
||||
|
||||
- Discord: http://discord.dappco.re
|
||||
- Repo: https://github.com/Snider/Core
|
||||
- Repo: https://forge.lthn.ai/core/cli
|
||||
|
||||
## Vision
|
||||
|
||||
|
|
@ -17,12 +22,31 @@ Core is an **opinionated Web3 desktop application framework** providing:
|
|||
|
||||
**Mental model:** A secure, encrypted workspace manager where each "workspace" is a cryptographically isolated environment. The framework handles windows, menus, trays, config, and i18n.
|
||||
|
||||
## Quick Start
|
||||
## CLI Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Install Core
|
||||
go install forge.lthn.ai/core/cli/cmd/core@latest
|
||||
|
||||
# 2. Verify environment
|
||||
core doctor
|
||||
|
||||
# 3. Run tests in any Go/PHP project
|
||||
core go test # or core php test
|
||||
|
||||
# 4. Build and preview release
|
||||
core build
|
||||
core ci
|
||||
```
|
||||
|
||||
For more details, see the [User Guide](docs/user-guide.md).
|
||||
|
||||
## Framework Quick Start (Go)
|
||||
|
||||
```go
|
||||
import core "github.com/Snider/Core"
|
||||
import core "forge.lthn.ai/core/cli/pkg/framework/core"
|
||||
|
||||
app := core.New(
|
||||
app, err := core.New(
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
```
|
||||
|
|
@ -56,6 +80,55 @@ task cli:build # Build to cmd/core/bin/core
|
|||
task cli:run # Build and run
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Core uses a layered configuration system where values are resolved in the following priority:
|
||||
|
||||
1. **Command-line flags** (if applicable)
|
||||
2. **Environment variables**
|
||||
3. **Configuration file**
|
||||
4. **Default values**
|
||||
|
||||
### Configuration File
|
||||
|
||||
The default configuration file is located at `~/.core/config.yaml`.
|
||||
|
||||
#### Format
|
||||
|
||||
The file uses YAML format and supports nested structures.
|
||||
|
||||
```yaml
|
||||
# ~/.core/config.yaml
|
||||
dev:
|
||||
editor: vim
|
||||
debug: true
|
||||
|
||||
log:
|
||||
level: info
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Layered Configuration Mapping
|
||||
|
||||
Any configuration value can be overridden using environment variables with the `CORE_CONFIG_` prefix. After stripping the `CORE_CONFIG_` prefix, the remaining variable name is converted to lowercase and underscores are replaced with dots to map to the configuration hierarchy.
|
||||
|
||||
**Examples:**
|
||||
- `CORE_CONFIG_DEV_EDITOR=nano` maps to `dev.editor: nano`
|
||||
- `CORE_CONFIG_LOG_LEVEL=debug` maps to `log.level: debug`
|
||||
|
||||
#### Common Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CORE_DAEMON` | Set to `1` to run the application in daemon mode. |
|
||||
| `NO_COLOR` | If set (to any value), disables ANSI color output. |
|
||||
| `MCP_ADDR` | Address for the MCP TCP server (e.g., `localhost:9100`). If not set, MCP uses Stdio. |
|
||||
| `COOLIFY_TOKEN` | API token for Coolify deployments. |
|
||||
| `AGENTIC_TOKEN` | API token for Agentic services. |
|
||||
| `UNIFI_URL` | URL of the UniFi controller (e.g., `https://192.168.1.1`). |
|
||||
| `UNIFI_INSECURE` | Set to `1` or `true` to skip UniFi TLS verification. |
|
||||
|
||||
## All Tasks
|
||||
|
||||
| Task | Description |
|
||||
|
|
@ -64,7 +137,7 @@ task cli:run # Build and run
|
|||
| `task test-gen` | Generate test stubs for public API |
|
||||
| `task check` | go mod tidy + tests + review |
|
||||
| `task review` | CodeRabbit review |
|
||||
| `task cov` | Generate coverage.txt |
|
||||
| `task cov` | Run tests with coverage report |
|
||||
| `task cov-view` | Open HTML coverage report |
|
||||
| `task sync` | Update public API Go files |
|
||||
|
||||
|
|
@ -76,21 +149,20 @@ task cli:run # Build and run
|
|||
|
||||
```
|
||||
.
|
||||
├── core.go # Facade re-exporting pkg/core
|
||||
├── main.go # CLI application entry point
|
||||
├── pkg/
|
||||
│ ├── core/ # Service container, DI, Runtime[T]
|
||||
│ ├── config/ # JSON persistence, XDG paths
|
||||
│ ├── display/ # Windows, tray, menus (Wails)
|
||||
│ ├── framework/core/ # Service container, DI, Runtime[T]
|
||||
│ ├── crypt/ # Hashing, checksums, PGP
|
||||
│ │ └── openpgp/ # Full PGP implementation
|
||||
│ ├── io/ # Medium interface + backends
|
||||
│ ├── workspace/ # Encrypted workspace management
|
||||
│ ├── help/ # In-app documentation
|
||||
│ └── i18n/ # Internationalization
|
||||
├── cmd/
|
||||
│ ├── core/ # CLI application
|
||||
│ └── core-gui/ # Wails GUI application
|
||||
└── go.work # Links root, cmd/core, cmd/core-gui
|
||||
│ ├── i18n/ # Internationalization
|
||||
│ ├── repos/ # Multi-repo registry & management
|
||||
│ ├── agentic/ # AI agent task management
|
||||
│ └── mcp/ # Model Context Protocol service
|
||||
├── internal/
|
||||
│ ├── cmd/ # CLI command implementations
|
||||
│ └── variants/ # Build variants (full, minimal, etc.)
|
||||
└── go.mod # Go module definition
|
||||
```
|
||||
|
||||
### Service Pattern (Dual-Constructor DI)
|
||||
|
|
@ -138,7 +210,7 @@ app.RegisterService(application.NewService(coreService)) // Only Core is regist
|
|||
**Currently exposed** (see `cmd/core-gui/public/bindings/`):
|
||||
```typescript
|
||||
// From frontend:
|
||||
import { ACTION, Config, Service } from './bindings/github.com/Snider/Core/pkg/core'
|
||||
import { ACTION, Config, Service } from './bindings/forge.lthn.ai/core/cli/pkg/core'
|
||||
|
||||
ACTION(msg) // Broadcast IPC message
|
||||
Config() // Get config service reference
|
||||
|
|
@ -147,13 +219,47 @@ Service("workspace") // Get service by name (returns any)
|
|||
|
||||
**NOT exposed:** Direct calls like `workspace.CreateWorkspace()` or `crypt.Hash()`.
|
||||
|
||||
## Configuration Management
|
||||
|
||||
Core uses a **centralized configuration service** implemented in `pkg/config`, with YAML-based persistence and layered overrides.
|
||||
|
||||
The `pkg/config` package provides:
|
||||
|
||||
- YAML-backed persistence at `~/.core/config.yaml`
|
||||
- Dot-notation key access (for example: `cfg.Set("dev.editor", "vim")`, `cfg.GetString("dev.editor")`)
|
||||
- Environment variable overlay support (env vars can override persisted values)
|
||||
- Thread-safe operations for concurrent reads/writes
|
||||
|
||||
Application code should treat `pkg/config` as the **primary configuration mechanism**. Direct reads/writes to YAML files should generally be avoided from application logic in favour of using this centralized service.
|
||||
|
||||
### Project and Service Configuration Files
|
||||
|
||||
In addition to the centralized configuration service, Core uses several YAML files for project-specific build/CI and service configuration. These live alongside (but are distinct from) the centralized configuration:
|
||||
|
||||
- **Project Configuration** (in the `.core/` directory of the project root):
|
||||
- `build.yaml`: Build targets, flags, and project metadata.
|
||||
- `release.yaml`: Release automation, changelog settings, and publishing targets.
|
||||
- `ci.yaml`: CI pipeline configuration.
|
||||
- **Global Configuration** (in the `~/.core/` directory):
|
||||
- `config.yaml`: Centralized user/framework settings and defaults, managed via `pkg/config`.
|
||||
- `agentic.yaml`: Configuration for agentic services (BaseURL, Token, etc.).
|
||||
- **Registry Configuration** (`repos.yaml`, auto-discovered):
|
||||
- Multi-repo registry definition.
|
||||
- Searched in the current directory and its parent directories (walking up).
|
||||
- Then in `~/Code/host-uk/repos.yaml`.
|
||||
- Finally in `~/.config/core/repos.yaml`.
|
||||
|
||||
### Format
|
||||
|
||||
All persisted configuration files described above use **YAML** format for readability and nested structure support.
|
||||
|
||||
### The IPC Bridge Pattern (Chosen Architecture)
|
||||
|
||||
Sub-services are accessed via Core's **IPC/ACTION system**, not direct Wails bindings:
|
||||
|
||||
```typescript
|
||||
// Frontend calls Core.ACTION() with typed messages
|
||||
import { ACTION } from './bindings/github.com/Snider/Core/pkg/core'
|
||||
import { ACTION } from './bindings/forge.lthn.ai/core/cli/pkg/core'
|
||||
|
||||
// Open a window
|
||||
ACTION({ action: "display.open_window", name: "settings", options: { Title: "Settings", Width: 800 } })
|
||||
|
|
@ -187,16 +293,15 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|||
|
||||
### Generating Bindings
|
||||
|
||||
Wails v3 bindings are typically generated in the GUI repository (e.g., `core-gui`).
|
||||
|
||||
```bash
|
||||
cd cmd/core-gui
|
||||
wails3 generate bindings # Regenerate after Go changes
|
||||
```
|
||||
|
||||
Bindings output to `cmd/core-gui/public/bindings/github.com/Snider/Core/` mirroring Go package structure.
|
||||
|
||||
---
|
||||
|
||||
### Service Interfaces (`pkg/core/interfaces.go`)
|
||||
### Service Interfaces (`pkg/framework/core/interfaces.go`)
|
||||
|
||||
```go
|
||||
type Config interface {
|
||||
|
|
@ -229,54 +334,27 @@ type Crypt interface {
|
|||
|
||||
| Package | Notes |
|
||||
|---------|-------|
|
||||
| `pkg/core` | Service container, DI, thread-safe - solid |
|
||||
| `pkg/config` | JSON persistence, XDG paths - solid |
|
||||
| `pkg/crypt` | Hashing, checksums, PGP - solid, well-tested |
|
||||
| `pkg/help` | Embedded docs, Show/ShowAt - solid |
|
||||
| `pkg/framework/core` | Service container, DI, thread-safe - solid |
|
||||
| `pkg/config` | Layered YAML configuration, XDG paths - solid |
|
||||
| `pkg/crypt` | Hashing, checksums, symmetric/asymmetric - solid, well-tested |
|
||||
| `pkg/help` | Embedded docs, full-text search - solid |
|
||||
| `pkg/i18n` | Multi-language with go-i18n - solid |
|
||||
| `pkg/io` | Medium interface + local backend - solid |
|
||||
| `pkg/workspace` | Workspace creation, switching, file ops - functional |
|
||||
|
||||
### Partial
|
||||
|
||||
| Package | Issues |
|
||||
|---------|--------|
|
||||
| `pkg/display` | Window creation works; menu/tray handlers are TODOs |
|
||||
|
||||
---
|
||||
|
||||
## Priority Work Items
|
||||
|
||||
### 1. IMPLEMENT: System Tray Brand Support
|
||||
|
||||
`pkg/display/tray.go:52-63` - Commented brand-specific menu items need implementation.
|
||||
|
||||
### 2. ADD: Integration Tests
|
||||
|
||||
| Package | Notes |
|
||||
|---------|-------|
|
||||
| `pkg/display` | Integration tests requiring Wails runtime (27% unit coverage) |
|
||||
| `pkg/repos` | Multi-repo registry & management - solid |
|
||||
| `pkg/agentic` | AI agent task management - solid |
|
||||
| `pkg/mcp` | Model Context Protocol service - solid |
|
||||
|
||||
---
|
||||
|
||||
## Package Deep Dives
|
||||
|
||||
### pkg/workspace - The Core Feature
|
||||
### pkg/crypt
|
||||
|
||||
Each workspace is:
|
||||
1. Identified by LTHN hash of user identifier
|
||||
2. Has directory structure: `config/`, `log/`, `data/`, `files/`, `keys/`
|
||||
3. Gets a PGP keypair generated on creation
|
||||
4. Files accessed via obfuscated paths
|
||||
|
||||
The `workspaceList` maps workspace IDs to public keys.
|
||||
|
||||
### pkg/crypt/openpgp
|
||||
|
||||
Full PGP using `github.com/ProtonMail/go-crypto`:
|
||||
- `CreateKeyPair(name, passphrase)` - RSA-4096 with revocation cert
|
||||
- `EncryptPGP()` - Encrypt + optional signing
|
||||
- `DecryptPGP()` - Decrypt + optional signature verification
|
||||
The crypt package provides a comprehensive suite of cryptographic primitives:
|
||||
- **Hashing & Checksums**: SHA-256, SHA-512, and CRC32 support.
|
||||
- **Symmetric Encryption**: AES-GCM and ChaCha20-Poly1305 for secure data at rest.
|
||||
- **Key Derivation**: Argon2id for secure password hashing.
|
||||
- **Asymmetric Encryption**: PGP implementation in the `pkg/crypt/openpgp` subpackage using `github.com/ProtonMail/go-crypto`.
|
||||
|
||||
### pkg/io - Storage Abstraction
|
||||
|
||||
|
|
@ -339,10 +417,27 @@ Implementations: `local/`, `sftp/`, `webdav/`
|
|||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **[User Guide](docs/user-guide.md)**: Detailed usage and concepts.
|
||||
- **[FAQ](docs/faq.md)**: Frequently asked questions.
|
||||
- **[Workflows](docs/workflows.md)**: Common task sequences.
|
||||
- **[Troubleshooting](docs/troubleshooting.md)**: Solving common issues.
|
||||
- **[Configuration](docs/configuration.md)**: Config file reference.
|
||||
|
||||
```bash
|
||||
# Check environment
|
||||
core doctor
|
||||
|
||||
# Command help
|
||||
core <command> --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For New Contributors
|
||||
|
||||
1. Run `task test` to verify all tests pass
|
||||
2. Follow TDD: `task test-gen` creates stubs, implement to pass
|
||||
3. The dual-constructor pattern is intentional: `New(deps)` for tests, `Register()` for runtime
|
||||
4. See `cmd/core-gui/main.go` for how services wire together
|
||||
5. IPC handlers in each service's `HandleIPCEvents()` are the frontend bridge
|
||||
4. IPC handlers in each service's `HandleIPCEvents()` are the frontend bridge
|
||||
|
|
|
|||
6
Taskfile.yaml
Normal file
6
Taskfile.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
version: '3'
|
||||
|
||||
tasks:
|
||||
build:
|
||||
cmds:
|
||||
- go build -o build/bin/core cmd/app/main.go
|
||||
136
Taskfile.yml
136
Taskfile.yml
|
|
@ -1,16 +1,55 @@
|
|||
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/go/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"
|
||||
desc: "Build core CLI to ./bin/core (dev build with debug info)"
|
||||
cmds:
|
||||
- go build -o ./bin/core .
|
||||
- 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"
|
||||
desc: "Install core CLI to system PATH (dev build)"
|
||||
cmds:
|
||||
- go install .
|
||||
- go install -ldflags '{{.LDFLAGS}}' .
|
||||
|
||||
cli:install:release:
|
||||
desc: "Install core CLI for release (smaller binary)"
|
||||
cmds:
|
||||
- go install -ldflags '{{.LDFLAGS_RELEASE}}' .
|
||||
|
||||
# --- Development ---
|
||||
test:
|
||||
|
|
@ -33,6 +72,11 @@ tasks:
|
|||
cmds:
|
||||
- core go cov
|
||||
|
||||
cov-view:
|
||||
desc: "Open HTML coverage report"
|
||||
cmds:
|
||||
- core go cov --open
|
||||
|
||||
fmt:
|
||||
desc: "Format Go code"
|
||||
cmds:
|
||||
|
|
@ -115,6 +159,90 @@ tasks:
|
|||
cmds:
|
||||
- go run ./internal/tools/i18n-validate ./...
|
||||
|
||||
# --- Core IDE (Wails v3) ---
|
||||
ide:dev:
|
||||
desc: "Run Core IDE in Wails dev mode"
|
||||
dir: cmd/core-ide
|
||||
cmds:
|
||||
- cd frontend && npm install && npm run build
|
||||
- wails3 dev
|
||||
|
||||
ide:build:
|
||||
desc: "Build Core IDE production binary"
|
||||
dir: cmd/core-ide
|
||||
cmds:
|
||||
- cd frontend && npm install && npm run build
|
||||
- wails3 build
|
||||
|
||||
ide:frontend:
|
||||
desc: "Build Core IDE frontend only"
|
||||
dir: cmd/core-ide/frontend
|
||||
cmds:
|
||||
- npm install
|
||||
- npm run build
|
||||
|
||||
# --- Core App (FrankenPHP + Wails v3) ---
|
||||
app:setup:
|
||||
desc: "Install PHP-ZTS build dependency for Core App"
|
||||
cmds:
|
||||
- brew tap shivammathur/php 2>/dev/null || true
|
||||
- brew install shivammathur/php/php@8.4-zts
|
||||
|
||||
app:composer:
|
||||
desc: "Install Laravel dependencies for Core App"
|
||||
dir: cmd/core-app/laravel
|
||||
cmds:
|
||||
- composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
app:build:
|
||||
desc: "Build Core App (FrankenPHP + Laravel desktop binary)"
|
||||
dir: cmd/core-app
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
CGO_CFLAGS:
|
||||
sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes
|
||||
CGO_LDFLAGS:
|
||||
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)"
|
||||
cmds:
|
||||
- go build -tags nowatcher -o ../../bin/core-app .
|
||||
|
||||
app:dev:
|
||||
desc: "Build and run Core App"
|
||||
dir: cmd/core-app
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
CGO_CFLAGS:
|
||||
sh: /opt/homebrew/opt/php@8.4-zts/bin/php-config --includes
|
||||
CGO_LDFLAGS:
|
||||
sh: "echo -L/opt/homebrew/opt/php@8.4-zts/lib $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --ldflags) $(/opt/homebrew/opt/php@8.4-zts/bin/php-config --libs)"
|
||||
DYLD_LIBRARY_PATH: "/opt/homebrew/opt/php@8.4-zts/lib"
|
||||
cmds:
|
||||
- go build -tags nowatcher -o ../../bin/core-app .
|
||||
- ../../bin/core-app
|
||||
|
||||
# --- 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"
|
||||
|
|
|
|||
349
cmd/ai/cmd_agent.go
Normal file
349
cmd/ai/cmd_agent.go
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/agentci"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/config"
|
||||
)
|
||||
|
||||
// AddAgentCommands registers the 'agent' subcommand group under 'ai'.
|
||||
func AddAgentCommands(parent *cli.Command) {
|
||||
agentCmd := &cli.Command{
|
||||
Use: "agent",
|
||||
Short: "Manage AgentCI dispatch targets",
|
||||
}
|
||||
|
||||
agentCmd.AddCommand(agentAddCmd())
|
||||
agentCmd.AddCommand(agentListCmd())
|
||||
agentCmd.AddCommand(agentStatusCmd())
|
||||
agentCmd.AddCommand(agentLogsCmd())
|
||||
agentCmd.AddCommand(agentSetupCmd())
|
||||
agentCmd.AddCommand(agentRemoveCmd())
|
||||
|
||||
parent.AddCommand(agentCmd)
|
||||
}
|
||||
|
||||
func loadConfig() (*config.Config, error) {
|
||||
return config.New()
|
||||
}
|
||||
|
||||
func agentAddCmd() *cli.Command {
|
||||
cmd := &cli.Command{
|
||||
Use: "add <name> <user@host>",
|
||||
Short: "Add an agent to the config and verify SSH",
|
||||
Args: cli.ExactArgs(2),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
name := args[0]
|
||||
host := args[1]
|
||||
|
||||
forgejoUser, _ := cmd.Flags().GetString("forgejo-user")
|
||||
if forgejoUser == "" {
|
||||
forgejoUser = name
|
||||
}
|
||||
queueDir, _ := cmd.Flags().GetString("queue-dir")
|
||||
if queueDir == "" {
|
||||
queueDir = "/home/claude/ai-work/queue"
|
||||
}
|
||||
model, _ := cmd.Flags().GetString("model")
|
||||
dualRun, _ := cmd.Flags().GetBool("dual-run")
|
||||
|
||||
// Scan and add host key to known_hosts.
|
||||
parts := strings.Split(host, "@")
|
||||
hostname := parts[len(parts)-1]
|
||||
|
||||
fmt.Printf("Scanning host key for %s... ", hostname)
|
||||
scanCmd := exec.Command("ssh-keyscan", "-H", hostname)
|
||||
keys, err := scanCmd.Output()
|
||||
if err != nil {
|
||||
fmt.Println(errorStyle.Render("FAILED"))
|
||||
return fmt.Errorf("failed to scan host keys: %w", err)
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
|
||||
f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open known_hosts: %w", err)
|
||||
}
|
||||
if _, err := f.Write(keys); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("failed to write known_hosts: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
fmt.Println(successStyle.Render("OK"))
|
||||
|
||||
// Test SSH with strict host key checking.
|
||||
fmt.Printf("Testing SSH to %s... ", host)
|
||||
testCmd := agentci.SecureSSHCommand(host, "echo ok")
|
||||
out, err := testCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Println(errorStyle.Render("FAILED"))
|
||||
return fmt.Errorf("SSH failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
fmt.Println(successStyle.Render("OK"))
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ac := agentci.AgentConfig{
|
||||
Host: host,
|
||||
QueueDir: queueDir,
|
||||
ForgejoUser: forgejoUser,
|
||||
Model: model,
|
||||
DualRun: dualRun,
|
||||
Active: true,
|
||||
}
|
||||
if err := agentci.SaveAgent(cfg, name, ac); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Agent %s added (%s)\n", successStyle.Render(name), host)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("forgejo-user", "", "Forgejo username (defaults to agent name)")
|
||||
cmd.Flags().String("queue-dir", "", "Remote queue directory (default: /home/claude/ai-work/queue)")
|
||||
cmd.Flags().String("model", "sonnet", "Primary AI model")
|
||||
cmd.Flags().Bool("dual-run", false, "Enable Clotho dual-run verification")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func agentListCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Use: "list",
|
||||
Short: "List configured agents",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agents, err := agentci.ListAgents(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(agents) == 0 {
|
||||
fmt.Println(dimStyle.Render("No agents configured. Use 'core ai agent add' to add one."))
|
||||
return nil
|
||||
}
|
||||
|
||||
table := cli.NewTable("NAME", "HOST", "MODEL", "DUAL", "ACTIVE", "QUEUE")
|
||||
for name, ac := range agents {
|
||||
active := dimStyle.Render("no")
|
||||
if ac.Active {
|
||||
active = successStyle.Render("yes")
|
||||
}
|
||||
dual := dimStyle.Render("no")
|
||||
if ac.DualRun {
|
||||
dual = successStyle.Render("yes")
|
||||
}
|
||||
|
||||
// Quick SSH check for queue depth.
|
||||
queue := dimStyle.Render("-")
|
||||
checkCmd := agentci.SecureSSHCommand(ac.Host, fmt.Sprintf("ls %s/ticket-*.json 2>/dev/null | wc -l", ac.QueueDir))
|
||||
out, err := checkCmd.Output()
|
||||
if err == nil {
|
||||
n := strings.TrimSpace(string(out))
|
||||
if n != "0" {
|
||||
queue = n
|
||||
} else {
|
||||
queue = "0"
|
||||
}
|
||||
}
|
||||
|
||||
table.AddRow(name, ac.Host, ac.Model, dual, active, queue)
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func agentStatusCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Use: "status <name>",
|
||||
Short: "Check agent status via SSH",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
name := args[0]
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agents, err := agentci.ListAgents(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ac, ok := agents[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("agent %q not found", name)
|
||||
}
|
||||
|
||||
script := `
|
||||
echo "=== Queue ==="
|
||||
ls ~/ai-work/queue/ticket-*.json 2>/dev/null | wc -l
|
||||
echo "=== Active ==="
|
||||
ls ~/ai-work/active/ticket-*.json 2>/dev/null || echo "none"
|
||||
echo "=== Done ==="
|
||||
ls ~/ai-work/done/ticket-*.json 2>/dev/null | wc -l
|
||||
echo "=== Lock ==="
|
||||
if [ -f ~/ai-work/.runner.lock ]; then
|
||||
PID=$(cat ~/ai-work/.runner.lock)
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "RUNNING (PID $PID)"
|
||||
else
|
||||
echo "STALE (PID $PID)"
|
||||
fi
|
||||
else
|
||||
echo "IDLE"
|
||||
fi
|
||||
`
|
||||
|
||||
sshCmd := agentci.SecureSSHCommand(ac.Host, script)
|
||||
sshCmd.Stdout = os.Stdout
|
||||
sshCmd.Stderr = os.Stderr
|
||||
return sshCmd.Run()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func agentLogsCmd() *cli.Command {
|
||||
cmd := &cli.Command{
|
||||
Use: "logs <name>",
|
||||
Short: "Stream agent runner logs",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
name := args[0]
|
||||
follow, _ := cmd.Flags().GetBool("follow")
|
||||
lines, _ := cmd.Flags().GetInt("lines")
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agents, err := agentci.ListAgents(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ac, ok := agents[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("agent %q not found", name)
|
||||
}
|
||||
|
||||
remoteCmd := fmt.Sprintf("tail -n %d ~/ai-work/logs/runner.log", lines)
|
||||
if follow {
|
||||
remoteCmd = fmt.Sprintf("tail -f -n %d ~/ai-work/logs/runner.log", lines)
|
||||
}
|
||||
|
||||
sshCmd := agentci.SecureSSHCommand(ac.Host, remoteCmd)
|
||||
sshCmd.Stdout = os.Stdout
|
||||
sshCmd.Stderr = os.Stderr
|
||||
sshCmd.Stdin = os.Stdin
|
||||
return sshCmd.Run()
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolP("follow", "f", false, "Follow log output")
|
||||
cmd.Flags().IntP("lines", "n", 50, "Number of lines to show")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func agentSetupCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Use: "setup <name>",
|
||||
Short: "Bootstrap agent machine (create dirs, copy runner, install cron)",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
name := args[0]
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agents, err := agentci.ListAgents(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ac, ok := agents[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("agent %q not found — use 'core ai agent add' first", name)
|
||||
}
|
||||
|
||||
// Find the setup script relative to the binary or in known locations.
|
||||
scriptPath := findSetupScript()
|
||||
if scriptPath == "" {
|
||||
return fmt.Errorf("agent-setup.sh not found — expected in scripts/ directory")
|
||||
}
|
||||
|
||||
fmt.Printf("Setting up %s on %s...\n", name, ac.Host)
|
||||
setupCmd := exec.Command("bash", scriptPath, ac.Host)
|
||||
setupCmd.Stdout = os.Stdout
|
||||
setupCmd.Stderr = os.Stderr
|
||||
if err := setupCmd.Run(); err != nil {
|
||||
return fmt.Errorf("setup failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(successStyle.Render("Setup complete!"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func agentRemoveCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Use: "remove <name>",
|
||||
Short: "Remove an agent from config",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
name := args[0]
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := agentci.RemoveAgent(cfg, name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Agent %s removed.\n", name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// findSetupScript looks for agent-setup.sh in common locations.
|
||||
func findSetupScript() string {
|
||||
exe, _ := os.Executable()
|
||||
if exe != "" {
|
||||
dir := filepath.Dir(exe)
|
||||
candidates := []string{
|
||||
filepath.Join(dir, "scripts", "agent-setup.sh"),
|
||||
filepath.Join(dir, "..", "scripts", "agent-setup.sh"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
if cwd != "" {
|
||||
p := filepath.Join(cwd, "scripts", "agent-setup.sh")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// Style aliases from shared package
|
||||
|
|
@ -28,8 +28,8 @@ var (
|
|||
|
||||
// Task-specific styles (aliases to shared where possible)
|
||||
var (
|
||||
taskIDStyle = cli.TitleStyle // Bold + blue
|
||||
taskTitleStyle = cli.ValueStyle // Light gray
|
||||
taskIDStyle = cli.TitleStyle // Bold + blue
|
||||
taskTitleStyle = cli.ValueStyle // Light gray
|
||||
taskLabelStyle = cli.NewStyle().Foreground(cli.ColourViolet500) // Violet for labels
|
||||
)
|
||||
|
||||
|
|
@ -8,11 +8,14 @@
|
|||
// - task:commit: Create commits with task references
|
||||
// - task:pr: Create pull requests linked to tasks
|
||||
// - claude: Claude Code CLI integration (planned)
|
||||
// - rag: RAG tools (ingest, query, collections)
|
||||
// - metrics: View AI/security event metrics
|
||||
package ai
|
||||
|
||||
import (
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
ragcmd "forge.lthn.ai/core/cli/cmd/rag"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
@ -57,6 +60,21 @@ func initCommands() {
|
|||
|
||||
// Add agentic task commands
|
||||
AddAgenticCommands(aiCmd)
|
||||
|
||||
// Add RAG subcommands (core ai rag ...)
|
||||
ragcmd.AddRAGSubcommands(aiCmd)
|
||||
|
||||
// Add metrics subcommand (core ai metrics)
|
||||
addMetricsCommand(aiCmd)
|
||||
|
||||
// Add agent management commands (core ai agent ...)
|
||||
AddAgentCommands(aiCmd)
|
||||
|
||||
// Add rate limit management commands (core ai ratelimits ...)
|
||||
AddRateLimitCommands(aiCmd)
|
||||
|
||||
// Add dispatch commands (core ai dispatch run/watch/status)
|
||||
AddDispatchCommands(aiCmd)
|
||||
}
|
||||
|
||||
// AddAICommands registers the 'ai' command and all subcommands.
|
||||
498
cmd/ai/cmd_dispatch.go
Normal file
498
cmd/ai/cmd_dispatch.go
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// AddDispatchCommands registers the 'dispatch' subcommand group under 'ai'.
|
||||
// These commands run ON the agent machine to process the work queue.
|
||||
func AddDispatchCommands(parent *cli.Command) {
|
||||
dispatchCmd := &cli.Command{
|
||||
Use: "dispatch",
|
||||
Short: "Agent work queue processor (runs on agent machine)",
|
||||
}
|
||||
|
||||
dispatchCmd.AddCommand(dispatchRunCmd())
|
||||
dispatchCmd.AddCommand(dispatchWatchCmd())
|
||||
dispatchCmd.AddCommand(dispatchStatusCmd())
|
||||
|
||||
parent.AddCommand(dispatchCmd)
|
||||
}
|
||||
|
||||
// dispatchTicket represents the work item JSON structure.
|
||||
type dispatchTicket struct {
|
||||
ID string `json:"id"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
IssueNumber int `json:"issue_number"`
|
||||
IssueTitle string `json:"issue_title"`
|
||||
IssueBody string `json:"issue_body"`
|
||||
TargetBranch string `json:"target_branch"`
|
||||
EpicNumber int `json:"epic_number"`
|
||||
ForgeURL string `json:"forge_url"`
|
||||
ForgeToken string `json:"forge_token"`
|
||||
ForgeUser string `json:"forgejo_user"`
|
||||
Model string `json:"model"`
|
||||
Runner string `json:"runner"`
|
||||
Timeout string `json:"timeout"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultWorkDir = "ai-work"
|
||||
lockFileName = ".runner.lock"
|
||||
)
|
||||
|
||||
type runnerPaths struct {
|
||||
root string
|
||||
queue string
|
||||
active string
|
||||
done string
|
||||
logs string
|
||||
jobs string
|
||||
lock string
|
||||
}
|
||||
|
||||
func getPaths(baseDir string) runnerPaths {
|
||||
if baseDir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
baseDir = filepath.Join(home, defaultWorkDir)
|
||||
}
|
||||
return runnerPaths{
|
||||
root: baseDir,
|
||||
queue: filepath.Join(baseDir, "queue"),
|
||||
active: filepath.Join(baseDir, "active"),
|
||||
done: filepath.Join(baseDir, "done"),
|
||||
logs: filepath.Join(baseDir, "logs"),
|
||||
jobs: filepath.Join(baseDir, "jobs"),
|
||||
lock: filepath.Join(baseDir, lockFileName),
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchRunCmd() *cli.Command {
|
||||
cmd := &cli.Command{
|
||||
Use: "run",
|
||||
Short: "Process a single ticket from the queue",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
workDir, _ := cmd.Flags().GetString("work-dir")
|
||||
paths := getPaths(workDir)
|
||||
|
||||
if err := ensureDispatchDirs(paths); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := acquireLock(paths.lock); err != nil {
|
||||
log.Info("Runner locked, skipping run", "lock", paths.lock)
|
||||
return nil
|
||||
}
|
||||
defer releaseLock(paths.lock)
|
||||
|
||||
ticketFile, err := pickOldestTicket(paths.queue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ticketFile == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return processTicket(paths, ticketFile)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func dispatchWatchCmd() *cli.Command {
|
||||
cmd := &cli.Command{
|
||||
Use: "watch",
|
||||
Short: "Run as a daemon, polling the queue",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
workDir, _ := cmd.Flags().GetString("work-dir")
|
||||
interval, _ := cmd.Flags().GetDuration("interval")
|
||||
paths := getPaths(workDir)
|
||||
|
||||
if err := ensureDispatchDirs(paths); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("Starting dispatch watcher", "dir", paths.root, "interval", interval)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
runCycle(paths)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
runCycle(paths)
|
||||
case <-sigChan:
|
||||
log.Info("Shutting down watcher...")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
|
||||
cmd.Flags().Duration("interval", 5*time.Minute, "Polling interval")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func dispatchStatusCmd() *cli.Command {
|
||||
cmd := &cli.Command{
|
||||
Use: "status",
|
||||
Short: "Show runner status",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
workDir, _ := cmd.Flags().GetString("work-dir")
|
||||
paths := getPaths(workDir)
|
||||
|
||||
lockStatus := "IDLE"
|
||||
if data, err := os.ReadFile(paths.lock); err == nil {
|
||||
pidStr := strings.TrimSpace(string(data))
|
||||
pid, _ := strconv.Atoi(pidStr)
|
||||
if isProcessAlive(pid) {
|
||||
lockStatus = fmt.Sprintf("RUNNING (PID %d)", pid)
|
||||
} else {
|
||||
lockStatus = fmt.Sprintf("STALE (PID %d)", pid)
|
||||
}
|
||||
}
|
||||
|
||||
countFiles := func(dir string) int {
|
||||
entries, _ := os.ReadDir(dir)
|
||||
count := 0
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasPrefix(e.Name(), "ticket-") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
fmt.Println("=== Agent Dispatch Status ===")
|
||||
fmt.Printf("Work Dir: %s\n", paths.root)
|
||||
fmt.Printf("Status: %s\n", lockStatus)
|
||||
fmt.Printf("Queue: %d\n", countFiles(paths.queue))
|
||||
fmt.Printf("Active: %d\n", countFiles(paths.active))
|
||||
fmt.Printf("Done: %d\n", countFiles(paths.done))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("work-dir", "", "Working directory (default: ~/ai-work)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCycle(paths runnerPaths) {
|
||||
if err := acquireLock(paths.lock); err != nil {
|
||||
log.Debug("Runner locked, skipping cycle")
|
||||
return
|
||||
}
|
||||
defer releaseLock(paths.lock)
|
||||
|
||||
ticketFile, err := pickOldestTicket(paths.queue)
|
||||
if err != nil {
|
||||
log.Error("Failed to pick ticket", "error", err)
|
||||
return
|
||||
}
|
||||
if ticketFile == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := processTicket(paths, ticketFile); err != nil {
|
||||
log.Error("Failed to process ticket", "file", ticketFile, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func processTicket(paths runnerPaths, ticketPath string) error {
|
||||
fileName := filepath.Base(ticketPath)
|
||||
log.Info("Processing ticket", "file", fileName)
|
||||
|
||||
activePath := filepath.Join(paths.active, fileName)
|
||||
if err := os.Rename(ticketPath, activePath); err != nil {
|
||||
return fmt.Errorf("failed to move ticket to active: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(activePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read ticket: %w", err)
|
||||
}
|
||||
var t dispatchTicket
|
||||
if err := json.Unmarshal(data, &t); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal ticket: %w", err)
|
||||
}
|
||||
|
||||
jobDir := filepath.Join(paths.jobs, fmt.Sprintf("%s-%s-%d", t.RepoOwner, t.RepoName, t.IssueNumber))
|
||||
repoDir := filepath.Join(jobDir, t.RepoName)
|
||||
if err := os.MkdirAll(jobDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := prepareRepo(t, repoDir); err != nil {
|
||||
reportToForge(t, false, fmt.Sprintf("Git setup failed: %v", err))
|
||||
moveToDone(paths, activePath, fileName)
|
||||
return err
|
||||
}
|
||||
|
||||
prompt := buildPrompt(t)
|
||||
|
||||
logFile := filepath.Join(paths.logs, fmt.Sprintf("%s-%s-%d.log", t.RepoOwner, t.RepoName, t.IssueNumber))
|
||||
success, exitCode, runErr := runAgent(t, prompt, repoDir, logFile)
|
||||
|
||||
msg := fmt.Sprintf("Agent completed work on #%d. Exit code: %d.", t.IssueNumber, exitCode)
|
||||
if !success {
|
||||
msg = fmt.Sprintf("Agent failed on #%d (exit code: %d). Check logs on agent machine.", t.IssueNumber, exitCode)
|
||||
if runErr != nil {
|
||||
msg += fmt.Sprintf(" Error: %v", runErr)
|
||||
}
|
||||
}
|
||||
reportToForge(t, success, msg)
|
||||
|
||||
moveToDone(paths, activePath, fileName)
|
||||
log.Info("Ticket complete", "id", t.ID, "success", success)
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareRepo(t dispatchTicket, repoDir string) error {
|
||||
user := t.ForgeUser
|
||||
if user == "" {
|
||||
host, _ := os.Hostname()
|
||||
user = fmt.Sprintf("%s-%s", host, os.Getenv("USER"))
|
||||
}
|
||||
|
||||
cleanURL := strings.TrimPrefix(t.ForgeURL, "https://")
|
||||
cleanURL = strings.TrimPrefix(cleanURL, "http://")
|
||||
cloneURL := fmt.Sprintf("https://%s:%s@%s/%s/%s.git", user, t.ForgeToken, cleanURL, t.RepoOwner, t.RepoName)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(repoDir, ".git")); err == nil {
|
||||
log.Info("Updating existing repo", "dir", repoDir)
|
||||
cmds := [][]string{
|
||||
{"git", "fetch", "origin"},
|
||||
{"git", "checkout", t.TargetBranch},
|
||||
{"git", "pull", "origin", t.TargetBranch},
|
||||
}
|
||||
for _, args := range cmds {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Dir = repoDir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if args[1] == "checkout" {
|
||||
createCmd := exec.Command("git", "checkout", "-b", t.TargetBranch, "origin/"+t.TargetBranch)
|
||||
createCmd.Dir = repoDir
|
||||
if _, err2 := createCmd.CombinedOutput(); err2 == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("git command %v failed: %s", args, string(out))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Info("Cloning repo", "url", t.RepoOwner+"/"+t.RepoName)
|
||||
cmd := exec.Command("git", "clone", "-b", t.TargetBranch, cloneURL, repoDir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git clone failed: %s", string(out))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildPrompt(t dispatchTicket) string {
|
||||
return fmt.Sprintf(`You are working on issue #%d in %s/%s.
|
||||
|
||||
Title: %s
|
||||
|
||||
Description:
|
||||
%s
|
||||
|
||||
The repo is cloned at the current directory on branch '%s'.
|
||||
Create a feature branch from '%s', make minimal targeted changes, commit referencing #%d, and push.
|
||||
Then create a PR targeting '%s' using the forgejo MCP tools or git push.`,
|
||||
t.IssueNumber, t.RepoOwner, t.RepoName,
|
||||
t.IssueTitle,
|
||||
t.IssueBody,
|
||||
t.TargetBranch,
|
||||
t.TargetBranch, t.IssueNumber,
|
||||
t.TargetBranch,
|
||||
)
|
||||
}
|
||||
|
||||
func runAgent(t dispatchTicket, prompt, dir, logPath string) (bool, int, error) {
|
||||
timeout := 30 * time.Minute
|
||||
if t.Timeout != "" {
|
||||
if d, err := time.ParseDuration(t.Timeout); err == nil {
|
||||
timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
model := t.Model
|
||||
if model == "" {
|
||||
model = "sonnet"
|
||||
}
|
||||
|
||||
log.Info("Running agent", "runner", t.Runner, "model", model)
|
||||
|
||||
// For Gemini runner, wrap with rate limiting.
|
||||
if t.Runner == "gemini" {
|
||||
return executeWithRateLimit(ctx, model, prompt, func() (bool, int, error) {
|
||||
return execAgent(ctx, t.Runner, model, prompt, dir, logPath)
|
||||
})
|
||||
}
|
||||
|
||||
return execAgent(ctx, t.Runner, model, prompt, dir, logPath)
|
||||
}
|
||||
|
||||
func execAgent(ctx context.Context, runner, model, prompt, dir, logPath string) (bool, int, error) {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
switch runner {
|
||||
case "codex":
|
||||
cmd = exec.CommandContext(ctx, "codex", "exec", "--full-auto", prompt)
|
||||
case "gemini":
|
||||
args := []string{"-p", "-", "-y", "-m", model}
|
||||
cmd = exec.CommandContext(ctx, "gemini", args...)
|
||||
cmd.Stdin = strings.NewReader(prompt)
|
||||
default: // claude
|
||||
cmd = exec.CommandContext(ctx, "claude", "-p", "--model", model, "--dangerously-skip-permissions", "--output-format", "text")
|
||||
cmd.Stdin = strings.NewReader(prompt)
|
||||
}
|
||||
|
||||
cmd.Dir = dir
|
||||
|
||||
f, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
return false, -1, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cmd.Stdout = f
|
||||
cmd.Stderr = f
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
exitCode := -1
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
}
|
||||
return false, exitCode, err
|
||||
}
|
||||
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func reportToForge(t dispatchTicket, success bool, body string) {
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments",
|
||||
strings.TrimSuffix(t.ForgeURL, "/"), t.RepoOwner, t.RepoName, t.IssueNumber)
|
||||
|
||||
payload := map[string]string{"body": body}
|
||||
jsonBody, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
log.Error("Failed to create request", "err", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+t.ForgeToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Error("Failed to report to Forge", "err", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
log.Warn("Forge reported error", "status", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func moveToDone(paths runnerPaths, activePath, fileName string) {
|
||||
donePath := filepath.Join(paths.done, fileName)
|
||||
if err := os.Rename(activePath, donePath); err != nil {
|
||||
log.Error("Failed to move ticket to done", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDispatchDirs(p runnerPaths) error {
|
||||
dirs := []string{p.queue, p.active, p.done, p.logs, p.jobs}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir %s failed: %w", d, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func acquireLock(lockPath string) error {
|
||||
if data, err := os.ReadFile(lockPath); err == nil {
|
||||
pidStr := strings.TrimSpace(string(data))
|
||||
pid, _ := strconv.Atoi(pidStr)
|
||||
if isProcessAlive(pid) {
|
||||
return fmt.Errorf("locked by PID %d", pid)
|
||||
}
|
||||
log.Info("Removing stale lock", "pid", pid)
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
|
||||
return os.WriteFile(lockPath, []byte(fmt.Sprintf("%d", os.Getpid())), 0644)
|
||||
}
|
||||
|
||||
func releaseLock(lockPath string) {
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
|
||||
func isProcessAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return process.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func pickOldestTicket(queueDir string) (string, error) {
|
||||
entries, err := os.ReadDir(queueDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var tickets []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasPrefix(e.Name(), "ticket-") && strings.HasSuffix(e.Name(), ".json") {
|
||||
tickets = append(tickets, filepath.Join(queueDir, e.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
if len(tickets) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
sort.Strings(tickets)
|
||||
return tickets[0], nil
|
||||
}
|
||||
|
|
@ -10,9 +10,9 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/agentic"
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/agentic"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// task:commit command flags
|
||||
131
cmd/ai/cmd_metrics.go
Normal file
131
cmd/ai/cmd_metrics.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// cmd_metrics.go implements the metrics viewing command.
|
||||
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/ai"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
metricsSince string
|
||||
metricsJSON bool
|
||||
)
|
||||
|
||||
var metricsCmd = &cli.Command{
|
||||
Use: "metrics",
|
||||
Short: i18n.T("cmd.ai.metrics.short"),
|
||||
Long: i18n.T("cmd.ai.metrics.long"),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runMetrics()
|
||||
},
|
||||
}
|
||||
|
||||
func initMetricsFlags() {
|
||||
metricsCmd.Flags().StringVar(&metricsSince, "since", "7d", i18n.T("cmd.ai.metrics.flag.since"))
|
||||
metricsCmd.Flags().BoolVar(&metricsJSON, "json", false, i18n.T("common.flag.json"))
|
||||
}
|
||||
|
||||
func addMetricsCommand(parent *cli.Command) {
|
||||
initMetricsFlags()
|
||||
parent.AddCommand(metricsCmd)
|
||||
}
|
||||
|
||||
func runMetrics() error {
|
||||
since, err := parseDuration(metricsSince)
|
||||
if err != nil {
|
||||
return cli.Err("invalid --since value %q: %v", metricsSince, err)
|
||||
}
|
||||
|
||||
sinceTime := time.Now().Add(-since)
|
||||
events, err := ai.ReadEvents(sinceTime)
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "read", "metrics")
|
||||
}
|
||||
|
||||
if metricsJSON {
|
||||
summary := ai.Summary(events)
|
||||
output, err := json.MarshalIndent(summary, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := ai.Summary(events)
|
||||
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", dimStyle.Render("Period:"), metricsSince)
|
||||
total, _ := summary["total"].(int)
|
||||
cli.Print("%s %d\n", dimStyle.Render("Total events:"), total)
|
||||
cli.Blank()
|
||||
|
||||
// By type
|
||||
if byType, ok := summary["by_type"].([]map[string]any); ok && len(byType) > 0 {
|
||||
cli.Print("%s\n", dimStyle.Render("By type:"))
|
||||
for _, entry := range byType {
|
||||
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
|
||||
}
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
// By repo
|
||||
if byRepo, ok := summary["by_repo"].([]map[string]any); ok && len(byRepo) > 0 {
|
||||
cli.Print("%s\n", dimStyle.Render("By repo:"))
|
||||
for _, entry := range byRepo {
|
||||
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
|
||||
}
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
// By agent
|
||||
if byAgent, ok := summary["by_agent"].([]map[string]any); ok && len(byAgent) > 0 {
|
||||
cli.Print("%s\n", dimStyle.Render("By contributor:"))
|
||||
for _, entry := range byAgent {
|
||||
cli.Print(" %-30s %v\n", entry["key"], entry["count"])
|
||||
}
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
cli.Text(i18n.T("cmd.ai.metrics.none_found"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDuration parses a human-friendly duration like "7d", "24h", "30d".
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
if len(s) < 2 {
|
||||
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||
}
|
||||
|
||||
unit := s[len(s)-1]
|
||||
value := s[:len(s)-1]
|
||||
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(value, "%d", &n); err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||
}
|
||||
|
||||
if n <= 0 {
|
||||
return 0, fmt.Errorf("duration must be positive: %s", s)
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case 'd':
|
||||
return time.Duration(n) * 24 * time.Hour, nil
|
||||
case 'h':
|
||||
return time.Duration(n) * time.Hour, nil
|
||||
case 'm':
|
||||
return time.Duration(n) * time.Minute, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown unit %c in duration: %s", unit, s)
|
||||
}
|
||||
}
|
||||
213
cmd/ai/cmd_ratelimits.go
Normal file
213
cmd/ai/cmd_ratelimits.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/config"
|
||||
"forge.lthn.ai/core/go/pkg/ratelimit"
|
||||
)
|
||||
|
||||
// AddRateLimitCommands registers the 'ratelimits' subcommand group under 'ai'.
|
||||
func AddRateLimitCommands(parent *cli.Command) {
|
||||
rlCmd := &cli.Command{
|
||||
Use: "ratelimits",
|
||||
Short: "Manage Gemini API rate limits",
|
||||
}
|
||||
|
||||
rlCmd.AddCommand(rlShowCmd())
|
||||
rlCmd.AddCommand(rlResetCmd())
|
||||
rlCmd.AddCommand(rlCountCmd())
|
||||
rlCmd.AddCommand(rlConfigCmd())
|
||||
rlCmd.AddCommand(rlCheckCmd())
|
||||
|
||||
parent.AddCommand(rlCmd)
|
||||
}
|
||||
|
||||
func rlShowCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Use: "show",
|
||||
Short: "Show current rate limit usage",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
rl, err := ratelimit.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rl.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stats := rl.AllStats()
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
fmt.Fprintln(w, "MODEL\tRPM\tTPM\tRPD\tSTATUS")
|
||||
|
||||
for model, s := range stats {
|
||||
rpmStr := fmt.Sprintf("%d/%s", s.RPM, formatLimit(s.MaxRPM))
|
||||
tpmStr := fmt.Sprintf("%d/%s", s.TPM, formatLimit(s.MaxTPM))
|
||||
rpdStr := fmt.Sprintf("%d/%s", s.RPD, formatLimit(s.MaxRPD))
|
||||
|
||||
status := "OK"
|
||||
if (s.MaxRPM > 0 && s.RPM >= s.MaxRPM) ||
|
||||
(s.MaxTPM > 0 && s.TPM >= s.MaxTPM) ||
|
||||
(s.MaxRPD > 0 && s.RPD >= s.MaxRPD) {
|
||||
status = "LIMITED"
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", model, rpmStr, tpmStr, rpdStr, status)
|
||||
}
|
||||
w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rlResetCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Use: "reset [model]",
|
||||
Short: "Reset usage counters for a model (or all)",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
rl, err := ratelimit.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rl.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
model := ""
|
||||
if len(args) > 0 {
|
||||
model = args[0]
|
||||
}
|
||||
|
||||
rl.Reset(model)
|
||||
if err := rl.Persist(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if model == "" {
|
||||
fmt.Println("Reset stats for all models.")
|
||||
} else {
|
||||
fmt.Printf("Reset stats for model %q.\n", model)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rlCountCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Use: "count <model> <text>",
|
||||
Short: "Count tokens for text using Gemini API",
|
||||
Args: cli.ExactArgs(2),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
model := args[0]
|
||||
text := args[1]
|
||||
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var apiKey string
|
||||
if err := cfg.Get("agentci.gemini_api_key", &apiKey); err != nil || apiKey == "" {
|
||||
apiKey = os.Getenv("GEMINI_API_KEY")
|
||||
}
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("GEMINI_API_KEY not found in config or env")
|
||||
}
|
||||
|
||||
count, err := ratelimit.CountTokens(apiKey, model, text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Model: %s\nTokens: %d\n", model, count)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rlConfigCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Use: "config",
|
||||
Short: "Show configured quotas",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
rl, err := ratelimit.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
fmt.Fprintln(w, "MODEL\tMAX RPM\tMAX TPM\tMAX RPD")
|
||||
|
||||
for model, q := range rl.Quotas {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
||||
model,
|
||||
formatLimit(q.MaxRPM),
|
||||
formatLimit(q.MaxTPM),
|
||||
formatLimit(q.MaxRPD))
|
||||
}
|
||||
w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rlCheckCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Use: "check <model> <estimated-tokens>",
|
||||
Short: "Check rate limit capacity for a model",
|
||||
Args: cli.ExactArgs(2),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
model := args[0]
|
||||
tokens, err := strconv.Atoi(args[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid token count: %w", err)
|
||||
}
|
||||
|
||||
rl, err := ratelimit.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rl.Load(); err != nil {
|
||||
fmt.Printf("Warning: could not load existing state: %v\n", err)
|
||||
}
|
||||
|
||||
stats := rl.Stats(model)
|
||||
canSend := rl.CanSend(model, tokens)
|
||||
|
||||
status := "RATE LIMITED"
|
||||
if canSend {
|
||||
status = "OK"
|
||||
}
|
||||
|
||||
fmt.Printf("Model: %s\n", model)
|
||||
fmt.Printf("Request Cost: %d tokens\n", tokens)
|
||||
fmt.Printf("Status: %s\n", status)
|
||||
fmt.Printf("\nCurrent Usage (1m window):\n")
|
||||
fmt.Printf(" RPM: %d / %s\n", stats.RPM, formatLimit(stats.MaxRPM))
|
||||
fmt.Printf(" TPM: %d / %s\n", stats.TPM, formatLimit(stats.MaxTPM))
|
||||
fmt.Printf(" RPD: %d / %s (reset: %s)\n", stats.RPD, formatLimit(stats.MaxRPD), stats.DayStart.Format(time.RFC3339))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func formatLimit(limit int) string {
|
||||
if limit == 0 {
|
||||
return "∞"
|
||||
}
|
||||
if limit >= 1000000 {
|
||||
return fmt.Sprintf("%dM", limit/1000000)
|
||||
}
|
||||
if limit >= 1000 {
|
||||
return fmt.Sprintf("%dK", limit/1000)
|
||||
}
|
||||
return fmt.Sprintf("%d", limit)
|
||||
}
|
||||
|
|
@ -9,9 +9,10 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/agentic"
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/agentic"
|
||||
"forge.lthn.ai/core/go/pkg/ai"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// tasks command flags
|
||||
|
|
@ -165,6 +166,13 @@ var taskCmd = &cli.Command{
|
|||
return cli.WrapVerb(err, "claim", "task")
|
||||
}
|
||||
|
||||
// Record task claim event
|
||||
_ = ai.Record(ai.Event{
|
||||
Type: "task.claimed",
|
||||
AgentID: cfg.AgentID,
|
||||
Data: map[string]any{"task_id": task.ID, "title": task.Title},
|
||||
})
|
||||
|
||||
cli.Print("%s %s\n", successStyle.Render(">>"), i18n.T("i18n.done.claim", "task"))
|
||||
cli.Print(" %s %s\n", i18n.Label("status"), formatTaskStatus(claimedTask.Status))
|
||||
}
|
||||
|
|
@ -286,4 +294,4 @@ func formatTaskStatus(s agentic.TaskStatus) string {
|
|||
default:
|
||||
return dimStyle.Render(string(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/agentic"
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/agentic"
|
||||
"forge.lthn.ai/core/go/pkg/ai"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// task:update command flags
|
||||
|
|
@ -92,6 +93,13 @@ var taskCompleteCmd = &cli.Command{
|
|||
return cli.WrapVerb(err, "complete", "task")
|
||||
}
|
||||
|
||||
// Record task completion event
|
||||
_ = ai.Record(ai.Event{
|
||||
Type: "task.completed",
|
||||
AgentID: cfg.AgentID,
|
||||
Data: map[string]any{"task_id": taskID, "success": !taskCompleteFailed},
|
||||
})
|
||||
|
||||
if taskCompleteFailed {
|
||||
cli.Print("%s %s\n", errorStyle.Render(">>"), i18n.T("cmd.ai.task_complete.failed", map[string]interface{}{"ID": taskID}))
|
||||
} else {
|
||||
49
cmd/ai/ratelimit_dispatch.go
Normal file
49
cmd/ai/ratelimit_dispatch.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
"forge.lthn.ai/core/go/pkg/ratelimit"
|
||||
)
|
||||
|
||||
// executeWithRateLimit wraps an agent execution with rate limiting logic.
|
||||
// It estimates token usage, waits for capacity, executes the runner, and records usage.
|
||||
func executeWithRateLimit(ctx context.Context, model, prompt string, runner func() (bool, int, error)) (bool, int, error) {
|
||||
rl, err := ratelimit.New()
|
||||
if err != nil {
|
||||
log.Warn("Failed to initialize rate limiter, proceeding without limits", "error", err)
|
||||
return runner()
|
||||
}
|
||||
|
||||
if err := rl.Load(); err != nil {
|
||||
log.Warn("Failed to load rate limit state", "error", err)
|
||||
}
|
||||
|
||||
// Estimate tokens from prompt length (1 token ≈ 4 chars)
|
||||
estTokens := len(prompt) / 4
|
||||
if estTokens == 0 {
|
||||
estTokens = 1
|
||||
}
|
||||
|
||||
log.Info("Checking rate limits", "model", model, "est_tokens", estTokens)
|
||||
|
||||
if err := rl.WaitForCapacity(ctx, model, estTokens); err != nil {
|
||||
return false, -1, err
|
||||
}
|
||||
|
||||
success, exitCode, runErr := runner()
|
||||
|
||||
// Record usage with conservative output estimate (actual tokens unknown from shell runner).
|
||||
outputEst := estTokens / 10
|
||||
if outputEst < 50 {
|
||||
outputEst = 50
|
||||
}
|
||||
rl.RecordUsage(model, estTokens, outputEst)
|
||||
|
||||
if err := rl.Persist(); err != nil {
|
||||
log.Warn("Failed to persist rate limit state", "error", err)
|
||||
}
|
||||
|
||||
return success, exitCode, runErr
|
||||
}
|
||||
112
cmd/collect/cmd.go
Normal file
112
cmd/collect/cmd.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddCollectCommands)
|
||||
}
|
||||
|
||||
// Style aliases from shared package
|
||||
var (
|
||||
dimStyle = cli.DimStyle
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
)
|
||||
|
||||
// Shared flags across all collect subcommands
|
||||
var (
|
||||
collectOutputDir string
|
||||
collectVerbose bool
|
||||
collectDryRun bool
|
||||
)
|
||||
|
||||
// AddCollectCommands registers the 'collect' command and all subcommands.
|
||||
func AddCollectCommands(root *cli.Command) {
|
||||
collectCmd := &cli.Command{
|
||||
Use: "collect",
|
||||
Short: i18n.T("cmd.collect.short"),
|
||||
Long: i18n.T("cmd.collect.long"),
|
||||
}
|
||||
|
||||
// Persistent flags shared across subcommands
|
||||
cli.PersistentStringFlag(collectCmd, &collectOutputDir, "output", "o", "./collect", i18n.T("cmd.collect.flag.output"))
|
||||
cli.PersistentBoolFlag(collectCmd, &collectVerbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
|
||||
cli.PersistentBoolFlag(collectCmd, &collectDryRun, "dry-run", "", false, i18n.T("cmd.collect.flag.dry_run"))
|
||||
|
||||
root.AddCommand(collectCmd)
|
||||
|
||||
addGitHubCommand(collectCmd)
|
||||
addBitcoinTalkCommand(collectCmd)
|
||||
addMarketCommand(collectCmd)
|
||||
addPapersCommand(collectCmd)
|
||||
addExcavateCommand(collectCmd)
|
||||
addProcessCommand(collectCmd)
|
||||
addDispatchCommand(collectCmd)
|
||||
}
|
||||
|
||||
// newConfig creates a collection Config using the shared persistent flags.
|
||||
// It uses io.Local for real filesystem access rather than the mock medium.
|
||||
func newConfig() *collect.Config {
|
||||
cfg := collect.NewConfigWithMedium(io.Local, collectOutputDir)
|
||||
cfg.Verbose = collectVerbose
|
||||
cfg.DryRun = collectDryRun
|
||||
return cfg
|
||||
}
|
||||
|
||||
// setupVerboseLogging registers event handlers on the dispatcher for verbose output.
|
||||
func setupVerboseLogging(cfg *collect.Config) {
|
||||
if !cfg.Verbose {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Dispatcher.On(collect.EventStart, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", dimStyle.Render("[start]"), e.Message)
|
||||
})
|
||||
cfg.Dispatcher.On(collect.EventProgress, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", dimStyle.Render("[progress]"), e.Message)
|
||||
})
|
||||
cfg.Dispatcher.On(collect.EventItem, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", dimStyle.Render("[item]"), e.Message)
|
||||
})
|
||||
cfg.Dispatcher.On(collect.EventError, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", errorStyle.Render("[error]"), e.Message)
|
||||
})
|
||||
cfg.Dispatcher.On(collect.EventComplete, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", successStyle.Render("[complete]"), e.Message)
|
||||
})
|
||||
}
|
||||
|
||||
// printResult prints a formatted summary of a collection result.
|
||||
func printResult(result *collect.Result) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if result.Items > 0 {
|
||||
cli.Success(fmt.Sprintf("Collected %d items from %s", result.Items, result.Source))
|
||||
} else {
|
||||
cli.Dim(fmt.Sprintf("No items collected from %s", result.Source))
|
||||
}
|
||||
|
||||
if result.Skipped > 0 {
|
||||
cli.Dim(fmt.Sprintf(" Skipped: %d", result.Skipped))
|
||||
}
|
||||
|
||||
if result.Errors > 0 {
|
||||
cli.Warn(fmt.Sprintf(" Errors: %d", result.Errors))
|
||||
}
|
||||
|
||||
if collectVerbose && len(result.Files) > 0 {
|
||||
cli.Dim(fmt.Sprintf(" Files: %d", len(result.Files)))
|
||||
for _, f := range result.Files {
|
||||
cli.Print(" %s\n", dimStyle.Render(f))
|
||||
}
|
||||
}
|
||||
}
|
||||
64
cmd/collect/cmd_bitcointalk.go
Normal file
64
cmd/collect/cmd_bitcointalk.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// BitcoinTalk command flags
|
||||
var bitcointalkPages int
|
||||
|
||||
// addBitcoinTalkCommand adds the 'bitcointalk' subcommand to the collect parent.
|
||||
func addBitcoinTalkCommand(parent *cli.Command) {
|
||||
btcCmd := &cli.Command{
|
||||
Use: "bitcointalk <topic-id|url>",
|
||||
Short: i18n.T("cmd.collect.bitcointalk.short"),
|
||||
Long: i18n.T("cmd.collect.bitcointalk.long"),
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runBitcoinTalk(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cli.IntFlag(btcCmd, &bitcointalkPages, "pages", "p", 0, i18n.T("cmd.collect.bitcointalk.flag.pages"))
|
||||
|
||||
parent.AddCommand(btcCmd)
|
||||
}
|
||||
|
||||
func runBitcoinTalk(target string) error {
|
||||
var topicID, url string
|
||||
|
||||
// Determine if argument is a URL or topic ID
|
||||
if strings.HasPrefix(target, "http") {
|
||||
url = target
|
||||
} else {
|
||||
topicID = target
|
||||
}
|
||||
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
collector := &collect.BitcoinTalkCollector{
|
||||
TopicID: topicID,
|
||||
URL: url,
|
||||
Pages: bitcointalkPages,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would collect from BitcoinTalk topic " + target)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := collector.Collect(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "bitcointalk collection failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
130
cmd/collect/cmd_dispatch.go
Normal file
130
cmd/collect/cmd_dispatch.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
collectpkg "forge.lthn.ai/core/go/pkg/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// addDispatchCommand adds the 'dispatch' subcommand to the collect parent.
|
||||
func addDispatchCommand(parent *cli.Command) {
|
||||
dispatchCmd := &cli.Command{
|
||||
Use: "dispatch <event>",
|
||||
Short: i18n.T("cmd.collect.dispatch.short"),
|
||||
Long: i18n.T("cmd.collect.dispatch.long"),
|
||||
Args: cli.MinimumNArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runDispatch(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
// Add hooks subcommand group
|
||||
hooksCmd := &cli.Command{
|
||||
Use: "hooks",
|
||||
Short: i18n.T("cmd.collect.dispatch.hooks.short"),
|
||||
}
|
||||
|
||||
addHooksListCommand(hooksCmd)
|
||||
addHooksRegisterCommand(hooksCmd)
|
||||
|
||||
dispatchCmd.AddCommand(hooksCmd)
|
||||
parent.AddCommand(dispatchCmd)
|
||||
}
|
||||
|
||||
func runDispatch(eventType string) error {
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
// Validate event type
|
||||
switch eventType {
|
||||
case collectpkg.EventStart,
|
||||
collectpkg.EventProgress,
|
||||
collectpkg.EventItem,
|
||||
collectpkg.EventError,
|
||||
collectpkg.EventComplete:
|
||||
// Valid event type
|
||||
default:
|
||||
return cli.Err("unknown event type: %s (valid: start, progress, item, error, complete)", eventType)
|
||||
}
|
||||
|
||||
event := collectpkg.Event{
|
||||
Type: eventType,
|
||||
Source: "cli",
|
||||
Message: fmt.Sprintf("Manual dispatch of %s event", eventType),
|
||||
Time: time.Now(),
|
||||
}
|
||||
|
||||
cfg.Dispatcher.Emit(event)
|
||||
cli.Success(fmt.Sprintf("Dispatched %s event", eventType))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addHooksListCommand adds the 'hooks list' subcommand.
|
||||
func addHooksListCommand(parent *cli.Command) {
|
||||
listCmd := &cli.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("cmd.collect.dispatch.hooks.list.short"),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runHooksList()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func runHooksList() error {
|
||||
eventTypes := []string{
|
||||
collectpkg.EventStart,
|
||||
collectpkg.EventProgress,
|
||||
collectpkg.EventItem,
|
||||
collectpkg.EventError,
|
||||
collectpkg.EventComplete,
|
||||
}
|
||||
|
||||
table := cli.NewTable("Event", "Status")
|
||||
for _, et := range eventTypes {
|
||||
table.AddRow(et, dimStyle.Render("no hooks registered"))
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print("%s\n\n", cli.HeaderStyle.Render("Registered Hooks"))
|
||||
table.Render()
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addHooksRegisterCommand adds the 'hooks register' subcommand.
|
||||
func addHooksRegisterCommand(parent *cli.Command) {
|
||||
registerCmd := &cli.Command{
|
||||
Use: "register <event> <command>",
|
||||
Short: i18n.T("cmd.collect.dispatch.hooks.register.short"),
|
||||
Args: cli.ExactArgs(2),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runHooksRegister(args[0], args[1])
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(registerCmd)
|
||||
}
|
||||
|
||||
func runHooksRegister(eventType, command string) error {
|
||||
// Validate event type
|
||||
switch eventType {
|
||||
case collectpkg.EventStart,
|
||||
collectpkg.EventProgress,
|
||||
collectpkg.EventItem,
|
||||
collectpkg.EventError,
|
||||
collectpkg.EventComplete:
|
||||
// Valid
|
||||
default:
|
||||
return cli.Err("unknown event type: %s (valid: start, progress, item, error, complete)", eventType)
|
||||
}
|
||||
|
||||
cli.Success(fmt.Sprintf("Registered hook for %s: %s", eventType, command))
|
||||
return nil
|
||||
}
|
||||
103
cmd/collect/cmd_excavate.go
Normal file
103
cmd/collect/cmd_excavate.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Excavate command flags
|
||||
var (
|
||||
excavateScanOnly bool
|
||||
excavateResume bool
|
||||
)
|
||||
|
||||
// addExcavateCommand adds the 'excavate' subcommand to the collect parent.
|
||||
func addExcavateCommand(parent *cli.Command) {
|
||||
excavateCmd := &cli.Command{
|
||||
Use: "excavate <project>",
|
||||
Short: i18n.T("cmd.collect.excavate.short"),
|
||||
Long: i18n.T("cmd.collect.excavate.long"),
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runExcavate(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cli.BoolFlag(excavateCmd, &excavateScanOnly, "scan-only", "", false, i18n.T("cmd.collect.excavate.flag.scan_only"))
|
||||
cli.BoolFlag(excavateCmd, &excavateResume, "resume", "r", false, i18n.T("cmd.collect.excavate.flag.resume"))
|
||||
|
||||
parent.AddCommand(excavateCmd)
|
||||
}
|
||||
|
||||
func runExcavate(project string) error {
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
// Load state for resume
|
||||
if excavateResume {
|
||||
if err := cfg.State.Load(); err != nil {
|
||||
return cli.Wrap(err, "failed to load collection state")
|
||||
}
|
||||
}
|
||||
|
||||
// Build collectors for the project
|
||||
collectors := buildProjectCollectors(project)
|
||||
if len(collectors) == 0 {
|
||||
return cli.Err("no collectors configured for project: %s", project)
|
||||
}
|
||||
|
||||
excavator := &collect.Excavator{
|
||||
Collectors: collectors,
|
||||
ScanOnly: excavateScanOnly,
|
||||
Resume: excavateResume,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info(fmt.Sprintf("Dry run: would excavate project %s with %d collectors", project, len(collectors)))
|
||||
for _, c := range collectors {
|
||||
cli.Dim(fmt.Sprintf(" - %s", c.Name()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := excavator.Run(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "excavation failed")
|
||||
}
|
||||
|
||||
// Save state for future resume
|
||||
if err := cfg.State.Save(); err != nil {
|
||||
cli.Warnf("Failed to save state: %v", err)
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildProjectCollectors creates collectors based on the project name.
|
||||
// This maps known project names to their collector configurations.
|
||||
func buildProjectCollectors(project string) []collect.Collector {
|
||||
switch project {
|
||||
case "bitcoin":
|
||||
return []collect.Collector{
|
||||
&collect.GitHubCollector{Org: "bitcoin", Repo: "bitcoin"},
|
||||
&collect.MarketCollector{CoinID: "bitcoin", Historical: true},
|
||||
}
|
||||
case "ethereum":
|
||||
return []collect.Collector{
|
||||
&collect.GitHubCollector{Org: "ethereum", Repo: "go-ethereum"},
|
||||
&collect.MarketCollector{CoinID: "ethereum", Historical: true},
|
||||
&collect.PapersCollector{Source: "all", Query: "ethereum"},
|
||||
}
|
||||
default:
|
||||
// Treat unknown projects as GitHub org/repo
|
||||
return []collect.Collector{
|
||||
&collect.GitHubCollector{Org: project},
|
||||
}
|
||||
}
|
||||
}
|
||||
78
cmd/collect/cmd_github.go
Normal file
78
cmd/collect/cmd_github.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// GitHub command flags
|
||||
var (
|
||||
githubOrg bool
|
||||
githubIssuesOnly bool
|
||||
githubPRsOnly bool
|
||||
)
|
||||
|
||||
// addGitHubCommand adds the 'github' subcommand to the collect parent.
|
||||
func addGitHubCommand(parent *cli.Command) {
|
||||
githubCmd := &cli.Command{
|
||||
Use: "github <org/repo>",
|
||||
Short: i18n.T("cmd.collect.github.short"),
|
||||
Long: i18n.T("cmd.collect.github.long"),
|
||||
Args: cli.MinimumNArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runGitHub(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cli.BoolFlag(githubCmd, &githubOrg, "org", "", false, i18n.T("cmd.collect.github.flag.org"))
|
||||
cli.BoolFlag(githubCmd, &githubIssuesOnly, "issues-only", "", false, i18n.T("cmd.collect.github.flag.issues_only"))
|
||||
cli.BoolFlag(githubCmd, &githubPRsOnly, "prs-only", "", false, i18n.T("cmd.collect.github.flag.prs_only"))
|
||||
|
||||
parent.AddCommand(githubCmd)
|
||||
}
|
||||
|
||||
func runGitHub(target string) error {
|
||||
if githubIssuesOnly && githubPRsOnly {
|
||||
return cli.Err("--issues-only and --prs-only are mutually exclusive")
|
||||
}
|
||||
|
||||
// Parse org/repo argument
|
||||
var org, repo string
|
||||
if strings.Contains(target, "/") {
|
||||
parts := strings.SplitN(target, "/", 2)
|
||||
org = parts[0]
|
||||
repo = parts[1]
|
||||
} else if githubOrg {
|
||||
org = target
|
||||
} else {
|
||||
return cli.Err("argument must be in org/repo format, or use --org for organisation-wide collection")
|
||||
}
|
||||
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
collector := &collect.GitHubCollector{
|
||||
Org: org,
|
||||
Repo: repo,
|
||||
IssuesOnly: githubIssuesOnly,
|
||||
PRsOnly: githubPRsOnly,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would collect from GitHub " + target)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := collector.Collect(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "github collection failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
58
cmd/collect/cmd_market.go
Normal file
58
cmd/collect/cmd_market.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Market command flags
|
||||
var (
|
||||
marketHistorical bool
|
||||
marketFromDate string
|
||||
)
|
||||
|
||||
// addMarketCommand adds the 'market' subcommand to the collect parent.
|
||||
func addMarketCommand(parent *cli.Command) {
|
||||
marketCmd := &cli.Command{
|
||||
Use: "market <coin>",
|
||||
Short: i18n.T("cmd.collect.market.short"),
|
||||
Long: i18n.T("cmd.collect.market.long"),
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runMarket(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cli.BoolFlag(marketCmd, &marketHistorical, "historical", "H", false, i18n.T("cmd.collect.market.flag.historical"))
|
||||
cli.StringFlag(marketCmd, &marketFromDate, "from", "f", "", i18n.T("cmd.collect.market.flag.from"))
|
||||
|
||||
parent.AddCommand(marketCmd)
|
||||
}
|
||||
|
||||
func runMarket(coinID string) error {
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
collector := &collect.MarketCollector{
|
||||
CoinID: coinID,
|
||||
Historical: marketHistorical,
|
||||
FromDate: marketFromDate,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would collect market data for " + coinID)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := collector.Collect(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "market collection failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
63
cmd/collect/cmd_papers.go
Normal file
63
cmd/collect/cmd_papers.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Papers command flags
|
||||
var (
|
||||
papersSource string
|
||||
papersCategory string
|
||||
papersQuery string
|
||||
)
|
||||
|
||||
// addPapersCommand adds the 'papers' subcommand to the collect parent.
|
||||
func addPapersCommand(parent *cli.Command) {
|
||||
papersCmd := &cli.Command{
|
||||
Use: "papers",
|
||||
Short: i18n.T("cmd.collect.papers.short"),
|
||||
Long: i18n.T("cmd.collect.papers.long"),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runPapers()
|
||||
},
|
||||
}
|
||||
|
||||
cli.StringFlag(papersCmd, &papersSource, "source", "s", "all", i18n.T("cmd.collect.papers.flag.source"))
|
||||
cli.StringFlag(papersCmd, &papersCategory, "category", "c", "", i18n.T("cmd.collect.papers.flag.category"))
|
||||
cli.StringFlag(papersCmd, &papersQuery, "query", "q", "", i18n.T("cmd.collect.papers.flag.query"))
|
||||
|
||||
parent.AddCommand(papersCmd)
|
||||
}
|
||||
|
||||
func runPapers() error {
|
||||
if papersQuery == "" {
|
||||
return cli.Err("--query (-q) is required")
|
||||
}
|
||||
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
collector := &collect.PapersCollector{
|
||||
Source: papersSource,
|
||||
Category: papersCategory,
|
||||
Query: papersQuery,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would collect papers from " + papersSource)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := collector.Collect(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "papers collection failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
48
cmd/collect/cmd_process.go
Normal file
48
cmd/collect/cmd_process.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// addProcessCommand adds the 'process' subcommand to the collect parent.
|
||||
func addProcessCommand(parent *cli.Command) {
|
||||
processCmd := &cli.Command{
|
||||
Use: "process <source> <dir>",
|
||||
Short: i18n.T("cmd.collect.process.short"),
|
||||
Long: i18n.T("cmd.collect.process.long"),
|
||||
Args: cli.ExactArgs(2),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runProcess(args[0], args[1])
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(processCmd)
|
||||
}
|
||||
|
||||
func runProcess(source, dir string) error {
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
processor := &collect.Processor{
|
||||
Source: source,
|
||||
Dir: dir,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would process " + source + " data in " + dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := processor.Process(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "processing failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
602
cmd/community/index.html
Normal file
602
cmd/community/index.html
Normal 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 95–100% · 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 95–100%. 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>
|
||||
18
cmd/config/cmd.go
Normal file
18
cmd/config/cmd.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package config
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddConfigCommands)
|
||||
}
|
||||
|
||||
// AddConfigCommands registers the 'config' command group and all subcommands.
|
||||
func AddConfigCommands(root *cli.Command) {
|
||||
configCmd := cli.NewGroup("config", "Manage configuration", "")
|
||||
root.AddCommand(configCmd)
|
||||
|
||||
addGetCommand(configCmd)
|
||||
addSetCommand(configCmd)
|
||||
addListCommand(configCmd)
|
||||
addPathCommand(configCmd)
|
||||
}
|
||||
40
cmd/config/cmd_get.go
Normal file
40
cmd/config/cmd_get.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/config"
|
||||
)
|
||||
|
||||
func addGetCommand(parent *cli.Command) {
|
||||
cmd := cli.NewCommand("get", "Get a configuration value", "", func(cmd *cli.Command, args []string) error {
|
||||
key := args[0]
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var value any
|
||||
if err := cfg.Get(key, &value); err != nil {
|
||||
return cli.Err("key not found: %s", key)
|
||||
}
|
||||
|
||||
fmt.Println(value)
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.WithArgs(cmd, cli.ExactArgs(1))
|
||||
cli.WithExample(cmd, "core config get dev.editor")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func loadConfig() (*config.Config, error) {
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "failed to load config")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
35
cmd/config/cmd_list.go
Normal file
35
cmd/config/cmd_list.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func addListCommand(parent *cli.Command) {
|
||||
cmd := cli.NewCommand("list", "List all configuration values", "", func(cmd *cli.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
all := cfg.All()
|
||||
if len(all) == 0 {
|
||||
cli.Dim("No configuration values set")
|
||||
return nil
|
||||
}
|
||||
|
||||
out, err := yaml.Marshal(all)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to format config")
|
||||
}
|
||||
|
||||
fmt.Print(string(out))
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.WithArgs(cmd, cli.NoArgs())
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
23
cmd/config/cmd_path.go
Normal file
23
cmd/config/cmd_path.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
func addPathCommand(parent *cli.Command) {
|
||||
cmd := cli.NewCommand("path", "Show the configuration file path", "", func(cmd *cli.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(cfg.Path())
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.WithArgs(cmd, cli.NoArgs())
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
29
cmd/config/cmd_set.go
Normal file
29
cmd/config/cmd_set.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
func addSetCommand(parent *cli.Command) {
|
||||
cmd := cli.NewCommand("set", "Set a configuration value", "", func(cmd *cli.Command, args []string) error {
|
||||
key := args[0]
|
||||
value := args[1]
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cfg.Set(key, value); err != nil {
|
||||
return cli.Wrap(err, "failed to set config value")
|
||||
}
|
||||
|
||||
cli.Success(key + " = " + value)
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.WithArgs(cmd, cli.ExactArgs(2))
|
||||
cli.WithExample(cmd, "core config set dev.editor vim")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
100
cmd/core-app/CODEX_PROMPT.md
Normal file
100
cmd/core-app/CODEX_PROMPT.md
Normal 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
37
cmd/core-app/Taskfile.yml
Normal 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
|
||||
48
cmd/core-app/app_service.go
Normal file
48
cmd/core-app/app_service.go
Normal 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
52
cmd/core-app/embed.go
Normal 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
167
cmd/core-app/env.go
Normal 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
67
cmd/core-app/go.mod
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
module forge.lthn.ai/core/go/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/go => ../..
|
||||
185
cmd/core-app/go.sum
Normal file
185
cmd/core-app/go.sum
Normal 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
137
cmd/core-app/handler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
BIN
cmd/core-app/icons/appicon.png
Normal file
BIN
cmd/core-app/icons/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 B |
24
cmd/core-app/icons/icons.go
Normal file
24
cmd/core-app/icons/icons.go
Normal 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
|
||||
BIN
cmd/core-app/icons/tray-dark.png
Normal file
BIN
cmd/core-app/icons/tray-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 B |
BIN
cmd/core-app/icons/tray-light.png
Normal file
BIN
cmd/core-app/icons/tray-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 B |
BIN
cmd/core-app/icons/tray-template.png
Normal file
BIN
cmd/core-app/icons/tray-template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 B |
13
cmd/core-app/laravel/.env.example
Normal file
13
cmd/core-app/laravel/.env.example
Normal 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
5
cmd/core-app/laravel/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/vendor/
|
||||
/node_modules/
|
||||
/.env
|
||||
/bootstrap/cache/*.php
|
||||
/storage/*.key
|
||||
46
cmd/core-app/laravel/app/Http/Middleware/QuotaMiddleware.php
Normal file
46
cmd/core-app/laravel/app/Http/Middleware/QuotaMiddleware.php
Normal 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);
|
||||
}
|
||||
}
|
||||
27
cmd/core-app/laravel/app/Livewire/Counter.php
Normal file
27
cmd/core-app/laravel/app/Livewire/Counter.php
Normal 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');
|
||||
}
|
||||
}
|
||||
111
cmd/core-app/laravel/app/Livewire/Dashboard/ActivityFeed.php
Normal file
111
cmd/core-app/laravel/app/Livewire/Dashboard/ActivityFeed.php
Normal 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');
|
||||
}
|
||||
}
|
||||
85
cmd/core-app/laravel/app/Livewire/Dashboard/AgentFleet.php
Normal file
85
cmd/core-app/laravel/app/Livewire/Dashboard/AgentFleet.php
Normal 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');
|
||||
}
|
||||
}
|
||||
93
cmd/core-app/laravel/app/Livewire/Dashboard/HumanActions.php
Normal file
93
cmd/core-app/laravel/app/Livewire/Dashboard/HumanActions.php
Normal 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');
|
||||
}
|
||||
}
|
||||
125
cmd/core-app/laravel/app/Livewire/Dashboard/JobQueue.php
Normal file
125
cmd/core-app/laravel/app/Livewire/Dashboard/JobQueue.php
Normal 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');
|
||||
}
|
||||
}
|
||||
60
cmd/core-app/laravel/app/Livewire/Dashboard/Metrics.php
Normal file
60
cmd/core-app/laravel/app/Livewire/Dashboard/Metrics.php
Normal 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');
|
||||
}
|
||||
}
|
||||
43
cmd/core-app/laravel/app/Models/AgentAllowance.php
Normal file
43
cmd/core-app/laravel/app/Models/AgentAllowance.php
Normal 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();
|
||||
}
|
||||
}
|
||||
26
cmd/core-app/laravel/app/Models/ModelQuota.php
Normal file
26
cmd/core-app/laravel/app/Models/ModelQuota.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
cmd/core-app/laravel/app/Models/QuotaUsage.php
Normal file
36
cmd/core-app/laravel/app/Models/QuotaUsage.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QuotaUsage extends Model
|
||||
{
|
||||
protected $table = 'quota_usage';
|
||||
|
||||
protected $fillable = [
|
||||
'agent_id',
|
||||
'tokens_used',
|
||||
'jobs_started',
|
||||
'active_jobs',
|
||||
'period_date',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tokens_used' => 'integer',
|
||||
'jobs_started' => 'integer',
|
||||
'active_jobs' => 'integer',
|
||||
'period_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function allowance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AgentAllowance::class, 'agent_id', 'agent_id');
|
||||
}
|
||||
}
|
||||
29
cmd/core-app/laravel/app/Models/UsageReport.php
Normal file
29
cmd/core-app/laravel/app/Models/UsageReport.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UsageReport extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'agent_id',
|
||||
'job_id',
|
||||
'model',
|
||||
'tokens_in',
|
||||
'tokens_out',
|
||||
'event',
|
||||
'reported_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tokens_in' => 'integer',
|
||||
'tokens_out' => 'integer',
|
||||
'reported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
45
cmd/core-app/laravel/app/Providers/AppServiceProvider.php
Normal file
45
cmd/core-app/laravel/app/Providers/AppServiceProvider.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Forgejo\ForgejoService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Throwable;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(ForgejoService::class, function ($app): ForgejoService {
|
||||
/** @var array<string, mixed> $config */
|
||||
$config = $app['config']->get('forgejo', []);
|
||||
|
||||
return new ForgejoService(
|
||||
instances: $config['instances'] ?? [],
|
||||
defaultInstance: $config['default'] ?? 'forge',
|
||||
timeout: $config['timeout'] ?? 30,
|
||||
retryTimes: $config['retry_times'] ?? 3,
|
||||
retrySleep: $config['retry_sleep'] ?? 500,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Auto-migrate on first boot. Single-user desktop app with
|
||||
// SQLite — safe to run on every startup. The --force flag
|
||||
// is required in production, --no-interaction prevents prompts.
|
||||
try {
|
||||
Artisan::call('migrate', [
|
||||
'--force' => true,
|
||||
'--no-interaction' => true,
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
// Silently skip — DB might not exist yet (e.g. during
|
||||
// composer operations or first extraction).
|
||||
}
|
||||
}
|
||||
}
|
||||
183
cmd/core-app/laravel/app/Services/AllowanceService.php
Normal file
183
cmd/core-app/laravel/app/Services/AllowanceService.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AgentAllowance;
|
||||
use App\Models\ModelQuota;
|
||||
use App\Models\QuotaUsage;
|
||||
use App\Models\UsageReport;
|
||||
|
||||
class AllowanceService
|
||||
{
|
||||
/**
|
||||
* Pre-dispatch check: verify agent has remaining allowance.
|
||||
*
|
||||
* @return array{allowed: bool, status: string, remaining_tokens: int, remaining_jobs: int, reason: ?string}
|
||||
*/
|
||||
public function check(string $agentId, string $model = ''): array
|
||||
{
|
||||
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
|
||||
|
||||
if (! $allowance) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'remaining_tokens' => 0,
|
||||
'remaining_jobs' => 0,
|
||||
'reason' => 'no allowance configured for agent',
|
||||
];
|
||||
}
|
||||
|
||||
$usage = QuotaUsage::firstOrCreate(
|
||||
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
|
||||
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
|
||||
);
|
||||
|
||||
$result = [
|
||||
'allowed' => true,
|
||||
'status' => 'ok',
|
||||
'remaining_tokens' => -1,
|
||||
'remaining_jobs' => -1,
|
||||
'reason' => null,
|
||||
];
|
||||
|
||||
// Check model allowlist
|
||||
if ($model !== '' && ! empty($allowance->model_allowlist)) {
|
||||
if (! in_array($model, $allowance->model_allowlist, true)) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => "model not in allowlist: {$model}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily token limit
|
||||
if ($allowance->daily_token_limit > 0) {
|
||||
$remaining = $allowance->daily_token_limit - $usage->tokens_used;
|
||||
$result['remaining_tokens'] = $remaining;
|
||||
|
||||
if ($remaining <= 0) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => 'daily token limit exceeded',
|
||||
]);
|
||||
}
|
||||
|
||||
$ratio = $usage->tokens_used / $allowance->daily_token_limit;
|
||||
if ($ratio >= 0.8) {
|
||||
$result['status'] = 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily job limit
|
||||
if ($allowance->daily_job_limit > 0) {
|
||||
$remaining = $allowance->daily_job_limit - $usage->jobs_started;
|
||||
$result['remaining_jobs'] = $remaining;
|
||||
|
||||
if ($remaining <= 0) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => 'daily job limit exceeded',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check concurrent jobs
|
||||
if ($allowance->concurrent_jobs > 0 && $usage->active_jobs >= $allowance->concurrent_jobs) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => 'concurrent job limit reached',
|
||||
]);
|
||||
}
|
||||
|
||||
// Check global model quota
|
||||
if ($model !== '') {
|
||||
$modelQuota = ModelQuota::where('model', $model)->first();
|
||||
|
||||
if ($modelQuota && $modelQuota->daily_token_budget > 0) {
|
||||
$modelUsage = UsageReport::where('model', $model)
|
||||
->whereDate('reported_at', now()->toDateString())
|
||||
->sum(\DB::raw('tokens_in + tokens_out'));
|
||||
|
||||
if ($modelUsage >= $modelQuota->daily_token_budget) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => "global model token budget exceeded for: {$model}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage from an agent runner report.
|
||||
*/
|
||||
public function recordUsage(array $report): void
|
||||
{
|
||||
$agentId = $report['agent_id'];
|
||||
$totalTokens = ($report['tokens_in'] ?? 0) + ($report['tokens_out'] ?? 0);
|
||||
|
||||
$usage = QuotaUsage::firstOrCreate(
|
||||
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
|
||||
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
|
||||
);
|
||||
|
||||
// Persist the raw report
|
||||
UsageReport::create([
|
||||
'agent_id' => $report['agent_id'],
|
||||
'job_id' => $report['job_id'],
|
||||
'model' => $report['model'] ?? null,
|
||||
'tokens_in' => $report['tokens_in'] ?? 0,
|
||||
'tokens_out' => $report['tokens_out'] ?? 0,
|
||||
'event' => $report['event'],
|
||||
'reported_at' => $report['timestamp'] ?? now(),
|
||||
]);
|
||||
|
||||
match ($report['event']) {
|
||||
'job_started' => $usage->increment('jobs_started') || $usage->increment('active_jobs'),
|
||||
'job_completed' => $this->handleCompleted($usage, $totalTokens),
|
||||
'job_failed' => $this->handleFailed($usage, $totalTokens),
|
||||
'job_cancelled' => $this->handleCancelled($usage, $totalTokens),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset daily usage counters for an agent.
|
||||
*/
|
||||
public function resetAgent(string $agentId): void
|
||||
{
|
||||
QuotaUsage::updateOrCreate(
|
||||
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
|
||||
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
|
||||
);
|
||||
}
|
||||
|
||||
private function handleCompleted(QuotaUsage $usage, int $totalTokens): void
|
||||
{
|
||||
$usage->increment('tokens_used', $totalTokens);
|
||||
$usage->decrement('active_jobs');
|
||||
}
|
||||
|
||||
private function handleFailed(QuotaUsage $usage, int $totalTokens): void
|
||||
{
|
||||
$returnAmount = intdiv($totalTokens, 2);
|
||||
$usage->increment('tokens_used', $totalTokens - $returnAmount);
|
||||
$usage->decrement('active_jobs');
|
||||
}
|
||||
|
||||
private function handleCancelled(QuotaUsage $usage, int $totalTokens): void
|
||||
{
|
||||
$usage->decrement('active_jobs');
|
||||
// 100% returned — no token charge
|
||||
}
|
||||
}
|
||||
155
cmd/core-app/laravel/app/Services/Forgejo/ForgejoClient.php
Normal file
155
cmd/core-app/laravel/app/Services/Forgejo/ForgejoClient.php
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Forgejo;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Low-level HTTP client for a single Forgejo instance.
|
||||
*
|
||||
* Wraps the Laravel HTTP client with token auth, retry, and
|
||||
* base-URL scoping so callers never deal with raw HTTP details.
|
||||
*/
|
||||
class ForgejoClient
|
||||
{
|
||||
private PendingRequest $http;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $baseUrl,
|
||||
private readonly string $token,
|
||||
int $timeout = 30,
|
||||
int $retryTimes = 3,
|
||||
int $retrySleep = 500,
|
||||
) {
|
||||
if ($this->token === '') {
|
||||
throw new RuntimeException("Forgejo API token is required for {$this->baseUrl}");
|
||||
}
|
||||
|
||||
$this->http = Http::baseUrl(rtrim($this->baseUrl, '/') . '/api/v1')
|
||||
->withHeaders([
|
||||
'Authorization' => "token {$this->token}",
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
->timeout($timeout)
|
||||
->retry($retryTimes, $retrySleep, fn (?\Throwable $e, PendingRequest $req): bool =>
|
||||
$e instanceof \Illuminate\Http\Client\ConnectionException
|
||||
);
|
||||
}
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
// ----- Generic verbs -----
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function get(string $path, array $query = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->get($path, $query));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function post(string $path, array $data = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->post($path, $data));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function patch(string $path, array $data = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->patch($path, $data));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function put(string $path, array $data = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->put($path, $data));
|
||||
}
|
||||
|
||||
public function delete(string $path): void
|
||||
{
|
||||
$response = $this->http->delete($path);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo DELETE {$path} failed [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a path and return the raw response body as a string.
|
||||
* Useful for endpoints that return non-JSON content (e.g. diffs).
|
||||
*/
|
||||
public function getRaw(string $path, array $query = []): string
|
||||
{
|
||||
$response = $this->http->get($path, $query);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo GET {$path} failed [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate through all pages of a list endpoint.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function paginate(string $path, array $query = [], int $limit = 50): array
|
||||
{
|
||||
$all = [];
|
||||
$page = 1;
|
||||
|
||||
do {
|
||||
$response = $this->http->get($path, array_merge($query, [
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
]));
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo GET {$path} page {$page} failed [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
|
||||
$items = $response->json();
|
||||
|
||||
if (!is_array($items) || $items === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
array_push($all, ...$items);
|
||||
|
||||
// Forgejo returns total count in x-total-count header.
|
||||
$total = (int) $response->header('x-total-count');
|
||||
$page++;
|
||||
} while (count($all) < $total);
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
// ----- Internals -----
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function decodeOrFail(Response $response): array
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo API error [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
}
|
||||
302
cmd/core-app/laravel/app/Services/Forgejo/ForgejoService.php
Normal file
302
cmd/core-app/laravel/app/Services/Forgejo/ForgejoService.php
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Forgejo;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Business-logic layer for Forgejo operations.
|
||||
*
|
||||
* Manages multiple Forgejo instances (forge, dev, qa) and provides
|
||||
* a unified API for issues, pull requests, repositories, and user
|
||||
* management. Mirrors the Go pkg/forge API surface.
|
||||
*/
|
||||
class ForgejoService
|
||||
{
|
||||
/** @var array<string, ForgejoClient> */
|
||||
private array $clients = [];
|
||||
|
||||
private string $defaultInstance;
|
||||
|
||||
/**
|
||||
* @param array<string, array{url: string, token: string}> $instances
|
||||
*/
|
||||
public function __construct(
|
||||
array $instances,
|
||||
string $defaultInstance = 'forge',
|
||||
private readonly int $timeout = 30,
|
||||
private readonly int $retryTimes = 3,
|
||||
private readonly int $retrySleep = 500,
|
||||
) {
|
||||
$this->defaultInstance = $defaultInstance;
|
||||
|
||||
foreach ($instances as $name => $cfg) {
|
||||
if (($cfg['token'] ?? '') === '') {
|
||||
continue; // skip unconfigured instances
|
||||
}
|
||||
|
||||
$this->clients[$name] = new ForgejoClient(
|
||||
baseUrl: $cfg['url'],
|
||||
token: $cfg['token'],
|
||||
timeout: $this->timeout,
|
||||
retryTimes: $this->retryTimes,
|
||||
retrySleep: $this->retrySleep,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Instance resolution
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
public function client(?string $instance = null): ForgejoClient
|
||||
{
|
||||
$name = $instance ?? $this->defaultInstance;
|
||||
|
||||
return $this->clients[$name]
|
||||
?? throw new RuntimeException("Forgejo instance '{$name}' is not configured or has no token");
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function instances(): array
|
||||
{
|
||||
return array_keys($this->clients);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Issue Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createIssue(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $body = '',
|
||||
array $labels = [],
|
||||
string $assignee = '',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
$data = ['title' => $title, 'body' => $body];
|
||||
|
||||
if ($labels !== []) {
|
||||
$data['labels'] = $labels;
|
||||
}
|
||||
if ($assignee !== '') {
|
||||
$data['assignees'] = [$assignee];
|
||||
}
|
||||
|
||||
return $this->client($instance)->post("/repos/{$owner}/{$repo}/issues", $data);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function updateIssue(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $number,
|
||||
array $fields,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->patch("/repos/{$owner}/{$repo}/issues/{$number}", $fields);
|
||||
}
|
||||
|
||||
public function closeIssue(string $owner, string $repo, int $number, ?string $instance = null): array
|
||||
{
|
||||
return $this->updateIssue($owner, $repo, $number, ['state' => 'closed'], $instance);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function addComment(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $number,
|
||||
string $body,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->post(
|
||||
"/repos/{$owner}/{$repo}/issues/{$number}/comments",
|
||||
['body' => $body],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listIssues(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $state = 'open',
|
||||
int $page = 1,
|
||||
int $limit = 50,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->get("/repos/{$owner}/{$repo}/issues", [
|
||||
'state' => $state,
|
||||
'type' => 'issues',
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Pull Request Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createPR(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $head,
|
||||
string $base,
|
||||
string $title,
|
||||
string $body = '',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->post("/repos/{$owner}/{$repo}/pulls", [
|
||||
'head' => $head,
|
||||
'base' => $base,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
public function mergePR(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $number,
|
||||
string $strategy = 'merge',
|
||||
?string $instance = null,
|
||||
): void {
|
||||
$this->client($instance)->post("/repos/{$owner}/{$repo}/pulls/{$number}/merge", [
|
||||
'Do' => $strategy,
|
||||
'delete_branch_after_merge' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listPRs(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $state = 'open',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->paginate("/repos/{$owner}/{$repo}/pulls", [
|
||||
'state' => $state,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getPRDiff(string $owner, string $repo, int $number, ?string $instance = null): string
|
||||
{
|
||||
return $this->client($instance)->getRaw("/repos/{$owner}/{$repo}/pulls/{$number}.diff");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Repository Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listRepos(string $org, ?string $instance = null): array
|
||||
{
|
||||
return $this->client($instance)->paginate("/orgs/{$org}/repos");
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getRepo(string $owner, string $name, ?string $instance = null): array
|
||||
{
|
||||
return $this->client($instance)->get("/repos/{$owner}/{$name}");
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createBranch(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $name,
|
||||
string $from = '',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
$data = ['new_branch_name' => $name];
|
||||
|
||||
if ($from !== '') {
|
||||
$data['old_branch_name'] = $from;
|
||||
}
|
||||
|
||||
return $this->client($instance)->post("/repos/{$owner}/{$repo}/branches", $data);
|
||||
}
|
||||
|
||||
public function deleteBranch(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $name,
|
||||
?string $instance = null,
|
||||
): void {
|
||||
$this->client($instance)->delete("/repos/{$owner}/{$repo}/branches/{$name}");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// User / Token Management
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createUser(
|
||||
string $username,
|
||||
string $email,
|
||||
string $password,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->post('/admin/users', [
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'must_change_password' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createToken(
|
||||
string $username,
|
||||
string $name,
|
||||
array $scopes = [],
|
||||
?string $instance = null,
|
||||
): array {
|
||||
$data = ['name' => $name];
|
||||
|
||||
if ($scopes !== []) {
|
||||
$data['scopes'] = $scopes;
|
||||
}
|
||||
|
||||
return $this->client($instance)->post("/users/{$username}/tokens", $data);
|
||||
}
|
||||
|
||||
public function revokeToken(string $username, int $tokenId, ?string $instance = null): void
|
||||
{
|
||||
$this->client($instance)->delete("/users/{$username}/tokens/{$tokenId}");
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function addToOrg(
|
||||
string $username,
|
||||
string $org,
|
||||
int $teamId,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->put("/teams/{$teamId}/members/{$username}");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Org Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listOrgs(?string $instance = null): array
|
||||
{
|
||||
return $this->client($instance)->paginate('/user/orgs');
|
||||
}
|
||||
}
|
||||
21
cmd/core-app/laravel/artisan
Normal file
21
cmd/core-app/laravel/artisan
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
|
||||
$status = $kernel->handle(
|
||||
$input = new Symfony\Component\Console\Input\ArgvInput,
|
||||
new Symfony\Component\Console\Output\ConsoleOutput
|
||||
);
|
||||
|
||||
$kernel->terminate($input, $status);
|
||||
|
||||
exit($status);
|
||||
21
cmd/core-app/laravel/bootstrap/app.php
Normal file
21
cmd/core-app/laravel/bootstrap/app.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
})
|
||||
->create();
|
||||
7
cmd/core-app/laravel/bootstrap/providers.php
Normal file
7
cmd/core-app/laravel/bootstrap/providers.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
29
cmd/core-app/laravel/composer.json
Normal file
29
cmd/core-app/laravel/composer.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "host-uk/core-app",
|
||||
"description": "Embedded Laravel application for Core App desktop",
|
||||
"license": "EUPL-1.2",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/octane": "^2.0",
|
||||
"livewire/livewire": "^4.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"@php artisan package:discover --ansi"
|
||||
]
|
||||
}
|
||||
}
|
||||
6149
cmd/core-app/laravel/composer.lock
generated
Normal file
6149
cmd/core-app/laravel/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
cmd/core-app/laravel/config/app.php
Normal file
19
cmd/core-app/laravel/config/app.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'name' => env('APP_NAME', 'Core App'),
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
'timezone' => 'UTC',
|
||||
'locale' => 'en',
|
||||
'fallback_locale' => 'en',
|
||||
'faker_locale' => 'en_GB',
|
||||
'cipher' => 'AES-256-CBC',
|
||||
'key' => env('APP_KEY'),
|
||||
'maintenance' => [
|
||||
'driver' => 'file',
|
||||
],
|
||||
];
|
||||
21
cmd/core-app/laravel/config/cache.php
Normal file
21
cmd/core-app/laravel/config/cache.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default' => env('CACHE_STORE', 'file'),
|
||||
|
||||
'stores' => [
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
],
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', 'core_app_cache_'),
|
||||
];
|
||||
25
cmd/core-app/laravel/config/database.php
Normal file
25
cmd/core-app/laravel/config/database.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default' => 'sqlite',
|
||||
|
||||
'connections' => [
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => true,
|
||||
'busy_timeout' => 5000,
|
||||
'journal_mode' => 'wal',
|
||||
'synchronous' => 'normal',
|
||||
],
|
||||
],
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
];
|
||||
51
cmd/core-app/laravel/config/forgejo.php
Normal file
51
cmd/core-app/laravel/config/forgejo.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Forgejo Instance
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The instance name to use when no explicit instance is specified.
|
||||
|
|
||||
*/
|
||||
'default' => env('FORGEJO_DEFAULT', 'forge'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Forgejo Instances
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Each entry defines a Forgejo instance the platform can talk to.
|
||||
| The service auto-routes by matching the configured URL.
|
||||
|
|
||||
| url — Base URL of the Forgejo instance (no trailing slash)
|
||||
| token — Admin API token for the instance
|
||||
|
|
||||
*/
|
||||
'instances' => [
|
||||
'forge' => [
|
||||
'url' => env('FORGEJO_FORGE_URL', 'https://forge.lthn.ai'),
|
||||
'token' => env('FORGEJO_FORGE_TOKEN', ''),
|
||||
],
|
||||
'dev' => [
|
||||
'url' => env('FORGEJO_DEV_URL', 'https://dev.lthn.ai'),
|
||||
'token' => env('FORGEJO_DEV_TOKEN', ''),
|
||||
],
|
||||
'qa' => [
|
||||
'url' => env('FORGEJO_QA_URL', 'https://qa.lthn.ai'),
|
||||
'token' => env('FORGEJO_QA_TOKEN', ''),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Client Settings
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'timeout' => (int) env('FORGEJO_TIMEOUT', 30),
|
||||
'retry_times' => (int) env('FORGEJO_RETRY_TIMES', 3),
|
||||
'retry_sleep' => (int) env('FORGEJO_RETRY_SLEEP', 500),
|
||||
];
|
||||
25
cmd/core-app/laravel/config/logging.php
Normal file
25
cmd/core-app/laravel/config/logging.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'default' => env('LOG_CHANNEL', 'single'),
|
||||
|
||||
'channels' => [
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'warning'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => Monolog\Handler\StreamHandler::class,
|
||||
'with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'processors' => [Monolog\Processor\PsrLogMessageProcessor::class],
|
||||
],
|
||||
],
|
||||
];
|
||||
22
cmd/core-app/laravel/config/session.php
Normal file
22
cmd/core-app/laravel/config/session.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'driver' => env('SESSION_DRIVER', 'file'),
|
||||
'lifetime' => env('SESSION_LIFETIME', 120),
|
||||
'expire_on_close' => true,
|
||||
'encrypt' => false,
|
||||
'files' => storage_path('framework/sessions'),
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
'table' => 'sessions',
|
||||
'store' => env('SESSION_STORE'),
|
||||
'lottery' => [2, 100],
|
||||
'cookie' => env('SESSION_COOKIE', 'core_app_session'),
|
||||
'path' => '/',
|
||||
'domain' => null,
|
||||
'secure' => false,
|
||||
'http_only' => true,
|
||||
'same_site' => 'lax',
|
||||
'partitioned' => false,
|
||||
];
|
||||
10
cmd/core-app/laravel/config/view.php
Normal file
10
cmd/core-app/laravel/config/view.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'paths' => [
|
||||
resource_path('views'),
|
||||
],
|
||||
'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))),
|
||||
];
|
||||
BIN
cmd/core-app/laravel/database/database.sqlite
Normal file
BIN
cmd/core-app/laravel/database/database.sqlite
Normal file
Binary file not shown.
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue