Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cba76e22cf | ||
|
|
990711e7c7 | ||
|
|
abb6e4e97f | ||
|
|
050304ff51 | ||
|
|
0812c7a772 | ||
|
|
f576463c04 | ||
|
|
4ed2cb28f4 | ||
|
|
9b100de2d9 |
No files matched your search
@@ -0,0 +1,202 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Go checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: go test
|
||||
run: go test -race -count=1 ./...
|
||||
|
||||
- name: go build
|
||||
run: go build ./...
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.artifact }}
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- artifact: limen-linux-amd64
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
- artifact: limen-linux-arm64
|
||||
goos: linux
|
||||
goarch: arm64
|
||||
- artifact: limen-macos-amd64
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
- artifact: limen-macos-arm64
|
||||
goos: darwin
|
||||
goarch: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o "dist/${{ matrix.artifact }}" .
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: dist/${{ matrix.artifact }}
|
||||
|
||||
release:
|
||||
name: Publish GitHub release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f -exec cp {} release/ \;
|
||||
cd release
|
||||
shasum -a 256 ./limen-* > checksums.txt
|
||||
ls -la
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/*
|
||||
generate_release_notes: true
|
||||
|
||||
update-homebrew:
|
||||
name: Update Homebrew tap
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN_SET: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }}
|
||||
steps:
|
||||
- name: Verify Homebrew tap token
|
||||
run: |
|
||||
if [ "${HOMEBREW_TAP_TOKEN_SET}" != "true" ]; then
|
||||
echo "::error::HOMEBREW_TAP_TOKEN repo secret is required to update KofTwentyTwo/homebrew-tap."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Checkout Homebrew tap
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: KofTwentyTwo/homebrew-tap
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
path: homebrew-tap
|
||||
|
||||
- name: Update formula
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG_NAME#v}"
|
||||
SHA_MACOS_ARM64=$(shasum -a 256 artifacts/limen-macos-arm64/limen-macos-arm64 | cut -d' ' -f1)
|
||||
SHA_MACOS_AMD64=$(shasum -a 256 artifacts/limen-macos-amd64/limen-macos-amd64 | cut -d' ' -f1)
|
||||
SHA_LINUX_ARM64=$(shasum -a 256 artifacts/limen-linux-arm64/limen-linux-arm64 | cut -d' ' -f1)
|
||||
SHA_LINUX_AMD64=$(shasum -a 256 artifacts/limen-linux-amd64/limen-linux-amd64 | cut -d' ' -f1)
|
||||
|
||||
FORMULA="homebrew-tap/Formula/limen.rb"
|
||||
mkdir -p "$(dirname "$FORMULA")"
|
||||
cat > "$FORMULA" <<RUBY
|
||||
class Limen < Formula
|
||||
desc "Terminal launcher TUI for tmux and SSH"
|
||||
homepage "https://github.com/KofTwentyTwo/limen"
|
||||
version "${VERSION}"
|
||||
license "GPL-3.0-only"
|
||||
|
||||
depends_on "tmux"
|
||||
|
||||
host_cpu = RbConfig::CONFIG.fetch("host_cpu", "")
|
||||
macos_arm = OS.mac? && (Hardware::CPU.arm? || host_cpu == "arm64" || RUBY_PLATFORM.start_with?("arm64-darwin"))
|
||||
linux_arm = OS.linux? && (Hardware::CPU.arm? || host_cpu == "aarch64" || host_cpu == "arm64")
|
||||
|
||||
if macos_arm
|
||||
url "https://github.com/KofTwentyTwo/limen/releases/download/v#{version}/limen-macos-arm64"
|
||||
sha256 "${SHA_MACOS_ARM64}"
|
||||
elsif OS.mac?
|
||||
url "https://github.com/KofTwentyTwo/limen/releases/download/v#{version}/limen-macos-amd64"
|
||||
sha256 "${SHA_MACOS_AMD64}"
|
||||
elsif linux_arm
|
||||
url "https://github.com/KofTwentyTwo/limen/releases/download/v#{version}/limen-linux-arm64"
|
||||
sha256 "${SHA_LINUX_ARM64}"
|
||||
else
|
||||
url "https://github.com/KofTwentyTwo/limen/releases/download/v#{version}/limen-linux-amd64"
|
||||
sha256 "${SHA_LINUX_AMD64}"
|
||||
end
|
||||
|
||||
def install
|
||||
binary = Dir["limen-*"].first
|
||||
chmod 0755, binary
|
||||
bin.install binary => "limen"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/limen --version")
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
cd homebrew-tap
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add Formula/limen.rb
|
||||
if git status -s | grep -q "Formula/limen.rb"; then
|
||||
git commit -m "Update limen to ${GITHUB_REF_NAME}"
|
||||
git push
|
||||
else
|
||||
echo "Formula already current."
|
||||
fi
|
||||
@@ -8,29 +8,30 @@ Project memory for AI assistants working in this repo. Read this first.
|
||||
|
||||
## Current status
|
||||
|
||||
**Design phase.** No Go source yet. The repository contains:
|
||||
**Early implementation.** The repository contains:
|
||||
|
||||
- `docs/DESIGN.md` — full design and requirements document.
|
||||
- Standard repo scaffolding (README, LICENSE, CONTRIBUTING, etc.).
|
||||
- `.github/` — CI workflow placeholder, issue templates, PR template.
|
||||
- `internal/` packages for config, state, probing, SSH, tmux parsing, exec planning, formatting, and UI.
|
||||
- `main.go`, `go.mod`, and `flake.nix` for the working binary and Nix package.
|
||||
- `.github/` — CI and release workflows, issue templates, PR template.
|
||||
|
||||
Implementation work has not started. The next milestone is to:
|
||||
The next implementation milestones are to:
|
||||
|
||||
1. Add `go.mod`, `main.go`, and a minimal `internal/` package skeleton matching §6.3 of `DESIGN.md`.
|
||||
2. Wire up the Nix flake (`flake.nix`) so `nix run` produces a binary.
|
||||
3. Implement stages incrementally — config parsing first, then probing, then UI.
|
||||
1. Harden the terminal UI with real-world tmux and SSH smoke testing.
|
||||
2. Keep release automation green for tagged releases and downstream package managers.
|
||||
3. Continue from `docs/DESIGN.md`; if behavior changes, update the design doc in the same PR.
|
||||
|
||||
## Key conventions
|
||||
|
||||
| Topic | Convention |
|
||||
| ------------------ | ------------------------------------------------------------------------- |
|
||||
| Language | Go 1.21+, no CGO |
|
||||
| Language | Go 1.24+, no CGO |
|
||||
| TUI framework | `bubbletea` + `lipgloss` |
|
||||
| Default branch | `develop` (PRs target this); `main` reserved for tagged releases |
|
||||
| Commit style | Conventional Commits (`feat:`, `fix:`, `docs:` …) — see `CONTRIBUTING.md` |
|
||||
| License | GPL-3.0 |
|
||||
| Build | Nix flake (`packages.default`) + plain `go build` for dev |
|
||||
| Release | goreleaser via GitHub Actions on tag push |
|
||||
| Release | GitHub Actions tag workflow, direct Go cross-builds, GitHub Releases, Homebrew tap |
|
||||
| Test framework | Standard `testing` package; `teatest` for TUI integration |
|
||||
| Go modules | One `internal/` per concern (config, state, probe, ssh, ui, exec, format) |
|
||||
|
||||
@@ -47,8 +48,8 @@ Implementation work has not started. The next milestone is to:
|
||||
Targets in order of priority:
|
||||
|
||||
1. **Nix flake** (`nix run github:KofTwentyTwo/limen`) — primary distribution channel.
|
||||
2. **Homebrew tap** at `KofTwentyTwo/homebrew-tap` (not yet created) — `brew install KofTwentyTwo/tap/limen`.
|
||||
3. **GitHub Releases** with goreleaser-built binaries for darwin/linux × amd64/arm64.
|
||||
2. **Homebrew tap** at `KofTwentyTwo/homebrew-tap` — `brew install KofTwentyTwo/tap/limen`.
|
||||
3. **GitHub Releases** with direct Go-built binaries for macOS/Linux × amd64/arm64.
|
||||
|
||||
Eventual goal: submit to `homebrew-core` once the tool stabilizes.
|
||||
|
||||
|
||||
+7
-1
@@ -39,6 +39,7 @@ Examples:
|
||||
- Go: standard `gofmt`. `golangci-lint` runs in CI (will be added once Go code lands).
|
||||
- Markdown: 100-column soft wrap; tables and code blocks may exceed.
|
||||
- Comments: explain *why*, not *what*. Code shows what.
|
||||
- Local tool dependencies are listed in [`docs/DEV-TOOLS.md`](docs/DEV-TOOLS.md).
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -60,7 +61,12 @@ Examples:
|
||||
|
||||
## Releases
|
||||
|
||||
Tags trigger goreleaser via GitHub Actions, which cross-compiles for darwin/linux × amd64/arm64 and publishes binaries to the GitHub Releases page. See [`docs/DESIGN.md` §10.4](docs/DESIGN.md).
|
||||
Tags trigger GitHub Actions, which runs the Go checks, builds macOS/Linux binaries for amd64/arm64, publishes GitHub Release assets, and updates the `KofTwentyTwo/homebrew-tap` formula. See [`docs/DESIGN.md` §10.4](docs/DESIGN.md).
|
||||
|
||||
Release prerequisites:
|
||||
- `KofTwentyTwo/homebrew-tap` must exist.
|
||||
- The `limen` repository must have a `HOMEBREW_TAP_TOKEN` Actions secret with contents read/write access to `KofTwentyTwo/homebrew-tap`.
|
||||
- The release tag must use the `vMAJOR.MINOR.PATCH` form, for example `v0.1.0`.
|
||||
|
||||
## Code of conduct
|
||||
|
||||
|
||||
@@ -2,51 +2,83 @@
|
||||
|
||||
> *Latin: "threshold". The doorsill one crosses when entering.*
|
||||
|
||||
A terminal launcher TUI for tmux and SSH. The first thing you see when you open a new terminal: pick a host, pick a session, get to work.
|
||||
A two-stage terminal launcher TUI for tmux and SSH. The first thing you see when opening a new terminal: pick a host, pick a session, get to work.
|
||||
|
||||
**Status:** Design phase. No working binary yet — see [`docs/DESIGN.md`](docs/DESIGN.md) for the spec we're building toward.
|
||||
**Status**: Active implementation. The TUI launches host and session pickers. See [`docs/DESIGN.md`](docs/DESIGN.md) for the canonical architecture specification.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
## What It Does
|
||||
|
||||
Replaces tmux's name-prompt-on-launch with a two-stage TUI:
|
||||
`limen` replaces tmux's default name-prompt-on-launch with an interactive, keyboard-driven TUI:
|
||||
|
||||
1. **Host picker** — choose `localhost` or one of your declared SSH targets. Live status dots show which hosts are reachable and how many sessions each has.
|
||||
2. **Session picker** — pick an existing tmux session to attach to, or create a new one with a name. Works the same whether the host is local or remote.
|
||||
1. **Host Picker**: Choose `localhost` or one of your declared SSH targets. Live status indicators show reachability and active session counts.
|
||||
2. **Session Picker**: Pick an existing tmux session to attach to, or spawn a new named session. Behaves consistently across local and remote targets.
|
||||
|
||||
Press `Esc` at any point and `limen` drops you into a fresh unnamed tmux session at the appropriate host. No deliberation tax.
|
||||
Pressing `Esc` at any stage drops you into a fresh unnamed tmux session at the selected host with zero deliberation tax.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Tech Stack & Architecture
|
||||
|
||||
- **Language & Runtime**: Go 1.22+
|
||||
- **TUI Engine**: [Charm Bubble Tea](https://github.com/charmbracelet/bubbletea) (Elm Architecture) & [Lip Gloss](https://github.com/charmbracelet/lipgloss) styling
|
||||
- **Orchestration**: `tmux` IPC & native OpenSSH background client stream
|
||||
- **Configuration**: Single JSON fleet declaration at `~/.config/limen/hosts.json`
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Homebrew (macOS / Linux)
|
||||
```bash
|
||||
brew install KofTwentyTwo/tap/limen
|
||||
```
|
||||
╭─ limen ─────────────────────────╮ ╭─ DETAILS ──────────────────────────╮
|
||||
│ │ │ │
|
||||
│ ● localhost (renova) 3 sess. │ │ prod │
|
||||
│ ● prod 2 sess. │ ◀ │ ──────────────────────────────────│
|
||||
│ ● dev 1 sess. │ │ deploy@prod-server-01.example.com │
|
||||
│ ○ builder unreach. │ │ │
|
||||
│ ● sandbox — │ │ Status: ● online │
|
||||
│ │ │ Sessions: 2 (api, ops) │
|
||||
│ │ │ Last attached: 2 hours ago │
|
||||
│ │ │ │
|
||||
│ │ │ Production application servers. │
|
||||
╰─────────────────────────────────╯ ╰────────────────────────────────────╯
|
||||
|
||||
↑↓ navigate · enter connect · / search · ? help · esc skip
|
||||
### Nix Flake Run
|
||||
```bash
|
||||
nix run github:KofTwentyTwo/limen
|
||||
```
|
||||
|
||||
### Binary Releases
|
||||
Pre-built binaries are available on the [Releases](https://github.com/KofTwentyTwo/limen/releases) page:
|
||||
|
||||
| Platform | Architecture | Binary Name |
|
||||
| :--- | :--- | :--- |
|
||||
| **Linux** | x86_64 | `limen-linux-amd64` |
|
||||
| **Linux** | ARM64 | `limen-linux-arm64` |
|
||||
| **macOS** | Intel | `limen-macos-amd64` |
|
||||
| **macOS** | Apple Silicon | `limen-macos-arm64` |
|
||||
|
||||
---
|
||||
|
||||
## Local Build & Verification
|
||||
|
||||
### Build from Source
|
||||
```bash
|
||||
# Build binary
|
||||
go build -ldflags="-s -w" -o bin/limen ./cmd/limen
|
||||
|
||||
# Or build via Nix
|
||||
nix build
|
||||
```
|
||||
|
||||
### Quality Gates & Verification
|
||||
```bash
|
||||
# 1. Run Unit & Integration Tests
|
||||
go test -v ./...
|
||||
|
||||
# 2. Run Go Linter & Vet
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
## Fleet Configuration
|
||||
|
||||
Coming soon. The design targets these distribution channels:
|
||||
|
||||
- **Nix flake** — `nix run github:KofTwentyTwo/limen`
|
||||
- **Homebrew tap** — `brew install KofTwentyTwo/tap/limen` (planned)
|
||||
- **Binary releases** — GitHub Releases page (planned)
|
||||
|
||||
## Configuration
|
||||
|
||||
A single JSON file at `~/.config/limen/hosts.json` declares your fleet. Example:
|
||||
Declare target hosts in `~/.config/limen/hosts.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -58,30 +90,10 @@ A single JSON file at `~/.config/limen/hosts.json` declares your fleet. Example:
|
||||
}
|
||||
```
|
||||
|
||||
`localhost` is implicit and always available — never declare it in this file.
|
||||
|
||||
See [`docs/DESIGN.md` §7.1](docs/DESIGN.md) for the full schema.
|
||||
`localhost` is implicit and managed automatically by `limen`. See [`docs/DESIGN.md`](docs/DESIGN.md) for full schema specifications.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
The full design and requirements document lives at [`docs/DESIGN.md`](docs/DESIGN.md). That document is the source of truth for what `limen` is and should be; implementation work proceeds from there.
|
||||
|
||||
## Status & roadmap
|
||||
|
||||
This repository currently contains only the design document and standard repo scaffolding. Implementation will land on the `develop` branch.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
[GPL-3.0](LICENSE).
|
||||
|
||||
## See also
|
||||
|
||||
- [tmux](https://github.com/tmux/tmux/wiki) — the multiplexer `limen` orchestrates
|
||||
- [bubbletea](https://github.com/charmbracelet/bubbletea) — the TUI framework `limen` is built on
|
||||
- [WezTerm](https://wezfurlong.org/wezterm/) — the terminal `limen` is designed to live inside
|
||||
+12
-11
@@ -126,8 +126,8 @@ The first screen the user sees. Split-pane layout. Host list on the left, detail
|
||||
- The header line above the boxes says `limen` (left) and `DETAILS` (right), styled with the accent color.
|
||||
- Status dots: filled green circle (`●`) for online, hollow circle (`○`) for unreachable, dash (`—`) for "no sessions yet" indication when applicable.
|
||||
- Session count column right-aligned.
|
||||
- localhost is always the first entry. Its display name is `localhost (<short-hostname>)` where short-hostname comes from `hostname -s`. This makes it immediately clear which physical machine the user is sitting at.
|
||||
- Hosts are listed in the order declared in the config file. No automatic reordering. (Sorting by "most recently attached" is a future feature, called out as such.)
|
||||
- localhost's display name is `localhost (<short-hostname>)` where short-hostname comes from `hostname -s`. This makes it immediately clear which physical machine the user is sitting at.
|
||||
- Hosts are listed A-Z by display name, case-insensitive. The implicit localhost entry participates in this ordering.
|
||||
- A footer line shows the keyboard cheat sheet, dimmed.
|
||||
|
||||
**Details pane content (for the highlighted host):**
|
||||
@@ -149,6 +149,8 @@ The first screen the user sees. Split-pane layout. Host list on the left, detail
|
||||
| `End` / `G` | Jump to last entry |
|
||||
| `Enter` | Accept; advance to stage 2 with this host as target |
|
||||
| `/` | Open search/filter mode (filter the list as you type) |
|
||||
| Text input | Start search/filter mode with the typed character |
|
||||
| `Tab` | Complete the filter to the highlighted host name |
|
||||
| `Esc` | If in search mode, cancel filter. Otherwise, escape semantics (see §5.5) |
|
||||
| `?` | Toggle help overlay |
|
||||
| `Ctrl+C` / `q` | Quit `limen` entirely without exec'ing anything. Exit code 130 / 0 respectively |
|
||||
@@ -177,7 +179,7 @@ Same split-pane layout as stage 1, applied to sessions on the chosen host. The u
|
||||
**Layout requirements:**
|
||||
|
||||
- The left-pane header shows the host name (e.g. `prod`), not the word "limen", so the user knows where they are.
|
||||
- `+ New session` is always the first entry, followed by a horizontal separator (`─` rule), followed by existing sessions in tmux's default ordering (most recently created first, matching `tmux ls`).
|
||||
- `+ New session` is always the first entry, followed by a horizontal separator (`─` rule), followed by existing sessions sorted A-Z by name, case-insensitive.
|
||||
- `●` marks the currently-attached session if any (i.e., the one a tmux client is currently inside). On localhost, there can be one. On remotes accessed via `ssh ... tmux ls`, attached-state comes from `#{session_attached}`.
|
||||
- `★` next to the window count marks the currently-attached session (redundant with the dot for users who scan the right column).
|
||||
|
||||
@@ -212,7 +214,7 @@ Same as stage 1, with the addition that when the highlighted entry is `+ New ses
|
||||
|
||||
### 5.4 Search / filter mode
|
||||
|
||||
In either stage, `/` opens an inline filter. The list narrows to entries matching the typed substring (case-insensitive, fuzzy match optional but not required for v1 — simple substring is fine). Esc cancels filter. Enter selects the top match.
|
||||
In either picker stage, `/` opens an inline filter. Typing a letter while not already filtering also opens the inline filter and seeds it with that character, so the user can type directly toward the target host or session without first pressing `/`. The list narrows to entries whose visible host or session name starts with the typed prefix, case-insensitive. Tab completes the filter to the highlighted host or session name. Arrow keys continue to navigate the filtered matches. Esc cancels filter. Enter selects the highlighted match.
|
||||
|
||||
While filter is active, the cheat-sheet footer changes to indicate `esc cancel filter`.
|
||||
|
||||
@@ -614,7 +616,7 @@ limen/
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ ├── ci.yml # go test + go vet on push/PR
|
||||
│ └── release.yml # tag → goreleaser → binaries + checksums
|
||||
│ └── release.yml # tag -> Go cross-builds -> releases + Homebrew tap
|
||||
├── docs/
|
||||
│ ├── DESIGN.md # This document (or a slightly edited copy)
|
||||
│ └── README.md # User-facing docs
|
||||
@@ -624,7 +626,6 @@ limen/
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── main.go
|
||||
├── .goreleaser.yaml # Cross-compile darwin + linux, amd64 + arm64
|
||||
├── LICENSE # GPL-3.0 (matches KofTwentyTwo OSS standard)
|
||||
└── README.md # Repo top-level readme
|
||||
```
|
||||
@@ -677,7 +678,7 @@ home.packages = [ inputs.limen.packages.${system}.default ];
|
||||
|
||||
Path A — **personal tap (preferred for initial release):**
|
||||
|
||||
The user creates `github.com/<owner>/homebrew-tap` with a `Formula/limen.rb` that fetches the GitHub release tarball and `go build`s, OR (better) fetches the pre-built binary published by goreleaser. Users install via `brew install <owner>/tap/limen`.
|
||||
The user creates `github.com/<owner>/homebrew-tap` with a `Formula/limen.rb` that fetches the pre-built binary published to GitHub Releases. Users install via `brew install <owner>/tap/limen`.
|
||||
|
||||
Path B — **homebrew-core (later):**
|
||||
|
||||
@@ -698,10 +699,10 @@ git push origin v0.1.0
|
||||
|
||||
GitHub Actions runs `release.yml`:
|
||||
1. Checks out at the tag.
|
||||
2. Runs `goreleaser release --clean`.
|
||||
3. goreleaser cross-compiles for `darwin/amd64`, `darwin/arm64`, `linux/amd64`, `linux/arm64`.
|
||||
4. Uploads release artifacts (tarballs + `checksums.txt`) to the GitHub release page.
|
||||
5. (Optional) Updates the homebrew tap formula via a goreleaser hook.
|
||||
2. Runs `go vet`, `go test -race -count=1 ./...`, and `go build ./...`.
|
||||
3. Cross-compiles with CGO disabled for `darwin/amd64`, `darwin/arm64`, `linux/amd64`, `linux/arm64`.
|
||||
4. Uploads release artifacts and `checksums.txt` to the GitHub release page.
|
||||
5. Updates the `KofTwentyTwo/homebrew-tap` formula with release URLs and SHA256 hashes using `HOMEBREW_TAP_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Development Tools
|
||||
|
||||
This repo is intentionally light on local tooling. The following packages cover
|
||||
normal development, release verification, and runtime smoke testing.
|
||||
|
||||
## Home Manager Package Set
|
||||
|
||||
```nix
|
||||
home.packages = with pkgs; [
|
||||
go_1_24
|
||||
gopls
|
||||
gotools
|
||||
|
||||
git
|
||||
gh
|
||||
ripgrep
|
||||
|
||||
nix
|
||||
actionlint
|
||||
shellcheck
|
||||
|
||||
ruby
|
||||
tmux
|
||||
openssh
|
||||
];
|
||||
```
|
||||
|
||||
## Why Each Tool Is Here
|
||||
|
||||
| Tool | Use |
|
||||
|------|-----|
|
||||
| `go_1_24` | Builds and tests the Go module declared in `go.mod`. |
|
||||
| `gopls` | Go language-server support for editors. |
|
||||
| `gotools` | Standard Go helper tools used by editors and local workflows. |
|
||||
| `git` | Source control, branches, tags, and tap repo updates. |
|
||||
| `gh` | GitHub issue, PR, repository, and release workflows. |
|
||||
| `ripgrep` | Fast repo search while developing and reviewing. |
|
||||
| `nix` | Flake checks, package builds, and downstream Nix integration testing. |
|
||||
| `actionlint` | Static validation for GitHub Actions workflows. |
|
||||
| `shellcheck` | Shell validation used directly and by `actionlint`. |
|
||||
| `ruby` | Local syntax checks for the Homebrew formula. |
|
||||
| `tmux` | Runtime dependency for local session creation and attachment. |
|
||||
| `openssh` | Runtime dependency for remote host connections. |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -10,17 +10,19 @@
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
version = "0.1.0";
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.buildGoModule {
|
||||
pname = "limen";
|
||||
version = "0.1.0";
|
||||
inherit version;
|
||||
src = ./.;
|
||||
vendorHash = null;
|
||||
vendorHash = "sha256-zYQ6hMFBFDPNlViB7EmnxbXozardyYeHiUSiG4uS/9Y=";
|
||||
|
||||
ldflags = [
|
||||
"-s"
|
||||
"-w"
|
||||
"-X main.version=${version}"
|
||||
];
|
||||
|
||||
meta = {
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
module github.com/KofTwentyTwo/limen
|
||||
|
||||
go 1.22
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20260525135217-abeec2b8bf0b
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.2 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
|
||||
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20260525135217-abeec2b8bf0b h1:Cw2ewBSl8ZRJFPlBHDiSpRNCATyzc/7yffziFeT0ymQ=
|
||||
github.com/charmbracelet/x/exp/teatest v0.0.0-20260525135217-abeec2b8bf0b/go.mod h1:aPVjFrBwbJgj5Qz1F0IXsnbcOVJcMKgu1ySUfTAxh7k=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
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=
|
||||
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/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
osexec "os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -113,7 +114,13 @@ func Handoff(plan Plan, opts Options) int {
|
||||
st.LastAttached[plan.HostName] = deps.Now().UTC()
|
||||
_ = deps.SaveState(deps.StatePath, st)
|
||||
|
||||
if err := deps.Exec(plan.Argv[0], plan.Argv, deps.Environ()); err != nil {
|
||||
execPath, err := osexec.LookPath(plan.Argv[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err)
|
||||
return 127
|
||||
}
|
||||
|
||||
if err := deps.Exec(execPath, plan.Argv, deps.Environ()); err != nil {
|
||||
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err)
|
||||
return 127
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package exec
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
osexec "os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -144,8 +145,12 @@ func TestHandoffUpdatesStateBeforeExec(t *testing.T) {
|
||||
var execArgv []string
|
||||
var execEnv []string
|
||||
var stderr bytes.Buffer
|
||||
wantPath, err := osexec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skipf("sh is unavailable: %v", err)
|
||||
}
|
||||
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh", "-t", "prod", "tmux", "new"}}, Options{
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"sh", "-c", "true"}}, Options{
|
||||
StatePath: "/tmp/state.json",
|
||||
Now: func() time.Time { return now },
|
||||
Environ: func() []string { return []string{"TERM=xterm-256color"} },
|
||||
@@ -181,7 +186,7 @@ func TestHandoffUpdatesStateBeforeExec(t *testing.T) {
|
||||
if _, ok := saved.LastAttached["localhost"]; !ok {
|
||||
t.Fatal("existing localhost timestamp was not preserved")
|
||||
}
|
||||
if execPath != "ssh" || !reflect.DeepEqual(execArgv, []string{"ssh", "-t", "prod", "tmux", "new"}) {
|
||||
if execPath != wantPath || !reflect.DeepEqual(execArgv, []string{"sh", "-c", "true"}) {
|
||||
t.Fatalf("exec saw %q %#v", execPath, execArgv)
|
||||
}
|
||||
if !reflect.DeepEqual(execEnv, []string{"TERM=xterm-256color"}) {
|
||||
@@ -192,10 +197,44 @@ func TestHandoffUpdatesStateBeforeExec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffResolvesExecutableBeforeExec(t *testing.T) {
|
||||
wantPath, err := osexec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skipf("sh is unavailable: %v", err)
|
||||
}
|
||||
|
||||
var execPath string
|
||||
var execArgv []string
|
||||
|
||||
code := Handoff(Plan{HostName: "localhost", Argv: []string{"sh", "-c", "true"}}, Options{
|
||||
LoadState: func(path string) state.State {
|
||||
return state.State{}
|
||||
},
|
||||
SaveState: func(path string, st state.State) error {
|
||||
return nil
|
||||
},
|
||||
Exec: func(path string, argv []string, env []string) error {
|
||||
execPath = path
|
||||
execArgv = append([]string(nil), argv...)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("Handoff exit code = %d, want 0", code)
|
||||
}
|
||||
if execPath != wantPath {
|
||||
t.Fatalf("exec path = %q, want %q", execPath, wantPath)
|
||||
}
|
||||
if !reflect.DeepEqual(execArgv, []string{"sh", "-c", "true"}) {
|
||||
t.Fatalf("exec argv = %#v", execArgv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandoffTreatsStateSaveFailureAsAdvisory(t *testing.T) {
|
||||
var calledExec bool
|
||||
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh"}}, Options{
|
||||
code := Handoff(Plan{HostName: "prod", Argv: []string{"sh"}}, Options{
|
||||
LoadState: func(path string) state.State {
|
||||
return state.State{}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
// Package ui contains limen's Bubble Tea models.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/KofTwentyTwo/limen/internal/config"
|
||||
limenexec "github.com/KofTwentyTwo/limen/internal/exec"
|
||||
"github.com/KofTwentyTwo/limen/internal/format"
|
||||
"github.com/KofTwentyTwo/limen/internal/probe"
|
||||
"github.com/KofTwentyTwo/limen/internal/state"
|
||||
"github.com/KofTwentyTwo/limen/internal/ui/theme"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWidth = 100
|
||||
defaultHeight = 30
|
||||
|
||||
tinyTerminalMessage = "terminal too small for limen UI; falling back to bare tmux new"
|
||||
)
|
||||
|
||||
type stage int
|
||||
|
||||
const (
|
||||
stageHosts stage = iota
|
||||
stageSessions
|
||||
stageNewSession
|
||||
)
|
||||
|
||||
// App is the data needed to start the TUI.
|
||||
type App struct {
|
||||
Config config.Config
|
||||
State state.State
|
||||
ProbeResults <-chan probe.ProbeResult
|
||||
Now func() time.Time
|
||||
Hostname string
|
||||
}
|
||||
|
||||
// Result is the selected command plan or quit outcome from the TUI.
|
||||
type Result struct {
|
||||
Plan limenexec.Plan
|
||||
HasPlan bool
|
||||
ExitCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
// Run starts the Bubble Tea program and returns the final user decision.
|
||||
func Run(app App) (Result, error) {
|
||||
program := tea.NewProgram(NewModel(app), tea.WithAltScreen())
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
return Result{ExitCode: 1}, err
|
||||
}
|
||||
|
||||
model, ok := finalModel.(Model)
|
||||
if !ok {
|
||||
return Result{ExitCode: 1}, fmt.Errorf("unexpected final model %T", finalModel)
|
||||
}
|
||||
return model.Result(), nil
|
||||
}
|
||||
|
||||
// Model is the root Bubble Tea model for both picker stages.
|
||||
type Model struct {
|
||||
stage stage
|
||||
width int
|
||||
height int
|
||||
hosts []hostEntry
|
||||
hostCursor int
|
||||
sessionCursor int
|
||||
filter string
|
||||
filtering bool
|
||||
help bool
|
||||
newName string
|
||||
inputError string
|
||||
probeResults <-chan probe.ProbeResult
|
||||
now func() time.Time
|
||||
result Result
|
||||
}
|
||||
|
||||
type hostEntry struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Connection string
|
||||
Description string
|
||||
Remote *config.Host
|
||||
Probing bool
|
||||
Reachable bool
|
||||
Sessions []probe.SessionInfo
|
||||
LastAttached time.Time
|
||||
}
|
||||
|
||||
type sessionRow struct {
|
||||
New bool
|
||||
Session probe.SessionInfo
|
||||
}
|
||||
|
||||
type probeMsg probe.ProbeResult
|
||||
type probesDoneMsg struct{}
|
||||
|
||||
// NewModel constructs the initial root model.
|
||||
func NewModel(app App) Model {
|
||||
now := app.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
hostname := app.Hostname
|
||||
if hostname == "" {
|
||||
if local, err := os.Hostname(); err == nil {
|
||||
hostname = local
|
||||
}
|
||||
}
|
||||
|
||||
hosts := make([]hostEntry, 0, len(app.Config.Hosts)+1)
|
||||
hosts = append(hosts, hostEntry{
|
||||
Name: probe.LocalhostName,
|
||||
DisplayName: "localhost (" + shortHostname(hostname) + ")",
|
||||
Connection: "localhost",
|
||||
Probing: true,
|
||||
LastAttached: app.State.LastAttached[probe.LocalhostName],
|
||||
})
|
||||
for _, host := range app.Config.Hosts {
|
||||
hostCopy := host
|
||||
hosts = append(hosts, hostEntry{
|
||||
Name: host.Name,
|
||||
DisplayName: host.Name,
|
||||
Connection: connectionLine(host),
|
||||
Description: host.Description,
|
||||
Remote: &hostCopy,
|
||||
Probing: true,
|
||||
LastAttached: app.State.LastAttached[host.Name],
|
||||
})
|
||||
}
|
||||
sort.SliceStable(hosts, func(i int, j int) bool {
|
||||
return labelLess(hosts[i].DisplayName, hosts[j].DisplayName)
|
||||
})
|
||||
|
||||
probeResults := app.ProbeResults
|
||||
if probeResults == nil {
|
||||
probeResults = probe.Start(contextBackground(), app.Config.Hosts, probe.Options{})
|
||||
}
|
||||
|
||||
return Model{
|
||||
width: defaultWidth,
|
||||
height: defaultHeight,
|
||||
hosts: hosts,
|
||||
probeResults: probeResults,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func contextBackground() context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// Init starts listening for probe results.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return waitForProbe(m.probeResults)
|
||||
}
|
||||
|
||||
// Update applies keyboard, resize, and probe messages.
|
||||
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
|
||||
if msg.Width < 50 || msg.Height < 15 {
|
||||
m.acceptPlan(mustPlan(limenexec.LocalNew("")))
|
||||
m.result.Message = tinyTerminalMessage
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case probeMsg:
|
||||
m.applyProbeResult(probe.ProbeResult(msg))
|
||||
m.clampCursor()
|
||||
return m, waitForProbe(m.probeResults)
|
||||
case probesDoneMsg:
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
return m.updateKey(msg)
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// View renders the active picker stage.
|
||||
func (m Model) View() string {
|
||||
if m.width < 50 || m.height < 15 {
|
||||
return tinyTerminalMessage
|
||||
}
|
||||
|
||||
var body string
|
||||
if m.width < 80 || m.height < 24 {
|
||||
body = m.singleColumnView()
|
||||
} else {
|
||||
body = m.splitView()
|
||||
}
|
||||
if m.help {
|
||||
body = body + "\n\n" + m.helpView()
|
||||
}
|
||||
return body + "\n\n" + m.footer()
|
||||
}
|
||||
|
||||
// Result returns the final user decision.
|
||||
func (m Model) Result() Result {
|
||||
return m.result
|
||||
}
|
||||
|
||||
func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.help {
|
||||
switch msg.String() {
|
||||
case "?", "esc":
|
||||
m.help = false
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
if m.filtering {
|
||||
return m.updateFilterKey(msg)
|
||||
}
|
||||
|
||||
if msg.Type == tea.KeyRunes && m.stage != stageNewSession && startsTypeahead(msg.String()) {
|
||||
m.filtering = true
|
||||
m.filter = string(msg.Runes)
|
||||
m.clampCursor()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
m.result.ExitCode = 130
|
||||
return m, tea.Quit
|
||||
case "q":
|
||||
m.result.ExitCode = 0
|
||||
return m, tea.Quit
|
||||
case "?":
|
||||
m.help = !m.help
|
||||
return m, nil
|
||||
case "/":
|
||||
if m.stage != stageNewSession {
|
||||
m.filtering = true
|
||||
m.filter = ""
|
||||
}
|
||||
return m, nil
|
||||
case "tab":
|
||||
if m.stage != stageNewSession {
|
||||
m.filtering = true
|
||||
m.completeFilter()
|
||||
}
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
m.moveCursor(-1)
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
m.moveCursor(1)
|
||||
return m, nil
|
||||
case "home", "g":
|
||||
m.setCursor(0)
|
||||
return m, nil
|
||||
case "end", "G":
|
||||
m.setCursor(m.entryCount() - 1)
|
||||
return m, nil
|
||||
case "esc":
|
||||
return m.escape()
|
||||
case "enter":
|
||||
return m.enter()
|
||||
case "backspace":
|
||||
if m.stage == stageNewSession && len(m.newName) > 0 {
|
||||
m.newName = m.newName[:len(m.newName)-1]
|
||||
m.inputError = ""
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if msg.Type == tea.KeyRunes && m.stage == stageNewSession {
|
||||
m.newName += string(msg.Runes)
|
||||
m.inputError = ""
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.filtering = false
|
||||
m.filter = ""
|
||||
m.clampCursor()
|
||||
return m, nil
|
||||
case "enter":
|
||||
m.filtering = false
|
||||
return m.enter()
|
||||
case "tab":
|
||||
m.completeFilter()
|
||||
return m, nil
|
||||
case "up":
|
||||
m.moveCursor(-1)
|
||||
return m, nil
|
||||
case "down":
|
||||
m.moveCursor(1)
|
||||
return m, nil
|
||||
case "home":
|
||||
m.setCursor(0)
|
||||
return m, nil
|
||||
case "end":
|
||||
m.setCursor(m.entryCount() - 1)
|
||||
return m, nil
|
||||
case "backspace":
|
||||
if len(m.filter) > 0 {
|
||||
m.filter = m.filter[:len(m.filter)-1]
|
||||
m.clampCursor()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if msg.Type == tea.KeyRunes {
|
||||
m.filter += string(msg.Runes)
|
||||
m.clampCursor()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) enter() (tea.Model, tea.Cmd) {
|
||||
switch m.stage {
|
||||
case stageHosts:
|
||||
indexes := m.filteredHostIndexes()
|
||||
if len(indexes) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.hostCursor = indexes[m.hostCursor]
|
||||
m.stage = stageSessions
|
||||
m.sessionCursor = 0
|
||||
m.filter = ""
|
||||
m.filtering = false
|
||||
return m, nil
|
||||
case stageSessions:
|
||||
rows := m.filteredSessionRows()
|
||||
if len(rows) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
row := rows[m.sessionCursor]
|
||||
if row.New {
|
||||
m.stage = stageNewSession
|
||||
m.newName = ""
|
||||
m.inputError = ""
|
||||
m.filter = ""
|
||||
m.filtering = false
|
||||
return m, nil
|
||||
}
|
||||
host := m.selectedHost()
|
||||
session := row.Session
|
||||
if host.Remote == nil {
|
||||
m.acceptPlan(limenexec.LocalAttach(session.Name))
|
||||
} else {
|
||||
m.acceptPlan(limenexec.RemoteAttach(*host.Remote, session.Name))
|
||||
}
|
||||
return m, tea.Quit
|
||||
case stageNewSession:
|
||||
host := m.selectedHost()
|
||||
var plan limenexec.Plan
|
||||
var err error
|
||||
if host.Remote == nil {
|
||||
plan, err = limenexec.LocalNew(m.newName)
|
||||
} else {
|
||||
plan, err = limenexec.RemoteNew(*host.Remote, m.newName)
|
||||
}
|
||||
if err != nil {
|
||||
m.inputError = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.acceptPlan(plan)
|
||||
return m, tea.Quit
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) escape() (tea.Model, tea.Cmd) {
|
||||
if m.stage == stageHosts {
|
||||
m.acceptPlan(mustPlan(limenexec.LocalNew("")))
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
host := m.selectedHost()
|
||||
var plan limenexec.Plan
|
||||
if host.Remote == nil {
|
||||
plan = mustPlan(limenexec.LocalNew(""))
|
||||
} else {
|
||||
plan = mustPlan(limenexec.RemoteNew(*host.Remote, ""))
|
||||
}
|
||||
m.acceptPlan(plan)
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
func (m *Model) acceptPlan(plan limenexec.Plan) {
|
||||
m.result = Result{Plan: plan, HasPlan: true, ExitCode: 0}
|
||||
}
|
||||
|
||||
func (m *Model) applyProbeResult(result probe.ProbeResult) {
|
||||
for i := range m.hosts {
|
||||
if m.hosts[i].Name != result.HostName {
|
||||
continue
|
||||
}
|
||||
m.hosts[i].Probing = false
|
||||
m.hosts[i].Reachable = result.Reachable
|
||||
m.hosts[i].Sessions = result.Sessions
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func waitForProbe(results <-chan probe.ProbeResult) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
result, ok := <-results
|
||||
if !ok {
|
||||
return probesDoneMsg{}
|
||||
}
|
||||
return probeMsg(result)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) splitView() string {
|
||||
leftWidth := maxInt(30, int(float64(m.width)*0.40))
|
||||
rightWidth := maxInt(30, m.width-leftWidth-4)
|
||||
height := maxInt(10, m.height-6)
|
||||
|
||||
left := theme.Pane.Width(leftWidth).Height(height).Render(m.listView())
|
||||
right := theme.Pane.Width(rightWidth).Height(height).Render(m.detailView())
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
|
||||
}
|
||||
|
||||
func (m Model) singleColumnView() string {
|
||||
return theme.Pane.Width(maxInt(30, m.width-4)).Render(m.listView())
|
||||
}
|
||||
|
||||
func (m Model) listView() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(theme.Title.Render(m.leftTitle()))
|
||||
builder.WriteString("\n\n")
|
||||
|
||||
switch m.stage {
|
||||
case stageHosts:
|
||||
m.writeHostRows(&builder)
|
||||
case stageSessions, stageNewSession:
|
||||
m.writeSessionRows(&builder)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (m Model) leftTitle() string {
|
||||
if m.stage == stageHosts {
|
||||
return "limen"
|
||||
}
|
||||
return m.selectedHost().DisplayName
|
||||
}
|
||||
|
||||
func (m Model) writeHostRows(builder *strings.Builder) {
|
||||
indexes := m.filteredHostIndexes()
|
||||
if len(indexes) == 0 {
|
||||
builder.WriteString(theme.Dimmed.Render("no matching hosts"))
|
||||
return
|
||||
}
|
||||
for row, index := range indexes {
|
||||
host := m.hosts[index]
|
||||
cursor := " "
|
||||
if row == m.hostCursor {
|
||||
cursor = ">"
|
||||
}
|
||||
line := fmt.Sprintf("%s %s %-24s %s", cursor, statusDot(host), host.DisplayName, sessionSummary(host))
|
||||
if row == m.hostCursor {
|
||||
line = theme.Selected.Render(line)
|
||||
}
|
||||
builder.WriteString(line)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) writeSessionRows(builder *strings.Builder) {
|
||||
rows := m.filteredSessionRows()
|
||||
if len(rows) == 0 {
|
||||
builder.WriteString(theme.Dimmed.Render("no matching sessions"))
|
||||
return
|
||||
}
|
||||
|
||||
for row, entry := range rows {
|
||||
cursor := " "
|
||||
if row == m.sessionCursor {
|
||||
cursor = ">"
|
||||
}
|
||||
line := cursor + " " + sessionRowText(entry)
|
||||
if row == m.sessionCursor {
|
||||
line = theme.Selected.Render(line)
|
||||
}
|
||||
builder.WriteString(line)
|
||||
builder.WriteString("\n")
|
||||
if entry.New && len(rows) > 1 {
|
||||
builder.WriteString(theme.Dimmed.Render("──────────────────────────────"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) detailView() string {
|
||||
if m.stage == stageNewSession {
|
||||
return m.newSessionView()
|
||||
}
|
||||
if m.stage == stageSessions {
|
||||
return m.sessionDetailView()
|
||||
}
|
||||
|
||||
host := m.selectedHost()
|
||||
var builder strings.Builder
|
||||
builder.WriteString(theme.Title.Render("DETAILS"))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(host.DisplayName)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(theme.Dimmed.Render("──────────────────────────────────"))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(host.Connection)
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString("Status: ")
|
||||
builder.WriteString(statusText(host))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("Sessions: ")
|
||||
builder.WriteString(sessionDetail(host))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString("Last attached: ")
|
||||
builder.WriteString(format.RelativeTime(host.LastAttached, m.now()))
|
||||
if host.Description != "" {
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(host.Description)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (m Model) sessionDetailView() string {
|
||||
rows := m.filteredSessionRows()
|
||||
if len(rows) == 0 {
|
||||
return theme.Title.Render("DETAILS") + "\n\n" + theme.Dimmed.Render("no matching sessions")
|
||||
}
|
||||
if rows[m.sessionCursor].New {
|
||||
return m.newSessionView()
|
||||
}
|
||||
|
||||
session := rows[m.sessionCursor].Session
|
||||
var builder strings.Builder
|
||||
builder.WriteString(theme.Title.Render("DETAILS"))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString(session.Name)
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(theme.Dimmed.Render("──────────────────────────────────"))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString("Windows: ")
|
||||
builder.WriteString(fmt.Sprintf("%d", session.Windows))
|
||||
builder.WriteString("\n")
|
||||
if !session.Created.IsZero() {
|
||||
builder.WriteString("Created: ")
|
||||
builder.WriteString(session.Created.Format("Mon Jan 2 15:04"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("Attached: ")
|
||||
if session.Attached {
|
||||
builder.WriteString("★ currently attached")
|
||||
} else {
|
||||
builder.WriteString("no")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (m Model) newSessionView() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(theme.Title.Render("+ New session"))
|
||||
builder.WriteString("\n\n")
|
||||
builder.WriteString("Name for the new session:\n\n")
|
||||
builder.WriteString("> ")
|
||||
builder.WriteString(m.newName)
|
||||
builder.WriteString("\n\n")
|
||||
if m.inputError != "" {
|
||||
builder.WriteString(m.inputError)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString(theme.Dimmed.Render("Enter to create. Esc creates an unnamed session."))
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (m Model) helpView() string {
|
||||
escapeHelp := "esc skip"
|
||||
if m.stage != stageHosts {
|
||||
escapeHelp = "esc new unnamed"
|
||||
}
|
||||
return theme.Pane.Render("↑/k up ↓/j down enter connect / search " + escapeHelp + " q quit ctrl+c quit")
|
||||
}
|
||||
|
||||
func (m Model) footer() string {
|
||||
if m.filtering {
|
||||
return theme.Dimmed.Render("/ " + m.filter + " · enter select · esc cancel filter")
|
||||
}
|
||||
if m.stage == stageNewSession {
|
||||
return theme.Dimmed.Render("enter create · esc new unnamed · q quit")
|
||||
}
|
||||
if m.stage == stageSessions {
|
||||
return theme.Dimmed.Render("↑↓ navigate · enter connect · / search · ? help · esc new unnamed")
|
||||
}
|
||||
return theme.Dimmed.Render("↑↓ navigate · enter connect · / search · ? help · esc skip")
|
||||
}
|
||||
|
||||
func (m Model) selectedHost() hostEntry {
|
||||
if len(m.hosts) == 0 {
|
||||
return hostEntry{Name: probe.LocalhostName, DisplayName: "localhost", Connection: "localhost"}
|
||||
}
|
||||
if m.hostCursor < 0 || m.hostCursor >= len(m.hosts) {
|
||||
return m.hosts[0]
|
||||
}
|
||||
return m.hosts[m.hostCursor]
|
||||
}
|
||||
|
||||
func (m Model) sessionEntries() []probe.SessionInfo {
|
||||
sessions := append([]probe.SessionInfo(nil), m.selectedHost().Sessions...)
|
||||
sort.SliceStable(sessions, func(i int, j int) bool {
|
||||
return labelLess(sessions[i].Name, sessions[j].Name)
|
||||
})
|
||||
return sessions
|
||||
}
|
||||
|
||||
func (m *Model) moveCursor(delta int) {
|
||||
m.setCursor(m.currentCursor() + delta)
|
||||
}
|
||||
|
||||
func (m *Model) setCursor(value int) {
|
||||
count := m.entryCount()
|
||||
if count == 0 {
|
||||
m.hostCursor = 0
|
||||
m.sessionCursor = 0
|
||||
return
|
||||
}
|
||||
if value < 0 {
|
||||
value = 0
|
||||
}
|
||||
if value >= count {
|
||||
value = count - 1
|
||||
}
|
||||
switch m.stage {
|
||||
case stageHosts:
|
||||
m.hostCursor = value
|
||||
case stageSessions, stageNewSession:
|
||||
m.sessionCursor = value
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) clampCursor() {
|
||||
m.setCursor(m.currentCursor())
|
||||
}
|
||||
|
||||
func (m *Model) completeFilter() {
|
||||
completion, ok := m.currentCompletion()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m.filter = completion
|
||||
m.clampCursor()
|
||||
}
|
||||
|
||||
func (m Model) currentCompletion() (string, bool) {
|
||||
if m.stage == stageHosts {
|
||||
indexes := m.filteredHostIndexes()
|
||||
if len(indexes) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return m.hosts[indexes[m.hostCursor]].Name, true
|
||||
}
|
||||
|
||||
rows := m.filteredSessionRows()
|
||||
if len(rows) == 0 {
|
||||
return "", false
|
||||
}
|
||||
row := rows[m.sessionCursor]
|
||||
if row.New {
|
||||
return "new session", true
|
||||
}
|
||||
return row.Session.Name, true
|
||||
}
|
||||
|
||||
func (m Model) currentCursor() int {
|
||||
if m.stage == stageHosts {
|
||||
return m.hostCursor
|
||||
}
|
||||
return m.sessionCursor
|
||||
}
|
||||
|
||||
func (m Model) entryCount() int {
|
||||
if m.stage == stageHosts {
|
||||
return len(m.filteredHostIndexes())
|
||||
}
|
||||
return len(m.filteredSessionRows())
|
||||
}
|
||||
|
||||
func (m Model) filteredHostIndexes() []int {
|
||||
indexes := make([]int, 0, len(m.hosts))
|
||||
filter := strings.ToLower(m.filter)
|
||||
for i, host := range m.hosts {
|
||||
if filter == "" || startsWithFold(host.DisplayName, filter) {
|
||||
indexes = append(indexes, i)
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
func (m Model) filteredSessionRows() []sessionRow {
|
||||
filter := strings.ToLower(m.filter)
|
||||
rows := make([]sessionRow, 0, len(m.sessionEntries())+1)
|
||||
|
||||
if filter == "" || startsWithFold("new session", filter) {
|
||||
rows = append(rows, sessionRow{New: true})
|
||||
}
|
||||
for _, session := range m.sessionEntries() {
|
||||
if filter == "" || startsWithFold(session.Name, filter) {
|
||||
rows = append(rows, sessionRow{Session: session})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func sessionRowText(row sessionRow) string {
|
||||
if row.New {
|
||||
return "+ New session"
|
||||
}
|
||||
|
||||
marker := " "
|
||||
if row.Session.Attached {
|
||||
marker = "●"
|
||||
}
|
||||
star := ""
|
||||
if row.Session.Attached {
|
||||
star = " ★"
|
||||
}
|
||||
return fmt.Sprintf("%s %-18s %d windows%s", marker, row.Session.Name, row.Session.Windows, star)
|
||||
}
|
||||
|
||||
func statusDot(host hostEntry) string {
|
||||
switch {
|
||||
case host.Probing:
|
||||
return theme.ProbingStyle.Render("…")
|
||||
case host.Reachable:
|
||||
return theme.OnlineStyle.Render("●")
|
||||
default:
|
||||
return theme.UnreachableStyle.Render("○")
|
||||
}
|
||||
}
|
||||
|
||||
func statusText(host hostEntry) string {
|
||||
switch {
|
||||
case host.Probing:
|
||||
return theme.ProbingStyle.Render("… probing")
|
||||
case host.Reachable:
|
||||
return theme.OnlineStyle.Render("● online")
|
||||
default:
|
||||
return theme.UnreachableStyle.Render("○ unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func sessionSummary(host hostEntry) string {
|
||||
if host.Probing {
|
||||
return "probing"
|
||||
}
|
||||
if !host.Reachable {
|
||||
return "unreach."
|
||||
}
|
||||
if len(host.Sessions) == 0 {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%d sess.", len(host.Sessions))
|
||||
}
|
||||
|
||||
func sessionDetail(host hostEntry) string {
|
||||
if host.Probing {
|
||||
return "probing"
|
||||
}
|
||||
if !host.Reachable {
|
||||
return "n/a"
|
||||
}
|
||||
if len(host.Sessions) == 0 {
|
||||
return "—"
|
||||
}
|
||||
names := make([]string, 0, len(host.Sessions))
|
||||
for _, session := range host.Sessions {
|
||||
names = append(names, session.Name)
|
||||
}
|
||||
return fmt.Sprintf("%d (%s)", len(host.Sessions), strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
func connectionLine(host config.Host) string {
|
||||
target := host.Hostname
|
||||
if host.User != "" {
|
||||
target = host.User + "@" + target
|
||||
}
|
||||
if host.Port != 0 && host.Port != 22 {
|
||||
target = fmt.Sprintf("%s:%d", target, host.Port)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func shortHostname(hostname string) string {
|
||||
if hostname == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.SplitN(hostname, ".", 2)[0]
|
||||
}
|
||||
|
||||
func mustPlan(plan limenexec.Plan, err error) limenexec.Plan {
|
||||
if err != nil {
|
||||
return limenexec.Plan{}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func labelLess(left string, right string) bool {
|
||||
leftFolded := strings.ToLower(left)
|
||||
rightFolded := strings.ToLower(right)
|
||||
if leftFolded == rightFolded {
|
||||
return left < right
|
||||
}
|
||||
return leftFolded < rightFolded
|
||||
}
|
||||
|
||||
func startsTypeahead(key string) bool {
|
||||
return key != "/" && key != "?" && key != "q"
|
||||
}
|
||||
|
||||
func startsWithFold(value string, foldedPrefix string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(value), foldedPrefix)
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/exp/teatest"
|
||||
|
||||
"github.com/KofTwentyTwo/limen/internal/config"
|
||||
limenexec "github.com/KofTwentyTwo/limen/internal/exec"
|
||||
"github.com/KofTwentyTwo/limen/internal/probe"
|
||||
"github.com/KofTwentyTwo/limen/internal/state"
|
||||
)
|
||||
|
||||
func TestTeatestHostSelectionAdvancesToSessionPicker(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(100, 30))
|
||||
defer func() {
|
||||
_ = tm.Quit()
|
||||
}()
|
||||
|
||||
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
|
||||
return bytes.Contains(bts, []byte("localhost (renova)"))
|
||||
}, teatest.WithDuration(2*time.Second))
|
||||
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
|
||||
return bytes.Contains(bts, []byte("+ New session"))
|
||||
}, teatest.WithDuration(2*time.Second))
|
||||
}
|
||||
|
||||
func TestHostsAreSortedByDisplayName(t *testing.T) {
|
||||
model := NewModel(App{
|
||||
Config: config.Config{Hosts: []config.Host{
|
||||
{Name: "zeta", Hostname: "zeta.example.com"},
|
||||
{Name: "alpha", Hostname: "alpha.example.com"},
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Hostname: "renova.local",
|
||||
})
|
||||
|
||||
view := model.View()
|
||||
assertOrder(t, view, "alpha", "localhost (renova)", "zeta")
|
||||
}
|
||||
|
||||
func TestSelectExistingRemoteSessionProducesAttachPlan(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = updateModel(t, model, probeMsg(probe.ProbeResult{
|
||||
HostName: "prod",
|
||||
Reachable: true,
|
||||
Sessions: []probe.SessionInfo{{Name: "api", Windows: 3, Attached: true}},
|
||||
}))
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "attach", "-t", "api"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsAreSortedByName(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "zeta", Windows: 1},
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
view := model.View()
|
||||
assertOrder(t, view, "+ New session", "api", "ops", "zeta")
|
||||
}
|
||||
|
||||
func TestTypingAndTabCompleteHostSelection(t *testing.T) {
|
||||
model := NewModel(App{
|
||||
Config: config.Config{Hosts: []config.Host{
|
||||
{Name: "prod", Hostname: "prod.example.com"},
|
||||
{Name: "builder", Hostname: "builder.example.com"},
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Hostname: "renova.local",
|
||||
})
|
||||
|
||||
for _, r := range "pr" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyTab})
|
||||
|
||||
if !model.filtering || model.filter != "prod" {
|
||||
t.Fatalf("filtering = %v, filter = %q; want completed prod filter", model.filtering, model.filter)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if model.stage != stageSessions {
|
||||
t.Fatalf("stage = %v, want sessions", model.stage)
|
||||
}
|
||||
if got := model.selectedHost().Name; got != "prod" {
|
||||
t.Fatalf("selected host = %q, want prod", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypingBoundNavigationLetterStartsHostFilter(t *testing.T) {
|
||||
model := NewModel(App{
|
||||
Config: config.Config{Hosts: []config.Host{
|
||||
{Name: "gamma", Hostname: "gamma.example.com"},
|
||||
{Name: "prod", Hostname: "prod.example.com"},
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Hostname: "renova.local",
|
||||
})
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
|
||||
|
||||
if !model.filtering || model.filter != "g" {
|
||||
t.Fatalf("filtering = %v, filter = %q; want g filter", model.filtering, model.filter)
|
||||
}
|
||||
if view := model.View(); strings.Contains(view, "prod") {
|
||||
t.Fatalf("View() = %q, want prod filtered out", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostFilterOnlyMatchesNamePrefixes(t *testing.T) {
|
||||
model := NewModel(App{
|
||||
Config: config.Config{Hosts: []config.Host{
|
||||
{Name: "prod", Hostname: "prod.example.com"},
|
||||
{Name: "sandbox", Hostname: "box.example.com"},
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Hostname: "renova.local",
|
||||
})
|
||||
|
||||
for _, r := range "box" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
|
||||
view := model.View()
|
||||
if strings.Contains(view, "sandbox") || strings.Contains(view, "box.example.com") {
|
||||
t.Fatalf("View() = %q, want non-prefix host and connection filtered out", view)
|
||||
}
|
||||
if !strings.Contains(view, "no matching hosts") {
|
||||
t.Fatalf("View() = %q, want no matching hosts", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFilterSelectsMatchingSession(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
for _, r := range "ops" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "attach", "-t", "ops"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionFilterOnlyMatchesNamePrefixes(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range "pi" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
|
||||
view := model.View()
|
||||
if strings.Contains(view, "api") {
|
||||
t.Fatalf("View() = %q, want substring session filtered out", view)
|
||||
}
|
||||
if !strings.Contains(view, "no matching sessions") {
|
||||
t.Fatalf("View() = %q, want no matching sessions", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypingAndTabCompleteSessionSelection(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range "op" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyTab})
|
||||
|
||||
if !model.filtering || model.filter != "ops" {
|
||||
t.Fatalf("filtering = %v, filter = %q; want completed ops filter", model.filtering, model.filter)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "attach", "-t", "ops"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionArrowKeysNavigateWhileFiltering(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "app", Windows: 1},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range "a" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
if model.sessionCursor != 1 {
|
||||
t.Fatalf("sessionCursor = %d, want 1 after arrow down in filter mode", model.sessionCursor)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyUp})
|
||||
if model.sessionCursor != 0 {
|
||||
t.Fatalf("sessionCursor = %d, want 0 after arrow up in filter mode", model.sessionCursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeFromHostPickerProducesLocalNewPlan(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
|
||||
want := limenexec.Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new"}}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeFromRemoteSessionPickerProducesRemoteNewPlan(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "new"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSelectsMatchingHost(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
for _, r := range "prod" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
if model.stage != stageSessions {
|
||||
t.Fatalf("stage = %v, want sessions", model.stage)
|
||||
}
|
||||
if got := model.selectedHost().Name; got != "prod" {
|
||||
t.Fatalf("selected host = %q, want prod", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterCanBeCanceledAfterNoMatches(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
for _, r := range "missing" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
|
||||
if view := model.View(); !strings.Contains(view, "no matching hosts") {
|
||||
t.Fatalf("View() = %q, want no-match message", view)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if model.filtering || model.filter != "" {
|
||||
t.Fatalf("filtering = %v, filter = %q; want canceled", model.filtering, model.filter)
|
||||
}
|
||||
if view := model.View(); !strings.Contains(view, "prod") {
|
||||
t.Fatalf("View() = %q, want unfiltered host list", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionPromptRejectsInvalidName(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range "bad:name" {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
if model.inputError != "name cannot contain : or ." {
|
||||
t.Fatalf("inputError = %q", model.inputError)
|
||||
}
|
||||
if model.result.HasPlan {
|
||||
t.Fatalf("result = %#v, want no plan", model.result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionPromptCreatesTrimmedRemoteSession(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
for _, r := range " api " {
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
want := limenexec.Plan{
|
||||
HostName: "prod",
|
||||
Argv: []string{"ssh", "-t", "deploy@prod.example.com", "tmux", "new", "-s", "api"},
|
||||
}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPickerRendersSessionDetails(t *testing.T) {
|
||||
created := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC)
|
||||
model := modelWithProdSessions(t, probe.SessionInfo{Name: "api", Windows: 3, Attached: true, Created: created})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
|
||||
view := model.View()
|
||||
for _, want := range []string{"api", "Windows: 3", "Created: Tue May 26 13:45", "currently attached"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("View() missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeUpdatesClampSessionCursor(t *testing.T) {
|
||||
model := modelWithProdSessions(t,
|
||||
probe.SessionInfo{Name: "api", Windows: 3},
|
||||
probe.SessionInfo{Name: "ops", Windows: 1},
|
||||
)
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEnd})
|
||||
model = updateModel(t, model, probeMsg(probe.ProbeResult{
|
||||
HostName: "prod",
|
||||
Reachable: true,
|
||||
Sessions: []probe.SessionInfo{{Name: "api", Windows: 3}},
|
||||
}))
|
||||
|
||||
if model.sessionCursor != 1 {
|
||||
t.Fatalf("sessionCursor = %d, want clamped to 1", model.sessionCursor)
|
||||
}
|
||||
if view := model.View(); !strings.Contains(view, "api") {
|
||||
t.Fatalf("View() = %q, want remaining session", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetailsRenderProbeStateAndStateTimestamp(t *testing.T) {
|
||||
model := modelWithProdSessions(t, probe.SessionInfo{Name: "api", Windows: 3})
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
|
||||
view := model.View()
|
||||
for _, want := range []string{"deploy@prod.example.com", "online", "1 (api)", "1 hour ago", "Production application servers."} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("View() missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableHostDetailsShowNoSessionInventory(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = updateModel(t, model, probeMsg(probe.ProbeResult{HostName: "prod", Reachable: false}))
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyDown})
|
||||
|
||||
view := model.View()
|
||||
for _, want := range []string{"unreachable", "Sessions: n/a"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("View() missing %q:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleColumnViewOmitsDetailsPane(t *testing.T) {
|
||||
model := updateModel(t, NewModel(testApp()), tea.WindowSizeMsg{Width: 70, Height: 20})
|
||||
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "limen") {
|
||||
t.Fatalf("View() = %q, want list pane", view)
|
||||
}
|
||||
if strings.Contains(view, "DETAILS") {
|
||||
t.Fatalf("View() = %q, want single-column layout without details pane", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpOverlayTogglesAndEscapeDismisses(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
|
||||
if !model.help {
|
||||
t.Fatal("help = false, want true")
|
||||
}
|
||||
if view := model.View(); !strings.Contains(view, "ctrl+c quit") {
|
||||
t.Fatalf("View() = %q, want help overlay", view)
|
||||
}
|
||||
|
||||
model = press(t, model, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if model.help {
|
||||
t.Fatal("help = true, want false")
|
||||
}
|
||||
if model.result.HasPlan {
|
||||
t.Fatalf("result = %#v, want no plan after dismissing help", model.result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuitKeysReturnExitCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key tea.KeyMsg
|
||||
want int
|
||||
}{
|
||||
{name: "q", key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}, want: 0},
|
||||
{name: "ctrl-c", key: tea.KeyMsg{Type: tea.KeyCtrlC}, want: 130},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
model := press(t, NewModel(testApp()), tt.key)
|
||||
if model.result.ExitCode != tt.want {
|
||||
t.Fatalf("exit code = %d, want %d", model.result.ExitCode, tt.want)
|
||||
}
|
||||
if model.result.HasPlan {
|
||||
t.Fatalf("result = %#v, want no plan", model.result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTinyTerminalFallsBackToLocalNew(t *testing.T) {
|
||||
model := NewModel(testApp())
|
||||
model = updateModel(t, model, tea.WindowSizeMsg{Width: 40, Height: 10})
|
||||
|
||||
want := limenexec.Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new"}}
|
||||
if !model.result.HasPlan || !reflect.DeepEqual(model.result.Plan, want) {
|
||||
t.Fatalf("result = %#v, want plan %#v", model.result, want)
|
||||
}
|
||||
if model.result.Message != tinyTerminalMessage {
|
||||
t.Fatalf("message = %q, want %q", model.result.Message, tinyTerminalMessage)
|
||||
}
|
||||
if model.View() != tinyTerminalMessage {
|
||||
t.Fatalf("View() = %q, want tiny terminal message", model.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForProbeReturnsResultAndDoneMessages(t *testing.T) {
|
||||
probes := make(chan probe.ProbeResult, 1)
|
||||
probes <- probe.ProbeResult{HostName: "prod", Reachable: true}
|
||||
close(probes)
|
||||
|
||||
got, ok := waitForProbe(probes)().(probeMsg)
|
||||
if !ok {
|
||||
t.Fatalf("first probe message type = %T, want probeMsg", got)
|
||||
}
|
||||
if got.HostName != "prod" || !got.Reachable {
|
||||
t.Fatalf("first probe message = %#v", got)
|
||||
}
|
||||
if got := waitForProbe(probes)(); got != (probesDoneMsg{}) {
|
||||
t.Fatalf("second probe message = %#v, want done", got)
|
||||
}
|
||||
}
|
||||
|
||||
func testApp() App {
|
||||
return App{
|
||||
Config: config.Config{Hosts: []config.Host{{
|
||||
Name: "prod",
|
||||
Hostname: "prod.example.com",
|
||||
User: "deploy",
|
||||
Description: "Production application servers.",
|
||||
}}},
|
||||
State: state.State{LastAttached: map[string]time.Time{
|
||||
"prod": time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC),
|
||||
}},
|
||||
ProbeResults: closedProbeResults(),
|
||||
Now: func() time.Time { return time.Date(2026, 5, 26, 14, 45, 0, 0, time.UTC) },
|
||||
Hostname: "renova.local",
|
||||
}
|
||||
}
|
||||
|
||||
func closedProbeResults() <-chan probe.ProbeResult {
|
||||
probes := make(chan probe.ProbeResult)
|
||||
close(probes)
|
||||
return probes
|
||||
}
|
||||
|
||||
func modelWithProdSessions(t *testing.T, sessions ...probe.SessionInfo) Model {
|
||||
t.Helper()
|
||||
return updateModel(t, NewModel(testApp()), probeMsg(probe.ProbeResult{
|
||||
HostName: "prod",
|
||||
Reachable: true,
|
||||
Sessions: sessions,
|
||||
}))
|
||||
}
|
||||
|
||||
func assertOrder(t *testing.T, text string, values ...string) {
|
||||
t.Helper()
|
||||
|
||||
last := -1
|
||||
for _, value := range values {
|
||||
index := strings.Index(text, value)
|
||||
if index == -1 {
|
||||
t.Fatalf("text missing %q:\n%s", value, text)
|
||||
}
|
||||
if index < last {
|
||||
t.Fatalf("%q appeared before prior value in:\n%s", value, text)
|
||||
}
|
||||
last = index
|
||||
}
|
||||
}
|
||||
|
||||
func press(t *testing.T, model Model, key tea.KeyMsg) Model {
|
||||
t.Helper()
|
||||
return updateModel(t, model, key)
|
||||
}
|
||||
|
||||
func updateModel(t *testing.T, model Model, msg tea.Msg) Model {
|
||||
t.Helper()
|
||||
updated, _ := model.Update(msg)
|
||||
next, ok := updated.(Model)
|
||||
if !ok {
|
||||
t.Fatalf("updated model type = %T, want ui.Model", updated)
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package theme defines the fixed v1 terminal styling for limen.
|
||||
package theme
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
Accent = lipgloss.Color("#5fd7ff")
|
||||
Online = lipgloss.Color("#5fdf87")
|
||||
Unreachable = lipgloss.Color("#6c6c6c")
|
||||
Probing = lipgloss.Color("#87afdf")
|
||||
Dim = lipgloss.Color("#8a8a8a")
|
||||
|
||||
Title = lipgloss.NewStyle().
|
||||
Foreground(Accent).
|
||||
Bold(true)
|
||||
|
||||
Pane = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(Accent).
|
||||
Padding(0, 1)
|
||||
|
||||
Selected = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#0f1720")).
|
||||
Background(Accent)
|
||||
|
||||
Dimmed = lipgloss.NewStyle().
|
||||
Foreground(Dim)
|
||||
|
||||
OnlineStyle = lipgloss.NewStyle().
|
||||
Foreground(Online)
|
||||
|
||||
UnreachableStyle = lipgloss.NewStyle().
|
||||
Foreground(Unreachable)
|
||||
|
||||
ProbingStyle = lipgloss.NewStyle().
|
||||
Foreground(Probing)
|
||||
)
|
||||
@@ -1,34 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/KofTwentyTwo/limen/internal/config"
|
||||
limenexec "github.com/KofTwentyTwo/limen/internal/exec"
|
||||
"github.com/KofTwentyTwo/limen/internal/state"
|
||||
"github.com/KofTwentyTwo/limen/internal/ui"
|
||||
)
|
||||
|
||||
type output struct {
|
||||
Config config.Config `json:"config"`
|
||||
State state.State `json:"state"`
|
||||
var version = "dev"
|
||||
|
||||
type appDeps struct {
|
||||
runUI func(ui.App) (ui.Result, error)
|
||||
handoff func(limenexec.Plan, limenexec.Options) int
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
os.Exit(runWithDeps(os.Args[1:], os.Stdout, os.Stderr, appDeps{
|
||||
runUI: ui.Run,
|
||||
handoff: limenexec.Handoff,
|
||||
}))
|
||||
}
|
||||
|
||||
func run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
return runWithDeps(args, stdout, stderr, appDeps{
|
||||
runUI: ui.Run,
|
||||
handoff: limenexec.Handoff,
|
||||
})
|
||||
}
|
||||
|
||||
func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
flags := flag.NewFlagSet("limen", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
|
||||
configPath := flags.String("config", config.DefaultPath(), "path to hosts.json")
|
||||
statePath := flags.String("state", state.DefaultPath(), "path to state.json")
|
||||
showVersion := flags.Bool("version", false, "print version")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
if *showVersion {
|
||||
fmt.Fprintln(stdout, version)
|
||||
return 0
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
fmt.Fprintf(stderr, "limen: unexpected argument %q\n", flags.Arg(0))
|
||||
return 2
|
||||
@@ -41,12 +63,21 @@ func run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
}
|
||||
|
||||
st := state.Load(*statePath)
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(output{Config: cfg, State: st}); err != nil {
|
||||
fmt.Fprintf(stderr, "limen: cannot write output: %v\n", err)
|
||||
|
||||
result, err := deps.runUI(ui.App{Config: cfg, State: st})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "limen: ui failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if !result.HasPlan {
|
||||
return result.ExitCode
|
||||
}
|
||||
if result.Message != "" {
|
||||
fmt.Fprintln(stderr, result.Message)
|
||||
}
|
||||
|
||||
return 0
|
||||
return deps.handoff(result.Plan, limenexec.Options{
|
||||
StatePath: *statePath,
|
||||
Stderr: stderr,
|
||||
})
|
||||
}
|
||||
+120
-75
@@ -2,15 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
limenexec "github.com/KofTwentyTwo/limen/internal/exec"
|
||||
"github.com/KofTwentyTwo/limen/internal/ui"
|
||||
)
|
||||
|
||||
func TestRunPrintsConfigAndStateAsJSON(t *testing.T) {
|
||||
func TestRunLoadsConfigStateAndHandsOffSelectedPlan(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "hosts.json")
|
||||
statePath := filepath.Join(dir, "state.json")
|
||||
@@ -22,88 +25,101 @@ func TestRunPrintsConfigAndStateAsJSON(t *testing.T) {
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(statePath, []byte(`{
|
||||
"lastAttached": {
|
||||
"prod": "2026-05-26T13:45:00Z"
|
||||
}
|
||||
}`), 0o600); err != nil {
|
||||
if err := os.WriteFile(statePath, []byte(`{"lastAttached":{"prod":"2026-05-26T13:45:00Z"}}`), 0o600); err != nil {
|
||||
t.Fatalf("write state fixture: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--config", configPath, "--state", statePath}, &stdout, &stderr)
|
||||
wantPlan := limenexec.Plan{HostName: "prod", Argv: []string{"ssh", "-t", "prod.example.com", "tmux", "new"}}
|
||||
var sawHosts int
|
||||
var sawStateEntries int
|
||||
var handedPlan limenexec.Plan
|
||||
var handedOptions limenexec.Options
|
||||
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", statePath}, &bytes.Buffer{}, &bytes.Buffer{}, appDeps{
|
||||
runUI: func(app ui.App) (ui.Result, error) {
|
||||
sawHosts = len(app.Config.Hosts)
|
||||
sawStateEntries = len(app.State.LastAttached)
|
||||
return ui.Result{Plan: wantPlan, HasPlan: true}, nil
|
||||
},
|
||||
handoff: func(plan limenexec.Plan, opts limenexec.Options) int {
|
||||
handedPlan = plan
|
||||
handedOptions = opts
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0; stderr = %q", code, stderr.String())
|
||||
t.Fatalf("run exit code = %d, want 0", code)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
if sawHosts != 1 {
|
||||
t.Fatalf("UI saw %d hosts, want 1", sawHosts)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Config struct {
|
||||
Hosts []struct {
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
User string `json:"user,omitempty"`
|
||||
} `json:"hosts"`
|
||||
} `json:"config"`
|
||||
State struct {
|
||||
LastAttached map[string]time.Time `json:"lastAttached"`
|
||||
} `json:"state"`
|
||||
if sawStateEntries != 1 {
|
||||
t.Fatalf("UI saw %d state entries, want 1", sawStateEntries)
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
if !reflect.DeepEqual(handedPlan, wantPlan) {
|
||||
t.Fatalf("handoff plan = %#v, want %#v", handedPlan, wantPlan)
|
||||
}
|
||||
|
||||
if len(got.Config.Hosts) != 1 {
|
||||
t.Fatalf("len(hosts) = %d, want 1", len(got.Config.Hosts))
|
||||
}
|
||||
if got.Config.Hosts[0].Name != "prod" || got.Config.Hosts[0].Hostname != "prod.example.com" || got.Config.Hosts[0].User != "deploy" {
|
||||
t.Fatalf("host = %#v", got.Config.Hosts[0])
|
||||
}
|
||||
|
||||
wantTime := time.Date(2026, 5, 26, 13, 45, 0, 0, time.UTC)
|
||||
if gotTime := got.State.LastAttached["prod"]; !gotTime.Equal(wantTime) {
|
||||
t.Fatalf("state timestamp = %s, want %s", gotTime, wantTime)
|
||||
if handedOptions.StatePath != statePath {
|
||||
t.Fatalf("handoff state path = %q, want %q", handedOptions.StatePath, statePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWarnsForMissingConfigAndPrintsEmptyHosts(t *testing.T) {
|
||||
func TestRunPrintsResultMessageBeforeHandoff(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "hosts.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"hosts":[]}`), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &bytes.Buffer{}, &stderr, appDeps{
|
||||
runUI: func(app ui.App) (ui.Result, error) {
|
||||
return ui.Result{
|
||||
Plan: limenexec.Plan{HostName: "localhost", Argv: []string{"tmux", "new"}},
|
||||
HasPlan: true,
|
||||
Message: "terminal too small for limen UI; falling back to bare tmux new",
|
||||
}, nil
|
||||
},
|
||||
handoff: func(plan limenexec.Plan, opts limenexec.Options) int {
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0", code)
|
||||
}
|
||||
want := "terminal too small for limen UI; falling back to bare tmux new\n"
|
||||
if stderr.String() != want {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWarnsForMissingConfigAndStillStartsUI(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "hosts.json")
|
||||
statePath := filepath.Join(t.TempDir(), "state.json")
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--config", configPath, "--state", statePath}, &stdout, &stderr)
|
||||
var sawHosts int
|
||||
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", statePath}, &bytes.Buffer{}, &stderr, appDeps{
|
||||
runUI: func(app ui.App) (ui.Result, error) {
|
||||
sawHosts = len(app.Config.Hosts)
|
||||
return ui.Result{ExitCode: 0}, nil
|
||||
},
|
||||
handoff: func(plan limenexec.Plan, opts limenexec.Options) int {
|
||||
t.Fatal("handoff should not be called")
|
||||
return 1
|
||||
},
|
||||
})
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0; stderr = %q", code, stderr.String())
|
||||
}
|
||||
|
||||
wantWarning := "limen: no hosts.json found at " + configPath + "; only localhost will be available\n"
|
||||
if stderr.String() != wantWarning {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), wantWarning)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Config struct {
|
||||
Hosts []struct{} `json:"hosts"`
|
||||
} `json:"config"`
|
||||
State struct {
|
||||
LastAttached map[string]time.Time `json:"lastAttached"`
|
||||
} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Config.Hosts) != 0 {
|
||||
t.Fatalf("len(hosts) = %d, want 0", len(got.Config.Hosts))
|
||||
}
|
||||
if len(got.State.LastAttached) != 0 {
|
||||
t.Fatalf("len(lastAttached) = %d, want 0", len(got.State.LastAttached))
|
||||
if sawHosts != 0 {
|
||||
t.Fatalf("UI saw %d configured hosts, want 0", sawHosts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +131,7 @@ func TestRunReturnsFailureForConfigErrors(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &stdout, &stderr)
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &stdout, &stderr, appDeps{})
|
||||
|
||||
if code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
@@ -164,29 +180,58 @@ func TestRunReturnsUsageFailureForUnexpectedArguments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsFailureWhenJSONOutputCannotBeWritten(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "hosts.json")
|
||||
statePath := filepath.Join(t.TempDir(), "state.json")
|
||||
func TestRunReturnsVersion(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := run([]string{"--version"}, &stdout, &stderr)
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0", code)
|
||||
}
|
||||
if stdout.String() != version+"\n" {
|
||||
t.Fatalf("stdout = %q, want version", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := run([]string{"--help"}, &stdout, &stderr)
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("run exit code = %d, want 0", code)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "Usage of limen") {
|
||||
t.Fatalf("stderr = %q, want usage", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsUIFailure(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "hosts.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"hosts":[]}`), 0o600); err != nil {
|
||||
t.Fatalf("write config fixture: %v", err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--config", configPath, "--state", statePath}, failingWriter{}, &stderr)
|
||||
code := runWithDeps([]string{"--config", configPath, "--state", filepath.Join(t.TempDir(), "state.json")}, &bytes.Buffer{}, &stderr, appDeps{
|
||||
runUI: func(app ui.App) (ui.Result, error) {
|
||||
return ui.Result{}, errors.New("terminal unavailable")
|
||||
},
|
||||
})
|
||||
|
||||
if code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
|
||||
want := "limen: cannot write output: write failed\n"
|
||||
want := "limen: ui failed: terminal unavailable\n"
|
||||
if stderr.String() != want {
|
||||
t.Fatalf("stderr = %q, want %q", stderr.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("write failed")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
class Limen < Formula
|
||||
desc "Terminal launcher TUI for tmux and SSH"
|
||||
homepage "https://github.com/KofTwentyTwo/limen"
|
||||
version "0.1.0"
|
||||
license "GPL-3.0-only"
|
||||
|
||||
depends_on "tmux"
|
||||
|
||||
host_cpu = RbConfig::CONFIG.fetch("host_cpu", "")
|
||||
macos_arm = OS.mac? && (Hardware::CPU.arm? || host_cpu == "arm64" || RUBY_PLATFORM.start_with?("arm64-darwin"))
|
||||
linux_arm = OS.linux? && (Hardware::CPU.arm? || host_cpu == "aarch64" || host_cpu == "arm64")
|
||||
|
||||
if macos_arm
|
||||
url "https://github.com/KofTwentyTwo/limen/releases/download/v#{version}/limen-macos-arm64"
|
||||
sha256 "PLACEHOLDER_MACOS_ARM64_SHA256"
|
||||
elsif OS.mac?
|
||||
url "https://github.com/KofTwentyTwo/limen/releases/download/v#{version}/limen-macos-amd64"
|
||||
sha256 "PLACEHOLDER_MACOS_AMD64_SHA256"
|
||||
elsif linux_arm
|
||||
url "https://github.com/KofTwentyTwo/limen/releases/download/v#{version}/limen-linux-arm64"
|
||||
sha256 "PLACEHOLDER_LINUX_ARM64_SHA256"
|
||||
else
|
||||
url "https://github.com/KofTwentyTwo/limen/releases/download/v#{version}/limen-linux-amd64"
|
||||
sha256 "PLACEHOLDER_LINUX_AMD64_SHA256"
|
||||
end
|
||||
|
||||
def install
|
||||
binary = Dir["limen-*"].first
|
||||
chmod 0755, binary
|
||||
bin.install binary => "limen"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/limen --version")
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user