Compare commits
No commits in common. "dev" and "v0.3.1" have entirely different histories.
121 changed files with 6089 additions and 5765 deletions
42
.gitignore
vendored
42
.gitignore
vendored
|
|
@ -1,28 +1,26 @@
|
|||
.idea/
|
||||
.vscode/
|
||||
wails3
|
||||
.task
|
||||
vendor/
|
||||
.idea
|
||||
node_modules/
|
||||
.DS_Store
|
||||
*.log
|
||||
.core/
|
||||
|
||||
# Build artefacts
|
||||
dist/
|
||||
bin/
|
||||
/core
|
||||
/cli
|
||||
|
||||
# Go
|
||||
vendor/
|
||||
go.work.sum
|
||||
.env
|
||||
.env.*.local
|
||||
coverage/
|
||||
coverage.out
|
||||
coverage.html
|
||||
coverage.txt
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*.local
|
||||
|
||||
# OS / tooling
|
||||
.task
|
||||
*.cache
|
||||
node_modules/
|
||||
/coverage.txt
|
||||
bin/
|
||||
dist/
|
||||
tasks
|
||||
/cli
|
||||
/core
|
||||
local.test
|
||||
/i18n-validate
|
||||
.angular/
|
||||
|
||||
patch_cov.*
|
||||
go.work.sum
|
||||
.kb
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import (
|
|||
)
|
||||
|
||||
// AddConfigCommands registers the 'config' command group and all subcommands.
|
||||
//
|
||||
// config.AddConfigCommands(rootCmd)
|
||||
func AddConfigCommands(root *cli.Command) {
|
||||
configCmd := cli.NewGroup("config", "Manage configuration", "")
|
||||
root.AddCommand(configCmd)
|
||||
|
|
@ -19,9 +17,9 @@ func AddConfigCommands(root *cli.Command) {
|
|||
}
|
||||
|
||||
func loadConfig() (*config.Config, error) {
|
||||
configuration, err := config.New()
|
||||
cfg, err := config.New()
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "failed to load config")
|
||||
}
|
||||
return configuration, nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
|
|
@ -8,17 +10,17 @@ func addGetCommand(parent *cli.Command) {
|
|||
cmd := cli.NewCommand("get", "Get a configuration value", "", func(cmd *cli.Command, args []string) error {
|
||||
key := args[0]
|
||||
|
||||
configuration, err := loadConfig()
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var value any
|
||||
if err := configuration.Get(key, &value); err != nil {
|
||||
if err := cfg.Get(key, &value); err != nil {
|
||||
return cli.Err("key not found: %s", key)
|
||||
}
|
||||
|
||||
cli.Println("%v", value)
|
||||
fmt.Println(value)
|
||||
return nil
|
||||
})
|
||||
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
|
@ -9,23 +10,23 @@ import (
|
|||
|
||||
func addListCommand(parent *cli.Command) {
|
||||
cmd := cli.NewCommand("list", "List all configuration values", "", func(cmd *cli.Command, args []string) error {
|
||||
configuration, err := loadConfig()
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
all := maps.Collect(configuration.All())
|
||||
all := maps.Collect(cfg.All())
|
||||
if len(all) == 0 {
|
||||
cli.Dim("No configuration values set")
|
||||
return nil
|
||||
}
|
||||
|
||||
output, err := yaml.Marshal(all)
|
||||
out, err := yaml.Marshal(all)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to format config")
|
||||
}
|
||||
|
||||
cli.Print("%s", string(output))
|
||||
fmt.Print(string(out))
|
||||
return nil
|
||||
})
|
||||
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func addPathCommand(parent *cli.Command) {
|
||||
cmd := cli.NewCommand("path", "Show the configuration file path", "", func(cmd *cli.Command, args []string) error {
|
||||
configuration, err := loadConfig()
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Println("%s", configuration.Path())
|
||||
fmt.Println(cfg.Path())
|
||||
return nil
|
||||
})
|
||||
|
||||
|
|
@ -9,12 +9,12 @@ func addSetCommand(parent *cli.Command) {
|
|||
key := args[0]
|
||||
value := args[1]
|
||||
|
||||
configuration, err := loadConfig()
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := configuration.Set(key, value); err != nil {
|
||||
if err := cfg.Set(key, value); err != nil {
|
||||
return cli.Wrap(err, "failed to set config value")
|
||||
}
|
||||
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRequiredChecksIncludesGo(t *testing.T) {
|
||||
checks := requiredChecks()
|
||||
|
||||
var found bool
|
||||
for _, c := range checks {
|
||||
if c.command == "go" {
|
||||
found = true
|
||||
assert.Equal(t, "version", c.versionFlag)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, found, "required checks should include the Go compiler")
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
// Package doctor provides environment check commands.
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Style aliases from shared
|
||||
var (
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
dimStyle = cli.DimStyle
|
||||
)
|
||||
|
||||
// Flag variable for doctor command
|
||||
var doctorVerbose bool
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
Use: "doctor",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDoctor(doctorVerbose)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
doctorCmd.Flags().BoolVar(&doctorVerbose, "verbose", false, i18n.T("cmd.doctor.verbose_flag"))
|
||||
}
|
||||
|
||||
func runDoctor(verbose bool) error {
|
||||
cli.Println("%s", i18n.T("common.progress.checking", map[string]any{"Item": "development environment"}))
|
||||
cli.Blank()
|
||||
|
||||
var passed, failed, optional int
|
||||
|
||||
// Check required tools
|
||||
cli.Println("%s", i18n.T("cmd.doctor.required"))
|
||||
for _, toolCheck := range requiredChecks() {
|
||||
ok, version := runCheck(toolCheck)
|
||||
if ok {
|
||||
if verbose {
|
||||
cli.Println("%s", formatCheckResult(true, toolCheck.name, version))
|
||||
} else {
|
||||
cli.Println("%s", formatCheckResult(true, toolCheck.name, ""))
|
||||
}
|
||||
passed++
|
||||
} else {
|
||||
cli.Println(" %s %s - %s", errorStyle.Render(cli.Glyph(":cross:")), toolCheck.name, toolCheck.description)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
// Check optional tools
|
||||
cli.Println("\n%s", i18n.T("cmd.doctor.optional"))
|
||||
for _, toolCheck := range optionalChecks() {
|
||||
ok, version := runCheck(toolCheck)
|
||||
if ok {
|
||||
if verbose {
|
||||
cli.Println("%s", formatCheckResult(true, toolCheck.name, version))
|
||||
} else {
|
||||
cli.Println("%s", formatCheckResult(true, toolCheck.name, ""))
|
||||
}
|
||||
passed++
|
||||
} else {
|
||||
cli.Println(" %s %s - %s", dimStyle.Render(cli.Glyph(":skip:")), toolCheck.name, dimStyle.Render(toolCheck.description))
|
||||
optional++
|
||||
}
|
||||
}
|
||||
|
||||
// Check GitHub access
|
||||
cli.Println("\n%s", i18n.T("cmd.doctor.github"))
|
||||
if checkGitHubSSH() {
|
||||
cli.Println("%s", formatCheckResult(true, i18n.T("cmd.doctor.ssh_found"), ""))
|
||||
} else {
|
||||
cli.Println(" %s %s", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.ssh_missing"))
|
||||
failed++
|
||||
}
|
||||
|
||||
if checkGitHubCLI() {
|
||||
cli.Println("%s", formatCheckResult(true, i18n.T("cmd.doctor.cli_auth"), ""))
|
||||
} else {
|
||||
cli.Println(" %s %s", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.cli_auth_missing"))
|
||||
failed++
|
||||
}
|
||||
|
||||
// Check workspace
|
||||
cli.Println("\n%s", i18n.T("cmd.doctor.workspace"))
|
||||
checkWorkspace()
|
||||
|
||||
// Summary
|
||||
cli.Blank()
|
||||
if failed > 0 {
|
||||
cli.Error(i18n.T("cmd.doctor.issues", map[string]any{"Count": failed}))
|
||||
cli.Println("\n%s", i18n.T("cmd.doctor.install_missing"))
|
||||
printInstallInstructions()
|
||||
return cli.Err("%s", i18n.T("cmd.doctor.issues_error", map[string]any{"Count": failed}))
|
||||
}
|
||||
|
||||
cli.Success(i18n.T("cmd.doctor.ready"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatCheckResult(ok bool, name, detail string) string {
|
||||
checkBuilder := cli.Check(name)
|
||||
if ok {
|
||||
checkBuilder.Pass()
|
||||
} else {
|
||||
checkBuilder.Fail()
|
||||
}
|
||||
if detail != "" {
|
||||
checkBuilder.Message(detail)
|
||||
} else {
|
||||
checkBuilder.Message("")
|
||||
}
|
||||
return checkBuilder.String()
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
io "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
)
|
||||
|
||||
// checkGitHubSSH checks if SSH keys exist for GitHub access.
|
||||
// Returns true if any standard SSH key file exists in ~/.ssh/.
|
||||
func checkGitHubSSH() bool {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
sshDirectory := core.Path(home, ".ssh")
|
||||
keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"}
|
||||
|
||||
for _, keyName := range keyPatterns {
|
||||
keyPath := core.Path(sshDirectory, keyName)
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// checkGitHubCLI checks if the GitHub CLI is authenticated.
|
||||
// Returns true when 'gh auth status' output contains "Logged in to".
|
||||
func checkGitHubCLI() bool {
|
||||
proc := exec.Command("gh", "auth", "status")
|
||||
output, _ := proc.CombinedOutput()
|
||||
return core.Contains(string(output), "Logged in to")
|
||||
}
|
||||
|
||||
// checkWorkspace checks for repos.yaml and counts cloned repos.
|
||||
func checkWorkspace() {
|
||||
registryPath, err := repos.FindRegistry(io.Local)
|
||||
if err == nil {
|
||||
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_yaml_found", map[string]any{"Path": registryPath}))
|
||||
|
||||
registry, err := repos.LoadRegistry(io.Local, registryPath)
|
||||
if err == nil {
|
||||
basePath := registry.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "./packages"
|
||||
}
|
||||
if !core.PathIsAbs(basePath) {
|
||||
basePath = core.Path(core.PathDir(registryPath), basePath)
|
||||
}
|
||||
if core.HasPrefix(basePath, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
basePath = core.Path(home, basePath[2:])
|
||||
}
|
||||
|
||||
// Count existing repos.
|
||||
allRepos := registry.List()
|
||||
var cloned int
|
||||
for _, repo := range allRepos {
|
||||
repoPath := core.Path(basePath, repo.Name)
|
||||
if _, err := os.Stat(core.Path(repoPath, ".git")); err == nil {
|
||||
cloned++
|
||||
}
|
||||
}
|
||||
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_cloned", map[string]any{"Cloned": cloned, "Total": len(allRepos)}))
|
||||
}
|
||||
} else {
|
||||
cli.Println(" %s %s", dimStyle.Render("○"), i18n.T("cmd.doctor.no_repos_yaml"))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
// printInstallInstructions prints operating-system-specific installation instructions.
|
||||
func printInstallInstructions() {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cli.Println(" %s", i18n.T("cmd.doctor.install_macos"))
|
||||
cli.Println(" %s", i18n.T("cmd.doctor.install_macos_cask"))
|
||||
case "linux":
|
||||
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_header"))
|
||||
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_git"))
|
||||
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_gh"))
|
||||
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_php"))
|
||||
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_node"))
|
||||
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_pnpm"))
|
||||
default:
|
||||
cli.Println(" %s", i18n.T("cmd.doctor.install_other"))
|
||||
}
|
||||
}
|
||||
116
cmd/core/go.mod
116
cmd/core/go.mod
|
|
@ -1,116 +0,0 @@
|
|||
module forge.lthn.ai/core/cli/cmd/core
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/cli v0.3.3
|
||||
forge.lthn.ai/core/config v0.1.3
|
||||
forge.lthn.ai/core/go-build v0.2.3
|
||||
forge.lthn.ai/core/go-cache v0.1.2
|
||||
forge.lthn.ai/core/go-crypt v0.1.7
|
||||
forge.lthn.ai/core/go-devops v0.1.9
|
||||
forge.lthn.ai/core/go-help v0.1.3
|
||||
forge.lthn.ai/core/go-i18n v0.1.4
|
||||
forge.lthn.ai/core/go-io v0.1.2
|
||||
forge.lthn.ai/core/go-scm v0.3.1
|
||||
forge.lthn.ai/core/lint v0.3.2
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
code.gitea.io/sdk/gitea v0.23.2 // indirect
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
|
||||
forge.lthn.ai/core/agent v0.3.1 // indirect
|
||||
forge.lthn.ai/core/go v0.3.1 // indirect
|
||||
forge.lthn.ai/core/go-container v0.1.3 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.4 // indirect
|
||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
forge.lthn.ai/core/go-process v0.2.3 // indirect
|
||||
forge.lthn.ai/core/go-store v0.1.6 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.0 // indirect
|
||||
github.com/Snider/Borg v0.2.0 // indirect
|
||||
github.com/TwiN/go-color v1.4.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // 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/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/getkin/kin-openapi v0.134.0 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 // indirect
|
||||
github.com/leaanthony/debme v1.2.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // 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.21 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // 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/oasdiff/kin-openapi v0.136.1 // indirect
|
||||
github.com/oasdiff/oasdiff v1.12.3 // indirect
|
||||
github.com/oasdiff/yaml v0.0.1 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/wI2L/jsondiff v0.7.0 // indirect
|
||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
)
|
||||
299
cmd/core/go.sum
299
cmd/core/go.sum
|
|
@ -1,299 +0,0 @@
|
|||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
||||
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
|
||||
forge.lthn.ai/core/agent v0.3.1 h1:Q6lkSg9nr2c1oj2Pr/s3LN7xItIyOmSRgLSfMaaNuyQ=
|
||||
forge.lthn.ai/core/agent v0.3.1/go.mod h1:FfHS10AkPcxnc+ms93QzNJtZ7dgcET0LvMcJAzY2h+w=
|
||||
forge.lthn.ai/core/cli v0.3.3 h1:dWvpiLZifuydqU4eH5+UdgCQ6/LOpS1x+O03pU7jkhk=
|
||||
forge.lthn.ai/core/cli v0.3.3/go.mod h1:PJ/cTufrVLz4KdlBhUkT/sOeh6uOSN6W7+/IvglRoBU=
|
||||
forge.lthn.ai/core/config v0.1.3 h1:mq02v7LFf9jHSqJakO08qYQnPP8oVMbJHlOxNARXBa8=
|
||||
forge.lthn.ai/core/config v0.1.3/go.mod h1:4+/ytojOSaPoAQ1uub1+GeOM8OoYdR9xqMtVA3SZ8Qk=
|
||||
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
|
||||
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
|
||||
forge.lthn.ai/core/go-build v0.2.3 h1:iTb0YpJj7PFAWJ+LmO6lusUfYvnyKIfABi1ohnYrdcw=
|
||||
forge.lthn.ai/core/go-build v0.2.3/go.mod h1:0CVFglD7cc07ew1c9IEv/BAniHGndHGwrWk27m4c4L8=
|
||||
forge.lthn.ai/core/go-cache v0.1.2 h1:mIt+dqe2Gnqcj3Q6y6wGOXu0MklPO/oWuF09UZUmS6w=
|
||||
forge.lthn.ai/core/go-cache v0.1.2/go.mod h1:7WbprZVfx/+t4cbJFXMo4sloWk2Eny+rZd8x1Ay9rLk=
|
||||
forge.lthn.ai/core/go-container v0.1.3 h1:Pb/latnVFBgyI4zDyYxAiRRqKrOYIAxL6om+k2YS1q8=
|
||||
forge.lthn.ai/core/go-container v0.1.3/go.mod h1:wIlly3pAluVQnQ+DLnZ15pENOFkJicWRRke6msCudLI=
|
||||
forge.lthn.ai/core/go-crypt v0.1.7 h1:tyDFnXjEksHFQpkFwCpEn+x7zvwh4LnaU+/fP3WmqZc=
|
||||
forge.lthn.ai/core/go-crypt v0.1.7/go.mod h1:mQdr6K8lWOcyHmSEW24vZPTThQF8fteVgZi8CO+Ko3Y=
|
||||
forge.lthn.ai/core/go-devops v0.1.9 h1:pgGTvCDeg1SgJIkpZfy1l6ZvkOGGGY+fa3aAcl3vRG4=
|
||||
forge.lthn.ai/core/go-devops v0.1.9/go.mod h1:uY37IzpargbgDBwazqYv6X5+e2bcCO+cn0jCYQA/YMk=
|
||||
forge.lthn.ai/core/go-help v0.1.3 h1:eKrj3o3ruvDD3c6NWUve8Y/uMqpfIE5/yR2eU6gdAF0=
|
||||
forge.lthn.ai/core/go-help v0.1.3/go.mod h1:JSZVb4Gd+P/dTc9laDJsqVCI6OrVbBbBPyPmvw3j4p4=
|
||||
forge.lthn.ai/core/go-i18n v0.1.4 h1:zOHUUJDgRo88/3tj++kN+VELg/buyZ4T2OSdG3HBbLQ=
|
||||
forge.lthn.ai/core/go-i18n v0.1.4/go.mod h1:aDyAfz7MMgWYgLkZCptfFmZ7jJg3ocwjEJ1WkJSvv4U=
|
||||
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
||||
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-io v0.1.2 h1:q8hj2jtOFqAgHlBr5wsUAOXtaFkxy9gqGrQT/il0WYA=
|
||||
forge.lthn.ai/core/go-io v0.1.2/go.mod h1:PbNKW1Q25ywSOoQXeGdQHbV5aiIrTXvHIQ5uhplA//g=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
forge.lthn.ai/core/go-process v0.2.3 h1:/ERqRYHgCNZjNT9NMinAAJJGJWSsHuCTiHFNEm6nTPY=
|
||||
forge.lthn.ai/core/go-process v0.2.3/go.mod h1:gVTbxL16ccUIexlFcyDtCy7LfYvD8Rtyzfo8bnXAXrU=
|
||||
forge.lthn.ai/core/go-scm v0.3.1 h1:G+DqVJLT+UjgUzu2Hnseyl2szhb0wB+DB8VYhq/bLOI=
|
||||
forge.lthn.ai/core/go-scm v0.3.1/go.mod h1:ER9fQBs8nnlJZQ6+ALnwv+boK/xiwg8jEc9VP6DMijk=
|
||||
forge.lthn.ai/core/go-store v0.1.6 h1:7T+K5cciXOaWRxge0WnGkt0PcK3epliWBa1G2FLEuac=
|
||||
forge.lthn.ai/core/go-store v0.1.6/go.mod h1:/2vqaAn+HgGU14N29B+vIfhjIsBzy7RC+AluI6BIUKI=
|
||||
forge.lthn.ai/core/lint v0.3.2 h1:3ZzHfb4OQS0r0NsQpsIrnBscgOE058KIDty3b45r00E=
|
||||
forge.lthn.ai/core/lint v0.3.2/go.mod h1:fInfXFlOCljqWh6fkjHqAUXok5vhblKc+toQJIihIPY=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
|
||||
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
|
||||
github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY=
|
||||
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
|
||||
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
|
||||
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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
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.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
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=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
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/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
|
||||
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
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/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
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=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4=
|
||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1/go.mod h1:3ebNU9QBrNpUO+Hj6bHaGpkh5pymDHQ+wwVPHTE4mCE=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
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/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-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.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
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/oasdiff/kin-openapi v0.136.1 h1:x1G9doDyPcagCNXDcMK5dt5yAmIgsSCiK7F5gPUiQdM=
|
||||
github.com/oasdiff/kin-openapi v0.136.1/go.mod h1:BMeaLn+GmFJKtHJ31JrgXFt91eZi/q+Og4tr7sq0BzI=
|
||||
github.com/oasdiff/oasdiff v1.12.3 h1:eUzJ/AiyyCY1KwUZPv7fosgDyETacIZbFesJrRz+QdY=
|
||||
github.com/oasdiff/oasdiff v1.12.3/go.mod h1:ApEJGlkuRdrcBgTE4ioicwIM7nzkxPqLPPvcB5AytQ0=
|
||||
github.com/oasdiff/yaml v0.0.1 h1:dPrn0F2PJ7HdzHPndJkArvB2Fw0cwgFdVUKCEkoFuds=
|
||||
github.com/oasdiff/yaml v0.0.1/go.mod h1:r8bgVgpWT5iIN/AgP0GljFvB6CicK+yL1nIAbm+8/QQ=
|
||||
github.com/oasdiff/yaml3 v0.0.1 h1:kReOSraQLTxuuGNX9aNeJ7tcsvUB2MS+iupdUrWe4Z0=
|
||||
github.com/oasdiff/yaml3 v0.0.1/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
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=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
|
||||
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
|
||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
||||
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=
|
||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
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=
|
||||
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.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
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.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.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
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=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
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.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=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
package help
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
gohelp "forge.lthn.ai/core/go-help"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func captureOutput(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldOut := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stdout = w
|
||||
|
||||
defer func() {
|
||||
os.Stdout = oldOut
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, r)
|
||||
require.NoError(t, err)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func newHelpCommand(t *testing.T) *cli.Command {
|
||||
t.Helper()
|
||||
|
||||
root := &cli.Command{Use: "core"}
|
||||
AddHelpCommands(root)
|
||||
|
||||
cmd, _, err := root.Find([]string{"help"})
|
||||
require.NoError(t, err)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func searchableHelpQuery(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
catalog := gohelp.DefaultCatalog()
|
||||
for _, candidate := range []string{"configuration", "docs", "search", "topic", "help"} {
|
||||
if _, err := catalog.Get(candidate); err == nil {
|
||||
continue
|
||||
}
|
||||
if len(catalog.Search(candidate)) > 0 {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
t.Skip("no suitable query found with suggestions")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAddHelpCommands_Good(t *testing.T) {
|
||||
cmd := newHelpCommand(t)
|
||||
|
||||
topics := gohelp.DefaultCatalog().List()
|
||||
require.NotEmpty(t, topics)
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
assert.Contains(t, out, "AVAILABLE HELP TOPICS")
|
||||
assert.Contains(t, out, topics[0].ID)
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search <topic>")
|
||||
}
|
||||
|
||||
func TestAddHelpCommands_Good_Serve(t *testing.T) {
|
||||
root := &cli.Command{Use: "core"}
|
||||
AddHelpCommands(root)
|
||||
|
||||
cmd, _, err := root.Find([]string{"help", "serve"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
oldStart := startHelpServer
|
||||
defer func() { startHelpServer = oldStart }()
|
||||
|
||||
var gotAddr string
|
||||
startHelpServer = func(catalog *gohelp.Catalog, addr string) error {
|
||||
require.NotNil(t, catalog)
|
||||
gotAddr = addr
|
||||
return nil
|
||||
}
|
||||
|
||||
require.NoError(t, cmd.Flags().Set("addr", "127.0.0.1:9090"))
|
||||
err = cmd.RunE(cmd, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "127.0.0.1:9090", gotAddr)
|
||||
}
|
||||
|
||||
func TestAddHelpCommands_Good_Search(t *testing.T) {
|
||||
root := &cli.Command{Use: "core"}
|
||||
AddHelpCommands(root)
|
||||
|
||||
cmd, _, err := root.Find([]string{"help", "search"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
query := searchableHelpQuery(t)
|
||||
require.NoError(t, cmd.Flags().Set("query", query))
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "SEARCH RESULTS")
|
||||
assert.Contains(t, out, query)
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search")
|
||||
}
|
||||
|
||||
func TestRenderSearchResults_Good(t *testing.T) {
|
||||
out := captureOutput(t, func() {
|
||||
err := renderSearchResults([]*gohelp.SearchResult{
|
||||
{
|
||||
Topic: &gohelp.Topic{
|
||||
ID: "config",
|
||||
Title: "Configuration",
|
||||
},
|
||||
Snippet: "Core is configured via environment variables.",
|
||||
},
|
||||
}, "config")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "SEARCH RESULTS")
|
||||
assert.Contains(t, out, "config - Configuration")
|
||||
assert.Contains(t, out, "Core is configured via environment variables.")
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search \"config\"")
|
||||
}
|
||||
|
||||
func TestRenderTopicList_Good(t *testing.T) {
|
||||
out := captureOutput(t, func() {
|
||||
err := renderTopicList([]*gohelp.Topic{
|
||||
{
|
||||
ID: "config",
|
||||
Title: "Configuration",
|
||||
Content: "# Configuration\n\nCore is configured via environment variables.\n\nMore details follow.",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "AVAILABLE HELP TOPICS")
|
||||
assert.Contains(t, out, "config - Configuration")
|
||||
assert.Contains(t, out, "Core is configured via environment variables.")
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search <topic>")
|
||||
}
|
||||
|
||||
func TestRenderTopic_Good(t *testing.T) {
|
||||
out := captureOutput(t, func() {
|
||||
renderTopic(&gohelp.Topic{
|
||||
ID: "config",
|
||||
Title: "Configuration",
|
||||
Content: "Core is configured via environment variables.",
|
||||
})
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "Configuration")
|
||||
assert.Contains(t, out, "Core is configured via environment variables.")
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help search \"config\"")
|
||||
}
|
||||
|
||||
func TestAddHelpCommands_Bad(t *testing.T) {
|
||||
t.Run("missing search results", func(t *testing.T) {
|
||||
cmd := newHelpCommand(t)
|
||||
require.NoError(t, cmd.Flags().Set("search", "zzzyyyxxx"))
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no help topics matched")
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help")
|
||||
assert.Contains(t, out, "core help search")
|
||||
})
|
||||
|
||||
t.Run("missing topic without suggestions shows hints", func(t *testing.T) {
|
||||
cmd := newHelpCommand(t)
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, []string{"definitely-not-a-real-topic"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "help topic")
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help")
|
||||
})
|
||||
|
||||
t.Run("missing search query", func(t *testing.T) {
|
||||
root := &cli.Command{Use: "core"}
|
||||
AddHelpCommands(root)
|
||||
|
||||
cmd, _, findErr := root.Find([]string{"help", "search"})
|
||||
require.NoError(t, findErr)
|
||||
require.NotNil(t, cmd)
|
||||
|
||||
var runErr error
|
||||
out := captureOutput(t, func() {
|
||||
runErr = cmd.RunE(cmd, nil)
|
||||
})
|
||||
require.Error(t, runErr)
|
||||
assert.Contains(t, runErr.Error(), "help search query is required")
|
||||
assert.Contains(t, out, "browse")
|
||||
assert.Contains(t, out, "core help")
|
||||
})
|
||||
|
||||
t.Run("missing topic shows suggestions when available", func(t *testing.T) {
|
||||
query := searchableHelpQuery(t)
|
||||
|
||||
cmd := newHelpCommand(t)
|
||||
out := captureOutput(t, func() {
|
||||
err := cmd.RunE(cmd, []string{query})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "help topic")
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "SEARCH RESULTS")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/cmd/core/config"
|
||||
"forge.lthn.ai/core/cli/cmd/core/doctor"
|
||||
"forge.lthn.ai/core/cli/cmd/core/help"
|
||||
"forge.lthn.ai/core/cli/cmd/core/pkgcmd"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
// Ecosystem commands — self-register via init() + cli.RegisterCommands()
|
||||
_ "forge.lthn.ai/core/go-build/cmd/build"
|
||||
_ "forge.lthn.ai/core/go-build/cmd/ci"
|
||||
_ "forge.lthn.ai/core/go-build/cmd/sdk"
|
||||
_ "forge.lthn.ai/core/go-crypt/cmd/crypt"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/deploy"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/dev"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/docs"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/gitcmd"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/setup"
|
||||
_ "forge.lthn.ai/core/go-scm/cmd/collect"
|
||||
_ "forge.lthn.ai/core/go-scm/cmd/forge"
|
||||
_ "forge.lthn.ai/core/go-scm/cmd/gitea"
|
||||
_ "forge.lthn.ai/core/lint/cmd/qa"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cli.Main(
|
||||
cli.WithCommands("config", config.AddConfigCommands),
|
||||
cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
cli.WithCommands("help", help.AddHelpCommands),
|
||||
cli.WithCommands("pkg", pkgcmd.AddPkgCommands),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
installTargetDir string
|
||||
installAddToReg bool
|
||||
)
|
||||
|
||||
// addPkgInstallCommand adds the 'pkg install' command.
|
||||
func addPkgInstallCommand(parent *cobra.Command) {
|
||||
installCmd := &cobra.Command{
|
||||
Use: "install <org/repo>",
|
||||
Short: i18n.T("cmd.pkg.install.short"),
|
||||
Long: i18n.T("cmd.pkg.install.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.repo_required"))
|
||||
}
|
||||
return runPkgInstall(args[0], installTargetDir, installAddToReg)
|
||||
},
|
||||
}
|
||||
|
||||
installCmd.Flags().StringVar(&installTargetDir, "dir", "", i18n.T("cmd.pkg.install.flag.dir"))
|
||||
installCmd.Flags().BoolVar(&installAddToReg, "add", false, i18n.T("cmd.pkg.install.flag.add"))
|
||||
|
||||
parent.AddCommand(installCmd)
|
||||
}
|
||||
|
||||
func runPkgInstall(repoArg, targetDirectory string, addToRegistry bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Parse org/repo argument.
|
||||
parts := core.Split(repoArg, "/")
|
||||
if len(parts) != 2 {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.invalid_repo_format"))
|
||||
}
|
||||
org, repoName := parts[0], parts[1]
|
||||
|
||||
// Determine target directory from registry or default.
|
||||
if targetDirectory == "" {
|
||||
if registryPath, err := repos.FindRegistry(coreio.Local); err == nil {
|
||||
if registry, err := repos.LoadRegistry(coreio.Local, registryPath); err == nil {
|
||||
targetDirectory = registry.BasePath
|
||||
if targetDirectory == "" {
|
||||
targetDirectory = "./packages"
|
||||
}
|
||||
if !core.PathIsAbs(targetDirectory) {
|
||||
targetDirectory = core.Path(core.PathDir(registryPath), targetDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetDirectory == "" {
|
||||
targetDirectory = "."
|
||||
}
|
||||
}
|
||||
|
||||
if core.HasPrefix(targetDirectory, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
targetDirectory = core.Path(home, targetDirectory[2:])
|
||||
}
|
||||
|
||||
repoPath := core.Path(targetDirectory, repoName)
|
||||
|
||||
if coreio.Local.Exists(core.Path(repoPath, ".git")) {
|
||||
cli.Println("%s %s", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := coreio.Local.EnsureDir(targetDirectory); err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.create", "directory"))
|
||||
}
|
||||
|
||||
cli.Println("%s %s/%s", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName)
|
||||
cli.Println("%s %s", dimStyle.Render(i18n.Label("target")), repoPath)
|
||||
cli.Blank()
|
||||
|
||||
cli.Print(" %s... ", dimStyle.Render(i18n.T("common.status.cloning")))
|
||||
err := gitClone(ctx, org, repoName, repoPath)
|
||||
if err != nil {
|
||||
cli.Println("%s", errorStyle.Render("✗ "+err.Error()))
|
||||
return err
|
||||
}
|
||||
cli.Println("%s", successStyle.Render("✓"))
|
||||
|
||||
if addToRegistry {
|
||||
if err := addToRegistryFile(org, repoName); err != nil {
|
||||
cli.Println(" %s %s: %s", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err)
|
||||
} else {
|
||||
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry"))
|
||||
}
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Println("%s %s", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addToRegistryFile(org, repoName string) error {
|
||||
registryPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
|
||||
}
|
||||
|
||||
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, exists := registry.Get(repoName); exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := coreio.Local.Read(registryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoType := detectRepoType(repoName)
|
||||
entry := cli.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
|
||||
repoName, repoType)
|
||||
|
||||
content += entry
|
||||
return coreio.Local.Write(registryPath, content)
|
||||
}
|
||||
|
||||
func detectRepoType(name string) string {
|
||||
lowerName := core.Lower(name)
|
||||
if core.Contains(lowerName, "-mod-") || core.HasSuffix(lowerName, "-mod") {
|
||||
return "module"
|
||||
}
|
||||
if core.Contains(lowerName, "-plug-") || core.HasSuffix(lowerName, "-plug") {
|
||||
return "plugin"
|
||||
}
|
||||
if core.Contains(lowerName, "-services-") || core.HasSuffix(lowerName, "-services") {
|
||||
return "service"
|
||||
}
|
||||
if core.Contains(lowerName, "-website-") || core.HasSuffix(lowerName, "-website") {
|
||||
return "website"
|
||||
}
|
||||
if core.HasPrefix(lowerName, "core-") {
|
||||
return "package"
|
||||
}
|
||||
return "package"
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRunPkgInstall_AllowsRepoShorthand_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
targetDir := filepath.Join(tmp, "packages")
|
||||
|
||||
originalGitClone := gitClone
|
||||
t.Cleanup(func() {
|
||||
gitClone = originalGitClone
|
||||
})
|
||||
|
||||
var gotOrg, gotRepo, gotPath string
|
||||
gitClone = func(_ context.Context, org, repoName, repoPath string) error {
|
||||
gotOrg = org
|
||||
gotRepo = repoName
|
||||
gotPath = repoPath
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPkgInstall("core-api", targetDir, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "host-uk", gotOrg)
|
||||
assert.Equal(t, "core-api", gotRepo)
|
||||
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
|
||||
_, err = os.Stat(targetDir)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRunPkgInstall_AllowsExplicitOrgRepo_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
targetDir := filepath.Join(tmp, "packages")
|
||||
|
||||
originalGitClone := gitClone
|
||||
t.Cleanup(func() {
|
||||
gitClone = originalGitClone
|
||||
})
|
||||
|
||||
var gotOrg, gotRepo, gotPath string
|
||||
gitClone = func(_ context.Context, org, repoName, repoPath string) error {
|
||||
gotOrg = org
|
||||
gotRepo = repoName
|
||||
gotPath = repoPath
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPkgInstall("myorg/core-api", targetDir, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "myorg", gotOrg)
|
||||
assert.Equal(t, "core-api", gotRepo)
|
||||
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
|
||||
}
|
||||
|
||||
func TestRunPkgInstall_InvalidRepoFormat_Bad(t *testing.T) {
|
||||
err := runPkgInstall("a/b/c", t.TempDir(), false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid repo format")
|
||||
}
|
||||
|
||||
func TestParsePkgInstallSource_Good(t *testing.T) {
|
||||
t.Run("default org and repo", func(t *testing.T) {
|
||||
org, repo, ref, err := parsePkgInstallSource("core-api")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "host-uk", org)
|
||||
assert.Equal(t, "core-api", repo)
|
||||
assert.Empty(t, ref)
|
||||
})
|
||||
|
||||
t.Run("explicit org and ref", func(t *testing.T) {
|
||||
org, repo, ref, err := parsePkgInstallSource("myorg/core-api@v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "myorg", org)
|
||||
assert.Equal(t, "core-api", repo)
|
||||
assert.Equal(t, "v1.2.3", ref)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunPkgInstall_WithRef_UsesRefClone_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
targetDir := filepath.Join(tmp, "packages")
|
||||
|
||||
originalGitCloneRef := gitCloneRef
|
||||
t.Cleanup(func() {
|
||||
gitCloneRef = originalGitCloneRef
|
||||
})
|
||||
|
||||
var gotOrg, gotRepo, gotPath, gotRef string
|
||||
gitCloneRef = func(_ context.Context, org, repoName, repoPath, ref string) error {
|
||||
gotOrg = org
|
||||
gotRepo = repoName
|
||||
gotPath = repoPath
|
||||
gotRef = ref
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPkgInstall("myorg/core-api@v1.2.3", targetDir, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "myorg", gotOrg)
|
||||
assert.Equal(t, "core-api", gotRepo)
|
||||
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
|
||||
assert.Equal(t, "v1.2.3", gotRef)
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// addPkgListCommand adds the 'pkg list' command.
|
||||
func addPkgListCommand(parent *cobra.Command) {
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("cmd.pkg.list.short"),
|
||||
Long: i18n.T("cmd.pkg.list.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPkgList()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func runPkgList() error {
|
||||
registryPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml_workspace"))
|
||||
}
|
||||
|
||||
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
||||
}
|
||||
|
||||
basePath := registry.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "."
|
||||
}
|
||||
if !core.PathIsAbs(basePath) {
|
||||
basePath = core.Path(core.PathDir(registryPath), basePath)
|
||||
}
|
||||
|
||||
allRepos := registry.List()
|
||||
if len(allRepos) == 0 {
|
||||
cli.Println("%s", i18n.T("cmd.pkg.list.no_packages"))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Println("%s\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title")))
|
||||
|
||||
var installed, missing int
|
||||
for _, repo := range allRepos {
|
||||
repoPath := core.Path(basePath, repo.Name)
|
||||
exists := coreio.Local.Exists(core.Path(repoPath, ".git"))
|
||||
if exists {
|
||||
installed++
|
||||
} else {
|
||||
missing++
|
||||
}
|
||||
|
||||
status := successStyle.Render("✓")
|
||||
if !exists {
|
||||
status = dimStyle.Render("○")
|
||||
}
|
||||
|
||||
description := repo.Description
|
||||
if len(description) > 40 {
|
||||
description = description[:37] + "..."
|
||||
}
|
||||
if description == "" {
|
||||
description = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
|
||||
}
|
||||
|
||||
cli.Println(" %s %s", status, repoNameStyle.Render(repo.Name))
|
||||
cli.Println(" %s", description)
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Println("%s %s", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing}))
|
||||
|
||||
if missing > 0 {
|
||||
cli.Println("\n%s %s", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var updateAll bool
|
||||
|
||||
// addPkgUpdateCommand adds the 'pkg update' command.
|
||||
func addPkgUpdateCommand(parent *cobra.Command) {
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update [packages...]",
|
||||
Short: i18n.T("cmd.pkg.update.short"),
|
||||
Long: i18n.T("cmd.pkg.update.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !updateAll && len(args) == 0 {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.specify_package"))
|
||||
}
|
||||
return runPkgUpdate(args, updateAll)
|
||||
},
|
||||
}
|
||||
|
||||
updateCmd.Flags().BoolVar(&updateAll, "all", false, i18n.T("cmd.pkg.update.flag.all"))
|
||||
|
||||
parent.AddCommand(updateCmd)
|
||||
}
|
||||
|
||||
func runPkgUpdate(packages []string, all bool) error {
|
||||
registryPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
|
||||
}
|
||||
|
||||
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
||||
}
|
||||
|
||||
basePath := registry.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "."
|
||||
}
|
||||
if !core.PathIsAbs(basePath) {
|
||||
basePath = core.Path(core.PathDir(registryPath), basePath)
|
||||
}
|
||||
|
||||
var toUpdate []string
|
||||
if all {
|
||||
for _, repo := range registry.List() {
|
||||
toUpdate = append(toUpdate, repo.Name)
|
||||
}
|
||||
} else {
|
||||
toUpdate = packages
|
||||
}
|
||||
|
||||
cli.Println("%s %s\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)}))
|
||||
|
||||
var updated, skipped, failed int
|
||||
for _, name := range toUpdate {
|
||||
repoPath := core.Path(basePath, name)
|
||||
|
||||
if _, err := coreio.Local.List(core.Path(repoPath, ".git")); err != nil {
|
||||
cli.Println(" %s %s (%s)", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
cli.Print(" %s %s... ", dimStyle.Render("↓"), name)
|
||||
|
||||
proc := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
|
||||
output, err := proc.CombinedOutput()
|
||||
if err != nil {
|
||||
cli.Println("%s", errorStyle.Render("✗"))
|
||||
cli.Println(" %s", core.Trim(string(output)))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if core.Contains(string(output), "Already up to date") {
|
||||
cli.Println("%s", dimStyle.Render(i18n.T("common.status.up_to_date")))
|
||||
} else {
|
||||
cli.Println("%s", successStyle.Render("✓"))
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Println("%s %s",
|
||||
dimStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.update.summary", map[string]int{"Updated": updated, "Skipped": skipped, "Failed": failed}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPkgOutdatedCommand adds the 'pkg outdated' command.
|
||||
func addPkgOutdatedCommand(parent *cobra.Command) {
|
||||
outdatedCmd := &cobra.Command{
|
||||
Use: "outdated",
|
||||
Short: i18n.T("cmd.pkg.outdated.short"),
|
||||
Long: i18n.T("cmd.pkg.outdated.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPkgOutdated()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(outdatedCmd)
|
||||
}
|
||||
|
||||
func runPkgOutdated() error {
|
||||
registryPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
|
||||
}
|
||||
|
||||
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
||||
}
|
||||
|
||||
basePath := registry.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "."
|
||||
}
|
||||
if !core.PathIsAbs(basePath) {
|
||||
basePath = core.Path(core.PathDir(registryPath), basePath)
|
||||
}
|
||||
|
||||
cli.Println("%s %s\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates"))
|
||||
|
||||
var outdated, upToDate, notInstalled int
|
||||
|
||||
for _, repo := range registry.List() {
|
||||
repoPath := core.Path(basePath, repo.Name)
|
||||
|
||||
if !coreio.Local.Exists(core.Path(repoPath, ".git")) {
|
||||
notInstalled++
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch updates silently.
|
||||
_ = exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run()
|
||||
|
||||
// Check commit count behind upstream.
|
||||
proc := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}")
|
||||
output, err := proc.Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
commitCount := core.Trim(string(output))
|
||||
if commitCount != "0" {
|
||||
cli.Println(" %s %s (%s)",
|
||||
errorStyle.Render("↓"), repoNameStyle.Render(repo.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": commitCount}))
|
||||
outdated++
|
||||
} else {
|
||||
upToDate++
|
||||
}
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
if outdated == 0 {
|
||||
cli.Println("%s %s", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date"))
|
||||
} else {
|
||||
cli.Println("%s %s",
|
||||
dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.pkg.outdated.summary", map[string]int{"Outdated": outdated, "UpToDate": upToDate}))
|
||||
cli.Println("\n%s %s", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,350 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-cache"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func capturePkgOutput(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
os.Stdout = w
|
||||
|
||||
defer func() {
|
||||
os.Stdout = oldStdout
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, r)
|
||||
require.NoError(t, err)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func withWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
oldwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(dir))
|
||||
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Chdir(oldwd))
|
||||
})
|
||||
}
|
||||
|
||||
func writeTestRegistry(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
registry := strings.TrimSpace(`
|
||||
org: host-uk
|
||||
base_path: .
|
||||
repos:
|
||||
core-alpha:
|
||||
type: foundation
|
||||
description: Alpha package
|
||||
core-beta:
|
||||
type: module
|
||||
description: Beta package
|
||||
`) + "\n"
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "repos.yaml"), []byte(registry), 0644))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "core-alpha", ".git"), 0755))
|
||||
}
|
||||
|
||||
func gitCommand(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "git %v failed: %s", args, string(out))
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func commitGitRepo(t *testing.T, dir, filename, content, message string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644))
|
||||
gitCommand(t, dir, "add", filename)
|
||||
gitCommand(t, dir, "commit", "-m", message)
|
||||
}
|
||||
|
||||
func setupOutdatedRegistry(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
tmp := t.TempDir()
|
||||
|
||||
remoteDir := filepath.Join(tmp, "remote.git")
|
||||
gitCommand(t, tmp, "init", "--bare", remoteDir)
|
||||
|
||||
seedDir := filepath.Join(tmp, "seed")
|
||||
require.NoError(t, os.MkdirAll(seedDir, 0755))
|
||||
gitCommand(t, seedDir, "init")
|
||||
gitCommand(t, seedDir, "config", "user.email", "test@test.com")
|
||||
gitCommand(t, seedDir, "config", "user.name", "Test")
|
||||
commitGitRepo(t, seedDir, "repo.txt", "v1\n", "initial")
|
||||
gitCommand(t, seedDir, "remote", "add", "origin", remoteDir)
|
||||
gitCommand(t, seedDir, "push", "-u", "origin", "master")
|
||||
|
||||
freshDir := filepath.Join(tmp, "core-fresh")
|
||||
gitCommand(t, tmp, "clone", remoteDir, freshDir)
|
||||
|
||||
staleDir := filepath.Join(tmp, "core-stale")
|
||||
gitCommand(t, tmp, "clone", remoteDir, staleDir)
|
||||
|
||||
commitGitRepo(t, seedDir, "repo.txt", "v2\n", "second")
|
||||
gitCommand(t, seedDir, "push")
|
||||
gitCommand(t, freshDir, "pull", "--ff-only")
|
||||
|
||||
registry := strings.TrimSpace(`
|
||||
org: host-uk
|
||||
base_path: .
|
||||
repos:
|
||||
core-fresh:
|
||||
type: foundation
|
||||
description: Fresh package
|
||||
core-stale:
|
||||
type: module
|
||||
description: Stale package
|
||||
core-missing:
|
||||
type: module
|
||||
description: Missing package
|
||||
`) + "\n"
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
|
||||
return tmp
|
||||
}
|
||||
|
||||
func TestRunPkgList_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeTestRegistry(t, tmp)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgList("table")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "core-alpha")
|
||||
assert.Contains(t, out, "core-beta")
|
||||
assert.Contains(t, out, "core setup")
|
||||
}
|
||||
|
||||
func TestRunPkgList_JSON(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeTestRegistry(t, tmp)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgList("json")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
var report pkgListReport
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
|
||||
assert.Equal(t, "json", report.Format)
|
||||
assert.Equal(t, 2, report.Total)
|
||||
assert.Equal(t, 1, report.Installed)
|
||||
assert.Equal(t, 1, report.Missing)
|
||||
require.Len(t, report.Packages, 2)
|
||||
assert.Equal(t, "core-alpha", report.Packages[0].Name)
|
||||
assert.True(t, report.Packages[0].Installed)
|
||||
assert.Equal(t, filepath.Join(tmp, "core-alpha"), report.Packages[0].Path)
|
||||
assert.Equal(t, "core-beta", report.Packages[1].Name)
|
||||
assert.False(t, report.Packages[1].Installed)
|
||||
}
|
||||
|
||||
func TestRunPkgList_UnsupportedFormat(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeTestRegistry(t, tmp)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
err := runPkgList("yaml")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported format")
|
||||
}
|
||||
|
||||
func TestRunPkgOutdated_JSON(t *testing.T) {
|
||||
tmp := setupOutdatedRegistry(t)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgOutdated("json")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
var report pkgOutdatedReport
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
|
||||
assert.Equal(t, "json", report.Format)
|
||||
assert.Equal(t, 3, report.Total)
|
||||
assert.Equal(t, 2, report.Installed)
|
||||
assert.Equal(t, 1, report.Missing)
|
||||
assert.Equal(t, 1, report.Outdated)
|
||||
assert.Equal(t, 1, report.UpToDate)
|
||||
require.Len(t, report.Packages, 3)
|
||||
|
||||
var staleFound, freshFound, missingFound bool
|
||||
for _, pkg := range report.Packages {
|
||||
switch pkg.Name {
|
||||
case "core-stale":
|
||||
staleFound = true
|
||||
assert.True(t, pkg.Installed)
|
||||
assert.False(t, pkg.UpToDate)
|
||||
assert.Equal(t, 1, pkg.Behind)
|
||||
case "core-fresh":
|
||||
freshFound = true
|
||||
assert.True(t, pkg.Installed)
|
||||
assert.True(t, pkg.UpToDate)
|
||||
assert.Equal(t, 0, pkg.Behind)
|
||||
case "core-missing":
|
||||
missingFound = true
|
||||
assert.False(t, pkg.Installed)
|
||||
assert.False(t, pkg.UpToDate)
|
||||
assert.Equal(t, 0, pkg.Behind)
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, staleFound)
|
||||
assert.True(t, freshFound)
|
||||
assert.True(t, missingFound)
|
||||
}
|
||||
|
||||
func TestRenderPkgSearchResults_ShowsMetadata(t *testing.T) {
|
||||
out := capturePkgOutput(t, func() {
|
||||
renderPkgSearchResults([]ghRepo{
|
||||
{
|
||||
FullName: "host-uk/core-alpha",
|
||||
Name: "core-alpha",
|
||||
Description: "Alpha package",
|
||||
Visibility: "private",
|
||||
StargazerCount: 42,
|
||||
PrimaryLanguage: ghLanguage{
|
||||
Name: "Go",
|
||||
},
|
||||
UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "host-uk/core-alpha")
|
||||
assert.Contains(t, out, "Alpha package")
|
||||
assert.Contains(t, out, "42 stars")
|
||||
assert.Contains(t, out, "Go")
|
||||
assert.Contains(t, out, "updated 2h ago")
|
||||
}
|
||||
|
||||
func TestRunPkgSearch_RespectsLimitWithCachedResults(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
writeTestRegistry(t, tmp)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
c, err := cache.New(nil, filepath.Join(tmp, ".core", "cache"), 0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.Set(cache.GitHubReposKey("host-uk"), []ghRepo{
|
||||
{
|
||||
FullName: "host-uk/core-alpha",
|
||||
Name: "core-alpha",
|
||||
Description: "Alpha package",
|
||||
Visibility: "public",
|
||||
UpdatedAt: time.Now().Add(-time.Hour).Format(time.RFC3339),
|
||||
StargazerCount: 1,
|
||||
PrimaryLanguage: ghLanguage{
|
||||
Name: "Go",
|
||||
},
|
||||
},
|
||||
{
|
||||
FullName: "host-uk/core-beta",
|
||||
Name: "core-beta",
|
||||
Description: "Beta package",
|
||||
Visibility: "public",
|
||||
UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
|
||||
StargazerCount: 2,
|
||||
PrimaryLanguage: ghLanguage{
|
||||
Name: "Go",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgSearch("host-uk", "*", "", 1, false, "table")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "core-alpha")
|
||||
assert.NotContains(t, out, "core-beta")
|
||||
}
|
||||
|
||||
func TestRunPkgUpdate_NoArgs_UpdatesAll(t *testing.T) {
|
||||
tmp := setupOutdatedRegistry(t)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgUpdate(nil, false, "table")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.Contains(t, out, "updating")
|
||||
assert.Contains(t, out, "core-fresh")
|
||||
assert.Contains(t, out, "core-stale")
|
||||
}
|
||||
|
||||
func TestRunPkgUpdate_JSON(t *testing.T) {
|
||||
tmp := setupOutdatedRegistry(t)
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
out := capturePkgOutput(t, func() {
|
||||
err := runPkgUpdate(nil, false, "json")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
var report pkgUpdateReport
|
||||
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
|
||||
assert.Equal(t, "json", report.Format)
|
||||
assert.Equal(t, 3, report.Total)
|
||||
assert.Equal(t, 2, report.Installed)
|
||||
assert.Equal(t, 1, report.Missing)
|
||||
assert.Equal(t, 1, report.Updated)
|
||||
assert.Equal(t, 1, report.UpToDate)
|
||||
assert.Equal(t, 0, report.Failed)
|
||||
require.Len(t, report.Packages, 3)
|
||||
|
||||
var updatedFound, upToDateFound, missingFound bool
|
||||
for _, pkg := range report.Packages {
|
||||
switch pkg.Name {
|
||||
case "core-stale":
|
||||
updatedFound = true
|
||||
assert.True(t, pkg.Installed)
|
||||
assert.Equal(t, "updated", pkg.Status)
|
||||
case "core-fresh":
|
||||
upToDateFound = true
|
||||
assert.True(t, pkg.Installed)
|
||||
assert.Equal(t, "up_to_date", pkg.Status)
|
||||
case "core-missing":
|
||||
missingFound = true
|
||||
assert.False(t, pkg.Installed)
|
||||
assert.Equal(t, "missing", pkg.Status)
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, updatedFound)
|
||||
assert.True(t, upToDateFound)
|
||||
assert.True(t, missingFound)
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
// cmd_remove.go implements the 'pkg remove' command with safety checks.
|
||||
//
|
||||
// Before removing a package, it verifies:
|
||||
// 1. No uncommitted changes exist
|
||||
// 2. No unpushed branches exist
|
||||
// This prevents accidental data loss from agents or tools that might
|
||||
// attempt to remove packages without cleaning up first.
|
||||
package pkgcmd
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var removeForce bool
|
||||
|
||||
func addPkgRemoveCommand(parent *cobra.Command) {
|
||||
removeCmd := &cobra.Command{
|
||||
Use: "remove <package>",
|
||||
Short: "Remove a package (with safety checks)",
|
||||
Long: `Removes a package directory after verifying it has no uncommitted
|
||||
changes or unpushed branches. Use --force to skip safety checks.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.repo_required"))
|
||||
}
|
||||
return runPkgRemove(args[0], removeForce)
|
||||
},
|
||||
}
|
||||
|
||||
removeCmd.Flags().BoolVar(&removeForce, "force", false, "Skip safety checks (dangerous)")
|
||||
|
||||
parent.AddCommand(removeCmd)
|
||||
}
|
||||
|
||||
func runPkgRemove(name string, force bool) error {
|
||||
// Find package path via registry.
|
||||
registryPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
|
||||
}
|
||||
|
||||
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
|
||||
}
|
||||
|
||||
basePath := registry.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "."
|
||||
}
|
||||
if !core.PathIsAbs(basePath) {
|
||||
basePath = core.Path(core.PathDir(registryPath), basePath)
|
||||
}
|
||||
|
||||
repoPath := core.Path(basePath, name)
|
||||
|
||||
if !coreio.Local.IsDir(core.Path(repoPath, ".git")) {
|
||||
return cli.Err("package %s is not installed at %s", name, repoPath)
|
||||
}
|
||||
|
||||
if !force {
|
||||
blocked, reasons := checkRepoSafety(repoPath)
|
||||
if blocked {
|
||||
cli.Println("%s Cannot remove %s:", errorStyle.Render("Blocked:"), repoNameStyle.Render(name))
|
||||
for _, reason := range reasons {
|
||||
cli.Println(" %s %s", errorStyle.Render("·"), reason)
|
||||
}
|
||||
cli.Println("\nResolve the issues above or use --force to override.")
|
||||
return cli.Err("package has unresolved changes")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the directory.
|
||||
cli.Print("%s %s... ", dimStyle.Render("Removing"), repoNameStyle.Render(name))
|
||||
|
||||
if err := coreio.Local.DeleteAll(repoPath); err != nil {
|
||||
cli.Println("%s", errorStyle.Render("x "+err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Println("%s", successStyle.Render("ok"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRepoSafety checks a git repo for uncommitted changes and unpushed branches.
|
||||
//
|
||||
// blocked, reasons := checkRepoSafety("/path/to/repo")
|
||||
// if blocked { fmt.Println(reasons) }
|
||||
func checkRepoSafety(repoPath string) (blocked bool, reasons []string) {
|
||||
// Check for uncommitted changes (staged, unstaged, untracked).
|
||||
proc := exec.Command("git", "-C", repoPath, "status", "--porcelain")
|
||||
output, err := proc.Output()
|
||||
if err == nil && core.Trim(string(output)) != "" {
|
||||
lines := core.Split(core.Trim(string(output)), "\n")
|
||||
blocked = true
|
||||
reasons = append(reasons, cli.Sprintf("has %d uncommitted changes", len(lines)))
|
||||
}
|
||||
|
||||
// Check for unpushed commits on current branch.
|
||||
proc = exec.Command("git", "-C", repoPath, "log", "--oneline", "@{u}..HEAD")
|
||||
output, err = proc.Output()
|
||||
if err == nil && core.Trim(string(output)) != "" {
|
||||
lines := core.Split(core.Trim(string(output)), "\n")
|
||||
blocked = true
|
||||
reasons = append(reasons, cli.Sprintf("has %d unpushed commits on current branch", len(lines)))
|
||||
}
|
||||
|
||||
// Check all local branches for unpushed work.
|
||||
proc = exec.Command("git", "-C", repoPath, "branch", "--no-merged", "origin/HEAD")
|
||||
output, _ = proc.Output()
|
||||
if trimmedOutput := core.Trim(string(output)); trimmedOutput != "" {
|
||||
branches := core.Split(trimmedOutput, "\n")
|
||||
var unmerged []string
|
||||
for _, branchName := range branches {
|
||||
branchName = core.Trim(branchName)
|
||||
branchName = core.TrimPrefix(branchName, "* ")
|
||||
if branchName != "" {
|
||||
unmerged = append(unmerged, branchName)
|
||||
}
|
||||
}
|
||||
if len(unmerged) > 0 {
|
||||
blocked = true
|
||||
reasons = append(reasons, cli.Sprintf("has %d unmerged branches: %s",
|
||||
len(unmerged), core.Join(", ", unmerged...)))
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stashed changes.
|
||||
proc = exec.Command("git", "-C", repoPath, "stash", "list")
|
||||
output, err = proc.Output()
|
||||
if err == nil && core.Trim(string(output)) != "" {
|
||||
lines := core.Split(core.Trim(string(output)), "\n")
|
||||
blocked = true
|
||||
reasons = append(reasons, cli.Sprintf("has %d stashed entries", len(lines)))
|
||||
}
|
||||
|
||||
return blocked, reasons
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestRepo(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
|
||||
repoPath := filepath.Join(dir, name)
|
||||
require.NoError(t, os.MkdirAll(repoPath, 0755))
|
||||
|
||||
gitCommand(t, repoPath, "init")
|
||||
gitCommand(t, repoPath, "config", "user.email", "test@test.com")
|
||||
gitCommand(t, repoPath, "config", "user.name", "Test")
|
||||
gitCommand(t, repoPath, "commit", "--allow-empty", "-m", "initial")
|
||||
|
||||
return repoPath
|
||||
}
|
||||
|
||||
func capturePkgStreams(t *testing.T, fn func()) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
oldStderr := os.Stderr
|
||||
|
||||
rOut, wOut, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
rErr, wErr, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
|
||||
os.Stdout = wOut
|
||||
os.Stderr = wErr
|
||||
|
||||
defer func() {
|
||||
os.Stdout = oldStdout
|
||||
os.Stderr = oldStderr
|
||||
}()
|
||||
|
||||
fn()
|
||||
|
||||
require.NoError(t, wOut.Close())
|
||||
require.NoError(t, wErr.Close())
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
_, err = io.Copy(&stdout, rOut)
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(&stderr, rErr)
|
||||
require.NoError(t, err)
|
||||
|
||||
return stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
func TestCheckRepoSafety_Clean(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
repoPath := setupTestRepo(t, tmp, "clean-repo")
|
||||
|
||||
blocked, reasons := checkRepoSafety(repoPath)
|
||||
assert.False(t, blocked)
|
||||
assert.Empty(t, reasons)
|
||||
}
|
||||
|
||||
func TestCheckRepoSafety_UncommittedChanges(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
repoPath := setupTestRepo(t, tmp, "dirty-repo")
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "new.txt"), []byte("data"), 0644))
|
||||
|
||||
blocked, reasons := checkRepoSafety(repoPath)
|
||||
assert.True(t, blocked)
|
||||
assert.NotEmpty(t, reasons)
|
||||
assert.Contains(t, reasons[0], "uncommitted changes")
|
||||
}
|
||||
|
||||
func TestCheckRepoSafety_Stash(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
repoPath := setupTestRepo(t, tmp, "stash-repo")
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "stash.txt"), []byte("data"), 0644))
|
||||
gitCommand(t, repoPath, "add", ".")
|
||||
gitCommand(t, repoPath, "stash")
|
||||
|
||||
blocked, reasons := checkRepoSafety(repoPath)
|
||||
assert.True(t, blocked)
|
||||
|
||||
found := false
|
||||
for _, r := range reasons {
|
||||
if strings.Contains(r, "stash") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected stash warning in reasons: %v", reasons)
|
||||
}
|
||||
|
||||
func TestRunPkgRemove_RemovesRegistryEntry_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
repoPath := setupTestRepo(t, tmp, "core-alpha")
|
||||
|
||||
registry := strings.TrimSpace(`
|
||||
version: 1
|
||||
org: host-uk
|
||||
base_path: .
|
||||
repos:
|
||||
core-alpha:
|
||||
type: foundation
|
||||
description: Alpha package
|
||||
core-beta:
|
||||
type: module
|
||||
description: Beta package
|
||||
`) + "\n"
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
|
||||
|
||||
oldwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chdir(tmp))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Chdir(oldwd))
|
||||
})
|
||||
|
||||
require.NoError(t, runPkgRemove("core-alpha", false))
|
||||
|
||||
_, err = os.Stat(repoPath)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
updated, err := os.ReadFile(filepath.Join(tmp, "repos.yaml"))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(updated), "core-alpha")
|
||||
assert.Contains(t, string(updated), "core-beta")
|
||||
}
|
||||
|
||||
func TestRunPkgRemove_Bad_BlockedWarningsGoToStderr(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
registry := strings.TrimSpace(`
|
||||
org: host-uk
|
||||
base_path: .
|
||||
repos:
|
||||
core-alpha:
|
||||
type: foundation
|
||||
description: Alpha package
|
||||
`) + "\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
|
||||
|
||||
repoPath := filepath.Join(tmp, "core-alpha")
|
||||
require.NoError(t, os.MkdirAll(repoPath, 0755))
|
||||
gitCommand(t, repoPath, "init")
|
||||
gitCommand(t, repoPath, "config", "user.email", "test@test.com")
|
||||
gitCommand(t, repoPath, "config", "user.name", "Test")
|
||||
commitGitRepo(t, repoPath, "file.txt", "v1\n", "initial")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "file.txt"), []byte("v2\n"), 0644))
|
||||
|
||||
withWorkingDir(t, tmp)
|
||||
|
||||
stdout, stderr := capturePkgStreams(t, func() {
|
||||
err := runPkgRemove("core-alpha", false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unresolved changes")
|
||||
})
|
||||
|
||||
assert.Empty(t, stdout)
|
||||
assert.Contains(t, stderr, "Cannot remove core-alpha")
|
||||
assert.Contains(t, stderr, "uncommitted changes")
|
||||
assert.Contains(t, stderr, "Resolve the issues above or use --force to override.")
|
||||
}
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-cache"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
searchOrg string
|
||||
searchPattern string
|
||||
searchType string
|
||||
searchLimit int
|
||||
searchRefresh bool
|
||||
)
|
||||
|
||||
// addPkgSearchCommand adds the 'pkg search' command.
|
||||
func addPkgSearchCommand(parent *cobra.Command) {
|
||||
searchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: i18n.T("cmd.pkg.search.short"),
|
||||
Long: i18n.T("cmd.pkg.search.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
org := searchOrg
|
||||
pattern := searchPattern
|
||||
limit := searchLimit
|
||||
if org == "" {
|
||||
org = "host-uk"
|
||||
}
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
return runPkgSearch(org, pattern, searchType, limit, searchRefresh)
|
||||
},
|
||||
}
|
||||
|
||||
searchCmd.Flags().StringVar(&searchOrg, "org", "", i18n.T("cmd.pkg.search.flag.org"))
|
||||
searchCmd.Flags().StringVar(&searchPattern, "pattern", "", i18n.T("cmd.pkg.search.flag.pattern"))
|
||||
searchCmd.Flags().StringVar(&searchType, "type", "", i18n.T("cmd.pkg.search.flag.type"))
|
||||
searchCmd.Flags().IntVar(&searchLimit, "limit", 0, i18n.T("cmd.pkg.search.flag.limit"))
|
||||
searchCmd.Flags().BoolVar(&searchRefresh, "refresh", false, i18n.T("cmd.pkg.search.flag.refresh"))
|
||||
|
||||
parent.AddCommand(searchCmd)
|
||||
}
|
||||
|
||||
type ghRepo struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Visibility string `json:"visibility"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error {
|
||||
// Initialise cache in workspace .core/ directory.
|
||||
var cacheDirectory string
|
||||
if registryPath, err := repos.FindRegistry(coreio.Local); err == nil {
|
||||
cacheDirectory = core.Path(core.PathDir(registryPath), ".core", "cache")
|
||||
}
|
||||
|
||||
cacheInstance, err := cache.New(coreio.Local, cacheDirectory, 0)
|
||||
if err != nil {
|
||||
cacheInstance = nil
|
||||
}
|
||||
|
||||
cacheKey := cache.GitHubReposKey(org)
|
||||
var ghRepos []ghRepo
|
||||
var fromCache bool
|
||||
|
||||
// Try cache first (unless refresh requested).
|
||||
if cacheInstance != nil && !refresh {
|
||||
if found, err := cacheInstance.Get(cacheKey, &ghRepos); found && err == nil {
|
||||
fromCache = true
|
||||
age := cacheInstance.Age(cacheKey)
|
||||
cli.Println("%s %s %s", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(cli.Sprintf("(%s ago)", age.Round(time.Second))))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from GitHub if not cached.
|
||||
if !fromCache {
|
||||
if !ghAuthenticated() {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.gh_not_authenticated"))
|
||||
}
|
||||
|
||||
if core.Env("GH_TOKEN") != "" {
|
||||
cli.Println("%s %s", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning"))
|
||||
cli.Println("%s %s\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset"))
|
||||
}
|
||||
|
||||
cli.Print("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org)
|
||||
|
||||
proc := exec.Command("gh", "repo", "list", org,
|
||||
"--json", "name,description,visibility,updatedAt,primaryLanguage",
|
||||
"--limit", cli.Sprintf("%d", limit))
|
||||
output, err := proc.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
cli.Blank()
|
||||
errorOutput := core.Trim(string(output))
|
||||
if core.Contains(errorOutput, "401") || core.Contains(errorOutput, "Bad credentials") {
|
||||
return cli.Err(i18n.T("cmd.pkg.error.auth_failed"))
|
||||
}
|
||||
return cli.Err("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errorOutput)
|
||||
}
|
||||
|
||||
result := core.JSONUnmarshal(output, &ghRepos)
|
||||
if !result.OK {
|
||||
return cli.Wrap(result.Value.(error), i18n.T("i18n.fail.parse", "results"))
|
||||
}
|
||||
|
||||
if cacheInstance != nil {
|
||||
_ = cacheInstance.Set(cacheKey, ghRepos)
|
||||
}
|
||||
|
||||
cli.Println("%s", successStyle.Render("✓"))
|
||||
}
|
||||
|
||||
// Filter by glob pattern and type.
|
||||
var filtered []ghRepo
|
||||
for _, repo := range ghRepos {
|
||||
if !matchGlob(pattern, repo.Name) {
|
||||
continue
|
||||
}
|
||||
if repoType != "" && !core.Contains(repo.Name, repoType) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, repo)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
cli.Println("%s", i18n.T("cmd.pkg.search.no_repos_found"))
|
||||
return nil
|
||||
}
|
||||
|
||||
slices.SortFunc(filtered, func(a, b ghRepo) int {
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
cli.Print(i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)}) + "\n\n")
|
||||
|
||||
for _, repo := range filtered {
|
||||
visibility := ""
|
||||
if repo.Visibility == "private" {
|
||||
visibility = dimStyle.Render(" " + i18n.T("cmd.pkg.search.private_label"))
|
||||
}
|
||||
|
||||
description := repo.Description
|
||||
if len(description) > 50 {
|
||||
description = description[:47] + "..."
|
||||
}
|
||||
if description == "" {
|
||||
description = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
|
||||
}
|
||||
|
||||
cli.Println(" %s%s", repoNameStyle.Render(repo.Name), visibility)
|
||||
cli.Println(" %s", description)
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Println("%s %s", i18n.T("common.hint.install_with"), dimStyle.Render(cli.Sprintf("core pkg install %s/<repo-name>", org)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchGlob does simple glob matching with * wildcards.
|
||||
//
|
||||
// matchGlob("core-*", "core-php") // true
|
||||
// matchGlob("*-mod", "core-php") // false
|
||||
func matchGlob(pattern, name string) bool {
|
||||
if pattern == "*" || pattern == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
parts := core.Split(pattern, "*")
|
||||
pos := 0
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
// Find part in name starting from pos.
|
||||
remaining := name[pos:]
|
||||
idx := -1
|
||||
for j := 0; j <= len(remaining)-len(part); j++ {
|
||||
if remaining[j:j+len(part)] == part {
|
||||
idx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
return false
|
||||
}
|
||||
if i == 0 && !core.HasPrefix(pattern, "*") && idx != 0 {
|
||||
return false
|
||||
}
|
||||
pos += idx + len(part)
|
||||
}
|
||||
if !core.HasSuffix(pattern, "*") && pos != len(name) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolvePkgSearchPattern_Good(t *testing.T) {
|
||||
t.Run("uses flag pattern when set", func(t *testing.T) {
|
||||
got := resolvePkgSearchPattern("core-*", []string{"api"})
|
||||
assert.Equal(t, "core-*", got)
|
||||
})
|
||||
|
||||
t.Run("uses positional pattern when flag is empty", func(t *testing.T) {
|
||||
got := resolvePkgSearchPattern("", []string{"api"})
|
||||
assert.Equal(t, "api", got)
|
||||
})
|
||||
|
||||
t.Run("defaults to wildcard when nothing is provided", func(t *testing.T) {
|
||||
got := resolvePkgSearchPattern("", nil)
|
||||
assert.Equal(t, "*", got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPkgSearchReport_Good(t *testing.T) {
|
||||
repos := []ghRepo{
|
||||
{
|
||||
FullName: "host-uk/core-api",
|
||||
Name: "core-api",
|
||||
Description: "REST API framework",
|
||||
Visibility: "public",
|
||||
UpdatedAt: "2026-03-30T12:00:00Z",
|
||||
StargazerCount: 42,
|
||||
PrimaryLanguage: ghLanguage{
|
||||
Name: "Go",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
report := buildPkgSearchReport("host-uk", "core-*", "api", 50, true, repos)
|
||||
|
||||
assert.Equal(t, "json", report.Format)
|
||||
assert.Equal(t, "host-uk", report.Org)
|
||||
assert.Equal(t, "core-*", report.Pattern)
|
||||
assert.Equal(t, "api", report.Type)
|
||||
assert.Equal(t, 50, report.Limit)
|
||||
assert.True(t, report.Cached)
|
||||
assert.Equal(t, 1, report.Count)
|
||||
requireRepo := report.Repos
|
||||
if assert.Len(t, requireRepo, 1) {
|
||||
assert.Equal(t, "core-api", requireRepo[0].Name)
|
||||
assert.Equal(t, "host-uk/core-api", requireRepo[0].FullName)
|
||||
assert.Equal(t, "REST API framework", requireRepo[0].Description)
|
||||
assert.Equal(t, "public", requireRepo[0].Visibility)
|
||||
assert.Equal(t, 42, requireRepo[0].StargazerCount)
|
||||
assert.Equal(t, "Go", requireRepo[0].PrimaryLanguage)
|
||||
assert.Equal(t, "2026-03-30T12:00:00Z", requireRepo[0].UpdatedAt)
|
||||
assert.NotEmpty(t, requireRepo[0].Updated)
|
||||
}
|
||||
|
||||
out, err := json.Marshal(report)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"format":"json"`)
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ package doctor
|
|||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
|
|
@ -26,13 +26,6 @@ func requiredChecks() []check {
|
|||
args: []string{"--version"},
|
||||
versionFlag: "--version",
|
||||
},
|
||||
{
|
||||
name: i18n.T("cmd.doctor.check.go.name"),
|
||||
description: i18n.T("cmd.doctor.check.go.description"),
|
||||
command: "go",
|
||||
args: []string{"version"},
|
||||
versionFlag: "version",
|
||||
},
|
||||
{
|
||||
name: i18n.T("cmd.doctor.check.gh.name"),
|
||||
description: i18n.T("cmd.doctor.check.gh.description"),
|
||||
|
|
@ -91,20 +84,18 @@ func optionalChecks() []check {
|
|||
}
|
||||
}
|
||||
|
||||
// runCheck executes a tool check and returns success status and version info.
|
||||
//
|
||||
// ok, version := runCheck(check{command: "git", args: []string{"--version"}})
|
||||
func runCheck(toolCheck check) (bool, string) {
|
||||
proc := exec.Command(toolCheck.command, toolCheck.args...)
|
||||
output, err := proc.CombinedOutput()
|
||||
// runCheck executes a tool check and returns success status and version info
|
||||
func runCheck(c check) (bool, string) {
|
||||
cmd := exec.Command(c.command, c.args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Extract first line as version info.
|
||||
lines := core.Split(core.Trim(string(output)), "\n")
|
||||
// Extract first line as version
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) > 0 {
|
||||
return true, core.Trim(lines[0])
|
||||
return true, strings.TrimSpace(lines[0])
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
|
@ -10,16 +10,9 @@
|
|||
// Provides platform-specific installation instructions for missing tools.
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// AddDoctorCommands registers the 'doctor' command and all subcommands.
|
||||
//
|
||||
// doctor.AddDoctorCommands(rootCmd)
|
||||
func AddDoctorCommands(root *cobra.Command) {
|
||||
doctorCmd.Short = i18n.T("cmd.doctor.short")
|
||||
doctorCmd.Long = i18n.T("cmd.doctor.long")
|
||||
root.AddCommand(doctorCmd)
|
||||
}
|
||||
122
cmd/doctor/cmd_doctor.go
Normal file
122
cmd/doctor/cmd_doctor.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Package doctor provides environment check commands.
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Style aliases from shared
|
||||
var (
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
dimStyle = cli.DimStyle
|
||||
)
|
||||
|
||||
// Flag variable for doctor command
|
||||
var doctorVerbose bool
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: i18n.T("cmd.doctor.short"),
|
||||
Long: i18n.T("cmd.doctor.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDoctor(doctorVerbose)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
doctorCmd.Flags().BoolVar(&doctorVerbose, "verbose", false, i18n.T("cmd.doctor.verbose_flag"))
|
||||
}
|
||||
|
||||
func runDoctor(verbose bool) error {
|
||||
fmt.Println(i18n.T("common.progress.checking", map[string]any{"Item": "development environment"}))
|
||||
fmt.Println()
|
||||
|
||||
var passed, failed, optional int
|
||||
|
||||
// Check required tools
|
||||
fmt.Println(i18n.T("cmd.doctor.required"))
|
||||
for _, c := range requiredChecks() {
|
||||
ok, version := runCheck(c)
|
||||
if ok {
|
||||
if verbose {
|
||||
fmt.Println(formatCheckResult(true, c.name, version))
|
||||
} else {
|
||||
fmt.Println(formatCheckResult(true, c.name, ""))
|
||||
}
|
||||
passed++
|
||||
} else {
|
||||
fmt.Printf(" %s %s - %s\n", errorStyle.Render(cli.Glyph(":cross:")), c.name, c.description)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
// Check optional tools
|
||||
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.optional"))
|
||||
for _, c := range optionalChecks() {
|
||||
ok, version := runCheck(c)
|
||||
if ok {
|
||||
if verbose {
|
||||
fmt.Println(formatCheckResult(true, c.name, version))
|
||||
} else {
|
||||
fmt.Println(formatCheckResult(true, c.name, ""))
|
||||
}
|
||||
passed++
|
||||
} else {
|
||||
fmt.Printf(" %s %s - %s\n", dimStyle.Render(cli.Glyph(":skip:")), c.name, dimStyle.Render(c.description))
|
||||
optional++
|
||||
}
|
||||
}
|
||||
|
||||
// Check GitHub access
|
||||
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.github"))
|
||||
if checkGitHubSSH() {
|
||||
fmt.Println(formatCheckResult(true, i18n.T("cmd.doctor.ssh_found"), ""))
|
||||
} else {
|
||||
fmt.Printf(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.ssh_missing"))
|
||||
failed++
|
||||
}
|
||||
|
||||
if checkGitHubCLI() {
|
||||
fmt.Println(formatCheckResult(true, i18n.T("cmd.doctor.cli_auth"), ""))
|
||||
} else {
|
||||
fmt.Printf(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.cli_auth_missing"))
|
||||
failed++
|
||||
}
|
||||
|
||||
// Check workspace
|
||||
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.workspace"))
|
||||
checkWorkspace()
|
||||
|
||||
// Summary
|
||||
fmt.Println()
|
||||
if failed > 0 {
|
||||
cli.Error(i18n.T("cmd.doctor.issues", map[string]any{"Count": failed}))
|
||||
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.install_missing"))
|
||||
printInstallInstructions()
|
||||
return errors.New(i18n.T("cmd.doctor.issues_error", map[string]any{"Count": failed}))
|
||||
}
|
||||
|
||||
cli.Success(i18n.T("cmd.doctor.ready"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatCheckResult(ok bool, name, detail string) string {
|
||||
check := cli.Check(name)
|
||||
if ok {
|
||||
check.Pass()
|
||||
} else {
|
||||
check.Fail()
|
||||
}
|
||||
if detail != "" {
|
||||
check.Message(detail)
|
||||
} else {
|
||||
check.Message("")
|
||||
}
|
||||
return check.String()
|
||||
}
|
||||
79
cmd/doctor/cmd_environment.go
Normal file
79
cmd/doctor/cmd_environment.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
)
|
||||
|
||||
// checkGitHubSSH checks if SSH keys exist for GitHub access
|
||||
func checkGitHubSSH() bool {
|
||||
// Just check if SSH keys exist - don't try to authenticate
|
||||
// (key might be locked/passphrase protected)
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"}
|
||||
|
||||
for _, key := range keyPatterns {
|
||||
keyPath := filepath.Join(sshDir, key)
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// checkGitHubCLI checks if the GitHub CLI is authenticated
|
||||
func checkGitHubCLI() bool {
|
||||
cmd := exec.Command("gh", "auth", "status")
|
||||
output, _ := cmd.CombinedOutput()
|
||||
// Check for any successful login (even if there's also a failing token)
|
||||
return strings.Contains(string(output), "Logged in to")
|
||||
}
|
||||
|
||||
// checkWorkspace checks for repos.yaml and counts cloned repos
|
||||
func checkWorkspace() {
|
||||
registryPath, err := repos.FindRegistry(io.Local)
|
||||
if err == nil {
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_yaml_found", map[string]any{"Path": registryPath}))
|
||||
|
||||
reg, err := repos.LoadRegistry(io.Local, registryPath)
|
||||
if err == nil {
|
||||
basePath := reg.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "./packages"
|
||||
}
|
||||
if !filepath.IsAbs(basePath) {
|
||||
basePath = filepath.Join(filepath.Dir(registryPath), basePath)
|
||||
}
|
||||
if strings.HasPrefix(basePath, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
basePath = filepath.Join(home, basePath[2:])
|
||||
}
|
||||
|
||||
// Count existing repos
|
||||
allRepos := reg.List()
|
||||
var cloned int
|
||||
for _, repo := range allRepos {
|
||||
repoPath := filepath.Join(basePath, repo.Name)
|
||||
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
|
||||
cloned++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_cloned", map[string]any{"Cloned": cloned, "Total": len(allRepos)}))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" %s %s\n", dimStyle.Render("○"), i18n.T("cmd.doctor.no_repos_yaml"))
|
||||
}
|
||||
}
|
||||
26
cmd/doctor/cmd_install.go
Normal file
26
cmd/doctor/cmd_install.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package doctor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
// printInstallInstructions prints OS-specific installation instructions
|
||||
func printInstallInstructions() {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_macos"))
|
||||
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_macos_cask"))
|
||||
case "linux":
|
||||
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_header"))
|
||||
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_git"))
|
||||
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_gh"))
|
||||
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_php"))
|
||||
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_node"))
|
||||
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_pnpm"))
|
||||
default:
|
||||
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_other"))
|
||||
}
|
||||
}
|
||||
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-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-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(), "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-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-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(), "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(), "CGO_ENABLED=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)
|
||||
}
|
||||
635
cmd/gocmd/cmd_qa.go
Normal file
635
cmd/gocmd/cmd_qa.go
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/lint/cmd/qa"
|
||||
"forge.lthn.ai/core/go-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)
|
||||
|
||||
if qaJSON {
|
||||
return emitQAJSON(results, coverageVal, branchVal, failed, duration)
|
||||
}
|
||||
|
||||
return emitQASummary(passed, failed, duration)
|
||||
}
|
||||
|
||||
func emitQAJSON(results []CheckResult, coverageVal, branchVal *float64, failed int, duration time.Duration) error {
|
||||
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)
|
||||
}
|
||||
|
||||
func emitQASummary(passed, failed int, duration time.Duration) error {
|
||||
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-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
|
||||
}
|
||||
240
cmd/gocmd/coverage_test.go
Normal file
240
cmd/gocmd/coverage_test.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
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, err := os.CreateTemp("", "test-coverage-empty-*.out")
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove(tmpfileEmpty.Name())
|
||||
_, err = tmpfileEmpty.Write([]byte(contentEmpty))
|
||||
assert.NoError(t, err)
|
||||
err = tmpfileEmpty.Close()
|
||||
assert.NoError(t, err)
|
||||
|
||||
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, err := os.CreateTemp("", "test-coverage-malformed-*.out")
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove(tmpfileMalformed.Name())
|
||||
_, err = tmpfileMalformed.Write([]byte(contentMalformed))
|
||||
assert.NoError(t, err)
|
||||
err = tmpfileMalformed.Close()
|
||||
assert.NoError(t, err)
|
||||
|
||||
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, err := os.CreateTemp("", "test-coverage-malformed2-*.out")
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove(tmpfileMalformed2.Name())
|
||||
_, err = tmpfileMalformed2.Write([]byte(contentMalformed2))
|
||||
assert.NoError(t, err)
|
||||
err = tmpfileMalformed2.Close()
|
||||
assert.NoError(t, err)
|
||||
|
||||
pct, err = calculateBlockCoverage(tmpfileMalformed2.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
|
||||
// Test completely empty file
|
||||
tmpfileEmpty2, err := os.CreateTemp("", "test-coverage-empty2-*.out")
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove(tmpfileEmpty2.Name())
|
||||
err = tmpfileEmpty2.Close()
|
||||
assert.NoError(t, err)
|
||||
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")
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
package help
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-help"
|
||||
)
|
||||
|
||||
// AddHelpCommands registers the help command and subcommands.
|
||||
//
|
||||
// help.AddHelpCommands(rootCmd)
|
||||
func AddHelpCommands(root *cli.Command) {
|
||||
var searchFlag string
|
||||
|
||||
|
|
@ -20,28 +19,28 @@ func AddHelpCommands(root *cli.Command) {
|
|||
if searchFlag != "" {
|
||||
results := catalog.Search(searchFlag)
|
||||
if len(results) == 0 {
|
||||
cli.Println("No topics found.")
|
||||
fmt.Println("No topics found.")
|
||||
return
|
||||
}
|
||||
cli.Println("Search Results:")
|
||||
for _, result := range results {
|
||||
cli.Println(" %s - %s", result.Topic.ID, result.Topic.Title)
|
||||
fmt.Println("Search Results:")
|
||||
for _, res := range results {
|
||||
fmt.Printf(" %s - %s\n", res.Topic.ID, res.Topic.Title)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
topics := catalog.List()
|
||||
cli.Println("Available Help Topics:")
|
||||
for _, topic := range topics {
|
||||
cli.Println(" %s - %s", topic.ID, topic.Title)
|
||||
fmt.Println("Available Help Topics:")
|
||||
for _, t := range topics {
|
||||
fmt.Printf(" %s - %s\n", t.ID, t.Title)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
topic, err := catalog.Get(args[0])
|
||||
if err != nil {
|
||||
cli.Errorf("Error: %v", err)
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -53,9 +52,11 @@ func AddHelpCommands(root *cli.Command) {
|
|||
root.AddCommand(helpCmd)
|
||||
}
|
||||
|
||||
func renderTopic(topic *help.Topic) {
|
||||
cli.Println("\n%s", cli.TitleStyle.Render(topic.Title))
|
||||
cli.Println("----------------------------------------")
|
||||
cli.Println("%s", topic.Content)
|
||||
cli.Blank()
|
||||
func renderTopic(t *help.Topic) {
|
||||
// Simple ANSI rendering for now
|
||||
// Use explicit ANSI codes or just print
|
||||
fmt.Printf("\n\033[1;34m%s\033[0m\n", t.Title) // Blue bold title
|
||||
fmt.Println("----------------------------------------")
|
||||
fmt.Println(t.Content)
|
||||
fmt.Println()
|
||||
}
|
||||
55
cmd/module/cmd.go
Normal file
55
cmd/module/cmd.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// Package module provides CLI commands for managing marketplace modules.
|
||||
//
|
||||
// Commands:
|
||||
// - install: Install a module from a Git repo
|
||||
// - list: List installed modules
|
||||
// - update: Update a module or all modules
|
||||
// - remove: Remove an installed module
|
||||
package module
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-scm/marketplace"
|
||||
"forge.lthn.ai/core/go-io/store"
|
||||
)
|
||||
|
||||
// AddModuleCommands registers the 'module' command and all subcommands.
|
||||
func AddModuleCommands(root *cli.Command) {
|
||||
moduleCmd := &cli.Command{
|
||||
Use: "module",
|
||||
Short: i18n.T("Manage marketplace modules"),
|
||||
}
|
||||
root.AddCommand(moduleCmd)
|
||||
|
||||
addInstallCommand(moduleCmd)
|
||||
addListCommand(moduleCmd)
|
||||
addUpdateCommand(moduleCmd)
|
||||
addRemoveCommand(moduleCmd)
|
||||
}
|
||||
|
||||
// moduleSetup returns the modules directory, store, and installer.
|
||||
// The caller must defer st.Close().
|
||||
func moduleSetup() (string, *store.Store, *marketplace.Installer, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", nil, nil, cli.Wrap(err, "failed to determine home directory")
|
||||
}
|
||||
|
||||
modulesDir := filepath.Join(home, ".core", "modules")
|
||||
if err := os.MkdirAll(modulesDir, 0755); err != nil {
|
||||
return "", nil, nil, cli.Wrap(err, "failed to create modules directory")
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(modulesDir, "modules.db")
|
||||
st, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
return "", nil, nil, cli.Wrap(err, "failed to open module store")
|
||||
}
|
||||
|
||||
inst := marketplace.NewInstaller(modulesDir, st)
|
||||
return modulesDir, st, inst, nil
|
||||
}
|
||||
59
cmd/module/cmd_install.go
Normal file
59
cmd/module/cmd_install.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-scm/marketplace"
|
||||
)
|
||||
|
||||
var (
|
||||
installRepo string
|
||||
installSignKey string
|
||||
)
|
||||
|
||||
func addInstallCommand(parent *cli.Command) {
|
||||
installCmd := cli.NewCommand(
|
||||
"install <code>",
|
||||
i18n.T("Install a module from a Git repo"),
|
||||
i18n.T("Install a module by cloning its Git repository, verifying the manifest signature, and registering it.\n\nThe --repo flag is required and specifies the Git URL to clone from."),
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
if installRepo == "" {
|
||||
return errors.New("--repo flag is required")
|
||||
}
|
||||
return runInstall(args[0], installRepo, installSignKey)
|
||||
},
|
||||
)
|
||||
installCmd.Args = cli.ExactArgs(1)
|
||||
installCmd.Example = " core module install my-module --repo https://forge.lthn.ai/modules/my-module.git\n core module install signed-mod --repo ssh://git@forge.lthn.ai:2223/modules/signed.git --sign-key abc123"
|
||||
|
||||
cli.StringFlag(installCmd, &installRepo, "repo", "r", "", i18n.T("Git repository URL to clone"))
|
||||
cli.StringFlag(installCmd, &installSignKey, "sign-key", "k", "", i18n.T("Hex-encoded ed25519 public key for manifest verification"))
|
||||
|
||||
parent.AddCommand(installCmd)
|
||||
}
|
||||
|
||||
func runInstall(code, repo, signKey string) error {
|
||||
_, st, inst, err := moduleSetup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
cli.Dim("Installing module " + code + " from " + repo + "...")
|
||||
|
||||
mod := marketplace.Module{
|
||||
Code: code,
|
||||
Repo: repo,
|
||||
SignKey: signKey,
|
||||
}
|
||||
|
||||
if err := inst.Install(context.Background(), mod); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Success("Module " + code + " installed successfully")
|
||||
return nil
|
||||
}
|
||||
51
cmd/module/cmd_list.go
Normal file
51
cmd/module/cmd_list.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package module
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
func addListCommand(parent *cli.Command) {
|
||||
listCmd := cli.NewCommand(
|
||||
"list",
|
||||
i18n.T("List installed modules"),
|
||||
"",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runList()
|
||||
},
|
||||
)
|
||||
|
||||
parent.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func runList() error {
|
||||
_, st, inst, err := moduleSetup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
installed, err := inst.Installed()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(installed) == 0 {
|
||||
cli.Dim("No modules installed")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := cli.NewTable("Code", "Name", "Version", "Repo")
|
||||
for _, m := range installed {
|
||||
table.AddRow(m.Code, m.Name, m.Version, m.Repo)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
table.Render()
|
||||
fmt.Println()
|
||||
cli.Dim(fmt.Sprintf("%d module(s) installed", len(installed)))
|
||||
|
||||
return nil
|
||||
}
|
||||
40
cmd/module/cmd_remove.go
Normal file
40
cmd/module/cmd_remove.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package module
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
func addRemoveCommand(parent *cli.Command) {
|
||||
removeCmd := cli.NewCommand(
|
||||
"remove <code>",
|
||||
i18n.T("Remove an installed module"),
|
||||
"",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runRemove(args[0])
|
||||
},
|
||||
)
|
||||
removeCmd.Args = cli.ExactArgs(1)
|
||||
|
||||
parent.AddCommand(removeCmd)
|
||||
}
|
||||
|
||||
func runRemove(code string) error {
|
||||
_, st, inst, err := moduleSetup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
if !cli.Confirm("Remove module " + code + "?") {
|
||||
cli.Dim("Cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := inst.Remove(code); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Success("Module " + code + " removed")
|
||||
return nil
|
||||
}
|
||||
85
cmd/module/cmd_update.go
Normal file
85
cmd/module/cmd_update.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package module
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
var updateAll bool
|
||||
|
||||
func addUpdateCommand(parent *cli.Command) {
|
||||
updateCmd := cli.NewCommand(
|
||||
"update [code]",
|
||||
i18n.T("Update a module or all modules"),
|
||||
i18n.T("Update a specific module to the latest version, or use --all to update all installed modules."),
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
if updateAll {
|
||||
return runUpdateAll()
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return errors.New("module code required (or use --all)")
|
||||
}
|
||||
return runUpdate(args[0])
|
||||
},
|
||||
)
|
||||
|
||||
cli.BoolFlag(updateCmd, &updateAll, "all", "a", false, i18n.T("Update all installed modules"))
|
||||
|
||||
parent.AddCommand(updateCmd)
|
||||
}
|
||||
|
||||
func runUpdate(code string) error {
|
||||
_, st, inst, err := moduleSetup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
cli.Dim("Updating " + code + "...")
|
||||
|
||||
if err := inst.Update(context.Background(), code); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Success("Module " + code + " updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runUpdateAll() error {
|
||||
_, st, inst, err := moduleSetup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
installed, err := inst.Installed()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(installed) == 0 {
|
||||
cli.Dim("No modules installed")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var updated, failed int
|
||||
for _, m := range installed {
|
||||
cli.Dim("Updating " + m.Code + "...")
|
||||
if err := inst.Update(ctx, m.Code); err != nil {
|
||||
cli.Errorf("Failed to update %s: %v", m.Code, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cli.Success(m.Code + " updated")
|
||||
updated++
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
cli.Dim(fmt.Sprintf("%d updated, %d failed", updated, failed))
|
||||
return nil
|
||||
}
|
||||
158
cmd/pkgcmd/cmd_install.go
Normal file
158
cmd/pkgcmd/cmd_install.go
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
installTargetDir string
|
||||
installAddToReg bool
|
||||
)
|
||||
|
||||
// addPkgInstallCommand adds the 'pkg install' command.
|
||||
func addPkgInstallCommand(parent *cobra.Command) {
|
||||
installCmd := &cobra.Command{
|
||||
Use: "install <org/repo>",
|
||||
Short: i18n.T("cmd.pkg.install.short"),
|
||||
Long: i18n.T("cmd.pkg.install.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New(i18n.T("cmd.pkg.error.repo_required"))
|
||||
}
|
||||
return runPkgInstall(args[0], installTargetDir, installAddToReg)
|
||||
},
|
||||
}
|
||||
|
||||
installCmd.Flags().StringVar(&installTargetDir, "dir", "", i18n.T("cmd.pkg.install.flag.dir"))
|
||||
installCmd.Flags().BoolVar(&installAddToReg, "add", false, i18n.T("cmd.pkg.install.flag.add"))
|
||||
|
||||
parent.AddCommand(installCmd)
|
||||
}
|
||||
|
||||
func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Parse org/repo
|
||||
parts := strings.Split(repoArg, "/")
|
||||
if len(parts) != 2 {
|
||||
return errors.New(i18n.T("cmd.pkg.error.invalid_repo_format"))
|
||||
}
|
||||
org, repoName := parts[0], parts[1]
|
||||
|
||||
// Determine target directory
|
||||
if targetDir == "" {
|
||||
if regPath, err := repos.FindRegistry(coreio.Local); err == nil {
|
||||
if reg, err := repos.LoadRegistry(coreio.Local, regPath); err == nil {
|
||||
targetDir = reg.BasePath
|
||||
if targetDir == "" {
|
||||
targetDir = "./packages"
|
||||
}
|
||||
if !filepath.IsAbs(targetDir) {
|
||||
targetDir = filepath.Join(filepath.Dir(regPath), targetDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetDir == "" {
|
||||
targetDir = "."
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(targetDir, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
targetDir = filepath.Join(home, targetDir[2:])
|
||||
}
|
||||
|
||||
repoPath := filepath.Join(targetDir, repoName)
|
||||
|
||||
if coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := coreio.Local.EnsureDir(targetDir); err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.create", "directory"), err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s/%s\n", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName)
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), repoPath)
|
||||
fmt.Println()
|
||||
|
||||
fmt.Printf(" %s... ", dimStyle.Render(i18n.T("common.status.cloning")))
|
||||
err := gitClone(ctx, org, repoName, repoPath)
|
||||
if err != nil {
|
||||
fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error()))
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s\n", successStyle.Render("✓"))
|
||||
|
||||
if addToRegistry {
|
||||
if err := addToRegistryFile(org, repoName); err != nil {
|
||||
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err)
|
||||
} else {
|
||||
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry"))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addToRegistryFile(org, repoName string) error {
|
||||
regPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
|
||||
}
|
||||
|
||||
reg, err := repos.LoadRegistry(coreio.Local, regPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, exists := reg.Get(repoName); exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := coreio.Local.Read(regPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoType := detectRepoType(repoName)
|
||||
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
|
||||
repoName, repoType)
|
||||
|
||||
content += entry
|
||||
return coreio.Local.Write(regPath, content)
|
||||
}
|
||||
|
||||
func detectRepoType(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") {
|
||||
return "module"
|
||||
}
|
||||
if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") {
|
||||
return "plugin"
|
||||
}
|
||||
if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") {
|
||||
return "service"
|
||||
}
|
||||
if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") {
|
||||
return "website"
|
||||
}
|
||||
if strings.HasPrefix(lower, "core-") {
|
||||
return "package"
|
||||
}
|
||||
return "package"
|
||||
}
|
||||
256
cmd/pkgcmd/cmd_manage.go
Normal file
256
cmd/pkgcmd/cmd_manage.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// addPkgListCommand adds the 'pkg list' command.
|
||||
func addPkgListCommand(parent *cobra.Command) {
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("cmd.pkg.list.short"),
|
||||
Long: i18n.T("cmd.pkg.list.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPkgList()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func runPkgList() error {
|
||||
regPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml_workspace"))
|
||||
}
|
||||
|
||||
reg, err := repos.LoadRegistry(coreio.Local, regPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
|
||||
}
|
||||
|
||||
basePath := reg.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "."
|
||||
}
|
||||
if !filepath.IsAbs(basePath) {
|
||||
basePath = filepath.Join(filepath.Dir(regPath), basePath)
|
||||
}
|
||||
|
||||
allRepos := reg.List()
|
||||
if len(allRepos) == 0 {
|
||||
fmt.Println(i18n.T("cmd.pkg.list.no_packages"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title")))
|
||||
|
||||
var installed, missing int
|
||||
for _, r := range allRepos {
|
||||
repoPath := filepath.Join(basePath, r.Name)
|
||||
exists := coreio.Local.Exists(filepath.Join(repoPath, ".git"))
|
||||
if exists {
|
||||
installed++
|
||||
} else {
|
||||
missing++
|
||||
}
|
||||
|
||||
status := successStyle.Render("✓")
|
||||
if !exists {
|
||||
status = dimStyle.Render("○")
|
||||
}
|
||||
|
||||
desc := r.Description
|
||||
if len(desc) > 40 {
|
||||
desc = desc[:37] + "..."
|
||||
}
|
||||
if desc == "" {
|
||||
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", status, repoNameStyle.Render(r.Name))
|
||||
fmt.Printf(" %s\n", desc)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing}))
|
||||
|
||||
if missing > 0 {
|
||||
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var updateAll bool
|
||||
|
||||
// addPkgUpdateCommand adds the 'pkg update' command.
|
||||
func addPkgUpdateCommand(parent *cobra.Command) {
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update [packages...]",
|
||||
Short: i18n.T("cmd.pkg.update.short"),
|
||||
Long: i18n.T("cmd.pkg.update.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !updateAll && len(args) == 0 {
|
||||
return errors.New(i18n.T("cmd.pkg.error.specify_package"))
|
||||
}
|
||||
return runPkgUpdate(args, updateAll)
|
||||
},
|
||||
}
|
||||
|
||||
updateCmd.Flags().BoolVar(&updateAll, "all", false, i18n.T("cmd.pkg.update.flag.all"))
|
||||
|
||||
parent.AddCommand(updateCmd)
|
||||
}
|
||||
|
||||
func runPkgUpdate(packages []string, all bool) error {
|
||||
regPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
|
||||
}
|
||||
|
||||
reg, err := repos.LoadRegistry(coreio.Local, regPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
|
||||
}
|
||||
|
||||
basePath := reg.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "."
|
||||
}
|
||||
if !filepath.IsAbs(basePath) {
|
||||
basePath = filepath.Join(filepath.Dir(regPath), basePath)
|
||||
}
|
||||
|
||||
var toUpdate []string
|
||||
if all {
|
||||
for _, r := range reg.List() {
|
||||
toUpdate = append(toUpdate, r.Name)
|
||||
}
|
||||
} else {
|
||||
toUpdate = packages
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)}))
|
||||
|
||||
var updated, skipped, failed int
|
||||
for _, name := range toUpdate {
|
||||
repoPath := filepath.Join(basePath, name)
|
||||
|
||||
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil {
|
||||
fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name)
|
||||
|
||||
cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Printf("%s\n", errorStyle.Render("✗"))
|
||||
fmt.Printf(" %s\n", strings.TrimSpace(string(output)))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(string(output), "Already up to date") {
|
||||
fmt.Printf("%s\n", dimStyle.Render(i18n.T("common.status.up_to_date")))
|
||||
} else {
|
||||
fmt.Printf("%s\n", successStyle.Render("✓"))
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n",
|
||||
dimStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.update.summary", map[string]int{"Updated": updated, "Skipped": skipped, "Failed": failed}))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPkgOutdatedCommand adds the 'pkg outdated' command.
|
||||
func addPkgOutdatedCommand(parent *cobra.Command) {
|
||||
outdatedCmd := &cobra.Command{
|
||||
Use: "outdated",
|
||||
Short: i18n.T("cmd.pkg.outdated.short"),
|
||||
Long: i18n.T("cmd.pkg.outdated.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPkgOutdated()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(outdatedCmd)
|
||||
}
|
||||
|
||||
func runPkgOutdated() error {
|
||||
regPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
|
||||
}
|
||||
|
||||
reg, err := repos.LoadRegistry(coreio.Local, regPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
|
||||
}
|
||||
|
||||
basePath := reg.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "."
|
||||
}
|
||||
if !filepath.IsAbs(basePath) {
|
||||
basePath = filepath.Join(filepath.Dir(regPath), basePath)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates"))
|
||||
|
||||
var outdated, upToDate, notInstalled int
|
||||
|
||||
for _, r := range reg.List() {
|
||||
repoPath := filepath.Join(basePath, r.Name)
|
||||
|
||||
if !coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
|
||||
notInstalled++
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch updates
|
||||
_ = exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run()
|
||||
|
||||
// Check if behind
|
||||
cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
count := strings.TrimSpace(string(output))
|
||||
if count != "0" {
|
||||
fmt.Printf(" %s %s (%s)\n",
|
||||
errorStyle.Render("↓"), repoNameStyle.Render(r.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": count}))
|
||||
outdated++
|
||||
} else {
|
||||
upToDate++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if outdated == 0 {
|
||||
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date"))
|
||||
} else {
|
||||
fmt.Printf("%s %s\n",
|
||||
dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.pkg.outdated.summary", map[string]int{"Outdated": outdated, "UpToDate": upToDate}))
|
||||
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ var (
|
|||
dimStyle = cli.DimStyle
|
||||
ghAuthenticated = cli.GhAuthenticated
|
||||
gitClone = cli.GitClone
|
||||
gitCloneRef = clonePackageAtRef
|
||||
)
|
||||
|
||||
// AddPkgCommands adds the 'pkg' command and subcommands for package management.
|
||||
144
cmd/pkgcmd/cmd_remove.go
Normal file
144
cmd/pkgcmd/cmd_remove.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// cmd_remove.go implements the 'pkg remove' command with safety checks.
|
||||
//
|
||||
// Before removing a package, it verifies:
|
||||
// 1. No uncommitted changes exist
|
||||
// 2. No unpushed branches exist
|
||||
// This prevents accidental data loss from agents or tools that might
|
||||
// attempt to remove packages without cleaning up first.
|
||||
package pkgcmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var removeForce bool
|
||||
|
||||
func addPkgRemoveCommand(parent *cobra.Command) {
|
||||
removeCmd := &cobra.Command{
|
||||
Use: "remove <package>",
|
||||
Short: "Remove a package (with safety checks)",
|
||||
Long: `Removes a package directory after verifying it has no uncommitted
|
||||
changes or unpushed branches. Use --force to skip safety checks.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New(i18n.T("cmd.pkg.error.repo_required"))
|
||||
}
|
||||
return runPkgRemove(args[0], removeForce)
|
||||
},
|
||||
}
|
||||
|
||||
removeCmd.Flags().BoolVar(&removeForce, "force", false, "Skip safety checks (dangerous)")
|
||||
|
||||
parent.AddCommand(removeCmd)
|
||||
}
|
||||
|
||||
func runPkgRemove(name string, force bool) error {
|
||||
// Find package path via registry
|
||||
regPath, err := repos.FindRegistry(coreio.Local)
|
||||
if err != nil {
|
||||
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
|
||||
}
|
||||
|
||||
reg, err := repos.LoadRegistry(coreio.Local, regPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
|
||||
}
|
||||
|
||||
basePath := reg.BasePath
|
||||
if basePath == "" {
|
||||
basePath = "."
|
||||
}
|
||||
if !filepath.IsAbs(basePath) {
|
||||
basePath = filepath.Join(filepath.Dir(regPath), basePath)
|
||||
}
|
||||
|
||||
repoPath := filepath.Join(basePath, name)
|
||||
|
||||
if !coreio.Local.IsDir(filepath.Join(repoPath, ".git")) {
|
||||
return fmt.Errorf("package %s is not installed at %s", name, repoPath)
|
||||
}
|
||||
|
||||
if !force {
|
||||
blocked, reasons := checkRepoSafety(repoPath)
|
||||
if blocked {
|
||||
fmt.Printf("%s Cannot remove %s:\n", errorStyle.Render("Blocked:"), repoNameStyle.Render(name))
|
||||
for _, r := range reasons {
|
||||
fmt.Printf(" %s %s\n", errorStyle.Render("·"), r)
|
||||
}
|
||||
fmt.Printf("\nResolve the issues above or use --force to override.\n")
|
||||
return errors.New("package has unresolved changes")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the directory
|
||||
fmt.Printf("%s %s... ", dimStyle.Render("Removing"), repoNameStyle.Render(name))
|
||||
|
||||
if err := coreio.Local.DeleteAll(repoPath); err != nil {
|
||||
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n", successStyle.Render("ok"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRepoSafety checks a git repo for uncommitted changes and unpushed branches.
|
||||
func checkRepoSafety(repoPath string) (blocked bool, reasons []string) {
|
||||
// Check for uncommitted changes (staged, unstaged, untracked)
|
||||
cmd := exec.Command("git", "-C", repoPath, "status", "--porcelain")
|
||||
output, err := cmd.Output()
|
||||
if err == nil && strings.TrimSpace(string(output)) != "" {
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
blocked = true
|
||||
reasons = append(reasons, fmt.Sprintf("has %d uncommitted changes", len(lines)))
|
||||
}
|
||||
|
||||
// Check for unpushed commits on current branch
|
||||
cmd = exec.Command("git", "-C", repoPath, "log", "--oneline", "@{u}..HEAD")
|
||||
output, err = cmd.Output()
|
||||
if err == nil && strings.TrimSpace(string(output)) != "" {
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
blocked = true
|
||||
reasons = append(reasons, fmt.Sprintf("has %d unpushed commits on current branch", len(lines)))
|
||||
}
|
||||
|
||||
// Check all local branches for unpushed work
|
||||
cmd = exec.Command("git", "-C", repoPath, "branch", "--no-merged", "origin/HEAD")
|
||||
output, _ = cmd.Output()
|
||||
if trimmed := strings.TrimSpace(string(output)); trimmed != "" {
|
||||
branches := strings.Split(trimmed, "\n")
|
||||
var unmerged []string
|
||||
for _, b := range branches {
|
||||
b = strings.TrimSpace(b)
|
||||
b = strings.TrimPrefix(b, "* ")
|
||||
if b != "" {
|
||||
unmerged = append(unmerged, b)
|
||||
}
|
||||
}
|
||||
if len(unmerged) > 0 {
|
||||
blocked = true
|
||||
reasons = append(reasons, fmt.Sprintf("has %d unmerged branches: %s",
|
||||
len(unmerged), strings.Join(unmerged, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stashed changes
|
||||
cmd = exec.Command("git", "-C", repoPath, "stash", "list")
|
||||
output, err = cmd.Output()
|
||||
if err == nil && strings.TrimSpace(string(output)) != "" {
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
blocked = true
|
||||
reasons = append(reasons, fmt.Sprintf("has %d stashed entries", len(lines)))
|
||||
}
|
||||
|
||||
return blocked, reasons
|
||||
}
|
||||
92
cmd/pkgcmd/cmd_remove_test.go
Normal file
92
cmd/pkgcmd/cmd_remove_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestRepo(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
repoPath := filepath.Join(dir, name)
|
||||
require.NoError(t, os.MkdirAll(repoPath, 0755))
|
||||
|
||||
cmds := [][]string{
|
||||
{"git", "init"},
|
||||
{"git", "config", "user.email", "test@test.com"},
|
||||
{"git", "config", "user.name", "Test"},
|
||||
{"git", "commit", "--allow-empty", "-m", "initial"},
|
||||
}
|
||||
for _, c := range cmds {
|
||||
cmd := exec.Command(c[0], c[1:]...)
|
||||
cmd.Dir = repoPath
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "cmd %v failed: %s", c, string(out))
|
||||
}
|
||||
return repoPath
|
||||
}
|
||||
|
||||
func TestCheckRepoSafety_Clean(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
repoPath := setupTestRepo(t, tmp, "clean-repo")
|
||||
|
||||
blocked, reasons := checkRepoSafety(repoPath)
|
||||
assert.False(t, blocked)
|
||||
assert.Empty(t, reasons)
|
||||
}
|
||||
|
||||
func TestCheckRepoSafety_UncommittedChanges(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
repoPath := setupTestRepo(t, tmp, "dirty-repo")
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "new.txt"), []byte("data"), 0644))
|
||||
|
||||
blocked, reasons := checkRepoSafety(repoPath)
|
||||
assert.True(t, blocked)
|
||||
assert.NotEmpty(t, reasons)
|
||||
assert.Contains(t, reasons[0], "uncommitted changes")
|
||||
}
|
||||
|
||||
func TestCheckRepoSafety_Stash(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
repoPath := setupTestRepo(t, tmp, "stash-repo")
|
||||
|
||||
// Create a file, add, stash
|
||||
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "stash.txt"), []byte("data"), 0644))
|
||||
cmd := exec.Command("git", "add", ".")
|
||||
cmd.Dir = repoPath
|
||||
require.NoError(t, cmd.Run())
|
||||
|
||||
cmd = exec.Command("git", "stash")
|
||||
cmd.Dir = repoPath
|
||||
require.NoError(t, cmd.Run())
|
||||
|
||||
blocked, reasons := checkRepoSafety(repoPath)
|
||||
assert.True(t, blocked)
|
||||
found := false
|
||||
for _, r := range reasons {
|
||||
if assert.ObjectsAreEqual("stashed", "") || len(r) > 0 {
|
||||
if contains(r, "stash") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected stash warning in reasons: %v", reasons)
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
206
cmd/pkgcmd/cmd_search.go
Normal file
206
cmd/pkgcmd/cmd_search.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package pkgcmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-cache"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/repos"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
searchOrg string
|
||||
searchPattern string
|
||||
searchType string
|
||||
searchLimit int
|
||||
searchRefresh bool
|
||||
)
|
||||
|
||||
// addPkgSearchCommand adds the 'pkg search' command.
|
||||
func addPkgSearchCommand(parent *cobra.Command) {
|
||||
searchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: i18n.T("cmd.pkg.search.short"),
|
||||
Long: i18n.T("cmd.pkg.search.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
org := searchOrg
|
||||
pattern := searchPattern
|
||||
limit := searchLimit
|
||||
if org == "" {
|
||||
org = "host-uk"
|
||||
}
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
return runPkgSearch(org, pattern, searchType, limit, searchRefresh)
|
||||
},
|
||||
}
|
||||
|
||||
searchCmd.Flags().StringVar(&searchOrg, "org", "", i18n.T("cmd.pkg.search.flag.org"))
|
||||
searchCmd.Flags().StringVar(&searchPattern, "pattern", "", i18n.T("cmd.pkg.search.flag.pattern"))
|
||||
searchCmd.Flags().StringVar(&searchType, "type", "", i18n.T("cmd.pkg.search.flag.type"))
|
||||
searchCmd.Flags().IntVar(&searchLimit, "limit", 0, i18n.T("cmd.pkg.search.flag.limit"))
|
||||
searchCmd.Flags().BoolVar(&searchRefresh, "refresh", false, i18n.T("cmd.pkg.search.flag.refresh"))
|
||||
|
||||
parent.AddCommand(searchCmd)
|
||||
}
|
||||
|
||||
type ghRepo struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
Visibility string `json:"visibility"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error {
|
||||
// Initialize cache in workspace .core/ directory
|
||||
var cacheDir string
|
||||
if regPath, err := repos.FindRegistry(coreio.Local); err == nil {
|
||||
cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache")
|
||||
}
|
||||
|
||||
c, err := cache.New(coreio.Local, cacheDir, 0)
|
||||
if err != nil {
|
||||
c = nil
|
||||
}
|
||||
|
||||
cacheKey := cache.GitHubReposKey(org)
|
||||
var ghRepos []ghRepo
|
||||
var fromCache bool
|
||||
|
||||
// Try cache first (unless refresh requested)
|
||||
if c != nil && !refresh {
|
||||
if found, err := c.Get(cacheKey, &ghRepos); found && err == nil {
|
||||
fromCache = true
|
||||
age := c.Age(cacheKey)
|
||||
fmt.Printf("%s %s %s\n", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second))))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from GitHub if not cached
|
||||
if !fromCache {
|
||||
if !ghAuthenticated() {
|
||||
return errors.New(i18n.T("cmd.pkg.error.gh_not_authenticated"))
|
||||
}
|
||||
|
||||
if os.Getenv("GH_TOKEN") != "" {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning"))
|
||||
fmt.Printf("%s %s\n\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset"))
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org)
|
||||
|
||||
cmd := exec.Command("gh", "repo", "list", org,
|
||||
"--json", "name,description,visibility,updatedAt,primaryLanguage",
|
||||
"--limit", fmt.Sprintf("%d", limit))
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
errStr := strings.TrimSpace(string(output))
|
||||
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") {
|
||||
return errors.New(i18n.T("cmd.pkg.error.auth_failed"))
|
||||
}
|
||||
return fmt.Errorf("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errStr)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &ghRepos); err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.parse", "results"), err)
|
||||
}
|
||||
|
||||
if c != nil {
|
||||
_ = c.Set(cacheKey, ghRepos)
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n", successStyle.Render("✓"))
|
||||
}
|
||||
|
||||
// Filter by glob pattern and type
|
||||
var filtered []ghRepo
|
||||
for _, r := range ghRepos {
|
||||
if !matchGlob(pattern, r.Name) {
|
||||
continue
|
||||
}
|
||||
if repoType != "" && !strings.Contains(r.Name, repoType) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
fmt.Println(i18n.T("cmd.pkg.search.no_repos_found"))
|
||||
return nil
|
||||
}
|
||||
|
||||
slices.SortFunc(filtered, func(a, b ghRepo) int {
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
fmt.Print(i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)}) + "\n\n")
|
||||
|
||||
for _, r := range filtered {
|
||||
visibility := ""
|
||||
if r.Visibility == "private" {
|
||||
visibility = dimStyle.Render(" " + i18n.T("cmd.pkg.search.private_label"))
|
||||
}
|
||||
|
||||
desc := r.Description
|
||||
if len(desc) > 50 {
|
||||
desc = desc[:47] + "..."
|
||||
}
|
||||
if desc == "" {
|
||||
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
|
||||
}
|
||||
|
||||
fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility)
|
||||
fmt.Printf(" %s\n", desc)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", i18n.T("common.hint.install_with"), dimStyle.Render(fmt.Sprintf("core pkg install %s/<repo-name>", org)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchGlob does simple glob matching with * wildcards
|
||||
func matchGlob(pattern, name string) bool {
|
||||
if pattern == "*" || pattern == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
parts := strings.Split(pattern, "*")
|
||||
pos := 0
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(name[pos:], part)
|
||||
if idx == -1 {
|
||||
return false
|
||||
}
|
||||
if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 {
|
||||
return false
|
||||
}
|
||||
pos += idx + len(part)
|
||||
}
|
||||
if !strings.HasSuffix(pattern, "*") && pos != len(name) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
29
cmd/plugin/cmd.go
Normal file
29
cmd/plugin/cmd.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Package plugin provides CLI commands for managing core plugins.
|
||||
//
|
||||
// Commands:
|
||||
// - install: Install a plugin from GitHub
|
||||
// - list: List installed plugins
|
||||
// - info: Show detailed plugin information
|
||||
// - update: Update a plugin or all plugins
|
||||
// - remove: Remove an installed plugin
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
// AddPluginCommands registers the 'plugin' command and all subcommands.
|
||||
func AddPluginCommands(root *cli.Command) {
|
||||
pluginCmd := &cli.Command{
|
||||
Use: "plugin",
|
||||
Short: i18n.T("Manage plugins"),
|
||||
}
|
||||
root.AddCommand(pluginCmd)
|
||||
|
||||
addInstallCommand(pluginCmd)
|
||||
addListCommand(pluginCmd)
|
||||
addInfoCommand(pluginCmd)
|
||||
addUpdateCommand(pluginCmd)
|
||||
addRemoveCommand(pluginCmd)
|
||||
}
|
||||
86
cmd/plugin/cmd_info.go
Normal file
86
cmd/plugin/cmd_info.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/plugin"
|
||||
)
|
||||
|
||||
func addInfoCommand(parent *cli.Command) {
|
||||
infoCmd := cli.NewCommand(
|
||||
"info <name>",
|
||||
i18n.T("Show detailed plugin information"),
|
||||
"",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runInfo(args[0])
|
||||
},
|
||||
)
|
||||
infoCmd.Args = cli.ExactArgs(1)
|
||||
|
||||
parent.AddCommand(infoCmd)
|
||||
}
|
||||
|
||||
func runInfo(name string) error {
|
||||
basePath, err := pluginBasePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := plugin.NewRegistry(io.Local, basePath)
|
||||
if err := registry.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, ok := registry.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin not found: %s", name)
|
||||
}
|
||||
|
||||
// Try to load the manifest for extended information
|
||||
loader := plugin.NewLoader(io.Local, basePath)
|
||||
manifest, manifestErr := loader.LoadPlugin(name)
|
||||
|
||||
fmt.Println()
|
||||
cli.Label("Name", cfg.Name)
|
||||
cli.Label("Version", cfg.Version)
|
||||
cli.Label("Source", cfg.Source)
|
||||
|
||||
status := "disabled"
|
||||
if cfg.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
cli.Label("Status", status)
|
||||
cli.Label("Installed", cfg.InstalledAt)
|
||||
cli.Label("Path", filepath.Join(basePath, name))
|
||||
|
||||
if manifestErr == nil && manifest != nil {
|
||||
if manifest.Description != "" {
|
||||
cli.Label("Description", manifest.Description)
|
||||
}
|
||||
if manifest.Author != "" {
|
||||
cli.Label("Author", manifest.Author)
|
||||
}
|
||||
if manifest.Entrypoint != "" {
|
||||
cli.Label("Entrypoint", manifest.Entrypoint)
|
||||
}
|
||||
if manifest.MinVersion != "" {
|
||||
cli.Label("Min Version", manifest.MinVersion)
|
||||
}
|
||||
if len(manifest.Dependencies) > 0 {
|
||||
for i, dep := range manifest.Dependencies {
|
||||
if i == 0 {
|
||||
cli.Label("Dependencies", dep)
|
||||
} else {
|
||||
fmt.Printf(" %s\n", dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
61
cmd/plugin/cmd_install.go
Normal file
61
cmd/plugin/cmd_install.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/plugin"
|
||||
)
|
||||
|
||||
func addInstallCommand(parent *cli.Command) {
|
||||
installCmd := cli.NewCommand(
|
||||
"install <source>",
|
||||
i18n.T("Install a plugin from GitHub"),
|
||||
i18n.T("Install a plugin from a GitHub repository.\n\nSource format: org/repo or org/repo@version"),
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runInstall(args[0])
|
||||
},
|
||||
)
|
||||
installCmd.Args = cli.ExactArgs(1)
|
||||
installCmd.Example = " core plugin install host-uk/core-plugin-example\n core plugin install host-uk/core-plugin-example@v1.0.0"
|
||||
|
||||
parent.AddCommand(installCmd)
|
||||
}
|
||||
|
||||
func runInstall(source string) error {
|
||||
basePath, err := pluginBasePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := plugin.NewRegistry(io.Local, basePath)
|
||||
if err := registry.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installer := plugin.NewInstaller(io.Local, registry)
|
||||
|
||||
cli.Dim("Installing plugin from " + source + "...")
|
||||
|
||||
if err := installer.Install(context.Background(), source); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, repo, _, _ := plugin.ParseSource(source)
|
||||
cli.Success("Plugin " + repo + " installed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginBasePath returns the default plugin directory (~/.core/plugins/).
|
||||
func pluginBasePath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", cli.Wrap(err, "failed to determine home directory")
|
||||
}
|
||||
return filepath.Join(home, ".core", "plugins"), nil
|
||||
}
|
||||
57
cmd/plugin/cmd_list.go
Normal file
57
cmd/plugin/cmd_list.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/plugin"
|
||||
)
|
||||
|
||||
func addListCommand(parent *cli.Command) {
|
||||
listCmd := cli.NewCommand(
|
||||
"list",
|
||||
i18n.T("List installed plugins"),
|
||||
"",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runList()
|
||||
},
|
||||
)
|
||||
|
||||
parent.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func runList() error {
|
||||
basePath, err := pluginBasePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := plugin.NewRegistry(io.Local, basePath)
|
||||
if err := registry.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plugins := registry.List()
|
||||
if len(plugins) == 0 {
|
||||
cli.Dim("No plugins installed")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := cli.NewTable("Name", "Version", "Source", "Status")
|
||||
for _, p := range plugins {
|
||||
status := "disabled"
|
||||
if p.Enabled {
|
||||
status = "enabled"
|
||||
}
|
||||
table.AddRow(p.Name, p.Version, p.Source, status)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
table.Render()
|
||||
fmt.Println()
|
||||
cli.Dim(fmt.Sprintf("%d plugin(s) installed", len(plugins)))
|
||||
|
||||
return nil
|
||||
}
|
||||
48
cmd/plugin/cmd_remove.go
Normal file
48
cmd/plugin/cmd_remove.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/plugin"
|
||||
)
|
||||
|
||||
func addRemoveCommand(parent *cli.Command) {
|
||||
removeCmd := cli.NewCommand(
|
||||
"remove <name>",
|
||||
i18n.T("Remove an installed plugin"),
|
||||
"",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runRemove(args[0])
|
||||
},
|
||||
)
|
||||
removeCmd.Args = cli.ExactArgs(1)
|
||||
|
||||
parent.AddCommand(removeCmd)
|
||||
}
|
||||
|
||||
func runRemove(name string) error {
|
||||
basePath, err := pluginBasePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := plugin.NewRegistry(io.Local, basePath)
|
||||
if err := registry.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cli.Confirm("Remove plugin " + name + "?") {
|
||||
cli.Dim("Cancelled")
|
||||
return nil
|
||||
}
|
||||
|
||||
installer := plugin.NewInstaller(io.Local, registry)
|
||||
|
||||
if err := installer.Remove(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Success("Plugin " + name + " removed")
|
||||
return nil
|
||||
}
|
||||
95
cmd/plugin/cmd_update.go
Normal file
95
cmd/plugin/cmd_update.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-scm/plugin"
|
||||
)
|
||||
|
||||
var updateAll bool
|
||||
|
||||
func addUpdateCommand(parent *cli.Command) {
|
||||
updateCmd := cli.NewCommand(
|
||||
"update [name]",
|
||||
i18n.T("Update a plugin or all plugins"),
|
||||
i18n.T("Update a specific plugin to the latest version, or use --all to update all installed plugins."),
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
if updateAll {
|
||||
return runUpdateAll()
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return errors.New("plugin name required (or use --all)")
|
||||
}
|
||||
return runUpdate(args[0])
|
||||
},
|
||||
)
|
||||
|
||||
cli.BoolFlag(updateCmd, &updateAll, "all", "a", false, i18n.T("Update all installed plugins"))
|
||||
|
||||
parent.AddCommand(updateCmd)
|
||||
}
|
||||
|
||||
func runUpdate(name string) error {
|
||||
basePath, err := pluginBasePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := plugin.NewRegistry(io.Local, basePath)
|
||||
if err := registry.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installer := plugin.NewInstaller(io.Local, registry)
|
||||
|
||||
cli.Dim("Updating " + name + "...")
|
||||
|
||||
if err := installer.Update(context.Background(), name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Success("Plugin " + name + " updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runUpdateAll() error {
|
||||
basePath, err := pluginBasePath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry := plugin.NewRegistry(io.Local, basePath)
|
||||
if err := registry.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plugins := registry.List()
|
||||
if len(plugins) == 0 {
|
||||
cli.Dim("No plugins installed")
|
||||
return nil
|
||||
}
|
||||
|
||||
installer := plugin.NewInstaller(io.Local, registry)
|
||||
ctx := context.Background()
|
||||
|
||||
var updated, failed int
|
||||
for _, p := range plugins {
|
||||
cli.Dim("Updating " + p.Name + "...")
|
||||
if err := installer.Update(ctx, p.Name); err != nil {
|
||||
cli.Errorf("Failed to update %s: %v", p.Name, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cli.Success(p.Name + " updated")
|
||||
updated++
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
cli.Dim(fmt.Sprintf("%d updated, %d failed", updated, failed))
|
||||
return nil
|
||||
}
|
||||
274
cmd/service/cmd.go
Normal file
274
cmd/service/cmd.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-process"
|
||||
"forge.lthn.ai/core/go-scm/manifest"
|
||||
)
|
||||
|
||||
// AddServiceCommands registers core start/stop/list/restart as top-level commands.
|
||||
func AddServiceCommands(root *cli.Command) {
|
||||
startCmd := cli.NewCommand("start", "Start a project daemon",
|
||||
"Reads .core/manifest.yaml and starts the named daemon (or the default).\n"+
|
||||
"The daemon runs detached in the background.",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runStart(args)
|
||||
},
|
||||
)
|
||||
|
||||
stopCmd := cli.NewCommand("stop", "Stop a project daemon",
|
||||
"Stops the named daemon for the current project, or all daemons if no name given.",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runStop(args)
|
||||
},
|
||||
)
|
||||
|
||||
listCmd := cli.NewCommand("list", "List running daemons",
|
||||
"Shows all running daemons tracked in ~/.core/daemons/.",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runList()
|
||||
},
|
||||
)
|
||||
|
||||
restartCmd := cli.NewCommand("restart", "Restart a project daemon",
|
||||
"Stops then starts the named daemon.",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
if err := runStop(args); err != nil {
|
||||
return err
|
||||
}
|
||||
return runStart(args)
|
||||
},
|
||||
)
|
||||
|
||||
root.AddCommand(startCmd, stopCmd, listCmd, restartCmd)
|
||||
}
|
||||
|
||||
func runStart(args []string) error {
|
||||
m, projectDir, err := findManifest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
daemonName, spec, err := resolveDaemon(m, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reg := process.DefaultRegistry()
|
||||
|
||||
// Check if already running.
|
||||
if _, ok := reg.Get(m.Code, daemonName); ok {
|
||||
return fmt.Errorf("%s/%s is already running", m.Code, daemonName)
|
||||
}
|
||||
|
||||
// Resolve binary.
|
||||
binary := spec.Binary
|
||||
if binary == "" {
|
||||
return fmt.Errorf("daemon %q has no binary specified", daemonName)
|
||||
}
|
||||
|
||||
binPath, err := exec.LookPath(binary)
|
||||
if err != nil {
|
||||
return fmt.Errorf("binary %q not found in PATH: %w", binary, err)
|
||||
}
|
||||
|
||||
// Launch detached.
|
||||
cmd := exec.Command(binPath, spec.Args...)
|
||||
cmd.Dir = projectDir
|
||||
cmd.Env = append(os.Environ(), "CORE_DAEMON=1")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Stdin = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start %s: %w", daemonName, err)
|
||||
}
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
// Wait for health if configured.
|
||||
health := spec.Health
|
||||
if health != "" && health != "127.0.0.1:0" {
|
||||
if process.WaitForHealth(health, 5000) {
|
||||
cli.LogInfo(fmt.Sprintf("Started %s/%s (PID %d, health %s)", m.Code, daemonName, pid, health))
|
||||
} else {
|
||||
cli.LogInfo(fmt.Sprintf("Started %s/%s (PID %d, health not yet ready)", m.Code, daemonName, pid))
|
||||
}
|
||||
} else {
|
||||
cli.LogInfo(fmt.Sprintf("Started %s/%s (PID %d)", m.Code, daemonName, pid))
|
||||
}
|
||||
|
||||
// Register in the daemon registry.
|
||||
if err := reg.Register(process.DaemonEntry{
|
||||
Code: m.Code,
|
||||
Daemon: daemonName,
|
||||
PID: pid,
|
||||
Health: health,
|
||||
Project: projectDir,
|
||||
Binary: binPath,
|
||||
}); err != nil {
|
||||
cli.LogWarn(fmt.Sprintf("Daemon started but registry failed: %v", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStop(args []string) error {
|
||||
reg := process.DefaultRegistry()
|
||||
|
||||
m, _, err := findManifest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If a specific daemon name was given, stop only that one.
|
||||
if len(args) > 0 {
|
||||
return stopDaemon(reg, m.Code, args[0])
|
||||
}
|
||||
|
||||
// No args: stop all daemons for this project.
|
||||
entries, err := reg.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stopped := 0
|
||||
for _, e := range entries {
|
||||
if e.Code == m.Code {
|
||||
if err := stopDaemon(reg, e.Code, e.Daemon); err != nil {
|
||||
cli.LogError(fmt.Sprintf("Failed to stop %s/%s: %v", e.Code, e.Daemon, err))
|
||||
} else {
|
||||
stopped++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stopped == 0 {
|
||||
cli.LogInfo("No running daemons for " + m.Code)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopDaemon(reg *process.Registry, code, daemon string) error {
|
||||
entry, ok := reg.Get(code, daemon)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s/%s is not running", code, daemon)
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(entry.PID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("process %d not found: %w", entry.PID, err)
|
||||
}
|
||||
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to signal PID %d: %w", entry.PID, err)
|
||||
}
|
||||
|
||||
// Wait for process to exit, escalate to SIGKILL after 30s.
|
||||
// Poll the process directly via Signal(0) rather than relying on
|
||||
// the daemon to self-unregister, which avoids PID reuse issues.
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
// Process is gone.
|
||||
_ = reg.Unregister(code, daemon)
|
||||
cli.LogInfo(fmt.Sprintf("Stopped %s/%s (PID %d)", code, daemon, entry.PID))
|
||||
return nil
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
|
||||
cli.LogWarn(fmt.Sprintf("%s/%s did not stop within 30s, sending SIGKILL", code, daemon))
|
||||
_ = proc.Signal(syscall.SIGKILL)
|
||||
_ = reg.Unregister(code, daemon)
|
||||
cli.LogInfo(fmt.Sprintf("Killed %s/%s (PID %d)", code, daemon, entry.PID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList() error {
|
||||
reg := process.DefaultRegistry()
|
||||
entries, err := reg.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No running daemons")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%-20s %-12s %-8s %-24s %s\n", "CODE", "DAEMON", "PID", "HEALTH", "PROJECT")
|
||||
for _, e := range entries {
|
||||
project := e.Project
|
||||
if project == "" {
|
||||
project = "-"
|
||||
}
|
||||
fmt.Printf("%-20s %-12s %-8d %-24s %s\n", e.Code, e.Daemon, e.PID, e.Health, project)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findManifest walks from cwd up to / looking for .core/manifest.yaml.
|
||||
func findManifest() (*manifest.Manifest, string, error) {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
for {
|
||||
path := filepath.Join(dir, ".core", "manifest.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
m, err := manifest.Parse(data)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("invalid manifest at %s: %w", path, err)
|
||||
}
|
||||
return m, dir, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("no .core/manifest.yaml found (checked cwd and parent directories)")
|
||||
}
|
||||
|
||||
// resolveDaemon finds the daemon entry by name or returns the default.
|
||||
func resolveDaemon(m *manifest.Manifest, args []string) (string, manifest.DaemonSpec, error) {
|
||||
if len(args) > 0 {
|
||||
name := args[0]
|
||||
spec, ok := m.Daemons[name]
|
||||
if !ok {
|
||||
return "", manifest.DaemonSpec{}, fmt.Errorf("daemon %q not found in manifest (available: %v)", name, daemonNames(m))
|
||||
}
|
||||
return name, spec, nil
|
||||
}
|
||||
|
||||
name, spec, ok := m.DefaultDaemon()
|
||||
if !ok {
|
||||
return "", manifest.DaemonSpec{}, fmt.Errorf("no default daemon in manifest (use: core start <name>)")
|
||||
}
|
||||
return name, spec, nil
|
||||
}
|
||||
|
||||
func daemonNames(m *manifest.Manifest) []string {
|
||||
var names []string
|
||||
for name := range m.Daemons {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
235
cmd/session/cmd_session.go
Normal file
235
cmd/session/cmd_session.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// Package session provides commands for replaying and searching Claude Code session transcripts.
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-session"
|
||||
)
|
||||
|
||||
// AddSessionCommands registers the 'session' command group.
|
||||
func AddSessionCommands(root *cli.Command) {
|
||||
sessionCmd := &cli.Command{
|
||||
Use: "session",
|
||||
Short: "Session recording and replay",
|
||||
}
|
||||
root.AddCommand(sessionCmd)
|
||||
|
||||
addListCommand(sessionCmd)
|
||||
addReplayCommand(sessionCmd)
|
||||
addSearchCommand(sessionCmd)
|
||||
}
|
||||
|
||||
func projectsDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
// Walk .claude/projects/ looking for dirs with .jsonl files
|
||||
base := filepath.Join(home, ".claude", "projects")
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
return base
|
||||
}
|
||||
// Return the first project dir that has .jsonl files
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(base, e.Name())
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, "*.jsonl"))
|
||||
if len(matches) > 0 {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func addListCommand(parent *cli.Command) {
|
||||
listCmd := &cli.Command{
|
||||
Use: "list",
|
||||
Short: "List recent sessions",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
sessions, err := session.ListSessions(projectsDir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
cli.Print("No sessions found")
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Print("%s", cli.HeaderStyle.Render("Recent Sessions"))
|
||||
cli.Print("%s", "")
|
||||
for i, s := range sessions {
|
||||
if i >= 20 {
|
||||
cli.Print(" ... and %d more", len(sessions)-20)
|
||||
break
|
||||
}
|
||||
dur := s.EndTime.Sub(s.StartTime)
|
||||
durStr := ""
|
||||
if dur > 0 {
|
||||
durStr = fmt.Sprintf(" (%s)", formatDur(dur))
|
||||
}
|
||||
id := s.ID
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
}
|
||||
cli.Print(" %s %s%s",
|
||||
cli.ValueStyle.Render(id),
|
||||
s.StartTime.Format("2006-01-02 15:04"),
|
||||
cli.DimStyle.Render(durStr))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func addReplayCommand(parent *cli.Command) {
|
||||
var mp4 bool
|
||||
var output string
|
||||
|
||||
replayCmd := &cli.Command{
|
||||
Use: "replay <session-id>",
|
||||
Short: "Generate HTML timeline (and optional MP4) from a session",
|
||||
Args: cli.MinimumNArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
id := args[0]
|
||||
path := findSession(id)
|
||||
if path == "" {
|
||||
return fmt.Errorf("session not found: %s", id)
|
||||
}
|
||||
|
||||
cli.Print("Parsing %s...", cli.ValueStyle.Render(filepath.Base(path)))
|
||||
|
||||
sess, _, err := session.ParseTranscript(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
|
||||
toolCount := 0
|
||||
for _, e := range sess.Events {
|
||||
if e.Type == "tool_use" {
|
||||
toolCount++
|
||||
}
|
||||
}
|
||||
cli.Print(" %d events, %d tool calls",
|
||||
len(sess.Events), toolCount)
|
||||
|
||||
// HTML output
|
||||
htmlPath := output
|
||||
if htmlPath == "" {
|
||||
htmlPath = fmt.Sprintf("session-%s.html", shortID(sess.ID))
|
||||
}
|
||||
if err := session.RenderHTML(sess, htmlPath); err != nil {
|
||||
return fmt.Errorf("render html: %w", err)
|
||||
}
|
||||
cli.Print("%s", cli.SuccessStyle.Render(fmt.Sprintf(" HTML: %s", htmlPath)))
|
||||
|
||||
// MP4 output
|
||||
if mp4 {
|
||||
mp4Path := strings.TrimSuffix(htmlPath, ".html") + ".mp4"
|
||||
if err := session.RenderMP4(sess, mp4Path); err != nil {
|
||||
cli.Print("%s", cli.ErrorStyle.Render(fmt.Sprintf(" MP4: %s", err)))
|
||||
} else {
|
||||
cli.Print("%s", cli.SuccessStyle.Render(fmt.Sprintf(" MP4: %s", mp4Path)))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
replayCmd.Flags().BoolVar(&mp4, "mp4", false, "Also generate MP4 video (requires vhs + ffmpeg)")
|
||||
replayCmd.Flags().StringVarP(&output, "output", "o", "", "Output file path")
|
||||
parent.AddCommand(replayCmd)
|
||||
}
|
||||
|
||||
func addSearchCommand(parent *cli.Command) {
|
||||
searchCmd := &cli.Command{
|
||||
Use: "search <query>",
|
||||
Short: "Search across session transcripts",
|
||||
Args: cli.MinimumNArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
query := strings.ToLower(strings.Join(args, " "))
|
||||
results, err := session.Search(projectsDir(), query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
cli.Print("No matches found")
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Print("%s", cli.HeaderStyle.Render(fmt.Sprintf("Found %d matches", len(results))))
|
||||
cli.Print("%s", "")
|
||||
for _, r := range results {
|
||||
id := r.SessionID
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
}
|
||||
cli.Print(" %s %s %s",
|
||||
cli.ValueStyle.Render(id),
|
||||
r.Timestamp.Format("15:04:05"),
|
||||
cli.DimStyle.Render(r.Tool))
|
||||
cli.Print(" %s", truncateStr(r.Match, 100))
|
||||
cli.Print("%s", "")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent.AddCommand(searchCmd)
|
||||
}
|
||||
|
||||
func findSession(id string) string {
|
||||
dir := projectsDir()
|
||||
// Try exact match first
|
||||
path := filepath.Join(dir, id+".jsonl")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
// Try prefix match
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, id+"*.jsonl"))
|
||||
if len(matches) == 1 {
|
||||
return matches[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shortID(id string) string {
|
||||
if len(id) > 8 {
|
||||
return id[:8]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func formatDur(d interface {
|
||||
Hours() float64
|
||||
Minutes() float64
|
||||
Seconds() float64
|
||||
}) string {
|
||||
type dur interface {
|
||||
Hours() float64
|
||||
Minutes() float64
|
||||
Seconds() float64
|
||||
}
|
||||
dd := d.(dur)
|
||||
h := int(dd.Hours())
|
||||
m := int(dd.Minutes()) % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
}
|
||||
s := int(dd.Seconds()) % 60
|
||||
if m > 0 {
|
||||
return fmt.Sprintf("%dm%ds", m, s)
|
||||
}
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
|
||||
func truncateStr(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
|
|
@ -33,5 +33,4 @@ core pkg update core-api
|
|||
|
||||
```bash
|
||||
core pkg outdated
|
||||
core pkg outdated --format json
|
||||
```
|
||||
|
|
|
|||
|
|
@ -60,10 +60,10 @@ core pkg search --refresh
|
|||
|
||||
## pkg install
|
||||
|
||||
Clone a package from GitHub. If you pass only a repo name, `core` assumes the `host-uk` org.
|
||||
Clone a package from GitHub.
|
||||
|
||||
```bash
|
||||
core pkg install [org/]repo [flags]
|
||||
core pkg install <org/repo> [flags]
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
|
@ -76,9 +76,6 @@ core pkg install [org/]repo [flags]
|
|||
### Examples
|
||||
|
||||
```bash
|
||||
# Clone from the default host-uk org
|
||||
core pkg install core-api
|
||||
|
||||
# Clone to packages/
|
||||
core pkg install host-uk/core-php
|
||||
|
||||
|
|
@ -101,16 +98,6 @@ core pkg list
|
|||
|
||||
Shows installed status (✓) and description for each package.
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--format` | Output format (`table` or `json`) |
|
||||
|
||||
### JSON Output
|
||||
|
||||
When `--format json` is set, `core pkg list` emits a structured report with package entries, installed state, and summary counts.
|
||||
|
||||
---
|
||||
|
||||
## pkg update
|
||||
|
|
@ -126,7 +113,6 @@ core pkg update [<name>...] [flags]
|
|||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--all` | Update all packages |
|
||||
| `--format` | Output format (`table` or `json`) |
|
||||
|
||||
### Examples
|
||||
|
||||
|
|
@ -136,15 +122,8 @@ core pkg update core-php
|
|||
|
||||
# Update all packages
|
||||
core pkg update --all
|
||||
|
||||
# JSON output for automation
|
||||
core pkg update --format json
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
When `--format json` is set, `core pkg update` emits a structured report with per-package update status and summary totals.
|
||||
|
||||
---
|
||||
|
||||
## pkg outdated
|
||||
|
|
@ -157,16 +136,6 @@ core pkg outdated
|
|||
|
||||
Fetches from remote and shows packages that are behind.
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--format` | Output format (`table` or `json`) |
|
||||
|
||||
### JSON Output
|
||||
|
||||
When `--format json` is set, `core pkg outdated` emits a structured report with package status, behind counts, and summary totals.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ core pkg search [flags]
|
|||
| `--type` | Filter by type in name (mod, services, plug, website) |
|
||||
| `--limit` | Max results (default: 50) |
|
||||
| `--refresh` | Bypass cache and fetch fresh data |
|
||||
| `--format` | Output format (`table` or `json`) |
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
@ -41,9 +40,6 @@ core pkg search --refresh
|
|||
|
||||
# Combine filters
|
||||
core pkg search --pattern "core-*" --type mod --limit 20
|
||||
|
||||
# JSON output for automation
|
||||
core pkg search --format json
|
||||
```
|
||||
|
||||
## Output
|
||||
|
|
|
|||
|
|
@ -85,11 +85,6 @@ Persistent flags are inherited by all subcommands:
|
|||
```go
|
||||
cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path")
|
||||
cli.PersistentBoolFlag(parentCmd, &debug, "debug", "", false, "Debug mode")
|
||||
cli.PersistentIntFlag(parentCmd, &retries, "retries", "r", 3, "Retry count")
|
||||
cli.PersistentInt64Flag(parentCmd, &seed, "seed", "", 0, "Seed value")
|
||||
cli.PersistentFloat64Flag(parentCmd, &ratio, "ratio", "", 1.0, "Scaling ratio")
|
||||
cli.PersistentDurationFlag(parentCmd, &timeout, "timeout", "t", 30*time.Second, "Timeout")
|
||||
cli.PersistentStringSliceFlag(parentCmd, &tags, "tag", "", nil, "Tags")
|
||||
```
|
||||
|
||||
## Args Validation
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ description: Daemon process management, PID files, health checks, and execution
|
|||
|
||||
# Daemon Mode
|
||||
|
||||
The framework provides execution mode detection and signal handling for daemon processes.
|
||||
The framework provides both low-level daemon primitives and a high-level command group that adds `start`, `stop`, `status`, and `run` subcommands to your CLI.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
|
|
@ -29,9 +29,63 @@ cli.IsStdinTTY() // stdin is a terminal?
|
|||
cli.IsStderrTTY() // stderr is a terminal?
|
||||
```
|
||||
|
||||
## Simple Daemon
|
||||
## Adding Daemon Commands
|
||||
|
||||
Use `cli.Context()` for cancellation-aware daemon loops:
|
||||
`AddDaemonCommand` registers a command group with four subcommands:
|
||||
|
||||
```go
|
||||
func AddMyCommands(root *cli.Command) {
|
||||
cli.AddDaemonCommand(root, cli.DaemonCommandConfig{
|
||||
Name: "daemon", // Command group name (default: "daemon")
|
||||
Description: "Manage the worker", // Short description
|
||||
PIDFile: "/var/run/myapp.pid",
|
||||
HealthAddr: ":9090",
|
||||
RunForeground: func(ctx context.Context, daemon *process.Daemon) error {
|
||||
// Your long-running service logic here.
|
||||
// ctx is cancelled on SIGINT/SIGTERM.
|
||||
return runWorker(ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
This creates:
|
||||
|
||||
- `myapp daemon start` -- Re-executes the binary as a background process with `CORE_DAEMON=1`
|
||||
- `myapp daemon stop` -- Sends SIGTERM to the daemon, waits for shutdown (30s timeout, then SIGKILL)
|
||||
- `myapp daemon status` -- Reports whether the daemon is running and queries health endpoints
|
||||
- `myapp daemon run` -- Runs in the foreground (for development or process managers like systemd)
|
||||
|
||||
### Custom Persistent Flags
|
||||
|
||||
Add flags that apply to all daemon subcommands:
|
||||
|
||||
```go
|
||||
cli.AddDaemonCommand(root, cli.DaemonCommandConfig{
|
||||
// ...
|
||||
Flags: func(cmd *cli.Command) {
|
||||
cli.PersistentStringFlag(cmd, &configPath, "config", "c", "", "Config file")
|
||||
},
|
||||
ExtraStartArgs: func() []string {
|
||||
return []string{"--config", configPath}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`ExtraStartArgs` passes additional flags when re-executing the binary as a daemon.
|
||||
|
||||
### Health Endpoints
|
||||
|
||||
When `HealthAddr` is set, the daemon serves:
|
||||
|
||||
- `GET /health` -- Liveness check (200 if server is up, 503 if health checks fail)
|
||||
- `GET /ready` -- Readiness check (200 if `daemon.SetReady(true)` has been called)
|
||||
|
||||
The `start` command waits up to 5 seconds for the health endpoint to become available before reporting success.
|
||||
|
||||
## Simple Daemon (Manual)
|
||||
|
||||
For cases where you do not need the full command group:
|
||||
|
||||
```go
|
||||
func runDaemon(cmd *cli.Command, args []string) error {
|
||||
|
|
@ -42,39 +96,6 @@ func runDaemon(cmd *cli.Command, args []string) error {
|
|||
}
|
||||
```
|
||||
|
||||
## Daemon Helper
|
||||
|
||||
Use `cli.NewDaemon()` when you want a helper that writes a PID file and serves
|
||||
basic `/health` and `/ready` probes:
|
||||
|
||||
```go
|
||||
daemon := cli.NewDaemon(cli.DaemonOptions{
|
||||
PIDFile: "/tmp/core.pid",
|
||||
HealthAddr: "127.0.0.1:8080",
|
||||
HealthCheck: func() bool {
|
||||
return true
|
||||
},
|
||||
ReadyCheck: func() bool {
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
if err := daemon.Start(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = daemon.Stop(context.Background())
|
||||
}()
|
||||
```
|
||||
|
||||
`Start()` writes the current process ID to the configured file, and `Stop()`
|
||||
removes it after shutting the probe server down.
|
||||
|
||||
If you need to stop a daemon process from outside its own process tree, use
|
||||
`cli.StopPIDFile(pidFile, timeout)`. It sends `SIGTERM`, waits up to the
|
||||
timeout for exit, escalates to `SIGKILL` if needed, and removes the PID file
|
||||
after the process stops.
|
||||
|
||||
## Shutdown with Timeout
|
||||
|
||||
The daemon stop logic sends SIGTERM and waits up to 30 seconds. If the process has not exited by then, it sends SIGKILL and removes the PID file.
|
||||
|
|
@ -96,3 +117,15 @@ cli.Init(cli.Options{
|
|||
```
|
||||
|
||||
No manual signal handling is needed in commands. Use `cli.Context()` for cancellation-aware operations.
|
||||
|
||||
## DaemonCommandConfig Reference
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `Name` | `string` | Command group name (default: `"daemon"`) |
|
||||
| `Description` | `string` | Short description for help text |
|
||||
| `PIDFile` | `string` | PID file path (default flag value) |
|
||||
| `HealthAddr` | `string` | Health check listen address (default flag value) |
|
||||
| `RunForeground` | `func(ctx, daemon) error` | Service logic for foreground/daemon mode |
|
||||
| `Flags` | `func(cmd)` | Registers custom persistent flags |
|
||||
| `ExtraStartArgs` | `func() []string` | Additional args for background re-exec |
|
||||
|
|
|
|||
|
|
@ -57,10 +57,10 @@ If a command returns an `*ExitError`, the process exits with that code. All othe
|
|||
This is the preferred way to register commands. It wraps your registration function in a Core service that participates in the lifecycle:
|
||||
|
||||
```go
|
||||
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup
|
||||
func WithCommands(name string, register func(root *Command)) core.Option
|
||||
```
|
||||
|
||||
During `Main()`, the CLI calls your function with the Core instance. Internally it retrieves the root cobra command and passes it to your register function:
|
||||
During startup, the Core framework calls your function with the root cobra command. Your function adds subcommands to it:
|
||||
|
||||
```go
|
||||
func AddScoreCommands(root *cli.Command) {
|
||||
|
|
@ -98,17 +98,18 @@ func main() {
|
|||
}
|
||||
```
|
||||
|
||||
Where `Commands()` returns a slice of `CommandSetup` functions:
|
||||
Where `Commands()` returns a slice of framework options:
|
||||
|
||||
```go
|
||||
package lemcmd
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func Commands() []cli.CommandSetup {
|
||||
return []cli.CommandSetup{
|
||||
func Commands() []core.Option {
|
||||
return []core.Option{
|
||||
cli.WithCommands("score", addScoreCommands),
|
||||
cli.WithCommands("gen", addGenCommands),
|
||||
cli.WithCommands("data", addDataCommands),
|
||||
|
|
@ -140,7 +141,7 @@ If you need more control over the lifecycle:
|
|||
cli.Init(cli.Options{
|
||||
AppName: "myapp",
|
||||
Version: "1.0.0",
|
||||
Services: []core.Service{...},
|
||||
Services: []core.Option{...},
|
||||
OnReload: func() error { return reloadConfig() },
|
||||
})
|
||||
defer cli.Shutdown()
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ The framework has three layers:
|
|||
| `TreeNode` | Tree structure with box-drawing connectors |
|
||||
| `TaskTracker` | Concurrent task display with live spinners |
|
||||
| `CheckBuilder` | Fluent API for pass/fail/skip result lines |
|
||||
| `Daemon` | PID file and probe helper for background processes |
|
||||
| `AnsiStyle` | Terminal text styling (bold, dim, colour) |
|
||||
|
||||
## Built-in Services
|
||||
|
|
|
|||
|
|
@ -280,5 +280,4 @@ cli.LogInfo("server started", "port", 8080)
|
|||
cli.LogWarn("slow query", "duration", "3.2s")
|
||||
cli.LogError("connection failed", "err", err)
|
||||
cli.LogSecurity("login attempt", "user", "admin")
|
||||
cli.LogSecurityf("login attempt from %s", username)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -135,12 +135,6 @@ choice := cli.Choose("Select a file:", files,
|
|||
)
|
||||
```
|
||||
|
||||
Enable `cli.Filter()` to let users type a substring and narrow the visible choices before selecting a number:
|
||||
|
||||
```go
|
||||
choice := cli.Choose("Select:", items, cli.Filter[Item]())
|
||||
```
|
||||
|
||||
With a default selection:
|
||||
|
||||
```go
|
||||
|
|
|
|||
|
|
@ -34,19 +34,17 @@ When word-wrap is enabled, the stream tracks the current column position and ins
|
|||
|
||||
## Custom Output Writer
|
||||
|
||||
By default, streams write to the CLI stdout writer (`stdoutWriter()`), so tests can
|
||||
redirect output via `cli.SetStdout` and other callers can provide any `io.Writer`:
|
||||
By default, streams write to `os.Stdout`. Redirect to any `io.Writer`:
|
||||
|
||||
```go
|
||||
var buf strings.Builder
|
||||
stream := cli.NewStream(cli.WithStreamOutput(&buf))
|
||||
// ... write tokens ...
|
||||
stream.Done()
|
||||
result, ok := stream.CapturedOK() // or buf.String()
|
||||
result := stream.Captured() // or buf.String()
|
||||
```
|
||||
|
||||
`Captured()` returns the output as a string when using a `*strings.Builder` or any `fmt.Stringer`.
|
||||
`CapturedOK()` reports whether capture is supported by the configured writer.
|
||||
|
||||
## Reading from `io.Reader`
|
||||
|
||||
|
|
@ -70,15 +68,14 @@ stream.Done()
|
|||
| `Done()` | Signal completion (adds trailing newline if needed) |
|
||||
| `Wait()` | Block until `Done` is called |
|
||||
| `Column()` | Current column position |
|
||||
| `Captured()` | Get output as string (returns `""` if capture is unsupported) |
|
||||
| `CapturedOK()` | Get output and support status |
|
||||
| `Captured()` | Get output as string (requires `*strings.Builder` or `fmt.Stringer` writer) |
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `WithWordWrap(cols)` | Set the word-wrap column width |
|
||||
| `WithStreamOutput(w)` | Set the output writer (default: `stdoutWriter()`) |
|
||||
| `WithStreamOutput(w)` | Set the output writer (default: `os.Stdout`) |
|
||||
|
||||
## Example: LLM Token Streaming
|
||||
|
||||
|
|
|
|||
191
go.mod
191
go.mod
|
|
@ -1,45 +1,198 @@
|
|||
module dappco.re/go/core/cli
|
||||
module forge.lthn.ai/core/cli
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require dappco.re/go/core v0.4.7
|
||||
|
||||
require (
|
||||
dappco.re/go/core/i18n v0.1.7
|
||||
dappco.re/go/core/log v0.0.4
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/mattn/go-runewidth v0.0.21
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/term v0.41.0
|
||||
forge.lthn.ai/core/config v0.1.0
|
||||
forge.lthn.ai/core/go v0.3.0
|
||||
forge.lthn.ai/core/go-cache v0.1.0
|
||||
forge.lthn.ai/core/go-crypt v0.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.3.3 // indirect
|
||||
dappco.re/go/core/inference v0.1.7 // indirect
|
||||
forge.lthn.ai/core/agent v0.2.0
|
||||
forge.lthn.ai/core/api v0.1.0
|
||||
forge.lthn.ai/core/go-ansible v0.1.1
|
||||
forge.lthn.ai/core/go-build v0.2.0
|
||||
forge.lthn.ai/core/go-container v0.1.1
|
||||
forge.lthn.ai/core/go-devops v0.0.3
|
||||
forge.lthn.ai/core/go-help v0.1.2
|
||||
forge.lthn.ai/core/go-i18n v0.1.0
|
||||
forge.lthn.ai/core/go-infra v0.1.1
|
||||
forge.lthn.ai/core/go-io v0.1.0
|
||||
forge.lthn.ai/core/go-log v0.0.1
|
||||
forge.lthn.ai/core/go-process v0.1.2
|
||||
forge.lthn.ai/core/go-scm v0.2.0
|
||||
forge.lthn.ai/core/go-session v0.1.4
|
||||
forge.lthn.ai/core/lint v0.3.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/term v0.40.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
code.gitea.io/sdk/gitea v0.23.2 // indirect
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
|
||||
forge.lthn.ai/core/go-agentic v0.0.2 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.0 // indirect
|
||||
forge.lthn.ai/core/go-ratelimit v0.1.0 // indirect
|
||||
forge.lthn.ai/core/go-store v0.1.3 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/99designs/gqlgen v0.17.87 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/Snider/Borg v0.2.0 // indirect
|
||||
github.com/TwiN/go-color v1.4.1 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // 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/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
||||
github.com/gin-contrib/authz v1.0.6 // indirect
|
||||
github.com/gin-contrib/cors v1.7.6 // indirect
|
||||
github.com/gin-contrib/expvar v1.0.3 // indirect
|
||||
github.com/gin-contrib/gzip v1.2.5 // indirect
|
||||
github.com/gin-contrib/httpsign v1.0.3 // indirect
|
||||
github.com/gin-contrib/location/v2 v2.0.0 // indirect
|
||||
github.com/gin-contrib/pprof v1.5.3 // indirect
|
||||
github.com/gin-contrib/secure v1.1.2 // indirect
|
||||
github.com/gin-contrib/sessions v1.0.4 // indirect
|
||||
github.com/gin-contrib/slog v1.2.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-contrib/static v1.1.5 // indirect
|
||||
github.com/gin-contrib/timeout v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/spec v0.22.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 // indirect
|
||||
github.com/leaanthony/debme v1.2.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // 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/oasdiff/oasdiff v1.11.10 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sosodev/duration v1.3.1 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/swaggo/gin-swagger v1.6.1 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
|
||||
github.com/wI2L/jsondiff v0.7.0 // indirect
|
||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
)
|
||||
|
|
|
|||
478
go.sum
478
go.sum
|
|
@ -1,19 +1,105 @@
|
|||
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
|
||||
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
||||
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
||||
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
||||
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
|
||||
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
||||
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
|
||||
forge.lthn.ai/core/agent v0.2.0 h1:sx5NEeDd9uAi6lJJKj/MHIYfK+aIgcBm4hx8pJ/GvKs=
|
||||
forge.lthn.ai/core/agent v0.2.0/go.mod h1:8LwRpgyAW70zTmPGVa6Ncs6+Y/ddFd6WLmGhJry41wU=
|
||||
forge.lthn.ai/core/api v0.1.0 h1:ZKnQx+L9vxLQSEjwpsD1eNcIQrE4YKV1c2AlMtseM6o=
|
||||
forge.lthn.ai/core/api v0.1.0/go.mod h1:c86Lk9AmaS0xbiRCEG/+du8s9KyYNHnp8RED35gR/Fo=
|
||||
forge.lthn.ai/core/config v0.1.0 h1:qj14x/dnOWcsXMBQWAT3FtA+/sy6Qd+1NFTg5Xoil1I=
|
||||
forge.lthn.ai/core/config v0.1.0/go.mod h1:8HYA29drAWlX+bO4VI1JhmKUgGU66E2Xge8D3tKd3Dg=
|
||||
forge.lthn.ai/core/go v0.3.0 h1:mOG97ApMprwx9Ked62FdWVwXTGSF6JO6m0DrVpoH2Q4=
|
||||
forge.lthn.ai/core/go v0.3.0/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
|
||||
forge.lthn.ai/core/go-agentic v0.0.2 h1:G2nhiFY0j66A8/dyPXrS3CDYT1VLIin//GDszz4zEEo=
|
||||
forge.lthn.ai/core/go-agentic v0.0.2/go.mod h1:wTZRajs+rt0YJbRk26ijC1sfICbg8O2782ZhCz2tv/k=
|
||||
forge.lthn.ai/core/go-ansible v0.1.1 h1:OexkGQ5uxu1ZY6oFsBdhE6uYfdJH4vClmSsqrLCtJUo=
|
||||
forge.lthn.ai/core/go-ansible v0.1.1/go.mod h1:YzzsLN6oMvA3WsiXBuvVVSs7CrNc4ncPHaGw/hst9sc=
|
||||
forge.lthn.ai/core/go-build v0.2.0 h1:wFn343k/VWUneUGlVqq12Zh+FHQFPxoo90SePCK0RsM=
|
||||
forge.lthn.ai/core/go-build v0.2.0/go.mod h1:7+Olm65EhM4OWwDFPpqG2WW9y9D5gl52WhOJA7sRdTY=
|
||||
forge.lthn.ai/core/go-cache v0.1.0 h1:yxPf4bWPZ1jxMnXg8UHBv2xLhet2CRsq5E9PLQYjyj4=
|
||||
forge.lthn.ai/core/go-cache v0.1.0/go.mod h1:7WbprZVfx/+t4cbJFXMo4sloWk2Eny+rZd8x1Ay9rLk=
|
||||
forge.lthn.ai/core/go-container v0.1.1 h1:dpx0BLwGZEz1k5e7Jjmq1PgyP0Q8VgC1C/IvCN+6y5Y=
|
||||
forge.lthn.ai/core/go-container v0.1.1/go.mod h1:fw/UHnrSW4cEsnRZLZkkJd+b57d1o2Lk/lOl9LwXIXQ=
|
||||
forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw=
|
||||
forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw=
|
||||
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=
|
||||
forge.lthn.ai/core/go-help v0.1.2 h1:JP8hhJDAvfjvPuCyLRbU/VEm7YkENAs8debItLkon3w=
|
||||
forge.lthn.ai/core/go-help v0.1.2/go.mod h1:JSZVb4Gd+P/dTc9laDJsqVCI6OrVbBbBPyPmvw3j4p4=
|
||||
forge.lthn.ai/core/go-i18n v0.1.0 h1:F7JVSoVkZtzx9JfhpntM9z3iQm1vnuMUi/Zklhz8PCI=
|
||||
forge.lthn.ai/core/go-i18n v0.1.0/go.mod h1:Q4xsrxuNCl/6NfMv1daria7t1RSiyy8ml+6jiPtUcBs=
|
||||
forge.lthn.ai/core/go-inference v0.1.0 h1:pO7etYgqV8LMKFdpW8/2RWncuECZJCIcf8nnezeZ5R4=
|
||||
forge.lthn.ai/core/go-inference v0.1.0/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-infra v0.1.1 h1:1vagpgFHuvtqWtUXM3vej164Y6lDboo1HigvhpMgt7A=
|
||||
forge.lthn.ai/core/go-infra v0.1.1/go.mod h1:TQdwQuMf7NJHoXU+XV5JiNF9K5VKKxVpkZvJMk+iJ4c=
|
||||
forge.lthn.ai/core/go-io v0.1.0 h1:aYNvmbU2VVsjXnut0WQ4DfVxcFdheziahJB32mfeJ7g=
|
||||
forge.lthn.ai/core/go-io v0.1.0/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0=
|
||||
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
|
||||
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
forge.lthn.ai/core/go-process v0.1.2 h1:0fdLJq/DPssilN9E5yude/xHNfZRKHghIjo++b5aXgc=
|
||||
forge.lthn.ai/core/go-process v0.1.2/go.mod h1:9oxVALrZaZCqFe8YDdheIS5bRUV1SBz4tVW/MflAtxM=
|
||||
forge.lthn.ai/core/go-ratelimit v0.1.0 h1:8Y6Mb/K5FMDng4B0wIh7beD05KXddi1BDwatI96XouA=
|
||||
forge.lthn.ai/core/go-ratelimit v0.1.0/go.mod h1:YdpKYTjx0Amw5Wn2fenl50zVLkdfZcp7pIb3wmv0fHw=
|
||||
forge.lthn.ai/core/go-scm v0.2.0 h1:TvDyCzw0HWzXjmqe6uPc46nPaRzc7MPGswmwZt0CmXo=
|
||||
forge.lthn.ai/core/go-scm v0.2.0/go.mod h1:Q/PV2FbqDlWnAOsXAd1pgSiHOlRCPW4HcPmOt8Z9H+E=
|
||||
forge.lthn.ai/core/go-session v0.1.4 h1:AWdE7g2Aze2XE/yMfJVE/I907Secd5Mp1CODgAm4xWY=
|
||||
forge.lthn.ai/core/go-session v0.1.4/go.mod h1:c0mzZE6U05+T9s0MaNsJZ2kgW1cqIRH/KIGbaBXG16k=
|
||||
forge.lthn.ai/core/go-store v0.1.3 h1:CSVTRdsOXm2pl+FCs12fHOc9eM88DcZRY6HghN98w/I=
|
||||
forge.lthn.ai/core/go-store v0.1.3/go.mod h1:op+ftjAqYskPv4OGvHZQf7/DLiRnFIdT0XCQTKR/GjE=
|
||||
forge.lthn.ai/core/lint v0.3.0 h1:ar5TSsoMsHWbfubQbWQAEqBzHixy93un1rWZQTjB/B0=
|
||||
forge.lthn.ai/core/lint v0.3.0/go.mod h1:0/1HXRIX2qV2+dQH0HmUMMV9u1hEta6tj3K+mpo4Kdg=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8=
|
||||
github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
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/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
|
||||
github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY=
|
||||
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
|
||||
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
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/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
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.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
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=
|
||||
|
|
@ -26,60 +112,408 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
|||
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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k=
|
||||
github.com/gin-contrib/authz v1.0.6/go.mod h1:A2B5Im1M/HIoHPjLc31j3RlENSE6j8euJY9NFdzZeYo=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/expvar v1.0.3 h1:nIbUaokxZfUEC/35h+RyWCP1SMF/suV/ARbXL3H3jrw=
|
||||
github.com/gin-contrib/expvar v1.0.3/go.mod h1:bwqqmhty1Zl2JYVLzBIL6CSHDWDbQoQoicalAnBvUnY=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I=
|
||||
github.com/gin-contrib/httpsign v1.0.3/go.mod h1:n4GC7StmHNBhIzWzuW2njKbZMeEWh4tDbmn3bD1ab+k=
|
||||
github.com/gin-contrib/location/v2 v2.0.0 h1:iLx5RatHQHSxgC0tm2AG0sIuQKecI7FhREessVd6RWY=
|
||||
github.com/gin-contrib/location/v2 v2.0.0/go.mod h1:276TDNr25NENBA/NQZUuEIlwxy/I5CYVFIr/d2TgOdU=
|
||||
github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
|
||||
github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY=
|
||||
github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U=
|
||||
github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
|
||||
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmnzz4=
|
||||
github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM=
|
||||
github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw=
|
||||
github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
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-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
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/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4=
|
||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1/go.mod h1:3ebNU9QBrNpUO+Hj6bHaGpkh5pymDHQ+wwVPHTE4mCE=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
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/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-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.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
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/oasdiff/oasdiff v1.11.10 h1:4I9VrktUoHmwydkJqVOC7Bd6BXKu9dc4UUP3PIu1VjM=
|
||||
github.com/oasdiff/oasdiff v1.11.10/go.mod h1:GXARzmqBKN8lZHsTQD35ZM41ePbu6JdAZza4sRMeEKg=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
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=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
|
||||
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
|
||||
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
|
||||
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
|
||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
||||
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=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
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/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs=
|
||||
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/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
|
||||
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.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
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/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
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-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
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.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.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.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.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=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
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.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=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
|
|||
51
main.go
Normal file
51
main.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/cmd/config"
|
||||
"forge.lthn.ai/core/cli/cmd/doctor"
|
||||
"forge.lthn.ai/core/cli/cmd/gocmd"
|
||||
"forge.lthn.ai/core/cli/cmd/help"
|
||||
"forge.lthn.ai/core/cli/cmd/module"
|
||||
"forge.lthn.ai/core/cli/cmd/pkgcmd"
|
||||
"forge.lthn.ai/core/cli/cmd/plugin"
|
||||
"forge.lthn.ai/core/cli/cmd/service"
|
||||
"forge.lthn.ai/core/cli/cmd/session"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
// Ecosystem command packages — self-register via init() + cli.RegisterCommands()
|
||||
_ "forge.lthn.ai/core/agent/cmd/agent"
|
||||
_ "forge.lthn.ai/core/agent/cmd/dispatch"
|
||||
_ "forge.lthn.ai/core/agent/cmd/taskgit"
|
||||
_ "forge.lthn.ai/core/go-ansible/cmd/ansible"
|
||||
_ "forge.lthn.ai/core/api/cmd/api"
|
||||
_ "forge.lthn.ai/core/go-build/cmd/build"
|
||||
_ "forge.lthn.ai/core/go-build/cmd/ci"
|
||||
_ "forge.lthn.ai/core/go-build/cmd/sdk"
|
||||
_ "forge.lthn.ai/core/go-container/cmd/vm"
|
||||
_ "forge.lthn.ai/core/go-crypt/cmd/crypt"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/deploy"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/dev"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/docs"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/gitcmd"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/setup"
|
||||
_ "forge.lthn.ai/core/go-infra/cmd/monitor"
|
||||
_ "forge.lthn.ai/core/go-infra/cmd/prod"
|
||||
_ "forge.lthn.ai/core/go-scm/cmd/collect"
|
||||
_ "forge.lthn.ai/core/go-scm/cmd/forge"
|
||||
_ "forge.lthn.ai/core/go-scm/cmd/gitea"
|
||||
_ "forge.lthn.ai/core/lint/cmd/qa"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cli.Main(
|
||||
cli.WithCommands("config", config.AddConfigCommands),
|
||||
cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
cli.WithCommands("help", help.AddHelpCommands),
|
||||
cli.WithCommands("module", module.AddModuleCommands),
|
||||
cli.WithCommands("pkg", pkgcmd.AddPkgCommands),
|
||||
cli.WithCommands("plugin", plugin.AddPluginCommands),
|
||||
cli.WithCommands("session", session.AddSessionCommands),
|
||||
cli.WithCommands("go", gocmd.AddGoCommands),
|
||||
cli.WithCommands("service", service.AddServiceCommands),
|
||||
)
|
||||
}
|
||||
|
|
@ -18,9 +18,8 @@ const (
|
|||
)
|
||||
|
||||
var (
|
||||
colorEnabled = true
|
||||
colorEnabledMu sync.RWMutex
|
||||
asciiDisabledColors bool
|
||||
colorEnabled = true
|
||||
colorEnabledMu sync.RWMutex
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
@ -49,18 +48,6 @@ func ColorEnabled() bool {
|
|||
func SetColorEnabled(enabled bool) {
|
||||
colorEnabledMu.Lock()
|
||||
colorEnabled = enabled
|
||||
if enabled {
|
||||
asciiDisabledColors = false
|
||||
}
|
||||
colorEnabledMu.Unlock()
|
||||
}
|
||||
|
||||
func restoreColorIfASCII() {
|
||||
colorEnabledMu.Lock()
|
||||
if asciiDisabledColors {
|
||||
colorEnabled = true
|
||||
asciiDisabledColors = false
|
||||
}
|
||||
colorEnabledMu.Unlock()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ func TestRender_ColorEnabled_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestUseASCII_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
// Save original state
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Enable first, then UseASCII should disable colors
|
||||
SetColorEnabled(true)
|
||||
|
|
@ -86,76 +88,10 @@ func TestUseASCII_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestUseUnicodeAndEmojiRestoreColorsAfterASCII(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
|
||||
SetColorEnabled(true)
|
||||
UseASCII()
|
||||
if ColorEnabled() {
|
||||
t.Fatal("UseASCII should disable colors")
|
||||
}
|
||||
|
||||
UseUnicode()
|
||||
if !ColorEnabled() {
|
||||
t.Fatal("UseUnicode should restore colors after ASCII mode")
|
||||
}
|
||||
|
||||
UseASCII()
|
||||
if ColorEnabled() {
|
||||
t.Fatal("UseASCII should disable colors again")
|
||||
}
|
||||
|
||||
UseEmoji()
|
||||
if !ColorEnabled() {
|
||||
t.Fatal("UseEmoji should restore colors after ASCII mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_NilStyle_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
var s *AnsiStyle
|
||||
got := s.Render("test")
|
||||
if got != "test" {
|
||||
t.Errorf("Nil style should return plain text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnsiStyle_Bad(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// Invalid hex colour falls back to white (255,255,255).
|
||||
SetColorEnabled(true)
|
||||
style := NewStyle().Foreground("notahex")
|
||||
got := style.Render("text")
|
||||
if !strings.Contains(got, "text") {
|
||||
t.Errorf("Invalid hex: expected 'text' in output, got %q", got)
|
||||
}
|
||||
|
||||
// Short hex (less than 6 chars) also falls back.
|
||||
style = NewStyle().Foreground("#abc")
|
||||
got = style.Render("x")
|
||||
if !strings.Contains(got, "x") {
|
||||
t.Errorf("Short hex: expected 'x' in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnsiStyle_Ugly(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
original := ColorEnabled()
|
||||
defer SetColorEnabled(original)
|
||||
|
||||
// All style modifiers stack without panicking.
|
||||
SetColorEnabled(true)
|
||||
style := NewStyle().Bold().Dim().Italic().Underline().
|
||||
Foreground("#3b82f6").Background("#1f2937")
|
||||
got := style.Render("styled")
|
||||
if !strings.Contains(got, "styled") {
|
||||
t.Errorf("All modifiers: expected 'styled' in output, got %q", got)
|
||||
}
|
||||
|
||||
// Empty string renders without panicking.
|
||||
got = style.Render("")
|
||||
_ = got
|
||||
}
|
||||
|
|
|
|||
103
pkg/cli/app.go
103
pkg/cli/app.go
|
|
@ -1,21 +1,17 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go-crypt/crypt/openpgp"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go-io/workspace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
var cliLocaleFS embed.FS
|
||||
|
||||
// AppName is the default CLI application name.
|
||||
// Override with WithAppName before calling Main.
|
||||
var AppName = "core"
|
||||
|
|
@ -34,16 +30,9 @@ var (
|
|||
)
|
||||
|
||||
// SemVer returns the full SemVer 2.0.0 version string.
|
||||
//
|
||||
// Examples:
|
||||
// // Release only:
|
||||
// // AppVersion=1.2.0 -> 1.2.0
|
||||
// cli.AppVersion = "1.2.0"
|
||||
// fmt.Println(cli.SemVer())
|
||||
//
|
||||
// // Pre-release + commit + date:
|
||||
// // AppVersion=1.2.0, BuildPreRelease=dev.8, BuildCommit=df94c24, BuildDate=20260206
|
||||
// // -> 1.2.0-dev.8+df94c24.20260206
|
||||
// - 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 != "" {
|
||||
|
|
@ -67,42 +56,17 @@ func WithAppName(name string) {
|
|||
AppName = name
|
||||
}
|
||||
|
||||
// LocaleSource pairs a filesystem with a directory for loading translations.
|
||||
type LocaleSource = i18n.FSSource
|
||||
|
||||
// WithLocales returns a locale source for use with MainWithLocales.
|
||||
// Main initialises and runs the CLI application.
|
||||
// Pass command services via WithCommands to register CLI commands
|
||||
// through the Core framework lifecycle.
|
||||
//
|
||||
// Example:
|
||||
// fs := embed.FS{}
|
||||
// locales := cli.WithLocales(fs, "locales")
|
||||
// cli.MainWithLocales([]cli.LocaleSource{locales})
|
||||
func WithLocales(fsys fs.FS, dir string) LocaleSource {
|
||||
return LocaleSource{FS: fsys, Dir: dir}
|
||||
}
|
||||
|
||||
// CommandSetup is a function that registers commands on the CLI after init.
|
||||
// cli.Main(
|
||||
// cli.WithCommands("config", config.AddConfigCommands),
|
||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// )
|
||||
//
|
||||
// Example:
|
||||
// cli.Main(
|
||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// )
|
||||
type CommandSetup func(c *core.Core)
|
||||
|
||||
// Main initialises and runs the CLI with the framework's built-in translations.
|
||||
//
|
||||
// Example:
|
||||
// cli.WithAppName("core")
|
||||
// cli.Main(config.AddConfigCommands)
|
||||
func Main(commands ...CommandSetup) {
|
||||
MainWithLocales(nil, commands...)
|
||||
}
|
||||
|
||||
// MainWithLocales initialises and runs the CLI with additional translation sources.
|
||||
//
|
||||
// Example:
|
||||
// locales := []cli.LocaleSource{cli.WithLocales(embeddedLocales, "locales")}
|
||||
// cli.MainWithLocales(locales, doctor.AddDoctorCommands)
|
||||
func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
|
||||
// Exits with code 1 on error or panic.
|
||||
func Main(commands ...core.Option) {
|
||||
// Recovery from panics
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
|
@ -112,31 +76,28 @@ func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
|
|||
}
|
||||
}()
|
||||
|
||||
// Build locale sources: framework built-in + caller's extras + registered packages
|
||||
extraFS := []i18n.FSSource{
|
||||
{FS: cliLocaleFS, Dir: "locales"},
|
||||
}
|
||||
extraFS = append(extraFS, locales...)
|
||||
for _, lfs := range RegisteredLocales() {
|
||||
extraFS = append(extraFS, i18n.FSSource{FS: lfs, Dir: "."})
|
||||
// Core services load first, then command services
|
||||
services := []core.Option{
|
||||
core.WithName("i18n", NewI18nService(I18nOptions{})),
|
||||
core.WithName("log", NewLogService(log.Options{
|
||||
Level: log.LevelInfo,
|
||||
})),
|
||||
core.WithName("crypt", openpgp.New),
|
||||
core.WithName("workspace", workspace.New),
|
||||
}
|
||||
services = append(services, commands...)
|
||||
|
||||
// Initialise CLI runtime
|
||||
// Initialise CLI runtime with services
|
||||
if err := Init(Options{
|
||||
AppName: AppName,
|
||||
Version: SemVer(),
|
||||
I18nSources: extraFS,
|
||||
AppName: AppName,
|
||||
Version: SemVer(),
|
||||
Services: services,
|
||||
}); err != nil {
|
||||
Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
defer Shutdown()
|
||||
|
||||
// Run command setup functions
|
||||
for _, setup := range commands {
|
||||
setup(Core())
|
||||
}
|
||||
|
||||
// Add completion command to the CLI's root
|
||||
RootCmd().AddCommand(newCompletionCmd())
|
||||
|
||||
|
|
@ -200,13 +161,13 @@ PowerShell:
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
switch args[0] {
|
||||
case "bash":
|
||||
_ = cmd.Root().GenBashCompletion(stdoutWriter())
|
||||
_ = cmd.Root().GenBashCompletion(os.Stdout)
|
||||
case "zsh":
|
||||
_ = cmd.Root().GenZshCompletion(stdoutWriter())
|
||||
_ = cmd.Root().GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
_ = cmd.Root().GenFishCompletion(stdoutWriter(), true)
|
||||
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(stdoutWriter())
|
||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CheckBuilder provides fluent API for check results.
|
||||
type CheckBuilder struct {
|
||||
name string
|
||||
|
|
@ -38,7 +40,7 @@ func (c *CheckBuilder) Fail() *CheckBuilder {
|
|||
func (c *CheckBuilder) Skip() *CheckBuilder {
|
||||
c.status = "skipped"
|
||||
c.style = DimStyle
|
||||
c.icon = Glyph(":skip:")
|
||||
c.icon = "-"
|
||||
return c
|
||||
}
|
||||
|
||||
|
|
@ -64,27 +66,26 @@ func (c *CheckBuilder) Message(msg string) *CheckBuilder {
|
|||
|
||||
// String returns the formatted check line.
|
||||
func (c *CheckBuilder) String() string {
|
||||
icon := compileGlyphs(c.icon)
|
||||
icon := c.icon
|
||||
if c.style != nil {
|
||||
icon = c.style.Render(icon)
|
||||
icon = c.style.Render(c.icon)
|
||||
}
|
||||
|
||||
name := Pad(compileGlyphs(c.name), 20)
|
||||
status := Pad(compileGlyphs(c.status), 10)
|
||||
status := c.status
|
||||
if c.style != nil && c.status != "" {
|
||||
status = c.style.Render(status)
|
||||
status = c.style.Render(c.status)
|
||||
}
|
||||
|
||||
if c.duration != "" {
|
||||
return Sprintf(" %s %s %s %s", icon, name, status, DimStyle.Render(compileGlyphs(c.duration)))
|
||||
return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
|
||||
}
|
||||
if status != "" {
|
||||
return Sprintf(" %s %s %s", icon, name, status)
|
||||
return fmt.Sprintf(" %s %s %s", icon, c.name, status)
|
||||
}
|
||||
return Sprintf(" %s %s", icon, name)
|
||||
return fmt.Sprintf(" %s %s", icon, c.name)
|
||||
}
|
||||
|
||||
// Print outputs the check result.
|
||||
func (c *CheckBuilder) Print() {
|
||||
Println("%s", c.String())
|
||||
fmt.Println(c.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +1,49 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestCheckBuilder_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
func TestCheckBuilder(t *testing.T) {
|
||||
UseASCII() // Deterministic output
|
||||
|
||||
checkResult := Check("database").Pass()
|
||||
got := checkResult.String()
|
||||
// Pass
|
||||
c := Check("foo").Pass()
|
||||
got := c.String()
|
||||
if got == "" {
|
||||
t.Error("Pass: expected non-empty output")
|
||||
t.Error("Empty output for Pass")
|
||||
}
|
||||
if !strings.Contains(got, "database") {
|
||||
t.Errorf("Pass: expected name in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckBuilder_Bad(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
checkResult := Check("lint").Fail()
|
||||
got := checkResult.String()
|
||||
if got == "" {
|
||||
t.Error("Fail: expected non-empty output")
|
||||
}
|
||||
|
||||
checkResult = Check("build").Skip()
|
||||
got = checkResult.String()
|
||||
if got == "" {
|
||||
t.Error("Skip: expected non-empty output")
|
||||
}
|
||||
|
||||
checkResult = Check("tests").Warn()
|
||||
got = checkResult.String()
|
||||
if got == "" {
|
||||
t.Error("Warn: expected non-empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckBuilder_Ugly(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseASCII()
|
||||
|
||||
// Zero-value builder should not panic.
|
||||
checkResult := &CheckBuilder{}
|
||||
got := checkResult.String()
|
||||
if got == "" {
|
||||
t.Error("Ugly: empty builder should still produce output")
|
||||
}
|
||||
|
||||
// Duration and Message chaining.
|
||||
checkResult = Check("audit").Pass().Duration("2.3s").Message("all clear")
|
||||
got = checkResult.String()
|
||||
if !strings.Contains(got, "2.3s") {
|
||||
t.Errorf("Ugly: expected duration in output, got %q", got)
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,32 +173,6 @@ func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []stri
|
|||
}
|
||||
}
|
||||
|
||||
// StringArrayFlag adds a string array flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var tags []string
|
||||
// cli.StringArrayFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
|
||||
func StringArrayFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringArrayVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringArrayVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// StringToStringFlag adds a string-to-string map flag to a command.
|
||||
// The value will be stored in the provided pointer.
|
||||
//
|
||||
// var labels map[string]string
|
||||
// cli.StringToStringFlag(cmd, &labels, "label", "l", nil, "Labels to apply")
|
||||
func StringToStringFlag(cmd *Command, ptr *map[string]string, name, short string, def map[string]string, usage string) {
|
||||
if short != "" {
|
||||
cmd.Flags().StringToStringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.Flags().StringToStringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Persistent Flag Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -221,69 +195,6 @@ func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, u
|
|||
}
|
||||
}
|
||||
|
||||
// PersistentIntFlag adds a persistent integer flag (inherited by subcommands).
|
||||
func PersistentIntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().IntVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().IntVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentInt64Flag adds a persistent int64 flag (inherited by subcommands).
|
||||
func PersistentInt64Flag(cmd *Command, ptr *int64, name, short string, def int64, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().Int64VarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().Int64Var(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentFloat64Flag adds a persistent float64 flag (inherited by subcommands).
|
||||
func PersistentFloat64Flag(cmd *Command, ptr *float64, name, short string, def float64, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().Float64VarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().Float64Var(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentDurationFlag adds a persistent time.Duration flag (inherited by subcommands).
|
||||
func PersistentDurationFlag(cmd *Command, ptr *time.Duration, name, short string, def time.Duration, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().DurationVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().DurationVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentStringSliceFlag adds a persistent string slice flag (inherited by subcommands).
|
||||
func PersistentStringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().StringSliceVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().StringSliceVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentStringArrayFlag adds a persistent string array flag (inherited by subcommands).
|
||||
func PersistentStringArrayFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().StringArrayVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().StringArrayVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// PersistentStringToStringFlag adds a persistent string-to-string map flag (inherited by subcommands).
|
||||
func PersistentStringToStringFlag(cmd *Command, ptr *map[string]string, name, short string, def map[string]string, usage string) {
|
||||
if short != "" {
|
||||
cmd.PersistentFlags().StringToStringVarP(ptr, name, short, def, usage)
|
||||
} else {
|
||||
cmd.PersistentFlags().StringToStringVar(ptr, name, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Command Configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCommand_Good(t *testing.T) {
|
||||
// NewCommand creates a command with RunE.
|
||||
called := false
|
||||
cmd := NewCommand("build", "Build the project", "", func(cmd *Command, args []string) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if cmd == nil {
|
||||
t.Fatal("NewCommand: returned nil")
|
||||
}
|
||||
if cmd.Use != "build" {
|
||||
t.Errorf("NewCommand: Use=%q, expected 'build'", cmd.Use)
|
||||
}
|
||||
if cmd.RunE == nil {
|
||||
t.Fatal("NewCommand: RunE is nil")
|
||||
}
|
||||
_ = called
|
||||
|
||||
// NewGroup creates a command with no RunE.
|
||||
groupCmd := NewGroup("dev", "Development commands", "")
|
||||
if groupCmd.RunE != nil {
|
||||
t.Error("NewGroup: RunE should be nil")
|
||||
}
|
||||
|
||||
// NewRun creates a command with Run.
|
||||
runCmd := NewRun("version", "Show version", "", func(cmd *Command, args []string) {})
|
||||
if runCmd.Run == nil {
|
||||
t.Fatal("NewRun: Run is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_Bad(t *testing.T) {
|
||||
// NewCommand with empty long string should not set Long.
|
||||
cmd := NewCommand("test", "Short desc", "", func(cmd *Command, args []string) error {
|
||||
return nil
|
||||
})
|
||||
if cmd.Long != "" {
|
||||
t.Errorf("NewCommand: Long should be empty, got %q", cmd.Long)
|
||||
}
|
||||
|
||||
// Flag helpers with empty short should not add short flag.
|
||||
var value string
|
||||
StringFlag(cmd, &value, "output", "", "default", "Output path")
|
||||
if cmd.Flags().Lookup("output") == nil {
|
||||
t.Error("StringFlag: flag 'output' not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_Ugly(t *testing.T) {
|
||||
// WithArgs and WithExample are chainable.
|
||||
cmd := NewCommand("deploy", "Deploy", "Long desc", func(cmd *Command, args []string) error {
|
||||
return nil
|
||||
})
|
||||
result := WithExample(cmd, "core deploy production")
|
||||
if result != cmd {
|
||||
t.Error("WithExample: should return the same command")
|
||||
}
|
||||
if cmd.Example != "core deploy production" {
|
||||
t.Errorf("WithExample: Example=%q", cmd.Example)
|
||||
}
|
||||
|
||||
// ExactArgs, NoArgs, MinimumNArgs, MaximumNArgs, ArbitraryArgs should not panic.
|
||||
_ = ExactArgs(1)
|
||||
_ = NoArgs()
|
||||
_ = MinimumNArgs(1)
|
||||
_ = MaximumNArgs(5)
|
||||
_ = ArbitraryArgs()
|
||||
_ = RangeArgs(1, 3)
|
||||
}
|
||||
|
|
@ -2,150 +2,76 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"context"
|
||||
"iter"
|
||||
"sync"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// WithCommands returns a CommandSetup that registers a command group.
|
||||
// The register function receives the root cobra command during Main().
|
||||
// WithCommands creates a framework Option that registers a command group.
|
||||
// The register function receives the root command during service startup,
|
||||
// allowing commands to participate in the Core lifecycle.
|
||||
//
|
||||
// cli.Main(
|
||||
// cli.WithCommands("config", config.AddConfigCommands),
|
||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// )
|
||||
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup {
|
||||
return func(c *core.Core) {
|
||||
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
|
||||
if root, ok := c.App().Runtime.(*cobra.Command); ok {
|
||||
register(root)
|
||||
}
|
||||
appendLocales(localeFS...)
|
||||
}
|
||||
func WithCommands(name string, register func(root *Command)) core.Option {
|
||||
return core.WithName("cmd."+name, func(c *core.Core) (any, error) {
|
||||
return &commandService{core: c, register: register}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// CommandRegistration is a function that adds commands to the CLI root.
|
||||
//
|
||||
// Example:
|
||||
// func addCommands(root *cobra.Command) {
|
||||
// root.AddCommand(cli.NewRun("ping", "Ping API", "", func(cmd *cli.Command, args []string) {
|
||||
// cli.Println("pong")
|
||||
// }))
|
||||
// }
|
||||
type commandService struct {
|
||||
core *core.Core
|
||||
register func(root *Command)
|
||||
}
|
||||
|
||||
func (s *commandService) OnStartup(_ context.Context) error {
|
||||
if root, ok := s.core.App.(*cobra.Command); ok {
|
||||
s.register(root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommandRegistration is a function that adds commands to the root.
|
||||
type CommandRegistration func(root *cobra.Command)
|
||||
|
||||
var (
|
||||
registeredCommands []CommandRegistration
|
||||
registeredCommandsMu sync.Mutex
|
||||
commandsAttached bool
|
||||
registeredLocales []fs.FS
|
||||
)
|
||||
|
||||
// RegisterCommands registers a function that adds commands to the CLI.
|
||||
// Optionally pass a locale fs.FS to provide translations for the commands.
|
||||
// Call this in your package's init() to register commands.
|
||||
//
|
||||
// func init() {
|
||||
// cli.RegisterCommands(AddCommands, locales.FS)
|
||||
// cli.RegisterCommands(AddCommands)
|
||||
// }
|
||||
//
|
||||
// Example:
|
||||
// cli.RegisterCommands(func(root *cobra.Command) {
|
||||
// root.AddCommand(cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
|
||||
// cli.Println(cli.SemVer())
|
||||
// }))
|
||||
// })
|
||||
func RegisterCommands(fn CommandRegistration, localeFS ...fs.FS) {
|
||||
registeredCommandsMu.Lock()
|
||||
registeredCommands = append(registeredCommands, fn)
|
||||
attached := commandsAttached && instance != nil && instance.root != nil
|
||||
root := instance
|
||||
registeredCommandsMu.Unlock()
|
||||
|
||||
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
|
||||
appendLocales(localeFS...)
|
||||
|
||||
// If commands already attached (CLI already running), attach immediately
|
||||
if attached {
|
||||
fn(root.root)
|
||||
}
|
||||
}
|
||||
|
||||
// appendLocales appends non-nil locale filesystems to the registry.
|
||||
func appendLocales(localeFS ...fs.FS) {
|
||||
var nonempty []fs.FS
|
||||
for _, lfs := range localeFS {
|
||||
if lfs != nil {
|
||||
nonempty = append(nonempty, lfs)
|
||||
}
|
||||
}
|
||||
if len(nonempty) == 0 {
|
||||
return
|
||||
}
|
||||
registeredCommandsMu.Lock()
|
||||
registeredLocales = append(registeredLocales, nonempty...)
|
||||
registeredCommandsMu.Unlock()
|
||||
}
|
||||
|
||||
func localeSourcesFromFS(localeFS ...fs.FS) []LocaleSource {
|
||||
sources := make([]LocaleSource, 0, len(localeFS))
|
||||
for _, lfs := range localeFS {
|
||||
if lfs != nil {
|
||||
sources = append(sources, LocaleSource{FS: lfs, Dir: "."})
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func loadLocaleSources(sources ...LocaleSource) {
|
||||
svc := i18n.Default()
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
for _, src := range sources {
|
||||
if src.FS == nil {
|
||||
continue
|
||||
}
|
||||
if err := svc.AddLoader(i18n.NewFSLoader(src.FS, src.Dir)); err != nil {
|
||||
LogDebug("failed to load locale source", "dir", src.Dir, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredLocales returns all locale filesystems registered by command packages.
|
||||
//
|
||||
// Example:
|
||||
// for _, fs := range cli.RegisteredLocales() {
|
||||
// _ = fs
|
||||
// }
|
||||
func RegisteredLocales() []fs.FS {
|
||||
// func AddCommands(root *cobra.Command) {
|
||||
// root.AddCommand(myCmd)
|
||||
// }
|
||||
func RegisterCommands(fn CommandRegistration) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
if len(registeredLocales) == 0 {
|
||||
return nil
|
||||
registeredCommands = append(registeredCommands, fn)
|
||||
|
||||
// If commands already attached (CLI already running), attach immediately
|
||||
if commandsAttached && instance != nil && instance.root != nil {
|
||||
fn(instance.root)
|
||||
}
|
||||
out := make([]fs.FS, len(registeredLocales))
|
||||
copy(out, registeredLocales)
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisteredCommands returns an iterator over the registered command functions.
|
||||
//
|
||||
// Example:
|
||||
// for attach := range cli.RegisteredCommands() {
|
||||
// _ = attach
|
||||
// }
|
||||
func RegisteredCommands() iter.Seq[CommandRegistration] {
|
||||
return func(yield func(CommandRegistration) bool) {
|
||||
registeredCommandsMu.Lock()
|
||||
snapshot := make([]CommandRegistration, len(registeredCommands))
|
||||
copy(snapshot, registeredCommands)
|
||||
registeredCommandsMu.Unlock()
|
||||
|
||||
for _, fn := range snapshot {
|
||||
defer registeredCommandsMu.Unlock()
|
||||
for _, fn := range registeredCommands {
|
||||
if !yield(fn) {
|
||||
return
|
||||
}
|
||||
|
|
@ -157,12 +83,11 @@ func RegisteredCommands() iter.Seq[CommandRegistration] {
|
|||
// Called by Init() after creating the root command.
|
||||
func attachRegisteredCommands(root *cobra.Command) {
|
||||
registeredCommandsMu.Lock()
|
||||
snapshot := make([]CommandRegistration, len(registeredCommands))
|
||||
copy(snapshot, registeredCommands)
|
||||
commandsAttached = true
|
||||
registeredCommandsMu.Unlock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
|
||||
for _, fn := range snapshot {
|
||||
for _, fn := range registeredCommands {
|
||||
fn(root)
|
||||
}
|
||||
commandsAttached = true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,16 +12,21 @@ import (
|
|||
// resetGlobals clears the CLI singleton and command registry for test isolation.
|
||||
func resetGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
doReset()
|
||||
t.Cleanup(doReset)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
// Restore clean state after each test.
|
||||
registeredCommandsMu.Lock()
|
||||
registeredCommands = nil
|
||||
commandsAttached = false
|
||||
registeredCommandsMu.Unlock()
|
||||
if instance != nil {
|
||||
Shutdown()
|
||||
}
|
||||
instance = nil
|
||||
once = sync.Once{}
|
||||
})
|
||||
|
||||
// doReset clears all package-level state. Only safe from a single goroutine
|
||||
// with no concurrent RegisterCommands calls in flight (i.e. test setup/teardown).
|
||||
func doReset() {
|
||||
registeredCommandsMu.Lock()
|
||||
registeredCommands = nil
|
||||
registeredLocales = nil
|
||||
commandsAttached = false
|
||||
registeredCommandsMu.Unlock()
|
||||
if instance != nil {
|
||||
|
|
@ -159,28 +164,3 @@ func TestWithAppName_Good(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestRegisterCommands_Ugly tests edge cases and concurrent registration.
|
||||
func TestRegisterCommands_Ugly(t *testing.T) {
|
||||
t.Run("register nil function does not panic", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
// Registering a nil function should not panic at registration time.
|
||||
assert.NotPanics(t, func() {
|
||||
RegisterCommands(nil)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("re-init after shutdown is idempotent", func(t *testing.T) {
|
||||
resetGlobals(t)
|
||||
|
||||
err := Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
Shutdown()
|
||||
|
||||
resetGlobals(t)
|
||||
err = Init(Options{AppName: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, RootCmd())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,6 @@ import (
|
|||
)
|
||||
|
||||
// Mode represents the CLI execution mode.
|
||||
//
|
||||
// mode := cli.DetectMode()
|
||||
// if mode == cli.ModeDaemon {
|
||||
// cli.LogInfo("running headless")
|
||||
// }
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
|
|
@ -39,11 +34,7 @@ func (m Mode) String() string {
|
|||
}
|
||||
|
||||
// DetectMode determines the execution mode based on environment.
|
||||
//
|
||||
// mode := cli.DetectMode()
|
||||
// // cli.ModeDaemon when CORE_DAEMON=1
|
||||
// // cli.ModePipe when stdout is not a terminal
|
||||
// // cli.ModeInteractive otherwise
|
||||
// Checks CORE_DAEMON env var first, then TTY status.
|
||||
func DetectMode() Mode {
|
||||
if os.Getenv("CORE_DAEMON") == "1" {
|
||||
return ModeDaemon
|
||||
|
|
@ -55,37 +46,17 @@ func DetectMode() Mode {
|
|||
}
|
||||
|
||||
// IsTTY returns true if stdout is a terminal.
|
||||
//
|
||||
// if cli.IsTTY() {
|
||||
// cli.Success("interactive output enabled")
|
||||
// }
|
||||
func IsTTY() bool {
|
||||
if f, ok := stdoutWriter().(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// IsStdinTTY returns true if stdin is a terminal.
|
||||
//
|
||||
// if !cli.IsStdinTTY() {
|
||||
// cli.Warn("input is piped")
|
||||
// }
|
||||
func IsStdinTTY() bool {
|
||||
if f, ok := stdinReader().(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
||||
}
|
||||
|
||||
// IsStderrTTY returns true if stderr is a terminal.
|
||||
//
|
||||
// if cli.IsStderrTTY() {
|
||||
// cli.Progress("load", 1, 3, "config")
|
||||
// }
|
||||
func IsStderrTTY() bool {
|
||||
if f, ok := stderrWriter().(*os.File); ok {
|
||||
return term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
return false
|
||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
||||
}
|
||||
|
||||
|
|
|
|||
264
pkg/cli/daemon_cmd.go
Normal file
264
pkg/cli/daemon_cmd.go
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-process"
|
||||
)
|
||||
|
||||
// DaemonCommandConfig configures the generic daemon CLI command group.
|
||||
type DaemonCommandConfig struct {
|
||||
// Name is the command group name (default: "daemon").
|
||||
Name string
|
||||
|
||||
// Description is the short description for the command group.
|
||||
Description string
|
||||
|
||||
// RunForeground is called when the daemon runs in foreground mode.
|
||||
// Receives context (cancelled on SIGINT/SIGTERM) and the started Daemon.
|
||||
// If nil, the run command just blocks until signal.
|
||||
RunForeground func(ctx context.Context, daemon *process.Daemon) error
|
||||
|
||||
// PIDFile default path.
|
||||
PIDFile string
|
||||
|
||||
// HealthAddr default address.
|
||||
HealthAddr string
|
||||
|
||||
// ExtraStartArgs returns additional CLI args to pass when re-execing
|
||||
// the binary as a background daemon.
|
||||
ExtraStartArgs func() []string
|
||||
|
||||
// Flags registers custom persistent flags on the daemon command group.
|
||||
Flags func(cmd *Command)
|
||||
}
|
||||
|
||||
// AddDaemonCommand registers start/stop/status/run subcommands on root.
|
||||
func AddDaemonCommand(root *Command, cfg DaemonCommandConfig) {
|
||||
if cfg.Name == "" {
|
||||
cfg.Name = "daemon"
|
||||
}
|
||||
if cfg.Description == "" {
|
||||
cfg.Description = "Manage the background daemon"
|
||||
}
|
||||
|
||||
daemonCmd := NewGroup(
|
||||
cfg.Name,
|
||||
cfg.Description,
|
||||
fmt.Sprintf("Manage the background daemon process.\n\n"+
|
||||
"Subcommands:\n"+
|
||||
" start - Start the daemon in the background\n"+
|
||||
" stop - Stop the running daemon\n"+
|
||||
" status - Show daemon status\n"+
|
||||
" run - Run in foreground (for development/debugging)"),
|
||||
)
|
||||
|
||||
PersistentStringFlag(daemonCmd, &cfg.HealthAddr, "health-addr", "", cfg.HealthAddr,
|
||||
"Health check endpoint address (empty to disable)")
|
||||
PersistentStringFlag(daemonCmd, &cfg.PIDFile, "pid-file", "", cfg.PIDFile,
|
||||
"PID file path (empty to disable)")
|
||||
|
||||
if cfg.Flags != nil {
|
||||
cfg.Flags(daemonCmd)
|
||||
}
|
||||
|
||||
startCmd := NewCommand("start", "Start the daemon in the background",
|
||||
"Re-executes the binary as a background daemon process.\n"+
|
||||
"The daemon PID is written to the PID file for later management.",
|
||||
func(cmd *Command, args []string) error {
|
||||
return daemonRunStart(cfg)
|
||||
},
|
||||
)
|
||||
|
||||
stopCmd := NewCommand("stop", "Stop the running daemon",
|
||||
"Sends SIGTERM to the daemon process identified by the PID file.\n"+
|
||||
"Waits for graceful shutdown before returning.",
|
||||
func(cmd *Command, args []string) error {
|
||||
return daemonRunStop(cfg)
|
||||
},
|
||||
)
|
||||
|
||||
statusCmd := NewCommand("status", "Show daemon status",
|
||||
"Checks if the daemon is running and queries its health endpoint.",
|
||||
func(cmd *Command, args []string) error {
|
||||
return daemonRunStatus(cfg)
|
||||
},
|
||||
)
|
||||
|
||||
runCmd := NewCommand("run", "Run the daemon in the foreground",
|
||||
"Runs the daemon in the current terminal (blocks until SIGINT/SIGTERM).\n"+
|
||||
"Useful for development, debugging, or running under a process manager.",
|
||||
func(cmd *Command, args []string) error {
|
||||
return daemonRunForeground(cfg)
|
||||
},
|
||||
)
|
||||
|
||||
daemonCmd.AddCommand(startCmd, stopCmd, statusCmd, runCmd)
|
||||
root.AddCommand(daemonCmd)
|
||||
}
|
||||
|
||||
func daemonRunStart(cfg DaemonCommandConfig) error {
|
||||
if pid, running := process.ReadPID(cfg.PIDFile); running {
|
||||
return fmt.Errorf("daemon already running (PID %d)", pid)
|
||||
}
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find executable: %w", err)
|
||||
}
|
||||
|
||||
args := []string{cfg.Name, "run",
|
||||
"--health-addr", cfg.HealthAddr,
|
||||
"--pid-file", cfg.PIDFile,
|
||||
}
|
||||
|
||||
if cfg.ExtraStartArgs != nil {
|
||||
args = append(args, cfg.ExtraStartArgs()...)
|
||||
}
|
||||
|
||||
cmd := exec.Command(exePath, args...)
|
||||
cmd.Env = append(os.Environ(), "CORE_DAEMON=1")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Stdin = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start daemon: %w", err)
|
||||
}
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
if cfg.HealthAddr != "" {
|
||||
if process.WaitForHealth(cfg.HealthAddr, 5_000) {
|
||||
LogInfo(fmt.Sprintf("Daemon started (PID %d, health %s)", pid, cfg.HealthAddr))
|
||||
} else {
|
||||
LogInfo(fmt.Sprintf("Daemon started (PID %d, health not yet ready)", pid))
|
||||
}
|
||||
} else {
|
||||
LogInfo(fmt.Sprintf("Daemon started (PID %d)", pid))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func daemonRunStop(cfg DaemonCommandConfig) error {
|
||||
pid, running := process.ReadPID(cfg.PIDFile)
|
||||
if !running {
|
||||
LogInfo("Daemon is not running")
|
||||
return nil
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find process %d: %w", pid, err)
|
||||
}
|
||||
|
||||
LogInfo(fmt.Sprintf("Stopping daemon (PID %d)", pid))
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to send SIGTERM to PID %d: %w", pid, err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
// Process is gone — clean up PID file if it lingers.
|
||||
_ = os.Remove(cfg.PIDFile)
|
||||
LogInfo("Daemon stopped")
|
||||
return nil
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
|
||||
LogWarn("Daemon did not stop within 30s, sending SIGKILL")
|
||||
_ = proc.Signal(syscall.SIGKILL)
|
||||
_ = os.Remove(cfg.PIDFile)
|
||||
LogInfo("Daemon killed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func daemonRunStatus(cfg DaemonCommandConfig) error {
|
||||
pid, running := process.ReadPID(cfg.PIDFile)
|
||||
if !running {
|
||||
fmt.Println("Daemon is not running")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Daemon is running (PID %d)\n", pid)
|
||||
|
||||
if cfg.HealthAddr != "" {
|
||||
healthURL := fmt.Sprintf("http://%s/health", cfg.HealthAddr)
|
||||
resp, err := http.Get(healthURL)
|
||||
if err != nil {
|
||||
fmt.Printf("Health: unreachable (%v)\n", err)
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
fmt.Println("Health: ok")
|
||||
} else {
|
||||
fmt.Printf("Health: unhealthy (HTTP %d)\n", resp.StatusCode)
|
||||
}
|
||||
|
||||
readyURL := fmt.Sprintf("http://%s/ready", cfg.HealthAddr)
|
||||
resp2, err := http.Get(readyURL)
|
||||
if err == nil {
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode == http.StatusOK {
|
||||
fmt.Println("Ready: yes")
|
||||
} else {
|
||||
fmt.Println("Ready: no")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func daemonRunForeground(cfg DaemonCommandConfig) error {
|
||||
os.Setenv("CORE_DAEMON", "1")
|
||||
|
||||
daemon := process.NewDaemon(process.DaemonOptions{
|
||||
PIDFile: cfg.PIDFile,
|
||||
HealthAddr: cfg.HealthAddr,
|
||||
ShutdownTimeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
if err := daemon.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start daemon: %w", err)
|
||||
}
|
||||
|
||||
daemon.SetReady(true)
|
||||
|
||||
ctx := Context()
|
||||
|
||||
if cfg.RunForeground != nil {
|
||||
svcErr := make(chan error, 1)
|
||||
go func() {
|
||||
svcErr <- cfg.RunForeground(ctx, daemon)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
LogInfo("Shutting down daemon")
|
||||
case err := <-svcErr:
|
||||
if err != nil {
|
||||
LogError(fmt.Sprintf("Service exited with error: %v", err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
return daemon.Stop()
|
||||
}
|
||||
44
pkg/cli/daemon_cmd_test.go
Normal file
44
pkg/cli/daemon_cmd_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddDaemonCommand_RegistersSubcommands(t *testing.T) {
|
||||
root := &Command{Use: "test"}
|
||||
|
||||
AddDaemonCommand(root, DaemonCommandConfig{
|
||||
Name: "daemon",
|
||||
PIDFile: "/tmp/test-daemon.pid",
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
||||
// Should have the daemon command
|
||||
daemonCmd, _, err := root.Find([]string{"daemon"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, daemonCmd)
|
||||
|
||||
// Should have subcommands
|
||||
var subNames []string
|
||||
for _, sub := range daemonCmd.Commands() {
|
||||
subNames = append(subNames, sub.Name())
|
||||
}
|
||||
assert.Contains(t, subNames, "start")
|
||||
assert.Contains(t, subNames, "stop")
|
||||
assert.Contains(t, subNames, "status")
|
||||
assert.Contains(t, subNames, "run")
|
||||
}
|
||||
|
||||
func TestDaemonCommandConfig_DefaultName(t *testing.T) {
|
||||
root := &Command{Use: "test"}
|
||||
|
||||
AddDaemonCommand(root, DaemonCommandConfig{})
|
||||
|
||||
// Should default to "daemon"
|
||||
daemonCmd, _, err := root.Find([]string{"daemon"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, daemonCmd)
|
||||
}
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DaemonOptions configures a background process helper.
|
||||
//
|
||||
// daemon := cli.NewDaemon(cli.DaemonOptions{
|
||||
// PIDFile: "/tmp/core.pid",
|
||||
// HealthAddr: "127.0.0.1:8080",
|
||||
// })
|
||||
type DaemonOptions struct {
|
||||
// PIDFile stores the current process ID on Start and removes it on Stop.
|
||||
PIDFile string
|
||||
|
||||
// HealthAddr binds the HTTP health server.
|
||||
// Pass an empty string to disable the server.
|
||||
HealthAddr string
|
||||
|
||||
// HealthPath serves the liveness probe endpoint.
|
||||
HealthPath string
|
||||
|
||||
// ReadyPath serves the readiness probe endpoint.
|
||||
ReadyPath string
|
||||
|
||||
// HealthCheck reports whether the process is healthy.
|
||||
// Defaults to true when nil.
|
||||
HealthCheck func() bool
|
||||
|
||||
// ReadyCheck reports whether the process is ready to serve traffic.
|
||||
// Defaults to HealthCheck when nil, or true when both are nil.
|
||||
ReadyCheck func() bool
|
||||
}
|
||||
|
||||
// Daemon manages a PID file and optional HTTP health endpoints.
|
||||
//
|
||||
// daemon := cli.NewDaemon(cli.DaemonOptions{PIDFile: "/tmp/core.pid"})
|
||||
// _ = daemon.Start(context.Background())
|
||||
type Daemon struct {
|
||||
opts DaemonOptions
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
addr string
|
||||
started bool
|
||||
}
|
||||
|
||||
var (
|
||||
processNow = time.Now
|
||||
processSleep = time.Sleep
|
||||
processAlive = func(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
err = proc.Signal(syscall.Signal(0))
|
||||
return err == nil || errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
processSignal = func(pid int, sig syscall.Signal) error {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return proc.Signal(sig)
|
||||
}
|
||||
processPollInterval = 100 * time.Millisecond
|
||||
processShutdownWait = 30 * time.Second
|
||||
)
|
||||
|
||||
// NewDaemon creates a daemon helper with sensible defaults.
|
||||
func NewDaemon(opts DaemonOptions) *Daemon {
|
||||
if opts.HealthPath == "" {
|
||||
opts.HealthPath = "/health"
|
||||
}
|
||||
if opts.ReadyPath == "" {
|
||||
opts.ReadyPath = "/ready"
|
||||
}
|
||||
return &Daemon{opts: opts}
|
||||
}
|
||||
|
||||
// Start writes the PID file and starts the health server, if configured.
|
||||
func (d *Daemon) Start(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.started {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := d.writePIDFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.opts.HealthAddr != "" {
|
||||
if err := d.startHealthServer(ctx); err != nil {
|
||||
_ = d.removePIDFile()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
d.started = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts down the health server and removes the PID file.
|
||||
func (d *Daemon) Stop(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
server := d.server
|
||||
listener := d.listener
|
||||
d.server = nil
|
||||
d.listener = nil
|
||||
d.addr = ""
|
||||
d.started = false
|
||||
d.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
|
||||
if server != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil && !isClosedServerError(err) {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if listener != nil {
|
||||
if err := listener.Close(); err != nil && !isListenerClosedError(err) && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.removePIDFile(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// HealthAddr returns the bound health server address, if running.
|
||||
func (d *Daemon) HealthAddr() string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.addr != "" {
|
||||
return d.addr
|
||||
}
|
||||
return d.opts.HealthAddr
|
||||
}
|
||||
|
||||
// StopPIDFile sends SIGTERM to the process identified by pidFile, waits for it
|
||||
// to exit, escalates to SIGKILL after the timeout, and then removes the file.
|
||||
//
|
||||
// If the PID file does not exist, StopPIDFile returns nil.
|
||||
func StopPIDFile(pidFile string, timeout time.Duration) error {
|
||||
if pidFile == "" {
|
||||
return nil
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = processShutdownWait
|
||||
}
|
||||
|
||||
rawPID, err := os.ReadFile(pidFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
pid, err := parsePID(strings.TrimSpace(string(rawPID)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse pid file %q: %w", pidFile, err)
|
||||
}
|
||||
|
||||
if err := processSignal(pid, syscall.SIGTERM); err != nil && !isProcessGone(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
deadline := processNow().Add(timeout)
|
||||
for processAlive(pid) && processNow().Before(deadline) {
|
||||
processSleep(processPollInterval)
|
||||
}
|
||||
|
||||
if processAlive(pid) {
|
||||
if err := processSignal(pid, syscall.SIGKILL); err != nil && !isProcessGone(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
deadline = processNow().Add(processShutdownWait)
|
||||
for processAlive(pid) && processNow().Before(deadline) {
|
||||
processSleep(processPollInterval)
|
||||
}
|
||||
|
||||
if processAlive(pid) {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
}
|
||||
}
|
||||
|
||||
return os.Remove(pidFile)
|
||||
}
|
||||
|
||||
func parsePID(raw string) (int, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("empty pid")
|
||||
}
|
||||
pid, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if pid <= 0 {
|
||||
return 0, fmt.Errorf("invalid pid %d", pid)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
func isProcessGone(err error) bool {
|
||||
return errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH)
|
||||
}
|
||||
|
||||
func (d *Daemon) writePIDFile() error {
|
||||
if d.opts.PIDFile == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(d.opts.PIDFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(d.opts.PIDFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func (d *Daemon) removePIDFile() error {
|
||||
if d.opts.PIDFile == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(d.opts.PIDFile); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) startHealthServer(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
healthCheck := d.opts.HealthCheck
|
||||
if healthCheck == nil {
|
||||
healthCheck = func() bool { return true }
|
||||
}
|
||||
readyCheck := d.opts.ReadyCheck
|
||||
if readyCheck == nil {
|
||||
readyCheck = healthCheck
|
||||
}
|
||||
|
||||
mux.HandleFunc(d.opts.HealthPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeProbe(w, healthCheck())
|
||||
})
|
||||
mux.HandleFunc(d.opts.ReadyPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeProbe(w, readyCheck())
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", d.opts.HealthAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: mux,
|
||||
BaseContext: func(net.Listener) context.Context {
|
||||
return ctx
|
||||
},
|
||||
}
|
||||
|
||||
d.listener = listener
|
||||
d.server = server
|
||||
d.addr = listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
err := server.Serve(listener)
|
||||
if err != nil && !isClosedServerError(err) {
|
||||
_ = err
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeProbe(w http.ResponseWriter, ok bool) {
|
||||
if ok {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "ok\n")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = io.WriteString(w, "unhealthy\n")
|
||||
}
|
||||
|
||||
func isClosedServerError(err error) bool {
|
||||
return err == nil || err == http.ErrServerClosed
|
||||
}
|
||||
|
||||
func isListenerClosedError(err error) bool {
|
||||
return err == nil || errors.Is(err, net.ErrClosed)
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDaemon_StartStop(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
pidFile := filepath.Join(tmp, "daemon.pid")
|
||||
ready := false
|
||||
|
||||
daemon := NewDaemon(DaemonOptions{
|
||||
PIDFile: pidFile,
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
HealthCheck: func() bool {
|
||||
return true
|
||||
},
|
||||
ReadyCheck: func() bool {
|
||||
return ready
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, daemon.Start(context.Background()))
|
||||
defer func() {
|
||||
require.NoError(t, daemon.Stop(context.Background()))
|
||||
}()
|
||||
|
||||
rawPID, err := os.ReadFile(pidFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, strconv.Itoa(os.Getpid()), strings.TrimSpace(string(rawPID)))
|
||||
|
||||
addr := daemon.HealthAddr()
|
||||
require.NotEmpty(t, addr)
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
resp, err := client.Get("http://" + addr + "/health")
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "ok\n", string(body))
|
||||
|
||||
resp, err = client.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
assert.Equal(t, "unhealthy\n", string(body))
|
||||
|
||||
ready = true
|
||||
|
||||
resp, err = client.Get("http://" + addr + "/ready")
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "ok\n", string(body))
|
||||
}
|
||||
|
||||
func TestDaemon_StopRemovesPIDFile(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
pidFile := filepath.Join(tmp, "daemon.pid")
|
||||
|
||||
daemon := NewDaemon(DaemonOptions{PIDFile: pidFile})
|
||||
require.NoError(t, daemon.Start(context.Background()))
|
||||
|
||||
_, err := os.Stat(pidFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, daemon.Stop(context.Background()))
|
||||
|
||||
_, err = os.Stat(pidFile)
|
||||
require.Error(t, err)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestStopPIDFile_Good(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
pidFile := filepath.Join(tmp, "daemon.pid")
|
||||
require.NoError(t, os.WriteFile(pidFile, []byte("1234\n"), 0o644))
|
||||
|
||||
originalSignal := processSignal
|
||||
originalAlive := processAlive
|
||||
originalNow := processNow
|
||||
originalSleep := processSleep
|
||||
originalPoll := processPollInterval
|
||||
originalShutdownWait := processShutdownWait
|
||||
t.Cleanup(func() {
|
||||
processSignal = originalSignal
|
||||
processAlive = originalAlive
|
||||
processNow = originalNow
|
||||
processSleep = originalSleep
|
||||
processPollInterval = originalPoll
|
||||
processShutdownWait = originalShutdownWait
|
||||
})
|
||||
|
||||
var mu sync.Mutex
|
||||
var signals []syscall.Signal
|
||||
processSignal = func(pid int, sig syscall.Signal) error {
|
||||
mu.Lock()
|
||||
signals = append(signals, sig)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
processAlive = func(pid int) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(signals) == 0 {
|
||||
return true
|
||||
}
|
||||
return signals[len(signals)-1] != syscall.SIGTERM
|
||||
}
|
||||
processPollInterval = 0
|
||||
processShutdownWait = 0
|
||||
|
||||
require.NoError(t, StopPIDFile(pidFile, time.Second))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, []syscall.Signal{syscall.SIGTERM}, signals)
|
||||
|
||||
_, err := os.Stat(pidFile)
|
||||
require.Error(t, err)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestStopPIDFile_Bad_Escalates(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
pidFile := filepath.Join(tmp, "daemon.pid")
|
||||
require.NoError(t, os.WriteFile(pidFile, []byte("4321\n"), 0o644))
|
||||
|
||||
originalSignal := processSignal
|
||||
originalAlive := processAlive
|
||||
originalNow := processNow
|
||||
originalSleep := processSleep
|
||||
originalPoll := processPollInterval
|
||||
originalShutdownWait := processShutdownWait
|
||||
t.Cleanup(func() {
|
||||
processSignal = originalSignal
|
||||
processAlive = originalAlive
|
||||
processNow = originalNow
|
||||
processSleep = originalSleep
|
||||
processPollInterval = originalPoll
|
||||
processShutdownWait = originalShutdownWait
|
||||
})
|
||||
|
||||
var mu sync.Mutex
|
||||
var signals []syscall.Signal
|
||||
current := time.Unix(0, 0)
|
||||
processNow = func() time.Time {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return current
|
||||
}
|
||||
processSleep = func(d time.Duration) {
|
||||
mu.Lock()
|
||||
current = current.Add(d)
|
||||
mu.Unlock()
|
||||
}
|
||||
processSignal = func(pid int, sig syscall.Signal) error {
|
||||
mu.Lock()
|
||||
signals = append(signals, sig)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
processAlive = func(pid int) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(signals) == 0 {
|
||||
return true
|
||||
}
|
||||
return signals[len(signals)-1] != syscall.SIGKILL
|
||||
}
|
||||
processPollInterval = 10 * time.Millisecond
|
||||
processShutdownWait = 0
|
||||
|
||||
require.NoError(t, StopPIDFile(pidFile, 15*time.Millisecond))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL}, signals)
|
||||
}
|
||||
|
|
@ -6,21 +6,16 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDetectMode_Good(t *testing.T) {
|
||||
t.Setenv("CORE_DAEMON", "1")
|
||||
assert.Equal(t, ModeDaemon, DetectMode())
|
||||
}
|
||||
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())
|
||||
})
|
||||
|
||||
func TestDetectMode_Bad(t *testing.T) {
|
||||
t.Setenv("CORE_DAEMON", "0")
|
||||
mode := DetectMode()
|
||||
assert.NotEqual(t, ModeDaemon, mode)
|
||||
}
|
||||
|
||||
func TestDetectMode_Ugly(t *testing.T) {
|
||||
// Mode.String() covers all branches including the default unknown case.
|
||||
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())
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,12 +78,6 @@ func Join(errs ...error) error {
|
|||
}
|
||||
|
||||
// ExitError represents an error that should cause the CLI to exit with a specific code.
|
||||
//
|
||||
// err := cli.Exit(2, cli.Err("validation failed"))
|
||||
// var exitErr *cli.ExitError
|
||||
// if cli.As(err, &exitErr) {
|
||||
// cli.Println("exit code:", exitErr.Code)
|
||||
// }
|
||||
type ExitError struct {
|
||||
Code int
|
||||
Err error
|
||||
|
|
@ -101,8 +95,7 @@ func (e *ExitError) Unwrap() error {
|
|||
}
|
||||
|
||||
// Exit creates a new ExitError with the given code and error.
|
||||
//
|
||||
// return cli.Exit(2, cli.Err("validation failed"))
|
||||
// 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
|
||||
|
|
@ -120,7 +113,7 @@ func Exit(code int, err error) error {
|
|||
func Fatal(err error) {
|
||||
if err != nil {
|
||||
LogError("Fatal error", "err", err)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -131,7 +124,7 @@ func Fatal(err error) {
|
|||
func Fatalf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
LogError("Fatal error", "msg", msg)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +140,7 @@ func FatalWrap(err error, msg string) {
|
|||
}
|
||||
LogError("Fatal error", "msg", msg, "err", err)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +157,6 @@ func FatalWrapVerb(err error, verb, subject string) {
|
|||
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(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestErrors_Good(t *testing.T) {
|
||||
// Err creates a formatted error.
|
||||
err := Err("key not found: %s", "theme")
|
||||
if err == nil {
|
||||
t.Fatal("Err: expected non-nil error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "theme") {
|
||||
t.Errorf("Err: expected 'theme' in message, got %q", err.Error())
|
||||
}
|
||||
|
||||
// Wrap prepends a message.
|
||||
base := errors.New("connection refused")
|
||||
wrapped := Wrap(base, "connect to database")
|
||||
if !strings.Contains(wrapped.Error(), "connect to database") {
|
||||
t.Errorf("Wrap: expected prefix in message, got %q", wrapped.Error())
|
||||
}
|
||||
if !Is(wrapped, base) {
|
||||
t.Error("Wrap: errors.Is should unwrap to original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors_Bad(t *testing.T) {
|
||||
// Wrap with nil error returns nil.
|
||||
if Wrap(nil, "should be nil") != nil {
|
||||
t.Error("Wrap(nil): expected nil return")
|
||||
}
|
||||
|
||||
// WrapVerb with nil error returns nil.
|
||||
if WrapVerb(nil, "load", "config") != nil {
|
||||
t.Error("WrapVerb(nil): expected nil return")
|
||||
}
|
||||
|
||||
// WrapAction with nil error returns nil.
|
||||
if WrapAction(nil, "connect") != nil {
|
||||
t.Error("WrapAction(nil): expected nil return")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors_Ugly(t *testing.T) {
|
||||
// Join with multiple errors.
|
||||
err1 := Err("first error")
|
||||
err2 := Err("second error")
|
||||
joined := Join(err1, err2)
|
||||
if joined == nil {
|
||||
t.Fatal("Join: expected non-nil error")
|
||||
}
|
||||
if !Is(joined, err1) {
|
||||
t.Error("Join: errors.Is should find first error")
|
||||
}
|
||||
|
||||
// Exit creates ExitError with correct code.
|
||||
exitErr := Exit(2, Err("exit with code 2"))
|
||||
if exitErr == nil {
|
||||
t.Fatal("Exit: expected non-nil error")
|
||||
}
|
||||
var exitErrorValue *ExitError
|
||||
if !As(exitErr, &exitErrorValue) {
|
||||
t.Fatal("Exit: expected *ExitError type")
|
||||
}
|
||||
if exitErrorValue.Code != 2 {
|
||||
t.Errorf("Exit: expected code 2, got %d", exitErrorValue.Code)
|
||||
}
|
||||
|
||||
// Exit with nil returns nil.
|
||||
if Exit(1, nil) != nil {
|
||||
t.Error("Exit(nil): expected nil return")
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ import (
|
|||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
|
|
@ -61,7 +60,7 @@ func NewFrame(variant string) *Frame {
|
|||
variant: variant,
|
||||
layout: Layout(variant),
|
||||
models: make(map[Region]Model),
|
||||
out: stderrWriter(),
|
||||
out: os.Stdout,
|
||||
done: make(chan struct{}),
|
||||
focused: RegionContent,
|
||||
keyMap: DefaultKeyMap(),
|
||||
|
|
@ -70,15 +69,6 @@ func NewFrame(variant string) *Frame {
|
|||
}
|
||||
}
|
||||
|
||||
// WithOutput sets the destination writer for rendered output.
|
||||
// Pass nil to keep the current writer unchanged.
|
||||
func (f *Frame) WithOutput(out io.Writer) *Frame {
|
||||
if out != nil {
|
||||
f.out = out
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Header sets the Header region model.
|
||||
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
|
||||
|
||||
|
|
@ -438,7 +428,6 @@ func (f *Frame) String() string {
|
|||
if view == "" {
|
||||
return ""
|
||||
}
|
||||
view = ansi.Strip(view)
|
||||
// Ensure trailing newline for non-TTY consistency
|
||||
if !strings.HasSuffix(view, "\n") {
|
||||
view += "\n"
|
||||
|
|
@ -463,11 +452,12 @@ func (f *Frame) termSize() (int, int) {
|
|||
return 80, 24 // sensible default
|
||||
}
|
||||
|
||||
|
||||
func (f *Frame) runLive() {
|
||||
opts := []tea.ProgramOption{
|
||||
tea.WithAltScreen(),
|
||||
}
|
||||
if f.out != stdoutWriter() {
|
||||
if f.out != os.Stdout {
|
||||
opts = append(opts, tea.WithOutput(f.out))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ func StatusLine(title string, pairs ...string) Model {
|
|||
}
|
||||
|
||||
func (s *statusLineModel) View(width, _ int) string {
|
||||
parts := []string{BoldStyle.Render(compileGlyphs(s.title))}
|
||||
parts := []string{BoldStyle.Render(s.title)}
|
||||
for _, p := range s.pairs {
|
||||
parts = append(parts, DimStyle.Render(compileGlyphs(p)))
|
||||
parts = append(parts, DimStyle.Render(p))
|
||||
}
|
||||
line := strings.Join(parts, " ")
|
||||
if width > 0 {
|
||||
|
|
@ -46,7 +46,7 @@ func KeyHints(hints ...string) Model {
|
|||
func (k *keyHintsModel) View(width, _ int) string {
|
||||
parts := make([]string, len(k.hints))
|
||||
for i, h := range k.hints {
|
||||
parts[i] = DimStyle.Render(compileGlyphs(h))
|
||||
parts[i] = DimStyle.Render(h)
|
||||
}
|
||||
line := strings.Join(parts, " ")
|
||||
if width > 0 {
|
||||
|
|
@ -70,11 +70,10 @@ func Breadcrumb(parts ...string) Model {
|
|||
func (b *breadcrumbModel) View(width, _ int) string {
|
||||
styled := make([]string, len(b.parts))
|
||||
for i, p := range b.parts {
|
||||
part := compileGlyphs(p)
|
||||
if i == len(b.parts)-1 {
|
||||
styled[i] = BoldStyle.Render(part)
|
||||
styled[i] = BoldStyle.Render(p)
|
||||
} else {
|
||||
styled[i] = DimStyle.Render(part)
|
||||
styled[i] = DimStyle.Render(p)
|
||||
}
|
||||
}
|
||||
line := strings.Join(styled, DimStyle.Render(" > "))
|
||||
|
|
@ -95,5 +94,5 @@ func StaticModel(text string) Model {
|
|||
}
|
||||
|
||||
func (s *staticModel) View(_, _ int) string {
|
||||
return compileGlyphs(s.text)
|
||||
return s.text
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFrameComponents_Good(t *testing.T) {
|
||||
// StatusLine renders title and pairs.
|
||||
model := StatusLine("core dev", "18 repos", "main")
|
||||
output := model.View(80, 1)
|
||||
if !strings.Contains(output, "core dev") {
|
||||
t.Errorf("StatusLine: expected 'core dev' in output, got %q", output)
|
||||
}
|
||||
|
||||
// KeyHints renders hints.
|
||||
hints := KeyHints("↑/↓ navigate", "enter select", "q quit")
|
||||
output = hints.View(80, 1)
|
||||
if !strings.Contains(output, "navigate") {
|
||||
t.Errorf("KeyHints: expected 'navigate' in output, got %q", output)
|
||||
}
|
||||
|
||||
// Breadcrumb renders navigation path.
|
||||
breadcrumb := Breadcrumb("core", "dev", "health")
|
||||
output = breadcrumb.View(80, 1)
|
||||
if !strings.Contains(output, "health") {
|
||||
t.Errorf("Breadcrumb: expected 'health' in output, got %q", output)
|
||||
}
|
||||
|
||||
// StaticModel returns static text.
|
||||
static := StaticModel("static content")
|
||||
output = static.View(80, 1)
|
||||
if output != "static content" {
|
||||
t.Errorf("StaticModel: expected 'static content', got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameComponents_Bad(t *testing.T) {
|
||||
// StatusLine with zero width should truncate to empty or short string.
|
||||
model := StatusLine("long title that should be truncated")
|
||||
output := model.View(0, 1)
|
||||
// Zero width means no truncation guard in current impl — just verify no panic.
|
||||
_ = output
|
||||
|
||||
// KeyHints with no hints should not panic.
|
||||
hints := KeyHints()
|
||||
output = hints.View(80, 1)
|
||||
_ = output
|
||||
}
|
||||
|
||||
func TestFrameComponents_Ugly(t *testing.T) {
|
||||
// Breadcrumb with single item has no separator.
|
||||
breadcrumb := Breadcrumb("root")
|
||||
output := breadcrumb.View(80, 1)
|
||||
if !strings.Contains(output, "root") {
|
||||
t.Errorf("Breadcrumb single: expected 'root', got %q", output)
|
||||
}
|
||||
|
||||
// StatusLine with very narrow width truncates output.
|
||||
model := StatusLine("core dev", "18 repos")
|
||||
output = model.View(5, 1)
|
||||
if len(output) > 10 {
|
||||
t.Errorf("StatusLine truncated: output too long for width 5, got %q", output)
|
||||
}
|
||||
}
|
||||
|
|
@ -551,40 +551,3 @@ func TestFrameMessageRouting_Good(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestFrame_Ugly(t *testing.T) {
|
||||
t.Run("navigate with nil model does not panic", func(t *testing.T) {
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Content(StaticModel("base"))
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
f.Navigate(nil)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("deeply nested back stack does not panic", func(t *testing.T) {
|
||||
f := NewFrame("C")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Content(StaticModel("p0"))
|
||||
for i := 1; i <= 20; i++ {
|
||||
f.Navigate(StaticModel("p" + string(rune('0'+i%10))))
|
||||
}
|
||||
for f.Back() {
|
||||
// drain the full history stack
|
||||
}
|
||||
assert.False(t, f.Back(), "no more history after full drain")
|
||||
})
|
||||
|
||||
t.Run("zero-size window renders without panic", func(t *testing.T) {
|
||||
f := NewFrame("HCF")
|
||||
f.out = &bytes.Buffer{}
|
||||
f.Content(StaticModel("x"))
|
||||
f.width = 0
|
||||
f.height = 0
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
_ = f.View()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,24 +20,15 @@ const (
|
|||
var currentTheme = ThemeUnicode
|
||||
|
||||
// UseUnicode switches the glyph theme to Unicode.
|
||||
func UseUnicode() {
|
||||
currentTheme = ThemeUnicode
|
||||
restoreColorIfASCII()
|
||||
}
|
||||
func UseUnicode() { currentTheme = ThemeUnicode }
|
||||
|
||||
// UseEmoji switches the glyph theme to Emoji.
|
||||
func UseEmoji() {
|
||||
currentTheme = ThemeEmoji
|
||||
restoreColorIfASCII()
|
||||
}
|
||||
func UseEmoji() { currentTheme = ThemeEmoji }
|
||||
|
||||
// UseASCII switches the glyph theme to ASCII and disables colors.
|
||||
func UseASCII() {
|
||||
currentTheme = ThemeASCII
|
||||
SetColorEnabled(false)
|
||||
colorEnabledMu.Lock()
|
||||
asciiDisabledColors = true
|
||||
colorEnabledMu.Unlock()
|
||||
}
|
||||
|
||||
func glyphMap() map[string]string {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ package cli
|
|||
|
||||
import "testing"
|
||||
|
||||
func TestGlyph_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
func TestGlyph(t *testing.T) {
|
||||
UseUnicode()
|
||||
if Glyph(":check:") != "✓" {
|
||||
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
|
||||
|
|
@ -15,49 +14,10 @@ func TestGlyph_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGlyph_Bad(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
// Unknown shortcode returns the shortcode unchanged.
|
||||
UseUnicode()
|
||||
got := Glyph(":unknown:")
|
||||
if got != ":unknown:" {
|
||||
t.Errorf("Unknown shortcode should return unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlyph_Ugly(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
// Empty shortcode should not panic.
|
||||
got := Glyph("")
|
||||
if got != "" {
|
||||
t.Errorf("Empty shortcode should return empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGlyphs_Good(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
func TestCompileGlyphs(t *testing.T) {
|
||||
UseUnicode()
|
||||
got := compileGlyphs("Status: :check:")
|
||||
if got != "Status: ✓" {
|
||||
t.Errorf("Expected 'Status: ✓', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGlyphs_Bad(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
UseUnicode()
|
||||
// Text with no shortcodes should be returned as-is.
|
||||
got := compileGlyphs("no glyphs here")
|
||||
if got != "no glyphs here" {
|
||||
t.Errorf("Expected unchanged text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGlyphs_Ugly(t *testing.T) {
|
||||
restoreThemeAndColors(t)
|
||||
// Empty string should not panic.
|
||||
got := compileGlyphs("")
|
||||
if got != "" {
|
||||
t.Errorf("Empty string should return empty, got %q", got)
|
||||
t.Errorf("Expected Status: ✓, got %s", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
165
pkg/cli/i18n.go
165
pkg/cli/i18n.go
|
|
@ -1,17 +1,170 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
// I18nService wraps i18n as a Core service.
|
||||
type I18nService struct {
|
||||
*core.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(*core.Core) (any, error) {
|
||||
return func(c *core.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: core.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 *core.Core, q core.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.
|
||||
//
|
||||
// label := cli.T("cmd.doctor.required")
|
||||
// msg := cli.T("cmd.doctor.issues", map[string]any{"Count": 3})
|
||||
func T(key string, args ...map[string]any) string {
|
||||
if len(args) > 0 {
|
||||
return i18n.T(key, args[0])
|
||||
if instance == nil {
|
||||
// CLI not initialised, use global i18n
|
||||
if len(args) > 0 {
|
||||
return i18n.T(key, args[0])
|
||||
}
|
||||
return i18n.T(key)
|
||||
}
|
||||
return i18n.T(key)
|
||||
|
||||
svc, err := core.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,30 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestT_Good(t *testing.T) {
|
||||
// T should return a non-empty string for any key
|
||||
// (falls back to the key itself when no translation is found).
|
||||
result := T("some.key")
|
||||
if result == "" {
|
||||
t.Error("T: returned empty string for unknown key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestT_Bad(t *testing.T) {
|
||||
// T with args map should not panic.
|
||||
result := T("cmd.doctor.issues", map[string]any{"Count": 0})
|
||||
if result == "" {
|
||||
t.Error("T with args: returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestT_Ugly(t *testing.T) {
|
||||
// T with empty key should not panic.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("T(\"\") panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
_ = T("")
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
stdin io.Reader = os.Stdin
|
||||
|
||||
stdoutOverride io.Writer
|
||||
stderrOverride io.Writer
|
||||
|
||||
ioMu sync.RWMutex
|
||||
)
|
||||
|
||||
// SetStdin overrides the default stdin reader for testing.
|
||||
// Pass nil to restore the real os.Stdin reader.
|
||||
func SetStdin(r io.Reader) {
|
||||
ioMu.Lock()
|
||||
defer ioMu.Unlock()
|
||||
if r == nil {
|
||||
stdin = os.Stdin
|
||||
return
|
||||
}
|
||||
stdin = r
|
||||
}
|
||||
|
||||
// SetStdout overrides the default stdout writer.
|
||||
// Pass nil to restore writes to os.Stdout.
|
||||
func SetStdout(w io.Writer) {
|
||||
ioMu.Lock()
|
||||
defer ioMu.Unlock()
|
||||
stdoutOverride = w
|
||||
}
|
||||
|
||||
// SetStderr overrides the default stderr writer.
|
||||
// Pass nil to restore writes to os.Stderr.
|
||||
func SetStderr(w io.Writer) {
|
||||
ioMu.Lock()
|
||||
defer ioMu.Unlock()
|
||||
stderrOverride = w
|
||||
}
|
||||
|
||||
func stdinReader() io.Reader {
|
||||
ioMu.RLock()
|
||||
defer ioMu.RUnlock()
|
||||
return stdin
|
||||
}
|
||||
|
||||
func stdoutWriter() io.Writer {
|
||||
ioMu.RLock()
|
||||
defer ioMu.RUnlock()
|
||||
if stdoutOverride != nil {
|
||||
return stdoutOverride
|
||||
}
|
||||
return os.Stdout
|
||||
}
|
||||
|
||||
func stderrWriter() io.Writer {
|
||||
ioMu.RLock()
|
||||
defer ioMu.RUnlock()
|
||||
if stderrOverride != nil {
|
||||
return stderrOverride
|
||||
}
|
||||
return os.Stderr
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ type Renderable interface {
|
|||
type StringBlock string
|
||||
|
||||
// Render returns the string content.
|
||||
func (s StringBlock) Render() string { return compileGlyphs(string(s)) }
|
||||
func (s StringBlock) Render() string { return string(s) }
|
||||
|
||||
// Layout creates a new layout from a variant string.
|
||||
func Layout(variant string) *Composite {
|
||||
|
|
|
|||
|
|
@ -2,49 +2,24 @@ package cli
|
|||
|
||||
import "testing"
|
||||
|
||||
func TestParseVariant_Good(t *testing.T) {
|
||||
composite, err := ParseVariant("H[LC]F")
|
||||
func TestParseVariant(t *testing.T) {
|
||||
c, err := ParseVariant("H[LC]F")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
if _, ok := composite.regions[RegionHeader]; !ok {
|
||||
if _, ok := c.regions[RegionHeader]; !ok {
|
||||
t.Error("Expected Header region")
|
||||
}
|
||||
if _, ok := composite.regions[RegionFooter]; !ok {
|
||||
if _, ok := c.regions[RegionFooter]; !ok {
|
||||
t.Error("Expected Footer region")
|
||||
}
|
||||
|
||||
headerSlot := composite.regions[RegionHeader]
|
||||
if headerSlot.child == nil {
|
||||
t.Error("Header should have child layout for H[LC]")
|
||||
hSlot := c.regions[RegionHeader]
|
||||
if hSlot.child == nil {
|
||||
t.Error("Header should have child layout")
|
||||
} else {
|
||||
if _, ok := headerSlot.child.regions[RegionLeft]; !ok {
|
||||
if _, ok := hSlot.child.regions[RegionLeft]; !ok {
|
||||
t.Error("Child should have Left region")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVariant_Bad(t *testing.T) {
|
||||
// Invalid region character.
|
||||
_, err := ParseVariant("X")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid region character 'X'")
|
||||
}
|
||||
|
||||
// Unmatched bracket.
|
||||
_, err = ParseVariant("H[C")
|
||||
if err == nil {
|
||||
t.Error("Expected error for unmatched bracket")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVariant_Ugly(t *testing.T) {
|
||||
// Empty variant should produce empty composite without panic.
|
||||
composite, err := ParseVariant("")
|
||||
if err != nil {
|
||||
t.Fatalf("Empty variant should not error: %v", err)
|
||||
}
|
||||
if len(composite.regions) != 0 {
|
||||
t.Errorf("Empty variant should have no regions, got %d", len(composite.regions))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
{
|
||||
"cmd": {
|
||||
"doctor": {
|
||||
"short": "Check development environment",
|
||||
"long": "Diagnose your development environment and report missing tools, configuration issues, and connectivity problems.",
|
||||
"verbose_flag": "Show detailed output",
|
||||
"required": "Required tools:",
|
||||
"optional": "Optional tools:",
|
||||
"github": "GitHub integration:",
|
||||
"workspace": "Workspace:",
|
||||
"ready": "Environment is ready",
|
||||
"install_missing": "Install missing tools:",
|
||||
"install_macos": "brew install",
|
||||
"install_macos_cask": "brew install --cask",
|
||||
"install_macos_go": "brew install go",
|
||||
"install_linux_header": "Install on Linux:",
|
||||
"install_linux_go": "sudo apt install golang-go",
|
||||
"install_linux_git": "sudo apt install git",
|
||||
"install_linux_node": "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs",
|
||||
"install_linux_php": "sudo apt install php php-cli php-mbstring php-xml php-curl",
|
||||
"install_linux_pnpm": "npm install -g pnpm",
|
||||
"install_linux_gh": "See https://github.com/cli/cli/blob/trunk/docs/install_linux.md",
|
||||
"install_other": "See tool documentation for installation",
|
||||
"issues": "Open issues assigned to you:",
|
||||
"issues_error": "Failed to fetch issues",
|
||||
"cli_auth": "GitHub CLI authenticated",
|
||||
"cli_auth_missing": "GitHub CLI not authenticated — run: gh auth login",
|
||||
"ssh_found": "SSH key found",
|
||||
"ssh_missing": "SSH key not found — run: ssh-keygen",
|
||||
"repos_yaml_found": "Workspace registry found: {{.Path}}",
|
||||
"repos_cloned": "{{.Cloned}}/{{.Total}} repos cloned",
|
||||
"no_repos_yaml": "No repos.yaml found (run from workspace root)",
|
||||
"check": {
|
||||
"git": { "name": "Git", "description": "Version control" },
|
||||
"go": { "name": "Go", "description": "Go compiler" },
|
||||
"docker": { "name": "Docker", "description": "Container runtime" },
|
||||
"node": { "name": "Node.js", "description": "JavaScript runtime" },
|
||||
"php": { "name": "PHP", "description": "PHP interpreter" },
|
||||
"composer": { "name": "Composer", "description": "PHP package manager" },
|
||||
"pnpm": { "name": "pnpm", "description": "Node package manager" },
|
||||
"gh": { "name": "GitHub CLI", "description": "GitHub integration" },
|
||||
"claude": { "name": "Claude Code", "description": "AI coding assistant" }
|
||||
}
|
||||
},
|
||||
"pkg": {
|
||||
"short": "Manage packages",
|
||||
"long": "Install, list, search, update, and remove packages from the Core ecosystem.",
|
||||
"no_description": "(no description)",
|
||||
"error": {
|
||||
"repo_required": "Repository argument required (e.g., core/go-io)",
|
||||
"invalid_repo_format": "Invalid format — use org/repo (e.g., core/go-io)",
|
||||
"no_repos_yaml": "No repos.yaml found",
|
||||
"no_repos_yaml_workspace": "No repos.yaml found in workspace",
|
||||
"specify_package": "Specify a package name",
|
||||
"auth_failed": "Authentication failed",
|
||||
"gh_not_authenticated": "GitHub CLI not authenticated — run: gh auth login",
|
||||
"search_failed": "Search failed"
|
||||
},
|
||||
"install": {
|
||||
"short": "Install a package",
|
||||
"long": "Clone a package from the Git forge into your workspace.",
|
||||
"installing_label": "Installing",
|
||||
"already_exists": "{{.Name}} already exists at {{.Path}}",
|
||||
"installed": "{{.Name}} installed successfully",
|
||||
"add_to_registry": "Adding to registry",
|
||||
"added_to_registry": "Added to repos.yaml",
|
||||
"flag": {
|
||||
"dir": "Target directory (default: workspace base path)",
|
||||
"add": "Add to repos.yaml registry after install"
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"short": "List installed packages",
|
||||
"long": "Show all packages registered in repos.yaml with their status.",
|
||||
"title": "Installed packages",
|
||||
"no_packages": "No packages found",
|
||||
"summary": "{{.Count}} packages",
|
||||
"install_missing": "Install missing: core pkg install"
|
||||
},
|
||||
"search": {
|
||||
"short": "Search available packages",
|
||||
"long": "Search the forge for available packages by name or pattern.",
|
||||
"fetching_label": "Searching",
|
||||
"cache_label": "Cached",
|
||||
"found_repos": "Found {{.Count}} repositories",
|
||||
"no_repos_found": "No matching repositories found",
|
||||
"private_label": "private",
|
||||
"gh_token_unset": "GITHUB_TOKEN not set",
|
||||
"gh_token_warning": "Set GITHUB_TOKEN for private repo access",
|
||||
"flag": {
|
||||
"org": "Organisation to search (default: core)",
|
||||
"pattern": "Filter by name pattern",
|
||||
"type": "Filter by type (package, application, template)",
|
||||
"limit": "Maximum results",
|
||||
"refresh": "Refresh cache"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"short": "Update a package",
|
||||
"long": "Pull latest changes for a package or all packages.",
|
||||
"updating": "Updating {{.Name}}",
|
||||
"not_installed": "{{.Name}} is not installed",
|
||||
"summary": "{{.Updated}}/{{.Total}} updated",
|
||||
"flag": {
|
||||
"all": "Update all packages"
|
||||
}
|
||||
},
|
||||
"outdated": {
|
||||
"short": "Show outdated packages",
|
||||
"long": "Check which packages have unpulled commits.",
|
||||
"all_up_to_date": "All packages are up to date",
|
||||
"commits_behind": "{{.Count}} commits behind",
|
||||
"update_with": "Update with: core pkg update {{.Name}}",
|
||||
"summary": "{{.Outdated}}/{{.Total}} outdated",
|
||||
"flag": {
|
||||
"format": "Output format: table or json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"hint": {
|
||||
"install_with": "Install with: {{.Command}}"
|
||||
},
|
||||
"progress": {
|
||||
"checking": "Checking {{.Item}}...",
|
||||
"checking_updates": "Checking for updates..."
|
||||
},
|
||||
"status": {
|
||||
"cloning": "Cloning",
|
||||
"up_to_date": "Up to date"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"fail": {
|
||||
"create": "Failed to create {{.Item}}",
|
||||
"load": "Failed to load {{.Item}}",
|
||||
"parse": "Failed to parse {{.Item}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
137
pkg/cli/log.go
137
pkg/cli/log.go
|
|
@ -1,50 +1,115 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// LogLevel aliases for convenience.
|
||||
// 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 = log.LevelWarn
|
||||
LogLevelInfo = log.LevelInfo
|
||||
// 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
|
||||
)
|
||||
|
||||
// LogDebug logs a debug message if the default logger is available.
|
||||
//
|
||||
// cli.LogDebug("cache miss", "key", cacheKey)
|
||||
func LogDebug(msg string, keyvals ...any) { log.Debug(msg, keyvals...) }
|
||||
|
||||
// LogInfo logs an info message.
|
||||
//
|
||||
// cli.LogInfo("configuration reloaded", "path", configPath)
|
||||
func LogInfo(msg string, keyvals ...any) { log.Info(msg, keyvals...) }
|
||||
|
||||
// LogWarn logs a warning message.
|
||||
//
|
||||
// cli.LogWarn("GitHub CLI not authenticated", "user", username)
|
||||
func LogWarn(msg string, keyvals ...any) { log.Warn(msg, keyvals...) }
|
||||
|
||||
// LogError logs an error message.
|
||||
//
|
||||
// cli.LogError("Fatal error", "err", err)
|
||||
func LogError(msg string, keyvals ...any) { log.Error(msg, keyvals...) }
|
||||
|
||||
// LogSecurity logs a security-sensitive message.
|
||||
//
|
||||
// cli.LogSecurity("login attempt", "user", "admin")
|
||||
func LogSecurity(msg string, keyvals ...any) { log.Security(msg, keyvals...) }
|
||||
|
||||
// LogSecurityf logs a formatted security-sensitive message.
|
||||
//
|
||||
// cli.LogSecurityf("login attempt from %s", username)
|
||||
func LogSecurityf(format string, args ...any) {
|
||||
log.Security(fmt.Sprintf(format, args...))
|
||||
// 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(*core.Core) (any, error) {
|
||||
return func(c *core.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 := core.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,43 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLog_Good(t *testing.T) {
|
||||
// All log functions should not panic when called without a configured logger.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("LogInfo panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
LogInfo("test info message", "key", "value")
|
||||
}
|
||||
|
||||
func TestLog_Bad(t *testing.T) {
|
||||
// LogError should not panic with an empty message.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("LogError panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
LogError("")
|
||||
}
|
||||
|
||||
func TestLog_Ugly(t *testing.T) {
|
||||
// All log levels should not panic.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("log function panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
LogDebug("debug", "k", "v")
|
||||
LogInfo("info", "k", "v")
|
||||
LogWarn("warn", "k", "v")
|
||||
LogError("error", "k", "v")
|
||||
|
||||
// Level constants should be accessible.
|
||||
_ = LogLevelQuiet
|
||||
_ = LogLevelError
|
||||
_ = LogLevelWarn
|
||||
_ = LogLevelInfo
|
||||
_ = LogLevelDebug
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue