From ec95b890d63c7a8aaa754db1cd73535c126ed7f1 Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Tue, 12 May 2026 17:25:01 +0200 Subject: [PATCH] feat: add cross-platform installer scripts and binary release pipeline Adds install/install.sh (Mac/Linux) and install/install.ps1 (Windows) for one-command install with no Python prerequisite, backed by a GitHub Actions matrix release workflow that builds PyInstaller standalone binaries for linux-x64, darwin-x64, darwin-arm64, and windows-x64. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 92 +++++++ ...g-session-2026-05-12-package-deployment.md | 252 ++++++++++++++++++ .../implementation-artifacts/deferred-work.md | 6 + .../spec-nimble-cross-platform-installer.md | 163 +++++++++++ install/install.ps1 | 109 ++++++++ install/install.sh | 159 +++++++++++ nimble/__main__.py | 4 + pyproject.toml | 1 + 8 files changed, 786 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 docs/bmad_output/brainstorming/brainstorming-session-2026-05-12-package-deployment.md create mode 100644 docs/bmad_output/implementation-artifacts/spec-nimble-cross-platform-installer.md create mode 100644 install/install.ps1 create mode 100644 install/install.sh create mode 100644 nimble/__main__.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..30e7046 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,92 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build: + permissions: + contents: write + + strategy: + matrix: + include: + - os: ubuntu-latest + target: linux-x64 + binary: nimble + - os: macos-14 + target: darwin-arm64 + binary: nimble + - os: macos-13 + target: darwin-x64 + binary: nimble + - os: windows-latest + target: windows-x64 + binary: nimble.exe + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Build binary + run: > + pyinstaller --onefile --name nimble + --collect-all pynput + nimble/__main__.py + + - name: Smoke test binary (Unix) + if: runner.os != 'Windows' + run: ./dist/nimble --help + + - name: Smoke test binary (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: .\dist\nimble.exe --help + + - name: Rename binary (Unix) + if: runner.os != 'Windows' + run: mv dist/nimble dist/nimble-${{ matrix.target }} + + - name: Rename binary (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: Move-Item dist\nimble.exe dist\nimble-${{ matrix.target }}.exe + + - name: Generate SHA256 (Unix) + if: runner.os != 'Windows' + run: | + cd dist + sha256sum nimble-${{ matrix.target }} > nimble-${{ matrix.target }}.sha256 + + - name: Generate SHA256 (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $hash = (Get-FileHash "dist\nimble-${{ matrix.target }}.exe" -Algorithm SHA256).Hash + $hash | Out-File -FilePath "dist\nimble-${{ matrix.target }}.exe.sha256" -NoNewline -Encoding ascii + + - name: Upload release assets (Unix) + if: runner.os != 'Windows' + uses: softprops/action-gh-release@v2 + with: + files: | + dist/nimble-${{ matrix.target }} + dist/nimble-${{ matrix.target }}.sha256 + + - name: Upload release assets (Windows) + if: runner.os == 'Windows' + uses: softprops/action-gh-release@v2 + with: + files: | + dist/nimble-${{ matrix.target }}.exe + dist/nimble-${{ matrix.target }}.exe.sha256 diff --git a/docs/bmad_output/brainstorming/brainstorming-session-2026-05-12-package-deployment.md b/docs/bmad_output/brainstorming/brainstorming-session-2026-05-12-package-deployment.md new file mode 100644 index 0000000..ef97098 --- /dev/null +++ b/docs/bmad_output/brainstorming/brainstorming-session-2026-05-12-package-deployment.md @@ -0,0 +1,252 @@ +--- +stepsCompleted: [1, 2, 3, 4] +inputDocuments: [] +session_topic: 'Packaging and distributing Nimble across different environments' +session_goals: 'List packaging/distribution options to evaluate; design the ideal install experience; identify edge cases and gotchas for each approach' +selected_approach: 'ai-recommended' +techniques_used: ['Constraint Mapping', 'Cross-Pollination', 'Reverse Brainstorming'] +ideas_generated: 31 +session_active: false +workflow_completed: true +context_file: '' +--- + +# Brainstorming Session Results + +**Facilitator:** Bernard +**Date:** 2026-05-12 + +## Session Overview + +**Topic:** Packaging and distributing Nimble across different environments +**Goals:** +1. Generate a comprehensive list of packaging/distribution options to evaluate +2. Design the ideal install experience — what it should feel and look like for users +3. Surface edge cases, failure modes, and gotchas for each approach + +### Session Setup + +AI-recommended technique sequence chosen for multi-angle exploration of a concrete technical challenge. + +## Technique Selection + +**Approach:** AI-Recommended Techniques +**Analysis Context:** Cross-platform distribution of a developer CLI tool with focus on install experience design, option evaluation, and risk mapping. + +**Recommended Techniques:** + +- **Constraint Mapping:** Establishes the playing field — what platforms, user types, and runtime dependencies define the solution space before generating options +- **Cross-Pollination:** Generates the distribution options list by studying how analogous tools (uv, rustup, mise, deno) handle the same challenge +- **Reverse Brainstorming:** Surfaces edge cases and gotchas by deliberately asking "how would this fail?" — directly informs install experience design + +**AI Rationale:** Goals span three phases naturally: understand constraints → generate options → stress-test them. These three techniques map perfectly to that arc, moving from structured/deep analysis to creative generation to adversarial risk discovery. + +--- + +## Phase 1: Constraint Mapping Results + +| # | Constraint | Impact | +|---|-----------|--------| +| 1 | Dual persona — developers primary, semi-technical secondary (all must use terminal) | Keep install command simple and memorable | +| 2 | Python-based internally — but users should not need Python installed | Bundle Python or compile to standalone binary | +| 3 | All 3 platforms: Mac, Linux, Windows — Day 1 | Multi-arch binary build pipeline required from launch | +| 4 | One-command experience goal — channel is secondary to the feeling | Architecture must make one command reliably work everywhere | +| 5 | Python 3.10+ requirement — internal build concern, not user concern | User's system Python is irrelevant with binary distribution | +| 6 | Shell installer as primary vector — `install.sh` + `install.ps1` | Thin scripts that download the right binary from GitHub Releases | +| 7 | Windows Day 1 — dual installer required | Forces binary targets: mac-arm64, mac-x64, linux-x64, linux-arm64, windows-x64 | +| 8 | GitHub Actions as primary CI target — for testing install across environments | `setup-nimble` action or clean curl-in-run-step story | +| 9 | Sudo required — installs to `/usr/local/bin` | Must warn users upfront; future `--user` flag for escape hatch | +| 10 | No auto-update yet — `nimble update` planned | Installer script should be reusable internally by update command | +| 11 | Modern Linux only — Ubuntu 22.04+, Debian 12+, Fedora 38+ | Can target glibc 2.35+; Alpine/musl is a separate future concern | + +--- + +## Phase 2: Cross-Pollination Results + +### Distribution Options Inventory + +#### Launch (Day 1) + +**[Distribution #1]**: `install.sh` — Mac/Linux Primary Installer +*Concept*: `curl -fsSL https://get.nimble.sh | sh` — detects OS/arch, downloads the right binary from GitHub Releases, places it on PATH. The canonical install path for Mac and Linux users. +*Pattern borrowed from*: uv (Astral), rustup, mise + +**[Distribution #2]**: `install.ps1` — Windows Primary Installer +*Concept*: `powershell -c "iwr https://get.nimble.sh/install.ps1 | iex"` — PowerShell equivalent, bypasses execution policy via inline execution, installs binary to system PATH. +*Pattern borrowed from*: uv Windows installer, Scoop installer + +**[Distribution #3]**: GitHub Releases Binary Distribution +*Concept*: Every release publishes pre-built binaries to GitHub Releases (nimble-darwin-arm64, nimble-darwin-x64, nimble-linux-x64, nimble-linux-arm64, nimble-windows-x64.exe + SHA256 checksums). Both install scripts and power users point here. +*Pattern borrowed from*: ruff, deno, gh CLI + +#### Install Script Internal Design + +**[Distribution #8]**: OS + Arch Detection with Normalization +*Concept*: `uname -s` for OS, `uname -m` for arch. Must normalize: `aarch64` → `arm64` before constructing the download URL. Without normalization, Apple Silicon and Linux ARM users get "exec format error." + +**[Distribution #9]**: SHA256 Checksum Verification +*Concept*: After downloading the binary, verify against a `.sha256` file published alongside each release on GitHub Releases. Fails loudly with a human-readable error if corrupted or tampered. + +**[Distribution #10]**: Install Location Override via Env Var +*Concept*: Default to `/usr/local/bin` (requires sudo), but respect `NIMBLE_INSTALL_DIR` env var. `curl ... | NIMBLE_INSTALL_DIR=~/.local/bin sh` installs without sudo for restricted environments. + +#### Post-Launch (add as demand materializes) + +**[Distribution #4]**: Homebrew Formula (Mac/Linux) +*Concept*: `brew install nimble-ai/tap/nimble` — a Homebrew tap (GitHub repo with ~50 lines of Ruby formula). Native install for the large chunk of developers who reach for `brew` first. Includes `brew upgrade` for free. + +**[Distribution #5]**: Scoop (Windows) +*Concept*: `scoop install nimble` — the most developer-friendly Windows package manager. No admin required (user scope install). Developers who use `gh`, `ruff`, `deno` on Windows already have Scoop. + +**[Distribution #6]**: `pipx install nimble-ai` (PyPI) +*Concept*: Since Nimble is Python-based, publishing to PyPI gives the Python developer community their native install path. pipx isolates the environment. Requires Python 3.10+ on user's machine — works well for the Python dev persona. + +**[Distribution #7]**: winget / Chocolatey (Windows) +*Concept*: winget is Microsoft-official and growing fast. Chocolatey has the largest catalog. Lower priority than Scoop for the developer persona, but broadens reach to IT-managed Windows environments. + +--- + +## Phase 3: Reverse Brainstorming — Gotcha Map + +### `install.sh` Gotchas (Mac/Linux) + +**[Gotcha #1]**: The `curl | sh` Security Wall +*What fails*: Security-conscious users and corporate IT policies block or distrust `curl | sh` — they can't inspect what runs before executing. +*Fix*: Always publish the raw script URL separately. Document both forms: +- Fast: `curl -fsSL https://get.nimble.sh | sh` +- Inspect-first: `curl -o install.sh https://get.nimble.sh && cat install.sh && sh install.sh` + +**[Gotcha #2]**: `uname -m` Arch Mismatch +*What fails*: Apple Silicon returns `arm64`, Linux ARM returns `aarch64`. Naive script downloads wrong binary → cryptic "exec format error." +*Fix*: Explicit normalization in script: map `aarch64` → `arm64` before constructing GitHub Releases URL. + +**[Gotcha #3]**: GitHub Releases Blocked by Corporate Proxy +*What fails*: Enterprise environments proxy or block GitHub Releases. Script downloads nothing, fails with confusing TLS error. +*Fix*: Detect failed download explicitly. Print: *"Could not reach GitHub Releases — if behind a proxy, set HTTPS_PROXY and retry."* + +**[Gotcha #10]**: Sudo Prompt Surprise +*What fails*: Script silently requires sudo mid-execution, hangs waiting for password prompt. Looks like it froze. +*Fix*: Check for sudo upfront, before any action. Print: *"Nimble installs to /usr/local/bin and requires sudo. You may be prompted for your password."* + +### `install.ps1` Gotchas (Windows) + +**[Gotcha #4]**: PowerShell Execution Policy Block +*What fails*: Windows defaults to `Restricted` or `RemoteSigned`. Downloaded `.ps1` gets Mark-of-the-Web flag and is blocked before running. +*Fix*: Use `iwr | iex` (inline execution) pattern in the recommended install command — bypasses execution policy entirely. Document `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser` as alternative. + +**[Gotcha #5]**: Windows Defender Quarantines the Binary +*What fails*: Even after successful download, Defender silently quarantines unsigned executable. `nimble` command missing after apparent success. +*Fix*: Code sign the binary. Self-signed cert reduces friction. EV cert (~$300/yr) eliminates it. + +**[Gotcha #6]**: PATH Not Active in Current Session +*What fails*: Installer adds to PATH but change only takes effect in new sessions. User types `nimble` immediately after install → "command not found" → files bug thinking install failed. +*Fix*: Script ends with explicit message: *"Nimble installed! Open a new terminal to use it."* On Windows, attempt `$env:PATH` refresh for current session where possible. + +### Cross-Platform Gotchas + +**[Gotcha #7]**: WSL Identity Crisis +*What fails*: WSL users see Linux but live in Windows. Docs say "Windows → use install.ps1" — confused users try wrong script. +*Fix*: Docs need explicit WSL callout: *"On WSL, use the Linux installer."* Script detects WSL via `/proc/version` containing `Microsoft` and prints a confirmation. + +**[Gotcha #8]**: Partial Download on Network Interruption +*What fails*: Network drops mid-download, corrupted binary placed on PATH. `nimble` crashes with bizarre error on every invocation. +*Fix*: Checksum verification catches this. Error message must say: *"Download may be corrupted — retry the install"* not a raw hash mismatch. + +**[Gotcha #9]**: Spaces or Unicode in PATH +*What fails*: User with `C:\Users\André\` or a space in their username causes script to silently fail or misplace the binary. +*Fix*: All path operations in both scripts must be quoted. Sounds obvious; catches many scripts out. + +--- + +## Idea Organization and Prioritization + +### Thematic Organization + +**Theme 1: Distribution Architecture** — what channels, in what order + +- Day 1: `install.sh` + `install.ps1` + GitHub Releases multi-arch binaries +- Soon after: Homebrew tap, pipx/PyPI, Scoop +- Later: winget, Chocolatey, `nimble update` self-update + +**Theme 2: Install Script Internal Design** — what the scripts must do + +- OS/arch detection with normalization +- SHA256 checksum verification +- `NIMBLE_INSTALL_DIR` override +- Upfront sudo warning +- Clear post-install message +- WSL detection + +**Theme 3: Windows-Specific** — the hardest platform + +- `iwr | iex` pattern to bypass execution policy +- Code signing to prevent Defender quarantine +- PATH session refresh or clear message +- WSL callout in docs + +**Theme 4: Resilience & User Trust** — silent failure prevention + +- Checksum catches partial downloads → human-readable error +- Corporate proxy detection → HTTPS_PROXY hint +- Inspect-first install form documented +- Quoted paths throughout + +### Prioritization Results + +**Quick wins — implement inside the install scripts:** +1. Upfront sudo warning before any action +2. Clear "open a new terminal" message at completion +3. `aarch64` → `arm64` normalization +4. Quoted paths throughout both scripts +5. WSL detection and confirmation message + +**Medium effort, high value — before launch:** +1. SHA256 checksum verification with human-readable error +2. `NIMBLE_INSTALL_DIR` env var for no-sudo installs +3. Corporate proxy failure detection with HTTPS_PROXY hint +4. `iwr | iex` pattern for PowerShell install command +5. Multi-arch binary build pipeline in CI (GitHub Actions on release tag) + +**Invest before launch:** +1. Code signing for Windows binary (self-signed minimum) +2. GitHub Releases publish automation + +**Post-launch when demand materializes:** +1. Homebrew tap, pipx/PyPI, Scoop +2. `nimble update` self-update command +3. EV code signing for Windows +4. winget / Chocolatey + +--- + +## Action Plan + +### This Week +1. **Set up binary build pipeline** — GitHub Actions workflow that builds multi-arch binaries (mac-arm64, mac-x64, linux-x64, linux-arm64, windows-x64) and publishes to GitHub Releases on every release tag, including SHA256 checksums +2. **Write `install.sh`** — OS/arch detection with normalization, checksum verification, upfront sudo check, `NIMBLE_INSTALL_DIR` support, WSL detection, clear post-install message +3. **Write `install.ps1`** — equivalent script, `iwr | iex` compatible, PATH refresh attempt, same messaging quality + +### Before Launch +4. **Test on clean environments** — fresh Ubuntu 22.04, macOS Intel, macOS Apple Silicon, Windows 10, Windows 11, WSL2 +5. **Document both install forms** — one-liner AND inspect-then-run, with WSL callout and platform guidance +6. **Self-sign the Windows binary** — reduce Defender friction from day one + +### Post-Launch +7. **Monitor where users get stuck** — prioritize Homebrew tap, pipx/PyPI, or Scoop based on actual issue reports and questions +8. **Implement `nimble update`** — reuse install script internally, do not create a separate code path + +--- + +## Session Summary and Insights + +**Key Achievements:** +- 11 constraints mapped that filter the entire solution space +- Clear Day 1 distribution decision: `install.sh` + `install.ps1` only +- 10 gotchas identified before users find them +- Concrete action plan with sequenced priorities + +**Breakthrough Insight:** The Python 3.10+ requirement is an *internal* build concern, not a user concern — the standalone binary approach decouples Nimble's runtime from the user's system entirely. This decision resolved the core distribution challenge. + +**Key Decision Made:** Start lean with two installer scripts. Every other distribution channel (brew, pipx, scoop, winget) is a post-launch spoke pointing to the same GitHub Releases binaries. Low maintenance cost when added; no cost if deferred. + +**Most Actionable Gotcha:** The sudo prompt surprise and the PATH-not-active-in-current-session issue are the two most likely to generate "install is broken" bug reports. Both are fixed with one line of output at the right moment in the script. diff --git a/docs/bmad_output/implementation-artifacts/deferred-work.md b/docs/bmad_output/implementation-artifacts/deferred-work.md index 6e0ee1f..253a384 100644 --- a/docs/bmad_output/implementation-artifacts/deferred-work.md +++ b/docs/bmad_output/implementation-artifacts/deferred-work.md @@ -1,5 +1,11 @@ # Deferred Work +## Deferred from: nimble-cross-platform-installer (2026-05-12) + +- `install/install.sh` + `install/install.ps1`: SHA256 mismatch error messages do not show the expected/actual hash values — a user cannot distinguish a transient network corruption from a MITM without the hash output. Add hash display in a future installer hardening pass. +- `install/install.ps1`: If the script is interrupted via Ctrl-C before or during the `try` block, the temp directory in `$env:TEMP` may not be cleaned up (PowerShell `finally` does not run on Ctrl-C). Acceptable for an installer; address with `Register-EngineEvent PowerShell.Exiting` in a future hardening pass. +- `install/install.sh` + `.github/workflows/release.yml`: Windows SHA256 sidecar file contains only the raw hash (no filename field); `sha256sum -c nimble-windows-x64.exe.sha256` fails on Linux/Mac because the standard format expects ` `. Unix sidecars produced by `sha256sum` include the filename. Normalise format in a future release pipeline pass. + ## Deferred from: fix-nimble-skill-config-hard-failure (2026-05-10) - `worker/entrypoint.py` except clause: `except (json.JSONDecodeError, ValueError)` — `ValueError` is redundant since `JSONDecodeError` is already a subclass. Pre-existing pattern from the original code; clean up in a future hardening pass. diff --git a/docs/bmad_output/implementation-artifacts/spec-nimble-cross-platform-installer.md b/docs/bmad_output/implementation-artifacts/spec-nimble-cross-platform-installer.md new file mode 100644 index 0000000..f6f3db3 --- /dev/null +++ b/docs/bmad_output/implementation-artifacts/spec-nimble-cross-platform-installer.md @@ -0,0 +1,163 @@ +--- +title: 'Nimble Cross-Platform Installer' +type: 'feature' +created: '2026-05-12' +status: 'done' +baseline_commit: 'd451d00315a1dc67b4897c4450bf71824c2895f4' +context: [] +--- + + + +## Intent + +**Problem:** Nimble has no user-facing install mechanism — the only path to a working binary is `pip install -e ".[dev]"` in a cloned repo, requiring Python 3.10+ and blocking anyone without a dev environment. + +**Approach:** Add a PyInstaller-based GitHub Actions release pipeline that publishes standalone binaries to GitHub Releases, and `install/install.sh` + `install/install.ps1` scripts that give any user a single-command install with no Python prerequisite. + +## Boundaries & Constraints + +**Always:** +- Binary targets: `linux-x64`, `darwin-x64`, `darwin-arm64`, `windows-x64` — each built natively on its platform runner (no cross-compilation) +- Default install paths: `/usr/local/bin` (Linux/Mac), `$env:ProgramFiles\Nimble` (Windows); both overridable via `NIMBLE_INSTALL_DIR` +- SHA256 checksum published alongside every binary; both scripts verify before placing binary on PATH +- Sudo/admin requirement stated upfront before any action is taken +- `aarch64` normalized to `arm64` in install.sh before constructing download URL +- WSL detected via `/proc/version` containing `Microsoft`; script prints confirmation and continues with Linux path +- All path variables double-quoted throughout both scripts +- Download failure exits non-zero and prints: `"Could not reach GitHub Releases — if behind a proxy, set HTTPS_PROXY and retry"` +- Checksum mismatch exits non-zero, deletes partial file, prints: `"Download may be corrupted — retry the install"` +- Post-install always prints: `"Nimble installed! Open a new terminal to use it."` +- GitHub Releases base URL: `https://github.com/adeptofvoltron/nimble/releases/download/` + +**Ask First:** +- Adding binary targets beyond the four defined above +- Changing default install location + +**Never:** +- PyPI packaging, Homebrew tap, Scoop, winget — post-launch +- `nimble update` self-update — post-launch +- `linux-arm64` target — deferred (no free native runner) + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Happy path Mac/Linux | `curl -fsSL .../install.sh \| sh`, no Python installed | Binary in `/usr/local/bin`, post-install message | — | +| Happy path Windows | `powershell -c "iwr .../install.ps1 \| iex"` | Binary on PATH, post-install message | — | +| Apple Silicon | `uname -m` returns `aarch64` | Normalized to `arm64`, correct binary fetched | — | +| WSL | `/proc/version` contains `Microsoft` | Prints WSL confirmation, continues with linux-x64 | — | +| `NIMBLE_INSTALL_DIR` set | Env var points to writable dir | Installs there, no sudo required | — | +| No sudo | User lacks sudo on Linux/Mac | Upfront warning printed, exits with `NIMBLE_INSTALL_DIR` hint | Exit non-zero | +| Corrupt/partial download | SHA256 mismatch | Partial file deleted, human-readable error printed | Exit non-zero | +| Network blocked | GitHub Releases unreachable | Proxy hint printed | Exit non-zero | +| Path with spaces/Unicode | `NIMBLE_INSTALL_DIR=/home/ján/bin` | Installs correctly via quoted paths | — | + + + +## Code Map + +- `nimble/__main__.py` — new; PyInstaller entry point wrapping `nimble.cli.commands:app` +- `nimble/cli/commands.py` — existing typer app; imported by `__main__.py` +- `install/install.sh` — new; POSIX sh installer for Mac/Linux +- `install/install.ps1` — new; PowerShell installer for Windows +- `.github/workflows/release.yml` — new; matrix release workflow across 4 platforms +- `.github/workflows/ci.yml` — existing; no changes +- `pyproject.toml` — add `pyinstaller>=6.0` to `[project.optional-dependencies] dev` + +## Tasks & Acceptance + +**Execution:** +- [x] `nimble/__main__.py` -- create with `from nimble.cli.commands import app` and `if __name__ == "__main__": app()` -- enables both `python -m nimble` and PyInstaller targeting +- [x] `pyproject.toml` -- add `pyinstaller>=6.0` to `[project.optional-dependencies] dev` -- makes PyInstaller available in dev and CI environments +- [x] `.github/workflows/release.yml` -- create release workflow triggered on `push` to `v*` tags; matrix: `[{os: ubuntu-latest, target: linux-x64}, {os: macos-latest, target: darwin-arm64}, {os: macos-13, target: darwin-x64}, {os: windows-latest, target: windows-x64}]`; each job: checkout → setup-python 3.10 → `pip install -e ".[dev]"` → `pyinstaller --onefile --name nimble nimble/__main__.py` → rename output to `nimble-{target}` (append `.exe` on Windows) → generate SHA256 sidecar → upload both files as release assets using `softprops/action-gh-release@v2` +- [x] `install/install.sh` -- create POSIX sh installer: detect OS/arch → normalize `aarch64`→`arm64` → detect WSL → check sudo upfront with warning → fetch latest tag via GitHub API → construct download URL → download with `curl -fsSL` (fallback `wget`) → verify SHA256 → install to `${NIMBLE_INSTALL_DIR:-/usr/local/bin}` (with sudo if default path) → print post-install message +- [x] `install/install.ps1` -- create PowerShell installer: detect arch → fetch latest tag via GitHub API → download binary with `Invoke-WebRequest` → verify SHA256 via `Get-FileHash` → install to `$env:NIMBLE_INSTALL_DIR` or `$env:ProgramFiles\Nimble` → add install dir to system PATH registry key if absent → print post-install message + +**Acceptance Criteria:** +- Given a published `v*` tag, when the release workflow completes, then GitHub Releases contains 8 files: `nimble-linux-x64`, `nimble-darwin-arm64`, `nimble-darwin-x64`, `nimble-windows-x64.exe` and their four `.sha256` companions +- Given `uname -m` returns `aarch64`, when install.sh runs, then it downloads `nimble-darwin-arm64` (not `nimble-darwin-aarch64`) +- Given `NIMBLE_INSTALL_DIR=~/.local/bin`, when install.sh runs, then binary is placed there and sudo is never invoked +- Given a SHA256 mismatch (simulated), when install.sh verifies, then it exits non-zero and prints the human-readable corruption message without leaving a partial binary behind +- Given `/proc/version` contains `Microsoft`, when install.sh runs, then it prints the WSL confirmation line before proceeding + +## Design Notes + +**PyInstaller entry point:** PyInstaller requires a plain script path, not a package entry-point string. `nimble/__main__.py` serves both PyInstaller and `python -m nimble` for dev use. + +**SHA256 generation in CI:** +- Linux/Mac: `sha256sum nimble-linux-x64 > nimble-linux-x64.sha256` +- Windows: `(Get-FileHash nimble-windows-x64.exe -Algorithm SHA256).Hash | Out-File nimble-windows-x64.exe.sha256 -NoNewline` + +**Latest-tag resolution in install.sh:** +```sh +LATEST=$(curl -fsSL https://api.github.com/repos/adeptofvoltron/nimble/releases/latest \ + | grep '"tag_name"' | cut -d'"' -f4) +``` + +**Windows PATH update:** Add install dir to the `HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment` `Path` key via `[Environment]::SetEnvironmentVariable` with `Machine` scope. + +## Verification + +**Commands:** +- `python -m nimble --help` -- expected: typer help output, confirms `__main__.py` wiring +- `pip install -e ".[dev]" && pyinstaller --onefile --name nimble-test nimble/__main__.py && ./dist/nimble-test --help` -- expected: help output from standalone binary with no Python on PATH +- `shellcheck install/install.sh` -- expected: exit 0, no POSIX compliance warnings + +**Manual checks:** +- Push a `v0.0.1-test` tag; confirm Actions shows 4 matrix jobs green and GitHub Releases page lists 8 assets +- On Windows: run `powershell -c "iwr .../install.ps1 | iex"`, open new terminal, confirm `nimble --help` works + +## Suggested Review Order + +**Entry point** + +- Thin wrapper enabling `python -m nimble` and PyInstaller targeting in one file + [`__main__.py:1`](../../../nimble/__main__.py#L1) + +**Release pipeline** + +- `permissions: contents: write` — required for `softprops/action-gh-release@v2` to upload assets + [`release.yml:10`](../../../.github/workflows/release.yml#L10) + +- `macos-14` pinned explicitly — guarantees Apple Silicon runner, not subject to `macos-latest` drift + [`release.yml:19`](../../../.github/workflows/release.yml#L19) + +- `--collect-all pynput` — bundles all pynput backends; static analysis misses dynamic platform imports + [`release.yml:48`](../../../.github/workflows/release.yml#L48) + +- Smoke test runs built binary before upload — catches PyInstaller import failures before they ship + [`release.yml:51`](../../../.github/workflows/release.yml#L51) + +**Unix installer** + +- Target validation rejects unsupported platforms with a clear message instead of a silent download failure + [`install.sh:33`](../../../install/install.sh#L33) + +- Writability check instead of string comparison — catches `NIMBLE_INSTALL_DIR=/usr/local/bin` edge case + [`install.sh:56`](../../../install/install.sh#L56) + +- Version format guard — catches GitHub API rate-limit responses before constructing a garbage URL + [`install.sh:80`](../../../install/install.sh#L80) + +- Single-line error messages match spec exactly; HTTPS_PROXY hint on any download failure + [`install.sh:99`](../../../install/install.sh#L99) + +**Windows installer** + +- Null-safe `$env:TEMP` resolution — falls back through `$env:TMP` to `GetTempPath()` + [`install.ps1:52`](../../../install/install.ps1#L52) + +- Error messages updated to "set HTTPS_PROXY" matching spec wording + [`install.ps1:63`](../../../install/install.ps1#L63) + +- PATH check uses `-split ';'` exact match — glob `-like` matched partial directory names + [`install.ps1:91`](../../../install/install.ps1#L91) + +- Null PATH registry value guarded — prevents leading semicolon on fresh Windows images + [`install.ps1:94`](../../../install/install.ps1#L94) + +**Dev dependencies** + +- PyInstaller added to dev extras — available in CI and local builds via `pip install -e ".[dev]"` + [`pyproject.toml:29`](../../../pyproject.toml#L29) diff --git a/install/install.ps1 b/install/install.ps1 new file mode 100644 index 0000000..9ca678a --- /dev/null +++ b/install/install.ps1 @@ -0,0 +1,109 @@ +#Requires -Version 5.1 +[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + +$ErrorActionPreference = "Stop" + +$Repo = "adeptofvoltron/nimble" +$Target = "windows-x64" +$BinaryName = "nimble.exe" + +# ── Resolve install directory ───────────────────────────────────────────────── +if ($env:NIMBLE_INSTALL_DIR) { + $InstallDir = $env:NIMBLE_INSTALL_DIR + $NeedAdmin = $false +} else { + $InstallDir = Join-Path $env:ProgramFiles "Nimble" + $NeedAdmin = $true +} + +# ── Admin check ─────────────────────────────────────────────────────────────── +if ($NeedAdmin) { + $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) + if (-not $isAdmin) { + Write-Host "Nimble installs to '$InstallDir' and requires administrator privileges." + Write-Host "Please re-run this script as Administrator, or set NIMBLE_INSTALL_DIR to a writable directory." + exit 1 + } +} + +# ── Fetch latest release tag ────────────────────────────────────────────────── +Write-Host "Fetching latest Nimble release..." +try { + $Release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -UseBasicParsing + $Tag = $Release.tag_name +} catch { + Write-Host "Error: Could not reach GitHub Releases — if behind a proxy, set HTTPS_PROXY and retry." + exit 1 +} + +if (-not $Tag) { + Write-Host "Error: Could not determine latest release tag (GitHub API may be rate-limiting — try again in a minute)." + exit 1 +} + +Write-Host "Installing Nimble $Tag ($Target)..." + +# ── Build download URLs ─────────────────────────────────────────────────────── +$BaseUrl = "https://github.com/$Repo/releases/download/$Tag" +$BinaryUrl = "$BaseUrl/nimble-$Target.exe" +$ChecksumUrl = "$BaseUrl/nimble-$Target.exe.sha256" + +# ── Download to temp directory ──────────────────────────────────────────────── +$TempBase = if ($env:TEMP) { $env:TEMP } elseif ($env:TMP) { $env:TMP } else { [System.IO.Path]::GetTempPath() } +$TmpDir = Join-Path $TempBase "nimble-install-$([System.IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $TmpDir | Out-Null + +$TmpBin = Join-Path $TmpDir $BinaryName +$TmpSum = Join-Path $TmpDir "nimble.sha256" + +try { + try { + Invoke-WebRequest -Uri $BinaryUrl -OutFile $TmpBin -UseBasicParsing + } catch { + Write-Host "Error: Could not reach GitHub Releases — if behind a proxy, set HTTPS_PROXY and retry." + exit 1 + } + + try { + Invoke-WebRequest -Uri $ChecksumUrl -OutFile $TmpSum -UseBasicParsing + } catch { + Write-Host "Error: Could not reach GitHub Releases — if behind a proxy, set HTTPS_PROXY and retry." + exit 1 + } + + # ── Verify SHA256 ───────────────────────────────────────────────────────── + $Expected = (Get-Content $TmpSum -Raw).Trim().ToUpper() -split '\s+' | Select-Object -First 1 + $Actual = (Get-FileHash $TmpBin -Algorithm SHA256).Hash.ToUpper() + + if ($Actual -ne $Expected) { + Write-Host "Error: Download may be corrupted — retry the install." + exit 1 + } + + # ── Install ─────────────────────────────────────────────────────────────── + if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir | Out-Null + } + + $Destination = Join-Path $InstallDir $BinaryName + Move-Item -Force $TmpBin $Destination + + # ── Add to PATH if not already present ──────────────────────────────────── + $PathScope = if ($NeedAdmin) { "Machine" } else { "User" } + $CurrentPath = [Environment]::GetEnvironmentVariable("Path", $PathScope) + if (-not $CurrentPath) { $CurrentPath = "" } + + $PathEntries = $CurrentPath -split ';' | Where-Object { $_ -ne '' } + if ($InstallDir -notin $PathEntries) { + $NewPath = ($PathEntries + $InstallDir) -join ';' + [Environment]::SetEnvironmentVariable("Path", $NewPath, $PathScope) + Write-Host "Added '$InstallDir' to system PATH." + } + +} finally { + Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue +} + +Write-Host "" +Write-Host "Nimble installed! Open a new terminal to use it." +Write-Host " Run: nimble --help" diff --git a/install/install.sh b/install/install.sh new file mode 100644 index 0000000..5941027 --- /dev/null +++ b/install/install.sh @@ -0,0 +1,159 @@ +#!/bin/sh +set -e + +REPO="adeptofvoltron/nimble" +BINARY_NAME="nimble" +DEFAULT_INSTALL_DIR="/usr/local/bin" + +# ── Detect OS ──────────────────────────────────────────────────────────────── +OS=$(uname -s) +case "$OS" in + Linux) PLATFORM="linux" ;; + Darwin) PLATFORM="darwin" ;; + *) + echo "Error: Unsupported operating system: $OS" >&2 + exit 1 + ;; +esac + +# ── Detect arch (normalize aarch64 → arm64) ────────────────────────────────── +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="x64" ;; + aarch64 | arm64) ARCH="arm64" ;; + *) + echo "Error: Unsupported architecture: $ARCH" >&2 + exit 1 + ;; +esac + +TARGET="${PLATFORM}-${ARCH}" + +# ── Validate supported target ───────────────────────────────────────────────── +case "$TARGET" in + linux-x64 | darwin-x64 | darwin-arm64) + : ;; + linux-arm64) + echo "Error: Linux ARM64 is not yet supported. Only linux-x64 is available on Linux." >&2 + exit 1 + ;; + *) + echo "Error: Unsupported platform: $TARGET" >&2 + exit 1 + ;; +esac + +# ── Detect WSL ─────────────────────────────────────────────────────────────── +if [ -f /proc/version ] && grep -qi "microsoft" /proc/version; then + echo "Detected WSL — using Linux installer (correct)." +fi + +# ── Resolve install directory ───────────────────────────────────────────────── +INSTALL_DIR="${NIMBLE_INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" + +# ── Sudo check ──────────────────────────────────────────────────────────────── +NEED_SUDO=false +if [ "$(id -u)" -ne 0 ]; then + if ! [ -w "$INSTALL_DIR" ] 2>/dev/null; then + NEED_SUDO=true + if ! command -v sudo >/dev/null 2>&1; then + echo "Error: Cannot write to \"$INSTALL_DIR\" and sudo is not available." >&2 + echo " Set NIMBLE_INSTALL_DIR to a writable directory and retry." >&2 + exit 1 + fi + echo "Nimble installs to \"$INSTALL_DIR\" and requires sudo. You may be prompted for your password." + if ! sudo -v 2>/dev/null; then + echo "Error: Could not obtain sudo. Set NIMBLE_INSTALL_DIR to a writable directory and retry." >&2 + exit 1 + fi + fi +fi + +# ── Fetch latest release tag ────────────────────────────────────────────────── +echo "Fetching latest Nimble release..." +LATEST=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' | cut -d'"' -f4) || true + +if [ -z "$LATEST" ]; then + echo "Error: Could not reach GitHub Releases — if behind a proxy, set HTTPS_PROXY and retry." >&2 + exit 1 +fi + +case "$LATEST" in + v*) : ;; + *) + echo "Error: Could not determine latest release tag (GitHub API may be rate-limiting — try again in a minute)." >&2 + exit 1 + ;; +esac + +echo "Installing Nimble $LATEST ($TARGET)..." + +# ── Build download URLs ─────────────────────────────────────────────────────── +BASE_URL="https://github.com/${REPO}/releases/download/${LATEST}" +BINARY_URL="${BASE_URL}/nimble-${TARGET}" +CHECKSUM_URL="${BASE_URL}/nimble-${TARGET}.sha256" + +# ── Download binary and checksum ────────────────────────────────────────────── +TMP_DIR=$(mktemp -d) +TMP_BIN="${TMP_DIR}/nimble" +TMP_SUM="${TMP_DIR}/nimble.sha256" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +download() { + URL="$1" + DEST="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL --output "$DEST" "$URL" || return 1 + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$DEST" "$URL" || return 1 + else + echo "Error: Neither curl nor wget found." >&2 + return 1 + fi +} + +if ! download "$BINARY_URL" "$TMP_BIN"; then + echo "Error: Could not reach GitHub Releases — if behind a proxy, set HTTPS_PROXY and retry." >&2 + exit 1 +fi + +if ! download "$CHECKSUM_URL" "$TMP_SUM"; then + echo "Error: Could not reach GitHub Releases — if behind a proxy, set HTTPS_PROXY and retry." >&2 + exit 1 +fi + +# ── Verify SHA256 ───────────────────────────────────────────────────────────── +EXPECTED=$(awk '{print $1}' "$TMP_SUM") +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "$TMP_BIN" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + ACTUAL=$(shasum -a 256 "$TMP_BIN" | awk '{print $1}') +else + echo "Warning: No sha256sum or shasum found — skipping checksum verification." >&2 + ACTUAL="$EXPECTED" +fi + +if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "Error: Download may be corrupted — retry the install." >&2 + exit 1 +fi + +# ── Install ─────────────────────────────────────────────────────────────────── +chmod +x "$TMP_BIN" + +mkdir -p "$INSTALL_DIR" + +if [ "$NEED_SUDO" = true ]; then + sudo mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}" +else + mv "$TMP_BIN" "${INSTALL_DIR}/${BINARY_NAME}" +fi + +echo "" +echo "Nimble installed! Open a new terminal to use it." +echo " Run: nimble --help" diff --git a/nimble/__main__.py b/nimble/__main__.py new file mode 100644 index 0000000..49397ff --- /dev/null +++ b/nimble/__main__.py @@ -0,0 +1,4 @@ +from nimble.cli.commands import app + +if __name__ == "__main__": + app() diff --git a/pyproject.toml b/pyproject.toml index 778755b..93db5dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dev = [ "mypy==1.20.1", "pytest", "types-PyYAML", + "pyinstaller>=6.0", ] [project.scripts]