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 <developers@lethean.io>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Vi 2026-02-05 11:16:23 +00:00 committed by GitHub
parent 6d65b70e0c
commit 4a1600e9be
41 changed files with 3835 additions and 0 deletions

97
docs/faq.md Normal file
View file

@ -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

100
docs/user-guide.md Normal file
View file

@ -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).

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 <owner/repo>",
Short: "List and manage issues",
Long: "List issues for a repository, or create a new issue.",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
owner, repo, err := splitOwnerRepo(args[0])
if err != nil {
return err
}
// If title is set, create an issue instead
if issuesTitle != "" {
return runCreateIssue(owner, repo)
}
return runListIssues(owner, repo)
},
}
cmd.Flags().StringVar(&issuesState, "state", "open", "Filter by state (open, closed, all)")
cmd.Flags().StringVar(&issuesTitle, "title", "", "Create issue with this title")
cmd.Flags().StringVar(&issuesBody, "body", "", "Issue body (used with --title)")
parent.AddCommand(cmd)
}
func runListIssues(owner, repo string) error {
client, err := gt.NewFromConfig("", "")
if err != nil {
return err
}
issues, err := client.ListIssues(owner, repo, gt.ListIssuesOpts{
State: issuesState,
})
if err != nil {
return err
}
if len(issues) == 0 {
cli.Text(fmt.Sprintf("No %s issues in %s/%s.", issuesState, owner, repo))
return nil
}
cli.Blank()
cli.Print(" %s\n\n", fmt.Sprintf("%d %s issues in %s/%s", len(issues), issuesState, owner, repo))
for _, issue := range issues {
printGiteaIssue(issue, owner, repo)
}
return nil
}
func runCreateIssue(owner, repo string) error {
client, err := gt.NewFromConfig("", "")
if err != nil {
return err
}
issue, err := client.CreateIssue(owner, repo, gitea.CreateIssueOption{
Title: issuesTitle,
Body: issuesBody,
})
if err != nil {
return err
}
cli.Blank()
cli.Success(fmt.Sprintf("Created issue #%d: %s", issue.Index, issue.Title))
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(issue.HTMLURL))
cli.Blank()
return nil
}
func printGiteaIssue(issue *gitea.Issue, owner, repo string) {
num := numberStyle.Render(fmt.Sprintf("#%d", issue.Index))
title := valueStyle.Render(cli.Truncate(issue.Title, 60))
line := fmt.Sprintf(" %s %s", num, title)
// Add labels
if len(issue.Labels) > 0 {
var labels []string
for _, l := range issue.Labels {
labels = append(labels, l.Name)
}
line += " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]")
}
// Add assignees
if len(issue.Assignees) > 0 {
var assignees []string
for _, a := range issue.Assignees {
assignees = append(assignees, "@"+a.UserName)
}
line += " " + infoStyle.Render(strings.Join(assignees, ", "))
}
cli.Text(line)
}
// splitOwnerRepo splits "owner/repo" into its parts.
func splitOwnerRepo(s string) (string, string, error) {
parts := strings.SplitN(s, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", cli.Err("expected format: owner/repo (got %q)", s)
}
return parts[0], parts[1], nil
}

View file

@ -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 <github-owner/repo>",
Short: "Mirror a GitHub repo to Gitea",
Long: `Create a pull mirror of a GitHub repository on your Gitea instance.
The mirror will be created under the specified Gitea organisation (or your user account).
Gitea will periodically sync changes from GitHub.
For private repos, a GitHub token is needed. By default it uses 'gh auth token'.`,
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
owner, repo, err := splitOwnerRepo(args[0])
if err != nil {
return err
}
return runMirror(owner, repo)
},
}
cmd.Flags().StringVar(&mirrorOrg, "org", "", "Gitea organisation to mirror into (default: your user account)")
cmd.Flags().StringVar(&mirrorGHToken, "github-token", "", "GitHub token for private repos (default: from gh auth token)")
parent.AddCommand(cmd)
}
func runMirror(githubOwner, githubRepo string) error {
client, err := gt.NewFromConfig("", "")
if err != nil {
return err
}
cloneURL := fmt.Sprintf("https://github.com/%s/%s.git", githubOwner, githubRepo)
// Determine target owner on Gitea
targetOwner := mirrorOrg
if targetOwner == "" {
user, _, err := client.API().GetMyUserInfo()
if err != nil {
return cli.WrapVerb(err, "get", "current user")
}
targetOwner = user.UserName
}
// Resolve GitHub token for source auth
ghToken := mirrorGHToken
if ghToken == "" {
ghToken = resolveGHToken()
}
cli.Print(" Mirroring %s/%s -> %s/%s on Gitea...\n", githubOwner, githubRepo, targetOwner, githubRepo)
repo, err := client.CreateMirror(targetOwner, githubRepo, cloneURL, ghToken)
if err != nil {
return err
}
cli.Blank()
cli.Success(fmt.Sprintf("Mirror created: %s", repo.FullName))
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(repo.HTMLURL))
cli.Print(" %s %s\n", dimStyle.Render("Clone:"), valueStyle.Render(repo.CloneURL))
cli.Blank()
return nil
}
// resolveGHToken tries to get a GitHub token from the gh CLI.
func resolveGHToken() string {
out, err := exec.Command("gh", "auth", "token").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

View file

@ -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 <owner/repo>",
Short: "List pull requests",
Long: "List pull requests for a repository.",
Args: cli.ExactArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
owner, repo, err := splitOwnerRepo(args[0])
if err != nil {
return err
}
return runListPRs(owner, repo)
},
}
cmd.Flags().StringVar(&prsState, "state", "open", "Filter by state (open, closed, all)")
parent.AddCommand(cmd)
}
func runListPRs(owner, repo string) error {
client, err := gt.NewFromConfig("", "")
if err != nil {
return err
}
prs, err := client.ListPullRequests(owner, repo, prsState)
if err != nil {
return err
}
if len(prs) == 0 {
cli.Text(fmt.Sprintf("No %s pull requests in %s/%s.", prsState, owner, repo))
return nil
}
cli.Blank()
cli.Print(" %s\n\n", fmt.Sprintf("%d %s pull requests in %s/%s", len(prs), prsState, owner, repo))
for _, pr := range prs {
printGiteaPR(pr)
}
return nil
}
func printGiteaPR(pr *sdk.PullRequest) {
num := numberStyle.Render(fmt.Sprintf("#%d", pr.Index))
title := valueStyle.Render(cli.Truncate(pr.Title, 50))
var author string
if pr.Poster != nil {
author = infoStyle.Render("@" + pr.Poster.UserName)
}
// Branch info
branch := dimStyle.Render(pr.Head.Ref + " -> " + pr.Base.Ref)
// Merge status
var status string
if pr.HasMerged {
status = successStyle.Render("merged")
} else if pr.State == sdk.StateClosed {
status = errorStyle.Render("closed")
} else {
status = warningStyle.Render("open")
}
// Labels
var labelStr string
if len(pr.Labels) > 0 {
var labels []string
for _, l := range pr.Labels {
labels = append(labels, l.Name)
}
labelStr = " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]")
}
cli.Print(" %s %s %s %s %s%s\n", num, title, author, status, branch, labelStr)
}

View file

@ -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
}

View file

@ -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> [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 }

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

36
pkg/ansible/ssh_test.go Normal file
View file

@ -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)
}

164
pkg/cli/app_test.go Normal file
View file

@ -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())
})
}

View file

@ -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)

View file

@ -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)
}

68
pkg/devops/ssh_utils.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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{}

37
pkg/gitea/client.go Normal file
View file

@ -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 }

92
pkg/gitea/config.go Normal file
View file

@ -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
}

109
pkg/gitea/issues.go Normal file
View file

@ -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
}

146
pkg/gitea/meta.go Normal file
View file

@ -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
}

110
pkg/gitea/repos.go Normal file
View file

@ -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
}

34
pkg/io/bench_test.go Normal file
View file

@ -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")
}
}

View file

@ -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.

View file

@ -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)
}

121
pkg/mcp/integration_test.go Normal file
View file

@ -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)
}

53
pkg/unifi/client.go Normal file
View file

@ -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 }

64
pkg/unifi/clients.go Normal file
View file

@ -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)
}

145
pkg/unifi/config.go Normal file
View file

@ -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
}

116
pkg/unifi/devices.go Normal file
View file

@ -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
}

62
pkg/unifi/networks.go Normal file
View file

@ -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
}

66
pkg/unifi/routes.go Normal file
View file

@ -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
}
}

17
pkg/unifi/sites.go Normal file
View file

@ -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
}

148
pkg/workspace/service.go Normal file
View file

@ -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)

View file

@ -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)
}