diff --git a/.core/release.yaml b/.core/release.yaml index b013c006..8cf86804 100644 --- a/.core/release.yaml +++ b/.core/release.yaml @@ -24,12 +24,6 @@ publishers: - type: github prerelease: false draft: false - - type: homebrew - tap: host-uk/homebrew-tap - formula: core - - type: scoop - bucket: host-uk/scoop-bucket - manifest: core changelog: include: diff --git a/.github/workflows/alpha-release.yml b/.github/workflows/alpha-release.yml index c75177c1..a5b24419 100644 --- a/.github/workflows/alpha-release.yml +++ b/.github/workflows/alpha-release.yml @@ -58,155 +58,20 @@ jobs: run: | EXT="" if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi - 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}" + go build -o "./bin/core${EXT}" . - name: Upload artifact uses: actions/upload-artifact@v4 with: name: core-${{ matrix.goos }}-${{ matrix.goarch }} - path: ./bin/core-* - - build-ide: - strategy: - 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: - - 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-* + path: ./bin/core* release: - needs: [build, build-ide] + needs: build 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 uses: actions/download-artifact@v7 with: @@ -222,8 +87,9 @@ jobs: - name: Create alpha release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.version }} run: | + VERSION="v${{ env.NEXT_VERSION }}-alpha.${{ github.run_number }}" + gh release create "$VERSION" \ --title "Alpha: $VERSION" \ --notes "Canary build from dev branch. @@ -244,14 +110,7 @@ jobs: ## Installation \`\`\`bash - # 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) + # macOS/Linux 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/ \`\`\` @@ -259,242 +118,3 @@ jobs: --prerelease \ --target dev \ 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 diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 0f4d11ec..9e00bab1 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -4,24 +4,22 @@ on: pull_request: types: [opened, reopened, ready_for_review] -permissions: - contents: write - pull-requests: write - jobs: merge: runs-on: ubuntu-latest - if: github.event.pull_request.draft == false + permissions: + contents: write + pull-requests: write steps: - name: Checkout - uses: actions/checkout@v4 - - - name: Enable auto-merge + uses: actions/checkout@v6 + - name: Auto Merge uses: actions/github-script@v7 env: PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} with: - github-token: ${{ secrets.GITHUB_TOKEN }} script: | const author = context.payload.pull_request.user.login; const association = context.payload.pull_request.author_association; @@ -30,22 +28,15 @@ jobs: const trustedBots = ['google-labs-jules[bot]']; const isTrustedBot = trustedBots.includes(author); - // Check author association from webhook payload + // Check author association from webhook payload (no API call needed) const trusted = ['MEMBER', 'OWNER', 'COLLABORATOR']; if (!isTrustedBot && !trusted.includes(association)) { core.info(`${author} is ${association} — skipping auto-merge`); return; } - try { - await exec.exec('gh', [ - 'pr', 'merge', process.env.PR_NUMBER, - '--auto', - '--merge', - '-R', `${context.repo.owner}/${context.repo.repo}` - ]); - core.info(`Auto-merge enabled for #${process.env.PR_NUMBER}`); - } catch (error) { - core.error(`Failed to enable auto-merge: ${error.message}`); - throw error; - } + await exec.exec('gh', [ + 'pr', 'merge', process.env.PR_NUMBER, + '--auto', + ]); + core.info(`Auto-merge enabled for #${process.env.PR_NUMBER}`); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97bf11e0..173e7c81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,16 @@ jobs: steps: - 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 uses: host-uk/build/actions/setup/go@v4.0.0 with: @@ -43,155 +53,20 @@ jobs: run: | EXT="" if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi - 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}" + go build -o "./bin/core${EXT}" . - name: Upload artifact uses: actions/upload-artifact@v4 with: name: core-${{ matrix.goos }}-${{ matrix.goarch }} - path: ./bin/core-* - - build-ide: - strategy: - 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: - - 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-* + path: ./bin/core* release: - needs: [build, build-ide] + needs: build 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 uses: actions/download-artifact@v7 with: @@ -213,242 +88,3 @@ jobs: --title "Release $TAG_NAME" \ --generate-notes \ 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 diff --git a/CLAUDE.md b/CLAUDE.md index 1361e2e5..a9b5d2b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Run a single test: `go test -run TestName ./...` ### Core Framework (`core.go`, `interfaces.go`) The `Core` struct is the central application container managing: -- **Services**: Named service registry with type-safe retrieval via `ServiceFor[T]()` +- **Services**: Named service registry with type-safe retrieval via `ServiceFor[T]()` and `MustServiceFor[T]()` - **Actions/IPC**: Message-passing system where services communicate via `ACTION(msg Message)` and register handlers via `RegisterAction()` - **Lifecycle**: Services implementing `Startable` (OnStartup) and/or `Stoppable` (OnShutdown) interfaces are automatically called during app lifecycle diff --git a/README.md b/README.md index 3aa1d35b..bd23ffcd 100644 --- a/README.md +++ b/README.md @@ -22,26 +22,7 @@ Core is an **opinionated Web3 desktop application framework** providing: **Mental model:** A secure, encrypted workspace manager where each "workspace" is a cryptographically isolated environment. The framework handles windows, menus, trays, config, and i18n. -## CLI Quick Start - -```bash -# 1. Install Core -go install github.com/host-uk/core/cmd/core@latest - -# 2. Verify environment -core doctor - -# 3. Run tests in any Go/PHP project -core go test # or core php test - -# 4. Build and preview release -core build -core ci -``` - -For more details, see the [User Guide](docs/user-guide.md). - -## Framework Quick Start (Go) +## Quick Start ```go import core "github.com/host-uk/core" @@ -412,24 +393,6 @@ Implementations: `local/`, `sftp/`, `webdav/` --- -## Getting Help - -- **[User Guide](docs/user-guide.md)**: Detailed usage and concepts. -- **[FAQ](docs/faq.md)**: Frequently asked questions. -- **[Workflows](docs/workflows.md)**: Common task sequences. -- **[Troubleshooting](docs/troubleshooting.md)**: Solving common issues. -- **[Configuration](docs/configuration.md)**: Config file reference. - -```bash -# Check environment -core doctor - -# Command help -core --help -``` - ---- - ## For New Contributors 1. Run `task test` to verify all tests pass diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index 54ba99c1..00000000 --- a/docs/faq.md +++ /dev/null @@ -1,97 +0,0 @@ -# Frequently Asked Questions (FAQ) - -Common questions and answers about the Core CLI and Framework. - -## General - -### What is Core? - -Core is a unified CLI and framework for building and managing Go, PHP, and Wails applications. It provides an opinionated set of tools for development, testing, building, and releasing projects within the host-uk ecosystem. - -### Is Core a CLI or a Framework? - -It is both. The Core Framework (`pkg/core`) is a library for building Go desktop applications with Wails. The Core CLI (`cmd/core`) is the tool you use to manage projects, run tests, build binaries, and handle multi-repository workspaces. - ---- - -## Installation - -### How do I install the Core CLI? - -The recommended way is via Go: - -```bash -go install github.com/host-uk/core/cmd/core@latest -``` - -Ensure your Go bin directory is in your PATH. See [Getting Started](getting-started.md) for more options. - -### I get "command not found: core" after installation. - -This usually means your Go bin directory is not in your system's PATH. Add it by adding this to your shell profile (`.bashrc`, `.zshrc`, etc.): - -```bash -export PATH="$PATH:$(go env GOPATH)/bin" -``` - ---- - -## Usage - -### Why does `core ci` not publish anything by default? - -Core is designed to be **safe by default**. `core ci` runs in dry-run mode to show you what would be published. To actually publish a release, you must use the `--we-are-go-for-launch` flag: - -```bash -core ci --we-are-go-for-launch -``` - -### How do I run tests for only one package? - -You can pass standard Go test flags to `core go test`: - -```bash -core go test ./pkg/my-package -``` - -### What is `core doctor` for? - -`core doctor` checks your development environment to ensure all required tools (Go, Git, Docker, etc.) are installed and correctly configured. It's the first thing you should run if something isn't working. - ---- - -## Configuration - -### Where is Core's configuration stored? - -- **Project-specific**: In the `.core/` directory within your project root. -- **Global**: In `~/.core/` or as defined by `CORE_CONFIG`. -- **Registry**: The `repos.yaml` file defines the multi-repo workspace. - -### How do I change the build targets? - -You can specify targets in `.core/release.yaml` or use the `--targets` flag with the `core build` command: - -```bash -core build --targets linux/amd64,darwin/arm64 -``` - ---- - -## Workspaces and Registry - -### What is a "workspace" in Core? - -In the context of the CLI, a workspace is a directory containing multiple repositories defined in a `repos.yaml` file. The `core dev` commands allow you to manage status, commits, and synchronization across all repositories in the workspace at once. - -### What is `repos.yaml`? - -`repos.yaml` is the "registry" for your workspace. It lists the repositories, their types (foundation, module, product), and their dependencies. Core uses this file to know which repositories to clone during `core setup`. - ---- - -## See Also - -- [Getting Started](getting-started.md) - Installation and first steps -- [User Guide](user-guide.md) - Detailed usage information -- [Troubleshooting](troubleshooting.md) - Solving common issues diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e3c892eb..c075f3a8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -293,30 +293,6 @@ go mod download --- -## AI and Agentic Issues - -### "ANTHROPIC_API_KEY not set" - -**Cause:** You're trying to use `core ai` or `core dev commit` (which uses Claude for messages) without an API key. - -**Fix:** - -```bash -export ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxx -``` - -### "failed to connect to Agentic API" - -**Cause:** Network issues or incorrect `AGENTIC_BASE_URL`. - -**Fix:** - -1. Check your internet connection -2. If using a custom endpoint, verify `AGENTIC_BASE_URL` -3. Ensure you are authenticated if required: `export AGENTIC_TOKEN=xxxx` - ---- - ## Getting More Help ### Enable Verbose Output diff --git a/docs/user-guide.md b/docs/user-guide.md deleted file mode 100644 index 3820d9aa..00000000 --- a/docs/user-guide.md +++ /dev/null @@ -1,100 +0,0 @@ -# User Guide - -This guide provides a comprehensive overview of how to use the Core CLI to manage your development workflow. - -## Key Concepts - -### Projects -A Project is a single repository containing code (Go, PHP, or Wails). Core helps you test, build, and release these projects using a consistent set of commands. - -### Workspaces -A Workspace is a collection of related projects. Core is designed to work across multiple repositories, allowing you to perform actions (like checking status or committing changes) on all of them at once. - -### Registry (`repos.yaml`) -The Registry is a configuration file that defines the repositories in your workspace. It includes information about where they are located on GitHub, their dependencies, and their purpose. - ---- - -## Daily Workflow - -### Working with a Single Project - -For a typical day-to-day development on a single project: - -1. **Verify your environment**: - ```bash - core doctor - ``` -2. **Run tests while you work**: - ```bash - core go test - ``` -3. **Keep code clean**: - ```bash - core go fmt --fix - core go lint - ``` -4. **Build and preview**: - ```bash - core build - ``` - -### Working with Multiple Repositories - -If you are working across many repositories in a workspace: - -1. **Check status of all repos**: - ```bash - core dev work --status - ``` -2. **Sync all changes**: - ```bash - core dev pull --all - ``` -3. **Commit and push everything**: - ```bash - core dev work - ``` - ---- - -## Building and Releasing - -Core separates the building of artifacts from the releasing of those artifacts. - -### 1. Build -The `core build` command detects your project type and builds binaries for your configured targets. Artifacts are placed in the `dist/` directory. - -### 2. Preview Release -Use `core ci` to see a summary of what would be included in a release (changelog, artifacts, etc.). This is a dry-run by default. - -### 3. Publish Release -When you are ready to publish to GitHub: -```bash -core ci --we-are-go-for-launch -``` - ---- - -## PHP and Laravel Development - -Core provides a unified development server for Laravel projects that orchestrates several services: - -```bash -core php dev -``` -This starts FrankenPHP, Vite, Horizon, Reverb, and Redis as configured in your `.core/php.yaml`. - ---- - -## Common Workflows - -For detailed examples of common end-to-end workflows, see the [Workflows](workflows.md) page. - ---- - -## Getting More Help - -- Use the `--help` flag with any command: `core build --help` -- Check the [FAQ](faq.md) for common questions. -- If you run into trouble, see the [Troubleshooting Guide](troubleshooting.md). diff --git a/go.mod b/go.mod index ea9b957e..f6538377 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/host-uk/core go 1.25.5 require ( - code.gitea.io/sdk/gitea v0.23.2 github.com/Snider/Borg v0.2.0 github.com/getkin/kin-openapi v0.133.0 github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555 @@ -32,20 +31,17 @@ require ( aead.dev/minisign v0.3.0 // indirect cloud.google.com/go v0.123.0 // 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/ProtonMail/go-crypto v1.3.0 // indirect github.com/TwiN/go-color v1.4.1 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/bahlo/generic-list-go v0.2.0 // 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/cloudflare/circl v1.6.3 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // 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/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.18.0 // indirect @@ -64,7 +60,6 @@ require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // 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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect diff --git a/internal/cmd/gitea/cmd_config.go b/internal/cmd/gitea/cmd_config.go deleted file mode 100644 index 87919ee4..00000000 --- a/internal/cmd/gitea/cmd_config.go +++ /dev/null @@ -1,106 +0,0 @@ -package gitea - -import ( - "fmt" - - "github.com/host-uk/core/pkg/cli" - gt "github.com/host-uk/core/pkg/gitea" -) - -// Config command flags. -var ( - configURL string - configToken string - configTest bool -) - -// addConfigCommand adds the 'config' subcommand for Gitea connection setup. -func addConfigCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "config", - Short: "Configure Gitea connection", - Long: "Set the Gitea instance URL and API token, or test the current connection.", - RunE: func(cmd *cli.Command, args []string) error { - return runConfig() - }, - } - - cmd.Flags().StringVar(&configURL, "url", "", "Gitea instance URL") - cmd.Flags().StringVar(&configToken, "token", "", "Gitea API token") - cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection") - - parent.AddCommand(cmd) -} - -func runConfig() error { - // If setting values, save them first - if configURL != "" || configToken != "" { - if err := gt.SaveConfig(configURL, configToken); err != nil { - return err - } - - if configURL != "" { - cli.Success(fmt.Sprintf("Gitea URL set to %s", configURL)) - } - if configToken != "" { - cli.Success("Gitea token saved") - } - } - - // If testing, verify the connection - if configTest { - return runConfigTest() - } - - // If no flags, show current config - if configURL == "" && configToken == "" && !configTest { - return showConfig() - } - - return nil -} - -func showConfig() error { - url, token, err := gt.ResolveConfig("", "") - if err != nil { - return err - } - - cli.Blank() - cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url)) - - if token != "" { - masked := token - if len(token) >= 8 { - masked = token[:4] + "..." + token[len(token)-4:] - } - cli.Print(" %s %s\n", dimStyle.Render("Token:"), valueStyle.Render(masked)) - } else { - cli.Print(" %s %s\n", dimStyle.Render("Token:"), warningStyle.Render("not set")) - } - - cli.Blank() - - return nil -} - -func runConfigTest() error { - client, err := gt.NewFromConfig(configURL, configToken) - if err != nil { - return err - } - - user, _, err := client.API().GetMyUserInfo() - if err != nil { - cli.Error("Connection failed") - return cli.WrapVerb(err, "connect to", "Gitea") - } - - cli.Blank() - cli.Success(fmt.Sprintf("Connected to %s", client.URL())) - cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user.UserName)) - cli.Print(" %s %s\n", dimStyle.Render("Email:"), valueStyle.Render(user.Email)) - cli.Blank() - - return nil -} diff --git a/internal/cmd/gitea/cmd_gitea.go b/internal/cmd/gitea/cmd_gitea.go deleted file mode 100644 index f5a85097..00000000 --- a/internal/cmd/gitea/cmd_gitea.go +++ /dev/null @@ -1,47 +0,0 @@ -// Package gitea provides CLI commands for managing a Gitea instance. -// -// Commands: -// - config: Configure Gitea connection (URL, token) -// - repos: List repositories -// - issues: List and create issues -// - prs: List pull requests -// - mirror: Create GitHub-to-Gitea mirrors -// - sync: Sync GitHub repos to Gitea upstream branches -package gitea - -import ( - "github.com/host-uk/core/pkg/cli" -) - -func init() { - cli.RegisterCommands(AddGiteaCommands) -} - -// Style aliases from shared package. -var ( - successStyle = cli.SuccessStyle - errorStyle = cli.ErrorStyle - warningStyle = cli.WarningStyle - dimStyle = cli.DimStyle - valueStyle = cli.ValueStyle - repoStyle = cli.RepoStyle - numberStyle = cli.NumberStyle - infoStyle = cli.InfoStyle -) - -// AddGiteaCommands registers the 'gitea' command and all subcommands. -func AddGiteaCommands(root *cli.Command) { - giteaCmd := &cli.Command{ - Use: "gitea", - Short: "Gitea instance management", - Long: "Manage repositories, issues, and pull requests on your Gitea instance.", - } - root.AddCommand(giteaCmd) - - addConfigCommand(giteaCmd) - addReposCommand(giteaCmd) - addIssuesCommand(giteaCmd) - addPRsCommand(giteaCmd) - addMirrorCommand(giteaCmd) - addSyncCommand(giteaCmd) -} diff --git a/internal/cmd/gitea/cmd_issues.go b/internal/cmd/gitea/cmd_issues.go deleted file mode 100644 index 9dc457bf..00000000 --- a/internal/cmd/gitea/cmd_issues.go +++ /dev/null @@ -1,133 +0,0 @@ -package gitea - -import ( - "fmt" - "strings" - - "code.gitea.io/sdk/gitea" - - "github.com/host-uk/core/pkg/cli" - gt "github.com/host-uk/core/pkg/gitea" -) - -// Issues command flags. -var ( - issuesState string - issuesTitle string - issuesBody string -) - -// addIssuesCommand adds the 'issues' subcommand for listing and creating issues. -func addIssuesCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "issues ", - Short: "List and manage issues", - Long: "List issues for a repository, or create a new issue.", - Args: cli.ExactArgs(1), - RunE: func(cmd *cli.Command, args []string) error { - owner, repo, err := splitOwnerRepo(args[0]) - if err != nil { - return err - } - - // If title is set, create an issue instead - if issuesTitle != "" { - return runCreateIssue(owner, repo) - } - - return runListIssues(owner, repo) - }, - } - - cmd.Flags().StringVar(&issuesState, "state", "open", "Filter by state (open, closed, all)") - cmd.Flags().StringVar(&issuesTitle, "title", "", "Create issue with this title") - cmd.Flags().StringVar(&issuesBody, "body", "", "Issue body (used with --title)") - - parent.AddCommand(cmd) -} - -func runListIssues(owner, repo string) error { - client, err := gt.NewFromConfig("", "") - if err != nil { - return err - } - - issues, err := client.ListIssues(owner, repo, gt.ListIssuesOpts{ - State: issuesState, - }) - if err != nil { - return err - } - - if len(issues) == 0 { - cli.Text(fmt.Sprintf("No %s issues in %s/%s.", issuesState, owner, repo)) - return nil - } - - cli.Blank() - cli.Print(" %s\n\n", fmt.Sprintf("%d %s issues in %s/%s", len(issues), issuesState, owner, repo)) - - for _, issue := range issues { - printGiteaIssue(issue, owner, repo) - } - - return nil -} - -func runCreateIssue(owner, repo string) error { - client, err := gt.NewFromConfig("", "") - if err != nil { - return err - } - - issue, err := client.CreateIssue(owner, repo, gitea.CreateIssueOption{ - Title: issuesTitle, - Body: issuesBody, - }) - if err != nil { - return err - } - - cli.Blank() - cli.Success(fmt.Sprintf("Created issue #%d: %s", issue.Index, issue.Title)) - cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(issue.HTMLURL)) - cli.Blank() - - return nil -} - -func printGiteaIssue(issue *gitea.Issue, owner, repo string) { - num := numberStyle.Render(fmt.Sprintf("#%d", issue.Index)) - title := valueStyle.Render(cli.Truncate(issue.Title, 60)) - - line := fmt.Sprintf(" %s %s", num, title) - - // Add labels - if len(issue.Labels) > 0 { - var labels []string - for _, l := range issue.Labels { - labels = append(labels, l.Name) - } - line += " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]") - } - - // Add assignees - if len(issue.Assignees) > 0 { - var assignees []string - for _, a := range issue.Assignees { - assignees = append(assignees, "@"+a.UserName) - } - line += " " + infoStyle.Render(strings.Join(assignees, ", ")) - } - - cli.Text(line) -} - -// splitOwnerRepo splits "owner/repo" into its parts. -func splitOwnerRepo(s string) (string, string, error) { - parts := strings.SplitN(s, "/", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", cli.Err("expected format: owner/repo (got %q)", s) - } - return parts[0], parts[1], nil -} diff --git a/internal/cmd/gitea/cmd_mirror.go b/internal/cmd/gitea/cmd_mirror.go deleted file mode 100644 index 14170424..00000000 --- a/internal/cmd/gitea/cmd_mirror.go +++ /dev/null @@ -1,92 +0,0 @@ -package gitea - -import ( - "fmt" - "os/exec" - "strings" - - "github.com/host-uk/core/pkg/cli" - gt "github.com/host-uk/core/pkg/gitea" -) - -// Mirror command flags. -var ( - mirrorOrg string - mirrorGHToken string -) - -// addMirrorCommand adds the 'mirror' subcommand for creating GitHub-to-Gitea mirrors. -func addMirrorCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "mirror ", - Short: "Mirror a GitHub repo to Gitea", - Long: `Create a pull mirror of a GitHub repository on your Gitea instance. - -The mirror will be created under the specified Gitea organisation (or your user account). -Gitea will periodically sync changes from GitHub. - -For private repos, a GitHub token is needed. By default it uses 'gh auth token'.`, - Args: cli.ExactArgs(1), - RunE: func(cmd *cli.Command, args []string) error { - owner, repo, err := splitOwnerRepo(args[0]) - if err != nil { - return err - } - return runMirror(owner, repo) - }, - } - - cmd.Flags().StringVar(&mirrorOrg, "org", "", "Gitea organisation to mirror into (default: your user account)") - cmd.Flags().StringVar(&mirrorGHToken, "github-token", "", "GitHub token for private repos (default: from gh auth token)") - - parent.AddCommand(cmd) -} - -func runMirror(githubOwner, githubRepo string) error { - client, err := gt.NewFromConfig("", "") - if err != nil { - return err - } - - cloneURL := fmt.Sprintf("https://github.com/%s/%s.git", githubOwner, githubRepo) - - // Determine target owner on Gitea - targetOwner := mirrorOrg - if targetOwner == "" { - user, _, err := client.API().GetMyUserInfo() - if err != nil { - return cli.WrapVerb(err, "get", "current user") - } - targetOwner = user.UserName - } - - // Resolve GitHub token for source auth - ghToken := mirrorGHToken - if ghToken == "" { - ghToken = resolveGHToken() - } - - cli.Print(" Mirroring %s/%s -> %s/%s on Gitea...\n", githubOwner, githubRepo, targetOwner, githubRepo) - - repo, err := client.CreateMirror(targetOwner, githubRepo, cloneURL, ghToken) - if err != nil { - return err - } - - cli.Blank() - cli.Success(fmt.Sprintf("Mirror created: %s", repo.FullName)) - cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(repo.HTMLURL)) - cli.Print(" %s %s\n", dimStyle.Render("Clone:"), valueStyle.Render(repo.CloneURL)) - cli.Blank() - - return nil -} - -// resolveGHToken tries to get a GitHub token from the gh CLI. -func resolveGHToken() string { - out, err := exec.Command("gh", "auth", "token").Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) -} diff --git a/internal/cmd/gitea/cmd_prs.go b/internal/cmd/gitea/cmd_prs.go deleted file mode 100644 index 4a6b71b6..00000000 --- a/internal/cmd/gitea/cmd_prs.go +++ /dev/null @@ -1,98 +0,0 @@ -package gitea - -import ( - "fmt" - "strings" - - sdk "code.gitea.io/sdk/gitea" - - "github.com/host-uk/core/pkg/cli" - gt "github.com/host-uk/core/pkg/gitea" -) - -// PRs command flags. -var ( - prsState string -) - -// addPRsCommand adds the 'prs' subcommand for listing pull requests. -func addPRsCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "prs ", - Short: "List pull requests", - Long: "List pull requests for a repository.", - Args: cli.ExactArgs(1), - RunE: func(cmd *cli.Command, args []string) error { - owner, repo, err := splitOwnerRepo(args[0]) - if err != nil { - return err - } - return runListPRs(owner, repo) - }, - } - - cmd.Flags().StringVar(&prsState, "state", "open", "Filter by state (open, closed, all)") - - parent.AddCommand(cmd) -} - -func runListPRs(owner, repo string) error { - client, err := gt.NewFromConfig("", "") - if err != nil { - return err - } - - prs, err := client.ListPullRequests(owner, repo, prsState) - if err != nil { - return err - } - - if len(prs) == 0 { - cli.Text(fmt.Sprintf("No %s pull requests in %s/%s.", prsState, owner, repo)) - return nil - } - - cli.Blank() - cli.Print(" %s\n\n", fmt.Sprintf("%d %s pull requests in %s/%s", len(prs), prsState, owner, repo)) - - for _, pr := range prs { - printGiteaPR(pr) - } - - return nil -} - -func printGiteaPR(pr *sdk.PullRequest) { - num := numberStyle.Render(fmt.Sprintf("#%d", pr.Index)) - title := valueStyle.Render(cli.Truncate(pr.Title, 50)) - - var author string - if pr.Poster != nil { - author = infoStyle.Render("@" + pr.Poster.UserName) - } - - // Branch info - branch := dimStyle.Render(pr.Head.Ref + " -> " + pr.Base.Ref) - - // Merge status - var status string - if pr.HasMerged { - status = successStyle.Render("merged") - } else if pr.State == sdk.StateClosed { - status = errorStyle.Render("closed") - } else { - status = warningStyle.Render("open") - } - - // Labels - var labelStr string - if len(pr.Labels) > 0 { - var labels []string - for _, l := range pr.Labels { - labels = append(labels, l.Name) - } - labelStr = " " + warningStyle.Render("["+strings.Join(labels, ", ")+"]") - } - - cli.Print(" %s %s %s %s %s%s\n", num, title, author, status, branch, labelStr) -} diff --git a/internal/cmd/gitea/cmd_repos.go b/internal/cmd/gitea/cmd_repos.go deleted file mode 100644 index 596d96a7..00000000 --- a/internal/cmd/gitea/cmd_repos.go +++ /dev/null @@ -1,125 +0,0 @@ -package gitea - -import ( - "fmt" - - "github.com/host-uk/core/pkg/cli" - gt "github.com/host-uk/core/pkg/gitea" -) - -// Repos command flags. -var ( - reposOrg string - reposMirrors bool -) - -// addReposCommand adds the 'repos' subcommand for listing repositories. -func addReposCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "repos", - Short: "List repositories", - Long: "List repositories from your Gitea instance, optionally filtered by organisation or mirror status.", - RunE: func(cmd *cli.Command, args []string) error { - return runRepos() - }, - } - - cmd.Flags().StringVar(&reposOrg, "org", "", "Filter by organisation") - cmd.Flags().BoolVar(&reposMirrors, "mirrors", false, "Show only mirror repositories") - - parent.AddCommand(cmd) -} - -func runRepos() error { - client, err := gt.NewFromConfig("", "") - if err != nil { - return err - } - - var repos []*giteaRepo - if reposOrg != "" { - raw, err := client.ListOrgRepos(reposOrg) - if err != nil { - return err - } - for _, r := range raw { - repos = append(repos, &giteaRepo{ - Name: r.Name, - FullName: r.FullName, - Mirror: r.Mirror, - Private: r.Private, - Stars: r.Stars, - CloneURL: r.CloneURL, - }) - } - } else { - raw, err := client.ListUserRepos() - if err != nil { - return err - } - for _, r := range raw { - repos = append(repos, &giteaRepo{ - Name: r.Name, - FullName: r.FullName, - Mirror: r.Mirror, - Private: r.Private, - Stars: r.Stars, - CloneURL: r.CloneURL, - }) - } - } - - // Filter mirrors if requested - if reposMirrors { - var filtered []*giteaRepo - for _, r := range repos { - if r.Mirror { - filtered = append(filtered, r) - } - } - repos = filtered - } - - if len(repos) == 0 { - cli.Text("No repositories found.") - return nil - } - - // Build table - table := cli.NewTable("Name", "Type", "Visibility", "Stars") - - for _, r := range repos { - repoType := "source" - if r.Mirror { - repoType = "mirror" - } - - visibility := successStyle.Render("public") - if r.Private { - visibility = warningStyle.Render("private") - } - - table.AddRow( - repoStyle.Render(r.FullName), - dimStyle.Render(repoType), - visibility, - fmt.Sprintf("%d", r.Stars), - ) - } - - cli.Blank() - cli.Print(" %s\n\n", fmt.Sprintf("%d repositories", len(repos))) - table.Render() - - return nil -} - -// giteaRepo is a simplified repo for display purposes. -type giteaRepo struct { - Name string - FullName string - Mirror bool - Private bool - Stars int - CloneURL string -} diff --git a/internal/cmd/gitea/cmd_sync.go b/internal/cmd/gitea/cmd_sync.go deleted file mode 100644 index d5edd6e6..00000000 --- a/internal/cmd/gitea/cmd_sync.go +++ /dev/null @@ -1,353 +0,0 @@ -package gitea - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "code.gitea.io/sdk/gitea" - - "github.com/host-uk/core/pkg/cli" - gt "github.com/host-uk/core/pkg/gitea" -) - -// Sync command flags. -var ( - syncOrg string - syncBasePath string - syncSetup bool -) - -// addSyncCommand adds the 'sync' subcommand for syncing GitHub repos to Gitea upstream branches. -func addSyncCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "sync [owner/repo...]", - Short: "Sync GitHub repos to Gitea upstream branches", - Long: `Push local GitHub content to Gitea as 'upstream' branches. - -Each repo gets: - - An 'upstream' branch tracking the GitHub default branch - - A 'main' branch (default) for private tasks, processes, and AI workflows - -Use --setup on first run to create the Gitea repos and configure remotes. -Without --setup, updates existing upstream branches from local clones.`, - Args: cli.MinimumNArgs(0), - RunE: func(cmd *cli.Command, args []string) error { - return runSync(args) - }, - } - - cmd.Flags().StringVar(&syncOrg, "org", "Host-UK", "Gitea organisation") - cmd.Flags().StringVar(&syncBasePath, "base-path", "~/Code/host-uk", "Base path for local repo clones") - cmd.Flags().BoolVar(&syncSetup, "setup", false, "Initial setup: create repos, configure remotes, push upstream branches") - - parent.AddCommand(cmd) -} - -// repoEntry holds info for a repo to sync. -type repoEntry struct { - name string - localPath string - defaultBranch string // the GitHub default branch (main, dev, etc.) -} - -func runSync(args []string) error { - client, err := gt.NewFromConfig("", "") - if err != nil { - return err - } - - // Expand base path - basePath := syncBasePath - if strings.HasPrefix(basePath, "~/") { - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("failed to resolve home directory: %w", err) - } - basePath = filepath.Join(home, basePath[2:]) - } - - // Build repo list: either from args or from the Gitea org - repos, err := buildRepoList(client, args, basePath) - if err != nil { - return err - } - - if len(repos) == 0 { - cli.Text("No repos to sync.") - return nil - } - - giteaURL := client.URL() - - if syncSetup { - return runSyncSetup(client, repos, giteaURL) - } - - return runSyncUpdate(repos, giteaURL) -} - -func buildRepoList(client *gt.Client, args []string, basePath string) ([]repoEntry, error) { - var repos []repoEntry - - if len(args) > 0 { - // Specific repos from args - for _, arg := range args { - name := arg - // Strip owner/ prefix if given - if parts := strings.SplitN(arg, "/", 2); len(parts) == 2 { - name = parts[1] - } - localPath := filepath.Join(basePath, name) - branch := detectDefaultBranch(localPath) - repos = append(repos, repoEntry{ - name: name, - localPath: localPath, - defaultBranch: branch, - }) - } - } else { - // All repos from the Gitea org - orgRepos, err := client.ListOrgRepos(syncOrg) - if err != nil { - return nil, err - } - for _, r := range orgRepos { - localPath := filepath.Join(basePath, r.Name) - branch := detectDefaultBranch(localPath) - repos = append(repos, repoEntry{ - name: r.Name, - localPath: localPath, - defaultBranch: branch, - }) - } - } - - return repos, nil -} - -// runSyncSetup handles first-time setup: delete mirrors, create repos, push upstream branches. -func runSyncSetup(client *gt.Client, repos []repoEntry, giteaURL string) error { - cli.Blank() - cli.Print(" Setting up %d repos in %s with upstream branches...\n\n", len(repos), syncOrg) - - var succeeded, failed int - - for _, repo := range repos { - cli.Print(" %s %s\n", dimStyle.Render(">>"), repoStyle.Render(repo.name)) - - // Step 1: Delete existing repo (mirror) if it exists - cli.Print(" Deleting existing mirror... ") - err := client.DeleteRepo(syncOrg, repo.name) - if err != nil { - cli.Print("%s (may not exist)\n", dimStyle.Render("skipped")) - } else { - cli.Print("%s\n", successStyle.Render("done")) - } - - // Step 2: Create empty repo - cli.Print(" Creating repo... ") - _, err = client.CreateOrgRepo(syncOrg, gitea.CreateRepoOption{ - Name: repo.name, - AutoInit: false, - DefaultBranch: "main", - }) - if err != nil { - cli.Print("%s\n", errorStyle.Render(err.Error())) - failed++ - continue - } - cli.Print("%s\n", successStyle.Render("done")) - - // Step 3: Add gitea remote to local clone - cli.Print(" Configuring remote... ") - remoteURL := fmt.Sprintf("%s/%s/%s.git", giteaURL, syncOrg, repo.name) - err = configureGiteaRemote(repo.localPath, remoteURL) - if err != nil { - cli.Print("%s\n", errorStyle.Render(err.Error())) - failed++ - continue - } - cli.Print("%s\n", successStyle.Render("done")) - - // Step 4: Push default branch as 'upstream' to Gitea - cli.Print(" Pushing %s -> upstream... ", repo.defaultBranch) - err = pushUpstream(repo.localPath, repo.defaultBranch) - if err != nil { - cli.Print("%s\n", errorStyle.Render(err.Error())) - failed++ - continue - } - cli.Print("%s\n", successStyle.Render("done")) - - // Step 5: Create 'main' branch from 'upstream' on Gitea - cli.Print(" Creating main branch... ") - err = createMainFromUpstream(client, syncOrg, repo.name) - if err != nil { - if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "409") { - cli.Print("%s\n", dimStyle.Render("exists")) - } else { - cli.Print("%s\n", errorStyle.Render(err.Error())) - failed++ - continue - } - } else { - cli.Print("%s\n", successStyle.Render("done")) - } - - // Step 6: Set default branch to 'main' - cli.Print(" Setting default branch... ") - _, _, err = client.API().EditRepo(syncOrg, repo.name, gitea.EditRepoOption{ - DefaultBranch: strPtr("main"), - }) - if err != nil { - cli.Print("%s\n", warningStyle.Render(err.Error())) - } else { - cli.Print("%s\n", successStyle.Render("main")) - } - - succeeded++ - cli.Blank() - } - - cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d repos set up", succeeded))) - if failed > 0 { - cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) - } - cli.Blank() - - return nil -} - -// runSyncUpdate pushes latest from local clones to Gitea upstream branches. -func runSyncUpdate(repos []repoEntry, giteaURL string) error { - cli.Blank() - cli.Print(" Syncing %d repos to %s upstream branches...\n\n", len(repos), syncOrg) - - var succeeded, failed int - - for _, repo := range repos { - cli.Print(" %s -> upstream ", repoStyle.Render(repo.name)) - - // Ensure remote exists - remoteURL := fmt.Sprintf("%s/%s/%s.git", giteaURL, syncOrg, repo.name) - _ = configureGiteaRemote(repo.localPath, remoteURL) - - // Fetch latest from GitHub (origin) - err := gitFetch(repo.localPath, "origin") - if err != nil { - cli.Print("%s\n", errorStyle.Render("fetch failed: "+err.Error())) - failed++ - continue - } - - // Push to Gitea upstream branch - err = pushUpstream(repo.localPath, repo.defaultBranch) - if err != nil { - cli.Print("%s\n", errorStyle.Render(err.Error())) - failed++ - continue - } - - cli.Print("%s\n", successStyle.Render("ok")) - succeeded++ - } - - cli.Blank() - cli.Print(" %s", successStyle.Render(fmt.Sprintf("%d synced", succeeded))) - if failed > 0 { - cli.Print(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) - } - cli.Blank() - - return nil -} - -// detectDefaultBranch returns the default branch for a local git repo. -func detectDefaultBranch(path string) string { - // Check what origin/HEAD points to - out, err := exec.Command("git", "-C", path, "symbolic-ref", "refs/remotes/origin/HEAD").Output() - if err == nil { - ref := strings.TrimSpace(string(out)) - // refs/remotes/origin/main -> main - if parts := strings.Split(ref, "/"); len(parts) > 0 { - return parts[len(parts)-1] - } - } - - // Fallback: check current branch - out, err = exec.Command("git", "-C", path, "branch", "--show-current").Output() - if err == nil { - branch := strings.TrimSpace(string(out)) - if branch != "" { - return branch - } - } - - return "main" -} - -// configureGiteaRemote adds or updates the 'gitea' remote on a local repo. -func configureGiteaRemote(localPath, remoteURL string) error { - // Check if remote exists - out, err := exec.Command("git", "-C", localPath, "remote", "get-url", "gitea").Output() - if err == nil { - // Remote exists — update if URL changed - existing := strings.TrimSpace(string(out)) - if existing != remoteURL { - cmd := exec.Command("git", "-C", localPath, "remote", "set-url", "gitea", remoteURL) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to update remote: %w", err) - } - } - return nil - } - - // Add new remote - cmd := exec.Command("git", "-C", localPath, "remote", "add", "gitea", remoteURL) - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to add remote: %w", err) - } - - return nil -} - -// pushUpstream pushes the local default branch to Gitea as 'upstream'. -func pushUpstream(localPath, defaultBranch string) error { - // Push origin's default branch as 'upstream' to gitea - refspec := fmt.Sprintf("refs/remotes/origin/%s:refs/heads/upstream", defaultBranch) - cmd := exec.Command("git", "-C", localPath, "push", "--force", "gitea", refspec) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%s", strings.TrimSpace(string(output))) - } - - return nil -} - -// gitFetch fetches latest from a remote. -func gitFetch(localPath, remote string) error { - cmd := exec.Command("git", "-C", localPath, "fetch", remote) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%s", strings.TrimSpace(string(output))) - } - return nil -} - -// createMainFromUpstream creates a 'main' branch from 'upstream' on Gitea via the API. -func createMainFromUpstream(client *gt.Client, org, repo string) error { - _, _, err := client.API().CreateBranch(org, repo, gitea.CreateBranchOption{ - BranchName: "main", - OldBranchName: "upstream", - }) - if err != nil { - return fmt.Errorf("create branch: %w", err) - } - - return nil -} - -func strPtr(s string) *string { return &s } diff --git a/internal/cmd/go/cmd_qa.go b/internal/cmd/go/cmd_qa.go index 9aefa485..ba086ee4 100644 --- a/internal/cmd/go/cmd_qa.go +++ b/internal/cmd/go/cmd_qa.go @@ -308,7 +308,7 @@ func runGoQA(cmd *cli.Command, args []string) error { } if failed > 0 { - return cli.Err("QA checks failed: %d passed, %d failed", passed, failed) + os.Exit(1) } return nil } diff --git a/internal/cmd/php/cmd_ci.go b/internal/cmd/php/cmd_ci.go index 40b23fe2..445e5e42 100644 --- a/internal/cmd/php/cmd_ci.go +++ b/internal/cmd/php/cmd_ci.go @@ -189,7 +189,7 @@ func runPHPCI() error { return err } if !result.Passed { - return cli.Exit(result.ExitCode, cli.Err("CI pipeline failed")) + os.Exit(result.ExitCode) } return nil } diff --git a/internal/cmd/qa/cmd_docblock.go b/internal/cmd/qa/cmd_docblock.go index 629f90b6..357e1b6f 100644 --- a/internal/cmd/qa/cmd_docblock.go +++ b/internal/cmd/qa/cmd_docblock.go @@ -167,7 +167,7 @@ func CheckDocblockCoverage(patterns []string) (*DocblockResult, error) { }, parser.ParseComments) if err != nil { // Log parse errors but continue to check other directories - cli.Warnf("failed to parse %s: %v", dir, err) + fmt.Fprintf(os.Stderr, "warning: failed to parse %s: %v\n", dir, err) continue } diff --git a/internal/cmd/sdk/cmd_sdk.go b/internal/cmd/sdk/cmd_sdk.go index 2c8b58c4..1854ef19 100644 --- a/internal/cmd/sdk/cmd_sdk.go +++ b/internal/cmd/sdk/cmd_sdk.go @@ -96,7 +96,8 @@ func runSDKDiff(basePath, specPath string) error { result, err := Diff(basePath, specPath) if err != nil { - return cli.Exit(2, cli.Wrap(err, i18n.Label("error"))) + fmt.Printf("%s %v\n", sdkErrorStyle.Render(i18n.Label("error")), err) + os.Exit(2) } if result.Breaking { @@ -104,7 +105,7 @@ func runSDKDiff(basePath, specPath string) error { for _, change := range result.Changes { fmt.Printf(" - %s\n", change) } - return cli.Exit(1, cli.Err("%s", result.Summary)) + os.Exit(1) } fmt.Printf("%s %s\n", sdkSuccessStyle.Render(i18n.T("cmd.sdk.label.ok")), result.Summary) diff --git a/internal/cmd/unifi/cmd_clients.go b/internal/cmd/unifi/cmd_clients.go deleted file mode 100644 index 3f453d7d..00000000 --- a/internal/cmd/unifi/cmd_clients.go +++ /dev/null @@ -1,112 +0,0 @@ -package unifi - -import ( - "errors" - "fmt" - - "github.com/host-uk/core/pkg/cli" - "github.com/host-uk/core/pkg/log" - uf "github.com/host-uk/core/pkg/unifi" -) - -// Clients command flags. -var ( - clientsSite string - clientsWired bool - clientsWireless bool -) - -// addClientsCommand adds the 'clients' subcommand for listing connected clients. -func addClientsCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "clients", - Short: "List connected clients", - Long: "List all connected clients on the UniFi network, optionally filtered by site or connection type.", - RunE: func(cmd *cli.Command, args []string) error { - return runClients() - }, - } - - cmd.Flags().StringVar(&clientsSite, "site", "", "Filter by site name") - cmd.Flags().BoolVar(&clientsWired, "wired", false, "Show only wired clients") - cmd.Flags().BoolVar(&clientsWireless, "wireless", false, "Show only wireless clients") - - parent.AddCommand(cmd) -} - -func runClients() error { - if clientsWired && clientsWireless { - return log.E("unifi.clients", "conflicting flags", errors.New("--wired and --wireless cannot both be set")) - } - - client, err := uf.NewFromConfig("", "", "", "", nil) - if err != nil { - return log.E("unifi.clients", "failed to initialise client", err) - } - - clients, err := client.GetClients(uf.ClientFilter{ - Site: clientsSite, - Wired: clientsWired, - Wireless: clientsWireless, - }) - if err != nil { - return log.E("unifi.clients", "failed to fetch clients", err) - } - - if len(clients) == 0 { - cli.Text("No clients found.") - return nil - } - - table := cli.NewTable("Name", "IP", "MAC", "Network", "Type", "Uptime") - - for _, cl := range clients { - name := cl.Name - if name == "" { - name = cl.Hostname - } - if name == "" { - name = "(unknown)" - } - - connType := cl.Essid - if cl.IsWired.Val { - connType = "wired" - } - - table.AddRow( - valueStyle.Render(name), - cl.IP, - dimStyle.Render(cl.Mac), - cl.Network, - dimStyle.Render(connType), - dimStyle.Render(formatUptime(cl.Uptime.Int())), - ) - } - - cli.Blank() - cli.Print(" %d clients\n\n", len(clients)) - table.Render() - - return nil -} - -// formatUptime converts seconds to a human-readable duration string. -func formatUptime(seconds int) string { - if seconds <= 0 { - return "-" - } - - days := seconds / 86400 - hours := (seconds % 86400) / 3600 - minutes := (seconds % 3600) / 60 - - switch { - case days > 0: - return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) - case hours > 0: - return fmt.Sprintf("%dh %dm", hours, minutes) - default: - return fmt.Sprintf("%dm", minutes) - } -} diff --git a/internal/cmd/unifi/cmd_config.go b/internal/cmd/unifi/cmd_config.go deleted file mode 100644 index ad10b6e0..00000000 --- a/internal/cmd/unifi/cmd_config.go +++ /dev/null @@ -1,155 +0,0 @@ -package unifi - -import ( - "fmt" - - "github.com/host-uk/core/pkg/cli" - uf "github.com/host-uk/core/pkg/unifi" -) - -// Config command flags. -var ( - configURL string - configUser string - configPass string - configAPIKey string - configInsecure bool - configTest bool -) - -// addConfigCommand adds the 'config' subcommand for UniFi connection setup. -func addConfigCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "config", - Short: "Configure UniFi connection", - Long: "Set the UniFi controller URL and credentials, or test the current connection.", - RunE: func(cmd *cli.Command, args []string) error { - return runConfig(cmd) - }, - } - - cmd.Flags().StringVar(&configURL, "url", "", "UniFi controller URL") - cmd.Flags().StringVar(&configUser, "user", "", "UniFi username") - cmd.Flags().StringVar(&configPass, "pass", "", "UniFi password") - cmd.Flags().StringVar(&configAPIKey, "apikey", "", "UniFi API key") - cmd.Flags().BoolVar(&configInsecure, "insecure", false, "Allow insecure TLS connections (e.g. self-signed certs)") - cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection") - - parent.AddCommand(cmd) -} - -func runConfig(cmd *cli.Command) error { - var insecure *bool - if cmd.Flags().Changed("insecure") { - insecure = &configInsecure - } - - // If setting values, save them first - if configURL != "" || configUser != "" || configPass != "" || configAPIKey != "" || insecure != nil { - if err := uf.SaveConfig(configURL, configUser, configPass, configAPIKey, insecure); err != nil { - return err - } - - if configURL != "" { - cli.Success(fmt.Sprintf("UniFi URL set to %s", configURL)) - } - if configUser != "" { - cli.Success("UniFi username saved") - } - if configPass != "" { - cli.Success("UniFi password saved") - } - if configAPIKey != "" { - cli.Success("UniFi API key saved") - } - if insecure != nil { - if *insecure { - cli.Warn("UniFi insecure mode enabled") - } else { - cli.Success("UniFi insecure mode disabled") - } - } - } - - // If testing, verify the connection - if configTest { - return runConfigTest(cmd) - } - - // If no flags, show current config - if configURL == "" && configUser == "" && configPass == "" && configAPIKey == "" && !cmd.Flags().Changed("insecure") && !configTest { - return showConfig() - } - - return nil -} - -func showConfig() error { - url, user, pass, apikey, insecure, err := uf.ResolveConfig("", "", "", "", nil) - if err != nil { - return err - } - - cli.Blank() - cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url)) - - if user != "" { - cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user)) - } else { - cli.Print(" %s %s\n", dimStyle.Render("User:"), warningStyle.Render("not set")) - } - - if pass != "" { - cli.Print(" %s %s\n", dimStyle.Render("Pass:"), valueStyle.Render("****")) - } else { - cli.Print(" %s %s\n", dimStyle.Render("Pass:"), warningStyle.Render("not set")) - } - - if apikey != "" { - masked := apikey - if len(apikey) >= 8 { - masked = apikey[:4] + "..." + apikey[len(apikey)-4:] - } - cli.Print(" %s %s\n", dimStyle.Render("API Key:"), valueStyle.Render(masked)) - } else { - cli.Print(" %s %s\n", dimStyle.Render("API Key:"), warningStyle.Render("not set")) - } - - if insecure { - cli.Print(" %s %s\n", dimStyle.Render("Insecure:"), warningStyle.Render("enabled")) - } else { - cli.Print(" %s %s\n", dimStyle.Render("Insecure:"), successStyle.Render("disabled")) - } - - cli.Blank() - - return nil -} - -func runConfigTest(cmd *cli.Command) error { - var insecure *bool - if cmd.Flags().Changed("insecure") { - insecure = &configInsecure - } - - client, err := uf.NewFromConfig(configURL, configUser, configPass, configAPIKey, insecure) - if err != nil { - return err - } - - sites, err := client.GetSites() - if err != nil { - cli.Error("Connection failed") - return cli.WrapVerb(err, "connect to", "UniFi controller") - } - - cli.Blank() - cli.Success(fmt.Sprintf("Connected to %s", client.URL())) - cli.Print(" %s %s\n", dimStyle.Render("Sites:"), numberStyle.Render(fmt.Sprintf("%d", len(sites)))) - for _, s := range sites { - cli.Print(" %s %s\n", valueStyle.Render(s.Name), dimStyle.Render(s.Desc)) - } - cli.Blank() - - return nil -} diff --git a/internal/cmd/unifi/cmd_devices.go b/internal/cmd/unifi/cmd_devices.go deleted file mode 100644 index 2f810c81..00000000 --- a/internal/cmd/unifi/cmd_devices.go +++ /dev/null @@ -1,74 +0,0 @@ -package unifi - -import ( - "strings" - - "github.com/host-uk/core/pkg/cli" - "github.com/host-uk/core/pkg/log" - uf "github.com/host-uk/core/pkg/unifi" -) - -// Devices command flags. -var ( - devicesSite string - devicesType string -) - -// addDevicesCommand adds the 'devices' subcommand for listing infrastructure devices. -func addDevicesCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "devices", - Short: "List infrastructure devices", - Long: "List all infrastructure devices (APs, switches, gateways) on the UniFi network.", - RunE: func(cmd *cli.Command, args []string) error { - return runDevices() - }, - } - - cmd.Flags().StringVar(&devicesSite, "site", "", "Filter by site name") - cmd.Flags().StringVar(&devicesType, "type", "", "Filter by device type (uap, usw, usg, udm, uxg)") - - parent.AddCommand(cmd) -} - -func runDevices() error { - client, err := uf.NewFromConfig("", "", "", "", nil) - if err != nil { - return log.E("unifi.devices", "failed to initialise client", err) - } - - devices, err := client.GetDeviceList(devicesSite, strings.ToLower(devicesType)) - if err != nil { - return log.E("unifi.devices", "failed to fetch devices", err) - } - - if len(devices) == 0 { - cli.Text("No devices found.") - return nil - } - - table := cli.NewTable("Name", "IP", "MAC", "Model", "Type", "Version", "Status") - - for _, d := range devices { - status := successStyle.Render("online") - if d.Status != 1 { - status = errorStyle.Render("offline") - } - - table.AddRow( - valueStyle.Render(d.Name), - d.IP, - dimStyle.Render(d.Mac), - d.Model, - dimStyle.Render(d.Type), - dimStyle.Render(d.Version), - status, - ) - } - - cli.Blank() - cli.Print(" %d devices\n\n", len(devices)) - table.Render() - - return nil -} diff --git a/internal/cmd/unifi/cmd_networks.go b/internal/cmd/unifi/cmd_networks.go deleted file mode 100644 index 9196fc94..00000000 --- a/internal/cmd/unifi/cmd_networks.go +++ /dev/null @@ -1,145 +0,0 @@ -package unifi - -import ( - "fmt" - - "github.com/host-uk/core/pkg/cli" - "github.com/host-uk/core/pkg/log" - uf "github.com/host-uk/core/pkg/unifi" -) - -// Networks command flags. -var ( - networksSite string -) - -// addNetworksCommand adds the 'networks' subcommand for listing network segments. -func addNetworksCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "networks", - Short: "List network segments", - Long: "List all network segments configured on the UniFi controller, showing VLANs, subnets, isolation, and DHCP.", - RunE: func(cmd *cli.Command, args []string) error { - return runNetworks() - }, - } - - cmd.Flags().StringVar(&networksSite, "site", "", "Site name (default: \"default\")") - - parent.AddCommand(cmd) -} - -func runNetworks() error { - client, err := uf.NewFromConfig("", "", "", "", nil) - if err != nil { - return log.E("unifi.networks", "failed to initialise client", err) - } - - networks, err := client.GetNetworks(networksSite) - if err != nil { - return log.E("unifi.networks", "failed to fetch networks", err) - } - - if len(networks) == 0 { - cli.Text("No networks found.") - return nil - } - - // Separate WANs, LANs, and VPNs - var wans, lans, vpns []uf.NetworkConf - for _, n := range networks { - switch n.Purpose { - case "wan": - wans = append(wans, n) - case "remote-user-vpn": - vpns = append(vpns, n) - default: - lans = append(lans, n) - } - } - - cli.Blank() - - // WANs - if len(wans) > 0 { - cli.Print(" %s\n\n", infoStyle.Render("WAN Interfaces")) - wanTable := cli.NewTable("Name", "Type", "Group", "Status") - for _, w := range wans { - status := successStyle.Render("enabled") - if !w.Enabled { - status = errorStyle.Render("disabled") - } - wanTable.AddRow( - valueStyle.Render(w.Name), - dimStyle.Render(w.WANType), - dimStyle.Render(w.WANNetworkGroup), - status, - ) - } - wanTable.Render() - cli.Blank() - } - - // LANs - if len(lans) > 0 { - cli.Print(" %s\n\n", infoStyle.Render("LAN Networks")) - lanTable := cli.NewTable("Name", "Subnet", "VLAN", "Isolated", "Internet", "DHCP", "mDNS") - for _, n := range lans { - vlan := dimStyle.Render("-") - if n.VLANEnabled { - vlan = numberStyle.Render(fmt.Sprintf("%d", n.VLAN)) - } - - isolated := successStyle.Render("no") - if n.NetworkIsolationEnabled { - isolated = warningStyle.Render("yes") - } - - internet := successStyle.Render("yes") - if !n.InternetAccessEnabled { - internet = errorStyle.Render("no") - } - - dhcp := dimStyle.Render("off") - if n.DHCPEnabled { - dhcp = fmt.Sprintf("%s - %s", n.DHCPStart, n.DHCPStop) - } - - mdns := dimStyle.Render("off") - if n.MDNSEnabled { - mdns = successStyle.Render("on") - } - - lanTable.AddRow( - valueStyle.Render(n.Name), - n.IPSubnet, - vlan, - isolated, - internet, - dhcp, - mdns, - ) - } - lanTable.Render() - cli.Blank() - } - - // VPNs - if len(vpns) > 0 { - cli.Print(" %s\n\n", infoStyle.Render("VPN Networks")) - vpnTable := cli.NewTable("Name", "Subnet", "Type") - for _, v := range vpns { - vpnTable.AddRow( - valueStyle.Render(v.Name), - v.IPSubnet, - dimStyle.Render(v.VPNType), - ) - } - vpnTable.Render() - cli.Blank() - } - - cli.Print(" %s\n\n", dimStyle.Render(fmt.Sprintf("%d networks total", len(networks)))) - - return nil -} diff --git a/internal/cmd/unifi/cmd_routes.go b/internal/cmd/unifi/cmd_routes.go deleted file mode 100644 index a6895a77..00000000 --- a/internal/cmd/unifi/cmd_routes.go +++ /dev/null @@ -1,86 +0,0 @@ -package unifi - -import ( - "fmt" - - "github.com/host-uk/core/pkg/cli" - "github.com/host-uk/core/pkg/log" - uf "github.com/host-uk/core/pkg/unifi" -) - -// Routes command flags. -var ( - routesSite string - routesType string -) - -// addRoutesCommand adds the 'routes' subcommand for listing the gateway routing table. -func addRoutesCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "routes", - Short: "List gateway routing table", - Long: "List the active routing table from the UniFi gateway, showing network segments and next-hop destinations.", - RunE: func(cmd *cli.Command, args []string) error { - return runRoutes() - }, - } - - cmd.Flags().StringVar(&routesSite, "site", "", "Site name (default: \"default\")") - cmd.Flags().StringVar(&routesType, "type", "", "Filter by route type (static, connected, kernel, bgp, ospf)") - - parent.AddCommand(cmd) -} - -func runRoutes() error { - client, err := uf.NewFromConfig("", "", "", "", nil) - if err != nil { - return log.E("unifi.routes", "failed to initialise client", err) - } - - routes, err := client.GetRoutes(routesSite) - if err != nil { - return log.E("unifi.routes", "failed to fetch routes", err) - } - - // Filter by type if requested - if routesType != "" { - var filtered []uf.Route - for _, r := range routes { - if uf.RouteTypeName(r.Type) == routesType || r.Type == routesType { - filtered = append(filtered, r) - } - } - routes = filtered - } - - if len(routes) == 0 { - cli.Text("No routes found.") - return nil - } - - table := cli.NewTable("Network", "Next Hop", "Interface", "Type", "Distance", "FIB") - - for _, r := range routes { - typeName := uf.RouteTypeName(r.Type) - - fib := dimStyle.Render("no") - if r.Selected { - fib = successStyle.Render("yes") - } - - table.AddRow( - valueStyle.Render(r.Network), - r.NextHop, - dimStyle.Render(r.Interface), - dimStyle.Render(typeName), - fmt.Sprintf("%d", r.Distance), - fib, - ) - } - - cli.Blank() - cli.Print(" %d routes\n\n", len(routes)) - table.Render() - - return nil -} diff --git a/internal/cmd/unifi/cmd_sites.go b/internal/cmd/unifi/cmd_sites.go deleted file mode 100644 index b7eace47..00000000 --- a/internal/cmd/unifi/cmd_sites.go +++ /dev/null @@ -1,53 +0,0 @@ -package unifi - -import ( - "github.com/host-uk/core/pkg/cli" - "github.com/host-uk/core/pkg/log" - uf "github.com/host-uk/core/pkg/unifi" -) - -// addSitesCommand adds the 'sites' subcommand for listing UniFi sites. -func addSitesCommand(parent *cli.Command) { - cmd := &cli.Command{ - Use: "sites", - Short: "List controller sites", - Long: "List all sites configured on the UniFi controller.", - RunE: func(cmd *cli.Command, args []string) error { - return runSites() - }, - } - - parent.AddCommand(cmd) -} - -func runSites() error { - client, err := uf.NewFromConfig("", "", "", "", nil) - if err != nil { - return log.E("unifi.sites", "failed to initialise client", err) - } - - sites, err := client.GetSites() - if err != nil { - return log.E("unifi.sites", "failed to fetch sites", err) - } - - if len(sites) == 0 { - cli.Text("No sites found.") - return nil - } - - table := cli.NewTable("Name", "Description") - - for _, s := range sites { - table.AddRow( - valueStyle.Render(s.Name), - dimStyle.Render(s.Desc), - ) - } - - cli.Blank() - cli.Print(" %d sites\n\n", len(sites)) - table.Render() - - return nil -} diff --git a/internal/cmd/unifi/cmd_unifi.go b/internal/cmd/unifi/cmd_unifi.go deleted file mode 100644 index be2d2331..00000000 --- a/internal/cmd/unifi/cmd_unifi.go +++ /dev/null @@ -1,46 +0,0 @@ -// Package unifi provides CLI commands for managing a UniFi network controller. -// -// Commands: -// - config: Configure UniFi connection (URL, credentials) -// - clients: List connected clients -// - devices: List infrastructure devices -// - sites: List controller sites -// - networks: List network segments and VLANs -// - routes: List gateway routing table -package unifi - -import ( - "github.com/host-uk/core/pkg/cli" -) - -func init() { - cli.RegisterCommands(AddUniFiCommands) -} - -// Style aliases from shared package. -var ( - successStyle = cli.SuccessStyle - errorStyle = cli.ErrorStyle - warningStyle = cli.WarningStyle - dimStyle = cli.DimStyle - valueStyle = cli.ValueStyle - numberStyle = cli.NumberStyle - infoStyle = cli.InfoStyle -) - -// AddUniFiCommands registers the 'unifi' command and all subcommands. -func AddUniFiCommands(root *cli.Command) { - unifiCmd := &cli.Command{ - Use: "unifi", - Short: "UniFi network management", - Long: "Manage sites, devices, and connected clients on your UniFi controller.", - } - root.AddCommand(unifiCmd) - - addConfigCommand(unifiCmd) - addClientsCommand(unifiCmd) - addDevicesCommand(unifiCmd) - addNetworksCommand(unifiCmd) - addRoutesCommand(unifiCmd) - addSitesCommand(unifiCmd) -} diff --git a/internal/cmd/updater/cmd.go b/internal/cmd/updater/cmd.go index 160eb509..ec42355b 100644 --- a/internal/cmd/updater/cmd.go +++ b/internal/cmd/updater/cmd.go @@ -3,6 +3,7 @@ package updater import ( "context" "fmt" + "os" "runtime" "github.com/host-uk/core/pkg/cli" @@ -132,6 +133,8 @@ func runUpdate(cmd *cobra.Command, args []string) error { cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName) cli.Print("%s Restarting...\n", cli.DimStyle.Render("→")) + // Exit so the watcher can restart us + os.Exit(0) return nil } @@ -176,6 +179,7 @@ func handleDevUpdate(currentVersion string) error { cli.Print("%s Updated to %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), release.TagName) cli.Print("%s Restarting...\n", cli.DimStyle.Render("→")) + os.Exit(0) return nil } @@ -212,5 +216,6 @@ func handleDevTagUpdate(currentVersion string) error { cli.Print("%s Updated to latest dev build\n", cli.SuccessStyle.Render(cli.Glyph(":check:"))) cli.Print("%s Restarting...\n", cli.DimStyle.Render("→")) + os.Exit(0) return nil } diff --git a/internal/variants/full.go b/internal/variants/full.go index 55ea68d3..c022de21 100644 --- a/internal/variants/full.go +++ b/internal/variants/full.go @@ -20,8 +20,6 @@ // - test: Test runner with coverage // - qa: Quality assurance workflows // - monitor: Security monitoring aggregation -// - gitea: Gitea instance management (repos, issues, PRs, mirrors) -// - unifi: UniFi network management (sites, devices, clients) package variants @@ -37,7 +35,6 @@ import ( _ "github.com/host-uk/core/internal/cmd/docs" _ "github.com/host-uk/core/internal/cmd/doctor" _ "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/help" _ "github.com/host-uk/core/internal/cmd/monitor" @@ -49,7 +46,6 @@ import ( _ "github.com/host-uk/core/internal/cmd/security" _ "github.com/host-uk/core/internal/cmd/setup" _ "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/vm" _ "github.com/host-uk/core/internal/cmd/workspace" diff --git a/local.test b/local.test new file mode 100755 index 00000000..9ad365ca Binary files /dev/null and b/local.test differ diff --git a/mkdocs.yml b/mkdocs.yml index acf8ed8f..810e16ee 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,26 +43,6 @@ markdown_extensions: nav: - Home: index.md - - User Documentation: - - User Guide: user-guide.md - - FAQ: faq.md - - Troubleshooting: troubleshooting.md - - Workflows: workflows.md - - CLI Reference: - - Overview: cmd/index.md - - AI: cmd/ai/index.md - - Build: cmd/build/index.md - - CI: cmd/ci/index.md - - Dev: cmd/dev/index.md - - Go: cmd/go/index.md - - PHP: cmd/php/index.md - - SDK: cmd/sdk/index.md - - Setup: cmd/setup/index.md - - Doctor: cmd/doctor/index.md - - Test: cmd/test/index.md - - VM: cmd/vm/index.md - - Pkg: cmd/pkg/index.md - - Docs: cmd/docs/index.md - Getting Started: - Installation: getting-started/installation.md - Quick Start: getting-started/quickstart.md @@ -91,14 +71,3 @@ nav: - API Reference: - Core: api/core.md - Display: api/display.md - - Development: - - Package Standards: pkg/PACKAGE_STANDARDS.md - - Internationalization: - - Overview: pkg/i18n/README.md - - Grammar: pkg/i18n/GRAMMAR.md - - Extending: pkg/i18n/EXTENDING.md - - Claude Skill: skill/index.md - - Reference: - - Configuration: configuration.md - - Migration: migration.md - - Glossary: glossary.md diff --git a/pkg/agentic/context.go b/pkg/agentic/context.go index 80cc962c..bbf35919 100644 --- a/pkg/agentic/context.go +++ b/pkg/agentic/context.go @@ -3,6 +3,7 @@ package agentic import ( "bytes" + goio "io" "os" "os/exec" "path/filepath" @@ -10,9 +11,12 @@ import ( "strings" "github.com/host-uk/core/pkg/ai" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/log" ) +const maxContextBytes = 5000 + // FileContent represents the content of a file for AI context. type FileContent struct { // Path is the relative path to the file. @@ -104,17 +108,24 @@ func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) { // Read files explicitly mentioned in the task for _, relPath := range task.Files { - fullPath := filepath.Join(dir, relPath) + fullPath := relPath + if !filepath.IsAbs(relPath) { + fullPath = filepath.Join(dir, relPath) + } - content, err := os.ReadFile(fullPath) + content, truncated, err := readAndTruncate(fullPath) if err != nil { - // Skip files that don't exist continue } + contentStr := string(content) + if truncated { + contentStr += "\n... (truncated)" + } + files = append(files, FileContent{ Path: relPath, - Content: string(content), + Content: contentStr, Language: detectLanguage(relPath), }) } @@ -163,16 +174,19 @@ func findRelatedCode(task *Task, dir string) ([]FileContent, error) { break } - fullPath := filepath.Join(dir, line) - content, err := os.ReadFile(fullPath) + fullPath := line + if !filepath.IsAbs(line) { + fullPath = filepath.Join(dir, line) + } + + content, truncated, err := readAndTruncate(fullPath) if err != nil { continue } - // Truncate large files contentStr := string(content) - if len(contentStr) > 5000 { - contentStr = contentStr[:5000] + "\n... (truncated)" + if truncated { + contentStr += "\n... (truncated)" } files = append(files, FileContent{ @@ -272,6 +286,30 @@ func detectLanguage(path string) string { return "text" } +// readAndTruncate reads up to maxContextBytes from a file. +func readAndTruncate(path string) ([]byte, bool, error) { + f, err := io.Local.ReadStream(path) + if err != nil { + return nil, false, err + } + defer func() { _ = f.Close() }() + + // Read up to maxContextBytes + 1 to detect truncation + reader := goio.LimitReader(f, maxContextBytes+1) + content, err := goio.ReadAll(reader) + if err != nil { + return nil, false, err + } + + truncated := false + if len(content) > maxContextBytes { + content = content[:maxContextBytes] + truncated = true + } + + return content, truncated, nil +} + // runGitCommand runs a git command and returns the output. func runGitCommand(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) diff --git a/pkg/agentic/service.go b/pkg/agentic/service.go index 1670aa23..6390e5d7 100644 --- a/pkg/agentic/service.go +++ b/pkg/agentic/service.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/host-uk/core/pkg/framework" - "github.com/host-uk/core/pkg/log" ) // Tasks for AI service @@ -69,16 +68,10 @@ func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, er switch m := t.(type) { case TaskCommit: err := s.doCommit(m) - if err != nil { - log.Error("agentic: commit task failed", "err", err, "path", m.Path) - } return nil, true, err case TaskPrompt: err := s.doPrompt(m) - if err != nil { - log.Error("agentic: prompt task failed", "err", err) - } return nil, true, err } return nil, false, nil diff --git a/pkg/ansible/executor.go b/pkg/ansible/executor.go index aa201bb1..f7e2d488 100644 --- a/pkg/ansible/executor.go +++ b/pkg/ansible/executor.go @@ -120,7 +120,7 @@ func (e *Executor) runPlay(ctx context.Context, play *Play) error { if err := e.gatherFacts(ctx, host, play); err != nil { // Non-fatal if e.Verbose > 0 { - log.Warn("gather facts failed", "host", host, "err", err) + fmt.Fprintf(os.Stderr, "Warning: gather facts failed for %s: %v\n", host, err) } } } diff --git a/pkg/ansible/ssh.go b/pkg/ansible/ssh.go index 2887d6da..e41be7a2 100644 --- a/pkg/ansible/ssh.go +++ b/pkg/ansible/ssh.go @@ -30,6 +30,7 @@ type SSHClient struct { becomeUser string becomePass string timeout time.Duration + insecure bool } // SSHConfig holds SSH connection configuration. @@ -43,6 +44,7 @@ type SSHConfig struct { BecomeUser string BecomePass string Timeout time.Duration + Insecure bool } // NewSSHClient creates a new SSH client. @@ -67,6 +69,7 @@ func NewSSHClient(cfg SSHConfig) (*SSHClient, error) { becomeUser: cfg.BecomeUser, becomePass: cfg.BecomePass, timeout: cfg.Timeout, + insecure: cfg.Insecure, } return client, nil @@ -134,27 +137,21 @@ func (c *SSHClient) Connect(ctx context.Context) error { // Host key verification var hostKeyCallback ssh.HostKeyCallback - home, err := os.UserHomeDir() - if err != nil { - return log.E("ssh.Connect", "failed to get user home dir", err) - } - knownHostsPath := filepath.Join(home, ".ssh", "known_hosts") - - // Ensure known_hosts file exists - if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) { - if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0700); err != nil { - return log.E("ssh.Connect", "failed to create .ssh dir", err) + if c.insecure { + hostKeyCallback = ssh.InsecureIgnoreHostKey() + } else { + home, err := os.UserHomeDir() + if err != nil { + return log.E("ssh.Connect", "failed to get user home dir", err) } - if err := os.WriteFile(knownHostsPath, nil, 0600); err != nil { - return log.E("ssh.Connect", "failed to create known_hosts file", err) - } - } + knownHostsPath := filepath.Join(home, ".ssh", "known_hosts") - cb, err := knownhosts.New(knownHostsPath) - if err != nil { - return log.E("ssh.Connect", "failed to load known_hosts", err) + cb, err := knownhosts.New(knownHostsPath) + if err != nil { + return log.E("ssh.Connect", "failed to load known_hosts (use Insecure=true to bypass)", err) + } + hostKeyCallback = cb } - hostKeyCallback = cb config := &ssh.ClientConfig{ User: c.user, diff --git a/pkg/ansible/ssh_test.go b/pkg/ansible/ssh_test.go deleted file mode 100644 index 17179b0d..00000000 --- a/pkg/ansible/ssh_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package ansible - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestNewSSHClient(t *testing.T) { - cfg := SSHConfig{ - Host: "localhost", - Port: 2222, - User: "root", - } - - client, err := NewSSHClient(cfg) - assert.NoError(t, err) - assert.NotNil(t, client) - assert.Equal(t, "localhost", client.host) - assert.Equal(t, 2222, client.port) - assert.Equal(t, "root", client.user) - assert.Equal(t, 30*time.Second, client.timeout) -} - -func TestSSHConfig_Defaults(t *testing.T) { - cfg := SSHConfig{ - Host: "localhost", - } - - client, err := NewSSHClient(cfg) - assert.NoError(t, err) - assert.Equal(t, 22, client.port) - assert.Equal(t, "root", client.user) - assert.Equal(t, 30*time.Second, client.timeout) -} diff --git a/pkg/cli/app.go b/pkg/cli/app.go index e904b178..0215a882 100644 --- a/pkg/cli/app.go +++ b/pkg/cli/app.go @@ -1,14 +1,10 @@ package cli import ( - "fmt" "os" - "runtime/debug" - "github.com/host-uk/core/pkg/crypt/openpgp" "github.com/host-uk/core/pkg/framework" "github.com/host-uk/core/pkg/log" - "github.com/host-uk/core/pkg/workspace" "github.com/spf13/cobra" ) @@ -24,17 +20,8 @@ var AppVersion = "dev" // Main initialises and runs the CLI application. // This is the main entry point for the CLI. -// Exits with code 1 on error or panic. +// Exits with code 1 on error. func Main() { - // Recovery from panics - defer func() { - if r := recover(); r != nil { - log.Error("recovered from panic", "error", r, "stack", string(debug.Stack())) - Shutdown() - Fatal(fmt.Errorf("panic: %v", r)) - } - }() - // Initialise CLI runtime with services if err := Init(Options{ AppName: AppName, @@ -44,27 +31,16 @@ func Main() { framework.WithName("log", NewLogService(log.Options{ Level: log.LevelInfo, })), - framework.WithName("crypt", openpgp.New), - framework.WithName("workspace", workspace.New), }, }); err != nil { - Error(err.Error()) - os.Exit(1) + Fatal(err) } defer Shutdown() // Add completion command to the CLI's root RootCmd().AddCommand(completionCmd) - if err := Execute(); err != nil { - code := 1 - var exitErr *ExitError - if As(err, &exitErr) { - code = exitErr.Code - } - Error(err.Error()) - os.Exit(code) - } + Fatal(Execute()) } // completionCmd generates shell completion scripts. diff --git a/pkg/cli/app_test.go b/pkg/cli/app_test.go deleted file mode 100644 index c11d5fe6..00000000 --- a/pkg/cli/app_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package cli - -import ( - "bytes" - "fmt" - "runtime/debug" - "sync" - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestPanicRecovery_Good verifies that the panic recovery mechanism -// catches panics and calls the appropriate shutdown and error handling. -func TestPanicRecovery_Good(t *testing.T) { - t.Run("recovery captures panic value and stack", func(t *testing.T) { - var recovered any - var capturedStack []byte - var shutdownCalled bool - - // Simulate the panic recovery pattern from Main() - func() { - defer func() { - if r := recover(); r != nil { - recovered = r - capturedStack = debug.Stack() - shutdownCalled = true // simulates Shutdown() call - } - }() - - panic("test panic") - }() - - assert.Equal(t, "test panic", recovered) - assert.True(t, shutdownCalled, "Shutdown should be called after panic recovery") - assert.NotEmpty(t, capturedStack, "Stack trace should be captured") - assert.Contains(t, string(capturedStack), "TestPanicRecovery_Good") - }) - - t.Run("recovery handles error type panics", func(t *testing.T) { - var recovered any - - func() { - defer func() { - if r := recover(); r != nil { - recovered = r - } - }() - - panic(fmt.Errorf("error panic")) - }() - - err, ok := recovered.(error) - assert.True(t, ok, "Recovered value should be an error") - assert.Equal(t, "error panic", err.Error()) - }) - - t.Run("recovery handles nil panic gracefully", func(t *testing.T) { - recoveryExecuted := false - - func() { - defer func() { - if r := recover(); r != nil { - recoveryExecuted = true - } - }() - - // No panic occurs - }() - - assert.False(t, recoveryExecuted, "Recovery block should not execute without panic") - }) -} - -// TestPanicRecovery_Bad tests error conditions in panic recovery. -func TestPanicRecovery_Bad(t *testing.T) { - t.Run("recovery handles concurrent panics", func(t *testing.T) { - var wg sync.WaitGroup - recoveryCount := 0 - var mu sync.Mutex - - for i := 0; i < 3; i++ { - wg.Add(1) - go func(id int) { - defer wg.Done() - defer func() { - if r := recover(); r != nil { - mu.Lock() - recoveryCount++ - mu.Unlock() - } - }() - - panic(fmt.Sprintf("panic from goroutine %d", id)) - }(i) - } - - wg.Wait() - assert.Equal(t, 3, recoveryCount, "All goroutine panics should be recovered") - }) -} - -// TestPanicRecovery_Ugly tests edge cases in panic recovery. -func TestPanicRecovery_Ugly(t *testing.T) { - t.Run("recovery handles typed panic values", func(t *testing.T) { - type customError struct { - code int - msg string - } - - var recovered any - - func() { - defer func() { - recovered = recover() - }() - - panic(customError{code: 500, msg: "internal error"}) - }() - - ce, ok := recovered.(customError) - assert.True(t, ok, "Should recover custom type") - assert.Equal(t, 500, ce.code) - assert.Equal(t, "internal error", ce.msg) - }) -} - -// TestMainPanicRecoveryPattern verifies the exact pattern used in Main(). -func TestMainPanicRecoveryPattern(t *testing.T) { - t.Run("pattern logs error and calls shutdown", func(t *testing.T) { - var logBuffer bytes.Buffer - var shutdownCalled bool - var fatalErr error - - // Mock implementations - mockLogError := func(msg string, args ...any) { - fmt.Fprintf(&logBuffer, msg, args...) - } - mockShutdown := func() { - shutdownCalled = true - } - mockFatal := func(err error) { - fatalErr = err - } - - // Execute the pattern from Main() - func() { - defer func() { - if r := recover(); r != nil { - mockLogError("recovered from panic: %v", r) - mockShutdown() - mockFatal(fmt.Errorf("panic: %v", r)) - } - }() - - panic("simulated crash") - }() - - assert.Contains(t, logBuffer.String(), "recovered from panic: simulated crash") - assert.True(t, shutdownCalled, "Shutdown must be called on panic") - assert.NotNil(t, fatalErr, "Fatal must be called with error") - assert.Equal(t, "panic: simulated crash", fatalErr.Error()) - }) -} diff --git a/pkg/cli/daemon.go b/pkg/cli/daemon.go index ccd3678b..692ccd6b 100644 --- a/pkg/cli/daemon.go +++ b/pkg/cli/daemon.go @@ -219,7 +219,7 @@ func (h *HealthServer) Start() error { go func() { if err := h.server.Serve(listener); err != http.ErrServerClosed { - LogError("health server error", "err", err) + LogError(fmt.Sprintf("health server error: %v", err)) } }() diff --git a/pkg/cli/errors.go b/pkg/cli/errors.go index bb9e0f71..3e482a25 100644 --- a/pkg/cli/errors.go +++ b/pkg/cli/errors.go @@ -77,86 +77,48 @@ func Join(errs ...error) error { return errors.Join(errs...) } -// ExitError represents an error that should cause the CLI to exit with a specific code. -type ExitError struct { - Code int - Err error -} - -func (e *ExitError) Error() string { - if e.Err == nil { - return "" - } - return e.Err.Error() -} - -func (e *ExitError) Unwrap() error { - return e.Err -} - -// Exit creates a new ExitError with the given code and error. -// Use this to return an error from a command with a specific exit code. -func Exit(code int, err error) error { - if err == nil { - return nil - } - return &ExitError{Code: code, Err: err} -} - // ───────────────────────────────────────────────────────────────────────────── -// Fatal Functions (Deprecated - return error from command instead) +// Fatal Functions (print and exit) // ───────────────────────────────────────────────────────────────────────────── -// Fatal prints an error message to stderr, logs it, and exits with code 1. -// -// Deprecated: return an error from the command instead. +// Fatal prints an error message and exits with code 1. func Fatal(err error) { if err != nil { - LogError("Fatal error", "err", err) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error())) + fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + err.Error())) os.Exit(1) } } -// Fatalf prints a formatted error message to stderr, logs it, and exits with code 1. -// -// Deprecated: return an error from the command instead. +// Fatalf prints a formatted error message and exits with code 1. func Fatalf(format string, args ...any) { msg := fmt.Sprintf(format, args...) - LogError("Fatal error", "msg", msg) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg)) + fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + msg)) os.Exit(1) } -// FatalWrap prints a wrapped error message to stderr, logs it, and exits with code 1. +// FatalWrap prints a wrapped error message and exits with code 1. // Does nothing if err is nil. // -// Deprecated: return an error from the command instead. -// // cli.FatalWrap(err, "load config") // Prints "✗ load config: " and exits func FatalWrap(err error, msg string) { if err == nil { return } - LogError("Fatal error", "msg", msg, "err", err) fullMsg := fmt.Sprintf("%s: %v", msg, err) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) + fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + fullMsg)) os.Exit(1) } -// FatalWrapVerb prints a wrapped error using i18n grammar to stderr, logs it, and exits with code 1. +// FatalWrapVerb prints a wrapped error using i18n grammar and exits with code 1. // Does nothing if err is nil. // -// Deprecated: return an error from the command instead. -// // cli.FatalWrapVerb(err, "load", "config") // Prints "✗ Failed to load config: " and exits func FatalWrapVerb(err error, verb, subject string) { if err == nil { return } msg := i18n.ActionFailed(verb, subject) - LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject) fullMsg := fmt.Sprintf("%s: %v", msg, err) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) + fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + fullMsg)) os.Exit(1) } diff --git a/pkg/cli/log.go b/pkg/cli/log.go index 2f8a5416..38884f74 100644 --- a/pkg/cli/log.go +++ b/pkg/cli/log.go @@ -68,31 +68,31 @@ func Log() *LogService { return svc } -// LogDebug logs a debug message with optional key-value pairs if log service is available. -func LogDebug(msg string, keyvals ...any) { +// LogDebug logs a debug message if log service is available. +func LogDebug(msg string) { if l := Log(); l != nil { - l.Debug(msg, keyvals...) + l.Debug(msg) } } -// LogInfo logs an info message with optional key-value pairs if log service is available. -func LogInfo(msg string, keyvals ...any) { +// LogInfo logs an info message if log service is available. +func LogInfo(msg string) { if l := Log(); l != nil { - l.Info(msg, keyvals...) + l.Info(msg) } } -// LogWarn logs a warning message with optional key-value pairs if log service is available. -func LogWarn(msg string, keyvals ...any) { +// LogWarn logs a warning message if log service is available. +func LogWarn(msg string) { if l := Log(); l != nil { - l.Warn(msg, keyvals...) + l.Warn(msg) } } -// LogError logs an error message with optional key-value pairs if log service is available. -func LogError(msg string, keyvals ...any) { +// LogError logs an error message if log service is available. +func LogError(msg string) { if l := Log(); l != nil { - l.Error(msg, keyvals...) + l.Error(msg) } } diff --git a/pkg/cli/output.go b/pkg/cli/output.go index 6c4fb7fc..670bda2f 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "os" "strings" "github.com/host-uk/core/pkg/i18n" @@ -46,50 +45,22 @@ func Successf(format string, args ...any) { Success(fmt.Sprintf(format, args...)) } -// Error prints an error message with cross (red) to stderr and logs it. +// Error prints an error message with cross (red). func Error(msg string) { - LogError(msg) - fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg)) + fmt.Println(ErrorStyle.Render(Glyph(":cross:") + " " + msg)) } -// Errorf prints a formatted error message to stderr and logs it. +// Errorf prints a formatted error message. func Errorf(format string, args ...any) { Error(fmt.Sprintf(format, args...)) } -// ErrorWrap prints a wrapped error message to stderr and logs it. -func ErrorWrap(err error, msg string) { - if err == nil { - return - } - Error(fmt.Sprintf("%s: %v", msg, err)) -} - -// ErrorWrapVerb prints a wrapped error using i18n grammar to stderr and logs it. -func ErrorWrapVerb(err error, verb, subject string) { - if err == nil { - return - } - msg := i18n.ActionFailed(verb, subject) - Error(fmt.Sprintf("%s: %v", msg, err)) -} - -// ErrorWrapAction prints a wrapped error using i18n grammar to stderr and logs it. -func ErrorWrapAction(err error, verb string) { - if err == nil { - return - } - msg := i18n.ActionFailed(verb, "") - Error(fmt.Sprintf("%s: %v", msg, err)) -} - -// Warn prints a warning message with warning symbol (amber) to stderr and logs it. +// Warn prints a warning message with warning symbol (amber). func Warn(msg string) { - LogWarn(msg) - fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+msg)) + fmt.Println(WarningStyle.Render(Glyph(":warn:") + " " + msg)) } -// Warnf prints a formatted warning message to stderr and logs it. +// Warnf prints a formatted warning message. func Warnf(format string, args ...any) { Warn(fmt.Sprintf(format, args...)) } diff --git a/pkg/cli/output_test.go b/pkg/cli/output_test.go index 91a92ecc..34f6a329 100644 --- a/pkg/cli/output_test.go +++ b/pkg/cli/output_test.go @@ -8,17 +8,14 @@ import ( ) func captureOutput(f func()) string { - oldOut := os.Stdout - oldErr := os.Stderr + old := os.Stdout r, w, _ := os.Pipe() os.Stdout = w - os.Stderr = w f() _ = w.Close() - os.Stdout = oldOut - os.Stderr = oldErr + os.Stdout = old var buf bytes.Buffer _, _ = io.Copy(&buf, r) diff --git a/pkg/cli/runtime.go b/pkg/cli/runtime.go index 9a33ccae..28de670c 100644 --- a/pkg/cli/runtime.go +++ b/pkg/cli/runtime.go @@ -15,6 +15,7 @@ package cli import ( "context" + "fmt" "os" "os/signal" "sync" @@ -57,10 +58,8 @@ func Init(opts Options) error { // Create root command rootCmd := &cobra.Command{ - Use: opts.AppName, - Version: opts.Version, - SilenceErrors: true, - SilenceUsage: true, + Use: opts.AppName, + Version: opts.Version, } // Attach all registered commands @@ -148,10 +147,9 @@ func Shutdown() { // --- Signal Service (internal) --- type signalService struct { - cancel context.CancelFunc - sigChan chan os.Signal - onReload func() error - shutdownOnce sync.Once + cancel context.CancelFunc + sigChan chan os.Signal + onReload func() error } // SignalOption configures signal handling. @@ -192,7 +190,7 @@ func (s *signalService) OnStartup(ctx context.Context) error { case syscall.SIGHUP: if s.onReload != nil { if err := s.onReload(); err != nil { - LogError("reload failed", "err", err) + LogError(fmt.Sprintf("reload failed: %v", err)) } else { LogInfo("configuration reloaded") } @@ -211,9 +209,7 @@ func (s *signalService) OnStartup(ctx context.Context) error { } func (s *signalService) OnShutdown(ctx context.Context) error { - s.shutdownOnce.Do(func() { - signal.Stop(s.sigChan) - close(s.sigChan) - }) + signal.Stop(s.sigChan) + close(s.sigChan) return nil } diff --git a/pkg/container/linuxkit.go b/pkg/container/linuxkit.go index 1906edb2..d3bba481 100644 --- a/pkg/container/linuxkit.go +++ b/pkg/container/linuxkit.go @@ -436,7 +436,7 @@ func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) err // Build SSH command sshArgs := []string{ "-p", fmt.Sprintf("%d", sshPort), - "-o", "StrictHostKeyChecking=yes", + "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", "root@localhost", diff --git a/pkg/crypt/openpgp/service.go b/pkg/crypt/openpgp/service.go deleted file mode 100644 index 10200588..00000000 --- a/pkg/crypt/openpgp/service.go +++ /dev/null @@ -1,191 +0,0 @@ -package openpgp - -import ( - "bytes" - "crypto" - goio "io" - "strings" - - "github.com/ProtonMail/go-crypto/openpgp" - "github.com/ProtonMail/go-crypto/openpgp/armor" - "github.com/ProtonMail/go-crypto/openpgp/packet" - core "github.com/host-uk/core/pkg/framework/core" -) - -// Service implements the core.Crypt interface using OpenPGP. -type Service struct { - core *core.Core -} - -// New creates a new OpenPGP service instance. -func New(c *core.Core) (any, error) { - return &Service{core: c}, nil -} - -// CreateKeyPair generates a new RSA-4096 PGP keypair. -// Returns the armored private key string. -func (s *Service) CreateKeyPair(name, passphrase string) (string, error) { - config := &packet.Config{ - Algorithm: packet.PubKeyAlgoRSA, - RSABits: 4096, - DefaultHash: crypto.SHA256, - DefaultCipher: packet.CipherAES256, - } - - entity, err := openpgp.NewEntity(name, "Workspace Key", "", config) - if err != nil { - return "", core.E("openpgp.CreateKeyPair", "failed to create entity", err) - } - - // Encrypt private key if passphrase is provided - if passphrase != "" { - err = entity.PrivateKey.Encrypt([]byte(passphrase)) - if err != nil { - return "", core.E("openpgp.CreateKeyPair", "failed to encrypt private key", err) - } - for _, subkey := range entity.Subkeys { - err = subkey.PrivateKey.Encrypt([]byte(passphrase)) - if err != nil { - return "", core.E("openpgp.CreateKeyPair", "failed to encrypt subkey", err) - } - } - } - - var buf bytes.Buffer - w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) - if err != nil { - return "", core.E("openpgp.CreateKeyPair", "failed to create armor encoder", err) - } - - // Manual serialization to avoid panic from re-signing encrypted keys - err = s.serializeEntity(w, entity) - if err != nil { - w.Close() - return "", core.E("openpgp.CreateKeyPair", "failed to serialize private key", err) - } - w.Close() - - return buf.String(), nil -} - -// serializeEntity manually serializes an OpenPGP entity to avoid re-signing. -func (s *Service) serializeEntity(w goio.Writer, e *openpgp.Entity) error { - err := e.PrivateKey.Serialize(w) - if err != nil { - return err - } - for _, ident := range e.Identities { - err = ident.UserId.Serialize(w) - if err != nil { - return err - } - err = ident.SelfSignature.Serialize(w) - if err != nil { - return err - } - } - for _, subkey := range e.Subkeys { - err = subkey.PrivateKey.Serialize(w) - if err != nil { - return err - } - err = subkey.Sig.Serialize(w) - if err != nil { - return err - } - } - return nil -} - -// EncryptPGP encrypts data for a recipient identified by their public key (armored string in recipientPath). -// The encrypted data is written to the provided writer and also returned as an armored string. -func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) { - entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(recipientPath)) - if err != nil { - return "", core.E("openpgp.EncryptPGP", "failed to read recipient key", err) - } - - var armoredBuf bytes.Buffer - armoredWriter, err := armor.Encode(&armoredBuf, "PGP MESSAGE", nil) - if err != nil { - return "", core.E("openpgp.EncryptPGP", "failed to create armor encoder", err) - } - - // MultiWriter to write to both the provided writer and our armored buffer - mw := goio.MultiWriter(writer, armoredWriter) - - w, err := openpgp.Encrypt(mw, entityList, nil, nil, nil) - if err != nil { - armoredWriter.Close() - return "", core.E("openpgp.EncryptPGP", "failed to start encryption", err) - } - - _, err = goio.WriteString(w, data) - if err != nil { - w.Close() - armoredWriter.Close() - return "", core.E("openpgp.EncryptPGP", "failed to write data", err) - } - - w.Close() - armoredWriter.Close() - - return armoredBuf.String(), nil -} - -// DecryptPGP decrypts a PGP message using the provided armored private key and passphrase. -func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error) { - entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey)) - if err != nil { - return "", core.E("openpgp.DecryptPGP", "failed to read private key", err) - } - - entity := entityList[0] - if entity.PrivateKey.Encrypted { - err = entity.PrivateKey.Decrypt([]byte(passphrase)) - if err != nil { - return "", core.E("openpgp.DecryptPGP", "failed to decrypt private key", err) - } - for _, subkey := range entity.Subkeys { - _ = subkey.PrivateKey.Decrypt([]byte(passphrase)) - } - } - - // Decrypt armored message - block, err := armor.Decode(strings.NewReader(message)) - if err != nil { - return "", core.E("openpgp.DecryptPGP", "failed to decode armored message", err) - } - - md, err := openpgp.ReadMessage(block.Body, entityList, nil, nil) - if err != nil { - return "", core.E("openpgp.DecryptPGP", "failed to read message", err) - } - - var buf bytes.Buffer - _, err = goio.Copy(&buf, md.UnverifiedBody) - if err != nil { - return "", core.E("openpgp.DecryptPGP", "failed to read decrypted body", err) - } - - return buf.String(), nil -} - -// HandleIPCEvents handles PGP-related IPC messages. -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - switch m := msg.(type) { - case map[string]any: - action, _ := m["action"].(string) - switch action { - case "openpgp.create_key_pair": - name, _ := m["name"].(string) - passphrase, _ := m["passphrase"].(string) - _, err := s.CreateKeyPair(name, passphrase) - return err - } - } - return nil -} - -// Ensure Service implements core.Crypt. -var _ core.Crypt = (*Service)(nil) diff --git a/pkg/crypt/openpgp/service_test.go b/pkg/crypt/openpgp/service_test.go deleted file mode 100644 index c6f1243b..00000000 --- a/pkg/crypt/openpgp/service_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package openpgp - -import ( - "bytes" - "testing" - - core "github.com/host-uk/core/pkg/framework/core" - "github.com/stretchr/testify/assert" -) - -func TestCreateKeyPair(t *testing.T) { - c, _ := core.New() - s := &Service{core: c} - - privKey, err := s.CreateKeyPair("test user", "password123") - assert.NoError(t, err) - assert.NotEmpty(t, privKey) - assert.Contains(t, privKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----") -} - -func TestEncryptDecrypt(t *testing.T) { - c, _ := core.New() - s := &Service{core: c} - - passphrase := "secret" - privKey, err := s.CreateKeyPair("test user", passphrase) - assert.NoError(t, err) - - // In this simple test, the public key is also in the armored private key string - // (openpgp.ReadArmoredKeyRing reads both) - publicKey := privKey - - data := "hello openpgp" - var buf bytes.Buffer - armored, err := s.EncryptPGP(&buf, publicKey, data) - assert.NoError(t, err) - assert.NotEmpty(t, armored) - assert.NotEmpty(t, buf.String()) - - decrypted, err := s.DecryptPGP(privKey, armored, passphrase) - assert.NoError(t, err) - assert.Equal(t, data, decrypted) -} diff --git a/pkg/devops/claude.go b/pkg/devops/claude.go index 7bfef0b3..d62b39d0 100644 --- a/pkg/devops/claude.go +++ b/pkg/devops/claude.go @@ -70,11 +70,11 @@ func (d *DevOps) Claude(ctx context.Context, projectDir string, opts ClaudeOptio // Build SSH command with agent forwarding args := []string{ - "-o", "StrictHostKeyChecking=yes", + "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", "-A", // SSH agent forwarding - "-p", fmt.Sprintf("%d", DefaultSSHPort), + "-p", "2222", } args = append(args, "root@localhost") @@ -132,10 +132,10 @@ func (d *DevOps) CopyGHAuth(ctx context.Context) error { // Use scp to copy gh config cmd := exec.CommandContext(ctx, "scp", - "-o", "StrictHostKeyChecking=yes", + "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", - "-P", fmt.Sprintf("%d", DefaultSSHPort), + "-P", "2222", "-r", ghConfigDir, "root@localhost:/root/.config/", ) diff --git a/pkg/devops/devops.go b/pkg/devops/devops.go index d3d6331e..2cad57c2 100644 --- a/pkg/devops/devops.go +++ b/pkg/devops/devops.go @@ -13,11 +13,6 @@ import ( "github.com/host-uk/core/pkg/io" ) -const ( - // DefaultSSHPort is the default port for SSH connections to the dev environment. - DefaultSSHPort = 2222 -) - // DevOps manages the portable development environment. type DevOps struct { medium io.Medium @@ -142,32 +137,12 @@ func (d *DevOps) Boot(ctx context.Context, opts BootOptions) error { Name: opts.Name, Memory: opts.Memory, CPUs: opts.CPUs, - SSHPort: DefaultSSHPort, + SSHPort: 2222, Detach: true, } _, err = d.container.Run(ctx, imagePath, runOpts) - if err != nil { - return err - } - - // Wait for SSH to be ready and scan host key - // We try for up to 60 seconds as the VM takes a moment to boot - var lastErr error - for i := 0; i < 30; i++ { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(2 * time.Second): - if err := ensureHostKey(ctx, runOpts.SSHPort); err == nil { - return nil - } else { - lastErr = err - } - } - } - - return fmt.Errorf("failed to verify host key after boot: %w", lastErr) + return err } // Stop stops the dev environment. @@ -221,7 +196,7 @@ type DevStatus struct { func (d *DevOps) Status(ctx context.Context) (*DevStatus, error) { status := &DevStatus{ Installed: d.images.IsInstalled(), - SSHPort: DefaultSSHPort, + SSHPort: 2222, } if info, ok := d.images.manifest.Images[ImageName()]; ok { diff --git a/pkg/devops/devops_test.go b/pkg/devops/devops_test.go index fc1789b0..2aef52fe 100644 --- a/pkg/devops/devops_test.go +++ b/pkg/devops/devops_test.go @@ -616,7 +616,6 @@ func TestDevOps_IsRunning_Bad_DifferentContainerName(t *testing.T) { } func TestDevOps_Boot_Good_FreshFlag(t *testing.T) { - t.Setenv("CORE_SKIP_SSH_SCAN", "true") tempDir, err := os.MkdirTemp("", "devops-test-*") require.NoError(t, err) t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) @@ -701,7 +700,6 @@ func TestDevOps_Stop_Bad_ContainerNotRunning(t *testing.T) { } func TestDevOps_Boot_Good_FreshWithNoExisting(t *testing.T) { - t.Setenv("CORE_SKIP_SSH_SCAN", "true") tempDir, err := os.MkdirTemp("", "devops-boot-fresh-*") require.NoError(t, err) t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) @@ -784,7 +782,6 @@ func TestDevOps_CheckUpdate_Delegates(t *testing.T) { } func TestDevOps_Boot_Good_Success(t *testing.T) { - t.Setenv("CORE_SKIP_SSH_SCAN", "true") tempDir, err := os.MkdirTemp("", "devops-boot-success-*") require.NoError(t, err) t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) diff --git a/pkg/devops/serve.go b/pkg/devops/serve.go index aac0e8ad..1e0dc802 100644 --- a/pkg/devops/serve.go +++ b/pkg/devops/serve.go @@ -59,11 +59,11 @@ func (d *DevOps) mountProject(ctx context.Context, path string) error { // Use reverse SSHFS mount // The VM connects back to host to mount the directory cmd := exec.CommandContext(ctx, "ssh", - "-o", "StrictHostKeyChecking=yes", + "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", "-R", "10000:localhost:22", // Reverse tunnel for SSHFS - "-p", fmt.Sprintf("%d", DefaultSSHPort), + "-p", "2222", "root@localhost", fmt.Sprintf("mkdir -p /app && sshfs -p 10000 %s@localhost:%s /app -o allow_other", os.Getenv("USER"), absPath), ) diff --git a/pkg/devops/shell.go b/pkg/devops/shell.go index fe94d1bd..8b524fac 100644 --- a/pkg/devops/shell.go +++ b/pkg/devops/shell.go @@ -33,11 +33,11 @@ func (d *DevOps) Shell(ctx context.Context, opts ShellOptions) error { // sshShell connects via SSH. func (d *DevOps) sshShell(ctx context.Context, command []string) error { args := []string{ - "-o", "StrictHostKeyChecking=yes", + "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=~/.core/known_hosts", "-o", "LogLevel=ERROR", "-A", // Agent forwarding - "-p", fmt.Sprintf("%d", DefaultSSHPort), + "-p", "2222", "root@localhost", } diff --git a/pkg/devops/ssh_utils.go b/pkg/devops/ssh_utils.go deleted file mode 100644 index d05902b8..00000000 --- a/pkg/devops/ssh_utils.go +++ /dev/null @@ -1,68 +0,0 @@ -package devops - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// ensureHostKey ensures that the host key for the dev environment is in the known hosts file. -// This is used after boot to allow StrictHostKeyChecking=yes to work. -func ensureHostKey(ctx context.Context, port int) error { - // Skip if requested (used in tests) - if os.Getenv("CORE_SKIP_SSH_SCAN") == "true" { - return nil - } - - home, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("get home dir: %w", err) - } - - knownHostsPath := filepath.Join(home, ".core", "known_hosts") - - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0755); err != nil { - return fmt.Errorf("create known_hosts dir: %w", err) - } - - // Get host key using ssh-keyscan - cmd := exec.CommandContext(ctx, "ssh-keyscan", "-p", fmt.Sprintf("%d", port), "localhost") - out, err := cmd.Output() - if err != nil { - return fmt.Errorf("ssh-keyscan failed: %w", err) - } - - if len(out) == 0 { - return fmt.Errorf("ssh-keyscan returned no keys") - } - - // Read existing known_hosts to avoid duplicates - existing, _ := os.ReadFile(knownHostsPath) - existingStr := string(existing) - - // Append new keys that aren't already there - f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return fmt.Errorf("open known_hosts: %w", err) - } - defer f.Close() - - lines := strings.Split(string(out), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if !strings.Contains(existingStr, line) { - if _, err := f.WriteString(line + "\n"); err != nil { - return fmt.Errorf("write known_hosts: %w", err) - } - } - } - - return nil -} diff --git a/pkg/framework/core/bench_test.go b/pkg/framework/core/bench_test.go deleted file mode 100644 index 2337c6ef..00000000 --- a/pkg/framework/core/bench_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package core - -import ( - "testing" -) - -func BenchmarkMessageBus_Action(b *testing.B) { - c, _ := New() - c.RegisterAction(func(c *Core, msg Message) error { - return nil - }) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = c.ACTION("test") - } -} - -func BenchmarkMessageBus_Query(b *testing.B) { - c, _ := New() - c.RegisterQuery(func(c *Core, q Query) (any, bool, error) { - return "result", true, nil - }) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _, _ = c.QUERY("test") - } -} - -func BenchmarkMessageBus_Perform(b *testing.B) { - c, _ := New() - c.RegisterTask(func(c *Core, t Task) (any, bool, error) { - return "result", true, nil - }) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _, _ = c.PERFORM("test") - } -} diff --git a/pkg/framework/core/core.go b/pkg/framework/core/core.go index 317b5039..bbfecbe7 100644 --- a/pkg/framework/core/core.go +++ b/pkg/framework/core/core.go @@ -285,12 +285,14 @@ func ServiceFor[T any](c *Core, name string) (T, error) { return typed, nil } -// MustServiceFor retrieves a typed service or returns an error if not found. -// -// Deprecated: use ServiceFor instead. This function does not panic on failure -// and is retained only for backward compatibility. -func MustServiceFor[T any](c *Core, name string) (T, error) { - return ServiceFor[T](c, name) +// MustServiceFor retrieves a registered service by name and asserts its type to the given interface T. +// It panics if the service is not found or cannot be cast to T. +func MustServiceFor[T any](c *Core, name string) T { + svc, err := ServiceFor[T](c, name) + if err != nil { + panic(err) + } + return svc } // App returns the global application instance. @@ -332,23 +334,15 @@ func ClearInstance() { } // Config returns the registered Config service. -func (c *Core) Config() (Config, error) { - return MustServiceFor[Config](c, "config") +func (c *Core) Config() Config { + cfg := MustServiceFor[Config](c, "config") + return cfg } // Display returns the registered Display service. -func (c *Core) Display() (Display, error) { - return MustServiceFor[Display](c, "display") -} - -// Workspace returns the registered Workspace service. -func (c *Core) Workspace() (Workspace, error) { - return MustServiceFor[Workspace](c, "workspace") -} - -// Crypt returns the registered Crypt service. -func (c *Core) Crypt() (Crypt, error) { - return MustServiceFor[Crypt](c, "crypt") +func (c *Core) Display() Display { + d := MustServiceFor[Display](c, "display") + return d } // Core returns self, implementing the CoreProvider interface. diff --git a/pkg/framework/core/core_test.go b/pkg/framework/core/core_test.go index 9ab42e91..60514354 100644 --- a/pkg/framework/core/core_test.go +++ b/pkg/framework/core/core_test.go @@ -68,24 +68,20 @@ func TestCore_Services_Good(t *testing.T) { err = c.RegisterService("display", &MockDisplayService{}) assert.NoError(t, err) - cfg, err := c.Config() - assert.NoError(t, err) - assert.NotNil(t, cfg) - - d, err := c.Display() - assert.NoError(t, err) - assert.NotNil(t, d) + assert.NotNil(t, c.Config()) + assert.NotNil(t, c.Display()) } func TestCore_Services_Ugly(t *testing.T) { c, err := New() assert.NoError(t, err) - _, err = c.Config() - assert.Error(t, err) - - _, err = c.Display() - assert.Error(t, err) + assert.Panics(t, func() { + c.Config() + }) + assert.Panics(t, func() { + c.Display() + }) } func TestCore_App_Good(t *testing.T) { @@ -126,15 +122,6 @@ func TestFeatures_IsEnabled_Good(t *testing.T) { assert.True(t, c.Features.IsEnabled("feature1")) assert.True(t, c.Features.IsEnabled("feature2")) assert.False(t, c.Features.IsEnabled("feature3")) - assert.False(t, c.Features.IsEnabled("")) -} - -func TestFeatures_IsEnabled_Edge(t *testing.T) { - c, _ := New() - c.Features.Flags = []string{" ", "foo"} - assert.True(t, c.Features.IsEnabled(" ")) - assert.True(t, c.Features.IsEnabled("foo")) - assert.False(t, c.Features.IsEnabled("FOO")) // Case sensitive check } func TestCore_ServiceLifecycle_Good(t *testing.T) { @@ -237,21 +224,21 @@ func TestCore_MustServiceFor_Good(t *testing.T) { assert.NoError(t, err) err = c.RegisterService("test", &MockService{Name: "test"}) assert.NoError(t, err) - svc, err := MustServiceFor[*MockService](c, "test") - assert.NoError(t, err) + svc := MustServiceFor[*MockService](c, "test") assert.Equal(t, "test", svc.GetName()) } func TestCore_MustServiceFor_Ugly(t *testing.T) { c, err := New() assert.NoError(t, err) - _, err = MustServiceFor[*MockService](c, "nonexistent") - assert.Error(t, err) - + assert.Panics(t, func() { + MustServiceFor[*MockService](c, "nonexistent") + }) err = c.RegisterService("test", "not a service") assert.NoError(t, err) - _, err = MustServiceFor[*MockService](c, "test") - assert.Error(t, err) + assert.Panics(t, func() { + MustServiceFor[*MockService](c, "test") + }) } type MockAction struct { diff --git a/pkg/framework/core/interfaces.go b/pkg/framework/core/interfaces.go index 8d587d20..632b68d7 100644 --- a/pkg/framework/core/interfaces.go +++ b/pkg/framework/core/interfaces.go @@ -3,7 +3,6 @@ package core import ( "context" "embed" - goio "io" "sync/atomic" ) @@ -110,28 +109,6 @@ type Display interface { OpenWindow(opts ...WindowOption) error } -// Workspace provides management for encrypted user workspaces. -type Workspace interface { - // CreateWorkspace creates a new encrypted workspace. - CreateWorkspace(identifier, password string) (string, error) - // SwitchWorkspace changes the active workspace. - SwitchWorkspace(name string) error - // WorkspaceFileGet retrieves the content of a file from the active workspace. - WorkspaceFileGet(filename string) (string, error) - // WorkspaceFileSet saves content to a file in the active workspace. - WorkspaceFileSet(filename, content string) error -} - -// Crypt provides PGP-based encryption, signing, and key management. -type Crypt interface { - // CreateKeyPair generates a new PGP keypair. - CreateKeyPair(name, passphrase string) (string, error) - // EncryptPGP encrypts data for a recipient. - EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) - // DecryptPGP decrypts a PGP message. - DecryptPGP(recipientPath, message, passphrase string, opts ...any) (string, error) -} - // ActionServiceStartup is a message sent when the application's services are starting up. // This provides a hook for services to perform initialization tasks. type ActionServiceStartup struct{} diff --git a/pkg/framework/core/message_bus_test.go b/pkg/framework/core/message_bus_test.go index 493c265b..e69ac95e 100644 --- a/pkg/framework/core/message_bus_test.go +++ b/pkg/framework/core/message_bus_test.go @@ -144,33 +144,3 @@ func TestMessageBus_ConcurrentAccess_Good(t *testing.T) { wg.Wait() } - -func TestMessageBus_Action_NoHandlers(t *testing.T) { - c, _ := New() - // Should not error if no handlers are registered - err := c.bus.action("no one listening") - assert.NoError(t, err) -} - -func TestMessageBus_Query_NoHandlers(t *testing.T) { - c, _ := New() - result, handled, err := c.bus.query(TestQuery{}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} - -func TestMessageBus_QueryAll_NoHandlers(t *testing.T) { - c, _ := New() - results, err := c.bus.queryAll(TestQuery{}) - assert.NoError(t, err) - assert.Empty(t, results) -} - -func TestMessageBus_Perform_NoHandlers(t *testing.T) { - c, _ := New() - result, handled, err := c.bus.perform(TestTask{}) - assert.NoError(t, err) - assert.False(t, handled) - assert.Nil(t, result) -} diff --git a/pkg/framework/core/runtime_pkg.go b/pkg/framework/core/runtime_pkg.go index 56f95974..0cb941db 100644 --- a/pkg/framework/core/runtime_pkg.go +++ b/pkg/framework/core/runtime_pkg.go @@ -34,7 +34,7 @@ func (r *ServiceRuntime[T]) Opts() T { // Config returns the registered Config service from the core application. // This is a convenience method for accessing the application's configuration. -func (r *ServiceRuntime[T]) Config() (Config, error) { +func (r *ServiceRuntime[T]) Config() Config { return r.core.Config() } diff --git a/pkg/framework/core/runtime_pkg_test.go b/pkg/framework/core/runtime_pkg_test.go index f46ad6e4..f58ebcbe 100644 --- a/pkg/framework/core/runtime_pkg_test.go +++ b/pkg/framework/core/runtime_pkg_test.go @@ -121,7 +121,8 @@ func TestNewServiceRuntime_Good(t *testing.T) { assert.Equal(t, c, sr.Core()) // We can't directly test sr.Config() without a registered config service, - // but we can ensure it returns an error. - _, err = sr.Config() - assert.Error(t, err) + // but we can ensure it doesn't panic. We'll test the panic case separately. + assert.Panics(t, func() { + sr.Config() + }) } diff --git a/pkg/framework/framework.go b/pkg/framework/framework.go index dea39655..7a50a025 100644 --- a/pkg/framework/framework.go +++ b/pkg/framework/framework.go @@ -60,11 +60,8 @@ func ServiceFor[T any](c *Core, name string) (T, error) { return core.ServiceFor[T](c, name) } -// MustServiceFor retrieves a typed service or returns an error if not found. -// -// Deprecated: use ServiceFor instead. This function does not panic on failure -// and is retained only for backward compatibility. -func MustServiceFor[T any](c *Core, name string) (T, error) { +// MustServiceFor retrieves a typed service or panics if not found. +func MustServiceFor[T any](c *Core, name string) T { return core.MustServiceFor[T](c, name) } diff --git a/pkg/gitea/client.go b/pkg/gitea/client.go deleted file mode 100644 index 2099534d..00000000 --- a/pkg/gitea/client.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package gitea provides a thin wrapper around the Gitea Go SDK -// for managing repositories, issues, and pull requests on a Gitea instance. -// -// Authentication is resolved from config file, environment variables, or flag overrides: -// -// 1. ~/.core/config.yaml keys: gitea.token, gitea.url -// 2. GITEA_TOKEN + GITEA_URL environment variables (override config file) -// 3. Flag overrides via core gitea config --url/--token (highest priority) -package gitea - -import ( - "code.gitea.io/sdk/gitea" - - "github.com/host-uk/core/pkg/log" -) - -// Client wraps the Gitea SDK client with config-based auth. -type Client struct { - api *gitea.Client - url string -} - -// New creates a new Gitea API client for the given URL and token. -func New(url, token string) (*Client, error) { - api, err := gitea.NewClient(url, gitea.SetToken(token)) - if err != nil { - return nil, log.E("gitea.New", "failed to create client", err) - } - - return &Client{api: api, url: url}, nil -} - -// API exposes the underlying SDK client for direct access. -func (c *Client) API() *gitea.Client { return c.api } - -// URL returns the Gitea instance URL. -func (c *Client) URL() string { return c.url } diff --git a/pkg/gitea/config.go b/pkg/gitea/config.go deleted file mode 100644 index 7dd881f8..00000000 --- a/pkg/gitea/config.go +++ /dev/null @@ -1,92 +0,0 @@ -package gitea - -import ( - "os" - - "github.com/host-uk/core/pkg/config" - "github.com/host-uk/core/pkg/log" -) - -const ( - // ConfigKeyURL is the config key for the Gitea instance URL. - ConfigKeyURL = "gitea.url" - // ConfigKeyToken is the config key for the Gitea API token. - ConfigKeyToken = "gitea.token" - - // DefaultURL is the default Gitea instance URL. - DefaultURL = "https://gitea.snider.dev" -) - -// NewFromConfig creates a Gitea client using the standard config resolution: -// -// 1. ~/.core/config.yaml keys: gitea.token, gitea.url -// 2. GITEA_TOKEN + GITEA_URL environment variables (override config file) -// 3. Provided flag overrides (highest priority; pass empty to skip) -func NewFromConfig(flagURL, flagToken string) (*Client, error) { - url, token, err := ResolveConfig(flagURL, flagToken) - if err != nil { - return nil, err - } - - if token == "" { - return nil, log.E("gitea.NewFromConfig", "no API token configured (set GITEA_TOKEN or run: core gitea config --token TOKEN)", nil) - } - - return New(url, token) -} - -// ResolveConfig resolves the Gitea URL and token from all config sources. -// Flag values take highest priority, then env vars, then config file. -func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { - // Start with config file values - cfg, cfgErr := config.New() - if cfgErr == nil { - _ = cfg.Get(ConfigKeyURL, &url) - _ = cfg.Get(ConfigKeyToken, &token) - } - - // Overlay environment variables - if envURL := os.Getenv("GITEA_URL"); envURL != "" { - url = envURL - } - if envToken := os.Getenv("GITEA_TOKEN"); envToken != "" { - token = envToken - } - - // Overlay flag values (highest priority) - if flagURL != "" { - url = flagURL - } - if flagToken != "" { - token = flagToken - } - - // Default URL if nothing configured - if url == "" { - url = DefaultURL - } - - return url, token, nil -} - -// SaveConfig persists the Gitea URL and/or token to the config file. -func SaveConfig(url, token string) error { - cfg, err := config.New() - if err != nil { - return log.E("gitea.SaveConfig", "failed to load config", err) - } - - if url != "" { - if err := cfg.Set(ConfigKeyURL, url); err != nil { - return log.E("gitea.SaveConfig", "failed to save URL", err) - } - } - - if token != "" { - if err := cfg.Set(ConfigKeyToken, token); err != nil { - return log.E("gitea.SaveConfig", "failed to save token", err) - } - } - - return nil -} diff --git a/pkg/gitea/issues.go b/pkg/gitea/issues.go deleted file mode 100644 index c5f1464c..00000000 --- a/pkg/gitea/issues.go +++ /dev/null @@ -1,109 +0,0 @@ -package gitea - -import ( - "code.gitea.io/sdk/gitea" - - "github.com/host-uk/core/pkg/log" -) - -// ListIssuesOpts configures issue listing. -type ListIssuesOpts struct { - State string // "open", "closed", "all" - Page int - Limit int -} - -// ListIssues returns issues for the given repository. -func (c *Client) ListIssues(owner, repo string, opts ListIssuesOpts) ([]*gitea.Issue, error) { - state := gitea.StateOpen - switch opts.State { - case "closed": - state = gitea.StateClosed - case "all": - state = gitea.StateAll - } - - limit := opts.Limit - if limit == 0 { - limit = 50 - } - - page := opts.Page - if page == 0 { - page = 1 - } - - issues, _, err := c.api.ListRepoIssues(owner, repo, gitea.ListIssueOption{ - ListOptions: gitea.ListOptions{Page: page, PageSize: limit}, - State: state, - Type: gitea.IssueTypeIssue, - }) - if err != nil { - return nil, log.E("gitea.ListIssues", "failed to list issues", err) - } - - return issues, nil -} - -// GetIssue returns a single issue by number. -func (c *Client) GetIssue(owner, repo string, number int64) (*gitea.Issue, error) { - issue, _, err := c.api.GetIssue(owner, repo, number) - if err != nil { - return nil, log.E("gitea.GetIssue", "failed to get issue", err) - } - - return issue, nil -} - -// CreateIssue creates a new issue in the given repository. -func (c *Client) CreateIssue(owner, repo string, opts gitea.CreateIssueOption) (*gitea.Issue, error) { - issue, _, err := c.api.CreateIssue(owner, repo, opts) - if err != nil { - return nil, log.E("gitea.CreateIssue", "failed to create issue", err) - } - - return issue, nil -} - -// ListPullRequests returns pull requests for the given repository. -func (c *Client) ListPullRequests(owner, repo string, state string) ([]*gitea.PullRequest, error) { - st := gitea.StateOpen - switch state { - case "closed": - st = gitea.StateClosed - case "all": - st = gitea.StateAll - } - - var all []*gitea.PullRequest - page := 1 - - for { - prs, resp, err := c.api.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{ - ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, - State: st, - }) - if err != nil { - return nil, log.E("gitea.ListPullRequests", "failed to list pull requests", err) - } - - all = append(all, prs...) - - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - - return all, nil -} - -// GetPullRequest returns a single pull request by number. -func (c *Client) GetPullRequest(owner, repo string, number int64) (*gitea.PullRequest, error) { - pr, _, err := c.api.GetPullRequest(owner, repo, number) - if err != nil { - return nil, log.E("gitea.GetPullRequest", "failed to get pull request", err) - } - - return pr, nil -} diff --git a/pkg/gitea/meta.go b/pkg/gitea/meta.go deleted file mode 100644 index 7d2e9030..00000000 --- a/pkg/gitea/meta.go +++ /dev/null @@ -1,146 +0,0 @@ -package gitea - -import ( - "time" - - "code.gitea.io/sdk/gitea" - - "github.com/host-uk/core/pkg/log" -) - -// PRMeta holds structural signals from a pull request, -// used by the pipeline MetaReader for AI-driven workflows. -type PRMeta struct { - Number int64 - Title string - State string - Author string - Branch string - BaseBranch string - Labels []string - Assignees []string - IsMerged bool - CreatedAt time.Time - UpdatedAt time.Time - CommentCount int -} - -// Comment represents a comment with metadata. -type Comment struct { - ID int64 - Author string - Body string - CreatedAt time.Time - UpdatedAt time.Time -} - -const commentPageSize = 50 - -// GetPRMeta returns structural signals for a pull request. -// This is the Gitea side of the dual MetaReader described in the pipeline design. -func (c *Client) GetPRMeta(owner, repo string, pr int64) (*PRMeta, error) { - pull, _, err := c.api.GetPullRequest(owner, repo, pr) - if err != nil { - return nil, log.E("gitea.GetPRMeta", "failed to get PR metadata", err) - } - - meta := &PRMeta{ - Number: pull.Index, - Title: pull.Title, - State: string(pull.State), - Branch: pull.Head.Ref, - BaseBranch: pull.Base.Ref, - IsMerged: pull.HasMerged, - } - - if pull.Created != nil { - meta.CreatedAt = *pull.Created - } - if pull.Updated != nil { - meta.UpdatedAt = *pull.Updated - } - - if pull.Poster != nil { - meta.Author = pull.Poster.UserName - } - - for _, label := range pull.Labels { - meta.Labels = append(meta.Labels, label.Name) - } - - for _, assignee := range pull.Assignees { - meta.Assignees = append(meta.Assignees, assignee.UserName) - } - - // Fetch comment count from the issue side (PRs are issues in Gitea). - // Paginate to get an accurate count. - count := 0 - page := 1 - for { - comments, _, listErr := c.api.ListIssueComments(owner, repo, pr, gitea.ListIssueCommentOptions{ - ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize}, - }) - if listErr != nil { - break - } - count += len(comments) - if len(comments) < commentPageSize { - break - } - page++ - } - meta.CommentCount = count - - return meta, nil -} - -// GetCommentBodies returns all comment bodies for a pull request. -// This reads full content, which is safe on the home lab Gitea instance. -func (c *Client) GetCommentBodies(owner, repo string, pr int64) ([]Comment, error) { - var comments []Comment - page := 1 - - for { - raw, _, err := c.api.ListIssueComments(owner, repo, pr, gitea.ListIssueCommentOptions{ - ListOptions: gitea.ListOptions{Page: page, PageSize: commentPageSize}, - }) - if err != nil { - return nil, log.E("gitea.GetCommentBodies", "failed to get PR comments", err) - } - - if len(raw) == 0 { - break - } - - for _, rc := range raw { - comment := Comment{ - ID: rc.ID, - Body: rc.Body, - CreatedAt: rc.Created, - UpdatedAt: rc.Updated, - } - if rc.Poster != nil { - comment.Author = rc.Poster.UserName - } - comments = append(comments, comment) - } - - if len(raw) < commentPageSize { - break - } - page++ - } - - return comments, nil -} - -// GetIssueBody returns the body text of an issue. -// This reads full content, which is safe on the home lab Gitea instance. -func (c *Client) GetIssueBody(owner, repo string, issue int64) (string, error) { - iss, _, err := c.api.GetIssue(owner, repo, issue) - if err != nil { - return "", log.E("gitea.GetIssueBody", "failed to get issue body", err) - } - - return iss.Body, nil -} diff --git a/pkg/gitea/repos.go b/pkg/gitea/repos.go deleted file mode 100644 index d70e5598..00000000 --- a/pkg/gitea/repos.go +++ /dev/null @@ -1,110 +0,0 @@ -package gitea - -import ( - "code.gitea.io/sdk/gitea" - - "github.com/host-uk/core/pkg/log" -) - -// ListOrgRepos returns all repositories for the given organisation. -func (c *Client) ListOrgRepos(org string) ([]*gitea.Repository, error) { - var all []*gitea.Repository - page := 1 - - for { - repos, resp, err := c.api.ListOrgRepos(org, gitea.ListOrgReposOptions{ - ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, - }) - if err != nil { - return nil, log.E("gitea.ListOrgRepos", "failed to list org repos", err) - } - - all = append(all, repos...) - - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - - return all, nil -} - -// ListUserRepos returns all repositories for the authenticated user. -func (c *Client) ListUserRepos() ([]*gitea.Repository, error) { - var all []*gitea.Repository - page := 1 - - for { - repos, resp, err := c.api.ListMyRepos(gitea.ListReposOptions{ - ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, - }) - if err != nil { - return nil, log.E("gitea.ListUserRepos", "failed to list user repos", err) - } - - all = append(all, repos...) - - if resp == nil || page >= resp.LastPage { - break - } - page++ - } - - return all, nil -} - -// GetRepo returns a single repository by owner and name. -func (c *Client) GetRepo(owner, name string) (*gitea.Repository, error) { - repo, _, err := c.api.GetRepo(owner, name) - if err != nil { - return nil, log.E("gitea.GetRepo", "failed to get repo", err) - } - - return repo, nil -} - -// CreateMirror creates a mirror repository on Gitea from a GitHub clone URL. -// This uses the Gitea migration API to set up a pull mirror. -// If authToken is provided, it is used to authenticate against the source (e.g. for private GitHub repos). -func (c *Client) CreateMirror(owner, name, cloneURL, authToken string) (*gitea.Repository, error) { - opts := gitea.MigrateRepoOption{ - RepoName: name, - RepoOwner: owner, - CloneAddr: cloneURL, - Service: gitea.GitServiceGithub, - Mirror: true, - Description: "Mirror of " + cloneURL, - } - - if authToken != "" { - opts.AuthToken = authToken - } - - repo, _, err := c.api.MigrateRepo(opts) - if err != nil { - return nil, log.E("gitea.CreateMirror", "failed to create mirror", err) - } - - return repo, nil -} - -// DeleteRepo deletes a repository from Gitea. -func (c *Client) DeleteRepo(owner, name string) error { - _, err := c.api.DeleteRepo(owner, name) - if err != nil { - return log.E("gitea.DeleteRepo", "failed to delete repo", err) - } - - return nil -} - -// CreateOrgRepo creates a new empty repository under an organisation. -func (c *Client) CreateOrgRepo(org string, opts gitea.CreateRepoOption) (*gitea.Repository, error) { - repo, _, err := c.api.CreateOrgRepo(org, opts) - if err != nil { - return nil, log.E("gitea.CreateOrgRepo", "failed to create org repo", err) - } - - return repo, nil -} diff --git a/pkg/io/bench_test.go b/pkg/io/bench_test.go deleted file mode 100644 index df242678..00000000 --- a/pkg/io/bench_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package io - -import ( - "testing" -) - -func BenchmarkMockMedium_Write(b *testing.B) { - m := NewMockMedium() - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = m.Write("test.txt", "some content") - } -} - -func BenchmarkMockMedium_Read(b *testing.B) { - m := NewMockMedium() - _ = m.Write("test.txt", "some content") - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = m.Read("test.txt") - } -} - -func BenchmarkMockMedium_List(b *testing.B) { - m := NewMockMedium() - _ = m.EnsureDir("dir") - for i := 0; i < 100; i++ { - _ = m.Write("dir/file"+string(rune(i))+".txt", "content") - } - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = m.List("dir") - } -} diff --git a/pkg/io/io.go b/pkg/io/io.go index 4b788358..2920008b 100644 --- a/pkg/io/io.go +++ b/pkg/io/io.go @@ -55,9 +55,17 @@ type Medium interface { // Create creates or truncates the named file. Create(path string) (goio.WriteCloser, error) - // Append opens the named file for appending, creating it if it doesn't exist. +// Append opens the named file for appending, creating it if it doesn't exist. Append(path string) (goio.WriteCloser, error) + // ReadStream returns a reader for the file content. + // Use this for large files to avoid loading the entire content into memory. + ReadStream(path string) (goio.ReadCloser, error) + + // WriteStream returns a writer for the file content. + // Use this for large files to avoid loading the entire content into memory. + WriteStream(path string) (goio.WriteCloser, error) + // Exists checks if a path exists (file or directory). Exists(path string) bool @@ -126,6 +134,16 @@ func Write(m Medium, path, content string) error { return m.Write(path, content) } +// ReadStream returns a reader for the file content from the given medium. +func ReadStream(m Medium, path string) (goio.ReadCloser, error) { + return m.ReadStream(path) +} + +// WriteStream returns a writer for the file content in the given medium. +func WriteStream(m Medium, path string) (goio.WriteCloser, error) { + return m.WriteStream(path) +} + // EnsureDir makes sure a directory exists in the given medium. func EnsureDir(m Medium, path string) error { return m.EnsureDir(path) @@ -357,6 +375,16 @@ func (m *MockMedium) Append(path string) (goio.WriteCloser, error) { }, nil } +// ReadStream returns a reader for the file content in the mock filesystem. +func (m *MockMedium) ReadStream(path string) (goio.ReadCloser, error) { + return m.Open(path) +} + +// WriteStream returns a writer for the file content in the mock filesystem. +func (m *MockMedium) WriteStream(path string) (goio.WriteCloser, error) { + return m.Create(path) +} + // MockFile implements fs.File for MockMedium. type MockFile struct { name string diff --git a/pkg/io/local/client.go b/pkg/io/local/client.go index db667d7a..84d46d83 100644 --- a/pkg/io/local/client.go +++ b/pkg/io/local/client.go @@ -213,6 +213,26 @@ func (m *Medium) Append(p string) (goio.WriteCloser, error) { return os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) } +// ReadStream returns a reader for the file content. +// +// This is a convenience wrapper around Open that exposes a streaming-oriented +// API, as required by the io.Medium interface, while Open provides the more +// general filesystem-level operation. Both methods are kept for semantic +// clarity and backward compatibility. +func (m *Medium) ReadStream(path string) (goio.ReadCloser, error) { + return m.Open(path) +} + +// WriteStream returns a writer for the file content. +// +// This is a convenience wrapper around Create that exposes a streaming-oriented +// API, as required by the io.Medium interface, while Create provides the more +// general filesystem-level operation. Both methods are kept for semantic +// clarity and backward compatibility. +func (m *Medium) WriteStream(path string) (goio.WriteCloser, error) { + return m.Create(path) +} + // Delete removes a file or empty directory. func (m *Medium) Delete(p string) error { full, err := m.validatePath(p) diff --git a/pkg/io/local/client_test.go b/pkg/io/local/client_test.go index b299c4fe..7a88a32a 100644 --- a/pkg/io/local/client_test.go +++ b/pkg/io/local/client_test.go @@ -1,8 +1,10 @@ package local import ( + "io" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -388,86 +390,38 @@ func TestIsDir_Good(t *testing.T) { assert.False(t, medium.IsDir("nonexistent")) } -func TestPath_Traversal_Advanced(t *testing.T) { - m := &Medium{root: "/sandbox"} - - // Multiple levels of traversal - assert.Equal(t, "/sandbox/file.txt", m.path("../../../file.txt")) - assert.Equal(t, "/sandbox/target", m.path("dir/../../target")) - - // Traversal with hidden files - assert.Equal(t, "/sandbox/.ssh/id_rsa", m.path(".ssh/id_rsa")) - assert.Equal(t, "/sandbox/id_rsa", m.path(".ssh/../id_rsa")) - - // Null bytes (Go's filepath.Clean handles them, but good to check) - assert.Equal(t, "/sandbox/file\x00.txt", m.path("file\x00.txt")) -} - -func TestValidatePath_Security(t *testing.T) { +func TestReadStream(t *testing.T) { root := t.TempDir() - m, err := New(root) + m, _ := New(root) + + content := "streaming content" + err := m.Write("stream.txt", content) assert.NoError(t, err) - // Create a directory outside the sandbox - outside := t.TempDir() - outsideFile := filepath.Join(outside, "secret.txt") - err = os.WriteFile(outsideFile, []byte("secret"), 0644) + reader, err := m.ReadStream("stream.txt") assert.NoError(t, err) + defer reader.Close() - // Test 1: Simple traversal - _, err = m.validatePath("../outside.txt") - assert.NoError(t, err) // path() sanitizes to root, so this shouldn't escape - - // Test 2: Symlink escape - // Create a symlink inside the sandbox pointing outside - linkPath := filepath.Join(root, "evil_link") - err = os.Symlink(outside, linkPath) + // Read only first 9 bytes + limitReader := io.LimitReader(reader, 9) + data, err := io.ReadAll(limitReader) assert.NoError(t, err) - - // Try to access a file through the symlink - _, err = m.validatePath("evil_link/secret.txt") - assert.Error(t, err) - assert.ErrorIs(t, err, os.ErrPermission) - - // Test 3: Nested symlink escape - innerDir := filepath.Join(root, "inner") - err = os.Mkdir(innerDir, 0755) - assert.NoError(t, err) - nestedLink := filepath.Join(innerDir, "nested_evil") - err = os.Symlink(outside, nestedLink) - assert.NoError(t, err) - - _, err = m.validatePath("inner/nested_evil/secret.txt") - assert.Error(t, err) - assert.ErrorIs(t, err, os.ErrPermission) + assert.Equal(t, "streaming", string(data)) } -func TestEmptyPaths(t *testing.T) { +func TestWriteStream(t *testing.T) { root := t.TempDir() - m, err := New(root) + m, _ := New(root) + + writer, err := m.WriteStream("output.txt") assert.NoError(t, err) - // Read empty path (should fail as it's a directory) - _, err = m.Read("") - assert.Error(t, err) - - // Write empty path (should fail as it's a directory) - err = m.Write("", "content") - assert.Error(t, err) - - // EnsureDir empty path (should be ok, it's just the root) - err = m.EnsureDir("") + _, err = io.Copy(writer, strings.NewReader("piped data")) + assert.NoError(t, err) + err = writer.Close() assert.NoError(t, err) - // IsDir empty path (should be true for root, but current impl returns false for "") - // Wait, I noticed IsDir returns false for "" in the code. - assert.False(t, m.IsDir("")) - - // Exists empty path (root exists) - assert.True(t, m.Exists("")) - - // List empty path (lists root) - entries, err := m.List("") + content, err := m.Read("output.txt") assert.NoError(t, err) - assert.NotNil(t, entries) + assert.Equal(t, "piped data", content) } diff --git a/pkg/log/errors.go b/pkg/log/errors.go index af55a429..c6775521 100644 --- a/pkg/log/errors.go +++ b/pkg/log/errors.go @@ -174,37 +174,6 @@ func Root(err error) error { } } -// StackTrace returns the logical stack trace (chain of operations) from an error. -// It returns an empty slice if no operational context is found. -func StackTrace(err error) []string { - var stack []string - for err != nil { - if e, ok := err.(*Err); ok { - if e.Op != "" { - stack = append(stack, e.Op) - } - } - err = errors.Unwrap(err) - } - return stack -} - -// FormatStackTrace returns a pretty-printed logical stack trace. -func FormatStackTrace(err error) string { - stack := StackTrace(err) - if len(stack) == 0 { - return "" - } - var res string - for i, op := range stack { - if i > 0 { - res += " -> " - } - res += op - } - return res -} - // --- Combined Log-and-Return Helpers --- // LogError logs an error at Error level and returns a wrapped error. diff --git a/pkg/log/errors_test.go b/pkg/log/errors_test.go index b403cfd2..96cbd12f 100644 --- a/pkg/log/errors_test.go +++ b/pkg/log/errors_test.go @@ -3,7 +3,6 @@ package log import ( "bytes" "errors" - "fmt" "strings" "testing" @@ -304,46 +303,3 @@ func TestMust_Ugly_Panics(t *testing.T) { output := buf.String() assert.True(t, strings.Contains(output, "[ERR]") || len(output) > 0) } - -func TestStackTrace_Good(t *testing.T) { - // Nested operations - err := E("op1", "msg1", nil) - err = Wrap(err, "op2", "msg2") - err = Wrap(err, "op3", "msg3") - - stack := StackTrace(err) - assert.Equal(t, []string{"op3", "op2", "op1"}, stack) - - // Format - formatted := FormatStackTrace(err) - assert.Equal(t, "op3 -> op2 -> op1", formatted) -} - -func TestStackTrace_PlainError(t *testing.T) { - err := errors.New("plain error") - assert.Empty(t, StackTrace(err)) - assert.Empty(t, FormatStackTrace(err)) -} - -func TestStackTrace_Nil(t *testing.T) { - assert.Empty(t, StackTrace(nil)) - assert.Empty(t, FormatStackTrace(nil)) -} - -func TestStackTrace_NoOp(t *testing.T) { - err := &Err{Msg: "no op"} - assert.Empty(t, StackTrace(err)) - assert.Empty(t, FormatStackTrace(err)) -} - -func TestStackTrace_Mixed(t *testing.T) { - err := E("inner", "msg", nil) - err = errors.New("middle: " + err.Error()) // Breaks the chain if not handled properly, but Unwrap should work if it's a wrapped error - // Wait, errors.New doesn't wrap. fmt.Errorf("%w") does. - err = E("inner", "msg", nil) - err = fmt.Errorf("wrapper: %w", err) - err = Wrap(err, "outer", "msg") - - stack := StackTrace(err) - assert.Equal(t, []string{"outer", "inner"}, stack) -} diff --git a/pkg/log/log.go b/pkg/log/log.go index 019e128d..a2bc9eb4 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -164,41 +164,6 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) { timestamp := styleTimestamp(time.Now().Format("15:04:05")) - // Automatically extract context from error if present in keyvals - origLen := len(keyvals) - for i := 0; i < origLen; i += 2 { - if i+1 < origLen { - if err, ok := keyvals[i+1].(error); ok { - if op := Op(err); op != "" { - // Check if op is already in keyvals - hasOp := false - for j := 0; j < len(keyvals); j += 2 { - if keyvals[j] == "op" { - hasOp = true - break - } - } - if !hasOp { - keyvals = append(keyvals, "op", op) - } - } - if stack := FormatStackTrace(err); stack != "" { - // Check if stack is already in keyvals - hasStack := false - for j := 0; j < len(keyvals); j += 2 { - if keyvals[j] == "stack" { - hasStack = true - break - } - } - if !hasStack { - keyvals = append(keyvals, "stack", stack) - } - } - } - } - } - // Format key-value pairs var kvStr string if len(keyvals) > 0 { diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go index 558e75b3..22752979 100644 --- a/pkg/log/log_test.go +++ b/pkg/log/log_test.go @@ -76,24 +76,6 @@ func TestLogger_KeyValues(t *testing.T) { } } -func TestLogger_ErrorContext(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Output: &buf, Level: LevelInfo}) - - err := E("test.Op", "failed", NewError("root cause")) - err = Wrap(err, "outer.Op", "outer failed") - - l.Error("something failed", "err", err) - - got := buf.String() - if !strings.Contains(got, "op=outer.Op") { - t.Errorf("expected output to contain op=outer.Op, got %q", got) - } - if !strings.Contains(got, "stack=outer.Op -> test.Op") { - t.Errorf("expected output to contain stack=outer.Op -> test.Op, got %q", got) - } -} - func TestLogger_SetLevel(t *testing.T) { l := New(Options{Level: LevelInfo}) diff --git a/pkg/mcp/integration_test.go b/pkg/mcp/integration_test.go deleted file mode 100644 index de35e66e..00000000 --- a/pkg/mcp/integration_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package mcp - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIntegration_FileTools(t *testing.T) { - tmpDir := t.TempDir() - s, err := New(WithWorkspaceRoot(tmpDir)) - assert.NoError(t, err) - - ctx := context.Background() - - // 1. Test file_write - writeInput := WriteFileInput{ - Path: "test.txt", - Content: "hello world", - } - _, writeOutput, err := s.writeFile(ctx, nil, writeInput) - assert.NoError(t, err) - assert.True(t, writeOutput.Success) - assert.Equal(t, "test.txt", writeOutput.Path) - - // Verify on disk - content, _ := os.ReadFile(filepath.Join(tmpDir, "test.txt")) - assert.Equal(t, "hello world", string(content)) - - // 2. Test file_read - readInput := ReadFileInput{ - Path: "test.txt", - } - _, readOutput, err := s.readFile(ctx, nil, readInput) - assert.NoError(t, err) - assert.Equal(t, "hello world", readOutput.Content) - assert.Equal(t, "plaintext", readOutput.Language) - - // 3. Test file_edit (replace_all=false) - editInput := EditDiffInput{ - Path: "test.txt", - OldString: "world", - NewString: "mcp", - } - _, editOutput, err := s.editDiff(ctx, nil, editInput) - assert.NoError(t, err) - assert.True(t, editOutput.Success) - assert.Equal(t, 1, editOutput.Replacements) - - // Verify change - _, readOutput, _ = s.readFile(ctx, nil, readInput) - assert.Equal(t, "hello mcp", readOutput.Content) - - // 4. Test file_edit (replace_all=true) - _ = s.medium.Write("multi.txt", "abc abc abc") - editInputMulti := EditDiffInput{ - Path: "multi.txt", - OldString: "abc", - NewString: "xyz", - ReplaceAll: true, - } - _, editOutput, err = s.editDiff(ctx, nil, editInputMulti) - assert.NoError(t, err) - assert.Equal(t, 3, editOutput.Replacements) - - content, _ = os.ReadFile(filepath.Join(tmpDir, "multi.txt")) - assert.Equal(t, "xyz xyz xyz", string(content)) - - // 5. Test dir_list - _ = s.medium.EnsureDir("subdir") - _ = s.medium.Write("subdir/file1.txt", "content1") - - listInput := ListDirectoryInput{ - Path: "subdir", - } - _, listOutput, err := s.listDirectory(ctx, nil, listInput) - assert.NoError(t, err) - assert.Len(t, listOutput.Entries, 1) - assert.Equal(t, "file1.txt", listOutput.Entries[0].Name) - assert.False(t, listOutput.Entries[0].IsDir) -} - -func TestIntegration_ErrorPaths(t *testing.T) { - tmpDir := t.TempDir() - s, err := New(WithWorkspaceRoot(tmpDir)) - assert.NoError(t, err) - - ctx := context.Background() - - // Read nonexistent file - _, _, err = s.readFile(ctx, nil, ReadFileInput{Path: "nonexistent.txt"}) - assert.Error(t, err) - - // Edit nonexistent file - _, _, err = s.editDiff(ctx, nil, EditDiffInput{ - Path: "nonexistent.txt", - OldString: "foo", - NewString: "bar", - }) - assert.Error(t, err) - - // Edit with empty old_string - _, _, err = s.editDiff(ctx, nil, EditDiffInput{ - Path: "test.txt", - OldString: "", - NewString: "bar", - }) - assert.Error(t, err) - - // Edit with old_string not found - _ = s.medium.Write("test.txt", "hello") - _, _, err = s.editDiff(ctx, nil, EditDiffInput{ - Path: "test.txt", - OldString: "missing", - NewString: "bar", - }) - assert.Error(t, err) -} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 289bb980..3f08ec79 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -11,7 +11,6 @@ import ( "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/io/local" - "github.com/host-uk/core/pkg/log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -295,7 +294,6 @@ func (s *Service) readFile(ctx context.Context, req *mcp.CallToolRequest, input s.logger.Info("MCP tool execution", "tool", "file_read", "path", input.Path, "user", log.Username()) content, err := s.medium.Read(input.Path) if err != nil { - log.Error("mcp: read file failed", "path", input.Path, "err", err) return nil, ReadFileOutput{}, fmt.Errorf("failed to read file: %w", err) } return nil, ReadFileOutput{ @@ -309,7 +307,6 @@ func (s *Service) writeFile(ctx context.Context, req *mcp.CallToolRequest, input s.logger.Security("MCP tool execution", "tool", "file_write", "path", input.Path, "user", log.Username()) // Medium.Write creates parent directories automatically if err := s.medium.Write(input.Path, input.Content); err != nil { - log.Error("mcp: write file failed", "path", input.Path, "err", err) return nil, WriteFileOutput{}, fmt.Errorf("failed to write file: %w", err) } return nil, WriteFileOutput{Success: true, Path: input.Path}, nil @@ -319,7 +316,6 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i s.logger.Info("MCP tool execution", "tool", "dir_list", "path", input.Path, "user", log.Username()) entries, err := s.medium.List(input.Path) if err != nil { - log.Error("mcp: list directory failed", "path", input.Path, "err", err) return nil, ListDirectoryOutput{}, fmt.Errorf("failed to list directory: %w", err) } result := make([]DirectoryEntry, 0, len(entries)) @@ -342,7 +338,6 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i func (s *Service) createDirectory(ctx context.Context, req *mcp.CallToolRequest, input CreateDirectoryInput) (*mcp.CallToolResult, CreateDirectoryOutput, error) { s.logger.Security("MCP tool execution", "tool", "dir_create", "path", input.Path, "user", log.Username()) if err := s.medium.EnsureDir(input.Path); err != nil { - log.Error("mcp: create directory failed", "path", input.Path, "err", err) return nil, CreateDirectoryOutput{}, fmt.Errorf("failed to create directory: %w", err) } return nil, CreateDirectoryOutput{Success: true, Path: input.Path}, nil @@ -351,7 +346,6 @@ func (s *Service) createDirectory(ctx context.Context, req *mcp.CallToolRequest, func (s *Service) deleteFile(ctx context.Context, req *mcp.CallToolRequest, input DeleteFileInput) (*mcp.CallToolResult, DeleteFileOutput, error) { s.logger.Security("MCP tool execution", "tool", "file_delete", "path", input.Path, "user", log.Username()) if err := s.medium.Delete(input.Path); err != nil { - log.Error("mcp: delete file failed", "path", input.Path, "err", err) return nil, DeleteFileOutput{}, fmt.Errorf("failed to delete file: %w", err) } return nil, DeleteFileOutput{Success: true, Path: input.Path}, nil @@ -360,7 +354,6 @@ func (s *Service) deleteFile(ctx context.Context, req *mcp.CallToolRequest, inpu func (s *Service) renameFile(ctx context.Context, req *mcp.CallToolRequest, input RenameFileInput) (*mcp.CallToolResult, RenameFileOutput, error) { s.logger.Security("MCP tool execution", "tool", "file_rename", "oldPath", input.OldPath, "newPath", input.NewPath, "user", log.Username()) if err := s.medium.Rename(input.OldPath, input.NewPath); err != nil { - log.Error("mcp: rename file failed", "oldPath", input.OldPath, "newPath", input.NewPath, "err", err) return nil, RenameFileOutput{}, fmt.Errorf("failed to rename file: %w", err) } return nil, RenameFileOutput{Success: true, OldPath: input.OldPath, NewPath: input.NewPath}, nil @@ -418,7 +411,6 @@ func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input content, err := s.medium.Read(input.Path) if err != nil { - log.Error("mcp: edit file read failed", "path", input.Path, "err", err) return nil, EditDiffOutput{}, fmt.Errorf("failed to read file: %w", err) } @@ -439,7 +431,6 @@ func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input } if err := s.medium.Write(input.Path, content); err != nil { - log.Error("mcp: edit file write failed", "path", input.Path, "err", err) return nil, EditDiffOutput{}, fmt.Errorf("failed to write file: %w", err) } diff --git a/pkg/mcp/transport_tcp.go b/pkg/mcp/transport_tcp.go index 507aef8f..27e976f3 100644 --- a/pkg/mcp/transport_tcp.go +++ b/pkg/mcp/transport_tcp.go @@ -9,7 +9,6 @@ import ( "os" "strings" - "github.com/host-uk/core/pkg/log" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -65,7 +64,7 @@ func (s *Service) ServeTCP(ctx context.Context, addr string) error { if addr == "" { addr = t.listener.Addr().String() } - s.logger.Security("MCP TCP server listening", "addr", addr, "user", log.Username()) +s.logger.Security("MCP TCP server listening", "addr", addr, "user", log.Username()) for { conn, err := t.listener.Accept() @@ -74,7 +73,7 @@ func (s *Service) ServeTCP(ctx context.Context, addr string) error { case <-ctx.Done(): return nil default: - s.logger.Error("MCP TCP accept error", "err", err, "user", log.Username()) +s.logger.Error("MCP TCP accept error", "err", err, "user", log.Username()) continue } } @@ -101,7 +100,7 @@ func (s *Service) handleConnection(ctx context.Context, conn net.Conn) { // Run server (blocks until connection closed) // Server.Run calls Connect, then Read loop. if err := server.Run(ctx, transport); err != nil { - s.logger.Error("MCP TCP connection error", "err", err, "remote", conn.RemoteAddr().String(), "user", log.Username()) +s.logger.Error("MCP TCP connection error", "err", err, "remote", conn.RemoteAddr().String(), "user", log.Username()) } } diff --git a/pkg/process/types.go b/pkg/process/types.go index 4489af74..74e03a6d 100644 --- a/pkg/process/types.go +++ b/pkg/process/types.go @@ -11,11 +11,8 @@ // ) // // // Get service and run a process -// svc, err := framework.ServiceFor[*process.Service](core, "process") -// if err != nil { -// return err -// } -// proc, err := svc.Start(ctx, "go", "test", "./...") +// svc := framework.MustServiceFor[*process.Service](core, "process") +// proc, _ := svc.Start(ctx, "go", "test", "./...") // // # Listening for Events // diff --git a/pkg/unifi/client.go b/pkg/unifi/client.go deleted file mode 100644 index 13b15d34..00000000 --- a/pkg/unifi/client.go +++ /dev/null @@ -1,53 +0,0 @@ -package unifi - -import ( - "crypto/tls" - "net/http" - - uf "github.com/unpoller/unifi/v5" - - "github.com/host-uk/core/pkg/log" -) - -// Client wraps the unpoller UniFi client with config-based auth. -type Client struct { - api *uf.Unifi - url string -} - -// New creates a new UniFi API client for the given controller URL and credentials. -// TLS verification can be disabled via the insecure parameter (useful for self-signed certs on home lab controllers). -func New(url, user, pass, apikey string, insecure bool) (*Client, error) { - cfg := &uf.Config{ - URL: url, - User: user, - Pass: pass, - APIKey: apikey, - } - - // Skip TLS verification if requested (e.g. for self-signed certs) - httpClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: insecure, - MinVersion: tls.VersionTLS12, - }, - }, - } - - api, err := uf.NewUnifi(cfg) - if err != nil { - return nil, log.E("unifi.New", "failed to create client", err) - } - - // Override the HTTP client to skip TLS verification - api.Client = httpClient - - return &Client{api: api, url: url}, nil -} - -// API exposes the underlying SDK client for direct access. -func (c *Client) API() *uf.Unifi { return c.api } - -// URL returns the UniFi controller URL. -func (c *Client) URL() string { return c.url } diff --git a/pkg/unifi/clients.go b/pkg/unifi/clients.go deleted file mode 100644 index 74e1ca2d..00000000 --- a/pkg/unifi/clients.go +++ /dev/null @@ -1,64 +0,0 @@ -package unifi - -import ( - uf "github.com/unpoller/unifi/v5" - - "github.com/host-uk/core/pkg/log" -) - -// ClientFilter controls which clients are returned. -type ClientFilter struct { - Site string // Filter by site name (empty = all sites) - Wired bool // Show only wired clients - Wireless bool // Show only wireless clients -} - -// GetClients returns connected clients from the UniFi controller, -// optionally filtered by site and connection type. -func (c *Client) GetClients(filter ClientFilter) ([]*uf.Client, error) { - sites, err := c.getSitesForFilter(filter.Site) - if err != nil { - return nil, err - } - - clients, err := c.api.GetClients(sites) - if err != nil { - return nil, log.E("unifi.GetClients", "failed to fetch clients", err) - } - - // Apply wired/wireless filter - if filter.Wired || filter.Wireless { - var filtered []*uf.Client - for _, cl := range clients { - if filter.Wired && cl.IsWired.Val { - filtered = append(filtered, cl) - } else if filter.Wireless && !cl.IsWired.Val { - filtered = append(filtered, cl) - } - } - return filtered, nil - } - - return clients, nil -} - -// getSitesForFilter resolves sites by name or returns all sites. -func (c *Client) getSitesForFilter(siteName string) ([]*uf.Site, error) { - sites, err := c.GetSites() - if err != nil { - return nil, err - } - - if siteName == "" { - return sites, nil - } - - // Filter to matching site - for _, s := range sites { - if s.Name == siteName { - return []*uf.Site{s}, nil - } - } - - return nil, log.E("unifi.getSitesForFilter", "site not found: "+siteName, nil) -} diff --git a/pkg/unifi/config.go b/pkg/unifi/config.go deleted file mode 100644 index 727b739e..00000000 --- a/pkg/unifi/config.go +++ /dev/null @@ -1,145 +0,0 @@ -// Package unifi provides a thin wrapper around the unpoller/unifi Go SDK -// for managing UniFi network controllers, devices, and connected clients. -// -// Authentication is resolved from config file, environment variables, or flag overrides: -// -// 1. ~/.core/config.yaml keys: unifi.url, unifi.user, unifi.pass, unifi.apikey -// 2. UNIFI_URL + UNIFI_USER + UNIFI_PASS + UNIFI_APIKEY environment variables (override config file) -// 3. Flag overrides via core unifi config --url/--user/--pass/--apikey (highest priority) -package unifi - -import ( - "os" - - "github.com/host-uk/core/pkg/config" - "github.com/host-uk/core/pkg/log" -) - -const ( - // ConfigKeyURL is the config key for the UniFi controller URL. - ConfigKeyURL = "unifi.url" - // ConfigKeyUser is the config key for the UniFi username. - ConfigKeyUser = "unifi.user" - // ConfigKeyPass is the config key for the UniFi password. - ConfigKeyPass = "unifi.pass" - // ConfigKeyAPIKey is the config key for the UniFi API key. - ConfigKeyAPIKey = "unifi.apikey" - // ConfigKeyInsecure is the config key for allowing insecure TLS connections. - ConfigKeyInsecure = "unifi.insecure" - - // DefaultURL is the default UniFi controller URL. - DefaultURL = "https://10.69.1.1" -) - -// NewFromConfig creates a UniFi client using the standard config resolution: -// -// 1. ~/.core/config.yaml keys: unifi.url, unifi.user, unifi.pass, unifi.apikey, unifi.insecure -// 2. UNIFI_URL + UNIFI_USER + UNIFI_PASS + UNIFI_APIKEY + UNIFI_INSECURE environment variables (override config file) -// 3. Provided flag overrides (highest priority; pass nil to skip) -func NewFromConfig(flagURL, flagUser, flagPass, flagAPIKey string, flagInsecure *bool) (*Client, error) { - url, user, pass, apikey, insecure, err := ResolveConfig(flagURL, flagUser, flagPass, flagAPIKey, flagInsecure) - if err != nil { - return nil, err - } - - if user == "" && apikey == "" { - return nil, log.E("unifi.NewFromConfig", "no credentials configured (set UNIFI_USER/UNIFI_PASS or UNIFI_APIKEY, or run: core unifi config)", nil) - } - - return New(url, user, pass, apikey, insecure) -} - -// ResolveConfig resolves the UniFi URL and credentials from all config sources. -// Flag values take highest priority, then env vars, then config file. -func ResolveConfig(flagURL, flagUser, flagPass, flagAPIKey string, flagInsecure *bool) (url, user, pass, apikey string, insecure bool, err error) { - // Start with config file values - cfg, cfgErr := config.New() - if cfgErr == nil { - _ = cfg.Get(ConfigKeyURL, &url) - _ = cfg.Get(ConfigKeyUser, &user) - _ = cfg.Get(ConfigKeyPass, &pass) - _ = cfg.Get(ConfigKeyAPIKey, &apikey) - _ = cfg.Get(ConfigKeyInsecure, &insecure) - } - - // Overlay environment variables - if envURL := os.Getenv("UNIFI_URL"); envURL != "" { - url = envURL - } - if envUser := os.Getenv("UNIFI_USER"); envUser != "" { - user = envUser - } - if envPass := os.Getenv("UNIFI_PASS"); envPass != "" { - pass = envPass - } - if envAPIKey := os.Getenv("UNIFI_APIKEY"); envAPIKey != "" { - apikey = envAPIKey - } - if envInsecure := os.Getenv("UNIFI_INSECURE"); envInsecure != "" { - insecure = envInsecure == "true" || envInsecure == "1" - } - - // Overlay flag values (highest priority) - if flagURL != "" { - url = flagURL - } - if flagUser != "" { - user = flagUser - } - if flagPass != "" { - pass = flagPass - } - if flagAPIKey != "" { - apikey = flagAPIKey - } - if flagInsecure != nil { - insecure = *flagInsecure - } - - // Default URL if nothing configured - if url == "" { - url = DefaultURL - } - - return url, user, pass, apikey, insecure, nil -} - -// SaveConfig persists the UniFi URL and/or credentials to the config file. -func SaveConfig(url, user, pass, apikey string, insecure *bool) error { - cfg, err := config.New() - if err != nil { - return log.E("unifi.SaveConfig", "failed to load config", err) - } - - if url != "" { - if err := cfg.Set(ConfigKeyURL, url); err != nil { - return log.E("unifi.SaveConfig", "failed to save URL", err) - } - } - - if user != "" { - if err := cfg.Set(ConfigKeyUser, user); err != nil { - return log.E("unifi.SaveConfig", "failed to save user", err) - } - } - - if pass != "" { - if err := cfg.Set(ConfigKeyPass, pass); err != nil { - return log.E("unifi.SaveConfig", "failed to save password", err) - } - } - - if apikey != "" { - if err := cfg.Set(ConfigKeyAPIKey, apikey); err != nil { - return log.E("unifi.SaveConfig", "failed to save API key", err) - } - } - - if insecure != nil { - if err := cfg.Set(ConfigKeyInsecure, *insecure); err != nil { - return log.E("unifi.SaveConfig", "failed to save insecure flag", err) - } - } - - return nil -} diff --git a/pkg/unifi/devices.go b/pkg/unifi/devices.go deleted file mode 100644 index 0e4e1940..00000000 --- a/pkg/unifi/devices.go +++ /dev/null @@ -1,116 +0,0 @@ -package unifi - -import ( - uf "github.com/unpoller/unifi/v5" - - "github.com/host-uk/core/pkg/log" -) - -// DeviceInfo is a flat representation of any UniFi infrastructure device. -type DeviceInfo struct { - Name string - IP string - Mac string - Model string - Version string - Type string // uap, usw, usg, udm, uxg - Status int // 1 = online -} - -// GetDevices returns the raw device container for a site (or all sites). -func (c *Client) GetDevices(siteName string) (*uf.Devices, error) { - sites, err := c.getSitesForFilter(siteName) - if err != nil { - return nil, err - } - - devices, err := c.api.GetDevices(sites) - if err != nil { - return nil, log.E("unifi.GetDevices", "failed to fetch devices", err) - } - - return devices, nil -} - -// GetDeviceList returns a flat list of all infrastructure devices, -// optionally filtered by device type (uap, usw, usg, udm, uxg). -func (c *Client) GetDeviceList(siteName, deviceType string) ([]DeviceInfo, error) { - devices, err := c.GetDevices(siteName) - if err != nil { - return nil, err - } - - var list []DeviceInfo - - if deviceType == "" || deviceType == "uap" { - for _, d := range devices.UAPs { - list = append(list, DeviceInfo{ - Name: d.Name, - IP: d.IP, - Mac: d.Mac, - Model: d.Model, - Version: d.Version, - Type: "uap", - Status: d.State.Int(), - }) - } - } - - if deviceType == "" || deviceType == "usw" { - for _, d := range devices.USWs { - list = append(list, DeviceInfo{ - Name: d.Name, - IP: d.IP, - Mac: d.Mac, - Model: d.Model, - Version: d.Version, - Type: "usw", - Status: d.State.Int(), - }) - } - } - - if deviceType == "" || deviceType == "usg" { - for _, d := range devices.USGs { - list = append(list, DeviceInfo{ - Name: d.Name, - IP: d.IP, - Mac: d.Mac, - Model: d.Model, - Version: d.Version, - Type: "usg", - Status: d.State.Int(), - }) - } - } - - if deviceType == "" || deviceType == "udm" { - for _, d := range devices.UDMs { - list = append(list, DeviceInfo{ - Name: d.Name, - IP: d.IP, - Mac: d.Mac, - Model: d.Model, - Version: d.Version, - Type: "udm", - Status: d.State.Int(), - }) - } - } - - if deviceType == "" || deviceType == "uxg" { - for _, d := range devices.UXGs { - list = append(list, DeviceInfo{ - Name: d.Name, - IP: d.IP, - Mac: d.Mac, - Model: d.Model, - Version: d.Version, - Type: "uxg", - Status: d.State.Int(), - }) - } - } - - return list, nil -} diff --git a/pkg/unifi/networks.go b/pkg/unifi/networks.go deleted file mode 100644 index 3ff33b75..00000000 --- a/pkg/unifi/networks.go +++ /dev/null @@ -1,62 +0,0 @@ -package unifi - -import ( - "encoding/json" - "fmt" - - "github.com/host-uk/core/pkg/log" -) - -// NetworkConf represents a UniFi network configuration entry. -type NetworkConf struct { - ID string `json:"_id"` - Name string `json:"name"` - Purpose string `json:"purpose"` // wan, corporate, remote-user-vpn - IPSubnet string `json:"ip_subnet"` // CIDR (e.g. "10.69.1.1/24") - VLAN int `json:"vlan"` // VLAN ID (0 = untagged) - VLANEnabled bool `json:"vlan_enabled"` // Whether VLAN tagging is active - Enabled bool `json:"enabled"` - NetworkGroup string `json:"networkgroup"` // LAN, WAN, WAN2 - NetworkIsolationEnabled bool `json:"network_isolation_enabled"` - InternetAccessEnabled bool `json:"internet_access_enabled"` - IsNAT bool `json:"is_nat"` - DHCPEnabled bool `json:"dhcpd_enabled"` - DHCPStart string `json:"dhcpd_start"` - DHCPStop string `json:"dhcpd_stop"` - DHCPDNS1 string `json:"dhcpd_dns_1"` - DHCPDNS2 string `json:"dhcpd_dns_2"` - DHCPDNSEnabled bool `json:"dhcpd_dns_enabled"` - MDNSEnabled bool `json:"mdns_enabled"` - FirewallZoneID string `json:"firewall_zone_id"` - GatewayType string `json:"gateway_type"` - VPNType string `json:"vpn_type"` - WANType string `json:"wan_type"` // pppoe, dhcp, static - WANNetworkGroup string `json:"wan_networkgroup"` -} - -// networkConfResponse is the raw API response wrapper. -type networkConfResponse struct { - Data []NetworkConf `json:"data"` -} - -// GetNetworks returns all network configurations from the controller. -// Uses the raw controller API for the full networkconf data. -func (c *Client) GetNetworks(siteName string) ([]NetworkConf, error) { - if siteName == "" { - siteName = "default" - } - - path := fmt.Sprintf("/api/s/%s/rest/networkconf", siteName) - - raw, err := c.api.GetJSON(path) - if err != nil { - return nil, log.E("unifi.GetNetworks", "failed to fetch networks", err) - } - - var resp networkConfResponse - if err := json.Unmarshal(raw, &resp); err != nil { - return nil, log.E("unifi.GetNetworks", "failed to parse networks", err) - } - - return resp.Data, nil -} diff --git a/pkg/unifi/routes.go b/pkg/unifi/routes.go deleted file mode 100644 index 6454b163..00000000 --- a/pkg/unifi/routes.go +++ /dev/null @@ -1,66 +0,0 @@ -package unifi - -import ( - "encoding/json" - "fmt" - "net/url" - - "github.com/host-uk/core/pkg/log" -) - -// Route represents a single entry in the UniFi gateway routing table. -type Route struct { - Network string `json:"pfx"` // CIDR prefix (e.g. "10.69.1.0/24") - NextHop string `json:"nh"` // Next-hop address or interface - Interface string `json:"intf"` // Interface name (e.g. "br0", "eth4") - Type string `json:"type"` // Route type (e.g. "S" static, "C" connected, "K" kernel) - Distance int `json:"distance"` // Administrative distance - Metric int `json:"metric"` // Route metric - Uptime int `json:"uptime"` // Uptime in seconds - Selected bool `json:"fib"` // Whether route is in the forwarding table -} - -// routeResponse is the raw API response wrapper. -type routeResponse struct { - Data []Route `json:"data"` -} - -// GetRoutes returns the active routing table from the gateway for the given site. -// Uses the raw controller API since unpoller doesn't wrap this endpoint. -func (c *Client) GetRoutes(siteName string) ([]Route, error) { - if siteName == "" { - siteName = "default" - } - - path := fmt.Sprintf("/api/s/%s/stat/routing", url.PathEscape(siteName)) - - raw, err := c.api.GetJSON(path) - if err != nil { - return nil, log.E("unifi.GetRoutes", "failed to fetch routing table", err) - } - - var resp routeResponse - if err := json.Unmarshal(raw, &resp); err != nil { - return nil, log.E("unifi.GetRoutes", "failed to parse routing table", err) - } - - return resp.Data, nil -} - -// RouteTypeName returns a human-readable name for the route type code. -func RouteTypeName(code string) string { - switch code { - case "S": - return "static" - case "C": - return "connected" - case "K": - return "kernel" - case "B": - return "bgp" - case "O": - return "ospf" - default: - return code - } -} diff --git a/pkg/unifi/sites.go b/pkg/unifi/sites.go deleted file mode 100644 index 7162b791..00000000 --- a/pkg/unifi/sites.go +++ /dev/null @@ -1,17 +0,0 @@ -package unifi - -import ( - uf "github.com/unpoller/unifi/v5" - - "github.com/host-uk/core/pkg/log" -) - -// GetSites returns all sites from the UniFi controller. -func (c *Client) GetSites() ([]*uf.Site, error) { - sites, err := c.api.GetSites() - if err != nil { - return nil, log.E("unifi.GetSites", "failed to fetch sites", err) - } - - return sites, nil -} diff --git a/pkg/workspace/service.go b/pkg/workspace/service.go deleted file mode 100644 index 67e37233..00000000 --- a/pkg/workspace/service.go +++ /dev/null @@ -1,148 +0,0 @@ -package workspace - -import ( - "crypto/sha256" - "encoding/hex" - "os" - "path/filepath" - "sync" - - core "github.com/host-uk/core/pkg/framework/core" - "github.com/host-uk/core/pkg/io" -) - -// Service implements the core.Workspace interface. -type Service struct { - core *core.Core - activeWorkspace string - rootPath string - medium io.Medium - mu sync.RWMutex -} - -// New creates a new Workspace service instance. -func New(c *core.Core) (any, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, core.E("workspace.New", "failed to determine home directory", err) - } - rootPath := filepath.Join(home, ".core", "workspaces") - - s := &Service{ - core: c, - rootPath: rootPath, - medium: io.Local, - } - - if err := s.medium.EnsureDir(rootPath); err != nil { - return nil, core.E("workspace.New", "failed to ensure root directory", err) - } - - return s, nil -} - -// CreateWorkspace creates a new encrypted workspace. -// Identifier is hashed (SHA-256 as proxy for LTHN) to create the directory name. -// A PGP keypair is generated using the password. -func (s *Service) CreateWorkspace(identifier, password string) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - - // 1. Identification (LTHN hash proxy) - hash := sha256.Sum256([]byte(identifier)) - wsID := hex.EncodeToString(hash[:]) - wsPath := filepath.Join(s.rootPath, wsID) - - if s.medium.Exists(wsPath) { - return "", core.E("workspace.CreateWorkspace", "workspace already exists", nil) - } - - // 2. Directory structure - dirs := []string{"config", "log", "data", "files", "keys"} - for _, d := range dirs { - if err := s.medium.EnsureDir(filepath.Join(wsPath, d)); err != nil { - return "", core.E("workspace.CreateWorkspace", "failed to create directory: "+d, err) - } - } - - // 3. PGP Keypair generation - crypt, err := s.core.Crypt() - if err != nil { - return "", core.E("workspace.CreateWorkspace", "failed to retrieve crypt service", err) - } - privKey, err := crypt.CreateKeyPair(identifier, password) - if err != nil { - return "", core.E("workspace.CreateWorkspace", "failed to generate keys", err) - } - - // Save private key - if err := s.medium.Write(filepath.Join(wsPath, "keys", "private.key"), privKey); err != nil { - return "", core.E("workspace.CreateWorkspace", "failed to save private key", err) - } - - return wsID, nil -} - -// SwitchWorkspace changes the active workspace. -func (s *Service) SwitchWorkspace(name string) error { - s.mu.Lock() - defer s.mu.Unlock() - - wsPath := filepath.Join(s.rootPath, name) - if !s.medium.IsDir(wsPath) { - return core.E("workspace.SwitchWorkspace", "workspace not found: "+name, nil) - } - - s.activeWorkspace = name - return nil -} - -// WorkspaceFileGet retrieves the content of a file from the active workspace. -// In a full implementation, this would involve decryption using the workspace key. -func (s *Service) WorkspaceFileGet(filename string) (string, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - if s.activeWorkspace == "" { - return "", core.E("workspace.WorkspaceFileGet", "no active workspace", nil) - } - - path := filepath.Join(s.rootPath, s.activeWorkspace, "files", filename) - return s.medium.Read(path) -} - -// WorkspaceFileSet saves content to a file in the active workspace. -// In a full implementation, this would involve encryption using the workspace key. -func (s *Service) WorkspaceFileSet(filename, content string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if s.activeWorkspace == "" { - return core.E("workspace.WorkspaceFileSet", "no active workspace", nil) - } - - path := filepath.Join(s.rootPath, s.activeWorkspace, "files", filename) - return s.medium.Write(path, content) -} - -// HandleIPCEvents handles workspace-related IPC messages. -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - switch m := msg.(type) { - case map[string]any: - action, _ := m["action"].(string) - switch action { - case "workspace.create": - id, _ := m["identifier"].(string) - pass, _ := m["password"].(string) - _, err := s.CreateWorkspace(id, pass) - return err - case "workspace.switch": - name, _ := m["name"].(string) - return s.SwitchWorkspace(name) - } - } - return nil -} - -// Ensure Service implements core.Workspace. -var _ core.Workspace = (*Service)(nil) diff --git a/pkg/workspace/service_test.go b/pkg/workspace/service_test.go deleted file mode 100644 index c8b89457..00000000 --- a/pkg/workspace/service_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package workspace - -import ( - "os" - "path/filepath" - "testing" - - "github.com/host-uk/core/pkg/crypt/openpgp" - core "github.com/host-uk/core/pkg/framework/core" - "github.com/stretchr/testify/assert" -) - -func TestWorkspace(t *testing.T) { - // Setup core with crypt service - c, _ := core.New( - core.WithName("crypt", openpgp.New), - ) - - tempHome, _ := os.MkdirTemp("", "core-test-home") - defer os.RemoveAll(tempHome) - - // Mock os.UserHomeDir by setting HOME env - oldHome := os.Getenv("HOME") - os.Setenv("HOME", tempHome) - defer os.Setenv("HOME", oldHome) - - s_any, err := New(c) - assert.NoError(t, err) - s := s_any.(*Service) - - // Test CreateWorkspace - id, err := s.CreateWorkspace("test-user", "pass123") - assert.NoError(t, err) - assert.NotEmpty(t, id) - - wsPath := filepath.Join(tempHome, ".core", "workspaces", id) - assert.DirExists(t, wsPath) - assert.DirExists(t, filepath.Join(wsPath, "keys")) - assert.FileExists(t, filepath.Join(wsPath, "keys", "private.key")) - - // Test SwitchWorkspace - err = s.SwitchWorkspace(id) - assert.NoError(t, err) - assert.Equal(t, id, s.activeWorkspace) - - // Test File operations - filename := "secret.txt" - content := "top secret info" - err = s.WorkspaceFileSet(filename, content) - assert.NoError(t, err) - - got, err := s.WorkspaceFileGet(filename) - assert.NoError(t, err) - assert.Equal(t, content, got) -}