Compare commits

..

No commits in common. "main" and "codex/tui-unified-shell" have entirely different histories.

9 changed files with 10 additions and 551 deletions

View File

@ -1,71 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Formatting is platform independent, so check it once rather than twice.
- name: gofmt
if: matrix.os == 'ubuntu-latest'
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:" >&2
echo "$unformatted" >&2
exit 1
fi
- name: go vet
run: go vet ./...
- name: go test
run: go test ./... -count=1
cross-build:
name: cross-build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Mirrors the release targets, so a platform-specific break surfaces on
# the pull request rather than at tag time.
- name: build all release targets
run: |
set -euo pipefail
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
goos="${target%/*}"
goarch="${target#*/}"
echo "==> ${goos}/${goarch}"
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \
go build -trimpath -o /tmp/sshkeeper-ci-build .
done

View File

@ -1,94 +0,0 @@
name: Nightly
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
# Two pushes in quick succession must not race for the rolling tag. Let the
# newer commit win rather than publishing a nightly built from older code.
concurrency:
group: nightly
cancel-in-progress: true
jobs:
nightly:
name: publish nightly
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Cheaper than the full release-check, but still refuses to publish a
# broken build.
- name: test
run: |
go vet ./...
go test ./... -count=1
# Version discovery is pinned to v* tags (see build.sh), so the rolling
# nightly tag below cannot hijack this value.
- name: resolve version
id: version
run: echo "value=$(git describe --tags --match 'v*' --always)" >> "$GITHUB_OUTPUT"
- name: build artifacts
env:
VERSION: ${{ steps.version.outputs.value }}
run: ./release.sh "$VERSION"
# Move the rolling tag before touching the release: a GitHub release must
# point at a tag, and this one always tracks the tip of main.
- name: move nightly tag
run: |
set -euo pipefail
git tag -f nightly
git push -f origin nightly
# Replace rather than update: assets are immutable once uploaded, so the
# old release has to go before the new archives can take its name.
- name: replace nightly release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.version.outputs.value }}
run: |
set -euo pipefail
# Heredoc, not an inline string: the notes are markdown and must not
# inherit this file's YAML indentation.
cat > /tmp/nightly-notes.md <<EOF
Automated build from the tip of \`main\`, rebuilt on every push.
**This is not a stable release.** It is untagged, unannounced and may be
broken. The \`Latest\` badge stays on the newest \`v*\` release, which is
what you want for normal use.
| | |
|---|---|
| Version | \`${VERSION}\` |
| Commit | ${GITHUB_SHA} |
| Built | $(date -u '+%Y-%m-%d %H:%M UTC') |
Verify downloads against \`checksums.txt\`.
EOF
gh release delete nightly --yes || echo "no previous nightly release"
gh release create nightly \
--prerelease \
--title "sshkeeper nightly (${VERSION})" \
--notes-file /tmp/nightly-notes.md \
"dist/sshkeeper_${VERSION}_linux_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_linux_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
dist/checksums.txt

View File

@ -1,65 +0,0 @@
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
name: publish ${{ github.ref_name }}
runs-on: ubuntu-latest
steps:
# Full history and tags: release.sh derives SOURCE_DATE_EPOCH from the
# tagged commit, and version discovery needs the v* tags to be present.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Gate the release on the same checks used locally. A red suite must not
# be able to publish.
- name: release checks
run: make release-check
# Build through release.sh rather than reimplementing packaging here, so
# CI and a local ./release.sh produce byte-identical archives.
- name: build artifacts
env:
VERSION: ${{ github.ref_name }}
run: ./release.sh "$VERSION"
- name: publish
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ github.ref_name }}
run: |
set -euo pipefail
# A hand-written docs/releases/<tag>.md wins; otherwise fall back to
# GitHub's generated changelog.
notes="docs/releases/${VERSION}.md"
if [ -f "$notes" ]; then
echo "Using hand-written notes from $notes"
set -- --notes-file "$notes"
else
echo "No $notes, generating notes from commit history"
set -- --generate-notes
fi
gh release create "$VERSION" \
--title "sshkeeper $VERSION" \
--verify-tag \
"$@" \
"dist/sshkeeper_${VERSION}_linux_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_linux_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_amd64.tar.gz" \
"dist/sshkeeper_${VERSION}_darwin_arm64.tar.gz" \
"dist/sshkeeper_${VERSION}_windows_amd64.zip" \
dist/checksums.txt

View File

@ -4,10 +4,7 @@ set -euo pipefail
cd "$(dirname "$0")"
APP=sshkeeper
# --match 'v*' keeps the rolling `nightly` tag from hijacking the version: a
# plain `git describe --tags` picks whichever tag is nearest, so a nightly build
# would otherwise stamp binaries "nightly" instead of v<last release>-N-g<sha>.
VERSION=$(git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo "dev")
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS="-s -w -X main.version=${VERSION}"
echo "==> Building ${APP} ${VERSION}..."

View File

@ -199,7 +199,6 @@ func init() {
forwardAddCmd.Flags().String("name", "", "Forward name")
forwardAddCmd.Flags().String("description", "", "Forward description")
forwardAddCmd.Flags().String("local-addr", "127.0.0.1", "Listen address")
forwardAddCmd.Flags().Int("local-port", 0, "Listen port")
forwardAddCmd.MarkFlagRequired("local-port")
forwardAddCmd.Flags().String("remote-addr", "", "Target address")
forwardAddCmd.Flags().Int("remote-port", 0, "Target port")

View File

@ -7,22 +7,8 @@ import (
"github.com/mirivlad/sshkeeper/internal/db"
"github.com/mirivlad/sshkeeper/internal/model"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// restoreFlags returns every flag touched during the test back to its default.
// The cobra commands are package-level singletons, so parsing argv into one
// leaks state into whatever test runs next.
func restoreFlags(t *testing.T, cmd *cobra.Command) {
t.Helper()
t.Cleanup(func() {
cmd.Flags().Visit(func(f *pflag.Flag) {
_ = f.Value.Set(f.DefValue)
f.Changed = false
})
})
}
func TestForwardEditUpdatesEnabledFlag(t *testing.T) {
testDB, err := db.Open(t.TempDir())
if err != nil {
@ -124,70 +110,3 @@ func TestForwardAddStoresNameAndDescription(t *testing.T) {
t.Fatalf("unexpected forward metadata: %#v", forwards[0])
}
}
// TestForwardAddParsesItsOwnFlags drives the real forwardAddCmd flag set the
// way the CLI does, instead of handing RunE a command built by the test.
//
// Regression: RunE read --local-port and init() marked it required, but the
// flag was never registered on forwardAddCmd. Cobra silently ignores
// MarkFlagRequired for an unknown flag and GetInt returns 0 for one, so every
// real invocation died on "invalid local port 0" while the sibling tests --
// which registered the flag on a throwaway command themselves -- kept passing.
func TestForwardAddParsesItsOwnFlags(t *testing.T) {
testDB, err := db.Open(t.TempDir())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer testDB.Close()
previousDB := appDB
appDB = testDB
t.Cleanup(func() { appDB = previousDB })
server := &model.Server{Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
if err := appDB.CreateServer(server); err != nil {
t.Fatalf("create server: %v", err)
}
restoreFlags(t, forwardAddCmd)
if err := forwardAddCmd.Flags().Parse([]string{
"--name", "Local PostgreSQL",
"--type", "local",
"--local-port", "15432",
"--remote-addr", "db01.internal.example.com",
"--remote-port", "5432",
}); err != nil {
t.Fatalf("parse forward add flags: %v", err)
}
if err := forwardAddCmd.RunE(forwardAddCmd, []string{"web"}); err != nil {
t.Fatalf("add forward: %v", err)
}
forwards, err := appDB.GetForwards(server.ID)
if err != nil {
t.Fatalf("get forwards: %v", err)
}
if len(forwards) != 1 {
t.Fatalf("expected one forward, got %d", len(forwards))
}
got := forwards[0]
if got.LocalPort != 15432 {
t.Fatalf("local port not carried through: got %d, want 15432", got.LocalPort)
}
if got.Type != model.ForwardLocal || got.RemoteAddr != "db01.internal.example.com" || got.RemotePort != 5432 {
t.Fatalf("unexpected forward: %#v", got)
}
}
// TestForwardAddRequiresLocalPort pins the flag's registration and its required
// annotation, which is what MarkFlagRequired silently failed to attach.
func TestForwardAddRequiresLocalPort(t *testing.T) {
flag := forwardAddCmd.Flags().Lookup("local-port")
if flag == nil {
t.Fatal("forward add does not register --local-port")
}
if _, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]; !ok {
t.Fatal("--local-port is registered but not marked required")
}
}

View File

@ -1,41 +1,10 @@
# Release Packaging
Releases are published by GitHub Actions. Pushing a `v*` tag is the whole
release procedure; the rest of this document describes what that automation
runs, and how to reproduce it by hand when needed.
## Workflows
| Workflow | Trigger | Result |
|----------|---------|--------|
| `ci.yml` | push to `main`, every pull request | `gofmt`, `go vet`, `go test` on Linux and macOS, plus a cross-build of all five release targets |
| `release.yml` | push of a `v*` tag | runs `make release-check`, then `release.sh`, then publishes the GitHub release |
| `nightly.yml` | push to `main` | rebuilds the tip of `main` and replaces the `nightly` prerelease |
`release.yml` builds through `release.sh` rather than reimplementing packaging,
so CI and a local run stay in step. See [Reproducibility](#reproducibility) for
what that guarantees.
### Release notes
`release.yml` looks for `docs/releases/<tag>.md`. If that file exists it becomes
the release body; otherwise GitHub generates notes from commit history. Write
the file before pushing the tag when a release deserves a real description.
### The nightly prerelease
`nightly.yml` force-moves a rolling `nightly` tag to the tip of `main` and
republishes a prerelease from it. It is marked prerelease deliberately, so
GitHub's `Latest` badge stays on the newest `v*` release.
Because that tag moves, version discovery in `build.sh` and `release.sh` is
pinned with `--match 'v*'`. Without the filter `git describe` would select
`nightly` and stamp binaries with it instead of `v<last release>-<n>-g<sha>`.
Keep the filter if you touch those scripts.
This document describes the manual release flow for sshkeeper.
## Create a Tag
Use a semantic version tag. Pushing it is what triggers `release.yml`:
Use a semantic version tag:
```bash
git status --short
@ -43,14 +12,8 @@ git tag -a v0.2.0 -m "sshkeeper v0.2.0"
git push origin v0.2.0
```
The remaining sections describe the manual equivalent, which is still the way
to test packaging locally or to recover if Actions is unavailable.
The release script uses `git describe --tags --match 'v*' --always --dirty` by
default. The `--match 'v*'` filter matters: nightly builds move a `nightly` tag
across `main`, and without the filter `git describe` would pick that tag and
stamp binaries `nightly` instead of `v<last release>-<n>-g<sha>`. You can also
pass the version explicitly:
The release script uses `git describe --tags --always --dirty` by default. You
can also pass the version explicitly:
```bash
./release.sh v0.2.0
@ -124,40 +87,9 @@ sha256sum -c checksums.txt
Expected result: every archive reports `OK`.
## Reproducibility
Rebuilding the same commit with the same Go version reproduces the **binaries**
byte for byte. `release.sh` pins everything that would otherwise vary:
- `-trimpath` and `CGO_ENABLED=0` keep build paths and the host toolchain out
of the binary;
- `SOURCE_DATE_EPOCH` (the commit timestamp) sets every archive mtime;
- `tar --sort=name --owner=0 --group=0 --numeric-owner` fixes entry order and
ownership, and `gzip -n` drops the compression timestamp;
- `normalize_package` forces 755 on directories and the program and 644 on
everything else, so the builder's umask cannot leak into the archive.
- the Windows zip is packaged under `LC_ALL=C` and `TZ=UTC`, because `sort`
orders entries by locale and zip stores DOS local time with no zone.
With those in place the archives themselves reproduce across hosts: a build on
`ubuntu-latest` (umask 022, C locale, UTC) and one on a workstation (umask 002,
`ru_RU.UTF-8`, UTC+08) produce identical checksums for all five archives.
The strongest check is still the binary, since it does not depend on the host's
`tar` and `gzip` at all:
```bash
tar -xzf sshkeeper_<version>_linux_amd64.tar.gz
sha256sum sshkeeper_<version>_linux_amd64/sshkeeper
```
Comparing whole-archive hashes works too, as long as both builds used the same
Go version.
## Publish in GitHub Release
`release.yml` does this automatically on tag push. To publish by hand, upload:
Upload these files to the release:
- all five platform archives
- `checksums.txt`

View File

@ -1,140 +0,0 @@
Release automation and reproducible packaging. sshkeeper itself behaves exactly
as in v0.3.1 — no functional changes to the TUI or the CLI.
This is also the first release published by GitHub Actions rather than by hand.
## In this release
**Archives are now reproducible.** Rebuilding a tag on a different machine used
to produce different checksums even when every packaged file was byte-identical,
because three host properties leaked into the archives:
| Leak | Effect |
|------|--------|
| File modes followed the builder's umask | umask 002 packaged `664`/`775`, umask 022 packaged `644`/`755` |
| `sort` orders entries by locale | a `ru_RU.UTF-8` host emitted `docs/` before `LICENSE`, a C locale the reverse |
| zip stores DOS local time with no zone | the same commit embedded `19:06` at UTC+08 and `11:06` at UTC |
All three are pinned now. A build on `ubuntu-latest` and one on a workstation
with a different umask, locale and timezone produce identical checksums for all
five archives. The binaries were always reproducible; only the packaging varied.
**CI.** The repository previously had no automation. It now runs `gofmt`,
`go vet` and `go test` on Linux *and* macOS for every push and pull request,
plus a cross-build of all five release targets. macOS is a stated release
target that until now was only ever cross-compiled, never tested.
**Releases are automated.** Pushing a `v*` tag runs the release checks, builds
through the same `release.sh` used locally, and publishes. Nightly builds from
`main` are published as a separate `nightly` prerelease, so the `Latest` badge
always points at a real release.
---
# Everything since v0.2.0
## Breaking change: full help moved off F1
**`Ctrl+H` opens full help. `F1` no longer has any binding.** `?` still opens
contextual quick help outside text editors. This landed in v0.3.0.
`Ctrl+H` is the BS control character (0x08). xterm and most modern emulators
send DEL (0x7F) for Backspace, so help and text editing do not collide. A
terminal configured to send BS for Backspace cannot tell them apart; switch it
to DEL (in xterm, `backarrowKey: false`).
Nothing else requires action when upgrading. Vaults, server profiles and stored
port forwards are unchanged, and no migration runs.
## The TUI was rebuilt around one shell (v0.3.0)
In v0.2.0 only the server dashboard had a real layout. Other screens rendered
free-form strings or the default Bubbles list frame, so they had no shared
width budget, no borders, and footers that floated wherever the content ended.
Every full-screen state now shares one contract: a header with breadcrumb and
truthful vault status, a separator, framed content panels, and a contextual
footer anchored to the last terminal row.
- Actions, search, tag input, confirmations and both help screens render inside
the shell.
- The port forward manager and editor use framed, width-budgeted layouts. Column
widths derive from the panel's inner width, so no row consumes the terminal's
last column.
- Tag, command template, template picker/mode/results and tunnel managers use
framed lists with a `>` selection marker, so selection never depends on colour
alone.
- Server and template editors use a framed form panel with the title moved into
the breadcrumb. Required markers, validation and dirty-state confirmation are
unchanged.
**Responsive layouts.** The supported floor is `60x16`. Wide (100+ columns)
shows two panels, medium (7099) stacks them, narrow (6069) keeps a single
compact panel. Below the floor only the minimum-size message renders. Long
ASCII, Cyrillic, CJK, combining and emoji content truncates by display cells
rather than byte count.
**Safety.** Destructive actions confirm with Cancel selected first and name the
exact target and its consequence. Status and help context stay truthful to
actual vault and connection state. Form validation prevents silent loss of
edits.
## `forward add` was completely broken (fixed in v0.3.1)
`sshkeeper forward add` could never succeed in v0.2.0 or v0.3.0. The command
read `--local-port` and marked it required, but the flag was never registered,
so cobra rejected it as unknown and the value fell back to 0:
```
$ sshkeeper forward add web --type local --local-port 15432 \
--remote-addr 127.0.0.1 --remote-port 5432
unknown flag: --local-port
```
No combination of arguments worked. Both documented forms work now:
```
$ sshkeeper forward add web --name "Local PostgreSQL" --type local \
--local-port 15432 --remote-addr db01.internal.example.com --remote-port 5432
✓ Forward added [1]
$ sshkeeper forward add web --name "SOCKS proxy" --type dynamic --local-port 1080
✓ Forward added [2]
```
Omitting the flag now reports `required flag(s) "local-port" not set` instead of
a misleading port-range error. The TUI (`Ctrl+W`) was never affected.
The command's tests had constructed their own throwaway cobra command and
registered the flags by hand, so the real command's registration was never
exercised and the suite passed against a broken command. Coverage now parses
argv into the actual command. An audit of every other command found no further
flag that is read but never registered.
## Also fixed since v0.2.0
- Port forward fields accept digits correctly (`10bcc07`).
- Platform and repository status are stated accurately in the docs: Linux and
macOS are primary release targets, Windows is experimental.
## Release-by-release
| Version | Contents |
|---------|----------|
| v0.3.0 | Unified TUI shell, responsive layouts, `F1``Ctrl+H` |
| v0.3.1 | `forward add` fix |
| v0.3.2 | Reproducible packaging, CI, automated releases |
## Install
```bash
tar -xzf sshkeeper_v0.3.2_linux_amd64.tar.gz
sudo install -m 0755 sshkeeper_v0.3.2_linux_amd64/sshkeeper /usr/local/bin/sshkeeper
```
Verify downloads against `checksums.txt`. Linux and macOS are the primary
release targets; the Windows build is experimental and needs OpenSSH Client
available as `ssh.exe` on `PATH`.
To verify a build yourself, check out the tag and run `./release.sh v0.3.2`
the checksums should match this release exactly, given the same Go version.

View File

@ -4,8 +4,7 @@ set -euo pipefail
cd "$(dirname "$0")"
APP=sshkeeper
# --match 'v*' ignores the rolling `nightly` tag; see build.sh for the details.
VERSION=${VERSION:-${1:-$(git describe --tags --match 'v*' --always --dirty 2>/dev/null || echo "dev")}}
VERSION=${VERSION:-${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}}
LDFLAGS="-s -w -X main.version=${VERSION}"
DIST_DIR="dist"
SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH:-$(git log -1 --format=%ct 2>/dev/null || date +%s)}
@ -28,16 +27,6 @@ package_docs() {
normalize_package() {
local package_dir="$1"
local binary="$2"
# Permissions must not depend on the builder's umask. Without this, a host
# with umask 002 packages 664/775 while one with umask 022 packages
# 644/755, and the archives differ even though every file inside is
# byte-identical.
find "${package_dir}" -type d -exec chmod 755 {} +
find "${package_dir}" -type f -exec chmod 644 {} +
chmod 755 "${package_dir}/${binary}"
find "${package_dir}" -exec touch -h -d "@${SOURCE_DATE_EPOCH}" {} +
}
@ -53,7 +42,7 @@ build_tarball() {
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}" .
package_docs "${package_dir}"
normalize_package "${package_dir}" "${APP}"
normalize_package "${package_dir}"
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH}" -cf - -C "${DIST_DIR}" "$(basename "${package_dir}")" | gzip -n > "${archive}"
rm -rf "${package_dir}"
}
@ -70,15 +59,8 @@ build_zip() {
GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 go build -trimpath -ldflags "${LDFLAGS}" -o "${package_dir}/${APP}.exe" .
package_docs "${package_dir}"
normalize_package "${package_dir}" "${APP}.exe"
# LC_ALL=C: `sort` is locale-sensitive, and it decides the entry order here.
# A ru_RU.UTF-8 host orders docs/ before LICENSE where a C locale does the
# reverse, producing a different archive from identical files.
#
# TZ=UTC: zip records DOS local time with no zone, so the same build in
# +08:00 and in UTC would embed different timestamps. tar needs neither —
# it sorts internally and stores Unix epochs.
(cd "${DIST_DIR}" && export LC_ALL=C TZ=UTC && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
normalize_package "${package_dir}"
(cd "${DIST_DIR}" && find "$(basename "${package_dir}")" -print | sort | zip -X -q "$(basename "${archive}")" -@)
rm -rf "${package_dir}"
}