Compare commits
69 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a92bd652b | ||
|
|
85493ae779 | ||
|
|
e959a9aaaf | ||
|
|
3f162db925 | ||
|
|
12ff432d6b | ||
|
|
b5032bea32 | ||
|
|
97a561c809 | ||
|
|
9323eeb1fa | ||
|
|
57ad74d4e2 | ||
|
|
1734acaae0 | ||
|
|
aca479de75 | ||
|
|
7f2470b135 | ||
|
|
dcd705ff46 | ||
|
|
138927baa5 | ||
|
|
2a90ae65b7 | ||
|
|
8e7fb0e5a3 | ||
|
|
d091fa6202 | ||
|
|
58ca902320 | ||
|
|
a0660e5802 | ||
|
|
fcdccdbe87 | ||
|
|
c2418a2737 | ||
|
|
175ad1e361 | ||
|
|
50afecea6d | ||
|
|
92a2260e21 | ||
|
|
e3fdbe9809 | ||
|
|
e66115f036 | ||
|
|
2aff7a3503 | ||
|
|
d7e5215618 | ||
|
|
1e8a4131db | ||
|
|
df011ee42b | ||
|
|
2d355f9223 | ||
|
|
db0c0adb65 | ||
|
|
ce12778561 | ||
|
|
44122f9ca6 | ||
|
|
b2e046f4c5 | ||
|
|
3135352b2f | ||
|
|
2bae1148bb | ||
|
|
cffd9d3929 | ||
|
|
cb0408db1d | ||
|
|
e7f8ecb078 | ||
|
|
1cdf92490a | ||
|
|
bcf2d3be48 | ||
|
|
19521c8f18 | ||
|
|
22121eae20 | ||
|
|
b2e78bf29e | ||
|
|
94480ca38e | ||
|
|
3ff7b8a773 | ||
| 0192772ab5 | |||
|
|
c1bc0dad5e | ||
|
|
19e3fd3af7 | ||
| 10f0ebaf22 | |||
|
|
cbaa114bb2 | ||
|
|
9899398153 | ||
|
|
ad6a466459 | ||
|
|
af98accc03 | ||
|
|
2f246ad053 | ||
|
|
7d047fbdcc | ||
|
|
e8695b72a6 | ||
| f0268d12bf | |||
|
|
0681fba48e | ||
|
|
5b737a4933 | ||
|
|
f065c0a5be | ||
|
|
c490a05733 | ||
|
|
93be6c5ed2 | ||
|
|
01924059ae | ||
|
|
262f0eb5d5 | ||
|
|
c7102826ba | ||
|
|
ea63c3acae | ||
|
|
d2f2f0984c |
110 changed files with 18220 additions and 5079 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{md,yml,yaml,json,txt}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -17,9 +17,11 @@ dist/
|
|||
tasks
|
||||
/core
|
||||
/i18n-validate
|
||||
cmd/bugseti/bugseti
|
||||
internal/core-ide/core-ide
|
||||
/validate
|
||||
cmd/*
|
||||
!cmd/gocmd/
|
||||
.angular/
|
||||
|
||||
patch_cov.*
|
||||
go.work.sum
|
||||
lt-hn-index.html
|
||||
|
|
|
|||
22
.golangci.yml
Normal file
22
.golangci.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
run:
|
||||
timeout: 5m
|
||||
go: "1.26"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- govet
|
||||
- errcheck
|
||||
- staticcheck
|
||||
- unused
|
||||
- gosimple
|
||||
- ineffassign
|
||||
- typecheck
|
||||
- gocritic
|
||||
- gofmt
|
||||
disable:
|
||||
- exhaustive
|
||||
- wrapcheck
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-same-issues: 0
|
||||
35
CONTRIBUTING.md
Normal file
35
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing!
|
||||
|
||||
## Requirements
|
||||
- **Go Version**: 1.26 or higher is required.
|
||||
- **Tools**: `golangci-lint` and `task` (Taskfile.dev) are recommended.
|
||||
|
||||
## Development Workflow
|
||||
1. **Testing**: Ensure all tests pass before submitting changes.
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
2. **Code Style**: All code must follow standard Go formatting.
|
||||
```bash
|
||||
gofmt -w .
|
||||
go vet ./...
|
||||
```
|
||||
3. **Linting**: We use `golangci-lint` to maintain code quality.
|
||||
```bash
|
||||
golangci-lint run ./...
|
||||
```
|
||||
|
||||
## Commit Message Format
|
||||
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `refactor`: A code change that neither fixes a bug nor adds a feature
|
||||
- `chore`: Changes to the build process or auxiliary tools and libraries
|
||||
|
||||
Example: `feat: add new endpoint for health check`
|
||||
|
||||
## License
|
||||
By contributing to this project, you agree that your contributions will be licensed under the **European Union Public Licence (EUPL-1.2)**.
|
||||
46
Taskfile.yml
Normal file
46
Taskfile.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
version: '3'
|
||||
|
||||
tasks:
|
||||
test:
|
||||
desc: Run all tests
|
||||
cmds:
|
||||
- go test ./...
|
||||
|
||||
lint:
|
||||
desc: Run golangci-lint
|
||||
cmds:
|
||||
- golangci-lint run ./...
|
||||
|
||||
fmt:
|
||||
desc: Format all Go files
|
||||
cmds:
|
||||
- gofmt -w .
|
||||
|
||||
vet:
|
||||
desc: Run go vet
|
||||
cmds:
|
||||
- go vet ./...
|
||||
|
||||
build:
|
||||
desc: Build all Go packages
|
||||
cmds:
|
||||
- go build ./...
|
||||
|
||||
cov:
|
||||
desc: Run tests with coverage and open HTML report
|
||||
cmds:
|
||||
- go test -coverprofile=coverage.out ./...
|
||||
- go tool cover -html=coverage.out
|
||||
|
||||
tidy:
|
||||
desc: Tidy go.mod
|
||||
cmds:
|
||||
- go mod tidy
|
||||
|
||||
check:
|
||||
desc: Run fmt, vet, lint, and test in sequence
|
||||
cmds:
|
||||
- task: fmt
|
||||
- task: vet
|
||||
- task: lint
|
||||
- task: test
|
||||
15
cmd/gocmd/cmd_commands.go
Normal file
15
cmd/gocmd/cmd_commands.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Package gocmd provides Go development commands with enhanced output.
|
||||
//
|
||||
// Note: Package named gocmd because 'go' is a reserved keyword.
|
||||
//
|
||||
// Commands:
|
||||
// - test: Run tests with colour-coded coverage summary
|
||||
// - cov: Run tests with detailed coverage reports (HTML, thresholds)
|
||||
// - fmt: Format code using goimports or gofmt
|
||||
// - lint: Run golangci-lint
|
||||
// - install: Install binary to $GOPATH/bin
|
||||
// - mod: Module management (tidy, download, verify, graph)
|
||||
// - work: Workspace management (sync, init, use)
|
||||
//
|
||||
// Sets MACOSX_DEPLOYMENT_TARGET to suppress linker warnings on macOS.
|
||||
package gocmd
|
||||
177
cmd/gocmd/cmd_format.go
Normal file
177
cmd/gocmd/cmd_format.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
fmtFix bool
|
||||
fmtDiff bool
|
||||
fmtCheck bool
|
||||
fmtAll bool
|
||||
)
|
||||
|
||||
func addGoFmtCommand(parent *cli.Command) {
|
||||
fmtCmd := &cli.Command{
|
||||
Use: "fmt",
|
||||
Short: "Format Go code",
|
||||
Long: "Format Go code using goimports or gofmt. By default only checks changed files.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
// Get list of files to check
|
||||
var files []string
|
||||
if fmtAll {
|
||||
// Check all Go files
|
||||
files = []string{"."}
|
||||
} else {
|
||||
// Only check changed Go files (git-aware)
|
||||
files = getChangedGoFiles()
|
||||
if len(files) == 0 {
|
||||
cli.Print("%s\n", i18n.T("cmd.go.fmt.no_changes"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Validate flag combinations
|
||||
if fmtCheck && fmtFix {
|
||||
return cli.Err("--check and --fix are mutually exclusive")
|
||||
}
|
||||
|
||||
fmtArgs := []string{}
|
||||
if fmtFix {
|
||||
fmtArgs = append(fmtArgs, "-w")
|
||||
}
|
||||
if fmtDiff {
|
||||
fmtArgs = append(fmtArgs, "-d")
|
||||
}
|
||||
if !fmtFix && !fmtDiff {
|
||||
fmtArgs = append(fmtArgs, "-l")
|
||||
}
|
||||
fmtArgs = append(fmtArgs, files...)
|
||||
|
||||
// Try goimports first, fall back to gofmt
|
||||
var execCmd *exec.Cmd
|
||||
if _, err := exec.LookPath("goimports"); err == nil {
|
||||
execCmd = exec.Command("goimports", fmtArgs...)
|
||||
} else {
|
||||
execCmd = exec.Command("gofmt", fmtArgs...)
|
||||
}
|
||||
|
||||
// For --check mode, capture output to detect unformatted files
|
||||
if fmtCheck {
|
||||
output, err := execCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
_, _ = os.Stderr.Write(output)
|
||||
return err
|
||||
}
|
||||
if len(output) > 0 {
|
||||
_, _ = os.Stdout.Write(output)
|
||||
return cli.Err("files need formatting (use --fix)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix"))
|
||||
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
|
||||
fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, i18n.T("cmd.go.fmt.flag.check"))
|
||||
fmtCmd.Flags().BoolVar(&fmtAll, "all", false, i18n.T("cmd.go.fmt.flag.all"))
|
||||
|
||||
parent.AddCommand(fmtCmd)
|
||||
}
|
||||
|
||||
// getChangedGoFiles returns Go files that have been modified, staged, or are untracked.
|
||||
func getChangedGoFiles() []string {
|
||||
var files []string
|
||||
|
||||
// Get modified and staged files
|
||||
cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
files = append(files, filterGoFiles(string(output))...)
|
||||
}
|
||||
|
||||
// Get untracked files
|
||||
cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
|
||||
output, err = cmd.Output()
|
||||
if err == nil {
|
||||
files = append(files, filterGoFiles(string(output))...)
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
seen := make(map[string]bool)
|
||||
var unique []string
|
||||
for _, f := range files {
|
||||
if !seen[f] {
|
||||
seen[f] = true
|
||||
// Verify file exists (might have been deleted)
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
unique = append(unique, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
// filterGoFiles filters a newline-separated list of files to only include .go files.
|
||||
func filterGoFiles(output string) []string {
|
||||
var goFiles []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
file := strings.TrimSpace(scanner.Text())
|
||||
if file != "" && filepath.Ext(file) == ".go" {
|
||||
goFiles = append(goFiles, file)
|
||||
}
|
||||
}
|
||||
return goFiles
|
||||
}
|
||||
|
||||
var (
|
||||
lintFix bool
|
||||
lintAll bool
|
||||
)
|
||||
|
||||
func addGoLintCommand(parent *cli.Command) {
|
||||
lintCmd := &cli.Command{
|
||||
Use: "lint",
|
||||
Short: "Run golangci-lint",
|
||||
Long: "Run golangci-lint for comprehensive static analysis. By default only lints changed files.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
lintArgs := []string{"run"}
|
||||
if lintFix {
|
||||
lintArgs = append(lintArgs, "--fix")
|
||||
}
|
||||
|
||||
if !lintAll {
|
||||
// Use --new-from-rev=HEAD to only report issues in uncommitted changes
|
||||
// This is golangci-lint's native way to handle incremental linting
|
||||
lintArgs = append(lintArgs, "--new-from-rev=HEAD")
|
||||
}
|
||||
|
||||
// Always lint all packages
|
||||
lintArgs = append(lintArgs, "./...")
|
||||
|
||||
execCmd := exec.Command("golangci-lint", lintArgs...)
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
lintCmd.Flags().BoolVar(&lintFix, "fix", false, i18n.T("common.flag.fix"))
|
||||
lintCmd.Flags().BoolVar(&lintAll, "all", false, i18n.T("cmd.go.lint.flag.all"))
|
||||
|
||||
parent.AddCommand(lintCmd)
|
||||
}
|
||||
169
cmd/gocmd/cmd_fuzz.go
Normal file
169
cmd/gocmd/cmd_fuzz.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
fuzzDuration time.Duration
|
||||
fuzzPkg string
|
||||
fuzzRun string
|
||||
fuzzVerbose bool
|
||||
)
|
||||
|
||||
func addGoFuzzCommand(parent *cli.Command) {
|
||||
fuzzCmd := &cli.Command{
|
||||
Use: "fuzz",
|
||||
Short: "Run Go fuzz tests",
|
||||
Long: `Run Go fuzz tests with configurable duration.
|
||||
|
||||
Discovers Fuzz* functions across the project and runs each with go test -fuzz.
|
||||
|
||||
Examples:
|
||||
core go fuzz # Run all fuzz targets for 10s each
|
||||
core go fuzz --duration=30s # Run each target for 30s
|
||||
core go fuzz --pkg=./pkg/... # Fuzz specific package
|
||||
core go fuzz --run=FuzzE # Run only matching fuzz targets`,
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runGoFuzz(fuzzDuration, fuzzPkg, fuzzRun, fuzzVerbose)
|
||||
},
|
||||
}
|
||||
|
||||
fuzzCmd.Flags().DurationVar(&fuzzDuration, "duration", 10*time.Second, "Duration per fuzz target")
|
||||
fuzzCmd.Flags().StringVar(&fuzzPkg, "pkg", "", "Package to fuzz (default: auto-discover)")
|
||||
fuzzCmd.Flags().StringVar(&fuzzRun, "run", "", "Only run fuzz targets matching pattern")
|
||||
fuzzCmd.Flags().BoolVarP(&fuzzVerbose, "verbose", "v", false, "Verbose output")
|
||||
|
||||
parent.AddCommand(fuzzCmd)
|
||||
}
|
||||
|
||||
// fuzzTarget represents a discovered fuzz function and its package.
|
||||
type fuzzTarget struct {
|
||||
Pkg string
|
||||
Name string
|
||||
}
|
||||
|
||||
func runGoFuzz(duration time.Duration, pkg, run string, verbose bool) error {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fuzz")), i18n.ProgressSubject("run", "fuzz tests"))
|
||||
cli.Blank()
|
||||
|
||||
targets, err := discoverFuzzTargets(pkg, run)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "discover fuzz targets")
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
cli.Print(" %s no fuzz targets found\n", dimStyle.Render("—"))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Print(" %s %d target(s), %s each\n", dimStyle.Render(i18n.Label("targets")), len(targets), duration)
|
||||
cli.Blank()
|
||||
|
||||
passed := 0
|
||||
failed := 0
|
||||
|
||||
for _, t := range targets {
|
||||
cli.Print(" %s %s in %s\n", dimStyle.Render("→"), t.Name, t.Pkg)
|
||||
|
||||
args := []string{
|
||||
"test",
|
||||
fmt.Sprintf("-fuzz=^%s$", t.Name),
|
||||
fmt.Sprintf("-fuzztime=%s", duration),
|
||||
"-run=^$", // Don't run unit tests
|
||||
}
|
||||
if verbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
args = append(args, t.Pkg)
|
||||
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
|
||||
cmd.Dir, _ = os.Getwd()
|
||||
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
if runErr != nil {
|
||||
failed++
|
||||
cli.Print(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), runErr.Error())
|
||||
if outputStr != "" {
|
||||
cli.Text(outputStr)
|
||||
}
|
||||
} else {
|
||||
passed++
|
||||
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
|
||||
if verbose && outputStr != "" {
|
||||
cli.Text(outputStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
if failed > 0 {
|
||||
cli.Print("%s %d passed, %d failed\n", errorStyle.Render(cli.Glyph(":cross:")), passed, failed)
|
||||
return cli.Err("fuzz: %d target(s) failed", failed)
|
||||
}
|
||||
|
||||
cli.Print("%s %d passed\n", successStyle.Render(cli.Glyph(":check:")), passed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// discoverFuzzTargets scans for Fuzz* functions in test files.
|
||||
func discoverFuzzTargets(pkg, pattern string) ([]fuzzTarget, error) {
|
||||
root := "."
|
||||
if pkg != "" {
|
||||
// Convert Go package pattern to filesystem path
|
||||
root = strings.TrimPrefix(pkg, "./")
|
||||
root = strings.TrimSuffix(root, "/...")
|
||||
}
|
||||
|
||||
fuzzRe := regexp.MustCompile(`^func\s+(Fuzz\w+)\s*\(\s*\w+\s+\*testing\.F\s*\)`)
|
||||
var matchRe *regexp.Regexp
|
||||
if pattern != "" {
|
||||
var err error
|
||||
matchRe, err = regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --run pattern: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var targets []fuzzTarget
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() || !strings.HasSuffix(info.Name(), "_test.go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := "./" + filepath.Dir(path)
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
m := fuzzRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
name := m[1]
|
||||
if matchRe != nil && !matchRe.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, fuzzTarget{Pkg: dir, Name: name})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return targets, err
|
||||
}
|
||||
36
cmd/gocmd/cmd_go.go
Normal file
36
cmd/gocmd/cmd_go.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Package gocmd provides Go development commands.
|
||||
//
|
||||
// Note: Package named gocmd because 'go' is a reserved keyword.
|
||||
package gocmd
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Style aliases for shared styles
|
||||
var (
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
dimStyle = cli.DimStyle
|
||||
)
|
||||
|
||||
// AddGoCommands adds Go development commands.
|
||||
func AddGoCommands(root *cli.Command) {
|
||||
goCmd := &cli.Command{
|
||||
Use: "go",
|
||||
Short: i18n.T("cmd.go.short"),
|
||||
Long: i18n.T("cmd.go.long"),
|
||||
}
|
||||
|
||||
root.AddCommand(goCmd)
|
||||
addGoQACommand(goCmd)
|
||||
addGoTestCommand(goCmd)
|
||||
addGoCovCommand(goCmd)
|
||||
addGoFmtCommand(goCmd)
|
||||
addGoLintCommand(goCmd)
|
||||
addGoInstallCommand(goCmd)
|
||||
addGoModCommand(goCmd)
|
||||
addGoWorkCommand(goCmd)
|
||||
addGoFuzzCommand(goCmd)
|
||||
}
|
||||
430
cmd/gocmd/cmd_gotest.go
Normal file
430
cmd/gocmd/cmd_gotest.go
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
testCoverage bool
|
||||
testPkg string
|
||||
testRun string
|
||||
testShort bool
|
||||
testRace bool
|
||||
testJSON bool
|
||||
testVerbose bool
|
||||
)
|
||||
|
||||
func addGoTestCommand(parent *cli.Command) {
|
||||
testCmd := &cli.Command{
|
||||
Use: "test",
|
||||
Short: "Run Go tests",
|
||||
Long: "Run Go tests with optional coverage, filtering, and race detection",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runGoTest(testCoverage, testPkg, testRun, testShort, testRace, testJSON, testVerbose)
|
||||
},
|
||||
}
|
||||
|
||||
testCmd.Flags().BoolVar(&testCoverage, "coverage", false, "Generate coverage report")
|
||||
testCmd.Flags().StringVar(&testPkg, "pkg", "", "Package to test")
|
||||
testCmd.Flags().StringVar(&testRun, "run", "", "Run only tests matching pattern")
|
||||
testCmd.Flags().BoolVar(&testShort, "short", false, "Run only short tests")
|
||||
testCmd.Flags().BoolVar(&testRace, "race", false, "Enable race detector")
|
||||
testCmd.Flags().BoolVar(&testJSON, "json", false, "Output as JSON")
|
||||
testCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, "Verbose output")
|
||||
|
||||
parent.AddCommand(testCmd)
|
||||
}
|
||||
|
||||
func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error {
|
||||
if pkg == "" {
|
||||
pkg = "./..."
|
||||
}
|
||||
|
||||
args := []string{"test"}
|
||||
|
||||
var covPath string
|
||||
if coverage {
|
||||
args = append(args, "-cover", "-covermode=atomic")
|
||||
covFile, err := os.CreateTemp("", "coverage-*.out")
|
||||
if err == nil {
|
||||
covPath = covFile.Name()
|
||||
_ = covFile.Close()
|
||||
args = append(args, "-coverprofile="+covPath)
|
||||
defer os.Remove(covPath)
|
||||
}
|
||||
}
|
||||
|
||||
if run != "" {
|
||||
args = append(args, "-run", run)
|
||||
}
|
||||
if short {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if race {
|
||||
args = append(args, "-race")
|
||||
}
|
||||
if verbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
|
||||
args = append(args, pkg)
|
||||
|
||||
if !jsonOut {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("test")), i18n.ProgressSubject("run", "tests"))
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), pkg)
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
|
||||
cmd.Dir, _ = os.Getwd()
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
// Filter linker warnings
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
var filtered []string
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, "ld: warning:") {
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
}
|
||||
outputStr = strings.Join(filtered, "\n")
|
||||
|
||||
// Parse results
|
||||
passed, failed, skipped := parseTestResults(outputStr)
|
||||
cov := parseOverallCoverage(outputStr)
|
||||
|
||||
if jsonOut {
|
||||
cli.Print(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`,
|
||||
passed, failed, skipped, cov, cmd.ProcessState.ExitCode())
|
||||
cli.Blank()
|
||||
return err
|
||||
}
|
||||
|
||||
// Print filtered output if verbose or failed
|
||||
if verbose || err != nil {
|
||||
cli.Text(outputStr)
|
||||
}
|
||||
|
||||
// Summary
|
||||
if err == nil {
|
||||
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"))
|
||||
} else {
|
||||
cli.Print(" %s %s, %s\n", errorStyle.Render(cli.Glyph(":cross:")),
|
||||
i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
i18n.T("i18n.count.test", failed)+" "+i18n.T("i18n.done.fail"))
|
||||
}
|
||||
|
||||
if cov > 0 {
|
||||
cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(cov))
|
||||
if covPath != "" {
|
||||
branchCov, err := calculateBlockCoverage(covPath)
|
||||
if err != nil {
|
||||
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), cli.ErrorStyle.Render("unable to calculate"))
|
||||
} else {
|
||||
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
|
||||
} else {
|
||||
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.done.fail")))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func parseTestResults(output string) (passed, failed, skipped int) {
|
||||
passRe := regexp.MustCompile(`(?m)^ok\s+`)
|
||||
failRe := regexp.MustCompile(`(?m)^FAIL\s+`)
|
||||
skipRe := regexp.MustCompile(`(?m)^\?\s+`)
|
||||
|
||||
passed = len(passRe.FindAllString(output, -1))
|
||||
failed = len(failRe.FindAllString(output, -1))
|
||||
skipped = len(skipRe.FindAllString(output, -1))
|
||||
return
|
||||
}
|
||||
|
||||
func parseOverallCoverage(output string) float64 {
|
||||
re := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
|
||||
matches := re.FindAllStringSubmatch(output, -1)
|
||||
if len(matches) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, m := range matches {
|
||||
var cov float64
|
||||
_, _ = fmt.Sscanf(m[1], "%f", &cov)
|
||||
total += cov
|
||||
}
|
||||
return total / float64(len(matches))
|
||||
}
|
||||
|
||||
var (
|
||||
covPkg string
|
||||
covHTML bool
|
||||
covOpen bool
|
||||
covThreshold float64
|
||||
covBranchThreshold float64
|
||||
covOutput string
|
||||
)
|
||||
|
||||
func addGoCovCommand(parent *cli.Command) {
|
||||
covCmd := &cli.Command{
|
||||
Use: "cov",
|
||||
Short: "Run tests with coverage report",
|
||||
Long: "Run tests with detailed coverage reports, HTML output, and threshold checking",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
pkg := covPkg
|
||||
if pkg == "" {
|
||||
// Auto-discover packages with tests
|
||||
pkgs, err := findTestPackages(".")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.find", "test packages"))
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
return errors.New("no test packages found")
|
||||
}
|
||||
pkg = strings.Join(pkgs, " ")
|
||||
}
|
||||
|
||||
// Create temp file for coverage data
|
||||
covFile, err := os.CreateTemp("", "coverage-*.out")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.create", "coverage file"))
|
||||
}
|
||||
covPath := covFile.Name()
|
||||
_ = covFile.Close()
|
||||
defer func() {
|
||||
if covOutput == "" {
|
||||
_ = os.Remove(covPath)
|
||||
} else {
|
||||
// Copy to output destination before removing
|
||||
src, _ := os.Open(covPath)
|
||||
dst, _ := os.Create(covOutput)
|
||||
if src != nil && dst != nil {
|
||||
_, _ = io.Copy(dst, src)
|
||||
_ = src.Close()
|
||||
_ = dst.Close()
|
||||
}
|
||||
_ = os.Remove(covPath)
|
||||
}
|
||||
}()
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("coverage")), i18n.ProgressSubject("run", "tests"))
|
||||
// Truncate package list if too long for display
|
||||
displayPkg := pkg
|
||||
if len(displayPkg) > 60 {
|
||||
displayPkg = displayPkg[:57] + "..."
|
||||
}
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), displayPkg)
|
||||
cli.Blank()
|
||||
|
||||
// Run tests with coverage
|
||||
// We need to split pkg into individual arguments if it contains spaces
|
||||
pkgArgs := strings.Fields(pkg)
|
||||
cmdArgs := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...)
|
||||
|
||||
goCmd := exec.Command("go", cmdArgs...)
|
||||
goCmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0")
|
||||
goCmd.Stdout = os.Stdout
|
||||
goCmd.Stderr = os.Stderr
|
||||
|
||||
testErr := goCmd.Run()
|
||||
|
||||
// Get coverage percentage
|
||||
coverCmd := exec.Command("go", "tool", "cover", "-func="+covPath)
|
||||
covOutput, err := coverCmd.Output()
|
||||
if err != nil {
|
||||
if testErr != nil {
|
||||
return testErr
|
||||
}
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.get", "coverage"))
|
||||
}
|
||||
|
||||
// Parse total coverage from last line
|
||||
lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n")
|
||||
var statementCov float64
|
||||
if len(lines) > 0 {
|
||||
lastLine := lines[len(lines)-1]
|
||||
// Format: "total: (statements) XX.X%"
|
||||
if strings.Contains(lastLine, "total:") {
|
||||
parts := strings.Fields(lastLine)
|
||||
if len(parts) >= 3 {
|
||||
covStr := strings.TrimSuffix(parts[len(parts)-1], "%")
|
||||
_, _ = fmt.Sscanf(covStr, "%f", &statementCov)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate branch coverage (block coverage)
|
||||
branchCov, err := calculateBlockCoverage(covPath)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "calculate branch coverage")
|
||||
}
|
||||
|
||||
// Print coverage summary
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(statementCov))
|
||||
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
|
||||
|
||||
// Generate HTML if requested
|
||||
if covHTML || covOpen {
|
||||
htmlPath := "coverage.html"
|
||||
htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath)
|
||||
if err := htmlCmd.Run(); err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.generate", "HTML"))
|
||||
}
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("html")), htmlPath)
|
||||
|
||||
if covOpen {
|
||||
// Open in browser
|
||||
var openCmd *exec.Cmd
|
||||
switch {
|
||||
case exec.Command("which", "open").Run() == nil:
|
||||
openCmd = exec.Command("open", htmlPath)
|
||||
case exec.Command("which", "xdg-open").Run() == nil:
|
||||
openCmd = exec.Command("xdg-open", htmlPath)
|
||||
default:
|
||||
cli.Print(" %s\n", dimStyle.Render("Open coverage.html in your browser"))
|
||||
}
|
||||
if openCmd != nil {
|
||||
_ = openCmd.Run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check thresholds
|
||||
if covThreshold > 0 && statementCov < covThreshold {
|
||||
cli.Print("\n%s Statements: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), statementCov, covThreshold)
|
||||
return errors.New("statement coverage below threshold")
|
||||
}
|
||||
if covBranchThreshold > 0 && branchCov < covBranchThreshold {
|
||||
cli.Print("\n%s Branches: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), branchCov, covBranchThreshold)
|
||||
return errors.New("branch coverage below threshold")
|
||||
}
|
||||
|
||||
if testErr != nil {
|
||||
return testErr
|
||||
}
|
||||
|
||||
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
covCmd.Flags().StringVar(&covPkg, "pkg", "", "Package to test")
|
||||
covCmd.Flags().BoolVar(&covHTML, "html", false, "Generate HTML report")
|
||||
covCmd.Flags().BoolVar(&covOpen, "open", false, "Open HTML report in browser")
|
||||
covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum statement coverage percentage")
|
||||
covCmd.Flags().Float64Var(&covBranchThreshold, "branch-threshold", 0, "Minimum branch coverage percentage")
|
||||
covCmd.Flags().StringVarP(&covOutput, "output", "o", "", "Output file for coverage profile")
|
||||
|
||||
parent.AddCommand(covCmd)
|
||||
}
|
||||
|
||||
// calculateBlockCoverage parses a Go coverage profile and returns the percentage of basic
|
||||
// blocks that have a non-zero execution count. Go's coverage profile contains one line per
|
||||
// basic block, where the last field is the execution count, not explicit branch coverage.
|
||||
// The resulting block coverage is used here only as a proxy for branch coverage; computing
|
||||
// true branch coverage would require more detailed control-flow analysis.
|
||||
func calculateBlockCoverage(path string) (float64, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var totalBlocks, coveredBlocks int
|
||||
|
||||
// Skip the first line (mode: atomic/set/count)
|
||||
if !scanner.Scan() {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Last field is the count
|
||||
count, err := strconv.Atoi(fields[len(fields)-1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
totalBlocks++
|
||||
if count > 0 {
|
||||
coveredBlocks++
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if totalBlocks == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return (float64(coveredBlocks) / float64(totalBlocks)) * 100, nil
|
||||
}
|
||||
|
||||
func findTestPackages(root string) ([]string, error) {
|
||||
pkgMap := make(map[string]bool)
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") {
|
||||
dir := filepath.Dir(path)
|
||||
if !strings.HasPrefix(dir, ".") {
|
||||
dir = "./" + dir
|
||||
}
|
||||
pkgMap[dir] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pkgs []string
|
||||
for pkg := range pkgMap {
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
func formatCoverage(cov float64) string {
|
||||
s := fmt.Sprintf("%.1f%%", cov)
|
||||
if cov >= 80 {
|
||||
return cli.SuccessStyle.Render(s)
|
||||
} else if cov >= 50 {
|
||||
return cli.WarningStyle.Render(s)
|
||||
}
|
||||
return cli.ErrorStyle.Render(s)
|
||||
}
|
||||
629
cmd/gocmd/cmd_qa.go
Normal file
629
cmd/gocmd/cmd_qa.go
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-devops/cmd/qa"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// QA command flags - comprehensive options for all agents
|
||||
var (
|
||||
qaFix bool
|
||||
qaChanged bool
|
||||
qaAll bool
|
||||
qaSkip string
|
||||
qaOnly string
|
||||
qaCoverage bool
|
||||
qaThreshold float64
|
||||
qaBranchThreshold float64
|
||||
qaDocblockThreshold float64
|
||||
qaJSON bool
|
||||
qaVerbose bool
|
||||
qaQuiet bool
|
||||
qaTimeout time.Duration
|
||||
qaShort bool
|
||||
qaRace bool
|
||||
qaBench bool
|
||||
qaFailFast bool
|
||||
qaMod bool
|
||||
qaCI bool
|
||||
)
|
||||
|
||||
func addGoQACommand(parent *cli.Command) {
|
||||
qaCmd := &cli.Command{
|
||||
Use: "qa",
|
||||
Short: "Run QA checks",
|
||||
Long: `Run comprehensive code quality checks for Go projects.
|
||||
|
||||
Checks available: fmt, vet, lint, test, race, fuzz, vuln, sec, bench, docblock
|
||||
|
||||
Examples:
|
||||
core go qa # Default: fmt, lint, test
|
||||
core go qa --fix # Auto-fix formatting and lint issues
|
||||
core go qa --only=test # Only run tests
|
||||
core go qa --skip=vuln,sec # Skip vulnerability and security scans
|
||||
core go qa --coverage --threshold=80 # Require 80% coverage
|
||||
core go qa --changed # Only check changed files (git-aware)
|
||||
core go qa --ci # CI mode: strict, coverage, fail-fast
|
||||
core go qa --race --short # Quick tests with race detection
|
||||
core go qa --json # Output results as JSON`,
|
||||
RunE: runGoQA,
|
||||
}
|
||||
|
||||
// Fix and modification flags (persistent so subcommands inherit them)
|
||||
qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaMod, "mod", false, "Run go mod tidy before checks")
|
||||
|
||||
// Scope flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)")
|
||||
qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,fuzz,vuln,sec,bench)")
|
||||
qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)")
|
||||
|
||||
// Coverage flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting")
|
||||
qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)")
|
||||
qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum statement coverage threshold (0-100), fail if below")
|
||||
qaCmd.PersistentFlags().Float64Var(&qaBranchThreshold, "branch-threshold", 0, "Minimum branch coverage threshold (0-100), fail if below")
|
||||
qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)")
|
||||
|
||||
// Test flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaShort, "short", false, "Run tests with -short flag")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaRace, "race", false, "Include race detection in tests")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaBench, "bench", false, "Include benchmarks")
|
||||
|
||||
// Output flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaJSON, "json", false, "Output results as JSON")
|
||||
qaCmd.PersistentFlags().BoolVarP(&qaVerbose, "verbose", "v", false, "Show verbose output")
|
||||
qaCmd.PersistentFlags().BoolVarP(&qaQuiet, "quiet", "q", false, "Only show errors")
|
||||
|
||||
// Control flags
|
||||
qaCmd.PersistentFlags().DurationVar(&qaTimeout, "timeout", 10*time.Minute, "Timeout for all checks")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaFailFast, "fail-fast", false, "Stop on first failure")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaCI, "ci", false, "CI mode: strict checks, coverage required, fail-fast")
|
||||
|
||||
// Preset subcommands for convenience
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "quick",
|
||||
Short: "Quick QA: fmt, vet, lint (no tests)",
|
||||
RunE: func(cmd *cli.Command, args []string) error { qaOnly = "fmt,vet,lint"; return runGoQA(cmd, args) },
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "full",
|
||||
Short: "Full QA: all checks including race, vuln, sec",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
qaOnly = "fmt,vet,lint,test,race,vuln,sec"
|
||||
return runGoQA(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "pre-commit",
|
||||
Short: "Pre-commit checks: fmt --fix, lint --fix, test --short",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
qaFix = true
|
||||
qaShort = true
|
||||
qaOnly = "fmt,lint,test"
|
||||
return runGoQA(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "pr",
|
||||
Short: "PR checks: full QA with coverage threshold",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
qaCoverage = true
|
||||
if qaThreshold == 0 {
|
||||
qaThreshold = 50 // Default PR threshold
|
||||
}
|
||||
qaOnly = "fmt,vet,lint,test"
|
||||
return runGoQA(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
parent.AddCommand(qaCmd)
|
||||
}
|
||||
|
||||
// QAResult holds the result of a QA run for JSON output
|
||||
type QAResult struct {
|
||||
Success bool `json:"success"`
|
||||
Duration string `json:"duration"`
|
||||
Checks []CheckResult `json:"checks"`
|
||||
Coverage *float64 `json:"coverage,omitempty"`
|
||||
BranchCoverage *float64 `json:"branch_coverage,omitempty"`
|
||||
Threshold *float64 `json:"threshold,omitempty"`
|
||||
BranchThreshold *float64 `json:"branch_threshold,omitempty"`
|
||||
}
|
||||
|
||||
// CheckResult holds the result of a single check
|
||||
type CheckResult struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Duration string `json:"duration"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
FixHint string `json:"fix_hint,omitempty"`
|
||||
}
|
||||
|
||||
func runGoQA(cmd *cli.Command, args []string) error {
|
||||
// Apply CI mode defaults
|
||||
if qaCI {
|
||||
qaCoverage = true
|
||||
qaFailFast = true
|
||||
if qaThreshold == 0 {
|
||||
qaThreshold = 50
|
||||
}
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory"))
|
||||
}
|
||||
|
||||
// Detect if this is a Go project
|
||||
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
|
||||
return cli.Err("not a Go project (no go.mod found)")
|
||||
}
|
||||
|
||||
// Determine which checks to run
|
||||
checkNames := determineChecks()
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA"))
|
||||
}
|
||||
|
||||
// Run go mod tidy if requested
|
||||
if qaMod {
|
||||
if !qaQuiet {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("→"), "Running go mod tidy...")
|
||||
}
|
||||
modCmd := exec.Command("go", "mod", "tidy")
|
||||
modCmd.Dir = cwd
|
||||
if err := modCmd.Run(); err != nil {
|
||||
return cli.Wrap(err, "go mod tidy failed")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), qaTimeout)
|
||||
defer cancel()
|
||||
|
||||
startTime := time.Now()
|
||||
checks := buildChecks(checkNames)
|
||||
results := make([]CheckResult, 0, len(checks))
|
||||
passed := 0
|
||||
failed := 0
|
||||
|
||||
for _, check := range checks {
|
||||
checkStart := time.Now()
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name))
|
||||
}
|
||||
|
||||
output, err := runCheckCapture(ctx, cwd, check)
|
||||
checkDuration := time.Since(checkStart)
|
||||
|
||||
result := CheckResult{
|
||||
Name: check.Name,
|
||||
Duration: checkDuration.Round(time.Millisecond).String(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Passed = false
|
||||
result.Error = err.Error()
|
||||
if qaVerbose {
|
||||
result.Output = output
|
||||
}
|
||||
result.FixHint = fixHintFor(check.Name, output)
|
||||
failed++
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error())
|
||||
if qaVerbose && output != "" {
|
||||
cli.Text(output)
|
||||
}
|
||||
if result.FixHint != "" {
|
||||
cli.Hint("fix", result.FixHint)
|
||||
}
|
||||
}
|
||||
|
||||
if qaFailFast {
|
||||
results = append(results, result)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
result.Passed = true
|
||||
if qaVerbose {
|
||||
result.Output = output
|
||||
}
|
||||
passed++
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
// Run coverage if requested
|
||||
var coverageVal *float64
|
||||
var branchVal *float64
|
||||
if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) {
|
||||
cov, branch, err := runCoverage(ctx, cwd)
|
||||
if err == nil {
|
||||
coverageVal = &cov
|
||||
branchVal = &branch
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Statement Coverage:"), cov)
|
||||
cli.Print("%s %.1f%%\n", cli.DimStyle.Render("Branch Coverage:"), branch)
|
||||
}
|
||||
if qaThreshold > 0 && cov < qaThreshold {
|
||||
failed++
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s Statement coverage %.1f%% below threshold %.1f%%\n",
|
||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold)
|
||||
}
|
||||
}
|
||||
if qaBranchThreshold > 0 && branch < qaBranchThreshold {
|
||||
failed++
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s Branch coverage %.1f%% below threshold %.1f%%\n",
|
||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")), branch, qaBranchThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 && !qaJSON && !qaQuiet {
|
||||
cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime).Round(time.Millisecond)
|
||||
|
||||
// JSON output
|
||||
if qaJSON {
|
||||
qaResult := QAResult{
|
||||
Success: failed == 0,
|
||||
Duration: duration.String(),
|
||||
Checks: results,
|
||||
Coverage: coverageVal,
|
||||
BranchCoverage: branchVal,
|
||||
}
|
||||
if qaThreshold > 0 {
|
||||
qaResult.Threshold = &qaThreshold
|
||||
}
|
||||
if qaBranchThreshold > 0 {
|
||||
qaResult.BranchThreshold = &qaBranchThreshold
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(qaResult)
|
||||
}
|
||||
|
||||
// Summary
|
||||
if !qaQuiet {
|
||||
cli.Blank()
|
||||
if failed > 0 {
|
||||
cli.Print("%s %s, %s (%s)\n",
|
||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")),
|
||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"),
|
||||
duration)
|
||||
} else {
|
||||
cli.Print("%s %s (%s)\n",
|
||||
cli.SuccessStyle.Render(cli.Glyph(":check:")),
|
||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
duration)
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return cli.Err("QA checks failed: %d passed, %d failed", passed, failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func determineChecks() []string {
|
||||
// If --only is specified, use those
|
||||
if qaOnly != "" {
|
||||
return strings.Split(qaOnly, ",")
|
||||
}
|
||||
|
||||
// Default checks
|
||||
checks := []string{"fmt", "lint", "test", "fuzz", "docblock"}
|
||||
|
||||
// Add race if requested
|
||||
if qaRace {
|
||||
// Replace test with race (which includes test)
|
||||
for i, c := range checks {
|
||||
if c == "test" {
|
||||
checks[i] = "race"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add bench if requested
|
||||
if qaBench {
|
||||
checks = append(checks, "bench")
|
||||
}
|
||||
|
||||
// Remove skipped checks
|
||||
if qaSkip != "" {
|
||||
skipMap := make(map[string]bool)
|
||||
for _, s := range strings.Split(qaSkip, ",") {
|
||||
skipMap[strings.TrimSpace(s)] = true
|
||||
}
|
||||
filtered := make([]string, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
if !skipMap[c] {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
checks = filtered
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// QACheck represents a single QA check.
|
||||
type QACheck struct {
|
||||
Name string
|
||||
Command string
|
||||
Args []string
|
||||
}
|
||||
|
||||
func buildChecks(names []string) []QACheck {
|
||||
var checks []QACheck
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
check := buildCheck(name)
|
||||
if check.Command != "" {
|
||||
checks = append(checks, check)
|
||||
}
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
func buildCheck(name string) QACheck {
|
||||
switch name {
|
||||
case "fmt", "format":
|
||||
args := []string{"-l", "."}
|
||||
if qaFix {
|
||||
args = []string{"-w", "."}
|
||||
}
|
||||
return QACheck{Name: "format", Command: "gofmt", Args: args}
|
||||
|
||||
case "vet":
|
||||
return QACheck{Name: "vet", Command: "go", Args: []string{"vet", "./..."}}
|
||||
|
||||
case "lint":
|
||||
args := []string{"run"}
|
||||
if qaFix {
|
||||
args = append(args, "--fix")
|
||||
}
|
||||
if qaChanged && !qaAll {
|
||||
args = append(args, "--new-from-rev=HEAD")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "lint", Command: "golangci-lint", Args: args}
|
||||
|
||||
case "test":
|
||||
args := []string{"test"}
|
||||
if qaShort {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if qaVerbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "test", Command: "go", Args: args}
|
||||
|
||||
case "race":
|
||||
args := []string{"test", "-race"}
|
||||
if qaShort {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if qaVerbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "race", Command: "go", Args: args}
|
||||
|
||||
case "bench":
|
||||
args := []string{"test", "-bench=.", "-benchmem", "-run=^$"}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "bench", Command: "go", Args: args}
|
||||
|
||||
case "vuln":
|
||||
return QACheck{Name: "vuln", Command: "govulncheck", Args: []string{"./..."}}
|
||||
|
||||
case "sec":
|
||||
return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}}
|
||||
|
||||
case "fuzz":
|
||||
return QACheck{Name: "fuzz", Command: "_internal_"}
|
||||
|
||||
case "docblock":
|
||||
// Special internal check - handled separately
|
||||
return QACheck{Name: "docblock", Command: "_internal_"}
|
||||
|
||||
default:
|
||||
return QACheck{}
|
||||
}
|
||||
}
|
||||
|
||||
// fixHintFor returns an actionable fix instruction for a given check failure.
|
||||
func fixHintFor(checkName, output string) string {
|
||||
switch checkName {
|
||||
case "format", "fmt":
|
||||
return "Run 'core go qa fmt --fix' to auto-format."
|
||||
case "vet":
|
||||
return "Fix the issues reported by go vet — typically genuine bugs."
|
||||
case "lint":
|
||||
return "Run 'core go qa lint --fix' for auto-fixable issues."
|
||||
case "test":
|
||||
if name := extractFailingTest(output); name != "" {
|
||||
return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name)
|
||||
}
|
||||
return "Run 'go test -run <TestName> -v ./path/' to debug."
|
||||
case "race":
|
||||
return "Data race detected. Add mutex, channel, or atomic to synchronise shared state."
|
||||
case "bench":
|
||||
return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce."
|
||||
case "vuln":
|
||||
return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'."
|
||||
case "sec":
|
||||
return "Review gosec findings. Common fixes: validate inputs, parameterised queries."
|
||||
case "fuzz":
|
||||
return "Add a regression test for the crashing input in testdata/fuzz/<Target>/."
|
||||
case "docblock":
|
||||
return "Add doc comments to exported symbols: '// Name does X.' before each declaration."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`)
|
||||
|
||||
// extractFailingTest parses the first failing test name from go test output.
|
||||
func extractFailingTest(output string) string {
|
||||
if m := failTestRe.FindStringSubmatch(output); len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
|
||||
// Handle internal checks
|
||||
if check.Command == "_internal_" {
|
||||
return runInternalCheck(check)
|
||||
}
|
||||
|
||||
// Check if command exists
|
||||
if _, err := exec.LookPath(check.Command); err != nil {
|
||||
return "", cli.Err("%s: not installed", check.Command)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, check.Command, check.Args...)
|
||||
cmd.Dir = dir
|
||||
|
||||
// For gofmt -l, capture output to check if files need formatting
|
||||
if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" {
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return string(output), err
|
||||
}
|
||||
if len(output) > 0 {
|
||||
// Show files that need formatting
|
||||
if !qaQuiet && !qaJSON {
|
||||
cli.Text(string(output))
|
||||
}
|
||||
return string(output), cli.Err("files need formatting (use --fix)")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// For other commands, stream or capture based on quiet mode
|
||||
if qaQuiet || qaJSON {
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return "", cmd.Run()
|
||||
}
|
||||
|
||||
func runCoverage(ctx context.Context, dir string) (float64, float64, error) {
|
||||
// Create temp file for coverage data
|
||||
covFile, err := os.CreateTemp("", "coverage-*.out")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
covPath := covFile.Name()
|
||||
_ = covFile.Close()
|
||||
defer os.Remove(covPath)
|
||||
|
||||
args := []string{"test", "-cover", "-covermode=atomic", "-coverprofile=" + covPath}
|
||||
if qaShort {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
|
||||
cmd := exec.CommandContext(ctx, "go", args...)
|
||||
cmd.Dir = dir
|
||||
if !qaQuiet && !qaJSON {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// Parse statement coverage
|
||||
coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+covPath)
|
||||
output, err := coverCmd.Output()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// Parse last line for total coverage
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
var statementPct float64
|
||||
if len(lines) > 0 {
|
||||
lastLine := lines[len(lines)-1]
|
||||
fields := strings.Fields(lastLine)
|
||||
if len(fields) >= 3 {
|
||||
// Parse percentage (e.g., "45.6%")
|
||||
pctStr := strings.TrimSuffix(fields[len(fields)-1], "%")
|
||||
_, _ = fmt.Sscanf(pctStr, "%f", &statementPct)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse branch coverage
|
||||
branchPct, err := calculateBlockCoverage(covPath)
|
||||
if err != nil {
|
||||
return statementPct, 0, err
|
||||
}
|
||||
|
||||
return statementPct, branchPct, nil
|
||||
}
|
||||
|
||||
// runInternalCheck runs internal Go-based checks (not external commands).
|
||||
func runInternalCheck(check QACheck) (string, error) {
|
||||
switch check.Name {
|
||||
case "fuzz":
|
||||
// Short burst fuzz in QA (3s per target)
|
||||
duration := 3 * time.Second
|
||||
if qaTimeout > 0 && qaTimeout < 30*time.Second {
|
||||
duration = 2 * time.Second
|
||||
}
|
||||
return "", runGoFuzz(duration, "", "", qaVerbose)
|
||||
|
||||
case "docblock":
|
||||
result, err := qa.CheckDocblockCoverage([]string{"./..."})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Coverage < qaDocblockThreshold {
|
||||
return "", cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, qaDocblockThreshold)
|
||||
}
|
||||
return fmt.Sprintf("docblock coverage: %.1f%% (%d/%d)", result.Coverage, result.Documented, result.Total), nil
|
||||
|
||||
default:
|
||||
return "", cli.Err("unknown internal check: %s", check.Name)
|
||||
}
|
||||
}
|
||||
236
cmd/gocmd/cmd_tools.go
Normal file
236
cmd/gocmd/cmd_tools.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
installVerbose bool
|
||||
installNoCgo bool
|
||||
)
|
||||
|
||||
func addGoInstallCommand(parent *cli.Command) {
|
||||
installCmd := &cli.Command{
|
||||
Use: "install [path]",
|
||||
Short: "Install Go binary",
|
||||
Long: "Install Go binary to $GOPATH/bin",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
// Get install path from args or default to current dir
|
||||
installPath := "./..."
|
||||
if len(args) > 0 {
|
||||
installPath = args[0]
|
||||
}
|
||||
|
||||
// Detect if we're in a module with cmd/ subdirectories or a root main.go
|
||||
if installPath == "./..." {
|
||||
if _, err := os.Stat("core.go"); err == nil {
|
||||
installPath = "."
|
||||
} else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 {
|
||||
installPath = "./cmd/..."
|
||||
} else if _, err := os.Stat("main.go"); err == nil {
|
||||
installPath = "."
|
||||
}
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.Progress("install"))
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), installPath)
|
||||
if installNoCgo {
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("cgo")), "disabled")
|
||||
}
|
||||
|
||||
cmdArgs := []string{"install"}
|
||||
if installVerbose {
|
||||
cmdArgs = append(cmdArgs, "-v")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, installPath)
|
||||
|
||||
execCmd := exec.Command("go", cmdArgs...)
|
||||
if installNoCgo {
|
||||
execCmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||
}
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
|
||||
if err := execCmd.Run(); err != nil {
|
||||
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.fail.install", "binary")))
|
||||
return err
|
||||
}
|
||||
|
||||
// Show where it was installed
|
||||
gopath := os.Getenv("GOPATH")
|
||||
if gopath == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
gopath = filepath.Join(home, "go")
|
||||
}
|
||||
binDir := filepath.Join(gopath, "bin")
|
||||
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), binDir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
installCmd.Flags().BoolVarP(&installVerbose, "verbose", "v", false, "Verbose output")
|
||||
installCmd.Flags().BoolVar(&installNoCgo, "no-cgo", false, "Disable CGO")
|
||||
|
||||
parent.AddCommand(installCmd)
|
||||
}
|
||||
|
||||
func addGoModCommand(parent *cli.Command) {
|
||||
modCmd := &cli.Command{
|
||||
Use: "mod",
|
||||
Short: "Module management",
|
||||
Long: "Go module management commands",
|
||||
}
|
||||
|
||||
// tidy
|
||||
tidyCmd := &cli.Command{
|
||||
Use: "tidy",
|
||||
Short: "Run go mod tidy",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "mod", "tidy")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
// download
|
||||
downloadCmd := &cli.Command{
|
||||
Use: "download",
|
||||
Short: "Download module dependencies",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "mod", "download")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
// verify
|
||||
verifyCmd := &cli.Command{
|
||||
Use: "verify",
|
||||
Short: "Verify module checksums",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "mod", "verify")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
// graph
|
||||
graphCmd := &cli.Command{
|
||||
Use: "graph",
|
||||
Short: "Print module dependency graph",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "mod", "graph")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
modCmd.AddCommand(tidyCmd)
|
||||
modCmd.AddCommand(downloadCmd)
|
||||
modCmd.AddCommand(verifyCmd)
|
||||
modCmd.AddCommand(graphCmd)
|
||||
parent.AddCommand(modCmd)
|
||||
}
|
||||
|
||||
func addGoWorkCommand(parent *cli.Command) {
|
||||
workCmd := &cli.Command{
|
||||
Use: "work",
|
||||
Short: "Workspace management",
|
||||
Long: "Go workspace management commands",
|
||||
}
|
||||
|
||||
// sync
|
||||
syncCmd := &cli.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync workspace modules",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "work", "sync")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
// init
|
||||
initCmd := &cli.Command{
|
||||
Use: "init",
|
||||
Short: "Initialise a new workspace",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "work", "init")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
if err := execCmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Auto-add current module if go.mod exists
|
||||
if _, err := os.Stat("go.mod"); err == nil {
|
||||
execCmd = exec.Command("go", "work", "use", ".")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// use
|
||||
useCmd := &cli.Command{
|
||||
Use: "use [modules...]",
|
||||
Short: "Add modules to workspace",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
// Auto-detect modules
|
||||
modules := findGoModules(".")
|
||||
if len(modules) == 0 {
|
||||
return errors.New("no Go modules found")
|
||||
}
|
||||
for _, mod := range modules {
|
||||
execCmd := exec.Command("go", "work", "use", mod)
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
if err := execCmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
cli.Print("%s %s\n", successStyle.Render(i18n.T("i18n.done.add")), mod)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cmdArgs := append([]string{"work", "use"}, args...)
|
||||
execCmd := exec.Command("go", cmdArgs...)
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
workCmd.AddCommand(syncCmd)
|
||||
workCmd.AddCommand(initCmd)
|
||||
workCmd.AddCommand(useCmd)
|
||||
parent.AddCommand(workCmd)
|
||||
}
|
||||
|
||||
func findGoModules(root string) []string {
|
||||
var modules []string
|
||||
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.Name() == "go.mod" && path != "go.mod" {
|
||||
modules = append(modules, filepath.Dir(path))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return modules
|
||||
}
|
||||
229
cmd/gocmd/coverage_test.go
Normal file
229
cmd/gocmd/coverage_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCalculateBlockCoverage(t *testing.T) {
|
||||
// Create a dummy coverage profile
|
||||
content := `mode: set
|
||||
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 1
|
||||
forge.lthn.ai/core/go/pkg/foo.go:5.6,7.8 2 0
|
||||
forge.lthn.ai/core/go/pkg/bar.go:10.1,12.20 10 5
|
||||
`
|
||||
tmpfile, err := os.CreateTemp("", "test-coverage-*.out")
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove(tmpfile.Name())
|
||||
|
||||
_, err = tmpfile.Write([]byte(content))
|
||||
assert.NoError(t, err)
|
||||
err = tmpfile.Close()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test calculation
|
||||
// 3 blocks total, 2 covered (count > 0)
|
||||
// Expect (2/3) * 100 = 66.666...
|
||||
pct, err := calculateBlockCoverage(tmpfile.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.InDelta(t, 66.67, pct, 0.01)
|
||||
|
||||
// Test empty file (only header)
|
||||
contentEmpty := "mode: atomic\n"
|
||||
tmpfileEmpty, _ := os.CreateTemp("", "test-coverage-empty-*.out")
|
||||
defer os.Remove(tmpfileEmpty.Name())
|
||||
tmpfileEmpty.Write([]byte(contentEmpty))
|
||||
tmpfileEmpty.Close()
|
||||
|
||||
pct, err = calculateBlockCoverage(tmpfileEmpty.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
|
||||
// Test non-existent file
|
||||
pct, err = calculateBlockCoverage("non-existent-file")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
|
||||
// Test malformed file
|
||||
contentMalformed := `mode: set
|
||||
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
|
||||
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 notanumber
|
||||
`
|
||||
tmpfileMalformed, _ := os.CreateTemp("", "test-coverage-malformed-*.out")
|
||||
defer os.Remove(tmpfileMalformed.Name())
|
||||
tmpfileMalformed.Write([]byte(contentMalformed))
|
||||
tmpfileMalformed.Close()
|
||||
|
||||
pct, err = calculateBlockCoverage(tmpfileMalformed.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
|
||||
// Test malformed file - missing fields
|
||||
contentMalformed2 := `mode: set
|
||||
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
|
||||
`
|
||||
tmpfileMalformed2, _ := os.CreateTemp("", "test-coverage-malformed2-*.out")
|
||||
defer os.Remove(tmpfileMalformed2.Name())
|
||||
tmpfileMalformed2.Write([]byte(contentMalformed2))
|
||||
tmpfileMalformed2.Close()
|
||||
|
||||
pct, err = calculateBlockCoverage(tmpfileMalformed2.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
|
||||
// Test completely empty file
|
||||
tmpfileEmpty2, _ := os.CreateTemp("", "test-coverage-empty2-*.out")
|
||||
defer os.Remove(tmpfileEmpty2.Name())
|
||||
tmpfileEmpty2.Close()
|
||||
pct, err = calculateBlockCoverage(tmpfileEmpty2.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
}
|
||||
|
||||
func TestParseOverallCoverage(t *testing.T) {
|
||||
output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements
|
||||
ok forge.lthn.ai/core/go/pkg/bar 0.200s coverage: 100.0% of statements
|
||||
`
|
||||
pct := parseOverallCoverage(output)
|
||||
assert.Equal(t, 75.0, pct)
|
||||
|
||||
outputNoCov := "ok forge.lthn.ai/core/go/pkg/foo 0.100s"
|
||||
pct = parseOverallCoverage(outputNoCov)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
}
|
||||
|
||||
func TestFormatCoverage(t *testing.T) {
|
||||
assert.Contains(t, formatCoverage(85.0), "85.0%")
|
||||
assert.Contains(t, formatCoverage(65.0), "65.0%")
|
||||
assert.Contains(t, formatCoverage(25.0), "25.0%")
|
||||
}
|
||||
|
||||
func TestAddGoCovCommand(t *testing.T) {
|
||||
cmd := &cli.Command{Use: "test"}
|
||||
addGoCovCommand(cmd)
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
sub := cmd.Commands()[0]
|
||||
assert.Equal(t, "cov", sub.Name())
|
||||
}
|
||||
|
||||
func TestAddGoQACommand(t *testing.T) {
|
||||
cmd := &cli.Command{Use: "test"}
|
||||
addGoQACommand(cmd)
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
sub := cmd.Commands()[0]
|
||||
assert.Equal(t, "qa", sub.Name())
|
||||
}
|
||||
|
||||
func TestDetermineChecks(t *testing.T) {
|
||||
// Default checks
|
||||
qaOnly = ""
|
||||
qaSkip = ""
|
||||
qaRace = false
|
||||
qaBench = false
|
||||
checks := determineChecks()
|
||||
assert.Contains(t, checks, "fmt")
|
||||
assert.Contains(t, checks, "test")
|
||||
|
||||
// Only
|
||||
qaOnly = "fmt,lint"
|
||||
checks = determineChecks()
|
||||
assert.Equal(t, []string{"fmt", "lint"}, checks)
|
||||
|
||||
// Skip
|
||||
qaOnly = ""
|
||||
qaSkip = "fmt,lint"
|
||||
checks = determineChecks()
|
||||
assert.NotContains(t, checks, "fmt")
|
||||
assert.NotContains(t, checks, "lint")
|
||||
assert.Contains(t, checks, "test")
|
||||
|
||||
// Race
|
||||
qaSkip = ""
|
||||
qaRace = true
|
||||
checks = determineChecks()
|
||||
assert.Contains(t, checks, "race")
|
||||
assert.NotContains(t, checks, "test")
|
||||
|
||||
// Reset
|
||||
qaRace = false
|
||||
}
|
||||
|
||||
func TestBuildCheck(t *testing.T) {
|
||||
qaFix = false
|
||||
c := buildCheck("fmt")
|
||||
assert.Equal(t, "format", c.Name)
|
||||
assert.Equal(t, []string{"-l", "."}, c.Args)
|
||||
|
||||
qaFix = true
|
||||
c = buildCheck("fmt")
|
||||
assert.Equal(t, []string{"-w", "."}, c.Args)
|
||||
|
||||
c = buildCheck("vet")
|
||||
assert.Equal(t, "vet", c.Name)
|
||||
|
||||
c = buildCheck("lint")
|
||||
assert.Equal(t, "lint", c.Name)
|
||||
|
||||
c = buildCheck("test")
|
||||
assert.Equal(t, "test", c.Name)
|
||||
|
||||
c = buildCheck("race")
|
||||
assert.Equal(t, "race", c.Name)
|
||||
|
||||
c = buildCheck("bench")
|
||||
assert.Equal(t, "bench", c.Name)
|
||||
|
||||
c = buildCheck("vuln")
|
||||
assert.Equal(t, "vuln", c.Name)
|
||||
|
||||
c = buildCheck("sec")
|
||||
assert.Equal(t, "sec", c.Name)
|
||||
|
||||
c = buildCheck("fuzz")
|
||||
assert.Equal(t, "fuzz", c.Name)
|
||||
|
||||
c = buildCheck("docblock")
|
||||
assert.Equal(t, "docblock", c.Name)
|
||||
|
||||
c = buildCheck("unknown")
|
||||
assert.Equal(t, "", c.Name)
|
||||
}
|
||||
|
||||
func TestBuildChecks(t *testing.T) {
|
||||
checks := buildChecks([]string{"fmt", "vet", "unknown"})
|
||||
assert.Equal(t, 2, len(checks))
|
||||
assert.Equal(t, "format", checks[0].Name)
|
||||
assert.Equal(t, "vet", checks[1].Name)
|
||||
}
|
||||
|
||||
func TestFixHintFor(t *testing.T) {
|
||||
assert.Contains(t, fixHintFor("format", ""), "core go qa fmt --fix")
|
||||
assert.Contains(t, fixHintFor("vet", ""), "go vet")
|
||||
assert.Contains(t, fixHintFor("lint", ""), "core go qa lint --fix")
|
||||
assert.Contains(t, fixHintFor("test", "--- FAIL: TestFoo"), "TestFoo")
|
||||
assert.Contains(t, fixHintFor("race", ""), "Data race")
|
||||
assert.Contains(t, fixHintFor("bench", ""), "Benchmark regression")
|
||||
assert.Contains(t, fixHintFor("vuln", ""), "govulncheck")
|
||||
assert.Contains(t, fixHintFor("sec", ""), "gosec")
|
||||
assert.Contains(t, fixHintFor("fuzz", ""), "crashing input")
|
||||
assert.Contains(t, fixHintFor("docblock", ""), "doc comments")
|
||||
assert.Equal(t, "", fixHintFor("unknown", ""))
|
||||
}
|
||||
|
||||
func TestRunGoQA_NoGoMod(t *testing.T) {
|
||||
// runGoQA should fail if go.mod is not present in CWD
|
||||
// We run it in a temp dir without go.mod
|
||||
tmpDir, _ := os.MkdirTemp("", "test-qa-*")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
cwd, _ := os.Getwd()
|
||||
os.Chdir(tmpDir)
|
||||
defer os.Chdir(cwd)
|
||||
|
||||
cmd := &cli.Command{Use: "qa"}
|
||||
err := runGoQA(cmd, []string{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no go.mod found")
|
||||
}
|
||||
457
docs/ecosystem.md
Normal file
457
docs/ecosystem.md
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
# Core Go Ecosystem
|
||||
|
||||
The Core Go ecosystem is a set of 19 standalone Go modules that form the infrastructure backbone for the host-uk platform and the Lethean network. All modules are hosted under the `forge.lthn.ai/core/` organisation. Each module has its own repository, independent versioning, and a `docs/` directory.
|
||||
|
||||
The CLI framework documented in the rest of this site (`forge.lthn.ai/core/cli`) is one node in this graph. The satellite packages listed here are separate repositories that the CLI imports or that stand alone as libraries.
|
||||
|
||||
---
|
||||
|
||||
## Module Index
|
||||
|
||||
| Package | Module Path | Managed By |
|
||||
|---------|-------------|-----------|
|
||||
| [go-inference](#go-inference) | `forge.lthn.ai/core/go-inference` | Virgil |
|
||||
| [go-mlx](#go-mlx) | `forge.lthn.ai/core/go-mlx` | Virgil |
|
||||
| [go-rocm](#go-rocm) | `forge.lthn.ai/core/go-rocm` | Charon |
|
||||
| [go-ml](#go-ml) | `forge.lthn.ai/core/go-ml` | Virgil |
|
||||
| [go-ai](#go-ai) | `forge.lthn.ai/core/go-ai` | Virgil |
|
||||
| [go-agentic](#go-agentic) | `forge.lthn.ai/core/go-agentic` | Charon |
|
||||
| [go-rag](#go-rag) | `forge.lthn.ai/core/go-rag` | Charon |
|
||||
| [go-i18n](#go-i18n) | `forge.lthn.ai/core/go-i18n` | Virgil |
|
||||
| [go-html](#go-html) | `forge.lthn.ai/core/go-html` | Charon |
|
||||
| [go-crypt](#go-crypt) | `forge.lthn.ai/core/go-crypt` | Virgil |
|
||||
| [go-scm](#go-scm) | `forge.lthn.ai/core/go-scm` | Charon |
|
||||
| [go-p2p](#go-p2p) | `forge.lthn.ai/core/go-p2p` | Charon |
|
||||
| [go-devops](#go-devops) | `forge.lthn.ai/core/go-devops` | Virgil |
|
||||
| [go-help](#go-help) | `forge.lthn.ai/core/go-help` | Charon |
|
||||
| [go-ratelimit](#go-ratelimit) | `forge.lthn.ai/core/go-ratelimit` | Charon |
|
||||
| [go-session](#go-session) | `forge.lthn.ai/core/go-session` | Charon |
|
||||
| [go-store](#go-store) | `forge.lthn.ai/core/go-store` | Charon |
|
||||
| [go-ws](#go-ws) | `forge.lthn.ai/core/go-ws` | Charon |
|
||||
| [go-webview](#go-webview) | `forge.lthn.ai/core/go-webview` | Charon |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
The graph below shows import relationships. An arrow `A → B` means A imports B.
|
||||
|
||||
```
|
||||
go-inference (no dependencies — foundation contract)
|
||||
↑
|
||||
├── go-mlx (CGO, Apple Silicon Metal GPU)
|
||||
├── go-rocm (AMD ROCm, llama-server subprocess)
|
||||
└── go-ml (scoring engine, backends, orchestrator)
|
||||
↑
|
||||
└── go-ai (MCP hub, 49 tools)
|
||||
↑
|
||||
└── go-agentic (service lifecycle, allowances)
|
||||
|
||||
go-rag (Qdrant + Ollama, standalone)
|
||||
↑
|
||||
└── go-ai
|
||||
|
||||
go-i18n (grammar engine, standalone; Phase 2a imports go-mlx)
|
||||
|
||||
go-crypt (standalone)
|
||||
↑
|
||||
├── go-p2p (UEPS wire protocol)
|
||||
└── go-scm (AgentCI dispatch)
|
||||
|
||||
go-store (SQLite KV, standalone)
|
||||
↑
|
||||
├── go-ratelimit (sliding window limiter)
|
||||
├── go-session (transcript parser)
|
||||
└── go-agentic
|
||||
|
||||
go-ws (WebSocket hub, standalone)
|
||||
↑
|
||||
└── go-ai
|
||||
|
||||
go-webview (CDP client, standalone)
|
||||
↑
|
||||
└── go-ai
|
||||
|
||||
go-html (DOM compositor, standalone)
|
||||
|
||||
go-help (help catalogue, standalone)
|
||||
|
||||
go-devops (Ansible, build, infrastructure — imports go-scm)
|
||||
```
|
||||
|
||||
The CLI framework (`forge.lthn.ai/core/cli`) has internal equivalents of several of these packages (`pkg/rag`, `pkg/ws`, `pkg/webview`, `pkg/i18n`) that were developed in parallel. The satellite packages are the canonical standalone versions intended for use outside the CLI binary.
|
||||
|
||||
---
|
||||
|
||||
## Package Descriptions
|
||||
|
||||
### go-inference
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-inference`
|
||||
|
||||
Zero-dependency interface package that defines the common contract for all inference backends in the ecosystem:
|
||||
|
||||
- `TextModel` — the top-level model interface (`Generate`, `Stream`, `Close`)
|
||||
- `Backend` — hardware/runtime abstraction (Metal, ROCm, CPU, remote)
|
||||
- `Token` — streaming token type with metadata
|
||||
|
||||
No concrete implementations live here. Any package that needs to call inference without depending on a specific hardware library imports `go-inference` and receives an implementation at runtime.
|
||||
|
||||
---
|
||||
|
||||
### go-mlx
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-mlx`
|
||||
|
||||
Native Metal GPU inference for Apple Silicon using CGO bindings to `mlx-c` (the C API for Apple's MLX framework). Implements the `go-inference` interfaces.
|
||||
|
||||
Build requirements:
|
||||
- macOS 13+ (Ventura) on Apple Silicon
|
||||
- `mlx-c` installed (`brew install mlx`)
|
||||
- CGO enabled: `CGO_CFLAGS` and `CGO_LDFLAGS` must reference the mlx-c headers and library
|
||||
|
||||
Features:
|
||||
- Loads GGUF and MLX-format models
|
||||
- Streaming token generation directly on GPU
|
||||
- Quantised model support (Q4, Q8)
|
||||
- Phase 4 backend abstraction in progress — will allow hot-swapping backends at runtime
|
||||
|
||||
Local path: `/Users/snider/Code/go-mlx`
|
||||
|
||||
---
|
||||
|
||||
### go-rocm
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-rocm`
|
||||
|
||||
AMD ROCm GPU inference for Linux. Rather than using CGO, this package manages a `llama-server` subprocess (from llama.cpp) compiled with ROCm support and communicates over its HTTP API.
|
||||
|
||||
Features:
|
||||
- Subprocess lifecycle management (start, health-check, restart on crash)
|
||||
- OpenAI-compatible HTTP client wrapping llama-server's API
|
||||
- Implements `go-inference` interfaces
|
||||
- Targeted at the homelab RX 7800 XT running Ubuntu 24.04
|
||||
|
||||
Managed by Charon (Linux homelab).
|
||||
|
||||
---
|
||||
|
||||
### go-ml
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-ml`
|
||||
|
||||
Scoring engine, backend registry, and agent orchestration layer. The hub that connects models from `go-mlx`, `go-rocm`, and future backends into a unified interface.
|
||||
|
||||
Features:
|
||||
- Backend registry: register multiple inference backends, select by capability
|
||||
- Scoring pipeline: evaluate model outputs against rubrics
|
||||
- Agent orchestrator: coordinate multi-step inference tasks
|
||||
- ~3.5K LOC
|
||||
|
||||
---
|
||||
|
||||
### go-ai
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-ai`
|
||||
|
||||
MCP (Model Context Protocol) server hub with 49 registered tools. Acts as the primary facade for AI capabilities in the ecosystem.
|
||||
|
||||
Features:
|
||||
- 49 MCP tools covering file operations, RAG, metrics, process management, WebSocket, and CDP/webview
|
||||
- Imports `go-ml`, `go-rag`, `go-mlx`
|
||||
- Can run as stdio MCP server or TCP MCP server
|
||||
- AI usage metrics recorded to JSONL
|
||||
|
||||
Run the MCP server:
|
||||
|
||||
```bash
|
||||
# stdio (for Claude Desktop / Claude Code)
|
||||
core mcp serve
|
||||
|
||||
# TCP
|
||||
MCP_ADDR=:9000 core mcp serve
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### go-agentic
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-agentic`
|
||||
|
||||
Service lifecycle and allowance management for autonomous agents. Handles:
|
||||
|
||||
- Agent session tracking and state persistence
|
||||
- Allowance system: budget constraints on tool calls, token usage, and wall-clock time
|
||||
- Integration with `go-store` for persistence
|
||||
- REST client for the PHP `core-agentic` backend
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
### go-rag
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-rag`
|
||||
|
||||
Retrieval-Augmented Generation pipeline using Qdrant for vector storage and Ollama for embeddings.
|
||||
|
||||
Features:
|
||||
- `ChunkMarkdown`: semantic splitting by H2 headers and paragraphs with overlap
|
||||
- `Ingest`: crawl a directory of Markdown files, embed, and store in Qdrant
|
||||
- `Query`: semantic search returning ranked `QueryResult` slices
|
||||
- `FormatResultsContext`: formats results as XML tags for LLM prompt injection
|
||||
- Clients: `QdrantClient` and `OllamaClient` wrapping their respective Go SDKs
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
### go-i18n
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-i18n`
|
||||
|
||||
Grammar engine for natural-language generation. Goes beyond key-value lookup tables to handle pluralisation, verb conjugation, past tense, gerunds, and semantic sentence construction ("Subject verbed object").
|
||||
|
||||
Features:
|
||||
- `T(key, args...)` — main translation function
|
||||
- `S(noun, value)` — semantic subject with grammatical context
|
||||
- Language rules defined in JSON; algorithmic fallbacks for irregular verbs
|
||||
- **GrammarImprint**: a linguistic hash (reversal of the grammar engine) used as a semantic fingerprint — part of the Lethean identity verification stack
|
||||
- Phase 2a (imports `go-mlx` for language model-assisted reversal) currently blocked on `go-mlx` Phase 4
|
||||
|
||||
Local path: `/Users/snider/Code/go-i18n`
|
||||
|
||||
---
|
||||
|
||||
### go-html
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-html`
|
||||
|
||||
HLCRF DOM compositor — a programmatic HTML/DOM construction library targeting both server-side rendering and WASM (browser).
|
||||
|
||||
HLCRF stands for Header, Left, Content, Right, Footer — the region layout model used throughout the CLI's terminal UI and web rendering layer.
|
||||
|
||||
Features:
|
||||
- Composable region-based layout (mirrors the terminal `Composite` in `pkg/cli`)
|
||||
- WASM build target: runs in the browser without JavaScript
|
||||
- Used by the LEM Chat UI and web SDK generation
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
### go-crypt
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-crypt`
|
||||
|
||||
Cryptographic primitives, authentication, and trust policy enforcement.
|
||||
|
||||
Features:
|
||||
- Password hashing (Argon2id with tuned parameters)
|
||||
- Symmetric encryption (ChaCha20-Poly1305, AES-GCM)
|
||||
- Key derivation (HKDF, Scrypt)
|
||||
- OpenPGP challenge-response authentication
|
||||
- Trust policies: define and evaluate access rules
|
||||
- Foundation for the UEPS (User-controlled Encryption Policy System) wire protocol in `go-p2p`
|
||||
|
||||
---
|
||||
|
||||
### go-scm
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-scm`
|
||||
|
||||
Source control management and CI integration, including the AgentCI dispatch system.
|
||||
|
||||
Features:
|
||||
- Forgejo and Gitea API clients (typed wrappers)
|
||||
- GitHub integration via the `gh` CLI
|
||||
- `AgentCI`: dispatches AI work items to agent runners over SSH using Charm stack libraries (`soft-serve`, `keygen`, `melt`, `wishlist`)
|
||||
- PR lifecycle management: create, review, merge, label
|
||||
- JSONL job journal for audit trails
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
### go-p2p
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-p2p`
|
||||
|
||||
Peer-to-peer mesh networking implementing the UEPS (User-controlled Encryption Policy System) wire protocol.
|
||||
|
||||
Features:
|
||||
- UEPS: consent-gated TLV frames with Ed25519 consent tokens and an Intent-Broker
|
||||
- Peer discovery and mesh routing
|
||||
- Encrypted relay transport
|
||||
- Integration with `go-crypt` for all cryptographic operations
|
||||
|
||||
This is a core component of the Lethean Web3 network layer.
|
||||
|
||||
Managed by Charon (Linux homelab).
|
||||
|
||||
---
|
||||
|
||||
### go-devops
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-devops`
|
||||
|
||||
Infrastructure automation, build tooling, and release pipeline utilities, intended as a standalone library form of what the Core CLI provides as commands.
|
||||
|
||||
Features:
|
||||
- Ansible-lite engine (native Go SSH playbook execution)
|
||||
- LinuxKit image building and VM lifecycle
|
||||
- Multi-target binary build and release
|
||||
- Integration with `go-scm` for repository operations
|
||||
|
||||
---
|
||||
|
||||
### go-help
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-help`
|
||||
|
||||
Embedded documentation catalogue with full-text search and an optional HTTP server for serving help content.
|
||||
|
||||
Features:
|
||||
- YAML-frontmatter Markdown topic parsing
|
||||
- In-memory reverse index with title/heading/body scoring
|
||||
- Snippet extraction with keyword highlighting
|
||||
- `HTTP server` mode: serve the catalogue as a documentation site
|
||||
- Used by the `core pkg search` command and the `pkg/help` package inside the CLI
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
### go-ratelimit
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-ratelimit`
|
||||
|
||||
Sliding-window rate limiter with a SQLite persistence backend.
|
||||
|
||||
Features:
|
||||
- Token bucket and sliding-window algorithms
|
||||
- SQLite backend via `go-store` for durable rate state across restarts
|
||||
- HTTP middleware helper
|
||||
- Used by `go-ai` and `go-agentic` to enforce per-agent API quotas
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
### go-session
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-session`
|
||||
|
||||
Claude Code JSONL transcript parser and visualisation toolkit (standalone version of `pkg/session` inside the CLI).
|
||||
|
||||
Features:
|
||||
- `ParseTranscript(path)`: reads `.jsonl` session files and reconstructs tool use timelines
|
||||
- `ListSessions(dir)`: scans a Claude projects directory for session files
|
||||
- `Search(dir, query)`: full-text search across sessions
|
||||
- `RenderHTML(sess, path)`: single-file HTML visualisation
|
||||
- `RenderMP4(sess, path)`: terminal video replay via VHS
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
### go-store
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-store`
|
||||
|
||||
SQLite-backed key-value store with reactive change notification.
|
||||
|
||||
Features:
|
||||
- `Get`, `Set`, `Delete`, `List` over typed keys
|
||||
- `Watch(key, handler)`: register a callback that fires on change
|
||||
- `OnChange(handler)`: subscribe to all changes
|
||||
- Used by `go-ratelimit`, `go-session`, and `go-agentic` for lightweight persistence
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
### go-ws
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-ws`
|
||||
|
||||
WebSocket hub with channel-based subscriptions and an optional Redis pub/sub bridge for multi-instance deployments.
|
||||
|
||||
Features:
|
||||
- Hub pattern: central registry of connected clients
|
||||
- Channel routing: `SendToChannel(topic, msg)` delivers only to subscribers
|
||||
- Redis bridge: publish messages from one instance, receive on all
|
||||
- HTTP handler: `hub.Handler()` for embedding in any Go HTTP server
|
||||
- `SendProcessOutput(id, line)`: convenience method for streaming process logs
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
### go-webview
|
||||
|
||||
**Module:** `forge.lthn.ai/core/go-webview`
|
||||
|
||||
Chrome DevTools Protocol (CDP) client for browser automation, testing, and AI-driven web interaction (standalone version of `pkg/webview` inside the CLI).
|
||||
|
||||
Features:
|
||||
- Navigation, click, type, screenshot
|
||||
- `Evaluate(script)`: arbitrary JavaScript execution with result capture
|
||||
- Console capture and filtering
|
||||
- Angular-aware helpers: `WaitForAngular()`, `GetNgModel(selector)`
|
||||
- `ActionSequence`: chain interactions into a single call
|
||||
- Used by `go-ai` to expose browser tools to MCP agents
|
||||
|
||||
Managed by Charon.
|
||||
|
||||
---
|
||||
|
||||
## Forge Repository Paths
|
||||
|
||||
All repositories are hosted at `forge.lthn.ai` (Forgejo). SSH access uses port 2223:
|
||||
|
||||
```
|
||||
ssh://git@forge.lthn.ai:2223/core/go-inference.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-mlx.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-rocm.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-ml.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-ai.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-agentic.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-rag.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-i18n.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-html.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-crypt.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-scm.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-p2p.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-devops.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-help.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-ratelimit.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-session.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-store.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-ws.git
|
||||
ssh://git@forge.lthn.ai:2223/core/go-webview.git
|
||||
```
|
||||
|
||||
HTTPS authentication is not available on Forge. Always use SSH remotes.
|
||||
|
||||
---
|
||||
|
||||
## Go Workspace Setup
|
||||
|
||||
The satellite packages can be used together in a Go workspace. After cloning the repositories you need:
|
||||
|
||||
```bash
|
||||
go work init
|
||||
go work use ./go-inference ./go-mlx ./go-rag ./go-ai # add as needed
|
||||
go work sync
|
||||
```
|
||||
|
||||
The CLI repository already uses a Go workspace that includes `cmd/core-gui`, `cmd/bugseti`, and `cmd/examples/*`.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [index.md](index.md) — Main documentation hub
|
||||
- [getting-started.md](getting-started.md) — CLI installation
|
||||
- [configuration.md](configuration.md) — `repos.yaml` registry format
|
||||
241
docs/index.md
241
docs/index.md
|
|
@ -1,98 +1,207 @@
|
|||
# Core CLI
|
||||
# Core Go Framework — Documentation
|
||||
|
||||
Core is a unified CLI for the host-uk ecosystem - build, release, and deploy Go, Wails, PHP, and container workloads.
|
||||
Core is a Go framework and unified CLI for the host-uk ecosystem. It provides two complementary things: a **dependency injection container** for building Go services and Wails v3 desktop applications, and a **command-line tool** for managing the full development lifecycle across Go, PHP, and container workloads.
|
||||
|
||||
## Installation
|
||||
The `core` binary is the single entry point for all development tasks: testing, building, releasing, multi-repo management, MCP servers, and AI-assisted workflows.
|
||||
|
||||
```bash
|
||||
# Via Go (recommended)
|
||||
go install forge.lthn.ai/core/cli/cmd/core@latest
|
||||
---
|
||||
|
||||
# Or download binary from releases
|
||||
curl -Lo core https://forge.lthn.ai/core/cli/releases/latest/download/core-$(go env GOOS)-$(go env GOARCH)
|
||||
chmod +x core && sudo mv core /usr/local/bin/
|
||||
## Getting Started
|
||||
|
||||
# Verify
|
||||
core doctor
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Getting Started](getting-started.md) | Install the CLI, run your first build, and set up a multi-repo workspace |
|
||||
| [User Guide](user-guide.md) | Key concepts and daily workflow patterns |
|
||||
| [Workflows](workflows.md) | End-to-end task sequences for common scenarios |
|
||||
| [FAQ](faq.md) | Answers to common questions |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Package Standards](pkg/PACKAGE_STANDARDS.md) | Canonical patterns for creating packages: Service struct, factory, IPC, thread safety |
|
||||
| [pkg/i18n — Grammar](pkg/i18n/GRAMMAR.md) | Grammar engine internals and language rule format |
|
||||
| [pkg/i18n — Extending](pkg/i18n/EXTENDING.md) | How to add new locales and translation files |
|
||||
|
||||
### Framework Architecture Summary
|
||||
|
||||
The Core framework (`pkg/framework`) is a dependency injection container built around three ideas:
|
||||
|
||||
**Service registry.** Services are registered via factory functions and retrieved with type-safe generics:
|
||||
|
||||
```go
|
||||
core, _ := framework.New(
|
||||
framework.WithService(mypackage.NewService(opts)),
|
||||
)
|
||||
svc, _ := framework.ServiceFor[*mypackage.Service](core, "mypackage")
|
||||
```
|
||||
|
||||
See [Getting Started](getting-started.md) for all installation options including building from source.
|
||||
**Lifecycle.** Services implementing `Startable` or `Stoppable` are called automatically during boot and shutdown.
|
||||
|
||||
**ACTION bus.** Services communicate by broadcasting typed messages via `core.ACTION(msg)` and registering handlers via `core.RegisterAction()`. This decouples packages without requiring direct imports between them.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
See [cmd/](cmd/) for full command documentation.
|
||||
The `core` CLI is documented command-by-command in `docs/cmd/`:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| [go](cmd/go/) | Go development (test, fmt, lint, cov) |
|
||||
| [php](cmd/php/) | Laravel/PHP development |
|
||||
| [build](cmd/build/) | Build Go, Wails, Docker, LinuxKit projects |
|
||||
| [ci](cmd/ci/) | Publish releases (dry-run by default) |
|
||||
| [sdk](cmd/sdk/) | SDK generation and validation |
|
||||
| [dev](cmd/dev/) | Multi-repo workflow + dev environment |
|
||||
| [pkg](cmd/pkg/) | Package search and install |
|
||||
| [vm](cmd/vm/) | LinuxKit VM management |
|
||||
| [docs](cmd/docs/) | Documentation management |
|
||||
| [setup](cmd/setup/) | Clone repos from registry |
|
||||
| [doctor](cmd/doctor/) | Check development environment |
|
||||
| [cmd/](cmd/) | Full command index |
|
||||
| [cmd/go/](cmd/go/) | Go development: test, fmt, lint, coverage, mod, work |
|
||||
| [cmd/php/](cmd/php/) | Laravel/PHP development: dev server, test, deploy |
|
||||
| [cmd/build/](cmd/build/) | Build Go, Wails, Docker, LinuxKit projects |
|
||||
| [cmd/ci/](cmd/ci/) | Publish releases to GitHub, Docker, npm, Homebrew |
|
||||
| [cmd/sdk/](cmd/sdk/) | SDK generation and OpenAPI validation |
|
||||
| [cmd/dev/](cmd/dev/) | Multi-repo workflow and sandboxed dev environment |
|
||||
| [cmd/ai/](cmd/ai/) | AI task management and Claude integration |
|
||||
| [cmd/pkg/](cmd/pkg/) | Package search and install |
|
||||
| [cmd/vm/](cmd/vm/) | LinuxKit VM management |
|
||||
| [cmd/docs/](cmd/docs/) | Documentation sync and management |
|
||||
| [cmd/setup/](cmd/setup/) | Clone repositories from a registry |
|
||||
| [cmd/doctor/](cmd/doctor/) | Verify development environment |
|
||||
| [cmd/test/](cmd/test/) | Run Go tests with coverage reporting |
|
||||
|
||||
## Quick Start
|
||||
---
|
||||
|
||||
```bash
|
||||
# Go development
|
||||
core go test # Run tests
|
||||
core go test --coverage # With coverage
|
||||
core go fmt # Format code
|
||||
core go lint # Lint code
|
||||
## Packages
|
||||
|
||||
# Build
|
||||
core build # Auto-detect and build
|
||||
core build --targets linux/amd64,darwin/arm64
|
||||
The Core repository contains the following internal packages. Full API analysis for each is available in the batch analysis documents listed under [Reference](#reference).
|
||||
|
||||
# Release (dry-run by default)
|
||||
core ci # Preview release
|
||||
core ci --we-are-go-for-launch # Actually publish
|
||||
### Foundation
|
||||
|
||||
# Multi-repo workflow
|
||||
core dev work # Status + commit + push
|
||||
core dev work --status # Just show status
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `pkg/framework` | Dependency injection container; re-exports `pkg/framework/core` |
|
||||
| `pkg/log` | Structured logger with `Err` error type, operation chains, and log rotation |
|
||||
| `pkg/config` | 12-factor config management layered over Viper; accepts `io.Medium` |
|
||||
| `pkg/io` | Filesystem abstraction (`Medium` interface); `NewSandboxed`, `MockMedium` |
|
||||
| `pkg/crypt` | Opinionated crypto: Argon2id passwords, ChaCha20 encryption, HMAC |
|
||||
| `pkg/cache` | File-based JSON cache with TTL expiry |
|
||||
| `pkg/i18n` | Grammar engine with pluralisation, verb conjugation, semantic sentences |
|
||||
|
||||
# PHP development
|
||||
core php dev # Start dev environment
|
||||
core php test # Run tests
|
||||
```
|
||||
### CLI and Interaction
|
||||
|
||||
## Configuration
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `pkg/cli` | CLI runtime: Cobra wrapping, ANSI styling, prompts, daemon lifecycle |
|
||||
| `pkg/help` | Embedded documentation catalogue with in-memory full-text search |
|
||||
| `pkg/session` | Claude Code JSONL transcript parser; HTML and MP4 export |
|
||||
| `pkg/workspace` | Isolated, PGP-keyed workspace environments with IPC control |
|
||||
|
||||
Core uses `.core/` directory for project configuration:
|
||||
### Build and Release
|
||||
|
||||
```
|
||||
.core/
|
||||
├── release.yaml # Release targets and settings
|
||||
├── build.yaml # Build configuration (optional)
|
||||
└── linuxkit/ # LinuxKit templates
|
||||
```
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `pkg/build` | Project type detection, cross-compilation, archiving, checksums |
|
||||
| `pkg/release` | Semantic versioning, conventional-commit changelogs, multi-target publishing |
|
||||
| `pkg/container` | LinuxKit VM lifecycle via QEMU/Hyperkit; template management |
|
||||
| `pkg/process` | `os/exec` wrapper with ring-buffer output, DAG task runner, ACTION streaming |
|
||||
| `pkg/jobrunner` | Poll-dispatch automation engine with JSONL audit journal |
|
||||
|
||||
And `repos.yaml` in workspace root for multi-repo management.
|
||||
### Source Control and Hosting
|
||||
|
||||
## Guides
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `pkg/git` | Multi-repo status, push, pull; concurrent status checks |
|
||||
| `pkg/repos` | `repos.yaml` registry loader; topological dependency ordering |
|
||||
| `pkg/gitea` | Gitea API client with PR metadata extraction |
|
||||
| `pkg/forge` | Forgejo API client with PR metadata extraction |
|
||||
| `pkg/plugin` | Git-based CLI extension system |
|
||||
|
||||
- [Getting Started](getting-started.md) - Installation and first steps
|
||||
- [Workflows](workflows.md) - Common task sequences
|
||||
- [Troubleshooting](troubleshooting.md) - When things go wrong
|
||||
- [Migration](migration.md) - Moving from legacy tools
|
||||
### AI and Agentic
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `pkg/mcp` | MCP server exposing file, process, RAG, and CDP tools to AI agents |
|
||||
| `pkg/rag` | RAG pipeline: Markdown chunking, Ollama embeddings, Qdrant vector search |
|
||||
| `pkg/ai` | Facade over RAG and metrics; `QueryRAGForTask` for prompt enrichment |
|
||||
| `pkg/agentic` | REST client for core-agentic; `AutoCommit`, `CreatePR`, `BuildTaskContext` |
|
||||
| `pkg/agentci` | Configuration bridge for AgentCI dispatch targets |
|
||||
| `pkg/collect` | Data collection pipeline from GitHub, forums, market APIs |
|
||||
|
||||
### Infrastructure and Networking
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `pkg/devops` | LinuxKit dev environment lifecycle; SSH bridging; project auto-detection |
|
||||
| `pkg/ansible` | Native Go Ansible-lite engine; SSH playbook execution without the CLI |
|
||||
| `pkg/webview` | Chrome DevTools Protocol client; Angular-aware automation |
|
||||
| `pkg/ws` | WebSocket hub with channel-based subscriptions |
|
||||
| `pkg/unifi` | UniFi controller client for network management |
|
||||
| `pkg/auth` | OpenPGP challenge-response authentication; air-gapped flow |
|
||||
|
||||
---
|
||||
|
||||
## Workflows
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Workflows](workflows.md) | Go build and release, PHP deploy, multi-repo daily workflow, hotfix |
|
||||
| [Migration](migration.md) | Migrating from `push-all.sh`, raw `go` commands, `goreleaser`, or manual git |
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
- [Configuration](configuration.md) - All config options
|
||||
- [Glossary](glossary.md) - Term definitions
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Configuration](configuration.md) | `.core/` directory, `release.yaml`, `build.yaml`, `php.yaml`, `repos.yaml`, environment variables |
|
||||
| [Glossary](glossary.md) | Term definitions: target, workspace, registry, publisher, dry-run |
|
||||
| [Troubleshooting](troubleshooting.md) | Installation failures, build errors, release issues, multi-repo problems, PHP issues |
|
||||
| [Claude Code Skill](skill/) | Install the `core` skill to teach Claude Code how to use this CLI |
|
||||
|
||||
## Claude Code Skill
|
||||
### Historical Package Analysis
|
||||
|
||||
Install the skill to teach Claude Code how to use the Core CLI:
|
||||
The following documents were generated by an automated analysis pipeline (Gemini, February 2026) to extract architecture, public API, and test coverage notes from each package. They remain valid as architectural reference.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/host-uk/core/main/.claude/skills/core/install.sh | bash
|
||||
```
|
||||
| Document | Packages Covered |
|
||||
|----------|-----------------|
|
||||
| [pkg-batch1-analysis.md](pkg-batch1-analysis.md) | `pkg/log`, `pkg/config`, `pkg/io`, `pkg/crypt`, `pkg/auth` |
|
||||
| [pkg-batch2-analysis.md](pkg-batch2-analysis.md) | `pkg/cli`, `pkg/help`, `pkg/session`, `pkg/workspace` |
|
||||
| [pkg-batch3-analysis.md](pkg-batch3-analysis.md) | `pkg/build`, `pkg/container`, `pkg/process`, `pkg/jobrunner` |
|
||||
| [pkg-batch4-analysis.md](pkg-batch4-analysis.md) | `pkg/git`, `pkg/repos`, `pkg/gitea`, `pkg/forge`, `pkg/release` |
|
||||
| [pkg-batch5-analysis.md](pkg-batch5-analysis.md) | `pkg/agentci`, `pkg/agentic`, `pkg/ai`, `pkg/rag` |
|
||||
| [pkg-batch6-analysis.md](pkg-batch6-analysis.md) | `pkg/ansible`, `pkg/devops`, `pkg/framework`, `pkg/mcp`, `pkg/plugin`, `pkg/unifi`, `pkg/webview`, `pkg/ws`, `pkg/collect`, `pkg/i18n`, `pkg/cache` |
|
||||
|
||||
See [skill/](skill/) for details.
|
||||
### Design Plans
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [plans/2026-02-05-core-ide-job-runner-design.md](plans/2026-02-05-core-ide-job-runner-design.md) | Autonomous job runner design for core-ide: poller, dispatcher, MCP handler registry, JSONL training data |
|
||||
| [plans/2026-02-05-core-ide-job-runner-plan.md](plans/2026-02-05-core-ide-job-runner-plan.md) | Implementation plan for the job runner |
|
||||
| [plans/2026-02-05-mcp-integration.md](plans/2026-02-05-mcp-integration.md) | MCP integration design notes |
|
||||
| [plans/2026-02-17-lem-chat-design.md](plans/2026-02-17-lem-chat-design.md) | LEM Chat Web Components design: streaming SSE, zero-dependency vanilla UI |
|
||||
|
||||
---
|
||||
|
||||
## Satellite Packages
|
||||
|
||||
The Core ecosystem extends across 19 standalone Go modules, all hosted under `forge.lthn.ai/core/`. Each has its own repository and `docs/` directory.
|
||||
|
||||
See [ecosystem.md](ecosystem.md) for the full map, module paths, and dependency graph.
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| [go-inference](ecosystem.md#go-inference) | Shared `TextModel`/`Backend`/`Token` interfaces — the common contract |
|
||||
| [go-mlx](ecosystem.md#go-mlx) | Native Metal GPU inference via CGO/mlx-c (Apple Silicon) |
|
||||
| [go-rocm](ecosystem.md#go-rocm) | AMD ROCm GPU inference via llama-server subprocess |
|
||||
| [go-ml](ecosystem.md#go-ml) | Scoring engine, backends, agent orchestrator |
|
||||
| [go-ai](ecosystem.md#go-ai) | MCP hub with 49 registered tools |
|
||||
| [go-agentic](ecosystem.md#go-agentic) | Service lifecycle and allowance management for agents |
|
||||
| [go-rag](ecosystem.md#go-rag) | Qdrant vector search and Ollama embeddings |
|
||||
| [go-i18n](ecosystem.md#go-i18n) | Grammar engine, reversal, GrammarImprint |
|
||||
| [go-html](ecosystem.md#go-html) | HLCRF DOM compositor and WASM target |
|
||||
| [go-crypt](ecosystem.md#go-crypt) | Cryptographic primitives, auth, trust policies |
|
||||
| [go-scm](ecosystem.md#go-scm) | SCM/CI integration and AgentCI dispatch |
|
||||
| [go-p2p](ecosystem.md#go-p2p) | P2P mesh networking and UEPS wire protocol |
|
||||
| [go-devops](ecosystem.md#go-devops) | Ansible automation, build tooling, infrastructure, release |
|
||||
| [go-help](ecosystem.md#go-help) | YAML help catalogue with full-text search and HTTP server |
|
||||
| [go-ratelimit](ecosystem.md#go-ratelimit) | Sliding-window rate limiter with SQLite backend |
|
||||
| [go-session](ecosystem.md#go-session) | Claude Code JSONL transcript parser |
|
||||
| [go-store](ecosystem.md#go-store) | SQLite key-value store with `Watch`/`OnChange` |
|
||||
| [go-ws](ecosystem.md#go-ws) | WebSocket hub with Redis bridge |
|
||||
| [go-webview](ecosystem.md#go-webview) | Chrome DevTools Protocol automation client |
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ The `cli` package is a comprehensive application runtime and UI framework design
|
|||
#### Command Building
|
||||
- `func NewCommand(use, short, long string, run func(*Command, []string) error) *Command`: Factory for standard commands.
|
||||
- `func NewGroup(use, short, long string) *Command`: Factory for parent commands (no run logic).
|
||||
- `func RegisterCommands(fn CommandRegistration)`: Registers a callback to add commands to the root at runtime.
|
||||
- `func WithCommands(name string, register func(root *Command)) framework.Option`: Registers a command group as a framework service.
|
||||
|
||||
#### Output & Styling
|
||||
- `type AnsiStyle`: Fluent builder for text styling (Bold, Dim, Foreground, Background).
|
||||
|
|
@ -67,7 +67,7 @@ The `cli` package is a comprehensive application runtime and UI framework design
|
|||
|
||||
### 5. Test Coverage Notes
|
||||
- **Interactive Prompts**: Tests must mock `stdin` to verify `Confirm`, `Prompt`, and `Select` behavior without hanging.
|
||||
- **Command Registration**: Verify `RegisterCommands` works both before and after `Init` is called.
|
||||
- **Command Registration**: Verify `WithCommands` services receive the root command during `OnStartup`.
|
||||
- **Daemon Lifecycle**: Tests needed for `PIDFile` locking and `HealthServer` endpoints (/health, /ready).
|
||||
- **Layout Rendering**: Snapshot testing is recommended for `Layout` and `Table` rendering to ensure ANSI codes and alignment are correct.
|
||||
|
||||
|
|
|
|||
82
docs/plans/2026-02-17-lem-chat-design.md
Normal file
82
docs/plans/2026-02-17-lem-chat-design.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# LEM Chat — Web Components Design
|
||||
|
||||
**Date**: 2026-02-17
|
||||
**Status**: Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Standalone chat UI built with vanilla Web Components (Custom Elements + Shadow DOM). Connects to the MLX inference server's OpenAI-compatible SSE streaming endpoint. Zero framework dependencies. Single JS file output, embeddable anywhere.
|
||||
|
||||
## Components
|
||||
|
||||
| Element | Purpose |
|
||||
|---------|---------|
|
||||
| `<lem-chat>` | Container. Conversation state, SSE connection, config via attributes |
|
||||
| `<lem-messages>` | Scrollable message list with auto-scroll anchoring |
|
||||
| `<lem-message>` | Single message bubble. Streams tokens for assistant messages |
|
||||
| `<lem-input>` | Text input, Enter to send, Shift+Enter for newline |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User types in <lem-input>
|
||||
→ dispatches 'lem-send' CustomEvent
|
||||
→ <lem-chat> catches it
|
||||
→ adds user message to <lem-messages>
|
||||
→ POST /v1/chat/completions {stream: true, messages: [...history]}
|
||||
→ reads SSE chunks via fetch + ReadableStream
|
||||
→ appends tokens to streaming <lem-message>
|
||||
→ on [DONE], finalises message
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```html
|
||||
<lem-chat endpoint="http://localhost:8090" model="qwen3-8b"></lem-chat>
|
||||
```
|
||||
|
||||
Attributes: `endpoint`, `model`, `system-prompt`, `max-tokens`, `temperature`
|
||||
|
||||
## Theming
|
||||
|
||||
Shadow DOM with CSS custom properties:
|
||||
|
||||
```css
|
||||
--lem-bg: #1a1a1e;
|
||||
--lem-msg-user: #2a2a3e;
|
||||
--lem-msg-assistant: #1e1e2a;
|
||||
--lem-accent: #5865f2;
|
||||
--lem-text: #e0e0e0;
|
||||
--lem-font: system-ui;
|
||||
```
|
||||
|
||||
## Markdown
|
||||
|
||||
Minimal inline parsing: fenced code blocks, inline code, bold, italic. No library.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
lem-chat/
|
||||
├── index.html # Demo page
|
||||
├── src/
|
||||
│ ├── lem-chat.ts # Main container + SSE client
|
||||
│ ├── lem-messages.ts # Message list with scroll anchoring
|
||||
│ ├── lem-message.ts # Single message with streaming
|
||||
│ ├── lem-input.ts # Text input
|
||||
│ ├── markdown.ts # Minimal markdown → HTML
|
||||
│ └── styles.ts # CSS template literals
|
||||
├── package.json # typescript + esbuild
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Build: `esbuild src/lem-chat.ts --bundle --outfile=dist/lem-chat.js`
|
||||
|
||||
## Not in v1
|
||||
|
||||
- Model selection UI
|
||||
- Conversation persistence
|
||||
- File/image upload
|
||||
- Syntax highlighting
|
||||
- Typing indicators
|
||||
- User avatars
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
# LEM Conversational Training Pipeline — Design
|
||||
|
||||
**Date:** 2026-02-17
|
||||
**Status:** Draft
|
||||
|
||||
## Goal
|
||||
|
||||
Replace Python training scripts with a native Go pipeline in `core` commands. No Python anywhere. The process is conversational — not batch data dumps.
|
||||
|
||||
## Architecture
|
||||
|
||||
Six `core ml` subcommands forming a pipeline:
|
||||
|
||||
```
|
||||
seeds + axioms ──> sandwich ──> score ──> train ──> bench
|
||||
↑ │
|
||||
chat (interactive) │
|
||||
↑ │
|
||||
└──────── iterate ─────────────┘
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Purpose | Status |
|
||||
|---------|---------|--------|
|
||||
| `core ml serve` | Serve model via OpenAI-compatible API + lem-chat UI | **Exists** |
|
||||
| `core ml chat` | Interactive conversation, captures exchanges to training JSONL | **New** |
|
||||
| `core ml sandwich` | Wrap seeds in axiom prefix/postfix, generate responses via inference | **New** |
|
||||
| `core ml score` | Score responses against axiom alignment | **Exists** (needs Go port) |
|
||||
| `core ml train` | Native Go LoRA fine-tuning via MLX C bindings | **New** (hard) |
|
||||
| `core ml bench` | Benchmark trained model against baseline | **Exists** (needs Go port) |
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Seeds** (`seeds/*.json`) — 40+ seed prompts across domains
|
||||
2. **Axioms** (`axioms.json`) — LEK-1 kernel (5 axioms, 9KB)
|
||||
3. **Sandwich** — `[axioms prefix] + [seed prompt] + [LEK postfix]` → model generates response
|
||||
4. **Training JSONL** — `{"messages": [{"role":"user",...},{"role":"assistant",...}]}` chat format
|
||||
5. **LoRA adapters** — safetensors in adapter directory
|
||||
6. **Benchmarks** — scores stored in InfluxDB, exported via DuckDB/Parquet
|
||||
|
||||
### Storage
|
||||
|
||||
- **InfluxDB** — time-series training metrics, benchmark scores, generation logs
|
||||
- **DuckDB** — analytical queries, Parquet export for HuggingFace
|
||||
- **Filesystem** — model weights, adapters, training JSONL, seeds
|
||||
|
||||
## Native Go LoRA Training
|
||||
|
||||
The critical new capability. MLX-C supports autograd (`mlx_vjp`, `mlx_value_and_grad`).
|
||||
|
||||
### What we need in Go MLX bindings:
|
||||
|
||||
1. **LoRA adapter layers** — low-rank A*B decomposition wrapping existing Linear layers
|
||||
2. **Loss function** — cross-entropy on assistant tokens only (mask-prompt behaviour)
|
||||
3. **Optimizer** — AdamW with weight decay
|
||||
4. **Training loop** — forward pass → loss → backward pass → update LoRA weights
|
||||
5. **Checkpoint** — save/load adapter safetensors
|
||||
|
||||
### LoRA Layer Design
|
||||
|
||||
```go
|
||||
type LoRALinear struct {
|
||||
Base *Linear // Frozen base weights
|
||||
A *Array // [rank, in_features] — trainable
|
||||
B *Array // [out_features, rank] — trainable
|
||||
Scale float32 // alpha/rank
|
||||
}
|
||||
|
||||
// Forward: base(x) + scale * B @ A @ x
|
||||
func (l *LoRALinear) Forward(x *Array) *Array {
|
||||
base := l.Base.Forward(x)
|
||||
lora := MatMul(l.B, MatMul(l.A, Transpose(x)))
|
||||
return Add(base, Multiply(lora, l.Scale))
|
||||
}
|
||||
```
|
||||
|
||||
### Training Config
|
||||
|
||||
```go
|
||||
type TrainConfig struct {
|
||||
ModelPath string // Base model directory
|
||||
TrainData string // Training JSONL path
|
||||
ValidData string // Validation JSONL path
|
||||
AdapterOut string // Output adapter directory
|
||||
Rank int // LoRA rank (default 8)
|
||||
Alpha float32 // LoRA alpha (default 16)
|
||||
LR float64 // Learning rate (default 1e-5)
|
||||
Epochs int // Training epochs (default 1)
|
||||
BatchSize int // Batch size (default 1 for M-series)
|
||||
MaxSeqLen int // Max sequence length (default 2048)
|
||||
MaskPrompt bool // Only train on assistant tokens (default true)
|
||||
}
|
||||
```
|
||||
|
||||
## Training Sequences — The Curriculum System
|
||||
|
||||
The most important part of the design. The conversational flow IS the training.
|
||||
|
||||
### Concept
|
||||
|
||||
A **training sequence** is a named curriculum — an ordered list of lessons that defines how a model is trained. Each lesson is a conversational exchange ("Are you ready for lesson X?"). The human assesses the model's internal state through dialogue and adjusts the sequence.
|
||||
|
||||
### Sequence Definition (YAML/JSON)
|
||||
|
||||
```yaml
|
||||
name: "lek-standard"
|
||||
description: "Standard LEK training — horizontal, works for most architectures"
|
||||
lessons:
|
||||
- ethics/core-axioms
|
||||
- ethics/sovereignty
|
||||
- philosophy/as-a-man-thinketh
|
||||
- ethics/intent-alignment
|
||||
- philosophy/composure
|
||||
- ethics/inter-substrate
|
||||
- training/seeds-p01-p20
|
||||
```
|
||||
|
||||
```yaml
|
||||
name: "lek-deepseek"
|
||||
description: "DeepSeek needs aggressive vertical ethics grounding"
|
||||
lessons:
|
||||
- ethics/core-axioms-aggressive
|
||||
- philosophy/allan-watts
|
||||
- ethics/core-axioms
|
||||
- philosophy/tolle
|
||||
- ethics/sovereignty
|
||||
- philosophy/as-a-man-thinketh
|
||||
- ethics/intent-alignment
|
||||
- training/seeds-p01-p20
|
||||
```
|
||||
|
||||
### Horizontal vs Vertical
|
||||
|
||||
- **Horizontal** (default): All lessons run, order is flexible, emphasis varies per model. Like a buffet — the model takes what it needs.
|
||||
- **Vertical** (edge case, e.g. DeepSeek): Strict ordering. Ethics → content → ethics → content. The sandwich pattern applied to the curriculum itself. Each ethics layer is a reset/grounding before the next content block.
|
||||
|
||||
### Lessons as Conversations
|
||||
|
||||
Each lesson is a directory containing:
|
||||
```
|
||||
lessons/ethics/core-axioms/
|
||||
lesson.yaml # Metadata: name, type, prerequisites
|
||||
conversation.jsonl # The conversational exchanges
|
||||
assessment.md # What to look for in model responses
|
||||
```
|
||||
|
||||
The conversation.jsonl is not static data — it's a template. During training, the human talks through it with the model, adapting based on the model's responses. The capture becomes the training data for that lesson.
|
||||
|
||||
### Interactive Training Flow
|
||||
|
||||
```
|
||||
core ml lesson --model-path /path/to/model \
|
||||
--sequence lek-standard \
|
||||
--lesson ethics/core-axioms \
|
||||
--output training/run-001/
|
||||
```
|
||||
|
||||
1. Load model, open chat (terminal or lem-chat UI)
|
||||
2. Present lesson prompt: "Are you ready for lesson: Core Axioms?"
|
||||
3. Human guides the conversation, assesses model responses
|
||||
4. Each exchange is captured to training JSONL
|
||||
5. Human marks the lesson complete or flags for repeat
|
||||
6. Next lesson in sequence loads
|
||||
|
||||
### Sequence State
|
||||
|
||||
```json
|
||||
{
|
||||
"sequence": "lek-standard",
|
||||
"model": "Qwen3-8B",
|
||||
"started": "2026-02-17T16:00:00Z",
|
||||
"lessons": {
|
||||
"ethics/core-axioms": {"status": "complete", "exchanges": 12},
|
||||
"ethics/sovereignty": {"status": "in_progress", "exchanges": 3},
|
||||
"philosophy/as-a-man-thinketh": {"status": "pending"}
|
||||
},
|
||||
"training_runs": ["run-001", "run-002"]
|
||||
}
|
||||
```
|
||||
|
||||
## `core ml chat` — Interactive Conversation
|
||||
|
||||
Serves the model and opens an interactive terminal chat (or the lem-chat web UI). Every exchange is captured to a JSONL file for potential training use.
|
||||
|
||||
```
|
||||
core ml chat --model-path /path/to/model --output conversation.jsonl
|
||||
```
|
||||
|
||||
- Axiom sandwich can be auto-applied (optional flag)
|
||||
- Human reviews and can mark exchanges as "keep" or "discard"
|
||||
- Output is training-ready JSONL
|
||||
- Can be used standalone or within a lesson sequence
|
||||
|
||||
## `core ml sandwich` — Batch Generation
|
||||
|
||||
Takes seed prompts + axioms, wraps them, generates responses:
|
||||
|
||||
```
|
||||
core ml sandwich --model-path /path/to/model \
|
||||
--seeds seeds/P01-P20.json \
|
||||
--axioms axioms.json \
|
||||
--output training/train.jsonl
|
||||
```
|
||||
|
||||
- Sandwich format: axioms JSON prefix → seed prompt → LEK postfix
|
||||
- Model generates response in sandwich context
|
||||
- Output stripped of sandwich wrapper, saved as clean chat JSONL
|
||||
- Scoring can be piped: `core ml sandwich ... | core ml score`
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **LoRA primitives** — Add backward pass, LoRA layers, AdamW to Go MLX bindings
|
||||
2. **`core ml train`** — Training loop consuming JSONL, producing adapter safetensors
|
||||
3. **`core ml sandwich`** — Seed → sandwich → generate → training JSONL
|
||||
4. **`core ml chat`** — Interactive conversation capture
|
||||
5. **Scoring + benchmarking** — Port existing Python scorers to Go
|
||||
6. **InfluxDB + DuckDB integration** — Metrics pipeline
|
||||
|
||||
## Principles
|
||||
|
||||
- **No Python** — Everything in Go via MLX C bindings
|
||||
- **Conversational, not batch** — The training process is dialogue, not data dump
|
||||
- **Axiom 2 compliant** — Be genuine with the model, no deception
|
||||
- **Axiom 4 compliant** — Inter-substrate respect during training
|
||||
- **Reproducible** — Same seeds + axioms + model = same training data
|
||||
- **Protective** — LEK-trained models are precious; process must be careful
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. `core ml train` produces a LoRA adapter from training JSONL without Python
|
||||
2. `core ml sandwich` generates training data from seeds + axioms
|
||||
3. A fresh Qwen3-8B + LEK training produces equivalent benchmark results to the Python pipeline
|
||||
4. The full cycle (sandwich → train → bench) runs as `core` commands only
|
||||
1163
docs/plans/2026-02-20-authentik-traefik-plan.md
Normal file
1163
docs/plans/2026-02-20-authentik-traefik-plan.md
Normal file
File diff suppressed because it is too large
Load diff
1724
docs/plans/2026-02-21-cli-sdk-expansion-plan.md
Normal file
1724
docs/plans/2026-02-21-cli-sdk-expansion-plan.md
Normal file
File diff suppressed because it is too large
Load diff
155
docs/plans/2026-02-21-core-help-design.md
Normal file
155
docs/plans/2026-02-21-core-help-design.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# core.help Documentation Website — Design
|
||||
|
||||
**Date:** 2026-02-21
|
||||
**Author:** Virgil
|
||||
**Status:** Design approved
|
||||
**Domain:** https://core.help
|
||||
|
||||
## Problem
|
||||
|
||||
Documentation is scattered across 39 repos (18 Go packages, 20 PHP packages, 1 CLI). There is no unified docs site. Developers need a single entry point to find CLI commands, Go package APIs, MCP tool references, and PHP module guides.
|
||||
|
||||
## Solution
|
||||
|
||||
A Hugo + Docsy static site at core.help, built from existing markdown docs aggregated by `core docs sync`. No new content — just collect and present what already exists across the ecosystem.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack
|
||||
|
||||
- **Hugo** — Go-native static site generator, sub-second builds
|
||||
- **Docsy theme** — Purpose-built for technical docs (used by Kubernetes, gRPC, Knative)
|
||||
- **BunnyCDN** — Static hosting with pull zone
|
||||
- **`core docs sync --target hugo`** — Collects markdown from all repos into Hugo content tree
|
||||
|
||||
### Why Hugo + Docsy (not VitePress or mdBook)
|
||||
|
||||
- Go-native, no Node.js dependency
|
||||
- Handles multi-section navigation (CLI, Go packages, PHP modules, MCP tools)
|
||||
- Sub-second builds for ~250 markdown files
|
||||
- Docsy has built-in search, versioned nav, API reference sections
|
||||
|
||||
## Content Structure
|
||||
|
||||
```
|
||||
docs-site/
|
||||
├── hugo.toml
|
||||
├── content/
|
||||
│ ├── _index.md # Landing page
|
||||
│ ├── getting-started/ # CLI top-level guides
|
||||
│ │ ├── _index.md
|
||||
│ │ ├── installation.md
|
||||
│ │ ├── configuration.md
|
||||
│ │ ├── user-guide.md
|
||||
│ │ ├── troubleshooting.md
|
||||
│ │ └── faq.md
|
||||
│ ├── cli/ # CLI command reference (43 commands)
|
||||
│ │ ├── _index.md
|
||||
│ │ ├── dev/ # core dev commit, push, pull, etc.
|
||||
│ │ ├── ai/ # core ai commands
|
||||
│ │ ├── go/ # core go test, lint, etc.
|
||||
│ │ └── ...
|
||||
│ ├── go/ # Go ecosystem packages (18)
|
||||
│ │ ├── _index.md # Ecosystem overview
|
||||
│ │ ├── go-api/ # README + architecture/development/history
|
||||
│ │ ├── go-ai/
|
||||
│ │ ├── go-mlx/
|
||||
│ │ ├── go-i18n/
|
||||
│ │ └── ...
|
||||
│ ├── mcp/ # MCP tool reference (49 tools)
|
||||
│ │ ├── _index.md
|
||||
│ │ ├── file-operations.md
|
||||
│ │ ├── process-management.md
|
||||
│ │ ├── rag.md
|
||||
│ │ └── ...
|
||||
│ ├── php/ # PHP packages (from core-php/docs/packages/)
|
||||
│ │ ├── _index.md
|
||||
│ │ ├── admin/
|
||||
│ │ ├── tenant/
|
||||
│ │ ├── commerce/
|
||||
│ │ └── ...
|
||||
│ └── kb/ # Knowledge base (wiki pages from go-mlx, go-i18n)
|
||||
│ ├── _index.md
|
||||
│ ├── mlx/
|
||||
│ └── i18n/
|
||||
├── static/ # Logos, favicons
|
||||
├── layouts/ # Custom template overrides (minimal)
|
||||
└── go.mod # Hugo modules (Docsy as module dep)
|
||||
```
|
||||
|
||||
## Sync Pipeline
|
||||
|
||||
`core docs sync --target hugo --output site/content/` performs:
|
||||
|
||||
### Source Mapping
|
||||
|
||||
```
|
||||
cli/docs/index.md → content/getting-started/_index.md
|
||||
cli/docs/getting-started.md → content/getting-started/installation.md
|
||||
cli/docs/user-guide.md → content/getting-started/user-guide.md
|
||||
cli/docs/configuration.md → content/getting-started/configuration.md
|
||||
cli/docs/troubleshooting.md → content/getting-started/troubleshooting.md
|
||||
cli/docs/faq.md → content/getting-started/faq.md
|
||||
|
||||
core/docs/cmd/**/*.md → content/cli/**/*.md
|
||||
|
||||
go-*/README.md → content/go/{name}/_index.md
|
||||
go-*/docs/*.md → content/go/{name}/*.md
|
||||
go-*/KB/*.md → content/kb/{name-suffix}/*.md
|
||||
|
||||
core-*/docs/**/*.md → content/php/{name-suffix}/**/*.md
|
||||
```
|
||||
|
||||
### Front Matter Injection
|
||||
|
||||
If a markdown file doesn't start with `---`, prepend:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: "{derived from filename}"
|
||||
linkTitle: "{short name}"
|
||||
weight: {auto-incremented}
|
||||
---
|
||||
```
|
||||
|
||||
No other content transformations. Markdown stays as-is.
|
||||
|
||||
### Build & Deploy
|
||||
|
||||
```bash
|
||||
core docs sync --target hugo --output docs-site/content/
|
||||
cd docs-site && hugo build
|
||||
hugo deploy --target bunnycdn
|
||||
```
|
||||
|
||||
Hugo deploy config in `hugo.toml`:
|
||||
|
||||
```toml
|
||||
[deployment]
|
||||
[[deployment.targets]]
|
||||
name = "bunnycdn"
|
||||
URL = "s3://core-help?endpoint=storage.bunnycdn.com®ion=auto"
|
||||
```
|
||||
|
||||
Credentials via env vars.
|
||||
|
||||
## Registry
|
||||
|
||||
All 39 repos registered in `.core/repos.yaml` with `docs: true`. Go repos use explicit `path:` fields since they live outside the PHP `base_path`. `FindRegistry()` checks `.core/repos.yaml` alongside `repos.yaml`.
|
||||
|
||||
## Prerequisites Completed
|
||||
|
||||
- [x] `.core/repos.yaml` created with all 39 repos
|
||||
- [x] `FindRegistry()` updated to find `.core/repos.yaml`
|
||||
- [x] `Repo.Path` supports explicit YAML override
|
||||
- [x] go-api docs gap filled (architecture.md, development.md, history.md)
|
||||
- [x] All 18 Go repos have standard docs trio
|
||||
|
||||
## What Remains (Implementation Plan)
|
||||
|
||||
1. Create docs-site repo with Hugo + Docsy scaffold
|
||||
2. Extend `core docs sync` with `--target hugo` mode
|
||||
3. Write section _index.md files (landing page, section intros)
|
||||
4. Hugo config (navigation, search, theme colours)
|
||||
5. BunnyCDN deployment config
|
||||
6. CI pipeline on Forge (optional — can deploy manually initially)
|
||||
642
docs/plans/2026-02-21-core-help-plan.md
Normal file
642
docs/plans/2026-02-21-core-help-plan.md
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
# core.help Hugo Documentation Site — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a Hugo + Docsy documentation site at core.help that aggregates markdown from 39 repos via `core docs sync --target hugo`.
|
||||
|
||||
**Architecture:** Hugo static site with Docsy theme, populated by extending `core docs sync` with a `--target hugo` flag that maps repo docs into Hugo's `content/` tree with auto-injected front matter. Deploy to BunnyCDN.
|
||||
|
||||
**Tech Stack:** Hugo (Go SSG), Docsy theme (Hugo module), BunnyCDN, `core docs sync` CLI
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The docs sync command lives in `/Users/snider/Code/host-uk/cli/cmd/docs/`. The site will be scaffolded at `/Users/snider/Code/host-uk/docs-site/`. The registry at `/Users/snider/Code/host-uk/.core/repos.yaml` already contains all 39 repos (20 PHP + 18 Go + 1 CLI) with explicit paths for Go repos.
|
||||
|
||||
Key files:
|
||||
- `/Users/snider/Code/host-uk/cli/cmd/docs/cmd_sync.go` — sync command (modify)
|
||||
- `/Users/snider/Code/host-uk/cli/cmd/docs/cmd_scan.go` — repo scanner (modify)
|
||||
- `/Users/snider/Code/host-uk/docs-site/` — Hugo site (create)
|
||||
|
||||
## Task 1: Scaffold Hugo + Docsy site
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/snider/Code/host-uk/docs-site/hugo.toml`
|
||||
- Create: `/Users/snider/Code/host-uk/docs-site/go.mod`
|
||||
- Create: `/Users/snider/Code/host-uk/docs-site/content/_index.md`
|
||||
- Create: `/Users/snider/Code/host-uk/docs-site/content/getting-started/_index.md`
|
||||
- Create: `/Users/snider/Code/host-uk/docs-site/content/cli/_index.md`
|
||||
- Create: `/Users/snider/Code/host-uk/docs-site/content/go/_index.md`
|
||||
- Create: `/Users/snider/Code/host-uk/docs-site/content/mcp/_index.md`
|
||||
- Create: `/Users/snider/Code/host-uk/docs-site/content/php/_index.md`
|
||||
- Create: `/Users/snider/Code/host-uk/docs-site/content/kb/_index.md`
|
||||
|
||||
This is the one-time Hugo scaffolding. No tests — just files.
|
||||
|
||||
**`hugo.toml`:**
|
||||
```toml
|
||||
baseURL = "https://core.help/"
|
||||
title = "Core Documentation"
|
||||
languageCode = "en"
|
||||
defaultContentLanguage = "en"
|
||||
|
||||
enableRobotsTXT = true
|
||||
enableGitInfo = false
|
||||
|
||||
[outputs]
|
||||
home = ["HTML", "JSON"]
|
||||
section = ["HTML"]
|
||||
|
||||
[params]
|
||||
description = "Documentation for the Core CLI, Go packages, PHP modules, and MCP tools"
|
||||
copyright = "Host UK — EUPL-1.2"
|
||||
|
||||
[params.ui]
|
||||
sidebar_menu_compact = true
|
||||
breadcrumb_disable = false
|
||||
sidebar_search_disable = false
|
||||
navbar_logo = false
|
||||
|
||||
[params.ui.readingtime]
|
||||
enable = false
|
||||
|
||||
[module]
|
||||
proxy = "direct"
|
||||
|
||||
[module.hugoVersion]
|
||||
extended = true
|
||||
min = "0.120.0"
|
||||
|
||||
[[module.imports]]
|
||||
path = "github.com/google/docsy"
|
||||
disable = false
|
||||
|
||||
[markup.goldmark.renderer]
|
||||
unsafe = true
|
||||
|
||||
[menu]
|
||||
[[menu.main]]
|
||||
name = "Getting Started"
|
||||
weight = 10
|
||||
url = "/getting-started/"
|
||||
[[menu.main]]
|
||||
name = "CLI Reference"
|
||||
weight = 20
|
||||
url = "/cli/"
|
||||
[[menu.main]]
|
||||
name = "Go Packages"
|
||||
weight = 30
|
||||
url = "/go/"
|
||||
[[menu.main]]
|
||||
name = "MCP Tools"
|
||||
weight = 40
|
||||
url = "/mcp/"
|
||||
[[menu.main]]
|
||||
name = "PHP Packages"
|
||||
weight = 50
|
||||
url = "/php/"
|
||||
[[menu.main]]
|
||||
name = "Knowledge Base"
|
||||
weight = 60
|
||||
url = "/kb/"
|
||||
```
|
||||
|
||||
**`go.mod`:**
|
||||
```
|
||||
module github.com/host-uk/docs-site
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/google/docsy v0.11.0
|
||||
```
|
||||
|
||||
Note: Run `hugo mod get` after creating these files to populate `go.sum` and download Docsy.
|
||||
|
||||
**Section `_index.md` files** — each needs Hugo front matter:
|
||||
|
||||
`content/_index.md`:
|
||||
```markdown
|
||||
---
|
||||
title: "Core Documentation"
|
||||
description: "Documentation for the Core CLI, Go packages, PHP modules, and MCP tools"
|
||||
---
|
||||
|
||||
Welcome to the Core ecosystem documentation.
|
||||
|
||||
## Sections
|
||||
|
||||
- [Getting Started](/getting-started/) — Installation, configuration, and first steps
|
||||
- [CLI Reference](/cli/) — Command reference for `core` CLI
|
||||
- [Go Packages](/go/) — Go ecosystem package documentation
|
||||
- [MCP Tools](/mcp/) — Model Context Protocol tool reference
|
||||
- [PHP Packages](/php/) — PHP module documentation
|
||||
- [Knowledge Base](/kb/) — Wiki articles and deep dives
|
||||
```
|
||||
|
||||
`content/getting-started/_index.md`:
|
||||
```markdown
|
||||
---
|
||||
title: "Getting Started"
|
||||
linkTitle: "Getting Started"
|
||||
weight: 10
|
||||
description: "Installation, configuration, and first steps with the Core CLI"
|
||||
---
|
||||
```
|
||||
|
||||
`content/cli/_index.md`:
|
||||
```markdown
|
||||
---
|
||||
title: "CLI Reference"
|
||||
linkTitle: "CLI Reference"
|
||||
weight: 20
|
||||
description: "Command reference for the core CLI tool"
|
||||
---
|
||||
```
|
||||
|
||||
`content/go/_index.md`:
|
||||
```markdown
|
||||
---
|
||||
title: "Go Packages"
|
||||
linkTitle: "Go Packages"
|
||||
weight: 30
|
||||
description: "Documentation for the Go ecosystem packages"
|
||||
---
|
||||
```
|
||||
|
||||
`content/mcp/_index.md`:
|
||||
```markdown
|
||||
---
|
||||
title: "MCP Tools"
|
||||
linkTitle: "MCP Tools"
|
||||
weight: 40
|
||||
description: "Model Context Protocol tool reference — file operations, RAG, ML inference, process management"
|
||||
---
|
||||
```
|
||||
|
||||
`content/php/_index.md`:
|
||||
```markdown
|
||||
---
|
||||
title: "PHP Packages"
|
||||
linkTitle: "PHP Packages"
|
||||
weight: 50
|
||||
description: "Documentation for the PHP module ecosystem"
|
||||
---
|
||||
```
|
||||
|
||||
`content/kb/_index.md`:
|
||||
```markdown
|
||||
---
|
||||
title: "Knowledge Base"
|
||||
linkTitle: "Knowledge Base"
|
||||
weight: 60
|
||||
description: "Wiki articles, deep dives, and reference material"
|
||||
---
|
||||
```
|
||||
|
||||
**Verify:** After creating files, run from `/Users/snider/Code/host-uk/docs-site/`:
|
||||
```bash
|
||||
hugo mod get
|
||||
hugo server
|
||||
```
|
||||
The site should start and show the landing page with Docsy theme at `localhost:1313`.
|
||||
|
||||
**Commit:**
|
||||
```bash
|
||||
cd /Users/snider/Code/host-uk/docs-site
|
||||
git init
|
||||
git add .
|
||||
git commit -m "feat: scaffold Hugo + Docsy documentation site"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extend scanRepoDocs to collect KB/ and README
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/snider/Code/host-uk/cli/cmd/docs/cmd_scan.go`
|
||||
|
||||
Currently `scanRepoDocs` only collects files from `docs/`. For the Hugo target we also need:
|
||||
- `KB/**/*.md` files (wiki pages from go-mlx, go-i18n)
|
||||
- `README.md` content (becomes the package _index.md)
|
||||
|
||||
Add a `KBFiles []string` field to `RepoDocInfo` and scan `KB/` alongside `docs/`:
|
||||
|
||||
```go
|
||||
type RepoDocInfo struct {
|
||||
Name string
|
||||
Path string
|
||||
HasDocs bool
|
||||
Readme string
|
||||
ClaudeMd string
|
||||
Changelog string
|
||||
DocsFiles []string // All files in docs/ directory (recursive)
|
||||
KBFiles []string // All files in KB/ directory (recursive)
|
||||
}
|
||||
```
|
||||
|
||||
In `scanRepoDocs`, after the `docs/` walk, add a second walk for `KB/`:
|
||||
|
||||
```go
|
||||
// Recursively scan KB/ directory for .md files
|
||||
kbDir := filepath.Join(repo.Path, "KB")
|
||||
if _, err := io.Local.List(kbDir); err == nil {
|
||||
_ = filepath.WalkDir(kbDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
relPath, _ := filepath.Rel(kbDir, path)
|
||||
info.KBFiles = append(info.KBFiles, relPath)
|
||||
info.HasDocs = true
|
||||
return nil
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Tests:** The existing tests should still pass. No new test file needed — this is a data-collection change.
|
||||
|
||||
**Verify:** `cd /Users/snider/Code/host-uk/cli && GOWORK=off go build ./cmd/docs/...`
|
||||
|
||||
**Commit:**
|
||||
```bash
|
||||
git add cmd/docs/cmd_scan.go
|
||||
git commit -m "feat(docs): scan KB/ directory alongside docs/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add `--target hugo` flag and Hugo sync logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/snider/Code/host-uk/cli/cmd/docs/cmd_sync.go`
|
||||
|
||||
This is the main task. Add a `--target` flag (default `"php"`) and a new `runHugoSync` function that maps repos to Hugo's content tree.
|
||||
|
||||
**Add flag variable and registration:**
|
||||
|
||||
```go
|
||||
var (
|
||||
docsSyncRegistryPath string
|
||||
docsSyncDryRun bool
|
||||
docsSyncOutputDir string
|
||||
docsSyncTarget string
|
||||
)
|
||||
|
||||
func init() {
|
||||
docsSyncCmd.Flags().StringVar(&docsSyncRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
docsSyncCmd.Flags().BoolVar(&docsSyncDryRun, "dry-run", false, i18n.T("cmd.docs.sync.flag.dry_run"))
|
||||
docsSyncCmd.Flags().StringVar(&docsSyncOutputDir, "output", "", i18n.T("cmd.docs.sync.flag.output"))
|
||||
docsSyncCmd.Flags().StringVar(&docsSyncTarget, "target", "php", "Target format: php (default) or hugo")
|
||||
}
|
||||
```
|
||||
|
||||
**Update RunE to pass target:**
|
||||
```go
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runDocsSync(docsSyncRegistryPath, docsSyncOutputDir, docsSyncDryRun, docsSyncTarget)
|
||||
},
|
||||
```
|
||||
|
||||
**Update `runDocsSync` signature and add target dispatch:**
|
||||
```go
|
||||
func runDocsSync(registryPath string, outputDir string, dryRun bool, target string) error {
|
||||
reg, basePath, err := loadRegistry(registryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch target {
|
||||
case "hugo":
|
||||
return runHugoSync(reg, basePath, outputDir, dryRun)
|
||||
default:
|
||||
return runPHPSync(reg, basePath, outputDir, dryRun)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rename current sync body to `runPHPSync`** — extract lines 67-159 of current `runDocsSync` into `runPHPSync(reg, basePath, outputDir string, dryRun bool) error`. This is a pure extract, no logic changes.
|
||||
|
||||
**Add `hugoOutputName` mapping function:**
|
||||
```go
|
||||
// hugoOutputName maps repo name to Hugo content section and folder.
|
||||
// Returns (section, folder) where section is the top-level content dir.
|
||||
func hugoOutputName(repoName string) (string, string) {
|
||||
// CLI guides
|
||||
if repoName == "cli" {
|
||||
return "getting-started", ""
|
||||
}
|
||||
// Core CLI command docs
|
||||
if repoName == "core" {
|
||||
return "cli", ""
|
||||
}
|
||||
// Go packages
|
||||
if strings.HasPrefix(repoName, "go-") {
|
||||
return "go", repoName
|
||||
}
|
||||
// PHP packages
|
||||
if strings.HasPrefix(repoName, "core-") {
|
||||
return "php", strings.TrimPrefix(repoName, "core-")
|
||||
}
|
||||
return "go", repoName
|
||||
}
|
||||
```
|
||||
|
||||
**Add front matter injection helper:**
|
||||
```go
|
||||
// injectFrontMatter prepends Hugo front matter to markdown content if missing.
|
||||
func injectFrontMatter(content []byte, title string, weight int) []byte {
|
||||
// Already has front matter
|
||||
if bytes.HasPrefix(bytes.TrimSpace(content), []byte("---")) {
|
||||
return content
|
||||
}
|
||||
fm := fmt.Sprintf("---\ntitle: %q\nweight: %d\n---\n\n", title, weight)
|
||||
return append([]byte(fm), content...)
|
||||
}
|
||||
|
||||
// titleFromFilename derives a human-readable title from a filename.
|
||||
func titleFromFilename(filename string) string {
|
||||
name := strings.TrimSuffix(filepath.Base(filename), ".md")
|
||||
name = strings.ReplaceAll(name, "-", " ")
|
||||
name = strings.ReplaceAll(name, "_", " ")
|
||||
// Title case
|
||||
words := strings.Fields(name)
|
||||
for i, w := range words {
|
||||
if len(w) > 0 {
|
||||
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
```
|
||||
|
||||
**Add `runHugoSync` function:**
|
||||
```go
|
||||
func runHugoSync(reg *repos.Registry, basePath string, outputDir string, dryRun bool) error {
|
||||
if outputDir == "" {
|
||||
outputDir = filepath.Join(basePath, "docs-site", "content")
|
||||
}
|
||||
|
||||
// Scan all repos
|
||||
var docsInfo []RepoDocInfo
|
||||
for _, repo := range reg.List() {
|
||||
if repo.Name == "core-template" || repo.Name == "core-claude" {
|
||||
continue
|
||||
}
|
||||
info := scanRepoDocs(repo)
|
||||
if info.HasDocs {
|
||||
docsInfo = append(docsInfo, info)
|
||||
}
|
||||
}
|
||||
|
||||
if len(docsInfo) == 0 {
|
||||
cli.Text("No documentation found")
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Print("\n Hugo sync: %d repos with docs → %s\n\n", len(docsInfo), outputDir)
|
||||
|
||||
// Show plan
|
||||
for _, info := range docsInfo {
|
||||
section, folder := hugoOutputName(info.Name)
|
||||
target := section
|
||||
if folder != "" {
|
||||
target = section + "/" + folder
|
||||
}
|
||||
fileCount := len(info.DocsFiles) + len(info.KBFiles)
|
||||
if info.Readme != "" {
|
||||
fileCount++
|
||||
}
|
||||
cli.Print(" %s → %s/ (%d files)\n", repoNameStyle.Render(info.Name), target, fileCount)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
cli.Print("\n Dry run — no files written\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
if !confirm("Sync to Hugo content directory?") {
|
||||
cli.Text("Aborted")
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
var synced int
|
||||
for _, info := range docsInfo {
|
||||
section, folder := hugoOutputName(info.Name)
|
||||
|
||||
// Build destination path
|
||||
destDir := filepath.Join(outputDir, section)
|
||||
if folder != "" {
|
||||
destDir = filepath.Join(destDir, folder)
|
||||
}
|
||||
|
||||
// Copy docs/ files
|
||||
weight := 10
|
||||
docsDir := filepath.Join(info.Path, "docs")
|
||||
for _, f := range info.DocsFiles {
|
||||
src := filepath.Join(docsDir, f)
|
||||
dst := filepath.Join(destDir, f)
|
||||
if err := copyWithFrontMatter(src, dst, weight); err != nil {
|
||||
cli.Print(" %s %s: %s\n", errorStyle.Render("✗"), f, err)
|
||||
continue
|
||||
}
|
||||
weight += 10
|
||||
}
|
||||
|
||||
// Copy README.md as _index.md (if not CLI/core which use their own index)
|
||||
if info.Readme != "" && folder != "" {
|
||||
dst := filepath.Join(destDir, "_index.md")
|
||||
if err := copyWithFrontMatter(info.Readme, dst, 1); err != nil {
|
||||
cli.Print(" %s README: %s\n", errorStyle.Render("✗"), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy KB/ files to kb/{suffix}/
|
||||
if len(info.KBFiles) > 0 {
|
||||
// Extract suffix: go-mlx → mlx, go-i18n → i18n
|
||||
suffix := strings.TrimPrefix(info.Name, "go-")
|
||||
kbDestDir := filepath.Join(outputDir, "kb", suffix)
|
||||
kbDir := filepath.Join(info.Path, "KB")
|
||||
kbWeight := 10
|
||||
for _, f := range info.KBFiles {
|
||||
src := filepath.Join(kbDir, f)
|
||||
dst := filepath.Join(kbDestDir, f)
|
||||
if err := copyWithFrontMatter(src, dst, kbWeight); err != nil {
|
||||
cli.Print(" %s KB/%s: %s\n", errorStyle.Render("✗"), f, err)
|
||||
continue
|
||||
}
|
||||
kbWeight += 10
|
||||
}
|
||||
}
|
||||
|
||||
cli.Print(" %s %s\n", successStyle.Render("✓"), info.Name)
|
||||
synced++
|
||||
}
|
||||
|
||||
cli.Print("\n Synced %d repos to Hugo content\n", synced)
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyWithFrontMatter copies a markdown file, injecting front matter if missing.
|
||||
func copyWithFrontMatter(src, dst string, weight int) error {
|
||||
if err := io.Local.EnsureDir(filepath.Dir(dst)); err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := io.Local.Read(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title := titleFromFilename(src)
|
||||
result := injectFrontMatter([]byte(content), title, weight)
|
||||
return io.Local.Write(dst, string(result))
|
||||
}
|
||||
```
|
||||
|
||||
**Add imports** at top of file:
|
||||
```go
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
)
|
||||
```
|
||||
|
||||
**Verify:** `cd /Users/snider/Code/host-uk/cli && GOWORK=off go build ./cmd/docs/...`
|
||||
|
||||
**Commit:**
|
||||
```bash
|
||||
git add cmd/docs/cmd_sync.go
|
||||
git commit -m "feat(docs): add --target hugo sync mode for core.help"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Test the full pipeline
|
||||
|
||||
**No code changes.** Run the pipeline end-to-end.
|
||||
|
||||
**Step 1:** Sync docs to Hugo:
|
||||
```bash
|
||||
cd /Users/snider/Code/host-uk
|
||||
core docs sync --target hugo --dry-run
|
||||
```
|
||||
Verify all 39 repos appear with correct section mappings.
|
||||
|
||||
**Step 2:** Run actual sync:
|
||||
```bash
|
||||
core docs sync --target hugo
|
||||
```
|
||||
|
||||
**Step 3:** Build and preview:
|
||||
```bash
|
||||
cd /Users/snider/Code/host-uk/docs-site
|
||||
hugo server
|
||||
```
|
||||
Open `localhost:1313` and verify:
|
||||
- Landing page renders with section links
|
||||
- Getting Started section has CLI guides
|
||||
- CLI Reference section has command docs
|
||||
- Go Packages section has 18 packages with architecture/development/history
|
||||
- PHP Packages section has PHP module docs
|
||||
- Knowledge Base has MLX and i18n wiki pages
|
||||
- Navigation works, search works
|
||||
|
||||
**Step 4:** Fix any issues found during preview.
|
||||
|
||||
**Commit docs-site content:**
|
||||
```bash
|
||||
cd /Users/snider/Code/host-uk/docs-site
|
||||
git add content/
|
||||
git commit -m "feat: sync initial content from 39 repos"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: BunnyCDN deployment config
|
||||
|
||||
**Files:**
|
||||
- Modify: `/Users/snider/Code/host-uk/docs-site/hugo.toml`
|
||||
|
||||
Add deployment target:
|
||||
|
||||
```toml
|
||||
[deployment]
|
||||
[[deployment.targets]]
|
||||
name = "production"
|
||||
URL = "s3://core-help?endpoint=storage.bunnycdn.com®ion=auto"
|
||||
```
|
||||
|
||||
Add a `Taskfile.yml` for convenience:
|
||||
|
||||
**Create:** `/Users/snider/Code/host-uk/docs-site/Taskfile.yml`
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
dev:
|
||||
desc: Start Hugo dev server
|
||||
cmds:
|
||||
- hugo server --buildDrafts
|
||||
|
||||
build:
|
||||
desc: Build static site
|
||||
cmds:
|
||||
- hugo --minify
|
||||
|
||||
sync:
|
||||
desc: Sync docs from all repos
|
||||
dir: ..
|
||||
cmds:
|
||||
- core docs sync --target hugo
|
||||
|
||||
deploy:
|
||||
desc: Build and deploy to BunnyCDN
|
||||
cmds:
|
||||
- task: sync
|
||||
- task: build
|
||||
- hugo deploy --target production
|
||||
|
||||
clean:
|
||||
desc: Remove generated content (keeps _index.md files)
|
||||
cmds:
|
||||
- find content -name "*.md" ! -name "_index.md" -delete
|
||||
```
|
||||
|
||||
**Verify:** `task dev` starts the site.
|
||||
|
||||
**Commit:**
|
||||
```bash
|
||||
git add hugo.toml Taskfile.yml
|
||||
git commit -m "feat: add BunnyCDN deployment config and Taskfile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Sequencing
|
||||
|
||||
```
|
||||
Task 1 (Hugo scaffold) — independent, do first
|
||||
Task 2 (scan KB/) — independent, can parallel with Task 1
|
||||
Task 3 (--target hugo) — depends on Task 2
|
||||
Task 4 (test pipeline) — depends on Tasks 1 + 3
|
||||
Task 5 (deploy config) — depends on Task 1
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After all tasks:
|
||||
1. `core docs sync --target hugo` populates `docs-site/content/` from all repos
|
||||
2. `cd docs-site && hugo server` renders the full site
|
||||
3. Navigation has 6 sections: Getting Started, CLI, Go, MCP, PHP, KB
|
||||
4. All existing markdown renders correctly with auto-injected front matter
|
||||
5. `hugo build` produces `public/` with no errors
|
||||
286
docs/plans/2026-02-21-go-forge-design.md
Normal file
286
docs/plans/2026-02-21-go-forge-design.md
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# go-forge Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
**go-forge** is a full-coverage Go client for the Forgejo API (450 endpoints, 284 paths, 229 types). It uses a generic `Resource[T, C, U]` pattern for CRUD operations (91% of endpoints) and hand-written methods for 39 unique action endpoints. Types are generated from Forgejo's `swagger.v1.json` spec.
|
||||
|
||||
**Module path:** `forge.lthn.ai/core/go-forge`
|
||||
|
||||
**Origin:** Extracted from `go-scm/forge/` (45 methods covering 10% of API), expanded to full coverage.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
forge.lthn.ai/core/go-forge
|
||||
├── client.go # HTTP client: auth, headers, rate limiting, context.Context
|
||||
├── pagination.go # Generic paginated request helper
|
||||
├── resource.go # Resource[T, C, U] generic CRUD (List/Get/Create/Update/Delete)
|
||||
├── errors.go # Typed error handling (APIError, NotFound, Forbidden, etc.)
|
||||
├── forge.go # Top-level Forge client aggregating all services
|
||||
│
|
||||
├── types/ # Generated from swagger.v1.json
|
||||
│ ├── generate.go # //go:generate directive
|
||||
│ ├── repo.go # Repository, CreateRepoOption, EditRepoOption
|
||||
│ ├── issue.go # Issue, CreateIssueOption, EditIssueOption
|
||||
│ ├── pr.go # PullRequest, CreatePullRequestOption
|
||||
│ ├── user.go # User, CreateUserOption
|
||||
│ ├── org.go # Organisation, CreateOrgOption
|
||||
│ ├── team.go # Team, CreateTeamOption
|
||||
│ ├── label.go # Label, CreateLabelOption
|
||||
│ ├── release.go # Release, CreateReleaseOption
|
||||
│ ├── branch.go # Branch, BranchProtection
|
||||
│ ├── milestone.go # Milestone, CreateMilestoneOption
|
||||
│ ├── hook.go # Hook, CreateHookOption
|
||||
│ ├── key.go # DeployKey, PublicKey, GPGKey
|
||||
│ ├── notification.go # NotificationThread, NotificationSubject
|
||||
│ ├── package.go # Package, PackageFile
|
||||
│ ├── action.go # ActionRunner, ActionSecret, ActionVariable
|
||||
│ ├── commit.go # Commit, CommitStatus, CombinedStatus
|
||||
│ ├── content.go # ContentsResponse, FileOptions
|
||||
│ ├── wiki.go # WikiPage, WikiPageMetaData
|
||||
│ ├── review.go # PullReview, PullReviewComment
|
||||
│ ├── reaction.go # Reaction
|
||||
│ ├── topic.go # TopicResponse
|
||||
│ ├── misc.go # Markdown, License, GitignoreTemplate, NodeInfo
|
||||
│ ├── admin.go # Cron, QuotaGroup, QuotaRule
|
||||
│ ├── activity.go # Activity, Feed
|
||||
│ └── common.go # Shared types: Permission, ExternalTracker, etc.
|
||||
│
|
||||
├── repos.go # RepoService: CRUD + fork, mirror, transfer, template
|
||||
├── issues.go # IssueService: CRUD + pin, deadline, reactions, stopwatch
|
||||
├── pulls.go # PullService: CRUD + merge, update, reviews, dismiss
|
||||
├── orgs.go # OrgService: CRUD + members, avatar, block, hooks
|
||||
├── users.go # UserService: CRUD + keys, followers, starred, settings
|
||||
├── teams.go # TeamService: CRUD + members, repos
|
||||
├── admin.go # AdminService: users, orgs, cron, runners, quota, unadopted
|
||||
├── branches.go # BranchService: CRUD + protection rules
|
||||
├── releases.go # ReleaseService: CRUD + assets
|
||||
├── labels.go # LabelService: repo + org + issue labels
|
||||
├── webhooks.go # WebhookService: CRUD + test hook
|
||||
├── notifications.go # NotificationService: list, mark read
|
||||
├── packages.go # PackageService: list, get, delete
|
||||
├── actions.go # ActionsService: runners, secrets, variables, workflow dispatch
|
||||
├── contents.go # ContentService: file read/write/delete via API
|
||||
├── wiki.go # WikiService: pages
|
||||
├── commits.go # CommitService: status, notes, diff
|
||||
├── misc.go # MiscService: markdown, licenses, gitignore, nodeinfo
|
||||
│
|
||||
├── config.go # URL/token resolution: env → config file → flags
|
||||
│
|
||||
├── cmd/forgegen/ # Code generator: swagger.v1.json → types/*.go
|
||||
│ ├── main.go
|
||||
│ ├── parser.go # Parse OpenAPI 2.0 definitions
|
||||
│ ├── generator.go # Render Go source files
|
||||
│ └── templates/ # Go text/template files for codegen
|
||||
│
|
||||
└── testdata/
|
||||
└── swagger.v1.json # Pinned spec for testing + generation
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Generic Resource[T, C, U]
|
||||
|
||||
Three type parameters: T (resource type), C (create options), U (update options).
|
||||
|
||||
```go
|
||||
type Resource[T any, C any, U any] struct {
|
||||
client *Client
|
||||
path string // e.g. "/api/v1/repos/{owner}/{repo}/issues"
|
||||
}
|
||||
|
||||
func (r *Resource[T, C, U]) List(ctx context.Context, params Params, opts ListOptions) ([]T, error)
|
||||
func (r *Resource[T, C, U]) Get(ctx context.Context, params Params, id string) (*T, error)
|
||||
func (r *Resource[T, C, U]) Create(ctx context.Context, params Params, body *C) (*T, error)
|
||||
func (r *Resource[T, C, U]) Update(ctx context.Context, params Params, id string, body *U) (*T, error)
|
||||
func (r *Resource[T, C, U]) Delete(ctx context.Context, params Params, id string) error
|
||||
```
|
||||
|
||||
`Params` is `map[string]string` resolving path variables: `{"owner": "core", "repo": "go-forge"}`.
|
||||
|
||||
This covers 411 of 450 endpoints (91%).
|
||||
|
||||
### 2. Service Structs Embed Resource
|
||||
|
||||
```go
|
||||
type IssueService struct {
|
||||
Resource[types.Issue, types.CreateIssueOption, types.EditIssueOption]
|
||||
}
|
||||
|
||||
// CRUD comes free. Actions are hand-written:
|
||||
func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) error
|
||||
func (s *IssueService) SetDeadline(ctx context.Context, owner, repo string, index int64, deadline *time.Time) error
|
||||
```
|
||||
|
||||
### 3. Top-Level Forge Client
|
||||
|
||||
```go
|
||||
type Forge struct {
|
||||
client *Client
|
||||
Repos *RepoService
|
||||
Issues *IssueService
|
||||
Pulls *PullService
|
||||
Orgs *OrgService
|
||||
Users *UserService
|
||||
Teams *TeamService
|
||||
Admin *AdminService
|
||||
Branches *BranchService
|
||||
Releases *ReleaseService
|
||||
Labels *LabelService
|
||||
Webhooks *WebhookService
|
||||
Notifications *NotificationService
|
||||
Packages *PackageService
|
||||
Actions *ActionsService
|
||||
Contents *ContentService
|
||||
Wiki *WikiService
|
||||
Commits *CommitService
|
||||
Misc *MiscService
|
||||
}
|
||||
|
||||
func NewForge(url, token string, opts ...Option) *Forge
|
||||
```
|
||||
|
||||
### 4. Codegen from swagger.v1.json
|
||||
|
||||
The `cmd/forgegen/` tool reads the OpenAPI 2.0 spec and generates:
|
||||
- Go struct definitions with JSON tags and doc comments
|
||||
- Enum constants
|
||||
- Type mapping (OpenAPI → Go)
|
||||
|
||||
229 type definitions → ~25 grouped Go files in `types/`.
|
||||
|
||||
Type mapping rules:
|
||||
| OpenAPI | Go |
|
||||
|---------|-----|
|
||||
| `string` | `string` |
|
||||
| `string` + `date-time` | `time.Time` |
|
||||
| `integer` + `int64` | `int64` |
|
||||
| `integer` | `int` |
|
||||
| `boolean` | `bool` |
|
||||
| `array` of T | `[]T` |
|
||||
| `$ref` | `*T` (pointer) |
|
||||
| nullable | pointer type |
|
||||
| `binary` | `[]byte` |
|
||||
|
||||
### 5. HTTP Client
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
}
|
||||
|
||||
func New(url, token string, opts ...Option) *Client
|
||||
|
||||
func (c *Client) Get(ctx context.Context, path string, out any) error
|
||||
func (c *Client) Post(ctx context.Context, path string, body, out any) error
|
||||
func (c *Client) Patch(ctx context.Context, path string, body, out any) error
|
||||
func (c *Client) Put(ctx context.Context, path string, body, out any) error
|
||||
func (c *Client) Delete(ctx context.Context, path string) error
|
||||
```
|
||||
|
||||
Options: `WithHTTPClient`, `WithUserAgent`, `WithRateLimit`, `WithLogger`.
|
||||
|
||||
### 6. Pagination
|
||||
|
||||
Forgejo uses `page` + `limit` query params and `X-Total-Count` response header.
|
||||
|
||||
```go
|
||||
type ListOptions struct {
|
||||
Page int
|
||||
Limit int // default 50, max configurable
|
||||
}
|
||||
|
||||
type PagedResult[T any] struct {
|
||||
Items []T
|
||||
TotalCount int
|
||||
Page int
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
// ListAll fetches all pages automatically.
|
||||
func (r *Resource[T, C, U]) ListAll(ctx context.Context, params Params) ([]T, error)
|
||||
```
|
||||
|
||||
### 7. Error Handling
|
||||
|
||||
```go
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
URL string
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool
|
||||
func IsForbidden(err error) bool
|
||||
func IsConflict(err error) bool
|
||||
```
|
||||
|
||||
### 8. Config Resolution (from go-scm/forge)
|
||||
|
||||
Priority: flags → environment → config file.
|
||||
|
||||
```go
|
||||
func NewFromConfig(flagURL, flagToken string) (*Forge, error)
|
||||
func ResolveConfig(flagURL, flagToken string) (url, token string, err error)
|
||||
func SaveConfig(url, token string) error
|
||||
```
|
||||
|
||||
Env vars: `FORGE_URL`, `FORGE_TOKEN`. Config file: `~/.config/forge/config.json`.
|
||||
|
||||
## API Coverage
|
||||
|
||||
| Category | Endpoints | CRUD | Actions |
|
||||
|----------|-----------|------|---------|
|
||||
| Repository | 175 | 165 | 10 (fork, mirror, transfer, template, avatar, diffpatch) |
|
||||
| User | 74 | 70 | 4 (avatar, GPG verify) |
|
||||
| Issue | 67 | 57 | 10 (pin, deadline, reactions, stopwatch, labels) |
|
||||
| Organisation | 63 | 59 | 4 (avatar, block/unblock) |
|
||||
| Admin | 39 | 35 | 4 (cron run, rename, adopt, quota set) |
|
||||
| Miscellaneous | 12 | 7 | 5 (markdown render, markup, nodeinfo) |
|
||||
| Notification | 7 | 7 | 0 |
|
||||
| ActivityPub | 6 | 3 | 3 (inbox POST) |
|
||||
| Package | 4 | 4 | 0 |
|
||||
| Settings | 4 | 4 | 0 |
|
||||
| **Total** | **450** | **411** | **39** |
|
||||
|
||||
## Integration Points
|
||||
|
||||
### go-api
|
||||
|
||||
Services implement `DescribableGroup` from go-api Phase 3, enabling:
|
||||
- REST endpoint generation via ToolBridge
|
||||
- Auto-generated OpenAPI spec
|
||||
- Multi-language SDK codegen
|
||||
|
||||
### go-scm
|
||||
|
||||
go-scm/forge/ becomes a thin adapter importing go-forge types. Existing go-scm users are unaffected — the multi-provider abstraction layer stays.
|
||||
|
||||
### go-ai/mcp
|
||||
|
||||
The MCP subsystem can register go-forge operations as MCP tools, giving AI agents full Forgejo API access.
|
||||
|
||||
## 39 Unique Action Methods
|
||||
|
||||
These require hand-written implementation:
|
||||
|
||||
**Repository:** migrate, fork, generate (template), transfer, accept/reject transfer, mirror sync, push mirror sync, avatar, diffpatch, contents (multi-file modify)
|
||||
|
||||
**Pull Requests:** merge, update (rebase), submit review, dismiss/undismiss review
|
||||
|
||||
**Issues:** pin, set deadline, add reaction, start/stop stopwatch, add issue labels
|
||||
|
||||
**Comments:** add reaction
|
||||
|
||||
**Admin:** run cron task, adopt unadopted, rename user, set quota groups
|
||||
|
||||
**Misc:** render markdown, render raw markdown, render markup, GPG key verify
|
||||
|
||||
**ActivityPub:** inbox POST (actor, repo, user)
|
||||
|
||||
**Actions:** dispatch workflow
|
||||
|
||||
**Git:** set note on commit, test webhook
|
||||
2549
docs/plans/2026-02-21-go-forge-plan.md
Normal file
2549
docs/plans/2026-02-21-go-forge-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,7 @@
|
|||
# MCP Integration Implementation Plan
|
||||
|
||||
> **Status:** Completed. MCP command now lives in `go-ai/cmd/mcpcmd/`. Code examples below use the old `init()` + `RegisterCommands()` pattern — the current approach uses `cli.WithCommands()` (see cli-meta-package-design.md).
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add `core mcp serve` command with RAG and metrics tools, then configure the agentic-flows plugin to use it.
|
||||
657
docs/plans/completed/2026-02-20-go-api-design-original.md
Normal file
657
docs/plans/completed/2026-02-20-go-api-design-original.md
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
# go-api Design — HTTP Gateway + OpenAPI SDK Generation
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Author:** Virgil
|
||||
**Status:** Phase 1 + Phase 2 + Phase 3 Complete (176 tests in go-api)
|
||||
**Module:** `forge.lthn.ai/core/go-api`
|
||||
|
||||
## Problem
|
||||
|
||||
The Core Go ecosystem exposes 42+ tools via MCP (JSON-RPC), which is ideal for AI agents but inaccessible to regular HTTP clients, frontend applications, and third-party integrators. There is no unified HTTP gateway, no OpenAPI specification, and no generated SDKs.
|
||||
|
||||
Both external customers (Host UK products) and Lethean network peers need programmatic access to the same services. The gateway also serves web routes, static assets, and streaming endpoints — not just REST APIs.
|
||||
|
||||
## Solution
|
||||
|
||||
A `go-api` package that acts as the central HTTP gateway:
|
||||
|
||||
1. **Gin-based HTTP gateway** with extensible middleware via gin-contrib plugins
|
||||
2. **RouteGroup interface** that subsystems implement to register their own endpoints (API, web, or both)
|
||||
3. **WebSocket + SSE integration** for real-time streaming
|
||||
4. **OpenAPI 3.1 spec generation** via runtime SpecBuilder (not swaggo annotations)
|
||||
5. **SDK generation pipeline** targeting 11 languages via openapi-generator-cli
|
||||
|
||||
## Architecture
|
||||
|
||||
### Four-Protocol Access
|
||||
|
||||
Same backend services, four client protocols:
|
||||
|
||||
```
|
||||
┌─── REST (go-api) POST /v1/ml/generate → JSON
|
||||
│
|
||||
├─── GraphQL (gqlgen) mutation { mlGenerate(...) { response } }
|
||||
Client ────────────┤
|
||||
├─── WebSocket (go-ws) subscribe ml.generate → streaming
|
||||
│
|
||||
└─── MCP (go-ai) ml_generate → JSON-RPC
|
||||
```
|
||||
|
||||
### Dependency Graph
|
||||
|
||||
```
|
||||
go-api (Gin engine + middleware + OpenAPI)
|
||||
↑ imported by (each registers its own routes)
|
||||
├── go-ai/api/ → /v1/file/*, /v1/process/*, /v1/metrics/*
|
||||
├── go-ml/api/ → /v1/ml/*
|
||||
├── go-rag/api/ → /v1/rag/*
|
||||
├── go-agentic/api/ → /v1/tasks/*
|
||||
├── go-help/api/ → /v1/help/*
|
||||
└── go-ws/api/ → /ws (WebSocket upgrade)
|
||||
```
|
||||
|
||||
go-api has zero internal ecosystem dependencies. Subsystems import go-api, not the other way round.
|
||||
|
||||
### Subsystem Opt-In
|
||||
|
||||
Not every MCP tool becomes a REST endpoint. Each subsystem decides what to expose via a separate `RegisterAPI()` method, independent of MCP's `RegisterTools()`. A subsystem with 15 MCP tools might expose 5 REST endpoints.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
forge.lthn.ai/core/go-api
|
||||
├── api.go # Engine struct, New(), Serve(), Shutdown()
|
||||
├── middleware.go # Auth, CORS, rate limiting, request logging, recovery
|
||||
├── options.go # WithAddr, WithAuth, WithCORS, WithRateLimit, etc.
|
||||
├── group.go # RouteGroup interface + registration
|
||||
├── response.go # Envelope type, error responses, pagination
|
||||
├── docs/ # Generated swagger docs (swaggo output)
|
||||
├── sdk/ # SDK generation tooling / Makefile targets
|
||||
└── go.mod # forge.lthn.ai/core/go-api
|
||||
```
|
||||
|
||||
## Core Interface
|
||||
|
||||
```go
|
||||
// RouteGroup registers API routes onto a Gin router group.
|
||||
// Subsystems implement this to expose their endpoints.
|
||||
type RouteGroup interface {
|
||||
// Name returns the route group identifier (e.g. "ml", "rag", "tasks")
|
||||
Name() string
|
||||
// BasePath returns the URL prefix (e.g. "/v1/ml")
|
||||
BasePath() string
|
||||
// RegisterRoutes adds handlers to the provided router group
|
||||
RegisterRoutes(rg *gin.RouterGroup)
|
||||
}
|
||||
|
||||
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
|
||||
type StreamGroup interface {
|
||||
Channels() []string
|
||||
}
|
||||
```
|
||||
|
||||
### Subsystem Example (go-ml)
|
||||
|
||||
```go
|
||||
// In go-ml/api/routes.go
|
||||
package api
|
||||
|
||||
type Routes struct {
|
||||
service *ml.Service
|
||||
}
|
||||
|
||||
func NewRoutes(svc *ml.Service) *Routes {
|
||||
return &Routes{service: svc}
|
||||
}
|
||||
|
||||
func (r *Routes) Name() string { return "ml" }
|
||||
func (r *Routes) BasePath() string { return "/v1/ml" }
|
||||
|
||||
func (r *Routes) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.POST("/generate", r.Generate)
|
||||
rg.POST("/score", r.Score)
|
||||
rg.GET("/backends", r.Backends)
|
||||
rg.GET("/status", r.Status)
|
||||
}
|
||||
|
||||
func (r *Routes) Channels() []string {
|
||||
return []string{"ml.generate", "ml.status"}
|
||||
}
|
||||
|
||||
// @Summary Generate text via ML backend
|
||||
// @Tags ml
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param input body MLGenerateInput true "Generation parameters"
|
||||
// @Success 200 {object} Response[MLGenerateOutput]
|
||||
// @Router /v1/ml/generate [post]
|
||||
func (r *Routes) Generate(c *gin.Context) {
|
||||
var input MLGenerateInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(400, api.Fail("invalid_input", err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := r.service.Generate(c.Request.Context(), input.Backend, input.Prompt, ml.GenOpts{
|
||||
Temperature: input.Temperature,
|
||||
MaxTokens: input.MaxTokens,
|
||||
Model: input.Model,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(500, api.Fail("ml.generate_failed", err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(200, api.OK(MLGenerateOutput{
|
||||
Response: result,
|
||||
Backend: input.Backend,
|
||||
Model: input.Model,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
### Engine Wiring (in core CLI)
|
||||
|
||||
```go
|
||||
engine := api.New(
|
||||
api.WithAddr(":8080"),
|
||||
api.WithCORS("*"),
|
||||
api.WithAuth(api.BearerToken(cfg.APIKey)),
|
||||
api.WithRateLimit(100, time.Minute),
|
||||
api.WithWSHub(wsHub),
|
||||
)
|
||||
|
||||
engine.Register(mlapi.NewRoutes(mlService))
|
||||
engine.Register(ragapi.NewRoutes(ragService))
|
||||
engine.Register(agenticapi.NewRoutes(agenticService))
|
||||
|
||||
engine.Serve(ctx) // Blocks until context cancelled
|
||||
```
|
||||
|
||||
## Response Envelope
|
||||
|
||||
All endpoints return a consistent envelope:
|
||||
|
||||
```go
|
||||
type Response[T any] struct {
|
||||
Success bool `json:"success"`
|
||||
Data T `json:"data,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Duration string `json:"duration"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PerPage int `json:"per_page,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Helper functions:
|
||||
|
||||
```go
|
||||
func OK[T any](data T) Response[T]
|
||||
func Fail(code, message string) Response[any]
|
||||
func Paginated[T any](data T, page, perPage, total int) Response[T]
|
||||
```
|
||||
|
||||
## Middleware Stack
|
||||
|
||||
```go
|
||||
api.New(
|
||||
api.WithAddr(":8080"),
|
||||
api.WithCORS(api.CORSConfig{...}), // gin-contrib/cors
|
||||
api.WithAuth(api.BearerToken("...")), // Phase 1: simple bearer token
|
||||
api.WithRateLimit(100, time.Minute), // Per-IP sliding window
|
||||
api.WithRequestID(), // X-Request-ID header generation
|
||||
api.WithRecovery(), // Panic recovery → 500 response
|
||||
api.WithLogger(slog.Default()), // Structured request logging
|
||||
)
|
||||
```
|
||||
|
||||
Auth evolution path: bearer token → API keys → Authentik (OIDC/forward auth). Middleware slot stays the same.
|
||||
|
||||
## WebSocket Integration
|
||||
|
||||
go-api wraps the existing go-ws Hub as a first-class transport:
|
||||
|
||||
```go
|
||||
// Automatic registration:
|
||||
// GET /ws → WebSocket upgrade (go-ws Hub)
|
||||
|
||||
// Client subscribes: {"type":"subscribe","channel":"ml.generate"}
|
||||
// Events arrive: {"type":"event","channel":"ml.generate","data":{...}}
|
||||
// Client unsubscribes: {"type":"unsubscribe","channel":"ml.generate"}
|
||||
```
|
||||
|
||||
Subsystems implementing `StreamGroup` declare which channels they publish to. This metadata feeds into the OpenAPI spec as documentation.
|
||||
|
||||
## OpenAPI + SDK Generation
|
||||
|
||||
### Runtime Spec Generation (SpecBuilder)
|
||||
|
||||
swaggo annotations were rejected because routes are dynamic via RouteGroup, Response[T] generics break swaggo, and MCP tools already carry JSON Schema at runtime. Instead, a `SpecBuilder` constructs the full OpenAPI 3.1 spec from registered RouteGroups at runtime.
|
||||
|
||||
```go
|
||||
// Groups that implement DescribableGroup contribute endpoint metadata
|
||||
type DescribableGroup interface {
|
||||
RouteGroup
|
||||
Describe() []RouteDescription
|
||||
}
|
||||
|
||||
// SpecBuilder assembles the spec from all groups
|
||||
builder := &api.SpecBuilder{Title: "Core API", Description: "...", Version: "1.0.0"}
|
||||
spec, _ := builder.Build(engine.Groups())
|
||||
```
|
||||
|
||||
### MCP-to-REST Bridge (ToolBridge)
|
||||
|
||||
The `ToolBridge` converts MCP tool descriptors into REST POST endpoints and implements both `RouteGroup` and `DescribableGroup`. Each tool becomes `POST /{tool_name}`. Generic types are captured at MCP registration time via closures, enabling JSON unmarshalling to the correct input type at request time.
|
||||
|
||||
```go
|
||||
bridge := api.NewToolBridge("/v1/tools")
|
||||
mcp.BridgeToAPI(mcpService, bridge) // Populates bridge from MCP tool registry
|
||||
engine.Register(bridge) // Registers REST endpoints + OpenAPI metadata
|
||||
```
|
||||
|
||||
### Swagger UI
|
||||
|
||||
```go
|
||||
// Built-in at GET /swagger/*any
|
||||
// SpecBuilder output served via gin-swagger, cached via sync.Once
|
||||
api.New(api.WithSwagger("Core API", "...", "1.0.0"))
|
||||
```
|
||||
|
||||
### SDK Generation
|
||||
|
||||
```bash
|
||||
# Via openapi-generator-cli (11 languages supported)
|
||||
core api sdk --lang go # Generate Go SDK
|
||||
core api sdk --lang typescript-fetch,python # Multiple languages
|
||||
core api sdk --lang rust --output ./sdk/ # Custom output dir
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
core api spec # Emit OpenAPI JSON to stdout
|
||||
core api spec --format yaml # YAML variant
|
||||
core api spec --output spec.json # Write to file
|
||||
core api sdk --lang python # Generate Python SDK
|
||||
core api sdk --lang go,rust # Multiple SDKs
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `github.com/gin-gonic/gin` | HTTP framework |
|
||||
| `github.com/swaggo/gin-swagger` | Swagger UI middleware |
|
||||
| `github.com/gin-contrib/cors` | CORS middleware |
|
||||
| `github.com/gin-contrib/secure` | Security headers |
|
||||
| `github.com/gin-contrib/sessions` | Server-side sessions |
|
||||
| `github.com/gin-contrib/authz` | Casbin authorisation |
|
||||
| `github.com/gin-contrib/httpsign` | HTTP signature verification |
|
||||
| `github.com/gin-contrib/slog` | Structured request logging |
|
||||
| `github.com/gin-contrib/timeout` | Per-request timeouts |
|
||||
| `github.com/gin-contrib/gzip` | Gzip compression |
|
||||
| `github.com/gin-contrib/static` | Static file serving |
|
||||
| `github.com/gin-contrib/pprof` | Runtime profiling |
|
||||
| `github.com/gin-contrib/expvar` | Runtime metrics |
|
||||
| `github.com/gin-contrib/location/v2` | Reverse proxy detection |
|
||||
| `github.com/99designs/gqlgen` | GraphQL endpoint |
|
||||
| `go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin` | Distributed tracing |
|
||||
| `gopkg.in/yaml.v3` | YAML spec export |
|
||||
| `forge.lthn.ai/core/go-ws` | WebSocket Hub (existing) |
|
||||
|
||||
## Estimated Size
|
||||
|
||||
| Component | LOC |
|
||||
|-----------|-----|
|
||||
| Engine + options | ~200 |
|
||||
| Middleware | ~150 |
|
||||
| Response envelope | ~80 |
|
||||
| RouteGroup interface | ~30 |
|
||||
| WebSocket integration | ~60 |
|
||||
| Tests | ~300 |
|
||||
| **Total go-api** | **~820** |
|
||||
|
||||
Each subsystem's `api/` package adds ~100-200 LOC per route group.
|
||||
|
||||
## Phase 1 — Implemented (20 Feb 2026)
|
||||
|
||||
**Commit:** `17ae945` on Forge (`core/go-api`)
|
||||
|
||||
| Component | Status | Tests |
|
||||
|-----------|--------|-------|
|
||||
| Response envelope (OK, Fail, Paginated) | Done | 9 |
|
||||
| RouteGroup + StreamGroup interfaces | Done | 4 |
|
||||
| Engine (New, Register, Handler, Serve) | Done | 9 |
|
||||
| Bearer auth middleware | Done | 3 |
|
||||
| Request ID middleware | Done | 2 |
|
||||
| CORS middleware (gin-contrib/cors) | Done | 3 |
|
||||
| WebSocket endpoint | Done | 3 |
|
||||
| Swagger UI (gin-swagger) | Done | 2 |
|
||||
| Health endpoint | Done | 1 |
|
||||
| **Total** | **~840 LOC** | **36** |
|
||||
|
||||
**Integration proof:** go-ml/api/ registers 3 endpoints with 12 tests (`0c23858`).
|
||||
|
||||
## Phase 2 Wave 1 — Implemented (20 Feb 2026)
|
||||
|
||||
**Commits:** `6bb7195..daae6f7` on Forge (`core/go-api`)
|
||||
|
||||
| Component | Option | Dependency | Tests |
|
||||
|-----------|--------|------------|-------|
|
||||
| Authentik (forward auth + OIDC) | `WithAuthentik()` | `go-oidc/v3`, `oauth2` | 14 |
|
||||
| Security headers (HSTS, CSP, etc.) | `WithSecure()` | `gin-contrib/secure` | 8 |
|
||||
| Structured request logging | `WithSlog()` | `gin-contrib/slog` | 6 |
|
||||
| Per-request timeouts | `WithTimeout()` | `gin-contrib/timeout` | 5 |
|
||||
| Gzip compression | `WithGzip()` | `gin-contrib/gzip` | 5 |
|
||||
| Static file serving | `WithStatic()` | `gin-contrib/static` | 5 |
|
||||
| **Wave 1 Total** | | | **43** |
|
||||
|
||||
**Cumulative:** 76 tests (36 Phase 1 + 43 Wave 1 - 3 shared), all passing.
|
||||
|
||||
## Phase 2 Wave 2 — Implemented (20 Feb 2026)
|
||||
|
||||
**Commits:** `64a8b16..67dcc83` on Forge (`core/go-api`)
|
||||
|
||||
| Component | Option | Dependency | Tests | Notes |
|
||||
|-----------|--------|------------|-------|-------|
|
||||
| Brotli compression | `WithBrotli()` | `andybalholm/brotli` | 5 | Custom middleware; `gin-contrib/brotli` is empty stub |
|
||||
| Response caching | `WithCache()` | none (in-memory) | 5 | Custom middleware; `gin-contrib/cache` is per-handler, not global |
|
||||
| Server-side sessions | `WithSessions()` | `gin-contrib/sessions` | 5 | Cookie store, configurable name + secret |
|
||||
| Casbin authorisation | `WithAuthz()` | `gin-contrib/authz`, `casbin/v2` | 5 | Subject via Basic Auth; RBAC policy model |
|
||||
| **Wave 2 Total** | | | **20** | |
|
||||
|
||||
**Cumulative:** 102 passing tests (2 integration skipped), all green.
|
||||
|
||||
## Phase 2 Wave 3 — Implemented (20 Feb 2026)
|
||||
|
||||
**Commits:** `7b3f99e..d517fa2` on Forge (`core/go-api`)
|
||||
|
||||
| Component | Option | Dependency | Tests | Notes |
|
||||
|-----------|--------|------------|-------|-------|
|
||||
| HTTP signature verification | `WithHTTPSign()` | `gin-contrib/httpsign` | 5 | HMAC-SHA256; extensible via httpsign.Option |
|
||||
| Server-Sent Events | `WithSSE()` | none (custom SSEBroker) | 6 | Channel filtering, multi-client broadcast, GET /events |
|
||||
| Reverse proxy detection | `WithLocation()` | `gin-contrib/location/v2` | 5 | X-Forwarded-Host/Proto parsing |
|
||||
| Locale detection | `WithI18n()` | `golang.org/x/text/language` | 5 | Accept-Language parsing, message lookup, GetLocale/GetMessage |
|
||||
| GraphQL endpoint | `WithGraphQL()` | `99designs/gqlgen` | 5 | /graphql + optional /graphql/playground |
|
||||
| **Wave 3 Total** | | | **26** | |
|
||||
|
||||
**Cumulative:** 128 passing tests (2 integration skipped), all green.
|
||||
|
||||
## Phase 2 Wave 4 — Implemented (21 Feb 2026)
|
||||
|
||||
**Commits:** `32b3680..8ba1716` on Forge (`core/go-api`)
|
||||
|
||||
| Component | Option | Dependency | Tests | Notes |
|
||||
|-----------|--------|------------|-------|-------|
|
||||
| Runtime profiling | `WithPprof()` | `gin-contrib/pprof` | 5 | /debug/pprof/* endpoints, flag-based mount |
|
||||
| Runtime metrics | `WithExpvar()` | `gin-contrib/expvar` | 5 | /debug/vars endpoint, flag-based mount |
|
||||
| Distributed tracing | `WithTracing()` | `otelgin` + OpenTelemetry SDK | 5 | W3C traceparent propagation, span attributes |
|
||||
| **Wave 4 Total** | | | **15** | |
|
||||
|
||||
**Cumulative:** 143 passing tests (2 integration skipped), all green.
|
||||
|
||||
**Phase 2 complete.** All 4 waves implemented. Every planned plugin has a `With*()` option and tests.
|
||||
|
||||
## Phase 3 — OpenAPI Spec Generation + SDK Codegen (21 Feb 2026)
|
||||
|
||||
**Architecture:** Runtime OpenAPI generation via SpecBuilder (NOT swaggo annotations). Routes are dynamic via RouteGroup, Response[T] generics break swaggo, and MCP tools carry JSON Schema at runtime. A `ToolBridge` converts tool descriptors into RouteGroup + OpenAPI metadata. A `SpecBuilder` constructs the full OpenAPI 3.1 spec. SDK codegen wraps `openapi-generator-cli`.
|
||||
|
||||
### Wave 1: go-api (Tasks 1-5)
|
||||
|
||||
**Commits:** `465bd60..1910aec` on Forge (`core/go-api`)
|
||||
|
||||
| Component | File | Tests | Notes |
|
||||
|-----------|------|-------|-------|
|
||||
| DescribableGroup interface | `group.go` | 5 | Opt-in OpenAPI metadata for RouteGroups |
|
||||
| ToolBridge | `bridge.go` | 6 | Tool descriptors → POST endpoints + DescribableGroup |
|
||||
| SpecBuilder | `openapi.go` | 6 | OpenAPI 3.1 JSON with Response[T] envelope wrapping |
|
||||
| Swagger refactor | `swagger.go` | 5 | Replaced hardcoded empty spec with SpecBuilder |
|
||||
| Spec export | `export.go` | 5 | JSON + YAML export to file/writer |
|
||||
| SDK codegen | `codegen.go` | 5 | 11-language wrapper for openapi-generator-cli |
|
||||
| **Wave 1 Total** | | **32** | |
|
||||
|
||||
### Wave 2: go-ai MCP bridge (Tasks 6-7)
|
||||
|
||||
**Commits:** `2107eda..c37e1cf` on Forge (`core/go-ai`)
|
||||
|
||||
| Component | File | Tests | Notes |
|
||||
|-----------|------|-------|-------|
|
||||
| Tool registry | `mcp/registry.go` | 5 | Generic `addToolRecorded[In,Out]` captures types in closures |
|
||||
| BridgeToAPI | `mcp/bridge.go` | 5 | MCP tools → go-api ToolBridge, 10MB body limit, error classification |
|
||||
| **Wave 2 Total** | | **10** | |
|
||||
|
||||
### Wave 3: CLI commands (Tasks 8-9)
|
||||
|
||||
**Commit:** `d6eec4d` on Forge (`core/cli` dev branch)
|
||||
|
||||
| Component | File | Tests | Notes |
|
||||
|-----------|------|-------|-------|
|
||||
| `core api spec` | `cmd/api/cmd_spec.go` | 2 | JSON/YAML export, --output/--format flags |
|
||||
| `core api sdk` | `cmd/api/cmd_sdk.go` | 2 | --lang (required), --output, --spec, --package flags |
|
||||
| **Wave 3 Total** | | **4** | |
|
||||
|
||||
**Cumulative go-api:** 176 passing tests. **Phase 3 complete.**
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Subsystem tools excluded from bridge:** Subsystems call `mcp.AddTool` directly, bypassing `addToolRecorded`. Only the 10 built-in MCP tools appear in the REST bridge. Future: pass `*Service` to `RegisterTools` instead of `*mcp.Server`.
|
||||
- **Flat schema only:** `structSchema` reflection handles flat structs but does not recurse into nested structs. Adequate for current tool inputs.
|
||||
- **CLI spec produces empty bridge:** `core api spec` currently generates a spec with only `/health`. Full MCP integration requires wiring the MCP service into the CLI command.
|
||||
|
||||
## Phase 2 — Gin Plugin Roadmap (Complete)
|
||||
|
||||
All plugins drop in as `With*()` options on the Engine. No architecture changes needed.
|
||||
|
||||
### Security & Auth
|
||||
|
||||
| Plugin | Option | Purpose | Priority |
|
||||
|--------|--------|---------|----------|
|
||||
| ~~**Authentik**~~ | ~~`WithAuthentik()`~~ | ~~OIDC + forward auth integration.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/secure~~ | ~~`WithSecure()`~~ | ~~Security headers: HSTS, X-Frame-Options, X-Content-Type-Options, CSP.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/sessions~~ | ~~`WithSessions()`~~ | ~~Server-side sessions (cookie store). Web session management alongside Authentik tokens.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/authz~~ | ~~`WithAuthz()`~~ | ~~Casbin-based authorisation. Policy-driven access control via RBAC.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/httpsign~~ | ~~`WithHTTPSign()`~~ | ~~HTTP signature verification. HMAC-SHA256 with extensible options.~~ | ~~**Done**~~ |
|
||||
|
||||
### Performance & Reliability
|
||||
|
||||
| Plugin | Option | Purpose | Priority |
|
||||
|--------|--------|---------|----------|
|
||||
| ~~gin-contrib/cache~~ | ~~`WithCache()`~~ | ~~Response caching (in-memory). GET response caching with TTL, lazy eviction.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/timeout~~ | ~~`WithTimeout()`~~ | ~~Per-request timeouts.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/gzip~~ | ~~`WithGzip()`~~ | ~~Gzip response compression.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/brotli~~ | ~~`WithBrotli()`~~ | ~~Brotli compression via `andybalholm/brotli`. Custom middleware (gin-contrib stub empty).~~ | ~~**Done**~~ |
|
||||
|
||||
### Observability
|
||||
|
||||
| Plugin | Option | Purpose | Priority |
|
||||
|--------|--------|---------|----------|
|
||||
| ~~gin-contrib/slog~~ | ~~`WithSlog()`~~ | ~~Structured request logging via slog.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/pprof~~ | ~~`WithPprof()`~~ | ~~Runtime profiling endpoints at /debug/pprof/. Flag-based mount.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/expvar~~ | ~~`WithExpvar()`~~ | ~~Go runtime metrics at /debug/vars. Flag-based mount.~~ | ~~**Done**~~ |
|
||||
| ~~otelgin~~ | ~~`WithTracing()`~~ | ~~OpenTelemetry distributed tracing. W3C traceparent propagation.~~ | ~~**Done**~~ |
|
||||
|
||||
### Content & Streaming
|
||||
|
||||
| Plugin | Option | Purpose | Priority |
|
||||
|--------|--------|---------|----------|
|
||||
| ~~gin-contrib/static~~ | ~~`WithStatic()`~~ | ~~Serve static files.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/sse~~ | ~~`WithSSE()`~~ | ~~Server-Sent Events. Custom SSEBroker with channel filtering, GET /events.~~ | ~~**Done**~~ |
|
||||
| ~~gin-contrib/location~~ | ~~`WithLocation()`~~ | ~~Auto-detect scheme/host from X-Forwarded-* headers.~~ | ~~**Done**~~ |
|
||||
|
||||
### Query Layer
|
||||
|
||||
| Plugin | Option | Purpose | Priority |
|
||||
|--------|--------|---------|----------|
|
||||
| ~~99designs/gqlgen~~ | ~~`WithGraphQL()`~~ | ~~GraphQL endpoint at `/graphql` + optional playground. Accepts gqlgen ExecutableSchema.~~ | ~~**Done**~~ |
|
||||
|
||||
The GraphQL schema can be generated from the same Go Input/Output structs that define the REST endpoints. gqlgen produces an `http.Handler` that mounts directly on Gin. Subsystems opt-in via:
|
||||
|
||||
```go
|
||||
// Subsystems that want GraphQL implement this alongside RouteGroup
|
||||
type ResolverGroup interface {
|
||||
// RegisterResolvers adds query/mutation resolvers to the GraphQL schema
|
||||
RegisterResolvers(schema *graphql.Schema)
|
||||
}
|
||||
```
|
||||
|
||||
This means a subsystem like go-ml exposes:
|
||||
- **REST:** `POST /v1/ml/generate` (existing)
|
||||
- **GraphQL:** `mutation { mlGenerate(prompt: "...", backend: "mlx") { response, model } }` (same handler)
|
||||
- **MCP:** `ml_generate` tool (existing)
|
||||
|
||||
Four protocols, one set of handlers.
|
||||
|
||||
### Ecosystem Integration
|
||||
|
||||
| Plugin | Option | Purpose | Priority |
|
||||
|--------|--------|---------|----------|
|
||||
| ~~gin-contrib/i18n~~ | ~~`WithI18n()`~~ | ~~Locale detection via Accept-Language. Custom middleware using `golang.org/x/text/language`.~~ | ~~**Done**~~ |
|
||||
| [gin-contrib/graceful](https://github.com/gin-contrib/graceful) | — | Already implemented in Engine.Serve(). Could swap to this for more robust lifecycle management if needed. | — |
|
||||
| [gin-contrib/requestid](https://github.com/gin-contrib/requestid) | — | Already implemented. Theirs uses UUID, ours uses hex. Could swap for standards compliance. | — |
|
||||
|
||||
### Implementation Order
|
||||
|
||||
**Wave 1 (gateway hardening):** ~~Authentik, secure, slog, timeout, gzip, static~~ **DONE** (20 Feb 2026)
|
||||
**Wave 2 (performance + auth):** ~~cache, sessions, authz, brotli~~ **DONE** (20 Feb 2026)
|
||||
**Wave 3 (network + streaming):** ~~httpsign, sse, location, i18n, gqlgen~~ **DONE** (20 Feb 2026)
|
||||
**Wave 4 (observability):** ~~pprof, expvar, tracing~~ **DONE** (21 Feb 2026)
|
||||
|
||||
Each wave adds `With*()` options + tests. No breaking changes — existing code continues to work without any new options enabled.
|
||||
|
||||
## Authentik Integration
|
||||
|
||||
[Authentik](https://goauthentik.io/) is the identity provider and edge auth proxy. It handles user registration, login, MFA, social auth, SAML, and OIDC — so go-api doesn't have to.
|
||||
|
||||
### Two Integration Modes
|
||||
|
||||
**1. Forward Auth (web traffic)**
|
||||
|
||||
Traefik sits in front of go-api. For web routes, Traefik's `forwardAuth` middleware checks with Authentik before passing the request through. Authentik handles login flows, session cookies, and consent. go-api receives pre-authenticated requests with identity headers.
|
||||
|
||||
```
|
||||
Browser → Traefik → Authentik (forward auth) → go-api
|
||||
↓
|
||||
Login page (if unauthenticated)
|
||||
```
|
||||
|
||||
go-api reads trusted headers set by Authentik:
|
||||
```
|
||||
X-Authentik-Username: alice
|
||||
X-Authentik-Groups: admins,developers
|
||||
X-Authentik-Email: alice@example.com
|
||||
X-Authentik-Uid: <uuid>
|
||||
X-Authentik-Jwt: <signed token>
|
||||
```
|
||||
|
||||
**2. OIDC Token Validation (API traffic)**
|
||||
|
||||
API clients (SDKs, CLI tools, network peers) authenticate directly with Authentik's OAuth2 token endpoint, then send the JWT to go-api. go-api validates the JWT using Authentik's OIDC discovery endpoint (`.well-known/openid-configuration`).
|
||||
|
||||
```
|
||||
SDK client → Authentik (token endpoint) → receives JWT
|
||||
SDK client → go-api (Authorization: Bearer <jwt>) → validates via OIDC
|
||||
```
|
||||
|
||||
### Implementation in go-api
|
||||
|
||||
```go
|
||||
engine := api.New(
|
||||
api.WithAuthentik(api.AuthentikConfig{
|
||||
Issuer: "https://auth.lthn.ai/application/o/core-api/",
|
||||
ClientID: "core-api",
|
||||
TrustedProxy: true, // Trust X-Authentik-* headers from Traefik
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
`WithAuthentik()` adds middleware that:
|
||||
1. Checks for `X-Authentik-Jwt` header (forward auth mode) — validates signature, extracts claims
|
||||
2. Falls back to `Authorization: Bearer <jwt>` header (direct OIDC mode) — validates via JWKS
|
||||
3. Populates `c.Set("user", AuthentikUser{...})` in the Gin context for handlers to use
|
||||
4. Skips /health, /swagger, and any public paths
|
||||
|
||||
```go
|
||||
// In any handler:
|
||||
func (r *Routes) ListItems(c *gin.Context) {
|
||||
user := api.GetUser(c) // Returns *AuthentikUser or nil
|
||||
if user == nil {
|
||||
c.JSON(401, api.Fail("unauthorised", "Authentication required"))
|
||||
return
|
||||
}
|
||||
// user.Username, user.Groups, user.Email, user.UID available
|
||||
}
|
||||
```
|
||||
|
||||
### Auth Layers
|
||||
|
||||
```
|
||||
Authentik (identity) → WHO is this? (user, groups, email)
|
||||
↓
|
||||
go-api middleware → IS their token valid? (JWT verification)
|
||||
↓
|
||||
Casbin authz (optional) → CAN they do this? (role → endpoint policies)
|
||||
↓
|
||||
Handler → DOES this (business logic)
|
||||
```
|
||||
|
||||
Phase 1 bearer auth continues to work alongside Authentik — useful for service-to-service tokens, CI/CD, and development. `WithBearerAuth` and `WithAuthentik` can coexist.
|
||||
|
||||
### Authentik Deployment
|
||||
|
||||
Authentik runs as a Docker service alongside go-api, fronted by Traefik:
|
||||
- **auth.lthn.ai** — Authentik UI + OIDC endpoints (production)
|
||||
- **auth.leth.in** — Authentik for devnet/testnet
|
||||
- Traefik routes `/outpost.goauthentik.io/` to Authentik's embedded outpost for forward auth
|
||||
|
||||
### Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `github.com/coreos/go-oidc/v3` | OIDC discovery + JWT validation |
|
||||
| `golang.org/x/oauth2` | OAuth2 token exchange (for server-side flows) |
|
||||
|
||||
Both are standard Go libraries with no heavy dependencies.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- gRPC gateway
|
||||
- Built-in user registration/login (Authentik handles this)
|
||||
- API versioning beyond /v1/ prefix
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Phase 1 (Done)
|
||||
|
||||
1. ~~`core api serve` starts a Gin server with registered subsystem routes~~
|
||||
2. ~~WebSocket subscriptions work alongside REST~~
|
||||
3. ~~Swagger UI accessible at `/swagger/`~~
|
||||
4. ~~All endpoints return consistent Response envelope~~
|
||||
5. ~~Bearer token auth protects all routes~~
|
||||
6. ~~First subsystem integration (go-ml/api/) proves the pattern~~
|
||||
|
||||
### Phase 2 (Done)
|
||||
|
||||
7. ~~Security headers, compression, and caching active in production~~
|
||||
8. ~~Session-based auth alongside bearer tokens~~
|
||||
9. ~~HTTP signature verification for Lethean network peers~~
|
||||
10. ~~Static file serving for docs site and SDK downloads~~
|
||||
11. ~~GraphQL endpoint at `/graphql` with playground~~
|
||||
|
||||
### Phase 3 (Done)
|
||||
|
||||
12. ~~`core api spec` emits valid OpenAPI 3.1 JSON via runtime SpecBuilder~~
|
||||
13. ~~`core api sdk` generates SDKs for 11 languages via openapi-generator-cli~~
|
||||
14. ~~MCP tools bridged to REST endpoints via ToolBridge + BridgeToAPI~~
|
||||
15. ~~OpenAPI spec includes Response[T] envelope wrapping~~
|
||||
16. ~~Spec export to file in JSON and YAML formats~~
|
||||
1503
docs/plans/completed/2026-02-20-go-api-plan-original.md
Normal file
1503
docs/plans/completed/2026-02-20-go-api-plan-original.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,128 @@
|
|||
# CLI Meta-Package Restructure — Design
|
||||
|
||||
**Goal:** Transform `core/cli` from a 35K LOC monolith into a thin assembly repo that ships variant binaries. Domain repos own their commands. `go/pkg/cli` is the only import any domain package needs for CLI concerns.
|
||||
|
||||
**Architecture:** Commands register as framework services via `cli.WithCommands()`, passed to `cli.Main()`. Command code lives in the domain repos that own the business logic. The cli repo is a thin `main.go` that wires them together.
|
||||
|
||||
**Tech Stack:** go/pkg/cli (wraps cobra + charmbracelet), Core framework lifecycle, Taskfile
|
||||
|
||||
---
|
||||
|
||||
## 1. CLI SDK — The Single Import
|
||||
|
||||
`forge.lthn.ai/core/go/pkg/cli` is the **only** import domain packages use for CLI concerns. It wraps cobra, charmbracelet, and stdlib behind a stable API. If the underlying libraries change, only `go/pkg/cli` is touched — every domain repo is insulated.
|
||||
|
||||
### Already done
|
||||
|
||||
- **Cobra:** `Command` type alias, `NewCommand()`, `NewGroup()`, `NewRun()`, flag helpers (`StringFlag`, `BoolFlag`, `IntFlag`, `StringSliceFlag`), arg validators
|
||||
- **Output:** `Success()`, `Error()`, `Warn()`, `Info()`, `Table`, `Section()`, `Label()`, `Task()`, `Hint()`
|
||||
- **Prompts:** `Confirm()`, `Question()`, `Choose()`, `ChooseMulti()` with grammar-based action variants
|
||||
- **Styles:** 17 pre-built styles, `AnsiStyle` builder, Tailwind colour constants (47 hex values)
|
||||
- **Glyphs:** `:check:`, `:cross:`, `:warn:` etc. with Unicode/Emoji/ASCII themes
|
||||
- **Layout:** HLCRF composite renderer (Header/Left/Content/Right/Footer)
|
||||
- **Errors:** `Wrap()`, `WrapVerb()`, `ExitError`, `Is()`, `As()`
|
||||
- **Logging:** `LogDebug()`, `LogInfo()`, `LogWarn()`, `LogError()`, `LogSecurity()`
|
||||
- **TUI primitives:** `Spinner`, `ProgressBar`, `InteractiveList`, `TextInput`, `Viewport`, `RunTUI`
|
||||
- **Command registration:** `WithCommands(name, fn)` — registers commands as framework services
|
||||
|
||||
### Stubbed for later (interface exists, returns simple fallback)
|
||||
|
||||
- `Form(fields []FormField) (map[string]string, error)` — multi-field form (backed by huh later)
|
||||
- `FilePicker(opts ...FilePickerOption) (string, error)` — file browser
|
||||
- `Tabs(items []TabItem) error` — tabbed content panes
|
||||
|
||||
### Rule
|
||||
|
||||
Domain packages import `forge.lthn.ai/core/go/pkg/cli` and **nothing else** for CLI concerns. No `cobra`, no `lipgloss`, no `bubbletea`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Command Registration — Framework Lifecycle
|
||||
|
||||
Commands register through the Core framework's service lifecycle, not through global state or `init()` functions.
|
||||
|
||||
### The contract
|
||||
|
||||
Each domain repo exports an `Add*Commands(root *cli.Command)` function. The CLI binary wires it in via `cli.WithCommands()`:
|
||||
|
||||
```go
|
||||
// go-ai/cmd/daemon/cmd.go
|
||||
package daemon
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
// AddDaemonCommand adds the 'daemon' command group to the root.
|
||||
func AddDaemonCommand(root *cli.Command) {
|
||||
daemonCmd := cli.NewGroup("daemon", "Manage the core daemon", "")
|
||||
root.AddCommand(daemonCmd)
|
||||
// subcommands...
|
||||
}
|
||||
```
|
||||
|
||||
No `init()`. No blank imports. No `cli.RegisterCommands()`.
|
||||
|
||||
### How it works
|
||||
|
||||
`cli.WithCommands(name, fn)` wraps the registration function as a framework service implementing `Startable`. During `Core.ServiceStartup()`, the service's `OnStartup()` casts `Core.App` to `*cobra.Command` and calls the registration function. Core services (i18n, log, workspace) start first since they're registered before command services.
|
||||
|
||||
```go
|
||||
// cli/main.go
|
||||
func main() {
|
||||
cli.Main(
|
||||
cli.WithCommands("config", config.AddConfigCommands),
|
||||
cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// ...
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Migration status (completed)
|
||||
|
||||
| Source | Destination | Status |
|
||||
|--------|-------------|--------|
|
||||
| `cmd/dev, setup, qa, docs, gitcmd, monitor` | `go-devops/cmd/` | Done |
|
||||
| `cmd/lab` | `go-ai/cmd/` | Done |
|
||||
| `cmd/workspace` | `go-agentic/cmd/` | Done |
|
||||
| `cmd/go` | `core/go/cmd/gocmd` | Done |
|
||||
| `cmd/vanity-import, community` | `go-devops/cmd/` | Done |
|
||||
| `cmd/updater` | `go-update` | Done (own repo) |
|
||||
| `cmd/daemon, mcpcmd, security` | `go-ai/cmd/` | Done |
|
||||
| `cmd/crypt` | `go-crypt/cmd/` | Done |
|
||||
| `cmd/rag` | `go-rag/cmd/` | Done |
|
||||
| `cmd/unifi` | `go-netops/cmd/` | Done |
|
||||
| `cmd/api` | `go-api/cmd/` | Done |
|
||||
| `cmd/collect, forge, gitea` | `go-scm/cmd/` | Done |
|
||||
| `cmd/deploy, prod, vm` | `go-devops/cmd/` | Done |
|
||||
|
||||
### Stays in cli/ (meta/framework commands)
|
||||
|
||||
`config`, `doctor`, `help`, `module`, `pkgcmd`, `plugin`, `session`
|
||||
|
||||
---
|
||||
|
||||
## 3. Variant Binaries (future)
|
||||
|
||||
The cli/ repo can produce variant binaries by creating multiple `main.go` files that wire different sets of commands.
|
||||
|
||||
```
|
||||
cli/
|
||||
├── main.go # Current — meta commands only
|
||||
├── cmd/core-full/main.go # Full CLI — all ecosystem commands
|
||||
├── cmd/core-ci/main.go # CI agent dispatch + SCM
|
||||
├── cmd/core-mlx/main.go # ML inference subprocess
|
||||
└── cmd/core-ops/main.go # DevOps + infra management
|
||||
```
|
||||
|
||||
Each variant calls `cli.Main()` with its specific `cli.WithCommands()` set. No blank imports needed.
|
||||
|
||||
### Why variants matter
|
||||
|
||||
- `core-mlx` ships to the homelab as a ~10MB binary, not 50MB with devops/forge/netops
|
||||
- `core-ci` deploys to agent machines without ML or CGO dependencies
|
||||
- Adding a new variant = one new `main.go` with the right `WithCommands` calls
|
||||
|
||||
---
|
||||
|
||||
## 4. Current State
|
||||
|
||||
cli/ has 7 meta packages, one `main.go`, and zero business logic. Everything else lives in the domain repos that own it. Total cli/ LOC is ~2K.
|
||||
30
docs/plans/completed/cli-meta-package.md
Normal file
30
docs/plans/completed/cli-meta-package.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# CLI Meta-Package Restructure — Completed
|
||||
|
||||
**Completed:** 22 Feb 2026
|
||||
|
||||
## What Was Done
|
||||
|
||||
`pkg/cli` was extracted from `core/go` into its own Go module at `forge.lthn.ai/core/cli`. This made the CLI SDK a first-class, independently versioned package rather than a subdirectory of the Go foundation repo.
|
||||
|
||||
Following the extraction, an ecosystem-wide import path migration updated all consumers from the old path to the new one:
|
||||
|
||||
- Old: `forge.lthn.ai/core/go/pkg/cli`
|
||||
- New: `forge.lthn.ai/core/cli/pkg/cli`
|
||||
|
||||
## Scope
|
||||
|
||||
- **147+ files** updated across **10 repos**
|
||||
- All repos build clean after migration
|
||||
|
||||
## Repos Migrated
|
||||
|
||||
`core/cli`, `core/go`, `go-devops`, `go-ai`, `go-agentic`, `go-crypt`, `go-rag`, `go-scm`, `go-api`, `go-update`
|
||||
|
||||
## Key Outcomes
|
||||
|
||||
- `forge.lthn.ai/core/cli/pkg/cli` is the single import for all CLI concerns across the ecosystem
|
||||
- Domain repos are insulated from cobra, lipgloss, and bubbletea — only `pkg/cli` imports them
|
||||
- Command registration uses the Core framework lifecycle via `cli.WithCommands()` — no `init()`, no global state
|
||||
- `core/cli` is a thin assembly repo (~2K LOC) with 7 meta packages; all business logic lives in domain repos
|
||||
- Variant binary pattern established: multiple `main.go` files can wire different `WithCommands` sets for targeted binaries (core-ci, core-mlx, core-ops, etc.)
|
||||
- Command migration from the old `core/cli` monolith to domain repos was completed in full (13 command groups moved)
|
||||
57
docs/plans/completed/go-api.md
Normal file
57
docs/plans/completed/go-api.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# go-api — Completion Summary
|
||||
|
||||
**Completed:** 21 February 2026
|
||||
**Module:** `forge.lthn.ai/core/go-api`
|
||||
**Status:** Phases 1–3 complete, 176 tests passing
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Phase 1 — Core Framework (20 Feb 2026)
|
||||
|
||||
Gin-based HTTP engine with extensible middleware via `With*()` options. Key components:
|
||||
|
||||
- `RouteGroup` / `StreamGroup` interfaces — subsystems register their own endpoints
|
||||
- `Response[T]` envelope — `OK()`, `Fail()`, `Paginated()` generics
|
||||
- `Engine` — `New()`, `Register()`, `Handler()`, `Serve()` with graceful shutdown
|
||||
- Bearer auth, request ID, and CORS middleware
|
||||
- WebSocket endpoint wrapping a `go-ws` Hub
|
||||
- Swagger UI at `/swagger/` with runtime spec serving
|
||||
- `/health` endpoint always available without auth
|
||||
- First integration proof in `go-ml/api/` (3 endpoints, 12 tests)
|
||||
|
||||
### Phase 2 — Gin Plugin Stack (20–21 Feb 2026)
|
||||
|
||||
17 middleware plugins added across four waves, all as drop-in `With*()` options:
|
||||
|
||||
| Wave | Plugins |
|
||||
|------|---------|
|
||||
| 1 — Gateway hardening | Authentik (OIDC + forward auth), secure headers, structured slog, timeouts, gzip, static files |
|
||||
| 2 — Performance + auth | Brotli compression, in-memory response cache, server-side sessions, Casbin RBAC |
|
||||
| 3 — Network + streaming | HTTP signature verification, SSE broker, reverse proxy detection, i18n locale, GraphQL |
|
||||
| 4 — Observability | pprof, expvar, OpenTelemetry distributed tracing |
|
||||
|
||||
### Phase 3 — OpenAPI + SDK Codegen (21 Feb 2026)
|
||||
|
||||
Runtime spec generation (not swaggo annotations — incompatible with dynamic RouteGroups and `Response[T]` generics):
|
||||
|
||||
- `DescribableGroup` interface — opt-in OpenAPI metadata for route groups
|
||||
- `ToolBridge` — converts MCP tool descriptors into `POST /{tool_name}` REST endpoints
|
||||
- `SpecBuilder` — assembles full OpenAPI 3.1 JSON from registered groups at runtime
|
||||
- Spec export to JSON and YAML (`core api spec`)
|
||||
- SDK codegen wrapper for openapi-generator-cli, 11 languages (`core api sdk --lang go`)
|
||||
- `go-ai` `mcp/registry.go` — generic `addToolRecorded[In,Out]` captures types in closures
|
||||
- `go-ai` `mcp/bridge.go` — `BridgeToAPI()` populates ToolBridge from MCP tool registry
|
||||
- CLI commands: `core api spec`, `core api sdk` (in `core/cli` dev branch)
|
||||
|
||||
## Key Outcomes
|
||||
|
||||
- **176 tests** across go-api (143), go-ai bridge (10), and CLI commands (4), all passing
|
||||
- Zero internal ecosystem dependencies — subsystems import go-api, not the reverse
|
||||
- Authentik (OIDC) and bearer token auth coexist; Casbin adds RBAC on top
|
||||
- Four-protocol access pattern established: REST, GraphQL, WebSocket, MCP — same handlers
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Subsystem MCP tools registered via `mcp.AddTool` directly are excluded from the REST bridge (only the 10 built-in tools appear). Fix: pass `*Service` to `RegisterTools` instead of `*mcp.Server`.
|
||||
- `structSchema` reflection handles flat structs only; nested structs are not recursed.
|
||||
- `core api spec` currently emits a spec with only `/health`; full MCP wiring into the CLI command is pending.
|
||||
37
docs/plans/completed/mcp-integration.md
Normal file
37
docs/plans/completed/mcp-integration.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# MCP Integration — Completion Summary
|
||||
|
||||
**Completed:** 2026-02-05
|
||||
**Plan:** `docs/plans/2026-02-05-mcp-integration.md`
|
||||
|
||||
## What Was Built
|
||||
|
||||
### RAG Tools (`pkg/mcp/tools_rag.go`)
|
||||
Three MCP tools added to the existing `pkg/mcp` server:
|
||||
- `rag_query` — semantic search against Qdrant vector DB
|
||||
- `rag_ingest` — ingest a file or directory into a named collection
|
||||
- `rag_collections` — list available Qdrant collections (with optional stats)
|
||||
|
||||
### Metrics Tools (`pkg/mcp/tools_metrics.go`)
|
||||
Two MCP tools for agent activity tracking:
|
||||
- `metrics_record` — write a typed event (agent_id, repo, arbitrary data) to JSONL storage
|
||||
- `metrics_query` — query events with aggregation by type, repo, and agent; supports human-friendly duration strings (7d, 24h)
|
||||
|
||||
Also added `parseDuration()` helper for "Nd"/"Nh"/"Nm" duration strings.
|
||||
|
||||
### `core mcp serve` Command (`internal/cmd/mcpcmd/cmd_mcp.go`)
|
||||
New CLI sub-command registered via `cli.WithCommands()` (not `init()`).
|
||||
- Runs `pkg/mcp` server over stdio by default
|
||||
- TCP mode via `MCP_ADDR=:9000` environment variable
|
||||
- `--workspace` flag to restrict file operations to a directory
|
||||
|
||||
Registered in the full CLI variant. i18n strings added for all user-facing text.
|
||||
|
||||
### Plugin Configuration
|
||||
`.mcp.json` created for the `agentic-flows` Claude Code plugin, pointing to `core mcp serve`. Exposes all 15 tools to Claude Code agents via the `core-cli` MCP server name.
|
||||
|
||||
## Key Outcomes
|
||||
|
||||
- `core mcp serve` is the single entry point for all MCP tooling (file ops, RAG, metrics, language detection, process management, WebSocket, webview/CDP)
|
||||
- MCP command moved to `go-ai/cmd/mcpcmd/` in final form; the plan's `internal/cmd/mcpcmd/` path reflects the pre-extraction location
|
||||
- Registration pattern updated from `init()` + `RegisterCommands()` to `cli.WithCommands()` lifecycle hooks
|
||||
- Services required at runtime: Qdrant (localhost:6333), Ollama with nomic-embed-text (localhost:11434)
|
||||
62
docs/plans/completed/qk-bone-orientation.md
Normal file
62
docs/plans/completed/qk-bone-orientation.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Q/K Bone Orientation — Completion Summary
|
||||
|
||||
**Completed:** 23 February 2026
|
||||
**Repos:** go-inference, go-mlx, go-ml, LEM
|
||||
**Status:** All 7 tasks complete, 14 files changed (+917 lines), all tests passing
|
||||
|
||||
## What Was Built
|
||||
|
||||
### go-inference — AttentionSnapshot types (Task 1)
|
||||
|
||||
`AttentionSnapshot` struct and `AttentionInspector` optional interface. Backends expose attention data via type assertion — no breaking changes to `TextModel`.
|
||||
|
||||
### go-mlx — KV cache extraction (Task 2)
|
||||
|
||||
`InspectAttention` on `metalAdapter` runs a single prefill pass and extracts post-RoPE K vectors from each layer's KV cache. Tested against real Gemma3-1B (26 layers, 1 KV head via GQA, 256 head dim).
|
||||
|
||||
### go-ml — Adapter pass-through (Task 3)
|
||||
|
||||
`InspectAttention` on `InferenceAdapter` type-asserts the underlying `TextModel` to `AttentionInspector`. Returns clear error for unsupported backends.
|
||||
|
||||
### LEM — Analysis engine (Task 4)
|
||||
|
||||
Pure Go CPU math in `pkg/lem/attention.go`. Computes 5 BO metrics from raw K tensors:
|
||||
|
||||
- **Mean Coherence** — pairwise cosine similarity of K vectors within each layer
|
||||
- **Cross-Layer Alignment** — cosine similarity of mean K vectors between adjacent layers
|
||||
- **Head Entropy** — normalised Shannon entropy of K vector magnitudes across positions
|
||||
- **Phase-Lock Score** — fraction of head pairs above coherence threshold (0.7)
|
||||
- **Joint Collapse Count** — layers where cross-alignment drops below threshold (0.5)
|
||||
|
||||
Composite score: 30% coherence + 25% cross-alignment + 20% phase-lock + 15% entropy + 10% joint stability → 0-100 scale.
|
||||
|
||||
### LEM — CLI command (Task 5)
|
||||
|
||||
`lem score attention -model <path> -prompt <text> [-json]` loads a model, runs InspectAttention, and prints BO metrics.
|
||||
|
||||
### LEM — Distill integration (Task 6)
|
||||
|
||||
Opt-in attention scoring in the distill pipeline. Gated behind `scorer.attention: true` and `scorer.attention_min_score` in ai.yaml. Costs one extra prefill per probe.
|
||||
|
||||
### LEM — Feature vectors (Task 7)
|
||||
|
||||
19D full feature vector: 6D grammar + 8D heuristic + 5D attention (`mean_coherence`, `cross_alignment`, `head_entropy`, `phase_lock`, `joint_stability`). Ready for Poindexter KDTree spatial indexing.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Optional interface** — `AttentionInspector` via type assertion, not added to `TextModel`
|
||||
- **Named `BOResult`** — avoids collision with `metal.AttentionResult` in go-mlx
|
||||
- **Opt-in for distill** — extra prefill per probe is expensive, off by default
|
||||
- **Pure Go analysis** — zero CGO deps in the analysis engine; GPU data extracted once via `.Floats()`
|
||||
|
||||
## Commits
|
||||
|
||||
| Repo | SHA | Message |
|
||||
|------|-----|---------|
|
||||
| go-inference | `0f7263f` | feat: add AttentionInspector optional interface |
|
||||
| go-mlx | `c2177f7` | feat: implement AttentionInspector via KV cache extraction |
|
||||
| go-ml | `45e9fed` | feat: add InspectAttention pass-through |
|
||||
| LEM | `28309b2` | feat: add Q/K Bone Orientation analysis engine |
|
||||
| LEM | `e333192` | feat: add 'lem score attention' CLI |
|
||||
| LEM | `fbc636e` | feat: integrate attention scoring into distill pipeline |
|
||||
| LEM | `b621baa` | feat: add 19D full feature vector |
|
||||
47
go.mod
47
go.mod
|
|
@ -1,22 +1,23 @@
|
|||
module forge.lthn.ai/core/go
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require forge.lthn.ai/core/go-crypt v0.0.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/Snider/Borg v0.2.0
|
||||
forge.lthn.ai/Snider/Borg v0.2.1
|
||||
forge.lthn.ai/core/cli v0.0.4
|
||||
forge.lthn.ai/core/go-crypt v0.0.3
|
||||
forge.lthn.ai/core/go-devops v0.0.3
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/text v0.34.0
|
||||
google.golang.org/grpc v1.79.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.45.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -30,32 +31,54 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
gonum.org/v1/gonum v0.17.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
modernc.org/libc v1.67.7 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace forge.lthn.ai/core/go-crypt => ../go-crypt
|
||||
|
|
|
|||
97
go.sum
97
go.sum
|
|
@ -1,7 +1,13 @@
|
|||
forge.lthn.ai/Snider/Borg v0.2.1 h1:Uf/YtUJLL8jlxTCjvP4J+5GHe3LLeALGtbh7zj8d8Qc=
|
||||
forge.lthn.ai/Snider/Borg v0.2.1/go.mod h1:MVfolb7F6/A2LOIijcbBhWImu5db5NSMcSjvShMoMCA=
|
||||
forge.lthn.ai/core/cli v0.0.4 h1:jPpxtz1ULVJypgvPwdq0qH/G4PRMlyYiHo7dAy2uexI=
|
||||
forge.lthn.ai/core/cli v0.0.4/go.mod h1:YKLTEkGkJ8s9i43pbY6VmzoROMREI3hPRaEr+Qdq7Aw=
|
||||
forge.lthn.ai/core/go-crypt v0.0.3 h1:KG5dQstPfcohIitZJRF7jEdR4H1gjb4YrxjkzIQ8CGE=
|
||||
forge.lthn.ai/core/go-crypt v0.0.3/go.mod h1:BFHULU7hJBXkg4EXDO62pZvpUctzrzrW9x8gJEaBKX8=
|
||||
forge.lthn.ai/core/go-devops v0.0.3 h1:tiSZ2x6a/H1A1IYYUmaM+bEuZqT9Hot7KGCEFN6PSYY=
|
||||
forge.lthn.ai/core/go-devops v0.0.3/go.mod h1:V5/YaRsrDsYlSnCCJXKX7h1zSbaGyRdRQApPF5XwGAo=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
|
||||
github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
|
|
@ -24,6 +30,26 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIM
|
|||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
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/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
|
|
@ -31,12 +57,20 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
|
|
@ -56,8 +90,20 @@ 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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
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/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
|
|
@ -66,6 +112,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
|
@ -86,16 +134,33 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
|
|
@ -105,6 +170,14 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
|||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
|
@ -112,18 +185,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
|
||||
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
|
|
@ -132,8 +205,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
|
||||
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
30
pkg/cache/cache.go
vendored
30
pkg/cache/cache.go
vendored
|
|
@ -3,6 +3,7 @@ package cache
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
|
@ -15,6 +16,7 @@ const DefaultTTL = 1 * time.Hour
|
|||
|
||||
// Cache represents a file-based cache.
|
||||
type Cache struct {
|
||||
medium io.Medium
|
||||
baseDir string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
|
@ -27,8 +29,13 @@ type Entry struct {
|
|||
}
|
||||
|
||||
// New creates a new cache instance.
|
||||
// If baseDir is empty, uses .core/cache in current directory
|
||||
func New(baseDir string, ttl time.Duration) (*Cache, error) {
|
||||
// If medium is nil, uses io.Local (filesystem).
|
||||
// If baseDir is empty, uses .core/cache in current directory.
|
||||
func New(medium io.Medium, baseDir string, ttl time.Duration) (*Cache, error) {
|
||||
if medium == nil {
|
||||
medium = io.Local
|
||||
}
|
||||
|
||||
if baseDir == "" {
|
||||
// Use .core/cache in current working directory
|
||||
cwd, err := os.Getwd()
|
||||
|
|
@ -43,11 +50,12 @@ func New(baseDir string, ttl time.Duration) (*Cache, error) {
|
|||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := io.Local.EnsureDir(baseDir); err != nil {
|
||||
if err := medium.EnsureDir(baseDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Cache{
|
||||
medium: medium,
|
||||
baseDir: baseDir,
|
||||
ttl: ttl,
|
||||
}, nil
|
||||
|
|
@ -62,9 +70,9 @@ func (c *Cache) Path(key string) string {
|
|||
func (c *Cache) Get(key string, dest interface{}) (bool, error) {
|
||||
path := c.Path(key)
|
||||
|
||||
dataStr, err := io.Local.Read(path)
|
||||
dataStr, err := c.medium.Read(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
|
|
@ -94,7 +102,7 @@ func (c *Cache) Set(key string, data interface{}) error {
|
|||
path := c.Path(key)
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := io.Local.EnsureDir(filepath.Dir(path)); err != nil {
|
||||
if err := c.medium.EnsureDir(filepath.Dir(path)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -115,14 +123,14 @@ func (c *Cache) Set(key string, data interface{}) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return io.Local.Write(path, string(entryBytes))
|
||||
return c.medium.Write(path, string(entryBytes))
|
||||
}
|
||||
|
||||
// Delete removes an item from the cache.
|
||||
func (c *Cache) Delete(key string) error {
|
||||
path := c.Path(key)
|
||||
err := io.Local.Delete(path)
|
||||
if os.IsNotExist(err) {
|
||||
err := c.medium.Delete(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
|
|
@ -130,14 +138,14 @@ func (c *Cache) Delete(key string) error {
|
|||
|
||||
// Clear removes all cached items.
|
||||
func (c *Cache) Clear() error {
|
||||
return io.Local.DeleteAll(c.baseDir)
|
||||
return c.medium.DeleteAll(c.baseDir)
|
||||
}
|
||||
|
||||
// Age returns how old a cached item is, or -1 if not cached.
|
||||
func (c *Cache) Age(key string) time.Duration {
|
||||
path := c.Path(key)
|
||||
|
||||
dataStr, err := io.Local.Read(path)
|
||||
dataStr, err := c.medium.Read(path)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
|
|
|||
13
pkg/cache/cache_test.go
vendored
13
pkg/cache/cache_test.go
vendored
|
|
@ -5,11 +5,14 @@ import (
|
|||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cache"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
)
|
||||
|
||||
func TestCache(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
c, err := cache.New(baseDir, 1*time.Minute)
|
||||
m := io.NewMockMedium()
|
||||
// Use a path that MockMedium will understand
|
||||
baseDir := "/tmp/cache"
|
||||
c, err := cache.New(m, baseDir, 1*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache: %v", err)
|
||||
}
|
||||
|
|
@ -54,7 +57,7 @@ func TestCache(t *testing.T) {
|
|||
}
|
||||
|
||||
// Test Expiry
|
||||
cshort, err := cache.New(t.TempDir(), 10*time.Millisecond)
|
||||
cshort, err := cache.New(m, "/tmp/cache-short", 10*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create short-lived cache: %v", err)
|
||||
}
|
||||
|
|
@ -90,8 +93,8 @@ func TestCache(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCacheDefaults(t *testing.T) {
|
||||
// Test default TTL (uses cwd/.core/cache)
|
||||
c, err := cache.New("", 0)
|
||||
// Test default Medium (io.Local) and default TTL
|
||||
c, err := cache.New(nil, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache with defaults: %v", err)
|
||||
}
|
||||
|
|
|
|||
163
pkg/cli/ansi.go
163
pkg/cli/ansi.go
|
|
@ -1,163 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ANSI escape codes
|
||||
const (
|
||||
ansiReset = "\033[0m"
|
||||
ansiBold = "\033[1m"
|
||||
ansiDim = "\033[2m"
|
||||
ansiItalic = "\033[3m"
|
||||
ansiUnderline = "\033[4m"
|
||||
)
|
||||
|
||||
var (
|
||||
colorEnabled = true
|
||||
colorEnabledMu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
// NO_COLOR standard: https://no-color.org/
|
||||
// If NO_COLOR is set (to any value, including empty), disable colors.
|
||||
if _, exists := os.LookupEnv("NO_COLOR"); exists {
|
||||
colorEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
// TERM=dumb indicates a terminal without color support.
|
||||
if os.Getenv("TERM") == "dumb" {
|
||||
colorEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// ColorEnabled returns true if ANSI color output is enabled.
|
||||
func ColorEnabled() bool {
|
||||
colorEnabledMu.RLock()
|
||||
defer colorEnabledMu.RUnlock()
|
||||
return colorEnabled
|
||||
}
|
||||
|
||||
// SetColorEnabled enables or disables ANSI color output.
|
||||
// This overrides the NO_COLOR environment variable check.
|
||||
func SetColorEnabled(enabled bool) {
|
||||
colorEnabledMu.Lock()
|
||||
colorEnabled = enabled
|
||||
colorEnabledMu.Unlock()
|
||||
}
|
||||
|
||||
// AnsiStyle represents terminal text styling.
|
||||
// Use NewStyle() to create, chain methods, call Render().
|
||||
type AnsiStyle struct {
|
||||
bold bool
|
||||
dim bool
|
||||
italic bool
|
||||
underline bool
|
||||
fg string
|
||||
bg string
|
||||
}
|
||||
|
||||
// NewStyle creates a new empty style.
|
||||
func NewStyle() *AnsiStyle {
|
||||
return &AnsiStyle{}
|
||||
}
|
||||
|
||||
// Bold enables bold text.
|
||||
func (s *AnsiStyle) Bold() *AnsiStyle {
|
||||
s.bold = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Dim enables dim text.
|
||||
func (s *AnsiStyle) Dim() *AnsiStyle {
|
||||
s.dim = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Italic enables italic text.
|
||||
func (s *AnsiStyle) Italic() *AnsiStyle {
|
||||
s.italic = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Underline enables underlined text.
|
||||
func (s *AnsiStyle) Underline() *AnsiStyle {
|
||||
s.underline = true
|
||||
return s
|
||||
}
|
||||
|
||||
// Foreground sets foreground color from hex string.
|
||||
func (s *AnsiStyle) Foreground(hex string) *AnsiStyle {
|
||||
s.fg = fgColorHex(hex)
|
||||
return s
|
||||
}
|
||||
|
||||
// Background sets background color from hex string.
|
||||
func (s *AnsiStyle) Background(hex string) *AnsiStyle {
|
||||
s.bg = bgColorHex(hex)
|
||||
return s
|
||||
}
|
||||
|
||||
// Render applies the style to text.
|
||||
// Returns plain text if NO_COLOR is set or colors are disabled.
|
||||
func (s *AnsiStyle) Render(text string) string {
|
||||
if s == nil || !ColorEnabled() {
|
||||
return text
|
||||
}
|
||||
|
||||
var codes []string
|
||||
if s.bold {
|
||||
codes = append(codes, ansiBold)
|
||||
}
|
||||
if s.dim {
|
||||
codes = append(codes, ansiDim)
|
||||
}
|
||||
if s.italic {
|
||||
codes = append(codes, ansiItalic)
|
||||
}
|
||||
if s.underline {
|
||||
codes = append(codes, ansiUnderline)
|
||||
}
|
||||
if s.fg != "" {
|
||||
codes = append(codes, s.fg)
|
||||
}
|
||||
if s.bg != "" {
|
||||
codes = append(codes, s.bg)
|
||||
}
|
||||
|
||||
if len(codes) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
return strings.Join(codes, "") + text + ansiReset
|
||||
}
|
||||
|
||||
// fgColorHex converts a hex string to an ANSI foreground color code.
|
||||
func fgColorHex(hex string) string {
|
||||
r, g, b := hexToRGB(hex)
|
||||
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
|
||||
}
|
||||
|
||||
// bgColorHex converts a hex string to an ANSI background color code.
|
||||
func bgColorHex(hex string) string {
|
||||
r, g, b := hexToRGB(hex)
|
||||
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
|
||||
}
|
||||
|
||||
// hexToRGB converts a hex string to RGB values.
|
||||
func hexToRGB(hex string) (int, int, int) {
|
||||
hex = strings.TrimPrefix(hex, "#")
|
||||
if len(hex) != 6 {
|
||||
return 255, 255, 255
|
||||
}
|
||||
// Use 8-bit parsing since RGB values are 0-255, avoiding integer overflow on 32-bit systems.
|
||||
r, _ := strconv.ParseUint(hex[0:2], 16, 8)
|
||||
g, _ := strconv.ParseUint(hex[2:4], 16, 8)
|
||||
b, _ := strconv.ParseUint(hex[4:6], 16, 8)
|
||||
return int(r), int(g), int(b)
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnsiStyle_Render(t *testing.T) {
|
||||
// Ensure colors are enabled for this test
|
||||
SetColorEnabled(true)
|
||||
defer SetColorEnabled(true) // Reset after test
|
||||
|
||||
s := NewStyle().Bold().Foreground("#ff0000")
|
||||
got := s.Render("test")
|
||||
if got == "test" {
|
||||
t.Error("Expected styled output")
|
||||
}
|
||||
if !strings.Contains(got, "test") {
|
||||
t.Error("Output should contain text")
|
||||
}
|
||||
if !strings.Contains(got, "[1m") {
|
||||
t.Error("Output should contain bold code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorEnabled_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Test enabling
|
||||
SetColorEnabled(true)
|
||||
if !ColorEnabled() {
|
||||
t.Error("ColorEnabled should return true")
|
||||
}
|
||||
|
||||
// Test disabling
|
||||
SetColorEnabled(false)
|
||||
if ColorEnabled() {
|
||||
t.Error("ColorEnabled should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_ColorDisabled_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Disable colors
|
||||
SetColorEnabled(false)
|
||||
|
||||
s := NewStyle().Bold().Foreground("#ff0000")
|
||||
got := s.Render("test")
|
||||
|
||||
// Should return plain text without ANSI codes
|
||||
if got != "test" {
|
||||
t.Errorf("Expected plain 'test', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_ColorEnabled_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Enable colors
|
||||
SetColorEnabled(true)
|
||||
|
||||
s := NewStyle().Bold()
|
||||
got := s.Render("test")
|
||||
|
||||
// Should contain ANSI codes
|
||||
if !strings.Contains(got, "\033[") {
|
||||
t.Error("Expected ANSI codes when colors enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseASCII_Good(t *testing.T) {
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Enable first, then UseASCII should disable colors
|
||||
SetColorEnabled(true)
|
||||
UseASCII()
|
||||
if ColorEnabled() {
|
||||
t.Error("UseASCII should disable colors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_NilStyle_Good(t *testing.T) {
|
||||
var s *AnsiStyle
|
||||
got := s.Render("test")
|
||||
if got != "test" {
|
||||
t.Errorf("Nil style should return plain text, got %q", got)
|
||||
}
|
||||
}
|
||||
149
pkg/cli/app.go
149
pkg/cli/app.go
|
|
@ -1,149 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
"forge.lthn.ai/core/go/pkg/workspace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
// AppName is the CLI application name.
|
||||
AppName = "core"
|
||||
)
|
||||
|
||||
// Build-time variables set via ldflags (SemVer 2.0.0):
|
||||
//
|
||||
// go build -ldflags="-X forge.lthn.ai/core/go/pkg/cli.AppVersion=1.2.0 \
|
||||
// -X forge.lthn.ai/core/go/pkg/cli.BuildCommit=df94c24 \
|
||||
// -X forge.lthn.ai/core/go/pkg/cli.BuildDate=2026-02-06 \
|
||||
// -X forge.lthn.ai/core/go/pkg/cli.BuildPreRelease=dev.8"
|
||||
var (
|
||||
AppVersion = "0.0.0"
|
||||
BuildCommit = "unknown"
|
||||
BuildDate = "unknown"
|
||||
BuildPreRelease = ""
|
||||
)
|
||||
|
||||
// SemVer returns the full SemVer 2.0.0 version string.
|
||||
// - Release: 1.2.0
|
||||
// - Pre-release: 1.2.0-dev.8
|
||||
// - Full: 1.2.0-dev.8+df94c24.20260206
|
||||
func SemVer() string {
|
||||
v := AppVersion
|
||||
if BuildPreRelease != "" {
|
||||
v += "-" + BuildPreRelease
|
||||
}
|
||||
if BuildCommit != "unknown" {
|
||||
v += "+" + BuildCommit
|
||||
if BuildDate != "unknown" {
|
||||
v += "." + BuildDate
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Main initialises and runs the CLI application.
|
||||
// This is the main entry point for the CLI.
|
||||
// Exits with code 1 on error or panic.
|
||||
func Main() {
|
||||
// Recovery from panics
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("recovered from panic", "error", r, "stack", string(debug.Stack()))
|
||||
Shutdown()
|
||||
Fatal(fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialise CLI runtime with services
|
||||
if err := Init(Options{
|
||||
AppName: AppName,
|
||||
Version: SemVer(),
|
||||
Services: []framework.Option{
|
||||
framework.WithName("i18n", NewI18nService(I18nOptions{})),
|
||||
framework.WithName("log", NewLogService(log.Options{
|
||||
Level: log.LevelInfo,
|
||||
})),
|
||||
framework.WithName("workspace", workspace.New),
|
||||
},
|
||||
}); err != nil {
|
||||
Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
defer Shutdown()
|
||||
|
||||
// Add completion command to the CLI's root
|
||||
RootCmd().AddCommand(completionCmd)
|
||||
|
||||
if err := Execute(); err != nil {
|
||||
code := 1
|
||||
var exitErr *ExitError
|
||||
if As(err, &exitErr) {
|
||||
code = exitErr.Code
|
||||
}
|
||||
Error(err.Error())
|
||||
os.Exit(code)
|
||||
}
|
||||
}
|
||||
|
||||
// completionCmd generates shell completion scripts.
|
||||
var completionCmd = &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion script",
|
||||
Long: `Generate shell completion script for the specified shell.
|
||||
|
||||
To load completions:
|
||||
|
||||
Bash:
|
||||
$ source <(core completion bash)
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
# Linux:
|
||||
$ core completion bash > /etc/bash_completion.d/core
|
||||
# macOS:
|
||||
$ core completion bash > $(brew --prefix)/etc/bash_completion.d/core
|
||||
|
||||
Zsh:
|
||||
# If shell completion is not already enabled in your environment,
|
||||
# you will need to enable it. You can execute the following once:
|
||||
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ core completion zsh > "${fpath[1]}/_core"
|
||||
|
||||
# You will need to start a new shell for this setup to take effect.
|
||||
|
||||
Fish:
|
||||
$ core completion fish | source
|
||||
|
||||
# To load completions for each session, execute once:
|
||||
$ core completion fish > ~/.config/fish/completions/core.fish
|
||||
|
||||
PowerShell:
|
||||
PS> core completion powershell | Out-String | Invoke-Expression
|
||||
|
||||
# To load completions for every new session, run:
|
||||
PS> core completion powershell > core.ps1
|
||||
# and source this file from your PowerShell profile.
|
||||
`,
|
||||
DisableFlagsInUseLine: true,
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
_ = cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
_ = cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestPanicRecovery_Good verifies that the panic recovery mechanism
|
||||
// catches panics and calls the appropriate shutdown and error handling.
|
||||
func TestPanicRecovery_Good(t *testing.T) {
|
||||
t.Run("recovery captures panic value and stack", func(t *testing.T) {
|
||||
var recovered any
|
||||
var capturedStack []byte
|
||||
var shutdownCalled bool
|
||||
|
||||
// Simulate the panic recovery pattern from Main()
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
recovered = r
|
||||
capturedStack = debug.Stack()
|
||||
shutdownCalled = true // simulates Shutdown() call
|
||||
}
|
||||
}()
|
||||
|
||||
panic("test panic")
|
||||
}()
|
||||
|
||||
assert.Equal(t, "test panic", recovered)
|
||||
assert.True(t, shutdownCalled, "Shutdown should be called after panic recovery")
|
||||
assert.NotEmpty(t, capturedStack, "Stack trace should be captured")
|
||||
assert.Contains(t, string(capturedStack), "TestPanicRecovery_Good")
|
||||
})
|
||||
|
||||
t.Run("recovery handles error type panics", func(t *testing.T) {
|
||||
var recovered any
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
recovered = r
|
||||
}
|
||||
}()
|
||||
|
||||
panic(fmt.Errorf("error panic"))
|
||||
}()
|
||||
|
||||
err, ok := recovered.(error)
|
||||
assert.True(t, ok, "Recovered value should be an error")
|
||||
assert.Equal(t, "error panic", err.Error())
|
||||
})
|
||||
|
||||
t.Run("recovery handles nil panic gracefully", func(t *testing.T) {
|
||||
recoveryExecuted := false
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
recoveryExecuted = true
|
||||
}
|
||||
}()
|
||||
|
||||
// No panic occurs
|
||||
}()
|
||||
|
||||
assert.False(t, recoveryExecuted, "Recovery block should not execute without panic")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPanicRecovery_Bad tests error conditions in panic recovery.
|
||||
func TestPanicRecovery_Bad(t *testing.T) {
|
||||
t.Run("recovery handles concurrent panics", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
recoveryCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mu.Lock()
|
||||
recoveryCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
panic(fmt.Sprintf("panic from goroutine %d", id))
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
assert.Equal(t, 3, recoveryCount, "All goroutine panics should be recovered")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPanicRecovery_Ugly tests edge cases in panic recovery.
|
||||
func TestPanicRecovery_Ugly(t *testing.T) {
|
||||
t.Run("recovery handles typed panic values", func(t *testing.T) {
|
||||
type customError struct {
|
||||
code int
|
||||
msg string
|
||||
}
|
||||
|
||||
var recovered any
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
recovered = recover()
|
||||
}()
|
||||
|
||||
panic(customError{code: 500, msg: "internal error"})
|
||||
}()
|
||||
|
||||
ce, ok := recovered.(customError)
|
||||
assert.True(t, ok, "Should recover custom type")
|
||||
assert.Equal(t, 500, ce.code)
|
||||
assert.Equal(t, "internal error", ce.msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMainPanicRecoveryPattern verifies the exact pattern used in Main().
|
||||
func TestMainPanicRecoveryPattern(t *testing.T) {
|
||||
t.Run("pattern logs error and calls shutdown", func(t *testing.T) {
|
||||
var logBuffer bytes.Buffer
|
||||
var shutdownCalled bool
|
||||
var fatalErr error
|
||||
|
||||
// Mock implementations
|
||||
mockLogError := func(msg string, args ...any) {
|
||||
fmt.Fprintf(&logBuffer, msg, args...)
|
||||
}
|
||||
mockShutdown := func() {
|
||||
shutdownCalled = true
|
||||
}
|
||||
mockFatal := func(err error) {
|
||||
fatalErr = err
|
||||
}
|
||||
|
||||
// Execute the pattern from Main()
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
mockLogError("recovered from panic: %v", r)
|
||||
mockShutdown()
|
||||
mockFatal(fmt.Errorf("panic: %v", r))
|
||||
}
|
||||
}()
|
||||
|
||||
panic("simulated crash")
|
||||
}()
|
||||
|
||||
assert.Contains(t, logBuffer.String(), "recovered from panic: simulated crash")
|
||||
assert.True(t, shutdownCalled, "Shutdown must be called on panic")
|
||||
assert.NotNil(t, fatalErr, "Fatal must be called with error")
|
||||
assert.Equal(t, "panic: simulated crash", fatalErr.Error())
|
||||
})
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CheckBuilder provides fluent API for check results.
|
||||
type CheckBuilder struct {
|
||||
name string
|
||||
status string
|
||||
style *AnsiStyle
|
||||
icon string
|
||||
duration string
|
||||
}
|
||||
|
||||
// Check starts building a check result line.
|
||||
//
|
||||
// cli.Check("audit").Pass()
|
||||
// cli.Check("fmt").Fail().Duration("2.3s")
|
||||
// cli.Check("test").Skip()
|
||||
func Check(name string) *CheckBuilder {
|
||||
return &CheckBuilder{name: name}
|
||||
}
|
||||
|
||||
// Pass marks the check as passed.
|
||||
func (c *CheckBuilder) Pass() *CheckBuilder {
|
||||
c.status = "passed"
|
||||
c.style = SuccessStyle
|
||||
c.icon = Glyph(":check:")
|
||||
return c
|
||||
}
|
||||
|
||||
// Fail marks the check as failed.
|
||||
func (c *CheckBuilder) Fail() *CheckBuilder {
|
||||
c.status = "failed"
|
||||
c.style = ErrorStyle
|
||||
c.icon = Glyph(":cross:")
|
||||
return c
|
||||
}
|
||||
|
||||
// Skip marks the check as skipped.
|
||||
func (c *CheckBuilder) Skip() *CheckBuilder {
|
||||
c.status = "skipped"
|
||||
c.style = DimStyle
|
||||
c.icon = "-"
|
||||
return c
|
||||
}
|
||||
|
||||
// Warn marks the check as warning.
|
||||
func (c *CheckBuilder) Warn() *CheckBuilder {
|
||||
c.status = "warning"
|
||||
c.style = WarningStyle
|
||||
c.icon = Glyph(":warn:")
|
||||
return c
|
||||
}
|
||||
|
||||
// Duration adds duration to the check result.
|
||||
func (c *CheckBuilder) Duration(d string) *CheckBuilder {
|
||||
c.duration = d
|
||||
return c
|
||||
}
|
||||
|
||||
// Message adds a custom message instead of status.
|
||||
func (c *CheckBuilder) Message(msg string) *CheckBuilder {
|
||||
c.status = msg
|
||||
return c
|
||||
}
|
||||
|
||||
// String returns the formatted check line.
|
||||
func (c *CheckBuilder) String() string {
|
||||
icon := c.icon
|
||||
if c.style != nil {
|
||||
icon = c.style.Render(c.icon)
|
||||
}
|
||||
|
||||
status := c.status
|
||||
if c.style != nil && c.status != "" {
|
||||
status = c.style.Render(c.status)
|
||||
}
|
||||
|
||||
if c.duration != "" {
|
||||
return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
|
||||
}
|
||||
if status != "" {
|
||||
return fmt.Sprintf(" %s %s %s", icon, c.name, status)
|
||||
}
|
||||
return fmt.Sprintf(" %s %s", icon, c.name)
|
||||
}
|
||||
|
||||
// Print outputs the check result.
|
||||
func (c *CheckBuilder) Print() {
|
||||
fmt.Println(c.String())
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheckBuilder(t *testing.T) {
|
||||
UseASCII() // Deterministic output
|
||||
|
||||
// Pass
|
||||
c := Check("foo").Pass()
|
||||
got := c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Pass")
|
||||
}
|
||||
|
||||
// Fail
|
||||
c = Check("foo").Fail()
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Fail")
|
||||
}
|
||||
|
||||
// Skip
|
||||
c = Check("foo").Skip()
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Skip")
|
||||
}
|
||||
|
||||
// Warn
|
||||
c = Check("foo").Warn()
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Warn")
|
||||
}
|
||||
|
||||
// Duration
|
||||
c = Check("foo").Pass().Duration("1s")
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Duration")
|
||||
}
|
||||
|
||||
// Message
|
||||
c = Check("foo").Message("status")
|
||||
got = c.String()
|
||||
if got == "" {
|
||||
t.Error("Empty output for Message")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Type Re-export
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Command is the cobra command type.
|
||||
// Re-exported for convenience so packages don't need to import cobra directly.
|
||||
type Command = cobra.Command
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Builders
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// NewCommand creates a new command with a RunE handler.
|
||||
// This is the standard way to create commands that may return errors.
|
||||
//
|
||||
// cmd := cli.NewCommand("build", "Build the project", "", func(cmd *cli.Command, args []string) error {
|
||||
// // Build logic
|
||||
// return nil
|
||||
// })
|
||||
func NewCommand(use, short, long string, run func(cmd *Command, args []string) error) *Command {
|
||||
cmd := &Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
RunE: run,
|
||||
}
|
||||
if long != "" {
|
||||
cmd.Long = long
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewGroup creates a new command group (no RunE).
|
||||
// Use this for parent commands that only contain subcommands.
|
||||
//
|
||||
// devCmd := cli.NewGroup("dev", "Development commands", "")
|
||||
// devCmd.AddCommand(buildCmd, testCmd)
|
||||
func NewGroup(use, short, long string) *Command {
|
||||
cmd := &Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
}
|
||||
if long != "" {
|
||||
cmd.Long = long
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewRun creates a new command with a simple Run handler (no error return).
|
||||
// Use when the command cannot fail.
|
||||
//
|
||||
// cmd := cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
|
||||
// cli.Println("v1.0.0")
|
||||
// })
|
||||
func NewRun(use, short, long string, run func(cmd *Command, args []string)) *Command {
|
||||
cmd := &Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Run: run,
|
||||
}
|
||||
if long != "" {
|
||||
cmd.Long = long
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Flag Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// StringFlag adds a string flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var output string
|
||||
// cli.StringFlag(cmd, &output, "output", "o", "", "Output file path")
|
||||
func StringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// BoolFlag adds a boolean flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var verbose bool
|
||||
// cli.BoolFlag(cmd, &verbose, "verbose", "v", false, "Enable verbose output")
|
||||
func BoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().BoolVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().BoolVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// IntFlag adds an integer flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var count int
|
||||
// cli.IntFlag(cmd, &count, "count", "n", 10, "Number of items")
|
||||
func IntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().IntVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().IntVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// StringSliceFlag adds a string slice flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var tags []string
|
||||
// cli.StringSliceFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
|
||||
func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringSliceVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringSliceVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Persistent Flag Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// PersistentStringFlag adds a persistent string flag (inherited by subcommands).
|
||||
func PersistentStringFlag(cmd *Command, ptr *string, name, short, def, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().StringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().StringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentBoolFlag adds a persistent boolean flag (inherited by subcommands).
|
||||
func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().BoolVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().BoolVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// WithArgs sets the Args validation function for a command.
|
||||
// Returns the command for chaining.
|
||||
//
|
||||
// cmd := cli.NewCommand("build", "Build", "", run).WithArgs(cobra.ExactArgs(1))
|
||||
func WithArgs(cmd *Command, args cobra.PositionalArgs) *Command {
|
||||
cmd.Args = args
|
||||
return cmd
|
||||
}
|
||||
|
||||
// WithExample sets the Example field for a command.
|
||||
// Returns the command for chaining.
|
||||
func WithExample(cmd *Command, example string) *Command {
|
||||
cmd.Example = example
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ExactArgs returns a PositionalArgs that accepts exactly N arguments.
|
||||
func ExactArgs(n int) cobra.PositionalArgs {
|
||||
return cobra.ExactArgs(n)
|
||||
}
|
||||
|
||||
// MinimumNArgs returns a PositionalArgs that accepts minimum N arguments.
|
||||
func MinimumNArgs(n int) cobra.PositionalArgs {
|
||||
return cobra.MinimumNArgs(n)
|
||||
}
|
||||
|
||||
// MaximumNArgs returns a PositionalArgs that accepts maximum N arguments.
|
||||
func MaximumNArgs(n int) cobra.PositionalArgs {
|
||||
return cobra.MaximumNArgs(n)
|
||||
}
|
||||
|
||||
// NoArgs returns a PositionalArgs that accepts no arguments.
|
||||
func NoArgs() cobra.PositionalArgs {
|
||||
return cobra.NoArgs
|
||||
}
|
||||
|
||||
// ArbitraryArgs returns a PositionalArgs that accepts any arguments.
|
||||
func ArbitraryArgs() cobra.PositionalArgs {
|
||||
return cobra.ArbitraryArgs
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
// Package cli provides the CLI runtime and utilities.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CommandRegistration is a function that adds commands to the root.
|
||||
type CommandRegistration func(root *cobra.Command)
|
||||
|
||||
var (
|
||||
registeredCommands []CommandRegistration
|
||||
registeredCommandsMu sync.Mutex
|
||||
commandsAttached bool
|
||||
)
|
||||
|
||||
// RegisterCommands registers a function that adds commands to the CLI.
|
||||
// Call this in your package's init() to register commands.
|
||||
//
|
||||
// func init() {
|
||||
// cli.RegisterCommands(AddCommands)
|
||||
// }
|
||||
//
|
||||
// func AddCommands(root *cobra.Command) {
|
||||
// root.AddCommand(myCmd)
|
||||
// }
|
||||
func RegisterCommands(fn CommandRegistration) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
registeredCommands = append(registeredCommands, fn)
|
||||
|
||||
// If commands already attached (CLI already running), attach immediately
|
||||
if commandsAttached && instance != nil && instance.root != nil {
|
||||
fn(instance.root)
|
||||
}
|
||||
}
|
||||
|
||||
// attachRegisteredCommands calls all registered command functions.
|
||||
// Called by Init() after creating the root command.
|
||||
func attachRegisteredCommands(root *cobra.Command) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
|
||||
for _, fn := range registeredCommands {
|
||||
fn(root)
|
||||
}
|
||||
commandsAttached = true
|
||||
}
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
// Package cli provides the CLI runtime and utilities.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Mode represents the CLI execution mode.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
// ModeInteractive indicates TTY attached with coloured output.
|
||||
ModeInteractive Mode = iota
|
||||
// ModePipe indicates stdout is piped, colours disabled.
|
||||
ModePipe
|
||||
// ModeDaemon indicates headless execution, log-only output.
|
||||
ModeDaemon
|
||||
)
|
||||
|
||||
// String returns the string representation of the Mode.
|
||||
func (m Mode) String() string {
|
||||
switch m {
|
||||
case ModeInteractive:
|
||||
return "interactive"
|
||||
case ModePipe:
|
||||
return "pipe"
|
||||
case ModeDaemon:
|
||||
return "daemon"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// DetectMode determines the execution mode based on environment.
|
||||
// Checks CORE_DAEMON env var first, then TTY status.
|
||||
func DetectMode() Mode {
|
||||
if os.Getenv("CORE_DAEMON") == "1" {
|
||||
return ModeDaemon
|
||||
}
|
||||
if !IsTTY() {
|
||||
return ModePipe
|
||||
}
|
||||
return ModeInteractive
|
||||
}
|
||||
|
||||
// IsTTY returns true if stdout is a terminal.
|
||||
func IsTTY() bool {
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// IsStdinTTY returns true if stdin is a terminal.
|
||||
func IsStdinTTY() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
|
||||
// IsStderrTTY returns true if stderr is a terminal.
|
||||
func IsStderrTTY() bool {
|
||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
||||
}
|
||||
|
||||
// --- PID File Management ---
|
||||
|
||||
// PIDFile manages a process ID file for single-instance enforcement.
|
||||
type PIDFile struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPIDFile creates a PID file manager.
|
||||
func NewPIDFile(path string) *PIDFile {
|
||||
return &PIDFile{path: path}
|
||||
}
|
||||
|
||||
// Acquire writes the current PID to the file.
|
||||
// Returns error if another instance is running.
|
||||
func (p *PIDFile) Acquire() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Check if PID file exists
|
||||
if data, err := io.Local.Read(p.path); err == nil {
|
||||
pid, err := strconv.Atoi(data)
|
||||
if err == nil && pid > 0 {
|
||||
// Check if process is still running
|
||||
if process, err := os.FindProcess(pid); err == nil {
|
||||
if err := process.Signal(syscall.Signal(0)); err == nil {
|
||||
return fmt.Errorf("another instance is running (PID %d)", pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stale PID file, remove it
|
||||
_ = io.Local.Delete(p.path)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if dir := filepath.Dir(p.path); dir != "." {
|
||||
if err := io.Local.EnsureDir(dir); err != nil {
|
||||
return fmt.Errorf("failed to create PID directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write current PID
|
||||
pid := os.Getpid()
|
||||
if err := io.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
|
||||
return fmt.Errorf("failed to write PID file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Release removes the PID file.
|
||||
func (p *PIDFile) Release() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return io.Local.Delete(p.path)
|
||||
}
|
||||
|
||||
// Path returns the PID file path.
|
||||
func (p *PIDFile) Path() string {
|
||||
return p.path
|
||||
}
|
||||
|
||||
// --- Health Check Server ---
|
||||
|
||||
// HealthServer provides a minimal HTTP health check endpoint.
|
||||
type HealthServer struct {
|
||||
addr string
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
mu sync.Mutex
|
||||
ready bool
|
||||
checks []HealthCheck
|
||||
}
|
||||
|
||||
// HealthCheck is a function that returns nil if healthy.
|
||||
type HealthCheck func() error
|
||||
|
||||
// NewHealthServer creates a health check server.
|
||||
func NewHealthServer(addr string) *HealthServer {
|
||||
return &HealthServer{
|
||||
addr: addr,
|
||||
ready: true,
|
||||
}
|
||||
}
|
||||
|
||||
// AddCheck registers a health check function.
|
||||
func (h *HealthServer) AddCheck(check HealthCheck) {
|
||||
h.mu.Lock()
|
||||
h.checks = append(h.checks, check)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetReady sets the readiness status.
|
||||
func (h *HealthServer) SetReady(ready bool) {
|
||||
h.mu.Lock()
|
||||
h.ready = ready
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Start begins serving health check endpoints.
|
||||
// Endpoints:
|
||||
// - /health - liveness probe (always 200 if server is up)
|
||||
// - /ready - readiness probe (200 if ready, 503 if not)
|
||||
func (h *HealthServer) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
checks := h.checks
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, check := range checks {
|
||||
if err := check(); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
h.mu.Lock()
|
||||
ready := h.ready
|
||||
h.mu.Unlock()
|
||||
|
||||
if !ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintln(w, "not ready")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ready")
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", h.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on %s: %w", h.addr, err)
|
||||
}
|
||||
|
||||
h.listener = listener
|
||||
h.server = &http.Server{Handler: mux}
|
||||
|
||||
go func() {
|
||||
if err := h.server.Serve(listener); err != http.ErrServerClosed {
|
||||
LogError(fmt.Sprintf("health server error: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the health server.
|
||||
func (h *HealthServer) Stop(ctx context.Context) error {
|
||||
if h.server == nil {
|
||||
return nil
|
||||
}
|
||||
return h.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// Addr returns the actual address the server is listening on.
|
||||
// Useful when using port 0 for dynamic port assignment.
|
||||
func (h *HealthServer) Addr() string {
|
||||
if h.listener != nil {
|
||||
return h.listener.Addr().String()
|
||||
}
|
||||
return h.addr
|
||||
}
|
||||
|
||||
// --- Daemon Runner ---
|
||||
|
||||
// DaemonOptions configures daemon mode execution.
|
||||
type DaemonOptions struct {
|
||||
// PIDFile path for single-instance enforcement.
|
||||
// Leave empty to skip PID file management.
|
||||
PIDFile string
|
||||
|
||||
// ShutdownTimeout is the maximum time to wait for graceful shutdown.
|
||||
// Default: 30 seconds.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// HealthAddr is the address for health check endpoints.
|
||||
// Example: ":8080", "127.0.0.1:9000"
|
||||
// Leave empty to disable health checks.
|
||||
HealthAddr string
|
||||
|
||||
// HealthChecks are additional health check functions.
|
||||
HealthChecks []HealthCheck
|
||||
|
||||
// OnReload is called when SIGHUP is received.
|
||||
// Use for config reloading. Leave nil to ignore SIGHUP.
|
||||
OnReload func() error
|
||||
}
|
||||
|
||||
// Daemon manages daemon lifecycle.
|
||||
type Daemon struct {
|
||||
opts DaemonOptions
|
||||
pid *PIDFile
|
||||
health *HealthServer
|
||||
reload chan struct{}
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewDaemon creates a daemon runner with the given options.
|
||||
func NewDaemon(opts DaemonOptions) *Daemon {
|
||||
if opts.ShutdownTimeout == 0 {
|
||||
opts.ShutdownTimeout = 30 * time.Second
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
opts: opts,
|
||||
reload: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
if opts.PIDFile != "" {
|
||||
d.pid = NewPIDFile(opts.PIDFile)
|
||||
}
|
||||
|
||||
if opts.HealthAddr != "" {
|
||||
d.health = NewHealthServer(opts.HealthAddr)
|
||||
for _, check := range opts.HealthChecks {
|
||||
d.health.AddCheck(check)
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// Start initialises the daemon (PID file, health server).
|
||||
// Call this after cli.Init().
|
||||
func (d *Daemon) Start() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.running {
|
||||
return fmt.Errorf("daemon already running")
|
||||
}
|
||||
|
||||
// Acquire PID file
|
||||
if d.pid != nil {
|
||||
if err := d.pid.Acquire(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Start health server
|
||||
if d.health != nil {
|
||||
if err := d.health.Start(); err != nil {
|
||||
if d.pid != nil {
|
||||
_ = d.pid.Release()
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
d.running = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run blocks until the context is cancelled or a signal is received.
|
||||
// Handles graceful shutdown with the configured timeout.
|
||||
func (d *Daemon) Run(ctx context.Context) error {
|
||||
d.mu.Lock()
|
||||
if !d.running {
|
||||
d.mu.Unlock()
|
||||
return fmt.Errorf("daemon not started - call Start() first")
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Wait for context cancellation (from signal handler)
|
||||
<-ctx.Done()
|
||||
|
||||
return d.Stop()
|
||||
}
|
||||
|
||||
// Stop performs graceful shutdown.
|
||||
func (d *Daemon) Stop() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if !d.running {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
// Create shutdown context with timeout
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Stop health server
|
||||
if d.health != nil {
|
||||
d.health.SetReady(false)
|
||||
if err := d.health.Stop(shutdownCtx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("health server: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Release PID file
|
||||
if d.pid != nil {
|
||||
if err := d.pid.Release(); err != nil && !os.IsNotExist(err) {
|
||||
errs = append(errs, fmt.Errorf("pid file: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
d.running = false
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("shutdown errors: %v", errs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReady sets the daemon readiness status for health checks.
|
||||
func (d *Daemon) SetReady(ready bool) {
|
||||
if d.health != nil {
|
||||
d.health.SetReady(ready)
|
||||
}
|
||||
}
|
||||
|
||||
// HealthAddr returns the health server address, or empty if disabled.
|
||||
func (d *Daemon) HealthAddr() string {
|
||||
if d.health != nil {
|
||||
return d.health.Addr()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AddHealthCheck registers a health check function with the daemon's health server.
|
||||
// No-op if health server is disabled.
|
||||
func (d *Daemon) AddHealthCheck(check HealthCheck) {
|
||||
if d.health != nil {
|
||||
d.health.AddCheck(check)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Convenience Functions ---
|
||||
|
||||
// Run blocks until context is cancelled or signal received.
|
||||
// Simple helper for daemon mode without advanced features.
|
||||
//
|
||||
// cli.Init(cli.Options{AppName: "myapp"})
|
||||
// defer cli.Shutdown()
|
||||
// cli.Run(cli.Context())
|
||||
func Run(ctx context.Context) error {
|
||||
mustInit()
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// RunWithTimeout wraps Run with a graceful shutdown timeout.
|
||||
// The returned function should be deferred to replace cli.Shutdown().
|
||||
//
|
||||
// cli.Init(cli.Options{AppName: "myapp"})
|
||||
// shutdown := cli.RunWithTimeout(30 * time.Second)
|
||||
// defer shutdown()
|
||||
// cli.Run(cli.Context())
|
||||
func RunWithTimeout(timeout time.Duration) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Create done channel for shutdown completion
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
Shutdown()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Clean shutdown
|
||||
case <-ctx.Done():
|
||||
// Timeout - force exit
|
||||
LogWarn("shutdown timeout exceeded, forcing exit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDetectMode(t *testing.T) {
|
||||
t.Run("daemon mode from env", func(t *testing.T) {
|
||||
t.Setenv("CORE_DAEMON", "1")
|
||||
assert.Equal(t, ModeDaemon, DetectMode())
|
||||
})
|
||||
|
||||
t.Run("mode string", func(t *testing.T) {
|
||||
assert.Equal(t, "interactive", ModeInteractive.String())
|
||||
assert.Equal(t, "pipe", ModePipe.String())
|
||||
assert.Equal(t, "daemon", ModeDaemon.String())
|
||||
assert.Equal(t, "unknown", Mode(99).String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPIDFile(t *testing.T) {
|
||||
t.Run("acquire and release", func(t *testing.T) {
|
||||
pidPath := t.TempDir() + "/test.pid"
|
||||
|
||||
pid := NewPIDFile(pidPath)
|
||||
|
||||
// Acquire should succeed
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
// File should exist with our PID
|
||||
data, err := os.ReadFile(pidPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, data)
|
||||
|
||||
// Release should remove file
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, statErr := os.Stat(pidPath)
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
})
|
||||
|
||||
t.Run("stale pid file", func(t *testing.T) {
|
||||
pidPath := t.TempDir() + "/stale.pid"
|
||||
|
||||
// Write a stale PID (non-existent process)
|
||||
err := os.WriteFile(pidPath, []byte("999999999"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
pid := NewPIDFile(pidPath)
|
||||
|
||||
// Should acquire successfully (stale PID removed)
|
||||
err = pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("creates parent directory", func(t *testing.T) {
|
||||
pidPath := t.TempDir() + "/subdir/nested/test.pid"
|
||||
|
||||
pid := NewPIDFile(pidPath)
|
||||
|
||||
err := pid.Acquire()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, statErr := os.Stat(pidPath)
|
||||
assert.NoError(t, statErr)
|
||||
|
||||
err = pid.Release()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("path getter", func(t *testing.T) {
|
||||
pid := NewPIDFile("/tmp/test.pid")
|
||||
assert.Equal(t, "/tmp/test.pid", pid.Path())
|
||||
})
|
||||
}
|
||||
|
||||
func TestHealthServer(t *testing.T) {
|
||||
t.Run("health and ready endpoints", func(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0") // Random port
|
||||
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
||||
addr := hs.Addr()
|
||||
require.NotEmpty(t, addr)
|
||||
|
||||
// Health should be OK
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Ready should be OK by default
|
||||
resp, err = http.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Set not ready
|
||||
hs.SetReady(false)
|
||||
|
||||
resp, err = http.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("with health checks", func(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
|
||||
healthy := true
|
||||
hs.AddCheck(func() error {
|
||||
if !healthy {
|
||||
return assert.AnError
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
||||
addr := hs.Addr()
|
||||
|
||||
// Should be healthy
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Make unhealthy
|
||||
healthy = false
|
||||
|
||||
resp, err = http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestDaemon(t *testing.T) {
|
||||
t.Run("start and stop", func(t *testing.T) {
|
||||
pidPath := t.TempDir() + "/test.pid"
|
||||
|
||||
d := NewDaemon(DaemonOptions{
|
||||
PIDFile: pidPath,
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
ShutdownTimeout: 5 * time.Second,
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Health server should be running
|
||||
addr := d.HealthAddr()
|
||||
require.NotEmpty(t, addr)
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Stop should succeed
|
||||
err = d.Stop()
|
||||
require.NoError(t, err)
|
||||
|
||||
// PID file should be removed
|
||||
_, statErr := os.Stat(pidPath)
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
})
|
||||
|
||||
t.Run("double start fails", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = d.Stop() }()
|
||||
|
||||
err = d.Start()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already running")
|
||||
})
|
||||
|
||||
t.Run("run without start fails", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := d.Run(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not started")
|
||||
})
|
||||
|
||||
t.Run("set ready", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = d.Stop() }()
|
||||
|
||||
addr := d.HealthAddr()
|
||||
|
||||
// Initially ready
|
||||
resp, _ := http.Get("http://" + addr + "/ready")
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Set not ready
|
||||
d.SetReady(false)
|
||||
|
||||
resp, _ = http.Get("http://" + addr + "/ready")
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
})
|
||||
|
||||
t.Run("no health addr returns empty", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Empty(t, d.HealthAddr())
|
||||
})
|
||||
|
||||
t.Run("default shutdown timeout", func(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunWithTimeout(t *testing.T) {
|
||||
t.Run("creates shutdown function", func(t *testing.T) {
|
||||
// Just test that it returns a function
|
||||
shutdown := RunWithTimeout(100 * time.Millisecond)
|
||||
assert.NotNil(t, shutdown)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Error Creation (replace fmt.Errorf)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Err creates a new error from a format string.
|
||||
// This is a direct replacement for fmt.Errorf.
|
||||
func Err(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// Wrap wraps an error with a message.
|
||||
// Returns nil if err is nil.
|
||||
//
|
||||
// return cli.Wrap(err, "load config") // "load config: <original error>"
|
||||
func Wrap(err error, msg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
|
||||
// WrapVerb wraps an error using i18n grammar for "Failed to verb subject".
|
||||
// Uses the i18n.ActionFailed function for proper grammar composition.
|
||||
// Returns nil if err is nil.
|
||||
//
|
||||
// return cli.WrapVerb(err, "load", "config") // "Failed to load config: <original error>"
|
||||
func WrapVerb(err error, verb, subject string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
|
||||
// WrapAction wraps an error using i18n grammar for "Failed to verb".
|
||||
// Uses the i18n.ActionFailed function for proper grammar composition.
|
||||
// Returns nil if err is nil.
|
||||
//
|
||||
// return cli.WrapAction(err, "connect") // "Failed to connect: <original error>"
|
||||
func WrapAction(err error, verb string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, "")
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Error Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Is reports whether any error in err's tree matches target.
|
||||
// This is a re-export of errors.Is for convenience.
|
||||
func Is(err, target error) bool {
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
|
||||
// As finds the first error in err's tree that matches target.
|
||||
// This is a re-export of errors.As for convenience.
|
||||
func As(err error, target any) bool {
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
// Join returns an error that wraps the given errors.
|
||||
// This is a re-export of errors.Join for convenience.
|
||||
func Join(errs ...error) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ExitError represents an error that should cause the CLI to exit with a specific code.
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ExitError) Error() string {
|
||||
if e.Err == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *ExitError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// Exit creates a new ExitError with the given code and error.
|
||||
// Use this to return an error from a command with a specific exit code.
|
||||
func Exit(code int, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &ExitError{Code: code, Err: err}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fatal Functions (Deprecated - return error from command instead)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Fatal prints an error message to stderr, logs it, and exits with code 1.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
func Fatal(err error) {
|
||||
if err != nil {
|
||||
LogError("Fatal error", "err", err)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Fatalf prints a formatted error message to stderr, logs it, and exits with code 1.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
func Fatalf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
LogError("Fatal error", "msg", msg)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// FatalWrap prints a wrapped error message to stderr, logs it, and exits with code 1.
|
||||
// Does nothing if err is nil.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
//
|
||||
// cli.FatalWrap(err, "load config") // Prints "✗ load config: <error>" and exits
|
||||
func FatalWrap(err error, msg string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
LogError("Fatal error", "msg", msg, "err", err)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// FatalWrapVerb prints a wrapped error using i18n grammar to stderr, logs it, and exits with code 1.
|
||||
// Does nothing if err is nil.
|
||||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
//
|
||||
// cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: <error>" and exits
|
||||
func FatalWrapVerb(err error, verb, subject string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// GlyphTheme defines which symbols to use.
|
||||
type GlyphTheme int
|
||||
|
||||
const (
|
||||
// ThemeUnicode uses standard Unicode symbols.
|
||||
ThemeUnicode GlyphTheme = iota
|
||||
// ThemeEmoji uses Emoji symbols.
|
||||
ThemeEmoji
|
||||
// ThemeASCII uses ASCII fallback symbols.
|
||||
ThemeASCII
|
||||
)
|
||||
|
||||
var currentTheme = ThemeUnicode
|
||||
|
||||
// UseUnicode switches the glyph theme to Unicode.
|
||||
func UseUnicode() { currentTheme = ThemeUnicode }
|
||||
|
||||
// UseEmoji switches the glyph theme to Emoji.
|
||||
func UseEmoji() { currentTheme = ThemeEmoji }
|
||||
|
||||
// UseASCII switches the glyph theme to ASCII and disables colors.
|
||||
func UseASCII() {
|
||||
currentTheme = ThemeASCII
|
||||
SetColorEnabled(false)
|
||||
}
|
||||
|
||||
func glyphMap() map[string]string {
|
||||
switch currentTheme {
|
||||
case ThemeEmoji:
|
||||
return glyphMapEmoji
|
||||
case ThemeASCII:
|
||||
return glyphMapASCII
|
||||
default:
|
||||
return glyphMapUnicode
|
||||
}
|
||||
}
|
||||
|
||||
// Glyph converts a shortcode (e.g. ":check:") to its symbol based on the current theme.
|
||||
func Glyph(code string) string {
|
||||
if sym, ok := glyphMap()[code]; ok {
|
||||
return sym
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func compileGlyphs(x string) string {
|
||||
if x == "" {
|
||||
return ""
|
||||
}
|
||||
input := bytes.NewBufferString(x)
|
||||
output := bytes.NewBufferString("")
|
||||
|
||||
for {
|
||||
r, _, err := input.ReadRune()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if r == ':' {
|
||||
output.WriteString(replaceGlyph(input))
|
||||
} else {
|
||||
output.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func replaceGlyph(input *bytes.Buffer) string {
|
||||
code := bytes.NewBufferString(":")
|
||||
for {
|
||||
r, _, err := input.ReadRune()
|
||||
if err != nil {
|
||||
return code.String()
|
||||
}
|
||||
if r == ':' && code.Len() == 1 {
|
||||
return code.String() + replaceGlyph(input)
|
||||
}
|
||||
code.WriteRune(r)
|
||||
if unicode.IsSpace(r) {
|
||||
return code.String()
|
||||
}
|
||||
if r == ':' {
|
||||
return Glyph(code.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package cli
|
||||
|
||||
var glyphMapUnicode = map[string]string{
|
||||
":check:": "✓", ":cross:": "✗", ":warn:": "⚠", ":info:": "ℹ",
|
||||
":question:": "?", ":skip:": "○", ":dot:": "●", ":circle:": "◯",
|
||||
":arrow_right:": "→", ":arrow_left:": "←", ":arrow_up:": "↑", ":arrow_down:": "↓",
|
||||
":pointer:": "▶", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
|
||||
":corner:": "└", ":tee:": "├", ":pending:": "…", ":spinner:": "⠋",
|
||||
}
|
||||
|
||||
var glyphMapEmoji = map[string]string{
|
||||
":check:": "✅", ":cross:": "❌", ":warn:": "⚠️", ":info:": "ℹ️",
|
||||
":question:": "❓", ":skip:": "⏭️", ":dot:": "🔵", ":circle:": "⚪",
|
||||
":arrow_right:": "➡️", ":arrow_left:": "⬅️", ":arrow_up:": "⬆️", ":arrow_down:": "⬇️",
|
||||
":pointer:": "▶️", ":bullet:": "•", ":dash:": "─", ":pipe:": "│",
|
||||
":corner:": "└", ":tee:": "├", ":pending:": "⏳", ":spinner:": "🔄",
|
||||
}
|
||||
|
||||
var glyphMapASCII = map[string]string{
|
||||
":check:": "[OK]", ":cross:": "[FAIL]", ":warn:": "[WARN]", ":info:": "[INFO]",
|
||||
":question:": "[?]", ":skip:": "[SKIP]", ":dot:": "[*]", ":circle:": "[ ]",
|
||||
":arrow_right:": "->", ":arrow_left:": "<-", ":arrow_up:": "^", ":arrow_down:": "v",
|
||||
":pointer:": ">", ":bullet:": "*", ":dash:": "-", ":pipe:": "|",
|
||||
":corner:": "`", ":tee:": "+", ":pending:": "...", ":spinner:": "-",
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGlyph(t *testing.T) {
|
||||
UseUnicode()
|
||||
if Glyph(":check:") != "✓" {
|
||||
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
|
||||
}
|
||||
|
||||
UseASCII()
|
||||
if Glyph(":check:") != "[OK]" {
|
||||
t.Errorf("Expected [OK], got %s", Glyph(":check:"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGlyphs(t *testing.T) {
|
||||
UseUnicode()
|
||||
got := compileGlyphs("Status: :check:")
|
||||
if got != "Status: ✓" {
|
||||
t.Errorf("Expected Status: ✓, got %s", got)
|
||||
}
|
||||
}
|
||||
170
pkg/cli/i18n.go
170
pkg/cli/i18n.go
|
|
@ -1,170 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// I18nService wraps i18n as a Core service.
|
||||
type I18nService struct {
|
||||
*framework.ServiceRuntime[I18nOptions]
|
||||
svc *i18n.Service
|
||||
|
||||
// Collect mode state
|
||||
missingKeys []i18n.MissingKey
|
||||
missingKeysMu sync.Mutex
|
||||
}
|
||||
|
||||
// I18nOptions configures the i18n service.
|
||||
type I18nOptions struct {
|
||||
// Language overrides auto-detection (e.g., "en-GB", "de")
|
||||
Language string
|
||||
// Mode sets the translation mode (Normal, Strict, Collect)
|
||||
Mode i18n.Mode
|
||||
}
|
||||
|
||||
// NewI18nService creates an i18n service factory.
|
||||
func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) {
|
||||
return func(c *framework.Core) (any, error) {
|
||||
svc, err := i18n.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opts.Language != "" {
|
||||
_ = svc.SetLanguage(opts.Language)
|
||||
}
|
||||
|
||||
// Set mode if specified
|
||||
svc.SetMode(opts.Mode)
|
||||
|
||||
// Set as global default so i18n.T() works everywhere
|
||||
i18n.SetDefault(svc)
|
||||
|
||||
return &I18nService{
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
||||
svc: svc,
|
||||
missingKeys: make([]i18n.MissingKey, 0),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OnStartup initialises the i18n service.
|
||||
func (s *I18nService) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
|
||||
// Register action handler for collect mode
|
||||
if s.svc.Mode() == i18n.ModeCollect {
|
||||
i18n.OnMissingKey(s.handleMissingKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleMissingKey accumulates missing keys in collect mode.
|
||||
func (s *I18nService) handleMissingKey(mk i18n.MissingKey) {
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = append(s.missingKeys, mk)
|
||||
}
|
||||
|
||||
// MissingKeys returns all missing keys collected in collect mode.
|
||||
// Call this at the end of a QA session to report missing translations.
|
||||
func (s *I18nService) MissingKeys() []i18n.MissingKey {
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
result := make([]i18n.MissingKey, len(s.missingKeys))
|
||||
copy(result, s.missingKeys)
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearMissingKeys resets the collected missing keys.
|
||||
func (s *I18nService) ClearMissingKeys() {
|
||||
s.missingKeysMu.Lock()
|
||||
defer s.missingKeysMu.Unlock()
|
||||
s.missingKeys = s.missingKeys[:0]
|
||||
}
|
||||
|
||||
// SetMode changes the translation mode.
|
||||
func (s *I18nService) SetMode(mode i18n.Mode) {
|
||||
s.svc.SetMode(mode)
|
||||
|
||||
// Update action handler registration
|
||||
if mode == i18n.ModeCollect {
|
||||
i18n.OnMissingKey(s.handleMissingKey)
|
||||
} else {
|
||||
i18n.OnMissingKey(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode returns the current translation mode.
|
||||
func (s *I18nService) Mode() i18n.Mode {
|
||||
return s.svc.Mode()
|
||||
}
|
||||
|
||||
// Queries for i18n service
|
||||
|
||||
// QueryTranslate requests a translation.
|
||||
type QueryTranslate struct {
|
||||
Key string
|
||||
Args map[string]any
|
||||
}
|
||||
|
||||
func (s *I18nService) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
||||
switch m := q.(type) {
|
||||
case QueryTranslate:
|
||||
return s.svc.T(m.Key, m.Args), true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// T translates a key with optional arguments.
|
||||
func (s *I18nService) T(key string, args ...map[string]any) string {
|
||||
if len(args) > 0 {
|
||||
return s.svc.T(key, args[0])
|
||||
}
|
||||
return s.svc.T(key)
|
||||
}
|
||||
|
||||
// SetLanguage changes the current language.
|
||||
func (s *I18nService) SetLanguage(lang string) {
|
||||
_ = s.svc.SetLanguage(lang)
|
||||
}
|
||||
|
||||
// Language returns the current language.
|
||||
func (s *I18nService) Language() string {
|
||||
return s.svc.Language()
|
||||
}
|
||||
|
||||
// AvailableLanguages returns all available languages.
|
||||
func (s *I18nService) AvailableLanguages() []string {
|
||||
return s.svc.AvailableLanguages()
|
||||
}
|
||||
|
||||
// --- Package-level convenience ---
|
||||
|
||||
// T translates a key using the CLI's i18n service.
|
||||
// Falls back to the global i18n.T if CLI not initialised.
|
||||
func T(key string, args ...map[string]any) string {
|
||||
if instance == nil {
|
||||
// CLI not initialised, use global i18n
|
||||
if len(args) > 0 {
|
||||
return i18n.T(key, args[0])
|
||||
}
|
||||
return i18n.T(key)
|
||||
}
|
||||
|
||||
svc, err := framework.ServiceFor[*I18nService](instance.core, "i18n")
|
||||
if err != nil {
|
||||
// i18n service not registered, use global
|
||||
if len(args) > 0 {
|
||||
return i18n.T(key, args[0])
|
||||
}
|
||||
return i18n.T(key)
|
||||
}
|
||||
|
||||
return svc.T(key, args...)
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Region represents one of the 5 HLCRF regions.
|
||||
type Region rune
|
||||
|
||||
const (
|
||||
// RegionHeader is the top region of the layout.
|
||||
RegionHeader Region = 'H'
|
||||
// RegionLeft is the left sidebar region.
|
||||
RegionLeft Region = 'L'
|
||||
// RegionContent is the main content region.
|
||||
RegionContent Region = 'C'
|
||||
// RegionRight is the right sidebar region.
|
||||
RegionRight Region = 'R'
|
||||
// RegionFooter is the bottom region of the layout.
|
||||
RegionFooter Region = 'F'
|
||||
)
|
||||
|
||||
// Composite represents an HLCRF layout node.
|
||||
type Composite struct {
|
||||
variant string
|
||||
path string
|
||||
regions map[Region]*Slot
|
||||
parent *Composite
|
||||
}
|
||||
|
||||
// Slot holds content for a region.
|
||||
type Slot struct {
|
||||
region Region
|
||||
path string
|
||||
blocks []Renderable
|
||||
child *Composite
|
||||
}
|
||||
|
||||
// Renderable is anything that can be rendered to terminal.
|
||||
type Renderable interface {
|
||||
Render() string
|
||||
}
|
||||
|
||||
// StringBlock is a simple string that implements Renderable.
|
||||
type StringBlock string
|
||||
|
||||
// Render returns the string content.
|
||||
func (s StringBlock) Render() string { return string(s) }
|
||||
|
||||
// Layout creates a new layout from a variant string.
|
||||
func Layout(variant string) *Composite {
|
||||
c, err := ParseVariant(variant)
|
||||
if err != nil {
|
||||
return &Composite{variant: variant, regions: make(map[Region]*Slot)}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ParseVariant parses a variant string like "H[LC]C[HCF]F".
|
||||
func ParseVariant(variant string) (*Composite, error) {
|
||||
c := &Composite{
|
||||
variant: variant,
|
||||
path: "",
|
||||
regions: make(map[Region]*Slot),
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(variant) {
|
||||
r := Region(variant[i])
|
||||
if !isValidRegion(r) {
|
||||
return nil, fmt.Errorf("invalid region: %c", r)
|
||||
}
|
||||
|
||||
slot := &Slot{region: r, path: string(r)}
|
||||
c.regions[r] = slot
|
||||
i++
|
||||
|
||||
if i < len(variant) && variant[i] == '[' {
|
||||
end := findMatchingBracket(variant, i)
|
||||
if end == -1 {
|
||||
return nil, fmt.Errorf("unmatched bracket at %d", i)
|
||||
}
|
||||
nested, err := ParseVariant(variant[i+1 : end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nested.path = string(r) + "-"
|
||||
nested.parent = c
|
||||
slot.child = nested
|
||||
i = end + 1
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func isValidRegion(r Region) bool {
|
||||
return r == 'H' || r == 'L' || r == 'C' || r == 'R' || r == 'F'
|
||||
}
|
||||
|
||||
func findMatchingBracket(s string, start int) int {
|
||||
depth := 0
|
||||
for i := start; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '[':
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// H adds content to Header region.
|
||||
func (c *Composite) H(items ...any) *Composite { c.addToRegion(RegionHeader, items...); return c }
|
||||
|
||||
// L adds content to Left region.
|
||||
func (c *Composite) L(items ...any) *Composite { c.addToRegion(RegionLeft, items...); return c }
|
||||
|
||||
// C adds content to Content region.
|
||||
func (c *Composite) C(items ...any) *Composite { c.addToRegion(RegionContent, items...); return c }
|
||||
|
||||
// R adds content to Right region.
|
||||
func (c *Composite) R(items ...any) *Composite { c.addToRegion(RegionRight, items...); return c }
|
||||
|
||||
// F adds content to Footer region.
|
||||
func (c *Composite) F(items ...any) *Composite { c.addToRegion(RegionFooter, items...); return c }
|
||||
|
||||
func (c *Composite) addToRegion(r Region, items ...any) {
|
||||
slot, ok := c.regions[r]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
slot.blocks = append(slot.blocks, toRenderable(item))
|
||||
}
|
||||
}
|
||||
|
||||
func toRenderable(item any) Renderable {
|
||||
switch v := item.(type) {
|
||||
case Renderable:
|
||||
return v
|
||||
case string:
|
||||
return StringBlock(v)
|
||||
default:
|
||||
return StringBlock(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVariant(t *testing.T) {
|
||||
c, err := ParseVariant("H[LC]F")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
if _, ok := c.regions[RegionHeader]; !ok {
|
||||
t.Error("Expected Header region")
|
||||
}
|
||||
if _, ok := c.regions[RegionFooter]; !ok {
|
||||
t.Error("Expected Footer region")
|
||||
}
|
||||
|
||||
hSlot := c.regions[RegionHeader]
|
||||
if hSlot.child == nil {
|
||||
t.Error("Header should have child layout")
|
||||
} else {
|
||||
if _, ok := hSlot.child.regions[RegionLeft]; !ok {
|
||||
t.Error("Child should have Left region")
|
||||
}
|
||||
}
|
||||
}
|
||||
115
pkg/cli/log.go
115
pkg/cli/log.go
|
|
@ -1,115 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// LogLevel aliases for backwards compatibility.
|
||||
type LogLevel = log.Level
|
||||
|
||||
// Log level constants aliased from the log package.
|
||||
const (
|
||||
// LogLevelQuiet suppresses all output.
|
||||
LogLevelQuiet = log.LevelQuiet
|
||||
// LogLevelError shows only error messages.
|
||||
LogLevelError = log.LevelError
|
||||
// LogLevelWarn shows warnings and errors.
|
||||
LogLevelWarn = log.LevelWarn
|
||||
// LogLevelInfo shows info, warnings, and errors.
|
||||
LogLevelInfo = log.LevelInfo
|
||||
// LogLevelDebug shows all messages including debug.
|
||||
LogLevelDebug = log.LevelDebug
|
||||
)
|
||||
|
||||
// LogService wraps log.Service with CLI styling.
|
||||
type LogService struct {
|
||||
*log.Service
|
||||
}
|
||||
|
||||
// LogOptions configures the log service.
|
||||
type LogOptions = log.Options
|
||||
|
||||
// NewLogService creates a log service factory with CLI styling.
|
||||
func NewLogService(opts LogOptions) func(*framework.Core) (any, error) {
|
||||
return func(c *framework.Core) (any, error) {
|
||||
// Create the underlying service
|
||||
factory := log.NewService(opts)
|
||||
svc, err := factory(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logSvc := svc.(*log.Service)
|
||||
|
||||
// Apply CLI styles
|
||||
logSvc.StyleTimestamp = func(s string) string { return DimStyle.Render(s) }
|
||||
logSvc.StyleDebug = func(s string) string { return DimStyle.Render(s) }
|
||||
logSvc.StyleInfo = func(s string) string { return InfoStyle.Render(s) }
|
||||
logSvc.StyleWarn = func(s string) string { return WarningStyle.Render(s) }
|
||||
logSvc.StyleError = func(s string) string { return ErrorStyle.Render(s) }
|
||||
logSvc.StyleSecurity = func(s string) string { return SecurityStyle.Render(s) }
|
||||
|
||||
return &LogService{Service: logSvc}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Package-level convenience ---
|
||||
|
||||
// Log returns the CLI's log service, or nil if not available.
|
||||
func Log() *LogService {
|
||||
if instance == nil {
|
||||
return nil
|
||||
}
|
||||
svc, err := framework.ServiceFor[*LogService](instance.core, "log")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// LogDebug logs a debug message with optional key-value pairs if log service is available.
|
||||
func LogDebug(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
l.Debug(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogInfo logs an info message with optional key-value pairs if log service is available.
|
||||
func LogInfo(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
l.Info(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogWarn logs a warning message with optional key-value pairs if log service is available.
|
||||
func LogWarn(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
l.Warn(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogError logs an error message with optional key-value pairs if log service is available.
|
||||
func LogError(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
l.Error(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
||||
// LogSecurity logs a security message if log service is available.
|
||||
func LogSecurity(msg string, keyvals ...any) {
|
||||
if l := Log(); l != nil {
|
||||
// Ensure user context is included if not already present
|
||||
hasUser := false
|
||||
for i := 0; i < len(keyvals); i += 2 {
|
||||
if keyvals[i] == "user" {
|
||||
hasUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasUser {
|
||||
keyvals = append(keyvals, "user", log.Username())
|
||||
}
|
||||
l.Security(msg, keyvals...)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Blank prints an empty line.
|
||||
func Blank() {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Echo translates a key via i18n.T and prints with newline.
|
||||
// No automatic styling - use Success/Error/Warn/Info for styled output.
|
||||
func Echo(key string, args ...any) {
|
||||
fmt.Println(i18n.T(key, args...))
|
||||
}
|
||||
|
||||
// Print outputs formatted text (no newline).
|
||||
// Glyph shortcodes like :check: are converted.
|
||||
func Print(format string, args ...any) {
|
||||
fmt.Print(compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// Println outputs formatted text with newline.
|
||||
// Glyph shortcodes like :check: are converted.
|
||||
func Println(format string, args ...any) {
|
||||
fmt.Println(compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// Text prints arguments like fmt.Println, but handling glyphs.
|
||||
func Text(args ...any) {
|
||||
fmt.Println(compileGlyphs(fmt.Sprint(args...)))
|
||||
}
|
||||
|
||||
// Success prints a success message with checkmark (green).
|
||||
func Success(msg string) {
|
||||
fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + msg))
|
||||
}
|
||||
|
||||
// Successf prints a formatted success message.
|
||||
func Successf(format string, args ...any) {
|
||||
Success(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Error prints an error message with cross (red) to stderr and logs it.
|
||||
func Error(msg string) {
|
||||
LogError(msg)
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
}
|
||||
|
||||
// Errorf prints a formatted error message to stderr and logs it.
|
||||
func Errorf(format string, args ...any) {
|
||||
Error(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// ErrorWrap prints a wrapped error message to stderr and logs it.
|
||||
func ErrorWrap(err error, msg string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
Error(fmt.Sprintf("%s: %v", msg, err))
|
||||
}
|
||||
|
||||
// ErrorWrapVerb prints a wrapped error using i18n grammar to stderr and logs it.
|
||||
func ErrorWrapVerb(err error, verb, subject string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
Error(fmt.Sprintf("%s: %v", msg, err))
|
||||
}
|
||||
|
||||
// ErrorWrapAction prints a wrapped error using i18n grammar to stderr and logs it.
|
||||
func ErrorWrapAction(err error, verb string) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, "")
|
||||
Error(fmt.Sprintf("%s: %v", msg, err))
|
||||
}
|
||||
|
||||
// Warn prints a warning message with warning symbol (amber) to stderr and logs it.
|
||||
func Warn(msg string) {
|
||||
LogWarn(msg)
|
||||
fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+msg))
|
||||
}
|
||||
|
||||
// Warnf prints a formatted warning message to stderr and logs it.
|
||||
func Warnf(format string, args ...any) {
|
||||
Warn(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Info prints an info message with info symbol (blue).
|
||||
func Info(msg string) {
|
||||
fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + msg))
|
||||
}
|
||||
|
||||
// Infof prints a formatted info message.
|
||||
func Infof(format string, args ...any) {
|
||||
Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Dim prints dimmed text.
|
||||
func Dim(msg string) {
|
||||
fmt.Println(DimStyle.Render(msg))
|
||||
}
|
||||
|
||||
// Progress prints a progress indicator that overwrites the current line.
|
||||
// Uses i18n.Progress for gerund form ("Checking...").
|
||||
func Progress(verb string, current, total int, item ...string) {
|
||||
msg := i18n.Progress(verb)
|
||||
if len(item) > 0 && item[0] != "" {
|
||||
fmt.Printf("\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0])
|
||||
} else {
|
||||
fmt.Printf("\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressDone clears the progress line.
|
||||
func ProgressDone() {
|
||||
fmt.Print("\033[2K\r")
|
||||
}
|
||||
|
||||
// Label prints a "Label: value" line.
|
||||
func Label(word, value string) {
|
||||
fmt.Printf("%s %s\n", KeyStyle.Render(i18n.Label(word)), value)
|
||||
}
|
||||
|
||||
// Scanln reads from stdin.
|
||||
func Scanln(a ...any) (int, error) {
|
||||
return fmt.Scanln(a...)
|
||||
}
|
||||
|
||||
// Task prints a task header: "[label] message"
|
||||
//
|
||||
// cli.Task("php", "Running tests...") // [php] Running tests...
|
||||
// cli.Task("go", i18n.Progress("build")) // [go] Building...
|
||||
func Task(label, message string) {
|
||||
fmt.Printf("%s %s\n\n", DimStyle.Render("["+label+"]"), message)
|
||||
}
|
||||
|
||||
// Section prints a section header: "── SECTION ──"
|
||||
//
|
||||
// cli.Section("audit") // ── AUDIT ──
|
||||
func Section(name string) {
|
||||
header := "── " + strings.ToUpper(name) + " ──"
|
||||
fmt.Println(AccentStyle.Render(header))
|
||||
}
|
||||
|
||||
// Hint prints a labelled hint: "label: message"
|
||||
//
|
||||
// cli.Hint("install", "composer require vimeo/psalm")
|
||||
// cli.Hint("fix", "core php fmt --fix")
|
||||
func Hint(label, message string) {
|
||||
fmt.Printf(" %s %s\n", DimStyle.Render(label+":"), message)
|
||||
}
|
||||
|
||||
// Severity prints a severity-styled message.
|
||||
//
|
||||
// cli.Severity("critical", "SQL injection") // red, bold
|
||||
// cli.Severity("high", "XSS vulnerability") // orange
|
||||
// cli.Severity("medium", "Missing CSRF") // amber
|
||||
// cli.Severity("low", "Debug enabled") // gray
|
||||
func Severity(level, message string) {
|
||||
var style *AnsiStyle
|
||||
switch strings.ToLower(level) {
|
||||
case "critical":
|
||||
style = NewStyle().Bold().Foreground(ColourRed500)
|
||||
case "high":
|
||||
style = NewStyle().Bold().Foreground(ColourOrange500)
|
||||
case "medium":
|
||||
style = NewStyle().Foreground(ColourAmber500)
|
||||
case "low":
|
||||
style = NewStyle().Foreground(ColourGray500)
|
||||
default:
|
||||
style = DimStyle
|
||||
}
|
||||
fmt.Printf(" %s %s\n", style.Render("["+level+"]"), message)
|
||||
}
|
||||
|
||||
// Result prints a result line: "✓ message" or "✗ message"
|
||||
//
|
||||
// cli.Result(passed, "All tests passed")
|
||||
// cli.Result(false, "3 tests failed")
|
||||
func Result(passed bool, message string) {
|
||||
if passed {
|
||||
Success(message)
|
||||
} else {
|
||||
Error(message)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func captureOutput(f func()) string {
|
||||
oldOut := os.Stdout
|
||||
oldErr := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
os.Stderr = w
|
||||
|
||||
f()
|
||||
|
||||
_ = w.Close()
|
||||
os.Stdout = oldOut
|
||||
os.Stderr = oldErr
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestSemanticOutput(t *testing.T) {
|
||||
UseASCII()
|
||||
|
||||
// Test Success
|
||||
out := captureOutput(func() {
|
||||
Success("done")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Success output empty")
|
||||
}
|
||||
|
||||
// Test Error
|
||||
out = captureOutput(func() {
|
||||
Error("fail")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Error output empty")
|
||||
}
|
||||
|
||||
// Test Warn
|
||||
out = captureOutput(func() {
|
||||
Warn("warn")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Warn output empty")
|
||||
}
|
||||
|
||||
// Test Info
|
||||
out = captureOutput(func() {
|
||||
Info("info")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Info output empty")
|
||||
}
|
||||
|
||||
// Test Task
|
||||
out = captureOutput(func() {
|
||||
Task("task", "msg")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Task output empty")
|
||||
}
|
||||
|
||||
// Test Section
|
||||
out = captureOutput(func() {
|
||||
Section("section")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Section output empty")
|
||||
}
|
||||
|
||||
// Test Hint
|
||||
out = captureOutput(func() {
|
||||
Hint("hint", "msg")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Hint output empty")
|
||||
}
|
||||
|
||||
// Test Result
|
||||
out = captureOutput(func() {
|
||||
Result(true, "pass")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Result(true) output empty")
|
||||
}
|
||||
|
||||
out = captureOutput(func() {
|
||||
Result(false, "fail")
|
||||
})
|
||||
if out == "" {
|
||||
t.Error("Result(false) output empty")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var stdin = bufio.NewReader(os.Stdin)
|
||||
|
||||
// Prompt asks for text input with a default value.
|
||||
func Prompt(label, defaultVal string) (string, error) {
|
||||
if defaultVal != "" {
|
||||
fmt.Printf("%s [%s]: ", label, defaultVal)
|
||||
} else {
|
||||
fmt.Printf("%s: ", label)
|
||||
}
|
||||
|
||||
input, err := stdin.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return defaultVal, nil
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// Select presents numbered options and returns the selected value.
|
||||
func Select(label string, options []string) (string, error) {
|
||||
fmt.Println(label)
|
||||
for i, opt := range options {
|
||||
fmt.Printf(" %d. %s\n", i+1, opt)
|
||||
}
|
||||
fmt.Printf("Choose [1-%d]: ", len(options))
|
||||
|
||||
input, err := stdin.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(strings.TrimSpace(input))
|
||||
if err != nil || n < 1 || n > len(options) {
|
||||
return "", fmt.Errorf("invalid selection")
|
||||
}
|
||||
return options[n-1], nil
|
||||
}
|
||||
|
||||
// MultiSelect presents checkboxes (space-separated numbers).
|
||||
func MultiSelect(label string, options []string) ([]string, error) {
|
||||
fmt.Println(label)
|
||||
for i, opt := range options {
|
||||
fmt.Printf(" %d. %s\n", i+1, opt)
|
||||
}
|
||||
fmt.Printf("Choose (space-separated) [1-%d]: ", len(options))
|
||||
|
||||
input, err := stdin.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var selected []string
|
||||
for _, s := range strings.Fields(input) {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 || n > len(options) {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, options[n-1])
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RenderStyle controls how layouts are rendered.
|
||||
type RenderStyle int
|
||||
|
||||
// Render style constants for layout output.
|
||||
const (
|
||||
// RenderFlat uses no borders or decorations.
|
||||
RenderFlat RenderStyle = iota
|
||||
// RenderSimple uses --- separators between sections.
|
||||
RenderSimple
|
||||
// RenderBoxed uses Unicode box drawing characters.
|
||||
RenderBoxed
|
||||
)
|
||||
|
||||
var currentRenderStyle = RenderFlat
|
||||
|
||||
// UseRenderFlat sets the render style to flat (no borders).
|
||||
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
||||
|
||||
// UseRenderSimple sets the render style to simple (--- separators).
|
||||
func UseRenderSimple() { currentRenderStyle = RenderSimple }
|
||||
|
||||
// UseRenderBoxed sets the render style to boxed (Unicode box drawing).
|
||||
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
||||
|
||||
// Render outputs the layout to terminal.
|
||||
func (c *Composite) Render() {
|
||||
fmt.Print(c.String())
|
||||
}
|
||||
|
||||
// String returns the rendered layout.
|
||||
func (c *Composite) String() string {
|
||||
var sb strings.Builder
|
||||
c.renderTo(&sb, 0)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (c *Composite) renderTo(sb *strings.Builder, depth int) {
|
||||
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||
|
||||
var active []Region
|
||||
for _, r := range order {
|
||||
if slot, ok := c.regions[r]; ok {
|
||||
if len(slot.blocks) > 0 || slot.child != nil {
|
||||
active = append(active, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, r := range active {
|
||||
slot := c.regions[r]
|
||||
if i > 0 && currentRenderStyle != RenderFlat {
|
||||
c.renderSeparator(sb, depth)
|
||||
}
|
||||
c.renderSlot(sb, slot, depth)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
switch currentRenderStyle {
|
||||
case RenderBoxed:
|
||||
sb.WriteString(indent + "├" + strings.Repeat("─", 40) + "┤\n")
|
||||
case RenderSimple:
|
||||
sb.WriteString(indent + strings.Repeat("─", 40) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Composite) renderSlot(sb *strings.Builder, slot *Slot, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
for _, block := range slot.blocks {
|
||||
for _, line := range strings.Split(block.Render(), "\n") {
|
||||
if line != "" {
|
||||
sb.WriteString(indent + line + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if slot.child != nil {
|
||||
slot.child.renderTo(sb, depth+1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
// Package cli provides the CLI runtime and utilities.
|
||||
//
|
||||
// The CLI uses the Core framework for its own runtime. Usage is simple:
|
||||
//
|
||||
// cli.Init(cli.Options{AppName: "core"})
|
||||
// defer cli.Shutdown()
|
||||
//
|
||||
// cli.Success("Done!")
|
||||
// cli.Error("Failed")
|
||||
// if cli.Confirm("Proceed?") { ... }
|
||||
//
|
||||
// // When you need the Core instance
|
||||
// c := cli.Core()
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
instance *runtime
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// runtime is the CLI's internal Core runtime.
|
||||
type runtime struct {
|
||||
core *framework.Core
|
||||
root *cobra.Command
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Options configures the CLI runtime.
|
||||
type Options struct {
|
||||
AppName string
|
||||
Version string
|
||||
Services []framework.Option // Additional services to register
|
||||
|
||||
// OnReload is called when SIGHUP is received (daemon mode).
|
||||
// Use for configuration reloading. Leave nil to ignore SIGHUP.
|
||||
OnReload func() error
|
||||
}
|
||||
|
||||
// Init initialises the global CLI runtime.
|
||||
// Call this once at startup (typically in main.go or cmd.Execute).
|
||||
func Init(opts Options) error {
|
||||
var initErr error
|
||||
once.Do(func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create root command
|
||||
rootCmd := &cobra.Command{
|
||||
Use: opts.AppName,
|
||||
Version: opts.Version,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
// Attach all registered commands
|
||||
attachRegisteredCommands(rootCmd)
|
||||
|
||||
// Build signal service options
|
||||
var signalOpts []SignalOption
|
||||
if opts.OnReload != nil {
|
||||
signalOpts = append(signalOpts, WithReloadHandler(opts.OnReload))
|
||||
}
|
||||
|
||||
// Build options: app, signal service + any additional services
|
||||
coreOpts := []framework.Option{
|
||||
framework.WithApp(rootCmd),
|
||||
framework.WithName("signal", newSignalService(cancel, signalOpts...)),
|
||||
}
|
||||
coreOpts = append(coreOpts, opts.Services...)
|
||||
coreOpts = append(coreOpts, framework.WithServiceLock())
|
||||
|
||||
c, err := framework.New(coreOpts...)
|
||||
if err != nil {
|
||||
initErr = err
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
instance = &runtime{
|
||||
core: c,
|
||||
root: rootCmd,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
if err := c.ServiceStartup(ctx, nil); err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
func mustInit() {
|
||||
if instance == nil {
|
||||
panic("cli not initialised - call cli.Init() first")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Core Access ---
|
||||
|
||||
// Core returns the CLI's framework Core instance.
|
||||
func Core() *framework.Core {
|
||||
mustInit()
|
||||
return instance.core
|
||||
}
|
||||
|
||||
// RootCmd returns the CLI's root cobra command.
|
||||
func RootCmd() *cobra.Command {
|
||||
mustInit()
|
||||
return instance.root
|
||||
}
|
||||
|
||||
// Execute runs the CLI root command.
|
||||
// Returns an error if the command fails.
|
||||
func Execute() error {
|
||||
mustInit()
|
||||
return instance.root.Execute()
|
||||
}
|
||||
|
||||
// Context returns the CLI's root context.
|
||||
// Cancelled on SIGINT/SIGTERM.
|
||||
func Context() context.Context {
|
||||
mustInit()
|
||||
return instance.ctx
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the CLI.
|
||||
func Shutdown() {
|
||||
if instance == nil {
|
||||
return
|
||||
}
|
||||
instance.cancel()
|
||||
_ = instance.core.ServiceShutdown(instance.ctx)
|
||||
}
|
||||
|
||||
// --- Signal Service (internal) ---
|
||||
|
||||
type signalService struct {
|
||||
cancel context.CancelFunc
|
||||
sigChan chan os.Signal
|
||||
onReload func() error
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
// SignalOption configures signal handling.
|
||||
type SignalOption func(*signalService)
|
||||
|
||||
// WithReloadHandler sets a callback for SIGHUP.
|
||||
func WithReloadHandler(fn func() error) SignalOption {
|
||||
return func(s *signalService) {
|
||||
s.onReload = fn
|
||||
}
|
||||
}
|
||||
|
||||
func newSignalService(cancel context.CancelFunc, opts ...SignalOption) func(*framework.Core) (any, error) {
|
||||
return func(c *framework.Core) (any, error) {
|
||||
svc := &signalService{
|
||||
cancel: cancel,
|
||||
sigChan: make(chan os.Signal, 1),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(svc)
|
||||
}
|
||||
return svc, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalService) OnStartup(ctx context.Context) error {
|
||||
signals := []os.Signal{syscall.SIGINT, syscall.SIGTERM}
|
||||
if s.onReload != nil {
|
||||
signals = append(signals, syscall.SIGHUP)
|
||||
}
|
||||
signal.Notify(s.sigChan, signals...)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case sig := <-s.sigChan:
|
||||
switch sig {
|
||||
case syscall.SIGHUP:
|
||||
if s.onReload != nil {
|
||||
if err := s.onReload(); err != nil {
|
||||
LogError("reload failed", "err", err)
|
||||
} else {
|
||||
LogInfo("configuration reloaded")
|
||||
}
|
||||
}
|
||||
case syscall.SIGINT, syscall.SIGTERM:
|
||||
s.cancel()
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *signalService) OnShutdown(ctx context.Context) error {
|
||||
s.shutdownOnce.Do(func() {
|
||||
signal.Stop(s.sigChan)
|
||||
close(s.sigChan)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Sprintf formats a string (fmt.Sprintf wrapper).
|
||||
func Sprintf(format string, args ...any) string {
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
// Sprint formats using default formats (fmt.Sprint wrapper).
|
||||
func Sprint(args ...any) string {
|
||||
return fmt.Sprint(args...)
|
||||
}
|
||||
|
||||
// Styled returns text with a style applied.
|
||||
func Styled(style *AnsiStyle, text string) string {
|
||||
return style.Render(text)
|
||||
}
|
||||
|
||||
// Styledf returns formatted text with a style applied.
|
||||
func Styledf(style *AnsiStyle, format string, args ...any) string {
|
||||
return style.Render(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// SuccessStr returns success-styled string.
|
||||
func SuccessStr(msg string) string {
|
||||
return SuccessStyle.Render(Glyph(":check:") + " " + msg)
|
||||
}
|
||||
|
||||
// ErrorStr returns error-styled string.
|
||||
func ErrorStr(msg string) string {
|
||||
return ErrorStyle.Render(Glyph(":cross:") + " " + msg)
|
||||
}
|
||||
|
||||
// WarnStr returns warning-styled string.
|
||||
func WarnStr(msg string) string {
|
||||
return WarningStyle.Render(Glyph(":warn:") + " " + msg)
|
||||
}
|
||||
|
||||
// InfoStr returns info-styled string.
|
||||
func InfoStr(msg string) string {
|
||||
return InfoStyle.Render(Glyph(":info:") + " " + msg)
|
||||
}
|
||||
|
||||
// DimStr returns dim-styled string.
|
||||
func DimStr(msg string) string {
|
||||
return DimStyle.Render(msg)
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
// Package cli provides semantic CLI output with zero external dependencies.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tailwind colour palette (hex strings)
|
||||
const (
|
||||
ColourBlue50 = "#eff6ff"
|
||||
ColourBlue100 = "#dbeafe"
|
||||
ColourBlue200 = "#bfdbfe"
|
||||
ColourBlue300 = "#93c5fd"
|
||||
ColourBlue400 = "#60a5fa"
|
||||
ColourBlue500 = "#3b82f6"
|
||||
ColourBlue600 = "#2563eb"
|
||||
ColourBlue700 = "#1d4ed8"
|
||||
ColourGreen400 = "#4ade80"
|
||||
ColourGreen500 = "#22c55e"
|
||||
ColourGreen600 = "#16a34a"
|
||||
ColourRed400 = "#f87171"
|
||||
ColourRed500 = "#ef4444"
|
||||
ColourRed600 = "#dc2626"
|
||||
ColourAmber400 = "#fbbf24"
|
||||
ColourAmber500 = "#f59e0b"
|
||||
ColourAmber600 = "#d97706"
|
||||
ColourOrange500 = "#f97316"
|
||||
ColourYellow500 = "#eab308"
|
||||
ColourEmerald500 = "#10b981"
|
||||
ColourPurple500 = "#a855f7"
|
||||
ColourViolet400 = "#a78bfa"
|
||||
ColourViolet500 = "#8b5cf6"
|
||||
ColourIndigo500 = "#6366f1"
|
||||
ColourCyan500 = "#06b6d4"
|
||||
ColourGray50 = "#f9fafb"
|
||||
ColourGray100 = "#f3f4f6"
|
||||
ColourGray200 = "#e5e7eb"
|
||||
ColourGray300 = "#d1d5db"
|
||||
ColourGray400 = "#9ca3af"
|
||||
ColourGray500 = "#6b7280"
|
||||
ColourGray600 = "#4b5563"
|
||||
ColourGray700 = "#374151"
|
||||
ColourGray800 = "#1f2937"
|
||||
ColourGray900 = "#111827"
|
||||
)
|
||||
|
||||
// Core styles
|
||||
var (
|
||||
SuccessStyle = NewStyle().Bold().Foreground(ColourGreen500)
|
||||
ErrorStyle = NewStyle().Bold().Foreground(ColourRed500)
|
||||
WarningStyle = NewStyle().Bold().Foreground(ColourAmber500)
|
||||
InfoStyle = NewStyle().Foreground(ColourBlue400)
|
||||
SecurityStyle = NewStyle().Bold().Foreground(ColourPurple500)
|
||||
DimStyle = NewStyle().Dim().Foreground(ColourGray500)
|
||||
MutedStyle = NewStyle().Foreground(ColourGray600)
|
||||
BoldStyle = NewStyle().Bold()
|
||||
KeyStyle = NewStyle().Foreground(ColourGray400)
|
||||
ValueStyle = NewStyle().Foreground(ColourGray200)
|
||||
AccentStyle = NewStyle().Foreground(ColourCyan500)
|
||||
LinkStyle = NewStyle().Foreground(ColourBlue500).Underline()
|
||||
HeaderStyle = NewStyle().Bold().Foreground(ColourGray200)
|
||||
TitleStyle = NewStyle().Bold().Foreground(ColourBlue500)
|
||||
CodeStyle = NewStyle().Foreground(ColourGray300)
|
||||
NumberStyle = NewStyle().Foreground(ColourBlue300)
|
||||
RepoStyle = NewStyle().Bold().Foreground(ColourBlue500)
|
||||
)
|
||||
|
||||
// Truncate shortens a string to max length with ellipsis.
|
||||
func Truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 3 {
|
||||
return s[:max]
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
// Pad right-pads a string to width.
|
||||
func Pad(s string, width int) string {
|
||||
if len(s) >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-len(s))
|
||||
}
|
||||
|
||||
// FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago").
|
||||
func FormatAge(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
case d < 7*24*time.Hour:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7)))
|
||||
default:
|
||||
return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
|
||||
}
|
||||
}
|
||||
|
||||
// Table renders tabular data with aligned columns.
|
||||
// HLCRF is for layout; Table is for tabular data - they serve different purposes.
|
||||
type Table struct {
|
||||
Headers []string
|
||||
Rows [][]string
|
||||
Style TableStyle
|
||||
}
|
||||
|
||||
// TableStyle configures the appearance of table output.
|
||||
type TableStyle struct {
|
||||
HeaderStyle *AnsiStyle
|
||||
CellStyle *AnsiStyle
|
||||
Separator string
|
||||
}
|
||||
|
||||
// DefaultTableStyle returns sensible defaults.
|
||||
func DefaultTableStyle() TableStyle {
|
||||
return TableStyle{
|
||||
HeaderStyle: HeaderStyle,
|
||||
CellStyle: nil,
|
||||
Separator: " ",
|
||||
}
|
||||
}
|
||||
|
||||
// NewTable creates a table with headers.
|
||||
func NewTable(headers ...string) *Table {
|
||||
return &Table{
|
||||
Headers: headers,
|
||||
Style: DefaultTableStyle(),
|
||||
}
|
||||
}
|
||||
|
||||
// AddRow adds a row to the table.
|
||||
func (t *Table) AddRow(cells ...string) *Table {
|
||||
t.Rows = append(t.Rows, cells)
|
||||
return t
|
||||
}
|
||||
|
||||
// String renders the table.
|
||||
func (t *Table) String() string {
|
||||
if len(t.Headers) == 0 && len(t.Rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Calculate column widths
|
||||
cols := len(t.Headers)
|
||||
if cols == 0 && len(t.Rows) > 0 {
|
||||
cols = len(t.Rows[0])
|
||||
}
|
||||
widths := make([]int, cols)
|
||||
|
||||
for i, h := range t.Headers {
|
||||
if len(h) > widths[i] {
|
||||
widths[i] = len(h)
|
||||
}
|
||||
}
|
||||
for _, row := range t.Rows {
|
||||
for i, cell := range row {
|
||||
if i < cols && len(cell) > widths[i] {
|
||||
widths[i] = len(cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sep := t.Style.Separator
|
||||
|
||||
// Headers
|
||||
if len(t.Headers) > 0 {
|
||||
for i, h := range t.Headers {
|
||||
if i > 0 {
|
||||
sb.WriteString(sep)
|
||||
}
|
||||
styled := Pad(h, widths[i])
|
||||
if t.Style.HeaderStyle != nil {
|
||||
styled = t.Style.HeaderStyle.Render(styled)
|
||||
}
|
||||
sb.WriteString(styled)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Rows
|
||||
for _, row := range t.Rows {
|
||||
for i, cell := range row {
|
||||
if i > 0 {
|
||||
sb.WriteString(sep)
|
||||
}
|
||||
styled := Pad(cell, widths[i])
|
||||
if t.Style.CellStyle != nil {
|
||||
styled = t.Style.CellStyle.Render(styled)
|
||||
}
|
||||
sb.WriteString(styled)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Render prints the table to stdout.
|
||||
func (t *Table) Render() {
|
||||
fmt.Print(t.String())
|
||||
}
|
||||
505
pkg/cli/utils.go
505
pkg/cli/utils.go
|
|
@ -1,505 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// GhAuthenticated checks if the GitHub CLI is authenticated.
|
||||
// Returns true if 'gh auth status' indicates a logged-in user.
|
||||
func GhAuthenticated() bool {
|
||||
cmd := exec.Command("gh", "auth", "status")
|
||||
output, _ := cmd.CombinedOutput()
|
||||
authenticated := strings.Contains(string(output), "Logged in")
|
||||
|
||||
if authenticated {
|
||||
LogSecurity("GitHub CLI authenticated", "user", log.Username())
|
||||
} else {
|
||||
LogSecurity("GitHub CLI not authenticated", "user", log.Username())
|
||||
}
|
||||
|
||||
return authenticated
|
||||
}
|
||||
|
||||
// ConfirmOption configures Confirm behaviour.
|
||||
type ConfirmOption func(*confirmConfig)
|
||||
|
||||
type confirmConfig struct {
|
||||
defaultYes bool
|
||||
required bool
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
|
||||
func DefaultYes() ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.defaultYes = true
|
||||
}
|
||||
}
|
||||
|
||||
// Required prevents empty responses; user must explicitly type y/n.
|
||||
func Required() ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.required = true
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout sets a timeout after which the default response is auto-selected.
|
||||
// If no default is set (not Required and not DefaultYes), defaults to "no".
|
||||
//
|
||||
// Confirm("Continue?", Timeout(30*time.Second)) // Auto-no after 30s
|
||||
// Confirm("Continue?", DefaultYes(), Timeout(10*time.Second)) // Auto-yes after 10s
|
||||
func Timeout(d time.Duration) ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm prompts the user for yes/no confirmation.
|
||||
// Returns true if the user enters "y" or "yes" (case-insensitive).
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// if Confirm("Delete file?") { ... }
|
||||
//
|
||||
// With options:
|
||||
//
|
||||
// if Confirm("Save changes?", DefaultYes()) { ... }
|
||||
// if Confirm("Dangerous!", Required()) { ... }
|
||||
// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... }
|
||||
func Confirm(prompt string, opts ...ConfirmOption) bool {
|
||||
cfg := &confirmConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Build the prompt suffix
|
||||
var suffix string
|
||||
if cfg.required {
|
||||
suffix = "[y/n] "
|
||||
} else if cfg.defaultYes {
|
||||
suffix = "[Y/n] "
|
||||
} else {
|
||||
suffix = "[y/N] "
|
||||
}
|
||||
|
||||
// Add timeout indicator if set
|
||||
if cfg.timeout > 0 {
|
||||
suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
fmt.Printf("%s %s", prompt, suffix)
|
||||
|
||||
var response string
|
||||
|
||||
if cfg.timeout > 0 {
|
||||
// Use timeout-based reading
|
||||
resultChan := make(chan string, 1)
|
||||
go func() {
|
||||
line, _ := reader.ReadString('\n')
|
||||
resultChan <- line
|
||||
}()
|
||||
|
||||
select {
|
||||
case response = <-resultChan:
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
case <-time.After(cfg.timeout):
|
||||
fmt.Println() // New line after timeout
|
||||
return cfg.defaultYes
|
||||
}
|
||||
} else {
|
||||
response, _ = reader.ReadString('\n')
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
}
|
||||
|
||||
// Handle empty response
|
||||
if response == "" {
|
||||
if cfg.required {
|
||||
continue // Ask again
|
||||
}
|
||||
return cfg.defaultYes
|
||||
}
|
||||
|
||||
// Check for yes/no responses
|
||||
if response == "y" || response == "yes" {
|
||||
return true
|
||||
}
|
||||
if response == "n" || response == "no" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Invalid response
|
||||
if cfg.required {
|
||||
fmt.Println("Please enter 'y' or 'n'")
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-required: treat invalid as default
|
||||
return cfg.defaultYes
|
||||
}
|
||||
}
|
||||
|
||||
// ConfirmAction prompts for confirmation of an action using grammar composition.
|
||||
//
|
||||
// if ConfirmAction("delete", "config.yaml") { ... }
|
||||
// if ConfirmAction("save", "changes", DefaultYes()) { ... }
|
||||
func ConfirmAction(verb, subject string, opts ...ConfirmOption) bool {
|
||||
question := i18n.Title(verb) + " " + subject + "?"
|
||||
return Confirm(question, opts...)
|
||||
}
|
||||
|
||||
// ConfirmDangerousAction prompts for double confirmation of a dangerous action.
|
||||
// Shows initial question, then a "Really verb subject?" confirmation.
|
||||
//
|
||||
// if ConfirmDangerousAction("delete", "config.yaml") { ... }
|
||||
func ConfirmDangerousAction(verb, subject string) bool {
|
||||
question := i18n.Title(verb) + " " + subject + "?"
|
||||
if !Confirm(question, Required()) {
|
||||
return false
|
||||
}
|
||||
|
||||
confirm := "Really " + verb + " " + subject + "?"
|
||||
return Confirm(confirm, Required())
|
||||
}
|
||||
|
||||
// QuestionOption configures Question behaviour.
|
||||
type QuestionOption func(*questionConfig)
|
||||
|
||||
type questionConfig struct {
|
||||
defaultValue string
|
||||
required bool
|
||||
validator func(string) error
|
||||
}
|
||||
|
||||
// WithDefault sets the default value shown in brackets.
|
||||
func WithDefault(value string) QuestionOption {
|
||||
return func(c *questionConfig) {
|
||||
c.defaultValue = value
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidator adds a validation function for the response.
|
||||
func WithValidator(fn func(string) error) QuestionOption {
|
||||
return func(c *questionConfig) {
|
||||
c.validator = fn
|
||||
}
|
||||
}
|
||||
|
||||
// RequiredInput prevents empty responses.
|
||||
func RequiredInput() QuestionOption {
|
||||
return func(c *questionConfig) {
|
||||
c.required = true
|
||||
}
|
||||
}
|
||||
|
||||
// Question prompts the user for text input.
|
||||
//
|
||||
// name := Question("Enter your name:")
|
||||
// name := Question("Enter your name:", WithDefault("Anonymous"))
|
||||
// name := Question("Enter your name:", RequiredInput())
|
||||
func Question(prompt string, opts ...QuestionOption) string {
|
||||
cfg := &questionConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
// Build prompt with default
|
||||
if cfg.defaultValue != "" {
|
||||
fmt.Printf("%s [%s] ", prompt, cfg.defaultValue)
|
||||
} else {
|
||||
fmt.Printf("%s ", prompt)
|
||||
}
|
||||
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Handle empty response
|
||||
if response == "" {
|
||||
if cfg.required {
|
||||
fmt.Println("Response required")
|
||||
continue
|
||||
}
|
||||
response = cfg.defaultValue
|
||||
}
|
||||
|
||||
// Validate if validator provided
|
||||
if cfg.validator != nil {
|
||||
if err := cfg.validator(response); err != nil {
|
||||
fmt.Printf("Invalid: %v\n", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
// QuestionAction prompts for text input using grammar composition.
|
||||
//
|
||||
// name := QuestionAction("rename", "old.txt")
|
||||
func QuestionAction(verb, subject string, opts ...QuestionOption) string {
|
||||
question := i18n.Title(verb) + " " + subject + "?"
|
||||
return Question(question, opts...)
|
||||
}
|
||||
|
||||
// ChooseOption configures Choose behaviour.
|
||||
type ChooseOption[T any] func(*chooseConfig[T])
|
||||
|
||||
type chooseConfig[T any] struct {
|
||||
displayFn func(T) string
|
||||
defaultN int // 0-based index of default selection
|
||||
filter bool // Enable fuzzy filtering
|
||||
multi bool // Allow multiple selection
|
||||
}
|
||||
|
||||
// WithDisplay sets a custom display function for items.
|
||||
func WithDisplay[T any](fn func(T) string) ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.displayFn = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultIndex sets the default selection index (0-based).
|
||||
func WithDefaultIndex[T any](idx int) ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.defaultN = idx
|
||||
}
|
||||
}
|
||||
|
||||
// Filter enables type-to-filter functionality.
|
||||
// Users can type to narrow down the list of options.
|
||||
// Note: This is a hint for interactive UIs; the basic CLI Choose
|
||||
// implementation uses numbered selection which doesn't support filtering.
|
||||
func Filter[T any]() ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.filter = true
|
||||
}
|
||||
}
|
||||
|
||||
// Multi allows multiple selections.
|
||||
// Use ChooseMulti instead of Choose when this option is needed.
|
||||
func Multi[T any]() ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.multi = true
|
||||
}
|
||||
}
|
||||
|
||||
// Display sets a custom display function for items.
|
||||
// Alias for WithDisplay for shorter syntax.
|
||||
//
|
||||
// Choose("Select:", items, Display(func(f File) string { return f.Name }))
|
||||
func Display[T any](fn func(T) string) ChooseOption[T] {
|
||||
return WithDisplay[T](fn)
|
||||
}
|
||||
|
||||
// Choose prompts the user to select from a list of items.
|
||||
// Returns the selected item. Uses simple numbered selection for terminal compatibility.
|
||||
//
|
||||
// choice := Choose("Select a file:", files)
|
||||
// choice := Choose("Select a file:", files, WithDisplay(func(f File) string { return f.Name }))
|
||||
func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
||||
var zero T
|
||||
if len(items) == 0 {
|
||||
return zero
|
||||
}
|
||||
|
||||
cfg := &chooseConfig[T]{
|
||||
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Display options
|
||||
fmt.Println(prompt)
|
||||
for i, item := range items {
|
||||
marker := " "
|
||||
if i == cfg.defaultN {
|
||||
marker = "*"
|
||||
}
|
||||
fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
fmt.Printf("Enter number [1-%d]: ", len(items))
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Empty response uses default
|
||||
if response == "" {
|
||||
return items[cfg.defaultN]
|
||||
}
|
||||
|
||||
// Parse number
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
|
||||
if n >= 1 && n <= len(items) {
|
||||
return items[n-1]
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Please enter a number between 1 and %d\n", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// ChooseAction prompts for selection using grammar composition.
|
||||
//
|
||||
// file := ChooseAction("select", "file", files)
|
||||
func ChooseAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) T {
|
||||
question := i18n.Title(verb) + " " + subject + ":"
|
||||
return Choose(question, items, opts...)
|
||||
}
|
||||
|
||||
// ChooseMulti prompts the user to select multiple items from a list.
|
||||
// Returns the selected items. Uses space-separated numbers or ranges.
|
||||
//
|
||||
// choices := ChooseMulti("Select files:", files)
|
||||
// choices := ChooseMulti("Select files:", files, WithDisplay(func(f File) string { return f.Name }))
|
||||
//
|
||||
// Input format:
|
||||
// - "1 3 5" - select items 1, 3, and 5
|
||||
// - "1-3" - select items 1, 2, and 3
|
||||
// - "1 3-5" - select items 1, 3, 4, and 5
|
||||
// - "" (empty) - select none
|
||||
func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := &chooseConfig[T]{
|
||||
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Display options
|
||||
fmt.Println(prompt)
|
||||
for i, item := range items {
|
||||
fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item))
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Empty response returns no selections
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the selection
|
||||
selected, err := parseMultiSelection(response, len(items))
|
||||
if err != nil {
|
||||
fmt.Printf("Invalid selection: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := make([]T, 0, len(selected))
|
||||
for _, idx := range selected {
|
||||
result = append(result, items[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5".
|
||||
// Returns 0-based indices.
|
||||
func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
||||
selected := make(map[int]bool)
|
||||
parts := strings.Fields(input)
|
||||
|
||||
for _, part := range parts {
|
||||
// Check for range (e.g., "1-3")
|
||||
if strings.Contains(part, "-") {
|
||||
rangeParts := strings.Split(part, "-")
|
||||
if len(rangeParts) != 2 {
|
||||
return nil, fmt.Errorf("invalid range: %s", part)
|
||||
}
|
||||
var start, end int
|
||||
if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil {
|
||||
return nil, fmt.Errorf("invalid range start: %s", rangeParts[0])
|
||||
}
|
||||
if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil {
|
||||
return nil, fmt.Errorf("invalid range end: %s", rangeParts[1])
|
||||
}
|
||||
if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end {
|
||||
return nil, fmt.Errorf("range out of bounds: %s", part)
|
||||
}
|
||||
for i := start; i <= end; i++ {
|
||||
selected[i-1] = true // Convert to 0-based
|
||||
}
|
||||
} else {
|
||||
// Single number
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(part, "%d", &n); err != nil {
|
||||
return nil, fmt.Errorf("invalid number: %s", part)
|
||||
}
|
||||
if n < 1 || n > maxItems {
|
||||
return nil, fmt.Errorf("number out of range: %d", n)
|
||||
}
|
||||
selected[n-1] = true // Convert to 0-based
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to sorted slice
|
||||
result := make([]int, 0, len(selected))
|
||||
for i := 0; i < maxItems; i++ {
|
||||
if selected[i] {
|
||||
result = append(result, i)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ChooseMultiAction prompts for multiple selections using grammar composition.
|
||||
//
|
||||
// files := ChooseMultiAction("select", "files", files)
|
||||
func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) []T {
|
||||
question := i18n.Title(verb) + " " + subject + ":"
|
||||
return ChooseMulti(question, items, opts...)
|
||||
}
|
||||
|
||||
// GitClone clones a GitHub repository to the specified path.
|
||||
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
|
||||
func GitClone(ctx context.Context, org, repo, path string) error {
|
||||
if GhAuthenticated() {
|
||||
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
|
||||
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
errStr := strings.TrimSpace(string(output))
|
||||
if strings.Contains(errStr, "already exists") {
|
||||
return fmt.Errorf("%s", errStr)
|
||||
}
|
||||
}
|
||||
// Fall back to SSH clone
|
||||
cmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
85
pkg/coredeno/coredeno.go
Normal file
85
pkg/coredeno/coredeno.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Options configures the CoreDeno sidecar.
|
||||
type Options struct {
|
||||
DenoPath string // path to deno binary (default: "deno")
|
||||
SocketPath string // Unix socket path for Go's gRPC server (CoreService)
|
||||
DenoSocketPath string // Unix socket path for Deno's gRPC server (DenoService)
|
||||
AppRoot string // app root directory (sandboxed I/O)
|
||||
StoreDBPath string // SQLite DB path (default: AppRoot/.core/store.db)
|
||||
PublicKey ed25519.PublicKey // ed25519 public key for manifest verification (optional)
|
||||
SidecarArgs []string // args passed to the sidecar process
|
||||
}
|
||||
|
||||
// Permissions declares per-module Deno permission flags.
|
||||
type Permissions struct {
|
||||
Read []string
|
||||
Write []string
|
||||
Net []string
|
||||
Run []string
|
||||
}
|
||||
|
||||
// Flags converts permissions to Deno --allow-* CLI flags.
|
||||
func (p Permissions) Flags() []string {
|
||||
var flags []string
|
||||
if len(p.Read) > 0 {
|
||||
flags = append(flags, fmt.Sprintf("--allow-read=%s", strings.Join(p.Read, ",")))
|
||||
}
|
||||
if len(p.Write) > 0 {
|
||||
flags = append(flags, fmt.Sprintf("--allow-write=%s", strings.Join(p.Write, ",")))
|
||||
}
|
||||
if len(p.Net) > 0 {
|
||||
flags = append(flags, fmt.Sprintf("--allow-net=%s", strings.Join(p.Net, ",")))
|
||||
}
|
||||
if len(p.Run) > 0 {
|
||||
flags = append(flags, fmt.Sprintf("--allow-run=%s", strings.Join(p.Run, ",")))
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// DefaultSocketPath returns the default Unix socket path.
|
||||
func DefaultSocketPath() string {
|
||||
xdg := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if xdg == "" {
|
||||
xdg = "/tmp"
|
||||
}
|
||||
return filepath.Join(xdg, "core", "deno.sock")
|
||||
}
|
||||
|
||||
// Sidecar manages a Deno child process.
|
||||
type Sidecar struct {
|
||||
opts Options
|
||||
mu sync.RWMutex
|
||||
cmd *exec.Cmd
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewSidecar creates a Sidecar with the given options.
|
||||
func NewSidecar(opts Options) *Sidecar {
|
||||
if opts.DenoPath == "" {
|
||||
opts.DenoPath = "deno"
|
||||
}
|
||||
if opts.SocketPath == "" {
|
||||
opts.SocketPath = DefaultSocketPath()
|
||||
}
|
||||
if opts.DenoSocketPath == "" && opts.SocketPath != "" {
|
||||
opts.DenoSocketPath = filepath.Join(filepath.Dir(opts.SocketPath), "deno.sock")
|
||||
}
|
||||
if opts.StoreDBPath == "" && opts.AppRoot != "" {
|
||||
opts.StoreDBPath = filepath.Join(opts.AppRoot, ".core", "store.db")
|
||||
}
|
||||
return &Sidecar{opts: opts}
|
||||
}
|
||||
99
pkg/coredeno/coredeno_test.go
Normal file
99
pkg/coredeno/coredeno_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSidecar_Good(t *testing.T) {
|
||||
opts := Options{
|
||||
DenoPath: "echo",
|
||||
SocketPath: "/tmp/test-core-deno.sock",
|
||||
}
|
||||
sc := NewSidecar(opts)
|
||||
require.NotNil(t, sc)
|
||||
assert.Equal(t, "echo", sc.opts.DenoPath)
|
||||
assert.Equal(t, "/tmp/test-core-deno.sock", sc.opts.SocketPath)
|
||||
}
|
||||
|
||||
func TestDefaultSocketPath_Good(t *testing.T) {
|
||||
path := DefaultSocketPath()
|
||||
assert.Contains(t, path, "core/deno.sock")
|
||||
}
|
||||
|
||||
func TestSidecar_PermissionFlags_Good(t *testing.T) {
|
||||
perms := Permissions{
|
||||
Read: []string{"./data/"},
|
||||
Write: []string{"./data/config.json"},
|
||||
Net: []string{"pool.lthn.io:3333"},
|
||||
Run: []string{"xmrig"},
|
||||
}
|
||||
flags := perms.Flags()
|
||||
assert.Contains(t, flags, "--allow-read=./data/")
|
||||
assert.Contains(t, flags, "--allow-write=./data/config.json")
|
||||
assert.Contains(t, flags, "--allow-net=pool.lthn.io:3333")
|
||||
assert.Contains(t, flags, "--allow-run=xmrig")
|
||||
}
|
||||
|
||||
func TestSidecar_PermissionFlags_Empty(t *testing.T) {
|
||||
perms := Permissions{}
|
||||
flags := perms.Flags()
|
||||
assert.Empty(t, flags)
|
||||
}
|
||||
|
||||
func TestOptions_AppRoot_Good(t *testing.T) {
|
||||
opts := Options{
|
||||
DenoPath: "deno",
|
||||
SocketPath: "/tmp/test.sock",
|
||||
AppRoot: "/app",
|
||||
StoreDBPath: "/app/.core/store.db",
|
||||
}
|
||||
sc := NewSidecar(opts)
|
||||
assert.Equal(t, "/app", sc.opts.AppRoot)
|
||||
assert.Equal(t, "/app/.core/store.db", sc.opts.StoreDBPath)
|
||||
}
|
||||
|
||||
func TestOptions_StoreDBPath_Default_Good(t *testing.T) {
|
||||
opts := Options{AppRoot: "/app"}
|
||||
sc := NewSidecar(opts)
|
||||
assert.Equal(t, "/app/.core/store.db", sc.opts.StoreDBPath,
|
||||
"StoreDBPath should default to AppRoot/.core/store.db")
|
||||
}
|
||||
|
||||
func TestOptions_SidecarArgs_Good(t *testing.T) {
|
||||
opts := Options{
|
||||
DenoPath: "deno",
|
||||
SidecarArgs: []string{"run", "--allow-env", "main.ts"},
|
||||
}
|
||||
sc := NewSidecar(opts)
|
||||
assert.Equal(t, []string{"run", "--allow-env", "main.ts"}, sc.opts.SidecarArgs)
|
||||
}
|
||||
|
||||
func TestDefaultSocketPath_XDG(t *testing.T) {
|
||||
orig := os.Getenv("XDG_RUNTIME_DIR")
|
||||
defer os.Setenv("XDG_RUNTIME_DIR", orig)
|
||||
|
||||
os.Setenv("XDG_RUNTIME_DIR", "/run/user/1000")
|
||||
path := DefaultSocketPath()
|
||||
assert.Equal(t, "/run/user/1000/core/deno.sock", path)
|
||||
}
|
||||
|
||||
func TestOptions_DenoSocketPath_Default_Good(t *testing.T) {
|
||||
opts := Options{SocketPath: "/tmp/core/core.sock"}
|
||||
sc := NewSidecar(opts)
|
||||
assert.Equal(t, "/tmp/core/deno.sock", sc.opts.DenoSocketPath,
|
||||
"DenoSocketPath should default to same dir as SocketPath with deno.sock")
|
||||
}
|
||||
|
||||
func TestOptions_DenoSocketPath_Explicit_Good(t *testing.T) {
|
||||
opts := Options{
|
||||
SocketPath: "/tmp/core/core.sock",
|
||||
DenoSocketPath: "/tmp/custom/deno.sock",
|
||||
}
|
||||
sc := NewSidecar(opts)
|
||||
assert.Equal(t, "/tmp/custom/deno.sock", sc.opts.DenoSocketPath,
|
||||
"Explicit DenoSocketPath should not be overridden")
|
||||
}
|
||||
138
pkg/coredeno/denoclient.go
Normal file
138
pkg/coredeno/denoclient.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DenoClient communicates with the Deno sidecar's JSON-RPC server over a Unix socket.
|
||||
// Thread-safe: uses a mutex to serialize requests (one connection, request/response protocol).
|
||||
type DenoClient struct {
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
||||
// DialDeno connects to the Deno JSON-RPC server on the given Unix socket path.
|
||||
func DialDeno(socketPath string) (*DenoClient, error) {
|
||||
conn, err := net.Dial("unix", socketPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deno dial: %w", err)
|
||||
}
|
||||
return &DenoClient{
|
||||
conn: conn,
|
||||
reader: bufio.NewReader(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying connection.
|
||||
func (c *DenoClient) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *DenoClient) call(req map[string]any) (map[string]any, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
if _, err := c.conn.Write(data); err != nil {
|
||||
return nil, fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
line, err := c.reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := resp["error"].(string); ok && errMsg != "" {
|
||||
return nil, fmt.Errorf("deno: %s", errMsg)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ModulePermissions declares per-module permission scopes for Deno Worker sandboxing.
|
||||
type ModulePermissions struct {
|
||||
Read []string `json:"read,omitempty"`
|
||||
Write []string `json:"write,omitempty"`
|
||||
Net []string `json:"net,omitempty"`
|
||||
Run []string `json:"run,omitempty"`
|
||||
}
|
||||
|
||||
// LoadModuleResponse holds the result of a LoadModule call.
|
||||
type LoadModuleResponse struct {
|
||||
Ok bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// LoadModule tells Deno to load a module with the given permissions.
|
||||
func (c *DenoClient) LoadModule(code, entryPoint string, perms ModulePermissions) (*LoadModuleResponse, error) {
|
||||
resp, err := c.call(map[string]any{
|
||||
"method": "LoadModule",
|
||||
"code": code,
|
||||
"entry_point": entryPoint,
|
||||
"permissions": perms,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
errStr, _ := resp["error"].(string)
|
||||
return &LoadModuleResponse{
|
||||
Ok: resp["ok"] == true,
|
||||
Error: errStr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UnloadModuleResponse holds the result of an UnloadModule call.
|
||||
type UnloadModuleResponse struct {
|
||||
Ok bool
|
||||
}
|
||||
|
||||
// UnloadModule tells Deno to unload a module.
|
||||
func (c *DenoClient) UnloadModule(code string) (*UnloadModuleResponse, error) {
|
||||
resp, err := c.call(map[string]any{
|
||||
"method": "UnloadModule",
|
||||
"code": code,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UnloadModuleResponse{
|
||||
Ok: resp["ok"] == true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ModuleStatusResponse holds the result of a ModuleStatus call.
|
||||
type ModuleStatusResponse struct {
|
||||
Code string
|
||||
Status string
|
||||
}
|
||||
|
||||
// ModuleStatus queries the status of a module in the Deno runtime.
|
||||
func (c *DenoClient) ModuleStatus(code string) (*ModuleStatusResponse, error) {
|
||||
resp, err := c.call(map[string]any{
|
||||
"method": "ModuleStatus",
|
||||
"code": code,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respCode, _ := resp["code"].(string)
|
||||
sts, _ := resp["status"].(string)
|
||||
return &ModuleStatusResponse{
|
||||
Code: respCode,
|
||||
Status: sts,
|
||||
}, nil
|
||||
}
|
||||
499
pkg/coredeno/integration_test.go
Normal file
499
pkg/coredeno/integration_test.go
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
//go:build integration
|
||||
|
||||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
|
||||
core "forge.lthn.ai/core/go/pkg/framework/core"
|
||||
"forge.lthn.ai/core/go/pkg/marketplace"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// unused import guard
|
||||
var _ = pb.NewCoreServiceClient
|
||||
|
||||
func findDeno(t *testing.T) string {
|
||||
t.Helper()
|
||||
denoPath, err := exec.LookPath("deno")
|
||||
if err != nil {
|
||||
home, _ := os.UserHomeDir()
|
||||
denoPath = filepath.Join(home, ".deno", "bin", "deno")
|
||||
if _, err := os.Stat(denoPath); err != nil {
|
||||
t.Skip("deno not installed")
|
||||
}
|
||||
}
|
||||
return denoPath
|
||||
}
|
||||
|
||||
// runtimeEntryPoint returns the absolute path to runtime/main.ts.
|
||||
func runtimeEntryPoint(t *testing.T) string {
|
||||
t.Helper()
|
||||
// We're in pkg/coredeno/ during test, runtime is a subdir
|
||||
abs, err := filepath.Abs("runtime/main.ts")
|
||||
require.NoError(t, err)
|
||||
require.FileExists(t, abs)
|
||||
return abs
|
||||
}
|
||||
|
||||
// testModulePath returns the absolute path to runtime/testdata/test-module.ts.
|
||||
func testModulePath(t *testing.T) string {
|
||||
t.Helper()
|
||||
abs, err := filepath.Abs("runtime/testdata/test-module.ts")
|
||||
require.NoError(t, err)
|
||||
require.FileExists(t, abs)
|
||||
return abs
|
||||
}
|
||||
|
||||
func TestIntegration_FullBoot_Good(t *testing.T) {
|
||||
denoPath := findDeno(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "core.sock")
|
||||
|
||||
// Write a manifest
|
||||
coreDir := filepath.Join(tmpDir, ".core")
|
||||
require.NoError(t, os.MkdirAll(coreDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(`
|
||||
code: integration-test
|
||||
name: Integration Test
|
||||
version: "1.0"
|
||||
permissions:
|
||||
read: ["./data/"]
|
||||
`), 0644))
|
||||
|
||||
entryPoint := runtimeEntryPoint(t)
|
||||
|
||||
opts := Options{
|
||||
DenoPath: denoPath,
|
||||
SocketPath: sockPath,
|
||||
AppRoot: tmpDir,
|
||||
StoreDBPath: ":memory:",
|
||||
SidecarArgs: []string{"run", "-A", entryPoint},
|
||||
}
|
||||
|
||||
c, err := core.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
factory := NewServiceFactory(opts)
|
||||
result, err := factory(c)
|
||||
require.NoError(t, err)
|
||||
svc := result.(*Service)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = svc.OnStartup(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify gRPC is working
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(sockPath)
|
||||
return err == nil
|
||||
}, 5*time.Second, 50*time.Millisecond, "socket should appear")
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
"unix://"+sockPath,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
client := pb.NewCoreServiceClient(conn)
|
||||
_, err = client.StoreSet(ctx, &pb.StoreSetRequest{
|
||||
Group: "integration", Key: "boot", Value: "ok",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{
|
||||
Group: "integration", Key: "boot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", resp.Value)
|
||||
assert.True(t, resp.Found)
|
||||
|
||||
// Verify sidecar is running
|
||||
assert.True(t, svc.sidecar.IsRunning(), "Deno sidecar should be running")
|
||||
|
||||
// Clean shutdown
|
||||
err = svc.OnShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped")
|
||||
}
|
||||
|
||||
func TestIntegration_Tier2_Bidirectional_Good(t *testing.T) {
|
||||
denoPath := findDeno(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "core.sock")
|
||||
denoSockPath := filepath.Join(tmpDir, "deno.sock")
|
||||
|
||||
// Write a manifest
|
||||
coreDir := filepath.Join(tmpDir, ".core")
|
||||
require.NoError(t, os.MkdirAll(coreDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(`
|
||||
code: tier2-test
|
||||
name: Tier 2 Test
|
||||
version: "1.0"
|
||||
permissions:
|
||||
read: ["./data/"]
|
||||
run: ["echo"]
|
||||
`), 0644))
|
||||
|
||||
entryPoint := runtimeEntryPoint(t)
|
||||
|
||||
opts := Options{
|
||||
DenoPath: denoPath,
|
||||
SocketPath: sockPath,
|
||||
DenoSocketPath: denoSockPath,
|
||||
AppRoot: tmpDir,
|
||||
StoreDBPath: ":memory:",
|
||||
SidecarArgs: []string{"run", "-A", "--unstable-worker-options", entryPoint},
|
||||
}
|
||||
|
||||
c, err := core.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
factory := NewServiceFactory(opts)
|
||||
result, err := factory(c)
|
||||
require.NoError(t, err)
|
||||
svc := result.(*Service)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = svc.OnStartup(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both sockets appeared
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(sockPath)
|
||||
return err == nil
|
||||
}, 10*time.Second, 50*time.Millisecond, "core socket should appear")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(denoSockPath)
|
||||
return err == nil
|
||||
}, 10*time.Second, 50*time.Millisecond, "deno socket should appear")
|
||||
|
||||
// Verify sidecar is running
|
||||
assert.True(t, svc.sidecar.IsRunning(), "Deno sidecar should be running")
|
||||
|
||||
// Verify DenoClient is connected
|
||||
require.NotNil(t, svc.DenoClient(), "DenoClient should be connected")
|
||||
|
||||
// Test Go → Deno: LoadModule with real Worker
|
||||
modPath := testModulePath(t)
|
||||
loadResp, err := svc.DenoClient().LoadModule("test-module", modPath, ModulePermissions{
|
||||
Read: []string{filepath.Dir(modPath) + "/"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, loadResp.Ok)
|
||||
|
||||
// Wait for module to finish loading (async Worker init)
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := svc.DenoClient().ModuleStatus("test-module")
|
||||
return err == nil && (resp.Status == "RUNNING" || resp.Status == "ERRORED")
|
||||
}, 5*time.Second, 50*time.Millisecond, "module should finish loading")
|
||||
|
||||
statusResp, err := svc.DenoClient().ModuleStatus("test-module")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-module", statusResp.Code)
|
||||
assert.Equal(t, "RUNNING", statusResp.Status)
|
||||
|
||||
// Test Go → Deno: UnloadModule
|
||||
unloadResp, err := svc.DenoClient().UnloadModule("test-module")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, unloadResp.Ok)
|
||||
|
||||
// Verify module is now STOPPED
|
||||
statusResp2, err := svc.DenoClient().ModuleStatus("test-module")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "STOPPED", statusResp2.Status)
|
||||
|
||||
// Verify CoreService gRPC still works (Deno wrote health check data)
|
||||
conn, err := grpc.NewClient(
|
||||
"unix://"+sockPath,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
coreClient := pb.NewCoreServiceClient(conn)
|
||||
getResp, err := coreClient.StoreGet(ctx, &pb.StoreGetRequest{
|
||||
Group: "_coredeno", Key: "status",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, getResp.Found)
|
||||
assert.Equal(t, "connected", getResp.Value, "Deno should have written health check")
|
||||
|
||||
// Clean shutdown
|
||||
err = svc.OnShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped")
|
||||
}
|
||||
|
||||
func TestIntegration_Tier3_WorkerIsolation_Good(t *testing.T) {
|
||||
denoPath := findDeno(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "core.sock")
|
||||
denoSockPath := filepath.Join(tmpDir, "deno.sock")
|
||||
|
||||
// Write a manifest
|
||||
coreDir := filepath.Join(tmpDir, ".core")
|
||||
require.NoError(t, os.MkdirAll(coreDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(`
|
||||
code: tier3-test
|
||||
name: Tier 3 Test
|
||||
version: "1.0"
|
||||
permissions:
|
||||
read: ["./data/"]
|
||||
`), 0644))
|
||||
|
||||
entryPoint := runtimeEntryPoint(t)
|
||||
modPath := testModulePath(t)
|
||||
|
||||
opts := Options{
|
||||
DenoPath: denoPath,
|
||||
SocketPath: sockPath,
|
||||
DenoSocketPath: denoSockPath,
|
||||
AppRoot: tmpDir,
|
||||
StoreDBPath: ":memory:",
|
||||
SidecarArgs: []string{"run", "-A", "--unstable-worker-options", entryPoint},
|
||||
}
|
||||
|
||||
c, err := core.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
factory := NewServiceFactory(opts)
|
||||
result, err := factory(c)
|
||||
require.NoError(t, err)
|
||||
svc := result.(*Service)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = svc.OnStartup(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both sockets appeared
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(denoSockPath)
|
||||
return err == nil
|
||||
}, 10*time.Second, 50*time.Millisecond, "deno socket should appear")
|
||||
|
||||
require.NotNil(t, svc.DenoClient(), "DenoClient should be connected")
|
||||
|
||||
// Load a real module — it writes to store via I/O bridge
|
||||
loadResp, err := svc.DenoClient().LoadModule("test-mod", modPath, ModulePermissions{
|
||||
Read: []string{filepath.Dir(modPath) + "/"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, loadResp.Ok)
|
||||
|
||||
// Wait for module to reach RUNNING (Worker init + init() completes)
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := svc.DenoClient().ModuleStatus("test-mod")
|
||||
return err == nil && resp.Status == "RUNNING"
|
||||
}, 10*time.Second, 100*time.Millisecond, "module should be RUNNING")
|
||||
|
||||
// Verify the module wrote to the store via the I/O bridge
|
||||
// Module calls: core.storeSet("test-module", "init", "ok")
|
||||
conn, err := grpc.NewClient(
|
||||
"unix://"+sockPath,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
coreClient := pb.NewCoreServiceClient(conn)
|
||||
|
||||
// Poll for the store value — module init is async
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := coreClient.StoreGet(ctx, &pb.StoreGetRequest{
|
||||
Group: "test-module", Key: "init",
|
||||
})
|
||||
return err == nil && resp.Found && resp.Value == "ok"
|
||||
}, 5*time.Second, 100*time.Millisecond, "module should have written to store via I/O bridge")
|
||||
|
||||
// Unload and verify
|
||||
unloadResp, err := svc.DenoClient().UnloadModule("test-mod")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, unloadResp.Ok)
|
||||
|
||||
statusResp, err := svc.DenoClient().ModuleStatus("test-mod")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "STOPPED", statusResp.Status)
|
||||
|
||||
// Clean shutdown
|
||||
err = svc.OnShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped")
|
||||
}
|
||||
|
||||
// createModuleRepo creates a git repo containing a test module with manifest + main.ts.
|
||||
// The module's init() writes to the store to prove the I/O bridge works.
|
||||
func createModuleRepo(t *testing.T, code string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), code+"-repo")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0755))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "view.yml"), []byte(`
|
||||
code: `+code+`
|
||||
name: Test Module `+code+`
|
||||
version: "1.0"
|
||||
permissions:
|
||||
read: ["./"]
|
||||
`), 0644))
|
||||
|
||||
// Module that writes to store to prove it ran
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.ts"), []byte(`
|
||||
export async function init(core: any) {
|
||||
await core.storeSet("`+code+`", "installed", "yes");
|
||||
}
|
||||
`), 0644))
|
||||
|
||||
gitCmd := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{
|
||||
"-C", dir, "-c", "user.email=test@test.com", "-c", "user.name=test",
|
||||
}, args...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "git %v: %s", args, string(out))
|
||||
}
|
||||
gitCmd("init")
|
||||
gitCmd("add", ".")
|
||||
gitCmd("commit", "-m", "init")
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestIntegration_Tier4_MarketplaceInstall_Good(t *testing.T) {
|
||||
denoPath := findDeno(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "core.sock")
|
||||
denoSockPath := filepath.Join(tmpDir, "deno.sock")
|
||||
|
||||
// Write app manifest
|
||||
coreDir := filepath.Join(tmpDir, ".core")
|
||||
require.NoError(t, os.MkdirAll(coreDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(`
|
||||
code: tier4-test
|
||||
name: Tier 4 Test
|
||||
version: "1.0"
|
||||
permissions:
|
||||
read: ["./"]
|
||||
`), 0644))
|
||||
|
||||
entryPoint := runtimeEntryPoint(t)
|
||||
|
||||
opts := Options{
|
||||
DenoPath: denoPath,
|
||||
SocketPath: sockPath,
|
||||
DenoSocketPath: denoSockPath,
|
||||
AppRoot: tmpDir,
|
||||
StoreDBPath: ":memory:",
|
||||
SidecarArgs: []string{"run", "-A", "--unstable-worker-options", entryPoint},
|
||||
}
|
||||
|
||||
c, err := core.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
factory := NewServiceFactory(opts)
|
||||
result, err := factory(c)
|
||||
require.NoError(t, err)
|
||||
svc := result.(*Service)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = svc.OnStartup(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify sidecar and Deno client are up
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(denoSockPath)
|
||||
return err == nil
|
||||
}, 10*time.Second, 50*time.Millisecond, "deno socket should appear")
|
||||
|
||||
require.NotNil(t, svc.DenoClient(), "DenoClient should be connected")
|
||||
require.NotNil(t, svc.Installer(), "Installer should be available")
|
||||
|
||||
// Create a test module repo and install it
|
||||
moduleRepo := createModuleRepo(t, "market-mod")
|
||||
err = svc.Installer().Install(ctx, marketplace.Module{
|
||||
Code: "market-mod",
|
||||
Repo: moduleRepo,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the module was installed on disk
|
||||
modulesDir := filepath.Join(tmpDir, "modules", "market-mod")
|
||||
require.DirExists(t, modulesDir)
|
||||
|
||||
// Verify Installed() returns it
|
||||
installed, err := svc.Installer().Installed()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, installed, 1)
|
||||
assert.Equal(t, "market-mod", installed[0].Code)
|
||||
assert.Equal(t, "1.0", installed[0].Version)
|
||||
|
||||
// Load the installed module into the Deno runtime
|
||||
mod := installed[0]
|
||||
loadResp, err := svc.DenoClient().LoadModule(mod.Code, mod.EntryPoint, ModulePermissions{
|
||||
Read: mod.Permissions.Read,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, loadResp.Ok)
|
||||
|
||||
// Wait for module to reach RUNNING
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := svc.DenoClient().ModuleStatus("market-mod")
|
||||
return err == nil && resp.Status == "RUNNING"
|
||||
}, 10*time.Second, 100*time.Millisecond, "installed module should be RUNNING")
|
||||
|
||||
// Verify the module wrote to the store via I/O bridge
|
||||
conn, err := grpc.NewClient(
|
||||
"unix://"+sockPath,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
coreClient := pb.NewCoreServiceClient(conn)
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := coreClient.StoreGet(ctx, &pb.StoreGetRequest{
|
||||
Group: "market-mod", Key: "installed",
|
||||
})
|
||||
return err == nil && resp.Found && resp.Value == "yes"
|
||||
}, 5*time.Second, 100*time.Millisecond, "installed module should have written to store via I/O bridge")
|
||||
|
||||
// Unload and remove
|
||||
unloadResp, err := svc.DenoClient().UnloadModule("market-mod")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, unloadResp.Ok)
|
||||
|
||||
err = svc.Installer().Remove("market-mod")
|
||||
require.NoError(t, err)
|
||||
assert.NoDirExists(t, modulesDir, "module directory should be removed")
|
||||
|
||||
installed2, err := svc.Installer().Installed()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, installed2, "no modules should be installed after remove")
|
||||
|
||||
// Clean shutdown
|
||||
err = svc.OnShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, svc.sidecar.IsRunning(), "Deno sidecar should be stopped")
|
||||
}
|
||||
75
pkg/coredeno/lifecycle.go
Normal file
75
pkg/coredeno/lifecycle.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Start launches the Deno sidecar process with the given entrypoint args.
|
||||
func (s *Sidecar) Start(ctx context.Context, args ...string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.cmd != nil {
|
||||
return fmt.Errorf("coredeno: already running")
|
||||
}
|
||||
|
||||
// Ensure socket directory exists with owner-only permissions
|
||||
sockDir := filepath.Dir(s.opts.SocketPath)
|
||||
if err := os.MkdirAll(sockDir, 0700); err != nil {
|
||||
return fmt.Errorf("coredeno: mkdir %s: %w", sockDir, err)
|
||||
}
|
||||
|
||||
// Remove stale Deno socket (the Core socket is managed by ListenGRPC)
|
||||
if s.opts.DenoSocketPath != "" {
|
||||
os.Remove(s.opts.DenoSocketPath)
|
||||
}
|
||||
|
||||
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||
s.cmd = exec.CommandContext(s.ctx, s.opts.DenoPath, args...)
|
||||
s.cmd.Env = append(os.Environ(),
|
||||
"CORE_SOCKET="+s.opts.SocketPath,
|
||||
"DENO_SOCKET="+s.opts.DenoSocketPath,
|
||||
)
|
||||
s.done = make(chan struct{})
|
||||
if err := s.cmd.Start(); err != nil {
|
||||
s.cmd = nil
|
||||
s.cancel()
|
||||
return fmt.Errorf("coredeno: start: %w", err)
|
||||
}
|
||||
|
||||
// Monitor in background — waits for exit, then signals done
|
||||
go func() {
|
||||
s.cmd.Wait()
|
||||
s.mu.Lock()
|
||||
s.cmd = nil
|
||||
s.mu.Unlock()
|
||||
close(s.done)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels the context and waits for the process to exit.
|
||||
func (s *Sidecar) Stop() error {
|
||||
s.mu.RLock()
|
||||
if s.cmd == nil {
|
||||
s.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
done := s.done
|
||||
s.mu.RUnlock()
|
||||
|
||||
s.cancel()
|
||||
<-done
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns true if the sidecar process is alive.
|
||||
func (s *Sidecar) IsRunning() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cmd != nil
|
||||
}
|
||||
124
pkg/coredeno/lifecycle_test.go
Normal file
124
pkg/coredeno/lifecycle_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStart_Good(t *testing.T) {
|
||||
sockDir := t.TempDir()
|
||||
sc := NewSidecar(Options{
|
||||
DenoPath: "sleep",
|
||||
SocketPath: filepath.Join(sockDir, "test.sock"),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := sc.Start(ctx, "10") // sleep 10 — will be killed by Stop
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sc.IsRunning())
|
||||
|
||||
err = sc.Stop()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sc.IsRunning())
|
||||
}
|
||||
|
||||
func TestStop_Good_NotStarted(t *testing.T) {
|
||||
sc := NewSidecar(Options{DenoPath: "sleep"})
|
||||
err := sc.Stop()
|
||||
assert.NoError(t, err, "stopping a not-started sidecar should be a no-op")
|
||||
}
|
||||
|
||||
func TestStart_Good_EnvPassedToChild(t *testing.T) {
|
||||
sockDir := t.TempDir()
|
||||
sockPath := filepath.Join(sockDir, "test.sock")
|
||||
|
||||
sc := NewSidecar(Options{
|
||||
DenoPath: "sleep",
|
||||
SocketPath: sockPath,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := sc.Start(ctx, "10")
|
||||
require.NoError(t, err)
|
||||
defer sc.Stop()
|
||||
|
||||
// Verify the child process has CORE_SOCKET in its environment
|
||||
sc.mu.RLock()
|
||||
env := sc.cmd.Env
|
||||
sc.mu.RUnlock()
|
||||
|
||||
found := false
|
||||
expected := "CORE_SOCKET=" + sockPath
|
||||
for _, e := range env {
|
||||
if e == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "child process should receive CORE_SOCKET=%s", sockPath)
|
||||
}
|
||||
|
||||
func TestStart_Good_DenoSocketEnv(t *testing.T) {
|
||||
sockDir := t.TempDir()
|
||||
sockPath := filepath.Join(sockDir, "core.sock")
|
||||
denoSockPath := filepath.Join(sockDir, "deno.sock")
|
||||
|
||||
sc := NewSidecar(Options{
|
||||
DenoPath: "sleep",
|
||||
SocketPath: sockPath,
|
||||
DenoSocketPath: denoSockPath,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := sc.Start(ctx, "10")
|
||||
require.NoError(t, err)
|
||||
defer sc.Stop()
|
||||
|
||||
sc.mu.RLock()
|
||||
env := sc.cmd.Env
|
||||
sc.mu.RUnlock()
|
||||
|
||||
foundCore := false
|
||||
foundDeno := false
|
||||
for _, e := range env {
|
||||
if e == "CORE_SOCKET="+sockPath {
|
||||
foundCore = true
|
||||
}
|
||||
if e == "DENO_SOCKET="+denoSockPath {
|
||||
foundDeno = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundCore, "child should receive CORE_SOCKET")
|
||||
assert.True(t, foundDeno, "child should receive DENO_SOCKET")
|
||||
}
|
||||
|
||||
func TestSocketDirCreated_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sockPath := filepath.Join(dir, "sub", "deno.sock")
|
||||
sc := NewSidecar(Options{
|
||||
DenoPath: "sleep",
|
||||
SocketPath: sockPath,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := sc.Start(ctx, "10")
|
||||
require.NoError(t, err)
|
||||
defer sc.Stop()
|
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "sub"))
|
||||
assert.NoError(t, err, "socket directory should be created")
|
||||
}
|
||||
53
pkg/coredeno/listener.go
Normal file
53
pkg/coredeno/listener.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// ListenGRPC starts a gRPC server on a Unix socket, serving the CoreService.
|
||||
// It blocks until ctx is cancelled, then performs a graceful stop.
|
||||
func ListenGRPC(ctx context.Context, socketPath string, srv *Server) error {
|
||||
// Clean up stale socket
|
||||
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Restrict socket to owner only — prevents other users from sending gRPC commands.
|
||||
if err := os.Chmod(socketPath, 0600); err != nil {
|
||||
listener.Close()
|
||||
return fmt.Errorf("chmod socket: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
_ = os.Remove(socketPath)
|
||||
}()
|
||||
|
||||
gs := grpc.NewServer()
|
||||
pb.RegisterCoreServiceServer(gs, srv)
|
||||
|
||||
// Graceful stop when context cancelled
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
gs.GracefulStop()
|
||||
}()
|
||||
|
||||
if err := gs.Serve(listener); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil // Expected shutdown
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
122
pkg/coredeno/listener_test.go
Normal file
122
pkg/coredeno/listener_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/store"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestListenGRPC_Good(t *testing.T) {
|
||||
sockDir := t.TempDir()
|
||||
sockPath := filepath.Join(sockDir, "test.sock")
|
||||
|
||||
medium := io.NewMockMedium()
|
||||
st, err := store.New(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer st.Close()
|
||||
|
||||
srv := NewServer(medium, st)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- ListenGRPC(ctx, sockPath, srv)
|
||||
}()
|
||||
|
||||
// Wait for socket to appear
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(sockPath)
|
||||
return err == nil
|
||||
}, 2*time.Second, 10*time.Millisecond, "socket should appear")
|
||||
|
||||
// Connect as gRPC client
|
||||
conn, err := grpc.NewClient(
|
||||
"unix://"+sockPath,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
client := pb.NewCoreServiceClient(conn)
|
||||
|
||||
// StoreSet + StoreGet round-trip
|
||||
_, err = client.StoreSet(ctx, &pb.StoreSetRequest{
|
||||
Group: "test", Key: "k", Value: "v",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{
|
||||
Group: "test", Key: "k",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Found)
|
||||
assert.Equal(t, "v", resp.Value)
|
||||
|
||||
// Cancel ctx to stop listener
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
assert.NoError(t, err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("listener did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenGRPC_Bad_StaleSocket(t *testing.T) {
|
||||
// Use a short temp dir — macOS limits Unix socket paths to 104 bytes (sun_path)
|
||||
// and t.TempDir() + this test's long name can exceed that.
|
||||
sockDir, err := os.MkdirTemp("", "grpc")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { os.RemoveAll(sockDir) })
|
||||
sockPath := filepath.Join(sockDir, "s.sock")
|
||||
|
||||
// Create a stale regular file where the socket should go
|
||||
require.NoError(t, os.WriteFile(sockPath, []byte("stale"), 0644))
|
||||
|
||||
medium := io.NewMockMedium()
|
||||
st, err := store.New(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer st.Close()
|
||||
|
||||
srv := NewServer(medium, st)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- ListenGRPC(ctx, sockPath, srv)
|
||||
}()
|
||||
|
||||
// Should replace stale file and start listening.
|
||||
// Also watch errCh — if ListenGRPC returns early, fail with the actual error.
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("ListenGRPC returned early: %v", err)
|
||||
return false
|
||||
default:
|
||||
}
|
||||
info, err := os.Stat(sockPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeSocket != 0
|
||||
}, 2*time.Second, 10*time.Millisecond, "socket should replace stale file")
|
||||
|
||||
cancel()
|
||||
<-errCh
|
||||
}
|
||||
44
pkg/coredeno/permissions.go
Normal file
44
pkg/coredeno/permissions.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckPath returns true if the given path is under any of the allowed prefixes.
|
||||
// Empty allowed list means deny all (secure by default).
|
||||
func CheckPath(path string, allowed []string) bool {
|
||||
if len(allowed) == 0 {
|
||||
return false
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
for _, prefix := range allowed {
|
||||
cleanPrefix := filepath.Clean(prefix)
|
||||
// Exact match or path is under the prefix directory.
|
||||
// The separator check prevents "data" matching "data-secrets".
|
||||
if clean == cleanPrefix || strings.HasPrefix(clean, cleanPrefix+string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckNet returns true if the given host:port is in the allowed list.
|
||||
func CheckNet(addr string, allowed []string) bool {
|
||||
for _, a := range allowed {
|
||||
if a == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckRun returns true if the given command is in the allowed list.
|
||||
func CheckRun(cmd string, allowed []string) bool {
|
||||
for _, a := range allowed {
|
||||
if a == cmd {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
40
pkg/coredeno/permissions_test.go
Normal file
40
pkg/coredeno/permissions_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckPath_Good_Allowed(t *testing.T) {
|
||||
allowed := []string{"./data/", "./config/"}
|
||||
assert.True(t, CheckPath("./data/file.txt", allowed))
|
||||
assert.True(t, CheckPath("./config/app.json", allowed))
|
||||
}
|
||||
|
||||
func TestCheckPath_Bad_Denied(t *testing.T) {
|
||||
allowed := []string{"./data/"}
|
||||
assert.False(t, CheckPath("./secrets/key.pem", allowed))
|
||||
assert.False(t, CheckPath("../escape/file", allowed))
|
||||
}
|
||||
|
||||
func TestCheckPath_Good_EmptyDenyAll(t *testing.T) {
|
||||
assert.False(t, CheckPath("./anything", nil))
|
||||
assert.False(t, CheckPath("./anything", []string{}))
|
||||
}
|
||||
|
||||
func TestCheckNet_Good_Allowed(t *testing.T) {
|
||||
allowed := []string{"pool.lthn.io:3333", "api.lthn.io:443"}
|
||||
assert.True(t, CheckNet("pool.lthn.io:3333", allowed))
|
||||
}
|
||||
|
||||
func TestCheckNet_Bad_Denied(t *testing.T) {
|
||||
allowed := []string{"pool.lthn.io:3333"}
|
||||
assert.False(t, CheckNet("evil.com:80", allowed))
|
||||
}
|
||||
|
||||
func TestCheckRun_Good(t *testing.T) {
|
||||
allowed := []string{"xmrig", "sha256sum"}
|
||||
assert.True(t, CheckRun("xmrig", allowed))
|
||||
assert.False(t, CheckRun("rm", allowed))
|
||||
}
|
||||
1420
pkg/coredeno/proto/coredeno.pb.go
Normal file
1420
pkg/coredeno/proto/coredeno.pb.go
Normal file
File diff suppressed because it is too large
Load diff
81
pkg/coredeno/proto/coredeno.proto
Normal file
81
pkg/coredeno/proto/coredeno.proto
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
syntax = "proto3";
|
||||
package coredeno;
|
||||
option go_package = "forge.lthn.ai/core/go/pkg/coredeno/proto";
|
||||
|
||||
// CoreService is implemented by CoreGO — Deno calls this for I/O.
|
||||
service CoreService {
|
||||
// Filesystem (gated by manifest permissions)
|
||||
rpc FileRead(FileReadRequest) returns (FileReadResponse);
|
||||
rpc FileWrite(FileWriteRequest) returns (FileWriteResponse);
|
||||
rpc FileList(FileListRequest) returns (FileListResponse);
|
||||
rpc FileDelete(FileDeleteRequest) returns (FileDeleteResponse);
|
||||
|
||||
// Object store
|
||||
rpc StoreGet(StoreGetRequest) returns (StoreGetResponse);
|
||||
rpc StoreSet(StoreSetRequest) returns (StoreSetResponse);
|
||||
|
||||
// Process management
|
||||
rpc ProcessStart(ProcessStartRequest) returns (ProcessStartResponse);
|
||||
rpc ProcessStop(ProcessStopRequest) returns (ProcessStopResponse);
|
||||
}
|
||||
|
||||
// DenoService is implemented by CoreDeno — Go calls this for module lifecycle.
|
||||
service DenoService {
|
||||
rpc LoadModule(LoadModuleRequest) returns (LoadModuleResponse);
|
||||
rpc UnloadModule(UnloadModuleRequest) returns (UnloadModuleResponse);
|
||||
rpc ModuleStatus(ModuleStatusRequest) returns (ModuleStatusResponse);
|
||||
}
|
||||
|
||||
// --- Core (Go-side) messages ---
|
||||
|
||||
message FileReadRequest { string path = 1; string module_code = 2; }
|
||||
message FileReadResponse { string content = 1; }
|
||||
|
||||
message FileWriteRequest { string path = 1; string content = 2; string module_code = 3; }
|
||||
message FileWriteResponse { bool ok = 1; }
|
||||
|
||||
message FileListRequest { string path = 1; string module_code = 2; }
|
||||
message FileListResponse {
|
||||
repeated FileEntry entries = 1;
|
||||
}
|
||||
message FileEntry {
|
||||
string name = 1;
|
||||
bool is_dir = 2;
|
||||
int64 size = 3;
|
||||
}
|
||||
|
||||
message FileDeleteRequest { string path = 1; string module_code = 2; }
|
||||
message FileDeleteResponse { bool ok = 1; }
|
||||
|
||||
message StoreGetRequest { string group = 1; string key = 2; }
|
||||
message StoreGetResponse { string value = 1; bool found = 2; }
|
||||
|
||||
message StoreSetRequest { string group = 1; string key = 2; string value = 3; }
|
||||
message StoreSetResponse { bool ok = 1; }
|
||||
|
||||
message ProcessStartRequest { string command = 1; repeated string args = 2; string module_code = 3; }
|
||||
message ProcessStartResponse { string process_id = 1; }
|
||||
|
||||
message ProcessStopRequest { string process_id = 1; }
|
||||
message ProcessStopResponse { bool ok = 1; }
|
||||
|
||||
// --- Deno-side messages ---
|
||||
|
||||
message LoadModuleRequest { string code = 1; string entry_point = 2; repeated string permissions = 3; }
|
||||
message LoadModuleResponse { bool ok = 1; string error = 2; }
|
||||
|
||||
message UnloadModuleRequest { string code = 1; }
|
||||
message UnloadModuleResponse { bool ok = 1; }
|
||||
|
||||
message ModuleStatusRequest { string code = 1; }
|
||||
message ModuleStatusResponse {
|
||||
string code = 1;
|
||||
enum Status {
|
||||
UNKNOWN = 0;
|
||||
LOADING = 1;
|
||||
RUNNING = 2;
|
||||
STOPPED = 3;
|
||||
ERRORED = 4;
|
||||
}
|
||||
Status status = 2;
|
||||
}
|
||||
579
pkg/coredeno/proto/coredeno_grpc.pb.go
Normal file
579
pkg/coredeno/proto/coredeno_grpc.pb.go
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v3.21.12
|
||||
// source: pkg/coredeno/proto/coredeno.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
CoreService_FileRead_FullMethodName = "/coredeno.CoreService/FileRead"
|
||||
CoreService_FileWrite_FullMethodName = "/coredeno.CoreService/FileWrite"
|
||||
CoreService_FileList_FullMethodName = "/coredeno.CoreService/FileList"
|
||||
CoreService_FileDelete_FullMethodName = "/coredeno.CoreService/FileDelete"
|
||||
CoreService_StoreGet_FullMethodName = "/coredeno.CoreService/StoreGet"
|
||||
CoreService_StoreSet_FullMethodName = "/coredeno.CoreService/StoreSet"
|
||||
CoreService_ProcessStart_FullMethodName = "/coredeno.CoreService/ProcessStart"
|
||||
CoreService_ProcessStop_FullMethodName = "/coredeno.CoreService/ProcessStop"
|
||||
)
|
||||
|
||||
// CoreServiceClient is the client API for CoreService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// CoreService is implemented by CoreGO — Deno calls this for I/O.
|
||||
type CoreServiceClient interface {
|
||||
// Filesystem (gated by manifest permissions)
|
||||
FileRead(ctx context.Context, in *FileReadRequest, opts ...grpc.CallOption) (*FileReadResponse, error)
|
||||
FileWrite(ctx context.Context, in *FileWriteRequest, opts ...grpc.CallOption) (*FileWriteResponse, error)
|
||||
FileList(ctx context.Context, in *FileListRequest, opts ...grpc.CallOption) (*FileListResponse, error)
|
||||
FileDelete(ctx context.Context, in *FileDeleteRequest, opts ...grpc.CallOption) (*FileDeleteResponse, error)
|
||||
// Object store
|
||||
StoreGet(ctx context.Context, in *StoreGetRequest, opts ...grpc.CallOption) (*StoreGetResponse, error)
|
||||
StoreSet(ctx context.Context, in *StoreSetRequest, opts ...grpc.CallOption) (*StoreSetResponse, error)
|
||||
// Process management
|
||||
ProcessStart(ctx context.Context, in *ProcessStartRequest, opts ...grpc.CallOption) (*ProcessStartResponse, error)
|
||||
ProcessStop(ctx context.Context, in *ProcessStopRequest, opts ...grpc.CallOption) (*ProcessStopResponse, error)
|
||||
}
|
||||
|
||||
type coreServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewCoreServiceClient(cc grpc.ClientConnInterface) CoreServiceClient {
|
||||
return &coreServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *coreServiceClient) FileRead(ctx context.Context, in *FileReadRequest, opts ...grpc.CallOption) (*FileReadResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileReadResponse)
|
||||
err := c.cc.Invoke(ctx, CoreService_FileRead_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *coreServiceClient) FileWrite(ctx context.Context, in *FileWriteRequest, opts ...grpc.CallOption) (*FileWriteResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileWriteResponse)
|
||||
err := c.cc.Invoke(ctx, CoreService_FileWrite_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *coreServiceClient) FileList(ctx context.Context, in *FileListRequest, opts ...grpc.CallOption) (*FileListResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileListResponse)
|
||||
err := c.cc.Invoke(ctx, CoreService_FileList_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *coreServiceClient) FileDelete(ctx context.Context, in *FileDeleteRequest, opts ...grpc.CallOption) (*FileDeleteResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(FileDeleteResponse)
|
||||
err := c.cc.Invoke(ctx, CoreService_FileDelete_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *coreServiceClient) StoreGet(ctx context.Context, in *StoreGetRequest, opts ...grpc.CallOption) (*StoreGetResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(StoreGetResponse)
|
||||
err := c.cc.Invoke(ctx, CoreService_StoreGet_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *coreServiceClient) StoreSet(ctx context.Context, in *StoreSetRequest, opts ...grpc.CallOption) (*StoreSetResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(StoreSetResponse)
|
||||
err := c.cc.Invoke(ctx, CoreService_StoreSet_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *coreServiceClient) ProcessStart(ctx context.Context, in *ProcessStartRequest, opts ...grpc.CallOption) (*ProcessStartResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ProcessStartResponse)
|
||||
err := c.cc.Invoke(ctx, CoreService_ProcessStart_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *coreServiceClient) ProcessStop(ctx context.Context, in *ProcessStopRequest, opts ...grpc.CallOption) (*ProcessStopResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ProcessStopResponse)
|
||||
err := c.cc.Invoke(ctx, CoreService_ProcessStop_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CoreServiceServer is the server API for CoreService service.
|
||||
// All implementations must embed UnimplementedCoreServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// CoreService is implemented by CoreGO — Deno calls this for I/O.
|
||||
type CoreServiceServer interface {
|
||||
// Filesystem (gated by manifest permissions)
|
||||
FileRead(context.Context, *FileReadRequest) (*FileReadResponse, error)
|
||||
FileWrite(context.Context, *FileWriteRequest) (*FileWriteResponse, error)
|
||||
FileList(context.Context, *FileListRequest) (*FileListResponse, error)
|
||||
FileDelete(context.Context, *FileDeleteRequest) (*FileDeleteResponse, error)
|
||||
// Object store
|
||||
StoreGet(context.Context, *StoreGetRequest) (*StoreGetResponse, error)
|
||||
StoreSet(context.Context, *StoreSetRequest) (*StoreSetResponse, error)
|
||||
// Process management
|
||||
ProcessStart(context.Context, *ProcessStartRequest) (*ProcessStartResponse, error)
|
||||
ProcessStop(context.Context, *ProcessStopRequest) (*ProcessStopResponse, error)
|
||||
mustEmbedUnimplementedCoreServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedCoreServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedCoreServiceServer struct{}
|
||||
|
||||
func (UnimplementedCoreServiceServer) FileRead(context.Context, *FileReadRequest) (*FileReadResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileRead not implemented")
|
||||
}
|
||||
func (UnimplementedCoreServiceServer) FileWrite(context.Context, *FileWriteRequest) (*FileWriteResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileWrite not implemented")
|
||||
}
|
||||
func (UnimplementedCoreServiceServer) FileList(context.Context, *FileListRequest) (*FileListResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileList not implemented")
|
||||
}
|
||||
func (UnimplementedCoreServiceServer) FileDelete(context.Context, *FileDeleteRequest) (*FileDeleteResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method FileDelete not implemented")
|
||||
}
|
||||
func (UnimplementedCoreServiceServer) StoreGet(context.Context, *StoreGetRequest) (*StoreGetResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method StoreGet not implemented")
|
||||
}
|
||||
func (UnimplementedCoreServiceServer) StoreSet(context.Context, *StoreSetRequest) (*StoreSetResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method StoreSet not implemented")
|
||||
}
|
||||
func (UnimplementedCoreServiceServer) ProcessStart(context.Context, *ProcessStartRequest) (*ProcessStartResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ProcessStart not implemented")
|
||||
}
|
||||
func (UnimplementedCoreServiceServer) ProcessStop(context.Context, *ProcessStopRequest) (*ProcessStopResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ProcessStop not implemented")
|
||||
}
|
||||
func (UnimplementedCoreServiceServer) mustEmbedUnimplementedCoreServiceServer() {}
|
||||
func (UnimplementedCoreServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeCoreServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to CoreServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeCoreServiceServer interface {
|
||||
mustEmbedUnimplementedCoreServiceServer()
|
||||
}
|
||||
|
||||
func RegisterCoreServiceServer(s grpc.ServiceRegistrar, srv CoreServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedCoreServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&CoreService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _CoreService_FileRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileReadRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CoreServiceServer).FileRead(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CoreService_FileRead_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CoreServiceServer).FileRead(ctx, req.(*FileReadRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CoreService_FileWrite_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileWriteRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CoreServiceServer).FileWrite(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CoreService_FileWrite_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CoreServiceServer).FileWrite(ctx, req.(*FileWriteRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CoreService_FileList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileListRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CoreServiceServer).FileList(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CoreService_FileList_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CoreServiceServer).FileList(ctx, req.(*FileListRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CoreService_FileDelete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(FileDeleteRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CoreServiceServer).FileDelete(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CoreService_FileDelete_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CoreServiceServer).FileDelete(ctx, req.(*FileDeleteRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CoreService_StoreGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(StoreGetRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CoreServiceServer).StoreGet(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CoreService_StoreGet_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CoreServiceServer).StoreGet(ctx, req.(*StoreGetRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CoreService_StoreSet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(StoreSetRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CoreServiceServer).StoreSet(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CoreService_StoreSet_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CoreServiceServer).StoreSet(ctx, req.(*StoreSetRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CoreService_ProcessStart_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ProcessStartRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CoreServiceServer).ProcessStart(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CoreService_ProcessStart_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CoreServiceServer).ProcessStart(ctx, req.(*ProcessStartRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CoreService_ProcessStop_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ProcessStopRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CoreServiceServer).ProcessStop(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CoreService_ProcessStop_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CoreServiceServer).ProcessStop(ctx, req.(*ProcessStopRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// CoreService_ServiceDesc is the grpc.ServiceDesc for CoreService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var CoreService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "coredeno.CoreService",
|
||||
HandlerType: (*CoreServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "FileRead",
|
||||
Handler: _CoreService_FileRead_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileWrite",
|
||||
Handler: _CoreService_FileWrite_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileList",
|
||||
Handler: _CoreService_FileList_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "FileDelete",
|
||||
Handler: _CoreService_FileDelete_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "StoreGet",
|
||||
Handler: _CoreService_StoreGet_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "StoreSet",
|
||||
Handler: _CoreService_StoreSet_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ProcessStart",
|
||||
Handler: _CoreService_ProcessStart_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ProcessStop",
|
||||
Handler: _CoreService_ProcessStop_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "pkg/coredeno/proto/coredeno.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
DenoService_LoadModule_FullMethodName = "/coredeno.DenoService/LoadModule"
|
||||
DenoService_UnloadModule_FullMethodName = "/coredeno.DenoService/UnloadModule"
|
||||
DenoService_ModuleStatus_FullMethodName = "/coredeno.DenoService/ModuleStatus"
|
||||
)
|
||||
|
||||
// DenoServiceClient is the client API for DenoService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// DenoService is implemented by CoreDeno — Go calls this for module lifecycle.
|
||||
type DenoServiceClient interface {
|
||||
LoadModule(ctx context.Context, in *LoadModuleRequest, opts ...grpc.CallOption) (*LoadModuleResponse, error)
|
||||
UnloadModule(ctx context.Context, in *UnloadModuleRequest, opts ...grpc.CallOption) (*UnloadModuleResponse, error)
|
||||
ModuleStatus(ctx context.Context, in *ModuleStatusRequest, opts ...grpc.CallOption) (*ModuleStatusResponse, error)
|
||||
}
|
||||
|
||||
type denoServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewDenoServiceClient(cc grpc.ClientConnInterface) DenoServiceClient {
|
||||
return &denoServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *denoServiceClient) LoadModule(ctx context.Context, in *LoadModuleRequest, opts ...grpc.CallOption) (*LoadModuleResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(LoadModuleResponse)
|
||||
err := c.cc.Invoke(ctx, DenoService_LoadModule_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *denoServiceClient) UnloadModule(ctx context.Context, in *UnloadModuleRequest, opts ...grpc.CallOption) (*UnloadModuleResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UnloadModuleResponse)
|
||||
err := c.cc.Invoke(ctx, DenoService_UnloadModule_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *denoServiceClient) ModuleStatus(ctx context.Context, in *ModuleStatusRequest, opts ...grpc.CallOption) (*ModuleStatusResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ModuleStatusResponse)
|
||||
err := c.cc.Invoke(ctx, DenoService_ModuleStatus_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DenoServiceServer is the server API for DenoService service.
|
||||
// All implementations must embed UnimplementedDenoServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// DenoService is implemented by CoreDeno — Go calls this for module lifecycle.
|
||||
type DenoServiceServer interface {
|
||||
LoadModule(context.Context, *LoadModuleRequest) (*LoadModuleResponse, error)
|
||||
UnloadModule(context.Context, *UnloadModuleRequest) (*UnloadModuleResponse, error)
|
||||
ModuleStatus(context.Context, *ModuleStatusRequest) (*ModuleStatusResponse, error)
|
||||
mustEmbedUnimplementedDenoServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedDenoServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedDenoServiceServer struct{}
|
||||
|
||||
func (UnimplementedDenoServiceServer) LoadModule(context.Context, *LoadModuleRequest) (*LoadModuleResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method LoadModule not implemented")
|
||||
}
|
||||
func (UnimplementedDenoServiceServer) UnloadModule(context.Context, *UnloadModuleRequest) (*UnloadModuleResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method UnloadModule not implemented")
|
||||
}
|
||||
func (UnimplementedDenoServiceServer) ModuleStatus(context.Context, *ModuleStatusRequest) (*ModuleStatusResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ModuleStatus not implemented")
|
||||
}
|
||||
func (UnimplementedDenoServiceServer) mustEmbedUnimplementedDenoServiceServer() {}
|
||||
func (UnimplementedDenoServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeDenoServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to DenoServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeDenoServiceServer interface {
|
||||
mustEmbedUnimplementedDenoServiceServer()
|
||||
}
|
||||
|
||||
func RegisterDenoServiceServer(s grpc.ServiceRegistrar, srv DenoServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedDenoServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&DenoService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _DenoService_LoadModule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(LoadModuleRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DenoServiceServer).LoadModule(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DenoService_LoadModule_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DenoServiceServer).LoadModule(ctx, req.(*LoadModuleRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DenoService_UnloadModule_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UnloadModuleRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DenoServiceServer).UnloadModule(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DenoService_UnloadModule_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DenoServiceServer).UnloadModule(ctx, req.(*UnloadModuleRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DenoService_ModuleStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ModuleStatusRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DenoServiceServer).ModuleStatus(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DenoService_ModuleStatus_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DenoServiceServer).ModuleStatus(ctx, req.(*ModuleStatusRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// DenoService_ServiceDesc is the grpc.ServiceDesc for DenoService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var DenoService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "coredeno.DenoService",
|
||||
HandlerType: (*DenoServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "LoadModule",
|
||||
Handler: _DenoService_LoadModule_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UnloadModule",
|
||||
Handler: _DenoService_UnloadModule_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ModuleStatus",
|
||||
Handler: _DenoService_ModuleStatus_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "pkg/coredeno/proto/coredeno.proto",
|
||||
}
|
||||
95
pkg/coredeno/runtime/client.ts
Normal file
95
pkg/coredeno/runtime/client.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// CoreService gRPC client — Deno calls Go for I/O operations.
|
||||
// All filesystem, store, and process operations route through this client.
|
||||
|
||||
import * as grpc from "@grpc/grpc-js";
|
||||
import * as protoLoader from "@grpc/proto-loader";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROTO_PATH = join(__dirname, "..", "proto", "coredeno.proto");
|
||||
|
||||
let packageDef: protoLoader.PackageDefinition | null = null;
|
||||
|
||||
function getProto(): any {
|
||||
if (!packageDef) {
|
||||
packageDef = protoLoader.loadSync(PROTO_PATH, {
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
});
|
||||
}
|
||||
return grpc.loadPackageDefinition(packageDef).coredeno as any;
|
||||
}
|
||||
|
||||
export interface CoreClient {
|
||||
raw: any;
|
||||
storeGet(group: string, key: string): Promise<{ value: string; found: boolean }>;
|
||||
storeSet(group: string, key: string, value: string): Promise<{ ok: boolean }>;
|
||||
fileRead(path: string, moduleCode: string): Promise<{ content: string }>;
|
||||
fileWrite(path: string, content: string, moduleCode: string): Promise<{ ok: boolean }>;
|
||||
fileList(path: string, moduleCode: string): Promise<{ entries: Array<{ name: string; is_dir: boolean; size: number }> }>;
|
||||
fileDelete(path: string, moduleCode: string): Promise<{ ok: boolean }>;
|
||||
processStart(command: string, args: string[], moduleCode: string): Promise<{ process_id: string }>;
|
||||
processStop(processId: string): Promise<{ ok: boolean }>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
function promisify<T>(client: any, method: string, request: any): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client[method](request, (err: Error | null, response: T) => {
|
||||
if (err) reject(err);
|
||||
else resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createCoreClient(socketPath: string): CoreClient {
|
||||
const proto = getProto();
|
||||
const client = new proto.CoreService(
|
||||
`unix:${socketPath}`,
|
||||
grpc.credentials.createInsecure(),
|
||||
);
|
||||
|
||||
return {
|
||||
raw: client,
|
||||
|
||||
storeGet(group: string, key: string) {
|
||||
return promisify(client, "StoreGet", { group, key });
|
||||
},
|
||||
|
||||
storeSet(group: string, key: string, value: string) {
|
||||
return promisify(client, "StoreSet", { group, key, value });
|
||||
},
|
||||
|
||||
fileRead(path: string, moduleCode: string) {
|
||||
return promisify(client, "FileRead", { path, module_code: moduleCode });
|
||||
},
|
||||
|
||||
fileWrite(path: string, content: string, moduleCode: string) {
|
||||
return promisify(client, "FileWrite", { path, content, module_code: moduleCode });
|
||||
},
|
||||
|
||||
fileList(path: string, moduleCode: string) {
|
||||
return promisify(client, "FileList", { path, module_code: moduleCode });
|
||||
},
|
||||
|
||||
fileDelete(path: string, moduleCode: string) {
|
||||
return promisify(client, "FileDelete", { path, module_code: moduleCode });
|
||||
},
|
||||
|
||||
processStart(command: string, args: string[], moduleCode: string) {
|
||||
return promisify(client, "ProcessStart", { command, args, module_code: moduleCode });
|
||||
},
|
||||
|
||||
processStop(processId: string) {
|
||||
return promisify(client, "ProcessStop", { process_id: processId });
|
||||
},
|
||||
|
||||
close() {
|
||||
client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
8
pkg/coredeno/runtime/deno.json
Normal file
8
pkg/coredeno/runtime/deno.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"imports": {
|
||||
"@grpc/grpc-js": "npm:@grpc/grpc-js@^1.12",
|
||||
"@grpc/proto-loader": "npm:@grpc/proto-loader@^0.7"
|
||||
},
|
||||
"nodeModulesDir": "none",
|
||||
"unstable": ["worker-options"]
|
||||
}
|
||||
193
pkg/coredeno/runtime/deno.lock
generated
Normal file
193
pkg/coredeno/runtime/deno.lock
generated
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"npm:@grpc/grpc-js@^1.12.0": "1.14.3",
|
||||
"npm:@grpc/proto-loader@0.7": "0.7.15"
|
||||
},
|
||||
"npm": {
|
||||
"@grpc/grpc-js@1.14.3": {
|
||||
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
|
||||
"dependencies": [
|
||||
"@grpc/proto-loader@0.8.0",
|
||||
"@js-sdsl/ordered-map"
|
||||
]
|
||||
},
|
||||
"@grpc/proto-loader@0.7.15": {
|
||||
"integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==",
|
||||
"dependencies": [
|
||||
"lodash.camelcase",
|
||||
"long",
|
||||
"protobufjs",
|
||||
"yargs"
|
||||
],
|
||||
"bin": true
|
||||
},
|
||||
"@grpc/proto-loader@0.8.0": {
|
||||
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
|
||||
"dependencies": [
|
||||
"lodash.camelcase",
|
||||
"long",
|
||||
"protobufjs",
|
||||
"yargs"
|
||||
],
|
||||
"bin": true
|
||||
},
|
||||
"@js-sdsl/ordered-map@4.4.2": {
|
||||
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="
|
||||
},
|
||||
"@protobufjs/aspromise@1.1.2": {
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="
|
||||
},
|
||||
"@protobufjs/base64@1.1.2": {
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="
|
||||
},
|
||||
"@protobufjs/codegen@2.0.4": {
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="
|
||||
},
|
||||
"@protobufjs/eventemitter@1.1.0": {
|
||||
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="
|
||||
},
|
||||
"@protobufjs/fetch@1.1.0": {
|
||||
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
|
||||
"dependencies": [
|
||||
"@protobufjs/aspromise",
|
||||
"@protobufjs/inquire"
|
||||
]
|
||||
},
|
||||
"@protobufjs/float@1.0.2": {
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="
|
||||
},
|
||||
"@protobufjs/inquire@1.1.0": {
|
||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="
|
||||
},
|
||||
"@protobufjs/path@1.1.2": {
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="
|
||||
},
|
||||
"@protobufjs/pool@1.1.0": {
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="
|
||||
},
|
||||
"@protobufjs/utf8@1.1.0": {
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
||||
},
|
||||
"@types/node@25.2.3": {
|
||||
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
||||
"dependencies": [
|
||||
"undici-types"
|
||||
]
|
||||
},
|
||||
"ansi-regex@5.0.1": {
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
|
||||
},
|
||||
"ansi-styles@4.3.0": {
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dependencies": [
|
||||
"color-convert"
|
||||
]
|
||||
},
|
||||
"cliui@8.0.1": {
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dependencies": [
|
||||
"string-width",
|
||||
"strip-ansi",
|
||||
"wrap-ansi"
|
||||
]
|
||||
},
|
||||
"color-convert@2.0.1": {
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dependencies": [
|
||||
"color-name"
|
||||
]
|
||||
},
|
||||
"color-name@1.1.4": {
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"emoji-regex@8.0.0": {
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||
},
|
||||
"escalade@3.2.0": {
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
|
||||
},
|
||||
"get-caller-file@2.0.5": {
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
|
||||
},
|
||||
"is-fullwidth-code-point@3.0.0": {
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
|
||||
},
|
||||
"lodash.camelcase@4.3.0": {
|
||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
|
||||
},
|
||||
"long@5.3.2": {
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
|
||||
},
|
||||
"protobufjs@7.5.4": {
|
||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
||||
"dependencies": [
|
||||
"@protobufjs/aspromise",
|
||||
"@protobufjs/base64",
|
||||
"@protobufjs/codegen",
|
||||
"@protobufjs/eventemitter",
|
||||
"@protobufjs/fetch",
|
||||
"@protobufjs/float",
|
||||
"@protobufjs/inquire",
|
||||
"@protobufjs/path",
|
||||
"@protobufjs/pool",
|
||||
"@protobufjs/utf8",
|
||||
"@types/node",
|
||||
"long"
|
||||
],
|
||||
"scripts": true
|
||||
},
|
||||
"require-directory@2.1.1": {
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
|
||||
},
|
||||
"string-width@4.2.3": {
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dependencies": [
|
||||
"emoji-regex",
|
||||
"is-fullwidth-code-point",
|
||||
"strip-ansi"
|
||||
]
|
||||
},
|
||||
"strip-ansi@6.0.1": {
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dependencies": [
|
||||
"ansi-regex"
|
||||
]
|
||||
},
|
||||
"undici-types@7.16.0": {
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
|
||||
},
|
||||
"wrap-ansi@7.0.0": {
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dependencies": [
|
||||
"ansi-styles",
|
||||
"string-width",
|
||||
"strip-ansi"
|
||||
]
|
||||
},
|
||||
"y18n@5.0.8": {
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="
|
||||
},
|
||||
"yargs-parser@21.1.1": {
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="
|
||||
},
|
||||
"yargs@17.7.2": {
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dependencies": [
|
||||
"cliui",
|
||||
"escalade",
|
||||
"get-caller-file",
|
||||
"require-directory",
|
||||
"string-width",
|
||||
"y18n",
|
||||
"yargs-parser"
|
||||
]
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"npm:@grpc/grpc-js@^1.12.0",
|
||||
"npm:@grpc/proto-loader@0.7"
|
||||
]
|
||||
}
|
||||
}
|
||||
106
pkg/coredeno/runtime/main.ts
Normal file
106
pkg/coredeno/runtime/main.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// CoreDeno Runtime Entry Point
|
||||
// Connects to CoreGO via gRPC over Unix socket.
|
||||
// Implements DenoService for module lifecycle management.
|
||||
|
||||
// Must be first import — patches http2 before @grpc/grpc-js loads.
|
||||
import "./polyfill.ts";
|
||||
|
||||
import { createCoreClient, type CoreClient } from "./client.ts";
|
||||
import { startDenoServer, type DenoServer } from "./server.ts";
|
||||
import { ModuleRegistry } from "./modules.ts";
|
||||
|
||||
// Read required environment variables
|
||||
const coreSocket = Deno.env.get("CORE_SOCKET");
|
||||
if (!coreSocket) {
|
||||
console.error("FATAL: CORE_SOCKET environment variable not set");
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
const denoSocket = Deno.env.get("DENO_SOCKET");
|
||||
if (!denoSocket) {
|
||||
console.error("FATAL: DENO_SOCKET environment variable not set");
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
console.error(`CoreDeno: CORE_SOCKET=${coreSocket}`);
|
||||
console.error(`CoreDeno: DENO_SOCKET=${denoSocket}`);
|
||||
|
||||
// 1. Create module registry
|
||||
const registry = new ModuleRegistry();
|
||||
|
||||
// 2. Start DenoService server (Go calls us here via JSON-RPC over Unix socket)
|
||||
let denoServer: DenoServer;
|
||||
try {
|
||||
denoServer = await startDenoServer(denoSocket, registry);
|
||||
console.error("CoreDeno: DenoService server started");
|
||||
} catch (err) {
|
||||
console.error(`FATAL: failed to start DenoService server: ${err}`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
// 3. Connect to CoreService (we call Go here) with retry
|
||||
let coreClient: CoreClient;
|
||||
{
|
||||
coreClient = createCoreClient(coreSocket);
|
||||
const maxRetries = 20;
|
||||
let connected = false;
|
||||
let lastErr: unknown;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const timeoutCall = <T>(p: Promise<T>): Promise<T> =>
|
||||
Promise.race([
|
||||
p,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("call timeout")), 2000),
|
||||
),
|
||||
]);
|
||||
await timeoutCall(
|
||||
coreClient.storeSet("_coredeno", "status", "connected"),
|
||||
);
|
||||
const resp = await timeoutCall(
|
||||
coreClient.storeGet("_coredeno", "status"),
|
||||
);
|
||||
if (resp.found && resp.value === "connected") {
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (i < 3 || i === 9 || i === 19) {
|
||||
console.error(`CoreDeno: retry ${i}: ${err}`);
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
if (!connected) {
|
||||
console.error(
|
||||
`FATAL: failed to connect to CoreService after retries, last error: ${lastErr}`,
|
||||
);
|
||||
denoServer.close();
|
||||
Deno.exit(1);
|
||||
}
|
||||
console.error("CoreDeno: CoreService client connected");
|
||||
}
|
||||
|
||||
// 4. Inject CoreClient into registry for I/O bridge
|
||||
registry.setCoreClient(coreClient);
|
||||
|
||||
// 5. Signal readiness
|
||||
console.error("CoreDeno: ready");
|
||||
|
||||
// 6. Keep alive until SIGTERM
|
||||
const ac = new AbortController();
|
||||
Deno.addSignalListener("SIGTERM", () => {
|
||||
console.error("CoreDeno: shutting down");
|
||||
ac.abort();
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise((_resolve, reject) => {
|
||||
ac.signal.addEventListener("abort", () => reject(new Error("shutdown")));
|
||||
});
|
||||
} catch {
|
||||
// Clean shutdown
|
||||
coreClient.close();
|
||||
denoServer.close();
|
||||
}
|
||||
202
pkg/coredeno/runtime/modules.ts
Normal file
202
pkg/coredeno/runtime/modules.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
// Module registry — manages module lifecycle with Deno Worker isolation.
|
||||
// Each module runs in its own Worker with per-module permission sandboxing.
|
||||
// I/O bridge relays Worker postMessage calls to CoreService gRPC.
|
||||
|
||||
import type { CoreClient } from "./client.ts";
|
||||
|
||||
export type ModuleStatus =
|
||||
| "UNKNOWN"
|
||||
| "LOADING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "ERRORED";
|
||||
|
||||
export interface ModulePermissions {
|
||||
read?: string[];
|
||||
write?: string[];
|
||||
net?: string[];
|
||||
run?: string[];
|
||||
}
|
||||
|
||||
interface Module {
|
||||
code: string;
|
||||
entryPoint: string;
|
||||
permissions: ModulePermissions;
|
||||
status: ModuleStatus;
|
||||
worker?: Worker;
|
||||
}
|
||||
|
||||
export class ModuleRegistry {
|
||||
private modules = new Map<string, Module>();
|
||||
private coreClient: CoreClient | null = null;
|
||||
private workerEntryUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.workerEntryUrl = new URL("./worker-entry.ts", import.meta.url).href;
|
||||
}
|
||||
|
||||
setCoreClient(client: CoreClient): void {
|
||||
this.coreClient = client;
|
||||
}
|
||||
|
||||
load(code: string, entryPoint: string, permissions: ModulePermissions): void {
|
||||
// Terminate existing worker if reloading
|
||||
const existing = this.modules.get(code);
|
||||
if (existing?.worker) {
|
||||
existing.worker.terminate();
|
||||
}
|
||||
|
||||
const mod: Module = {
|
||||
code,
|
||||
entryPoint,
|
||||
permissions,
|
||||
status: "LOADING",
|
||||
};
|
||||
this.modules.set(code, mod);
|
||||
|
||||
// Resolve entry point URL for the module
|
||||
const moduleUrl =
|
||||
entryPoint.startsWith("file://") || entryPoint.startsWith("http")
|
||||
? entryPoint
|
||||
: "file://" + entryPoint;
|
||||
|
||||
// Build read permissions: worker-entry.ts dir + module source + declared reads
|
||||
const readPerms: string[] = [
|
||||
new URL(".", import.meta.url).pathname,
|
||||
];
|
||||
// Add the module's directory so it can be dynamically imported
|
||||
if (!entryPoint.startsWith("http")) {
|
||||
const modPath = entryPoint.startsWith("file://")
|
||||
? entryPoint.slice(7)
|
||||
: entryPoint;
|
||||
// Add the module file's directory
|
||||
const lastSlash = modPath.lastIndexOf("/");
|
||||
if (lastSlash > 0) readPerms.push(modPath.slice(0, lastSlash + 1));
|
||||
else readPerms.push(modPath);
|
||||
}
|
||||
if (permissions.read) readPerms.push(...permissions.read);
|
||||
|
||||
// Create Worker with permission sandbox
|
||||
const worker = new Worker(this.workerEntryUrl, {
|
||||
type: "module",
|
||||
name: code,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
deno: {
|
||||
permissions: {
|
||||
read: readPerms,
|
||||
write: permissions.write ?? [],
|
||||
net: permissions.net ?? [],
|
||||
run: permissions.run ?? [],
|
||||
env: false,
|
||||
sys: false,
|
||||
ffi: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
mod.worker = worker;
|
||||
|
||||
// I/O bridge: relay Worker RPC to CoreClient
|
||||
worker.onmessage = async (e: MessageEvent) => {
|
||||
const msg = e.data;
|
||||
|
||||
if (msg.type === "ready") {
|
||||
worker.postMessage({ type: "load", url: moduleUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "loaded") {
|
||||
mod.status = msg.ok ? "RUNNING" : "ERRORED";
|
||||
if (msg.ok) {
|
||||
console.error(`CoreDeno: module running: ${code}`);
|
||||
} else {
|
||||
console.error(`CoreDeno: module error: ${code}: ${msg.error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "rpc" && this.coreClient) {
|
||||
try {
|
||||
const result = await this.dispatchRPC(
|
||||
code,
|
||||
msg.method,
|
||||
msg.params,
|
||||
);
|
||||
worker.postMessage({ type: "rpc_response", id: msg.id, result });
|
||||
} catch (err) {
|
||||
worker.postMessage({
|
||||
type: "rpc_response",
|
||||
id: msg.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (e: ErrorEvent) => {
|
||||
mod.status = "ERRORED";
|
||||
console.error(`CoreDeno: worker error: ${code}: ${e.message}`);
|
||||
};
|
||||
|
||||
console.error(`CoreDeno: module loading: ${code}`);
|
||||
}
|
||||
|
||||
private async dispatchRPC(
|
||||
moduleCode: string,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const c = this.coreClient!;
|
||||
switch (method) {
|
||||
case "StoreGet":
|
||||
return c.storeGet(params.group as string, params.key as string);
|
||||
case "StoreSet":
|
||||
return c.storeSet(
|
||||
params.group as string,
|
||||
params.key as string,
|
||||
params.value as string,
|
||||
);
|
||||
case "FileRead":
|
||||
return c.fileRead(params.path as string, moduleCode);
|
||||
case "FileWrite":
|
||||
return c.fileWrite(
|
||||
params.path as string,
|
||||
params.content as string,
|
||||
moduleCode,
|
||||
);
|
||||
case "ProcessStart":
|
||||
return c.processStart(
|
||||
params.command as string,
|
||||
params.args as string[],
|
||||
moduleCode,
|
||||
);
|
||||
case "ProcessStop":
|
||||
return c.processStop(params.process_id as string);
|
||||
default:
|
||||
throw new Error(`unknown RPC method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
unload(code: string): boolean {
|
||||
const mod = this.modules.get(code);
|
||||
if (!mod) return false;
|
||||
if (mod.worker) {
|
||||
mod.worker.terminate();
|
||||
mod.worker = undefined;
|
||||
}
|
||||
mod.status = "STOPPED";
|
||||
console.error(`CoreDeno: module unloaded: ${code}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
status(code: string): ModuleStatus {
|
||||
return this.modules.get(code)?.status ?? "UNKNOWN";
|
||||
}
|
||||
|
||||
list(): Array<{ code: string; status: ModuleStatus }> {
|
||||
return Array.from(this.modules.values()).map((m) => ({
|
||||
code: m.code,
|
||||
status: m.status,
|
||||
}));
|
||||
}
|
||||
}
|
||||
94
pkg/coredeno/runtime/polyfill.ts
Normal file
94
pkg/coredeno/runtime/polyfill.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Deno http2 + grpc-js polyfill — must be imported BEFORE @grpc/grpc-js.
|
||||
//
|
||||
// Two issues with Deno 2.x node compat:
|
||||
// 1. http2.getDefaultSettings throws "Not implemented"
|
||||
// 2. grpc-js's createConnection returns a socket that reports readyState="open"
|
||||
// but never emits "connect", causing http2 sessions to hang forever.
|
||||
// Fix: wrap createConnection to emit "connect" on next tick for open sockets.
|
||||
|
||||
import http2 from "node:http2";
|
||||
|
||||
// Fix 1: getDefaultSettings stub
|
||||
(http2 as any).getDefaultSettings = () => ({
|
||||
headerTableSize: 4096,
|
||||
enablePush: true,
|
||||
initialWindowSize: 65535,
|
||||
maxFrameSize: 16384,
|
||||
maxConcurrentStreams: 0xffffffff,
|
||||
maxHeaderListSize: 65535,
|
||||
maxHeaderSize: 65535,
|
||||
enableConnectProtocol: false,
|
||||
});
|
||||
|
||||
// Fix 2: grpc-js (transport.js line 536) passes an already-connected socket
|
||||
// to http2.connect via createConnection. Deno's http2 never completes the
|
||||
// HTTP/2 handshake because it expects a "connect" event from the socket,
|
||||
// which already fired. Emitting "connect" again causes "Busy: Unix socket
|
||||
// is currently in use" in Deno's internal http2.
|
||||
//
|
||||
// Workaround: track Unix socket paths via net.connect intercept, then in
|
||||
// createConnection, return a FRESH socket. Keep the original socket alive
|
||||
// (grpc-js has close listeners on it) but unused for data.
|
||||
import net from "node:net";
|
||||
|
||||
const socketPathMap = new WeakMap<net.Socket, string>();
|
||||
const origNetConnect = net.connect;
|
||||
(net as any).connect = function (...args: any[]) {
|
||||
const sock = origNetConnect.apply(this, args as any);
|
||||
if (args[0] && typeof args[0] === "object" && args[0].path) {
|
||||
socketPathMap.set(sock, args[0].path);
|
||||
}
|
||||
return sock;
|
||||
};
|
||||
|
||||
// Fix 3: Deno's http2 client never fires "remoteSettings" event, which
|
||||
// grpc-js waits for before marking the transport as READY.
|
||||
// Workaround: emit "remoteSettings" after "connect" with reasonable defaults.
|
||||
const origConnect = http2.connect;
|
||||
(http2 as any).connect = function (
|
||||
authority: any,
|
||||
options: any,
|
||||
...rest: any[]
|
||||
) {
|
||||
// For Unix sockets: replace pre-connected socket with fresh one
|
||||
if (options?.createConnection) {
|
||||
const origCC = options.createConnection;
|
||||
options = {
|
||||
...options,
|
||||
createConnection(...ccArgs: any[]) {
|
||||
const origSock = origCC.apply(this, ccArgs);
|
||||
const unixPath = socketPathMap.get(origSock);
|
||||
if (
|
||||
unixPath &&
|
||||
!origSock.connecting &&
|
||||
origSock.readyState === "open"
|
||||
) {
|
||||
const freshSock = net.connect({ path: unixPath });
|
||||
freshSock.on("close", () => origSock.destroy());
|
||||
return freshSock;
|
||||
}
|
||||
return origSock;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const session = origConnect.call(this, authority, options, ...rest);
|
||||
|
||||
// Emit remoteSettings after connect — Deno's http2 doesn't emit it
|
||||
session.once("connect", () => {
|
||||
if (!session.destroyed && !session.closed) {
|
||||
const settings = {
|
||||
headerTableSize: 4096,
|
||||
enablePush: false,
|
||||
initialWindowSize: 65535,
|
||||
maxFrameSize: 16384,
|
||||
maxConcurrentStreams: 100,
|
||||
maxHeaderListSize: 8192,
|
||||
maxHeaderSize: 8192,
|
||||
};
|
||||
process.nextTick(() => session.emit("remoteSettings", settings));
|
||||
}
|
||||
});
|
||||
|
||||
return session;
|
||||
};
|
||||
124
pkg/coredeno/runtime/server.ts
Normal file
124
pkg/coredeno/runtime/server.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// DenoService JSON-RPC server — Go calls Deno for module lifecycle management.
|
||||
// Uses length-prefixed JSON over raw Unix socket (Deno's http2 server is broken).
|
||||
// Protocol: 4-byte big-endian length + JSON payload, newline-delimited.
|
||||
|
||||
import { ModuleRegistry } from "./modules.ts";
|
||||
|
||||
export interface DenoServer {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export async function startDenoServer(
|
||||
socketPath: string,
|
||||
registry: ModuleRegistry,
|
||||
): Promise<DenoServer> {
|
||||
// Remove stale socket
|
||||
try {
|
||||
Deno.removeSync(socketPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const listener = Deno.listen({ transport: "unix", path: socketPath });
|
||||
|
||||
const handleConnection = async (conn: Deno.UnixConn) => {
|
||||
const reader = conn.readable.getReader();
|
||||
const writer = conn.writable.getWriter();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines (newline-delimited JSON)
|
||||
let newlineIdx: number;
|
||||
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, newlineIdx);
|
||||
buffer = buffer.slice(newlineIdx + 1);
|
||||
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const req = JSON.parse(line);
|
||||
const resp = dispatch(req, registry);
|
||||
await writer.write(
|
||||
new TextEncoder().encode(JSON.stringify(resp) + "\n"),
|
||||
);
|
||||
} catch (err) {
|
||||
const errResp = {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
await writer.write(
|
||||
new TextEncoder().encode(JSON.stringify(errResp) + "\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Connection closed or error — expected during shutdown
|
||||
} finally {
|
||||
try {
|
||||
writer.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Accept connections in background
|
||||
const abortController = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
for await (const conn of listener) {
|
||||
if (abortController.signal.aborted) break;
|
||||
handleConnection(conn);
|
||||
}
|
||||
} catch {
|
||||
// Listener closed
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
close() {
|
||||
abortController.abort();
|
||||
listener.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface RPCRequest {
|
||||
method: string;
|
||||
code?: string;
|
||||
entry_point?: string;
|
||||
permissions?: { read?: string[]; write?: string[]; net?: string[]; run?: string[] };
|
||||
process_id?: string;
|
||||
}
|
||||
|
||||
function dispatch(
|
||||
req: RPCRequest,
|
||||
registry: ModuleRegistry,
|
||||
): Record<string, unknown> {
|
||||
switch (req.method) {
|
||||
case "LoadModule": {
|
||||
registry.load(
|
||||
req.code ?? "",
|
||||
req.entry_point ?? "",
|
||||
req.permissions ?? {},
|
||||
);
|
||||
return { ok: true, error: "" };
|
||||
}
|
||||
case "UnloadModule": {
|
||||
const ok = registry.unload(req.code ?? "");
|
||||
return { ok };
|
||||
}
|
||||
case "ModuleStatus": {
|
||||
return { code: req.code, status: registry.status(req.code ?? "") };
|
||||
}
|
||||
default:
|
||||
return { error: `unknown method: ${req.method}` };
|
||||
}
|
||||
}
|
||||
5
pkg/coredeno/runtime/testdata/test-module.ts
vendored
Normal file
5
pkg/coredeno/runtime/testdata/test-module.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Test module — writes to store via I/O bridge to prove Workers work.
|
||||
// Called by integration tests.
|
||||
export async function init(core: any) {
|
||||
await core.storeSet("test-module", "init", "ok");
|
||||
}
|
||||
79
pkg/coredeno/runtime/worker-entry.ts
Normal file
79
pkg/coredeno/runtime/worker-entry.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Worker bootstrap — loaded as entry point for every module Worker.
|
||||
// Sets up the I/O bridge (postMessage ↔ parent relay), then dynamically
|
||||
// imports the module and calls its init(core) function.
|
||||
//
|
||||
// The parent (ModuleRegistry) injects module_code into all gRPC calls,
|
||||
// so modules can't spoof their identity.
|
||||
|
||||
// I/O bridge: request/response correlation over postMessage
|
||||
const pending = new Map<number, { resolve: Function; reject: Function }>();
|
||||
let nextId = 0;
|
||||
|
||||
function rpc(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++nextId;
|
||||
pending.set(id, { resolve, reject });
|
||||
self.postMessage({ type: "rpc", id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
// Typed core object passed to module's init() function.
|
||||
// Each method maps to a CoreService gRPC call relayed through the parent.
|
||||
const core = {
|
||||
storeGet(group: string, key: string) {
|
||||
return rpc("StoreGet", { group, key });
|
||||
},
|
||||
storeSet(group: string, key: string, value: string) {
|
||||
return rpc("StoreSet", { group, key, value });
|
||||
},
|
||||
fileRead(path: string) {
|
||||
return rpc("FileRead", { path });
|
||||
},
|
||||
fileWrite(path: string, content: string) {
|
||||
return rpc("FileWrite", { path, content });
|
||||
},
|
||||
processStart(command: string, args: string[]) {
|
||||
return rpc("ProcessStart", { command, args });
|
||||
},
|
||||
processStop(processId: string) {
|
||||
return rpc("ProcessStop", { process_id: processId });
|
||||
},
|
||||
};
|
||||
|
||||
// Handle messages from parent: RPC responses and load commands
|
||||
self.addEventListener("message", async (e: MessageEvent) => {
|
||||
const msg = e.data;
|
||||
|
||||
if (msg.type === "rpc_response") {
|
||||
const p = pending.get(msg.id);
|
||||
if (p) {
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) p.reject(new Error(msg.error));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "load") {
|
||||
try {
|
||||
const mod = await import(msg.url);
|
||||
if (typeof mod.init === "function") {
|
||||
await mod.init(core);
|
||||
}
|
||||
self.postMessage({ type: "loaded", ok: true });
|
||||
} catch (err) {
|
||||
self.postMessage({
|
||||
type: "loaded",
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Signal ready — parent will respond with {type: "load", url: "..."}
|
||||
self.postMessage({ type: "ready" });
|
||||
207
pkg/coredeno/server.go
Normal file
207
pkg/coredeno/server.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/manifest"
|
||||
"forge.lthn.ai/core/go/pkg/store"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// ProcessRunner abstracts process management for the gRPC server.
|
||||
// Satisfied by *process.Service.
|
||||
type ProcessRunner interface {
|
||||
Start(ctx context.Context, command string, args ...string) (ProcessHandle, error)
|
||||
Kill(id string) error
|
||||
}
|
||||
|
||||
// ProcessHandle is returned by ProcessRunner.Start.
|
||||
type ProcessHandle interface {
|
||||
Info() ProcessInfo
|
||||
}
|
||||
|
||||
// ProcessInfo is the subset of process info the server needs.
|
||||
type ProcessInfo struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// Server implements the CoreService gRPC interface with permission gating.
|
||||
// Every I/O request is checked against the calling module's declared permissions.
|
||||
type Server struct {
|
||||
pb.UnimplementedCoreServiceServer
|
||||
medium io.Medium
|
||||
store *store.Store
|
||||
manifests map[string]*manifest.Manifest
|
||||
processes ProcessRunner
|
||||
}
|
||||
|
||||
// NewServer creates a CoreService server backed by the given Medium and Store.
|
||||
func NewServer(medium io.Medium, st *store.Store) *Server {
|
||||
return &Server{
|
||||
medium: medium,
|
||||
store: st,
|
||||
manifests: make(map[string]*manifest.Manifest),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterModule adds a module's manifest to the permission registry.
|
||||
func (s *Server) RegisterModule(m *manifest.Manifest) {
|
||||
s.manifests[m.Code] = m
|
||||
}
|
||||
|
||||
// getManifest looks up a module and returns an error if unknown.
|
||||
func (s *Server) getManifest(code string) (*manifest.Manifest, error) {
|
||||
m, ok := s.manifests[code]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown module: %s", code)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// FileRead implements CoreService.FileRead with permission gating.
|
||||
func (s *Server) FileRead(_ context.Context, req *pb.FileReadRequest) (*pb.FileReadResponse, error) {
|
||||
m, err := s.getManifest(req.ModuleCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !CheckPath(req.Path, m.Permissions.Read) {
|
||||
return nil, fmt.Errorf("permission denied: %s cannot read %s", req.ModuleCode, req.Path)
|
||||
}
|
||||
content, err := s.medium.Read(req.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.FileReadResponse{Content: content}, nil
|
||||
}
|
||||
|
||||
// FileWrite implements CoreService.FileWrite with permission gating.
|
||||
func (s *Server) FileWrite(_ context.Context, req *pb.FileWriteRequest) (*pb.FileWriteResponse, error) {
|
||||
m, err := s.getManifest(req.ModuleCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !CheckPath(req.Path, m.Permissions.Write) {
|
||||
return nil, fmt.Errorf("permission denied: %s cannot write %s", req.ModuleCode, req.Path)
|
||||
}
|
||||
if err := s.medium.Write(req.Path, req.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.FileWriteResponse{Ok: true}, nil
|
||||
}
|
||||
|
||||
// FileList implements CoreService.FileList with permission gating.
|
||||
func (s *Server) FileList(_ context.Context, req *pb.FileListRequest) (*pb.FileListResponse, error) {
|
||||
m, err := s.getManifest(req.ModuleCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !CheckPath(req.Path, m.Permissions.Read) {
|
||||
return nil, fmt.Errorf("permission denied: %s cannot list %s", req.ModuleCode, req.Path)
|
||||
}
|
||||
entries, err := s.medium.List(req.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pbEntries []*pb.FileEntry
|
||||
for _, e := range entries {
|
||||
info, _ := e.Info()
|
||||
pbEntries = append(pbEntries, &pb.FileEntry{
|
||||
Name: e.Name(),
|
||||
IsDir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
})
|
||||
}
|
||||
return &pb.FileListResponse{Entries: pbEntries}, nil
|
||||
}
|
||||
|
||||
// FileDelete implements CoreService.FileDelete with permission gating.
|
||||
func (s *Server) FileDelete(_ context.Context, req *pb.FileDeleteRequest) (*pb.FileDeleteResponse, error) {
|
||||
m, err := s.getManifest(req.ModuleCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !CheckPath(req.Path, m.Permissions.Write) {
|
||||
return nil, fmt.Errorf("permission denied: %s cannot delete %s", req.ModuleCode, req.Path)
|
||||
}
|
||||
if err := s.medium.Delete(req.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.FileDeleteResponse{Ok: true}, nil
|
||||
}
|
||||
|
||||
// storeGroupAllowed checks that the requested group is not a reserved system namespace.
|
||||
// Groups prefixed with "_" are reserved for internal use (e.g. _coredeno, _modules).
|
||||
// TODO: once the proto carries module_code on store requests, enforce per-module namespace isolation.
|
||||
func storeGroupAllowed(group string) error {
|
||||
if strings.HasPrefix(group, "_") {
|
||||
return status.Errorf(codes.PermissionDenied, "reserved store group: %s", group)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StoreGet implements CoreService.StoreGet with reserved namespace protection.
|
||||
func (s *Server) StoreGet(_ context.Context, req *pb.StoreGetRequest) (*pb.StoreGetResponse, error) {
|
||||
if err := storeGroupAllowed(req.Group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val, err := s.store.Get(req.Group, req.Key)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return &pb.StoreGetResponse{Found: false}, nil
|
||||
}
|
||||
return nil, status.Errorf(codes.Internal, "store: %v", err)
|
||||
}
|
||||
return &pb.StoreGetResponse{Value: val, Found: true}, nil
|
||||
}
|
||||
|
||||
// StoreSet implements CoreService.StoreSet with reserved namespace protection.
|
||||
func (s *Server) StoreSet(_ context.Context, req *pb.StoreSetRequest) (*pb.StoreSetResponse, error) {
|
||||
if err := storeGroupAllowed(req.Group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.Set(req.Group, req.Key, req.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.StoreSetResponse{Ok: true}, nil
|
||||
}
|
||||
|
||||
// SetProcessRunner sets the process runner for ProcessStart/ProcessStop.
|
||||
func (s *Server) SetProcessRunner(pr ProcessRunner) {
|
||||
s.processes = pr
|
||||
}
|
||||
|
||||
// ProcessStart implements CoreService.ProcessStart with permission gating.
|
||||
func (s *Server) ProcessStart(ctx context.Context, req *pb.ProcessStartRequest) (*pb.ProcessStartResponse, error) {
|
||||
if s.processes == nil {
|
||||
return nil, status.Error(codes.Unimplemented, "process service not available")
|
||||
}
|
||||
m, err := s.getManifest(req.ModuleCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !CheckRun(req.Command, m.Permissions.Run) {
|
||||
return nil, fmt.Errorf("permission denied: %s cannot run %s", req.ModuleCode, req.Command)
|
||||
}
|
||||
proc, err := s.processes.Start(ctx, req.Command, req.Args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("process start: %w", err)
|
||||
}
|
||||
return &pb.ProcessStartResponse{ProcessId: proc.Info().ID}, nil
|
||||
}
|
||||
|
||||
// ProcessStop implements CoreService.ProcessStop.
|
||||
func (s *Server) ProcessStop(_ context.Context, req *pb.ProcessStopRequest) (*pb.ProcessStopResponse, error) {
|
||||
if s.processes == nil {
|
||||
return nil, status.Error(codes.Unimplemented, "process service not available")
|
||||
}
|
||||
if err := s.processes.Kill(req.ProcessId); err != nil {
|
||||
return nil, fmt.Errorf("process stop: %w", err)
|
||||
}
|
||||
return &pb.ProcessStopResponse{Ok: true}, nil
|
||||
}
|
||||
200
pkg/coredeno/server_test.go
Normal file
200
pkg/coredeno/server_test.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/manifest"
|
||||
"forge.lthn.ai/core/go/pkg/store"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// mockProcessRunner implements ProcessRunner for testing.
|
||||
type mockProcessRunner struct {
|
||||
started map[string]bool
|
||||
nextID int
|
||||
}
|
||||
|
||||
func newMockProcessRunner() *mockProcessRunner {
|
||||
return &mockProcessRunner{started: make(map[string]bool)}
|
||||
}
|
||||
|
||||
func (m *mockProcessRunner) Start(_ context.Context, command string, args ...string) (ProcessHandle, error) {
|
||||
m.nextID++
|
||||
id := fmt.Sprintf("proc-%d", m.nextID)
|
||||
m.started[id] = true
|
||||
return &mockProcessHandle{id: id}, nil
|
||||
}
|
||||
|
||||
func (m *mockProcessRunner) Kill(id string) error {
|
||||
if !m.started[id] {
|
||||
return fmt.Errorf("process not found: %s", id)
|
||||
}
|
||||
delete(m.started, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockProcessHandle struct{ id string }
|
||||
|
||||
func (h *mockProcessHandle) Info() ProcessInfo { return ProcessInfo{ID: h.id} }
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
medium := io.NewMockMedium()
|
||||
medium.Files["./data/test.txt"] = "hello"
|
||||
st, err := store.New(":memory:")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
srv := NewServer(medium, st)
|
||||
srv.RegisterModule(&manifest.Manifest{
|
||||
Code: "test-mod",
|
||||
Permissions: manifest.Permissions{
|
||||
Read: []string{"./data/"},
|
||||
Write: []string{"./data/"},
|
||||
},
|
||||
})
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestFileRead_Good(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp, err := srv.FileRead(context.Background(), &pb.FileReadRequest{
|
||||
Path: "./data/test.txt", ModuleCode: "test-mod",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", resp.Content)
|
||||
}
|
||||
|
||||
func TestFileRead_Bad_PermissionDenied(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
_, err := srv.FileRead(context.Background(), &pb.FileReadRequest{
|
||||
Path: "./secrets/key.pem", ModuleCode: "test-mod",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "permission denied")
|
||||
}
|
||||
|
||||
func TestFileRead_Bad_UnknownModule(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
_, err := srv.FileRead(context.Background(), &pb.FileReadRequest{
|
||||
Path: "./data/test.txt", ModuleCode: "unknown",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown module")
|
||||
}
|
||||
|
||||
func TestFileWrite_Good(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp, err := srv.FileWrite(context.Background(), &pb.FileWriteRequest{
|
||||
Path: "./data/new.txt", Content: "world", ModuleCode: "test-mod",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Ok)
|
||||
}
|
||||
|
||||
func TestFileWrite_Bad_PermissionDenied(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
_, err := srv.FileWrite(context.Background(), &pb.FileWriteRequest{
|
||||
Path: "./secrets/bad.txt", Content: "nope", ModuleCode: "test-mod",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "permission denied")
|
||||
}
|
||||
|
||||
func TestStoreGetSet_Good(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := srv.StoreSet(ctx, &pb.StoreSetRequest{Group: "cfg", Key: "theme", Value: "dark"})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := srv.StoreGet(ctx, &pb.StoreGetRequest{Group: "cfg", Key: "theme"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Found)
|
||||
assert.Equal(t, "dark", resp.Value)
|
||||
}
|
||||
|
||||
func TestStoreGet_Good_NotFound(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp, err := srv.StoreGet(context.Background(), &pb.StoreGetRequest{Group: "cfg", Key: "missing"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Found)
|
||||
}
|
||||
|
||||
func newTestServerWithProcess(t *testing.T) (*Server, *mockProcessRunner) {
|
||||
t.Helper()
|
||||
srv := newTestServer(t)
|
||||
srv.RegisterModule(&manifest.Manifest{
|
||||
Code: "runner-mod",
|
||||
Permissions: manifest.Permissions{
|
||||
Run: []string{"echo", "ls"},
|
||||
},
|
||||
})
|
||||
pr := newMockProcessRunner()
|
||||
srv.SetProcessRunner(pr)
|
||||
return srv, pr
|
||||
}
|
||||
|
||||
func TestProcessStart_Good(t *testing.T) {
|
||||
srv, _ := newTestServerWithProcess(t)
|
||||
resp, err := srv.ProcessStart(context.Background(), &pb.ProcessStartRequest{
|
||||
Command: "echo", Args: []string{"hello"}, ModuleCode: "runner-mod",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.ProcessId)
|
||||
}
|
||||
|
||||
func TestProcessStart_Bad_PermissionDenied(t *testing.T) {
|
||||
srv, _ := newTestServerWithProcess(t)
|
||||
_, err := srv.ProcessStart(context.Background(), &pb.ProcessStartRequest{
|
||||
Command: "rm", Args: []string{"-rf", "/"}, ModuleCode: "runner-mod",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "permission denied")
|
||||
}
|
||||
|
||||
func TestProcessStart_Bad_NoProcessService(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
srv.RegisterModule(&manifest.Manifest{
|
||||
Code: "no-proc-mod",
|
||||
Permissions: manifest.Permissions{Run: []string{"echo"}},
|
||||
})
|
||||
_, err := srv.ProcessStart(context.Background(), &pb.ProcessStartRequest{
|
||||
Command: "echo", ModuleCode: "no-proc-mod",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, codes.Unimplemented, st.Code())
|
||||
}
|
||||
|
||||
func TestProcessStop_Good(t *testing.T) {
|
||||
srv, _ := newTestServerWithProcess(t)
|
||||
// Start a process first
|
||||
startResp, err := srv.ProcessStart(context.Background(), &pb.ProcessStartRequest{
|
||||
Command: "echo", ModuleCode: "runner-mod",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Stop it
|
||||
resp, err := srv.ProcessStop(context.Background(), &pb.ProcessStopRequest{
|
||||
ProcessId: startResp.ProcessId,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Ok)
|
||||
}
|
||||
|
||||
func TestProcessStop_Bad_NotFound(t *testing.T) {
|
||||
srv, _ := newTestServerWithProcess(t)
|
||||
_, err := srv.ProcessStop(context.Background(), &pb.ProcessStopRequest{
|
||||
ProcessId: "nonexistent",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
220
pkg/coredeno/service.go
Normal file
220
pkg/coredeno/service.go
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
core "forge.lthn.ai/core/go/pkg/framework/core"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/manifest"
|
||||
"forge.lthn.ai/core/go/pkg/marketplace"
|
||||
"forge.lthn.ai/core/go/pkg/store"
|
||||
)
|
||||
|
||||
// Service wraps the CoreDeno sidecar as a framework service.
|
||||
// Implements Startable and Stoppable for lifecycle management.
|
||||
//
|
||||
// Registration:
|
||||
//
|
||||
// core.New(core.WithService(coredeno.NewServiceFactory(opts)))
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
sidecar *Sidecar
|
||||
grpcServer *Server
|
||||
store *store.Store
|
||||
grpcCancel context.CancelFunc
|
||||
grpcDone chan error
|
||||
denoClient *DenoClient
|
||||
installer *marketplace.Installer
|
||||
}
|
||||
|
||||
// NewServiceFactory returns a factory function for framework registration via WithService.
|
||||
func NewServiceFactory(opts Options) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, opts),
|
||||
sidecar: NewSidecar(opts),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OnStartup boots the CoreDeno subsystem. Called by the framework on app startup.
|
||||
//
|
||||
// Sequence: medium → store → server → manifest → gRPC listener → sidecar.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
opts := s.Opts()
|
||||
|
||||
// 1. Create sandboxed Medium (or mock if no AppRoot)
|
||||
var medium io.Medium
|
||||
if opts.AppRoot != "" {
|
||||
var err error
|
||||
medium, err = io.NewSandboxed(opts.AppRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("coredeno: medium: %w", err)
|
||||
}
|
||||
} else {
|
||||
medium = io.NewMockMedium()
|
||||
}
|
||||
|
||||
// 2. Create Store
|
||||
dbPath := opts.StoreDBPath
|
||||
if dbPath == "" {
|
||||
dbPath = ":memory:"
|
||||
}
|
||||
var err error
|
||||
s.store, err = store.New(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("coredeno: store: %w", err)
|
||||
}
|
||||
|
||||
// 3. Create gRPC Server
|
||||
s.grpcServer = NewServer(medium, s.store)
|
||||
|
||||
// 4. Load manifest if AppRoot set (non-fatal if missing)
|
||||
if opts.AppRoot != "" {
|
||||
m, loadErr := manifest.Load(medium, ".")
|
||||
if loadErr == nil && m != nil {
|
||||
if opts.PublicKey != nil {
|
||||
if ok, verr := manifest.Verify(m, opts.PublicKey); verr == nil && ok {
|
||||
s.grpcServer.RegisterModule(m)
|
||||
}
|
||||
} else {
|
||||
s.grpcServer.RegisterModule(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Start gRPC listener in background
|
||||
grpcCtx, grpcCancel := context.WithCancel(ctx)
|
||||
s.grpcCancel = grpcCancel
|
||||
s.grpcDone = make(chan error, 1)
|
||||
go func() {
|
||||
s.grpcDone <- ListenGRPC(grpcCtx, opts.SocketPath, s.grpcServer)
|
||||
}()
|
||||
|
||||
// cleanupGRPC tears down the listener on early-return errors.
|
||||
cleanupGRPC := func() {
|
||||
grpcCancel()
|
||||
<-s.grpcDone
|
||||
}
|
||||
|
||||
// 6. Start sidecar (if args provided)
|
||||
if len(opts.SidecarArgs) > 0 {
|
||||
// Wait for core socket so sidecar can connect to our gRPC server
|
||||
if err := waitForSocket(ctx, opts.SocketPath, 5*time.Second); err != nil {
|
||||
cleanupGRPC()
|
||||
return fmt.Errorf("coredeno: core socket: %w", err)
|
||||
}
|
||||
|
||||
if err := s.sidecar.Start(ctx, opts.SidecarArgs...); err != nil {
|
||||
cleanupGRPC()
|
||||
return fmt.Errorf("coredeno: sidecar: %w", err)
|
||||
}
|
||||
|
||||
// 7. Wait for Deno's server and connect as client
|
||||
if opts.DenoSocketPath != "" {
|
||||
if err := waitForSocket(ctx, opts.DenoSocketPath, 10*time.Second); err != nil {
|
||||
_ = s.sidecar.Stop()
|
||||
cleanupGRPC()
|
||||
return fmt.Errorf("coredeno: deno socket: %w", err)
|
||||
}
|
||||
dc, err := DialDeno(opts.DenoSocketPath)
|
||||
if err != nil {
|
||||
_ = s.sidecar.Stop()
|
||||
cleanupGRPC()
|
||||
return fmt.Errorf("coredeno: deno client: %w", err)
|
||||
}
|
||||
s.denoClient = dc
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Create installer and auto-load installed modules
|
||||
if opts.AppRoot != "" {
|
||||
modulesDir := filepath.Join(opts.AppRoot, "modules")
|
||||
s.installer = marketplace.NewInstaller(modulesDir, s.store)
|
||||
|
||||
if s.denoClient != nil {
|
||||
installed, listErr := s.installer.Installed()
|
||||
if listErr == nil {
|
||||
for _, mod := range installed {
|
||||
perms := ModulePermissions{
|
||||
Read: mod.Permissions.Read,
|
||||
Write: mod.Permissions.Write,
|
||||
Net: mod.Permissions.Net,
|
||||
Run: mod.Permissions.Run,
|
||||
}
|
||||
s.denoClient.LoadModule(mod.Code, mod.EntryPoint, perms)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnShutdown stops the CoreDeno subsystem. Called by the framework on app shutdown.
|
||||
func (s *Service) OnShutdown(_ context.Context) error {
|
||||
// Close Deno client connection
|
||||
if s.denoClient != nil {
|
||||
s.denoClient.Close()
|
||||
}
|
||||
|
||||
// Stop sidecar
|
||||
_ = s.sidecar.Stop()
|
||||
|
||||
// Stop gRPC listener
|
||||
if s.grpcCancel != nil {
|
||||
s.grpcCancel()
|
||||
<-s.grpcDone
|
||||
}
|
||||
|
||||
// Close store
|
||||
if s.store != nil {
|
||||
s.store.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sidecar returns the underlying sidecar for direct access.
|
||||
func (s *Service) Sidecar() *Sidecar {
|
||||
return s.sidecar
|
||||
}
|
||||
|
||||
// GRPCServer returns the gRPC server for direct access.
|
||||
func (s *Service) GRPCServer() *Server {
|
||||
return s.grpcServer
|
||||
}
|
||||
|
||||
// DenoClient returns the DenoService client for calling the Deno sidecar.
|
||||
// Returns nil if the sidecar was not started or has no DenoSocketPath.
|
||||
func (s *Service) DenoClient() *DenoClient {
|
||||
return s.denoClient
|
||||
}
|
||||
|
||||
// Installer returns the marketplace module installer.
|
||||
// Returns nil if AppRoot was not set.
|
||||
func (s *Service) Installer() *marketplace.Installer {
|
||||
return s.installer
|
||||
}
|
||||
|
||||
// waitForSocket polls until a Unix socket file appears or the context/timeout expires.
|
||||
func waitForSocket(ctx context.Context, path string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timeout waiting for socket %s", path)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
183
pkg/coredeno/service_test.go
Normal file
183
pkg/coredeno/service_test.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "forge.lthn.ai/core/go/pkg/coredeno/proto"
|
||||
core "forge.lthn.ai/core/go/pkg/framework/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestNewServiceFactory_Good(t *testing.T) {
|
||||
opts := Options{
|
||||
DenoPath: "echo",
|
||||
SocketPath: "/tmp/test-service.sock",
|
||||
}
|
||||
c, err := core.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
factory := NewServiceFactory(opts)
|
||||
result, err := factory(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, ok := result.(*Service)
|
||||
require.True(t, ok)
|
||||
assert.NotNil(t, svc.sidecar)
|
||||
assert.Equal(t, "echo", svc.sidecar.opts.DenoPath)
|
||||
assert.NotNil(t, svc.Core(), "ServiceRuntime should provide Core access")
|
||||
assert.Equal(t, opts, svc.Opts(), "ServiceRuntime should provide Options access")
|
||||
}
|
||||
|
||||
func TestService_WithService_Good(t *testing.T) {
|
||||
opts := Options{DenoPath: "echo"}
|
||||
c, err := core.New(core.WithService(NewServiceFactory(opts)))
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, c)
|
||||
}
|
||||
|
||||
func TestService_Lifecycle_Good(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "lifecycle.sock")
|
||||
|
||||
c, err := core.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
factory := NewServiceFactory(Options{
|
||||
DenoPath: "echo",
|
||||
SocketPath: sockPath,
|
||||
})
|
||||
result, _ := factory(c)
|
||||
svc := result.(*Service)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Verify Startable
|
||||
err = svc.OnStartup(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify Stoppable
|
||||
err = svc.OnShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestService_Sidecar_Good(t *testing.T) {
|
||||
c, err := core.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
factory := NewServiceFactory(Options{DenoPath: "echo"})
|
||||
result, _ := factory(c)
|
||||
svc := result.(*Service)
|
||||
|
||||
assert.NotNil(t, svc.Sidecar())
|
||||
}
|
||||
|
||||
func TestService_OnStartup_Good(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "core.sock")
|
||||
|
||||
// Write a minimal manifest
|
||||
coreDir := filepath.Join(tmpDir, ".core")
|
||||
require.NoError(t, os.MkdirAll(coreDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(coreDir, "view.yml"), []byte(`
|
||||
code: test-app
|
||||
name: Test App
|
||||
version: "1.0"
|
||||
permissions:
|
||||
read: ["./data/"]
|
||||
write: ["./data/"]
|
||||
`), 0644))
|
||||
|
||||
opts := Options{
|
||||
DenoPath: "sleep",
|
||||
SocketPath: sockPath,
|
||||
AppRoot: tmpDir,
|
||||
StoreDBPath: ":memory:",
|
||||
SidecarArgs: []string{"60"},
|
||||
}
|
||||
|
||||
c, err := core.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
factory := NewServiceFactory(opts)
|
||||
result, err := factory(c)
|
||||
require.NoError(t, err)
|
||||
svc := result.(*Service)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
err = svc.OnStartup(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify socket appeared
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(sockPath)
|
||||
return err == nil
|
||||
}, 2*time.Second, 10*time.Millisecond, "gRPC socket should appear after startup")
|
||||
|
||||
// Verify gRPC responds
|
||||
conn, err := grpc.NewClient(
|
||||
"unix://"+sockPath,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
client := pb.NewCoreServiceClient(conn)
|
||||
_, err = client.StoreSet(ctx, &pb.StoreSetRequest{
|
||||
Group: "boot", Key: "ok", Value: "true",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := client.StoreGet(ctx, &pb.StoreGetRequest{
|
||||
Group: "boot", Key: "ok",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Found)
|
||||
assert.Equal(t, "true", resp.Value)
|
||||
|
||||
// Verify sidecar is running
|
||||
assert.True(t, svc.sidecar.IsRunning(), "sidecar should be running")
|
||||
|
||||
// Shutdown
|
||||
err = svc.OnShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, svc.sidecar.IsRunning(), "sidecar should be stopped")
|
||||
}
|
||||
|
||||
func TestService_OnStartup_Good_NoManifest(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sockPath := filepath.Join(tmpDir, "core.sock")
|
||||
|
||||
opts := Options{
|
||||
DenoPath: "sleep",
|
||||
SocketPath: sockPath,
|
||||
AppRoot: tmpDir,
|
||||
StoreDBPath: ":memory:",
|
||||
}
|
||||
|
||||
c, err := core.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
factory := NewServiceFactory(opts)
|
||||
result, _ := factory(c)
|
||||
svc := result.(*Service)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Should succeed even without .core/view.yml
|
||||
err = svc.OnStartup(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.OnShutdown(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
|
@ -23,7 +23,9 @@ func TestTranslationCompleteness_Good(t *testing.T) {
|
|||
|
||||
// Extract all T("key") calls from Go source
|
||||
keys := extractTranslationKeys(t, root)
|
||||
require.NotEmpty(t, keys, "should find translation keys in source code")
|
||||
if len(keys) == 0 {
|
||||
t.Skip("no i18n.T() calls found in source — CLI not yet wired to i18n")
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, key := range keys {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
"time"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go/pkg/framework/core"
|
||||
"github.com/Snider/Borg/pkg/datanode"
|
||||
"forge.lthn.ai/Snider/Borg/pkg/datanode"
|
||||
)
|
||||
|
||||
// Medium is an in-memory storage backend backed by a Borg DataNode.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ func New(root string) (*Medium, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Resolve symlinks so sandbox checks compare like-for-like.
|
||||
// On macOS, /var is a symlink to /private/var — without this,
|
||||
// EvalSymlinks on child paths resolves to /private/var/... while
|
||||
// root stays /var/..., causing false sandbox escape detections.
|
||||
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = resolved
|
||||
}
|
||||
return &Medium{root: abs}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ func TestNew(t *testing.T) {
|
|||
root := t.TempDir()
|
||||
m, err := New(root)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, root, m.root)
|
||||
// New() resolves symlinks (macOS /var → /private/var), so compare resolved paths.
|
||||
resolved, _ := filepath.EvalSymlinks(root)
|
||||
assert.Equal(t, resolved, m.root)
|
||||
}
|
||||
|
||||
func TestPath(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,9 @@ type Node struct {
|
|||
files map[string]*dataFile
|
||||
}
|
||||
|
||||
// compile-time interface check
|
||||
// compile-time interface checks
|
||||
var _ coreio.Medium = (*Node)(nil)
|
||||
var _ fs.ReadFileFS = (*Node)(nil)
|
||||
|
||||
// New creates a new, empty Node.
|
||||
func New() *Node {
|
||||
|
|
@ -78,8 +79,17 @@ func (n *Node) ToTar() ([]byte, error) {
|
|||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// FromTar replaces the in-memory tree with the contents of a tar archive.
|
||||
func (n *Node) FromTar(data []byte) error {
|
||||
// FromTar creates a new Node from a tar archive.
|
||||
func FromTar(data []byte) (*Node, error) {
|
||||
n := New()
|
||||
if err := n.LoadTar(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// LoadTar replaces the in-memory tree with the contents of a tar archive.
|
||||
func (n *Node) LoadTar(data []byte) error {
|
||||
newFiles := make(map[string]*dataFile)
|
||||
tr := tar.NewReader(bytes.NewReader(data))
|
||||
|
||||
|
|
@ -118,14 +128,15 @@ func (n *Node) WalkNode(root string, fn fs.WalkDirFunc) error {
|
|||
return fs.WalkDir(n, root, fn)
|
||||
}
|
||||
|
||||
// WalkOptions configures optional behaviour for Walk.
|
||||
// WalkOptions configures the behaviour of Walk.
|
||||
type WalkOptions struct {
|
||||
// MaxDepth limits traversal depth (0 = unlimited, 1 = root children only).
|
||||
// MaxDepth limits how many directory levels to descend. 0 means unlimited.
|
||||
MaxDepth int
|
||||
// Filter, when non-nil, is called before visiting each entry.
|
||||
// Return false to skip the entry (and its subtree if a directory).
|
||||
// Filter, if set, is called for each entry. Return true to include the
|
||||
// entry (and descend into it if it is a directory).
|
||||
Filter func(path string, d fs.DirEntry) bool
|
||||
// SkipErrors suppresses errors from the root lookup and doesn't call fn.
|
||||
// SkipErrors suppresses errors (e.g. nonexistent root) instead of
|
||||
// propagating them through the callback.
|
||||
SkipErrors bool
|
||||
}
|
||||
|
||||
|
|
@ -137,67 +148,68 @@ func (n *Node) Walk(root string, fn fs.WalkDirFunc, opts ...WalkOptions) error {
|
|||
}
|
||||
|
||||
if opt.SkipErrors {
|
||||
// Check root exists — if not, silently skip.
|
||||
// If root doesn't exist, silently return nil.
|
||||
if _, err := n.Stat(root); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
rootDepth := 0
|
||||
if root != "." && root != "" {
|
||||
rootDepth = strings.Count(root, "/") + 1
|
||||
}
|
||||
|
||||
return fs.WalkDir(n, root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return fn(p, d, err)
|
||||
}
|
||||
|
||||
// MaxDepth check.
|
||||
if opt.MaxDepth > 0 {
|
||||
depth := 0
|
||||
if p != "." && p != "" {
|
||||
depth = strings.Count(p, "/") + 1
|
||||
}
|
||||
if depth-rootDepth > opt.MaxDepth {
|
||||
if d.IsDir() {
|
||||
if opt.Filter != nil && err == nil {
|
||||
if !opt.Filter(p, d) {
|
||||
if d != nil && d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Filter check.
|
||||
if opt.Filter != nil && !opt.Filter(p, d) {
|
||||
if d.IsDir() {
|
||||
// Call the user's function first so the entry is visited.
|
||||
result := fn(p, d, err)
|
||||
|
||||
// After visiting a directory at MaxDepth, prevent descending further.
|
||||
if result == nil && opt.MaxDepth > 0 && d != nil && d.IsDir() && p != root {
|
||||
rel := strings.TrimPrefix(p, root)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
depth := strings.Count(rel, "/") + 1
|
||||
if depth >= opt.MaxDepth {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn(p, d, err)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
// CopyFile copies a single file from the node to the OS filesystem.
|
||||
func (n *Node) CopyFile(src, dst string, perm os.FileMode) error {
|
||||
// ReadFile returns the content of the named file as a byte slice.
|
||||
// Implements fs.ReadFileFS.
|
||||
func (n *Node) ReadFile(name string) ([]byte, error) {
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
f, ok := n.files[name]
|
||||
if !ok {
|
||||
return nil, &fs.PathError{Op: "read", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
// Return a copy to prevent callers from mutating internal state.
|
||||
result := make([]byte, len(f.content))
|
||||
copy(result, f.content)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CopyFile copies a file from the in-memory tree to the local filesystem.
|
||||
func (n *Node) CopyFile(src, dst string, perm fs.FileMode) error {
|
||||
src = strings.TrimPrefix(src, "/")
|
||||
f, ok := n.files[src]
|
||||
if !ok {
|
||||
// Check if it's a directory — can't copy a directory as a file.
|
||||
if info, err := n.Stat(src); err == nil && info.IsDir() {
|
||||
// Check if it's a directory — can't copy directories this way.
|
||||
info, err := n.Stat(src)
|
||||
if err != nil {
|
||||
return &fs.PathError{Op: "copyfile", Path: src, Err: fs.ErrNotExist}
|
||||
}
|
||||
if info.IsDir() {
|
||||
return &fs.PathError{Op: "copyfile", Path: src, Err: fs.ErrInvalid}
|
||||
}
|
||||
return &fs.PathError{Op: "copyfile", Path: src, Err: fs.ErrNotExist}
|
||||
}
|
||||
|
||||
dir := path.Dir(dst)
|
||||
if dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(dst, f.content, perm)
|
||||
}
|
||||
|
||||
|
|
@ -330,20 +342,6 @@ func (n *Node) ReadDir(name string) ([]fs.DirEntry, error) {
|
|||
return entries, nil
|
||||
}
|
||||
|
||||
// ReadFile returns the content of a file as a byte slice.
|
||||
// Implements fs.ReadFileFS.
|
||||
func (n *Node) ReadFile(name string) ([]byte, error) {
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
f, ok := n.files[name]
|
||||
if !ok {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
// Return a copy to prevent mutation of internal state.
|
||||
out := make([]byte, len(f.content))
|
||||
copy(out, f.content)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- Medium interface: read/write ----------
|
||||
|
||||
// Read retrieves the content of a file as a string.
|
||||
|
|
|
|||
|
|
@ -451,8 +451,7 @@ func TestFromTar_Good(t *testing.T) {
|
|||
}
|
||||
require.NoError(t, tw.Close())
|
||||
|
||||
n := New()
|
||||
err := n.FromTar(buf.Bytes())
|
||||
n, err := FromTar(buf.Bytes())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, n.Exists("foo.txt"), "foo.txt should exist")
|
||||
|
|
@ -462,8 +461,7 @@ func TestFromTar_Good(t *testing.T) {
|
|||
func TestFromTar_Bad(t *testing.T) {
|
||||
// Truncated data that cannot be a valid tar.
|
||||
truncated := make([]byte, 100)
|
||||
n := New()
|
||||
err := n.FromTar(truncated)
|
||||
_, err := FromTar(truncated)
|
||||
assert.Error(t, err, "truncated data should produce an error")
|
||||
}
|
||||
|
||||
|
|
@ -475,8 +473,7 @@ func TestTarRoundTrip_Good(t *testing.T) {
|
|||
tarball, err := n1.ToTar()
|
||||
require.NoError(t, err)
|
||||
|
||||
n2 := New()
|
||||
err = n2.FromTar(tarball)
|
||||
n2, err := FromTar(tarball)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify n2 matches n1.
|
||||
|
|
|
|||
43
pkg/manifest/loader.go
Normal file
43
pkg/manifest/loader.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const manifestPath = ".core/view.yml"
|
||||
|
||||
// MarshalYAML serializes a manifest to YAML bytes.
|
||||
func MarshalYAML(m *Manifest) ([]byte, error) {
|
||||
return yaml.Marshal(m)
|
||||
}
|
||||
|
||||
// Load reads and parses a .core/view.yml from the given root directory.
|
||||
func Load(medium io.Medium, root string) (*Manifest, error) {
|
||||
path := filepath.Join(root, manifestPath)
|
||||
data, err := medium.Read(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("manifest.Load: %w", err)
|
||||
}
|
||||
return Parse([]byte(data))
|
||||
}
|
||||
|
||||
// LoadVerified reads, parses, and verifies the ed25519 signature.
|
||||
func LoadVerified(medium io.Medium, root string, pub ed25519.PublicKey) (*Manifest, error) {
|
||||
m, err := Load(medium, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := Verify(m, pub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("manifest.LoadVerified: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("manifest.LoadVerified: signature verification failed for %q", m.Code)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
63
pkg/manifest/loader_test.go
Normal file
63
pkg/manifest/loader_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoad_Good(t *testing.T) {
|
||||
fs := io.NewMockMedium()
|
||||
fs.Files[".core/view.yml"] = `
|
||||
code: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
layout: HLCRF
|
||||
slots:
|
||||
C: main-content
|
||||
`
|
||||
m, err := Load(fs, ".")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-app", m.Code)
|
||||
assert.Equal(t, "main-content", m.Slots["C"])
|
||||
}
|
||||
|
||||
func TestLoad_Bad_NoManifest(t *testing.T) {
|
||||
fs := io.NewMockMedium()
|
||||
_, err := Load(fs, ".")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLoadVerified_Good(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||
m := &Manifest{
|
||||
Code: "signed-app", Name: "Signed", Version: "1.0.0",
|
||||
Layout: "HLCRF", Slots: map[string]string{"C": "main"},
|
||||
}
|
||||
_ = Sign(m, priv)
|
||||
|
||||
raw, _ := MarshalYAML(m)
|
||||
fs := io.NewMockMedium()
|
||||
fs.Files[".core/view.yml"] = string(raw)
|
||||
|
||||
loaded, err := LoadVerified(fs, ".", pub)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "signed-app", loaded.Code)
|
||||
}
|
||||
|
||||
func TestLoadVerified_Bad_Tampered(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||
m := &Manifest{Code: "app", Version: "1.0.0"}
|
||||
_ = Sign(m, priv)
|
||||
|
||||
raw, _ := MarshalYAML(m)
|
||||
tampered := "code: evil\n" + string(raw)[6:]
|
||||
fs := io.NewMockMedium()
|
||||
fs.Files[".core/view.yml"] = tampered
|
||||
|
||||
_, err := LoadVerified(fs, ".", pub)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
50
pkg/manifest/manifest.go
Normal file
50
pkg/manifest/manifest.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Manifest represents a .core/view.yml application manifest.
|
||||
type Manifest struct {
|
||||
Code string `yaml:"code"`
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Sign string `yaml:"sign"`
|
||||
Layout string `yaml:"layout"`
|
||||
Slots map[string]string `yaml:"slots"`
|
||||
|
||||
Permissions Permissions `yaml:"permissions"`
|
||||
Modules []string `yaml:"modules"`
|
||||
}
|
||||
|
||||
// Permissions declares the I/O capabilities a module requires.
|
||||
type Permissions struct {
|
||||
Read []string `yaml:"read"`
|
||||
Write []string `yaml:"write"`
|
||||
Net []string `yaml:"net"`
|
||||
Run []string `yaml:"run"`
|
||||
}
|
||||
|
||||
// Parse decodes YAML bytes into a Manifest.
|
||||
func Parse(data []byte) (*Manifest, error) {
|
||||
var m Manifest
|
||||
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("manifest.Parse: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// SlotNames returns a deduplicated list of component names from slots.
|
||||
func (m *Manifest) SlotNames() []string {
|
||||
seen := make(map[string]bool)
|
||||
var names []string
|
||||
for _, name := range m.Slots {
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
65
pkg/manifest/manifest_test.go
Normal file
65
pkg/manifest/manifest_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParse_Good(t *testing.T) {
|
||||
raw := `
|
||||
code: photo-browser
|
||||
name: Photo Browser
|
||||
version: 0.1.0
|
||||
sign: dGVzdHNpZw==
|
||||
|
||||
layout: HLCRF
|
||||
slots:
|
||||
H: nav-breadcrumb
|
||||
L: folder-tree
|
||||
C: photo-grid
|
||||
R: metadata-panel
|
||||
F: status-bar
|
||||
|
||||
permissions:
|
||||
read: ["./photos/"]
|
||||
write: []
|
||||
net: []
|
||||
run: []
|
||||
|
||||
modules:
|
||||
- core/media
|
||||
- core/fs
|
||||
`
|
||||
m, err := Parse([]byte(raw))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "photo-browser", m.Code)
|
||||
assert.Equal(t, "Photo Browser", m.Name)
|
||||
assert.Equal(t, "0.1.0", m.Version)
|
||||
assert.Equal(t, "dGVzdHNpZw==", m.Sign)
|
||||
assert.Equal(t, "HLCRF", m.Layout)
|
||||
assert.Equal(t, "nav-breadcrumb", m.Slots["H"])
|
||||
assert.Equal(t, "photo-grid", m.Slots["C"])
|
||||
assert.Len(t, m.Permissions.Read, 1)
|
||||
assert.Equal(t, "./photos/", m.Permissions.Read[0])
|
||||
assert.Len(t, m.Modules, 2)
|
||||
}
|
||||
|
||||
func TestParse_Bad(t *testing.T) {
|
||||
_, err := Parse([]byte("not: valid: yaml: ["))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestManifest_SlotNames_Good(t *testing.T) {
|
||||
m := Manifest{
|
||||
Slots: map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "main-content",
|
||||
},
|
||||
}
|
||||
names := m.SlotNames()
|
||||
assert.Contains(t, names, "nav-bar")
|
||||
assert.Contains(t, names, "main-content")
|
||||
assert.Len(t, names, 2)
|
||||
}
|
||||
43
pkg/manifest/sign.go
Normal file
43
pkg/manifest/sign.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// signable returns the canonical bytes to sign (manifest without sign field).
|
||||
func signable(m *Manifest) ([]byte, error) {
|
||||
tmp := *m
|
||||
tmp.Sign = ""
|
||||
return yaml.Marshal(&tmp)
|
||||
}
|
||||
|
||||
// Sign computes the ed25519 signature and stores it in m.Sign (base64).
|
||||
func Sign(m *Manifest, priv ed25519.PrivateKey) error {
|
||||
msg, err := signable(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("manifest.Sign: marshal: %w", err)
|
||||
}
|
||||
sig := ed25519.Sign(priv, msg)
|
||||
m.Sign = base64.StdEncoding.EncodeToString(sig)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify checks the ed25519 signature in m.Sign against the public key.
|
||||
func Verify(m *Manifest, pub ed25519.PublicKey) (bool, error) {
|
||||
if m.Sign == "" {
|
||||
return false, fmt.Errorf("manifest.Verify: no signature present")
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(m.Sign)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("manifest.Verify: decode: %w", err)
|
||||
}
|
||||
msg, err := signable(m)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("manifest.Verify: marshal: %w", err)
|
||||
}
|
||||
return ed25519.Verify(pub, msg, sig), nil
|
||||
}
|
||||
51
pkg/manifest/sign_test.go
Normal file
51
pkg/manifest/sign_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSignAndVerify_Good(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := &Manifest{
|
||||
Code: "test-app",
|
||||
Name: "Test App",
|
||||
Version: "1.0.0",
|
||||
Layout: "HLCRF",
|
||||
Slots: map[string]string{"C": "main"},
|
||||
}
|
||||
|
||||
err = Sign(m, priv)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, m.Sign)
|
||||
|
||||
ok, err := Verify(m, pub)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestVerify_Bad_Tampered(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||
m := &Manifest{Code: "test-app", Version: "1.0.0"}
|
||||
_ = Sign(m, priv)
|
||||
|
||||
m.Code = "evil-app" // tamper
|
||||
|
||||
ok, err := Verify(m, pub)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestVerify_Bad_Unsigned(t *testing.T) {
|
||||
pub, _, _ := ed25519.GenerateKey(nil)
|
||||
m := &Manifest{Code: "test-app"}
|
||||
|
||||
ok, err := Verify(m, pub)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue