refactor: migrate 14 cmd packages to their go-* repos
Moved CLI command packages to their respective ecosystem repos: - go-ai: daemon, mcpcmd, security - go-api: api - go-crypt: crypt, testcmd - go-devops: deploy, prod, vm - go-netops: unifi - go-rag: rag - go-scm: collect, forge, gitea Updated main.go imports to reference external repos. Updated cmd/ai to import rag from go-rag. Deleted 14 cmd/ directories (9,421 lines removed). Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
a7d09e4c67
commit
cce9adc043
80 changed files with 40 additions and 9421 deletions
|
|
@ -13,7 +13,7 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
ragcmd "forge.lthn.ai/core/cli/cmd/rag"
|
||||
ragcmd "forge.lthn.ai/core/go-rag/cmd/rag"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddAPICommands)
|
||||
}
|
||||
|
||||
// AddAPICommands registers the 'api' command group.
|
||||
func AddAPICommands(root *cli.Command) {
|
||||
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
|
||||
root.AddCommand(apiCmd)
|
||||
|
||||
addSpecCommand(apiCmd)
|
||||
addSDKCommand(apiCmd)
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
goapi "forge.lthn.ai/core/go-api"
|
||||
)
|
||||
|
||||
func addSDKCommand(parent *cli.Command) {
|
||||
var (
|
||||
lang string
|
||||
output string
|
||||
specFile string
|
||||
packageName string
|
||||
)
|
||||
|
||||
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
|
||||
if lang == "" {
|
||||
return fmt.Errorf("--lang is required. Supported: %s", strings.Join(goapi.SupportedLanguages(), ", "))
|
||||
}
|
||||
|
||||
// If no spec file provided, generate one to a temp file.
|
||||
if specFile == "" {
|
||||
builder := &goapi.SpecBuilder{
|
||||
Title: "Lethean Core API",
|
||||
Description: "Lethean Core API",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
|
||||
bridge := goapi.NewToolBridge("/tools")
|
||||
groups := []goapi.RouteGroup{bridge}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "openapi-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp spec file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if err := goapi.ExportSpec(tmpFile, "json", builder, groups); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("generate spec: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
specFile = tmpFile.Name()
|
||||
}
|
||||
|
||||
gen := &goapi.SDKGenerator{
|
||||
SpecPath: specFile,
|
||||
OutputDir: output,
|
||||
PackageName: packageName,
|
||||
}
|
||||
|
||||
if !gen.Available() {
|
||||
fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:")
|
||||
fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)")
|
||||
fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g")
|
||||
return fmt.Errorf("openapi-generator-cli not installed")
|
||||
}
|
||||
|
||||
// Generate for each language.
|
||||
languages := strings.Split(lang, ",")
|
||||
for _, l := range languages {
|
||||
l = strings.TrimSpace(l)
|
||||
fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l)
|
||||
if err := gen.Generate(context.Background(), l); err != nil {
|
||||
return fmt.Errorf("generate %s: %w", l, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
cli.StringFlag(cmd, &lang, "lang", "l", "", "Target language(s), comma-separated (e.g. go,python,typescript-fetch)")
|
||||
cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs")
|
||||
cli.StringFlag(cmd, &specFile, "spec", "s", "", "Path to existing OpenAPI spec (generates from MCP tools if not provided)")
|
||||
cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
goapi "forge.lthn.ai/core/go-api"
|
||||
)
|
||||
|
||||
func addSpecCommand(parent *cli.Command) {
|
||||
var (
|
||||
output string
|
||||
format string
|
||||
title string
|
||||
version string
|
||||
)
|
||||
|
||||
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
|
||||
// Build spec from registered route groups.
|
||||
// Additional groups can be added here as the platform grows.
|
||||
builder := &goapi.SpecBuilder{
|
||||
Title: title,
|
||||
Description: "Lethean Core API",
|
||||
Version: version,
|
||||
}
|
||||
|
||||
// Start with the default tool bridge — future versions will
|
||||
// auto-populate from the MCP tool registry once the bridge
|
||||
// integration lands in the local go-ai module.
|
||||
bridge := goapi.NewToolBridge("/tools")
|
||||
groups := []goapi.RouteGroup{bridge}
|
||||
|
||||
if output != "" {
|
||||
if err := goapi.ExportSpecToFile(output, format, builder, groups); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
|
||||
return nil
|
||||
}
|
||||
|
||||
return goapi.ExportSpec(os.Stdout, format, builder, groups)
|
||||
})
|
||||
|
||||
cli.StringFlag(cmd, &output, "output", "o", "", "Write spec to file instead of stdout")
|
||||
cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml")
|
||||
cli.StringFlag(cmd, &title, "title", "t", "Lethean Core API", "API title in spec")
|
||||
cli.StringFlag(cmd, &version, "version", "V", "1.0.0", "API version in spec")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
func TestAPISpecCmd_Good_CommandStructure(t *testing.T) {
|
||||
root := &cli.Command{Use: "root"}
|
||||
AddAPICommands(root)
|
||||
|
||||
apiCmd, _, err := root.Find([]string{"api"})
|
||||
if err != nil {
|
||||
t.Fatalf("api command not found: %v", err)
|
||||
}
|
||||
|
||||
specCmd, _, err := apiCmd.Find([]string{"spec"})
|
||||
if err != nil {
|
||||
t.Fatalf("spec subcommand not found: %v", err)
|
||||
}
|
||||
if specCmd.Use != "spec" {
|
||||
t.Fatalf("expected Use=spec, got %s", specCmd.Use)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISpecCmd_Good_JSON(t *testing.T) {
|
||||
root := &cli.Command{Use: "root"}
|
||||
AddAPICommands(root)
|
||||
|
||||
apiCmd, _, err := root.Find([]string{"api"})
|
||||
if err != nil {
|
||||
t.Fatalf("api command not found: %v", err)
|
||||
}
|
||||
|
||||
specCmd, _, err := apiCmd.Find([]string{"spec"})
|
||||
if err != nil {
|
||||
t.Fatalf("spec subcommand not found: %v", err)
|
||||
}
|
||||
|
||||
// Verify flags exist
|
||||
if specCmd.Flag("format") == nil {
|
||||
t.Fatal("expected --format flag on spec command")
|
||||
}
|
||||
if specCmd.Flag("output") == nil {
|
||||
t.Fatal("expected --output flag on spec command")
|
||||
}
|
||||
if specCmd.Flag("title") == nil {
|
||||
t.Fatal("expected --title flag on spec command")
|
||||
}
|
||||
if specCmd.Flag("version") == nil {
|
||||
t.Fatal("expected --version flag on spec command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISDKCmd_Bad_NoLang(t *testing.T) {
|
||||
root := &cli.Command{Use: "root"}
|
||||
AddAPICommands(root)
|
||||
|
||||
root.SetArgs([]string{"api", "sdk"})
|
||||
buf := new(bytes.Buffer)
|
||||
root.SetOut(buf)
|
||||
root.SetErr(buf)
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --lang not provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISDKCmd_Good_ValidatesLanguage(t *testing.T) {
|
||||
root := &cli.Command{Use: "root"}
|
||||
AddAPICommands(root)
|
||||
|
||||
apiCmd, _, err := root.Find([]string{"api"})
|
||||
if err != nil {
|
||||
t.Fatalf("api command not found: %v", err)
|
||||
}
|
||||
|
||||
sdkCmd, _, err := apiCmd.Find([]string{"sdk"})
|
||||
if err != nil {
|
||||
t.Fatalf("sdk subcommand not found: %v", err)
|
||||
}
|
||||
|
||||
// Verify flags exist
|
||||
if sdkCmd.Flag("lang") == nil {
|
||||
t.Fatal("expected --lang flag on sdk command")
|
||||
}
|
||||
if sdkCmd.Flag("output") == nil {
|
||||
t.Fatal("expected --output flag on sdk command")
|
||||
}
|
||||
if sdkCmd.Flag("spec") == nil {
|
||||
t.Fatal("expected --spec flag on sdk command")
|
||||
}
|
||||
if sdkCmd.Flag("package") == nil {
|
||||
t.Fatal("expected --package flag on sdk command")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddCollectCommands)
|
||||
}
|
||||
|
||||
// Style aliases from shared package
|
||||
var (
|
||||
dimStyle = cli.DimStyle
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
)
|
||||
|
||||
// Shared flags across all collect subcommands
|
||||
var (
|
||||
collectOutputDir string
|
||||
collectVerbose bool
|
||||
collectDryRun bool
|
||||
)
|
||||
|
||||
// AddCollectCommands registers the 'collect' command and all subcommands.
|
||||
func AddCollectCommands(root *cli.Command) {
|
||||
collectCmd := &cli.Command{
|
||||
Use: "collect",
|
||||
Short: i18n.T("cmd.collect.short"),
|
||||
Long: i18n.T("cmd.collect.long"),
|
||||
}
|
||||
|
||||
// Persistent flags shared across subcommands
|
||||
cli.PersistentStringFlag(collectCmd, &collectOutputDir, "output", "o", "./collect", i18n.T("cmd.collect.flag.output"))
|
||||
cli.PersistentBoolFlag(collectCmd, &collectVerbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
|
||||
cli.PersistentBoolFlag(collectCmd, &collectDryRun, "dry-run", "", false, i18n.T("cmd.collect.flag.dry_run"))
|
||||
|
||||
root.AddCommand(collectCmd)
|
||||
|
||||
addGitHubCommand(collectCmd)
|
||||
addBitcoinTalkCommand(collectCmd)
|
||||
addMarketCommand(collectCmd)
|
||||
addPapersCommand(collectCmd)
|
||||
addExcavateCommand(collectCmd)
|
||||
addProcessCommand(collectCmd)
|
||||
addDispatchCommand(collectCmd)
|
||||
}
|
||||
|
||||
// newConfig creates a collection Config using the shared persistent flags.
|
||||
// It uses io.Local for real filesystem access rather than the mock medium.
|
||||
func newConfig() *collect.Config {
|
||||
cfg := collect.NewConfigWithMedium(io.Local, collectOutputDir)
|
||||
cfg.Verbose = collectVerbose
|
||||
cfg.DryRun = collectDryRun
|
||||
return cfg
|
||||
}
|
||||
|
||||
// setupVerboseLogging registers event handlers on the dispatcher for verbose output.
|
||||
func setupVerboseLogging(cfg *collect.Config) {
|
||||
if !cfg.Verbose {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Dispatcher.On(collect.EventStart, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", dimStyle.Render("[start]"), e.Message)
|
||||
})
|
||||
cfg.Dispatcher.On(collect.EventProgress, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", dimStyle.Render("[progress]"), e.Message)
|
||||
})
|
||||
cfg.Dispatcher.On(collect.EventItem, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", dimStyle.Render("[item]"), e.Message)
|
||||
})
|
||||
cfg.Dispatcher.On(collect.EventError, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", errorStyle.Render("[error]"), e.Message)
|
||||
})
|
||||
cfg.Dispatcher.On(collect.EventComplete, func(e collect.Event) {
|
||||
cli.Print("%s %s\n", successStyle.Render("[complete]"), e.Message)
|
||||
})
|
||||
}
|
||||
|
||||
// printResult prints a formatted summary of a collection result.
|
||||
func printResult(result *collect.Result) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if result.Items > 0 {
|
||||
cli.Success(fmt.Sprintf("Collected %d items from %s", result.Items, result.Source))
|
||||
} else {
|
||||
cli.Dim(fmt.Sprintf("No items collected from %s", result.Source))
|
||||
}
|
||||
|
||||
if result.Skipped > 0 {
|
||||
cli.Dim(fmt.Sprintf(" Skipped: %d", result.Skipped))
|
||||
}
|
||||
|
||||
if result.Errors > 0 {
|
||||
cli.Warn(fmt.Sprintf(" Errors: %d", result.Errors))
|
||||
}
|
||||
|
||||
if collectVerbose && len(result.Files) > 0 {
|
||||
cli.Dim(fmt.Sprintf(" Files: %d", len(result.Files)))
|
||||
for _, f := range result.Files {
|
||||
cli.Print(" %s\n", dimStyle.Render(f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// BitcoinTalk command flags
|
||||
var bitcointalkPages int
|
||||
|
||||
// addBitcoinTalkCommand adds the 'bitcointalk' subcommand to the collect parent.
|
||||
func addBitcoinTalkCommand(parent *cli.Command) {
|
||||
btcCmd := &cli.Command{
|
||||
Use: "bitcointalk <topic-id|url>",
|
||||
Short: i18n.T("cmd.collect.bitcointalk.short"),
|
||||
Long: i18n.T("cmd.collect.bitcointalk.long"),
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runBitcoinTalk(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cli.IntFlag(btcCmd, &bitcointalkPages, "pages", "p", 0, i18n.T("cmd.collect.bitcointalk.flag.pages"))
|
||||
|
||||
parent.AddCommand(btcCmd)
|
||||
}
|
||||
|
||||
func runBitcoinTalk(target string) error {
|
||||
var topicID, url string
|
||||
|
||||
// Determine if argument is a URL or topic ID
|
||||
if strings.HasPrefix(target, "http") {
|
||||
url = target
|
||||
} else {
|
||||
topicID = target
|
||||
}
|
||||
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
collector := &collect.BitcoinTalkCollector{
|
||||
TopicID: topicID,
|
||||
URL: url,
|
||||
Pages: bitcointalkPages,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would collect from BitcoinTalk topic " + target)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := collector.Collect(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "bitcointalk collection failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
collectpkg "forge.lthn.ai/core/go-scm/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// addDispatchCommand adds the 'dispatch' subcommand to the collect parent.
|
||||
func addDispatchCommand(parent *cli.Command) {
|
||||
dispatchCmd := &cli.Command{
|
||||
Use: "dispatch <event>",
|
||||
Short: i18n.T("cmd.collect.dispatch.short"),
|
||||
Long: i18n.T("cmd.collect.dispatch.long"),
|
||||
Args: cli.MinimumNArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runDispatch(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
// Add hooks subcommand group
|
||||
hooksCmd := &cli.Command{
|
||||
Use: "hooks",
|
||||
Short: i18n.T("cmd.collect.dispatch.hooks.short"),
|
||||
}
|
||||
|
||||
addHooksListCommand(hooksCmd)
|
||||
addHooksRegisterCommand(hooksCmd)
|
||||
|
||||
dispatchCmd.AddCommand(hooksCmd)
|
||||
parent.AddCommand(dispatchCmd)
|
||||
}
|
||||
|
||||
func runDispatch(eventType string) error {
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
// Validate event type
|
||||
switch eventType {
|
||||
case collectpkg.EventStart,
|
||||
collectpkg.EventProgress,
|
||||
collectpkg.EventItem,
|
||||
collectpkg.EventError,
|
||||
collectpkg.EventComplete:
|
||||
// Valid event type
|
||||
default:
|
||||
return cli.Err("unknown event type: %s (valid: start, progress, item, error, complete)", eventType)
|
||||
}
|
||||
|
||||
event := collectpkg.Event{
|
||||
Type: eventType,
|
||||
Source: "cli",
|
||||
Message: fmt.Sprintf("Manual dispatch of %s event", eventType),
|
||||
Time: time.Now(),
|
||||
}
|
||||
|
||||
cfg.Dispatcher.Emit(event)
|
||||
cli.Success(fmt.Sprintf("Dispatched %s event", eventType))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addHooksListCommand adds the 'hooks list' subcommand.
|
||||
func addHooksListCommand(parent *cli.Command) {
|
||||
listCmd := &cli.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("cmd.collect.dispatch.hooks.list.short"),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runHooksList()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(listCmd)
|
||||
}
|
||||
|
||||
func runHooksList() error {
|
||||
eventTypes := []string{
|
||||
collectpkg.EventStart,
|
||||
collectpkg.EventProgress,
|
||||
collectpkg.EventItem,
|
||||
collectpkg.EventError,
|
||||
collectpkg.EventComplete,
|
||||
}
|
||||
|
||||
table := cli.NewTable("Event", "Status")
|
||||
for _, et := range eventTypes {
|
||||
table.AddRow(et, dimStyle.Render("no hooks registered"))
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print("%s\n\n", cli.HeaderStyle.Render("Registered Hooks"))
|
||||
table.Render()
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addHooksRegisterCommand adds the 'hooks register' subcommand.
|
||||
func addHooksRegisterCommand(parent *cli.Command) {
|
||||
registerCmd := &cli.Command{
|
||||
Use: "register <event> <command>",
|
||||
Short: i18n.T("cmd.collect.dispatch.hooks.register.short"),
|
||||
Args: cli.ExactArgs(2),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runHooksRegister(args[0], args[1])
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(registerCmd)
|
||||
}
|
||||
|
||||
func runHooksRegister(eventType, command string) error {
|
||||
// Validate event type
|
||||
switch eventType {
|
||||
case collectpkg.EventStart,
|
||||
collectpkg.EventProgress,
|
||||
collectpkg.EventItem,
|
||||
collectpkg.EventError,
|
||||
collectpkg.EventComplete:
|
||||
// Valid
|
||||
default:
|
||||
return cli.Err("unknown event type: %s (valid: start, progress, item, error, complete)", eventType)
|
||||
}
|
||||
|
||||
cli.Success(fmt.Sprintf("Registered hook for %s: %s", eventType, command))
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Excavate command flags
|
||||
var (
|
||||
excavateScanOnly bool
|
||||
excavateResume bool
|
||||
)
|
||||
|
||||
// addExcavateCommand adds the 'excavate' subcommand to the collect parent.
|
||||
func addExcavateCommand(parent *cli.Command) {
|
||||
excavateCmd := &cli.Command{
|
||||
Use: "excavate <project>",
|
||||
Short: i18n.T("cmd.collect.excavate.short"),
|
||||
Long: i18n.T("cmd.collect.excavate.long"),
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runExcavate(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cli.BoolFlag(excavateCmd, &excavateScanOnly, "scan-only", "", false, i18n.T("cmd.collect.excavate.flag.scan_only"))
|
||||
cli.BoolFlag(excavateCmd, &excavateResume, "resume", "r", false, i18n.T("cmd.collect.excavate.flag.resume"))
|
||||
|
||||
parent.AddCommand(excavateCmd)
|
||||
}
|
||||
|
||||
func runExcavate(project string) error {
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
// Load state for resume
|
||||
if excavateResume {
|
||||
if err := cfg.State.Load(); err != nil {
|
||||
return cli.Wrap(err, "failed to load collection state")
|
||||
}
|
||||
}
|
||||
|
||||
// Build collectors for the project
|
||||
collectors := buildProjectCollectors(project)
|
||||
if len(collectors) == 0 {
|
||||
return cli.Err("no collectors configured for project: %s", project)
|
||||
}
|
||||
|
||||
excavator := &collect.Excavator{
|
||||
Collectors: collectors,
|
||||
ScanOnly: excavateScanOnly,
|
||||
Resume: excavateResume,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info(fmt.Sprintf("Dry run: would excavate project %s with %d collectors", project, len(collectors)))
|
||||
for _, c := range collectors {
|
||||
cli.Dim(fmt.Sprintf(" - %s", c.Name()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := excavator.Run(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "excavation failed")
|
||||
}
|
||||
|
||||
// Save state for future resume
|
||||
if err := cfg.State.Save(); err != nil {
|
||||
cli.Warnf("Failed to save state: %v", err)
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildProjectCollectors creates collectors based on the project name.
|
||||
// This maps known project names to their collector configurations.
|
||||
func buildProjectCollectors(project string) []collect.Collector {
|
||||
switch project {
|
||||
case "bitcoin":
|
||||
return []collect.Collector{
|
||||
&collect.GitHubCollector{Org: "bitcoin", Repo: "bitcoin"},
|
||||
&collect.MarketCollector{CoinID: "bitcoin", Historical: true},
|
||||
}
|
||||
case "ethereum":
|
||||
return []collect.Collector{
|
||||
&collect.GitHubCollector{Org: "ethereum", Repo: "go-ethereum"},
|
||||
&collect.MarketCollector{CoinID: "ethereum", Historical: true},
|
||||
&collect.PapersCollector{Source: "all", Query: "ethereum"},
|
||||
}
|
||||
default:
|
||||
// Treat unknown projects as GitHub org/repo
|
||||
return []collect.Collector{
|
||||
&collect.GitHubCollector{Org: project},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// GitHub command flags
|
||||
var (
|
||||
githubOrg bool
|
||||
githubIssuesOnly bool
|
||||
githubPRsOnly bool
|
||||
)
|
||||
|
||||
// addGitHubCommand adds the 'github' subcommand to the collect parent.
|
||||
func addGitHubCommand(parent *cli.Command) {
|
||||
githubCmd := &cli.Command{
|
||||
Use: "github <org/repo>",
|
||||
Short: i18n.T("cmd.collect.github.short"),
|
||||
Long: i18n.T("cmd.collect.github.long"),
|
||||
Args: cli.MinimumNArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runGitHub(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cli.BoolFlag(githubCmd, &githubOrg, "org", "", false, i18n.T("cmd.collect.github.flag.org"))
|
||||
cli.BoolFlag(githubCmd, &githubIssuesOnly, "issues-only", "", false, i18n.T("cmd.collect.github.flag.issues_only"))
|
||||
cli.BoolFlag(githubCmd, &githubPRsOnly, "prs-only", "", false, i18n.T("cmd.collect.github.flag.prs_only"))
|
||||
|
||||
parent.AddCommand(githubCmd)
|
||||
}
|
||||
|
||||
func runGitHub(target string) error {
|
||||
if githubIssuesOnly && githubPRsOnly {
|
||||
return cli.Err("--issues-only and --prs-only are mutually exclusive")
|
||||
}
|
||||
|
||||
// Parse org/repo argument
|
||||
var org, repo string
|
||||
if strings.Contains(target, "/") {
|
||||
parts := strings.SplitN(target, "/", 2)
|
||||
org = parts[0]
|
||||
repo = parts[1]
|
||||
} else if githubOrg {
|
||||
org = target
|
||||
} else {
|
||||
return cli.Err("argument must be in org/repo format, or use --org for organisation-wide collection")
|
||||
}
|
||||
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
collector := &collect.GitHubCollector{
|
||||
Org: org,
|
||||
Repo: repo,
|
||||
IssuesOnly: githubIssuesOnly,
|
||||
PRsOnly: githubPRsOnly,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would collect from GitHub " + target)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := collector.Collect(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "github collection failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Market command flags
|
||||
var (
|
||||
marketHistorical bool
|
||||
marketFromDate string
|
||||
)
|
||||
|
||||
// addMarketCommand adds the 'market' subcommand to the collect parent.
|
||||
func addMarketCommand(parent *cli.Command) {
|
||||
marketCmd := &cli.Command{
|
||||
Use: "market <coin>",
|
||||
Short: i18n.T("cmd.collect.market.short"),
|
||||
Long: i18n.T("cmd.collect.market.long"),
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runMarket(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cli.BoolFlag(marketCmd, &marketHistorical, "historical", "H", false, i18n.T("cmd.collect.market.flag.historical"))
|
||||
cli.StringFlag(marketCmd, &marketFromDate, "from", "f", "", i18n.T("cmd.collect.market.flag.from"))
|
||||
|
||||
parent.AddCommand(marketCmd)
|
||||
}
|
||||
|
||||
func runMarket(coinID string) error {
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
collector := &collect.MarketCollector{
|
||||
CoinID: coinID,
|
||||
Historical: marketHistorical,
|
||||
FromDate: marketFromDate,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would collect market data for " + coinID)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := collector.Collect(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "market collection failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Papers command flags
|
||||
var (
|
||||
papersSource string
|
||||
papersCategory string
|
||||
papersQuery string
|
||||
)
|
||||
|
||||
// addPapersCommand adds the 'papers' subcommand to the collect parent.
|
||||
func addPapersCommand(parent *cli.Command) {
|
||||
papersCmd := &cli.Command{
|
||||
Use: "papers",
|
||||
Short: i18n.T("cmd.collect.papers.short"),
|
||||
Long: i18n.T("cmd.collect.papers.long"),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runPapers()
|
||||
},
|
||||
}
|
||||
|
||||
cli.StringFlag(papersCmd, &papersSource, "source", "s", "all", i18n.T("cmd.collect.papers.flag.source"))
|
||||
cli.StringFlag(papersCmd, &papersCategory, "category", "c", "", i18n.T("cmd.collect.papers.flag.category"))
|
||||
cli.StringFlag(papersCmd, &papersQuery, "query", "q", "", i18n.T("cmd.collect.papers.flag.query"))
|
||||
|
||||
parent.AddCommand(papersCmd)
|
||||
}
|
||||
|
||||
func runPapers() error {
|
||||
if papersQuery == "" {
|
||||
return cli.Err("--query (-q) is required")
|
||||
}
|
||||
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
collector := &collect.PapersCollector{
|
||||
Source: papersSource,
|
||||
Category: papersCategory,
|
||||
Query: papersQuery,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would collect papers from " + papersSource)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := collector.Collect(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "papers collection failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-scm/collect"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// addProcessCommand adds the 'process' subcommand to the collect parent.
|
||||
func addProcessCommand(parent *cli.Command) {
|
||||
processCmd := &cli.Command{
|
||||
Use: "process <source> <dir>",
|
||||
Short: i18n.T("cmd.collect.process.short"),
|
||||
Long: i18n.T("cmd.collect.process.long"),
|
||||
Args: cli.ExactArgs(2),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runProcess(args[0], args[1])
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(processCmd)
|
||||
}
|
||||
|
||||
func runProcess(source, dir string) error {
|
||||
cfg := newConfig()
|
||||
setupVerboseLogging(cfg)
|
||||
|
||||
processor := &collect.Processor{
|
||||
Source: source,
|
||||
Dir: dir,
|
||||
}
|
||||
|
||||
if cfg.DryRun {
|
||||
cli.Info("Dry run: would process " + source + " data in " + dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := processor.Process(ctx, cfg)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "processing failed")
|
||||
}
|
||||
|
||||
printResult(result)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package crypt
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddCryptCommands)
|
||||
}
|
||||
|
||||
// AddCryptCommands registers the 'crypt' command group and all subcommands.
|
||||
func AddCryptCommands(root *cli.Command) {
|
||||
cryptCmd := &cli.Command{
|
||||
Use: "crypt",
|
||||
Short: "Cryptographic utilities",
|
||||
Long: "Encrypt, decrypt, hash, and checksum files and data.",
|
||||
}
|
||||
root.AddCommand(cryptCmd)
|
||||
|
||||
addHashCommand(cryptCmd)
|
||||
addEncryptCommand(cryptCmd)
|
||||
addKeygenCommand(cryptCmd)
|
||||
addChecksumCommand(cryptCmd)
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-crypt/crypt"
|
||||
)
|
||||
|
||||
// Checksum command flags
|
||||
var (
|
||||
checksumSHA512 bool
|
||||
checksumVerify string
|
||||
)
|
||||
|
||||
func addChecksumCommand(parent *cli.Command) {
|
||||
checksumCmd := cli.NewCommand("checksum", "Compute file checksum", "", func(cmd *cli.Command, args []string) error {
|
||||
return runChecksum(args[0])
|
||||
})
|
||||
checksumCmd.Args = cli.ExactArgs(1)
|
||||
|
||||
cli.BoolFlag(checksumCmd, &checksumSHA512, "sha512", "", false, "Use SHA-512 instead of SHA-256")
|
||||
cli.StringFlag(checksumCmd, &checksumVerify, "verify", "", "", "Verify file against this hash")
|
||||
|
||||
parent.AddCommand(checksumCmd)
|
||||
}
|
||||
|
||||
func runChecksum(path string) error {
|
||||
var hash string
|
||||
var err error
|
||||
|
||||
if checksumSHA512 {
|
||||
hash, err = crypt.SHA512File(path)
|
||||
} else {
|
||||
hash, err = crypt.SHA256File(path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to compute checksum")
|
||||
}
|
||||
|
||||
if checksumVerify != "" {
|
||||
if hash == checksumVerify {
|
||||
cli.Success(fmt.Sprintf("Checksum matches: %s", filepath.Base(path)))
|
||||
return nil
|
||||
}
|
||||
cli.Error(fmt.Sprintf("Checksum mismatch: %s", filepath.Base(path)))
|
||||
cli.Dim(fmt.Sprintf(" expected: %s", checksumVerify))
|
||||
cli.Dim(fmt.Sprintf(" got: %s", hash))
|
||||
return cli.Err("checksum verification failed")
|
||||
}
|
||||
|
||||
algo := "SHA-256"
|
||||
if checksumSHA512 {
|
||||
algo = "SHA-512"
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s (%s)\n", hash, path, algo)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-crypt/crypt"
|
||||
)
|
||||
|
||||
// Encrypt command flags
|
||||
var (
|
||||
encryptPassphrase string
|
||||
encryptAES bool
|
||||
)
|
||||
|
||||
func addEncryptCommand(parent *cli.Command) {
|
||||
encryptCmd := cli.NewCommand("encrypt", "Encrypt a file", "", func(cmd *cli.Command, args []string) error {
|
||||
return runEncrypt(args[0])
|
||||
})
|
||||
encryptCmd.Args = cli.ExactArgs(1)
|
||||
|
||||
cli.StringFlag(encryptCmd, &encryptPassphrase, "passphrase", "p", "", "Passphrase (prompted if not given)")
|
||||
cli.BoolFlag(encryptCmd, &encryptAES, "aes", "", false, "Use AES-256-GCM instead of ChaCha20-Poly1305")
|
||||
|
||||
parent.AddCommand(encryptCmd)
|
||||
|
||||
decryptCmd := cli.NewCommand("decrypt", "Decrypt an encrypted file", "", func(cmd *cli.Command, args []string) error {
|
||||
return runDecrypt(args[0])
|
||||
})
|
||||
decryptCmd.Args = cli.ExactArgs(1)
|
||||
|
||||
cli.StringFlag(decryptCmd, &encryptPassphrase, "passphrase", "p", "", "Passphrase (prompted if not given)")
|
||||
cli.BoolFlag(decryptCmd, &encryptAES, "aes", "", false, "Use AES-256-GCM instead of ChaCha20-Poly1305")
|
||||
|
||||
parent.AddCommand(decryptCmd)
|
||||
}
|
||||
|
||||
func getPassphrase() (string, error) {
|
||||
if encryptPassphrase != "" {
|
||||
return encryptPassphrase, nil
|
||||
}
|
||||
return cli.Prompt("Passphrase", "")
|
||||
}
|
||||
|
||||
func runEncrypt(path string) error {
|
||||
passphrase, err := getPassphrase()
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to read passphrase")
|
||||
}
|
||||
if passphrase == "" {
|
||||
return cli.Err("passphrase cannot be empty")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to read file")
|
||||
}
|
||||
|
||||
var encrypted []byte
|
||||
if encryptAES {
|
||||
encrypted, err = crypt.EncryptAES(data, []byte(passphrase))
|
||||
} else {
|
||||
encrypted, err = crypt.Encrypt(data, []byte(passphrase))
|
||||
}
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to encrypt")
|
||||
}
|
||||
|
||||
outPath := path + ".enc"
|
||||
if err := os.WriteFile(outPath, encrypted, 0o600); err != nil {
|
||||
return cli.Wrap(err, "failed to write encrypted file")
|
||||
}
|
||||
|
||||
cli.Success(fmt.Sprintf("Encrypted %s -> %s", path, outPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDecrypt(path string) error {
|
||||
passphrase, err := getPassphrase()
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to read passphrase")
|
||||
}
|
||||
if passphrase == "" {
|
||||
return cli.Err("passphrase cannot be empty")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to read file")
|
||||
}
|
||||
|
||||
var decrypted []byte
|
||||
if encryptAES {
|
||||
decrypted, err = crypt.DecryptAES(data, []byte(passphrase))
|
||||
} else {
|
||||
decrypted, err = crypt.Decrypt(data, []byte(passphrase))
|
||||
}
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to decrypt")
|
||||
}
|
||||
|
||||
outPath := strings.TrimSuffix(path, ".enc")
|
||||
if outPath == path {
|
||||
outPath = path + ".dec"
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outPath, decrypted, 0o600); err != nil {
|
||||
return cli.Wrap(err, "failed to write decrypted file")
|
||||
}
|
||||
|
||||
cli.Success(fmt.Sprintf("Decrypted %s -> %s", path, outPath))
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-crypt/crypt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Hash command flags
|
||||
var (
|
||||
hashBcrypt bool
|
||||
hashVerify string
|
||||
)
|
||||
|
||||
func addHashCommand(parent *cli.Command) {
|
||||
hashCmd := cli.NewCommand("hash", "Hash a password with Argon2id or bcrypt", "", func(cmd *cli.Command, args []string) error {
|
||||
return runHash(args[0])
|
||||
})
|
||||
hashCmd.Args = cli.ExactArgs(1)
|
||||
|
||||
cli.BoolFlag(hashCmd, &hashBcrypt, "bcrypt", "b", false, "Use bcrypt instead of Argon2id")
|
||||
cli.StringFlag(hashCmd, &hashVerify, "verify", "", "", "Verify input against this hash")
|
||||
|
||||
parent.AddCommand(hashCmd)
|
||||
}
|
||||
|
||||
func runHash(input string) error {
|
||||
// Verify mode
|
||||
if hashVerify != "" {
|
||||
return runHashVerify(input, hashVerify)
|
||||
}
|
||||
|
||||
// Hash mode
|
||||
if hashBcrypt {
|
||||
hash, err := crypt.HashBcrypt(input, bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to hash password")
|
||||
}
|
||||
fmt.Println(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
hash, err := crypt.HashPassword(input)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to hash password")
|
||||
}
|
||||
fmt.Println(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runHashVerify(input, hash string) error {
|
||||
var match bool
|
||||
var err error
|
||||
|
||||
if hashBcrypt {
|
||||
match, err = crypt.VerifyBcrypt(input, hash)
|
||||
} else {
|
||||
match, err = crypt.VerifyPassword(input, hash)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to verify hash")
|
||||
}
|
||||
|
||||
if match {
|
||||
cli.Success("Password matches hash")
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Error("Password does not match hash")
|
||||
return cli.Err("hash verification failed")
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// Keygen command flags
|
||||
var (
|
||||
keygenLength int
|
||||
keygenHex bool
|
||||
keygenBase64 bool
|
||||
)
|
||||
|
||||
func addKeygenCommand(parent *cli.Command) {
|
||||
keygenCmd := cli.NewCommand("keygen", "Generate a random cryptographic key", "", func(cmd *cli.Command, args []string) error {
|
||||
return runKeygen()
|
||||
})
|
||||
|
||||
cli.IntFlag(keygenCmd, &keygenLength, "length", "l", 32, "Key length in bytes")
|
||||
cli.BoolFlag(keygenCmd, &keygenHex, "hex", "", false, "Output as hex string")
|
||||
cli.BoolFlag(keygenCmd, &keygenBase64, "base64", "", false, "Output as base64 string")
|
||||
|
||||
parent.AddCommand(keygenCmd)
|
||||
}
|
||||
|
||||
func runKeygen() error {
|
||||
if keygenHex && keygenBase64 {
|
||||
return cli.Err("--hex and --base64 are mutually exclusive")
|
||||
}
|
||||
if keygenLength <= 0 || keygenLength > 1024 {
|
||||
return cli.Err("key length must be between 1 and 1024 bytes")
|
||||
}
|
||||
|
||||
key := make([]byte, keygenLength)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return cli.Wrap(err, "failed to generate random key")
|
||||
}
|
||||
|
||||
switch {
|
||||
case keygenHex:
|
||||
fmt.Println(hex.EncodeToString(key))
|
||||
case keygenBase64:
|
||||
fmt.Println(base64.StdEncoding.EncodeToString(key))
|
||||
default:
|
||||
// Default to hex output
|
||||
fmt.Println(hex.EncodeToString(key))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
// Package daemon provides the `core daemon` command for running as a background service.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
"forge.lthn.ai/core/go-ai/mcp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddDaemonCommand)
|
||||
}
|
||||
|
||||
// Transport types for MCP server.
|
||||
const (
|
||||
TransportStdio = "stdio"
|
||||
TransportTCP = "tcp"
|
||||
TransportSocket = "socket"
|
||||
)
|
||||
|
||||
// Config holds daemon configuration.
|
||||
type Config struct {
|
||||
// MCPTransport is the MCP server transport type (stdio, tcp, socket).
|
||||
MCPTransport string
|
||||
// MCPAddr is the address/path for tcp or socket transports.
|
||||
MCPAddr string
|
||||
// HealthAddr is the address for health check endpoints.
|
||||
HealthAddr string
|
||||
// PIDFile is the path for the PID file.
|
||||
PIDFile string
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default daemon configuration.
|
||||
func DefaultConfig() Config {
|
||||
home, _ := os.UserHomeDir()
|
||||
return Config{
|
||||
MCPTransport: TransportTCP,
|
||||
MCPAddr: mcp.DefaultTCPAddr,
|
||||
HealthAddr: "127.0.0.1:9101",
|
||||
PIDFile: filepath.Join(home, ".core", "daemon.pid"),
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigFromEnv loads configuration from environment variables.
|
||||
func ConfigFromEnv() Config {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if v := os.Getenv("CORE_MCP_TRANSPORT"); v != "" {
|
||||
cfg.MCPTransport = v
|
||||
}
|
||||
if v := os.Getenv("CORE_MCP_ADDR"); v != "" {
|
||||
cfg.MCPAddr = v
|
||||
}
|
||||
if v := os.Getenv("CORE_HEALTH_ADDR"); v != "" {
|
||||
cfg.HealthAddr = v
|
||||
}
|
||||
if v := os.Getenv("CORE_PID_FILE"); v != "" {
|
||||
cfg.PIDFile = v
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// AddDaemonCommand adds the 'daemon' command group to the root.
|
||||
func AddDaemonCommand(root *cli.Command) {
|
||||
cfg := ConfigFromEnv()
|
||||
|
||||
daemonCmd := cli.NewGroup(
|
||||
"daemon",
|
||||
"Manage the core daemon",
|
||||
"Manage the core background daemon which provides long-running services.\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)",
|
||||
)
|
||||
|
||||
// Persistent flags inherited by all subcommands
|
||||
cli.PersistentStringFlag(daemonCmd, &cfg.MCPTransport, "mcp-transport", "t", cfg.MCPTransport,
|
||||
"MCP transport type (stdio, tcp, socket)")
|
||||
cli.PersistentStringFlag(daemonCmd, &cfg.MCPAddr, "mcp-addr", "a", cfg.MCPAddr,
|
||||
"MCP listen address (e.g., :9100 or /tmp/mcp.sock)")
|
||||
cli.PersistentStringFlag(daemonCmd, &cfg.HealthAddr, "health-addr", "", cfg.HealthAddr,
|
||||
"Health check endpoint address (empty to disable)")
|
||||
cli.PersistentStringFlag(daemonCmd, &cfg.PIDFile, "pid-file", "", cfg.PIDFile,
|
||||
"PID file path (empty to disable)")
|
||||
|
||||
// --- Subcommands ---
|
||||
|
||||
startCmd := cli.NewCommand("start", "Start the daemon in the background",
|
||||
"Re-executes the core binary as a background daemon process.\n"+
|
||||
"The daemon PID is written to the PID file for later management.",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runStart(cfg)
|
||||
},
|
||||
)
|
||||
|
||||
stopCmd := cli.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 *cli.Command, args []string) error {
|
||||
return runStop(cfg)
|
||||
},
|
||||
)
|
||||
|
||||
statusCmd := cli.NewCommand("status", "Show daemon status",
|
||||
"Checks if the daemon is running and queries its health endpoint.",
|
||||
func(cmd *cli.Command, args []string) error {
|
||||
return runStatus(cfg)
|
||||
},
|
||||
)
|
||||
|
||||
runCmd := cli.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 *cli.Command, args []string) error {
|
||||
return runForeground(cfg)
|
||||
},
|
||||
)
|
||||
|
||||
daemonCmd.AddCommand(startCmd, stopCmd, statusCmd, runCmd)
|
||||
root.AddCommand(daemonCmd)
|
||||
}
|
||||
|
||||
// runStart re-execs the current binary as a detached daemon process.
|
||||
func runStart(cfg Config) error {
|
||||
// Check if already running
|
||||
if pid, running := readPID(cfg.PIDFile); running {
|
||||
return fmt.Errorf("daemon already running (PID %d)", pid)
|
||||
}
|
||||
|
||||
// Find the current binary
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find executable: %w", err)
|
||||
}
|
||||
|
||||
// Build args for the foreground run command
|
||||
args := []string{"daemon", "run",
|
||||
"--mcp-transport", cfg.MCPTransport,
|
||||
"--mcp-addr", cfg.MCPAddr,
|
||||
"--health-addr", cfg.HealthAddr,
|
||||
"--pid-file", cfg.PIDFile,
|
||||
}
|
||||
|
||||
// Launch detached child with CORE_DAEMON=1
|
||||
cmd := exec.Command(exe, args...)
|
||||
cmd.Env = append(os.Environ(), "CORE_DAEMON=1")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Stdin = nil
|
||||
|
||||
// Detach from parent process group
|
||||
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
|
||||
|
||||
// Release the child process so it runs independently
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
// Wait briefly for the health endpoint to come up
|
||||
if cfg.HealthAddr != "" {
|
||||
ready := waitForHealth(cfg.HealthAddr, 5*time.Second)
|
||||
if ready {
|
||||
log.Info("Daemon started", "pid", pid, "health", cfg.HealthAddr)
|
||||
} else {
|
||||
log.Info("Daemon started (health check not yet ready)", "pid", pid)
|
||||
}
|
||||
} else {
|
||||
log.Info("Daemon started", "pid", pid)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runStop sends SIGTERM to the daemon process.
|
||||
func runStop(cfg Config) error {
|
||||
pid, running := readPID(cfg.PIDFile)
|
||||
if !running {
|
||||
log.Info("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)
|
||||
}
|
||||
|
||||
log.Info("Stopping daemon", "pid", pid)
|
||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to send SIGTERM to PID %d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Wait for the process to exit (poll PID file removal)
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, still := readPID(cfg.PIDFile); !still {
|
||||
log.Info("Daemon stopped")
|
||||
return nil
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
|
||||
log.Warn("Daemon did not stop within 30s, sending SIGKILL")
|
||||
_ = proc.Signal(syscall.SIGKILL)
|
||||
|
||||
// Clean up stale PID file
|
||||
_ = os.Remove(cfg.PIDFile)
|
||||
log.Info("Daemon killed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// runStatus checks daemon status via PID and health endpoint.
|
||||
func runStatus(cfg Config) error {
|
||||
pid, running := readPID(cfg.PIDFile)
|
||||
if !running {
|
||||
fmt.Println("Daemon is not running")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Daemon is running (PID %d)\n", pid)
|
||||
|
||||
// Query health endpoint if configured
|
||||
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)
|
||||
}
|
||||
|
||||
// Check readiness
|
||||
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
|
||||
}
|
||||
|
||||
// runForeground runs the daemon in the current process (blocking).
|
||||
// This is what `core daemon run` and the detached child process execute.
|
||||
func runForeground(cfg Config) error {
|
||||
os.Setenv("CORE_DAEMON", "1")
|
||||
|
||||
log.Info("Starting daemon",
|
||||
"transport", cfg.MCPTransport,
|
||||
"addr", cfg.MCPAddr,
|
||||
"health", cfg.HealthAddr,
|
||||
)
|
||||
|
||||
// Create MCP service
|
||||
mcpSvc, err := mcp.New()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create MCP service: %w", err)
|
||||
}
|
||||
|
||||
// Create daemon with health checks
|
||||
daemon := cli.NewDaemon(cli.DaemonOptions{
|
||||
PIDFile: cfg.PIDFile,
|
||||
HealthAddr: cfg.HealthAddr,
|
||||
ShutdownTimeout: 30,
|
||||
})
|
||||
|
||||
// Start daemon (acquires PID, starts health server)
|
||||
if err := daemon.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start daemon: %w", err)
|
||||
}
|
||||
|
||||
// Mark as ready
|
||||
daemon.SetReady(true)
|
||||
|
||||
// Start MCP server in a goroutine
|
||||
ctx := cli.Context()
|
||||
mcpErr := make(chan error, 1)
|
||||
go func() {
|
||||
mcpErr <- startMCP(ctx, mcpSvc, cfg)
|
||||
}()
|
||||
|
||||
log.Info("Daemon ready",
|
||||
"pid", os.Getpid(),
|
||||
"health", daemon.HealthAddr(),
|
||||
"services", "mcp",
|
||||
)
|
||||
|
||||
// Wait for shutdown signal or MCP error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info("Shutting down daemon")
|
||||
case err := <-mcpErr:
|
||||
if err != nil {
|
||||
log.Error("MCP server exited", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the daemon (releases PID, stops health server)
|
||||
return daemon.Stop()
|
||||
}
|
||||
|
||||
// startMCP starts the MCP server with the configured transport.
|
||||
func startMCP(ctx context.Context, svc *mcp.Service, cfg Config) error {
|
||||
switch cfg.MCPTransport {
|
||||
case TransportStdio:
|
||||
log.Info("Starting MCP server", "transport", "stdio")
|
||||
return svc.ServeStdio(ctx)
|
||||
|
||||
case TransportTCP:
|
||||
log.Info("Starting MCP server", "transport", "tcp", "addr", cfg.MCPAddr)
|
||||
return svc.ServeTCP(ctx, cfg.MCPAddr)
|
||||
|
||||
case TransportSocket:
|
||||
log.Info("Starting MCP server", "transport", "unix", "path", cfg.MCPAddr)
|
||||
return svc.ServeUnix(ctx, cfg.MCPAddr)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown MCP transport: %s (valid: stdio, tcp, socket)", cfg.MCPTransport)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// readPID reads the PID file and checks if the process is still running.
|
||||
func readPID(path string) (int, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Check if process is actually running
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return pid, false
|
||||
}
|
||||
|
||||
// Signal 0 tests if the process exists without actually sending a signal
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
return pid, false
|
||||
}
|
||||
|
||||
return pid, true
|
||||
}
|
||||
|
||||
// waitForHealth polls the health endpoint until it responds or timeout.
|
||||
func waitForHealth(addr string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
url := fmt.Sprintf("http://%s/health", addr)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get(url)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-devops/ansible"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
ansibleInventory string
|
||||
ansibleLimit string
|
||||
ansibleTags string
|
||||
ansibleSkipTags string
|
||||
ansibleVars []string
|
||||
ansibleVerbose int
|
||||
ansibleCheck bool
|
||||
)
|
||||
|
||||
var ansibleCmd = &cobra.Command{
|
||||
Use: "ansible <playbook>",
|
||||
Short: "Run Ansible playbooks natively (no Python required)",
|
||||
Long: `Execute Ansible playbooks using a pure Go implementation.
|
||||
|
||||
This command parses Ansible YAML playbooks and executes them natively,
|
||||
without requiring Python or ansible-playbook to be installed.
|
||||
|
||||
Supported modules:
|
||||
- shell, command, raw, script
|
||||
- copy, template, file, lineinfile, stat, slurp, fetch, get_url
|
||||
- apt, apt_key, apt_repository, package, pip
|
||||
- service, systemd
|
||||
- user, group
|
||||
- uri, wait_for, git, unarchive
|
||||
- debug, fail, assert, set_fact, pause
|
||||
|
||||
Examples:
|
||||
core deploy ansible playbooks/coolify/create.yml -i inventory/
|
||||
core deploy ansible site.yml -l production
|
||||
core deploy ansible deploy.yml -e "version=1.2.3" -e "env=prod"`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAnsible,
|
||||
}
|
||||
|
||||
var ansibleTestCmd = &cobra.Command{
|
||||
Use: "test <host>",
|
||||
Short: "Test SSH connectivity to a host",
|
||||
Long: `Test SSH connection and gather facts from a host.
|
||||
|
||||
Examples:
|
||||
core deploy ansible test linux.snider.dev -u claude -p claude
|
||||
core deploy ansible test server.example.com -i ~/.ssh/id_rsa`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAnsibleTest,
|
||||
}
|
||||
|
||||
var (
|
||||
testUser string
|
||||
testPassword string
|
||||
testKeyFile string
|
||||
testPort int
|
||||
)
|
||||
|
||||
func init() {
|
||||
// ansible command flags
|
||||
ansibleCmd.Flags().StringVarP(&ansibleInventory, "inventory", "i", "", "Inventory file or directory")
|
||||
ansibleCmd.Flags().StringVarP(&ansibleLimit, "limit", "l", "", "Limit to specific hosts")
|
||||
ansibleCmd.Flags().StringVarP(&ansibleTags, "tags", "t", "", "Only run plays and tasks tagged with these values")
|
||||
ansibleCmd.Flags().StringVar(&ansibleSkipTags, "skip-tags", "", "Skip plays and tasks tagged with these values")
|
||||
ansibleCmd.Flags().StringArrayVarP(&ansibleVars, "extra-vars", "e", nil, "Set additional variables (key=value)")
|
||||
ansibleCmd.Flags().CountVarP(&ansibleVerbose, "verbose", "v", "Increase verbosity")
|
||||
ansibleCmd.Flags().BoolVar(&ansibleCheck, "check", false, "Don't make any changes (dry run)")
|
||||
|
||||
// test command flags
|
||||
ansibleTestCmd.Flags().StringVarP(&testUser, "user", "u", "root", "SSH user")
|
||||
ansibleTestCmd.Flags().StringVarP(&testPassword, "password", "p", "", "SSH password")
|
||||
ansibleTestCmd.Flags().StringVarP(&testKeyFile, "key", "i", "", "SSH private key file")
|
||||
ansibleTestCmd.Flags().IntVar(&testPort, "port", 22, "SSH port")
|
||||
|
||||
// Add subcommands
|
||||
ansibleCmd.AddCommand(ansibleTestCmd)
|
||||
Cmd.AddCommand(ansibleCmd)
|
||||
}
|
||||
|
||||
func runAnsible(cmd *cobra.Command, args []string) error {
|
||||
playbookPath := args[0]
|
||||
|
||||
// Resolve playbook path
|
||||
if !filepath.IsAbs(playbookPath) {
|
||||
cwd, _ := os.Getwd()
|
||||
playbookPath = filepath.Join(cwd, playbookPath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(playbookPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("playbook not found: %s", playbookPath)
|
||||
}
|
||||
|
||||
// Create executor
|
||||
basePath := filepath.Dir(playbookPath)
|
||||
executor := ansible.NewExecutor(basePath)
|
||||
defer executor.Close()
|
||||
|
||||
// Set options
|
||||
executor.Limit = ansibleLimit
|
||||
executor.CheckMode = ansibleCheck
|
||||
executor.Verbose = ansibleVerbose
|
||||
|
||||
if ansibleTags != "" {
|
||||
executor.Tags = strings.Split(ansibleTags, ",")
|
||||
}
|
||||
if ansibleSkipTags != "" {
|
||||
executor.SkipTags = strings.Split(ansibleSkipTags, ",")
|
||||
}
|
||||
|
||||
// Parse extra vars
|
||||
for _, v := range ansibleVars {
|
||||
parts := strings.SplitN(v, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
executor.SetVar(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Load inventory
|
||||
if ansibleInventory != "" {
|
||||
invPath := ansibleInventory
|
||||
if !filepath.IsAbs(invPath) {
|
||||
cwd, _ := os.Getwd()
|
||||
invPath = filepath.Join(cwd, invPath)
|
||||
}
|
||||
|
||||
// Check if it's a directory
|
||||
info, err := os.Stat(invPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inventory not found: %s", invPath)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// Look for inventory.yml or hosts.yml
|
||||
for _, name := range []string{"inventory.yml", "hosts.yml", "inventory.yaml", "hosts.yaml"} {
|
||||
p := filepath.Join(invPath, name)
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
invPath = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := executor.SetInventory(invPath); err != nil {
|
||||
return fmt.Errorf("load inventory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up callbacks
|
||||
executor.OnPlayStart = func(play *ansible.Play) {
|
||||
fmt.Printf("\n%s %s\n", cli.TitleStyle.Render("PLAY"), cli.BoldStyle.Render("["+play.Name+"]"))
|
||||
fmt.Println(strings.Repeat("*", 70))
|
||||
}
|
||||
|
||||
executor.OnTaskStart = func(host string, task *ansible.Task) {
|
||||
taskName := task.Name
|
||||
if taskName == "" {
|
||||
taskName = task.Module
|
||||
}
|
||||
fmt.Printf("\n%s %s\n", cli.TitleStyle.Render("TASK"), cli.BoldStyle.Render("["+taskName+"]"))
|
||||
if ansibleVerbose > 0 {
|
||||
fmt.Printf("%s\n", cli.DimStyle.Render("host: "+host))
|
||||
}
|
||||
}
|
||||
|
||||
executor.OnTaskEnd = func(host string, task *ansible.Task, result *ansible.TaskResult) {
|
||||
status := "ok"
|
||||
style := cli.SuccessStyle
|
||||
|
||||
if result.Failed {
|
||||
status = "failed"
|
||||
style = cli.ErrorStyle
|
||||
} else if result.Skipped {
|
||||
status = "skipping"
|
||||
style = cli.DimStyle
|
||||
} else if result.Changed {
|
||||
status = "changed"
|
||||
style = cli.WarningStyle
|
||||
}
|
||||
|
||||
fmt.Printf("%s: [%s]", style.Render(status), host)
|
||||
if result.Msg != "" && ansibleVerbose > 0 {
|
||||
fmt.Printf(" => %s", result.Msg)
|
||||
}
|
||||
if result.Duration > 0 && ansibleVerbose > 1 {
|
||||
fmt.Printf(" (%s)", result.Duration.Round(time.Millisecond))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if result.Failed && result.Stderr != "" {
|
||||
fmt.Printf("%s\n", cli.ErrorStyle.Render(result.Stderr))
|
||||
}
|
||||
|
||||
if ansibleVerbose > 1 {
|
||||
if result.Stdout != "" {
|
||||
fmt.Printf("stdout: %s\n", strings.TrimSpace(result.Stdout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executor.OnPlayEnd = func(play *ansible.Play) {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Run playbook
|
||||
ctx := context.Background()
|
||||
start := time.Now()
|
||||
|
||||
fmt.Printf("%s Running playbook: %s\n", cli.BoldStyle.Render("▶"), playbookPath)
|
||||
|
||||
if err := executor.Run(ctx, playbookPath); err != nil {
|
||||
return fmt.Errorf("playbook failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Playbook completed in %s\n",
|
||||
cli.SuccessStyle.Render("✓"),
|
||||
time.Since(start).Round(time.Millisecond))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runAnsibleTest(cmd *cobra.Command, args []string) error {
|
||||
host := args[0]
|
||||
|
||||
fmt.Printf("Testing SSH connection to %s...\n", cli.BoldStyle.Render(host))
|
||||
|
||||
cfg := ansible.SSHConfig{
|
||||
Host: host,
|
||||
Port: testPort,
|
||||
User: testUser,
|
||||
Password: testPassword,
|
||||
KeyFile: testKeyFile,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ansible.NewSSHClient(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Test connection
|
||||
start := time.Now()
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
return fmt.Errorf("connect failed: %w", err)
|
||||
}
|
||||
connectTime := time.Since(start)
|
||||
|
||||
fmt.Printf("%s Connected in %s\n", cli.SuccessStyle.Render("✓"), connectTime.Round(time.Millisecond))
|
||||
|
||||
// Gather facts
|
||||
fmt.Println("\nGathering facts...")
|
||||
|
||||
// Hostname
|
||||
stdout, _, _, _ := client.Run(ctx, "hostname -f 2>/dev/null || hostname")
|
||||
fmt.Printf(" Hostname: %s\n", cli.BoldStyle.Render(strings.TrimSpace(stdout)))
|
||||
|
||||
// OS
|
||||
stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
|
||||
if stdout != "" {
|
||||
fmt.Printf(" OS: %s\n", strings.TrimSpace(stdout))
|
||||
}
|
||||
|
||||
// Kernel
|
||||
stdout, _, _, _ = client.Run(ctx, "uname -r")
|
||||
fmt.Printf(" Kernel: %s\n", strings.TrimSpace(stdout))
|
||||
|
||||
// Architecture
|
||||
stdout, _, _, _ = client.Run(ctx, "uname -m")
|
||||
fmt.Printf(" Architecture: %s\n", strings.TrimSpace(stdout))
|
||||
|
||||
// Memory
|
||||
stdout, _, _, _ = client.Run(ctx, "free -h | grep Mem | awk '{print $2}'")
|
||||
fmt.Printf(" Memory: %s\n", strings.TrimSpace(stdout))
|
||||
|
||||
// Disk
|
||||
stdout, _, _, _ = client.Run(ctx, "df -h / | tail -1 | awk '{print $2 \" total, \" $4 \" available\"}'")
|
||||
fmt.Printf(" Disk: %s\n", strings.TrimSpace(stdout))
|
||||
|
||||
// Docker
|
||||
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null")
|
||||
if err == nil {
|
||||
fmt.Printf(" Docker: %s\n", cli.SuccessStyle.Render(strings.TrimSpace(stdout)))
|
||||
} else {
|
||||
fmt.Printf(" Docker: %s\n", cli.DimStyle.Render("not installed"))
|
||||
}
|
||||
|
||||
// Check if Coolify is running
|
||||
stdout, _, _, _ = client.Run(ctx, "docker ps 2>/dev/null | grep -q coolify && echo 'running' || echo 'not running'")
|
||||
if strings.TrimSpace(stdout) == "running" {
|
||||
fmt.Printf(" Coolify: %s\n", cli.SuccessStyle.Render("running"))
|
||||
} else {
|
||||
fmt.Printf(" Coolify: %s\n", cli.DimStyle.Render("not installed"))
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s SSH test passed\n", cli.SuccessStyle.Render("✓"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package deploy
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddDeployCommands)
|
||||
}
|
||||
|
||||
// AddDeployCommands registers the 'deploy' command and all subcommands.
|
||||
func AddDeployCommands(root *cobra.Command) {
|
||||
root.AddCommand(Cmd)
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-devops/deploy/coolify"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
coolifyURL string
|
||||
coolifyToken string
|
||||
outputJSON bool
|
||||
)
|
||||
|
||||
// Cmd is the root deploy command.
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "deploy",
|
||||
Short: i18n.T("cmd.deploy.short"),
|
||||
Long: i18n.T("cmd.deploy.long"),
|
||||
}
|
||||
|
||||
var serversCmd = &cobra.Command{
|
||||
Use: "servers",
|
||||
Short: "List Coolify servers",
|
||||
RunE: runListServers,
|
||||
}
|
||||
|
||||
var projectsCmd = &cobra.Command{
|
||||
Use: "projects",
|
||||
Short: "List Coolify projects",
|
||||
RunE: runListProjects,
|
||||
}
|
||||
|
||||
var appsCmd = &cobra.Command{
|
||||
Use: "apps",
|
||||
Short: "List Coolify applications",
|
||||
RunE: runListApps,
|
||||
}
|
||||
|
||||
var dbsCmd = &cobra.Command{
|
||||
Use: "databases",
|
||||
Short: "List Coolify databases",
|
||||
Aliases: []string{"dbs", "db"},
|
||||
RunE: runListDatabases,
|
||||
}
|
||||
|
||||
var servicesCmd = &cobra.Command{
|
||||
Use: "services",
|
||||
Short: "List Coolify services",
|
||||
RunE: runListServices,
|
||||
}
|
||||
|
||||
var teamCmd = &cobra.Command{
|
||||
Use: "team",
|
||||
Short: "Show current team info",
|
||||
RunE: runTeam,
|
||||
}
|
||||
|
||||
var callCmd = &cobra.Command{
|
||||
Use: "call <operation> [params-json]",
|
||||
Short: "Call any Coolify API operation",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: runCall,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Global flags
|
||||
Cmd.PersistentFlags().StringVar(&coolifyURL, "url", os.Getenv("COOLIFY_URL"), "Coolify API URL")
|
||||
Cmd.PersistentFlags().StringVar(&coolifyToken, "token", os.Getenv("COOLIFY_TOKEN"), "Coolify API token")
|
||||
Cmd.PersistentFlags().BoolVar(&outputJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Add subcommands
|
||||
Cmd.AddCommand(serversCmd)
|
||||
Cmd.AddCommand(projectsCmd)
|
||||
Cmd.AddCommand(appsCmd)
|
||||
Cmd.AddCommand(dbsCmd)
|
||||
Cmd.AddCommand(servicesCmd)
|
||||
Cmd.AddCommand(teamCmd)
|
||||
Cmd.AddCommand(callCmd)
|
||||
}
|
||||
|
||||
func getClient() (*coolify.Client, error) {
|
||||
cfg := coolify.Config{
|
||||
BaseURL: coolifyURL,
|
||||
APIToken: coolifyToken,
|
||||
Timeout: 30,
|
||||
VerifySSL: true,
|
||||
}
|
||||
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = os.Getenv("COOLIFY_URL")
|
||||
}
|
||||
if cfg.APIToken == "" {
|
||||
cfg.APIToken = os.Getenv("COOLIFY_TOKEN")
|
||||
}
|
||||
|
||||
return coolify.NewClient(cfg)
|
||||
}
|
||||
|
||||
func outputResult(data any) error {
|
||||
if outputJSON {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(data)
|
||||
}
|
||||
|
||||
// Pretty print based on type
|
||||
switch v := data.(type) {
|
||||
case []map[string]any:
|
||||
for _, item := range v {
|
||||
printItem(item)
|
||||
}
|
||||
case map[string]any:
|
||||
printItem(v)
|
||||
default:
|
||||
fmt.Printf("%v\n", data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printItem(item map[string]any) {
|
||||
// Common fields to display
|
||||
if uuid, ok := item["uuid"].(string); ok {
|
||||
fmt.Printf("%s ", cli.DimStyle.Render(uuid[:8]))
|
||||
}
|
||||
if name, ok := item["name"].(string); ok {
|
||||
fmt.Printf("%s", cli.TitleStyle.Render(name))
|
||||
}
|
||||
if desc, ok := item["description"].(string); ok && desc != "" {
|
||||
fmt.Printf(" %s", cli.DimStyle.Render(desc))
|
||||
}
|
||||
if status, ok := item["status"].(string); ok {
|
||||
switch status {
|
||||
case "running":
|
||||
fmt.Printf(" %s", cli.SuccessStyle.Render("●"))
|
||||
case "stopped":
|
||||
fmt.Printf(" %s", cli.ErrorStyle.Render("○"))
|
||||
default:
|
||||
fmt.Printf(" %s", cli.DimStyle.Render(status))
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func runListServers(cmd *cobra.Command, args []string) error {
|
||||
client, err := getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
servers, err := client.ListServers(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
fmt.Println("No servers found")
|
||||
return nil
|
||||
}
|
||||
|
||||
return outputResult(servers)
|
||||
}
|
||||
|
||||
func runListProjects(cmd *cobra.Command, args []string) error {
|
||||
client, err := getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projects, err := client.ListProjects(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(projects) == 0 {
|
||||
fmt.Println("No projects found")
|
||||
return nil
|
||||
}
|
||||
|
||||
return outputResult(projects)
|
||||
}
|
||||
|
||||
func runListApps(cmd *cobra.Command, args []string) error {
|
||||
client, err := getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apps, err := client.ListApplications(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(apps) == 0 {
|
||||
fmt.Println("No applications found")
|
||||
return nil
|
||||
}
|
||||
|
||||
return outputResult(apps)
|
||||
}
|
||||
|
||||
func runListDatabases(cmd *cobra.Command, args []string) error {
|
||||
client, err := getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbs, err := client.ListDatabases(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(dbs) == 0 {
|
||||
fmt.Println("No databases found")
|
||||
return nil
|
||||
}
|
||||
|
||||
return outputResult(dbs)
|
||||
}
|
||||
|
||||
func runListServices(cmd *cobra.Command, args []string) error {
|
||||
client, err := getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
services, err := client.ListServices(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No services found")
|
||||
return nil
|
||||
}
|
||||
|
||||
return outputResult(services)
|
||||
}
|
||||
|
||||
func runTeam(cmd *cobra.Command, args []string) error {
|
||||
client, err := getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
team, err := client.GetTeam(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return outputResult(team)
|
||||
}
|
||||
|
||||
func runCall(cmd *cobra.Command, args []string) error {
|
||||
client, err := getClient()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "initialize", "client")
|
||||
}
|
||||
|
||||
operation := args[0]
|
||||
var params map[string]any
|
||||
if len(args) > 1 {
|
||||
if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil {
|
||||
return fmt.Errorf("invalid JSON params: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := client.Call(context.Background(), operation, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return outputResult(result)
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// Auth command flags.
|
||||
var (
|
||||
authURL string
|
||||
authToken string
|
||||
)
|
||||
|
||||
// addAuthCommand adds the 'auth' subcommand for authentication status and login.
|
||||
func addAuthCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "auth",
|
||||
Short: "Show authentication status",
|
||||
Long: "Show the current Forgejo authentication status, or log in with a new token.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runAuth()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&authURL, "url", "", "Forgejo instance URL")
|
||||
cmd.Flags().StringVar(&authToken, "token", "", "API token (create at <url>/user/settings/applications)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runAuth() error {
|
||||
// If credentials provided, save them first
|
||||
if authURL != "" || authToken != "" {
|
||||
if err := fg.SaveConfig(authURL, authToken); err != nil {
|
||||
return err
|
||||
}
|
||||
if authURL != "" {
|
||||
cli.Success(fmt.Sprintf("URL set to %s", authURL))
|
||||
}
|
||||
if authToken != "" {
|
||||
cli.Success("Token saved")
|
||||
}
|
||||
}
|
||||
|
||||
// Always show current auth status
|
||||
url, token, err := fg.ResolveConfig(authURL, authToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Auth:"), warningStyle.Render("not authenticated"))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Hint:"), dimStyle.Render(fmt.Sprintf("core forge auth --token TOKEN (create at %s/user/settings/applications)", url)))
|
||||
cli.Blank()
|
||||
return nil
|
||||
}
|
||||
|
||||
client, err := fg.NewFromConfig(authURL, authToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, _, err := client.API().GetMyUserInfo()
|
||||
if err != nil {
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Auth:"), errorStyle.Render("token invalid or expired"))
|
||||
cli.Blank()
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Success(fmt.Sprintf("Authenticated to %s", client.URL()))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email))
|
||||
if user.IsAdmin {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Role:"), infoStyle.Render("admin"))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// Config command flags.
|
||||
var (
|
||||
configURL string
|
||||
configToken string
|
||||
configTest bool
|
||||
)
|
||||
|
||||
// addConfigCommand adds the 'config' subcommand for Forgejo connection setup.
|
||||
func addConfigCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "config",
|
||||
Short: "Configure Forgejo connection",
|
||||
Long: "Set the Forgejo instance URL and API token, or test the current connection.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runConfig()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&configURL, "url", "", "Forgejo instance URL")
|
||||
cmd.Flags().StringVar(&configToken, "token", "", "Forgejo API token")
|
||||
cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runConfig() error {
|
||||
// If setting values, save them first
|
||||
if configURL != "" || configToken != "" {
|
||||
if err := fg.SaveConfig(configURL, configToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if configURL != "" {
|
||||
cli.Success(fmt.Sprintf("Forgejo URL set to %s", configURL))
|
||||
}
|
||||
if configToken != "" {
|
||||
cli.Success("Forgejo token saved")
|
||||
}
|
||||
}
|
||||
|
||||
// If testing, verify the connection
|
||||
if configTest {
|
||||
return runConfigTest()
|
||||
}
|
||||
|
||||
// If no flags, show current config
|
||||
if configURL == "" && configToken == "" && !configTest {
|
||||
return showConfig()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func showConfig() error {
|
||||
url, token, err := fg.ResolveConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url))
|
||||
|
||||
if token != "" {
|
||||
masked := token
|
||||
if len(token) >= 8 {
|
||||
masked = token[:4] + "..." + token[len(token)-4:]
|
||||
}
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Token:"), valueStyle.Render(masked))
|
||||
} else {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Token:"), warningStyle.Render("not set"))
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigTest() error {
|
||||
client, err := fg.NewFromConfig(configURL, configToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, _, err := client.API().GetMyUserInfo()
|
||||
if err != nil {
|
||||
cli.Error("Connection failed")
|
||||
return cli.WrapVerb(err, "connect to", "Forgejo")
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Success(fmt.Sprintf("Connected to %s", client.URL()))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email))
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
// Package forge provides CLI commands for managing a Forgejo instance.
|
||||
//
|
||||
// Commands:
|
||||
// - config: Configure Forgejo connection (URL, token)
|
||||
// - status: Show instance status and version
|
||||
// - repos: List repositories
|
||||
// - issues: List and create issues
|
||||
// - prs: List pull requests
|
||||
// - migrate: Migrate repos from external services
|
||||
// - sync: Sync GitHub repos to Forgejo upstream branches
|
||||
// - orgs: List organisations
|
||||
// - labels: List and create labels
|
||||
package forge
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddForgeCommands)
|
||||
}
|
||||
|
||||
// Style aliases from shared package.
|
||||
var (
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
warningStyle = cli.WarningStyle
|
||||
dimStyle = cli.DimStyle
|
||||
valueStyle = cli.ValueStyle
|
||||
repoStyle = cli.RepoStyle
|
||||
numberStyle = cli.NumberStyle
|
||||
infoStyle = cli.InfoStyle
|
||||
)
|
||||
|
||||
// AddForgeCommands registers the 'forge' command and all subcommands.
|
||||
func AddForgeCommands(root *cli.Command) {
|
||||
forgeCmd := &cli.Command{
|
||||
Use: "forge",
|
||||
Short: "Forgejo instance management",
|
||||
Long: "Manage repositories, issues, pull requests, and organisations on your Forgejo instance.",
|
||||
}
|
||||
root.AddCommand(forgeCmd)
|
||||
|
||||
addConfigCommand(forgeCmd)
|
||||
addStatusCommand(forgeCmd)
|
||||
addReposCommand(forgeCmd)
|
||||
addIssuesCommand(forgeCmd)
|
||||
addPRsCommand(forgeCmd)
|
||||
addMigrateCommand(forgeCmd)
|
||||
addSyncCommand(forgeCmd)
|
||||
addOrgsCommand(forgeCmd)
|
||||
addLabelsCommand(forgeCmd)
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// Issues command flags.
|
||||
var (
|
||||
issuesState string
|
||||
issuesTitle string
|
||||
issuesBody string
|
||||
)
|
||||
|
||||
// addIssuesCommand adds the 'issues' subcommand for listing and creating issues.
|
||||
func addIssuesCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "issues [owner/repo]",
|
||||
Short: "List and manage issues",
|
||||
Long: "List issues for a repository, or list all open issues across all your repos.",
|
||||
Args: cli.MaximumNArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return runListAllIssues()
|
||||
}
|
||||
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If title is set, create an issue instead
|
||||
if issuesTitle != "" {
|
||||
return runCreateIssue(owner, repo)
|
||||
}
|
||||
|
||||
return runListIssues(owner, repo)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&issuesState, "state", "open", "Filter by state (open, closed, all)")
|
||||
cmd.Flags().StringVar(&issuesTitle, "title", "", "Create issue with this title")
|
||||
cmd.Flags().StringVar(&issuesBody, "body", "", "Issue body (used with --title)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runListAllIssues() error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Collect all repos: user repos + all org repos, deduplicated
|
||||
seen := make(map[string]bool)
|
||||
var allRepos []*forgejo.Repository
|
||||
|
||||
userRepos, err := client.ListUserRepos()
|
||||
if err == nil {
|
||||
for _, r := range userRepos {
|
||||
if !seen[r.FullName] {
|
||||
seen[r.FullName] = true
|
||||
allRepos = append(allRepos, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
orgs, err := client.ListMyOrgs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
repos, err := client.ListOrgRepos(org.UserName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range repos {
|
||||
if !seen[r.FullName] {
|
||||
seen[r.FullName] = true
|
||||
allRepos = append(allRepos, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := 0
|
||||
cli.Blank()
|
||||
|
||||
for _, repo := range allRepos {
|
||||
if repo.OpenIssues == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
owner, name := repo.Owner.UserName, repo.Name
|
||||
issues, err := client.ListIssues(owner, name, fg.ListIssuesOpts{
|
||||
State: issuesState,
|
||||
})
|
||||
if err != nil || len(issues) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
cli.Print(" %s %s\n", repoStyle.Render(repo.FullName), dimStyle.Render(fmt.Sprintf("(%d)", len(issues))))
|
||||
for _, issue := range issues {
|
||||
printForgeIssue(issue)
|
||||
}
|
||||
cli.Blank()
|
||||
total += len(issues)
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
cli.Text(fmt.Sprintf("No %s issues found.", issuesState))
|
||||
} else {
|
||||
cli.Print(" %s\n", dimStyle.Render(fmt.Sprintf("%d %s issues total", total, issuesState)))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runListIssues(owner, repo string) error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issues, err := client.ListIssues(owner, repo, fg.ListIssuesOpts{
|
||||
State: issuesState,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
cli.Text(fmt.Sprintf("No %s issues in %s/%s.", issuesState, owner, repo))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s\n\n", fmt.Sprintf("%d %s issues in %s/%s", len(issues), issuesState, owner, repo))
|
||||
|
||||
for _, issue := range issues {
|
||||
printForgeIssue(issue)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCreateIssue(owner, repo string) error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issue, err := client.CreateIssue(owner, repo, forgejo.CreateIssueOption{
|
||||
Title: issuesTitle,
|
||||
Body: issuesBody,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Success(fmt.Sprintf("Created issue #%d: %s", issue.Index, issue.Title))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(issue.HTMLURL))
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printForgeIssue(issue *forgejo.Issue) {
|
||||
num := numberStyle.Render(fmt.Sprintf("#%d", issue.Index))
|
||||
title := valueStyle.Render(cli.Truncate(issue.Title, 60))
|
||||
|
||||
line := fmt.Sprintf(" %s %s", num, title)
|
||||
|
||||
// Add labels
|
||||
if len(issue.Labels) > 0 {
|
||||
var labels []string
|
||||
for _, l := range issue.Labels {
|
||||
labels = append(labels, l.Name)
|
||||
}
|
||||
line += " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]")
|
||||
}
|
||||
|
||||
// Add assignees
|
||||
if len(issue.Assignees) > 0 {
|
||||
var assignees []string
|
||||
for _, a := range issue.Assignees {
|
||||
assignees = append(assignees, "@"+a.UserName)
|
||||
}
|
||||
line += " " + infoStyle.Render(strings.Join(assignees, ", "))
|
||||
}
|
||||
|
||||
cli.Text(line)
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// Labels command flags.
|
||||
var (
|
||||
labelsCreate string
|
||||
labelsColor string
|
||||
labelsRepo string
|
||||
)
|
||||
|
||||
// addLabelsCommand adds the 'labels' subcommand for listing and creating labels.
|
||||
func addLabelsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "labels <org>",
|
||||
Short: "List and manage labels",
|
||||
Long: `List labels from an organisation's repos, or create a new label.
|
||||
|
||||
Labels are listed from the first repo in the organisation. Use --repo to target a specific repo.
|
||||
|
||||
Examples:
|
||||
core forge labels Private-Host-UK
|
||||
core forge labels Private-Host-UK --create "feature" --color "00aabb"
|
||||
core forge labels Private-Host-UK --repo Enchantrix`,
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
if labelsCreate != "" {
|
||||
return runCreateLabel(args[0])
|
||||
}
|
||||
return runListLabels(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&labelsCreate, "create", "", "Create a label with this name")
|
||||
cmd.Flags().StringVar(&labelsColor, "color", "0075ca", "Label colour (hex, e.g. 00aabb)")
|
||||
cmd.Flags().StringVar(&labelsRepo, "repo", "", "Target a specific repo (default: first org repo)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runListLabels(org string) error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var labels []*forgejo.Label
|
||||
if labelsRepo != "" {
|
||||
labels, err = client.ListRepoLabels(org, labelsRepo)
|
||||
} else {
|
||||
labels, err = client.ListOrgLabels(org)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(labels) == 0 {
|
||||
cli.Text("No labels found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s\n\n", fmt.Sprintf("%d labels", len(labels)))
|
||||
|
||||
table := cli.NewTable("Name", "Color", "Description")
|
||||
|
||||
for _, l := range labels {
|
||||
table.AddRow(
|
||||
warningStyle.Render(l.Name),
|
||||
dimStyle.Render("#"+l.Color),
|
||||
cli.Truncate(l.Description, 50),
|
||||
)
|
||||
}
|
||||
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCreateLabel(org string) error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Determine target repo
|
||||
repo := labelsRepo
|
||||
if repo == "" {
|
||||
repos, err := client.ListOrgRepos(org)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(repos) == 0 {
|
||||
return cli.Err("no repos in org %s to create label on", org)
|
||||
}
|
||||
repo = repos[0].Name
|
||||
org = repos[0].Owner.UserName
|
||||
}
|
||||
|
||||
label, err := client.CreateRepoLabel(org, repo, forgejo.CreateLabelOption{
|
||||
Name: labelsCreate,
|
||||
Color: "#" + labelsColor,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Success(fmt.Sprintf("Created label %q on %s/%s", label.Name, org, repo))
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// Migrate command flags.
|
||||
var (
|
||||
migrateOrg string
|
||||
migrateService string
|
||||
migrateToken string
|
||||
migrateMirror bool
|
||||
)
|
||||
|
||||
// addMigrateCommand adds the 'migrate' subcommand for importing repos from external services.
|
||||
func addMigrateCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "migrate <clone-url>",
|
||||
Short: "Migrate a repo from an external service",
|
||||
Long: `Migrate a repository from GitHub, GitLab, Gitea, or other services into Forgejo.
|
||||
|
||||
Unlike a simple mirror, migration imports issues, labels, pull requests, releases, and more.
|
||||
|
||||
Examples:
|
||||
core forge migrate https://github.com/owner/repo --org MyOrg --service github
|
||||
core forge migrate https://gitea.example.com/owner/repo --service gitea --token TOKEN`,
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runMigrate(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&migrateOrg, "org", "", "Forgejo organisation to migrate into (default: your user account)")
|
||||
cmd.Flags().StringVar(&migrateService, "service", "github", "Source service type (github, gitlab, gitea, forgejo, gogs, git)")
|
||||
cmd.Flags().StringVar(&migrateToken, "token", "", "Auth token for the source service")
|
||||
cmd.Flags().BoolVar(&migrateMirror, "mirror", false, "Set up as a mirror (periodic sync)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runMigrate(cloneURL string) error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Determine target owner on Forgejo
|
||||
targetOwner := migrateOrg
|
||||
if targetOwner == "" {
|
||||
user, _, err := client.API().GetMyUserInfo()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "current user")
|
||||
}
|
||||
targetOwner = user.UserName
|
||||
}
|
||||
|
||||
// Extract repo name from clone URL
|
||||
repoName := extractRepoName(cloneURL)
|
||||
if repoName == "" {
|
||||
return cli.Err("could not extract repo name from URL: %s", cloneURL)
|
||||
}
|
||||
|
||||
// Map service flag to SDK type
|
||||
service := mapServiceType(migrateService)
|
||||
|
||||
cli.Print(" Migrating %s -> %s/%s on Forgejo...\n", cloneURL, targetOwner, repoName)
|
||||
|
||||
opts := forgejo.MigrateRepoOption{
|
||||
RepoName: repoName,
|
||||
RepoOwner: targetOwner,
|
||||
CloneAddr: cloneURL,
|
||||
Service: service,
|
||||
Mirror: migrateMirror,
|
||||
AuthToken: migrateToken,
|
||||
Issues: true,
|
||||
Labels: true,
|
||||
PullRequests: true,
|
||||
Releases: true,
|
||||
Milestones: true,
|
||||
Wiki: true,
|
||||
Description: "Migrated from " + cloneURL,
|
||||
}
|
||||
|
||||
repo, err := client.MigrateRepo(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Success(fmt.Sprintf("Migration complete: %s", repo.FullName))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(repo.HTMLURL))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Clone:"), valueStyle.Render(repo.CloneURL))
|
||||
if migrateMirror {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Type:"), dimStyle.Render("mirror (periodic sync)"))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapServiceType(s string) forgejo.GitServiceType {
|
||||
switch s {
|
||||
case "github":
|
||||
return forgejo.GitServiceGithub
|
||||
case "gitlab":
|
||||
return forgejo.GitServiceGitlab
|
||||
case "gitea":
|
||||
return forgejo.GitServiceGitea
|
||||
case "forgejo":
|
||||
return forgejo.GitServiceForgejo
|
||||
case "gogs":
|
||||
return forgejo.GitServiceGogs
|
||||
default:
|
||||
return forgejo.GitServicePlain
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// addOrgsCommand adds the 'orgs' subcommand for listing organisations.
|
||||
func addOrgsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "orgs",
|
||||
Short: "List organisations",
|
||||
Long: "List all organisations the authenticated user belongs to.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runOrgs()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runOrgs() error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
orgs, err := client.ListMyOrgs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(orgs) == 0 {
|
||||
cli.Text("No organisations found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s\n\n", fmt.Sprintf("%d organisations", len(orgs)))
|
||||
|
||||
table := cli.NewTable("Name", "Visibility", "Description")
|
||||
|
||||
for _, org := range orgs {
|
||||
visibility := successStyle.Render(org.Visibility)
|
||||
if org.Visibility == "private" {
|
||||
visibility = warningStyle.Render(org.Visibility)
|
||||
}
|
||||
|
||||
desc := cli.Truncate(org.Description, 50)
|
||||
if desc == "" {
|
||||
desc = dimStyle.Render("-")
|
||||
}
|
||||
|
||||
table.AddRow(
|
||||
repoStyle.Render(org.UserName),
|
||||
visibility,
|
||||
desc,
|
||||
)
|
||||
}
|
||||
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// PRs command flags.
|
||||
var (
|
||||
prsState string
|
||||
)
|
||||
|
||||
// addPRsCommand adds the 'prs' subcommand for listing pull requests.
|
||||
func addPRsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "prs <owner/repo>",
|
||||
Short: "List pull requests",
|
||||
Long: "List pull requests for a repository.",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runListPRs(owner, repo)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&prsState, "state", "open", "Filter by state (open, closed, all)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runListPRs(owner, repo string) error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prs, err := client.ListPullRequests(owner, repo, prsState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(prs) == 0 {
|
||||
cli.Text(fmt.Sprintf("No %s pull requests in %s/%s.", prsState, owner, repo))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s\n\n", fmt.Sprintf("%d %s pull requests in %s/%s", len(prs), prsState, owner, repo))
|
||||
|
||||
for _, pr := range prs {
|
||||
printForgePR(pr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printForgePR(pr *forgejo.PullRequest) {
|
||||
num := numberStyle.Render(fmt.Sprintf("#%d", pr.Index))
|
||||
title := valueStyle.Render(cli.Truncate(pr.Title, 50))
|
||||
|
||||
var author string
|
||||
if pr.Poster != nil {
|
||||
author = infoStyle.Render("@" + pr.Poster.UserName)
|
||||
}
|
||||
|
||||
// Branch info
|
||||
branch := dimStyle.Render(pr.Head.Ref + " -> " + pr.Base.Ref)
|
||||
|
||||
// Merge status
|
||||
var status string
|
||||
if pr.HasMerged {
|
||||
status = successStyle.Render("merged")
|
||||
} else if pr.State == forgejo.StateClosed {
|
||||
status = errorStyle.Render("closed")
|
||||
} else {
|
||||
status = warningStyle.Render("open")
|
||||
}
|
||||
|
||||
// Labels
|
||||
var labelStr string
|
||||
if len(pr.Labels) > 0 {
|
||||
var labels []string
|
||||
for _, l := range pr.Labels {
|
||||
labels = append(labels, l.Name)
|
||||
}
|
||||
labelStr = " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]")
|
||||
}
|
||||
|
||||
cli.Print(" %s %s %s %s %s%s\n", num, title, author, status, branch, labelStr)
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// Repos command flags.
|
||||
var (
|
||||
reposOrg string
|
||||
reposMirrors bool
|
||||
)
|
||||
|
||||
// addReposCommand adds the 'repos' subcommand for listing repositories.
|
||||
func addReposCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "repos",
|
||||
Short: "List repositories",
|
||||
Long: "List repositories from your Forgejo instance, optionally filtered by organisation or mirror status.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runRepos()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&reposOrg, "org", "", "Filter by organisation")
|
||||
cmd.Flags().BoolVar(&reposMirrors, "mirrors", false, "Show only mirror repositories")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runRepos() error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var repos []*forgejo.Repository
|
||||
if reposOrg != "" {
|
||||
repos, err = client.ListOrgRepos(reposOrg)
|
||||
} else {
|
||||
repos, err = client.ListUserRepos()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Filter mirrors if requested
|
||||
if reposMirrors {
|
||||
var filtered []*forgejo.Repository
|
||||
for _, r := range repos {
|
||||
if r.Mirror {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
repos = filtered
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
cli.Text("No repositories found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build table
|
||||
table := cli.NewTable("Name", "Type", "Visibility", "Stars")
|
||||
|
||||
for _, r := range repos {
|
||||
repoType := "source"
|
||||
if r.Mirror {
|
||||
repoType = "mirror"
|
||||
}
|
||||
|
||||
visibility := successStyle.Render("public")
|
||||
if r.Private {
|
||||
visibility = warningStyle.Render("private")
|
||||
}
|
||||
|
||||
table.AddRow(
|
||||
repoStyle.Render(r.FullName),
|
||||
dimStyle.Render(repoType),
|
||||
visibility,
|
||||
fmt.Sprintf("%d", r.Stars),
|
||||
)
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s\n\n", fmt.Sprintf("%d repositories", len(repos)))
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// addStatusCommand adds the 'status' subcommand for instance info.
|
||||
func addStatusCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "status",
|
||||
Short: "Show Forgejo instance status",
|
||||
Long: "Display Forgejo instance version, authenticated user, and summary counts.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runStatus()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runStatus() error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get server version
|
||||
ver, _, err := client.API().ServerVersion()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "server version")
|
||||
}
|
||||
|
||||
// Get authenticated user
|
||||
user, _, err := client.API().GetMyUserInfo()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "user info")
|
||||
}
|
||||
|
||||
// Get org count
|
||||
orgs, err := client.ListMyOrgs()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "list", "organisations")
|
||||
}
|
||||
|
||||
// Get repo count
|
||||
repos, err := client.ListUserRepos()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "list", "repositories")
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Instance:"), valueStyle.Render(client.URL()))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Version:"), valueStyle.Render(ver))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Orgs:"), numberStyle.Render(fmt.Sprintf("%d", len(orgs))))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Repos:"), numberStyle.Render(fmt.Sprintf("%d", len(repos))))
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
fg "forge.lthn.ai/core/go-scm/forge"
|
||||
)
|
||||
|
||||
// Sync command flags.
|
||||
var (
|
||||
syncOrg string
|
||||
syncBasePath string
|
||||
syncSetup bool
|
||||
)
|
||||
|
||||
// addSyncCommand adds the 'sync' subcommand for syncing GitHub repos to Forgejo upstream branches.
|
||||
func addSyncCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "sync <owner/repo> [owner/repo...]",
|
||||
Short: "Sync GitHub repos to Forgejo upstream branches",
|
||||
Long: `Push local GitHub content to Forgejo as 'upstream' branches.
|
||||
|
||||
Each repo gets:
|
||||
- An 'upstream' branch tracking the GitHub default branch
|
||||
- A 'main' branch (default) for private tasks, processes, and AI workflows
|
||||
|
||||
Use --setup on first run to create the Forgejo repos and configure remotes.
|
||||
Without --setup, updates existing upstream branches from local clones.`,
|
||||
Args: cli.MinimumNArgs(0),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runSync(args)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&syncOrg, "org", "Host-UK", "Forgejo organisation")
|
||||
cmd.Flags().StringVar(&syncBasePath, "base-path", "~/Code/host-uk", "Base path for local repo clones")
|
||||
cmd.Flags().BoolVar(&syncSetup, "setup", false, "Initial setup: create repos, configure remotes, push upstream branches")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// syncRepoEntry holds info for a repo to sync.
|
||||
type syncRepoEntry struct {
|
||||
name string
|
||||
localPath string
|
||||
defaultBranch string
|
||||
}
|
||||
|
||||
func runSync(args []string) error {
|
||||
client, err := fg.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Expand base path
|
||||
basePath := syncBasePath
|
||||
if strings.HasPrefix(basePath, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve home directory: %w", err)
|
||||
}
|
||||
basePath = filepath.Join(home, basePath[2:])
|
||||
}
|
||||
|
||||
// Build repo list: either from args or from the Forgejo org
|
||||
repos, err := buildSyncRepoList(client, args, basePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
cli.Text("No repos to sync.")
|
||||
return nil
|
||||
}
|
||||
|
||||
forgeURL := client.URL()
|
||||
|
||||
if syncSetup {
|
||||
return runSyncSetup(client, repos, forgeURL)
|
||||
}
|
||||
|
||||
return runSyncUpdate(repos, forgeURL)
|
||||
}
|
||||
|
||||
func buildSyncRepoList(client *fg.Client, args []string, basePath string) ([]syncRepoEntry, error) {
|
||||
var repos []syncRepoEntry
|
||||
|
||||
if len(args) > 0 {
|
||||
for _, arg := range args {
|
||||
name := arg
|
||||
if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 {
|
||||
name = parts[1]
|
||||
}
|
||||
localPath := filepath.Join(basePath, name)
|
||||
branch := syncDetectDefaultBranch(localPath)
|
||||
repos = append(repos, syncRepoEntry{
|
||||
name: name,
|
||||
localPath: localPath,
|
||||
defaultBranch: branch,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
orgRepos, err := client.ListOrgRepos(syncOrg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range orgRepos {
|
||||
localPath := filepath.Join(basePath, r.Name)
|
||||
branch := syncDetectDefaultBranch(localPath)
|
||||
repos = append(repos, syncRepoEntry{
|
||||
name: r.Name,
|
||||
localPath: localPath,
|
||||
defaultBranch: branch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
func runSyncSetup(client *fg.Client, repos []syncRepoEntry, forgeURL string) error {
|
||||
cli.Blank()
|
||||
cli.Print(" Setting up %d repos in %s with upstream branches...\n\n", len(repos), syncOrg)
|
||||
|
||||
var succeeded, failed int
|
||||
|
||||
for _, repo := range repos {
|
||||
cli.Print(" %s %s\n", dimStyle.Render(">>"), repoStyle.Render(repo.name))
|
||||
|
||||
// Step 1: Delete existing repo if it exists
|
||||
cli.Print(" Deleting existing repo... ")
|
||||
err := client.DeleteRepo(syncOrg, repo.name)
|
||||
if err != nil {
|
||||
cli.Print("%s (may not exist)\n", dimStyle.Render("skipped"))
|
||||
} else {
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
}
|
||||
|
||||
// Step 2: Create empty repo
|
||||
cli.Print(" Creating repo... ")
|
||||
_, err = client.CreateOrgRepo(syncOrg, forgejo.CreateRepoOption{
|
||||
Name: repo.name,
|
||||
AutoInit: false,
|
||||
DefaultBranch: "main",
|
||||
})
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
|
||||
// Step 3: Add forge remote to local clone
|
||||
cli.Print(" Configuring remote... ")
|
||||
remoteURL := fmt.Sprintf("%s/%s/%s.git", forgeURL, syncOrg, repo.name)
|
||||
err = syncConfigureForgeRemote(repo.localPath, remoteURL)
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
|
||||
// Step 4: Push default branch as 'upstream' to Forgejo
|
||||
cli.Print(" Pushing %s -> upstream... ", repo.defaultBranch)
|
||||
err = syncPushUpstream(repo.localPath, repo.defaultBranch)
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
|
||||
// Step 5: Create 'main' branch from 'upstream' on Forgejo
|
||||
cli.Print(" Creating main branch... ")
|
||||
err = syncCreateMainFromUpstream(client, syncOrg, repo.name)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "409") {
|
||||
cli.Print("%s\n", dimStyle.Render("exists"))
|
||||
} else {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
}
|
||||
|
||||
// Step 6: Set default branch to 'main'
|
||||
cli.Print(" Setting default branch... ")
|
||||
_, _, err = client.API().EditRepo(syncOrg, repo.name, forgejo.EditRepoOption{
|
||||
DefaultBranch: strPtr("main"),
|
||||
})
|
||||
if err != nil {
|
||||
cli.Print("%s\n", warningStyle.Render(err.Error()))
|
||||
} else {
|
||||
cli.Print("%s\n", successStyle.Render("main"))
|
||||
}
|
||||
|
||||
succeeded++
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d repos set up", succeeded)))
|
||||
if failed > 0 {
|
||||
cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed)))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSyncUpdate(repos []syncRepoEntry, forgeURL string) error {
|
||||
cli.Blank()
|
||||
cli.Print(" Syncing %d repos to %s upstream branches...\n\n", len(repos), syncOrg)
|
||||
|
||||
var succeeded, failed int
|
||||
|
||||
for _, repo := range repos {
|
||||
cli.Print(" %s -> upstream ", repoStyle.Render(repo.name))
|
||||
|
||||
// Ensure remote exists
|
||||
remoteURL := fmt.Sprintf("%s/%s/%s.git", forgeURL, syncOrg, repo.name)
|
||||
_ = syncConfigureForgeRemote(repo.localPath, remoteURL)
|
||||
|
||||
// Fetch latest from GitHub (origin)
|
||||
err := syncGitFetch(repo.localPath, "origin")
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render("fetch failed: "+err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
// Push to Forgejo upstream branch
|
||||
err = syncPushUpstream(repo.localPath, repo.defaultBranch)
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
cli.Print("%s\n", successStyle.Render("ok"))
|
||||
succeeded++
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d synced", succeeded)))
|
||||
if failed > 0 {
|
||||
cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed)))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDetectDefaultBranch(path string) string {
|
||||
out, err := exec.Command("git", "-C", path, "symbolic-ref", "refs/remotes/origin/HEAD").Output()
|
||||
if err == nil {
|
||||
ref := strings.TrimSpace(string(out))
|
||||
if parts := strings.Split(ref, "/"); len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
out, err = exec.Command("git", "-C", path, "branch", "--show-current").Output()
|
||||
if err == nil {
|
||||
branch := strings.TrimSpace(string(out))
|
||||
if branch != "" {
|
||||
return branch
|
||||
}
|
||||
}
|
||||
|
||||
return "main"
|
||||
}
|
||||
|
||||
func syncConfigureForgeRemote(localPath, remoteURL string) error {
|
||||
out, err := exec.Command("git", "-C", localPath, "remote", "get-url", "forge").Output()
|
||||
if err == nil {
|
||||
existing := strings.TrimSpace(string(out))
|
||||
if existing != remoteURL {
|
||||
cmd := exec.Command("git", "-C", localPath, "remote", "set-url", "forge", remoteURL)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to update remote: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "-C", localPath, "remote", "add", "forge", remoteURL)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to add remote: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncPushUpstream(localPath, defaultBranch string) error {
|
||||
refspec := fmt.Sprintf("refs/remotes/origin/%s:refs/heads/upstream", defaultBranch)
|
||||
cmd := exec.Command("git", "-C", localPath, "push", "--force", "forge", refspec)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncGitFetch(localPath, remote string) error {
|
||||
cmd := exec.Command("git", "-C", localPath, "fetch", remote)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncCreateMainFromUpstream(client *fg.Client, org, repo string) error {
|
||||
_, _, err := client.API().CreateBranch(org, repo, forgejo.CreateBranchOption{
|
||||
BranchName: "main",
|
||||
OldBranchName: "upstream",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create branch: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
package forge
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
// splitOwnerRepo splits "owner/repo" into its parts.
|
||||
func splitOwnerRepo(s string) (string, string, error) {
|
||||
parts := strings.SplitN(s, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", cli.Err("expected format: owner/repo (got %q)", s)
|
||||
}
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
// strPtr returns a pointer to the given string.
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// extractRepoName extracts a repository name from a clone URL.
|
||||
// e.g. "https://github.com/owner/repo.git" -> "repo"
|
||||
func extractRepoName(cloneURL string) string {
|
||||
// Get the last path segment
|
||||
name := path.Base(cloneURL)
|
||||
// Strip .git suffix
|
||||
name = strings.TrimSuffix(name, ".git")
|
||||
if name == "" || name == "." || name == "/" {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
gt "forge.lthn.ai/core/go-scm/gitea"
|
||||
)
|
||||
|
||||
// Config command flags.
|
||||
var (
|
||||
configURL string
|
||||
configToken string
|
||||
configTest bool
|
||||
)
|
||||
|
||||
// addConfigCommand adds the 'config' subcommand for Gitea connection setup.
|
||||
func addConfigCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "config",
|
||||
Short: "Configure Gitea connection",
|
||||
Long: "Set the Gitea instance URL and API token, or test the current connection.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runConfig()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&configURL, "url", "", "Gitea instance URL")
|
||||
cmd.Flags().StringVar(&configToken, "token", "", "Gitea API token")
|
||||
cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runConfig() error {
|
||||
// If setting values, save them first
|
||||
if configURL != "" || configToken != "" {
|
||||
if err := gt.SaveConfig(configURL, configToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if configURL != "" {
|
||||
cli.Success(fmt.Sprintf("Gitea URL set to %s", configURL))
|
||||
}
|
||||
if configToken != "" {
|
||||
cli.Success("Gitea token saved")
|
||||
}
|
||||
}
|
||||
|
||||
// If testing, verify the connection
|
||||
if configTest {
|
||||
return runConfigTest()
|
||||
}
|
||||
|
||||
// If no flags, show current config
|
||||
if configURL == "" && configToken == "" && !configTest {
|
||||
return showConfig()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func showConfig() error {
|
||||
url, token, err := gt.ResolveConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url))
|
||||
|
||||
if token != "" {
|
||||
masked := token
|
||||
if len(token) >= 8 {
|
||||
masked = token[:4] + "..." + token[len(token)-4:]
|
||||
}
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Token:"), valueStyle.Render(masked))
|
||||
} else {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Token:"), warningStyle.Render("not set"))
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigTest() error {
|
||||
client, err := gt.NewFromConfig(configURL, configToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, _, err := client.API().GetMyUserInfo()
|
||||
if err != nil {
|
||||
cli.Error("Connection failed")
|
||||
return cli.WrapVerb(err, "connect to", "Gitea")
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Success(fmt.Sprintf("Connected to %s", client.URL()))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email))
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
// Package gitea provides CLI commands for managing a Gitea instance.
|
||||
//
|
||||
// Commands:
|
||||
// - config: Configure Gitea connection (URL, token)
|
||||
// - repos: List repositories
|
||||
// - issues: List and create issues
|
||||
// - prs: List pull requests
|
||||
// - mirror: Create GitHub-to-Gitea mirrors
|
||||
// - sync: Sync GitHub repos to Gitea upstream branches
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddGiteaCommands)
|
||||
}
|
||||
|
||||
// Style aliases from shared package.
|
||||
var (
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
warningStyle = cli.WarningStyle
|
||||
dimStyle = cli.DimStyle
|
||||
valueStyle = cli.ValueStyle
|
||||
repoStyle = cli.RepoStyle
|
||||
numberStyle = cli.NumberStyle
|
||||
infoStyle = cli.InfoStyle
|
||||
)
|
||||
|
||||
// AddGiteaCommands registers the 'gitea' command and all subcommands.
|
||||
func AddGiteaCommands(root *cli.Command) {
|
||||
giteaCmd := &cli.Command{
|
||||
Use: "gitea",
|
||||
Short: "Gitea instance management",
|
||||
Long: "Manage repositories, issues, and pull requests on your Gitea instance.",
|
||||
}
|
||||
root.AddCommand(giteaCmd)
|
||||
|
||||
addConfigCommand(giteaCmd)
|
||||
addReposCommand(giteaCmd)
|
||||
addIssuesCommand(giteaCmd)
|
||||
addPRsCommand(giteaCmd)
|
||||
addMirrorCommand(giteaCmd)
|
||||
addSyncCommand(giteaCmd)
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
gt "forge.lthn.ai/core/go-scm/gitea"
|
||||
)
|
||||
|
||||
// Issues command flags.
|
||||
var (
|
||||
issuesState string
|
||||
issuesTitle string
|
||||
issuesBody string
|
||||
)
|
||||
|
||||
// addIssuesCommand adds the 'issues' subcommand for listing and creating issues.
|
||||
func addIssuesCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "issues <owner/repo>",
|
||||
Short: "List and manage issues",
|
||||
Long: "List issues for a repository, or create a new issue.",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If title is set, create an issue instead
|
||||
if issuesTitle != "" {
|
||||
return runCreateIssue(owner, repo)
|
||||
}
|
||||
|
||||
return runListIssues(owner, repo)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&issuesState, "state", "open", "Filter by state (open, closed, all)")
|
||||
cmd.Flags().StringVar(&issuesTitle, "title", "", "Create issue with this title")
|
||||
cmd.Flags().StringVar(&issuesBody, "body", "", "Issue body (used with --title)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runListIssues(owner, repo string) error {
|
||||
client, err := gt.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issues, err := client.ListIssues(owner, repo, gt.ListIssuesOpts{
|
||||
State: issuesState,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
cli.Text(fmt.Sprintf("No %s issues in %s/%s.", issuesState, owner, repo))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s\n\n", fmt.Sprintf("%d %s issues in %s/%s", len(issues), issuesState, owner, repo))
|
||||
|
||||
for _, issue := range issues {
|
||||
printGiteaIssue(issue, owner, repo)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCreateIssue(owner, repo string) error {
|
||||
client, err := gt.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issue, err := client.CreateIssue(owner, repo, gitea.CreateIssueOption{
|
||||
Title: issuesTitle,
|
||||
Body: issuesBody,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Success(fmt.Sprintf("Created issue #%d: %s", issue.Index, issue.Title))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(issue.HTMLURL))
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printGiteaIssue(issue *gitea.Issue, owner, repo string) {
|
||||
num := numberStyle.Render(fmt.Sprintf("#%d", issue.Index))
|
||||
title := valueStyle.Render(cli.Truncate(issue.Title, 60))
|
||||
|
||||
line := fmt.Sprintf(" %s %s", num, title)
|
||||
|
||||
// Add labels
|
||||
if len(issue.Labels) > 0 {
|
||||
var labels []string
|
||||
for _, l := range issue.Labels {
|
||||
labels = append(labels, l.Name)
|
||||
}
|
||||
line += " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]")
|
||||
}
|
||||
|
||||
// Add assignees
|
||||
if len(issue.Assignees) > 0 {
|
||||
var assignees []string
|
||||
for _, a := range issue.Assignees {
|
||||
assignees = append(assignees, "@"+a.UserName)
|
||||
}
|
||||
line += " " + infoStyle.Render(strings.Join(assignees, ", "))
|
||||
}
|
||||
|
||||
cli.Text(line)
|
||||
}
|
||||
|
||||
// splitOwnerRepo splits "owner/repo" into its parts.
|
||||
func splitOwnerRepo(s string) (string, string, error) {
|
||||
parts := strings.SplitN(s, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", cli.Err("expected format: owner/repo (got %q)", s)
|
||||
}
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
gt "forge.lthn.ai/core/go-scm/gitea"
|
||||
)
|
||||
|
||||
// Mirror command flags.
|
||||
var (
|
||||
mirrorOrg string
|
||||
mirrorGHToken string
|
||||
)
|
||||
|
||||
// addMirrorCommand adds the 'mirror' subcommand for creating GitHub-to-Gitea mirrors.
|
||||
func addMirrorCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "mirror <github-owner/repo>",
|
||||
Short: "Mirror a GitHub repo to Gitea",
|
||||
Long: `Create a pull mirror of a GitHub repository on your Gitea instance.
|
||||
|
||||
The mirror will be created under the specified Gitea organisation (or your user account).
|
||||
Gitea will periodically sync changes from GitHub.
|
||||
|
||||
For private repos, a GitHub token is needed. By default it uses 'gh auth token'.`,
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runMirror(owner, repo)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&mirrorOrg, "org", "", "Gitea organisation to mirror into (default: your user account)")
|
||||
cmd.Flags().StringVar(&mirrorGHToken, "github-token", "", "GitHub token for private repos (default: from gh auth token)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runMirror(githubOwner, githubRepo string) error {
|
||||
client, err := gt.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cloneURL := fmt.Sprintf("https://github.com/%s/%s.git", githubOwner, githubRepo)
|
||||
|
||||
// Determine target owner on Gitea
|
||||
targetOwner := mirrorOrg
|
||||
if targetOwner == "" {
|
||||
user, _, err := client.API().GetMyUserInfo()
|
||||
if err != nil {
|
||||
return cli.WrapVerb(err, "get", "current user")
|
||||
}
|
||||
targetOwner = user.UserName
|
||||
}
|
||||
|
||||
// Resolve GitHub token for source auth
|
||||
ghToken := mirrorGHToken
|
||||
if ghToken == "" {
|
||||
ghToken = resolveGHToken()
|
||||
}
|
||||
|
||||
cli.Print(" Mirroring %s/%s -> %s/%s on Gitea...\n", githubOwner, githubRepo, targetOwner, githubRepo)
|
||||
|
||||
repo, err := client.CreateMirror(targetOwner, githubRepo, cloneURL, ghToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Success(fmt.Sprintf("Mirror created: %s", repo.FullName))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(repo.HTMLURL))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Clone:"), valueStyle.Render(repo.CloneURL))
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveGHToken tries to get a GitHub token from the gh CLI.
|
||||
func resolveGHToken() string {
|
||||
out, err := exec.Command("gh", "auth", "token").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "code.gitea.io/sdk/gitea"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
gt "forge.lthn.ai/core/go-scm/gitea"
|
||||
)
|
||||
|
||||
// PRs command flags.
|
||||
var (
|
||||
prsState string
|
||||
)
|
||||
|
||||
// addPRsCommand adds the 'prs' subcommand for listing pull requests.
|
||||
func addPRsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "prs <owner/repo>",
|
||||
Short: "List pull requests",
|
||||
Long: "List pull requests for a repository.",
|
||||
Args: cli.ExactArgs(1),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
owner, repo, err := splitOwnerRepo(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runListPRs(owner, repo)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&prsState, "state", "open", "Filter by state (open, closed, all)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runListPRs(owner, repo string) error {
|
||||
client, err := gt.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prs, err := client.ListPullRequests(owner, repo, prsState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(prs) == 0 {
|
||||
cli.Text(fmt.Sprintf("No %s pull requests in %s/%s.", prsState, owner, repo))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s\n\n", fmt.Sprintf("%d %s pull requests in %s/%s", len(prs), prsState, owner, repo))
|
||||
|
||||
for _, pr := range prs {
|
||||
printGiteaPR(pr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printGiteaPR(pr *sdk.PullRequest) {
|
||||
num := numberStyle.Render(fmt.Sprintf("#%d", pr.Index))
|
||||
title := valueStyle.Render(cli.Truncate(pr.Title, 50))
|
||||
|
||||
var author string
|
||||
if pr.Poster != nil {
|
||||
author = infoStyle.Render("@" + pr.Poster.UserName)
|
||||
}
|
||||
|
||||
// Branch info
|
||||
branch := dimStyle.Render(pr.Head.Ref + " -> " + pr.Base.Ref)
|
||||
|
||||
// Merge status
|
||||
var status string
|
||||
if pr.HasMerged {
|
||||
status = successStyle.Render("merged")
|
||||
} else if pr.State == sdk.StateClosed {
|
||||
status = errorStyle.Render("closed")
|
||||
} else {
|
||||
status = warningStyle.Render("open")
|
||||
}
|
||||
|
||||
// Labels
|
||||
var labelStr string
|
||||
if len(pr.Labels) > 0 {
|
||||
var labels []string
|
||||
for _, l := range pr.Labels {
|
||||
labels = append(labels, l.Name)
|
||||
}
|
||||
labelStr = " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]")
|
||||
}
|
||||
|
||||
cli.Print(" %s %s %s %s %s%s\n", num, title, author, status, branch, labelStr)
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
gt "forge.lthn.ai/core/go-scm/gitea"
|
||||
)
|
||||
|
||||
// Repos command flags.
|
||||
var (
|
||||
reposOrg string
|
||||
reposMirrors bool
|
||||
)
|
||||
|
||||
// addReposCommand adds the 'repos' subcommand for listing repositories.
|
||||
func addReposCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "repos",
|
||||
Short: "List repositories",
|
||||
Long: "List repositories from your Gitea instance, optionally filtered by organisation or mirror status.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runRepos()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&reposOrg, "org", "", "Filter by organisation")
|
||||
cmd.Flags().BoolVar(&reposMirrors, "mirrors", false, "Show only mirror repositories")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runRepos() error {
|
||||
client, err := gt.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var repos []*giteaRepo
|
||||
if reposOrg != "" {
|
||||
raw, err := client.ListOrgRepos(reposOrg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range raw {
|
||||
repos = append(repos, &giteaRepo{
|
||||
Name: r.Name,
|
||||
FullName: r.FullName,
|
||||
Mirror: r.Mirror,
|
||||
Private: r.Private,
|
||||
Stars: r.Stars,
|
||||
CloneURL: r.CloneURL,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
raw, err := client.ListUserRepos()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range raw {
|
||||
repos = append(repos, &giteaRepo{
|
||||
Name: r.Name,
|
||||
FullName: r.FullName,
|
||||
Mirror: r.Mirror,
|
||||
Private: r.Private,
|
||||
Stars: r.Stars,
|
||||
CloneURL: r.CloneURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Filter mirrors if requested
|
||||
if reposMirrors {
|
||||
var filtered []*giteaRepo
|
||||
for _, r := range repos {
|
||||
if r.Mirror {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
repos = filtered
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
cli.Text("No repositories found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build table
|
||||
table := cli.NewTable("Name", "Type", "Visibility", "Stars")
|
||||
|
||||
for _, r := range repos {
|
||||
repoType := "source"
|
||||
if r.Mirror {
|
||||
repoType = "mirror"
|
||||
}
|
||||
|
||||
visibility := successStyle.Render("public")
|
||||
if r.Private {
|
||||
visibility = warningStyle.Render("private")
|
||||
}
|
||||
|
||||
table.AddRow(
|
||||
repoStyle.Render(r.FullName),
|
||||
dimStyle.Render(repoType),
|
||||
visibility,
|
||||
fmt.Sprintf("%d", r.Stars),
|
||||
)
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s\n\n", fmt.Sprintf("%d repositories", len(repos)))
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// giteaRepo is a simplified repo for display purposes.
|
||||
type giteaRepo struct {
|
||||
Name string
|
||||
FullName string
|
||||
Mirror bool
|
||||
Private bool
|
||||
Stars int
|
||||
CloneURL string
|
||||
}
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
gt "forge.lthn.ai/core/go-scm/gitea"
|
||||
)
|
||||
|
||||
// Sync command flags.
|
||||
var (
|
||||
syncOrg string
|
||||
syncBasePath string
|
||||
syncSetup bool
|
||||
)
|
||||
|
||||
// addSyncCommand adds the 'sync' subcommand for syncing GitHub repos to Gitea upstream branches.
|
||||
func addSyncCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "sync <owner/repo> [owner/repo...]",
|
||||
Short: "Sync GitHub repos to Gitea upstream branches",
|
||||
Long: `Push local GitHub content to Gitea as 'upstream' branches.
|
||||
|
||||
Each repo gets:
|
||||
- An 'upstream' branch tracking the GitHub default branch
|
||||
- A 'main' branch (default) for private tasks, processes, and AI workflows
|
||||
|
||||
Use --setup on first run to create the Gitea repos and configure remotes.
|
||||
Without --setup, updates existing upstream branches from local clones.`,
|
||||
Args: cli.MinimumNArgs(0),
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runSync(args)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&syncOrg, "org", "Host-UK", "Gitea organisation")
|
||||
cmd.Flags().StringVar(&syncBasePath, "base-path", "~/Code/host-uk", "Base path for local repo clones")
|
||||
cmd.Flags().BoolVar(&syncSetup, "setup", false, "Initial setup: create repos, configure remotes, push upstream branches")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// repoEntry holds info for a repo to sync.
|
||||
type repoEntry struct {
|
||||
name string
|
||||
localPath string
|
||||
defaultBranch string // the GitHub default branch (main, dev, etc.)
|
||||
}
|
||||
|
||||
func runSync(args []string) error {
|
||||
client, err := gt.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Expand base path
|
||||
basePath := syncBasePath
|
||||
if strings.HasPrefix(basePath, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve home directory: %w", err)
|
||||
}
|
||||
basePath = filepath.Join(home, basePath[2:])
|
||||
}
|
||||
|
||||
// Build repo list: either from args or from the Gitea org
|
||||
repos, err := buildRepoList(client, args, basePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
cli.Text("No repos to sync.")
|
||||
return nil
|
||||
}
|
||||
|
||||
giteaURL := client.URL()
|
||||
|
||||
if syncSetup {
|
||||
return runSyncSetup(client, repos, giteaURL)
|
||||
}
|
||||
|
||||
return runSyncUpdate(repos, giteaURL)
|
||||
}
|
||||
|
||||
func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEntry, error) {
|
||||
var repos []repoEntry
|
||||
|
||||
if len(args) > 0 {
|
||||
// Specific repos from args
|
||||
for _, arg := range args {
|
||||
name := arg
|
||||
// Strip owner/ prefix if given
|
||||
if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 {
|
||||
name = parts[1]
|
||||
}
|
||||
localPath := filepath.Join(basePath, name)
|
||||
branch := detectDefaultBranch(localPath)
|
||||
repos = append(repos, repoEntry{
|
||||
name: name,
|
||||
localPath: localPath,
|
||||
defaultBranch: branch,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// All repos from the Gitea org
|
||||
orgRepos, err := client.ListOrgRepos(syncOrg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range orgRepos {
|
||||
localPath := filepath.Join(basePath, r.Name)
|
||||
branch := detectDefaultBranch(localPath)
|
||||
repos = append(repos, repoEntry{
|
||||
name: r.Name,
|
||||
localPath: localPath,
|
||||
defaultBranch: branch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
// runSyncSetup handles first-time setup: delete mirrors, create repos, push upstream branches.
|
||||
func runSyncSetup(client *gt.Client, repos []repoEntry, giteaURL string) error {
|
||||
cli.Blank()
|
||||
cli.Print(" Setting up %d repos in %s with upstream branches...\n\n", len(repos), syncOrg)
|
||||
|
||||
var succeeded, failed int
|
||||
|
||||
for _, repo := range repos {
|
||||
cli.Print(" %s %s\n", dimStyle.Render(">>"), repoStyle.Render(repo.name))
|
||||
|
||||
// Step 1: Delete existing repo (mirror) if it exists
|
||||
cli.Print(" Deleting existing mirror... ")
|
||||
err := client.DeleteRepo(syncOrg, repo.name)
|
||||
if err != nil {
|
||||
cli.Print("%s (may not exist)\n", dimStyle.Render("skipped"))
|
||||
} else {
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
}
|
||||
|
||||
// Step 2: Create empty repo
|
||||
cli.Print(" Creating repo... ")
|
||||
_, err = client.CreateOrgRepo(syncOrg, gitea.CreateRepoOption{
|
||||
Name: repo.name,
|
||||
AutoInit: false,
|
||||
DefaultBranch: "main",
|
||||
})
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
|
||||
// Step 3: Add gitea remote to local clone
|
||||
cli.Print(" Configuring remote... ")
|
||||
remoteURL := fmt.Sprintf("%s/%s/%s.git", giteaURL, syncOrg, repo.name)
|
||||
err = configureGiteaRemote(repo.localPath, remoteURL)
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
|
||||
// Step 4: Push default branch as 'upstream' to Gitea
|
||||
cli.Print(" Pushing %s -> upstream... ", repo.defaultBranch)
|
||||
err = pushUpstream(repo.localPath, repo.defaultBranch)
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
|
||||
// Step 5: Create 'main' branch from 'upstream' on Gitea
|
||||
cli.Print(" Creating main branch... ")
|
||||
err = createMainFromUpstream(client, syncOrg, repo.name)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "409") {
|
||||
cli.Print("%s\n", dimStyle.Render("exists"))
|
||||
} else {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
cli.Print("%s\n", successStyle.Render("done"))
|
||||
}
|
||||
|
||||
// Step 6: Set default branch to 'main'
|
||||
cli.Print(" Setting default branch... ")
|
||||
_, _, err = client.API().EditRepo(syncOrg, repo.name, gitea.EditRepoOption{
|
||||
DefaultBranch: strPtr("main"),
|
||||
})
|
||||
if err != nil {
|
||||
cli.Print("%s\n", warningStyle.Render(err.Error()))
|
||||
} else {
|
||||
cli.Print("%s\n", successStyle.Render("main"))
|
||||
}
|
||||
|
||||
succeeded++
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d repos set up", succeeded)))
|
||||
if failed > 0 {
|
||||
cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed)))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSyncUpdate pushes latest from local clones to Gitea upstream branches.
|
||||
func runSyncUpdate(repos []repoEntry, giteaURL string) error {
|
||||
cli.Blank()
|
||||
cli.Print(" Syncing %d repos to %s upstream branches...\n\n", len(repos), syncOrg)
|
||||
|
||||
var succeeded, failed int
|
||||
|
||||
for _, repo := range repos {
|
||||
cli.Print(" %s -> upstream ", repoStyle.Render(repo.name))
|
||||
|
||||
// Ensure remote exists
|
||||
remoteURL := fmt.Sprintf("%s/%s/%s.git", giteaURL, syncOrg, repo.name)
|
||||
_ = configureGiteaRemote(repo.localPath, remoteURL)
|
||||
|
||||
// Fetch latest from GitHub (origin)
|
||||
err := gitFetch(repo.localPath, "origin")
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render("fetch failed: "+err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
// Push to Gitea upstream branch
|
||||
err = pushUpstream(repo.localPath, repo.defaultBranch)
|
||||
if err != nil {
|
||||
cli.Print("%s\n", errorStyle.Render(err.Error()))
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
cli.Print("%s\n", successStyle.Render("ok"))
|
||||
succeeded++
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d synced", succeeded)))
|
||||
if failed > 0 {
|
||||
cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed)))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectDefaultBranch returns the default branch for a local git repo.
|
||||
func detectDefaultBranch(path string) string {
|
||||
// Check what origin/HEAD points to
|
||||
out, err := exec.Command("git", "-C", path, "symbolic-ref", "refs/remotes/origin/HEAD").Output()
|
||||
if err == nil {
|
||||
ref := strings.TrimSpace(string(out))
|
||||
// refs/remotes/origin/main -> main
|
||||
if parts := strings.Split(ref, "/"); len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check current branch
|
||||
out, err = exec.Command("git", "-C", path, "branch", "--show-current").Output()
|
||||
if err == nil {
|
||||
branch := strings.TrimSpace(string(out))
|
||||
if branch != "" {
|
||||
return branch
|
||||
}
|
||||
}
|
||||
|
||||
return "main"
|
||||
}
|
||||
|
||||
// configureGiteaRemote adds or updates the 'gitea' remote on a local repo.
|
||||
func configureGiteaRemote(localPath, remoteURL string) error {
|
||||
// Check if remote exists
|
||||
out, err := exec.Command("git", "-C", localPath, "remote", "get-url", "gitea").Output()
|
||||
if err == nil {
|
||||
// Remote exists — update if URL changed
|
||||
existing := strings.TrimSpace(string(out))
|
||||
if existing != remoteURL {
|
||||
cmd := exec.Command("git", "-C", localPath, "remote", "set-url", "gitea", remoteURL)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to update remote: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add new remote
|
||||
cmd := exec.Command("git", "-C", localPath, "remote", "add", "gitea", remoteURL)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to add remote: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushUpstream pushes the local default branch to Gitea as 'upstream'.
|
||||
func pushUpstream(localPath, defaultBranch string) error {
|
||||
// Push origin's default branch as 'upstream' to gitea
|
||||
refspec := fmt.Sprintf("refs/remotes/origin/%s:refs/heads/upstream", defaultBranch)
|
||||
cmd := exec.Command("git", "-C", localPath, "push", "--force", "gitea", refspec)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitFetch fetches latest from a remote.
|
||||
func gitFetch(localPath, remote string) error {
|
||||
cmd := exec.Command("git", "-C", localPath, "fetch", remote)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createMainFromUpstream creates a 'main' branch from 'upstream' on Gitea via the API.
|
||||
func createMainFromUpstream(client *gt.Client, org, repo string) error {
|
||||
_, _, err := client.API().CreateBranch(org, repo, gitea.CreateBranchOption{
|
||||
BranchName: "main",
|
||||
OldBranchName: "upstream",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create branch: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
// Package mcpcmd provides the MCP server command.
|
||||
//
|
||||
// Commands:
|
||||
// - mcp serve: Start the MCP server for AI tool integration
|
||||
package mcpcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-ai/mcp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddMCPCommands)
|
||||
}
|
||||
|
||||
var workspaceFlag string
|
||||
|
||||
var mcpCmd = &cli.Command{
|
||||
Use: "mcp",
|
||||
Short: "MCP server for AI tool integration",
|
||||
Long: "Model Context Protocol (MCP) server providing file operations, RAG, and metrics tools.",
|
||||
}
|
||||
|
||||
var serveCmd = &cli.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the MCP server",
|
||||
Long: `Start the MCP server on stdio (default) or TCP.
|
||||
|
||||
The server provides file operations, RAG tools, and metrics tools for AI assistants.
|
||||
|
||||
Environment variables:
|
||||
MCP_ADDR TCP address to listen on (e.g., "localhost:9999")
|
||||
If not set, uses stdio transport.
|
||||
|
||||
Examples:
|
||||
# Start with stdio transport (for Claude Code integration)
|
||||
core mcp serve
|
||||
|
||||
# Start with workspace restriction
|
||||
core mcp serve --workspace /path/to/project
|
||||
|
||||
# Start TCP server
|
||||
MCP_ADDR=localhost:9999 core mcp serve`,
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runServe()
|
||||
},
|
||||
}
|
||||
|
||||
func initFlags() {
|
||||
cli.StringFlag(serveCmd, &workspaceFlag, "workspace", "w", "", "Restrict file operations to this directory (empty = unrestricted)")
|
||||
}
|
||||
|
||||
// AddMCPCommands registers the 'mcp' command and all subcommands.
|
||||
func AddMCPCommands(root *cli.Command) {
|
||||
initFlags()
|
||||
mcpCmd.AddCommand(serveCmd)
|
||||
root.AddCommand(mcpCmd)
|
||||
}
|
||||
|
||||
func runServe() error {
|
||||
// Build MCP service options
|
||||
var opts []mcp.Option
|
||||
|
||||
if workspaceFlag != "" {
|
||||
opts = append(opts, mcp.WithWorkspaceRoot(workspaceFlag))
|
||||
} else {
|
||||
// Explicitly unrestricted when no workspace specified
|
||||
opts = append(opts, mcp.WithWorkspaceRoot(""))
|
||||
}
|
||||
|
||||
// Create the MCP service
|
||||
svc, err := mcp.New(opts...)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "create MCP service")
|
||||
}
|
||||
|
||||
// Set up signal handling for clean shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigCh
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Run the server (blocks until context cancelled or error)
|
||||
return svc.Run(ctx)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package prod
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddProdCommands)
|
||||
}
|
||||
|
||||
// AddProdCommands registers the 'prod' command and all subcommands.
|
||||
func AddProdCommands(root *cobra.Command) {
|
||||
root.AddCommand(Cmd)
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
package prod
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-devops/infra"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var dnsCmd = &cobra.Command{
|
||||
Use: "dns",
|
||||
Short: "Manage DNS records via CloudNS",
|
||||
Long: `View and manage DNS records for host.uk.com via CloudNS API.
|
||||
|
||||
Requires:
|
||||
CLOUDNS_AUTH_ID CloudNS auth ID
|
||||
CLOUDNS_AUTH_PASSWORD CloudNS auth password`,
|
||||
}
|
||||
|
||||
var dnsListCmd = &cobra.Command{
|
||||
Use: "list [zone]",
|
||||
Short: "List DNS records",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runDNSList,
|
||||
}
|
||||
|
||||
var dnsSetCmd = &cobra.Command{
|
||||
Use: "set <host> <type> <value>",
|
||||
Short: "Create or update a DNS record",
|
||||
Long: `Create or update a DNS record. Example:
|
||||
core prod dns set hermes.lb A 1.2.3.4
|
||||
core prod dns set "*.host.uk.com" CNAME hermes.lb.host.uk.com`,
|
||||
Args: cobra.ExactArgs(3),
|
||||
RunE: runDNSSet,
|
||||
}
|
||||
|
||||
var (
|
||||
dnsZone string
|
||||
dnsTTL int
|
||||
)
|
||||
|
||||
func init() {
|
||||
dnsCmd.PersistentFlags().StringVar(&dnsZone, "zone", "host.uk.com", "DNS zone")
|
||||
|
||||
dnsSetCmd.Flags().IntVar(&dnsTTL, "ttl", 300, "Record TTL in seconds")
|
||||
|
||||
dnsCmd.AddCommand(dnsListCmd)
|
||||
dnsCmd.AddCommand(dnsSetCmd)
|
||||
}
|
||||
|
||||
func getDNSClient() (*infra.CloudNSClient, error) {
|
||||
authID := os.Getenv("CLOUDNS_AUTH_ID")
|
||||
authPass := os.Getenv("CLOUDNS_AUTH_PASSWORD")
|
||||
if authID == "" || authPass == "" {
|
||||
return nil, fmt.Errorf("CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD required")
|
||||
}
|
||||
return infra.NewCloudNSClient(authID, authPass), nil
|
||||
}
|
||||
|
||||
func runDNSList(cmd *cobra.Command, args []string) error {
|
||||
dns, err := getDNSClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zone := dnsZone
|
||||
if len(args) > 0 {
|
||||
zone = args[0]
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
records, err := dns.ListRecords(ctx, zone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list records: %w", err)
|
||||
}
|
||||
|
||||
cli.Print("%s DNS records for %s\n\n", cli.BoldStyle.Render("▶"), cli.TitleStyle.Render(zone))
|
||||
|
||||
if len(records) == 0 {
|
||||
cli.Print(" No records found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
for id, r := range records {
|
||||
cli.Print(" %s %-6s %-30s %s TTL:%s\n",
|
||||
cli.DimStyle.Render(id),
|
||||
cli.BoldStyle.Render(r.Type),
|
||||
r.Host,
|
||||
r.Record,
|
||||
r.TTL)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDNSSet(cmd *cobra.Command, args []string) error {
|
||||
dns, err := getDNSClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host := args[0]
|
||||
recordType := args[1]
|
||||
value := args[2]
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
changed, err := dns.EnsureRecord(ctx, dnsZone, host, recordType, value, dnsTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set record: %w", err)
|
||||
}
|
||||
|
||||
if changed {
|
||||
cli.Print("%s %s %s %s -> %s\n",
|
||||
cli.SuccessStyle.Render("✓"),
|
||||
recordType, host, dnsZone, value)
|
||||
} else {
|
||||
cli.Print("%s Record already correct\n", cli.DimStyle.Render("·"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
package prod
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-devops/infra"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var lbCmd = &cobra.Command{
|
||||
Use: "lb",
|
||||
Short: "Manage Hetzner load balancer",
|
||||
Long: `View and manage the Hetzner Cloud managed load balancer.
|
||||
|
||||
Requires: HCLOUD_TOKEN`,
|
||||
}
|
||||
|
||||
var lbStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show load balancer status and target health",
|
||||
RunE: runLBStatus,
|
||||
}
|
||||
|
||||
var lbCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create load balancer from infra.yaml",
|
||||
RunE: runLBCreate,
|
||||
}
|
||||
|
||||
func init() {
|
||||
lbCmd.AddCommand(lbStatusCmd)
|
||||
lbCmd.AddCommand(lbCreateCmd)
|
||||
}
|
||||
|
||||
func getHCloudClient() (*infra.HCloudClient, error) {
|
||||
token := os.Getenv("HCLOUD_TOKEN")
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("HCLOUD_TOKEN environment variable required")
|
||||
}
|
||||
return infra.NewHCloudClient(token), nil
|
||||
}
|
||||
|
||||
func runLBStatus(cmd *cobra.Command, args []string) error {
|
||||
hc, err := getHCloudClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
lbs, err := hc.ListLoadBalancers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list load balancers: %w", err)
|
||||
}
|
||||
|
||||
if len(lbs) == 0 {
|
||||
cli.Print("No load balancers found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, lb := range lbs {
|
||||
cli.Print("%s %s\n", cli.BoldStyle.Render("▶"), cli.TitleStyle.Render(lb.Name))
|
||||
cli.Print(" ID: %d\n", lb.ID)
|
||||
cli.Print(" IP: %s\n", lb.PublicNet.IPv4.IP)
|
||||
cli.Print(" Algorithm: %s\n", lb.Algorithm.Type)
|
||||
cli.Print(" Location: %s\n", lb.Location.Name)
|
||||
|
||||
if len(lb.Services) > 0 {
|
||||
cli.Print("\n Services:\n")
|
||||
for _, s := range lb.Services {
|
||||
cli.Print(" %s :%d -> :%d proxy_protocol=%v\n",
|
||||
s.Protocol, s.ListenPort, s.DestinationPort, s.Proxyprotocol)
|
||||
}
|
||||
}
|
||||
|
||||
if len(lb.Targets) > 0 {
|
||||
cli.Print("\n Targets:\n")
|
||||
for _, t := range lb.Targets {
|
||||
ip := ""
|
||||
if t.IP != nil {
|
||||
ip = t.IP.IP
|
||||
}
|
||||
for _, hs := range t.HealthStatus {
|
||||
icon := cli.SuccessStyle.Render("●")
|
||||
if hs.Status != "healthy" {
|
||||
icon = cli.ErrorStyle.Render("○")
|
||||
}
|
||||
cli.Print(" %s %s :%d %s\n", icon, ip, hs.ListenPort, hs.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runLBCreate(cmd *cobra.Command, args []string) error {
|
||||
cfg, _, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
return stepLoadBalancer(ctx, cfg)
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
package prod
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
infraFile string
|
||||
)
|
||||
|
||||
// Cmd is the root prod command.
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "prod",
|
||||
Short: "Production infrastructure management",
|
||||
Long: `Manage the Host UK production infrastructure.
|
||||
|
||||
Commands:
|
||||
status Show infrastructure health and connectivity
|
||||
setup Phase 1: discover topology, create LB, configure DNS
|
||||
dns Manage DNS records via CloudNS
|
||||
lb Manage Hetzner load balancer
|
||||
ssh SSH into a production host
|
||||
|
||||
Configuration is read from infra.yaml in the project root.`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
Cmd.PersistentFlags().StringVar(&infraFile, "config", "", "Path to infra.yaml (auto-discovered if not set)")
|
||||
|
||||
Cmd.AddCommand(statusCmd)
|
||||
Cmd.AddCommand(setupCmd)
|
||||
Cmd.AddCommand(dnsCmd)
|
||||
Cmd.AddCommand(lbCmd)
|
||||
Cmd.AddCommand(sshCmd)
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
package prod
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-devops/infra"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var setupCmd = &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Phase 1: discover topology, create LB, configure DNS",
|
||||
Long: `Run the Phase 1 foundation setup:
|
||||
|
||||
1. Discover Hetzner topology (Cloud + Robot servers)
|
||||
2. Create Hetzner managed load balancer
|
||||
3. Configure DNS records via CloudNS
|
||||
4. Verify connectivity to all hosts
|
||||
|
||||
Required environment variables:
|
||||
HCLOUD_TOKEN Hetzner Cloud API token
|
||||
HETZNER_ROBOT_USER Hetzner Robot username
|
||||
HETZNER_ROBOT_PASS Hetzner Robot password
|
||||
CLOUDNS_AUTH_ID CloudNS auth ID
|
||||
CLOUDNS_AUTH_PASSWORD CloudNS auth password`,
|
||||
RunE: runSetup,
|
||||
}
|
||||
|
||||
var (
|
||||
setupDryRun bool
|
||||
setupStep string
|
||||
)
|
||||
|
||||
func init() {
|
||||
setupCmd.Flags().BoolVar(&setupDryRun, "dry-run", false, "Show what would be done without making changes")
|
||||
setupCmd.Flags().StringVar(&setupStep, "step", "", "Run a specific step only (discover, lb, dns)")
|
||||
}
|
||||
|
||||
func runSetup(cmd *cobra.Command, args []string) error {
|
||||
cfg, cfgPath, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Print("%s Production setup from %s\n\n",
|
||||
cli.BoldStyle.Render("▶"),
|
||||
cli.DimStyle.Render(cfgPath))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
steps := []struct {
|
||||
name string
|
||||
fn func(context.Context, *infra.Config) error
|
||||
}{
|
||||
{"discover", stepDiscover},
|
||||
{"lb", stepLoadBalancer},
|
||||
{"dns", stepDNS},
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
if setupStep != "" && setupStep != step.name {
|
||||
continue
|
||||
}
|
||||
|
||||
cli.Print("\n%s Step: %s\n", cli.BoldStyle.Render("━━"), cli.TitleStyle.Render(step.name))
|
||||
|
||||
if err := step.fn(ctx, cfg); err != nil {
|
||||
cli.Print(" %s %s: %s\n", cli.ErrorStyle.Render("✗"), step.name, err)
|
||||
return fmt.Errorf("step %s failed: %w", step.name, err)
|
||||
}
|
||||
|
||||
cli.Print(" %s %s complete\n", cli.SuccessStyle.Render("✓"), step.name)
|
||||
}
|
||||
|
||||
cli.Print("\n%s Setup complete\n", cli.SuccessStyle.Render("✓"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepDiscover(ctx context.Context, cfg *infra.Config) error {
|
||||
// Discover HCloud servers
|
||||
hcloudToken := os.Getenv("HCLOUD_TOKEN")
|
||||
if hcloudToken != "" {
|
||||
cli.Print(" Discovering Hetzner Cloud servers...\n")
|
||||
|
||||
hc := infra.NewHCloudClient(hcloudToken)
|
||||
servers, err := hc.ListServers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list HCloud servers: %w", err)
|
||||
}
|
||||
|
||||
for _, s := range servers {
|
||||
cli.Print(" %s %s %s %s %s\n",
|
||||
cli.SuccessStyle.Render("●"),
|
||||
cli.BoldStyle.Render(s.Name),
|
||||
s.PublicNet.IPv4.IP,
|
||||
s.ServerType.Name,
|
||||
cli.DimStyle.Render(s.Datacenter.Name))
|
||||
}
|
||||
} else {
|
||||
cli.Print(" %s HCLOUD_TOKEN not set — skipping Cloud discovery\n",
|
||||
cli.WarningStyle.Render("⚠"))
|
||||
}
|
||||
|
||||
// Discover Robot servers
|
||||
robotUser := os.Getenv("HETZNER_ROBOT_USER")
|
||||
robotPass := os.Getenv("HETZNER_ROBOT_PASS")
|
||||
if robotUser != "" && robotPass != "" {
|
||||
cli.Print(" Discovering Hetzner Robot servers...\n")
|
||||
|
||||
hr := infra.NewHRobotClient(robotUser, robotPass)
|
||||
servers, err := hr.ListServers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list Robot servers: %w", err)
|
||||
}
|
||||
|
||||
for _, s := range servers {
|
||||
status := cli.SuccessStyle.Render("●")
|
||||
if s.Status != "ready" {
|
||||
status = cli.WarningStyle.Render("○")
|
||||
}
|
||||
cli.Print(" %s %s %s %s %s\n",
|
||||
status,
|
||||
cli.BoldStyle.Render(s.ServerName),
|
||||
s.ServerIP,
|
||||
s.Product,
|
||||
cli.DimStyle.Render(s.Datacenter))
|
||||
}
|
||||
} else {
|
||||
cli.Print(" %s HETZNER_ROBOT_USER/PASS not set — skipping Robot discovery\n",
|
||||
cli.WarningStyle.Render("⚠"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error {
|
||||
hcloudToken := os.Getenv("HCLOUD_TOKEN")
|
||||
if hcloudToken == "" {
|
||||
return fmt.Errorf("HCLOUD_TOKEN required for load balancer management")
|
||||
}
|
||||
|
||||
hc := infra.NewHCloudClient(hcloudToken)
|
||||
|
||||
// Check if LB already exists
|
||||
lbs, err := hc.ListLoadBalancers(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list load balancers: %w", err)
|
||||
}
|
||||
|
||||
for _, lb := range lbs {
|
||||
if lb.Name == cfg.LoadBalancer.Name {
|
||||
cli.Print(" Load balancer '%s' already exists (ID: %d, IP: %s)\n",
|
||||
lb.Name, lb.ID, lb.PublicNet.IPv4.IP)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if setupDryRun {
|
||||
cli.Print(" [dry-run] Would create load balancer '%s' (%s) in %s\n",
|
||||
cfg.LoadBalancer.Name, cfg.LoadBalancer.Type, cfg.LoadBalancer.Location)
|
||||
for _, b := range cfg.LoadBalancer.Backends {
|
||||
if host, ok := cfg.Hosts[b.Host]; ok {
|
||||
cli.Print(" [dry-run] Backend: %s (%s:%d)\n", b.Host, host.IP, b.Port)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build targets from config
|
||||
targets := make([]infra.HCloudLBCreateTarget, 0, len(cfg.LoadBalancer.Backends))
|
||||
for _, b := range cfg.LoadBalancer.Backends {
|
||||
host, ok := cfg.Hosts[b.Host]
|
||||
if !ok {
|
||||
return fmt.Errorf("backend host '%s' not found in config", b.Host)
|
||||
}
|
||||
targets = append(targets, infra.HCloudLBCreateTarget{
|
||||
Type: "ip",
|
||||
IP: &infra.HCloudLBTargetIP{IP: host.IP},
|
||||
})
|
||||
}
|
||||
|
||||
// Build services
|
||||
services := make([]infra.HCloudLBService, 0, len(cfg.LoadBalancer.Listeners))
|
||||
for _, l := range cfg.LoadBalancer.Listeners {
|
||||
svc := infra.HCloudLBService{
|
||||
Protocol: l.Protocol,
|
||||
ListenPort: l.Frontend,
|
||||
DestinationPort: l.Backend,
|
||||
Proxyprotocol: l.ProxyProtocol,
|
||||
HealthCheck: &infra.HCloudLBHealthCheck{
|
||||
Protocol: cfg.LoadBalancer.Health.Protocol,
|
||||
Port: l.Backend,
|
||||
Interval: cfg.LoadBalancer.Health.Interval,
|
||||
Timeout: 10,
|
||||
Retries: 3,
|
||||
HTTP: &infra.HCloudLBHCHTTP{
|
||||
Path: cfg.LoadBalancer.Health.Path,
|
||||
StatusCode: "2??",
|
||||
},
|
||||
},
|
||||
}
|
||||
services = append(services, svc)
|
||||
}
|
||||
|
||||
req := infra.HCloudLBCreateRequest{
|
||||
Name: cfg.LoadBalancer.Name,
|
||||
LoadBalancerType: cfg.LoadBalancer.Type,
|
||||
Location: cfg.LoadBalancer.Location,
|
||||
Algorithm: infra.HCloudLBAlgorithm{Type: cfg.LoadBalancer.Algorithm},
|
||||
Services: services,
|
||||
Targets: targets,
|
||||
Labels: map[string]string{
|
||||
"project": "host-uk",
|
||||
"managed": "core-cli",
|
||||
},
|
||||
}
|
||||
|
||||
cli.Print(" Creating load balancer '%s'...\n", cfg.LoadBalancer.Name)
|
||||
|
||||
lb, err := hc.CreateLoadBalancer(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create load balancer: %w", err)
|
||||
}
|
||||
|
||||
cli.Print(" Created: %s (ID: %d, IP: %s)\n",
|
||||
cli.BoldStyle.Render(lb.Name), lb.ID, lb.PublicNet.IPv4.IP)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stepDNS(ctx context.Context, cfg *infra.Config) error {
|
||||
authID := os.Getenv("CLOUDNS_AUTH_ID")
|
||||
authPass := os.Getenv("CLOUDNS_AUTH_PASSWORD")
|
||||
if authID == "" || authPass == "" {
|
||||
return fmt.Errorf("CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD required")
|
||||
}
|
||||
|
||||
dns := infra.NewCloudNSClient(authID, authPass)
|
||||
|
||||
for zoneName, zone := range cfg.DNS.Zones {
|
||||
cli.Print(" Zone: %s\n", cli.BoldStyle.Render(zoneName))
|
||||
|
||||
for _, rec := range zone.Records {
|
||||
value := rec.Value
|
||||
// Skip templated values (need LB IP first)
|
||||
if value == "{{.lb_ip}}" {
|
||||
cli.Print(" %s %s %s %s — %s\n",
|
||||
cli.WarningStyle.Render("⚠"),
|
||||
rec.Name, rec.Type, value,
|
||||
cli.DimStyle.Render("needs LB IP (run setup --step=lb first)"))
|
||||
continue
|
||||
}
|
||||
|
||||
if setupDryRun {
|
||||
cli.Print(" [dry-run] %s %s -> %s (TTL: %d)\n",
|
||||
rec.Type, rec.Name, value, rec.TTL)
|
||||
continue
|
||||
}
|
||||
|
||||
changed, err := dns.EnsureRecord(ctx, zoneName, rec.Name, rec.Type, value, rec.TTL)
|
||||
if err != nil {
|
||||
cli.Print(" %s %s %s: %s\n", cli.ErrorStyle.Render("✗"), rec.Type, rec.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if changed {
|
||||
cli.Print(" %s %s %s -> %s\n",
|
||||
cli.SuccessStyle.Render("✓"),
|
||||
rec.Type, rec.Name, value)
|
||||
} else {
|
||||
cli.Print(" %s %s %s (no change)\n",
|
||||
cli.DimStyle.Render("·"),
|
||||
rec.Type, rec.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
package prod
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var sshCmd = &cobra.Command{
|
||||
Use: "ssh <host>",
|
||||
Short: "SSH into a production host",
|
||||
Long: `Open an SSH session to a production host defined in infra.yaml.
|
||||
|
||||
Examples:
|
||||
core prod ssh noc
|
||||
core prod ssh de
|
||||
core prod ssh de2
|
||||
core prod ssh build`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runSSH,
|
||||
}
|
||||
|
||||
func runSSH(cmd *cobra.Command, args []string) error {
|
||||
cfg, _, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
host, ok := cfg.Hosts[name]
|
||||
if !ok {
|
||||
// List available hosts
|
||||
cli.Print("Unknown host '%s'. Available:\n", name)
|
||||
for n, h := range cfg.Hosts {
|
||||
cli.Print(" %s %s (%s)\n", cli.BoldStyle.Render(n), h.IP, h.Role)
|
||||
}
|
||||
return fmt.Errorf("host '%s' not found in infra.yaml", name)
|
||||
}
|
||||
|
||||
sshArgs := []string{
|
||||
"ssh",
|
||||
"-i", host.SSH.Key,
|
||||
"-p", fmt.Sprintf("%d", host.SSH.Port),
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
fmt.Sprintf("%s@%s", host.SSH.User, host.IP),
|
||||
}
|
||||
|
||||
cli.Print("%s %s@%s (%s)\n",
|
||||
cli.BoldStyle.Render("▶"),
|
||||
host.SSH.User, host.FQDN,
|
||||
cli.DimStyle.Render(host.IP))
|
||||
|
||||
sshPath, err := exec.LookPath("ssh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh not found: %w", err)
|
||||
}
|
||||
|
||||
// Replace current process with SSH
|
||||
return syscall.Exec(sshPath, sshArgs, os.Environ())
|
||||
}
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
package prod
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-devops/ansible"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go-devops/infra"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show production infrastructure health",
|
||||
Long: `Check connectivity, services, and cluster health across all production hosts.
|
||||
|
||||
Tests:
|
||||
- SSH connectivity to all hosts
|
||||
- Docker daemon status
|
||||
- Coolify controller (noc)
|
||||
- Galera cluster state (de, de2)
|
||||
- Redis Sentinel status (de, de2)
|
||||
- Load balancer health (if HCLOUD_TOKEN set)`,
|
||||
RunE: runStatus,
|
||||
}
|
||||
|
||||
type hostStatus struct {
|
||||
Name string
|
||||
Host *infra.Host
|
||||
Connected bool
|
||||
ConnTime time.Duration
|
||||
OS string
|
||||
Docker string
|
||||
Services map[string]string
|
||||
Error error
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, args []string) error {
|
||||
cfg, cfgPath, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Print("%s Infrastructure status from %s\n\n",
|
||||
cli.BoldStyle.Render("▶"),
|
||||
cli.DimStyle.Render(cfgPath))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check all hosts in parallel
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
statuses []hostStatus
|
||||
)
|
||||
|
||||
for name, host := range cfg.Hosts {
|
||||
wg.Add(1)
|
||||
go func(name string, host *infra.Host) {
|
||||
defer wg.Done()
|
||||
s := checkHost(ctx, name, host)
|
||||
mu.Lock()
|
||||
statuses = append(statuses, s)
|
||||
mu.Unlock()
|
||||
}(name, host)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Print results in consistent order
|
||||
order := []string{"noc", "de", "de2", "build"}
|
||||
for _, name := range order {
|
||||
for _, s := range statuses {
|
||||
if s.Name == name {
|
||||
printHostStatus(s)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check LB if token available
|
||||
if token := os.Getenv("HCLOUD_TOKEN"); token != "" {
|
||||
fmt.Println()
|
||||
checkLoadBalancer(ctx, token)
|
||||
} else {
|
||||
fmt.Println()
|
||||
cli.Print("%s Load balancer: %s\n",
|
||||
cli.DimStyle.Render(" ○"),
|
||||
cli.DimStyle.Render("HCLOUD_TOKEN not set (skipped)"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkHost(ctx context.Context, name string, host *infra.Host) hostStatus {
|
||||
s := hostStatus{
|
||||
Name: name,
|
||||
Host: host,
|
||||
Services: make(map[string]string),
|
||||
}
|
||||
|
||||
sshCfg := ansible.SSHConfig{
|
||||
Host: host.IP,
|
||||
Port: host.SSH.Port,
|
||||
User: host.SSH.User,
|
||||
KeyFile: host.SSH.Key,
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ansible.NewSSHClient(sshCfg)
|
||||
if err != nil {
|
||||
s.Error = fmt.Errorf("create SSH client: %w", err)
|
||||
return s
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
start := time.Now()
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
s.Error = fmt.Errorf("SSH connect: %w", err)
|
||||
return s
|
||||
}
|
||||
s.Connected = true
|
||||
s.ConnTime = time.Since(start)
|
||||
|
||||
// OS info
|
||||
stdout, _, _, _ := client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
|
||||
s.OS = strings.TrimSpace(stdout)
|
||||
|
||||
// Docker
|
||||
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null | head -1")
|
||||
if err == nil && stdout != "" {
|
||||
s.Docker = strings.TrimSpace(stdout)
|
||||
}
|
||||
|
||||
// Check each expected service
|
||||
for _, svc := range host.Services {
|
||||
status := checkService(ctx, client, svc)
|
||||
s.Services[svc] = status
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func checkService(ctx context.Context, client *ansible.SSHClient, service string) string {
|
||||
switch service {
|
||||
case "coolify":
|
||||
stdout, _, _, _ := client.Run(ctx, "docker ps --format '{{.Names}}' 2>/dev/null | grep -c coolify")
|
||||
if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" {
|
||||
return "running"
|
||||
}
|
||||
return "not running"
|
||||
|
||||
case "traefik":
|
||||
stdout, _, _, _ := client.Run(ctx, "docker ps --format '{{.Names}}' 2>/dev/null | grep -c traefik")
|
||||
if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" {
|
||||
return "running"
|
||||
}
|
||||
return "not running"
|
||||
|
||||
case "galera":
|
||||
// Check Galera cluster state
|
||||
stdout, _, _, _ := client.Run(ctx,
|
||||
"docker exec $(docker ps -q --filter name=mariadb 2>/dev/null || echo none) "+
|
||||
"mariadb -u root -e \"SHOW STATUS LIKE 'wsrep_cluster_size'\" --skip-column-names 2>/dev/null | awk '{print $2}'")
|
||||
size := strings.TrimSpace(stdout)
|
||||
if size != "" && size != "0" {
|
||||
return fmt.Sprintf("cluster_size=%s", size)
|
||||
}
|
||||
// Try non-Docker
|
||||
stdout, _, _, _ = client.Run(ctx,
|
||||
"mariadb -u root -e \"SHOW STATUS LIKE 'wsrep_cluster_size'\" --skip-column-names 2>/dev/null | awk '{print $2}'")
|
||||
size = strings.TrimSpace(stdout)
|
||||
if size != "" && size != "0" {
|
||||
return fmt.Sprintf("cluster_size=%s", size)
|
||||
}
|
||||
return "not running"
|
||||
|
||||
case "redis":
|
||||
stdout, _, _, _ := client.Run(ctx,
|
||||
"docker exec $(docker ps -q --filter name=redis 2>/dev/null || echo none) "+
|
||||
"redis-cli ping 2>/dev/null")
|
||||
if strings.TrimSpace(stdout) == "PONG" {
|
||||
return "running"
|
||||
}
|
||||
stdout, _, _, _ = client.Run(ctx, "redis-cli ping 2>/dev/null")
|
||||
if strings.TrimSpace(stdout) == "PONG" {
|
||||
return "running"
|
||||
}
|
||||
return "not running"
|
||||
|
||||
case "forgejo-runner":
|
||||
stdout, _, _, _ := client.Run(ctx, "systemctl is-active forgejo-runner 2>/dev/null || docker ps --format '{{.Names}}' 2>/dev/null | grep -c runner")
|
||||
val := strings.TrimSpace(stdout)
|
||||
if val == "active" || (val != "0" && val != "") {
|
||||
return "running"
|
||||
}
|
||||
return "not running"
|
||||
|
||||
default:
|
||||
// Generic docker container check
|
||||
stdout, _, _, _ := client.Run(ctx,
|
||||
fmt.Sprintf("docker ps --format '{{.Names}}' 2>/dev/null | grep -c %s", service))
|
||||
if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" {
|
||||
return "running"
|
||||
}
|
||||
return "not running"
|
||||
}
|
||||
}
|
||||
|
||||
func printHostStatus(s hostStatus) {
|
||||
// Host header
|
||||
roleStyle := cli.DimStyle
|
||||
switch s.Host.Role {
|
||||
case "app":
|
||||
roleStyle = cli.SuccessStyle
|
||||
case "bastion":
|
||||
roleStyle = cli.WarningStyle
|
||||
case "builder":
|
||||
roleStyle = cli.InfoStyle
|
||||
}
|
||||
|
||||
cli.Print(" %s %s %s %s\n",
|
||||
cli.BoldStyle.Render(s.Name),
|
||||
cli.DimStyle.Render(s.Host.IP),
|
||||
roleStyle.Render(s.Host.Role),
|
||||
cli.DimStyle.Render(s.Host.FQDN))
|
||||
|
||||
if s.Error != nil {
|
||||
cli.Print(" %s %s\n", cli.ErrorStyle.Render("✗"), s.Error)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.Connected {
|
||||
cli.Print(" %s SSH unreachable\n", cli.ErrorStyle.Render("✗"))
|
||||
return
|
||||
}
|
||||
|
||||
// Connection info
|
||||
cli.Print(" %s SSH %s",
|
||||
cli.SuccessStyle.Render("✓"),
|
||||
cli.DimStyle.Render(s.ConnTime.Round(time.Millisecond).String()))
|
||||
if s.OS != "" {
|
||||
cli.Print(" %s", cli.DimStyle.Render(s.OS))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if s.Docker != "" {
|
||||
cli.Print(" %s %s\n", cli.SuccessStyle.Render("✓"), cli.DimStyle.Render(s.Docker))
|
||||
}
|
||||
|
||||
// Services
|
||||
for _, svc := range s.Host.Services {
|
||||
status, ok := s.Services[svc]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
icon := cli.SuccessStyle.Render("●")
|
||||
style := cli.SuccessStyle
|
||||
if status == "not running" {
|
||||
icon = cli.ErrorStyle.Render("○")
|
||||
style = cli.ErrorStyle
|
||||
}
|
||||
|
||||
cli.Print(" %s %s %s\n", icon, svc, style.Render(status))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func checkLoadBalancer(ctx context.Context, token string) {
|
||||
hc := infra.NewHCloudClient(token)
|
||||
lbs, err := hc.ListLoadBalancers(ctx)
|
||||
if err != nil {
|
||||
cli.Print(" %s Load balancer: %s\n", cli.ErrorStyle.Render("✗"), err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(lbs) == 0 {
|
||||
cli.Print(" %s No load balancers found\n", cli.DimStyle.Render("○"))
|
||||
return
|
||||
}
|
||||
|
||||
for _, lb := range lbs {
|
||||
cli.Print(" %s LB: %s IP: %s Targets: %d\n",
|
||||
cli.SuccessStyle.Render("●"),
|
||||
cli.BoldStyle.Render(lb.Name),
|
||||
lb.PublicNet.IPv4.IP,
|
||||
len(lb.Targets))
|
||||
|
||||
for _, t := range lb.Targets {
|
||||
for _, hs := range t.HealthStatus {
|
||||
icon := cli.SuccessStyle.Render("●")
|
||||
if hs.Status != "healthy" {
|
||||
icon = cli.ErrorStyle.Render("○")
|
||||
}
|
||||
ip := ""
|
||||
if t.IP != nil {
|
||||
ip = t.IP.IP
|
||||
}
|
||||
cli.Print(" %s :%d %s %s\n", icon, hs.ListenPort, hs.Status, cli.DimStyle.Render(ip))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (*infra.Config, string, error) {
|
||||
if infraFile != "" {
|
||||
cfg, err := infra.Load(infraFile)
|
||||
return cfg, infraFile, err
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return infra.Discover(cwd)
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go-rag"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
listCollections bool
|
||||
showStats bool
|
||||
deleteCollection string
|
||||
)
|
||||
|
||||
var collectionsCmd = &cobra.Command{
|
||||
Use: "collections",
|
||||
Short: i18n.T("cmd.rag.collections.short"),
|
||||
Long: i18n.T("cmd.rag.collections.long"),
|
||||
RunE: runCollections,
|
||||
}
|
||||
|
||||
func runCollections(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Connect to Qdrant
|
||||
qdrantClient, err := rag.NewQdrantClient(rag.QdrantConfig{
|
||||
Host: qdrantHost,
|
||||
Port: qdrantPort,
|
||||
UseTLS: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to Qdrant: %w", err)
|
||||
}
|
||||
defer func() { _ = qdrantClient.Close() }()
|
||||
|
||||
// Handle delete
|
||||
if deleteCollection != "" {
|
||||
exists, err := qdrantClient.CollectionExists(ctx, deleteCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("collection not found: %s", deleteCollection)
|
||||
}
|
||||
if err := qdrantClient.DeleteCollection(ctx, deleteCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Deleted collection: %s\n", deleteCollection)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List collections
|
||||
collections, err := qdrantClient.ListCollections(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(collections) == 0 {
|
||||
fmt.Println("No collections found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n\n", cli.TitleStyle.Render("Collections"))
|
||||
|
||||
for _, name := range collections {
|
||||
if showStats {
|
||||
info, err := qdrantClient.CollectionInfo(ctx, name)
|
||||
if err != nil {
|
||||
fmt.Printf(" %s (error: %v)\n", name, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %s\n", cli.ValueStyle.Render(name))
|
||||
fmt.Printf(" Points: %d\n", info.PointCount)
|
||||
fmt.Printf(" Status: %s\n", info.Status)
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Printf(" %s\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
// Package rag provides RAG (Retrieval Augmented Generation) commands.
|
||||
//
|
||||
// Commands:
|
||||
// - core ai rag ingest: Ingest markdown files into Qdrant
|
||||
// - core ai rag query: Query the vector database
|
||||
// - core ai rag collections: List and manage collections
|
||||
package rag
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// AddRAGSubcommands registers the 'rag' command as a subcommand of parent.
|
||||
// Called from the ai command package to mount under "core ai rag".
|
||||
func AddRAGSubcommands(parent *cobra.Command) {
|
||||
initFlags()
|
||||
ragCmd.AddCommand(ingestCmd)
|
||||
ragCmd.AddCommand(queryCmd)
|
||||
ragCmd.AddCommand(collectionsCmd)
|
||||
parent.AddCommand(ragCmd)
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go-rag"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
collection string
|
||||
recreate bool
|
||||
chunkSize int
|
||||
chunkOverlap int
|
||||
)
|
||||
|
||||
var ingestCmd = &cobra.Command{
|
||||
Use: "ingest [directory]",
|
||||
Short: i18n.T("cmd.rag.ingest.short"),
|
||||
Long: i18n.T("cmd.rag.ingest.long"),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runIngest,
|
||||
}
|
||||
|
||||
func runIngest(cmd *cobra.Command, args []string) error {
|
||||
directory := "."
|
||||
if len(args) > 0 {
|
||||
directory = args[0]
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Connect to Qdrant
|
||||
fmt.Printf("Connecting to Qdrant at %s:%d...\n", qdrantHost, qdrantPort)
|
||||
qdrantClient, err := rag.NewQdrantClient(rag.QdrantConfig{
|
||||
Host: qdrantHost,
|
||||
Port: qdrantPort,
|
||||
UseTLS: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to Qdrant: %w", err)
|
||||
}
|
||||
defer func() { _ = qdrantClient.Close() }()
|
||||
|
||||
if err := qdrantClient.HealthCheck(ctx); err != nil {
|
||||
return fmt.Errorf("qdrant health check failed: %w", err)
|
||||
}
|
||||
|
||||
// Connect to Ollama
|
||||
fmt.Printf("Using embedding model: %s (via %s:%d)\n", model, ollamaHost, ollamaPort)
|
||||
ollamaClient, err := rag.NewOllamaClient(rag.OllamaConfig{
|
||||
Host: ollamaHost,
|
||||
Port: ollamaPort,
|
||||
Model: model,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to Ollama: %w", err)
|
||||
}
|
||||
|
||||
if err := ollamaClient.VerifyModel(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Configure ingestion
|
||||
if chunkSize <= 0 {
|
||||
return fmt.Errorf("chunk-size must be > 0")
|
||||
}
|
||||
if chunkOverlap < 0 || chunkOverlap >= chunkSize {
|
||||
return fmt.Errorf("chunk-overlap must be >= 0 and < chunk-size")
|
||||
}
|
||||
|
||||
cfg := rag.IngestConfig{
|
||||
Directory: directory,
|
||||
Collection: collection,
|
||||
Recreate: recreate,
|
||||
Verbose: verbose,
|
||||
BatchSize: 100,
|
||||
Chunk: rag.ChunkConfig{
|
||||
Size: chunkSize,
|
||||
Overlap: chunkOverlap,
|
||||
},
|
||||
}
|
||||
|
||||
// Progress callback
|
||||
progress := func(file string, chunks int, total int) {
|
||||
if verbose {
|
||||
fmt.Printf(" Processed: %s (%d chunks total)\n", file, chunks)
|
||||
} else {
|
||||
fmt.Printf("\r %s (%d chunks) ", cli.DimStyle.Render(file), chunks)
|
||||
}
|
||||
}
|
||||
|
||||
// Run ingestion
|
||||
fmt.Printf("\nIngesting from: %s\n", directory)
|
||||
if recreate {
|
||||
fmt.Printf(" (recreating collection: %s)\n", collection)
|
||||
}
|
||||
|
||||
stats, err := rag.Ingest(ctx, qdrantClient, ollamaClient, cfg, progress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Summary
|
||||
fmt.Printf("\n\n%s\n", cli.TitleStyle.Render("Ingestion complete!"))
|
||||
fmt.Printf(" Files processed: %d\n", stats.Files)
|
||||
fmt.Printf(" Chunks created: %d\n", stats.Chunks)
|
||||
if stats.Errors > 0 {
|
||||
fmt.Printf(" Errors: %s\n", cli.ErrorStyle.Render(fmt.Sprintf("%d", stats.Errors)))
|
||||
}
|
||||
fmt.Printf(" Collection: %s\n", collection)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go-rag"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
queryCollection string
|
||||
limit int
|
||||
threshold float32
|
||||
category string
|
||||
format string
|
||||
)
|
||||
|
||||
var queryCmd = &cobra.Command{
|
||||
Use: "query [question]",
|
||||
Short: i18n.T("cmd.rag.query.short"),
|
||||
Long: i18n.T("cmd.rag.query.long"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runQuery,
|
||||
}
|
||||
|
||||
func runQuery(cmd *cobra.Command, args []string) error {
|
||||
question := args[0]
|
||||
ctx := context.Background()
|
||||
|
||||
// Connect to Qdrant
|
||||
qdrantClient, err := rag.NewQdrantClient(rag.QdrantConfig{
|
||||
Host: qdrantHost,
|
||||
Port: qdrantPort,
|
||||
UseTLS: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to Qdrant: %w", err)
|
||||
}
|
||||
defer func() { _ = qdrantClient.Close() }()
|
||||
|
||||
// Connect to Ollama
|
||||
ollamaClient, err := rag.NewOllamaClient(rag.OllamaConfig{
|
||||
Host: ollamaHost,
|
||||
Port: ollamaPort,
|
||||
Model: model,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to Ollama: %w", err)
|
||||
}
|
||||
|
||||
// Configure query
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
cfg := rag.QueryConfig{
|
||||
Collection: queryCollection,
|
||||
Limit: uint64(limit),
|
||||
Threshold: threshold,
|
||||
Category: category,
|
||||
}
|
||||
|
||||
// Run query
|
||||
results, err := rag.Query(ctx, qdrantClient, ollamaClient, question, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Format output
|
||||
switch format {
|
||||
case "json":
|
||||
fmt.Println(rag.FormatResultsJSON(results))
|
||||
case "context":
|
||||
fmt.Println(rag.FormatResultsContext(results))
|
||||
default:
|
||||
fmt.Println(rag.FormatResultsText(results))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
package rag
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Shared flags
|
||||
var (
|
||||
qdrantHost string
|
||||
qdrantPort int
|
||||
ollamaHost string
|
||||
ollamaPort int
|
||||
model string
|
||||
verbose bool
|
||||
)
|
||||
|
||||
var ragCmd = &cobra.Command{
|
||||
Use: "rag",
|
||||
Short: i18n.T("cmd.rag.short"),
|
||||
Long: i18n.T("cmd.rag.long"),
|
||||
}
|
||||
|
||||
func initFlags() {
|
||||
// Qdrant connection flags (persistent) - defaults to localhost for local development
|
||||
qHost := "localhost"
|
||||
if v := os.Getenv("QDRANT_HOST"); v != "" {
|
||||
qHost = v
|
||||
}
|
||||
ragCmd.PersistentFlags().StringVar(&qdrantHost, "qdrant-host", qHost, i18n.T("cmd.rag.flag.qdrant_host"))
|
||||
|
||||
qPort := 6334
|
||||
if v := os.Getenv("QDRANT_PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
qPort = p
|
||||
}
|
||||
}
|
||||
ragCmd.PersistentFlags().IntVar(&qdrantPort, "qdrant-port", qPort, i18n.T("cmd.rag.flag.qdrant_port"))
|
||||
|
||||
// Ollama connection flags (persistent) - defaults to localhost for local development
|
||||
oHost := "localhost"
|
||||
if v := os.Getenv("OLLAMA_HOST"); v != "" {
|
||||
oHost = v
|
||||
}
|
||||
ragCmd.PersistentFlags().StringVar(&ollamaHost, "ollama-host", oHost, i18n.T("cmd.rag.flag.ollama_host"))
|
||||
|
||||
oPort := 11434
|
||||
if v := os.Getenv("OLLAMA_PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
oPort = p
|
||||
}
|
||||
}
|
||||
ragCmd.PersistentFlags().IntVar(&ollamaPort, "ollama-port", oPort, i18n.T("cmd.rag.flag.ollama_port"))
|
||||
|
||||
m := "nomic-embed-text"
|
||||
if v := os.Getenv("EMBEDDING_MODEL"); v != "" {
|
||||
m = v
|
||||
}
|
||||
ragCmd.PersistentFlags().StringVar(&model, "model", m, i18n.T("cmd.rag.flag.model"))
|
||||
|
||||
// Verbose flag (persistent)
|
||||
ragCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, i18n.T("common.flag.verbose"))
|
||||
|
||||
// Ingest command flags
|
||||
ingestCmd.Flags().StringVar(&collection, "collection", "hostuk-docs", i18n.T("cmd.rag.ingest.flag.collection"))
|
||||
ingestCmd.Flags().BoolVar(&recreate, "recreate", false, i18n.T("cmd.rag.ingest.flag.recreate"))
|
||||
ingestCmd.Flags().IntVar(&chunkSize, "chunk-size", 500, i18n.T("cmd.rag.ingest.flag.chunk_size"))
|
||||
ingestCmd.Flags().IntVar(&chunkOverlap, "chunk-overlap", 50, i18n.T("cmd.rag.ingest.flag.chunk_overlap"))
|
||||
|
||||
// Query command flags
|
||||
queryCmd.Flags().StringVar(&queryCollection, "collection", "hostuk-docs", i18n.T("cmd.rag.query.flag.collection"))
|
||||
queryCmd.Flags().IntVar(&limit, "top", 5, i18n.T("cmd.rag.query.flag.top"))
|
||||
queryCmd.Flags().Float32Var(&threshold, "threshold", 0.5, i18n.T("cmd.rag.query.flag.threshold"))
|
||||
queryCmd.Flags().StringVar(&category, "category", "", i18n.T("cmd.rag.query.flag.category"))
|
||||
queryCmd.Flags().StringVar(&format, "format", "text", i18n.T("cmd.rag.query.flag.format"))
|
||||
|
||||
// Collections command flags
|
||||
collectionsCmd.Flags().BoolVar(&listCollections, "list", false, i18n.T("cmd.rag.collections.flag.list"))
|
||||
collectionsCmd.Flags().BoolVar(&showStats, "stats", false, i18n.T("cmd.rag.collections.flag.stats"))
|
||||
collectionsCmd.Flags().StringVar(&deleteCollection, "delete", "", i18n.T("cmd.rag.collections.flag.delete"))
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package security
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddSecurityCommands)
|
||||
}
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
func addAlertsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "alerts",
|
||||
Short: i18n.T("cmd.security.alerts.short"),
|
||||
Long: i18n.T("cmd.security.alerts.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
return runAlerts()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
|
||||
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// AlertOutput represents a unified alert for output.
|
||||
type AlertOutput struct {
|
||||
Repo string `json:"repo"`
|
||||
Severity string `json:"severity"`
|
||||
ID string `json:"id"`
|
||||
Package string `json:"package,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func runAlerts() error {
|
||||
if err := checkGH(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// External target mode: bypass registry entirely
|
||||
if securityTarget != "" {
|
||||
return runAlertsForTarget(securityTarget)
|
||||
}
|
||||
|
||||
reg, err := loadRegistry(securityRegistryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoList := getReposToCheck(reg, securityRepo)
|
||||
if len(repoList) == 0 {
|
||||
return cli.Err("repo not found: %s", securityRepo)
|
||||
}
|
||||
|
||||
var allAlerts []AlertOutput
|
||||
summary := &AlertSummary{}
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
|
||||
|
||||
// Fetch Dependabot alerts
|
||||
depAlerts, err := fetchDependabotAlerts(repoFullName)
|
||||
if err == nil {
|
||||
for _, alert := range depAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
severity := alert.Advisory.Severity
|
||||
if !filterBySeverity(severity, securitySeverity) {
|
||||
continue
|
||||
}
|
||||
summary.Add(severity)
|
||||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
ID: alert.Advisory.CVEID,
|
||||
Package: alert.Dependency.Package.Name,
|
||||
Version: alert.SecurityVulnerability.VulnerableVersionRange,
|
||||
Type: "dependabot",
|
||||
Message: alert.Advisory.Summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch code scanning alerts
|
||||
codeAlerts, err := fetchCodeScanningAlerts(repoFullName)
|
||||
if err == nil {
|
||||
for _, alert := range codeAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
severity := alert.Rule.Severity
|
||||
if !filterBySeverity(severity, securitySeverity) {
|
||||
continue
|
||||
}
|
||||
summary.Add(severity)
|
||||
location := fmt.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine)
|
||||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
ID: alert.Rule.ID,
|
||||
Location: location,
|
||||
Type: alert.Tool.Name,
|
||||
Message: alert.Rule.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch secret scanning alerts
|
||||
secretAlerts, err := fetchSecretScanningAlerts(repoFullName)
|
||||
if err == nil {
|
||||
for _, alert := range secretAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
if !filterBySeverity("high", securitySeverity) {
|
||||
continue
|
||||
}
|
||||
summary.Add("high") // Secrets are always high severity
|
||||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: "high",
|
||||
ID: fmt.Sprintf("secret-%d", alert.Number),
|
||||
Type: "secret-scanning",
|
||||
Message: alert.SecretType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if securityJSON {
|
||||
output, err := json.MarshalIndent(allAlerts, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print summary
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Alerts:"), summary.String())
|
||||
cli.Blank()
|
||||
|
||||
if len(allAlerts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print table
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
|
||||
// Format: repo SEVERITY ID package/location type
|
||||
location := alert.Package
|
||||
if location == "" {
|
||||
location = alert.Location
|
||||
}
|
||||
if alert.Version != "" {
|
||||
location = fmt.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version))
|
||||
}
|
||||
|
||||
cli.Print("%-20s %s %-16s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.ID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Type),
|
||||
)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runAlertsForTarget runs unified alert checks against an external repo target.
|
||||
func runAlertsForTarget(target string) error {
|
||||
repo, fullName := buildTargetRepo(target)
|
||||
if repo == nil {
|
||||
return cli.Err("invalid target format: use owner/repo (e.g. wailsapp/wails)")
|
||||
}
|
||||
|
||||
var allAlerts []AlertOutput
|
||||
summary := &AlertSummary{}
|
||||
|
||||
// Fetch Dependabot alerts
|
||||
depAlerts, err := fetchDependabotAlerts(fullName)
|
||||
if err == nil {
|
||||
for _, alert := range depAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
severity := alert.Advisory.Severity
|
||||
if !filterBySeverity(severity, securitySeverity) {
|
||||
continue
|
||||
}
|
||||
summary.Add(severity)
|
||||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
ID: alert.Advisory.CVEID,
|
||||
Package: alert.Dependency.Package.Name,
|
||||
Version: alert.SecurityVulnerability.VulnerableVersionRange,
|
||||
Type: "dependabot",
|
||||
Message: alert.Advisory.Summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch code scanning alerts
|
||||
codeAlerts, err := fetchCodeScanningAlerts(fullName)
|
||||
if err == nil {
|
||||
for _, alert := range codeAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
severity := alert.Rule.Severity
|
||||
if !filterBySeverity(severity, securitySeverity) {
|
||||
continue
|
||||
}
|
||||
summary.Add(severity)
|
||||
location := fmt.Sprintf("%s:%d", alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine)
|
||||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
ID: alert.Rule.ID,
|
||||
Location: location,
|
||||
Type: alert.Tool.Name,
|
||||
Message: alert.Rule.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch secret scanning alerts
|
||||
secretAlerts, err := fetchSecretScanningAlerts(fullName)
|
||||
if err == nil {
|
||||
for _, alert := range secretAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
if !filterBySeverity("high", securitySeverity) {
|
||||
continue
|
||||
}
|
||||
summary.Add("high")
|
||||
allAlerts = append(allAlerts, AlertOutput{
|
||||
Repo: repo.Name,
|
||||
Severity: "high",
|
||||
ID: fmt.Sprintf("secret-%d", alert.Number),
|
||||
Type: "secret-scanning",
|
||||
Message: alert.SecretType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if securityJSON {
|
||||
output, err := json.MarshalIndent(allAlerts, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Alerts ("+fullName+"):"), summary.String())
|
||||
cli.Blank()
|
||||
|
||||
if len(allAlerts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
location := alert.Package
|
||||
if location == "" {
|
||||
location = alert.Location
|
||||
}
|
||||
if alert.Version != "" {
|
||||
location = fmt.Sprintf("%s %s", location, cli.DimStyle.Render(alert.Version))
|
||||
}
|
||||
cli.Print("%-20s %s %-16s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.ID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Type),
|
||||
)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchDependabotAlerts(repoFullName string) ([]DependabotAlert, error) {
|
||||
endpoint := fmt.Sprintf("repos/%s/dependabot/alerts?state=open", repoFullName)
|
||||
output, err := runGHAPI(endpoint)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("fetch dependabot alerts for %s", repoFullName))
|
||||
}
|
||||
|
||||
var alerts []DependabotAlert
|
||||
if err := json.Unmarshal(output, &alerts); err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("parse dependabot alerts for %s", repoFullName))
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func fetchCodeScanningAlerts(repoFullName string) ([]CodeScanningAlert, error) {
|
||||
endpoint := fmt.Sprintf("repos/%s/code-scanning/alerts?state=open", repoFullName)
|
||||
output, err := runGHAPI(endpoint)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("fetch code-scanning alerts for %s", repoFullName))
|
||||
}
|
||||
|
||||
var alerts []CodeScanningAlert
|
||||
if err := json.Unmarshal(output, &alerts); err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("parse code-scanning alerts for %s", repoFullName))
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func fetchSecretScanningAlerts(repoFullName string) ([]SecretScanningAlert, error) {
|
||||
endpoint := fmt.Sprintf("repos/%s/secret-scanning/alerts?state=open", repoFullName)
|
||||
output, err := runGHAPI(endpoint)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("fetch secret-scanning alerts for %s", repoFullName))
|
||||
}
|
||||
|
||||
var alerts []SecretScanningAlert
|
||||
if err := json.Unmarshal(output, &alerts); err != nil {
|
||||
return nil, cli.Wrap(err, fmt.Sprintf("parse secret-scanning alerts for %s", repoFullName))
|
||||
}
|
||||
return alerts, nil
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
func addDepsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "deps",
|
||||
Short: i18n.T("cmd.security.deps.short"),
|
||||
Long: i18n.T("cmd.security.deps.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
return runDeps()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
|
||||
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// DepAlert represents a dependency vulnerability for output.
|
||||
type DepAlert struct {
|
||||
Repo string `json:"repo"`
|
||||
Severity string `json:"severity"`
|
||||
CVE string `json:"cve"`
|
||||
Package string `json:"package"`
|
||||
Ecosystem string `json:"ecosystem"`
|
||||
Vulnerable string `json:"vulnerable_range"`
|
||||
PatchedVersion string `json:"patched_version,omitempty"`
|
||||
Manifest string `json:"manifest"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
func runDeps() error {
|
||||
if err := checkGH(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// External target mode: bypass registry entirely
|
||||
if securityTarget != "" {
|
||||
return runDepsForTarget(securityTarget)
|
||||
}
|
||||
|
||||
reg, err := loadRegistry(securityRegistryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoList := getReposToCheck(reg, securityRepo)
|
||||
if len(repoList) == 0 {
|
||||
return cli.Err("repo not found: %s", securityRepo)
|
||||
}
|
||||
|
||||
var allAlerts []DepAlert
|
||||
summary := &AlertSummary{}
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
|
||||
|
||||
alerts, err := fetchDependabotAlerts(repoFullName)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
||||
severity := alert.Advisory.Severity
|
||||
if !filterBySeverity(severity, securitySeverity) {
|
||||
continue
|
||||
}
|
||||
|
||||
summary.Add(severity)
|
||||
|
||||
depAlert := DepAlert{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
CVE: alert.Advisory.CVEID,
|
||||
Package: alert.Dependency.Package.Name,
|
||||
Ecosystem: alert.Dependency.Package.Ecosystem,
|
||||
Vulnerable: alert.SecurityVulnerability.VulnerableVersionRange,
|
||||
PatchedVersion: alert.SecurityVulnerability.FirstPatchedVersion.Identifier,
|
||||
Manifest: alert.Dependency.ManifestPath,
|
||||
Summary: alert.Advisory.Summary,
|
||||
}
|
||||
allAlerts = append(allAlerts, depAlert)
|
||||
}
|
||||
}
|
||||
|
||||
if securityJSON {
|
||||
output, err := json.MarshalIndent(allAlerts, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print summary
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Dependabot:"), summary.String())
|
||||
cli.Blank()
|
||||
|
||||
if len(allAlerts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print table
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
|
||||
// Format upgrade suggestion
|
||||
upgrade := alert.Vulnerable
|
||||
if alert.PatchedVersion != "" {
|
||||
upgrade = fmt.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion))
|
||||
}
|
||||
|
||||
cli.Print("%-16s %s %-16s %-30s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.CVE,
|
||||
alert.Package,
|
||||
upgrade,
|
||||
)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDepsForTarget runs dependency checks against an external repo target.
|
||||
func runDepsForTarget(target string) error {
|
||||
repo, fullName := buildTargetRepo(target)
|
||||
if repo == nil {
|
||||
return cli.Err("invalid target format: use owner/repo (e.g. wailsapp/wails)")
|
||||
}
|
||||
|
||||
var allAlerts []DepAlert
|
||||
summary := &AlertSummary{}
|
||||
|
||||
alerts, err := fetchDependabotAlerts(fullName)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "fetch dependabot alerts for "+fullName)
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
severity := alert.Advisory.Severity
|
||||
if !filterBySeverity(severity, securitySeverity) {
|
||||
continue
|
||||
}
|
||||
summary.Add(severity)
|
||||
allAlerts = append(allAlerts, DepAlert{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
CVE: alert.Advisory.CVEID,
|
||||
Package: alert.Dependency.Package.Name,
|
||||
Ecosystem: alert.Dependency.Package.Ecosystem,
|
||||
Vulnerable: alert.SecurityVulnerability.VulnerableVersionRange,
|
||||
PatchedVersion: alert.SecurityVulnerability.FirstPatchedVersion.Identifier,
|
||||
Manifest: alert.Dependency.ManifestPath,
|
||||
Summary: alert.Advisory.Summary,
|
||||
})
|
||||
}
|
||||
|
||||
if securityJSON {
|
||||
output, err := json.MarshalIndent(allAlerts, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Dependabot ("+fullName+"):"), summary.String())
|
||||
cli.Blank()
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
upgrade := alert.Vulnerable
|
||||
if alert.PatchedVersion != "" {
|
||||
upgrade = fmt.Sprintf("%s -> %s", alert.Vulnerable, cli.SuccessStyle.Render(alert.PatchedVersion))
|
||||
}
|
||||
cli.Print("%-16s %s %-16s %-30s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.CVE,
|
||||
alert.Package,
|
||||
upgrade,
|
||||
)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-ai/ai"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
jobsTargets []string
|
||||
jobsIssueRepo string
|
||||
jobsDryRun bool
|
||||
jobsCopies int
|
||||
)
|
||||
|
||||
func addJobsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "jobs",
|
||||
Short: i18n.T("cmd.security.jobs.short"),
|
||||
Long: i18n.T("cmd.security.jobs.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
return runJobs()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringSliceVar(&jobsTargets, "targets", nil, i18n.T("cmd.security.jobs.flag.targets"))
|
||||
cmd.Flags().StringVar(&jobsIssueRepo, "issue-repo", "host-uk/core", i18n.T("cmd.security.jobs.flag.issue_repo"))
|
||||
cmd.Flags().BoolVar(&jobsDryRun, "dry-run", false, i18n.T("cmd.security.jobs.flag.dry_run"))
|
||||
cmd.Flags().IntVar(&jobsCopies, "copies", 1, i18n.T("cmd.security.jobs.flag.copies"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runJobs() error {
|
||||
if err := checkGH(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(jobsTargets) == 0 {
|
||||
return cli.Err("at least one --targets value required (e.g. --targets wailsapp/wails)")
|
||||
}
|
||||
|
||||
if jobsCopies < 1 {
|
||||
return cli.Err("--copies must be at least 1")
|
||||
}
|
||||
|
||||
var failedCount int
|
||||
for _, target := range jobsTargets {
|
||||
if err := createJobForTarget(target); err != nil {
|
||||
cli.Print("%s %s: %v\n", cli.ErrorStyle.Render(">>"), target, err)
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if failedCount == len(jobsTargets) {
|
||||
return cli.Err("all targets failed to process")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createJobForTarget(target string) error {
|
||||
parts := strings.SplitN(target, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid target format: use owner/repo")
|
||||
}
|
||||
|
||||
// Gather findings
|
||||
summary := &AlertSummary{}
|
||||
var findings []string
|
||||
var fetchErrors int
|
||||
|
||||
// Code scanning
|
||||
codeAlerts, err := fetchCodeScanningAlerts(target)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: failed to fetch code scanning alerts: %v\n", cli.WarningStyle.Render(">>"), target, err)
|
||||
fetchErrors++
|
||||
}
|
||||
if err == nil {
|
||||
for _, alert := range codeAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
severity := alert.Rule.Severity
|
||||
if severity == "" {
|
||||
severity = "medium"
|
||||
}
|
||||
summary.Add(severity)
|
||||
findings = append(findings, fmt.Sprintf("- [%s] %s: %s (%s:%d)",
|
||||
strings.ToUpper(severity), alert.Tool.Name, alert.Rule.Description,
|
||||
alert.MostRecentInstance.Location.Path, alert.MostRecentInstance.Location.StartLine))
|
||||
}
|
||||
}
|
||||
|
||||
// Dependabot
|
||||
depAlerts, err := fetchDependabotAlerts(target)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: failed to fetch dependabot alerts: %v\n", cli.WarningStyle.Render(">>"), target, err)
|
||||
fetchErrors++
|
||||
}
|
||||
if err == nil {
|
||||
for _, alert := range depAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
summary.Add(alert.Advisory.Severity)
|
||||
findings = append(findings, fmt.Sprintf("- [%s] %s: %s (%s)",
|
||||
strings.ToUpper(alert.Advisory.Severity), alert.Dependency.Package.Name,
|
||||
alert.Advisory.Summary, alert.Advisory.CVEID))
|
||||
}
|
||||
}
|
||||
|
||||
// Secret scanning
|
||||
secretAlerts, err := fetchSecretScanningAlerts(target)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: failed to fetch secret scanning alerts: %v\n", cli.WarningStyle.Render(">>"), target, err)
|
||||
fetchErrors++
|
||||
}
|
||||
if err == nil {
|
||||
for _, alert := range secretAlerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
summary.Add("high")
|
||||
findings = append(findings, fmt.Sprintf("- [HIGH] Secret: %s (#%d)", alert.SecretType, alert.Number))
|
||||
}
|
||||
}
|
||||
|
||||
if fetchErrors == 3 {
|
||||
return fmt.Errorf("failed to fetch any alerts for %s", target)
|
||||
}
|
||||
|
||||
if summary.Total == 0 {
|
||||
cli.Print("%s %s: %s\n", cli.SuccessStyle.Render(">>"), target, "No open findings")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build issue body
|
||||
title := fmt.Sprintf("Security scan: %s", target)
|
||||
body := buildJobIssueBody(target, summary, findings)
|
||||
|
||||
for i := range jobsCopies {
|
||||
issueTitle := title
|
||||
if jobsCopies > 1 {
|
||||
issueTitle = fmt.Sprintf("%s (#%d)", title, i+1)
|
||||
}
|
||||
|
||||
if jobsDryRun {
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("[dry-run] Would create issue:"), issueTitle)
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render(" Repo:"), jobsIssueRepo)
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render(" Labels:"), "type:security-scan,repo:"+target)
|
||||
cli.Print("%s %d findings\n", cli.DimStyle.Render(" Findings:"), summary.Total)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create issue via gh CLI
|
||||
cmd := exec.Command("gh", "issue", "create",
|
||||
"--repo", jobsIssueRepo,
|
||||
"--title", issueTitle,
|
||||
"--body", body,
|
||||
"--label", "type:security-scan,repo:"+target,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return cli.Wrap(err, fmt.Sprintf("create issue for %s: %s", target, string(output)))
|
||||
}
|
||||
|
||||
issueURL := strings.TrimSpace(string(output))
|
||||
cli.Print("%s %s: %s\n", cli.SuccessStyle.Render(">>"), issueTitle, issueURL)
|
||||
|
||||
// Record metrics
|
||||
_ = ai.Record(ai.Event{
|
||||
Type: "security.job_created",
|
||||
Timestamp: time.Now(),
|
||||
Repo: target,
|
||||
Data: map[string]any{
|
||||
"issue_repo": jobsIssueRepo,
|
||||
"issue_url": issueURL,
|
||||
"total": summary.Total,
|
||||
"critical": summary.Critical,
|
||||
"high": summary.High,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildJobIssueBody(target string, summary *AlertSummary, findings []string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "## Security Scan: %s\n\n", target)
|
||||
fmt.Fprintf(&sb, "**Summary:** %s\n\n", summary.String())
|
||||
|
||||
sb.WriteString("### Findings\n\n")
|
||||
if len(findings) > 50 {
|
||||
// Truncate long lists
|
||||
for _, f := range findings[:50] {
|
||||
sb.WriteString(f + "\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n... and %d more\n", len(findings)-50)
|
||||
} else {
|
||||
for _, f := range findings {
|
||||
sb.WriteString(f + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n### Checklist\n\n")
|
||||
sb.WriteString("- [ ] Review findings above\n")
|
||||
sb.WriteString("- [ ] Triage by severity (critical/high first)\n")
|
||||
sb.WriteString("- [ ] Create PRs for fixes\n")
|
||||
sb.WriteString("- [ ] Verify fixes resolve alerts\n")
|
||||
|
||||
sb.WriteString("\n### Instructions\n\n")
|
||||
sb.WriteString("1. Claim this issue by assigning yourself\n")
|
||||
fmt.Fprintf(&sb, "2. Run `core security alerts --target %s` for the latest findings\n", target)
|
||||
sb.WriteString("3. Work through the checklist above\n")
|
||||
sb.WriteString("4. Close this issue when all findings are addressed\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-ai/ai"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
scanTool string
|
||||
)
|
||||
|
||||
func addScanCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "scan",
|
||||
Short: i18n.T("cmd.security.scan.short"),
|
||||
Long: i18n.T("cmd.security.scan.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
return runScan()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
cmd.Flags().StringVar(&securitySeverity, "severity", "", i18n.T("cmd.security.flag.severity"))
|
||||
cmd.Flags().StringVar(&scanTool, "tool", "", i18n.T("cmd.security.scan.flag.tool"))
|
||||
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// ScanAlert represents a code scanning alert for output.
|
||||
type ScanAlert struct {
|
||||
Repo string `json:"repo"`
|
||||
Severity string `json:"severity"`
|
||||
RuleID string `json:"rule_id"`
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path"`
|
||||
Line int `json:"line"`
|
||||
Description string `json:"description"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func runScan() error {
|
||||
if err := checkGH(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// External target mode: bypass registry entirely
|
||||
if securityTarget != "" {
|
||||
return runScanForTarget(securityTarget)
|
||||
}
|
||||
|
||||
reg, err := loadRegistry(securityRegistryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoList := getReposToCheck(reg, securityRepo)
|
||||
if len(repoList) == 0 {
|
||||
return cli.Err("repo not found: %s", securityRepo)
|
||||
}
|
||||
|
||||
var allAlerts []ScanAlert
|
||||
summary := &AlertSummary{}
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
|
||||
|
||||
alerts, err := fetchCodeScanningAlerts(repoFullName)
|
||||
if err != nil {
|
||||
cli.Print("%s %s: %v\n", cli.WarningStyle.Render(">>"), repoFullName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by tool if specified
|
||||
if scanTool != "" && alert.Tool.Name != scanTool {
|
||||
continue
|
||||
}
|
||||
|
||||
severity := alert.Rule.Severity
|
||||
if severity == "" {
|
||||
severity = "medium" // Default if not specified
|
||||
}
|
||||
|
||||
if !filterBySeverity(severity, securitySeverity) {
|
||||
continue
|
||||
}
|
||||
|
||||
summary.Add(severity)
|
||||
|
||||
scanAlert := ScanAlert{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
RuleID: alert.Rule.ID,
|
||||
Tool: alert.Tool.Name,
|
||||
Path: alert.MostRecentInstance.Location.Path,
|
||||
Line: alert.MostRecentInstance.Location.StartLine,
|
||||
Description: alert.Rule.Description,
|
||||
Message: alert.MostRecentInstance.Message.Text,
|
||||
}
|
||||
allAlerts = append(allAlerts, scanAlert)
|
||||
}
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
_ = ai.Record(ai.Event{
|
||||
Type: "security.scan",
|
||||
Timestamp: time.Now(),
|
||||
Data: map[string]any{
|
||||
"total": summary.Total,
|
||||
"critical": summary.Critical,
|
||||
"high": summary.High,
|
||||
"medium": summary.Medium,
|
||||
"low": summary.Low,
|
||||
},
|
||||
})
|
||||
|
||||
if securityJSON {
|
||||
output, err := json.MarshalIndent(allAlerts, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print summary
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Code Scanning:"), summary.String())
|
||||
cli.Blank()
|
||||
|
||||
if len(allAlerts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print table
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
|
||||
location := fmt.Sprintf("%s:%d", alert.Path, alert.Line)
|
||||
|
||||
cli.Print("%-16s %s %-20s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.RuleID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Tool),
|
||||
)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runScanForTarget runs a code scanning check against an external repo target.
|
||||
func runScanForTarget(target string) error {
|
||||
repo, fullName := buildTargetRepo(target)
|
||||
if repo == nil {
|
||||
return cli.Err("invalid target format: use owner/repo (e.g. wailsapp/wails)")
|
||||
}
|
||||
|
||||
var allAlerts []ScanAlert
|
||||
summary := &AlertSummary{}
|
||||
|
||||
alerts, err := fetchCodeScanningAlerts(fullName)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "fetch code-scanning alerts for "+fullName)
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
if scanTool != "" && alert.Tool.Name != scanTool {
|
||||
continue
|
||||
}
|
||||
severity := alert.Rule.Severity
|
||||
if severity == "" {
|
||||
severity = "medium"
|
||||
}
|
||||
if !filterBySeverity(severity, securitySeverity) {
|
||||
continue
|
||||
}
|
||||
summary.Add(severity)
|
||||
allAlerts = append(allAlerts, ScanAlert{
|
||||
Repo: repo.Name,
|
||||
Severity: severity,
|
||||
RuleID: alert.Rule.ID,
|
||||
Tool: alert.Tool.Name,
|
||||
Path: alert.MostRecentInstance.Location.Path,
|
||||
Line: alert.MostRecentInstance.Location.StartLine,
|
||||
Description: alert.Rule.Description,
|
||||
Message: alert.MostRecentInstance.Message.Text,
|
||||
})
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
_ = ai.Record(ai.Event{
|
||||
Type: "security.scan",
|
||||
Timestamp: time.Now(),
|
||||
Repo: fullName,
|
||||
Data: map[string]any{
|
||||
"target": fullName,
|
||||
"total": summary.Total,
|
||||
"critical": summary.Critical,
|
||||
"high": summary.High,
|
||||
"medium": summary.Medium,
|
||||
"low": summary.Low,
|
||||
},
|
||||
})
|
||||
|
||||
if securityJSON {
|
||||
output, err := json.MarshalIndent(allAlerts, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Code Scanning ("+fullName+"):"), summary.String())
|
||||
cli.Blank()
|
||||
|
||||
if len(allAlerts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
sevStyle := severityStyle(alert.Severity)
|
||||
location := fmt.Sprintf("%s:%d", alert.Path, alert.Line)
|
||||
cli.Print("%-16s %s %-20s %-40s %s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
sevStyle.Render(fmt.Sprintf("%-8s", alert.Severity)),
|
||||
alert.RuleID,
|
||||
location,
|
||||
cli.DimStyle.Render(alert.Tool),
|
||||
)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
func addSecretsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "secrets",
|
||||
Short: i18n.T("cmd.security.secrets.short"),
|
||||
Long: i18n.T("cmd.security.secrets.long"),
|
||||
RunE: func(c *cli.Command, args []string) error {
|
||||
return runSecrets()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&securityRegistryPath, "registry", "", i18n.T("common.flag.registry"))
|
||||
cmd.Flags().StringVar(&securityRepo, "repo", "", i18n.T("cmd.security.flag.repo"))
|
||||
cmd.Flags().BoolVar(&securityJSON, "json", false, i18n.T("common.flag.json"))
|
||||
cmd.Flags().StringVar(&securityTarget, "target", "", i18n.T("cmd.security.flag.target"))
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
// SecretAlert represents a secret scanning alert for output.
|
||||
type SecretAlert struct {
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
SecretType string `json:"secret_type"`
|
||||
State string `json:"state"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
PushProtection bool `json:"push_protection_bypassed"`
|
||||
}
|
||||
|
||||
func runSecrets() error {
|
||||
if err := checkGH(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// External target mode: bypass registry entirely
|
||||
if securityTarget != "" {
|
||||
return runSecretsForTarget(securityTarget)
|
||||
}
|
||||
|
||||
reg, err := loadRegistry(securityRegistryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoList := getReposToCheck(reg, securityRepo)
|
||||
if len(repoList) == 0 {
|
||||
return cli.Err("repo not found: %s", securityRepo)
|
||||
}
|
||||
|
||||
var allAlerts []SecretAlert
|
||||
openCount := 0
|
||||
|
||||
for _, repo := range repoList {
|
||||
repoFullName := fmt.Sprintf("%s/%s", reg.Org, repo.Name)
|
||||
|
||||
alerts, err := fetchSecretScanningAlerts(repoFullName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
openCount++
|
||||
|
||||
secretAlert := SecretAlert{
|
||||
Repo: repo.Name,
|
||||
Number: alert.Number,
|
||||
SecretType: alert.SecretType,
|
||||
State: alert.State,
|
||||
Resolution: alert.Resolution,
|
||||
PushProtection: alert.PushProtection,
|
||||
}
|
||||
allAlerts = append(allAlerts, secretAlert)
|
||||
}
|
||||
}
|
||||
|
||||
if securityJSON {
|
||||
output, err := json.MarshalIndent(allAlerts, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print summary
|
||||
cli.Blank()
|
||||
if openCount > 0 {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.ErrorStyle.Render(fmt.Sprintf("%d open", openCount)))
|
||||
} else {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets:"), cli.SuccessStyle.Render("No exposed secrets"))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
if len(allAlerts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print table
|
||||
for _, alert := range allAlerts {
|
||||
bypassed := ""
|
||||
if alert.PushProtection {
|
||||
bypassed = cli.WarningStyle.Render(" (push protection bypassed)")
|
||||
}
|
||||
|
||||
cli.Print("%-16s %-6d %-30s%s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
alert.Number,
|
||||
cli.ErrorStyle.Render(alert.SecretType),
|
||||
bypassed,
|
||||
)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSecretsForTarget runs secret scanning checks against an external repo target.
|
||||
func runSecretsForTarget(target string) error {
|
||||
repo, fullName := buildTargetRepo(target)
|
||||
if repo == nil {
|
||||
return cli.Err("invalid target format: use owner/repo (e.g. wailsapp/wails)")
|
||||
}
|
||||
|
||||
var allAlerts []SecretAlert
|
||||
openCount := 0
|
||||
|
||||
alerts, err := fetchSecretScanningAlerts(fullName)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "fetch secret-scanning alerts for "+fullName)
|
||||
}
|
||||
|
||||
for _, alert := range alerts {
|
||||
if alert.State != "open" {
|
||||
continue
|
||||
}
|
||||
openCount++
|
||||
allAlerts = append(allAlerts, SecretAlert{
|
||||
Repo: repo.Name,
|
||||
Number: alert.Number,
|
||||
SecretType: alert.SecretType,
|
||||
State: alert.State,
|
||||
Resolution: alert.Resolution,
|
||||
PushProtection: alert.PushProtection,
|
||||
})
|
||||
}
|
||||
|
||||
if securityJSON {
|
||||
output, err := json.MarshalIndent(allAlerts, "", " ")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "marshal JSON output")
|
||||
}
|
||||
cli.Text(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
if openCount > 0 {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets ("+fullName+"):"), cli.ErrorStyle.Render(fmt.Sprintf("%d open", openCount)))
|
||||
} else {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("Secrets ("+fullName+"):"), cli.SuccessStyle.Render("No exposed secrets"))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
for _, alert := range allAlerts {
|
||||
bypassed := ""
|
||||
if alert.PushProtection {
|
||||
bypassed = cli.WarningStyle.Render(" (push protection bypassed)")
|
||||
}
|
||||
cli.Print("%-16s %-6d %-30s%s\n",
|
||||
cli.ValueStyle.Render(alert.Repo),
|
||||
alert.Number,
|
||||
cli.ErrorStyle.Render(alert.SecretType),
|
||||
bypassed,
|
||||
)
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"forge.lthn.ai/core/go/pkg/repos"
|
||||
)
|
||||
|
||||
var (
|
||||
// Command flags
|
||||
securityRegistryPath string
|
||||
securityRepo string
|
||||
securitySeverity string
|
||||
securityJSON bool
|
||||
securityTarget string // External repo target (e.g. "wailsapp/wails")
|
||||
)
|
||||
|
||||
// AddSecurityCommands adds the 'security' command to the root.
|
||||
func AddSecurityCommands(root *cli.Command) {
|
||||
secCmd := &cli.Command{
|
||||
Use: "security",
|
||||
Short: i18n.T("cmd.security.short"),
|
||||
Long: i18n.T("cmd.security.long"),
|
||||
}
|
||||
|
||||
addAlertsCommand(secCmd)
|
||||
addDepsCommand(secCmd)
|
||||
addScanCommand(secCmd)
|
||||
addSecretsCommand(secCmd)
|
||||
addJobsCommand(secCmd)
|
||||
|
||||
root.AddCommand(secCmd)
|
||||
}
|
||||
|
||||
// DependabotAlert represents a Dependabot vulnerability alert.
|
||||
type DependabotAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
Advisory struct {
|
||||
Severity string `json:"severity"`
|
||||
CVEID string `json:"cve_id"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
} `json:"security_advisory"`
|
||||
Dependency struct {
|
||||
Package struct {
|
||||
Name string `json:"name"`
|
||||
Ecosystem string `json:"ecosystem"`
|
||||
} `json:"package"`
|
||||
ManifestPath string `json:"manifest_path"`
|
||||
} `json:"dependency"`
|
||||
SecurityVulnerability struct {
|
||||
Package struct {
|
||||
Name string `json:"name"`
|
||||
Ecosystem string `json:"ecosystem"`
|
||||
} `json:"package"`
|
||||
FirstPatchedVersion struct {
|
||||
Identifier string `json:"identifier"`
|
||||
} `json:"first_patched_version"`
|
||||
VulnerableVersionRange string `json:"vulnerable_version_range"`
|
||||
} `json:"security_vulnerability"`
|
||||
}
|
||||
|
||||
// CodeScanningAlert represents a code scanning alert.
|
||||
type CodeScanningAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
DismissedReason string `json:"dismissed_reason"`
|
||||
Rule struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
} `json:"rule"`
|
||||
Tool struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
} `json:"tool"`
|
||||
MostRecentInstance struct {
|
||||
Location struct {
|
||||
Path string `json:"path"`
|
||||
StartLine int `json:"start_line"`
|
||||
EndLine int `json:"end_line"`
|
||||
} `json:"location"`
|
||||
Message struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"message"`
|
||||
} `json:"most_recent_instance"`
|
||||
}
|
||||
|
||||
// SecretScanningAlert represents a secret scanning alert.
|
||||
type SecretScanningAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
SecretType string `json:"secret_type"`
|
||||
Secret string `json:"secret"`
|
||||
PushProtection bool `json:"push_protection_bypassed"`
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
|
||||
// loadRegistry loads the repository registry.
|
||||
func loadRegistry(registryPath string) (*repos.Registry, error) {
|
||||
if registryPath != "" {
|
||||
reg, err := repos.LoadRegistry(io.Local, registryPath)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "load registry")
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
path, err := repos.FindRegistry(io.Local)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "find registry")
|
||||
}
|
||||
reg, err := repos.LoadRegistry(io.Local, path)
|
||||
if err != nil {
|
||||
return nil, cli.Wrap(err, "load registry")
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
|
||||
// checkGH verifies gh CLI is available.
|
||||
func checkGH() error {
|
||||
if _, err := exec.LookPath("gh"); err != nil {
|
||||
return errors.New(i18n.T("error.gh_not_found"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runGHAPI runs a gh api command and returns the output.
|
||||
func runGHAPI(endpoint string) ([]byte, error) {
|
||||
cmd := exec.Command("gh", "api", endpoint, "--paginate")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
stderr := string(exitErr.Stderr)
|
||||
// Handle common errors gracefully
|
||||
if strings.Contains(stderr, "404") || strings.Contains(stderr, "Not Found") {
|
||||
return []byte("[]"), nil // Return empty array for not found
|
||||
}
|
||||
if strings.Contains(stderr, "403") {
|
||||
return nil, fmt.Errorf("access denied (check token permissions)")
|
||||
}
|
||||
}
|
||||
return nil, cli.Wrap(err, "run gh api")
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// severityStyle returns the appropriate style for a severity level.
|
||||
func severityStyle(severity string) *cli.AnsiStyle {
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical":
|
||||
return cli.ErrorStyle
|
||||
case "high":
|
||||
return cli.WarningStyle
|
||||
case "medium":
|
||||
return cli.ValueStyle
|
||||
default:
|
||||
return cli.DimStyle
|
||||
}
|
||||
}
|
||||
|
||||
// filterBySeverity checks if the severity matches the filter.
|
||||
func filterBySeverity(severity, filter string) bool {
|
||||
if filter == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
severities := strings.Split(strings.ToLower(filter), ",")
|
||||
sev := strings.ToLower(severity)
|
||||
|
||||
for _, s := range severities {
|
||||
if strings.TrimSpace(s) == sev {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getReposToCheck returns the list of repos to check based on flags.
|
||||
func getReposToCheck(reg *repos.Registry, repoFilter string) []*repos.Repo {
|
||||
if repoFilter != "" {
|
||||
if repo, ok := reg.Get(repoFilter); ok {
|
||||
return []*repos.Repo{repo}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return reg.List()
|
||||
}
|
||||
|
||||
// buildTargetRepo creates a synthetic Repo entry for an external target (e.g. "wailsapp/wails").
|
||||
func buildTargetRepo(target string) (*repos.Repo, string) {
|
||||
parts := strings.SplitN(target, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return nil, ""
|
||||
}
|
||||
return &repos.Repo{Name: parts[1]}, target
|
||||
}
|
||||
|
||||
// AlertSummary holds aggregated alert counts.
|
||||
type AlertSummary struct {
|
||||
Critical int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
Unknown int
|
||||
Total int
|
||||
}
|
||||
|
||||
// Add increments summary counters for the provided severity.
|
||||
func (s *AlertSummary) Add(severity string) {
|
||||
s.Total++
|
||||
switch strings.ToLower(severity) {
|
||||
case "critical":
|
||||
s.Critical++
|
||||
case "high":
|
||||
s.High++
|
||||
case "medium":
|
||||
s.Medium++
|
||||
case "low":
|
||||
s.Low++
|
||||
default:
|
||||
s.Unknown++
|
||||
}
|
||||
}
|
||||
|
||||
// String renders a human-readable summary of alert counts.
|
||||
func (s *AlertSummary) String() string {
|
||||
parts := []string{}
|
||||
if s.Critical > 0 {
|
||||
parts = append(parts, cli.ErrorStyle.Render(fmt.Sprintf("%d critical", s.Critical)))
|
||||
}
|
||||
if s.High > 0 {
|
||||
parts = append(parts, cli.WarningStyle.Render(fmt.Sprintf("%d high", s.High)))
|
||||
}
|
||||
if s.Medium > 0 {
|
||||
parts = append(parts, cli.ValueStyle.Render(fmt.Sprintf("%d medium", s.Medium)))
|
||||
}
|
||||
if s.Low > 0 {
|
||||
parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d low", s.Low)))
|
||||
}
|
||||
if s.Unknown > 0 {
|
||||
parts = append(parts, cli.DimStyle.Render(fmt.Sprintf("%d unknown", s.Unknown)))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return cli.SuccessStyle.Render("No alerts")
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
// Package testcmd provides Go test running commands with enhanced output.
|
||||
//
|
||||
// Note: Package named testcmd to avoid conflict with Go's test package.
|
||||
//
|
||||
// Features:
|
||||
// - Colour-coded pass/fail/skip output
|
||||
// - Per-package coverage breakdown with --coverage
|
||||
// - JSON output for CI/agents with --json
|
||||
// - Filters linker warnings on macOS
|
||||
//
|
||||
// Flags: --verbose, --coverage, --short, --pkg, --run, --race, --json
|
||||
package testcmd
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddTestCommands)
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
// Package testcmd provides test running commands.
|
||||
//
|
||||
// Note: Package named testcmd to avoid conflict with Go's test package.
|
||||
package testcmd
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Style aliases from shared
|
||||
var (
|
||||
testHeaderStyle = cli.RepoStyle
|
||||
testPassStyle = cli.SuccessStyle
|
||||
testFailStyle = cli.ErrorStyle
|
||||
testSkipStyle = cli.WarningStyle
|
||||
testDimStyle = cli.DimStyle
|
||||
testCovHighStyle = cli.NewStyle().Foreground(cli.ColourGreen500)
|
||||
testCovMedStyle = cli.NewStyle().Foreground(cli.ColourAmber500)
|
||||
testCovLowStyle = cli.NewStyle().Foreground(cli.ColourRed500)
|
||||
)
|
||||
|
||||
// Flag variables for test command
|
||||
var (
|
||||
testVerbose bool
|
||||
testCoverage bool
|
||||
testShort bool
|
||||
testPkg string
|
||||
testRun string
|
||||
testRace bool
|
||||
testJSON bool
|
||||
)
|
||||
|
||||
var testCmd = &cobra.Command{
|
||||
Use: "test",
|
||||
Short: i18n.T("cmd.test.short"),
|
||||
Long: i18n.T("cmd.test.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runTest(testVerbose, testCoverage, testShort, testPkg, testRun, testRace, testJSON)
|
||||
},
|
||||
}
|
||||
|
||||
func initTestFlags() {
|
||||
testCmd.Flags().BoolVar(&testVerbose, "verbose", false, i18n.T("cmd.test.flag.verbose"))
|
||||
testCmd.Flags().BoolVar(&testCoverage, "coverage", false, i18n.T("common.flag.coverage"))
|
||||
testCmd.Flags().BoolVar(&testShort, "short", false, i18n.T("cmd.test.flag.short"))
|
||||
testCmd.Flags().StringVar(&testPkg, "pkg", "", i18n.T("cmd.test.flag.pkg"))
|
||||
testCmd.Flags().StringVar(&testRun, "run", "", i18n.T("cmd.test.flag.run"))
|
||||
testCmd.Flags().BoolVar(&testRace, "race", false, i18n.T("cmd.test.flag.race"))
|
||||
testCmd.Flags().BoolVar(&testJSON, "json", false, i18n.T("cmd.test.flag.json"))
|
||||
}
|
||||
|
||||
// AddTestCommands registers the 'test' command and all subcommands.
|
||||
func AddTestCommands(root *cobra.Command) {
|
||||
initTestFlags()
|
||||
root.AddCommand(testCmd)
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
package testcmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
type packageCoverage struct {
|
||||
name string
|
||||
coverage float64
|
||||
hasCov bool
|
||||
}
|
||||
|
||||
type testResults struct {
|
||||
packages []packageCoverage
|
||||
passed int
|
||||
failed int
|
||||
skipped int
|
||||
totalCov float64
|
||||
covCount int
|
||||
failedPkgs []string
|
||||
}
|
||||
|
||||
func parseTestOutput(output string) testResults {
|
||||
results := testResults{}
|
||||
|
||||
// Regex patterns - handle both timed and cached test results
|
||||
// Example: ok forge.lthn.ai/core/go-crypt/crypt 0.015s coverage: 91.2% of statements
|
||||
// Example: ok forge.lthn.ai/core/go-crypt/crypt (cached) coverage: 91.2% of statements
|
||||
okPattern := regexp.MustCompile(`^ok\s+(\S+)\s+(?:[\d.]+s|\(cached\))(?:\s+coverage:\s+([\d.]+)%)?`)
|
||||
failPattern := regexp.MustCompile(`^FAIL\s+(\S+)`)
|
||||
skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`)
|
||||
coverPattern := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if matches := okPattern.FindStringSubmatch(line); matches != nil {
|
||||
pkg := packageCoverage{name: matches[1]}
|
||||
if len(matches) > 2 && matches[2] != "" {
|
||||
cov, _ := strconv.ParseFloat(matches[2], 64)
|
||||
pkg.coverage = cov
|
||||
pkg.hasCov = true
|
||||
results.totalCov += cov
|
||||
results.covCount++
|
||||
}
|
||||
results.packages = append(results.packages, pkg)
|
||||
results.passed++
|
||||
} else if matches := failPattern.FindStringSubmatch(line); matches != nil {
|
||||
results.failed++
|
||||
results.failedPkgs = append(results.failedPkgs, matches[1])
|
||||
} else if matches := skipPattern.FindStringSubmatch(line); matches != nil {
|
||||
results.skipped++
|
||||
} else if matches := coverPattern.FindStringSubmatch(line); matches != nil {
|
||||
// Catch any additional coverage lines
|
||||
cov, _ := strconv.ParseFloat(matches[1], 64)
|
||||
if cov > 0 {
|
||||
// Find the last package without coverage and update it
|
||||
for i := len(results.packages) - 1; i >= 0; i-- {
|
||||
if !results.packages[i].hasCov {
|
||||
results.packages[i].coverage = cov
|
||||
results.packages[i].hasCov = true
|
||||
results.totalCov += cov
|
||||
results.covCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func printTestSummary(results testResults, showCoverage bool) {
|
||||
// Print pass/fail summary
|
||||
total := results.passed + results.failed
|
||||
if total > 0 {
|
||||
fmt.Printf(" %s %s", testPassStyle.Render("✓"), i18n.T("i18n.count.passed", results.passed))
|
||||
if results.failed > 0 {
|
||||
fmt.Printf(" %s %s", testFailStyle.Render("✗"), i18n.T("i18n.count.failed", results.failed))
|
||||
}
|
||||
if results.skipped > 0 {
|
||||
fmt.Printf(" %s %s", testSkipStyle.Render("○"), i18n.T("i18n.count.skipped", results.skipped))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Print failed packages
|
||||
if len(results.failedPkgs) > 0 {
|
||||
fmt.Printf("\n %s\n", i18n.T("cmd.test.failed_packages"))
|
||||
for _, pkg := range results.failedPkgs {
|
||||
fmt.Printf(" %s %s\n", testFailStyle.Render("✗"), pkg)
|
||||
}
|
||||
}
|
||||
|
||||
// Print coverage
|
||||
if showCoverage {
|
||||
printCoverageSummary(results)
|
||||
} else if results.covCount > 0 {
|
||||
avgCov := results.totalCov / float64(results.covCount)
|
||||
fmt.Printf("\n %s %s\n", i18n.Label("coverage"), formatCoverage(avgCov))
|
||||
}
|
||||
}
|
||||
|
||||
func printCoverageSummary(results testResults) {
|
||||
if len(results.packages) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n %s\n", testHeaderStyle.Render(i18n.T("cmd.test.coverage_by_package")))
|
||||
|
||||
// Sort packages by name
|
||||
sort.Slice(results.packages, func(i, j int) bool {
|
||||
return results.packages[i].name < results.packages[j].name
|
||||
})
|
||||
|
||||
// Find max package name length for alignment
|
||||
maxLen := 0
|
||||
for _, pkg := range results.packages {
|
||||
name := shortenPackageName(pkg.name)
|
||||
if len(name) > maxLen {
|
||||
maxLen = len(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Print each package
|
||||
for _, pkg := range results.packages {
|
||||
if !pkg.hasCov {
|
||||
continue
|
||||
}
|
||||
name := shortenPackageName(pkg.name)
|
||||
padLen := maxLen - len(name) + 2
|
||||
if padLen < 0 {
|
||||
padLen = 2
|
||||
}
|
||||
padding := strings.Repeat(" ", padLen)
|
||||
fmt.Printf(" %s%s%s\n", name, padding, formatCoverage(pkg.coverage))
|
||||
}
|
||||
|
||||
// Print average
|
||||
if results.covCount > 0 {
|
||||
avgCov := results.totalCov / float64(results.covCount)
|
||||
avgLabel := i18n.T("cmd.test.label.average")
|
||||
padLen := maxLen - len(avgLabel) + 2
|
||||
if padLen < 0 {
|
||||
padLen = 2
|
||||
}
|
||||
padding := strings.Repeat(" ", padLen)
|
||||
fmt.Printf("\n %s%s%s\n", testHeaderStyle.Render(avgLabel), padding, formatCoverage(avgCov))
|
||||
}
|
||||
}
|
||||
|
||||
func formatCoverage(cov float64) string {
|
||||
s := fmt.Sprintf("%.1f%%", cov)
|
||||
if cov >= 80 {
|
||||
return testCovHighStyle.Render(s)
|
||||
} else if cov >= 50 {
|
||||
return testCovMedStyle.Render(s)
|
||||
}
|
||||
return testCovLowStyle.Render(s)
|
||||
}
|
||||
|
||||
func shortenPackageName(name string) string {
|
||||
// Remove common prefixes
|
||||
prefixes := []string{
|
||||
"forge.lthn.ai/core/cli/",
|
||||
"forge.lthn.ai/core/gui/",
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return strings.TrimPrefix(name, prefix)
|
||||
}
|
||||
}
|
||||
return filepath.Base(name)
|
||||
}
|
||||
|
||||
func printJSONResults(results testResults, exitCode int) {
|
||||
// Simple JSON output for agents
|
||||
fmt.Printf("{\n")
|
||||
fmt.Printf(" \"passed\": %d,\n", results.passed)
|
||||
fmt.Printf(" \"failed\": %d,\n", results.failed)
|
||||
fmt.Printf(" \"skipped\": %d,\n", results.skipped)
|
||||
if results.covCount > 0 {
|
||||
avgCov := results.totalCov / float64(results.covCount)
|
||||
fmt.Printf(" \"coverage\": %.1f,\n", avgCov)
|
||||
}
|
||||
fmt.Printf(" \"exit_code\": %d,\n", exitCode)
|
||||
if len(results.failedPkgs) > 0 {
|
||||
fmt.Printf(" \"failed_packages\": [\n")
|
||||
for i, pkg := range results.failedPkgs {
|
||||
comma := ","
|
||||
if i == len(results.failedPkgs)-1 {
|
||||
comma = ""
|
||||
}
|
||||
fmt.Printf(" %q%s\n", pkg, comma)
|
||||
}
|
||||
fmt.Printf(" ]\n")
|
||||
} else {
|
||||
fmt.Printf(" \"failed_packages\": []\n")
|
||||
}
|
||||
fmt.Printf("}\n")
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
package testcmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error {
|
||||
// Detect if we're in a Go project
|
||||
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
|
||||
return errors.New(i18n.T("cmd.test.error.no_go_mod"))
|
||||
}
|
||||
|
||||
// Build command arguments
|
||||
args := []string{"test"}
|
||||
|
||||
// Default to ./... if no package specified
|
||||
if pkg == "" {
|
||||
pkg = "./..."
|
||||
}
|
||||
|
||||
// Add flags
|
||||
if verbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
if short {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if run != "" {
|
||||
args = append(args, "-run", run)
|
||||
}
|
||||
if race {
|
||||
args = append(args, "-race")
|
||||
}
|
||||
|
||||
// Always add coverage
|
||||
args = append(args, "-cover")
|
||||
|
||||
// Add package pattern
|
||||
args = append(args, pkg)
|
||||
|
||||
// Create command
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir, _ = os.Getwd()
|
||||
|
||||
// Set environment to suppress macOS linker warnings
|
||||
cmd.Env = append(os.Environ(), getMacOSDeploymentTarget())
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Printf("%s %s\n", testHeaderStyle.Render(i18n.Label("test")), i18n.ProgressSubject("run", "tests"))
|
||||
fmt.Printf(" %s %s\n", i18n.Label("package"), testDimStyle.Render(pkg))
|
||||
if run != "" {
|
||||
fmt.Printf(" %s %s\n", i18n.Label("filter"), testDimStyle.Render(run))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Capture output for parsing
|
||||
var stdout, stderr strings.Builder
|
||||
|
||||
if verbose && !jsonOutput {
|
||||
// Stream output in verbose mode, but also capture for parsing
|
||||
cmd.Stdout = io.MultiWriter(os.Stdout, &stdout)
|
||||
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
|
||||
} else {
|
||||
// Capture output for parsing
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
}
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
}
|
||||
}
|
||||
|
||||
// Combine stdout and stderr for parsing, filtering linker warnings
|
||||
combined := filterLinkerWarnings(stdout.String() + "\n" + stderr.String())
|
||||
|
||||
// Parse results
|
||||
results := parseTestOutput(combined)
|
||||
|
||||
if jsonOutput {
|
||||
// JSON output for CI/agents
|
||||
printJSONResults(results, exitCode)
|
||||
if exitCode != 0 {
|
||||
return errors.New(i18n.T("i18n.fail.run", "tests"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Print summary
|
||||
if !verbose {
|
||||
printTestSummary(results, coverage)
|
||||
} else if coverage {
|
||||
// In verbose mode, still show coverage summary at end
|
||||
fmt.Println()
|
||||
printCoverageSummary(results)
|
||||
}
|
||||
|
||||
if exitCode != 0 {
|
||||
fmt.Printf("\n%s %s\n", testFailStyle.Render(i18n.T("cli.fail")), i18n.T("cmd.test.tests_failed"))
|
||||
return errors.New(i18n.T("i18n.fail.run", "tests"))
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s %s\n", testPassStyle.Render(i18n.T("cli.pass")), i18n.T("common.result.all_passed"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMacOSDeploymentTarget() string {
|
||||
if runtime.GOOS == "darwin" {
|
||||
// Use deployment target matching current macOS to suppress linker warnings
|
||||
return "MACOSX_DEPLOYMENT_TARGET=26.0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func filterLinkerWarnings(output string) string {
|
||||
// Filter out ld: warning lines that pollute the output
|
||||
var filtered []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Skip linker warnings
|
||||
if strings.HasPrefix(line, "ld: warning:") {
|
||||
continue
|
||||
}
|
||||
// Skip test binary build comments
|
||||
if strings.HasPrefix(line, "# ") && strings.HasSuffix(line, ".test") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
return strings.Join(filtered, "\n")
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package testcmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestShortenPackageName(t *testing.T) {
|
||||
assert.Equal(t, "pkg/foo", shortenPackageName("forge.lthn.ai/core/go/pkg/foo"))
|
||||
assert.Equal(t, "cli-php", shortenPackageName("forge.lthn.ai/core/cli-php"))
|
||||
assert.Equal(t, "bar", shortenPackageName("github.com/other/bar"))
|
||||
}
|
||||
|
||||
func TestFormatCoverageTest(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 TestParseTestOutput(t *testing.T) {
|
||||
output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements
|
||||
FAIL forge.lthn.ai/core/go/pkg/bar
|
||||
? forge.lthn.ai/core/go/pkg/baz [no test files]
|
||||
`
|
||||
results := parseTestOutput(output)
|
||||
assert.Equal(t, 1, results.passed)
|
||||
assert.Equal(t, 1, results.failed)
|
||||
assert.Equal(t, 1, results.skipped)
|
||||
assert.Equal(t, 1, len(results.failedPkgs))
|
||||
assert.Equal(t, "forge.lthn.ai/core/go/pkg/bar", results.failedPkgs[0])
|
||||
assert.Equal(t, 1, len(results.packages))
|
||||
assert.Equal(t, 50.0, results.packages[0].coverage)
|
||||
}
|
||||
|
||||
func TestPrintCoverageSummarySafe(t *testing.T) {
|
||||
// This tests the bug fix for long package names causing negative Repeat count
|
||||
results := testResults{
|
||||
packages: []packageCoverage{
|
||||
{name: "forge.lthn.ai/core/go/pkg/short", coverage: 100, hasCov: true},
|
||||
{name: "forge.lthn.ai/core/go/pkg/a-very-very-very-very-very-long-package-name-that-might-cause-issues", coverage: 80, hasCov: true},
|
||||
},
|
||||
passed: 2,
|
||||
totalCov: 180,
|
||||
covCount: 2,
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
assert.NotPanics(t, func() {
|
||||
printCoverageSummary(results)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
package unifi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
uf "forge.lthn.ai/core/go-netops/unifi"
|
||||
)
|
||||
|
||||
// Clients command flags.
|
||||
var (
|
||||
clientsSite string
|
||||
clientsWired bool
|
||||
clientsWireless bool
|
||||
)
|
||||
|
||||
// addClientsCommand adds the 'clients' subcommand for listing connected clients.
|
||||
func addClientsCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "clients",
|
||||
Short: "List connected clients",
|
||||
Long: "List all connected clients on the UniFi network, optionally filtered by site or connection type.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runClients()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&clientsSite, "site", "", "Filter by site name")
|
||||
cmd.Flags().BoolVar(&clientsWired, "wired", false, "Show only wired clients")
|
||||
cmd.Flags().BoolVar(&clientsWireless, "wireless", false, "Show only wireless clients")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runClients() error {
|
||||
if clientsWired && clientsWireless {
|
||||
return log.E("unifi.clients", "conflicting flags", errors.New("--wired and --wireless cannot both be set"))
|
||||
}
|
||||
|
||||
client, err := uf.NewFromConfig("", "", "", "", nil)
|
||||
if err != nil {
|
||||
return log.E("unifi.clients", "failed to initialise client", err)
|
||||
}
|
||||
|
||||
clients, err := client.GetClients(uf.ClientFilter{
|
||||
Site: clientsSite,
|
||||
Wired: clientsWired,
|
||||
Wireless: clientsWireless,
|
||||
})
|
||||
if err != nil {
|
||||
return log.E("unifi.clients", "failed to fetch clients", err)
|
||||
}
|
||||
|
||||
if len(clients) == 0 {
|
||||
cli.Text("No clients found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := cli.NewTable("Name", "IP", "MAC", "Network", "Type", "Uptime")
|
||||
|
||||
for _, cl := range clients {
|
||||
name := cl.Name
|
||||
if name == "" {
|
||||
name = cl.Hostname
|
||||
}
|
||||
if name == "" {
|
||||
name = "(unknown)"
|
||||
}
|
||||
|
||||
connType := cl.Essid
|
||||
if cl.IsWired.Val {
|
||||
connType = "wired"
|
||||
}
|
||||
|
||||
table.AddRow(
|
||||
valueStyle.Render(name),
|
||||
cl.IP,
|
||||
dimStyle.Render(cl.Mac),
|
||||
cl.Network,
|
||||
dimStyle.Render(connType),
|
||||
dimStyle.Render(formatUptime(cl.Uptime.Int())),
|
||||
)
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %d clients\n\n", len(clients))
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatUptime converts seconds to a human-readable duration string.
|
||||
func formatUptime(seconds int) string {
|
||||
if seconds <= 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
days := seconds / 86400
|
||||
hours := (seconds % 86400) / 3600
|
||||
minutes := (seconds % 3600) / 60
|
||||
|
||||
switch {
|
||||
case days > 0:
|
||||
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||
case hours > 0:
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
default:
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
package unifi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
uf "forge.lthn.ai/core/go-netops/unifi"
|
||||
)
|
||||
|
||||
// Config command flags.
|
||||
var (
|
||||
configURL string
|
||||
configUser string
|
||||
configPass string
|
||||
configAPIKey string
|
||||
configInsecure bool
|
||||
configTest bool
|
||||
)
|
||||
|
||||
// addConfigCommand adds the 'config' subcommand for UniFi connection setup.
|
||||
func addConfigCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "config",
|
||||
Short: "Configure UniFi connection",
|
||||
Long: "Set the UniFi controller URL and credentials, or test the current connection.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runConfig(cmd)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&configURL, "url", "", "UniFi controller URL")
|
||||
cmd.Flags().StringVar(&configUser, "user", "", "UniFi username")
|
||||
cmd.Flags().StringVar(&configPass, "pass", "", "UniFi password")
|
||||
cmd.Flags().StringVar(&configAPIKey, "apikey", "", "UniFi API key")
|
||||
cmd.Flags().BoolVar(&configInsecure, "insecure", false, "Allow insecure TLS connections (e.g. self-signed certs)")
|
||||
cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runConfig(cmd *cli.Command) error {
|
||||
var insecure *bool
|
||||
if cmd.Flags().Changed("insecure") {
|
||||
insecure = &configInsecure
|
||||
}
|
||||
|
||||
// If setting values, save them first
|
||||
if configURL != "" || configUser != "" || configPass != "" || configAPIKey != "" || insecure != nil {
|
||||
if err := uf.SaveConfig(configURL, configUser, configPass, configAPIKey, insecure); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if configURL != "" {
|
||||
cli.Success(fmt.Sprintf("UniFi URL set to %s", configURL))
|
||||
}
|
||||
if configUser != "" {
|
||||
cli.Success("UniFi username saved")
|
||||
}
|
||||
if configPass != "" {
|
||||
cli.Success("UniFi password saved")
|
||||
}
|
||||
if configAPIKey != "" {
|
||||
cli.Success("UniFi API key saved")
|
||||
}
|
||||
if insecure != nil {
|
||||
if *insecure {
|
||||
cli.Warn("UniFi insecure mode enabled")
|
||||
} else {
|
||||
cli.Success("UniFi insecure mode disabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If testing, verify the connection
|
||||
if configTest {
|
||||
return runConfigTest(cmd)
|
||||
}
|
||||
|
||||
// If no flags, show current config
|
||||
if configURL == "" && configUser == "" && configPass == "" && configAPIKey == "" && !cmd.Flags().Changed("insecure") && !configTest {
|
||||
return showConfig()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func showConfig() error {
|
||||
url, user, pass, apikey, insecure, err := uf.ResolveConfig("", "", "", "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url))
|
||||
|
||||
if user != "" {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user))
|
||||
} else {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("User:"), warningStyle.Render("not set"))
|
||||
}
|
||||
|
||||
if pass != "" {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Pass:"), valueStyle.Render("****"))
|
||||
} else {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Pass:"), warningStyle.Render("not set"))
|
||||
}
|
||||
|
||||
if apikey != "" {
|
||||
masked := apikey
|
||||
if len(apikey) >= 8 {
|
||||
masked = apikey[:4] + "..." + apikey[len(apikey)-4:]
|
||||
}
|
||||
cli.Print(" %s %s\n", dimStyle.Render("API Key:"), valueStyle.Render(masked))
|
||||
} else {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("API Key:"), warningStyle.Render("not set"))
|
||||
}
|
||||
|
||||
if insecure {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Insecure:"), warningStyle.Render("enabled"))
|
||||
} else {
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Insecure:"), successStyle.Render("disabled"))
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigTest(cmd *cli.Command) error {
|
||||
var insecure *bool
|
||||
if cmd.Flags().Changed("insecure") {
|
||||
insecure = &configInsecure
|
||||
}
|
||||
|
||||
client, err := uf.NewFromConfig(configURL, configUser, configPass, configAPIKey, insecure)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sites, err := client.GetSites()
|
||||
if err != nil {
|
||||
cli.Error("Connection failed")
|
||||
return cli.WrapVerb(err, "connect to", "UniFi controller")
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Success(fmt.Sprintf("Connected to %s", client.URL()))
|
||||
cli.Print(" %s %s\n", dimStyle.Render("Sites:"), numberStyle.Render(fmt.Sprintf("%d", len(sites))))
|
||||
for _, s := range sites {
|
||||
cli.Print(" %s %s\n", valueStyle.Render(s.Name), dimStyle.Render(s.Desc))
|
||||
}
|
||||
cli.Blank()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
package unifi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
uf "forge.lthn.ai/core/go-netops/unifi"
|
||||
)
|
||||
|
||||
// Devices command flags.
|
||||
var (
|
||||
devicesSite string
|
||||
devicesType string
|
||||
)
|
||||
|
||||
// addDevicesCommand adds the 'devices' subcommand for listing infrastructure devices.
|
||||
func addDevicesCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "devices",
|
||||
Short: "List infrastructure devices",
|
||||
Long: "List all infrastructure devices (APs, switches, gateways) on the UniFi network.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runDevices()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&devicesSite, "site", "", "Filter by site name")
|
||||
cmd.Flags().StringVar(&devicesType, "type", "", "Filter by device type (uap, usw, usg, udm, uxg)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runDevices() error {
|
||||
client, err := uf.NewFromConfig("", "", "", "", nil)
|
||||
if err != nil {
|
||||
return log.E("unifi.devices", "failed to initialise client", err)
|
||||
}
|
||||
|
||||
devices, err := client.GetDeviceList(devicesSite, strings.ToLower(devicesType))
|
||||
if err != nil {
|
||||
return log.E("unifi.devices", "failed to fetch devices", err)
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
cli.Text("No devices found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := cli.NewTable("Name", "IP", "MAC", "Model", "Type", "Version", "Status")
|
||||
|
||||
for _, d := range devices {
|
||||
status := successStyle.Render("online")
|
||||
if d.Status != 1 {
|
||||
status = errorStyle.Render("offline")
|
||||
}
|
||||
|
||||
table.AddRow(
|
||||
valueStyle.Render(d.Name),
|
||||
d.IP,
|
||||
dimStyle.Render(d.Mac),
|
||||
d.Model,
|
||||
dimStyle.Render(d.Type),
|
||||
dimStyle.Render(d.Version),
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %d devices\n\n", len(devices))
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
package unifi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
uf "forge.lthn.ai/core/go-netops/unifi"
|
||||
)
|
||||
|
||||
// Networks command flags.
|
||||
var (
|
||||
networksSite string
|
||||
)
|
||||
|
||||
// addNetworksCommand adds the 'networks' subcommand for listing network segments.
|
||||
func addNetworksCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "networks",
|
||||
Short: "List network segments",
|
||||
Long: "List all network segments configured on the UniFi controller, showing VLANs, subnets, isolation, and DHCP.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runNetworks()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&networksSite, "site", "", "Site name (default: \"default\")")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runNetworks() error {
|
||||
client, err := uf.NewFromConfig("", "", "", "", nil)
|
||||
if err != nil {
|
||||
return log.E("unifi.networks", "failed to initialise client", err)
|
||||
}
|
||||
|
||||
networks, err := client.GetNetworks(networksSite)
|
||||
if err != nil {
|
||||
return log.E("unifi.networks", "failed to fetch networks", err)
|
||||
}
|
||||
|
||||
if len(networks) == 0 {
|
||||
cli.Text("No networks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Separate WANs, LANs, and VPNs
|
||||
var wans, lans, vpns []uf.NetworkConf
|
||||
for _, n := range networks {
|
||||
switch n.Purpose {
|
||||
case "wan":
|
||||
wans = append(wans, n)
|
||||
case "remote-user-vpn":
|
||||
vpns = append(vpns, n)
|
||||
default:
|
||||
lans = append(lans, n)
|
||||
}
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
|
||||
// WANs
|
||||
if len(wans) > 0 {
|
||||
cli.Print(" %s\n\n", infoStyle.Render("WAN Interfaces"))
|
||||
wanTable := cli.NewTable("Name", "Type", "Group", "Status")
|
||||
for _, w := range wans {
|
||||
status := successStyle.Render("enabled")
|
||||
if !w.Enabled {
|
||||
status = errorStyle.Render("disabled")
|
||||
}
|
||||
wanTable.AddRow(
|
||||
valueStyle.Render(w.Name),
|
||||
dimStyle.Render(w.WANType),
|
||||
dimStyle.Render(w.WANNetworkGroup),
|
||||
status,
|
||||
)
|
||||
}
|
||||
wanTable.Render()
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
// LANs
|
||||
if len(lans) > 0 {
|
||||
cli.Print(" %s\n\n", infoStyle.Render("LAN Networks"))
|
||||
lanTable := cli.NewTable("Name", "Subnet", "VLAN", "Isolated", "Internet", "DHCP", "mDNS")
|
||||
for _, n := range lans {
|
||||
vlan := dimStyle.Render("-")
|
||||
if n.VLANEnabled {
|
||||
vlan = numberStyle.Render(fmt.Sprintf("%d", n.VLAN))
|
||||
}
|
||||
|
||||
isolated := successStyle.Render("no")
|
||||
if n.NetworkIsolationEnabled {
|
||||
isolated = warningStyle.Render("yes")
|
||||
}
|
||||
|
||||
internet := successStyle.Render("yes")
|
||||
if !n.InternetAccessEnabled {
|
||||
internet = errorStyle.Render("no")
|
||||
}
|
||||
|
||||
dhcp := dimStyle.Render("off")
|
||||
if n.DHCPEnabled {
|
||||
dhcp = fmt.Sprintf("%s - %s", n.DHCPStart, n.DHCPStop)
|
||||
}
|
||||
|
||||
mdns := dimStyle.Render("off")
|
||||
if n.MDNSEnabled {
|
||||
mdns = successStyle.Render("on")
|
||||
}
|
||||
|
||||
lanTable.AddRow(
|
||||
valueStyle.Render(n.Name),
|
||||
n.IPSubnet,
|
||||
vlan,
|
||||
isolated,
|
||||
internet,
|
||||
dhcp,
|
||||
mdns,
|
||||
)
|
||||
}
|
||||
lanTable.Render()
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
// VPNs
|
||||
if len(vpns) > 0 {
|
||||
cli.Print(" %s\n\n", infoStyle.Render("VPN Networks"))
|
||||
vpnTable := cli.NewTable("Name", "Subnet", "Type")
|
||||
for _, v := range vpns {
|
||||
vpnTable.AddRow(
|
||||
valueStyle.Render(v.Name),
|
||||
v.IPSubnet,
|
||||
dimStyle.Render(v.VPNType),
|
||||
)
|
||||
}
|
||||
vpnTable.Render()
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
cli.Print(" %s\n\n", dimStyle.Render(fmt.Sprintf("%d networks total", len(networks))))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
package unifi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
uf "forge.lthn.ai/core/go-netops/unifi"
|
||||
)
|
||||
|
||||
// Routes command flags.
|
||||
var (
|
||||
routesSite string
|
||||
routesType string
|
||||
)
|
||||
|
||||
// addRoutesCommand adds the 'routes' subcommand for listing the gateway routing table.
|
||||
func addRoutesCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "routes",
|
||||
Short: "List gateway routing table",
|
||||
Long: "List the active routing table from the UniFi gateway, showing network segments and next-hop destinations.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runRoutes()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&routesSite, "site", "", "Site name (default: \"default\")")
|
||||
cmd.Flags().StringVar(&routesType, "type", "", "Filter by route type (static, connected, kernel, bgp, ospf)")
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runRoutes() error {
|
||||
client, err := uf.NewFromConfig("", "", "", "", nil)
|
||||
if err != nil {
|
||||
return log.E("unifi.routes", "failed to initialise client", err)
|
||||
}
|
||||
|
||||
routes, err := client.GetRoutes(routesSite)
|
||||
if err != nil {
|
||||
return log.E("unifi.routes", "failed to fetch routes", err)
|
||||
}
|
||||
|
||||
// Filter by type if requested
|
||||
if routesType != "" {
|
||||
var filtered []uf.Route
|
||||
for _, r := range routes {
|
||||
if uf.RouteTypeName(r.Type) == routesType || r.Type == routesType {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
routes = filtered
|
||||
}
|
||||
|
||||
if len(routes) == 0 {
|
||||
cli.Text("No routes found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := cli.NewTable("Network", "Next Hop", "Interface", "Type", "Distance", "FIB")
|
||||
|
||||
for _, r := range routes {
|
||||
typeName := uf.RouteTypeName(r.Type)
|
||||
|
||||
fib := dimStyle.Render("no")
|
||||
if r.Selected {
|
||||
fib = successStyle.Render("yes")
|
||||
}
|
||||
|
||||
table.AddRow(
|
||||
valueStyle.Render(r.Network),
|
||||
r.NextHop,
|
||||
dimStyle.Render(r.Interface),
|
||||
dimStyle.Render(typeName),
|
||||
fmt.Sprintf("%d", r.Distance),
|
||||
fib,
|
||||
)
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %d routes\n\n", len(routes))
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
package unifi
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
uf "forge.lthn.ai/core/go-netops/unifi"
|
||||
)
|
||||
|
||||
// addSitesCommand adds the 'sites' subcommand for listing UniFi sites.
|
||||
func addSitesCommand(parent *cli.Command) {
|
||||
cmd := &cli.Command{
|
||||
Use: "sites",
|
||||
Short: "List controller sites",
|
||||
Long: "List all sites configured on the UniFi controller.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runSites()
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(cmd)
|
||||
}
|
||||
|
||||
func runSites() error {
|
||||
client, err := uf.NewFromConfig("", "", "", "", nil)
|
||||
if err != nil {
|
||||
return log.E("unifi.sites", "failed to initialise client", err)
|
||||
}
|
||||
|
||||
sites, err := client.GetSites()
|
||||
if err != nil {
|
||||
return log.E("unifi.sites", "failed to fetch sites", err)
|
||||
}
|
||||
|
||||
if len(sites) == 0 {
|
||||
cli.Text("No sites found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := cli.NewTable("Name", "Description")
|
||||
|
||||
for _, s := range sites {
|
||||
table.AddRow(
|
||||
valueStyle.Render(s.Name),
|
||||
dimStyle.Render(s.Desc),
|
||||
)
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
cli.Print(" %d sites\n\n", len(sites))
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// Package unifi provides CLI commands for managing a UniFi network controller.
|
||||
//
|
||||
// Commands:
|
||||
// - config: Configure UniFi connection (URL, credentials)
|
||||
// - clients: List connected clients
|
||||
// - devices: List infrastructure devices
|
||||
// - sites: List controller sites
|
||||
// - networks: List network segments and VLANs
|
||||
// - routes: List gateway routing table
|
||||
package unifi
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddUniFiCommands)
|
||||
}
|
||||
|
||||
// Style aliases from shared package.
|
||||
var (
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
warningStyle = cli.WarningStyle
|
||||
dimStyle = cli.DimStyle
|
||||
valueStyle = cli.ValueStyle
|
||||
numberStyle = cli.NumberStyle
|
||||
infoStyle = cli.InfoStyle
|
||||
)
|
||||
|
||||
// AddUniFiCommands registers the 'unifi' command and all subcommands.
|
||||
func AddUniFiCommands(root *cli.Command) {
|
||||
unifiCmd := &cli.Command{
|
||||
Use: "unifi",
|
||||
Short: "UniFi network management",
|
||||
Long: "Manage sites, devices, and connected clients on your UniFi controller.",
|
||||
}
|
||||
root.AddCommand(unifiCmd)
|
||||
|
||||
addConfigCommand(unifiCmd)
|
||||
addClientsCommand(unifiCmd)
|
||||
addDevicesCommand(unifiCmd)
|
||||
addNetworksCommand(unifiCmd)
|
||||
addRoutesCommand(unifiCmd)
|
||||
addSitesCommand(unifiCmd)
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
// Package vm provides LinuxKit virtual machine management commands.
|
||||
//
|
||||
// Commands:
|
||||
// - run: Run a VM from image (.iso, .qcow2, .vmdk, .raw) or template
|
||||
// - ps: List running VMs
|
||||
// - stop: Stop a running VM
|
||||
// - logs: View VM logs
|
||||
// - exec: Execute command in VM via SSH
|
||||
// - templates: Manage LinuxKit templates (list, build)
|
||||
//
|
||||
// Uses qemu or hyperkit depending on system availability.
|
||||
// Templates are built from YAML definitions and can include variables.
|
||||
package vm
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
package vm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
goio "io"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-devops/container"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
runName string
|
||||
runDetach bool
|
||||
runMemory int
|
||||
runCPUs int
|
||||
runSSHPort int
|
||||
runTemplateName string
|
||||
runVarFlags []string
|
||||
)
|
||||
|
||||
// addVMRunCommand adds the 'run' command under vm.
|
||||
func addVMRunCommand(parent *cobra.Command) {
|
||||
runCmd := &cobra.Command{
|
||||
Use: "run [image]",
|
||||
Short: i18n.T("cmd.vm.run.short"),
|
||||
Long: i18n.T("cmd.vm.run.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts := container.RunOptions{
|
||||
Name: runName,
|
||||
Detach: runDetach,
|
||||
Memory: runMemory,
|
||||
CPUs: runCPUs,
|
||||
SSHPort: runSSHPort,
|
||||
}
|
||||
|
||||
// If template is specified, build and run from template
|
||||
if runTemplateName != "" {
|
||||
vars := ParseVarFlags(runVarFlags)
|
||||
return RunFromTemplate(runTemplateName, vars, opts)
|
||||
}
|
||||
|
||||
// Otherwise, require an image path
|
||||
if len(args) == 0 {
|
||||
return errors.New(i18n.T("cmd.vm.run.error.image_required"))
|
||||
}
|
||||
image := args[0]
|
||||
|
||||
return runContainer(image, runName, runDetach, runMemory, runCPUs, runSSHPort)
|
||||
},
|
||||
}
|
||||
|
||||
runCmd.Flags().StringVar(&runName, "name", "", i18n.T("cmd.vm.run.flag.name"))
|
||||
runCmd.Flags().BoolVarP(&runDetach, "detach", "d", false, i18n.T("cmd.vm.run.flag.detach"))
|
||||
runCmd.Flags().IntVar(&runMemory, "memory", 0, i18n.T("cmd.vm.run.flag.memory"))
|
||||
runCmd.Flags().IntVar(&runCPUs, "cpus", 0, i18n.T("cmd.vm.run.flag.cpus"))
|
||||
runCmd.Flags().IntVar(&runSSHPort, "ssh-port", 0, i18n.T("cmd.vm.run.flag.ssh_port"))
|
||||
runCmd.Flags().StringVar(&runTemplateName, "template", "", i18n.T("cmd.vm.run.flag.template"))
|
||||
runCmd.Flags().StringArrayVar(&runVarFlags, "var", nil, i18n.T("cmd.vm.run.flag.var"))
|
||||
|
||||
parent.AddCommand(runCmd)
|
||||
}
|
||||
|
||||
func runContainer(image, name string, detach bool, memory, cpus, sshPort int) error {
|
||||
manager, err := container.NewLinuxKitManager(io.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
||||
}
|
||||
|
||||
opts := container.RunOptions{
|
||||
Name: name,
|
||||
Detach: detach,
|
||||
Memory: memory,
|
||||
CPUs: cpus,
|
||||
SSHPort: sshPort,
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("image")), image)
|
||||
if name != "" {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.name")), name)
|
||||
}
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
c, err := manager.Run(ctx, image, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.run", "container")+": %w", err)
|
||||
}
|
||||
|
||||
if detach {
|
||||
fmt.Printf("%s %s\n", successStyle.Render(i18n.Label("started")), c.ID)
|
||||
fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
|
||||
fmt.Println()
|
||||
fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]interface{}{"ID": c.ID[:8]}))
|
||||
fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]interface{}{"ID": c.ID[:8]}))
|
||||
} else {
|
||||
fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var psAll bool
|
||||
|
||||
// addVMPsCommand adds the 'ps' command under vm.
|
||||
func addVMPsCommand(parent *cobra.Command) {
|
||||
psCmd := &cobra.Command{
|
||||
Use: "ps",
|
||||
Short: i18n.T("cmd.vm.ps.short"),
|
||||
Long: i18n.T("cmd.vm.ps.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return listContainers(psAll)
|
||||
},
|
||||
}
|
||||
|
||||
psCmd.Flags().BoolVarP(&psAll, "all", "a", false, i18n.T("cmd.vm.ps.flag.all"))
|
||||
|
||||
parent.AddCommand(psCmd)
|
||||
}
|
||||
|
||||
func listContainers(all bool) error {
|
||||
manager, err := container.NewLinuxKitManager(io.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
containers, err := manager.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.list", "containers")+": %w", err)
|
||||
}
|
||||
|
||||
// Filter if not showing all
|
||||
if !all {
|
||||
filtered := make([]*container.Container, 0)
|
||||
for _, c := range containers {
|
||||
if c.Status == container.StatusRunning {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
containers = filtered
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
if all {
|
||||
fmt.Println(i18n.T("cmd.vm.ps.no_containers"))
|
||||
} else {
|
||||
fmt.Println(i18n.T("cmd.vm.ps.no_running"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintln(w, i18n.T("cmd.vm.ps.header"))
|
||||
_, _ = fmt.Fprintln(w, "--\t----\t-----\t------\t-------\t---")
|
||||
|
||||
for _, c := range containers {
|
||||
// Shorten image path
|
||||
imageName := c.Image
|
||||
if len(imageName) > 30 {
|
||||
imageName = "..." + imageName[len(imageName)-27:]
|
||||
}
|
||||
|
||||
// Format duration
|
||||
duration := formatDuration(time.Since(c.StartedAt))
|
||||
|
||||
// Status with color
|
||||
status := string(c.Status)
|
||||
switch c.Status {
|
||||
case container.StatusRunning:
|
||||
status = successStyle.Render(status)
|
||||
case container.StatusStopped:
|
||||
status = dimStyle.Render(status)
|
||||
case container.StatusError:
|
||||
status = errorStyle.Render(status)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n",
|
||||
c.ID[:8], c.Name, imageName, status, duration, c.PID)
|
||||
}
|
||||
|
||||
_ = w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
|
||||
// addVMStopCommand adds the 'stop' command under vm.
|
||||
func addVMStopCommand(parent *cobra.Command) {
|
||||
stopCmd := &cobra.Command{
|
||||
Use: "stop <container-id>",
|
||||
Short: i18n.T("cmd.vm.stop.short"),
|
||||
Long: i18n.T("cmd.vm.stop.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New(i18n.T("cmd.vm.error.id_required"))
|
||||
}
|
||||
return stopContainer(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(stopCmd)
|
||||
}
|
||||
|
||||
func stopContainer(id string) error {
|
||||
manager, err := container.NewLinuxKitManager(io.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
||||
}
|
||||
|
||||
// Support partial ID matching
|
||||
fullID, err := resolveContainerID(manager, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.stop.stopping")), fullID[:8])
|
||||
|
||||
ctx := context.Background()
|
||||
if err := manager.Stop(ctx, fullID); err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.stop", "container")+": %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n", successStyle.Render(i18n.T("common.status.stopped")))
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveContainerID resolves a partial ID to a full ID.
|
||||
func resolveContainerID(manager *container.LinuxKitManager, partialID string) (string, error) {
|
||||
ctx := context.Background()
|
||||
containers, err := manager.List(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var matches []*container.Container
|
||||
for _, c := range containers {
|
||||
if strings.HasPrefix(c.ID, partialID) || strings.HasPrefix(c.Name, partialID) {
|
||||
matches = append(matches, c)
|
||||
}
|
||||
}
|
||||
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", errors.New(i18n.T("cmd.vm.error.no_match", map[string]interface{}{"ID": partialID}))
|
||||
case 1:
|
||||
return matches[0].ID, nil
|
||||
default:
|
||||
return "", errors.New(i18n.T("cmd.vm.error.multiple_match", map[string]interface{}{"ID": partialID}))
|
||||
}
|
||||
}
|
||||
|
||||
var logsFollow bool
|
||||
|
||||
// addVMLogsCommand adds the 'logs' command under vm.
|
||||
func addVMLogsCommand(parent *cobra.Command) {
|
||||
logsCmd := &cobra.Command{
|
||||
Use: "logs <container-id>",
|
||||
Short: i18n.T("cmd.vm.logs.short"),
|
||||
Long: i18n.T("cmd.vm.logs.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New(i18n.T("cmd.vm.error.id_required"))
|
||||
}
|
||||
return viewLogs(args[0], logsFollow)
|
||||
},
|
||||
}
|
||||
|
||||
logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, i18n.T("common.flag.follow"))
|
||||
|
||||
parent.AddCommand(logsCmd)
|
||||
}
|
||||
|
||||
func viewLogs(id string, follow bool) error {
|
||||
manager, err := container.NewLinuxKitManager(io.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
||||
}
|
||||
|
||||
fullID, err := resolveContainerID(manager, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
reader, err := manager.Logs(ctx, fullID, follow)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.get", "logs")+": %w", err)
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
_, err = goio.Copy(os.Stdout, reader)
|
||||
return err
|
||||
}
|
||||
|
||||
// addVMExecCommand adds the 'exec' command under vm.
|
||||
func addVMExecCommand(parent *cobra.Command) {
|
||||
execCmd := &cobra.Command{
|
||||
Use: "exec <container-id> <command> [args...]",
|
||||
Short: i18n.T("cmd.vm.exec.short"),
|
||||
Long: i18n.T("cmd.vm.exec.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 2 {
|
||||
return errors.New(i18n.T("cmd.vm.error.id_and_cmd_required"))
|
||||
}
|
||||
return execInContainer(args[0], args[1:])
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(execCmd)
|
||||
}
|
||||
|
||||
func execInContainer(id string, cmd []string) error {
|
||||
manager, err := container.NewLinuxKitManager(io.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.init", "container manager")+": %w", err)
|
||||
}
|
||||
|
||||
fullID, err := resolveContainerID(manager, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return manager.Exec(ctx, fullID, cmd)
|
||||
}
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
package vm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"forge.lthn.ai/core/go-devops/container"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// addVMTemplatesCommand adds the 'templates' command under vm.
|
||||
func addVMTemplatesCommand(parent *cobra.Command) {
|
||||
templatesCmd := &cobra.Command{
|
||||
Use: "templates",
|
||||
Short: i18n.T("cmd.vm.templates.short"),
|
||||
Long: i18n.T("cmd.vm.templates.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return listTemplates()
|
||||
},
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
addTemplatesShowCommand(templatesCmd)
|
||||
addTemplatesVarsCommand(templatesCmd)
|
||||
|
||||
parent.AddCommand(templatesCmd)
|
||||
}
|
||||
|
||||
// addTemplatesShowCommand adds the 'templates show' subcommand.
|
||||
func addTemplatesShowCommand(parent *cobra.Command) {
|
||||
showCmd := &cobra.Command{
|
||||
Use: "show <template-name>",
|
||||
Short: i18n.T("cmd.vm.templates.show.short"),
|
||||
Long: i18n.T("cmd.vm.templates.show.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New(i18n.T("cmd.vm.error.template_required"))
|
||||
}
|
||||
return showTemplate(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(showCmd)
|
||||
}
|
||||
|
||||
// addTemplatesVarsCommand adds the 'templates vars' subcommand.
|
||||
func addTemplatesVarsCommand(parent *cobra.Command) {
|
||||
varsCmd := &cobra.Command{
|
||||
Use: "vars <template-name>",
|
||||
Short: i18n.T("cmd.vm.templates.vars.short"),
|
||||
Long: i18n.T("cmd.vm.templates.vars.long"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New(i18n.T("cmd.vm.error.template_required"))
|
||||
}
|
||||
return showTemplateVars(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
parent.AddCommand(varsCmd)
|
||||
}
|
||||
|
||||
func listTemplates() error {
|
||||
templates := container.ListTemplates()
|
||||
|
||||
if len(templates) == 0 {
|
||||
fmt.Println(i18n.T("cmd.vm.templates.no_templates"))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.vm.templates.title")))
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintln(w, i18n.T("cmd.vm.templates.header"))
|
||||
_, _ = fmt.Fprintln(w, "----\t-----------")
|
||||
|
||||
for _, tmpl := range templates {
|
||||
desc := tmpl.Description
|
||||
if len(desc) > 60 {
|
||||
desc = desc[:57] + "..."
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\n", repoNameStyle.Render(tmpl.Name), desc)
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.show"), dimStyle.Render("core vm templates show <name>"))
|
||||
fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.vars"), dimStyle.Render("core vm templates vars <name>"))
|
||||
fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template <name> --var SSH_KEY=\"...\""))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func showTemplate(name string) error {
|
||||
content, err := container.GetTemplate(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
|
||||
fmt.Println(content)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func showTemplateVars(name string) error {
|
||||
content, err := container.GetTemplate(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
required, optional := container.ExtractVariables(content)
|
||||
|
||||
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(name))
|
||||
|
||||
if len(required) > 0 {
|
||||
fmt.Printf("%s\n", errorStyle.Render(i18n.T("cmd.vm.templates.vars.required")))
|
||||
for _, v := range required {
|
||||
fmt.Printf(" %s\n", varStyle.Render("${"+v+"}"))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(optional) > 0 {
|
||||
fmt.Printf("%s\n", successStyle.Render(i18n.T("cmd.vm.templates.vars.optional")))
|
||||
for v, def := range optional {
|
||||
fmt.Printf(" %s = %s\n",
|
||||
varStyle.Render("${"+v+"}"),
|
||||
defaultStyle.Render(def))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(required) == 0 && len(optional) == 0 {
|
||||
fmt.Println(i18n.T("cmd.vm.templates.vars.none"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunFromTemplate builds and runs a LinuxKit image from a template.
|
||||
func RunFromTemplate(templateName string, vars map[string]string, runOpts container.RunOptions) error {
|
||||
// Apply template with variables
|
||||
content, err := container.ApplyTemplate(templateName, vars)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("common.error.failed", map[string]any{"Action": "apply template"})+": %w", err)
|
||||
}
|
||||
|
||||
// Create a temporary directory for the build
|
||||
tmpDir, err := os.MkdirTemp("", "core-linuxkit-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("common.error.failed", map[string]any{"Action": "create temp directory"})+": %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
// Write the YAML file
|
||||
yamlPath := filepath.Join(tmpDir, templateName+".yml")
|
||||
if err := os.WriteFile(yamlPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf(i18n.T("common.error.failed", map[string]any{"Action": "write template"})+": %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.template")), repoNameStyle.Render(templateName))
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.building")), yamlPath)
|
||||
|
||||
// Build the image using linuxkit
|
||||
outputPath := filepath.Join(tmpDir, templateName)
|
||||
if err := buildLinuxKitImage(yamlPath, outputPath); err != nil {
|
||||
return fmt.Errorf(i18n.T("common.error.failed", map[string]any{"Action": "build image"})+": %w", err)
|
||||
}
|
||||
|
||||
// Find the built image (linuxkit creates .iso or other format)
|
||||
imagePath := findBuiltImage(outputPath)
|
||||
if imagePath == "" {
|
||||
return errors.New(i18n.T("cmd.vm.error.no_image_found"))
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("common.label.image")), imagePath)
|
||||
fmt.Println()
|
||||
|
||||
// Run the image
|
||||
manager, err := container.NewLinuxKitManager(io.Local)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("common.error.failed", map[string]any{"Action": "initialize container manager"})+": %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.hypervisor")), manager.Hypervisor().Name())
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
c, err := manager.Run(ctx, imagePath, runOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf(i18n.T("i18n.fail.run", "container")+": %w", err)
|
||||
}
|
||||
|
||||
if runOpts.Detach {
|
||||
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("common.label.started")), c.ID)
|
||||
fmt.Printf("%s %d\n", dimStyle.Render(i18n.T("cmd.vm.label.pid")), c.PID)
|
||||
fmt.Println()
|
||||
fmt.Println(i18n.T("cmd.vm.hint.view_logs", map[string]interface{}{"ID": c.ID[:8]}))
|
||||
fmt.Println(i18n.T("cmd.vm.hint.stop", map[string]interface{}{"ID": c.ID[:8]}))
|
||||
} else {
|
||||
fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.T("cmd.vm.label.container_stopped")), c.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildLinuxKitImage builds a LinuxKit image from a YAML file.
|
||||
func buildLinuxKitImage(yamlPath, outputPath string) error {
|
||||
// Check if linuxkit is available
|
||||
lkPath, err := lookupLinuxKit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build the image
|
||||
// linuxkit build --format iso-bios --name <output> <yaml>
|
||||
cmd := exec.Command(lkPath, "build",
|
||||
"--format", "iso-bios",
|
||||
"--name", outputPath,
|
||||
yamlPath)
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// findBuiltImage finds the built image file.
|
||||
func findBuiltImage(basePath string) string {
|
||||
// LinuxKit can create different formats
|
||||
extensions := []string{".iso", "-bios.iso", ".qcow2", ".raw", ".vmdk"}
|
||||
|
||||
for _, ext := range extensions {
|
||||
path := basePath + ext
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// Check directory for any image file
|
||||
dir := filepath.Dir(basePath)
|
||||
base := filepath.Base(basePath)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if strings.HasPrefix(name, base) {
|
||||
for _, ext := range []string{".iso", ".qcow2", ".raw", ".vmdk"} {
|
||||
if strings.HasSuffix(name, ext) {
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// lookupLinuxKit finds the linuxkit binary.
|
||||
func lookupLinuxKit() (string, error) {
|
||||
// Check PATH first
|
||||
if path, err := exec.LookPath("linuxkit"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Check common locations
|
||||
paths := []string{
|
||||
"/usr/local/bin/linuxkit",
|
||||
"/opt/homebrew/bin/linuxkit",
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New(i18n.T("cmd.vm.error.linuxkit_not_found"))
|
||||
}
|
||||
|
||||
// ParseVarFlags parses --var flags into a map.
|
||||
// Format: --var KEY=VALUE or --var KEY="VALUE"
|
||||
func ParseVarFlags(varFlags []string) map[string]string {
|
||||
vars := make(map[string]string)
|
||||
|
||||
for _, v := range varFlags {
|
||||
parts := strings.SplitN(v, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
// Remove surrounding quotes if present
|
||||
value = strings.Trim(value, "\"'")
|
||||
vars[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return vars
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
// Package vm provides LinuxKit VM management commands.
|
||||
package vm
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddVMCommands)
|
||||
}
|
||||
|
||||
// Style aliases from shared
|
||||
var (
|
||||
repoNameStyle = cli.RepoStyle
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
dimStyle = cli.DimStyle
|
||||
)
|
||||
|
||||
// VM-specific styles
|
||||
var (
|
||||
varStyle = cli.NewStyle().Foreground(cli.ColourAmber500)
|
||||
defaultStyle = cli.NewStyle().Foreground(cli.ColourGray500).Italic()
|
||||
)
|
||||
|
||||
// AddVMCommands adds container-related commands under 'vm' to the CLI.
|
||||
func AddVMCommands(root *cobra.Command) {
|
||||
vmCmd := &cobra.Command{
|
||||
Use: "vm",
|
||||
Short: i18n.T("cmd.vm.short"),
|
||||
Long: i18n.T("cmd.vm.long"),
|
||||
}
|
||||
|
||||
root.AddCommand(vmCmd)
|
||||
addVMRunCommand(vmCmd)
|
||||
addVMPsCommand(vmCmd)
|
||||
addVMStopCommand(vmCmd)
|
||||
addVMLogsCommand(vmCmd)
|
||||
addVMExecCommand(vmCmd)
|
||||
addVMTemplatesCommand(vmCmd)
|
||||
}
|
||||
19
go.mod
19
go.mod
|
|
@ -5,27 +5,24 @@ go 1.25.5
|
|||
require (
|
||||
forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f
|
||||
forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3
|
||||
forge.lthn.ai/core/go-ai v0.0.0-20260221192232-bc9597c19153
|
||||
forge.lthn.ai/core/go-api v0.0.0-20260221015744-0d3479839dc5
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649
|
||||
forge.lthn.ai/core/go-devops v0.0.0-20260221192100-4b5739fbd7ac
|
||||
forge.lthn.ai/core/go-ai v0.0.0-20260221193804-c3de6c4935d5
|
||||
forge.lthn.ai/core/go-api v0.0.0-20260221193814-9d35070573b8
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2
|
||||
forge.lthn.ai/core/go-devops v0.0.0-20260221193818-400d8a76901e
|
||||
forge.lthn.ai/core/go-inference v0.0.0-20260220151119-1576f744d105 // indirect
|
||||
forge.lthn.ai/core/go-ml v0.0.0-20260221191458-812c926dac42
|
||||
forge.lthn.ai/core/go-mlx v0.0.0-20260221191404-2292557fd65f // indirect
|
||||
forge.lthn.ai/core/go-netops v0.0.0-20260221192152-565b16a848ae
|
||||
forge.lthn.ai/core/go-rag v0.0.0-20260221191926-4c741992dc78
|
||||
forge.lthn.ai/core/go-scm v0.0.0-20260221192735-5bfafcd6fc87
|
||||
forge.lthn.ai/core/go-netops v0.0.0-20260221193827-b865250390a4
|
||||
forge.lthn.ai/core/go-rag v0.0.0-20260221193811-2a8d8b0820b5
|
||||
forge.lthn.ai/core/go-scm v0.0.0-20260221193836-7eb28df79d0b
|
||||
forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.23.2
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
|
||||
github.com/Snider/Borg v0.2.0
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/term v0.40.0
|
||||
|
|
@ -36,6 +33,7 @@ require (
|
|||
require (
|
||||
aead.dev/minisign v0.3.0 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/99designs/gqlgen v0.17.87 // indirect
|
||||
|
|
@ -204,6 +202,7 @@ require (
|
|||
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-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
|
|
|
|||
30
go.sum
30
go.sum
|
|
@ -3,8 +3,6 @@ aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
|
|||
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
|
||||
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=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
|
|
@ -13,26 +11,26 @@ forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f h1:CcSh/FFY93K5m0vADHLx
|
|||
forge.lthn.ai/core/go v0.0.0-20260221191103-d091fa62023f/go.mod h1:WCPJVEZm/6mTcJimHV0uX8ZhnKEF3dN0rQp13ByaSPg=
|
||||
forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3 h1:6H3hjqHY0loJJe9iCofFzw6x5JDIbi6JNSL0oW2TKFE=
|
||||
forge.lthn.ai/core/go-agentic v0.0.0-20260221191948-ad0cf5c932a3/go.mod h1:2WCSLupRyAeSpmFWM5+OPG0/wa4KMQCO8gA0hM9cUq8=
|
||||
forge.lthn.ai/core/go-ai v0.0.0-20260221192232-bc9597c19153 h1:11XJI5RPm38l664KC9acRZz2gA+RLpmxCdg5JorimoM=
|
||||
forge.lthn.ai/core/go-ai v0.0.0-20260221192232-bc9597c19153/go.mod h1:GdcXgm3jwvh4AVxrlCa0Zbw4vASeNV8JSAXfftCJVRc=
|
||||
forge.lthn.ai/core/go-api v0.0.0-20260221015744-0d3479839dc5 h1:60reee4fmT4USZqEd6dyCTXsTj47eOOEc6Pp0HHJbd0=
|
||||
forge.lthn.ai/core/go-api v0.0.0-20260221015744-0d3479839dc5/go.mod h1:f0hPLX+GZT/ME8Tb7c8wVDlfLqnpOKRwf2k5lpJq87g=
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649 h1:Rs3bfSU8u1wkzYeL21asL7IcJIBVwOhtRidcEVj/PkA=
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649/go.mod h1:RS+sz5lChrbc1AEmzzOULsTiMv3bwcwVtwbZi+c/Yjk=
|
||||
forge.lthn.ai/core/go-devops v0.0.0-20260221192100-4b5739fbd7ac h1:agYaMGTUw0n/vPrv0i8mTxbKt5NItDcsXhCKQHoivy8=
|
||||
forge.lthn.ai/core/go-devops v0.0.0-20260221192100-4b5739fbd7ac/go.mod h1:FSp7+jfV3QXyPzL1C8XZm6W57vjT8cbWly8vf/bPJEg=
|
||||
forge.lthn.ai/core/go-ai v0.0.0-20260221193804-c3de6c4935d5 h1:ppJV+0PsTjUALPuA0eZx8mnvjDAfCwR5ASBcnPhCqjI=
|
||||
forge.lthn.ai/core/go-ai v0.0.0-20260221193804-c3de6c4935d5/go.mod h1:GMURVEDR3TkzmgSML8//CHyTZ/WcuzRR0IGNuAJSqKA=
|
||||
forge.lthn.ai/core/go-api v0.0.0-20260221193814-9d35070573b8 h1:a0+HIcWhbvXqQh8h+UY2oOxks9UHBHFNRHPuSUBUkoY=
|
||||
forge.lthn.ai/core/go-api v0.0.0-20260221193814-9d35070573b8/go.mod h1:7HmXnlMJGEmHsEe/l/o96xLmLdQj5bIng6c2mSur2N8=
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2 h1:2eXqQXF+1AyitPJox9Yjewb6w8fO0JHFw7gPqk8WqIM=
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221193816-fde12e1539b2/go.mod h1:o4vkJgoT9u+r7DR42LIJHW6L5vMS3Au8gaaCA5Cved0=
|
||||
forge.lthn.ai/core/go-devops v0.0.0-20260221193818-400d8a76901e h1:ya3vWejLAb9+66FesDYakBi1lTmbHPA/gex6hgZ4zoo=
|
||||
forge.lthn.ai/core/go-devops v0.0.0-20260221193818-400d8a76901e/go.mod h1:FSp7+jfV3QXyPzL1C8XZm6W57vjT8cbWly8vf/bPJEg=
|
||||
forge.lthn.ai/core/go-inference v0.0.0-20260220151119-1576f744d105 h1:CVUVxp1BfUI8wmlEUW0Nay8w4hADR54nqBmeF+KK2Ac=
|
||||
forge.lthn.ai/core/go-inference v0.0.0-20260220151119-1576f744d105/go.mod h1:hmLtynfw1yo0ByuX3pslLZMgCdqJH2r+2+wGJDhmmi0=
|
||||
forge.lthn.ai/core/go-ml v0.0.0-20260221191458-812c926dac42 h1:rxhnHgWVGnQ93/mhUyLxIw/Q2l80njiGfNvv0kKISb0=
|
||||
forge.lthn.ai/core/go-ml v0.0.0-20260221191458-812c926dac42/go.mod h1:lmhzv04VCP41ym7Wuhck+T1HeC5PoLtfOqXe8fW26Hc=
|
||||
forge.lthn.ai/core/go-mlx v0.0.0-20260221191404-2292557fd65f h1:dlb6hFFhxfnJvD1ZYoQVsxD9NM4CV+sXkjHa6kBGzeE=
|
||||
forge.lthn.ai/core/go-mlx v0.0.0-20260221191404-2292557fd65f/go.mod h1:QHspfOk9MgbuG6Wb4m+RzQyCMibtoQNZw+hUs4yclOA=
|
||||
forge.lthn.ai/core/go-netops v0.0.0-20260221192152-565b16a848ae h1:1WPKohhwPCEPnKZPx80AqJS306QkKemGU0W4TKUgvqA=
|
||||
forge.lthn.ai/core/go-netops v0.0.0-20260221192152-565b16a848ae/go.mod h1:YljW66VyXrWX5/kfmDlFaeFRewXA2/ss9F6shSTr5Rs=
|
||||
forge.lthn.ai/core/go-rag v0.0.0-20260221191926-4c741992dc78 h1:M7ftoQ3AB87W/h4cELK+dxetzLoQi68KwnK2JhkSA8k=
|
||||
forge.lthn.ai/core/go-rag v0.0.0-20260221191926-4c741992dc78/go.mod h1:f0WQYSeg3Oc7gCHTLUL0aCIzK1fS2mgMBDnBzjKgOzQ=
|
||||
forge.lthn.ai/core/go-scm v0.0.0-20260221192735-5bfafcd6fc87 h1:1rkrRCVOq4hjKGkXxPmyBDVjxs82VV84ED/WnrYjptE=
|
||||
forge.lthn.ai/core/go-scm v0.0.0-20260221192735-5bfafcd6fc87/go.mod h1:lK2RacccYr9Uvntbhx9sPupXlI2IvNufeil4mXVpdEM=
|
||||
forge.lthn.ai/core/go-netops v0.0.0-20260221193827-b865250390a4 h1:Fgxkkli7B06qZFAxXFOBiuDQ6Z19DH3/9r8Ppimnp/Y=
|
||||
forge.lthn.ai/core/go-netops v0.0.0-20260221193827-b865250390a4/go.mod h1:1s/NQwXUfbvg0HQRLAda26LGlvAReTVFQcUJGDhY/Ng=
|
||||
forge.lthn.ai/core/go-rag v0.0.0-20260221193811-2a8d8b0820b5 h1:AD7a3IY0W/LaxmPRnPkxuxbWYw11/jsm2zELG1FfSNY=
|
||||
forge.lthn.ai/core/go-rag v0.0.0-20260221193811-2a8d8b0820b5/go.mod h1:s5OWHz87LELq2UKk93cBFJA9pydLoytHN1pPbgX0ShE=
|
||||
forge.lthn.ai/core/go-scm v0.0.0-20260221193836-7eb28df79d0b h1:GrL3ApTDLCdbPusNjv6rI9qNjqQ+srX1ilwg8I5M0UA=
|
||||
forge.lthn.ai/core/go-scm v0.0.0-20260221193836-7eb28df79d0b/go.mod h1:rCTonaMb6UMkyWd/34jg3zp4UXUl85jcb5vj5K+UG0I=
|
||||
forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf h1:EDKI+OM0M+l4+VclG5XuUDoYAM8yu8uleFYReeEYwHY=
|
||||
forge.lthn.ai/core/go-store v0.1.1-0.20260220151120-0284110ccadf/go.mod h1:FpUlLEX/ebyoxpk96F7ktr0vYvmFtC5Rpi9fi88UVqw=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
|
|
|
|||
31
main.go
31
main.go
|
|
@ -3,40 +3,41 @@ package main
|
|||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
// Commands via self-registration
|
||||
// Commands via self-registration (local to CLI)
|
||||
_ "forge.lthn.ai/core/cli/cmd/ai"
|
||||
_ "forge.lthn.ai/core/cli/cmd/api"
|
||||
_ "forge.lthn.ai/core/cli/cmd/collect"
|
||||
_ "forge.lthn.ai/core/cli/cmd/config"
|
||||
_ "forge.lthn.ai/core/cli/cmd/crypt"
|
||||
_ "forge.lthn.ai/core/cli/cmd/daemon"
|
||||
_ "forge.lthn.ai/core/cli/cmd/deploy"
|
||||
_ "forge.lthn.ai/core/cli/cmd/dev"
|
||||
_ "forge.lthn.ai/core/cli/cmd/docs"
|
||||
_ "forge.lthn.ai/core/cli/cmd/doctor"
|
||||
_ "forge.lthn.ai/core/cli/cmd/forge"
|
||||
_ "forge.lthn.ai/core/cli/cmd/gitcmd"
|
||||
_ "forge.lthn.ai/core/cli/cmd/go"
|
||||
_ "forge.lthn.ai/core/cli/cmd/help"
|
||||
_ "forge.lthn.ai/core/cli/cmd/lab"
|
||||
_ "forge.lthn.ai/core/cli/cmd/mcpcmd"
|
||||
_ "forge.lthn.ai/core/go-ml/cmd"
|
||||
_ "forge.lthn.ai/core/cli/cmd/module"
|
||||
_ "forge.lthn.ai/core/cli/cmd/monitor"
|
||||
_ "forge.lthn.ai/core/cli/cmd/pkgcmd"
|
||||
_ "forge.lthn.ai/core/cli/cmd/plugin"
|
||||
_ "forge.lthn.ai/core/cli/cmd/prod"
|
||||
_ "forge.lthn.ai/core/cli/cmd/qa"
|
||||
_ "forge.lthn.ai/core/cli/cmd/rag"
|
||||
_ "forge.lthn.ai/core/cli/cmd/security"
|
||||
_ "forge.lthn.ai/core/cli/cmd/session"
|
||||
_ "forge.lthn.ai/core/cli/cmd/setup"
|
||||
_ "forge.lthn.ai/core/cli/cmd/test"
|
||||
_ "forge.lthn.ai/core/cli/cmd/unifi"
|
||||
_ "forge.lthn.ai/core/cli/cmd/updater"
|
||||
_ "forge.lthn.ai/core/cli/cmd/vm"
|
||||
_ "forge.lthn.ai/core/cli/cmd/workspace"
|
||||
|
||||
// Commands via self-registration (external repos)
|
||||
_ "forge.lthn.ai/core/go-ai/cmd/daemon"
|
||||
_ "forge.lthn.ai/core/go-ai/cmd/mcpcmd"
|
||||
_ "forge.lthn.ai/core/go-ai/cmd/security"
|
||||
_ "forge.lthn.ai/core/go-api/cmd/api"
|
||||
_ "forge.lthn.ai/core/go-crypt/cmd/crypt"
|
||||
_ "forge.lthn.ai/core/go-crypt/cmd/testcmd"
|
||||
_ "forge.lthn.ai/core/go-devops/build/buildcmd"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/deploy"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/prod"
|
||||
_ "forge.lthn.ai/core/go-devops/cmd/vm"
|
||||
_ "forge.lthn.ai/core/go-ml/cmd"
|
||||
_ "forge.lthn.ai/core/go-netops/cmd/unifi"
|
||||
_ "forge.lthn.ai/core/go-scm/cmd/collect"
|
||||
_ "forge.lthn.ai/core/go-scm/cmd/forge"
|
||||
|
||||
// Variant repos (optional — comment out to exclude)
|
||||
// _ "forge.lthn.ai/core/php"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue