Compare commits
146 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c205be9b86 | ||
|
|
20a2e77e19 | ||
|
|
6b321fe5c9 | ||
|
|
bfc47c8400 | ||
|
|
cdae3a9ac5 | ||
|
|
050ee5bd8f | ||
|
|
8b345eba5b | ||
|
|
7b5f5e7181 | ||
| 3e0c9d7809 | |||
|
|
fcf5f9cfd5 | ||
| 91ef8c02cf | |||
|
|
b1afac56bb | ||
| d65aebd298 | |||
|
|
821f7d191d | ||
| d93504e94a | |||
|
|
905dfae6b1 | ||
| d146c73e43 | |||
|
|
be2e2db845 | ||
| d7c416d257 | |||
|
|
4e9b42e7d0 | ||
| e0aba4b863 | |||
|
|
aa07b4bbb1 | ||
| 53e9fc13be | |||
|
|
63481f127c | ||
| 3ce53ed394 | |||
|
|
50d9158920 | ||
| 9a80a604f4 | |||
|
|
3862b7c032 | ||
| fb914b99c2 | |||
|
|
81be3b701e | ||
| b5e67b2430 | |||
|
|
11ac2c62c6 | ||
| 048133c02f | |||
|
|
e1edbc1f9b | ||
| 5761524570 | |||
|
|
c0cb67cada | ||
| ba456e4560 | |||
|
|
07bea81d4a | ||
| 0703a5727d | |||
|
|
6192340ec0 | ||
| 32e096c6e1 | |||
|
|
4f7a4c3a20 | ||
| 3cf13e751e | |||
|
|
f8ba7be626 | ||
| c02e88a6ff | |||
|
|
f9bf2231e5 | ||
| 1cf8e17e1c | |||
|
|
d59e6acd72 | ||
| c6c07f0ee4 | |||
|
|
8a7567c705 | ||
| 125d5e76a1 | |||
|
|
32b824a8a4 | ||
| 5e663d6d94 | |||
|
|
4ec7341e76 | ||
| 2c837453fb | |||
|
|
1242723ac1 | ||
| 76bccc0526 | |||
|
|
207a38e236 | ||
| 893b6d0c09 | |||
|
|
904a5c057b | ||
| 37fdcdb7b4 | |||
|
|
87513483e8 | ||
| 83d649add0 | |||
|
|
817bdea525 | ||
| 53b5552554 | |||
|
|
b8bfdcf731 | ||
| 60c9f92eca | |||
|
|
c3f2d6abb7 | ||
| 742b1d2a9e | |||
|
|
7bf060986d | ||
| 6c39c0f932 | |||
|
|
f71bdb3bf4 | ||
| be63660740 | |||
|
|
b8f3c9698a | ||
| 5a7335888c | |||
|
|
88ec9264a9 | ||
|
|
5c8f08b60e | ||
|
|
aa537c89ca | ||
|
|
aa5c0f810a | ||
|
|
a035cb2169 | ||
|
|
e96ea6d7c2 | ||
|
|
43d4bbd2dc | ||
|
|
a5142dea78 | ||
|
|
37310c7cbd | ||
|
|
f376372630 | ||
|
|
2a9177a30b | ||
|
|
e259ce323b | ||
|
|
323f408601 | ||
|
|
8b30e80688 | ||
|
|
58f07603cd | ||
|
|
e29b6e4889 | ||
|
|
cf9c068650 | ||
|
|
dc30159392 | ||
|
|
cdc765611f | ||
|
|
7dadf41670 | ||
|
|
181d9546b4 | ||
|
|
419e7f745b | ||
|
|
4d127de05f | ||
|
|
9fd432aed3 | ||
|
|
b1850124de | ||
|
|
02d4ee74e6 | ||
|
|
12496ba57c | ||
|
|
a2f27b9af4 | ||
|
|
f13c3bf095 | ||
|
|
96aef54baf | ||
|
|
4e258c80b1 | ||
|
|
9c64f239a8 | ||
|
|
c6fae794b3 | ||
|
|
fcadba08b1 | ||
|
|
27e44f069a | ||
|
|
32342dfd31 | ||
|
|
d84d8cc838 | ||
|
|
4c072f9463 | ||
|
|
04d244425b | ||
|
|
d50b006af9 | ||
|
|
9aff00de1e | ||
|
|
10de071704 | ||
|
|
7fda1cf320 | ||
|
|
0595bf7e0f | ||
|
|
1dd401fa04 | ||
|
|
c67582c76b | ||
|
|
c74524bc58 | ||
| e177418c90 | |||
|
|
bcbc25974e | ||
|
|
92da6e8a73 | ||
|
|
542698c579 | ||
|
|
85eaceec05 | ||
|
|
9aaa0c0707 | ||
|
|
0c1b74c637 | ||
|
|
91de96994a | ||
|
|
5ebdc602d1 | ||
|
|
d67295ad2a | ||
|
|
7e7b83cd70 | ||
|
|
ee7e9d1abf | ||
|
|
bf994fab17 | ||
|
|
77c7d19402 | ||
|
|
e58d804779 | ||
|
|
417c7cbbf4 | ||
|
|
1dd0cfb79d | ||
|
|
2efcbd59ec | ||
|
|
55b556d1af | ||
|
|
7e2c7cd2f6 | ||
|
|
c6f9f41e0b | ||
|
|
614bc46d24 | ||
|
|
09b851ffd3 | ||
|
|
5c3c4e0f35 |
121 changed files with 5762 additions and 5801 deletions
42
.gitignore
vendored
42
.gitignore
vendored
|
|
@ -1,26 +1,28 @@
|
||||||
wails3
|
.idea/
|
||||||
.task
|
.vscode/
|
||||||
vendor/
|
|
||||||
.idea
|
|
||||||
node_modules/
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
.env
|
.core/
|
||||||
.env.*.local
|
|
||||||
|
# Build artefacts
|
||||||
|
dist/
|
||||||
|
bin/
|
||||||
|
/core
|
||||||
|
/cli
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
|
go.work.sum
|
||||||
coverage/
|
coverage/
|
||||||
coverage.out
|
coverage.out
|
||||||
coverage.html
|
coverage.html
|
||||||
*.cache
|
coverage.txt
|
||||||
/coverage.txt
|
|
||||||
bin/
|
|
||||||
dist/
|
|
||||||
tasks
|
|
||||||
/cli
|
|
||||||
/core
|
|
||||||
local.test
|
|
||||||
/i18n-validate
|
|
||||||
.angular/
|
|
||||||
|
|
||||||
patch_cov.*
|
# Environment / secrets
|
||||||
go.work.sum
|
.env
|
||||||
.kb
|
.env.*.local
|
||||||
|
|
||||||
|
# OS / tooling
|
||||||
|
.task
|
||||||
|
*.cache
|
||||||
|
node_modules/
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddConfigCommands registers the 'config' command group and all subcommands.
|
// AddConfigCommands registers the 'config' command group and all subcommands.
|
||||||
|
//
|
||||||
|
// config.AddConfigCommands(rootCmd)
|
||||||
func AddConfigCommands(root *cli.Command) {
|
func AddConfigCommands(root *cli.Command) {
|
||||||
configCmd := cli.NewGroup("config", "Manage configuration", "")
|
configCmd := cli.NewGroup("config", "Manage configuration", "")
|
||||||
root.AddCommand(configCmd)
|
root.AddCommand(configCmd)
|
||||||
|
|
@ -17,9 +19,9 @@ func AddConfigCommands(root *cli.Command) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() (*config.Config, error) {
|
func loadConfig() (*config.Config, error) {
|
||||||
cfg, err := config.New()
|
configuration, err := config.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cli.Wrap(err, "failed to load config")
|
return nil, cli.Wrap(err, "failed to load config")
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return configuration, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -10,17 +8,17 @@ func addGetCommand(parent *cli.Command) {
|
||||||
cmd := cli.NewCommand("get", "Get a configuration value", "", func(cmd *cli.Command, args []string) error {
|
cmd := cli.NewCommand("get", "Get a configuration value", "", func(cmd *cli.Command, args []string) error {
|
||||||
key := args[0]
|
key := args[0]
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
configuration, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var value any
|
var value any
|
||||||
if err := cfg.Get(key, &value); err != nil {
|
if err := configuration.Get(key, &value); err != nil {
|
||||||
return cli.Err("key not found: %s", key)
|
return cli.Err("key not found: %s", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(value)
|
cli.Println("%v", value)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"maps"
|
"maps"
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
|
|
@ -10,23 +9,23 @@ import (
|
||||||
|
|
||||||
func addListCommand(parent *cli.Command) {
|
func addListCommand(parent *cli.Command) {
|
||||||
cmd := cli.NewCommand("list", "List all configuration values", "", func(cmd *cli.Command, args []string) error {
|
cmd := cli.NewCommand("list", "List all configuration values", "", func(cmd *cli.Command, args []string) error {
|
||||||
cfg, err := loadConfig()
|
configuration, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
all := maps.Collect(cfg.All())
|
all := maps.Collect(configuration.All())
|
||||||
if len(all) == 0 {
|
if len(all) == 0 {
|
||||||
cli.Dim("No configuration values set")
|
cli.Dim("No configuration values set")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := yaml.Marshal(all)
|
output, err := yaml.Marshal(all)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cli.Wrap(err, "failed to format config")
|
return cli.Wrap(err, "failed to format config")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Print(string(out))
|
cli.Print("%s", string(output))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addPathCommand(parent *cli.Command) {
|
func addPathCommand(parent *cli.Command) {
|
||||||
cmd := cli.NewCommand("path", "Show the configuration file path", "", func(cmd *cli.Command, args []string) error {
|
cmd := cli.NewCommand("path", "Show the configuration file path", "", func(cmd *cli.Command, args []string) error {
|
||||||
cfg, err := loadConfig()
|
configuration, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(cfg.Path())
|
cli.Println("%s", configuration.Path())
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -9,12 +9,12 @@ func addSetCommand(parent *cli.Command) {
|
||||||
key := args[0]
|
key := args[0]
|
||||||
value := args[1]
|
value := args[1]
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
configuration, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.Set(key, value); err != nil {
|
if err := configuration.Set(key, value); err != nil {
|
||||||
return cli.Wrap(err, "failed to set config value")
|
return cli.Wrap(err, "failed to set config value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2,8 +2,8 @@ package doctor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"forge.lthn.ai/core/go-i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -26,6 +26,13 @@ func requiredChecks() []check {
|
||||||
args: []string{"--version"},
|
args: []string{"--version"},
|
||||||
versionFlag: "--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"),
|
name: i18n.T("cmd.doctor.check.gh.name"),
|
||||||
description: i18n.T("cmd.doctor.check.gh.description"),
|
description: i18n.T("cmd.doctor.check.gh.description"),
|
||||||
|
|
@ -84,18 +91,20 @@ func optionalChecks() []check {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runCheck executes a tool check and returns success status and version info
|
// 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...)
|
// ok, version := runCheck(check{command: "git", args: []string{"--version"}})
|
||||||
output, err := cmd.CombinedOutput()
|
func runCheck(toolCheck check) (bool, string) {
|
||||||
|
proc := exec.Command(toolCheck.command, toolCheck.args...)
|
||||||
|
output, err := proc.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, ""
|
return false, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract first line as version
|
// Extract first line as version info.
|
||||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
lines := core.Split(core.Trim(string(output)), "\n")
|
||||||
if len(lines) > 0 {
|
if len(lines) > 0 {
|
||||||
return true, strings.TrimSpace(lines[0])
|
return true, core.Trim(lines[0])
|
||||||
}
|
}
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
22
cmd/core/doctor/cmd_checks_test.go
Normal file
22
cmd/core/doctor/cmd_checks_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
@ -10,9 +10,16 @@
|
||||||
// Provides platform-specific installation instructions for missing tools.
|
// Provides platform-specific installation instructions for missing tools.
|
||||||
package doctor
|
package doctor
|
||||||
|
|
||||||
import "github.com/spf13/cobra"
|
import (
|
||||||
|
"forge.lthn.ai/core/go-i18n"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
// AddDoctorCommands registers the 'doctor' command and all subcommands.
|
// AddDoctorCommands registers the 'doctor' command and all subcommands.
|
||||||
|
//
|
||||||
|
// doctor.AddDoctorCommands(rootCmd)
|
||||||
func AddDoctorCommands(root *cobra.Command) {
|
func AddDoctorCommands(root *cobra.Command) {
|
||||||
|
doctorCmd.Short = i18n.T("cmd.doctor.short")
|
||||||
|
doctorCmd.Long = i18n.T("cmd.doctor.long")
|
||||||
root.AddCommand(doctorCmd)
|
root.AddCommand(doctorCmd)
|
||||||
}
|
}
|
||||||
117
cmd/core/doctor/cmd_doctor.go
Normal file
117
cmd/core/doctor/cmd_doctor.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
77
cmd/core/doctor/cmd_environment.go
Normal file
77
cmd/core/doctor/cmd_environment.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
26
cmd/core/doctor/cmd_install.go
Normal file
26
cmd/core/doctor/cmd_install.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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
Normal file
116
cmd/core/go.mod
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
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
Normal file
299
cmd/core/go.sum
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
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,12 +1,13 @@
|
||||||
package help
|
package help
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-help"
|
"forge.lthn.ai/core/go-help"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AddHelpCommands registers the help command and subcommands.
|
||||||
|
//
|
||||||
|
// help.AddHelpCommands(rootCmd)
|
||||||
func AddHelpCommands(root *cli.Command) {
|
func AddHelpCommands(root *cli.Command) {
|
||||||
var searchFlag string
|
var searchFlag string
|
||||||
|
|
||||||
|
|
@ -19,28 +20,28 @@ func AddHelpCommands(root *cli.Command) {
|
||||||
if searchFlag != "" {
|
if searchFlag != "" {
|
||||||
results := catalog.Search(searchFlag)
|
results := catalog.Search(searchFlag)
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
fmt.Println("No topics found.")
|
cli.Println("No topics found.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println("Search Results:")
|
cli.Println("Search Results:")
|
||||||
for _, res := range results {
|
for _, result := range results {
|
||||||
fmt.Printf(" %s - %s\n", res.Topic.ID, res.Topic.Title)
|
cli.Println(" %s - %s", result.Topic.ID, result.Topic.Title)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
topics := catalog.List()
|
topics := catalog.List()
|
||||||
fmt.Println("Available Help Topics:")
|
cli.Println("Available Help Topics:")
|
||||||
for _, t := range topics {
|
for _, topic := range topics {
|
||||||
fmt.Printf(" %s - %s\n", t.ID, t.Title)
|
cli.Println(" %s - %s", topic.ID, topic.Title)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
topic, err := catalog.Get(args[0])
|
topic, err := catalog.Get(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error: %v\n", err)
|
cli.Errorf("Error: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,11 +53,9 @@ func AddHelpCommands(root *cli.Command) {
|
||||||
root.AddCommand(helpCmd)
|
root.AddCommand(helpCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTopic(t *help.Topic) {
|
func renderTopic(topic *help.Topic) {
|
||||||
// Simple ANSI rendering for now
|
cli.Println("\n%s", cli.TitleStyle.Render(topic.Title))
|
||||||
// Use explicit ANSI codes or just print
|
cli.Println("----------------------------------------")
|
||||||
fmt.Printf("\n\033[1;34m%s\033[0m\n", t.Title) // Blue bold title
|
cli.Println("%s", topic.Content)
|
||||||
fmt.Println("----------------------------------------")
|
cli.Blank()
|
||||||
fmt.Println(t.Content)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
}
|
||||||
241
cmd/core/help/cmd_test.go
Normal file
241
cmd/core/help/cmd_test.go
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
}
|
||||||
33
cmd/core/main.go
Normal file
33
cmd/core/main.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
156
cmd/core/pkgcmd/cmd_install.go
Normal file
156
cmd/core/pkgcmd/cmd_install.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
114
cmd/core/pkgcmd/cmd_install_test.go
Normal file
114
cmd/core/pkgcmd/cmd_install_test.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
254
cmd/core/pkgcmd/cmd_manage.go
Normal file
254
cmd/core/pkgcmd/cmd_manage.go
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
350
cmd/core/pkgcmd/cmd_manage_test.go
Normal file
350
cmd/core/pkgcmd/cmd_manage_test.go
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ var (
|
||||||
dimStyle = cli.DimStyle
|
dimStyle = cli.DimStyle
|
||||||
ghAuthenticated = cli.GhAuthenticated
|
ghAuthenticated = cli.GhAuthenticated
|
||||||
gitClone = cli.GitClone
|
gitClone = cli.GitClone
|
||||||
|
gitCloneRef = clonePackageAtRef
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddPkgCommands adds the 'pkg' command and subcommands for package management.
|
// AddPkgCommands adds the 'pkg' command and subcommands for package management.
|
||||||
145
cmd/core/pkgcmd/cmd_remove.go
Normal file
145
cmd/core/pkgcmd/cmd_remove.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
// 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
|
||||||
|
}
|
||||||
174
cmd/core/pkgcmd/cmd_remove_test.go
Normal file
174
cmd/core/pkgcmd/cmd_remove_test.go
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
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.")
|
||||||
|
}
|
||||||
214
cmd/core/pkgcmd/cmd_search.go
Normal file
214
cmd/core/pkgcmd/cmd_search.go
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
66
cmd/core/pkgcmd/cmd_search_test.go
Normal file
66
cmd/core/pkgcmd/cmd_search_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
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"`)
|
||||||
|
}
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
// 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
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,430 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,635 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
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,55 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,144 +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 (
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,274 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
// 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,4 +33,5 @@ core pkg update core-api
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
core pkg outdated
|
core pkg outdated
|
||||||
|
core pkg outdated --format json
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,10 @@ core pkg search --refresh
|
||||||
|
|
||||||
## pkg install
|
## pkg install
|
||||||
|
|
||||||
Clone a package from GitHub.
|
Clone a package from GitHub. If you pass only a repo name, `core` assumes the `host-uk` org.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
core pkg install <org/repo> [flags]
|
core pkg install [org/]repo [flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Flags
|
### Flags
|
||||||
|
|
@ -76,6 +76,9 @@ core pkg install <org/repo> [flags]
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Clone from the default host-uk org
|
||||||
|
core pkg install core-api
|
||||||
|
|
||||||
# Clone to packages/
|
# Clone to packages/
|
||||||
core pkg install host-uk/core-php
|
core pkg install host-uk/core-php
|
||||||
|
|
||||||
|
|
@ -98,6 +101,16 @@ core pkg list
|
||||||
|
|
||||||
Shows installed status (✓) and description for each package.
|
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
|
## pkg update
|
||||||
|
|
@ -113,6 +126,7 @@ core pkg update [<name>...] [flags]
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `--all` | Update all packages |
|
| `--all` | Update all packages |
|
||||||
|
| `--format` | Output format (`table` or `json`) |
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
|
|
@ -122,8 +136,15 @@ core pkg update core-php
|
||||||
|
|
||||||
# Update all packages
|
# Update all packages
|
||||||
core pkg update --all
|
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
|
## pkg outdated
|
||||||
|
|
@ -136,6 +157,16 @@ core pkg outdated
|
||||||
|
|
||||||
Fetches from remote and shows packages that are behind.
|
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
|
## See Also
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ core pkg search [flags]
|
||||||
| `--type` | Filter by type in name (mod, services, plug, website) |
|
| `--type` | Filter by type in name (mod, services, plug, website) |
|
||||||
| `--limit` | Max results (default: 50) |
|
| `--limit` | Max results (default: 50) |
|
||||||
| `--refresh` | Bypass cache and fetch fresh data |
|
| `--refresh` | Bypass cache and fetch fresh data |
|
||||||
|
| `--format` | Output format (`table` or `json`) |
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|
@ -40,6 +41,9 @@ core pkg search --refresh
|
||||||
|
|
||||||
# Combine filters
|
# Combine filters
|
||||||
core pkg search --pattern "core-*" --type mod --limit 20
|
core pkg search --pattern "core-*" --type mod --limit 20
|
||||||
|
|
||||||
|
# JSON output for automation
|
||||||
|
core pkg search --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,11 @@ Persistent flags are inherited by all subcommands:
|
||||||
```go
|
```go
|
||||||
cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path")
|
cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path")
|
||||||
cli.PersistentBoolFlag(parentCmd, &debug, "debug", "", false, "Debug mode")
|
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
|
## Args Validation
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ description: Daemon process management, PID files, health checks, and execution
|
||||||
|
|
||||||
# Daemon Mode
|
# Daemon Mode
|
||||||
|
|
||||||
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.
|
The framework provides execution mode detection and signal handling for daemon processes.
|
||||||
|
|
||||||
## Execution Modes
|
## Execution Modes
|
||||||
|
|
||||||
|
|
@ -29,63 +29,9 @@ cli.IsStdinTTY() // stdin is a terminal?
|
||||||
cli.IsStderrTTY() // stderr is a terminal?
|
cli.IsStderrTTY() // stderr is a terminal?
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding Daemon Commands
|
## Simple Daemon
|
||||||
|
|
||||||
`AddDaemonCommand` registers a command group with four subcommands:
|
Use `cli.Context()` for cancellation-aware daemon loops:
|
||||||
|
|
||||||
```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
|
```go
|
||||||
func runDaemon(cmd *cli.Command, args []string) error {
|
func runDaemon(cmd *cli.Command, args []string) error {
|
||||||
|
|
@ -96,6 +42,39 @@ 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
|
## 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.
|
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.
|
||||||
|
|
@ -117,15 +96,3 @@ cli.Init(cli.Options{
|
||||||
```
|
```
|
||||||
|
|
||||||
No manual signal handling is needed in commands. Use `cli.Context()` for cancellation-aware operations.
|
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:
|
This is the preferred way to register commands. It wraps your registration function in a Core service that participates in the lifecycle:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func WithCommands(name string, register func(root *Command)) core.Option
|
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup
|
||||||
```
|
```
|
||||||
|
|
||||||
During startup, the Core framework calls your function with the root cobra command. Your function adds subcommands to it:
|
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:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func AddScoreCommands(root *cli.Command) {
|
func AddScoreCommands(root *cli.Command) {
|
||||||
|
|
@ -98,18 +98,17 @@ func main() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Where `Commands()` returns a slice of framework options:
|
Where `Commands()` returns a slice of `CommandSetup` functions:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package lemcmd
|
package lemcmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Commands() []core.Option {
|
func Commands() []cli.CommandSetup {
|
||||||
return []core.Option{
|
return []cli.CommandSetup{
|
||||||
cli.WithCommands("score", addScoreCommands),
|
cli.WithCommands("score", addScoreCommands),
|
||||||
cli.WithCommands("gen", addGenCommands),
|
cli.WithCommands("gen", addGenCommands),
|
||||||
cli.WithCommands("data", addDataCommands),
|
cli.WithCommands("data", addDataCommands),
|
||||||
|
|
@ -141,7 +140,7 @@ If you need more control over the lifecycle:
|
||||||
cli.Init(cli.Options{
|
cli.Init(cli.Options{
|
||||||
AppName: "myapp",
|
AppName: "myapp",
|
||||||
Version: "1.0.0",
|
Version: "1.0.0",
|
||||||
Services: []core.Option{...},
|
Services: []core.Service{...},
|
||||||
OnReload: func() error { return reloadConfig() },
|
OnReload: func() error { return reloadConfig() },
|
||||||
})
|
})
|
||||||
defer cli.Shutdown()
|
defer cli.Shutdown()
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ The framework has three layers:
|
||||||
| `TreeNode` | Tree structure with box-drawing connectors |
|
| `TreeNode` | Tree structure with box-drawing connectors |
|
||||||
| `TaskTracker` | Concurrent task display with live spinners |
|
| `TaskTracker` | Concurrent task display with live spinners |
|
||||||
| `CheckBuilder` | Fluent API for pass/fail/skip result lines |
|
| `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) |
|
| `AnsiStyle` | Terminal text styling (bold, dim, colour) |
|
||||||
|
|
||||||
## Built-in Services
|
## Built-in Services
|
||||||
|
|
|
||||||
|
|
@ -280,4 +280,5 @@ cli.LogInfo("server started", "port", 8080)
|
||||||
cli.LogWarn("slow query", "duration", "3.2s")
|
cli.LogWarn("slow query", "duration", "3.2s")
|
||||||
cli.LogError("connection failed", "err", err)
|
cli.LogError("connection failed", "err", err)
|
||||||
cli.LogSecurity("login attempt", "user", "admin")
|
cli.LogSecurity("login attempt", "user", "admin")
|
||||||
|
cli.LogSecurityf("login attempt from %s", username)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,12 @@ 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:
|
With a default selection:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,19 @@ When word-wrap is enabled, the stream tracks the current column position and ins
|
||||||
|
|
||||||
## Custom Output Writer
|
## Custom Output Writer
|
||||||
|
|
||||||
By default, streams write to `os.Stdout`. Redirect to any `io.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`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
stream := cli.NewStream(cli.WithStreamOutput(&buf))
|
stream := cli.NewStream(cli.WithStreamOutput(&buf))
|
||||||
// ... write tokens ...
|
// ... write tokens ...
|
||||||
stream.Done()
|
stream.Done()
|
||||||
result := stream.Captured() // or buf.String()
|
result, ok := stream.CapturedOK() // or buf.String()
|
||||||
```
|
```
|
||||||
|
|
||||||
`Captured()` returns the output as a string when using a `*strings.Builder` or any `fmt.Stringer`.
|
`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`
|
## Reading from `io.Reader`
|
||||||
|
|
||||||
|
|
@ -68,14 +70,15 @@ stream.Done()
|
||||||
| `Done()` | Signal completion (adds trailing newline if needed) |
|
| `Done()` | Signal completion (adds trailing newline if needed) |
|
||||||
| `Wait()` | Block until `Done` is called |
|
| `Wait()` | Block until `Done` is called |
|
||||||
| `Column()` | Current column position |
|
| `Column()` | Current column position |
|
||||||
| `Captured()` | Get output as string (requires `*strings.Builder` or `fmt.Stringer` writer) |
|
| `Captured()` | Get output as string (returns `""` if capture is unsupported) |
|
||||||
|
| `CapturedOK()` | Get output and support status |
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `WithWordWrap(cols)` | Set the word-wrap column width |
|
| `WithWordWrap(cols)` | Set the word-wrap column width |
|
||||||
| `WithStreamOutput(w)` | Set the output writer (default: `os.Stdout`) |
|
| `WithStreamOutput(w)` | Set the output writer (default: `stdoutWriter()`) |
|
||||||
|
|
||||||
## Example: LLM Token Streaming
|
## Example: LLM Token Streaming
|
||||||
|
|
||||||
|
|
|
||||||
152
go.mod
152
go.mod
|
|
@ -1,169 +1,45 @@
|
||||||
module forge.lthn.ai/core/cli
|
module dappco.re/go/core/cli
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require dappco.re/go/core v0.4.7
|
||||||
forge.lthn.ai/core/go v0.1.0
|
|
||||||
forge.lthn.ai/core/go-cache v0.1.0
|
|
||||||
forge.lthn.ai/core/config v0.1.0
|
|
||||||
forge.lthn.ai/core/go-crypt v0.1.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/api v0.1.0
|
dappco.re/go/core/i18n v0.1.7
|
||||||
forge.lthn.ai/core/go-devops v0.0.3
|
dappco.re/go/core/log v0.0.4
|
||||||
forge.lthn.ai/core/go-help v0.1.2
|
|
||||||
forge.lthn.ai/core/go-i18n v0.1.0
|
|
||||||
forge.lthn.ai/core/go-io v0.0.3
|
|
||||||
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.0.2
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
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/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/term v0.40.0
|
golang.org/x/term v0.41.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.gitea.io/sdk/gitea v0.23.2 // indirect
|
dappco.re/go/core v0.3.3 // indirect
|
||||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
|
dappco.re/go/core/inference v0.1.7 // indirect
|
||||||
forge.lthn.ai/core/go-agentic v0.0.2 // indirect
|
|
||||||
forge.lthn.ai/core/go-inference v0.0.2 // indirect
|
|
||||||
forge.lthn.ai/core/go-store v0.1.2 // 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/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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // 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/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.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/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/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/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/inconshreveable/mousetrap v1.1.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/leodido/go-urn v1.4.0 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/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/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/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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.7.16 // indirect
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // 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
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
222
go.sum
222
go.sum
|
|
@ -1,58 +1,19 @@
|
||||||
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
|
||||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
|
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
|
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
||||||
forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ=
|
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
||||||
forge.lthn.ai/core/go-agentic v0.0.2 h1:G2nhiFY0j66A8/dyPXrS3CDYT1VLIin//GDszz4zEEo=
|
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
||||||
forge.lthn.ai/core/go-agentic v0.0.2/go.mod h1:wTZRajs+rt0YJbRk26ijC1sfICbg8O2782ZhCz2tv/k=
|
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
||||||
forge.lthn.ai/core/go-api v0.1.2 h1:zGmU2CqCQ0n0cntNvprdc7HoucD4E631wBdZw+taK1w=
|
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
|
||||||
forge.lthn.ai/core/go-cache v0.1.0 h1:yxPf4bWPZ1jxMnXg8UHBv2xLhet2CRsq5E9PLQYjyj4=
|
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||||
forge.lthn.ai/core/go-cache v0.1.0/go.mod h1:7WbprZVfx/+t4cbJFXMo4sloWk2Eny+rZd8x1Ay9rLk=
|
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||||
forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw=
|
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||||
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.0.2 h1:aHjBkYyLKxLr9tbO4AvzzV/lsZueGq/jeo33SLh113k=
|
|
||||||
forge.lthn.ai/core/go-inference v0.0.2/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
|
||||||
forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI=
|
|
||||||
forge.lthn.ai/core/go-io v0.0.3/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-scm v0.0.2 h1:Ue+gS5vxZkDgTvQrqYu9QdaqEezuTV1kZY3TMqM2uho=
|
|
||||||
forge.lthn.ai/core/go-scm v0.0.2/go.mod h1:ODGFiiLKU9ytcDU55G7ZLNIFhCCpcOn2Qjv4QRGwXoU=
|
|
||||||
forge.lthn.ai/core/go-store v0.1.2 h1:aqI/QQ/MJQvB0yt009odmFEuBYjePs7Ne3H9cimwpY4=
|
|
||||||
forge.lthn.ai/core/go-store v0.1.2/go.mod h1:op+ftjAqYskPv4OGvHZQf7/DLiRnFIdT0XCQTKR/GjE=
|
|
||||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
|
||||||
github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8=
|
|
||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
|
||||||
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/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
|
||||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
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/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
|
||||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
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 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
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 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
|
@ -65,199 +26,60 @@ 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/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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
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/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
|
||||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
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/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/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k=
|
|
||||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
|
||||||
github.com/gin-contrib/expvar v1.0.3 h1:nIbUaokxZfUEC/35h+RyWCP1SMF/suV/ARbXL3H3jrw=
|
|
||||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
|
||||||
github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I=
|
|
||||||
github.com/gin-contrib/location/v2 v2.0.0 h1:iLx5RatHQHSxgC0tm2AG0sIuQKecI7FhREessVd6RWY=
|
|
||||||
github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM=
|
|
||||||
github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U=
|
|
||||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
|
||||||
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
|
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
|
||||||
github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmnzz4=
|
|
||||||
github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw=
|
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
|
||||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|
||||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
|
||||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
|
||||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
|
||||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
|
||||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
|
||||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
|
||||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
|
||||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
|
||||||
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
|
||||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
|
||||||
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
|
|
||||||
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.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
|
||||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
|
||||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
|
||||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
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/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
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/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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
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/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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
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/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/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
|
||||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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/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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
|
||||||
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
|
|
||||||
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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
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.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
|
||||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
|
||||||
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=
|
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4=
|
|
||||||
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
|
|
||||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
|
|
||||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
|
||||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
|
||||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
|
||||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
|
||||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
|
||||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
|
|
|
||||||
51
main.go
51
main.go
|
|
@ -1,51 +0,0 @@
|
||||||
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,8 +18,9 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
colorEnabled = true
|
colorEnabled = true
|
||||||
colorEnabledMu sync.RWMutex
|
colorEnabledMu sync.RWMutex
|
||||||
|
asciiDisabledColors bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -48,6 +49,18 @@ func ColorEnabled() bool {
|
||||||
func SetColorEnabled(enabled bool) {
|
func SetColorEnabled(enabled bool) {
|
||||||
colorEnabledMu.Lock()
|
colorEnabledMu.Lock()
|
||||||
colorEnabled = enabled
|
colorEnabled = enabled
|
||||||
|
if enabled {
|
||||||
|
asciiDisabledColors = false
|
||||||
|
}
|
||||||
|
colorEnabledMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreColorIfASCII() {
|
||||||
|
colorEnabledMu.Lock()
|
||||||
|
if asciiDisabledColors {
|
||||||
|
colorEnabled = true
|
||||||
|
asciiDisabledColors = false
|
||||||
|
}
|
||||||
colorEnabledMu.Unlock()
|
colorEnabledMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,7 @@ func TestRender_ColorEnabled_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUseASCII_Good(t *testing.T) {
|
func TestUseASCII_Good(t *testing.T) {
|
||||||
// Save original state
|
restoreThemeAndColors(t)
|
||||||
original := ColorEnabled()
|
|
||||||
defer SetColorEnabled(original)
|
|
||||||
|
|
||||||
// Enable first, then UseASCII should disable colors
|
// Enable first, then UseASCII should disable colors
|
||||||
SetColorEnabled(true)
|
SetColorEnabled(true)
|
||||||
|
|
@ -88,10 +86,76 @@ 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) {
|
func TestRender_NilStyle_Good(t *testing.T) {
|
||||||
|
restoreThemeAndColors(t)
|
||||||
var s *AnsiStyle
|
var s *AnsiStyle
|
||||||
got := s.Render("test")
|
got := s.Render("test")
|
||||||
if got != "test" {
|
if got != "test" {
|
||||||
t.Errorf("Nil style should return plain text, got %q", got)
|
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,17 +1,21 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-crypt/crypt/openpgp"
|
"dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go-i18n"
|
||||||
"forge.lthn.ai/core/go-log"
|
"forge.lthn.ai/core/go-log"
|
||||||
"forge.lthn.ai/core/go-io/workspace"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed locales/*.json
|
||||||
|
var cliLocaleFS embed.FS
|
||||||
|
|
||||||
// AppName is the default CLI application name.
|
// AppName is the default CLI application name.
|
||||||
// Override with WithAppName before calling Main.
|
// Override with WithAppName before calling Main.
|
||||||
var AppName = "core"
|
var AppName = "core"
|
||||||
|
|
@ -30,9 +34,16 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// SemVer returns the full SemVer 2.0.0 version string.
|
// SemVer returns the full SemVer 2.0.0 version string.
|
||||||
// - Release: 1.2.0
|
//
|
||||||
// - Pre-release: 1.2.0-dev.8
|
// Examples:
|
||||||
// - Full: 1.2.0-dev.8+df94c24.20260206
|
// // 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
|
||||||
func SemVer() string {
|
func SemVer() string {
|
||||||
v := AppVersion
|
v := AppVersion
|
||||||
if BuildPreRelease != "" {
|
if BuildPreRelease != "" {
|
||||||
|
|
@ -56,17 +67,42 @@ func WithAppName(name string) {
|
||||||
AppName = name
|
AppName = name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main initialises and runs the CLI application.
|
// LocaleSource pairs a filesystem with a directory for loading translations.
|
||||||
// Pass command services via WithCommands to register CLI commands
|
type LocaleSource = i18n.FSSource
|
||||||
// through the Core framework lifecycle.
|
|
||||||
|
// WithLocales returns a locale source for use with MainWithLocales.
|
||||||
//
|
//
|
||||||
// cli.Main(
|
// Example:
|
||||||
// cli.WithCommands("config", config.AddConfigCommands),
|
// fs := embed.FS{}
|
||||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
// 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.
|
||||||
//
|
//
|
||||||
// Exits with code 1 on error or panic.
|
// Example:
|
||||||
func Main(commands ...core.Option) {
|
// 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) {
|
||||||
// Recovery from panics
|
// Recovery from panics
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
|
@ -76,28 +112,31 @@ func Main(commands ...core.Option) {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Core services load first, then command services
|
// Build locale sources: framework built-in + caller's extras + registered packages
|
||||||
services := []core.Option{
|
extraFS := []i18n.FSSource{
|
||||||
core.WithName("i18n", NewI18nService(I18nOptions{})),
|
{FS: cliLocaleFS, Dir: "locales"},
|
||||||
core.WithName("log", NewLogService(log.Options{
|
}
|
||||||
Level: log.LevelInfo,
|
extraFS = append(extraFS, locales...)
|
||||||
})),
|
for _, lfs := range RegisteredLocales() {
|
||||||
core.WithName("crypt", openpgp.New),
|
extraFS = append(extraFS, i18n.FSSource{FS: lfs, Dir: "."})
|
||||||
core.WithName("workspace", workspace.New),
|
|
||||||
}
|
}
|
||||||
services = append(services, commands...)
|
|
||||||
|
|
||||||
// Initialise CLI runtime with services
|
// Initialise CLI runtime
|
||||||
if err := Init(Options{
|
if err := Init(Options{
|
||||||
AppName: AppName,
|
AppName: AppName,
|
||||||
Version: SemVer(),
|
Version: SemVer(),
|
||||||
Services: services,
|
I18nSources: extraFS,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
Error(err.Error())
|
Error(err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer Shutdown()
|
defer Shutdown()
|
||||||
|
|
||||||
|
// Run command setup functions
|
||||||
|
for _, setup := range commands {
|
||||||
|
setup(Core())
|
||||||
|
}
|
||||||
|
|
||||||
// Add completion command to the CLI's root
|
// Add completion command to the CLI's root
|
||||||
RootCmd().AddCommand(newCompletionCmd())
|
RootCmd().AddCommand(newCompletionCmd())
|
||||||
|
|
||||||
|
|
@ -161,13 +200,13 @@ PowerShell:
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "bash":
|
case "bash":
|
||||||
_ = cmd.Root().GenBashCompletion(os.Stdout)
|
_ = cmd.Root().GenBashCompletion(stdoutWriter())
|
||||||
case "zsh":
|
case "zsh":
|
||||||
_ = cmd.Root().GenZshCompletion(os.Stdout)
|
_ = cmd.Root().GenZshCompletion(stdoutWriter())
|
||||||
case "fish":
|
case "fish":
|
||||||
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
|
_ = cmd.Root().GenFishCompletion(stdoutWriter(), true)
|
||||||
case "powershell":
|
case "powershell":
|
||||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
_ = cmd.Root().GenPowerShellCompletionWithDesc(stdoutWriter())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// CheckBuilder provides fluent API for check results.
|
// CheckBuilder provides fluent API for check results.
|
||||||
type CheckBuilder struct {
|
type CheckBuilder struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -40,7 +38,7 @@ func (c *CheckBuilder) Fail() *CheckBuilder {
|
||||||
func (c *CheckBuilder) Skip() *CheckBuilder {
|
func (c *CheckBuilder) Skip() *CheckBuilder {
|
||||||
c.status = "skipped"
|
c.status = "skipped"
|
||||||
c.style = DimStyle
|
c.style = DimStyle
|
||||||
c.icon = "-"
|
c.icon = Glyph(":skip:")
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,26 +64,27 @@ func (c *CheckBuilder) Message(msg string) *CheckBuilder {
|
||||||
|
|
||||||
// String returns the formatted check line.
|
// String returns the formatted check line.
|
||||||
func (c *CheckBuilder) String() string {
|
func (c *CheckBuilder) String() string {
|
||||||
icon := c.icon
|
icon := compileGlyphs(c.icon)
|
||||||
if c.style != nil {
|
if c.style != nil {
|
||||||
icon = c.style.Render(c.icon)
|
icon = c.style.Render(icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
status := c.status
|
name := Pad(compileGlyphs(c.name), 20)
|
||||||
|
status := Pad(compileGlyphs(c.status), 10)
|
||||||
if c.style != nil && c.status != "" {
|
if c.style != nil && c.status != "" {
|
||||||
status = c.style.Render(c.status)
|
status = c.style.Render(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.duration != "" {
|
if c.duration != "" {
|
||||||
return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
|
return Sprintf(" %s %s %s %s", icon, name, status, DimStyle.Render(compileGlyphs(c.duration)))
|
||||||
}
|
}
|
||||||
if status != "" {
|
if status != "" {
|
||||||
return fmt.Sprintf(" %s %s %s", icon, c.name, status)
|
return Sprintf(" %s %s %s", icon, name, status)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(" %s %s", icon, c.name)
|
return Sprintf(" %s %s", icon, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print outputs the check result.
|
// Print outputs the check result.
|
||||||
func (c *CheckBuilder) Print() {
|
func (c *CheckBuilder) Print() {
|
||||||
fmt.Println(c.String())
|
Println("%s", c.String())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,62 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestCheckBuilder(t *testing.T) {
|
func TestCheckBuilder_Good(t *testing.T) {
|
||||||
|
restoreThemeAndColors(t)
|
||||||
UseASCII() // Deterministic output
|
UseASCII() // Deterministic output
|
||||||
|
|
||||||
// Pass
|
checkResult := Check("database").Pass()
|
||||||
c := Check("foo").Pass()
|
got := checkResult.String()
|
||||||
got := c.String()
|
|
||||||
if got == "" {
|
if got == "" {
|
||||||
t.Error("Empty output for Pass")
|
t.Error("Pass: expected non-empty output")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(got, "database") {
|
||||||
// Fail
|
t.Errorf("Pass: expected name in output, got %q", got)
|
||||||
c = Check("foo").Fail()
|
}
|
||||||
got = c.String()
|
}
|
||||||
if got == "" {
|
|
||||||
t.Error("Empty output for Fail")
|
func TestCheckBuilder_Bad(t *testing.T) {
|
||||||
}
|
restoreThemeAndColors(t)
|
||||||
|
UseASCII()
|
||||||
// Skip
|
|
||||||
c = Check("foo").Skip()
|
checkResult := Check("lint").Fail()
|
||||||
got = c.String()
|
got := checkResult.String()
|
||||||
if got == "" {
|
if got == "" {
|
||||||
t.Error("Empty output for Skip")
|
t.Error("Fail: expected non-empty output")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn
|
checkResult = Check("build").Skip()
|
||||||
c = Check("foo").Warn()
|
got = checkResult.String()
|
||||||
got = c.String()
|
if got == "" {
|
||||||
if got == "" {
|
t.Error("Skip: expected non-empty output")
|
||||||
t.Error("Empty output for Warn")
|
}
|
||||||
}
|
|
||||||
|
checkResult = Check("tests").Warn()
|
||||||
// Duration
|
got = checkResult.String()
|
||||||
c = Check("foo").Pass().Duration("1s")
|
if got == "" {
|
||||||
got = c.String()
|
t.Error("Warn: expected non-empty output")
|
||||||
if got == "" {
|
}
|
||||||
t.Error("Empty output for Duration")
|
}
|
||||||
}
|
|
||||||
|
func TestCheckBuilder_Ugly(t *testing.T) {
|
||||||
// Message
|
restoreThemeAndColors(t)
|
||||||
c = Check("foo").Message("status")
|
UseASCII()
|
||||||
got = c.String()
|
|
||||||
if got == "" {
|
// Zero-value builder should not panic.
|
||||||
t.Error("Empty output for Message")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,32 @@ 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
|
// Persistent Flag Helpers
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -195,6 +221,69 @@ 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
|
// Command Configuration
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
73
pkg/cli/command_test.go
Normal file
73
pkg/cli/command_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
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,76 +2,150 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"io/fs"
|
||||||
"iter"
|
"iter"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"dappco.re/go/core"
|
||||||
|
"forge.lthn.ai/core/go-i18n"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WithCommands creates a framework Option that registers a command group.
|
// WithCommands returns a CommandSetup that registers a command group.
|
||||||
// The register function receives the root command during service startup,
|
// The register function receives the root cobra command during Main().
|
||||||
// allowing commands to participate in the Core lifecycle.
|
|
||||||
//
|
//
|
||||||
// cli.Main(
|
// cli.Main(
|
||||||
// cli.WithCommands("config", config.AddConfigCommands),
|
// cli.WithCommands("config", config.AddConfigCommands),
|
||||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||||
// )
|
// )
|
||||||
func WithCommands(name string, register func(root *Command)) core.Option {
|
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup {
|
||||||
return core.WithName("cmd."+name, func(c *core.Core) (any, error) {
|
return func(c *core.Core) {
|
||||||
return &commandService{core: c, register: register}, nil
|
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
|
||||||
})
|
if root, ok := c.App().Runtime.(*cobra.Command); ok {
|
||||||
}
|
register(root)
|
||||||
|
}
|
||||||
type commandService struct {
|
appendLocales(localeFS...)
|
||||||
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.
|
// 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 CommandRegistration func(root *cobra.Command)
|
type CommandRegistration func(root *cobra.Command)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
registeredCommands []CommandRegistration
|
registeredCommands []CommandRegistration
|
||||||
registeredCommandsMu sync.Mutex
|
registeredCommandsMu sync.Mutex
|
||||||
commandsAttached bool
|
commandsAttached bool
|
||||||
|
registeredLocales []fs.FS
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterCommands registers a function that adds commands to the CLI.
|
// RegisterCommands registers a function that adds commands to the CLI.
|
||||||
// Call this in your package's init() to register commands.
|
// Optionally pass a locale fs.FS to provide translations for the commands.
|
||||||
//
|
//
|
||||||
// func init() {
|
// func init() {
|
||||||
// cli.RegisterCommands(AddCommands)
|
// cli.RegisterCommands(AddCommands, locales.FS)
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// func AddCommands(root *cobra.Command) {
|
// Example:
|
||||||
// root.AddCommand(myCmd)
|
// cli.RegisterCommands(func(root *cobra.Command) {
|
||||||
// }
|
// root.AddCommand(cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
|
||||||
func RegisterCommands(fn CommandRegistration) {
|
// cli.Println(cli.SemVer())
|
||||||
|
// }))
|
||||||
|
// })
|
||||||
|
func RegisterCommands(fn CommandRegistration, localeFS ...fs.FS) {
|
||||||
registeredCommandsMu.Lock()
|
registeredCommandsMu.Lock()
|
||||||
defer registeredCommandsMu.Unlock()
|
|
||||||
registeredCommands = append(registeredCommands, fn)
|
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 commands already attached (CLI already running), attach immediately
|
||||||
if commandsAttached && instance != nil && instance.root != nil {
|
if attached {
|
||||||
fn(instance.root)
|
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 {
|
||||||
|
registeredCommandsMu.Lock()
|
||||||
|
defer registeredCommandsMu.Unlock()
|
||||||
|
if len(registeredLocales) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]fs.FS, len(registeredLocales))
|
||||||
|
copy(out, registeredLocales)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// RegisteredCommands returns an iterator over the registered command functions.
|
// RegisteredCommands returns an iterator over the registered command functions.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// for attach := range cli.RegisteredCommands() {
|
||||||
|
// _ = attach
|
||||||
|
// }
|
||||||
func RegisteredCommands() iter.Seq[CommandRegistration] {
|
func RegisteredCommands() iter.Seq[CommandRegistration] {
|
||||||
return func(yield func(CommandRegistration) bool) {
|
return func(yield func(CommandRegistration) bool) {
|
||||||
registeredCommandsMu.Lock()
|
registeredCommandsMu.Lock()
|
||||||
defer registeredCommandsMu.Unlock()
|
snapshot := make([]CommandRegistration, len(registeredCommands))
|
||||||
for _, fn := range registeredCommands {
|
copy(snapshot, registeredCommands)
|
||||||
|
registeredCommandsMu.Unlock()
|
||||||
|
|
||||||
|
for _, fn := range snapshot {
|
||||||
if !yield(fn) {
|
if !yield(fn) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -83,11 +157,12 @@ func RegisteredCommands() iter.Seq[CommandRegistration] {
|
||||||
// Called by Init() after creating the root command.
|
// Called by Init() after creating the root command.
|
||||||
func attachRegisteredCommands(root *cobra.Command) {
|
func attachRegisteredCommands(root *cobra.Command) {
|
||||||
registeredCommandsMu.Lock()
|
registeredCommandsMu.Lock()
|
||||||
defer registeredCommandsMu.Unlock()
|
snapshot := make([]CommandRegistration, len(registeredCommands))
|
||||||
|
copy(snapshot, registeredCommands)
|
||||||
|
commandsAttached = true
|
||||||
|
registeredCommandsMu.Unlock()
|
||||||
|
|
||||||
for _, fn := range registeredCommands {
|
for _, fn := range snapshot {
|
||||||
fn(root)
|
fn(root)
|
||||||
}
|
}
|
||||||
commandsAttached = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,16 @@ import (
|
||||||
// resetGlobals clears the CLI singleton and command registry for test isolation.
|
// resetGlobals clears the CLI singleton and command registry for test isolation.
|
||||||
func resetGlobals(t *testing.T) {
|
func resetGlobals(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Cleanup(func() {
|
doReset()
|
||||||
// Restore clean state after each test.
|
t.Cleanup(doReset)
|
||||||
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()
|
registeredCommandsMu.Lock()
|
||||||
registeredCommands = nil
|
registeredCommands = nil
|
||||||
|
registeredLocales = nil
|
||||||
commandsAttached = false
|
commandsAttached = false
|
||||||
registeredCommandsMu.Unlock()
|
registeredCommandsMu.Unlock()
|
||||||
if instance != nil {
|
if instance != nil {
|
||||||
|
|
@ -164,3 +159,28 @@ 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,6 +8,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mode represents the CLI execution mode.
|
// Mode represents the CLI execution mode.
|
||||||
|
//
|
||||||
|
// mode := cli.DetectMode()
|
||||||
|
// if mode == cli.ModeDaemon {
|
||||||
|
// cli.LogInfo("running headless")
|
||||||
|
// }
|
||||||
type Mode int
|
type Mode int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -34,7 +39,11 @@ func (m Mode) String() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectMode determines the execution mode based on environment.
|
// DetectMode determines the execution mode based on environment.
|
||||||
// Checks CORE_DAEMON env var first, then TTY status.
|
//
|
||||||
|
// mode := cli.DetectMode()
|
||||||
|
// // cli.ModeDaemon when CORE_DAEMON=1
|
||||||
|
// // cli.ModePipe when stdout is not a terminal
|
||||||
|
// // cli.ModeInteractive otherwise
|
||||||
func DetectMode() Mode {
|
func DetectMode() Mode {
|
||||||
if os.Getenv("CORE_DAEMON") == "1" {
|
if os.Getenv("CORE_DAEMON") == "1" {
|
||||||
return ModeDaemon
|
return ModeDaemon
|
||||||
|
|
@ -46,17 +55,37 @@ func DetectMode() Mode {
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTTY returns true if stdout is a terminal.
|
// IsTTY returns true if stdout is a terminal.
|
||||||
|
//
|
||||||
|
// if cli.IsTTY() {
|
||||||
|
// cli.Success("interactive output enabled")
|
||||||
|
// }
|
||||||
func IsTTY() bool {
|
func IsTTY() bool {
|
||||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
if f, ok := stdoutWriter().(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStdinTTY returns true if stdin is a terminal.
|
// IsStdinTTY returns true if stdin is a terminal.
|
||||||
|
//
|
||||||
|
// if !cli.IsStdinTTY() {
|
||||||
|
// cli.Warn("input is piped")
|
||||||
|
// }
|
||||||
func IsStdinTTY() bool {
|
func IsStdinTTY() bool {
|
||||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
if f, ok := stdinReader().(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStderrTTY returns true if stderr is a terminal.
|
// IsStderrTTY returns true if stderr is a terminal.
|
||||||
|
//
|
||||||
|
// if cli.IsStderrTTY() {
|
||||||
|
// cli.Progress("load", 1, 3, "config")
|
||||||
|
// }
|
||||||
func IsStderrTTY() bool {
|
func IsStderrTTY() bool {
|
||||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
if f, ok := stderrWriter().(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
322
pkg/cli/daemon_process.go
Normal file
322
pkg/cli/daemon_process.go
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
199
pkg/cli/daemon_process_test.go
Normal file
199
pkg/cli/daemon_process_test.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
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,16 +6,21 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDetectMode(t *testing.T) {
|
func TestDetectMode_Good(t *testing.T) {
|
||||||
t.Run("daemon mode from env", func(t *testing.T) {
|
t.Setenv("CORE_DAEMON", "1")
|
||||||
t.Setenv("CORE_DAEMON", "1")
|
assert.Equal(t, ModeDaemon, DetectMode())
|
||||||
assert.Equal(t, ModeDaemon, DetectMode())
|
}
|
||||||
})
|
|
||||||
|
func TestDetectMode_Bad(t *testing.T) {
|
||||||
t.Run("mode string", func(t *testing.T) {
|
t.Setenv("CORE_DAEMON", "0")
|
||||||
assert.Equal(t, "interactive", ModeInteractive.String())
|
mode := DetectMode()
|
||||||
assert.Equal(t, "pipe", ModePipe.String())
|
assert.NotEqual(t, ModeDaemon, mode)
|
||||||
assert.Equal(t, "daemon", ModeDaemon.String())
|
}
|
||||||
assert.Equal(t, "unknown", Mode(99).String())
|
|
||||||
})
|
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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,12 @@ func Join(errs ...error) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExitError represents an error that should cause the CLI to exit with a specific code.
|
// 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 {
|
type ExitError struct {
|
||||||
Code int
|
Code int
|
||||||
Err error
|
Err error
|
||||||
|
|
@ -95,7 +101,8 @@ func (e *ExitError) Unwrap() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exit creates a new ExitError with the given code and error.
|
// Exit creates a new ExitError with the given code and error.
|
||||||
// Use this to return an error from a command with a specific exit code.
|
//
|
||||||
|
// return cli.Exit(2, cli.Err("validation failed"))
|
||||||
func Exit(code int, err error) error {
|
func Exit(code int, err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -113,7 +120,7 @@ func Exit(code int, err error) error {
|
||||||
func Fatal(err error) {
|
func Fatal(err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
LogError("Fatal error", "err", err)
|
LogError("Fatal error", "err", err)
|
||||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +131,7 @@ func Fatal(err error) {
|
||||||
func Fatalf(format string, args ...any) {
|
func Fatalf(format string, args ...any) {
|
||||||
msg := fmt.Sprintf(format, args...)
|
msg := fmt.Sprintf(format, args...)
|
||||||
LogError("Fatal error", "msg", msg)
|
LogError("Fatal error", "msg", msg)
|
||||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +147,7 @@ func FatalWrap(err error, msg string) {
|
||||||
}
|
}
|
||||||
LogError("Fatal error", "msg", msg, "err", err)
|
LogError("Fatal error", "msg", msg, "err", err)
|
||||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,6 +164,6 @@ func FatalWrapVerb(err error, verb, subject string) {
|
||||||
msg := i18n.ActionFailed(verb, subject)
|
msg := i18n.ActionFailed(verb, subject)
|
||||||
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
|
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
|
||||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
pkg/cli/errors_test.go
Normal file
76
pkg/cli/errors_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
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,6 +10,7 @@ import (
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -60,7 +61,7 @@ func NewFrame(variant string) *Frame {
|
||||||
variant: variant,
|
variant: variant,
|
||||||
layout: Layout(variant),
|
layout: Layout(variant),
|
||||||
models: make(map[Region]Model),
|
models: make(map[Region]Model),
|
||||||
out: os.Stdout,
|
out: stderrWriter(),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
focused: RegionContent,
|
focused: RegionContent,
|
||||||
keyMap: DefaultKeyMap(),
|
keyMap: DefaultKeyMap(),
|
||||||
|
|
@ -69,6 +70,15 @@ 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.
|
// Header sets the Header region model.
|
||||||
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
|
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
|
||||||
|
|
||||||
|
|
@ -428,6 +438,7 @@ func (f *Frame) String() string {
|
||||||
if view == "" {
|
if view == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
view = ansi.Strip(view)
|
||||||
// Ensure trailing newline for non-TTY consistency
|
// Ensure trailing newline for non-TTY consistency
|
||||||
if !strings.HasSuffix(view, "\n") {
|
if !strings.HasSuffix(view, "\n") {
|
||||||
view += "\n"
|
view += "\n"
|
||||||
|
|
@ -452,12 +463,11 @@ func (f *Frame) termSize() (int, int) {
|
||||||
return 80, 24 // sensible default
|
return 80, 24 // sensible default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (f *Frame) runLive() {
|
func (f *Frame) runLive() {
|
||||||
opts := []tea.ProgramOption{
|
opts := []tea.ProgramOption{
|
||||||
tea.WithAltScreen(),
|
tea.WithAltScreen(),
|
||||||
}
|
}
|
||||||
if f.out != os.Stdout {
|
if f.out != stdoutWriter() {
|
||||||
opts = append(opts, tea.WithOutput(f.out))
|
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 {
|
func (s *statusLineModel) View(width, _ int) string {
|
||||||
parts := []string{BoldStyle.Render(s.title)}
|
parts := []string{BoldStyle.Render(compileGlyphs(s.title))}
|
||||||
for _, p := range s.pairs {
|
for _, p := range s.pairs {
|
||||||
parts = append(parts, DimStyle.Render(p))
|
parts = append(parts, DimStyle.Render(compileGlyphs(p)))
|
||||||
}
|
}
|
||||||
line := strings.Join(parts, " ")
|
line := strings.Join(parts, " ")
|
||||||
if width > 0 {
|
if width > 0 {
|
||||||
|
|
@ -46,7 +46,7 @@ func KeyHints(hints ...string) Model {
|
||||||
func (k *keyHintsModel) View(width, _ int) string {
|
func (k *keyHintsModel) View(width, _ int) string {
|
||||||
parts := make([]string, len(k.hints))
|
parts := make([]string, len(k.hints))
|
||||||
for i, h := range k.hints {
|
for i, h := range k.hints {
|
||||||
parts[i] = DimStyle.Render(h)
|
parts[i] = DimStyle.Render(compileGlyphs(h))
|
||||||
}
|
}
|
||||||
line := strings.Join(parts, " ")
|
line := strings.Join(parts, " ")
|
||||||
if width > 0 {
|
if width > 0 {
|
||||||
|
|
@ -70,10 +70,11 @@ func Breadcrumb(parts ...string) Model {
|
||||||
func (b *breadcrumbModel) View(width, _ int) string {
|
func (b *breadcrumbModel) View(width, _ int) string {
|
||||||
styled := make([]string, len(b.parts))
|
styled := make([]string, len(b.parts))
|
||||||
for i, p := range b.parts {
|
for i, p := range b.parts {
|
||||||
|
part := compileGlyphs(p)
|
||||||
if i == len(b.parts)-1 {
|
if i == len(b.parts)-1 {
|
||||||
styled[i] = BoldStyle.Render(p)
|
styled[i] = BoldStyle.Render(part)
|
||||||
} else {
|
} else {
|
||||||
styled[i] = DimStyle.Render(p)
|
styled[i] = DimStyle.Render(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
line := strings.Join(styled, DimStyle.Render(" > "))
|
line := strings.Join(styled, DimStyle.Render(" > "))
|
||||||
|
|
@ -94,5 +95,5 @@ func StaticModel(text string) Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *staticModel) View(_, _ int) string {
|
func (s *staticModel) View(_, _ int) string {
|
||||||
return s.text
|
return compileGlyphs(s.text)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
pkg/cli/frame_components_test.go
Normal file
65
pkg/cli/frame_components_test.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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,3 +551,40 @@ 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,15 +20,24 @@ const (
|
||||||
var currentTheme = ThemeUnicode
|
var currentTheme = ThemeUnicode
|
||||||
|
|
||||||
// UseUnicode switches the glyph theme to Unicode.
|
// UseUnicode switches the glyph theme to Unicode.
|
||||||
func UseUnicode() { currentTheme = ThemeUnicode }
|
func UseUnicode() {
|
||||||
|
currentTheme = ThemeUnicode
|
||||||
|
restoreColorIfASCII()
|
||||||
|
}
|
||||||
|
|
||||||
// UseEmoji switches the glyph theme to Emoji.
|
// UseEmoji switches the glyph theme to Emoji.
|
||||||
func UseEmoji() { currentTheme = ThemeEmoji }
|
func UseEmoji() {
|
||||||
|
currentTheme = ThemeEmoji
|
||||||
|
restoreColorIfASCII()
|
||||||
|
}
|
||||||
|
|
||||||
// UseASCII switches the glyph theme to ASCII and disables colors.
|
// UseASCII switches the glyph theme to ASCII and disables colors.
|
||||||
func UseASCII() {
|
func UseASCII() {
|
||||||
currentTheme = ThemeASCII
|
currentTheme = ThemeASCII
|
||||||
SetColorEnabled(false)
|
SetColorEnabled(false)
|
||||||
|
colorEnabledMu.Lock()
|
||||||
|
asciiDisabledColors = true
|
||||||
|
colorEnabledMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func glyphMap() map[string]string {
|
func glyphMap() map[string]string {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ package cli
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestGlyph(t *testing.T) {
|
func TestGlyph_Good(t *testing.T) {
|
||||||
|
restoreThemeAndColors(t)
|
||||||
UseUnicode()
|
UseUnicode()
|
||||||
if Glyph(":check:") != "✓" {
|
if Glyph(":check:") != "✓" {
|
||||||
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
|
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
|
||||||
|
|
@ -14,10 +15,49 @@ func TestGlyph(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompileGlyphs(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)
|
||||||
UseUnicode()
|
UseUnicode()
|
||||||
got := compileGlyphs("Status: :check:")
|
got := compileGlyphs("Status: :check:")
|
||||||
if got != "Status: ✓" {
|
if got != "Status: ✓" {
|
||||||
t.Errorf("Expected Status: ✓, got %s", got)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
165
pkg/cli/i18n.go
165
pkg/cli/i18n.go
|
|
@ -1,170 +1,17 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"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.
|
// T translates a key using the CLI's i18n service.
|
||||||
// Falls back to the global i18n.T if CLI not initialised.
|
// 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 {
|
func T(key string, args ...map[string]any) string {
|
||||||
if instance == nil {
|
if len(args) > 0 {
|
||||||
// CLI not initialised, use global i18n
|
return i18n.T(key, args[0])
|
||||||
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...)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
pkg/cli/i18n_test.go
Normal file
30
pkg/cli/i18n_test.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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("")
|
||||||
|
}
|
||||||
68
pkg/cli/io.go
Normal file
68
pkg/cli/io.go
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
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
|
type StringBlock string
|
||||||
|
|
||||||
// Render returns the string content.
|
// Render returns the string content.
|
||||||
func (s StringBlock) Render() string { return string(s) }
|
func (s StringBlock) Render() string { return compileGlyphs(string(s)) }
|
||||||
|
|
||||||
// Layout creates a new layout from a variant string.
|
// Layout creates a new layout from a variant string.
|
||||||
func Layout(variant string) *Composite {
|
func Layout(variant string) *Composite {
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,49 @@ package cli
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestParseVariant(t *testing.T) {
|
func TestParseVariant_Good(t *testing.T) {
|
||||||
c, err := ParseVariant("H[LC]F")
|
composite, err := ParseVariant("H[LC]F")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse failed: %v", err)
|
t.Fatalf("Parse failed: %v", err)
|
||||||
}
|
}
|
||||||
if _, ok := c.regions[RegionHeader]; !ok {
|
if _, ok := composite.regions[RegionHeader]; !ok {
|
||||||
t.Error("Expected Header region")
|
t.Error("Expected Header region")
|
||||||
}
|
}
|
||||||
if _, ok := c.regions[RegionFooter]; !ok {
|
if _, ok := composite.regions[RegionFooter]; !ok {
|
||||||
t.Error("Expected Footer region")
|
t.Error("Expected Footer region")
|
||||||
}
|
}
|
||||||
|
|
||||||
hSlot := c.regions[RegionHeader]
|
headerSlot := composite.regions[RegionHeader]
|
||||||
if hSlot.child == nil {
|
if headerSlot.child == nil {
|
||||||
t.Error("Header should have child layout")
|
t.Error("Header should have child layout for H[LC]")
|
||||||
} else {
|
} else {
|
||||||
if _, ok := hSlot.child.regions[RegionLeft]; !ok {
|
if _, ok := headerSlot.child.regions[RegionLeft]; !ok {
|
||||||
t.Error("Child should have Left region")
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
141
pkg/cli/locales/en.json
Normal file
141
pkg/cli/locales/en.json
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
{
|
||||||
|
"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,115 +1,50 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"fmt"
|
||||||
"forge.lthn.ai/core/go/pkg/log"
|
|
||||||
|
"forge.lthn.ai/core/go-log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogLevel aliases for backwards compatibility.
|
// LogLevel aliases for convenience.
|
||||||
type LogLevel = log.Level
|
type LogLevel = log.Level
|
||||||
|
|
||||||
// Log level constants aliased from the log package.
|
|
||||||
const (
|
const (
|
||||||
// LogLevelQuiet suppresses all output.
|
|
||||||
LogLevelQuiet = log.LevelQuiet
|
LogLevelQuiet = log.LevelQuiet
|
||||||
// LogLevelError shows only error messages.
|
|
||||||
LogLevelError = log.LevelError
|
LogLevelError = log.LevelError
|
||||||
// LogLevelWarn shows warnings and errors.
|
LogLevelWarn = log.LevelWarn
|
||||||
LogLevelWarn = log.LevelWarn
|
LogLevelInfo = log.LevelInfo
|
||||||
// LogLevelInfo shows info, warnings, and errors.
|
|
||||||
LogLevelInfo = log.LevelInfo
|
|
||||||
// LogLevelDebug shows all messages including debug.
|
|
||||||
LogLevelDebug = log.LevelDebug
|
LogLevelDebug = log.LevelDebug
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogService wraps log.Service with CLI styling.
|
// LogDebug logs a debug message if the default logger is available.
|
||||||
type LogService struct {
|
//
|
||||||
*log.Service
|
// cli.LogDebug("cache miss", "key", cacheKey)
|
||||||
}
|
func LogDebug(msg string, keyvals ...any) { log.Debug(msg, keyvals...) }
|
||||||
|
|
||||||
// LogOptions configures the log service.
|
// LogInfo logs an info message.
|
||||||
type LogOptions = log.Options
|
//
|
||||||
|
// cli.LogInfo("configuration reloaded", "path", configPath)
|
||||||
// NewLogService creates a log service factory with CLI styling.
|
func LogInfo(msg string, keyvals ...any) { log.Info(msg, keyvals...) }
|
||||||
func NewLogService(opts LogOptions) func(*core.Core) (any, error) {
|
|
||||||
return func(c *core.Core) (any, error) {
|
// LogWarn logs a warning message.
|
||||||
// Create the underlying service
|
//
|
||||||
factory := log.NewService(opts)
|
// cli.LogWarn("GitHub CLI not authenticated", "user", username)
|
||||||
svc, err := factory(c)
|
func LogWarn(msg string, keyvals ...any) { log.Warn(msg, keyvals...) }
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
// LogError logs an error message.
|
||||||
}
|
//
|
||||||
|
// cli.LogError("Fatal error", "err", err)
|
||||||
logSvc := svc.(*log.Service)
|
func LogError(msg string, keyvals ...any) { log.Error(msg, keyvals...) }
|
||||||
|
|
||||||
// Apply CLI styles
|
// LogSecurity logs a security-sensitive message.
|
||||||
logSvc.StyleTimestamp = func(s string) string { return DimStyle.Render(s) }
|
//
|
||||||
logSvc.StyleDebug = func(s string) string { return DimStyle.Render(s) }
|
// cli.LogSecurity("login attempt", "user", "admin")
|
||||||
logSvc.StyleInfo = func(s string) string { return InfoStyle.Render(s) }
|
func LogSecurity(msg string, keyvals ...any) { log.Security(msg, keyvals...) }
|
||||||
logSvc.StyleWarn = func(s string) string { return WarningStyle.Render(s) }
|
|
||||||
logSvc.StyleError = func(s string) string { return ErrorStyle.Render(s) }
|
// LogSecurityf logs a formatted security-sensitive message.
|
||||||
logSvc.StyleSecurity = func(s string) string { return SecurityStyle.Render(s) }
|
//
|
||||||
|
// cli.LogSecurityf("login attempt from %s", username)
|
||||||
return &LogService{Service: logSvc}, nil
|
func LogSecurityf(format string, args ...any) {
|
||||||
}
|
log.Security(fmt.Sprintf(format, args...))
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
pkg/cli/log_test.go
Normal file
43
pkg/cli/log_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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