From 4a1600e9bee501df443ed82c36e970e9a6426a4d Mon Sep 17 00:00:00 2001 From: Vi Date: Thu, 5 Feb 2026 11:16:23 +0000 Subject: [PATCH] fix: restore packages accidentally deleted during PR #313 rebase (#333) During conflict resolution for PR #313 (streaming API), the agent incorrectly assumed that modify/delete conflicts meant the PR intended to remove these packages. This was wrong - PR #313 was only about adding streaming API to pkg/io. Restored packages: - pkg/workspace - workspace management service - pkg/unifi - UniFi controller client - pkg/gitea - Gitea API client - pkg/crypt/openpgp - OpenPGP encryption service - internal/cmd/gitea - Gitea CLI commands - internal/cmd/unifi - UniFi CLI commands Also restored: - Various test files (bench_test.go, integration_test.go, etc.) - pkg/framework/core/interfaces.go (Workspace/Crypt interfaces) - pkg/log/errors.go (error helpers) - Documentation (faq.md, user-guide.md) This allows PR #297 (MCP daemon mode) to proceed as it depends on pkg/workspace. Co-authored-by: Claude Co-authored-by: Claude Opus 4.5 --- docs/faq.md | 97 ++++++++ docs/user-guide.md | 100 ++++++++ internal/cmd/gitea/cmd_config.go | 106 +++++++++ internal/cmd/gitea/cmd_gitea.go | 47 ++++ internal/cmd/gitea/cmd_issues.go | 133 +++++++++++ internal/cmd/gitea/cmd_mirror.go | 92 ++++++++ internal/cmd/gitea/cmd_prs.go | 98 ++++++++ internal/cmd/gitea/cmd_repos.go | 125 ++++++++++ internal/cmd/gitea/cmd_sync.go | 353 +++++++++++++++++++++++++++++ internal/cmd/unifi/cmd_clients.go | 112 +++++++++ internal/cmd/unifi/cmd_config.go | 155 +++++++++++++ internal/cmd/unifi/cmd_devices.go | 74 ++++++ internal/cmd/unifi/cmd_networks.go | 145 ++++++++++++ internal/cmd/unifi/cmd_routes.go | 86 +++++++ internal/cmd/unifi/cmd_sites.go | 53 +++++ internal/cmd/unifi/cmd_unifi.go | 46 ++++ pkg/ansible/ssh_test.go | 36 +++ pkg/cli/app_test.go | 164 ++++++++++++++ pkg/crypt/openpgp/service.go | 191 ++++++++++++++++ pkg/crypt/openpgp/service_test.go | 43 ++++ pkg/devops/ssh_utils.go | 68 ++++++ pkg/framework/core/bench_test.go | 38 ++++ pkg/framework/core/interfaces.go | 23 ++ pkg/gitea/client.go | 37 +++ pkg/gitea/config.go | 92 ++++++++ pkg/gitea/issues.go | 109 +++++++++ pkg/gitea/meta.go | 146 ++++++++++++ pkg/gitea/repos.go | 110 +++++++++ pkg/io/bench_test.go | 34 +++ pkg/log/errors.go | 31 +++ pkg/log/errors_test.go | 44 ++++ pkg/mcp/integration_test.go | 121 ++++++++++ pkg/unifi/client.go | 53 +++++ pkg/unifi/clients.go | 64 ++++++ pkg/unifi/config.go | 145 ++++++++++++ pkg/unifi/devices.go | 116 ++++++++++ pkg/unifi/networks.go | 62 +++++ pkg/unifi/routes.go | 66 ++++++ pkg/unifi/sites.go | 17 ++ pkg/workspace/service.go | 148 ++++++++++++ pkg/workspace/service_test.go | 55 +++++ 41 files changed, 3835 insertions(+) create mode 100644 docs/faq.md create mode 100644 docs/user-guide.md create mode 100644 internal/cmd/gitea/cmd_config.go create mode 100644 internal/cmd/gitea/cmd_gitea.go create mode 100644 internal/cmd/gitea/cmd_issues.go create mode 100644 internal/cmd/gitea/cmd_mirror.go create mode 100644 internal/cmd/gitea/cmd_prs.go create mode 100644 internal/cmd/gitea/cmd_repos.go create mode 100644 internal/cmd/gitea/cmd_sync.go create mode 100644 internal/cmd/unifi/cmd_clients.go create mode 100644 internal/cmd/unifi/cmd_config.go create mode 100644 internal/cmd/unifi/cmd_devices.go create mode 100644 internal/cmd/unifi/cmd_networks.go create mode 100644 internal/cmd/unifi/cmd_routes.go create mode 100644 internal/cmd/unifi/cmd_sites.go create mode 100644 internal/cmd/unifi/cmd_unifi.go create mode 100644 pkg/ansible/ssh_test.go create mode 100644 pkg/cli/app_test.go create mode 100644 pkg/crypt/openpgp/service.go create mode 100644 pkg/crypt/openpgp/service_test.go create mode 100644 pkg/devops/ssh_utils.go create mode 100644 pkg/framework/core/bench_test.go create mode 100644 pkg/gitea/client.go create mode 100644 pkg/gitea/config.go create mode 100644 pkg/gitea/issues.go create mode 100644 pkg/gitea/meta.go create mode 100644 pkg/gitea/repos.go create mode 100644 pkg/io/bench_test.go create mode 100644 pkg/mcp/integration_test.go create mode 100644 pkg/unifi/client.go create mode 100644 pkg/unifi/clients.go create mode 100644 pkg/unifi/config.go create mode 100644 pkg/unifi/devices.go create mode 100644 pkg/unifi/networks.go create mode 100644 pkg/unifi/routes.go create mode 100644 pkg/unifi/sites.go create mode 100644 pkg/workspace/service.go create mode 100644 pkg/workspace/service_test.go diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 00000000..54ba99c1 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,97 @@ +# Frequently Asked Questions (FAQ) + +Common questions and answers about the Core CLI and Framework. + +## General + +### What is Core? + +Core is a unified CLI and framework for building and managing Go, PHP, and Wails applications. It provides an opinionated set of tools for development, testing, building, and releasing projects within the host-uk ecosystem. + +### Is Core a CLI or a Framework? + +It is both. The Core Framework (`pkg/core`) is a library for building Go desktop applications with Wails. The Core CLI (`cmd/core`) is the tool you use to manage projects, run tests, build binaries, and handle multi-repository workspaces. + +--- + +## Installation + +### How do I install the Core CLI? + +The recommended way is via Go: + +```bash +go install github.com/host-uk/core/cmd/core@latest +``` + +Ensure your Go bin directory is in your PATH. See [Getting Started](getting-started.md) for more options. + +### I get "command not found: core" after installation. + +This usually means your Go bin directory is not in your system's PATH. Add it by adding this to your shell profile (`.bashrc`, `.zshrc`, etc.): + +```bash +export PATH="$PATH:$(go env GOPATH)/bin" +``` + +--- + +## Usage + +### Why does `core ci` not publish anything by default? + +Core is designed to be **safe by default**. `core ci` runs in dry-run mode to show you what would be published. To actually publish a release, you must use the `--we-are-go-for-launch` flag: + +```bash +core ci --we-are-go-for-launch +``` + +### How do I run tests for only one package? + +You can pass standard Go test flags to `core go test`: + +```bash +core go test ./pkg/my-package +``` + +### What is `core doctor` for? + +`core doctor` checks your development environment to ensure all required tools (Go, Git, Docker, etc.) are installed and correctly configured. It's the first thing you should run if something isn't working. + +--- + +## Configuration + +### Where is Core's configuration stored? + +- **Project-specific**: In the `.core/` directory within your project root. +- **Global**: In `~/.core/` or as defined by `CORE_CONFIG`. +- **Registry**: The `repos.yaml` file defines the multi-repo workspace. + +### How do I change the build targets? + +You can specify targets in `.core/release.yaml` or use the `--targets` flag with the `core build` command: + +```bash +core build --targets linux/amd64,darwin/arm64 +``` + +--- + +## Workspaces and Registry + +### What is a "workspace" in Core? + +In the context of the CLI, a workspace is a directory containing multiple repositories defined in a `repos.yaml` file. The `core dev` commands allow you to manage status, commits, and synchronization across all repositories in the workspace at once. + +### What is `repos.yaml`? + +`repos.yaml` is the "registry" for your workspace. It lists the repositories, their types (foundation, module, product), and their dependencies. Core uses this file to know which repositories to clone during `core setup`. + +--- + +## See Also + +- [Getting Started](getting-started.md) - Installation and first steps +- [User Guide](user-guide.md) - Detailed usage information +- [Troubleshooting](troubleshooting.md) - Solving common issues diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 00000000..3820d9aa --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,100 @@ +# User Guide + +This guide provides a comprehensive overview of how to use the Core CLI to manage your development workflow. + +## Key Concepts + +### Projects +A Project is a single repository containing code (Go, PHP, or Wails). Core helps you test, build, and release these projects using a consistent set of commands. + +### Workspaces +A Workspace is a collection of related projects. Core is designed to work across multiple repositories, allowing you to perform actions (like checking status or committing changes) on all of them at once. + +### Registry (`repos.yaml`) +The Registry is a configuration file that defines the repositories in your workspace. It includes information about where they are located on GitHub, their dependencies, and their purpose. + +--- + +## Daily Workflow + +### Working with a Single Project + +For a typical day-to-day development on a single project: + +1. **Verify your environment**: + ```bash + core doctor + ``` +2. **Run tests while you work**: + ```bash + core go test + ``` +3. **Keep code clean**: + ```bash + core go fmt --fix + core go lint + ``` +4. **Build and preview**: + ```bash + core build + ``` + +### Working with Multiple Repositories + +If you are working across many repositories in a workspace: + +1. **Check status of all repos**: + ```bash + core dev work --status + ``` +2. **Sync all changes**: + ```bash + core dev pull --all + ``` +3. **Commit and push everything**: + ```bash + core dev work + ``` + +--- + +## Building and Releasing + +Core separates the building of artifacts from the releasing of those artifacts. + +### 1. Build +The `core build` command detects your project type and builds binaries for your configured targets. Artifacts are placed in the `dist/` directory. + +### 2. Preview Release +Use `core ci` to see a summary of what would be included in a release (changelog, artifacts, etc.). This is a dry-run by default. + +### 3. Publish Release +When you are ready to publish to GitHub: +```bash +core ci --we-are-go-for-launch +``` + +--- + +## PHP and Laravel Development + +Core provides a unified development server for Laravel projects that orchestrates several services: + +```bash +core php dev +``` +This starts FrankenPHP, Vite, Horizon, Reverb, and Redis as configured in your `.core/php.yaml`. + +--- + +## Common Workflows + +For detailed examples of common end-to-end workflows, see the [Workflows](workflows.md) page. + +--- + +## Getting More Help + +- Use the `--help` flag with any command: `core build --help` +- Check the [FAQ](faq.md) for common questions. +- If you run into trouble, see the [Troubleshooting Guide](troubleshooting.md). diff --git a/internal/cmd/gitea/cmd_config.go b/internal/cmd/gitea/cmd_config.go new file mode 100644 index 00000000..87919ee4 --- /dev/null +++ b/internal/cmd/gitea/cmd_config.go @@ -0,0 +1,106 @@ +package gitea + +import ( + "fmt" + + "github.com/host-uk/core/pkg/cli" + gt "github.com/host-uk/core/pkg/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/internal/cmd/gitea/cmd_gitea.go b/internal/cmd/gitea/cmd_gitea.go new file mode 100644 index 00000000..f5a85097 --- /dev/null +++ b/internal/cmd/gitea/cmd_gitea.go @@ -0,0 +1,47 @@ +// 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 ( + "github.com/host-uk/core/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/internal/cmd/gitea/cmd_issues.go b/internal/cmd/gitea/cmd_issues.go new file mode 100644 index 00000000..9dc457bf --- /dev/null +++ b/internal/cmd/gitea/cmd_issues.go @@ -0,0 +1,133 @@ +package gitea + +import ( + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" + + "github.com/host-uk/core/pkg/cli" + gt "github.com/host-uk/core/pkg/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/internal/cmd/gitea/cmd_mirror.go b/internal/cmd/gitea/cmd_mirror.go new file mode 100644 index 00000000..14170424 --- /dev/null +++ b/internal/cmd/gitea/cmd_mirror.go @@ -0,0 +1,92 @@ +package gitea + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/host-uk/core/pkg/cli" + gt "github.com/host-uk/core/pkg/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/internal/cmd/gitea/cmd_prs.go b/internal/cmd/gitea/cmd_prs.go new file mode 100644 index 00000000..4a6b71b6 --- /dev/null +++ b/internal/cmd/gitea/cmd_prs.go @@ -0,0 +1,98 @@ +package gitea + +import ( + "fmt" + "strings" + + sdk "code.gitea.io/sdk/gitea" + + "github.com/host-uk/core/pkg/cli" + gt "github.com/host-uk/core/pkg/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/internal/cmd/gitea/cmd_repos.go b/internal/cmd/gitea/cmd_repos.go new file mode 100644 index 00000000..596d96a7 --- /dev/null +++ b/internal/cmd/gitea/cmd_repos.go @@ -0,0 +1,125 @@ +package gitea + +import ( + "fmt" + + "github.com/host-uk/core/pkg/cli" + gt "github.com/host-uk/core/pkg/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/internal/cmd/gitea/cmd_sync.go b/internal/cmd/gitea/cmd_sync.go new file mode 100644 index 00000000..d5edd6e6 --- /dev/null +++ b/internal/cmd/gitea/cmd_sync.go @@ -0,0 +1,353 @@ +package gitea + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "code.gitea.io/sdk/gitea" + + "github.com/host-uk/core/pkg/cli" + gt "github.com/host-uk/core/pkg/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/internal/cmd/unifi/cmd_clients.go b/internal/cmd/unifi/cmd_clients.go new file mode 100644 index 00000000..3f453d7d --- /dev/null +++ b/internal/cmd/unifi/cmd_clients.go @@ -0,0 +1,112 @@ +package unifi + +import ( + "errors" + "fmt" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/log" + uf "github.com/host-uk/core/pkg/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/internal/cmd/unifi/cmd_config.go b/internal/cmd/unifi/cmd_config.go new file mode 100644 index 00000000..ad10b6e0 --- /dev/null +++ b/internal/cmd/unifi/cmd_config.go @@ -0,0 +1,155 @@ +package unifi + +import ( + "fmt" + + "github.com/host-uk/core/pkg/cli" + uf "github.com/host-uk/core/pkg/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/internal/cmd/unifi/cmd_devices.go b/internal/cmd/unifi/cmd_devices.go new file mode 100644 index 00000000..2f810c81 --- /dev/null +++ b/internal/cmd/unifi/cmd_devices.go @@ -0,0 +1,74 @@ +package unifi + +import ( + "strings" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/log" + uf "github.com/host-uk/core/pkg/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/internal/cmd/unifi/cmd_networks.go b/internal/cmd/unifi/cmd_networks.go new file mode 100644 index 00000000..9196fc94 --- /dev/null +++ b/internal/cmd/unifi/cmd_networks.go @@ -0,0 +1,145 @@ +package unifi + +import ( + "fmt" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/log" + uf "github.com/host-uk/core/pkg/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/internal/cmd/unifi/cmd_routes.go b/internal/cmd/unifi/cmd_routes.go new file mode 100644 index 00000000..a6895a77 --- /dev/null +++ b/internal/cmd/unifi/cmd_routes.go @@ -0,0 +1,86 @@ +package unifi + +import ( + "fmt" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/log" + uf "github.com/host-uk/core/pkg/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/internal/cmd/unifi/cmd_sites.go b/internal/cmd/unifi/cmd_sites.go new file mode 100644 index 00000000..b7eace47 --- /dev/null +++ b/internal/cmd/unifi/cmd_sites.go @@ -0,0 +1,53 @@ +package unifi + +import ( + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/log" + uf "github.com/host-uk/core/pkg/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/internal/cmd/unifi/cmd_unifi.go b/internal/cmd/unifi/cmd_unifi.go new file mode 100644 index 00000000..be2d2331 --- /dev/null +++ b/internal/cmd/unifi/cmd_unifi.go @@ -0,0 +1,46 @@ +// 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 ( + "github.com/host-uk/core/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/pkg/ansible/ssh_test.go b/pkg/ansible/ssh_test.go new file mode 100644 index 00000000..17179b0d --- /dev/null +++ b/pkg/ansible/ssh_test.go @@ -0,0 +1,36 @@ +package ansible + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNewSSHClient(t *testing.T) { + cfg := SSHConfig{ + Host: "localhost", + Port: 2222, + User: "root", + } + + client, err := NewSSHClient(cfg) + assert.NoError(t, err) + assert.NotNil(t, client) + assert.Equal(t, "localhost", client.host) + assert.Equal(t, 2222, client.port) + assert.Equal(t, "root", client.user) + assert.Equal(t, 30*time.Second, client.timeout) +} + +func TestSSHConfig_Defaults(t *testing.T) { + cfg := SSHConfig{ + Host: "localhost", + } + + client, err := NewSSHClient(cfg) + assert.NoError(t, err) + assert.Equal(t, 22, client.port) + assert.Equal(t, "root", client.user) + assert.Equal(t, 30*time.Second, client.timeout) +} diff --git a/pkg/cli/app_test.go b/pkg/cli/app_test.go new file mode 100644 index 00000000..c11d5fe6 --- /dev/null +++ b/pkg/cli/app_test.go @@ -0,0 +1,164 @@ +package cli + +import ( + "bytes" + "fmt" + "runtime/debug" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestPanicRecovery_Good verifies that the panic recovery mechanism +// catches panics and calls the appropriate shutdown and error handling. +func TestPanicRecovery_Good(t *testing.T) { + t.Run("recovery captures panic value and stack", func(t *testing.T) { + var recovered any + var capturedStack []byte + var shutdownCalled bool + + // Simulate the panic recovery pattern from Main() + func() { + defer func() { + if r := recover(); r != nil { + recovered = r + capturedStack = debug.Stack() + shutdownCalled = true // simulates Shutdown() call + } + }() + + panic("test panic") + }() + + assert.Equal(t, "test panic", recovered) + assert.True(t, shutdownCalled, "Shutdown should be called after panic recovery") + assert.NotEmpty(t, capturedStack, "Stack trace should be captured") + assert.Contains(t, string(capturedStack), "TestPanicRecovery_Good") + }) + + t.Run("recovery handles error type panics", func(t *testing.T) { + var recovered any + + func() { + defer func() { + if r := recover(); r != nil { + recovered = r + } + }() + + panic(fmt.Errorf("error panic")) + }() + + err, ok := recovered.(error) + assert.True(t, ok, "Recovered value should be an error") + assert.Equal(t, "error panic", err.Error()) + }) + + t.Run("recovery handles nil panic gracefully", func(t *testing.T) { + recoveryExecuted := false + + func() { + defer func() { + if r := recover(); r != nil { + recoveryExecuted = true + } + }() + + // No panic occurs + }() + + assert.False(t, recoveryExecuted, "Recovery block should not execute without panic") + }) +} + +// TestPanicRecovery_Bad tests error conditions in panic recovery. +func TestPanicRecovery_Bad(t *testing.T) { + t.Run("recovery handles concurrent panics", func(t *testing.T) { + var wg sync.WaitGroup + recoveryCount := 0 + var mu sync.Mutex + + for i := 0; i < 3; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + mu.Lock() + recoveryCount++ + mu.Unlock() + } + }() + + panic(fmt.Sprintf("panic from goroutine %d", id)) + }(i) + } + + wg.Wait() + assert.Equal(t, 3, recoveryCount, "All goroutine panics should be recovered") + }) +} + +// TestPanicRecovery_Ugly tests edge cases in panic recovery. +func TestPanicRecovery_Ugly(t *testing.T) { + t.Run("recovery handles typed panic values", func(t *testing.T) { + type customError struct { + code int + msg string + } + + var recovered any + + func() { + defer func() { + recovered = recover() + }() + + panic(customError{code: 500, msg: "internal error"}) + }() + + ce, ok := recovered.(customError) + assert.True(t, ok, "Should recover custom type") + assert.Equal(t, 500, ce.code) + assert.Equal(t, "internal error", ce.msg) + }) +} + +// TestMainPanicRecoveryPattern verifies the exact pattern used in Main(). +func TestMainPanicRecoveryPattern(t *testing.T) { + t.Run("pattern logs error and calls shutdown", func(t *testing.T) { + var logBuffer bytes.Buffer + var shutdownCalled bool + var fatalErr error + + // Mock implementations + mockLogError := func(msg string, args ...any) { + fmt.Fprintf(&logBuffer, msg, args...) + } + mockShutdown := func() { + shutdownCalled = true + } + mockFatal := func(err error) { + fatalErr = err + } + + // Execute the pattern from Main() + func() { + defer func() { + if r := recover(); r != nil { + mockLogError("recovered from panic: %v", r) + mockShutdown() + mockFatal(fmt.Errorf("panic: %v", r)) + } + }() + + panic("simulated crash") + }() + + assert.Contains(t, logBuffer.String(), "recovered from panic: simulated crash") + assert.True(t, shutdownCalled, "Shutdown must be called on panic") + assert.NotNil(t, fatalErr, "Fatal must be called with error") + assert.Equal(t, "panic: simulated crash", fatalErr.Error()) + }) +} diff --git a/pkg/crypt/openpgp/service.go b/pkg/crypt/openpgp/service.go new file mode 100644 index 00000000..10200588 --- /dev/null +++ b/pkg/crypt/openpgp/service.go @@ -0,0 +1,191 @@ +package openpgp + +import ( + "bytes" + "crypto" + goio "io" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" + core "github.com/host-uk/core/pkg/framework/core" +) + +// Service implements the core.Crypt interface using OpenPGP. +type Service struct { + core *core.Core +} + +// New creates a new OpenPGP service instance. +func New(c *core.Core) (any, error) { + return &Service{core: c}, nil +} + +// CreateKeyPair generates a new RSA-4096 PGP keypair. +// Returns the armored private key string. +func (s *Service) CreateKeyPair(name, passphrase string) (string, error) { + config := &packet.Config{ + Algorithm: packet.PubKeyAlgoRSA, + RSABits: 4096, + DefaultHash: crypto.SHA256, + DefaultCipher: packet.CipherAES256, + } + + entity, err := openpgp.NewEntity(name, "Workspace Key", "", config) + if err != nil { + return "", core.E("openpgp.CreateKeyPair", "failed to create entity", err) + } + + // Encrypt private key if passphrase is provided + if passphrase != "" { + err = entity.PrivateKey.Encrypt([]byte(passphrase)) + if err != nil { + return "", core.E("openpgp.CreateKeyPair", "failed to encrypt private key", err) + } + for _, subkey := range entity.Subkeys { + err = subkey.PrivateKey.Encrypt([]byte(passphrase)) + if err != nil { + return "", core.E("openpgp.CreateKeyPair", "failed to encrypt subkey", err) + } + } + } + + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + if err != nil { + return "", core.E("openpgp.CreateKeyPair", "failed to create armor encoder", err) + } + + // Manual serialization to avoid panic from re-signing encrypted keys + err = s.serializeEntity(w, entity) + if err != nil { + w.Close() + return "", core.E("openpgp.CreateKeyPair", "failed to serialize private key", err) + } + w.Close() + + return buf.String(), nil +} + +// serializeEntity manually serializes an OpenPGP entity to avoid re-signing. +func (s *Service) serializeEntity(w goio.Writer, e *openpgp.Entity) error { + err := e.PrivateKey.Serialize(w) + if err != nil { + return err + } + for _, ident := range e.Identities { + err = ident.UserId.Serialize(w) + if err != nil { + return err + } + err = ident.SelfSignature.Serialize(w) + if err != nil { + return err + } + } + for _, subkey := range e.Subkeys { + err = subkey.PrivateKey.Serialize(w) + if err != nil { + return err + } + err = subkey.Sig.Serialize(w) + if err != nil { + return err + } + } + return nil +} + +// EncryptPGP encrypts data for a recipient identified by their public key (armored string in recipientPath). +// The encrypted data is written to the provided writer and also returned as an armored string. +func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) { + entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(recipientPath)) + if err != nil { + return "", core.E("openpgp.EncryptPGP", "failed to read recipient key", err) + } + + var armoredBuf bytes.Buffer + armoredWriter, err := armor.Encode(&armoredBuf, "PGP MESSAGE", nil) + if err != nil { + return "", core.E("openpgp.EncryptPGP", "failed to create armor encoder", err) + } + + // MultiWriter to write to both the provided writer and our armored buffer + mw := goio.MultiWriter(writer, armoredWriter) + + w, err := openpgp.Encrypt(mw, entityList, nil, nil, nil) + if err != nil { + armoredWriter.Close() + return "", core.E("openpgp.EncryptPGP", "failed to start encryption", err) + } + + _, err = goio.WriteString(w, data) + if err != nil { + w.Close() + armoredWriter.Close() + return "", core.E("openpgp.EncryptPGP", "failed to write data", err) + } + + w.Close() + armoredWriter.Close() + + return armoredBuf.String(), nil +} + +// DecryptPGP decrypts a PGP message using the provided armored private key and passphrase. +func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error) { + entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey)) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to read private key", err) + } + + entity := entityList[0] + if entity.PrivateKey.Encrypted { + err = entity.PrivateKey.Decrypt([]byte(passphrase)) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to decrypt private key", err) + } + for _, subkey := range entity.Subkeys { + _ = subkey.PrivateKey.Decrypt([]byte(passphrase)) + } + } + + // Decrypt armored message + block, err := armor.Decode(strings.NewReader(message)) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to decode armored message", err) + } + + md, err := openpgp.ReadMessage(block.Body, entityList, nil, nil) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to read message", err) + } + + var buf bytes.Buffer + _, err = goio.Copy(&buf, md.UnverifiedBody) + if err != nil { + return "", core.E("openpgp.DecryptPGP", "failed to read decrypted body", err) + } + + return buf.String(), nil +} + +// HandleIPCEvents handles PGP-related IPC messages. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + switch m := msg.(type) { + case map[string]any: + action, _ := m["action"].(string) + switch action { + case "openpgp.create_key_pair": + name, _ := m["name"].(string) + passphrase, _ := m["passphrase"].(string) + _, err := s.CreateKeyPair(name, passphrase) + return err + } + } + return nil +} + +// Ensure Service implements core.Crypt. +var _ core.Crypt = (*Service)(nil) diff --git a/pkg/crypt/openpgp/service_test.go b/pkg/crypt/openpgp/service_test.go new file mode 100644 index 00000000..c6f1243b --- /dev/null +++ b/pkg/crypt/openpgp/service_test.go @@ -0,0 +1,43 @@ +package openpgp + +import ( + "bytes" + "testing" + + core "github.com/host-uk/core/pkg/framework/core" + "github.com/stretchr/testify/assert" +) + +func TestCreateKeyPair(t *testing.T) { + c, _ := core.New() + s := &Service{core: c} + + privKey, err := s.CreateKeyPair("test user", "password123") + assert.NoError(t, err) + assert.NotEmpty(t, privKey) + assert.Contains(t, privKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----") +} + +func TestEncryptDecrypt(t *testing.T) { + c, _ := core.New() + s := &Service{core: c} + + passphrase := "secret" + privKey, err := s.CreateKeyPair("test user", passphrase) + assert.NoError(t, err) + + // In this simple test, the public key is also in the armored private key string + // (openpgp.ReadArmoredKeyRing reads both) + publicKey := privKey + + data := "hello openpgp" + var buf bytes.Buffer + armored, err := s.EncryptPGP(&buf, publicKey, data) + assert.NoError(t, err) + assert.NotEmpty(t, armored) + assert.NotEmpty(t, buf.String()) + + decrypted, err := s.DecryptPGP(privKey, armored, passphrase) + assert.NoError(t, err) + assert.Equal(t, data, decrypted) +} diff --git a/pkg/devops/ssh_utils.go b/pkg/devops/ssh_utils.go new file mode 100644 index 00000000..d05902b8 --- /dev/null +++ b/pkg/devops/ssh_utils.go @@ -0,0 +1,68 @@ +package devops + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ensureHostKey ensures that the host key for the dev environment is in the known hosts file. +// This is used after boot to allow StrictHostKeyChecking=yes to work. +func ensureHostKey(ctx context.Context, port int) error { + // Skip if requested (used in tests) + if os.Getenv("CORE_SKIP_SSH_SCAN") == "true" { + return nil + } + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } + + knownHostsPath := filepath.Join(home, ".core", "known_hosts") + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0755); err != nil { + return fmt.Errorf("create known_hosts dir: %w", err) + } + + // Get host key using ssh-keyscan + cmd := exec.CommandContext(ctx, "ssh-keyscan", "-p", fmt.Sprintf("%d", port), "localhost") + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("ssh-keyscan failed: %w", err) + } + + if len(out) == 0 { + return fmt.Errorf("ssh-keyscan returned no keys") + } + + // Read existing known_hosts to avoid duplicates + existing, _ := os.ReadFile(knownHostsPath) + existingStr := string(existing) + + // Append new keys that aren't already there + f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("open known_hosts: %w", err) + } + defer f.Close() + + lines := strings.Split(string(out), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if !strings.Contains(existingStr, line) { + if _, err := f.WriteString(line + "\n"); err != nil { + return fmt.Errorf("write known_hosts: %w", err) + } + } + } + + return nil +} diff --git a/pkg/framework/core/bench_test.go b/pkg/framework/core/bench_test.go new file mode 100644 index 00000000..2337c6ef --- /dev/null +++ b/pkg/framework/core/bench_test.go @@ -0,0 +1,38 @@ +package core + +import ( + "testing" +) + +func BenchmarkMessageBus_Action(b *testing.B) { + c, _ := New() + c.RegisterAction(func(c *Core, msg Message) error { + return nil + }) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = c.ACTION("test") + } +} + +func BenchmarkMessageBus_Query(b *testing.B) { + c, _ := New() + c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { + return "result", true, nil + }) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = c.QUERY("test") + } +} + +func BenchmarkMessageBus_Perform(b *testing.B) { + c, _ := New() + c.RegisterTask(func(c *Core, t Task) (any, bool, error) { + return "result", true, nil + }) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _, _ = c.PERFORM("test") + } +} diff --git a/pkg/framework/core/interfaces.go b/pkg/framework/core/interfaces.go index 632b68d7..8d587d20 100644 --- a/pkg/framework/core/interfaces.go +++ b/pkg/framework/core/interfaces.go @@ -3,6 +3,7 @@ package core import ( "context" "embed" + goio "io" "sync/atomic" ) @@ -109,6 +110,28 @@ type Display interface { OpenWindow(opts ...WindowOption) error } +// Workspace provides management for encrypted user workspaces. +type Workspace interface { + // CreateWorkspace creates a new encrypted workspace. + CreateWorkspace(identifier, password string) (string, error) + // SwitchWorkspace changes the active workspace. + SwitchWorkspace(name string) error + // WorkspaceFileGet retrieves the content of a file from the active workspace. + WorkspaceFileGet(filename string) (string, error) + // WorkspaceFileSet saves content to a file in the active workspace. + WorkspaceFileSet(filename, content string) error +} + +// Crypt provides PGP-based encryption, signing, and key management. +type Crypt interface { + // CreateKeyPair generates a new PGP keypair. + CreateKeyPair(name, passphrase string) (string, error) + // EncryptPGP encrypts data for a recipient. + EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) + // DecryptPGP decrypts a PGP message. + DecryptPGP(recipientPath, message, passphrase string, opts ...any) (string, error) +} + // ActionServiceStartup is a message sent when the application's services are starting up. // This provides a hook for services to perform initialization tasks. type ActionServiceStartup struct{} diff --git a/pkg/gitea/client.go b/pkg/gitea/client.go new file mode 100644 index 00000000..2099534d --- /dev/null +++ b/pkg/gitea/client.go @@ -0,0 +1,37 @@ +// Package gitea provides a thin wrapper around the Gitea Go SDK +// for managing repositories, issues, and pull requests on a Gitea instance. +// +// Authentication is resolved from config file, environment variables, or flag overrides: +// +// 1. ~/.core/config.yaml keys: gitea.token, gitea.url +// 2. GITEA_TOKEN + GITEA_URL environment variables (override config file) +// 3. Flag overrides via core gitea config --url/--token (highest priority) +package gitea + +import ( + "code.gitea.io/sdk/gitea" + + "github.com/host-uk/core/pkg/log" +) + +// Client wraps the Gitea SDK client with config-based auth. +type Client struct { + api *gitea.Client + url string +} + +// New creates a new Gitea API client for the given URL and token. +func New(url, token string) (*Client, error) { + api, err := gitea.NewClient(url, gitea.SetToken(token)) + if err != nil { + return nil, log.E("gitea.New", "failed to create client", err) + } + + return &Client{api: api, url: url}, nil +} + +// API exposes the underlying SDK client for direct access. +func (c *Client) API() *gitea.Client { return c.api } + +// URL returns the Gitea instance URL. +func (c *Client) URL() string { return c.url } diff --git a/pkg/gitea/config.go b/pkg/gitea/config.go new file mode 100644 index 00000000..7dd881f8 --- /dev/null +++ b/pkg/gitea/config.go @@ -0,0 +1,92 @@ +package gitea + +import ( + "os" + + "github.com/host-uk/core/pkg/config" + "github.com/host-uk/core/pkg/log" +) + +const ( + // ConfigKeyURL is the config key for the Gitea instance URL. + ConfigKeyURL = "gitea.url" + // ConfigKeyToken is the config key for the Gitea API token. + ConfigKeyToken = "gitea.token" + + // DefaultURL is the default Gitea instance URL. + DefaultURL = "https://gitea.snider.dev" +) + +// NewFromConfig creates a Gitea client using the standard config resolution: +// +// 1. ~/.core/config.yaml keys: gitea.token, gitea.url +// 2. GITEA_TOKEN + GITEA_URL environment variables (override config file) +// 3. Provided flag overrides (highest priority; pass empty to skip) +func NewFromConfig(flagURL, flagToken string) (*Client, error) { + url, token, err := ResolveConfig(flagURL, flagToken) + if err != nil { + return nil, err + } + + if token == "" { + return nil, log.E("gitea.NewFromConfig", "no API token configured (set GITEA_TOKEN or run: core gitea config --token TOKEN)", nil) + } + + return New(url, token) +} + +// ResolveConfig resolves the Gitea URL and token from all config sources. +// Flag values take highest priority, then env vars, then config file. +func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { + // Start with config file values + cfg, cfgErr := config.New() + if cfgErr == nil { + _ = cfg.Get(ConfigKeyURL, &url) + _ = cfg.Get(ConfigKeyToken, &token) + } + + // Overlay environment variables + if envURL := os.Getenv("GITEA_URL"); envURL != "" { + url = envURL + } + if envToken := os.Getenv("GITEA_TOKEN"); envToken != "" { + token = envToken + } + + // Overlay flag values (highest priority) + if flagURL != "" { + url = flagURL + } + if flagToken != "" { + token = flagToken + } + + // Default URL if nothing configured + if url == "" { + url = DefaultURL + } + + return url, token, nil +} + +// SaveConfig persists the Gitea URL and/or token to the config file. +func SaveConfig(url, token string) error { + cfg, err := config.New() + if err != nil { + return log.E("gitea.SaveConfig", "failed to load config", err) + } + + if url != "" { + if err := cfg.Set(ConfigKeyURL, url); err != nil { + return log.E("gitea.SaveConfig", "failed to save URL", err) + } + } + + if token != "" { + if err := cfg.Set(ConfigKeyToken, token); err != nil { + return log.E("gitea.SaveConfig", "failed to save token", err) + } + } + + return nil +} diff --git a/pkg/gitea/issues.go b/pkg/gitea/issues.go new file mode 100644 index 00000000..c5f1464c --- /dev/null +++ b/pkg/gitea/issues.go @@ -0,0 +1,109 @@ +package gitea + +import ( + "code.gitea.io/sdk/gitea" + + "github.com/host-uk/core/pkg/log" +) + +// ListIssuesOpts configures issue listing. +type ListIssuesOpts struct { + State string // "open", "closed", "all" + Page int + Limit int +} + +// ListIssues returns issues for the given repository. +func (c *Client) ListIssues(owner, repo string, opts ListIssuesOpts) ([]*gitea.Issue, error) { + state := gitea.StateOpen + switch opts.State { + case "closed": + state = gitea.StateClosed + case "all": + state = gitea.StateAll + } + + limit := opts.Limit + if limit == 0 { + limit = 50 + } + + page := opts.Page + if page == 0 { + page = 1 + } + + issues, _, err := c.api.ListRepoIssues(owner, repo, gitea.ListIssueOption{ + ListOptions: gitea.ListOptions{Page: page, PageSize: limit}, + State: state, + Type: gitea.IssueTypeIssue, + }) + if err != nil { + return nil, log.E("gitea.ListIssues", "failed to list issues", err) + } + + return issues, nil +} + +// GetIssue returns a single issue by number. +func (c *Client) GetIssue(owner, repo string, number int64) (*gitea.Issue, error) { + issue, _, err := c.api.GetIssue(owner, repo, number) + if err != nil { + return nil, log.E("gitea.GetIssue", "failed to get issue", err) + } + + return issue, nil +} + +// CreateIssue creates a new issue in the given repository. +func (c *Client) CreateIssue(owner, repo string, opts gitea.CreateIssueOption) (*gitea.Issue, error) { + issue, _, err := c.api.CreateIssue(owner, repo, opts) + if err != nil { + return nil, log.E("gitea.CreateIssue", "failed to create issue", err) + } + + return issue, nil +} + +// ListPullRequests returns pull requests for the given repository. +func (c *Client) ListPullRequests(owner, repo string, state string) ([]*gitea.PullRequest, error) { + st := gitea.StateOpen + switch state { + case "closed": + st = gitea.StateClosed + case "all": + st = gitea.StateAll + } + + var all []*gitea.PullRequest + page := 1 + + for { + prs, resp, err := c.api.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + State: st, + }) + if err != nil { + return nil, log.E("gitea.ListPullRequests", "failed to list pull requests", err) + } + + all = append(all, prs...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + +// GetPullRequest returns a single pull request by number. +func (c *Client) GetPullRequest(owner, repo string, number int64) (*gitea.PullRequest, error) { + pr, _, err := c.api.GetPullRequest(owner, repo, number) + if err != nil { + return nil, log.E("gitea.GetPullRequest", "failed to get pull request", err) + } + + return pr, nil +} diff --git a/pkg/gitea/meta.go b/pkg/gitea/meta.go new file mode 100644 index 00000000..7d2e9030 --- /dev/null +++ b/pkg/gitea/meta.go @@ -0,0 +1,146 @@ +package gitea + +import ( + "time" + + "code.gitea.io/sdk/gitea" + + "github.com/host-uk/core/pkg/log" +) + +// PRMeta holds structural signals from a pull request, +// used by the pipeline MetaReader for AI-driven workflows. +type PRMeta struct { + Number int64 + Title string + State string + Author string + Branch string + BaseBranch string + Labels []string + Assignees []string + IsMerged bool + CreatedAt time.Time + UpdatedAt time.Time + CommentCount int +} + +// Comment represents a comment with metadata. +type Comment struct { + ID int64 + Author string + Body string + CreatedAt time.Time + UpdatedAt time.Time +} + +const commentPageSize = 50 + +// GetPRMeta returns structural signals for a pull request. +// This is the Gitea side of the dual MetaReader described in the pipeline design. +func (c *Client) GetPRMeta(owner, repo string, pr int64) (*PRMeta, error) { + pull, _, err := c.api.GetPullRequest(owner, repo, pr) + if err != nil { + return nil, log.E("gitea.GetPRMeta", "failed to get PR metadata", err) + } + + meta := &PRMeta{ + Number: pull.Index, + Title: pull.Title, + State: string(pull.State), + Branch: pull.Head.Ref, + BaseBranch: pull.Base.Ref, + IsMerged: pull.HasMerged, + } + + if pull.Created != nil { + meta.CreatedAt = *pull.Created + } + if pull.Updated != nil { + meta.UpdatedAt = *pull.Updated + } + + if pull.Poster != nil { + meta.Author = pull.Poster.UserName + } + + for _, label := range pull.Labels { + meta.Labels = append(meta.Labels, label.Name) + } + + for _, assignee := range pull.Assignees { + meta.Assignees = append(meta.Assignees, assignee.UserName) + } + + // Fetch comment count from the issue side (PRs are issues in Gitea). + // Paginate to get an accurate count. + count := 0 + page := 1 + for { + comments, _, listErr := c.api.ListIssueComments(owner, repo, pr, gitea.ListIssueCommentOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize}, + }) + if listErr != nil { + break + } + count += len(comments) + if len(comments) < commentPageSize { + break + } + page++ + } + meta.CommentCount = count + + return meta, nil +} + +// GetCommentBodies returns all comment bodies for a pull request. +// This reads full content, which is safe on the home lab Gitea instance. +func (c *Client) GetCommentBodies(owner, repo string, pr int64) ([]Comment, error) { + var comments []Comment + page := 1 + + for { + raw, _, err := c.api.ListIssueComments(owner, repo, pr, gitea.ListIssueCommentOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize}, + }) + if err != nil { + return nil, log.E("gitea.GetCommentBodies", "failed to get PR comments", err) + } + + if len(raw) == 0 { + break + } + + for _, rc := range raw { + comment := Comment{ + ID: rc.ID, + Body: rc.Body, + CreatedAt: rc.Created, + UpdatedAt: rc.Updated, + } + if rc.Poster != nil { + comment.Author = rc.Poster.UserName + } + comments = append(comments, comment) + } + + if len(raw) < commentPageSize { + break + } + page++ + } + + return comments, nil +} + +// GetIssueBody returns the body text of an issue. +// This reads full content, which is safe on the home lab Gitea instance. +func (c *Client) GetIssueBody(owner, repo string, issue int64) (string, error) { + iss, _, err := c.api.GetIssue(owner, repo, issue) + if err != nil { + return "", log.E("gitea.GetIssueBody", "failed to get issue body", err) + } + + return iss.Body, nil +} diff --git a/pkg/gitea/repos.go b/pkg/gitea/repos.go new file mode 100644 index 00000000..d70e5598 --- /dev/null +++ b/pkg/gitea/repos.go @@ -0,0 +1,110 @@ +package gitea + +import ( + "code.gitea.io/sdk/gitea" + + "github.com/host-uk/core/pkg/log" +) + +// ListOrgRepos returns all repositories for the given organisation. +func (c *Client) ListOrgRepos(org string) ([]*gitea.Repository, error) { + var all []*gitea.Repository + page := 1 + + for { + repos, resp, err := c.api.ListOrgRepos(org, gitea.ListOrgReposOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, log.E("gitea.ListOrgRepos", "failed to list org repos", err) + } + + all = append(all, repos...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + +// ListUserRepos returns all repositories for the authenticated user. +func (c *Client) ListUserRepos() ([]*gitea.Repository, error) { + var all []*gitea.Repository + page := 1 + + for { + repos, resp, err := c.api.ListMyRepos(gitea.ListReposOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, log.E("gitea.ListUserRepos", "failed to list user repos", err) + } + + all = append(all, repos...) + + if resp == nil || page >= resp.LastPage { + break + } + page++ + } + + return all, nil +} + +// GetRepo returns a single repository by owner and name. +func (c *Client) GetRepo(owner, name string) (*gitea.Repository, error) { + repo, _, err := c.api.GetRepo(owner, name) + if err != nil { + return nil, log.E("gitea.GetRepo", "failed to get repo", err) + } + + return repo, nil +} + +// CreateMirror creates a mirror repository on Gitea from a GitHub clone URL. +// This uses the Gitea migration API to set up a pull mirror. +// If authToken is provided, it is used to authenticate against the source (e.g. for private GitHub repos). +func (c *Client) CreateMirror(owner, name, cloneURL, authToken string) (*gitea.Repository, error) { + opts := gitea.MigrateRepoOption{ + RepoName: name, + RepoOwner: owner, + CloneAddr: cloneURL, + Service: gitea.GitServiceGithub, + Mirror: true, + Description: "Mirror of " + cloneURL, + } + + if authToken != "" { + opts.AuthToken = authToken + } + + repo, _, err := c.api.MigrateRepo(opts) + if err != nil { + return nil, log.E("gitea.CreateMirror", "failed to create mirror", err) + } + + return repo, nil +} + +// DeleteRepo deletes a repository from Gitea. +func (c *Client) DeleteRepo(owner, name string) error { + _, err := c.api.DeleteRepo(owner, name) + if err != nil { + return log.E("gitea.DeleteRepo", "failed to delete repo", err) + } + + return nil +} + +// CreateOrgRepo creates a new empty repository under an organisation. +func (c *Client) CreateOrgRepo(org string, opts gitea.CreateRepoOption) (*gitea.Repository, error) { + repo, _, err := c.api.CreateOrgRepo(org, opts) + if err != nil { + return nil, log.E("gitea.CreateOrgRepo", "failed to create org repo", err) + } + + return repo, nil +} diff --git a/pkg/io/bench_test.go b/pkg/io/bench_test.go new file mode 100644 index 00000000..df242678 --- /dev/null +++ b/pkg/io/bench_test.go @@ -0,0 +1,34 @@ +package io + +import ( + "testing" +) + +func BenchmarkMockMedium_Write(b *testing.B) { + m := NewMockMedium() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = m.Write("test.txt", "some content") + } +} + +func BenchmarkMockMedium_Read(b *testing.B) { + m := NewMockMedium() + _ = m.Write("test.txt", "some content") + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = m.Read("test.txt") + } +} + +func BenchmarkMockMedium_List(b *testing.B) { + m := NewMockMedium() + _ = m.EnsureDir("dir") + for i := 0; i < 100; i++ { + _ = m.Write("dir/file"+string(rune(i))+".txt", "content") + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = m.List("dir") + } +} diff --git a/pkg/log/errors.go b/pkg/log/errors.go index c6775521..af55a429 100644 --- a/pkg/log/errors.go +++ b/pkg/log/errors.go @@ -174,6 +174,37 @@ func Root(err error) error { } } +// StackTrace returns the logical stack trace (chain of operations) from an error. +// It returns an empty slice if no operational context is found. +func StackTrace(err error) []string { + var stack []string + for err != nil { + if e, ok := err.(*Err); ok { + if e.Op != "" { + stack = append(stack, e.Op) + } + } + err = errors.Unwrap(err) + } + return stack +} + +// FormatStackTrace returns a pretty-printed logical stack trace. +func FormatStackTrace(err error) string { + stack := StackTrace(err) + if len(stack) == 0 { + return "" + } + var res string + for i, op := range stack { + if i > 0 { + res += " -> " + } + res += op + } + return res +} + // --- Combined Log-and-Return Helpers --- // LogError logs an error at Error level and returns a wrapped error. diff --git a/pkg/log/errors_test.go b/pkg/log/errors_test.go index 96cbd12f..b403cfd2 100644 --- a/pkg/log/errors_test.go +++ b/pkg/log/errors_test.go @@ -3,6 +3,7 @@ package log import ( "bytes" "errors" + "fmt" "strings" "testing" @@ -303,3 +304,46 @@ func TestMust_Ugly_Panics(t *testing.T) { output := buf.String() assert.True(t, strings.Contains(output, "[ERR]") || len(output) > 0) } + +func TestStackTrace_Good(t *testing.T) { + // Nested operations + err := E("op1", "msg1", nil) + err = Wrap(err, "op2", "msg2") + err = Wrap(err, "op3", "msg3") + + stack := StackTrace(err) + assert.Equal(t, []string{"op3", "op2", "op1"}, stack) + + // Format + formatted := FormatStackTrace(err) + assert.Equal(t, "op3 -> op2 -> op1", formatted) +} + +func TestStackTrace_PlainError(t *testing.T) { + err := errors.New("plain error") + assert.Empty(t, StackTrace(err)) + assert.Empty(t, FormatStackTrace(err)) +} + +func TestStackTrace_Nil(t *testing.T) { + assert.Empty(t, StackTrace(nil)) + assert.Empty(t, FormatStackTrace(nil)) +} + +func TestStackTrace_NoOp(t *testing.T) { + err := &Err{Msg: "no op"} + assert.Empty(t, StackTrace(err)) + assert.Empty(t, FormatStackTrace(err)) +} + +func TestStackTrace_Mixed(t *testing.T) { + err := E("inner", "msg", nil) + err = errors.New("middle: " + err.Error()) // Breaks the chain if not handled properly, but Unwrap should work if it's a wrapped error + // Wait, errors.New doesn't wrap. fmt.Errorf("%w") does. + err = E("inner", "msg", nil) + err = fmt.Errorf("wrapper: %w", err) + err = Wrap(err, "outer", "msg") + + stack := StackTrace(err) + assert.Equal(t, []string{"outer", "inner"}, stack) +} diff --git a/pkg/mcp/integration_test.go b/pkg/mcp/integration_test.go new file mode 100644 index 00000000..de35e66e --- /dev/null +++ b/pkg/mcp/integration_test.go @@ -0,0 +1,121 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntegration_FileTools(t *testing.T) { + tmpDir := t.TempDir() + s, err := New(WithWorkspaceRoot(tmpDir)) + assert.NoError(t, err) + + ctx := context.Background() + + // 1. Test file_write + writeInput := WriteFileInput{ + Path: "test.txt", + Content: "hello world", + } + _, writeOutput, err := s.writeFile(ctx, nil, writeInput) + assert.NoError(t, err) + assert.True(t, writeOutput.Success) + assert.Equal(t, "test.txt", writeOutput.Path) + + // Verify on disk + content, _ := os.ReadFile(filepath.Join(tmpDir, "test.txt")) + assert.Equal(t, "hello world", string(content)) + + // 2. Test file_read + readInput := ReadFileInput{ + Path: "test.txt", + } + _, readOutput, err := s.readFile(ctx, nil, readInput) + assert.NoError(t, err) + assert.Equal(t, "hello world", readOutput.Content) + assert.Equal(t, "plaintext", readOutput.Language) + + // 3. Test file_edit (replace_all=false) + editInput := EditDiffInput{ + Path: "test.txt", + OldString: "world", + NewString: "mcp", + } + _, editOutput, err := s.editDiff(ctx, nil, editInput) + assert.NoError(t, err) + assert.True(t, editOutput.Success) + assert.Equal(t, 1, editOutput.Replacements) + + // Verify change + _, readOutput, _ = s.readFile(ctx, nil, readInput) + assert.Equal(t, "hello mcp", readOutput.Content) + + // 4. Test file_edit (replace_all=true) + _ = s.medium.Write("multi.txt", "abc abc abc") + editInputMulti := EditDiffInput{ + Path: "multi.txt", + OldString: "abc", + NewString: "xyz", + ReplaceAll: true, + } + _, editOutput, err = s.editDiff(ctx, nil, editInputMulti) + assert.NoError(t, err) + assert.Equal(t, 3, editOutput.Replacements) + + content, _ = os.ReadFile(filepath.Join(tmpDir, "multi.txt")) + assert.Equal(t, "xyz xyz xyz", string(content)) + + // 5. Test dir_list + _ = s.medium.EnsureDir("subdir") + _ = s.medium.Write("subdir/file1.txt", "content1") + + listInput := ListDirectoryInput{ + Path: "subdir", + } + _, listOutput, err := s.listDirectory(ctx, nil, listInput) + assert.NoError(t, err) + assert.Len(t, listOutput.Entries, 1) + assert.Equal(t, "file1.txt", listOutput.Entries[0].Name) + assert.False(t, listOutput.Entries[0].IsDir) +} + +func TestIntegration_ErrorPaths(t *testing.T) { + tmpDir := t.TempDir() + s, err := New(WithWorkspaceRoot(tmpDir)) + assert.NoError(t, err) + + ctx := context.Background() + + // Read nonexistent file + _, _, err = s.readFile(ctx, nil, ReadFileInput{Path: "nonexistent.txt"}) + assert.Error(t, err) + + // Edit nonexistent file + _, _, err = s.editDiff(ctx, nil, EditDiffInput{ + Path: "nonexistent.txt", + OldString: "foo", + NewString: "bar", + }) + assert.Error(t, err) + + // Edit with empty old_string + _, _, err = s.editDiff(ctx, nil, EditDiffInput{ + Path: "test.txt", + OldString: "", + NewString: "bar", + }) + assert.Error(t, err) + + // Edit with old_string not found + _ = s.medium.Write("test.txt", "hello") + _, _, err = s.editDiff(ctx, nil, EditDiffInput{ + Path: "test.txt", + OldString: "missing", + NewString: "bar", + }) + assert.Error(t, err) +} diff --git a/pkg/unifi/client.go b/pkg/unifi/client.go new file mode 100644 index 00000000..13b15d34 --- /dev/null +++ b/pkg/unifi/client.go @@ -0,0 +1,53 @@ +package unifi + +import ( + "crypto/tls" + "net/http" + + uf "github.com/unpoller/unifi/v5" + + "github.com/host-uk/core/pkg/log" +) + +// Client wraps the unpoller UniFi client with config-based auth. +type Client struct { + api *uf.Unifi + url string +} + +// New creates a new UniFi API client for the given controller URL and credentials. +// TLS verification can be disabled via the insecure parameter (useful for self-signed certs on home lab controllers). +func New(url, user, pass, apikey string, insecure bool) (*Client, error) { + cfg := &uf.Config{ + URL: url, + User: user, + Pass: pass, + APIKey: apikey, + } + + // Skip TLS verification if requested (e.g. for self-signed certs) + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: insecure, + MinVersion: tls.VersionTLS12, + }, + }, + } + + api, err := uf.NewUnifi(cfg) + if err != nil { + return nil, log.E("unifi.New", "failed to create client", err) + } + + // Override the HTTP client to skip TLS verification + api.Client = httpClient + + return &Client{api: api, url: url}, nil +} + +// API exposes the underlying SDK client for direct access. +func (c *Client) API() *uf.Unifi { return c.api } + +// URL returns the UniFi controller URL. +func (c *Client) URL() string { return c.url } diff --git a/pkg/unifi/clients.go b/pkg/unifi/clients.go new file mode 100644 index 00000000..74e1ca2d --- /dev/null +++ b/pkg/unifi/clients.go @@ -0,0 +1,64 @@ +package unifi + +import ( + uf "github.com/unpoller/unifi/v5" + + "github.com/host-uk/core/pkg/log" +) + +// ClientFilter controls which clients are returned. +type ClientFilter struct { + Site string // Filter by site name (empty = all sites) + Wired bool // Show only wired clients + Wireless bool // Show only wireless clients +} + +// GetClients returns connected clients from the UniFi controller, +// optionally filtered by site and connection type. +func (c *Client) GetClients(filter ClientFilter) ([]*uf.Client, error) { + sites, err := c.getSitesForFilter(filter.Site) + if err != nil { + return nil, err + } + + clients, err := c.api.GetClients(sites) + if err != nil { + return nil, log.E("unifi.GetClients", "failed to fetch clients", err) + } + + // Apply wired/wireless filter + if filter.Wired || filter.Wireless { + var filtered []*uf.Client + for _, cl := range clients { + if filter.Wired && cl.IsWired.Val { + filtered = append(filtered, cl) + } else if filter.Wireless && !cl.IsWired.Val { + filtered = append(filtered, cl) + } + } + return filtered, nil + } + + return clients, nil +} + +// getSitesForFilter resolves sites by name or returns all sites. +func (c *Client) getSitesForFilter(siteName string) ([]*uf.Site, error) { + sites, err := c.GetSites() + if err != nil { + return nil, err + } + + if siteName == "" { + return sites, nil + } + + // Filter to matching site + for _, s := range sites { + if s.Name == siteName { + return []*uf.Site{s}, nil + } + } + + return nil, log.E("unifi.getSitesForFilter", "site not found: "+siteName, nil) +} diff --git a/pkg/unifi/config.go b/pkg/unifi/config.go new file mode 100644 index 00000000..727b739e --- /dev/null +++ b/pkg/unifi/config.go @@ -0,0 +1,145 @@ +// Package unifi provides a thin wrapper around the unpoller/unifi Go SDK +// for managing UniFi network controllers, devices, and connected clients. +// +// Authentication is resolved from config file, environment variables, or flag overrides: +// +// 1. ~/.core/config.yaml keys: unifi.url, unifi.user, unifi.pass, unifi.apikey +// 2. UNIFI_URL + UNIFI_USER + UNIFI_PASS + UNIFI_APIKEY environment variables (override config file) +// 3. Flag overrides via core unifi config --url/--user/--pass/--apikey (highest priority) +package unifi + +import ( + "os" + + "github.com/host-uk/core/pkg/config" + "github.com/host-uk/core/pkg/log" +) + +const ( + // ConfigKeyURL is the config key for the UniFi controller URL. + ConfigKeyURL = "unifi.url" + // ConfigKeyUser is the config key for the UniFi username. + ConfigKeyUser = "unifi.user" + // ConfigKeyPass is the config key for the UniFi password. + ConfigKeyPass = "unifi.pass" + // ConfigKeyAPIKey is the config key for the UniFi API key. + ConfigKeyAPIKey = "unifi.apikey" + // ConfigKeyInsecure is the config key for allowing insecure TLS connections. + ConfigKeyInsecure = "unifi.insecure" + + // DefaultURL is the default UniFi controller URL. + DefaultURL = "https://10.69.1.1" +) + +// NewFromConfig creates a UniFi client using the standard config resolution: +// +// 1. ~/.core/config.yaml keys: unifi.url, unifi.user, unifi.pass, unifi.apikey, unifi.insecure +// 2. UNIFI_URL + UNIFI_USER + UNIFI_PASS + UNIFI_APIKEY + UNIFI_INSECURE environment variables (override config file) +// 3. Provided flag overrides (highest priority; pass nil to skip) +func NewFromConfig(flagURL, flagUser, flagPass, flagAPIKey string, flagInsecure *bool) (*Client, error) { + url, user, pass, apikey, insecure, err := ResolveConfig(flagURL, flagUser, flagPass, flagAPIKey, flagInsecure) + if err != nil { + return nil, err + } + + if user == "" && apikey == "" { + return nil, log.E("unifi.NewFromConfig", "no credentials configured (set UNIFI_USER/UNIFI_PASS or UNIFI_APIKEY, or run: core unifi config)", nil) + } + + return New(url, user, pass, apikey, insecure) +} + +// ResolveConfig resolves the UniFi URL and credentials from all config sources. +// Flag values take highest priority, then env vars, then config file. +func ResolveConfig(flagURL, flagUser, flagPass, flagAPIKey string, flagInsecure *bool) (url, user, pass, apikey string, insecure bool, err error) { + // Start with config file values + cfg, cfgErr := config.New() + if cfgErr == nil { + _ = cfg.Get(ConfigKeyURL, &url) + _ = cfg.Get(ConfigKeyUser, &user) + _ = cfg.Get(ConfigKeyPass, &pass) + _ = cfg.Get(ConfigKeyAPIKey, &apikey) + _ = cfg.Get(ConfigKeyInsecure, &insecure) + } + + // Overlay environment variables + if envURL := os.Getenv("UNIFI_URL"); envURL != "" { + url = envURL + } + if envUser := os.Getenv("UNIFI_USER"); envUser != "" { + user = envUser + } + if envPass := os.Getenv("UNIFI_PASS"); envPass != "" { + pass = envPass + } + if envAPIKey := os.Getenv("UNIFI_APIKEY"); envAPIKey != "" { + apikey = envAPIKey + } + if envInsecure := os.Getenv("UNIFI_INSECURE"); envInsecure != "" { + insecure = envInsecure == "true" || envInsecure == "1" + } + + // Overlay flag values (highest priority) + if flagURL != "" { + url = flagURL + } + if flagUser != "" { + user = flagUser + } + if flagPass != "" { + pass = flagPass + } + if flagAPIKey != "" { + apikey = flagAPIKey + } + if flagInsecure != nil { + insecure = *flagInsecure + } + + // Default URL if nothing configured + if url == "" { + url = DefaultURL + } + + return url, user, pass, apikey, insecure, nil +} + +// SaveConfig persists the UniFi URL and/or credentials to the config file. +func SaveConfig(url, user, pass, apikey string, insecure *bool) error { + cfg, err := config.New() + if err != nil { + return log.E("unifi.SaveConfig", "failed to load config", err) + } + + if url != "" { + if err := cfg.Set(ConfigKeyURL, url); err != nil { + return log.E("unifi.SaveConfig", "failed to save URL", err) + } + } + + if user != "" { + if err := cfg.Set(ConfigKeyUser, user); err != nil { + return log.E("unifi.SaveConfig", "failed to save user", err) + } + } + + if pass != "" { + if err := cfg.Set(ConfigKeyPass, pass); err != nil { + return log.E("unifi.SaveConfig", "failed to save password", err) + } + } + + if apikey != "" { + if err := cfg.Set(ConfigKeyAPIKey, apikey); err != nil { + return log.E("unifi.SaveConfig", "failed to save API key", err) + } + } + + if insecure != nil { + if err := cfg.Set(ConfigKeyInsecure, *insecure); err != nil { + return log.E("unifi.SaveConfig", "failed to save insecure flag", err) + } + } + + return nil +} diff --git a/pkg/unifi/devices.go b/pkg/unifi/devices.go new file mode 100644 index 00000000..0e4e1940 --- /dev/null +++ b/pkg/unifi/devices.go @@ -0,0 +1,116 @@ +package unifi + +import ( + uf "github.com/unpoller/unifi/v5" + + "github.com/host-uk/core/pkg/log" +) + +// DeviceInfo is a flat representation of any UniFi infrastructure device. +type DeviceInfo struct { + Name string + IP string + Mac string + Model string + Version string + Type string // uap, usw, usg, udm, uxg + Status int // 1 = online +} + +// GetDevices returns the raw device container for a site (or all sites). +func (c *Client) GetDevices(siteName string) (*uf.Devices, error) { + sites, err := c.getSitesForFilter(siteName) + if err != nil { + return nil, err + } + + devices, err := c.api.GetDevices(sites) + if err != nil { + return nil, log.E("unifi.GetDevices", "failed to fetch devices", err) + } + + return devices, nil +} + +// GetDeviceList returns a flat list of all infrastructure devices, +// optionally filtered by device type (uap, usw, usg, udm, uxg). +func (c *Client) GetDeviceList(siteName, deviceType string) ([]DeviceInfo, error) { + devices, err := c.GetDevices(siteName) + if err != nil { + return nil, err + } + + var list []DeviceInfo + + if deviceType == "" || deviceType == "uap" { + for _, d := range devices.UAPs { + list = append(list, DeviceInfo{ + Name: d.Name, + IP: d.IP, + Mac: d.Mac, + Model: d.Model, + Version: d.Version, + Type: "uap", + Status: d.State.Int(), + }) + } + } + + if deviceType == "" || deviceType == "usw" { + for _, d := range devices.USWs { + list = append(list, DeviceInfo{ + Name: d.Name, + IP: d.IP, + Mac: d.Mac, + Model: d.Model, + Version: d.Version, + Type: "usw", + Status: d.State.Int(), + }) + } + } + + if deviceType == "" || deviceType == "usg" { + for _, d := range devices.USGs { + list = append(list, DeviceInfo{ + Name: d.Name, + IP: d.IP, + Mac: d.Mac, + Model: d.Model, + Version: d.Version, + Type: "usg", + Status: d.State.Int(), + }) + } + } + + if deviceType == "" || deviceType == "udm" { + for _, d := range devices.UDMs { + list = append(list, DeviceInfo{ + Name: d.Name, + IP: d.IP, + Mac: d.Mac, + Model: d.Model, + Version: d.Version, + Type: "udm", + Status: d.State.Int(), + }) + } + } + + if deviceType == "" || deviceType == "uxg" { + for _, d := range devices.UXGs { + list = append(list, DeviceInfo{ + Name: d.Name, + IP: d.IP, + Mac: d.Mac, + Model: d.Model, + Version: d.Version, + Type: "uxg", + Status: d.State.Int(), + }) + } + } + + return list, nil +} diff --git a/pkg/unifi/networks.go b/pkg/unifi/networks.go new file mode 100644 index 00000000..3ff33b75 --- /dev/null +++ b/pkg/unifi/networks.go @@ -0,0 +1,62 @@ +package unifi + +import ( + "encoding/json" + "fmt" + + "github.com/host-uk/core/pkg/log" +) + +// NetworkConf represents a UniFi network configuration entry. +type NetworkConf struct { + ID string `json:"_id"` + Name string `json:"name"` + Purpose string `json:"purpose"` // wan, corporate, remote-user-vpn + IPSubnet string `json:"ip_subnet"` // CIDR (e.g. "10.69.1.1/24") + VLAN int `json:"vlan"` // VLAN ID (0 = untagged) + VLANEnabled bool `json:"vlan_enabled"` // Whether VLAN tagging is active + Enabled bool `json:"enabled"` + NetworkGroup string `json:"networkgroup"` // LAN, WAN, WAN2 + NetworkIsolationEnabled bool `json:"network_isolation_enabled"` + InternetAccessEnabled bool `json:"internet_access_enabled"` + IsNAT bool `json:"is_nat"` + DHCPEnabled bool `json:"dhcpd_enabled"` + DHCPStart string `json:"dhcpd_start"` + DHCPStop string `json:"dhcpd_stop"` + DHCPDNS1 string `json:"dhcpd_dns_1"` + DHCPDNS2 string `json:"dhcpd_dns_2"` + DHCPDNSEnabled bool `json:"dhcpd_dns_enabled"` + MDNSEnabled bool `json:"mdns_enabled"` + FirewallZoneID string `json:"firewall_zone_id"` + GatewayType string `json:"gateway_type"` + VPNType string `json:"vpn_type"` + WANType string `json:"wan_type"` // pppoe, dhcp, static + WANNetworkGroup string `json:"wan_networkgroup"` +} + +// networkConfResponse is the raw API response wrapper. +type networkConfResponse struct { + Data []NetworkConf `json:"data"` +} + +// GetNetworks returns all network configurations from the controller. +// Uses the raw controller API for the full networkconf data. +func (c *Client) GetNetworks(siteName string) ([]NetworkConf, error) { + if siteName == "" { + siteName = "default" + } + + path := fmt.Sprintf("/api/s/%s/rest/networkconf", siteName) + + raw, err := c.api.GetJSON(path) + if err != nil { + return nil, log.E("unifi.GetNetworks", "failed to fetch networks", err) + } + + var resp networkConfResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, log.E("unifi.GetNetworks", "failed to parse networks", err) + } + + return resp.Data, nil +} diff --git a/pkg/unifi/routes.go b/pkg/unifi/routes.go new file mode 100644 index 00000000..6454b163 --- /dev/null +++ b/pkg/unifi/routes.go @@ -0,0 +1,66 @@ +package unifi + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/host-uk/core/pkg/log" +) + +// Route represents a single entry in the UniFi gateway routing table. +type Route struct { + Network string `json:"pfx"` // CIDR prefix (e.g. "10.69.1.0/24") + NextHop string `json:"nh"` // Next-hop address or interface + Interface string `json:"intf"` // Interface name (e.g. "br0", "eth4") + Type string `json:"type"` // Route type (e.g. "S" static, "C" connected, "K" kernel) + Distance int `json:"distance"` // Administrative distance + Metric int `json:"metric"` // Route metric + Uptime int `json:"uptime"` // Uptime in seconds + Selected bool `json:"fib"` // Whether route is in the forwarding table +} + +// routeResponse is the raw API response wrapper. +type routeResponse struct { + Data []Route `json:"data"` +} + +// GetRoutes returns the active routing table from the gateway for the given site. +// Uses the raw controller API since unpoller doesn't wrap this endpoint. +func (c *Client) GetRoutes(siteName string) ([]Route, error) { + if siteName == "" { + siteName = "default" + } + + path := fmt.Sprintf("/api/s/%s/stat/routing", url.PathEscape(siteName)) + + raw, err := c.api.GetJSON(path) + if err != nil { + return nil, log.E("unifi.GetRoutes", "failed to fetch routing table", err) + } + + var resp routeResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, log.E("unifi.GetRoutes", "failed to parse routing table", err) + } + + return resp.Data, nil +} + +// RouteTypeName returns a human-readable name for the route type code. +func RouteTypeName(code string) string { + switch code { + case "S": + return "static" + case "C": + return "connected" + case "K": + return "kernel" + case "B": + return "bgp" + case "O": + return "ospf" + default: + return code + } +} diff --git a/pkg/unifi/sites.go b/pkg/unifi/sites.go new file mode 100644 index 00000000..7162b791 --- /dev/null +++ b/pkg/unifi/sites.go @@ -0,0 +1,17 @@ +package unifi + +import ( + uf "github.com/unpoller/unifi/v5" + + "github.com/host-uk/core/pkg/log" +) + +// GetSites returns all sites from the UniFi controller. +func (c *Client) GetSites() ([]*uf.Site, error) { + sites, err := c.api.GetSites() + if err != nil { + return nil, log.E("unifi.GetSites", "failed to fetch sites", err) + } + + return sites, nil +} diff --git a/pkg/workspace/service.go b/pkg/workspace/service.go new file mode 100644 index 00000000..67e37233 --- /dev/null +++ b/pkg/workspace/service.go @@ -0,0 +1,148 @@ +package workspace + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "sync" + + core "github.com/host-uk/core/pkg/framework/core" + "github.com/host-uk/core/pkg/io" +) + +// Service implements the core.Workspace interface. +type Service struct { + core *core.Core + activeWorkspace string + rootPath string + medium io.Medium + mu sync.RWMutex +} + +// New creates a new Workspace service instance. +func New(c *core.Core) (any, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, core.E("workspace.New", "failed to determine home directory", err) + } + rootPath := filepath.Join(home, ".core", "workspaces") + + s := &Service{ + core: c, + rootPath: rootPath, + medium: io.Local, + } + + if err := s.medium.EnsureDir(rootPath); err != nil { + return nil, core.E("workspace.New", "failed to ensure root directory", err) + } + + return s, nil +} + +// CreateWorkspace creates a new encrypted workspace. +// Identifier is hashed (SHA-256 as proxy for LTHN) to create the directory name. +// A PGP keypair is generated using the password. +func (s *Service) CreateWorkspace(identifier, password string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // 1. Identification (LTHN hash proxy) + hash := sha256.Sum256([]byte(identifier)) + wsID := hex.EncodeToString(hash[:]) + wsPath := filepath.Join(s.rootPath, wsID) + + if s.medium.Exists(wsPath) { + return "", core.E("workspace.CreateWorkspace", "workspace already exists", nil) + } + + // 2. Directory structure + dirs := []string{"config", "log", "data", "files", "keys"} + for _, d := range dirs { + if err := s.medium.EnsureDir(filepath.Join(wsPath, d)); err != nil { + return "", core.E("workspace.CreateWorkspace", "failed to create directory: "+d, err) + } + } + + // 3. PGP Keypair generation + crypt, err := s.core.Crypt() + if err != nil { + return "", core.E("workspace.CreateWorkspace", "failed to retrieve crypt service", err) + } + privKey, err := crypt.CreateKeyPair(identifier, password) + if err != nil { + return "", core.E("workspace.CreateWorkspace", "failed to generate keys", err) + } + + // Save private key + if err := s.medium.Write(filepath.Join(wsPath, "keys", "private.key"), privKey); err != nil { + return "", core.E("workspace.CreateWorkspace", "failed to save private key", err) + } + + return wsID, nil +} + +// SwitchWorkspace changes the active workspace. +func (s *Service) SwitchWorkspace(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + + wsPath := filepath.Join(s.rootPath, name) + if !s.medium.IsDir(wsPath) { + return core.E("workspace.SwitchWorkspace", "workspace not found: "+name, nil) + } + + s.activeWorkspace = name + return nil +} + +// WorkspaceFileGet retrieves the content of a file from the active workspace. +// In a full implementation, this would involve decryption using the workspace key. +func (s *Service) WorkspaceFileGet(filename string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.activeWorkspace == "" { + return "", core.E("workspace.WorkspaceFileGet", "no active workspace", nil) + } + + path := filepath.Join(s.rootPath, s.activeWorkspace, "files", filename) + return s.medium.Read(path) +} + +// WorkspaceFileSet saves content to a file in the active workspace. +// In a full implementation, this would involve encryption using the workspace key. +func (s *Service) WorkspaceFileSet(filename, content string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.activeWorkspace == "" { + return core.E("workspace.WorkspaceFileSet", "no active workspace", nil) + } + + path := filepath.Join(s.rootPath, s.activeWorkspace, "files", filename) + return s.medium.Write(path, content) +} + +// HandleIPCEvents handles workspace-related IPC messages. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + switch m := msg.(type) { + case map[string]any: + action, _ := m["action"].(string) + switch action { + case "workspace.create": + id, _ := m["identifier"].(string) + pass, _ := m["password"].(string) + _, err := s.CreateWorkspace(id, pass) + return err + case "workspace.switch": + name, _ := m["name"].(string) + return s.SwitchWorkspace(name) + } + } + return nil +} + +// Ensure Service implements core.Workspace. +var _ core.Workspace = (*Service)(nil) diff --git a/pkg/workspace/service_test.go b/pkg/workspace/service_test.go new file mode 100644 index 00000000..c8b89457 --- /dev/null +++ b/pkg/workspace/service_test.go @@ -0,0 +1,55 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/host-uk/core/pkg/crypt/openpgp" + core "github.com/host-uk/core/pkg/framework/core" + "github.com/stretchr/testify/assert" +) + +func TestWorkspace(t *testing.T) { + // Setup core with crypt service + c, _ := core.New( + core.WithName("crypt", openpgp.New), + ) + + tempHome, _ := os.MkdirTemp("", "core-test-home") + defer os.RemoveAll(tempHome) + + // Mock os.UserHomeDir by setting HOME env + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tempHome) + defer os.Setenv("HOME", oldHome) + + s_any, err := New(c) + assert.NoError(t, err) + s := s_any.(*Service) + + // Test CreateWorkspace + id, err := s.CreateWorkspace("test-user", "pass123") + assert.NoError(t, err) + assert.NotEmpty(t, id) + + wsPath := filepath.Join(tempHome, ".core", "workspaces", id) + assert.DirExists(t, wsPath) + assert.DirExists(t, filepath.Join(wsPath, "keys")) + assert.FileExists(t, filepath.Join(wsPath, "keys", "private.key")) + + // Test SwitchWorkspace + err = s.SwitchWorkspace(id) + assert.NoError(t, err) + assert.Equal(t, id, s.activeWorkspace) + + // Test File operations + filename := "secret.txt" + content := "top secret info" + err = s.WorkspaceFileSet(filename, content) + assert.NoError(t, err) + + got, err := s.WorkspaceFileGet(filename) + assert.NoError(t, err) + assert.Equal(t, content, got) +}