From cce9adc043aab669248f823e8fd37c39294dc466 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 19:42:16 +0000 Subject: [PATCH] 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 --- cmd/ai/cmd_commands.go | 2 +- cmd/api/cmd.go | 18 -- cmd/api/cmd_sdk.go | 87 -------- cmd/api/cmd_spec.go | 54 ----- cmd/api/cmd_test.go | 101 --------- cmd/collect/cmd.go | 112 ---------- cmd/collect/cmd_bitcointalk.go | 64 ------ cmd/collect/cmd_dispatch.go | 130 ----------- cmd/collect/cmd_excavate.go | 103 --------- cmd/collect/cmd_github.go | 78 ------- cmd/collect/cmd_market.go | 58 ----- cmd/collect/cmd_papers.go | 63 ------ cmd/collect/cmd_process.go | 48 ---- cmd/crypt/cmd.go | 22 -- cmd/crypt/cmd_checksum.go | 61 ----- cmd/crypt/cmd_encrypt.go | 115 ---------- cmd/crypt/cmd_hash.go | 74 ------ cmd/crypt/cmd_keygen.go | 55 ----- cmd/daemon/cmd.go | 397 --------------------------------- cmd/deploy/cmd_ansible.go | 312 -------------------------- cmd/deploy/cmd_commands.go | 15 -- cmd/deploy/cmd_deploy.go | 280 ----------------------- cmd/forge/cmd_auth.go | 86 ------- cmd/forge/cmd_config.go | 106 --------- cmd/forge/cmd_forge.go | 53 ----- cmd/forge/cmd_issues.go | 200 ----------------- cmd/forge/cmd_labels.go | 120 ---------- cmd/forge/cmd_migrate.go | 121 ---------- cmd/forge/cmd_orgs.go | 66 ------ cmd/forge/cmd_prs.go | 98 -------- cmd/forge/cmd_repos.go | 94 -------- cmd/forge/cmd_status.go | 63 ------ cmd/forge/cmd_sync.go | 334 --------------------------- cmd/forge/helpers.go | 33 --- cmd/gitea/cmd_config.go | 106 --------- cmd/gitea/cmd_gitea.go | 47 ---- cmd/gitea/cmd_issues.go | 133 ----------- cmd/gitea/cmd_mirror.go | 92 -------- cmd/gitea/cmd_prs.go | 98 -------- cmd/gitea/cmd_repos.go | 125 ----------- cmd/gitea/cmd_sync.go | 353 ----------------------------- cmd/mcpcmd/cmd_mcp.go | 96 -------- cmd/prod/cmd_commands.go | 15 -- cmd/prod/cmd_dns.go | 129 ----------- cmd/prod/cmd_lb.go | 113 ---------- cmd/prod/cmd_prod.go | 35 --- cmd/prod/cmd_setup.go | 284 ----------------------- cmd/prod/cmd_ssh.go | 64 ------ cmd/prod/cmd_status.go | 325 --------------------------- cmd/rag/cmd_collections.go | 86 ------- cmd/rag/cmd_commands.go | 21 -- cmd/rag/cmd_ingest.go | 117 ---------- cmd/rag/cmd_query.go | 81 ------- cmd/rag/cmd_rag.go | 84 ------- cmd/security/cmd.go | 7 - cmd/security/cmd_alerts.go | 340 ---------------------------- cmd/security/cmd_deps.go | 210 ----------------- cmd/security/cmd_jobs.go | 229 ------------------- cmd/security/cmd_scan.go | 254 --------------------- cmd/security/cmd_secrets.go | 191 ---------------- cmd/security/cmd_security.go | 256 --------------------- cmd/test/cmd_commands.go | 18 -- cmd/test/cmd_main.go | 58 ----- cmd/test/cmd_output.go | 211 ------------------ cmd/test/cmd_runner.go | 145 ------------ cmd/test/output_test.go | 52 ----- cmd/unifi/cmd_clients.go | 112 ---------- cmd/unifi/cmd_config.go | 155 ------------- cmd/unifi/cmd_devices.go | 74 ------ cmd/unifi/cmd_networks.go | 145 ------------ cmd/unifi/cmd_routes.go | 86 ------- cmd/unifi/cmd_sites.go | 53 ----- cmd/unifi/cmd_unifi.go | 46 ---- cmd/vm/cmd_commands.go | 13 -- cmd/vm/cmd_container.go | 345 ---------------------------- cmd/vm/cmd_templates.go | 311 -------------------------- cmd/vm/cmd_vm.go | 43 ---- go.mod | 19 +- go.sum | 30 ++- main.go | 31 +-- 80 files changed, 40 insertions(+), 9421 deletions(-) delete mode 100644 cmd/api/cmd.go delete mode 100644 cmd/api/cmd_sdk.go delete mode 100644 cmd/api/cmd_spec.go delete mode 100644 cmd/api/cmd_test.go delete mode 100644 cmd/collect/cmd.go delete mode 100644 cmd/collect/cmd_bitcointalk.go delete mode 100644 cmd/collect/cmd_dispatch.go delete mode 100644 cmd/collect/cmd_excavate.go delete mode 100644 cmd/collect/cmd_github.go delete mode 100644 cmd/collect/cmd_market.go delete mode 100644 cmd/collect/cmd_papers.go delete mode 100644 cmd/collect/cmd_process.go delete mode 100644 cmd/crypt/cmd.go delete mode 100644 cmd/crypt/cmd_checksum.go delete mode 100644 cmd/crypt/cmd_encrypt.go delete mode 100644 cmd/crypt/cmd_hash.go delete mode 100644 cmd/crypt/cmd_keygen.go delete mode 100644 cmd/daemon/cmd.go delete mode 100644 cmd/deploy/cmd_ansible.go delete mode 100644 cmd/deploy/cmd_commands.go delete mode 100644 cmd/deploy/cmd_deploy.go delete mode 100644 cmd/forge/cmd_auth.go delete mode 100644 cmd/forge/cmd_config.go delete mode 100644 cmd/forge/cmd_forge.go delete mode 100644 cmd/forge/cmd_issues.go delete mode 100644 cmd/forge/cmd_labels.go delete mode 100644 cmd/forge/cmd_migrate.go delete mode 100644 cmd/forge/cmd_orgs.go delete mode 100644 cmd/forge/cmd_prs.go delete mode 100644 cmd/forge/cmd_repos.go delete mode 100644 cmd/forge/cmd_status.go delete mode 100644 cmd/forge/cmd_sync.go delete mode 100644 cmd/forge/helpers.go delete mode 100644 cmd/gitea/cmd_config.go delete mode 100644 cmd/gitea/cmd_gitea.go delete mode 100644 cmd/gitea/cmd_issues.go delete mode 100644 cmd/gitea/cmd_mirror.go delete mode 100644 cmd/gitea/cmd_prs.go delete mode 100644 cmd/gitea/cmd_repos.go delete mode 100644 cmd/gitea/cmd_sync.go delete mode 100644 cmd/mcpcmd/cmd_mcp.go delete mode 100644 cmd/prod/cmd_commands.go delete mode 100644 cmd/prod/cmd_dns.go delete mode 100644 cmd/prod/cmd_lb.go delete mode 100644 cmd/prod/cmd_prod.go delete mode 100644 cmd/prod/cmd_setup.go delete mode 100644 cmd/prod/cmd_ssh.go delete mode 100644 cmd/prod/cmd_status.go delete mode 100644 cmd/rag/cmd_collections.go delete mode 100644 cmd/rag/cmd_commands.go delete mode 100644 cmd/rag/cmd_ingest.go delete mode 100644 cmd/rag/cmd_query.go delete mode 100644 cmd/rag/cmd_rag.go delete mode 100644 cmd/security/cmd.go delete mode 100644 cmd/security/cmd_alerts.go delete mode 100644 cmd/security/cmd_deps.go delete mode 100644 cmd/security/cmd_jobs.go delete mode 100644 cmd/security/cmd_scan.go delete mode 100644 cmd/security/cmd_secrets.go delete mode 100644 cmd/security/cmd_security.go delete mode 100644 cmd/test/cmd_commands.go delete mode 100644 cmd/test/cmd_main.go delete mode 100644 cmd/test/cmd_output.go delete mode 100644 cmd/test/cmd_runner.go delete mode 100644 cmd/test/output_test.go delete mode 100644 cmd/unifi/cmd_clients.go delete mode 100644 cmd/unifi/cmd_config.go delete mode 100644 cmd/unifi/cmd_devices.go delete mode 100644 cmd/unifi/cmd_networks.go delete mode 100644 cmd/unifi/cmd_routes.go delete mode 100644 cmd/unifi/cmd_sites.go delete mode 100644 cmd/unifi/cmd_unifi.go delete mode 100644 cmd/vm/cmd_commands.go delete mode 100644 cmd/vm/cmd_container.go delete mode 100644 cmd/vm/cmd_templates.go delete mode 100644 cmd/vm/cmd_vm.go diff --git a/cmd/ai/cmd_commands.go b/cmd/ai/cmd_commands.go index 55e3ff65..25c224bf 100644 --- a/cmd/ai/cmd_commands.go +++ b/cmd/ai/cmd_commands.go @@ -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" ) diff --git a/cmd/api/cmd.go b/cmd/api/cmd.go deleted file mode 100644 index 8c3dfc10..00000000 --- a/cmd/api/cmd.go +++ /dev/null @@ -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) -} diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go deleted file mode 100644 index 4a5c27f0..00000000 --- a/cmd/api/cmd_sdk.go +++ /dev/null @@ -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) -} diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go deleted file mode 100644 index 39c1c0e6..00000000 --- a/cmd/api/cmd_spec.go +++ /dev/null @@ -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) -} diff --git a/cmd/api/cmd_test.go b/cmd/api/cmd_test.go deleted file mode 100644 index ec7da674..00000000 --- a/cmd/api/cmd_test.go +++ /dev/null @@ -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") - } -} diff --git a/cmd/collect/cmd.go b/cmd/collect/cmd.go deleted file mode 100644 index e1a81935..00000000 --- a/cmd/collect/cmd.go +++ /dev/null @@ -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)) - } - } -} diff --git a/cmd/collect/cmd_bitcointalk.go b/cmd/collect/cmd_bitcointalk.go deleted file mode 100644 index 92d9d4c2..00000000 --- a/cmd/collect/cmd_bitcointalk.go +++ /dev/null @@ -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 ", - 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 -} diff --git a/cmd/collect/cmd_dispatch.go b/cmd/collect/cmd_dispatch.go deleted file mode 100644 index 09fafe61..00000000 --- a/cmd/collect/cmd_dispatch.go +++ /dev/null @@ -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 ", - 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 ", - 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 -} diff --git a/cmd/collect/cmd_excavate.go b/cmd/collect/cmd_excavate.go deleted file mode 100644 index 5aa4fbdd..00000000 --- a/cmd/collect/cmd_excavate.go +++ /dev/null @@ -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 ", - 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}, - } - } -} diff --git a/cmd/collect/cmd_github.go b/cmd/collect/cmd_github.go deleted file mode 100644 index c71980f4..00000000 --- a/cmd/collect/cmd_github.go +++ /dev/null @@ -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 ", - 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 -} diff --git a/cmd/collect/cmd_market.go b/cmd/collect/cmd_market.go deleted file mode 100644 index 57b874ae..00000000 --- a/cmd/collect/cmd_market.go +++ /dev/null @@ -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 ", - 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 -} diff --git a/cmd/collect/cmd_papers.go b/cmd/collect/cmd_papers.go deleted file mode 100644 index 547181cd..00000000 --- a/cmd/collect/cmd_papers.go +++ /dev/null @@ -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 -} diff --git a/cmd/collect/cmd_process.go b/cmd/collect/cmd_process.go deleted file mode 100644 index c3eb6b4c..00000000 --- a/cmd/collect/cmd_process.go +++ /dev/null @@ -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 ", - 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 -} diff --git a/cmd/crypt/cmd.go b/cmd/crypt/cmd.go deleted file mode 100644 index 36a4659d..00000000 --- a/cmd/crypt/cmd.go +++ /dev/null @@ -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) -} diff --git a/cmd/crypt/cmd_checksum.go b/cmd/crypt/cmd_checksum.go deleted file mode 100644 index 0d726ade..00000000 --- a/cmd/crypt/cmd_checksum.go +++ /dev/null @@ -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 -} diff --git a/cmd/crypt/cmd_encrypt.go b/cmd/crypt/cmd_encrypt.go deleted file mode 100644 index 7205a31c..00000000 --- a/cmd/crypt/cmd_encrypt.go +++ /dev/null @@ -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 -} diff --git a/cmd/crypt/cmd_hash.go b/cmd/crypt/cmd_hash.go deleted file mode 100644 index fd6ef3ca..00000000 --- a/cmd/crypt/cmd_hash.go +++ /dev/null @@ -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") -} diff --git a/cmd/crypt/cmd_keygen.go b/cmd/crypt/cmd_keygen.go deleted file mode 100644 index af3f28d5..00000000 --- a/cmd/crypt/cmd_keygen.go +++ /dev/null @@ -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 -} diff --git a/cmd/daemon/cmd.go b/cmd/daemon/cmd.go deleted file mode 100644 index 45a3f1b4..00000000 --- a/cmd/daemon/cmd.go +++ /dev/null @@ -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 -} diff --git a/cmd/deploy/cmd_ansible.go b/cmd/deploy/cmd_ansible.go deleted file mode 100644 index 27ad9af0..00000000 --- a/cmd/deploy/cmd_ansible.go +++ /dev/null @@ -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 ", - 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 ", - 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 -} diff --git a/cmd/deploy/cmd_commands.go b/cmd/deploy/cmd_commands.go deleted file mode 100644 index f43150c1..00000000 --- a/cmd/deploy/cmd_commands.go +++ /dev/null @@ -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) -} diff --git a/cmd/deploy/cmd_deploy.go b/cmd/deploy/cmd_deploy.go deleted file mode 100644 index 8ec43d9b..00000000 --- a/cmd/deploy/cmd_deploy.go +++ /dev/null @@ -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 [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) -} diff --git a/cmd/forge/cmd_auth.go b/cmd/forge/cmd_auth.go deleted file mode 100644 index c488a3fa..00000000 --- a/cmd/forge/cmd_auth.go +++ /dev/null @@ -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 /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 -} diff --git a/cmd/forge/cmd_config.go b/cmd/forge/cmd_config.go deleted file mode 100644 index 85749d2f..00000000 --- a/cmd/forge/cmd_config.go +++ /dev/null @@ -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 -} diff --git a/cmd/forge/cmd_forge.go b/cmd/forge/cmd_forge.go deleted file mode 100644 index 246729e2..00000000 --- a/cmd/forge/cmd_forge.go +++ /dev/null @@ -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) -} diff --git a/cmd/forge/cmd_issues.go b/cmd/forge/cmd_issues.go deleted file mode 100644 index 6b40644d..00000000 --- a/cmd/forge/cmd_issues.go +++ /dev/null @@ -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) -} diff --git a/cmd/forge/cmd_labels.go b/cmd/forge/cmd_labels.go deleted file mode 100644 index 5ad421a6..00000000 --- a/cmd/forge/cmd_labels.go +++ /dev/null @@ -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 ", - 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 -} diff --git a/cmd/forge/cmd_migrate.go b/cmd/forge/cmd_migrate.go deleted file mode 100644 index 8f22c7bd..00000000 --- a/cmd/forge/cmd_migrate.go +++ /dev/null @@ -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 ", - 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 - } -} diff --git a/cmd/forge/cmd_orgs.go b/cmd/forge/cmd_orgs.go deleted file mode 100644 index accb21dc..00000000 --- a/cmd/forge/cmd_orgs.go +++ /dev/null @@ -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 -} diff --git a/cmd/forge/cmd_prs.go b/cmd/forge/cmd_prs.go deleted file mode 100644 index 0e26e702..00000000 --- a/cmd/forge/cmd_prs.go +++ /dev/null @@ -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 ", - 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) -} diff --git a/cmd/forge/cmd_repos.go b/cmd/forge/cmd_repos.go deleted file mode 100644 index 0784f2ba..00000000 --- a/cmd/forge/cmd_repos.go +++ /dev/null @@ -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 -} diff --git a/cmd/forge/cmd_status.go b/cmd/forge/cmd_status.go deleted file mode 100644 index 2de8b59c..00000000 --- a/cmd/forge/cmd_status.go +++ /dev/null @@ -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 -} diff --git a/cmd/forge/cmd_sync.go b/cmd/forge/cmd_sync.go deleted file mode 100644 index d5a3fc1f..00000000 --- a/cmd/forge/cmd_sync.go +++ /dev/null @@ -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...]", - 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 -} diff --git a/cmd/forge/helpers.go b/cmd/forge/helpers.go deleted file mode 100644 index 1a168eee..00000000 --- a/cmd/forge/helpers.go +++ /dev/null @@ -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 -} diff --git a/cmd/gitea/cmd_config.go b/cmd/gitea/cmd_config.go deleted file mode 100644 index 6fdd49d1..00000000 --- a/cmd/gitea/cmd_config.go +++ /dev/null @@ -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 -} diff --git a/cmd/gitea/cmd_gitea.go b/cmd/gitea/cmd_gitea.go deleted file mode 100644 index 87bc6310..00000000 --- a/cmd/gitea/cmd_gitea.go +++ /dev/null @@ -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) -} diff --git a/cmd/gitea/cmd_issues.go b/cmd/gitea/cmd_issues.go deleted file mode 100644 index cb627988..00000000 --- a/cmd/gitea/cmd_issues.go +++ /dev/null @@ -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 ", - 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 -} diff --git a/cmd/gitea/cmd_mirror.go b/cmd/gitea/cmd_mirror.go deleted file mode 100644 index 7b466caf..00000000 --- a/cmd/gitea/cmd_mirror.go +++ /dev/null @@ -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 ", - 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)) -} diff --git a/cmd/gitea/cmd_prs.go b/cmd/gitea/cmd_prs.go deleted file mode 100644 index ad9e629c..00000000 --- a/cmd/gitea/cmd_prs.go +++ /dev/null @@ -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 ", - 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) -} diff --git a/cmd/gitea/cmd_repos.go b/cmd/gitea/cmd_repos.go deleted file mode 100644 index 69af886d..00000000 --- a/cmd/gitea/cmd_repos.go +++ /dev/null @@ -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 -} diff --git a/cmd/gitea/cmd_sync.go b/cmd/gitea/cmd_sync.go deleted file mode 100644 index 0e525fd7..00000000 --- a/cmd/gitea/cmd_sync.go +++ /dev/null @@ -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...]", - 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 } diff --git a/cmd/mcpcmd/cmd_mcp.go b/cmd/mcpcmd/cmd_mcp.go deleted file mode 100644 index 7c7c1ace..00000000 --- a/cmd/mcpcmd/cmd_mcp.go +++ /dev/null @@ -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) -} diff --git a/cmd/prod/cmd_commands.go b/cmd/prod/cmd_commands.go deleted file mode 100644 index b4d5f387..00000000 --- a/cmd/prod/cmd_commands.go +++ /dev/null @@ -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) -} diff --git a/cmd/prod/cmd_dns.go b/cmd/prod/cmd_dns.go deleted file mode 100644 index bea8097d..00000000 --- a/cmd/prod/cmd_dns.go +++ /dev/null @@ -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 ", - 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 -} diff --git a/cmd/prod/cmd_lb.go b/cmd/prod/cmd_lb.go deleted file mode 100644 index b707d24c..00000000 --- a/cmd/prod/cmd_lb.go +++ /dev/null @@ -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) -} diff --git a/cmd/prod/cmd_prod.go b/cmd/prod/cmd_prod.go deleted file mode 100644 index 6489654d..00000000 --- a/cmd/prod/cmd_prod.go +++ /dev/null @@ -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) -} diff --git a/cmd/prod/cmd_setup.go b/cmd/prod/cmd_setup.go deleted file mode 100644 index 776cbd91..00000000 --- a/cmd/prod/cmd_setup.go +++ /dev/null @@ -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 -} diff --git a/cmd/prod/cmd_ssh.go b/cmd/prod/cmd_ssh.go deleted file mode 100644 index 37fc1140..00000000 --- a/cmd/prod/cmd_ssh.go +++ /dev/null @@ -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 ", - 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()) -} diff --git a/cmd/prod/cmd_status.go b/cmd/prod/cmd_status.go deleted file mode 100644 index b816c84d..00000000 --- a/cmd/prod/cmd_status.go +++ /dev/null @@ -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) -} diff --git a/cmd/rag/cmd_collections.go b/cmd/rag/cmd_collections.go deleted file mode 100644 index 32001418..00000000 --- a/cmd/rag/cmd_collections.go +++ /dev/null @@ -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 -} diff --git a/cmd/rag/cmd_commands.go b/cmd/rag/cmd_commands.go deleted file mode 100644 index ba8b6fb2..00000000 --- a/cmd/rag/cmd_commands.go +++ /dev/null @@ -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) -} diff --git a/cmd/rag/cmd_ingest.go b/cmd/rag/cmd_ingest.go deleted file mode 100644 index 42ab76dc..00000000 --- a/cmd/rag/cmd_ingest.go +++ /dev/null @@ -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 -} diff --git a/cmd/rag/cmd_query.go b/cmd/rag/cmd_query.go deleted file mode 100644 index e2679ff1..00000000 --- a/cmd/rag/cmd_query.go +++ /dev/null @@ -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 -} diff --git a/cmd/rag/cmd_rag.go b/cmd/rag/cmd_rag.go deleted file mode 100644 index 23d27f78..00000000 --- a/cmd/rag/cmd_rag.go +++ /dev/null @@ -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")) -} diff --git a/cmd/security/cmd.go b/cmd/security/cmd.go deleted file mode 100644 index 3557d19a..00000000 --- a/cmd/security/cmd.go +++ /dev/null @@ -1,7 +0,0 @@ -package security - -import "forge.lthn.ai/core/go/pkg/cli" - -func init() { - cli.RegisterCommands(AddSecurityCommands) -} diff --git a/cmd/security/cmd_alerts.go b/cmd/security/cmd_alerts.go deleted file mode 100644 index 537e83d4..00000000 --- a/cmd/security/cmd_alerts.go +++ /dev/null @@ -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 -} diff --git a/cmd/security/cmd_deps.go b/cmd/security/cmd_deps.go deleted file mode 100644 index 9a3df43f..00000000 --- a/cmd/security/cmd_deps.go +++ /dev/null @@ -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 -} diff --git a/cmd/security/cmd_jobs.go b/cmd/security/cmd_jobs.go deleted file mode 100644 index e3f7b242..00000000 --- a/cmd/security/cmd_jobs.go +++ /dev/null @@ -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() -} diff --git a/cmd/security/cmd_scan.go b/cmd/security/cmd_scan.go deleted file mode 100644 index 5d7ccb03..00000000 --- a/cmd/security/cmd_scan.go +++ /dev/null @@ -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 -} diff --git a/cmd/security/cmd_secrets.go b/cmd/security/cmd_secrets.go deleted file mode 100644 index 04e18929..00000000 --- a/cmd/security/cmd_secrets.go +++ /dev/null @@ -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 -} diff --git a/cmd/security/cmd_security.go b/cmd/security/cmd_security.go deleted file mode 100644 index e4b37a20..00000000 --- a/cmd/security/cmd_security.go +++ /dev/null @@ -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, " | ") -} diff --git a/cmd/test/cmd_commands.go b/cmd/test/cmd_commands.go deleted file mode 100644 index 6660f937..00000000 --- a/cmd/test/cmd_commands.go +++ /dev/null @@ -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) -} diff --git a/cmd/test/cmd_main.go b/cmd/test/cmd_main.go deleted file mode 100644 index 428d0352..00000000 --- a/cmd/test/cmd_main.go +++ /dev/null @@ -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) -} diff --git a/cmd/test/cmd_output.go b/cmd/test/cmd_output.go deleted file mode 100644 index 450cf2b2..00000000 --- a/cmd/test/cmd_output.go +++ /dev/null @@ -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") -} diff --git a/cmd/test/cmd_runner.go b/cmd/test/cmd_runner.go deleted file mode 100644 index ac080a66..00000000 --- a/cmd/test/cmd_runner.go +++ /dev/null @@ -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") -} diff --git a/cmd/test/output_test.go b/cmd/test/output_test.go deleted file mode 100644 index 8e7d6824..00000000 --- a/cmd/test/output_test.go +++ /dev/null @@ -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) - }) -} diff --git a/cmd/unifi/cmd_clients.go b/cmd/unifi/cmd_clients.go deleted file mode 100644 index 6cdbb97e..00000000 --- a/cmd/unifi/cmd_clients.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/unifi/cmd_config.go b/cmd/unifi/cmd_config.go deleted file mode 100644 index c7f8e6f0..00000000 --- a/cmd/unifi/cmd_config.go +++ /dev/null @@ -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 -} diff --git a/cmd/unifi/cmd_devices.go b/cmd/unifi/cmd_devices.go deleted file mode 100644 index 289d927f..00000000 --- a/cmd/unifi/cmd_devices.go +++ /dev/null @@ -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 -} diff --git a/cmd/unifi/cmd_networks.go b/cmd/unifi/cmd_networks.go deleted file mode 100644 index d23042bc..00000000 --- a/cmd/unifi/cmd_networks.go +++ /dev/null @@ -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 -} diff --git a/cmd/unifi/cmd_routes.go b/cmd/unifi/cmd_routes.go deleted file mode 100644 index d8e924ea..00000000 --- a/cmd/unifi/cmd_routes.go +++ /dev/null @@ -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 -} diff --git a/cmd/unifi/cmd_sites.go b/cmd/unifi/cmd_sites.go deleted file mode 100644 index 45586ca9..00000000 --- a/cmd/unifi/cmd_sites.go +++ /dev/null @@ -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 -} diff --git a/cmd/unifi/cmd_unifi.go b/cmd/unifi/cmd_unifi.go deleted file mode 100644 index 2d5dfb9e..00000000 --- a/cmd/unifi/cmd_unifi.go +++ /dev/null @@ -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) -} diff --git a/cmd/vm/cmd_commands.go b/cmd/vm/cmd_commands.go deleted file mode 100644 index 2631e824..00000000 --- a/cmd/vm/cmd_commands.go +++ /dev/null @@ -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 diff --git a/cmd/vm/cmd_container.go b/cmd/vm/cmd_container.go deleted file mode 100644 index 805990ea..00000000 --- a/cmd/vm/cmd_container.go +++ /dev/null @@ -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 ", - 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 ", - 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 [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) -} diff --git a/cmd/vm/cmd_templates.go b/cmd/vm/cmd_templates.go deleted file mode 100644 index d8a4e878..00000000 --- a/cmd/vm/cmd_templates.go +++ /dev/null @@ -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 ", - 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 ", - 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 ")) - fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.vars"), dimStyle.Render("core vm templates vars ")) - fmt.Printf("%s %s\n", i18n.T("cmd.vm.templates.hint.run"), dimStyle.Render("core vm run --template --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 - 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 -} diff --git a/cmd/vm/cmd_vm.go b/cmd/vm/cmd_vm.go deleted file mode 100644 index aa7ce9cf..00000000 --- a/cmd/vm/cmd_vm.go +++ /dev/null @@ -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) -} diff --git a/go.mod b/go.mod index f26bafc2..4fe16a41 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 3e0c5ecb..ec7ce66d 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index 305505ea..13767d22 100644 --- a/main.go +++ b/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"