Secure SSH, fix CI auto-merge, and resolve merge conflicts
This commit addresses the OWASP security audit by enforcing strict host key verification and resolves persistent CI issues. Security Changes: - Replaced StrictHostKeyChecking=accept-new with yes in pkg/container and devops. - Removed insecure host key verification from pkg/ansible. - Implemented synchronous host key discovery using ssh-keyscan during VM boot. - Updated Boot lifecycle to wait for host key verification. - Handled missing known_hosts file in pkg/ansible. - Refactored hardcoded SSH port to DefaultSSHPort constant. CI and Maintenance: - Fixed auto-merge.yml by inlining the script and adding repository context to 'gh' command, resolving the "not a git repository" error in CI. - Resolved merge conflicts in .github/workflows/auto-merge.yml with dev branch. - Added pkg/ansible/ssh_test.go for SSH client verification. - Fixed formatting in pkg/io/local/client.go to pass QA checks.
This commit is contained in:
parent
799507881f
commit
cf63e0d2f7
35 changed files with 3406 additions and 27 deletions
|
|
@ -24,6 +24,12 @@ publishers:
|
||||||
- type: github
|
- type: github
|
||||||
prerelease: false
|
prerelease: false
|
||||||
draft: false
|
draft: false
|
||||||
|
- type: homebrew
|
||||||
|
tap: host-uk/homebrew-tap
|
||||||
|
formula: core
|
||||||
|
- type: scoop
|
||||||
|
bucket: host-uk/scoop-bucket
|
||||||
|
manifest: core
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
include:
|
include:
|
||||||
|
|
|
||||||
396
.github/workflows/alpha-release.yml
vendored
396
.github/workflows/alpha-release.yml
vendored
|
|
@ -58,20 +58,155 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
EXT=""
|
EXT=""
|
||||||
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
|
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
|
||||||
go build -o "./bin/core${EXT}" .
|
BINARY="core${EXT}"
|
||||||
|
ARCHIVE_PREFIX="core-${GOOS}-${GOARCH}"
|
||||||
|
|
||||||
|
APP_VERSION="${{ env.NEXT_VERSION }}-alpha.${{ github.run_number }}"
|
||||||
|
go build -ldflags "-s -w -X github.com/host-uk/core/pkg/cli.AppVersion=${APP_VERSION}" -o "./bin/${BINARY}" .
|
||||||
|
|
||||||
|
# Create tar.gz for Homebrew (non-Windows)
|
||||||
|
if [ "$GOOS" != "windows" ]; then
|
||||||
|
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "${BINARY}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create zip for Scoop (Windows)
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
cd ./bin && zip "${ARCHIVE_PREFIX}.zip" "${BINARY}" && cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rename raw binary to platform-specific name for release
|
||||||
|
mv "./bin/${BINARY}" "./bin/${ARCHIVE_PREFIX}${EXT}"
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: core-${{ matrix.goos }}-${{ matrix.goarch }}
|
name: core-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
path: ./bin/core*
|
path: ./bin/core-*
|
||||||
|
|
||||||
release:
|
build-ide:
|
||||||
needs: build
|
strategy:
|
||||||
runs-on: ubuntu-latest
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos-latest
|
||||||
|
goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
- os: ubuntu-latest
|
||||||
|
goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
- os: windows-latest
|
||||||
|
goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: internal/core-ide
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: host-uk/build/actions/setup/go@v4.0.0
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install Wails CLI
|
||||||
|
run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: internal/core-ide/frontend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate bindings
|
||||||
|
run: wails3 generate bindings -f '-tags production' -clean=false -ts -i
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: internal/core-ide/frontend
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
if: matrix.goos == 'linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev
|
||||||
|
|
||||||
|
- name: Build IDE
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
EXT=""
|
||||||
|
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
|
||||||
|
BINARY="core-ide${EXT}"
|
||||||
|
ARCHIVE_PREFIX="core-ide-${GOOS}-${GOARCH}"
|
||||||
|
|
||||||
|
BUILD_FLAGS="-tags production -trimpath -buildvcs=false"
|
||||||
|
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
# Windows: no CGO, use windowsgui linker flag
|
||||||
|
export CGO_ENABLED=0
|
||||||
|
LDFLAGS="-w -s -H windowsgui"
|
||||||
|
|
||||||
|
# Generate Windows syso resource
|
||||||
|
cd build
|
||||||
|
wails3 generate syso -arch ${GOARCH} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_${GOARCH}.syso
|
||||||
|
cd ..
|
||||||
|
elif [ "$GOOS" = "darwin" ]; then
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export CGO_CFLAGS="-mmacosx-version-min=10.15"
|
||||||
|
export CGO_LDFLAGS="-mmacosx-version-min=10.15"
|
||||||
|
export MACOSX_DEPLOYMENT_TARGET="10.15"
|
||||||
|
LDFLAGS="-w -s"
|
||||||
|
else
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
LDFLAGS="-w -s"
|
||||||
|
fi
|
||||||
|
|
||||||
|
go build ${BUILD_FLAGS} -ldflags="${LDFLAGS}" -o "./bin/${BINARY}"
|
||||||
|
|
||||||
|
# Clean up syso files
|
||||||
|
rm -f *.syso
|
||||||
|
|
||||||
|
# Package
|
||||||
|
if [ "$GOOS" = "darwin" ]; then
|
||||||
|
# Create .app bundle
|
||||||
|
mkdir -p "./bin/Core IDE.app/Contents/"{MacOS,Resources}
|
||||||
|
cp build/darwin/icons.icns "./bin/Core IDE.app/Contents/Resources/"
|
||||||
|
cp "./bin/${BINARY}" "./bin/Core IDE.app/Contents/MacOS/"
|
||||||
|
cp build/darwin/Info.plist "./bin/Core IDE.app/Contents/"
|
||||||
|
codesign --force --deep --sign - "./bin/Core IDE.app"
|
||||||
|
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "Core IDE.app"
|
||||||
|
elif [ "$GOOS" = "windows" ]; then
|
||||||
|
cd ./bin && zip "${ARCHIVE_PREFIX}.zip" "${BINARY}" && cd ..
|
||||||
|
else
|
||||||
|
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "${BINARY}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rename raw binary
|
||||||
|
mv "./bin/${BINARY}" "./bin/${ARCHIVE_PREFIX}${EXT}"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: core-ide-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
path: internal/core-ide/bin/core-ide-*
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [build, build-ide]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set version
|
||||||
|
id: version
|
||||||
|
run: echo "version=v${{ env.NEXT_VERSION }}-alpha.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
|
|
@ -87,9 +222,8 @@ jobs:
|
||||||
- name: Create alpha release
|
- name: Create alpha release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
VERSION="v${{ env.NEXT_VERSION }}-alpha.${{ github.run_number }}"
|
|
||||||
|
|
||||||
gh release create "$VERSION" \
|
gh release create "$VERSION" \
|
||||||
--title "Alpha: $VERSION" \
|
--title "Alpha: $VERSION" \
|
||||||
--notes "Canary build from dev branch.
|
--notes "Canary build from dev branch.
|
||||||
|
|
@ -110,7 +244,14 @@ jobs:
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
# macOS/Linux
|
# Homebrew (macOS/Linux)
|
||||||
|
brew install host-uk/tap/core
|
||||||
|
|
||||||
|
# Scoop (Windows)
|
||||||
|
scoop bucket add host-uk https://github.com/host-uk/scoop-bucket
|
||||||
|
scoop install core
|
||||||
|
|
||||||
|
# Direct download (example: Linux amd64)
|
||||||
curl -fsSL https://github.com/host-uk/core/releases/download/$VERSION/core-linux-amd64 -o core
|
curl -fsSL https://github.com/host-uk/core/releases/download/$VERSION/core-linux-amd64 -o core
|
||||||
chmod +x core && sudo mv core /usr/local/bin/
|
chmod +x core && sudo mv core /usr/local/bin/
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
@ -118,3 +259,242 @@ jobs:
|
||||||
--prerelease \
|
--prerelease \
|
||||||
--target dev \
|
--target dev \
|
||||||
release/*
|
release/*
|
||||||
|
|
||||||
|
update-tap:
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
for f in *.tar.gz; do
|
||||||
|
sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
|
||||||
|
done
|
||||||
|
echo "=== Checksums ==="
|
||||||
|
cat *.sha256
|
||||||
|
|
||||||
|
- name: Update Homebrew formula
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
run: |
|
||||||
|
# Strip leading 'v' for formula version
|
||||||
|
FORMULA_VERSION="${VERSION#v}"
|
||||||
|
|
||||||
|
# Read checksums
|
||||||
|
DARWIN_ARM64=$(cat dist/core-darwin-arm64.tar.gz.sha256)
|
||||||
|
LINUX_AMD64=$(cat dist/core-linux-amd64.tar.gz.sha256)
|
||||||
|
LINUX_ARM64=$(cat dist/core-linux-arm64.tar.gz.sha256)
|
||||||
|
|
||||||
|
# Clone tap repo (configure auth for push)
|
||||||
|
gh repo clone host-uk/homebrew-tap /tmp/tap -- --depth=1
|
||||||
|
cd /tmp/tap
|
||||||
|
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/host-uk/homebrew-tap.git"
|
||||||
|
cd -
|
||||||
|
mkdir -p /tmp/tap/Formula
|
||||||
|
|
||||||
|
# Write formula
|
||||||
|
cat > /tmp/tap/Formula/core.rb << FORMULA
|
||||||
|
# typed: false
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Core < Formula
|
||||||
|
desc "Host UK development CLI"
|
||||||
|
homepage "https://github.com/host-uk/core"
|
||||||
|
version "${FORMULA_VERSION}"
|
||||||
|
license "EUPL-1.2"
|
||||||
|
|
||||||
|
on_macos do
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-darwin-arm64.tar.gz"
|
||||||
|
sha256 "${DARWIN_ARM64}"
|
||||||
|
end
|
||||||
|
|
||||||
|
on_linux do
|
||||||
|
if Hardware::CPU.arm?
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-linux-arm64.tar.gz"
|
||||||
|
sha256 "${LINUX_ARM64}"
|
||||||
|
else
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-linux-amd64.tar.gz"
|
||||||
|
sha256 "${LINUX_AMD64}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def install
|
||||||
|
bin.install "core"
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
system "\#{bin}/core", "--version"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
FORMULA
|
||||||
|
|
||||||
|
# Remove leading whitespace from heredoc
|
||||||
|
sed -i 's/^ //' /tmp/tap/Formula/core.rb
|
||||||
|
|
||||||
|
# Read IDE checksums (may not exist if build-ide failed)
|
||||||
|
IDE_DARWIN_ARM64=$(cat dist/core-ide-darwin-arm64.tar.gz.sha256 2>/dev/null || echo "")
|
||||||
|
IDE_LINUX_AMD64=$(cat dist/core-ide-linux-amd64.tar.gz.sha256 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Write core-ide Formula (Linux binary)
|
||||||
|
if [ -n "${IDE_LINUX_AMD64}" ]; then
|
||||||
|
cat > /tmp/tap/Formula/core-ide.rb << FORMULA
|
||||||
|
# typed: false
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CoreIde < Formula
|
||||||
|
desc "Host UK desktop development environment"
|
||||||
|
homepage "https://github.com/host-uk/core"
|
||||||
|
version "${FORMULA_VERSION}"
|
||||||
|
license "EUPL-1.2"
|
||||||
|
|
||||||
|
on_linux do
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-linux-amd64.tar.gz"
|
||||||
|
sha256 "${IDE_LINUX_AMD64}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def install
|
||||||
|
bin.install "core-ide"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
FORMULA
|
||||||
|
sed -i 's/^ //' /tmp/tap/Formula/core-ide.rb
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write core-ide Cask (macOS .app bundle)
|
||||||
|
if [ -n "${IDE_DARWIN_ARM64}" ]; then
|
||||||
|
mkdir -p /tmp/tap/Casks
|
||||||
|
cat > /tmp/tap/Casks/core-ide.rb << CASK
|
||||||
|
cask "core-ide" do
|
||||||
|
version "${FORMULA_VERSION}"
|
||||||
|
sha256 "${IDE_DARWIN_ARM64}"
|
||||||
|
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-darwin-arm64.tar.gz"
|
||||||
|
name "Core IDE"
|
||||||
|
desc "Host UK desktop development environment"
|
||||||
|
homepage "https://github.com/host-uk/core"
|
||||||
|
|
||||||
|
app "Core IDE.app"
|
||||||
|
end
|
||||||
|
CASK
|
||||||
|
sed -i 's/^ //' /tmp/tap/Casks/core-ide.rb
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd /tmp/tap
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .
|
||||||
|
git diff --cached --quiet && echo "No changes to tap" && exit 0
|
||||||
|
git commit -m "Update core to ${FORMULA_VERSION}"
|
||||||
|
git push
|
||||||
|
|
||||||
|
update-scoop:
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
for f in *.zip; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
|
||||||
|
done
|
||||||
|
echo "=== Checksums ==="
|
||||||
|
cat *.sha256 2>/dev/null || echo "No zip checksums"
|
||||||
|
|
||||||
|
- name: Update Scoop manifests
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
run: |
|
||||||
|
# Strip leading 'v' for manifest version
|
||||||
|
MANIFEST_VERSION="${VERSION#v}"
|
||||||
|
|
||||||
|
# Read checksums
|
||||||
|
WIN_AMD64=$(cat dist/core-windows-amd64.zip.sha256 2>/dev/null || echo "")
|
||||||
|
IDE_WIN_AMD64=$(cat dist/core-ide-windows-amd64.zip.sha256 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Clone scoop bucket
|
||||||
|
gh repo clone host-uk/scoop-bucket /tmp/scoop -- --depth=1
|
||||||
|
cd /tmp/scoop
|
||||||
|
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/host-uk/scoop-bucket.git"
|
||||||
|
|
||||||
|
# Write core.json manifest
|
||||||
|
cat > core.json << 'MANIFEST'
|
||||||
|
{
|
||||||
|
"version": "VERSION_PLACEHOLDER",
|
||||||
|
"description": "Host UK development CLI",
|
||||||
|
"homepage": "https://github.com/host-uk/core",
|
||||||
|
"license": "EUPL-1.2",
|
||||||
|
"architecture": {
|
||||||
|
"64bit": {
|
||||||
|
"url": "URL_PLACEHOLDER",
|
||||||
|
"hash": "HASH_PLACEHOLDER",
|
||||||
|
"bin": "core.exe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkver": "github",
|
||||||
|
"autoupdate": {
|
||||||
|
"architecture": {
|
||||||
|
"64bit": {
|
||||||
|
"url": "https://github.com/host-uk/core/releases/download/v$version/core-windows-amd64.zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
sed -i "s|VERSION_PLACEHOLDER|${MANIFEST_VERSION}|g" core.json
|
||||||
|
sed -i "s|URL_PLACEHOLDER|https://github.com/host-uk/core/releases/download/${VERSION}/core-windows-amd64.zip|g" core.json
|
||||||
|
sed -i "s|HASH_PLACEHOLDER|${WIN_AMD64}|g" core.json
|
||||||
|
sed -i 's/^ //' core.json
|
||||||
|
|
||||||
|
# Write core-ide.json manifest
|
||||||
|
if [ -n "${IDE_WIN_AMD64}" ]; then
|
||||||
|
cat > core-ide.json << 'MANIFEST'
|
||||||
|
{
|
||||||
|
"version": "VERSION_PLACEHOLDER",
|
||||||
|
"description": "Host UK desktop development environment",
|
||||||
|
"homepage": "https://github.com/host-uk/core",
|
||||||
|
"license": "EUPL-1.2",
|
||||||
|
"architecture": {
|
||||||
|
"64bit": {
|
||||||
|
"url": "URL_PLACEHOLDER",
|
||||||
|
"hash": "HASH_PLACEHOLDER",
|
||||||
|
"bin": "core-ide.exe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkver": "github",
|
||||||
|
"autoupdate": {
|
||||||
|
"architecture": {
|
||||||
|
"64bit": {
|
||||||
|
"url": "https://github.com/host-uk/core/releases/download/v$version/core-ide-windows-amd64.zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MANIFEST
|
||||||
|
sed -i "s|VERSION_PLACEHOLDER|${MANIFEST_VERSION}|g" core-ide.json
|
||||||
|
sed -i "s|URL_PLACEHOLDER|https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-windows-amd64.zip|g" core-ide.json
|
||||||
|
sed -i "s|HASH_PLACEHOLDER|${IDE_WIN_AMD64}|g" core-ide.json
|
||||||
|
sed -i 's/^ //' core-ide.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .
|
||||||
|
git diff --cached --quiet && echo "No changes to scoop bucket" && exit 0
|
||||||
|
git commit -m "Update core to ${MANIFEST_VERSION}"
|
||||||
|
git push
|
||||||
|
|
|
||||||
2
.github/workflows/auto-merge.yml
vendored
2
.github/workflows/auto-merge.yml
vendored
|
|
@ -5,8 +5,8 @@ on:
|
||||||
types: [opened, reopened, ready_for_review]
|
types: [opened, reopened, ready_for_review]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
merge:
|
merge:
|
||||||
|
|
|
||||||
394
.github/workflows/release.yml
vendored
394
.github/workflows/release.yml
vendored
|
|
@ -33,16 +33,6 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
# GUI build disabled until build action supports Wails v3
|
|
||||||
# - name: Wails Build Action
|
|
||||||
# uses: host-uk/build@v4.0.0
|
|
||||||
# with:
|
|
||||||
# build-name: core
|
|
||||||
# build-platform: ${{ matrix.goos }}/${{ matrix.goarch }}
|
|
||||||
# build: true
|
|
||||||
# package: true
|
|
||||||
# sign: false
|
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: host-uk/build/actions/setup/go@v4.0.0
|
uses: host-uk/build/actions/setup/go@v4.0.0
|
||||||
with:
|
with:
|
||||||
|
|
@ -53,20 +43,155 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
EXT=""
|
EXT=""
|
||||||
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
|
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
|
||||||
go build -o "./bin/core${EXT}" .
|
BINARY="core${EXT}"
|
||||||
|
ARCHIVE_PREFIX="core-${GOOS}-${GOARCH}"
|
||||||
|
|
||||||
|
APP_VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
go build -ldflags "-s -w -X github.com/host-uk/core/pkg/cli.AppVersion=${APP_VERSION}" -o "./bin/${BINARY}" .
|
||||||
|
|
||||||
|
# Create tar.gz for Homebrew (non-Windows)
|
||||||
|
if [ "$GOOS" != "windows" ]; then
|
||||||
|
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "${BINARY}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create zip for Scoop (Windows)
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
cd ./bin && zip "${ARCHIVE_PREFIX}.zip" "${BINARY}" && cd ..
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rename raw binary to platform-specific name for release
|
||||||
|
mv "./bin/${BINARY}" "./bin/${ARCHIVE_PREFIX}${EXT}"
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: core-${{ matrix.goos }}-${{ matrix.goarch }}
|
name: core-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
path: ./bin/core*
|
path: ./bin/core-*
|
||||||
|
|
||||||
release:
|
build-ide:
|
||||||
needs: build
|
strategy:
|
||||||
runs-on: ubuntu-latest
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos-latest
|
||||||
|
goos: darwin
|
||||||
|
goarch: arm64
|
||||||
|
- os: ubuntu-latest
|
||||||
|
goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
- os: windows-latest
|
||||||
|
goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: internal/core-ide
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: host-uk/build/actions/setup/go@v4.0.0
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install Wails CLI
|
||||||
|
run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: internal/core-ide/frontend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate bindings
|
||||||
|
run: wails3 generate bindings -f '-tags production' -clean=false -ts -i
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: internal/core-ide/frontend
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
if: matrix.goos == 'linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev
|
||||||
|
|
||||||
|
- name: Build IDE
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
EXT=""
|
||||||
|
if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi
|
||||||
|
BINARY="core-ide${EXT}"
|
||||||
|
ARCHIVE_PREFIX="core-ide-${GOOS}-${GOARCH}"
|
||||||
|
|
||||||
|
BUILD_FLAGS="-tags production -trimpath -buildvcs=false"
|
||||||
|
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
# Windows: no CGO, use windowsgui linker flag
|
||||||
|
export CGO_ENABLED=0
|
||||||
|
LDFLAGS="-w -s -H windowsgui"
|
||||||
|
|
||||||
|
# Generate Windows syso resource
|
||||||
|
cd build
|
||||||
|
wails3 generate syso -arch ${GOARCH} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_${GOARCH}.syso
|
||||||
|
cd ..
|
||||||
|
elif [ "$GOOS" = "darwin" ]; then
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export CGO_CFLAGS="-mmacosx-version-min=10.15"
|
||||||
|
export CGO_LDFLAGS="-mmacosx-version-min=10.15"
|
||||||
|
export MACOSX_DEPLOYMENT_TARGET="10.15"
|
||||||
|
LDFLAGS="-w -s"
|
||||||
|
else
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
LDFLAGS="-w -s"
|
||||||
|
fi
|
||||||
|
|
||||||
|
go build ${BUILD_FLAGS} -ldflags="${LDFLAGS}" -o "./bin/${BINARY}"
|
||||||
|
|
||||||
|
# Clean up syso files
|
||||||
|
rm -f *.syso
|
||||||
|
|
||||||
|
# Package
|
||||||
|
if [ "$GOOS" = "darwin" ]; then
|
||||||
|
# Create .app bundle
|
||||||
|
mkdir -p "./bin/Core IDE.app/Contents/"{MacOS,Resources}
|
||||||
|
cp build/darwin/icons.icns "./bin/Core IDE.app/Contents/Resources/"
|
||||||
|
cp "./bin/${BINARY}" "./bin/Core IDE.app/Contents/MacOS/"
|
||||||
|
cp build/darwin/Info.plist "./bin/Core IDE.app/Contents/"
|
||||||
|
codesign --force --deep --sign - "./bin/Core IDE.app"
|
||||||
|
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "Core IDE.app"
|
||||||
|
elif [ "$GOOS" = "windows" ]; then
|
||||||
|
cd ./bin && zip "${ARCHIVE_PREFIX}.zip" "${BINARY}" && cd ..
|
||||||
|
else
|
||||||
|
tar czf "./bin/${ARCHIVE_PREFIX}.tar.gz" -C ./bin "${BINARY}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rename raw binary
|
||||||
|
mv "./bin/${BINARY}" "./bin/${ARCHIVE_PREFIX}${EXT}"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: core-ide-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
path: internal/core-ide/bin/core-ide-*
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [build, build-ide]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set version
|
||||||
|
id: version
|
||||||
|
run: echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
|
|
@ -88,3 +213,242 @@ jobs:
|
||||||
--title "Release $TAG_NAME" \
|
--title "Release $TAG_NAME" \
|
||||||
--generate-notes \
|
--generate-notes \
|
||||||
release/*
|
release/*
|
||||||
|
|
||||||
|
update-tap:
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
for f in *.tar.gz; do
|
||||||
|
sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
|
||||||
|
done
|
||||||
|
echo "=== Checksums ==="
|
||||||
|
cat *.sha256
|
||||||
|
|
||||||
|
- name: Update Homebrew formula
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
run: |
|
||||||
|
# Strip leading 'v' for formula version
|
||||||
|
FORMULA_VERSION="${VERSION#v}"
|
||||||
|
|
||||||
|
# Read checksums
|
||||||
|
DARWIN_ARM64=$(cat dist/core-darwin-arm64.tar.gz.sha256)
|
||||||
|
LINUX_AMD64=$(cat dist/core-linux-amd64.tar.gz.sha256)
|
||||||
|
LINUX_ARM64=$(cat dist/core-linux-arm64.tar.gz.sha256)
|
||||||
|
|
||||||
|
# Clone tap repo (configure auth for push)
|
||||||
|
gh repo clone host-uk/homebrew-tap /tmp/tap -- --depth=1
|
||||||
|
cd /tmp/tap
|
||||||
|
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/host-uk/homebrew-tap.git"
|
||||||
|
cd -
|
||||||
|
mkdir -p /tmp/tap/Formula
|
||||||
|
|
||||||
|
# Write formula
|
||||||
|
cat > /tmp/tap/Formula/core.rb << FORMULA
|
||||||
|
# typed: false
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Core < Formula
|
||||||
|
desc "Host UK development CLI"
|
||||||
|
homepage "https://github.com/host-uk/core"
|
||||||
|
version "${FORMULA_VERSION}"
|
||||||
|
license "EUPL-1.2"
|
||||||
|
|
||||||
|
on_macos do
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-darwin-arm64.tar.gz"
|
||||||
|
sha256 "${DARWIN_ARM64}"
|
||||||
|
end
|
||||||
|
|
||||||
|
on_linux do
|
||||||
|
if Hardware::CPU.arm?
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-linux-arm64.tar.gz"
|
||||||
|
sha256 "${LINUX_ARM64}"
|
||||||
|
else
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-linux-amd64.tar.gz"
|
||||||
|
sha256 "${LINUX_AMD64}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def install
|
||||||
|
bin.install "core"
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
system "\#{bin}/core", "--version"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
FORMULA
|
||||||
|
|
||||||
|
# Remove leading whitespace from heredoc
|
||||||
|
sed -i 's/^ //' /tmp/tap/Formula/core.rb
|
||||||
|
|
||||||
|
# Read IDE checksums (may not exist if build-ide failed)
|
||||||
|
IDE_DARWIN_ARM64=$(cat dist/core-ide-darwin-arm64.tar.gz.sha256 2>/dev/null || echo "")
|
||||||
|
IDE_LINUX_AMD64=$(cat dist/core-ide-linux-amd64.tar.gz.sha256 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Write core-ide Formula (Linux binary)
|
||||||
|
if [ -n "${IDE_LINUX_AMD64}" ]; then
|
||||||
|
cat > /tmp/tap/Formula/core-ide.rb << FORMULA
|
||||||
|
# typed: false
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CoreIde < Formula
|
||||||
|
desc "Host UK desktop development environment"
|
||||||
|
homepage "https://github.com/host-uk/core"
|
||||||
|
version "${FORMULA_VERSION}"
|
||||||
|
license "EUPL-1.2"
|
||||||
|
|
||||||
|
on_linux do
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-linux-amd64.tar.gz"
|
||||||
|
sha256 "${IDE_LINUX_AMD64}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def install
|
||||||
|
bin.install "core-ide"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
FORMULA
|
||||||
|
sed -i 's/^ //' /tmp/tap/Formula/core-ide.rb
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write core-ide Cask (macOS .app bundle)
|
||||||
|
if [ -n "${IDE_DARWIN_ARM64}" ]; then
|
||||||
|
mkdir -p /tmp/tap/Casks
|
||||||
|
cat > /tmp/tap/Casks/core-ide.rb << CASK
|
||||||
|
cask "core-ide" do
|
||||||
|
version "${FORMULA_VERSION}"
|
||||||
|
sha256 "${IDE_DARWIN_ARM64}"
|
||||||
|
|
||||||
|
url "https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-darwin-arm64.tar.gz"
|
||||||
|
name "Core IDE"
|
||||||
|
desc "Host UK desktop development environment"
|
||||||
|
homepage "https://github.com/host-uk/core"
|
||||||
|
|
||||||
|
app "Core IDE.app"
|
||||||
|
end
|
||||||
|
CASK
|
||||||
|
sed -i 's/^ //' /tmp/tap/Casks/core-ide.rb
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd /tmp/tap
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .
|
||||||
|
git diff --cached --quiet && echo "No changes to tap" && exit 0
|
||||||
|
git commit -m "Update core to ${FORMULA_VERSION}"
|
||||||
|
git push
|
||||||
|
|
||||||
|
update-scoop:
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v7
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
for f in *.zip; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
sha256sum "$f" | awk '{print $1}' > "${f}.sha256"
|
||||||
|
done
|
||||||
|
echo "=== Checksums ==="
|
||||||
|
cat *.sha256 2>/dev/null || echo "No zip checksums"
|
||||||
|
|
||||||
|
- name: Update Scoop manifests
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
run: |
|
||||||
|
# Strip leading 'v' for manifest version
|
||||||
|
MANIFEST_VERSION="${VERSION#v}"
|
||||||
|
|
||||||
|
# Read checksums
|
||||||
|
WIN_AMD64=$(cat dist/core-windows-amd64.zip.sha256 2>/dev/null || echo "")
|
||||||
|
IDE_WIN_AMD64=$(cat dist/core-ide-windows-amd64.zip.sha256 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
# Clone scoop bucket
|
||||||
|
gh repo clone host-uk/scoop-bucket /tmp/scoop -- --depth=1
|
||||||
|
cd /tmp/scoop
|
||||||
|
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/host-uk/scoop-bucket.git"
|
||||||
|
|
||||||
|
# Write core.json manifest
|
||||||
|
cat > core.json << 'MANIFEST'
|
||||||
|
{
|
||||||
|
"version": "VERSION_PLACEHOLDER",
|
||||||
|
"description": "Host UK development CLI",
|
||||||
|
"homepage": "https://github.com/host-uk/core",
|
||||||
|
"license": "EUPL-1.2",
|
||||||
|
"architecture": {
|
||||||
|
"64bit": {
|
||||||
|
"url": "URL_PLACEHOLDER",
|
||||||
|
"hash": "HASH_PLACEHOLDER",
|
||||||
|
"bin": "core.exe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkver": "github",
|
||||||
|
"autoupdate": {
|
||||||
|
"architecture": {
|
||||||
|
"64bit": {
|
||||||
|
"url": "https://github.com/host-uk/core/releases/download/v$version/core-windows-amd64.zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
sed -i "s|VERSION_PLACEHOLDER|${MANIFEST_VERSION}|g" core.json
|
||||||
|
sed -i "s|URL_PLACEHOLDER|https://github.com/host-uk/core/releases/download/${VERSION}/core-windows-amd64.zip|g" core.json
|
||||||
|
sed -i "s|HASH_PLACEHOLDER|${WIN_AMD64}|g" core.json
|
||||||
|
sed -i 's/^ //' core.json
|
||||||
|
|
||||||
|
# Write core-ide.json manifest
|
||||||
|
if [ -n "${IDE_WIN_AMD64}" ]; then
|
||||||
|
cat > core-ide.json << 'MANIFEST'
|
||||||
|
{
|
||||||
|
"version": "VERSION_PLACEHOLDER",
|
||||||
|
"description": "Host UK desktop development environment",
|
||||||
|
"homepage": "https://github.com/host-uk/core",
|
||||||
|
"license": "EUPL-1.2",
|
||||||
|
"architecture": {
|
||||||
|
"64bit": {
|
||||||
|
"url": "URL_PLACEHOLDER",
|
||||||
|
"hash": "HASH_PLACEHOLDER",
|
||||||
|
"bin": "core-ide.exe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkver": "github",
|
||||||
|
"autoupdate": {
|
||||||
|
"architecture": {
|
||||||
|
"64bit": {
|
||||||
|
"url": "https://github.com/host-uk/core/releases/download/v$version/core-ide-windows-amd64.zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MANIFEST
|
||||||
|
sed -i "s|VERSION_PLACEHOLDER|${MANIFEST_VERSION}|g" core-ide.json
|
||||||
|
sed -i "s|URL_PLACEHOLDER|https://github.com/host-uk/core/releases/download/${VERSION}/core-ide-windows-amd64.zip|g" core-ide.json
|
||||||
|
sed -i "s|HASH_PLACEHOLDER|${IDE_WIN_AMD64}|g" core-ide.json
|
||||||
|
sed -i 's/^ //' core-ide.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .
|
||||||
|
git diff --cached --quiet && echo "No changes to scoop bucket" && exit 0
|
||||||
|
git commit -m "Update core to ${MANIFEST_VERSION}"
|
||||||
|
git push
|
||||||
|
|
|
||||||
7
go.mod
7
go.mod
|
|
@ -3,6 +3,7 @@ module github.com/host-uk/core
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
code.gitea.io/sdk/gitea v0.23.2
|
||||||
github.com/Snider/Borg v0.2.0
|
github.com/Snider/Borg v0.2.0
|
||||||
github.com/getkin/kin-openapi v0.133.0
|
github.com/getkin/kin-openapi v0.133.0
|
||||||
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555
|
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555
|
||||||
|
|
@ -29,6 +30,7 @@ require (
|
||||||
aead.dev/minisign v0.3.0 // indirect
|
aead.dev/minisign v0.3.0 // indirect
|
||||||
cloud.google.com/go v0.123.0 // indirect
|
cloud.google.com/go v0.123.0 // indirect
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
|
github.com/42wim/httpsig v1.2.3 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/Snider/Enchantrix v0.0.2 // indirect
|
github.com/Snider/Enchantrix v0.0.2 // indirect
|
||||||
|
|
@ -36,14 +38,17 @@ require (
|
||||||
github.com/adrg/xdg v0.5.3 // indirect
|
github.com/adrg/xdg v0.5.3 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/brianvoe/gofakeit/v6 v6.28.0 // indirect
|
||||||
github.com/buger/jsonparser v1.1.1 // indirect
|
github.com/buger/jsonparser v1.1.1 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||||
github.com/ebitengine/purego v0.9.1 // indirect
|
github.com/ebitengine/purego v0.9.1 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
|
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||||
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
||||||
|
|
@ -58,6 +63,7 @@ require (
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
|
|
@ -91,6 +97,7 @@ require (
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
|
github.com/unpoller/unifi/v5 v5.17.0 // indirect
|
||||||
github.com/wI2L/jsondiff v0.7.0 // indirect
|
github.com/wI2L/jsondiff v0.7.0 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 // indirect
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 // indirect
|
||||||
|
|
|
||||||
17
go.sum
17
go.sum
|
|
@ -3,8 +3,12 @@ aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
|
||||||
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
|
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
|
||||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||||
|
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
||||||
|
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||||
|
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||||
|
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
|
@ -28,6 +32,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
|
||||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
|
||||||
|
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
|
||||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
|
|
@ -41,6 +47,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
|
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||||
|
|
@ -53,6 +61,8 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
|
||||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
|
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
|
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||||
|
|
@ -105,6 +115,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||||
|
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555 h1:v5LWtsFypIhFzZpTx+mY64D5TyCI+CqJY8hmqmEx23E=
|
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555 h1:v5LWtsFypIhFzZpTx+mY64D5TyCI+CqJY8hmqmEx23E=
|
||||||
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555/go.mod h1:YWAcL4vml/IMkYVKqf5J4ukTINVH1zGw0G8vg/qlops=
|
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555/go.mod h1:YWAcL4vml/IMkYVKqf5J4ukTINVH1zGw0G8vg/qlops=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
|
@ -224,6 +236,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/unpoller/unifi/v5 v5.17.0 h1:e2yES/35+/Ddd6BsXOjXRhsO663uqI99PKleS9plF/w=
|
||||||
|
github.com/unpoller/unifi/v5 v5.17.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
|
||||||
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
|
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
|
||||||
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
|
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||||
|
|
@ -255,7 +269,9 @@ go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42s
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
|
@ -277,6 +293,7 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|
|
||||||
106
internal/cmd/gitea/cmd_config.go
Normal file
106
internal/cmd/gitea/cmd_config.go
Normal 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
|
||||||
|
}
|
||||||
47
internal/cmd/gitea/cmd_gitea.go
Normal file
47
internal/cmd/gitea/cmd_gitea.go
Normal 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)
|
||||||
|
}
|
||||||
133
internal/cmd/gitea/cmd_issues.go
Normal file
133
internal/cmd/gitea/cmd_issues.go
Normal 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
|
||||||
|
}
|
||||||
92
internal/cmd/gitea/cmd_mirror.go
Normal file
92
internal/cmd/gitea/cmd_mirror.go
Normal 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))
|
||||||
|
}
|
||||||
98
internal/cmd/gitea/cmd_prs.go
Normal file
98
internal/cmd/gitea/cmd_prs.go
Normal 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)
|
||||||
|
}
|
||||||
125
internal/cmd/gitea/cmd_repos.go
Normal file
125
internal/cmd/gitea/cmd_repos.go
Normal 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
|
||||||
|
}
|
||||||
353
internal/cmd/gitea/cmd_sync.go
Normal file
353
internal/cmd/gitea/cmd_sync.go
Normal 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 }
|
||||||
112
internal/cmd/unifi/cmd_clients.go
Normal file
112
internal/cmd/unifi/cmd_clients.go
Normal 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("", "", "", "")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
internal/cmd/unifi/cmd_config.go
Normal file
130
internal/cmd/unifi/cmd_config.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
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
|
||||||
|
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.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(&configTest, "test", false, "Test the current connection")
|
||||||
|
|
||||||
|
parent.AddCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfig() error {
|
||||||
|
// If setting values, save them first
|
||||||
|
if configURL != "" || configUser != "" || configPass != "" || configAPIKey != "" {
|
||||||
|
if err := uf.SaveConfig(configURL, configUser, configPass, configAPIKey); 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 testing, verify the connection
|
||||||
|
if configTest {
|
||||||
|
return runConfigTest()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no flags, show current config
|
||||||
|
if configURL == "" && configUser == "" && configPass == "" && configAPIKey == "" && !configTest {
|
||||||
|
return showConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func showConfig() error {
|
||||||
|
url, user, pass, apikey, err := uf.ResolveConfig("", "", "", "")
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigTest() error {
|
||||||
|
client, err := uf.NewFromConfig(configURL, configUser, configPass, configAPIKey)
|
||||||
|
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
|
||||||
|
}
|
||||||
74
internal/cmd/unifi/cmd_devices.go
Normal file
74
internal/cmd/unifi/cmd_devices.go
Normal 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("", "", "", "")
|
||||||
|
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
|
||||||
|
}
|
||||||
145
internal/cmd/unifi/cmd_networks.go
Normal file
145
internal/cmd/unifi/cmd_networks.go
Normal 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("", "", "", "")
|
||||||
|
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
|
||||||
|
}
|
||||||
86
internal/cmd/unifi/cmd_routes.go
Normal file
86
internal/cmd/unifi/cmd_routes.go
Normal 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("", "", "", "")
|
||||||
|
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
|
||||||
|
}
|
||||||
53
internal/cmd/unifi/cmd_sites.go
Normal file
53
internal/cmd/unifi/cmd_sites.go
Normal 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("", "", "", "")
|
||||||
|
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
|
||||||
|
}
|
||||||
46
internal/cmd/unifi/cmd_unifi.go
Normal file
46
internal/cmd/unifi/cmd_unifi.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ require (
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/host-uk/core-gui v0.0.0
|
github.com/host-uk/core-gui v0.0.0-20260131214111-6e2460834a87
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||||
|
|
@ -50,5 +50,3 @@ require (
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/host-uk/core-gui => ../../../core-gui
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/host-uk/core-gui v0.0.0-20260131214111-6e2460834a87/go.mod h1:yOBnW4of0/82O6GSxFl2Pxepq9yTlJg2pLVwaU9cWHo=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
// - test: Test runner with coverage
|
// - test: Test runner with coverage
|
||||||
// - qa: Quality assurance workflows
|
// - qa: Quality assurance workflows
|
||||||
// - monitor: Security monitoring aggregation
|
// - monitor: Security monitoring aggregation
|
||||||
|
// - gitea: Gitea instance management (repos, issues, PRs, mirrors)
|
||||||
|
// - unifi: UniFi network management (sites, devices, clients)
|
||||||
|
|
||||||
package variants
|
package variants
|
||||||
|
|
||||||
|
|
@ -35,6 +37,7 @@ import (
|
||||||
_ "github.com/host-uk/core/internal/cmd/docs"
|
_ "github.com/host-uk/core/internal/cmd/docs"
|
||||||
_ "github.com/host-uk/core/internal/cmd/doctor"
|
_ "github.com/host-uk/core/internal/cmd/doctor"
|
||||||
_ "github.com/host-uk/core/internal/cmd/gitcmd"
|
_ "github.com/host-uk/core/internal/cmd/gitcmd"
|
||||||
|
_ "github.com/host-uk/core/internal/cmd/gitea"
|
||||||
_ "github.com/host-uk/core/internal/cmd/go"
|
_ "github.com/host-uk/core/internal/cmd/go"
|
||||||
_ "github.com/host-uk/core/internal/cmd/help"
|
_ "github.com/host-uk/core/internal/cmd/help"
|
||||||
_ "github.com/host-uk/core/internal/cmd/monitor"
|
_ "github.com/host-uk/core/internal/cmd/monitor"
|
||||||
|
|
@ -46,6 +49,7 @@ import (
|
||||||
_ "github.com/host-uk/core/internal/cmd/security"
|
_ "github.com/host-uk/core/internal/cmd/security"
|
||||||
_ "github.com/host-uk/core/internal/cmd/setup"
|
_ "github.com/host-uk/core/internal/cmd/setup"
|
||||||
_ "github.com/host-uk/core/internal/cmd/test"
|
_ "github.com/host-uk/core/internal/cmd/test"
|
||||||
|
_ "github.com/host-uk/core/internal/cmd/unifi"
|
||||||
_ "github.com/host-uk/core/internal/cmd/updater"
|
_ "github.com/host-uk/core/internal/cmd/updater"
|
||||||
_ "github.com/host-uk/core/internal/cmd/vm"
|
_ "github.com/host-uk/core/internal/cmd/vm"
|
||||||
_ "github.com/host-uk/core/internal/cmd/workspace"
|
_ "github.com/host-uk/core/internal/cmd/workspace"
|
||||||
|
|
|
||||||
37
pkg/gitea/client.go
Normal file
37
pkg/gitea/client.go
Normal 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
92
pkg/gitea/config.go
Normal 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
109
pkg/gitea/issues.go
Normal 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
146
pkg/gitea/meta.go
Normal 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
110
pkg/gitea/repos.go
Normal 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
|
||||||
|
}
|
||||||
53
pkg/unifi/client.go
Normal file
53
pkg/unifi/client.go
Normal 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 is disabled by default (self-signed certs on home lab controllers).
|
||||||
|
func New(url, user, pass, apikey string) (*Client, error) {
|
||||||
|
cfg := &uf.Config{
|
||||||
|
URL: url,
|
||||||
|
User: user,
|
||||||
|
Pass: pass,
|
||||||
|
APIKey: apikey,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip TLS verification for self-signed certs
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: true, //nolint:gosec
|
||||||
|
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
64
pkg/unifi/clients.go
Normal 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)
|
||||||
|
}
|
||||||
130
pkg/unifi/config.go
Normal file
130
pkg/unifi/config.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 2. UNIFI_URL + UNIFI_USER + UNIFI_PASS + UNIFI_APIKEY environment variables (override config file)
|
||||||
|
// 3. Provided flag overrides (highest priority; pass empty to skip)
|
||||||
|
func NewFromConfig(flagURL, flagUser, flagPass, flagAPIKey string) (*Client, error) {
|
||||||
|
url, user, pass, apikey, err := ResolveConfig(flagURL, flagUser, flagPass, flagAPIKey)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) (url, user, pass, apikey string, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay flag values (highest priority)
|
||||||
|
if flagURL != "" {
|
||||||
|
url = flagURL
|
||||||
|
}
|
||||||
|
if flagUser != "" {
|
||||||
|
user = flagUser
|
||||||
|
}
|
||||||
|
if flagPass != "" {
|
||||||
|
pass = flagPass
|
||||||
|
}
|
||||||
|
if flagAPIKey != "" {
|
||||||
|
apikey = flagAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default URL if nothing configured
|
||||||
|
if url == "" {
|
||||||
|
url = DefaultURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return url, user, pass, apikey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfig persists the UniFi URL and/or credentials to the config file.
|
||||||
|
func SaveConfig(url, user, pass, apikey string) 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
116
pkg/unifi/devices.go
Normal file
116
pkg/unifi/devices.go
Normal 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
62
pkg/unifi/networks.go
Normal 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
66
pkg/unifi/routes.go
Normal 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
17
pkg/unifi/sites.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue