From f3dce1f4abe7c11082094e7915eda71d31c5d82c Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Tue, 26 May 2026 19:59:34 +0200 Subject: [PATCH] init Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- .envrc | 1 + .github/.goreleaser.yaml | 34 ++ .github/CONTRIBUTING.md | 10 + .github/FUNDING.yml | 1 + .github/scripts/inject-exec.py | 36 +++ .github/workflows/release.yml | 28 ++ .gitignore | 5 + LICENSE | 21 ++ README.md | 88 ++++++ cmd/jwt-tui/main.go | 83 +++++ flake.lock | 142 +++++++++ flake.nix | 41 +++ go.mod | 52 ++++ go.sum | 120 +++++++ internal/config/config.go | 70 +++++ internal/config/default_config.yaml | 11 + internal/config/keybindings.go | 14 + internal/highlight/highlight.go | 90 ++++++ internal/jwt/jwt.go | 151 +++++++++ internal/keys/keys.go | 75 +++++ internal/style/border.go | 39 +++ internal/ui/docs.md | 123 ++++++++ internal/ui/model.go | 463 ++++++++++++++++++++++++++++ internal/ui/update.go | 149 +++++++++ internal/ui/view.go | 89 ++++++ internal/util/editor.go | 47 +++ nix/gomod2nix.toml | 174 +++++++++++ nix/package.nix | 21 ++ nix/shell.nix | 40 +++ 29 files changed, 2218 insertions(+) create mode 100644 .envrc create mode 100644 .github/.goreleaser.yaml create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/FUNDING.yml create mode 100644 .github/scripts/inject-exec.py create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/jwt-tui/main.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/default_config.yaml create mode 100644 internal/config/keybindings.go create mode 100644 internal/highlight/highlight.go create mode 100644 internal/jwt/jwt.go create mode 100644 internal/keys/keys.go create mode 100644 internal/style/border.go create mode 100644 internal/ui/docs.md create mode 100644 internal/ui/model.go create mode 100644 internal/ui/update.go create mode 100644 internal/ui/view.go create mode 100644 internal/util/editor.go create mode 100644 nix/gomod2nix.toml create mode 100644 nix/package.nix create mode 100644 nix/shell.nix diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/.goreleaser.yaml b/.github/.goreleaser.yaml new file mode 100644 index 0000000..14c1933 --- /dev/null +++ b/.github/.goreleaser.yaml @@ -0,0 +1,34 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - binary: jwt-tui + main: ./cmd/jwt-tui + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: + - tar.gz + name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^ci:" diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..7b8ae86 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +Everybody is invited and welcome to contribute. There is a lot to do... Check the issues! + +The process is straight-forward. + +- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1) +- Fork this git repository +- Write your changes (bug fixes, new features, ...). +- Create a Pull Request against the main branch. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b1c5749 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: anotherhadi diff --git a/.github/scripts/inject-exec.py b/.github/scripts/inject-exec.py new file mode 100644 index 0000000..d7fed5e --- /dev/null +++ b/.github/scripts/inject-exec.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import re +import subprocess +import sys +from pathlib import Path + +PATTERN = re.compile(r".*?", re.DOTALL) + + +def replace(match): + cmd = match.group(1).strip() + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + output = result.stdout + if result.returncode != 0: + print( + f"[inject-exec] command failed ({result.returncode}): {cmd}", + file=sys.stderr, + ) + print(result.stderr, file=sys.stderr) + sys.exit(1) + output = re.sub(r"\n?|\n?", "", output) + if output and not output.endswith("\n"): + output += "\n" + return f"\n{output}" + + +def process(path): + content = Path(path).read_text() + new_content = PATTERN.sub(replace, content) + if new_content != content: + Path(path).write_text(new_content) + print(f"[inject-exec] updated {path}") + + +for p in sys.argv[1:]: + process(p) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bf265c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean --config .github/.goreleaser.yaml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e95065 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.claude/ +CLAUDE.md +result/ +.pre-commit-config.yaml +.mypy_cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ce7478 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hadi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..011c18f --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Jwt-tui + +A terminal UI for inspecting, editing, and signing JSON Web Tokens (JWTs). + +Built with [Bubbletea](https://charm.land/bubbletea) and [Lipgloss](https://charm.land/lipgloss). + +## Features + +- **Decode**: paste a JWT and instantly see the pretty-printed header and payload +- **Encode**: edit the header or payload JSON and get a freshly signed token +- **Verify**: real-time signature validation against a secret (HS256 / HS384 / HS512 / none) +- **4-panel layout**: header, payload, JWT, and secret all visible and editable at once +- **Themeable**: Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once. +- **Rebindable keys**: customize keybindings in the config file + +## Installation + +
+Go install + +```sh +go install github.com/anotherhadi/jwt-tui/cmd/jwt-tui@latest +``` + +Requires Go 1.22+. The binary will be placed in `$GOPATH/bin` (or `~/go/bin`). + +
+ +
+Nix (temporary run, no install) + +```sh +nix run github:anotherhadi/jwt-tui +``` + +
+ +
+NixOS (flake) + +Add jwt-tui to your flake inputs: + +```nix +inputs.jwt-tui.url = "github:anotherhadi/jwt-tui"; +``` + +Then add the package to your system or home-manager packages: + +```nix +environment.systemPackages = [ inputs.jwt-tui.packages.${pkgs.system}.default ]; +``` + +
+ +## Usage + +```sh +jwt-tui # launch with default config +jwt-tui -t # pre-fill the JWT token +jwt-tui -t -s mysecret # pre-fill token and secret key +jwt-tui -t $(cat token.txt) -s mysecret # read token from a file +``` + +### Keybindings + + +``` +Usage: jwt-tui [flags] + + --add-default-config copy the default config file to the config path and exit + -c, --config string path to config file + -s, --secret string pre-fill the secret key + -t, --token string pre-fill the encoded JWT token + -v, --version print version +``` + + +## Configuration + +The config file lives at `~/.config/jwt-tui/config.yaml` by default. Run `--add-default-config` to generate it. + +Exemple: + +```yaml +keybindings: + quit: "ctrl+c" + cycle_focus: "tab" +``` diff --git a/cmd/jwt-tui/main.go b/cmd/jwt-tui/main.go new file mode 100644 index 0000000..43d328f --- /dev/null +++ b/cmd/jwt-tui/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime/debug" + + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/jwt-tui/internal/config" + "github.com/anotherhadi/jwt-tui/internal/keys" + "github.com/anotherhadi/jwt-tui/internal/ui" + "github.com/spf13/pflag" +) + +// Version is overwritten at build time by goreleaser/ldflag with the current version tag, or "dev" if not set. +var version = "dev" + +func init() { + if version != "dev" { + return + } + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + version = info.Main.Version + } +} + +func main() { + var ( + flagConfig = pflag.StringP("config", "c", "", "path to config file") + flagAddDefaultConfig = pflag.Bool("add-default-config", false, "copy the default config file to the config path and exit") + flagToken = pflag.StringP("token", "t", "", "pre-fill the encoded JWT token") + flagSecret = pflag.StringP("secret", "s", "", "pre-fill the secret key") + flagVersion = pflag.BoolP("version", "v", false, "print version") + ) + pflag.CommandLine.SetOutput(os.Stdout) + pflag.Usage = func() { + fmt.Println("Usage: jwt-tui [flags]") + fmt.Println("") + pflag.PrintDefaults() + } + pflag.Parse() + + if *flagVersion { + fmt.Println(version) + os.Exit(0) + } + + // Accept a bare positional argument as the token + if *flagToken == "" && pflag.NArg() > 0 { + t := pflag.Arg(0) + flagToken = &t + } + + home, _ := os.UserHomeDir() + cfgPath := filepath.Join(home, ".config", "jwt-tui", "config.yaml") + if *flagConfig != "" { + cfgPath = *flagConfig + } + + if *flagAddDefaultConfig { + if err := config.WriteDefaultConfig(cfgPath); err != nil { + fmt.Fprintf(os.Stderr, "write-config: %v\n", err) + os.Exit(1) + } + fmt.Printf("default config written to %s\n", cfgPath) + return + } + + if err := config.Load(cfgPath); err != nil { + fmt.Fprintf(os.Stderr, "config: %v\n", err) + os.Exit(1) + } + + keys.Init(config.Global) + + m := ui.New(*flagToken, *flagSecret) + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "tui: %v\n", err) + os.Exit(1) + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..408d579 --- /dev/null +++ b/flake.lock @@ -0,0 +1,142 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1778507602, + "narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "gomod2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770585520, + "narHash": "sha256-yBz9Ozd5Wb56i3e3cHZ8WcbzCQ9RlVaiW18qDYA/AzA=", + "owner": "nix-community", + "repo": "gomod2nix", + "rev": "1201ddd1279c35497754f016ef33d5e060f3da8d", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "gomod2nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1779560665, + "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "git-hooks": "git-hooks", + "gomod2nix": "gomod2nix", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..163f447 --- /dev/null +++ b/flake.nix @@ -0,0 +1,41 @@ +{ + description = "A TUI for inspecting, editing, and signing JSON Web Tokens (JWTs)."; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + gomod2nix = { + url = "github:nix-community/gomod2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + git-hooks = { + url = "github:cachix/git-hooks.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { + self, + nixpkgs, + gomod2nix, + git-hooks, + }: let + supportedSystems = ["x86_64-linux" "aarch64-linux"]; + + forAllSystems = f: + nixpkgs.lib.genAttrs supportedSystems + (system: f system (import nixpkgs {inherit system;})); + in { + packages = forAllSystems (system: pkgs: + import ./nix/package.nix { + inherit pkgs; + buildGoApplication = gomod2nix.legacyPackages.${system}.buildGoApplication; + }); + devShells = forAllSystems (system: pkgs: { + default = import ./nix/shell.nix { + inherit pkgs; + gitHooksLib = git-hooks.lib.${system}; + gomod2nixPkgs = gomod2nix.legacyPackages.${system}; + }; + }); + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6d55a06 --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module github.com/anotherhadi/jwt-tui + +go 1.26.2 + +require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.6 + charm.land/glamour/v2 v2.0.0 + charm.land/lipgloss/v2 v2.0.3 + github.com/anotherhadi/ilovetui v0.1.6 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b636d4d --- /dev/null +++ b/go.sum @@ -0,0 +1,120 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/anotherhadi/ilovetui v0.1.6 h1:NKg+T1DpV08Q4r+iowFrXF+0bTd6Y2f4OFpFwhsfsyY= +github.com/anotherhadi/ilovetui v0.1.6/go.mod h1:HVai6u5NGKSMOpmioYpwrN0lSxQjc7HtISUc5hTwvOw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +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= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +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.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +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/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/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f29a762 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,70 @@ +package config + +import ( + _ "embed" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +//go:embed default_config.yaml +var defaultConfig []byte + +type Config struct { + Keybindings Keybindings `mapstructure:"keybindings"` +} + +var Global *Config + +func Load(path string) error { + var defaults map[string]any + if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil { + return fmt.Errorf("default config: %w", err) + } + for k, v := range flatten("", defaults) { + viper.SetDefault(k, v) + } + + viper.SetConfigType("yaml") + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return err + } + } + + Global = &Config{} + return viper.Unmarshal(Global) +} + +func WriteDefaultConfig(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + if err := os.WriteFile(path, defaultConfig, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} + +func flatten(prefix string, m map[string]any) map[string]any { + out := make(map[string]any) + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "." + k + } + if nested, ok := v.(map[string]any); ok { + for nk, nv := range flatten(key, nested) { + out[nk] = nv + } + } else { + out[key] = v + } + } + return out +} diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml new file mode 100644 index 0000000..7e0513b --- /dev/null +++ b/internal/config/default_config.yaml @@ -0,0 +1,11 @@ +keybindings: + quit: "ctrl+c,q" + cycle_focus: "tab" + edit: "e,enter" + edit_external: "E" + docs: "d" + help_toggle: "?" + clear: "x" + reset: "r" + copy: "y,ctrl+shift+c" + paste: "p,ctrl+shift+v" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go new file mode 100644 index 0000000..36cf9f3 --- /dev/null +++ b/internal/config/keybindings.go @@ -0,0 +1,14 @@ +package config + +type Keybindings struct { + Quit string `mapstructure:"quit"` + CycleFocus string `mapstructure:"cycle_focus"` + Edit string `mapstructure:"edit"` + EditExternal string `mapstructure:"edit_external"` + Docs string `mapstructure:"docs"` + HelpToggle string `mapstructure:"help_toggle"` + Clear string `mapstructure:"clear"` + Reset string `mapstructure:"reset"` + Copy string `mapstructure:"copy"` + Paste string `mapstructure:"paste"` +} diff --git a/internal/highlight/highlight.go b/internal/highlight/highlight.go new file mode 100644 index 0000000..911b6c2 --- /dev/null +++ b/internal/highlight/highlight.go @@ -0,0 +1,90 @@ +package highlight + +import ( + "strings" + + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "image/color" +) + +func paint(c color.Color, s string) string { + return lipgloss.NewStyle().Foreground(c).Render(s) +} + +// JSON applies syntax coloring to a pretty-printed JSON string using ilovetui colors. +func JSON(s string) string { + var out strings.Builder + i, n := 0, len(s) + for i < n { + ch := s[i] + switch { + case ch == '"': + j := i + 1 + for j < n { + if s[j] == '\\' { + j += 2 + continue + } + if s[j] == '"' { + j++ + break + } + j++ + } + str := s[i:j] + k := j + for k < n && (s[k] == ' ' || s[k] == '\t') { + k++ + } + if k < n && s[k] == ':' { + out.WriteString(paint(ilovetui.S.Primary, str)) + } else { + out.WriteString(paint(ilovetui.S.Success, str)) + } + i = j + case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'): + j := i + if s[j] == '-' { + j++ + } + for j < n && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.' || s[j] == 'e' || s[j] == 'E' || s[j] == '+' || s[j] == '-') { + j++ + } + out.WriteString(paint(ilovetui.S.Warning, s[i:j])) + i = j + case i+4 <= n && s[i:i+4] == "true": + out.WriteString(paint(ilovetui.S.Error, "true")) + i += 4 + case i+5 <= n && s[i:i+5] == "false": + out.WriteString(paint(ilovetui.S.Error, "false")) + i += 5 + case i+4 <= n && s[i:i+4] == "null": + out.WriteString(paint(ilovetui.S.Muted, "null")) + i += 4 + case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',': + out.WriteString(paint(ilovetui.S.Subtle, string(ch))) + i++ + default: + out.WriteByte(ch) + i++ + } + } + return out.String() +} + +// JWT colors the three dot-separated parts of a JWT token in distinct colors. +func JWT(s string) string { + dot := paint(ilovetui.S.Subtle, ".") + parts := strings.SplitN(s, ".", 3) + switch len(parts) { + case 1: + return paint(ilovetui.S.Primary, parts[0]) + case 2: + return paint(ilovetui.S.Primary, parts[0]) + dot + paint(ilovetui.S.Success, parts[1]) + default: + return paint(ilovetui.S.Primary, parts[0]) + dot + + paint(ilovetui.S.Success, parts[1]) + dot + + paint(ilovetui.S.Warning, parts[2]) + } +} diff --git a/internal/jwt/jwt.go b/internal/jwt/jwt.go new file mode 100644 index 0000000..197cf90 --- /dev/null +++ b/internal/jwt/jwt.go @@ -0,0 +1,151 @@ +package jwt + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/json" + "fmt" + "hash" + "strings" +) + +// Decode splits a JWT and returns pretty-printed header and payload JSON. +func Decode(token string) (header, payload string, err error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", "", fmt.Errorf("expected 3 parts, got %d", len(parts)) + } + + hdrBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", "", fmt.Errorf("header: %w", err) + } + plBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", "", fmt.Errorf("payload: %w", err) + } + + var hdrObj any + if err := json.Unmarshal(hdrBytes, &hdrObj); err != nil { + return "", "", fmt.Errorf("header JSON: %w", err) + } + var plObj any + if err := json.Unmarshal(plBytes, &plObj); err != nil { + return "", "", fmt.Errorf("payload JSON: %w", err) + } + + hdrPretty, _ := json.MarshalIndent(hdrObj, "", " ") + plPretty, _ := json.MarshalIndent(plObj, "", " ") + + return string(hdrPretty), string(plPretty), nil +} + +// Encode builds and signs a JWT from raw JSON header and payload strings. +func Encode(header, payload, secret string) (string, error) { + var hdrObj map[string]any + if err := json.Unmarshal([]byte(header), &hdrObj); err != nil { + return "", fmt.Errorf("header JSON: %w", err) + } + var plObj any + if err := json.Unmarshal([]byte(payload), &plObj); err != nil { + return "", fmt.Errorf("payload JSON: %w", err) + } + + hdrCompact, _ := json.Marshal(hdrObj) + plCompact, _ := json.Marshal(plObj) + + hdrB64 := base64.RawURLEncoding.EncodeToString(hdrCompact) + plB64 := base64.RawURLEncoding.EncodeToString(plCompact) + signingInput := hdrB64 + "." + plB64 + + alg, _ := hdrObj["alg"].(string) + + h, err := hashForAlg(alg) + if err != nil { + return signingInput + ".", fmt.Errorf("%w", err) + } + if h == nil { + return signingInput + ".", nil + } + + mac := hmac.New(h, []byte(secret)) + mac.Write([]byte(signingInput)) + sig := mac.Sum(nil) + + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +// Verify checks whether the JWT signature is valid for the given secret. +// Returns (false, nil) for an invalid signature, (true, nil) for valid. +func Verify(token, secret string) (bool, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return false, fmt.Errorf("expected 3 parts, got %d", len(parts)) + } + + hdrBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return false, fmt.Errorf("header encoding: %w", err) + } + var hdrObj map[string]any + if err := json.Unmarshal(hdrBytes, &hdrObj); err != nil { + return false, fmt.Errorf("header JSON: %w", err) + } + + alg, _ := hdrObj["alg"].(string) + + h, err := hashForAlg(alg) + if err != nil { + return false, err + } + if h == nil { + return parts[2] == "", nil + } + + signingInput := parts[0] + "." + parts[1] + mac := hmac.New(h, []byte(secret)) + mac.Write([]byte(signingInput)) + expected := mac.Sum(nil) + + actual, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return false, fmt.Errorf("signature encoding: %w", err) + } + + return hmac.Equal(actual, expected), nil +} + +// Algorithm returns the "alg" claim from the JWT header, or "" if unreadable. +func Algorithm(token string) string { + parts := strings.SplitN(token, ".", 3) + if len(parts) < 1 { + return "" + } + hdrBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "" + } + var hdrObj map[string]any + if err := json.Unmarshal(hdrBytes, &hdrObj); err != nil { + return "" + } + alg, _ := hdrObj["alg"].(string) + return alg +} + +func hashForAlg(alg string) (func() hash.Hash, error) { + switch strings.ToUpper(alg) { + case "HS256": + return sha256.New, nil + case "HS384": + return sha512.New384, nil + case "HS512": + return sha512.New, nil + case "NONE", "": + return nil, nil + default: + return nil, fmt.Errorf("unsupported algorithm: %s", alg) + } +} diff --git a/internal/keys/keys.go b/internal/keys/keys.go new file mode 100644 index 0000000..333d6d6 --- /dev/null +++ b/internal/keys/keys.go @@ -0,0 +1,75 @@ +package keys + +import ( + "strings" + + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/jwt-tui/internal/config" +) + +type KeyMap struct { + Quit key.Binding + CycleFocus key.Binding + Edit key.Binding + EditExternal key.Binding + Docs key.Binding + HelpToggle key.Binding + Clear key.Binding + Reset key.Binding + Copy key.Binding + Paste key.Binding +} + +var Keys *KeyMap + +func Init(cfg *config.Config) { + kb := cfg.Keybindings + Keys = &KeyMap{ + Quit: binding(kb.Quit, "quit"), + CycleFocus: binding(kb.CycleFocus, "cycle focus"), + Edit: binding(kb.Edit, "edit"), + EditExternal: binding(kb.EditExternal, "edit in $EDITOR"), + Docs: binding(kb.Docs, "docs"), + HelpToggle: binding(kb.HelpToggle, "help"), + Clear: binding(kb.Clear, "clear"), + Reset: binding(kb.Reset, "reset"), + Copy: binding(kb.Copy, "copy"), + Paste: binding(kb.Paste, "paste"), + } +} + +func parseKeys(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if k := strings.TrimSpace(p); k != "" { + out = append(out, k) + } + } + return out +} + +func ChunkByWidth(bindings []key.Binding, termWidth int) [][]key.Binding { + cols := termWidth / 26 + if cols < 2 { + cols = 2 + } else if cols > 7 { + cols = 7 + } + perCol := (len(bindings) + cols - 1) / cols + var out [][]key.Binding + for i := 0; i < len(bindings); i += perCol { + end := i + perCol + if end > len(bindings) { + end = len(bindings) + } + out = append(out, bindings[i:end]) + } + return out +} + +func binding(s, help string) key.Binding { + keys := parseKeys(s) + display := strings.Join(keys, "/") + return key.NewBinding(key.WithKeys(keys...), key.WithHelp(display, help)) +} diff --git a/internal/style/border.go b/internal/style/border.go new file mode 100644 index 0000000..243fbf7 --- /dev/null +++ b/internal/style/border.go @@ -0,0 +1,39 @@ +package style + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +func PanelContentH(totalH int) int { + h := totalH - 2 + if h < 0 { + return 0 + } + return h +} + +// RenderWithTitle renders a bordered box with a title embedded in the top border. +// The title may contain ANSI color codes. width and height are the total outer dimensions. +func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string { + boxH := height - 1 + if contentH := boxH - 1; contentH > 0 { + lines := strings.Split(content, "\n") + if len(lines) > contentH { + content = strings.Join(lines[:contentH], "\n") + } + } + box := border.BorderTop(false).Width(width).Height(boxH).Render(content) + + boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0]) + titleW := lipgloss.Width(title) // strips ANSI for measurement + fillW := boxWidth - titleW - 4 // 4 = "╭ " + " " + "╮" + if fillW < 0 { + fillW = 0 + } + bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()) + topLine := bc.Render("╭ ") + title + bc.Render(" "+strings.Repeat("─", fillW)+"╮") + + return lipgloss.JoinVertical(lipgloss.Left, topLine, box) +} diff --git a/internal/ui/docs.md b/internal/ui/docs.md new file mode 100644 index 0000000..53d5f92 --- /dev/null +++ b/internal/ui/docs.md @@ -0,0 +1,123 @@ +# JWT Reference + +## Structure + +A JSON Web Token is three Base64URL-encoded parts joined by dots: + +``` +header.payload.signature +``` + +- **Header**: algorithm and token type +- **Payload**: claims (statements about an entity) +- **Signature**: HMAC or RSA/ECDSA over header + payload + +The header and payload are readable by anyone. JWTs are _signed_, not _encrypted_. +Use JWE if you need confidentiality. + +## Header + +```json +{ + "alg": "HS256", + "typ": "JWT" +} +``` + +Common header parameters: + +| Param | Description | +| ----- | ------------------------------------------ | +| `alg` | Signing algorithm (`HS256`, `RS256`, etc.) | +| `typ` | Token type: always `JWT` | +| `kid` | Key ID: hint for which key to use | +| `cty` | Content type: used for nested JWTs | + +## Payload (Claims) + +**Registered claims** (all optional, but recommended): + +| Claim | Type | Description | +| ----- | ------ | --------------------------------------- | +| `iss` | string | Issuer: who created the token | +| `sub` | string | Subject: principal the token is about | +| `aud` | string | Audience: intended recipient(s) | +| `exp` | number | Expiration time (Unix timestamp) | +| `nbf` | number | Not before: token valid after this time | +| `iat` | number | Issued at (Unix timestamp) | +| `jti` | string | JWT ID: unique identifier | + +**Private claims** are any additional fields agreed upon by the parties. + +## Algorithms + +| Algorithm | Type | Key type | +| --------- | -------------- | ------------------------- | +| `HS256` | HMAC + SHA-256 | Shared secret | +| `HS384` | HMAC + SHA-384 | Shared secret | +| `HS512` | HMAC + SHA-512 | Shared secret | +| `RS256` | RSA + SHA-256 | RSA key pair | +| `RS384` | RSA + SHA-384 | RSA key pair | +| `RS512` | RSA + SHA-512 | RSA key pair | +| `ES256` | ECDSA + P-256 | EC key pair | +| `ES384` | ECDSA + P-384 | EC key pair | +| `ES512` | ECDSA + P-521 | EC key pair | +| `none` | No signature | ⚠ Never use in production | + +> This tool supports **HS256**, **HS384**, and **HS512**. + +## Signature Computation + +For HMAC algorithms: + +``` +signature = HMAC-SHA256( + base64url(header) + "." + base64url(payload), + secret +) +``` + +The final token: + +``` +base64url(header) + "." + base64url(payload) + "." + base64url(signature) +``` + +## Security + +- **Never use `alg: none`**: disables signature verification entirely. +- Use **long, random secrets**: at least 256 bits (32 bytes) for HS256. +- Always validate **`exp`** (expiration) and **`nbf`** (not before). +- Validate **`iss`** and **`aud`** to prevent token reuse across services. +- The payload is **base64-encoded, not encrypted**: never store passwords or PII. +- Prefer **asymmetric algorithms** (RS256, ES256) for public-facing APIs. +- Store secrets in environment variables or a secrets manager, never in code. + +## Brute-forcing a JWT Secret + +If a token is signed with a weak HMAC secret, it can be recovered offline. +Both **hashcat** and **john** accept the raw JWT string as input: + +```bash +# hashcat mode 16500 targets JWT (HS256/384/512) +hashcat -a 0 -m 16500 wordlist.txt + +# john the ripper +john --format=HMAC-SHA256 --wordlist=wordlist.txt jwt.txt +``` + +This only works against **HS\*** algorithms where the secret is a simple password or passphrase. + +## Configuration + +jwt-tui looks for a config file at `~/.config/jwt-tui/config.yaml`. +If the file does not exist the built-in defaults are used automatically. + +To get a starting point you can edit, run: + +``` +jwt-tui --add-default-config +``` + +This writes the default config to `~/.config/jwt-tui/config.yaml` (or to the path given with `--config`). +You can then open that file in any text editor and change the values you want. diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..565f45f --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,463 @@ +package ui + +import ( + _ "embed" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" + "github.com/anotherhadi/jwt-tui/internal/highlight" + "github.com/anotherhadi/jwt-tui/internal/jwt" + "github.com/anotherhadi/jwt-tui/internal/keys" + "github.com/anotherhadi/jwt-tui/internal/style" +) + +//go:embed docs.md +var jwtDocsMD string + +// Panel indices in clockwise order starting top-left: +// +// top-left (0=JWT) → top-right (1=Header) +// ↓ +// bot-left (3=Secret) ← bot-right (2=Payload) +const ( + panelJWT = 0 + panelHeader = 1 + panelPayload = 2 + panelSecret = 3 +) + +const exampleJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + +var panelPlaceholders = [4]string{ + exampleJWT, + "{\n \"alg\": \"HS256\",\n \"typ\": \"JWT\"\n}", + "{\n \"sub\": \"1234567890\",\n \"name\": \"John Doe\",\n \"iat\": 1516239022\n}", + "your-256-bit-secret", +} + +var panelTAPlaceholders = [4]string{ + exampleJWT, + "{\n \"alg\": \"HS256\",\n \"typ\": \"JWT\"\n}", + "{\n \"sub\": \"1234567890\",\n \"name\": \"John Doe\",\n \"iat\": 1516239022\n}", + "your-256-bit-secret", +} + +type panelState struct { + vp viewport.Model + ta textarea.Model + editing bool +} + +type keyMap struct { + CycleFocus key.Binding + Edit key.Binding + EditExternal key.Binding + Clear key.Binding + Reset key.Binding + Copy key.Binding + Paste key.Binding + Docs key.Binding + HelpToggle key.Binding + Quit key.Binding + width int +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.CycleFocus, k.Edit, k.EditExternal, k.HelpToggle, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + all := []key.Binding{k.CycleFocus, k.Edit, k.EditExternal, k.Copy, k.Paste, k.Clear, k.Reset, k.Docs, k.Quit} + return keys.ChunkByWidth(all, k.width) +} + +type docsKeyMap struct { + Close key.Binding +} + +func (k docsKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Close} } +func (k docsKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{{k.Close}} } + +type Model struct { + panels [4]panelState + initial [4]string // per-panel initial values (for reset) + focus int + + showDocs bool + docsVP viewport.Model + + pendingEditorPanel int + pendingPastePanel int + + sigValid *bool + sigStatus string + errMsg string + + help help.Model + keymap keyMap + docsKeys docsKeyMap + + width, height int +} + +func New(initialToken, initialSecret string) Model { + token := strings.TrimSpace(initialToken) + secret := strings.TrimSpace(initialSecret) + + var initVals [4]string + initVals[panelJWT] = token + initVals[panelSecret] = secret + if token != "" { + header, payload, _ := jwt.Decode(token) + initVals[panelHeader] = header + initVals[panelPayload] = payload + } + + m := Model{ + initial: initVals, + focus: panelJWT, + help: ilovetui.NewHelp(), + keymap: keyMap{ + CycleFocus: keys.Keys.CycleFocus, + Edit: keys.Keys.Edit, + EditExternal: keys.Keys.EditExternal, + Clear: keys.Keys.Clear, + Reset: keys.Keys.Reset, + Copy: keys.Keys.Copy, + Paste: keys.Keys.Paste, + Docs: keys.Keys.Docs, + HelpToggle: keys.Keys.HelpToggle, + Quit: keys.Keys.Quit, + }, + docsKeys: docsKeyMap{ + Close: keys.Keys.Docs, + }, + } + + for i := range m.panels { + ta := ilovetui.NewTextarea(false) + ta.Placeholder = panelTAPlaceholders[i] + vp := ilovetui.NewViewport() + vp.SoftWrap = true + m.panels[i].ta = ta + m.panels[i].vp = vp + } + + m.docsVP = ilovetui.NewViewport() + m.docsVP.SoftWrap = true + + for i, val := range initVals { + m.panels[i].ta.SetValue(val) + if val != "" { + m.setViewportContent(i, val) + } + } + + if token != "" { + m.revalidate() + } + + return m +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m *Model) helpHeight() int { + if !m.help.ShowAll { + return 1 + } + max := 0 + for _, col := range m.keymap.FullHelp() { + if len(col) > max { + max = len(col) + } + } + return max +} + +func (m *Model) setViewportContent(panel int, raw string) { + var content string + switch panel { + case panelHeader, panelPayload: + content = highlight.JSON(raw) + case panelJWT: + content = highlight.JWT(raw) + default: + content = lipgloss.NewStyle().Foreground(ilovetui.S.Text).Render(raw) + } + m.panels[panel].vp.SetContent(content) +} + +func (m *Model) recalcSizes() { + if m.width == 0 || m.height == 0 { + return + } + + leftW := m.width / 2 + rightW := m.width - leftW + helpH := m.helpHeight() + availH := m.height - helpH - 1 // -1 for the error line + topH := availH / 2 + bottomH := availH - topH + + setPanel := func(idx, w, h int) { + cw := max(1, w-2) + ch := max(1, h-2) + m.panels[idx].vp.SetWidth(cw) + m.panels[idx].vp.SetHeight(ch) + m.panels[idx].ta.SetWidth(cw) + m.panels[idx].ta.SetHeight(ch) + } + + setPanel(panelJWT, leftW, topH) + setPanel(panelHeader, rightW, topH) + setPanel(panelPayload, rightW, bottomH) + setPanel(panelSecret, leftW, bottomH) + + m.help.SetWidth(m.width) + + docsAvailH := m.height - 1 + m.docsVP.SetHeight(max(1, docsAvailH-2)) + m.docsVP.SetWidth(max(1, m.width-4)) +} + +func (m *Model) revalidate() { + jwtVal := m.panels[panelJWT].ta.Value() + secVal := m.panels[panelSecret].ta.Value() + if jwtVal == "" { + m.sigValid = nil + m.sigStatus = "" + return + } + valid, err := jwt.Verify(jwtVal, secVal) + if err != nil { + m.sigValid = nil + m.sigStatus = "" + m.errMsg = err.Error() + return + } + m.errMsg = "" + m.sigValid = &valid + if valid { + m.sigStatus = "✓ Signature Verified" + } else { + m.sigStatus = "✗ Invalid Signature" + } +} + +func (m *Model) decodeJWT() { + token := m.panels[panelJWT].ta.Value() + if token == "" { + m.sigValid = nil + m.sigStatus = "" + m.errMsg = "" + m.panels[panelHeader].ta.SetValue("") + m.panels[panelHeader].vp.SetContent("") + m.panels[panelPayload].ta.SetValue("") + m.panels[panelPayload].vp.SetContent("") + return + } + header, payload, err := jwt.Decode(token) + if err != nil { + m.sigValid = nil + m.sigStatus = "" + m.errMsg = err.Error() + m.panels[panelHeader].ta.SetValue("") + m.panels[panelHeader].vp.SetContent("") + m.panels[panelPayload].ta.SetValue("") + m.panels[panelPayload].vp.SetContent("") + return + } + m.errMsg = "" + m.panels[panelHeader].ta.SetValue(header) + m.panels[panelPayload].ta.SetValue(payload) + m.setViewportContent(panelHeader, header) + m.setViewportContent(panelPayload, payload) + m.revalidate() +} + +func (m *Model) rebuildJWT() { + header := m.panels[panelHeader].ta.Value() + payload := m.panels[panelPayload].ta.Value() + if header == "" && payload == "" { + m.panels[panelJWT].ta.SetValue("") + m.panels[panelJWT].vp.SetContent("") + m.sigValid = nil + m.sigStatus = "" + m.errMsg = "" + return + } + token, err := jwt.Encode(header, payload, m.panels[panelSecret].ta.Value()) + if err != nil { + m.sigValid = nil + m.sigStatus = "" + m.errMsg = err.Error() + return + } + m.errMsg = "" + m.panels[panelJWT].ta.SetValue(token) + m.setViewportContent(panelJWT, token) + m.revalidate() +} + +func (m *Model) exitEditMode() { + p := &m.panels[m.focus] + if !p.editing { + return + } + p.editing = false + p.ta.Placeholder = panelTAPlaceholders[m.focus] + p.ta.Blur() + raw := p.ta.Value() + if raw != "" { + m.setViewportContent(m.focus, raw) + } + switch m.focus { + case panelHeader, panelPayload: + m.rebuildJWT() + case panelJWT: + m.decodeJWT() + case panelSecret: + m.revalidate() + } +} + +func (m *Model) enterEditMode() tea.Cmd { + p := &m.panels[m.focus] + p.editing = true + p.ta.Placeholder = "" + return p.ta.Focus() +} + +// clearPanel empties the focused panel and enters edit mode. +func (m *Model) clearPanel() tea.Cmd { + m.exitEditMode() + m.panels[m.focus].ta.SetValue("") + m.panels[m.focus].vp.SetContent("") + // Clearing JWT also wipes derived header/payload + if m.focus == panelJWT { + m.panels[panelHeader].ta.SetValue("") + m.panels[panelHeader].vp.SetContent("") + m.panels[panelPayload].ta.SetValue("") + m.panels[panelPayload].vp.SetContent("") + m.sigValid = nil + m.sigStatus = "" + } else { + switch m.focus { + case panelHeader, panelPayload: + m.rebuildJWT() + case panelSecret: + m.revalidate() + } + } + return m.enterEditMode() +} + +// resetPanel restores the focused panel to its initial value. +func (m *Model) resetPanel() { + m.exitEditMode() + val := m.initial[m.focus] + m.panels[m.focus].ta.SetValue(val) + if val != "" { + m.setViewportContent(m.focus, val) + } else { + m.panels[m.focus].vp.SetContent("") + } + // Resetting JWT also restores derived header/payload from initial + if m.focus == panelJWT { + m.panels[panelHeader].ta.SetValue(m.initial[panelHeader]) + if m.initial[panelHeader] != "" { + m.setViewportContent(panelHeader, m.initial[panelHeader]) + } else { + m.panels[panelHeader].vp.SetContent("") + } + m.panels[panelPayload].ta.SetValue(m.initial[panelPayload]) + if m.initial[panelPayload] != "" { + m.setViewportContent(panelPayload, m.initial[panelPayload]) + } else { + m.panels[panelPayload].vp.SetContent("") + } + m.revalidate() + } else { + switch m.focus { + case panelHeader, panelPayload: + m.rebuildJWT() + case panelSecret: + m.revalidate() + } + } +} + +func (m *Model) renderDocs() { + width := max(40, m.docsVP.Width()) + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(ilovetui.GlamourStyleConfig()), + glamour.WithWordWrap(width), + ) + if err != nil { + m.docsVP.SetContent(jwtDocsMD) + return + } + rendered, err := renderer.Render(jwtDocsMD) + if err != nil { + m.docsVP.SetContent(jwtDocsMD) + return + } + m.docsVP.SetContent(rendered) + m.docsVP.SetYOffset(0) +} + +func (m Model) borderFor(panel int) lipgloss.Style { + if panel == m.focus && !m.showDocs { + return ilovetui.S.PanelFocused + } + return ilovetui.S.Panel +} + +func (m Model) panelTitle(panel int, name string) string { + bc := lipgloss.NewStyle().Foreground(m.borderFor(panel).GetBorderTopForeground()) + title := bc.Render(name) + if panel == m.focus && m.panels[panel].editing { + title += ilovetui.S.Faint.Render(" [edit]") + } + return title +} + +func (m Model) secretTitle() string { + name := m.panelTitle(panelSecret, "Secret") + + var sigStr string + if m.sigValid == nil { + sigStr = ilovetui.S.Faint.Render("·") + } else if *m.sigValid { + sigStr = lipgloss.NewStyle().Foreground(ilovetui.S.Success).Render("✓") + } else { + sigStr = lipgloss.NewStyle().Foreground(ilovetui.S.Error).Render("✗") + } + + return name + ilovetui.S.Faint.Render(" · ") + sigStr +} + +func (m *Model) renderPanelContent(panel int) string { + p := &m.panels[panel] + if p.editing { + return p.ta.View() + } + if p.ta.Value() == "" { + return ilovetui.S.Faint.Render(panelPlaceholders[panel]) + } + return ilovetui.ViewportView(&p.vp) +} + +func (m *Model) renderPanel(panel int, title string, w, h int) string { + return style.RenderWithTitle(m.borderFor(panel), title, m.renderPanelContent(panel), w, h) +} diff --git a/internal/ui/update.go b/internal/ui/update.go new file mode 100644 index 0000000..9541f8a --- /dev/null +++ b/internal/ui/update.go @@ -0,0 +1,149 @@ +package ui + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/anotherhadi/jwt-tui/internal/keys" + "github.com/anotherhadi/jwt-tui/internal/util" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.keymap.width = msg.Width + m.recalcSizes() + if m.showDocs { + m.renderDocs() + } + return m, nil + + case tea.ClipboardMsg: + content := msg.String() + if content != "" { + panel := m.pendingPastePanel + m.panels[panel].ta.SetValue(content) + m.setViewportContent(panel, content) + switch panel { + case panelHeader, panelPayload: + m.rebuildJWT() + case panelJWT: + m.decodeJWT() + case panelSecret: + m.revalidate() + } + } + return m, nil + + case util.EditorFinishedMsg: + if msg.Err == nil && msg.Content != "" { + panel := m.pendingEditorPanel + m.panels[panel].ta.SetValue(msg.Content) + m.setViewportContent(panel, msg.Content) + switch panel { + case panelHeader, panelPayload: + m.rebuildJWT() + case panelJWT: + m.decodeJWT() + case panelSecret: + m.revalidate() + } + } + return m, nil + + case tea.KeyPressMsg: + // Docs overlay: only scrolling, d or esc to close, ctrl+c to quit + if m.showDocs { + switch { + case key.Matches(msg, keys.Keys.Quit): + return m, tea.Quit + case key.Matches(msg, keys.Keys.Docs), msg.String() == "esc": + m.showDocs = false + default: + var cmd tea.Cmd + m.docsVP, cmd = m.docsVP.Update(msg) + return m, cmd + } + return m, nil + } + + // In edit mode: esc and ctrl+c exit edit mode, everything else goes to the textarea + if m.panels[m.focus].editing { + if msg.String() == "esc" || msg.String() == "ctrl+c" { + m.exitEditMode() + return m, nil + } + p := &m.panels[m.focus] + prev := p.ta.Value() + var cmd tea.Cmd + p.ta, cmd = p.ta.Update(msg) + if p.ta.Value() != prev { + switch m.focus { + case panelHeader, panelPayload: + m.rebuildJWT() + case panelJWT: + m.decodeJWT() + case panelSecret: + m.revalidate() + } + } + return m, cmd + } + + // View mode shortcuts + switch { + case key.Matches(msg, keys.Keys.Quit): + return m, tea.Quit + + case key.Matches(msg, keys.Keys.HelpToggle): + m.help.ShowAll = !m.help.ShowAll + m.recalcSizes() + + case key.Matches(msg, keys.Keys.Docs): + m.help.ShowAll = false + m.showDocs = true + m.renderDocs() + + case key.Matches(msg, keys.Keys.CycleFocus): + m.focus = (m.focus + 1) % 4 + + case key.Matches(msg, keys.Keys.Edit): + return m, m.enterEditMode() + + case key.Matches(msg, keys.Keys.EditExternal): + m.pendingEditorPanel = m.focus + return m, util.OpenExternalEditor(m.panels[m.focus].ta.Value()) + + case key.Matches(msg, keys.Keys.Copy): + return m, tea.SetClipboard(m.panels[m.focus].ta.Value()) + + case key.Matches(msg, keys.Keys.Paste): + m.pendingPastePanel = m.focus + return m, tea.ReadClipboard + + case key.Matches(msg, keys.Keys.Clear): + return m, m.clearPanel() + + case key.Matches(msg, keys.Keys.Reset): + m.resetPanel() + + default: + var cmd tea.Cmd + m.panels[m.focus].vp, cmd = m.panels[m.focus].vp.Update(msg) + return m, cmd + } + return m, nil + + default: + if m.panels[m.focus].editing { + p := &m.panels[m.focus] + var cmd tea.Cmd + p.ta, cmd = p.ta.Update(msg) + return m, cmd + } + var cmd tea.Cmd + m.panels[m.focus].vp, cmd = m.panels[m.focus].vp.Update(msg) + return m, cmd + } +} diff --git a/internal/ui/view.go b/internal/ui/view.go new file mode 100644 index 0000000..4c11cf8 --- /dev/null +++ b/internal/ui/view.go @@ -0,0 +1,89 @@ +package ui + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + ilovetui "github.com/anotherhadi/ilovetui" +) + +func (m Model) View() tea.View { + var content string + if m.width == 0 { + content = "" + } else if m.showDocs { + content = m.renderDocsView() + } else { + content = m.renderMainView() + } + v := tea.NewView(content) + v.AltScreen = true + return v +} + +func (m Model) renderMainView() string { + leftW := m.width / 2 + rightW := m.width - leftW + helpH := m.helpHeight() + availH := m.height - helpH - 1 + topH := availH / 2 + bottomH := availH - topH + + // Layout (clockwise from top-left): + // top-left=JWT top-right=Header + // bot-left=Secret bot-right=Payload + jwtPanel := m.renderPanel(panelJWT, m.panelTitle(panelJWT, "Encoded"), leftW, topH) + headerPanel := m.renderPanel(panelHeader, m.panelTitle(panelHeader, "Header"), rightW, topH) + payloadPanel := m.renderPanel(panelPayload, m.panelTitle(panelPayload, "Payload"), rightW, bottomH) + secretPanel := m.renderPanel(panelSecret, m.secretTitle(), leftW, bottomH) + + left := lipgloss.JoinVertical(lipgloss.Left, jwtPanel, secretPanel) + right := lipgloss.JoinVertical(lipgloss.Left, headerPanel, payloadPanel) + main := lipgloss.JoinHorizontal(lipgloss.Top, left, right) + + return lipgloss.JoinVertical(lipgloss.Left, main, m.renderErrorLine(), m.renderHelpBar()) +} + +func (m Model) renderDocsView() string { + docsBorder := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ilovetui.S.Subtle). + Padding(0, 1) + + window := docsBorder.Render(ilovetui.ViewportView(&m.docsVP)) + helpStr := m.help.View(m.docsKeys) + return lipgloss.JoinVertical(lipgloss.Left, window, helpStr) +} + +func (m Model) renderErrorLine() string { + if m.errMsg == "" { + return "" + } + return lipgloss.NewStyle().Foreground(ilovetui.S.Error).Render(" " + m.errMsg) +} + +func (m Model) renderHelpBar() string { + helpStr := m.help.View(m.keymap) + + var sigStr string + if m.sigValid == nil { + sigStr = ilovetui.S.Faint.Render("-") + } else if *m.sigValid { + sigStr = lipgloss.NewStyle().Foreground(ilovetui.S.Success).Bold(true).Render(m.sigStatus) + } else { + sigStr = lipgloss.NewStyle().Foreground(ilovetui.S.Error).Bold(true).Render(m.sigStatus) + } + + // Align sig status to the right of the last line of helpStr + helpLines := strings.Split(helpStr, "\n") + lastLine := helpLines[len(helpLines)-1] + lastLineW := lipgloss.Width(lastLine) + sigW := lipgloss.Width(sigStr) + pad := m.width - lastLineW - sigW + if pad < 1 { + pad = 1 + } + helpLines[len(helpLines)-1] = lastLine + strings.Repeat(" ", pad) + sigStr + return strings.Join(helpLines, "\n") +} diff --git a/internal/util/editor.go b/internal/util/editor.go new file mode 100644 index 0000000..1d0b779 --- /dev/null +++ b/internal/util/editor.go @@ -0,0 +1,47 @@ +package util + +import ( + "os" + "os/exec" + + tea "charm.land/bubbletea/v2" +) + +type EditorFinishedMsg struct { + Content string + Err error +} + +func OpenExternalEditor(content string) tea.Cmd { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = os.Getenv("VISUAL") + } + if editor == "" { + editor = "vi" + } + + f, err := os.CreateTemp("", "jwt-tui-*.json") + if err != nil { + return func() tea.Msg { return EditorFinishedMsg{Err: err} } + } + tmpPath := f.Name() + if _, werr := f.WriteString(content); werr != nil { + f.Close() + os.Remove(tmpPath) + return func() tea.Msg { return EditorFinishedMsg{Err: werr} } + } + f.Close() + + return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg { + defer os.Remove(tmpPath) + if err != nil { + return EditorFinishedMsg{Err: err} + } + data, readErr := os.ReadFile(tmpPath) + if readErr != nil { + return EditorFinishedMsg{Err: readErr} + } + return EditorFinishedMsg{Content: string(data)} + }) +} diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml new file mode 100644 index 0000000..c66c6b1 --- /dev/null +++ b/nix/gomod2nix.toml @@ -0,0 +1,174 @@ +schema = 3 + +[mod] + [mod.'charm.land/bubbles/v2'] + version = 'v2.1.0' + hash = 'sha256-2OmqpBrl+taOJzAhVM6OReLmoYRxZOXx9JqFNjQjsPA=' + + [mod.'charm.land/bubbletea/v2'] + version = 'v2.0.6' + hash = 'sha256-1jxXmcnI4peUE0Xs3HGe57pIhRONx235aAaeqm2r434=' + + [mod.'charm.land/glamour/v2'] + version = 'v2.0.0' + hash = 'sha256-CZYlNGw2MihqnSHf1Xxqz55NnqW9fVpLxyvLItryIw4=' + + [mod.'charm.land/lipgloss/v2'] + version = 'v2.0.3' + hash = 'sha256-/RFkSUscU3NwymzT+PfizGf3XyQIdVGQlX7vxktCUGk=' + + [mod.'github.com/alecthomas/chroma/v2'] + version = 'v2.14.0' + hash = 'sha256-d+zcIobMS5Y0/Ym9Uxubf20uyw0aBCr0f1oEOAGHlEA=' + + [mod.'github.com/anotherhadi/ilovetui'] + version = 'v0.1.6' + hash = 'sha256-7E+7UFks5vM3XWCvX2joFmHGcW7qqnoox6ZPFglaLO4=' + + [mod.'github.com/atotto/clipboard'] + version = 'v0.1.4' + hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk=' + + [mod.'github.com/aymerick/douceur'] + version = 'v0.2.0' + hash = 'sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=' + + [mod.'github.com/charmbracelet/colorprofile'] + version = 'v0.4.3' + hash = 'sha256-y+QDUxGOKhugEMQLRUTZYT2C+wKqYHnMLJ44jbh7+JA=' + + [mod.'github.com/charmbracelet/ultraviolet'] + version = 'v0.0.0-20260416155717-489999b90468' + hash = 'sha256-HAex/0iEd42Wk1t+AR0O8J+F2ZAYU2sTw9ea0EfmKEU=' + + [mod.'github.com/charmbracelet/x/ansi'] + version = 'v0.11.7' + hash = 'sha256-q8BZJq4K7NE5ETocN9/G/EoV0dUyD703ONSfHiUYzWQ=' + + [mod.'github.com/charmbracelet/x/exp/slice'] + version = 'v0.0.0-20250327172914-2fdc97757edf' + hash = 'sha256-C1tksnevc/RdytJRQg5LQ0+VVSWlTwbNGic649m6E1Q=' + + [mod.'github.com/charmbracelet/x/term'] + version = 'v0.2.2' + hash = 'sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4=' + + [mod.'github.com/charmbracelet/x/termios'] + version = 'v0.1.1' + hash = 'sha256-sri3LpHCBhGvnJldDzBxwbbZpeSGZVCJFOUL45uBFds=' + + [mod.'github.com/charmbracelet/x/windows'] + version = 'v0.2.2' + hash = 'sha256-CvmE8kAC5wlPSeWjl2hc5xizvGS2FeOLHw84froldkk=' + + [mod.'github.com/clipperhouse/displaywidth'] + version = 'v0.11.0' + hash = 'sha256-WokyTaofEy95xlshqK5YDzpemhXV5oaQifxS9YyfCXo=' + + [mod.'github.com/clipperhouse/uax29/v2'] + version = 'v2.7.0' + hash = 'sha256-GO3az7WiGcwU0OvmocwdfR5ohGRL8NbjscIaMyhAdxE=' + + [mod.'github.com/dlclark/regexp2'] + version = 'v1.11.0' + hash = 'sha256-iXBBgykYu9Dcd+7LMJyRYc3Ry47jmuLGZFW13zU6toU=' + + [mod.'github.com/fsnotify/fsnotify'] + version = 'v1.9.0' + hash = 'sha256-WtpE1N6dpHwEvIub7Xp/CrWm0fd6PX7MKA4PV44rp2g=' + + [mod.'github.com/go-viper/mapstructure/v2'] + version = 'v2.4.0' + hash = 'sha256-lLfcV9z4n94hDhgyXJlde4bFB0hfzlbh+polqcJCwGE=' + + [mod.'github.com/gorilla/css'] + version = 'v1.0.1' + hash = 'sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=' + + [mod.'github.com/lucasb-eyer/go-colorful'] + version = 'v1.4.0' + hash = 'sha256-i/3GDHKEMLCy0kc3mtyk58UWYOPmKoUVaq6QCAWXKP0=' + + [mod.'github.com/mattn/go-runewidth'] + version = 'v0.0.23' + hash = 'sha256-SmChZ2U1aR8pW3LPhdM7KcVF5TO6VcHgRzBtUXbBWJA=' + + [mod.'github.com/microcosm-cc/bluemonday'] + version = 'v1.0.27' + hash = 'sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es=' + + [mod.'github.com/muesli/cancelreader'] + version = 'v0.2.2' + hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ=' + + [mod.'github.com/pelletier/go-toml/v2'] + version = 'v2.2.4' + hash = 'sha256-8qQIPldbsS5RO8v/FW/se3ZsAyvLzexiivzJCbGRg2Q=' + + [mod.'github.com/rivo/uniseg'] + version = 'v0.4.7' + hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=' + + [mod.'github.com/sagikazarmark/locafero'] + version = 'v0.11.0' + hash = 'sha256-PUX8dzJtkD8YDZFNqpHnl4qgb0tE1W/DLnL7V+/d1z4=' + + [mod.'github.com/sourcegraph/conc'] + version = 'v0.3.1-0.20240121214520-5f936abd7ae8' + hash = 'sha256-AUNFlY6K7s1aoW/vb4pjK84ROdnVZY1i6cOmdeG+wN8=' + + [mod.'github.com/spf13/afero'] + version = 'v1.15.0' + hash = 'sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8=' + + [mod.'github.com/spf13/cast'] + version = 'v1.10.0' + hash = 'sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk=' + + [mod.'github.com/spf13/pflag'] + version = 'v1.0.10' + hash = 'sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU=' + + [mod.'github.com/spf13/viper'] + version = 'v1.21.0' + hash = 'sha256-A9A8i7HH/ge4j3hw7G++HNj8BjhhpZKvxHhfY+QAxkI=' + + [mod.'github.com/subosito/gotenv'] + version = 'v1.6.0' + hash = 'sha256-LspbjTniiq2xAICSXmgqP7carwlNaLqnCTQfw2pa80A=' + + [mod.'github.com/xo/terminfo'] + version = 'v0.0.0-20220910002029-abceb7e1c41e' + hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=' + + [mod.'github.com/yuin/goldmark'] + version = 'v1.7.8' + hash = 'sha256-SNJMPPiXkRDLVOldrHN0ErC3bUB2VoWaLDkd9zmMATw=' + + [mod.'github.com/yuin/goldmark-emoji'] + version = 'v1.0.5' + hash = 'sha256-GtMipzIcZ0Be7y8fhZ9VkT9dg6bqj9U+DhvliGcMkaU=' + + [mod.'go.yaml.in/yaml/v3'] + version = 'v3.0.4' + hash = 'sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4=' + + [mod.'golang.org/x/net'] + version = 'v0.39.0' + hash = 'sha256-IP29+yGphWKUT7wHTyzqA2rnRT4AJ7oWcT6NKLzkWcM=' + + [mod.'golang.org/x/sync'] + version = 'v0.20.0' + hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y=' + + [mod.'golang.org/x/sys'] + version = 'v0.43.0' + hash = 'sha256-aDQXqSTZES2l/132PBxhZN4ywldpPyfm7LByYCHzzwM=' + + [mod.'golang.org/x/text'] + version = 'v0.28.0' + hash = 'sha256-8UlJniGK+km4Hmrw6XMxELnExgrih7+z8tU26Cntmto=' + + [mod.'gopkg.in/yaml.v3'] + version = 'v3.0.1' + hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=' diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..54e24dd --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,21 @@ +{ + pkgs, + buildGoApplication, +}: let + pname = "jwt-tui"; + version = "0.1.0"; + ldflags = ["-s" "-w" "-X main.version=${version}"]; + pkg = buildGoApplication { + inherit pname version ldflags; + src = ../.; + modules = ./gomod2nix.toml; + meta = with pkgs.lib; { + description = "A TUI for inspecting, editing, and signing JSON Web Tokens (JWTs)."; + homepage = "https://github.com/anotherhadi/jwt-tui"; + platforms = platforms.unix; + }; + }; +in { + "${pname}" = pkg; + default = pkg; +} diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 0000000..bc70b79 --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,40 @@ +{ + pkgs, + gitHooksLib, + gomod2nixPkgs, +}: let + hooks = gitHooksLib.run { + src = ../.; + hooks = { + gofmt.enable = true; + govet.enable = true; + gomod2nix = { + enable = true; + name = "gomod2nix"; + entry = "gomod2nix --outdir ./nix"; + language = "system"; + files = "go\\.(mod|sum)$"; + pass_filenames = false; + }; + + inject-exec = { + enable = true; + name = "inject-exec"; + entry = "python3 .github/scripts/inject-exec.py README.md"; + language = "system"; + files = "(README\\.md|cmd/)"; + pass_filenames = false; + }; + }; + }; +in + pkgs.mkShell { + packages = with pkgs; + [ + go + gomod2nixPkgs.gomod2nix + ] + ++ hooks.enabledPackages; + + shellHook = hooks.shellHook; + }