Compare commits

..

42 Commits

Author SHA1 Message Date
lukaszraczylo 38b7a06c53 Update go.mod and go.sum (#28) 2026-01-12 03:41:55 +00:00
lukaszraczylo 7ad96e3f72 Update go.mod and go.sum (#27) 2026-01-10 03:37:23 +00:00
lukaszraczylo ac7c855de5 Update go.mod and go.sum (#26) 2026-01-09 03:39:39 +00:00
lukaszraczylo 4074a7186c Update go.mod and go.sum (#25) 2026-01-07 03:39:19 +00:00
lukaszraczylo a5cc95a26e Update go.mod and go.sum (#24) 2025-12-23 03:39:14 +00:00
lukaszraczylo 0f977683cd Update go.mod and go.sum (#23) 2025-12-21 03:39:29 +00:00
lukaszraczylo dcebdf718a Update go.mod and go.sum (#22) 2025-12-20 03:32:25 +00:00
lukaszraczylo 5967f26c21 Update go.mod and go.sum (#21) 2025-12-19 03:37:51 +00:00
lukaszraczylo 285ced6755 fixup! Update go.mod and go.sum (#20) 2025-12-18 09:38:02 +00:00
lukaszraczylo 9fe076acb2 Update go.mod and go.sum (#20) 2025-12-18 03:37:26 +00:00
lukaszraczylo 92746efcf5 Update go.mod and go.sum (#19) 2025-12-15 03:40:40 +00:00
lukaszraczylo 391bce366d fixup! fixup! Add artifacts signing. 2025-12-15 00:16:16 +00:00
lukaszraczylo 9fd8f9b03b fixup! Add artifacts signing. 2025-12-14 23:56:42 +00:00
lukaszraczylo 7032bb5bee Add artifacts signing. 2025-12-14 23:29:27 +00:00
lukaszraczylo 6cb4f91ece Cleanup and refactor. 2025-12-14 18:17:20 +00:00
lukaszraczylo 5d600043f0 Update go.mod and go.sum (#18) 2025-12-13 03:32:36 +00:00
lukaszraczylo 9bb6fbc48d Update go.mod and go.sum (#17) 2025-12-12 03:38:34 +00:00
lukaszraczylo f4334ebdc9 Update go.mod and go.sum (#16) 2025-12-11 03:38:45 +00:00
lukaszraczylo 50f94bda87 Update go.mod and go.sum (#15) 2025-12-09 01:13:24 +00:00
lukaszraczylo d9888f1a56 Cleanup (#14)
* Codebase cleanup
2025-12-09 01:06:38 +00:00
lukaszraczylo 7dec532e18 Use shared PR workflow 2025-12-08 01:32:30 +00:00
lukaszraczylo aa7695b3be Trigger autoupdate. 2025-12-08 01:10:11 +00:00
lukaszraczylo 1bacd31f27 fixup! Add verified param to the releaser. 2025-12-07 16:40:37 +00:00
lukaszraczylo bfecbdf056 Add verified param to the releaser. 2025-12-07 14:40:12 +00:00
lukaszraczylo 754108474c fixup! fixup! fixup! Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 14:36:45 +00:00
lukaszraczylo 690c587c0a fixup! fixup! Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 14:16:43 +00:00
lukaszraczylo 0d03f228f9 fixup! Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 14:04:38 +00:00
lukaszraczylo 2a44c6ff9c Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 13:59:51 +00:00
lukaszraczylo 8672d932bb Move build and release to shared workflow 2025-12-07 13:34:14 +00:00
lukaszraczylo 87317adb91 Move to shared workflow. 2025-12-07 13:16:41 +00:00
lukaszraczylo ced7e80a06 Update the autoupdate workflow 2025-12-07 12:35:56 +00:00
lukaszraczylo 13723733df Update autoupdate workflow. LOL. 2025-12-03 00:47:06 +00:00
lukaszraczylo 9538623bcb Remove unused struct 2025-12-02 18:13:05 +00:00
lukaszraczylo 8bb377909c Add startupSettleTime to zeroconf to avoid race conditions. 2025-12-02 18:06:11 +00:00
lukaszraczylo 263a0370d3 fixup! fixup! Fix autoupdate workflow. 2025-12-01 14:39:54 +00:00
lukaszraczylo 62eca4a9a1 Update go.mod and go.sum
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-01 14:34:23 +00:00
lukaszraczylo ea20a037b9 fixup! Fix autoupdate workflow. 2025-12-01 14:31:06 +00:00
lukaszraczylo 46db732f87 Fix autoupdate workflow. 2025-12-01 14:24:34 +00:00
lukaszraczylo a297ba7073 Enhancement: Empty config
When user starts kportal for the first time, and there is no config file,
kportal should create an empty config file with default values and empty
forwarding rules, so that user can easily edit the config file and add their
own rules.
2025-11-29 12:44:33 +00:00
lukaszraczylo 518879dc56 Update menus to wrap for smaller screens. 2025-11-29 02:09:54 +00:00
lukaszraczylo 649227b201 Update taps confusion. 2025-11-29 01:39:21 +00:00
lukaszraczylo 28e2fc315a Create CNAME 2025-11-28 00:29:37 +00:00
39 changed files with 857 additions and 504 deletions
+11 -64
View File
@@ -5,69 +5,16 @@ on:
schedule:
- cron: "0 3 * * *"
env:
GO_VERSION: ">=1.21"
permissions:
contents: write
actions: write
pull-requests: write
security-events: write
jobs:
# This job is responsible for preparation of the build
# environment variables.
prepare:
name: Preparing build context
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
id: cache
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Go get dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
go get ./...
# This job is responsible for running tests and linting the codebase
test:
name: "Unit testing"
runs-on: ubuntu-latest
container: golang:1
needs: [prepare]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Ensure full history is checked out
token: ${{ secrets.GHCR_TOKEN }}
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Install dependencies
run: |
apt-get update
apt-get install ca-certificates make -y
update-ca-certificates
go mod tidy
go get -u -v ./...
go mod tidy -v
- name: Run unit tests
run: |
CI_RUN=${CI} make test
git config --global --add safe.directory /__w/kportal/kportal
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Update go.mod and go.sum"
commit_options: "--no-verify --signoff"
file_pattern: "go.mod go.sum"
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: ">=1.24"
release-workflow: "release.yml"
secrets: inherit
+22
View File
@@ -0,0 +1,22 @@
name: Pull Request
on:
pull_request:
branches:
- main
push:
branches:
- "**"
- "!main"
permissions:
contents: write
actions: write
pull-requests: write
security-events: write
jobs:
pr-checks:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: ">=1.24"
+9 -80
View File
@@ -5,90 +5,19 @@ on:
branches:
- main
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- "**.go"
- "go.mod"
- "go.sum"
workflow_dispatch:
permissions:
contents: write
packages: write
id-token: write
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run tests with race detector
run: go test -race -v ./...
version:
name: Calculate Version
needs: test
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version_formatted.outputs.version }}
version_tag: ${{ steps.version_formatted.outputs.version_tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Calculate semantic version
id: semver
uses: lukaszraczylo/semver-generator@v1
with:
config_file: semver.yaml
repository_local: true
- name: Format version
id: version_formatted
run: |
VERSION="${{ steps.semver.outputs.semantic_version }}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "version_tag=v${VERSION}" >> $GITHUB_OUTPUT
- name: Display version
run: |
echo "Calculated version: ${{ steps.version_formatted.outputs.version }}"
echo "Version tag: ${{ steps.version_formatted.outputs.version_tag }}"
release:
name: Release with GoReleaser
needs: version
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Create and push tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a ${{ needs.version.outputs.version_tag }} -m "Release ${{ needs.version.outputs.version_tag }}"
git push origin ${{ needs.version.outputs.version_tag }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: ">=1.24"
secrets: inherit
+2 -2
View File
@@ -6,7 +6,7 @@ on:
push:
branches: ["main"]
paths:
- 'docs/**'
- "docs/**"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -39,7 +39,7 @@ jobs:
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: 'docs/'
path: "docs/"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+25 -10
View File
@@ -23,11 +23,11 @@ builds:
archives:
- id: kportal
format: tar.gz
formats: [tar.gz]
name_template: "kportal-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
formats: [zip]
files:
- LICENSE
- README.md
@@ -53,17 +53,32 @@ release:
draft: false
prerelease: auto
brews:
homebrew_casks:
- repository:
owner: lukaszraczylo
name: brew-taps
name: homebrew-taps
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Formula
directory: Casks
homepage: https://lukaszraczylo.github.io/kportal
description: "Modern Kubernetes port-forward manager with interactive TUI"
license: MIT
test: |
system "#{bin}/kportal", "--version"
dependencies:
- name: kubernetes-cli
type: optional
url:
verified: github.com/lukaszraczylo/kportal
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{staged_path}/kportal"]
end
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
args:
- sign-blob
- "--bundle=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
+20 -2
View File
@@ -54,12 +54,17 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i
## 📦 Installation
### Homebrew (macOS/Linux)
### Homebrew (macOS)
```bash
brew install lukaszraczylo/brew-taps/kportal
brew install --cask lukaszraczylo/taps/kportal
```
> **Note**: If you previously installed via `brew install lukaszraczylo/taps/kportal` (formula), uninstall first:
> ```bash
> brew uninstall kportal
> ```
### Quick Install
```bash
@@ -78,6 +83,19 @@ cd kportal
make build && make install
```
### Verifying Release Signatures
All release checksums are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. To verify:
```bash
# Download the checksum file and its sigstore bundle from the release
cosign verify-blob \
--certificate-identity-regexp "https://github.com/lukaszraczylo/kportal/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--bundle "kportal-<version>-checksums.txt.sigstore.json" \
kportal-<version>-checksums.txt
```
## 🚀 Quick Start
Create `.kportal.yaml`:
+58 -6
View File
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
@@ -49,6 +50,23 @@ var (
appVersion = "0.1.0" // Set via ldflags during build
)
// promptCreateConfig asks the user if they want to create a new config file.
// Returns true if the user answers yes, false otherwise.
func promptCreateConfig(path string) bool {
fmt.Printf("Configuration file not found: %s\n", path)
fmt.Print("Would you like to create an empty configuration? [Y/n] ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.TrimSpace(strings.ToLower(response))
// Empty response (just Enter) defaults to yes
return response == "" || response == "y" || response == "yes"
}
func main() {
flag.Parse()
@@ -173,14 +191,38 @@ func main() {
// Load configuration
cfg, err := config.LoadConfig(*configFile)
configIsNew := false
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
if err == config.ErrConfigNotFound {
// Config file doesn't exist - offer to create it
if !promptCreateConfig(*configFile) {
os.Exit(0)
}
// Create empty config file
if err := config.CreateEmptyConfigFile(*configFile); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err)
os.Exit(1)
}
fmt.Printf("Created %s\n", *configFile)
fmt.Println("Use 'n' in the UI to add port forwards, or edit the file manually.")
fmt.Println()
// Load the newly created config
cfg, err = config.LoadConfig(*configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
configIsNew = true
} else {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
}
// Validate configuration
// Validate configuration (allow empty configs for newly created files)
validator := config.NewValidator()
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 {
fmt.Fprint(os.Stderr, config.FormatValidationErrors(errs))
os.Exit(1)
}
@@ -253,9 +295,9 @@ func main() {
// Interactive mode with bubbletea
bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) {
if enable {
manager.EnableForward(id)
_ = manager.EnableForward(id)
} else {
manager.DisableForward(id)
_ = manager.DisableForward(id)
}
}, appVersion)
@@ -267,16 +309,26 @@ func main() {
bubbleTeaUI.SetHTTPLogSubscriber(func(forwardID string, callback func(entry ui.HTTPLogEntry)) func() {
worker := manager.GetWorker(forwardID)
if worker == nil {
logger.Debug("HTTP log subscription failed: worker not found", map[string]interface{}{
"forward_id": forwardID,
})
return func() {} // No-op cleanup
}
proxy := worker.GetHTTPProxy()
if proxy == nil {
// This is expected for forwards without httpLog enabled - not an error
logger.Debug("HTTP log subscription skipped: proxy not enabled", map[string]interface{}{
"forward_id": forwardID,
})
return func() {} // HTTP logging not enabled for this forward
}
proxyLogger := proxy.GetLogger()
if proxyLogger == nil {
logger.Debug("HTTP log subscription failed: logger not available", map[string]interface{}{
"forward_id": forwardID,
})
return func() {}
}
+1
View File
@@ -0,0 +1 @@
kportal.raczylo.com
+3 -3
View File
@@ -556,11 +556,11 @@
<i class="fas fa-beer text-orange-500 dark:text-orange-400 text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Homebrew</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS & Linux</p>
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS</p>
</div>
</div>
<div onclick="copyToClipboard('brew install lukaszraczylo/brew-taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
<code class="block whitespace-nowrap font-mono">brew install lukaszraczylo/brew-taps/kportal</code>
<div onclick="copyToClipboard('brew install --cask lukaszraczylo/taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
<code class="block whitespace-nowrap font-mono">brew install --cask lukaszraczylo/taps/kportal</code>
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-orange-400 transition-colors duration-300"></i></div>
</div>
</div>
+20 -21
View File
@@ -1,6 +1,6 @@
module github.com/nvm/kportal
go 1.24.2
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
@@ -10,28 +10,28 @@ require (
github.com/grandcat/zeroconf v1.0.0
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.34.2
k8s.io/apimachinery v0.34.2
k8s.io/client-go v0.34.2
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
k8s.io/klog/v2 v2.130.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/x/ansi v0.11.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.0 // indirect
github.com/clipperhouse/displaywidth v0.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
@@ -44,7 +44,6 @@ require (
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
@@ -54,7 +53,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/miekg/dns v1.1.70 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -70,20 +69,20 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
+48 -68
View File
@@ -1,3 +1,5 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -8,20 +10,20 @@ github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEe
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
@@ -40,10 +42,10 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
@@ -76,15 +78,13 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
@@ -93,8 +93,6 @@ github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -108,8 +106,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -128,18 +126,18 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -152,66 +150,48 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -221,18 +201,18 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
-17
View File
@@ -27,16 +27,6 @@ type Config struct {
ProgressCallback ProgressCallback // Optional callback for progress updates
}
// DefaultConfig returns a default benchmark configuration
func DefaultConfig() Config {
return Config{
Method: "GET",
Concurrency: 10,
Requests: 100,
Timeout: 30 * time.Second,
}
}
// Runner executes HTTP benchmarks
type Runner struct {
client *http.Client
@@ -221,10 +211,3 @@ func (r *Runner) makeRequest(ctx context.Context, cfg Config) (statusCode int, b
return resp.StatusCode, int64(len(respBody)), bytesWritten, nil
}
// Progress represents the current progress of a benchmark run
type Progress struct {
Completed int
Total int
Elapsed time.Duration
}
-9
View File
@@ -206,15 +206,6 @@ func TestRunnerWithBody(t *testing.T) {
assert.Equal(t, int64(15), results.BytesWritten)
}
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
assert.Equal(t, "GET", cfg.Method)
assert.Equal(t, 10, cfg.Concurrency)
assert.Equal(t, 100, cfg.Requests)
assert.Equal(t, 30*time.Second, cfg.Timeout)
}
func TestRunnerWithProgressCallback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Millisecond) // Add small delay so progress ticker can fire
+63
View File
@@ -2,6 +2,7 @@ package config
import (
"bytes"
"errors"
"fmt"
"os"
"strings"
@@ -10,6 +11,9 @@ import (
"gopkg.in/yaml.v3"
)
// ErrConfigNotFound is returned when the configuration file does not exist
var ErrConfigNotFound = fmt.Errorf("config file not found")
const (
// maxConfigSize is the maximum allowed configuration file size (10MB)
maxConfigSize = 10 * 1024 * 1024
@@ -282,6 +286,9 @@ func LoadConfig(path string) (*Config, error) {
// Validate file size before reading
fileInfo, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrConfigNotFound
}
return nil, fmt.Errorf("failed to stat config file: %w", err)
}
@@ -289,6 +296,7 @@ func LoadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("config file too large: %d bytes (max %d)", fileInfo.Size(), maxConfigSize)
}
// #nosec G304 -- path is validated in main.go (no system dirs, absolute path)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
@@ -337,3 +345,58 @@ func (c *Config) GetAllForwards() []Forward {
return forwards
}
// NewEmptyConfig returns a minimal empty configuration with no forwards.
// This is used when creating a new config file for the first time.
func NewEmptyConfig() *Config {
return &Config{
Contexts: []Context{},
}
}
// IsEmpty returns true if the configuration has no forwards defined.
func (c *Config) IsEmpty() bool {
return len(c.Contexts) == 0 || len(c.GetAllForwards()) == 0
}
// CreateEmptyConfigFile creates a new empty configuration file at the given path.
// Returns an error if the file already exists or cannot be created.
func CreateEmptyConfigFile(path string) error {
// Check if file already exists
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("config file already exists: %s", path)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to check config file: %w", err)
}
cfg := NewEmptyConfig()
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal empty config: %w", err)
}
// Add a helpful comment header
header := `# kportal configuration file
# Add port forwards using the 'n' key in the TUI, or manually add them below.
#
# Example forward:
# contexts:
# - name: my-cluster
# namespaces:
# - name: default
# forwards:
# - resource: service/my-service
# protocol: tcp
# port: 8080
# localPort: 8080
#
`
content := header + string(data)
// Write with restrictive permissions (0600)
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
+121 -1
View File
@@ -97,7 +97,7 @@ func TestLoadConfig_FileNotFound(t *testing.T) {
cfg, err := LoadConfig("/non/existent/path/.kportal.yaml")
assert.Error(t, err, "LoadConfig should fail with non-existent file")
assert.Nil(t, cfg, "config should be nil on error")
assert.Contains(t, err.Error(), "failed to stat config file", "error should mention stat failure")
assert.Equal(t, ErrConfigNotFound, err, "should return ErrConfigNotFound")
}
func TestForward_ID(t *testing.T) {
@@ -397,3 +397,123 @@ func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) {
})
}
}
func TestNewEmptyConfig(t *testing.T) {
cfg := NewEmptyConfig()
assert.NotNil(t, cfg, "NewEmptyConfig should return non-nil config")
assert.Empty(t, cfg.Contexts, "NewEmptyConfig should have empty contexts")
assert.True(t, cfg.IsEmpty(), "NewEmptyConfig should be considered empty")
}
func TestConfig_IsEmpty(t *testing.T) {
tests := []struct {
name string
config *Config
expected bool
}{
{
name: "nil contexts",
config: &Config{},
expected: true,
},
{
name: "empty contexts slice",
config: &Config{Contexts: []Context{}},
expected: true,
},
{
name: "context with empty namespaces",
config: &Config{
Contexts: []Context{
{Name: "test", Namespaces: []Namespace{}},
},
},
expected: true,
},
{
name: "context with namespace but no forwards",
config: &Config{
Contexts: []Context{
{
Name: "test",
Namespaces: []Namespace{
{Name: "default", Forwards: []Forward{}},
},
},
},
},
expected: true,
},
{
name: "config with forward",
config: &Config{
Contexts: []Context{
{
Name: "test",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app", Port: 8080, LocalPort: 8080},
},
},
},
},
},
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.config.IsEmpty())
})
}
}
func TestCreateEmptyConfigFile(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create empty config file
err := CreateEmptyConfigFile(configPath)
assert.NoError(t, err, "CreateEmptyConfigFile should succeed")
// Verify file exists
_, err = os.Stat(configPath)
assert.NoError(t, err, "config file should exist")
// Verify file is readable and parseable
cfg, err := LoadConfig(configPath)
assert.NoError(t, err, "should be able to load created config")
assert.NotNil(t, cfg, "config should not be nil")
assert.True(t, cfg.IsEmpty(), "created config should be empty")
// Verify file permissions (0600)
info, _ := os.Stat(configPath)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "file should have 0600 permissions")
// Verify file contains helpful header
content, _ := os.ReadFile(configPath)
assert.Contains(t, string(content), "# kportal configuration file", "should contain header comment")
assert.Contains(t, string(content), "Example forward", "should contain example")
}
func TestCreateEmptyConfigFile_AlreadyExists(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create existing file
err := os.WriteFile(configPath, []byte("existing content"), 0644)
assert.NoError(t, err)
// Try to create config file - should fail
err = CreateEmptyConfigFile(configPath)
assert.Error(t, err, "CreateEmptyConfigFile should fail when file exists")
assert.Contains(t, err.Error(), "already exists")
// Verify original content is preserved
content, _ := os.ReadFile(configPath)
assert.Equal(t, "existing content", string(content))
}
+2 -2
View File
@@ -264,8 +264,8 @@ func (m *Mutator) writeAtomic(cfg *Config) error {
// Atomic rename
if err := os.Rename(tmpFile, m.configPath); err != nil {
// Clean up temp file on failure
os.Remove(tmpFile)
// Clean up temp file on failure - error ignored as we're already handling the rename error
_ = os.Remove(tmpFile)
return fmt.Errorf("failed to rename temp file: %w", err)
}
+13 -5
View File
@@ -22,11 +22,6 @@ type ValidationError struct {
Context map[string]string // Additional context information
}
// Error implements the error interface.
func (e ValidationError) Error() string {
return e.Message
}
// Validator validates configuration files.
type Validator struct{}
@@ -37,6 +32,13 @@ func NewValidator() *Validator {
// ValidateConfig validates the entire configuration and returns all errors found.
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
return v.ValidateConfigWithOptions(cfg, false)
}
// ValidateConfigWithOptions validates configuration with configurable strictness.
// When allowEmpty is true, empty configurations (no contexts/forwards) are allowed.
// This is useful for newly created config files where the user will add forwards via the TUI.
func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []ValidationError {
var errs []ValidationError
if cfg == nil {
@@ -46,6 +48,12 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
}}
}
// If empty configs are allowed and this config is empty, skip structure validation
if allowEmpty && cfg.IsEmpty() {
// Still validate health check and reliability if present (they don't require forwards)
return errs
}
// Validate structure
errs = append(errs, v.validateStructure(cfg)...)
+138 -9
View File
@@ -621,15 +621,6 @@ func TestFormatValidationErrors(t *testing.T) {
}
}
func TestValidationError_Error(t *testing.T) {
err := ValidationError{
Field: "port",
Message: "Invalid port 0",
}
assert.Equal(t, "Invalid port 0", err.Error(), "Error() should return the message")
}
func TestValidator_ValidateStructure(t *testing.T) {
validator := NewValidator()
@@ -972,3 +963,141 @@ func TestIsAlphanumeric(t *testing.T) {
})
}
}
func TestValidator_ValidateConfigWithOptions(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
allowEmpty bool
expectErrors bool
}{
{
name: "empty config - strict mode",
config: &Config{Contexts: []Context{}},
allowEmpty: false,
expectErrors: true,
},
{
name: "empty config - allow empty",
config: &Config{Contexts: []Context{}},
allowEmpty: true,
expectErrors: false,
},
{
name: "nil contexts - allow empty",
config: &Config{},
allowEmpty: true,
expectErrors: false,
},
{
name: "context with no forwards - allow empty",
config: &Config{
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{Name: "default", Forwards: []Forward{}},
},
},
},
},
allowEmpty: true,
expectErrors: false,
},
{
name: "valid config - strict mode",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
allowEmpty: false,
expectErrors: false,
},
{
name: "valid config - allow empty (should still validate)",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
allowEmpty: true,
expectErrors: false,
},
{
name: "invalid forward in non-empty config - allow empty still validates",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 0, // Invalid port
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
allowEmpty: true,
expectErrors: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfigWithOptions(tt.config, tt.allowEmpty)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
+8 -4
View File
@@ -22,6 +22,7 @@ type Watcher struct {
done chan struct{}
verbose bool
wg sync.WaitGroup // Ensures watch goroutine exits before Stop returns
stopOnce sync.Once // Ensures Stop is safe to call multiple times
}
// NewWatcher creates a new file watcher for the given config file.
@@ -33,7 +34,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
absPath, err := filepath.Abs(configPath)
if err != nil {
watcher.Close()
_ = watcher.Close()
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
}
@@ -41,7 +42,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
// (many editors delete and recreate files on save)
dir := filepath.Dir(absPath)
if err := watcher.Add(dir); err != nil {
watcher.Close()
_ = watcher.Close()
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
}
@@ -61,9 +62,12 @@ func (w *Watcher) Start() {
}
// Stop stops watching the configuration file and waits for the watch goroutine to exit.
// Safe to call multiple times.
func (w *Watcher) Stop() {
close(w.done)
w.watcher.Close()
w.stopOnce.Do(func() {
close(w.done)
_ = w.watcher.Close()
})
w.wg.Wait() // Wait for watch goroutine to exit
}
+2
View File
@@ -25,6 +25,7 @@ type KFTrayConfig struct {
// ConvertKFTrayToKPortal converts kftray JSON configuration to kportal YAML format
func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
// Read kftray JSON config
// #nosec G304 -- inputFile is from command line argument for explicit conversion
data, err := os.ReadFile(inputFile)
if err != nil {
return fmt.Errorf("failed to read input file: %w", err)
@@ -57,6 +58,7 @@ func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
// GetConversionSummary returns statistics about the kftray configuration
func GetConversionSummary(inputFile string) (map[string]map[string]int, int, error) {
// #nosec G304 -- inputFile is from command line argument for explicit conversion
data, err := os.ReadFile(inputFile)
if err != nil {
return nil, 0, fmt.Errorf("failed to read input file: %w", err)
-9
View File
@@ -135,15 +135,6 @@ func (b *Bus) Close() {
// Helper functions for creating common events
// NewForwardEvent creates a forward-related event
func NewForwardEvent(eventType EventType, forwardID string, data map[string]interface{}) Event {
return Event{
Type: eventType,
ForwardID: forwardID,
Data: data,
}
}
// NewHealthEvent creates a health status change event
func NewHealthEvent(forwardID string, status string, errorMsg string) Event {
return Event{
-10
View File
@@ -149,16 +149,6 @@ func TestBus_ConcurrentAccess(t *testing.T) {
assert.Equal(t, int64(100), atomic.LoadInt64(&count))
}
func TestNewForwardEvent(t *testing.T) {
event := NewForwardEvent(EventForwardStarting, "test-id", map[string]interface{}{
"pod": "my-pod",
})
assert.Equal(t, EventForwardStarting, event.Type)
assert.Equal(t, "test-id", event.ForwardID)
assert.Equal(t, "my-pod", event.Data["pod"])
}
func TestNewHealthEvent(t *testing.T) {
event := NewHealthEvent("test-id", "Active", "")
+3 -27
View File
@@ -139,11 +139,6 @@ func (m *Manager) SetMDNSPublisher(publisher *mdns.Publisher) {
m.mdnsPublisher = publisher
}
// GetEventBus returns the event bus for subscribing to manager events
func (m *Manager) GetEventBus() *events.Bus {
return m.eventBus
}
// Start initializes and starts all port-forwards from the configuration.
func (m *Manager) Start(cfg *config.Config) error {
if cfg == nil {
@@ -169,8 +164,10 @@ func (m *Manager) Start(cfg *config.Config) error {
// Get all forwards from config
forwards := cfg.GetAllForwards()
// Empty config is valid - user can add forwards later via TUI
if len(forwards) == 0 {
return fmt.Errorf("no forwards configured")
log.Printf("No forwards configured - use 'n' to add forwards")
return nil
}
// Check port availability before starting
@@ -491,27 +488,6 @@ func (m *Manager) stopWorkerInternal(id string, removeFromUI bool) error {
return nil
}
// GetActiveForwards returns a list of all active forward IDs.
func (m *Manager) GetActiveForwards() []string {
m.workersMu.RLock()
defer m.workersMu.RUnlock()
ids := make([]string, 0, len(m.workers))
for id := range m.workers {
ids = append(ids, id)
}
return ids
}
// GetWorkerCount returns the number of active workers.
func (m *Manager) GetWorkerCount() int {
m.workersMu.RLock()
defer m.workersMu.RUnlock()
return len(m.workers)
}
// GetWorker returns a worker by ID, or nil if not found.
func (m *Manager) GetWorker(id string) *ForwardWorker {
m.workersMu.RLock()
+3 -43
View File
@@ -7,7 +7,6 @@ import (
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestNewManager tests manager creation
@@ -53,41 +52,6 @@ func TestManager_SetStatusUI(t *testing.T) {
assert.Equal(t, mockUI, manager.statusUI)
}
// TestManager_GetEventBus tests getting the event bus
func TestManager_GetEventBus(t *testing.T) {
manager, err := NewManager(false)
if err != nil {
t.Skip("Skipping test - no kubeconfig available")
}
defer manager.Stop()
bus := manager.GetEventBus()
assert.NotNil(t, bus)
}
// TestManager_GetWorkerCount tests worker count tracking
func TestManager_GetWorkerCount(t *testing.T) {
manager, err := NewManager(false)
if err != nil {
t.Skip("Skipping test - no kubeconfig available")
}
defer manager.Stop()
assert.Equal(t, 0, manager.GetWorkerCount())
}
// TestManager_GetActiveForwards tests getting active forwards
func TestManager_GetActiveForwards(t *testing.T) {
manager, err := NewManager(false)
if err != nil {
t.Skip("Skipping test - no kubeconfig available")
}
defer manager.Stop()
forwards := manager.GetActiveForwards()
assert.Empty(t, forwards)
}
// TestManager_GetWorker tests getting a worker by ID
func TestManager_GetWorker(t *testing.T) {
manager, err := NewManager(false)
@@ -124,8 +88,8 @@ func TestManager_Start_EmptyForwards(t *testing.T) {
cfg := &config.Config{}
err = manager.Start(cfg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no forwards configured")
// Empty config is now valid - allows users to add forwards via TUI
assert.NoError(t, err)
}
// TestManager_Reload_NilConfig tests reloading with nil config
@@ -362,12 +326,8 @@ func TestManager_EventBusIntegration(t *testing.T) {
// Event bus should be wired to health checker and watchdog
assert.NotNil(t, manager.eventBus)
// Get event bus
bus := manager.GetEventBus()
require.NotNil(t, bus)
// SubscribeAll should work (no return value in this API)
bus.SubscribeAll(func(event events.Event) {
manager.eventBus.SubscribeAll(func(event events.Event) {
// Handler
})
}
+3 -1
View File
@@ -77,6 +77,7 @@ func getProcessNameByPID(pid string) string {
// getProcessNameByPIDWindows retrieves the process name for a given PID on Windows
func getProcessNameByPIDWindows(pid string) string {
// #nosec G204 -- pid is validated by isValidPID() to contain only digits
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %s", pid), "/FO", "CSV", "/NH")
output, err := cmd.Output()
if err != nil {
@@ -145,7 +146,7 @@ func (pc *PortChecker) isPortAvailable(port int) bool {
if err != nil {
return false
}
listener.Close()
_ = listener.Close()
return true
}
@@ -166,6 +167,7 @@ func (pc *PortChecker) getProcessUsingPort(port int) string {
func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
// Use lsof to find the process
// lsof -i :PORT -sTCP:LISTEN -t returns PIDs
// #nosec G204 -- port is an integer from config validation, not user input
cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN", "-t")
output, err := cmd.Output()
if err != nil {
+13
View File
@@ -336,6 +336,11 @@ func (w *ForwardWorker) establishForward(podName string) error {
// Start port forwarding in a goroutine
errChan := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("port forward panicked: %v", r)
}
}()
errChan <- w.portForwarder.Forward(forwardCtx, req)
}()
@@ -409,6 +414,14 @@ func (w *ForwardWorker) startHTTPProxy() error {
// Calculate internal port for k8s tunnel
targetPort := w.forward.LocalPort + httpLogPortOffset
// Validate that the target port is available before attempting to bind
portChecker := NewPortChecker()
if !portChecker.isPortAvailable(targetPort) {
usedBy := portChecker.getProcessUsingPort(targetPort)
return fmt.Errorf("HTTP proxy target port %d is already in use by %s (forward port %d + offset %d)",
targetPort, usedBy, w.forward.LocalPort, httpLogPortOffset)
}
proxy, err := httplog.NewProxy(&w.forward, targetPort)
if err != nil {
return fmt.Errorf("failed to create HTTP proxy: %w", err)
+7 -8
View File
@@ -365,7 +365,8 @@ func (c *Checker) checkPort(forwardID string) {
}
}
// Update health status
// Update health status and capture eventBus while holding lock
var bus *events.Bus
c.mu.Lock()
if health, exists := c.ports[forwardID]; exists {
health.Status = newStatus
@@ -378,17 +379,15 @@ func (c *Checker) checkPort(forwardID string) {
health.LastActivity = now
}
}
// Capture eventBus while we have the lock to avoid race condition
bus = c.eventBus
c.mu.Unlock()
// Notify if status changed
if oldStatus != newStatus {
c.notifyStatusChange(forwardID, newStatus, errorMsg)
// Publish to event bus if available
c.mu.RLock()
bus := c.eventBus
c.mu.RUnlock()
// Publish to event bus if available (captured while holding lock above)
if bus != nil {
if newStatus == StatusStale {
bus.Publish(events.NewStaleEvent(forwardID, errorMsg))
@@ -409,7 +408,7 @@ func (c *Checker) checkTCPDial(port int) error {
if err != nil {
return err
}
conn.Close()
_ = conn.Close()
return nil
}
@@ -427,7 +426,7 @@ func (c *Checker) checkDataTransfer(port int) error {
// Set a short read deadline to detect hung connections
// We don't expect to receive data, but we want to verify the connection isn't hung
conn.SetReadDeadline(time.Now().Add(c.timeout))
_ = conn.SetReadDeadline(time.Now().Add(c.timeout))
// Try to read a small amount of data
// Most servers will either:
+1
View File
@@ -51,6 +51,7 @@ func NewLogger(forwardID, logFile string, maxBodyLen int) (*Logger, error) {
// Log entries are delivered via callbacks to the UI
l.output = io.Discard
} else {
// #nosec G304 -- logFile is from config validation, not arbitrary user input
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return nil, err
+8 -7
View File
@@ -85,12 +85,13 @@ func (p *Proxy) Start() error {
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
p.logError(r, err)
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("Proxy error: " + err.Error()))
_, _ = w.Write([]byte("Proxy error: " + err.Error()))
},
}
p.server = &http.Server{
Handler: proxy,
Handler: proxy,
ReadHeaderTimeout: 10 * time.Second,
}
p.running = true
@@ -122,8 +123,8 @@ func (p *Proxy) Stop() error {
defer cancel()
if err := p.server.Shutdown(ctx); err != nil {
// Force close
p.server.Close()
// Force close - error ignored as we're already shutting down
_ = p.server.Close()
}
if err := p.logger.Close(); err != nil {
@@ -173,7 +174,7 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
reqEntry.Headers = flattenHeaders(req.Header)
}
t.proxy.logger.Log(reqEntry)
_ = t.proxy.logger.Log(reqEntry)
// Make the request
resp, err := t.transport.RoundTrip(req)
@@ -207,7 +208,7 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
respEntry.Headers = flattenHeaders(resp.Header)
}
t.proxy.logger.Log(respEntry)
_ = t.proxy.logger.Log(respEntry)
return resp, nil
}
@@ -269,7 +270,7 @@ func (p *Proxy) logError(req *http.Request, err error) {
Path: req.URL.Path,
Error: err.Error(),
}
p.logger.Log(entry)
_ = p.logger.Log(entry)
}
// flattenHeaders converts http.Header to map[string]string
+1 -1
View File
@@ -356,6 +356,6 @@ func CheckPortAvailability(port int) (bool, string, error) {
}
// Port is available, close the listener
listener.Close()
_ = listener.Close()
return true, "", nil
}
+1 -1
View File
@@ -228,7 +228,7 @@ func TestResolveTargetPort(t *testing.T) {
for i := range tt.pods {
objects = append(objects, &tt.pods[i])
}
fakeClient := fake.NewSimpleClientset(objects...)
fakeClient := fake.NewClientset(objects...)
// Create discovery instance (we only need it to call resolveTargetPort)
d := &Discovery{}
+12 -17
View File
@@ -89,6 +89,11 @@ func (p *Publisher) Register(forwardID, alias string, localPort int) error {
p.servers[forwardID] = server
p.aliases[forwardID] = alias
// Allow zeroconf's internal goroutines (recv4, recv6) to fully initialize.
// This prevents a race condition where shutdown() could set connections to nil
// while recv goroutines are still starting up.
time.Sleep(startupSettleTime)
logger.Info("mDNS hostname registered", map[string]interface{}{
"forward_id": forwardID,
"hostname": GetHostname(alias),
@@ -151,6 +156,13 @@ func (p *Publisher) Stop() {
logger.Info("mDNS publisher stopped", nil)
}
// startupSettleTime is a small delay after zeroconf registration to allow internal
// goroutines (recv4, recv6) to fully initialize before any shutdown can occur.
// This works around a race condition in grandcat/zeroconf where shutdown() sets
// connections to nil while recv goroutines may still be initializing.
// See: https://github.com/grandcat/zeroconf/issues/95
const startupSettleTime = 50 * time.Millisecond
// shutdownSettleTime is a small delay after zeroconf shutdown to allow internal
// goroutines to exit cleanly. This works around a race condition in the
// grandcat/zeroconf library where recv4() can access ipv4conn after shutdown()
@@ -183,29 +195,12 @@ func shutdownWithTimeout(server *zeroconf.Server, forwardID string) {
}
}
// IsEnabled returns whether mDNS publishing is enabled.
func (p *Publisher) IsEnabled() bool {
return p.enabled
}
// GetDomain returns the mDNS domain being used (always "local" per RFC 6762).
func (p *Publisher) GetDomain() string {
return mdnsDomain
}
// GetHostname returns the full mDNS hostname for an alias.
// Example: GetHostname("myapp") returns "myapp.local"
func GetHostname(alias string) string {
return alias + "." + mdnsDomain
}
// GetRegisteredCount returns the number of currently registered hostnames.
func (p *Publisher) GetRegisteredCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.servers)
}
// getLocalIPs returns the local IP addresses for logging purposes.
func getLocalIPs() []string {
var ips []string
+25 -16
View File
@@ -13,15 +13,17 @@ import (
func TestNewPublisher_Disabled(t *testing.T) {
p := NewPublisher(false)
assert.False(t, p.IsEnabled())
assert.Equal(t, 0, p.GetRegisteredCount())
// When disabled, Register should succeed but be a no-op
err := p.Register("forward-1", "test-alias", 8080)
assert.NoError(t, err)
}
func TestNewPublisher_Enabled(t *testing.T) {
p := NewPublisher(true)
defer p.Stop()
assert.True(t, p.IsEnabled())
assert.Equal(t, 0, p.GetRegisteredCount())
// Enabled publisher should be created successfully
assert.NotNil(t, p)
}
func TestRegister_WhenDisabled_NoOp(t *testing.T) {
@@ -30,16 +32,17 @@ func TestRegister_WhenDisabled_NoOp(t *testing.T) {
err := p.Register("forward-1", "test-alias", 8080)
assert.NoError(t, err)
assert.Equal(t, 0, p.GetRegisteredCount())
// Unregister should also be safe when disabled
p.Unregister("forward-1")
}
func TestRegister_EmptyAlias_NoOp(t *testing.T) {
p := NewPublisher(true)
defer p.Stop()
err := p.Register("forward-1", "", 8080)
assert.NoError(t, err)
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestUnregister_WhenDisabled_NoOp(t *testing.T) {
@@ -51,10 +54,10 @@ func TestUnregister_WhenDisabled_NoOp(t *testing.T) {
func TestUnregister_NotRegistered_NoOp(t *testing.T) {
p := NewPublisher(true)
defer p.Stop()
// Should not panic
p.Unregister("non-existent")
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestStop_WhenDisabled_NoOp(t *testing.T) {
@@ -69,7 +72,6 @@ func TestStop_WhenNoRegistrations(t *testing.T) {
// Should not panic
p.Stop()
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestGetLocalIPs(t *testing.T) {
@@ -84,6 +86,11 @@ func TestGetLocalIPs(t *testing.T) {
}
}
func TestGetHostname(t *testing.T) {
hostname := GetHostname("myapp")
assert.Equal(t, "myapp.local", hostname)
}
// Integration tests - only run when explicitly requested
// These tests actually register mDNS services and require network access
@@ -96,9 +103,10 @@ func TestRegister_Integration(t *testing.T) {
defer p.Stop()
err := p.Register("forward-1", "test-service", 8080)
assert.NoError(t, err)
assert.Equal(t, 1, p.GetRegisteredCount())
// Verify by checking that unregister doesn't panic
p.Unregister("forward-1")
}
func TestRegister_Duplicate_Idempotent_Integration(t *testing.T) {
@@ -112,12 +120,10 @@ func TestRegister_Duplicate_Idempotent_Integration(t *testing.T) {
// First registration
err := p.Register("forward-1", "test-service", 8080)
assert.NoError(t, err)
assert.Equal(t, 1, p.GetRegisteredCount())
// Second registration with same ID should be idempotent
err = p.Register("forward-1", "test-service", 8080)
assert.NoError(t, err)
assert.Equal(t, 1, p.GetRegisteredCount())
}
func TestRegister_MultipleForwards_Integration(t *testing.T) {
@@ -135,7 +141,6 @@ func TestRegister_MultipleForwards_Integration(t *testing.T) {
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.NoError(t, err3)
assert.Equal(t, 3, p.GetRegisteredCount())
}
func TestUnregister_Success_Integration(t *testing.T) {
@@ -146,9 +151,13 @@ func TestUnregister_Success_Integration(t *testing.T) {
p := NewPublisher(true)
defer p.Stop()
p.Register("forward-1", "test-service", 8080)
assert.Equal(t, 1, p.GetRegisteredCount())
err := p.Register("forward-1", "test-service", 8080)
assert.NoError(t, err)
// Unregister should not panic and should handle it gracefully
p.Unregister("forward-1")
assert.Equal(t, 0, p.GetRegisteredCount())
// Re-registering should work after unregister
err = p.Register("forward-1", "test-service-2", 8080)
assert.NoError(t, err)
}
+12 -2
View File
@@ -11,6 +11,9 @@ const (
initialDelay = 1 * time.Second
maxDelay = 10 * time.Second
jitterPct = 0.1 // 10% jitter
// maxAttempt caps the exponent to prevent math.Pow overflow
// 2^30 seconds is ~34 years, well above maxDelay, so this is safe
maxAttempt = 30
)
// Backoff implements exponential backoff with jitter for retry logic.
@@ -24,7 +27,8 @@ type Backoff struct {
func NewBackoff() *Backoff {
return &Backoff{
attempt: 0,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
// #nosec G404 -- math/rand is appropriate for backoff jitter; cryptographic randomness not needed
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
@@ -32,8 +36,14 @@ func NewBackoff() *Backoff {
// The duration follows exponential backoff: 1s → 2s → 4s → 8s → 10s (max).
// A 10% jitter is added to prevent thundering herd effects.
func (b *Backoff) Next() time.Duration {
// Cap attempt to prevent overflow in math.Pow
attempt := b.attempt
if attempt > maxAttempt {
attempt = maxAttempt
}
// Calculate base delay: 2^attempt seconds
exp := math.Pow(2, float64(b.attempt))
exp := math.Pow(2, float64(attempt))
delay := time.Duration(exp) * time.Second
// Cap at max delay
+82 -15
View File
@@ -602,20 +602,82 @@ func (m model) renderMainView() string {
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Bench %s: Logs %s: Quit │ Total: %d",
keyStyle.Render("↑↓"),
keyStyle.Render("jk"),
keyStyle.Render("Space"),
keyStyle.Render("n"),
keyStyle.Render("e"),
keyStyle.Render("d"),
keyStyle.Render("b"),
keyStyle.Render("l"),
keyStyle.Render("q"),
len(m.ui.forwardOrder))
// Get terminal width for footer wrapping
termWidth := m.termWidth
if termWidth == 0 {
termWidth = 120
}
// Fill space to push footer to bottom (reserve 2 lines: 1 for spacing, 1 for footer)
footerHeight := 2
// Define key bindings as structured data for flexible rendering
type keyBinding struct {
key string
desc string
}
bindings := []keyBinding{
{"↑↓/jk", "Navigate"},
{"Space", "Toggle"},
{"n", "New"},
{"e", "Edit"},
{"d", "Delete"},
{"b", "Bench"},
{"l", "Logs"},
{"q", "Quit"},
}
// Build footer lines that fit within terminal width
var footerLines []string
var currentLine strings.Builder
currentLineVisualLen := 0
// Calculate how much space we need for the total count suffix
totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder))
totalSuffixLen := len(totalSuffix)
// Available width (account for some margin)
availableWidth := termWidth - 4
for i, binding := range bindings {
// Build this binding's text
keyRendered := keyStyle.Render(binding.key)
bindingText := keyRendered + ": " + binding.desc
// Visual length without ANSI codes
bindingVisualLen := len(binding.key) + 2 + len(binding.desc)
// Add separator if not first item on line
separator := ""
separatorLen := 0
if currentLine.Len() > 0 {
separator = " "
separatorLen = 2
}
// Check if this binding fits on current line
// For the last binding, also need to fit the total suffix
neededWidth := currentLineVisualLen + separatorLen + bindingVisualLen
if i == len(bindings)-1 {
neededWidth += totalSuffixLen
}
if neededWidth > availableWidth && currentLine.Len() > 0 {
// Start a new line
footerLines = append(footerLines, currentLine.String())
currentLine.Reset()
currentLineVisualLen = 0
separator = ""
separatorLen = 0
}
currentLine.WriteString(separator)
currentLine.WriteString(bindingText)
currentLineVisualLen += separatorLen + bindingVisualLen
}
// Add total count to the last line
currentLine.WriteString(totalSuffix)
footerLines = append(footerLines, currentLine.String())
// Calculate footer height
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
remainingLines := termHeight - currentLines - footerHeight
if remainingLines > 0 {
b.WriteString(strings.Repeat("\n", remainingLines))
@@ -623,7 +685,12 @@ func (m model) renderMainView() string {
// Add footer at bottom
b.WriteString("\n")
b.WriteString(footerStyle.Render(footer))
for i, line := range footerLines {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(footerStyle.Render(line))
}
return b.String()
}
@@ -736,7 +803,7 @@ func (m model) renderDeleteConfirmation() string {
}
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("←/→: Navigate Enter: Confirm Esc: Cancel"))
b.WriteString(wrapHelpText("←/→: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth)))
// Wrap in a box using wizard style
boxStyle := lipgloss.NewStyle().
+90
View File
@@ -194,6 +194,96 @@ func renderTextInput(label, value string, valid bool) string {
return b.String()
}
// wizardHelpWidth returns an appropriate width for wizard help text
// based on terminal width. For modals, we use a sensible maximum.
func wizardHelpWidth(termWidth int) int {
if termWidth == 0 {
termWidth = 80
}
// Wizard modals shouldn't be wider than 70 chars typically
// but on narrow terminals, use available space minus padding
maxWidth := 70
available := termWidth - 10 // account for modal borders and padding
if available < maxWidth {
return available
}
return maxWidth
}
// wrapHelpText wraps help text to fit within the given width.
// Help text is expected to be in the format "key: action key: action ..."
// separated by double spaces. On smaller screens, it wraps to multiple lines.
func wrapHelpText(text string, width int) string {
if width <= 0 {
width = 80 // Default width
}
// Account for some padding/margin
availableWidth := width - 4
if availableWidth < 20 {
availableWidth = 20
}
// If text fits, return as-is
if len(text) <= availableWidth {
return helpStyle.Render(text)
}
// Split by double-space separator (common in help text)
parts := strings.Split(text, " ")
if len(parts) <= 1 {
// No double-space separators, just truncate
if len(text) > availableWidth-3 {
return helpStyle.Render(text[:availableWidth-3] + "...")
}
return helpStyle.Render(text)
}
var lines []string
var currentLine strings.Builder
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Check if adding this part would exceed width
addition := part
if currentLine.Len() > 0 {
addition = " " + part
}
if currentLine.Len()+len(addition) > availableWidth && currentLine.Len() > 0 {
// Start new line
lines = append(lines, currentLine.String())
currentLine.Reset()
currentLine.WriteString(part)
} else {
if currentLine.Len() > 0 {
currentLine.WriteString(" ")
}
currentLine.WriteString(part)
}
// Handle last part
if i == len(parts)-1 && currentLine.Len() > 0 {
lines = append(lines, currentLine.String())
}
}
// Join with newlines and apply style to each line
var result strings.Builder
for i, line := range lines {
if i > 0 {
result.WriteString("\n")
}
result.WriteString(helpStyle.Render(line))
}
return result.String()
}
// overlayContent overlays modal content centered on the base view
// Note: base parameter is kept for API compatibility but not used since
// lipgloss.Place provides cleaner centering without background artifacts
+29 -24
View File
@@ -109,10 +109,11 @@ func (m model) renderSelectContext() string {
}
b.WriteString("\n")
helpWidth := wizardHelpWidth(m.termWidth)
if wizard.searchFilter != "" {
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts))))
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts)), helpWidth))
} else {
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel", helpWidth))
}
return b.String()
@@ -150,10 +151,11 @@ func (m model) renderSelectNamespace() string {
}
b.WriteString("\n")
helpWidth := wizardHelpWidth(m.termWidth)
if wizard.searchFilter != "" {
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces))))
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces)), helpWidth))
} else {
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth))
}
return b.String()
@@ -192,7 +194,7 @@ func (m model) renderSelectResourceType() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -305,14 +307,15 @@ func (m model) renderEnterResource() string {
b.WriteString("\n")
// Show appropriate help text based on resource type and filter state
helpWidth := wizardHelpWidth(m.termWidth)
if wizard.selectedResourceType == ResourceTypeService {
if wizard.searchFilter != "" {
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services))))
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services)), helpWidth))
} else {
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth))
}
} else {
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", helpWidth))
}
return b.String()
@@ -403,7 +406,7 @@ func (m model) renderEnterRemotePort() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
} else {
// Text input mode (no detected ports or user chose manual entry)
if len(wizard.detectedPorts) > 0 {
@@ -436,7 +439,7 @@ func (m model) renderEnterRemotePort() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
}
return b.String()
@@ -477,7 +480,7 @@ func (m model) renderEnterLocalPort() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -535,7 +538,7 @@ func (m model) renderConfirmation() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Enter: Confirm Esc: Back"))
b.WriteString(wrapHelpText("↑/↓/Tab: Navigate Enter: Confirm Esc: Back", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -578,7 +581,7 @@ func (m model) renderSuccess() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select"))
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -641,7 +644,7 @@ func (m model) renderRemoveSelection() string {
selectedCount := wizard.getSelectedCount()
b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards)))
b.WriteString(helpStyle.Render("Space: Toggle a: All n: None Enter: Remove Esc: Cancel"))
b.WriteString(wrapHelpText("Space: Toggle a: All n: None Enter: Remove Esc: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -676,7 +679,7 @@ func (m model) renderRemoveConfirmation() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Confirm Esc: Cancel"))
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -740,7 +743,7 @@ func (m model) renderBenchmarkConfig() string {
b.WriteString("\n")
b.WriteString(mutedStyle.Render(fmt.Sprintf("Will send %d requests with %d concurrent workers", state.requests, state.concurrency)))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel"))
b.WriteString(wrapHelpText("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -780,7 +783,7 @@ func (m model) renderBenchmarkRunning() string {
b.WriteString(mutedStyle.Render(fmt.Sprintf("Method: %s Concurrency: %d", state.method, state.concurrency)))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Please wait..."))
b.WriteString(wrapHelpText("Please wait...", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -796,14 +799,14 @@ func (m model) renderBenchmarkResults() string {
if state.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", state.error)))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
return b.String()
}
if state.results == nil {
b.WriteString(mutedStyle.Render("No results available"))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -872,7 +875,7 @@ func (m model) renderBenchmarkResults() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -1076,8 +1079,10 @@ func (m model) renderHTTPLog() string {
}
b.WriteString("\n")
// Help line at bottom
b.WriteString(helpStyle.Render(" ↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close"))
// Help line at bottom (wrap for smaller screens)
helpText := "↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close"
b.WriteString(" ")
b.WriteString(wrapHelpText(helpText, termWidth-4))
return b.String()
}
@@ -1273,9 +1278,9 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
if state.copyMessage != "" {
b.WriteString(successStyle.Render(state.copyMessage))
b.WriteString(" ")
b.WriteString(helpStyle.Render("↑/↓: Scroll c: Copy Esc: Back"))
b.WriteString(wrapHelpText("↑/↓: Scroll c: Copy Esc: Back", termWidth-10))
} else {
b.WriteString(helpStyle.Render("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back"))
b.WriteString(wrapHelpText("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back", termWidth-10))
}
return b.String()
+1 -7
View File
@@ -144,15 +144,9 @@ func parseVersion(v string) []int {
for _, p := range parts {
var num int
fmt.Sscanf(p, "%d", &num)
_, _ = fmt.Sscanf(p, "%d", &num)
result = append(result, num)
}
return result
}
// FormatUpdateMessage formats a user-friendly update notification
func (u *UpdateInfo) FormatUpdateMessage() string {
return fmt.Sprintf("New version available: %s (current: %s) - %s",
u.LatestVersion, u.CurrentVersion, u.ReleaseURL)
}
-13
View File
@@ -75,16 +75,3 @@ func TestIsNewerVersion(t *testing.T) {
})
}
}
func TestUpdateInfo_FormatUpdateMessage(t *testing.T) {
info := &UpdateInfo{
CurrentVersion: "0.1.0",
LatestVersion: "0.2.0",
ReleaseURL: "https://github.com/nvm/kportal/releases/tag/v0.2.0",
}
msg := info.FormatUpdateMessage()
assert.Contains(t, msg, "0.2.0")
assert.Contains(t, msg, "0.1.0")
assert.Contains(t, msg, "https://github.com/nvm/kportal/releases/tag/v0.2.0")
}