Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- "site/**"
- "install.sh"
- ".github/workflows/pages.yml"
workflow_dispatch:

Expand All @@ -28,6 +29,12 @@ jobs:
run: |
version="$(gh release view --json tagName --jq .tagName)"
sed -i "s/VERSION/${version}/g" site/index.html
# The installer lives at the repository root as the single source of
# truth and is copied into the Pages artifact so that
# https://jwtd.sh/install.sh serves it. It is never edited here, so the
# hosted script is byte-identical to the reviewed one.
- name: Publish the install script at jwtd.sh/install.sh
run: install -m 0755 install.sh site/install.sh
- uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0
with:
path: site
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ jwtd
.idea/
dist/
completions/
# Copied from the repository root by the Pages workflow at build time.
site/install.sh
.worktrees/
result
result-*
12 changes: 11 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ Artifacts cross the build/release job boundary as two separate uploads: `jwtd-re

Release notes are auto-generated (`--generate-notes`), which lists only merged PR titles. `RELEASE_NOTES.md` holds hand-written prose for the next release: when present and non-empty it is prepended to the generated notes at release creation. Clear it after a release so its contents do not repeat on the following one.

### Install script

`install.sh` is a package-manager-free installer for macOS and Linux, served at `https://jwtd.sh/install.sh` (`curl -fsSL https://jwtd.sh/install.sh | sh`). It maps `uname -s`/`uname -m` onto the GoReleaser archive names (`jwtd-<os>-<arch>.tar.gz`, the four darwin/linux × amd64/arm64 targets), downloads that archive plus `checksums.txt` from the latest release — or from `--version <tag>` — extracts the binary, and installs it into `~/.local/bin` (overridable with `--dir`/`JWTD_INSTALL_DIR`). Windows is deliberately unreachable: it is served by WinGet and Scoop.

**Verification is not optional, and nothing is written before it passes.** The archive is always checked against its `checksums.txt` entry (`sha256sum`, falling back to `shasum -a 256`), and when `cosign` is on `PATH` the keyless bundle over `checksums.txt` is verified against the same certificate identity and issuer the README documents; a cosign failure aborts. cosign itself stays optional because most machines do not have it and the checksum already pins the bytes — but a present cosign is never advisory. The script runs under POSIX `sh` (it is piped into whatever `/bin/sh` the user has, not necessarily bash) and never calls `sudo`, so piping it into a shell is not a privilege decision.

The binary is copied into the install directory under a temporary name and then `mv`'d into place, so the replacement is a same-filesystem `rename(2)`: an upgrade cannot leave a half-written binary behind, and it does not fail with `ETXTBSY` when the running shell's own `jwtd` is being replaced. A cross-device `mv` straight from the temp directory would do both.

There is one copy of the script. `.github/workflows/pages.yml` copies the repository root file into the Pages artifact (`install -m 0755 install.sh site/install.sh`, which is git-ignored) so the hosted script is byte-identical to the reviewed one, and the workflow redeploys when `install.sh` changes. `install_test.go` holds down the contract: the archive naming against `.goreleaser.yaml`, verification ordering, the Cosign identity matching the README, `sh`/no-`sudo`, rejection of unsupported platforms (driven by a stubbed `uname`, so the test never touches the network), and the README/site one-liner.

## Dependencies

| Package | Purpose |
Expand Down Expand Up @@ -141,7 +151,7 @@ JWTD_KEY=key.pem jwtd <token> # same, via environment variable
## Conventions

- **Single package.** All code stays in package `main`, split across topical files (`main.go`, `jwe.go`, `keys.go`, `output.go`, `jsonout.go`, `claims.go`).
- **Tests mirror the source files:** `main_test.go`, `jwe_test.go`, `keys_test.go`, `output_test.go`, `jsonout_test.go`, `claims_test.go`, with shared fixtures (key generation, token signing/encryption helpers) in `helpers_test.go` and GoReleaser/release-workflow invariants in `workflow_test.go`. Use table-driven tests where multiple cases share the same structure.
- **Tests mirror the source files:** `main_test.go`, `jwe_test.go`, `keys_test.go`, `output_test.go`, `jsonout_test.go`, `claims_test.go`, with shared fixtures (key generation, token signing/encryption helpers) in `helpers_test.go` GoReleaser/release-workflow invariants in `workflow_test.go`, website/Pages invariants in `site_test.go`, and installer invariants in `install_test.go`. Use table-driven tests where multiple cases share the same structure.
- **Color scheme** is configured in `newFormatter()` via `go-prettyjson` and `fatih/color`. Colors auto-disable when stdout is not a TTY.
- **Error handling:** Return errors up the call stack with `fmt.Errorf` wrapping (`%w`). The root command suppresses Cobra's automatic error and usage output; `main()` renders non-signature errors and exits nonzero, while invalid signatures print their own details and return `errInvalidSignature`.
- **Formatting:** Use `gofmt`/`goimports` standard formatting. No special linter configuration.
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ A CLI tool that decodes and pretty-prints JSON Web Tokens (JWTs) and JSON Web En

## Installation

### Install script (macOS and Linux)

```sh
curl -fsSL https://jwtd.sh/install.sh | sh
```

Downloads the release archive for the detected OS and architecture, verifies it against the release's `checksums.txt`, and installs the binary into `~/.local/bin` — no root privileges and no package manager required. When [Cosign](https://docs.sigstore.dev/) is installed, the keyless signature over `checksums.txt` is verified as well; without it the checksum verification still runs and a mismatch aborts the installation.

Pass options after `--`:

```sh
curl -fsSL https://jwtd.sh/install.sh | sh -s -- --version v5.3.0 # pin a release
curl -fsSL https://jwtd.sh/install.sh | sh -s -- --dir /usr/local/bin
```

`JWTD_VERSION` and `JWTD_INSTALL_DIR` set the same two values. Run the script with `--help` for the full list. The script is [`install.sh`](install.sh) in this repository; review it before piping it into a shell.

Windows is served by [WinGet](#winget-windows) and [Scoop](#scoop-windows) instead.

### Homebrew (macOS and Linux)

```sh
Expand Down
9 changes: 9 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## Install script

jwtd can now be installed on macOS and Linux without a package manager:

```sh
curl -fsSL https://jwtd.sh/install.sh | sh
```

The script picks the release archive matching your OS and architecture, verifies it against the release's `checksums.txt` — and, when `cosign` is installed, verifies the keyless signature over that checksum file — then installs the binary into `~/.local/bin`. No root privileges are involved. Pin a release with `--version v5.3.0` or install elsewhere with `--dir /usr/local/bin`.
242 changes: 242 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
#!/bin/sh
#
# jwtd installer for macOS and Linux.
#
# Downloads the release archive matching the detected OS and architecture,
# verifies it against the release's checksums.txt (and, when cosign is
# installed, verifies the keyless signature over that checksum file), then
# installs the binary into ~/.local/bin. No root privileges are required.
#
# curl -fsSL https://jwtd.sh/install.sh | sh
# curl -fsSL https://jwtd.sh/install.sh | sh -s -- --version v5.3.0
# curl -fsSL https://jwtd.sh/install.sh | sh -s -- --dir /usr/local/bin
#
# Windows is served by Scoop and WinGet instead; see the README.

set -eu

REPO="webcodr/jwtd"
CERTIFICATE_IDENTITY_REGEXP="^https://github.com/webcodr/jwtd/\.github/workflows/release\.yml@"
CERTIFICATE_OIDC_ISSUER="https://token.actions.githubusercontent.com"

info() {
printf '%s\n' "$*" >&2
}

warn() {
printf 'warning: %s\n' "$*" >&2
}

die() {
printf 'error: %s\n' "$*" >&2
exit 1
}

have() {
command -v "$1" >/dev/null 2>&1
}

usage() {
cat <<'EOF'
Install jwtd, a CLI that decodes and pretty-prints JWT, JWS, and JWE tokens.

Usage:
install.sh [--version <tag>] [--dir <path>]

Options:
-v, --version <tag> Release to install (default: the latest release).
Accepts "5.3.0" or "v5.3.0".
-d, --dir <path> Installation directory (default: ~/.local/bin).
-h, --help Show this help.

Environment:
JWTD_VERSION Same as --version.
JWTD_INSTALL_DIR Same as --dir.

The archive is always verified against the release's checksums.txt. When
cosign is installed, the keyless Cosign bundle over checksums.txt is verified
as well.
EOF
}

# detect_os and detect_arch map uname output onto the GOOS/GOARCH pair used in
# the release archive names (jwtd-<os>-<arch>.tar.gz).
detect_os() {
kernel=$(uname -s)
case "$kernel" in
Linux) printf 'linux\n' ;;
Darwin) printf 'darwin\n' ;;
*) die "unsupported operating system: $kernel (this script installs on Linux and macOS; on Windows use 'winget install WebCodr.jwtd' or Scoop)" ;;
esac
}

detect_arch() {
machine=$(uname -m)
case "$machine" in
x86_64 | amd64) printf 'amd64\n' ;;
aarch64 | arm64) printf 'arm64\n' ;;
*) die "unsupported architecture: $machine (release binaries are built for amd64 and arm64)" ;;
esac
}

# Under Rosetta 2 a translated shell reports x86_64, which would install the
# Intel binary on Apple silicon. sysctl.proc_translated is set only in that
# case, so it distinguishes translation from a genuine Intel Mac.
correct_rosetta_arch() {
if [ "$1" = "darwin" ] && [ "$2" = "amd64" ] && have sysctl &&
[ "$(sysctl -n sysctl.proc_translated 2>/dev/null || printf '0\n')" = "1" ]; then
printf 'arm64\n'
else
printf '%s\n' "$2"
fi
}

download() {
url=$1
destination=$2
if have curl; then
curl -fsSL --proto '=https' --tlsv1.2 -o "$destination" "$url" ||
die "could not download $url (check the release tag and your network connection)"
elif have wget; then
wget -q --https-only -O "$destination" "$url" ||
die "could not download $url (check the release tag and your network connection)"
else
die "neither curl nor wget is available; install one of them and re-run"
fi
}

# verify_checksum matches the archive against its checksums.txt entry. The
# entry is selected by exact file name and written out verbatim so the
# checksum tool sees the original "<hash> <name>" formatting.
verify_checksum() {
archive=$1
if ! awk -v want="$archive" '$2 == want { print $0; found = 1 } END { exit !found }' \
checksums.txt >"$archive.sha256"; then
die "checksums.txt has no entry for $archive"
fi

if have sha256sum; then
sha256sum -c "$archive.sha256" >/dev/null ||
die "checksum mismatch for $archive; refusing to install"
elif have shasum; then
shasum -a 256 -c "$archive.sha256" >/dev/null ||
die "checksum mismatch for $archive; refusing to install"
else
die "neither sha256sum nor shasum is available; cannot verify the download"
fi
info "Checksum verified: $archive"
}

# verify_signature is best-effort by design: cosign is not a dependency most
# machines have, and the checksum above already pins the archive bytes. When
# cosign is present the bundle is verified and a failure is fatal.
verify_signature() {
base_url=$1
if ! have cosign; then
info "cosign not found - skipping signature verification (install cosign to verify the release signature)"
return
fi

download "$base_url/checksums.txt.sigstore.json" checksums.txt.sigstore.json
cosign verify-blob \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp "$CERTIFICATE_IDENTITY_REGEXP" \
--certificate-oidc-issuer "$CERTIFICATE_OIDC_ISSUER" \
checksums.txt >/dev/null 2>&1 ||
die "cosign could not verify checksums.txt against the jwtd release workflow; refusing to install"
info "Signature verified: checksums.txt (cosign, keyless)"
}

# report_path_hint keeps the installer honest about the one thing it cannot do
# for the user: ~/.local/bin is not on every PATH by default.
report_path_hint() {
directory=$1
case ":$PATH:" in
*":$directory:"*) return ;;
esac

warn "$directory is not on your PATH. Add it with one of:"
# $PATH stays literal here: the hint is a command for the user to run.
# shellcheck disable=SC2016
printf ' bash/zsh: echo '\''export PATH="%s:$PATH"'\'' >> ~/.profile\n' "$directory" >&2
printf ' fish: fish_add_path %s\n' "$directory" >&2
}

version=${JWTD_VERSION-}
install_dir=${JWTD_INSTALL_DIR-}

while [ $# -gt 0 ]; do
case "$1" in
-v | --version)
[ $# -ge 2 ] || die "--version requires a release tag"
version=$2
shift 2
;;
-d | --dir)
[ $# -ge 2 ] || die "--dir requires a path"
install_dir=$2
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
die "unknown option: $1 (run with --help for usage)"
;;
esac
done

[ -n "$install_dir" ] || install_dir="$HOME/.local/bin"
# The download happens from a temporary working directory, so a relative --dir
# has to be anchored to the caller's directory before that cd.
case "$install_dir" in
/*) ;;
*) install_dir="$PWD/$install_dir" ;;
esac

os=$(detect_os)
arch=$(detect_arch)
arch=$(correct_rosetta_arch "$os" "$arch")
archive="jwtd-$os-$arch.tar.gz"

if [ -n "$version" ]; then
case "$version" in
v*) ;;
*) version="v$version" ;;
esac
base_url="https://github.com/$REPO/releases/download/$version"
info "Installing jwtd $version ($os/$arch)"
else
base_url="https://github.com/$REPO/releases/latest/download"
info "Installing the latest jwtd release ($os/$arch)"
fi

work_dir=$(mktemp -d 2>/dev/null || mktemp -d -t jwtd-install)
staged=""
trap 'rm -rf "$work_dir"; [ -z "$staged" ] || rm -f "$staged"' EXIT INT HUP TERM
cd "$work_dir"

download "$base_url/$archive" "$archive"
download "$base_url/checksums.txt" checksums.txt
verify_checksum "$archive"
verify_signature "$base_url"

tar -xzf "$archive" jwtd
[ -f jwtd ] || die "the release archive did not contain a jwtd binary"

mkdir -p "$install_dir" || die "could not create $install_dir"

# Copy into the target directory first, then rename within it. A cross-device
# mv would rewrite the destination in place, which fails with ETXTBSY when the
# running shell's jwtd is being upgraded; rename(2) inside one filesystem
# replaces the old binary atomically instead.
installed="$install_dir/jwtd"
staged="$install_dir/.jwtd.install.$$"
cp jwtd "$staged" || die "could not write to $install_dir (choose another directory with --dir)"
chmod 0755 "$staged"
mv -f "$staged" "$installed" || die "could not install into $install_dir (choose another directory with --dir)"
staged=""

info "Installed $("$installed" --version 2>/dev/null || printf 'jwtd\n') to $installed"
report_path_hint "$install_dir"
Loading
Loading