feat: update changelog (#131)

closes #130 

- CHANGELOG.md file for people who can view changelog from github
---
`iris changelog` command:
`iris changelog [version]` (e.g. iris changelog v0.5.1)
`iris changelog -n 3` (see changelog of 3 latest releaseas)

=> `iris changelog` looks just like CHANGELOG.md, grouped headers and
all. It only stops looking that way if we personally overwrite that
release's description with something else
=> With 1-hour local cache (invalidated on updater-channel change),
`--refresh` flag to bypass, graceful fallback to stale cache on GitHub
rate-limiting

---
- New auto update config
```toml
# 0 = off (default, notify only), 1 = auto-install, 2 = always confirm first
auto-update = 1
```

- New just debug-changelog and just debug-autoupdate recipes (fully
offline, mocked HTTP servers, no real network/config touched)
This commit is contained in:
VERSE
2026-08-07 17:16:18 +07:00
committed by GitHub
parent 4a4ab3c102
commit 87b91c45d7
25 changed files with 2275 additions and 90 deletions
+12 -4
View File
@@ -33,8 +33,10 @@ jobs:
IFS='.' read -r major minor patch <<< "$VERSION_NUM"
patch=$((patch + 1))
NEXT_BASE="v${major}.${minor}.${patch}"
SHORT=$(git rev-parse --short HEAD)
SHORT=$(git rev-parse --short=6 HEAD)
echo "version=${NEXT_BASE}-nightly.${SHORT}" >> $GITHUB_OUTPUT
echo "short_sha=${SHORT}" >> $GITHUB_OUTPUT
echo "subject=$(git log -1 --format=%s)" >> $GITHUB_OUTPUT
- name: Build Binaries
run: |
@@ -71,12 +73,18 @@ jobs:
with:
tag_name: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }}
# shaped like GoReleaser's changelog groups so `iris changelog` renders it
body: |
# 🌙 **Nightly build** - `${{ steps.version.outputs.version }}`
Nightly release from commit: ${{ github.sha }}
# Update:
`iris update`
### Nightly
* ${{ steps.version.outputs.short_sha }} ${{ steps.version.outputs.subject }}
## Update
```bash
iris update
```
> install manually, this build will **not** trigger an update notification
files: dist/*.tar.gz
+27
View File
@@ -62,3 +62,30 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
# nightly tags also match refs/tags/v*, so they're excluded below
- name: Generate CHANGELOG.md
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-nightly.')
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --tag ${{ github.ref_name }} --output /tmp/CHANGELOG.md
- name: Commit CHANGELOG.md
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-nightly.')
run: |
# avoids a fast-forward race against the tag's detached HEAD
git fetch origin main
git checkout -B main origin/main
cp /tmp/CHANGELOG.md CHANGELOG.md
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
if git diff --cached --quiet; then
echo "CHANGELOG.md unchanged, nothing to commit"
else
# [skip ci] avoids retriggering nightly.yml for a docs-only push
git commit -m "docs(changelog): update for ${GITHUB_REF_NAME} [skip ci]"
git push origin main
fi
+29
View File
@@ -14,6 +14,35 @@ git:
# already has them and fails with 422 already_exists
prerelease_suffix: "-nightly"
changelog:
use: git
sort: asc
abbrev: 6
format: "{{ .SHA }} {{ .Message }}"
filters:
# these would otherwise fall under the "Bug fixes" group below since
# they start with `fix`, but they're infra-only and not user-facing
exclude:
- "(?i)^fix\\(ci\\)"
- "(?i)^fix\\(release\\)"
groups:
- title: "Features"
regexp: '(?i)^.*?feat(\(.+\))?!?:.+$'
order: 0
- title: "Bug fixes"
regexp: '(?i)^.*?fix(\(.+\))?!?:.+$'
order: 1
- title: "Performance"
regexp: '(?i)^.*?perf(\(.+\))?!?:.+$'
order: 2
- title: "Refactors"
regexp: '(?i)^.*?refactor(\(.+\))?!?:.+$'
order: 3
- title: "Documentation"
regexp: '(?i)^.*?docs?(\(.+\))?!?:.+$'
order: 4
# unmatched types (chore, build, ci, test, ...) are silently dropped
builds:
- env:
- CGO_ENABLED=0
+428
View File
@@ -0,0 +1,428 @@
# Changelog
All notable changes to this project are documented in this file.
## [Unreleased]
### Bug fixes
- Follow the shell working directory in the wrapper process ([#129](https://github.com/versenilvis/iris/issues/129)) ([4a4ab3](https://github.com/versenilvis/iris/commit/4a4ab3c102067f7a24f737987e80fbb7cedbe86c))
- Fall back to raw body for nightly releases ([005ed5](https://github.com/versenilvis/iris/commit/005ed5ae469953eec4d20d91fe647cb42151b98b))
- Invalidate cache when the updater channel changes ([5845fc](https://github.com/versenilvis/iris/commit/5845fcb0511b3a6b6bfae2cadba3735e244e3331))
- Only strip GoReleaser's exact heading, not real content ([a8779d](https://github.com/versenilvis/iris/commit/a8779d56e67beb859caaee3af7371bbdb829ef77))
- Use full commit SHAs in CHANGELOG.md for GitHub autolinking ([4d5e86](https://github.com/versenilvis/iris/commit/4d5e86ac9d142c38be7c9231f6576cc73371a73d))
- Link commit SHAs and PR numbers in CHANGELOG.md ([4416d1](https://github.com/versenilvis/iris/commit/4416d16fe6c216950d25856d212a9c8d3af8d0bf))
### Documentation
- Document changelog and auto-update ([80ee17](https://github.com/versenilvis/iris/commit/80ee178932a7d2b1b75dd516e9b95aee035c05b5))
### Features
- Generate CHANGELOG.md with git-cliff ([6f9a2b](https://github.com/versenilvis/iris/commit/6f9a2b9500535a70f08b2bd47949d9e560122013))
- Group release notes by commit type ([0421ed](https://github.com/versenilvis/iris/commit/0421ed853b79759ba6e215a95288c9f5a72ef9c3))
- Add changelog command ([5745dc](https://github.com/versenilvis/iris/commit/5745dc7ca46872076d3f9369d85fb3743db856e5))
- Show changelog in update notification ([27e745](https://github.com/versenilvis/iris/commit/27e7453ab27998c0ffd643cb8821c0a56c998989))
- Add auto-update option ([db2055](https://github.com/versenilvis/iris/commit/db2055a4c3e50a11099a5a32c881c5241014bfc9))
- Auto install updates in background ([359480](https://github.com/versenilvis/iris/commit/359480d77c065f783761ee86e988eaee58c1ec31))
- Shape nightly changelog like stable release groups ([ae39b4](https://github.com/versenilvis/iris/commit/ae39b4731f381d2e5f192b805e49a57f463f38e7))
- Render release notes with glamour ([8b2dba](https://github.com/versenilvis/iris/commit/8b2dba99fa0022016d7d731ef617fb2aaa1868f8))
- Compact rendering and per-version lookup ([445553](https://github.com/versenilvis/iris/commit/445553b15180dff0af5113e8a89be55442f54d64))
## [v0.5.2](https://github.com/versenilvis/iris/releases/tag/v0.5.2) - 2026-08-06
### Bug fixes
- Start iris in multiplexer panes instead of inheriting a dead one ([#120](https://github.com/versenilvis/iris/issues/120)) ([fc1634](https://github.com/versenilvis/iris/commit/fc1634b00799b2c88ef8f02710da1e5e39787e7d))
## [v0.5.1](https://github.com/versenilvis/iris/releases/tag/v0.5.1) - 2026-08-06
### Bug fixes
- Mode doesn't work correctly ([#128](https://github.com/versenilvis/iris/issues/128)) ([7a97ea](https://github.com/versenilvis/iris/commit/7a97ea9016a40693f89115be089b4b8e6ad58c2d))
### Documentation
- Remove ⌘ ([ce4043](https://github.com/versenilvis/iris/commit/ce404335ed8114c64a2d7068dcd1ea2437a751d2))
## [v0.5.0](https://github.com/versenilvis/iris/releases/tag/v0.5.0) - 2026-08-06
### Bug fixes
- Update stale vendorHash after lipgloss v2 upgrade ([#99](https://github.com/versenilvis/iris/issues/99)) ([476ca3](https://github.com/versenilvis/iris/commit/476ca3946b5e129471663a9269d1481b22fd4963))
- Support kitty keyboard protocol for ctrl keybindings ([#101](https://github.com/versenilvis/iris/issues/101)) ([d669e9](https://github.com/versenilvis/iris/commit/d669e97423a7ca9326d17b1289d06ba90942bd77))
- Isolate __complete probe from the controlling terminal ([#111](https://github.com/versenilvis/iris/issues/111)) ([0b77c9](https://github.com/versenilvis/iris/commit/0b77c9334b218eb1d7249983ceae424904ebe071))
- Make Right Arrow configurable and correct overlay positioning ([#110](https://github.com/versenilvis/iris/issues/110)) ([dfaa05](https://github.com/versenilvis/iris/commit/dfaa051f31671eaf45b0bd0bc5a2694df00e3db0))
- Handle non-ascii characters command ([#118](https://github.com/versenilvis/iris/issues/118)) ([75aa1f](https://github.com/versenilvis/iris/commit/75aa1fbe383c05e33ca10c13798225af2c23cdf3))
- Update nightly releases ([#125](https://github.com/versenilvis/iris/issues/125)) ([0595dd](https://github.com/versenilvis/iris/commit/0595ddd4b903639464b6248be60de3c76ed77738))
### Documentation
- Add navigate-right config ([b79d25](https://github.com/versenilvis/iris/commit/b79d25a0827805a5eaed12ca56b022e5e9e97969))
- Theme config and list of theme templates ([#116](https://github.com/versenilvis/iris/issues/116)) ([b5734b](https://github.com/versenilvis/iris/commit/b5734b4e85dfb3cf6654300b42663adbb6ace231))
- Add themes directory reference ([3a97ef](https://github.com/versenilvis/iris/commit/3a97efad183a59d46dbd3f7b7aa2f64a4e2eb118))
- AI guide ([#117](https://github.com/versenilvis/iris/issues/117)) ([30619a](https://github.com/versenilvis/iris/commit/30619a862182371d3654989cc4d273eba381f0f5))
- Add auto-start setup instructions ([53f104](https://github.com/versenilvis/iris/commit/53f104577f5b7f5a0c398b061b03e77f74c0f71a))
- Small note in shell config and fix line space ([994ff8](https://github.com/versenilvis/iris/commit/994ff836752bf4c5246a3aec236dd597421dccf0))
### Features
- Adopt x/ansi instead of hand rolling escape sequences ([#96](https://github.com/versenilvis/iris/issues/96)) ([df42c9](https://github.com/versenilvis/iris/commit/df42c95fa935d647fe27fad4414630e606951187))
- Support tool aliases ([#107](https://github.com/versenilvis/iris/issues/107)) ([6faa2e](https://github.com/versenilvis/iris/commit/6faa2e3274b71c9056f3b8d8445f2d3fc7e609ff))
- Add theme customization ([#81](https://github.com/versenilvis/iris/issues/81)) ([b14b26](https://github.com/versenilvis/iris/commit/b14b26e663fe0c5b6144d46370c2ea6bb2d956e4))
### Refactors
- Use XDG config for all platforms ([#119](https://github.com/versenilvis/iris/issues/119)) ([d299d1](https://github.com/versenilvis/iris/commit/d299d1c7c7c66394c6a260451410f87b6559ac9c))
## [v0.4.21](https://github.com/versenilvis/iris/releases/tag/v0.4.21) - 2026-07-31
### Documentation
- Reorder badge and description ([23bd71](https://github.com/versenilvis/iris/commit/23bd7188990253500bfd142bc8213c886d7be78a))
### Features
- Support for XDG base directories ([#95](https://github.com/versenilvis/iris/issues/95)) ([bf75dc](https://github.com/versenilvis/iris/commit/bf75dc239bd2432c10c3e36bc313b5407c23807f))
- Upgrade to lipgloss v2 ([#94](https://github.com/versenilvis/iris/issues/94)) ([efc49b](https://github.com/versenilvis/iris/commit/efc49bacfe7249880e3d2d2410abc43df51f5c18))
## [v0.4.20](https://github.com/versenilvis/iris/releases/tag/v0.4.20) - 2026-07-31
### Bug fixes
- Dynamic select key ui component ([#90](https://github.com/versenilvis/iris/issues/90)) ([8a1cec](https://github.com/versenilvis/iris/commit/8a1cec331aff82cb7c9463be77240c1775eeb8df))
## [v0.4.19](https://github.com/versenilvis/iris/releases/tag/v0.4.19) - 2026-07-31
### Bug fixes
- Resolve enter key submission and keybinding issues ([#89](https://github.com/versenilvis/iris/issues/89)) ([235a7f](https://github.com/versenilvis/iris/commit/235a7f98ea2cac3f9c546955dc0b70097c9e442f))
## [v0.4.15](https://github.com/versenilvis/iris/releases/tag/v0.4.15) - 2026-07-31
### Bug fixes
- Respect zdotdir config ([#83](https://github.com/versenilvis/iris/issues/83)) ([e9e50c](https://github.com/versenilvis/iris/commit/e9e50c7dea0186efe45e86a194fa8327e9c8d441))
## [v0.4.14](https://github.com/versenilvis/iris/releases/tag/v0.4.14) - 2026-07-31
### Bug fixes
- Sync config_cmd.go template with init.go template ([966378](https://github.com/versenilvis/iris/commit/9663788de65b06767ab21a9f5ac1c41386364c55))
## [v0.4.13](https://github.com/versenilvis/iris/releases/tag/v0.4.13) - 2026-07-31
### Documentation
- Warning about potential conflicts ([4421d6](https://github.com/versenilvis/iris/commit/4421d6e6a53cc3ed43a2688abac16f9d46535772))
### Features
- Ui width, auto exec, selection and navigation configs ([#80](https://github.com/versenilvis/iris/issues/80)) ([a885c7](https://github.com/versenilvis/iris/commit/a885c7462abaacb44607df11bbf6fa3b37c4ccd3))
## [v0.4.12](https://github.com/versenilvis/iris/releases/tag/v0.4.12) - 2026-07-31
### Bug fixes
- Arrow up history entries ([#77](https://github.com/versenilvis/iris/issues/77)) ([cee756](https://github.com/versenilvis/iris/commit/cee756e2c110f98681f3e9f4283e44bf1963999b))
- Sync shell working directory for suggestions ([#78](https://github.com/versenilvis/iris/issues/78)) ([9f43fc](https://github.com/versenilvis/iris/commit/9f43fcb3527d5a72c108d53ccc381cb5fd7943f9))
## [v0.4.11](https://github.com/versenilvis/iris/releases/tag/v0.4.11) - 2026-07-30
### Features
- Add shell login option ([#75](https://github.com/versenilvis/iris/issues/75)) ([2573ad](https://github.com/versenilvis/iris/commit/2573ad73b4dd55932248f54558ff0e31d6fb349e))
## [v0.4.10](https://github.com/versenilvis/iris/releases/tag/v0.4.10) - 2026-07-30
### Bug fixes
- Substring fuzzy search ([#74](https://github.com/versenilvis/iris/issues/74)) ([37182f](https://github.com/versenilvis/iris/commit/37182f72127b6ebf134edaae22c7d87b0a491b4c))
### Documentation
- Move user guide to top level readme ([#70](https://github.com/versenilvis/iris/issues/70)) ([004925](https://github.com/versenilvis/iris/commit/004925684443b7f250a8174a0286a99b638590b7))
## [v0.4.9](https://github.com/versenilvis/iris/releases/tag/v0.4.9) - 2026-07-30
### Bug fixes
- Show symbolic links in suggestion ([#69](https://github.com/versenilvis/iris/issues/69)) ([979bd6](https://github.com/versenilvis/iris/commit/979bd6f5d608b9b5f34efc682a940190fd9a99ce))
## [v0.4.8](https://github.com/versenilvis/iris/releases/tag/v0.4.8) - 2026-07-29
### Bug fixes
- Iris stops working after exec a command in bash ([#66](https://github.com/versenilvis/iris/issues/66)) ([812d73](https://github.com/versenilvis/iris/commit/812d73b42a27f920ed08a1bbc1c2fd83c13c3a74))
## [v0.4.7](https://github.com/versenilvis/iris/releases/tag/v0.4.7) - 2026-07-29
### Bug fixes
- Stdin handling and exit command ([#58](https://github.com/versenilvis/iris/issues/58)) ([d73921](https://github.com/versenilvis/iris/commit/d73921e4199330e8996056f639d68fd9c3c62781))
## [v0.4.6](https://github.com/versenilvis/iris/releases/tag/v0.4.6) - 2026-07-29
### Features
- Add some more config options ([#64](https://github.com/versenilvis/iris/issues/64)) ([d6cfb9](https://github.com/versenilvis/iris/commit/d6cfb9c90981db34a188744d95f4352261f33332))
## [v0.4.5](https://github.com/versenilvis/iris/releases/tag/v0.4.5) - 2026-07-28
### Features
- Add Nix flake for NixOS support ([#52](https://github.com/versenilvis/iris/issues/52)) ([69fb77](https://github.com/versenilvis/iris/commit/69fb77910deab604ae16d1f4752ccb0972c70a8f))
## [v0.4.4](https://github.com/versenilvis/iris/releases/tag/v0.4.4) - 2026-07-28
### Bug fixes
- ZDOTDIR and XDG_CONFIG_HOME config detecting ([#51](https://github.com/versenilvis/iris/issues/51)) ([ebb0be](https://github.com/versenilvis/iris/commit/ebb0bead7dce702c3e111ed7c1ed4e5f86ee6eb9))
## [v0.4.3](https://github.com/versenilvis/iris/releases/tag/v0.4.3) - 2026-07-28
### Bug fixes
- Set hex color for ghost text instead of hardcoded ANSI bright-black ([#49](https://github.com/versenilvis/iris/issues/49)) ([6f488c](https://github.com/versenilvis/iris/commit/6f488c02cd79219eb33c16c72fa8f90a0dd2315e))
## [v0.4.2](https://github.com/versenilvis/iris/releases/tag/v0.4.2) - 2026-07-28
### Bug fixes
- Ranking ([#46](https://github.com/versenilvis/iris/issues/46)) ([93061c](https://github.com/versenilvis/iris/commit/93061c89cab1c0191d429af9f822c72afadef017))
## [v0.4.0](https://github.com/versenilvis/iris/releases/tag/v0.4.0) - 2026-07-27
### Bug fixes
- Fuzzy search not working ([#44](https://github.com/versenilvis/iris/issues/44)) ([84f2c8](https://github.com/versenilvis/iris/commit/84f2c8f49a82a197a36eae3fdaa3ee1af4ec05c5))
### Documentation
- Update docs ([#43](https://github.com/versenilvis/iris/issues/43)) ([078e5e](https://github.com/versenilvis/iris/commit/078e5e817c276b65fafda158007210b6d77b0ab2))
### Features
- Transition workflow learning and fix history UX ([#39](https://github.com/versenilvis/iris/issues/39)) ([6ffb9f](https://github.com/versenilvis/iris/commit/6ffb9f83f21ee71d40ad77c9feb1cd494584e316))
- Better suggestion with cobra's cli __complete subcmd ([#40](https://github.com/versenilvis/iris/issues/40)) ([c4e9bc](https://github.com/versenilvis/iris/commit/c4e9bcdd88cdb3db818549e2974e58a46ab543f2))
- Better file path suggestions ([#41](https://github.com/versenilvis/iris/issues/41)) ([27e079](https://github.com/versenilvis/iris/commit/27e0792560cb2217f6c88485c78fb5c54399bbcb))
### Refactors
- Split justfile into small files ([#42](https://github.com/versenilvis/iris/issues/42)) ([1540b9](https://github.com/versenilvis/iris/commit/1540b9655af472345a7c8ef2e5b109c23758fe37))
## [v0.3.3](https://github.com/versenilvis/iris/releases/tag/v0.3.3) - 2026-07-14
### Bug fixes
- Skip alias expansion on paste ([#38](https://github.com/versenilvis/iris/issues/38)) ([85ac76](https://github.com/versenilvis/iris/commit/85ac76e85a276890b8924ab73d733d22431aab04))
### Features
- Scoring and frecency ([#37](https://github.com/versenilvis/iris/issues/37)) ([860b47](https://github.com/versenilvis/iris/commit/860b475b53e285cb2f80b16a4bb27b4cb4cfcc92))
## [v0.3.1](https://github.com/versenilvis/iris/releases/tag/v0.3.1) - 2026-07-13
### Bug fixes
- Enhance prompt ([#36](https://github.com/versenilvis/iris/issues/36)) ([f54e97](https://github.com/versenilvis/iris/commit/f54e97a6c0f3e8003f00f43ae3b07d647f90a942))
### Refactors
- Better project structure ([#35](https://github.com/versenilvis/iris/issues/35)) ([1e0494](https://github.com/versenilvis/iris/commit/1e049492421aa2b10cfcc584ac585d800d1883eb))
## [v0.3.0](https://github.com/versenilvis/iris/releases/tag/v0.3.0) - 2026-07-11
### Documentation
- Add showcase for kitty terminal ([#33](https://github.com/versenilvis/iris/issues/33)) ([4728ca](https://github.com/versenilvis/iris/commit/4728ca0c7f8202094e5956655550e64355063b03))
### Features
- AI suggestion ([#34](https://github.com/versenilvis/iris/issues/34)) ([94c8af](https://github.com/versenilvis/iris/commit/94c8af7b1d4408caddf1c6ed323defdc1867d037))
### Refactors
- Separate commands and its core into two folders ([#32](https://github.com/versenilvis/iris/issues/32)) ([ebde9b](https://github.com/versenilvis/iris/commit/ebde9ba68f72194ba0358aef2bca3ce8690617d0))
## [v0.2.8](https://github.com/versenilvis/iris/releases/tag/v0.2.8) - 2026-07-04
### Bug fixes
- Cannot update in dev mode ([#31](https://github.com/versenilvis/iris/issues/31)) ([6b9845](https://github.com/versenilvis/iris/commit/6b98455957303045071073738ac7a000feefa97c))
## [v0.2.7](https://github.com/versenilvis/iris/releases/tag/v0.2.7) - 2026-07-04
### Documentation
- Update to latest config and commands ([#28](https://github.com/versenilvis/iris/issues/28)) ([34f49e](https://github.com/versenilvis/iris/commit/34f49ed690aae3e232ac95f5a5c8f5dbf8614fce))
### Features
- More commands ([#30](https://github.com/versenilvis/iris/issues/30)) ([2ef3c9](https://github.com/versenilvis/iris/commit/2ef3c9081904885111d1880fb41df50c6d784fd7))
## [v0.2.6](https://github.com/versenilvis/iris/releases/tag/v0.2.6) - 2026-07-03
### Bug fixes
- Remove old binary before cp to avoid Text file busy error ([#27](https://github.com/versenilvis/iris/issues/27)) ([ffb2b9](https://github.com/versenilvis/iris/commit/ffb2b991a08d1a9a39b522d3db6f07577ea7a183))
## [v0.2.5](https://github.com/versenilvis/iris/releases/tag/v0.2.5) - 2026-07-03
### Bug fixes
- Actually execute install script when updating ([#26](https://github.com/versenilvis/iris/issues/26)) ([37b065](https://github.com/versenilvis/iris/commit/37b0651651459c62d0677fbb22026e446084d2d2))
## [v0.2.4](https://github.com/versenilvis/iris/releases/tag/v0.2.4) - 2026-07-03
### Features
- Allow passing specific shell to setup command ([#25](https://github.com/versenilvis/iris/issues/25)) ([ee1ef2](https://github.com/versenilvis/iris/commit/ee1ef2dd733607f74125c28e8385e562f90ca38c))
## [v0.2.3](https://github.com/versenilvis/iris/releases/tag/v0.2.3) - 2026-07-03
### Bug fixes
- Allow left/right movement after up arrow history recall ([#24](https://github.com/versenilvis/iris/issues/24)) ([4ca744](https://github.com/versenilvis/iris/commit/4ca744983c3c1e2234debf68add1fa755ec543e4))
## [v0.2.2](https://github.com/versenilvis/iris/releases/tag/v0.2.2) - 2026-07-03
### Bug fixes
- Installer ([#22](https://github.com/versenilvis/iris/issues/22)) ([df968e](https://github.com/versenilvis/iris/commit/df968e8d7f1f93cf9091425096df443b4eb1fcd8))
### Features
- Iris uninstall command ([#21](https://github.com/versenilvis/iris/issues/21)) ([2223f1](https://github.com/versenilvis/iris/commit/2223f1736be05c16df31ae15ce7805261b6bec49))
## [v0.2.0](https://github.com/versenilvis/iris/releases/tag/v0.2.0) - 2026-07-03
### Bug fixes
- Better suggestion and overlay movement on key presses ([#12](https://github.com/versenilvis/iris/issues/12)) ([aca3d1](https://github.com/versenilvis/iris/commit/aca3d1670cc7792765d3175c51da57d9b3634281))
- Menu deboucing ([#14](https://github.com/versenilvis/iris/issues/14)) ([c7960a](https://github.com/versenilvis/iris/commit/c7960aac46f6b7b4b985f4c01cf3282dcb37fb4c))
- Data race and overlay bugs ([#20](https://github.com/versenilvis/iris/issues/20)) ([ca9d30](https://github.com/versenilvis/iris/commit/ca9d30af9c0f9361cb5515d46afa36fdd12140d4))
### Documentation
- Update guidelines for new user and developer ([#16](https://github.com/versenilvis/iris/issues/16)) ([b9cec2](https://github.com/versenilvis/iris/commit/b9cec2b3ac0e2baa62add488948b987138bc0763))
- Introducing to iris ([#11](https://github.com/versenilvis/iris/issues/11)) ([24fdda](https://github.com/versenilvis/iris/commit/24fddaccfef8302a7850434f3ddebe5a0d2d8015))
### Features
- Better debugger ([#13](https://github.com/versenilvis/iris/issues/13)) ([f37f06](https://github.com/versenilvis/iris/commit/f37f0681ce2e22054fc636f37acc79d8347c6a1f))
### Performance
- Prevent memory leak, data race and speed up ComputeCursorCol processing ([#15](https://github.com/versenilvis/iris/issues/15)) ([8f89c1](https://github.com/versenilvis/iris/commit/8f89c15e725e8487ded62912ef264ca7a220a76c))
## [v0.1.0](https://github.com/versenilvis/iris/releases/tag/v0.1.0) - 2026-05-30
### Bug fixes
- Hide history when delete whole line ([afa3c2](https://github.com/versenilvis/iris/commit/afa3c28a8862f72cb489fe4781a0b75a8164a3d7))
- Continue to suggest command after tab ([61f05a](https://github.com/versenilvis/iris/commit/61f05a4c82b1c3a40af0d1c870a7ed13998dd98a))
- Handle quote when tokenize ([e465d9](https://github.com/versenilvis/iris/commit/e465d900a81ad6c847cfc4b9e408f13c2117ad56))
- Handle value attached flags ([110903](https://github.com/versenilvis/iris/commit/1109034c8729234424d47bd94e7cc01dcec15ce3))
- Add token injection to resolve shell aliases ([503d38](https://github.com/versenilvis/iris/commit/503d38e5c497ad8e15a06560c072d371555a849c))
- Remove sync.Once so we can always up-to-date with shell config ([8b25bd](https://github.com/versenilvis/iris/commit/8b25bdac0fc321f4b4d9bb53c4f36fdc9ab2bb80))
- Prioritize aliases ([2b760f](https://github.com/versenilvis/iris/commit/2b760fb82ae6c0e64a0c79963c30499a538ba295))
- Iris now reloads via signal handoff ([bed9c9](https://github.com/versenilvis/iris/commit/bed9c98747f5ed1395c7b255faf8cebfa6575c67))
- Return last shell insteadd of default shell on reload ([ccbf59](https://github.com/versenilvis/iris/commit/ccbf59201aa3953aa5c771ee43918a46bbe10328))
- Print reloading ([8dff68](https://github.com/versenilvis/iris/commit/8dff68601e5e2fa630d407074ef8c092547b0c15))
- Eliminate child shell leakage and terminal corruption on reload ([11225d](https://github.com/versenilvis/iris/commit/11225df6c839b5c287bf31dd5f7cc38085ea2251))
- Ctrl R open reverse search instead of switching to history ([f30656](https://github.com/versenilvis/iris/commit/f306566602d2eae39428864b8e8b14934d32e708))
- Iris keep suggesting when press tab continually ([fc21a6](https://github.com/versenilvis/iris/commit/fc21a6b0cc4ea13887a7809b36b72a190e715481))
- Up/down arrow hide menu ([c79867](https://github.com/versenilvis/iris/commit/c798673069e1b37f8ffdb12d0fe9742c57bd2285))
- Extra left border ([38118a](https://github.com/versenilvis/iris/commit/38118a9a4789329be922ac57d457e2c0b12a1b94))
- Should show in priority ([803244](https://github.com/versenilvis/iris/commit/8032448cb6f30fd6d4a412a308abbbc08e32a1b4))
- Prioritize exact matching then order ([a5c27b](https://github.com/versenilvis/iris/commit/a5c27bdb942e3c3dd2fc2e934c20e379c5ee155e))
- Now with same fuzzy tier, which command has higher score will be in higher order ([b045aa](https://github.com/versenilvis/iris/commit/b045aaf6af71ed9cc7332849f8147972762bd6b7))
- Key reading loop conflict ([27c7b9](https://github.com/versenilvis/iris/commit/27c7b9732658745e7afdc201d930f5799e7f641a))
- Wrong suggestion on chmod command ([d2909d](https://github.com/versenilvis/iris/commit/d2909d93aaeee6667d2d0c042126b05fd7eb7dee))
- Text file busy bug on setup ([44ff34](https://github.com/versenilvis/iris/commit/44ff34f3789d0c4f5f61671365a701960872d0c8))
- Cd and zoxide only shows dir ([9720b5](https://github.com/versenilvis/iris/commit/9720b5f4bcf2dd888973b91c3984187c5015ec20))
- Support folder contains space ([3c5a05](https://github.com/versenilvis/iris/commit/3c5a0580d1e6709dbc054fd4373a2f7a654e7ea0))
- Fix all problems related to test ([e587f0](https://github.com/versenilvis/iris/commit/e587f027982701d390fc1bc75702c93e0d66ee7c))
- All issues by golangci-lint ([c76541](https://github.com/versenilvis/iris/commit/c76541258acc79abce4a3685590e3e35dbdb0ed7))
- Fuzzy search shows commands that no one need ([88d6ee](https://github.com/versenilvis/iris/commit/88d6ee4e57d324076ae6b41aacc49241ad980ca5))
- Typo ([07b7d1](https://github.com/versenilvis/iris/commit/07b7d19347bfe3fe7188e331bfc6b10af937ad58))
- Err check rules and fix config ([56e04b](https://github.com/versenilvis/iris/commit/56e04be4098b22662a7ad96b3a10cbe4cf8752e0))
- Add user email and name to support ci/cd ([ae067f](https://github.com/versenilvis/iris/commit/ae067fa2004790f677f8350b744b3ff34f365ff1))
- Git doesnt show branch suggesstion properly ([0ec946](https://github.com/versenilvis/iris/commit/0ec946f408f4416aca1c520f8c326326a565a10d))
- Git ([9b206f](https://github.com/versenilvis/iris/commit/9b206fe701ab024a14903c9ff1315bb055991676))
- Issue #4 and #5 ([#6](https://github.com/versenilvis/iris/issues/6)) ([d06526](https://github.com/versenilvis/iris/commit/d06526cf21d59336d2d2f7fee5d492b1ead2f73c))
- Correct nightly tag, fix tmux startup, full-word suggestions only, and keep current dir on reload ([#8](https://github.com/versenilvis/iris/issues/8)) ([8556fa](https://github.com/versenilvis/iris/commit/8556fa626aecd555e1a87f0bd39621fe517cebd5))
### Documentation
- LICENSE ([2eac0c](https://github.com/versenilvis/iris/commit/2eac0cd38e21cde76cd45a5d9d450805e759e140))
- Add hot reload and shell aliases ([97d009](https://github.com/versenilvis/iris/commit/97d009f7ef6c0b9e020f69be155440fa7fa0a5ef))
- Updater ([72dd69](https://github.com/versenilvis/iris/commit/72dd6940e422932caa4977f61778f38917511ed6))
### Features
- Entry point ([c384c5](https://github.com/versenilvis/iris/commit/c384c57cb9ee749cf594505c5ec7c499cc062b47))
- Build and run ([f69e39](https://github.com/versenilvis/iris/commit/f69e390e3b55c87dea82a716045081a4e62faf63))
- Ipc server ([55bd6e](https://github.com/versenilvis/iris/commit/55bd6e1b481df0a4d773b0165543e54020f72a96))
- Threat-safe terminal writer ([551a09](https://github.com/versenilvis/iris/commit/551a096d2d0b23ee3e019695778702b3d8ee04d8))
- Iris command ([1fb279](https://github.com/versenilvis/iris/commit/1fb279e588f790e3402a8b8734eda72d6dfa71f2))
- History search ([515d6f](https://github.com/versenilvis/iris/commit/515d6f2a0a772708227936490aa77878fcb2f601))
- Drop down menu tui ([25ff2b](https://github.com/versenilvis/iris/commit/25ff2bcb2c4cf60da3a884cc48c30ed2c82ffd0f))
- Spec ([d38469](https://github.com/versenilvis/iris/commit/d384694ab341fcc384fd323bcf89ee5788088627))
- Some common commands ([aab3c6](https://github.com/versenilvis/iris/commit/aab3c6be2ed85e897149bf1a273761f5e4248b5d))
- Add partial ([e559c9](https://github.com/versenilvis/iris/commit/e559c960d9b5589b2609045f23eac7be4cc4c531))
- File generator for file suggestion ([76458c](https://github.com/versenilvis/iris/commit/76458ce52ef27f090d204d1885a274fc7119cc50))
- Refactor and add more commands ([f4816d](https://github.com/versenilvis/iris/commit/f4816d2b5f0fc371a413f61ebb13f321e610e8d7))
- Tracking CWD and simply return file desc ([7b37b8](https://github.com/versenilvis/iris/commit/7b37b8ae2823a4bad1660cba8486fce7a70e9534))
- Change priority ([b01844](https://github.com/versenilvis/iris/commit/b01844639bfc02dc7a727df3aae93a08648a2b6b))
- Optimize build command ([4cc785](https://github.com/versenilvis/iris/commit/4cc7853256480dbe1a2c0cbe1c9d2abf2b1acbb5))
- Dynamic git command completion ([f64c80](https://github.com/versenilvis/iris/commit/f64c8022b264e78ac3248e52b8b3d6386868c100))
- System PATH scannee ([525eac](https://github.com/versenilvis/iris/commit/525eac1bfa6e1c20246c6cc6d200be70e75790f1))
- Support aliases ([1f483c](https://github.com/versenilvis/iris/commit/1f483c5ab291bea8cdc35eec2a13ec9da21b6096))
- Scan shell aliases like .zshrc, .bashrc, ... ([4c70c2](https://github.com/versenilvis/iris/commit/4c70c242990acc4d1311712dfd7d189994a1b8ab))
- Rebuild and update pkg ([02fdd9](https://github.com/versenilvis/iris/commit/02fdd99e400b09b8c39dd1a86f7b2140cf056a48))
- In-place reloading on current TTY ([78e191](https://github.com/versenilvis/iris/commit/78e19199b0478efa1de09d549ef044c8f5d0b491))
- Shift tab to toggle menu ([7a9c28](https://github.com/versenilvis/iris/commit/7a9c28e6f68f3914d69649de6fd87e519ec5fa67))
- Stop showing suggestion when delete entire line ([887b5a](https://github.com/versenilvis/iris/commit/887b5af9580b29fa10bdf86fb478e9622d0aec79))
- Now pressing space show alias as full command ([7c11a3](https://github.com/versenilvis/iris/commit/7c11a3a620e2e6b0fd704b2a094399e4f98a0fe2))
- Continue last mode ([c7106f](https://github.com/versenilvis/iris/commit/c7106f9fc2a849f8262d36bcc73e92dd3791868a))
- Add bash and fish ([00826a](https://github.com/versenilvis/iris/commit/00826a5c075020a000948546cb9800489e7b182e))
- Ghost text ([edab80](https://github.com/versenilvis/iris/commit/edab80202690c088faaa7af0ba9e535a73b66260))
- Disable ghost text when using arrow keys ([ff8874](https://github.com/versenilvis/iris/commit/ff8874fefcbdcc5ec036e7e5381efbe8bff96753))
- Run with terminal ([5c8ac9](https://github.com/versenilvis/iris/commit/5c8ac9e4a65434f11004d430fcc694001dbb7d8c))
- Add more options and limit file extension ([667cf9](https://github.com/versenilvis/iris/commit/667cf9feae0b22690f5c911b5d36bd6f6f8076f6))
- Scan 1 level deeper in folder ([66b2b2](https://github.com/versenilvis/iris/commit/66b2b22d94a4f60618bbd31b905bf1242a900341))
- : install script ([ef2660](https://github.com/versenilvis/iris/commit/ef26602ded90ba3fdd64f6f74addcd9dc7773814))
- Setup ([b6e509](https://github.com/versenilvis/iris/commit/b6e5096092a8de4ec92ec35070999cf4e3998432))
- Detect another command is running to stop iris ([cea2a7](https://github.com/versenilvis/iris/commit/cea2a7bee72ec9dc5c689eb76d1dbdf89a59dca7))
- Show preview command when using arrow keys ([23e5b1](https://github.com/versenilvis/iris/commit/23e5b13f6d11b436dcac52126f023b4362eb1f64))
- Up/down arrow key now can choose last commands ([3c8eca](https://github.com/versenilvis/iris/commit/3c8eca3a62315b9594fde752125dfc88a1a5f504))
- Zoxide ([5d8187](https://github.com/versenilvis/iris/commit/5d81871aef68ad6b1e88b76832066579bb094e8f))
- Copy to local bin ([be3c08](https://github.com/versenilvis/iris/commit/be3c08171bdbf7b312551efa5413a9c43bc33c69))
- All tests ([17f33f](https://github.com/versenilvis/iris/commit/17f33ff480ff8f443107d682322d72f4b51cceb0))
- More commands, tests, add Registry conflict between tests ([d74c95](https://github.com/versenilvis/iris/commit/d74c959be4ebc8ac535686ab18eadb3f0d648af5))
- Version ([e6f4bd](https://github.com/versenilvis/iris/commit/e6f4bdd36d6024b2286985def87df30888244222))
- Updater ([1e923a](https://github.com/versenilvis/iris/commit/1e923a13d9816ce0414dbbd05d0fc219e40027e2))
- Update test ([f9eb19](https://github.com/versenilvis/iris/commit/f9eb1900464cb0190a0120acacde2846b1726e4a))
- Uninstaller ([eaa734](https://github.com/versenilvis/iris/commit/eaa7344a24a0a2b5feaad1daba379a3611152557))
- Updates and versioning ([#1](https://github.com/versenilvis/iris/issues/1)) ([9fd54c](https://github.com/versenilvis/iris/commit/9fd54c8c3dfa6e87750b1365615ccabef3c53a44))
- Issue template and release ci/cd ([b5c0e5](https://github.com/versenilvis/iris/commit/b5c0e5ec3d99ec4d754c6722cbe15d49f7a11fed))
- Issue template and ci/cd ([#2](https://github.com/versenilvis/iris/issues/2)) ([8f4d8b](https://github.com/versenilvis/iris/commit/8f4d8b7db7c32ac919f3f1121af251f3bbcff887))
- Nightly built ([9c1c77](https://github.com/versenilvis/iris/commit/9c1c7749b08983db182cee0f0d4ffaf485a6ed6d))
- Crash log ([#7](https://github.com/versenilvis/iris/issues/7)) ([bf67e5](https://github.com/versenilvis/iris/commit/bf67e50ea612b1a1e146e4dfc67de843877374e2))
- Toml config support ([#9](https://github.com/versenilvis/iris/issues/9)) ([3d76a3](https://github.com/versenilvis/iris/commit/3d76a3ee8816ac8fa2c5026551ff93d77bbd4762))
### Performance
- Using string.Builder instead of concatenation ([81b80d](https://github.com/versenilvis/iris/commit/81b80d079ce20956ee99e0343fc90c0d4d8b9295))
- Finding the first space and using a case-insensitive comparison ([caf4aa](https://github.com/versenilvis/iris/commit/caf4aa344e3f9ae1c0691cde3c91e89fa23d4629))
### Refactors
- Improve reading aliases in shell config ([71a75c](https://github.com/versenilvis/iris/commit/71a75ced2857f9ded98185ca2f110555f3c2146b))
- Shell adaptor ([736723](https://github.com/versenilvis/iris/commit/736723fbe8a6d520ec70ecd939e3a91bf013f624))
- Seperate into small files ([db8b6e](https://github.com/versenilvis/iris/commit/db8b6ec91bf741519ce3a056ab2d93d8dafcd1d6))
- Seperate into small files ([3adc04](https://github.com/versenilvis/iris/commit/3adc045c09575996ce966e8b83b4932a761bd64e))
+16
View File
@@ -229,6 +229,21 @@ cd iris
just reload
```
## Update
```bash
iris update
```
Checks for and installs the latest release. To see what's changed first:
```bash
iris changelog # latest release
iris changelog -n 3 # last 3 releases
```
IRIS can also check for and install updates on its own - see `updater.auto-update` in the [configuration guide](#configuration-guide) below.
## Uninstall
To completely uninstall IRIS, remove all configurations, and clean up your shell integration files, simply run:
@@ -310,6 +325,7 @@ deduplicate-branches = true
check-on-startup = true
channel = "stable" # "stable" or "nightly"
check-interval = "24h"
auto-update = 0 # 0 = off, 1 = auto-install, 2 = always confirm first
[ai]
enabled = false
+1 -1
View File
@@ -11,4 +11,4 @@ uncomplete = "uncomplete"
edn = "edn"
[files]
extend-exclude = ["internal/scoring/scorer_test.go"]
extend-exclude = ["internal/scoring/scorer_test.go", "CHANGELOG.md"]
+49
View File
@@ -0,0 +1,49 @@
[changelog]
header = "# Changelog\n\nAll notable changes to this project are documented in this file.\n\n"
body = """
{% if version %}\
## [{{ version }}](https://github.com/versenilvis/iris/releases/tag/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{{ "\n" }}\
{% for group, commits in commits | group_by(attribute="group") %}\
### {{ group | upper_first }}
{{ "\n" }}\
{% for commit in commits %}\
- {{ commit.message | upper_first }} ([{{ commit.id | truncate(length=6, end="") }}](https://github.com/versenilvis/iris/commit/{{ commit.id }}))
{% endfor %}\
{{ "\n" }}\
{% endfor %}\
"""
trim = true
footer = ""
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_preprocessors = [
{ pattern = '\(#([0-9]+)\)', replace = "([#${1}](https://github.com/versenilvis/iris/issues/${1}))" },
]
# stable tags only - `ignore_tags` was tried first but git-cliff merges an
# ignored tag's commits into the wrong release when nightly and stable tags share a commit range
tag_pattern = "^v[0-9]+\\.[0-9]+\\.[0-9]+$"
sort_commits = "oldest"
commit_parsers = [
{ message = "(?i)^feat", group = "Features" },
{ message = "(?i)^fix\\(ci\\)", skip = true },
{ message = "(?i)^fix\\(release\\)", skip = true },
{ message = "(?i)^fix", group = "Bug fixes" },
{ message = "(?i)^perf", group = "Performance" },
{ message = "(?i)^refactor", group = "Refactors" },
{ message = "(?i)^docs?", group = "Documentation" },
{ message = "(?i)^chore", skip = true },
{ message = "(?i)^build", skip = true },
{ message = "(?i)^ci", skip = true },
{ message = "(?i)^test", skip = true },
{ message = "(?i)^style", skip = true },
# catch-all: unlisted types (e.g. historical "pkg:") drop instead of leaking a stray group
{ message = ".*", skip = true },
]
+1 -1
View File
@@ -21,7 +21,7 @@ We use `just` as our primary command runner. Recipes are organized into modular
- `justfiles/test.just` (`[test]` group): `test`, `lint`, `analyze` (alias `ana`)
- `justfiles/gen.just` (`[gen]` group): `gen-docs`
- `justfiles/pkg.just` (`[pkg]` group): `pkg`
- `justfiles/debug.just` (`[debug]` group): `debug`, `debug-update`, `debug-notify`, `debug-install`
- `justfiles/debug.just` (`[debug]` group): `debug`, `debug-update`, `debug-changelog`, `debug-notify`, `debug-install`, `debug-autoupdate`
Run `just -l` to quickly view all available recipes organized by group.
+67 -15
View File
@@ -2,24 +2,39 @@
Iris has a built-in update notification system designed to be non-intrusive and zero-latency
The update system is designed to keep Iris up to date while staying out of the way. It performs an asynchronous network check when the shell starts and notifies you exactly once per version after you run a command. This prevents redundant notifications and ensures that you only see an update message when a new release is actually available. The system also includes dedicated debugging tools to verify the notification logic without requiring real releases
The update system is designed to keep Iris up to date while staying out of the way. It performs an asynchronous network check when the shell starts and notifies you exactly once per version after you run a command. This prevents redundant notifications and ensures that you only see an update message when a new release is actually available. Optionally, it can also install updates for you in the background. The system also includes dedicated debugging tools to verify the notification and auto-update logic without requiring real releases
## How it works
1. **Background check**: every time you open a new terminal, Iris launches a background goroutine to check for updates
- checks the network at most once every 6 hours to avoid GitHub API rate limiting
- checks the network at most once every `check-interval` (24h by default) to avoid GitHub API rate limiting
- has a 5 second timeout so it never hangs on a slow or missing network connection
- if there is no network, it fails silently with zero impact on startup
2. **State persistence**: state is stored in `~/.iris/update_state.json` with two fields:
- `last_check` - unix timestamp of the last network check
- `seen_version` - the latest version the user was already notified about
2. **State persistence**: state is stored in `$XDG_DATA_HOME/iris/state.toml` (or `~/.local/share/iris/state.toml`), including:
- `updater.last-check-time` / `updater.seen-version` - when the last check ran, and the latest version already notified about
- `updater.auto-update-target` / `updater.auto-update-attempt` - the auto-update escalation ladder's state (see [Auto-update](#auto-update) below)
- `updater.declined-version` - a version the user explicitly declined at an auto-update confirm prompt, never re-prompted
3. **Smart notification**:
- the notice only appears after you run your first command (triggered by the `IRIS_CMD_STOP` IPC signal from the shell hook)
- appears only once per session, never again even if you keep the terminal open
- if you have already been notified about a specific version, it will not show again until a newer GitHub release tag is detected
- when `iris update` is run successfully, the `seen_version` flag is cleared
- when `iris update` is run successfully, the `seen-version` flag is cleared
- includes up to two changelog bullets pulled from the release's grouped notes (see [Implementation details](#implementation-details) for where those come from)
## Auto-update
`updater.auto-update` (see [Configuration](#configuration)) controls whether the background check can install updates itself, not just notify:
- **`0` (default)**: notify only, exactly the behavior above.
- **`1`**: auto-install, with a per-version escalation ladder so a flaky network or a failing installer can't turn into a silent retry loop:
1. first detection of a newer version: installs it in the background, no prompt. Output is captured (not streamed) since the wrapper's terminal is in raw mode - a clean one-line notice is printed once the install finishes, if you're still in the session.
2. if that version is still current on the *next* check (the install didn't stick), it stops auto-installing and shows a confirm prompt instead: `[y/N]`, pressed like any other single keystroke.
3. if it's still not current after that, Iris gives up on that version - prints one notice pointing at `iris update`, and won't try again until a *newer* version is released.
- **`2`**: always confirm first, every time - never installs silently, never gives up, just keeps asking until you say yes or the version changes.
A confirm prompt declined with `n`/Esc/Ctrl+C records that version so it's never re-prompted (until a newer one comes along).
## Build-time versioning
@@ -31,14 +46,27 @@ go build -ldflags="-X github.com/versenilvis/iris/root.Version=v1.2.0" -o iris m
If not provided, the version defaults to dev. The dev version will never trigger an update notification
## Configuration
The `[updater]` section of `~/.config/iris/config.toml`:
```toml
[updater]
check-on-startup = true
channel = "stable" # "stable" or "nightly"
check-interval = "24h"
auto-update = 0 # 0 = off, 1 = auto-install, 2 = always confirm first
```
## Commands
- `iris version` - print the current version of the running binary
- `iris update` - manually check for and apply the latest release
- `iris changelog` - show grouped release notes fetched from GitHub (`-n <count>`, `--refresh`)
## Debugging and testing
There are two separate things to test: the update command itself, and the in-session notification banner
Four separate things to test: the update command, the in-session notification banner, the changelog command, and the background auto-updater
### Test 1: update command
@@ -71,27 +99,51 @@ just debug-notify v1.99.0
Inside the new session, run any command. Expected output after the command
```
[IRIS] new version v0.0.1 -> v1.99.0 available, run iris update to upgrade
- <first changelog bullet>
```
### Test 3: changelog command
Tests fetching, grouping, the response cache, and rate-limit fallback. No full Iris session needed
```bash
just build-release v0.0.1
just debug-changelog v1.99.0
```
### Test 4: background auto-updater
Tests the escalation ladder (silent install, then escalating to a confirm-worthy state, then giving up rather than looping) fully offline, by inspecting `state.toml` after each simulated check cycle. The confirm-prompt UI itself is interactive and isn't exercised here - set `auto-update` in a real config and watch a live session with shell hooks active to see it rendered
```bash
just debug-autoupdate
```
### Environment variables
| Variable | Purpose |
| -------------------------- | ----------------------------------------------------------------------------------- |
| `IRIS_UPDATE_URL` | override the GitHub API endpoint with a custom URL (used by `debug-update`) |
| --------------------------- | -------------------------------------------------------------------------------------------------------- |
| `IRIS_UPDATE_URL` | override the release-check endpoint with a custom URL (used by `debug-update`, `debug-autoupdate`) |
| `IRIS_MOCK_LATEST_VERSION` | skip network entirely, resolve to this version immediately (used by `debug-notify`) |
| `IRIS_CHANGELOG_URL` | override the changelog-fetch endpoint with a custom URL (used by `debug-changelog`) |
| `IRIS_INSTALL_URL` | override the install-script fetch URL (used by `debug-autoupdate`) |
### State reset
To force a fresh network check on next launch, delete the state file:
To force a fresh check on next launch, delete the state file:
```bash
rm ~/.iris/update_state.json
rm ~/.local/share/iris/state.toml
```
## Implementation details
| File | Purpose |
| ----------------- | ---------------------------------------------------------------------------------------- |
| `root/version.go` | holds the `Version` constant, defaults to `dev` |
| `root/update.go` | all update logic: state persistence, network fetch, version compare, commands |
| `root/wrapper.go` | wires the background check into the IPC loop, prints the notice on first `IRIS_CMD_STOP` |
| ------------------------ | ----------------------------------------------------------------------------------------------- |
| `root/version.go` | holds the `Version` variable, defaults to `dev` |
| `root/update.go` | version fetch/compare, state persistence, the `iris update`/`iris version` commands |
| `root/changelog_cmd.go` | the `iris changelog` command: fetch, cache, grouping-aware rendering |
| `root/autoupdate.go` | `performUpdate`, the auto-update escalation ladder, and the confirm-prompt keystroke handler |
| `root/wrapper.go` | wires the background check into the IPC loop, prints notices on `IRIS_CMD_STOP` |
| `.goreleaser.yaml` | `changelog:` block groups commits by type for the GitHub release body |
| `cliff.toml` | generates the committed `CHANGELOG.md` from the same commit history on each stable release |
+17 -1
View File
@@ -5,37 +5,53 @@ go 1.25.0
require (
charm.land/lipgloss/v2 v2.0.5
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/creack/pty v1.1.24
github.com/spf13/cobra v1.10.2
github.com/versenilvis/fuzzy v0.1.0-rc1.2
go.uber.org/goleak v1.3.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.30.0
golang.org/x/term v0.36.0
modernc.org/sqlite v1.53.0
)
require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.30.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+47 -2
View File
@@ -2,12 +2,34 @@ charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -23,30 +45,45 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-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-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -61,6 +98,10 @@ github.com/versenilvis/fuzzy v0.1.0-rc1.2 h1:wX7v7Oxt+3O9VOiBpmMwpWZCkCdeYeicjUX
github.com/versenilvis/fuzzy v0.1.0-rc1.2/go.mod h1:TCgCwxBa4px8NhCXH8SPePCyJsf/qPsl1i+G/HDdjyg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -68,13 +109,17 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+6
View File
@@ -61,6 +61,8 @@ type UpdaterConfig struct {
CheckOnStartup bool `toml:"check-on-startup"`
Channel string `toml:"channel"`
CheckInterval Duration `toml:"check-interval"`
// AutoUpdate: 0 = off (default), 1 = auto-install, 2 = always confirm first
AutoUpdate int `toml:"auto-update"`
}
type KeybindingsConfig struct {
@@ -281,6 +283,10 @@ func validate(cfg *Config) error {
return fmt.Errorf("updater.channel: invalid value %q (want: stable|nightly)", cfg.Updater.Channel)
}
if cfg.Updater.AutoUpdate < 0 || cfg.Updater.AutoUpdate > 2 {
return fmt.Errorf("updater.auto-update: invalid value %d (want: 0=off, 1=auto, 2=confirm)", cfg.Updater.AutoUpdate)
}
if cfg.UI.MaxSuggestions < 1 || cfg.UI.MaxSuggestions > 500 {
return fmt.Errorf("ui.max-suggestions: must be between 1 and 500")
}
+18
View File
@@ -186,6 +186,24 @@ model = "qwen-2.5-coder-32b"
}
}
func TestValidateAutoUpdateRange(t *testing.T) {
cfg := DefaultConfig()
for _, valid := range []int{0, 1, 2} {
cfg.Updater.AutoUpdate = valid
if err := validate(cfg); err != nil {
t.Errorf("expected auto-update=%d to be valid, got error: %v", valid, err)
}
}
for _, invalid := range []int{-1, 3} {
cfg.Updater.AutoUpdate = invalid
if err := validate(cfg); err == nil {
t.Errorf("expected auto-update=%d to be rejected", invalid)
}
}
}
func TestLoadSave(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "iris-config-test")
if err != nil {
+4
View File
@@ -32,6 +32,7 @@ func DefaultConfig() *Config {
CheckOnStartup: true,
Channel: "stable",
CheckInterval: Duration(24 * time.Hour),
AutoUpdate: 0,
},
AI: AIConfig{
Enabled: false,
@@ -61,6 +62,9 @@ func DefaultState() *State {
Updater: UpdaterState{
LastCheckTime: time.Time{},
SeenVersion: "",
AutoUpdateTarget: "",
AutoUpdateAttempt: 0,
DeclinedVersion: "",
},
}
}
+4
View File
@@ -11,6 +11,10 @@ import (
type UpdaterState struct {
LastCheckTime time.Time `toml:"last-check-time"`
SeenVersion string `toml:"seen-version"`
// auto-update escalation ladder state, see decideAutoUpdateAction
AutoUpdateTarget string `toml:"auto-update-target"`
AutoUpdateAttempt int `toml:"auto-update-attempt"`
DeclinedVersion string `toml:"declined-version"`
}
type State struct {
+216
View File
@@ -18,12 +18,228 @@ debug-update version="v1.99.0":
kill $SERVER_PID 2>/dev/null
rm -rf "$tmp"
# test the changelog command (fetch, grouping, cache, rate limiting)
# usage: just debug-changelog v1.99.0
[group('debug')]
debug-changelog version="v1.99.0":
#!/bin/sh
tmp=$(mktemp -d)
body='## Changelog\n### Bug fixes\n* abc1234 fix something\n\n## Update\n```bash\niris update\n```\n'
printf '[{"tag_name":"%s","published_at":"2026-01-01T00:00:00Z","body":"%s","prerelease":false},{"tag_name":"v0.0.1","published_at":"2025-12-01T00:00:00Z","body":"## Changelog\\n### Documentation\\n* def5678 older release\\n","prerelease":false}]' "{{version}}" "$body" > "$tmp/response.json"
python3 -m http.server 19997 --directory "$tmp" 2>/dev/null &
SERVER_PID=$!
sleep 0.3
# isolate the changelog cache so this doesn't read or write the real
# ~/.cache/iris/changelog-cache.json
export XDG_CACHE_HOME="$tmp/cache"
echo "--- testing iris changelog (latest only) ---"
IRIS_CHANGELOG_URL="http://localhost:19997/response.json" ./iris changelog
echo ""
echo "--- testing iris changelog -n 2 ---"
IRIS_CHANGELOG_URL="http://localhost:19997/response.json" ./iris changelog -n 2
echo ""
echo "--- testing cache hit (server killed, should still work) ---"
kill $SERVER_PID 2>/dev/null
IRIS_CHANGELOG_URL="http://localhost:19997/response.json" ./iris changelog
echo ""
echo "--- testing --refresh with no server (should error, not hang) ---"
IRIS_CHANGELOG_URL="http://localhost:19997/response.json" ./iris changelog --refresh
rm -rf "$tmp"
# test the in-session update notification banner (requires iris.zsh hook to be active)
# usage: just debug-notify v1.99.0
[group('debug')]
debug-notify version="v1.99.0":
IRIS_PID="" IRIS_MOCK_LATEST_VERSION="{{version}}" ./iris
# test the background auto-updater's escalation ladder (silent install,
# then repeated failures escalating to confirm, then giving up). fully
# offline - mocks the release API, install script, and release asset.
# usage: just debug-autoupdate
[group('debug')]
debug-autoupdate:
#!/bin/sh
PORT=19996
TMP=$(mktemp -d)
PASS=0
FAIL=0
trap "kill \$SERVER_PID 2>/dev/null; pkill -f \"$TMP/iris\" 2>/dev/null; rm -rf $TMP" EXIT
ok() { PASS=$((PASS+1)); printf " \033[32m✓\033[0m %s\n" "$1"; }
fail() { FAIL=$((FAIL+1)); printf " \033[31m✗\033[0m %s\n" "$1"; }
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
MACH=$(uname -m)
case "$MACH" in
x86_64|amd64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) echo "unsupported arch: $MACH"; exit 1 ;;
esac
ARCHIVE_NAME="iris_${OS}_${ARCH}.tar.gz"
echo "building old binary (v0.0.1)..."
go build -ldflags="-X github.com/versenilvis/iris/root.Version=v0.0.1" -o "$TMP/iris" ./cmd/iris
echo "building new binary (v1.99.0) + archive..."
mkdir -p "$TMP/new"
go build -ldflags="-X github.com/versenilvis/iris/root.Version=v1.99.0" -o "$TMP/new/iris" ./cmd/iris
tar -czf "$TMP/$ARCHIVE_NAME" -C "$TMP/new" iris
cp scripts/install.sh "$TMP/install.sh"
printf '#!/bin/sh\nexit 1\n' > "$TMP/install-fail.sh"
# --- mock server: release JSON, install script, and the tarball ---
python3 -c 'import sys, textwrap; exec(textwrap.dedent(r"""
import os, sys
from http.server import HTTPServer, BaseHTTPRequestHandler
port = int(sys.argv[1])
tmp_dir = sys.argv[2]
archive_name = sys.argv[3]
class mock_handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/update-response.json":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
body = "## Changelog\\n### Bug fixes\\n* abc1234 a test fix\\n"
resp = "{\"tag_name\":\"v1.99.0\",\"published_at\":\"2026-01-01T00:00:00Z\",\"body\":\"" + body + "\",\"prerelease\":false}"
self.wfile.write(resp.encode("utf-8"))
elif self.path in ("/install.sh", "/install-fail.sh"):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
with open(os.path.join(tmp_dir, self.path.lstrip("/")), "rb") as f:
self.wfile.write(f.read())
elif "/repos/versenilvis/iris/releases/latest" in self.path:
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
download_url = "http://localhost:" + str(port) + "/" + archive_name
self.wfile.write(("{\"tag_name\":\"v1.99.0\",\"assets\":[{\"browser_download_url\":\"" + download_url + "\"}]}").encode("utf-8"))
elif self.path == "/" + archive_name:
filepath = os.path.join(tmp_dir, archive_name)
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.end_headers()
with open(filepath, "rb") as f:
self.wfile.write(f.read())
else:
self.send_response(404)
self.end_headers()
def log_message(self, *args):
pass
HTTPServer(("", port), mock_handler).serve_forever()
"""))' "$PORT" "$TMP" "$ARCHIVE_NAME" 2>/dev/null &
SERVER_PID=$!
i=0
until curl -sf "http://localhost:${PORT}/update-response.json" >/dev/null 2>&1; do
i=$((i+1))
[ $i -ge 30 ] && echo "mock server failed to start" && exit 1
sleep 0.1
done
mkdir -p "$TMP/cfg/iris"
cat > "$TMP/cfg/iris/config.toml" << 'CFGEOF'
[updater]
check-on-startup = true
channel = "stable"
check-interval = "0s"
auto-update = 1
CFGEOF
# fresh process = fresh check cycle, since check-interval is 0s
run_check() {
HOME="$TMP/home" \
XDG_CONFIG_HOME="$TMP/cfg" \
XDG_DATA_HOME="$TMP/data" \
BIN_DIR="$TMP/installed" \
IRIS_UPDATE_URL="http://localhost:${PORT}/update-response.json" \
IRIS_INSTALL_URL="$1" \
"$TMP/iris" >"$TMP/out.log" 2>&1 &
RUN_PID=$!
sleep 1.5
kill $RUN_PID 2>/dev/null
wait $RUN_PID 2>/dev/null
}
state_field() {
# tolerate BurntSushi/toml's indentation of nested-table fields
grep -E "^[[:space:]]*$1" "$TMP/data/iris/state.toml" 2>/dev/null | head -1
}
echo ""
echo "--- running test cases ---"
# --- test 1: attempt 1 installs silently and succeeds ---
printf "test 1: silent auto-install (attempt 1)... "
run_check "http://localhost:${PORT}/install.sh"
if [ -x "$TMP/installed/iris" ] && "$TMP/installed/iris" version 2>&1 | grep -q "v1.99.0"; then
ok "binary auto-installed to v1.99.0"
else
fail "binary was not installed; see $TMP/out.log"
fi
if state_field "auto-update-attempt" | grep -q " 0$"; then
ok "attempt counter cleared after success"
else
fail "expected attempt counter 0 after success, got: $(state_field auto-update-attempt)"
fi
# --- tests 2-5: forced failures escalate, then give up instead of looping ---
rm -rf "$TMP/data" "$TMP/installed"
printf "test 2: attempt 1 (forced failure)... "
run_check "http://localhost:${PORT}/install-fail.sh"
if state_field "auto-update-attempt" | grep -q " 1$"; then
ok "attempt counter is 1"
else
fail "expected attempt counter 1, got: $(state_field auto-update-attempt)"
fi
printf "test 3: attempt 2 (forced failure again, escalates)... "
run_check "http://localhost:${PORT}/install-fail.sh"
if state_field "auto-update-attempt" | grep -q " 2$"; then
ok "attempt counter is 2 (would show a confirm prompt)"
else
fail "expected attempt counter 2, got: $(state_field auto-update-attempt)"
fi
printf "test 4: attempt 3 (gives up)... "
run_check "http://localhost:${PORT}/install-fail.sh"
if state_field "auto-update-attempt" | grep -q " 3$"; then
ok "attempt counter is 3 (gave up)"
else
fail "expected attempt counter 3, got: $(state_field auto-update-attempt)"
fi
printf "test 5: attempt 4+ (stays given up, no infinite retry)... "
run_check "http://localhost:${PORT}/install-fail.sh"
if state_field "auto-update-attempt" | grep -q " 3$"; then
ok "attempt counter stayed at 3"
else
fail "expected attempt counter to stay at 3, got: $(state_field auto-update-attempt)"
fi
echo ""
printf "results: \033[32m%d passed\033[0m, \033[31m%d failed\033[0m\n" "$PASS" "$FAIL"
echo ""
echo "note: the confirm-prompt UI (auto-update=2, or auto-update=1's 2nd"
echo "attempt) is interactive and needs a real terminal with iris shell"
echo "hooks active to see rendered - set auto-update in your real config"
echo "and watch a live session for that part."
[ $FAIL -eq 0 ] || exit 1
# test the install script locally
# usage: just debug-install v1.0.0
[group('debug')]
+173
View File
@@ -0,0 +1,173 @@
package root
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"sync/atomic"
"time"
"github.com/versenilvis/iris/internal/config"
)
const installScriptURL = "https://raw.githubusercontent.com/versenilvis/iris/main/scripts/install.sh"
func resolveInstallScriptURL() string {
if url := os.Getenv("IRIS_INSTALL_URL"); url != "" {
return url
}
return installScriptURL
}
// non-interactive output is captured, not streamed - the wrapper's terminal
// is in raw mode, so streaming installer output would corrupt the display
func performUpdate(latest string, interactive bool) (output string, err error) {
ctx := context.Background()
if !interactive {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
}
cmdRun := exec.CommandContext(ctx, "sh", "-c", "curl -sSL "+resolveInstallScriptURL()+" | sh")
if config.Get().Updater.Channel == "nightly" {
cmdRun.Env = append(os.Environ(), "IRIS_RELEASE_TAG="+latest)
}
if interactive {
cmdRun.Stdout = os.Stdout
cmdRun.Stderr = os.Stderr
cmdRun.Stdin = os.Stdin
return "", cmdRun.Run()
}
out, runErr := cmdRun.CombinedOutput()
return string(out), runErr
}
type autoUpdateAction int
const (
autoUpdateNotifyOnly autoUpdateAction = iota
autoUpdateInstallSilently
autoUpdateConfirm
autoUpdateGiveUp
)
// mode 1 escalates per target version: attempt 1 installs silently, attempt
// 2 asks first, attempt 3+ gives up. mode 2 always asks, never gives up
func decideAutoUpdateAction(mode int, latest string, st config.UpdaterState) autoUpdateAction {
if mode == 0 {
return autoUpdateNotifyOnly
}
if latest == st.DeclinedVersion {
return autoUpdateNotifyOnly
}
if mode == 2 {
return autoUpdateConfirm
}
attempt := st.AutoUpdateAttempt
if st.AutoUpdateTarget != latest {
attempt = 0
}
switch attempt {
case 0:
return autoUpdateInstallSilently
case 1:
return autoUpdateConfirm
default:
return autoUpdateGiveUp
}
}
var (
pendingAutoUpdateConfirm atomic.Bool
pendingAutoUpdateVersion atomic.Value
)
func armAutoUpdateConfirm(version string) {
pendingAutoUpdateVersion.Store(version)
pendingAutoUpdateConfirm.Store(true)
}
// reports whether the byte was consumed - callers must not fall through
// to normal key handling when true
func handleAutoUpdateConfirmKey(b byte) bool {
if !pendingAutoUpdateConfirm.Load() {
return false
}
switch b {
case 'y', 'Y':
pendingAutoUpdateConfirm.Store(false)
version, _ := pendingAutoUpdateVersion.Load().(string)
go runConfirmedAutoUpdate(version)
case 'n', 'N', 0x1b, 0x03: // n, N, Esc, Ctrl+C
pendingAutoUpdateConfirm.Store(false)
version, _ := pendingAutoUpdateVersion.Load().(string)
declineAutoUpdate(version)
}
return true
}
func runConfirmedAutoUpdate(version string) {
if version == "" {
return
}
writeStdout([]byte("\r\033[K\033[36m[IRIS] updating...\033[0m\n"))
if _, err := performUpdate(version, false); err != nil {
writeStdout(fmt.Appendf(nil, "\033[31m[IRIS] update failed: %v\033[0m\n", err))
return
}
state := config.LoadState()
state.Updater.AutoUpdateTarget = ""
state.Updater.AutoUpdateAttempt = 0
state.Updater.SeenVersion = ""
_ = config.SaveState(state)
writeStdout(fmt.Appendf(nil,
"\033[32m[IRIS] updated to %s, restart your terminal to use it\033[0m\n", version,
))
}
func declineAutoUpdate(version string) {
if version == "" {
return
}
state := config.LoadState()
state.Updater.DeclinedVersion = version
state.Updater.AutoUpdateTarget = ""
state.Updater.AutoUpdateAttempt = 0
_ = config.SaveState(state)
writeStdout([]byte("\r\033[K\033[33m[IRIS] update declined, run `iris update` any time to install manually\033[0m\n"))
}
func printAutoUpdateInstalledNotice(latest string) {
writeStdout(fmt.Appendf(nil,
"\r\033[K\033[32m[IRIS] auto-updated %s → %s\033[0m\n\033[32m[IRIS] restart your terminal to use the new version\033[0m\n",
Version, latest,
))
}
func printAutoUpdateConfirmPrompt(latest, notes string) {
var b strings.Builder
fmt.Fprintf(&b, "\r\033[K\033[33m[IRIS] new version %s → %s available\033[0m\n", Version, latest)
for _, line := range changelogSummaryLines(notes, 2) {
fmt.Fprintf(&b, "\033[33m - %s\033[0m\n", line)
}
fmt.Fprint(&b, "\033[33minstall now? [y/N] \033[0m")
writeStdout([]byte(b.String()))
}
func printAutoUpdateGiveUpNotice(latest string) {
writeStdout(fmt.Appendf(nil,
"\r\033[K\033[33m[IRIS] could not auto-update to %s after repeated attempts, run \033[1miris update\033[0m\033[33m to install manually\033[0m\n",
latest,
))
}
+209
View File
@@ -0,0 +1,209 @@
package root
import (
"io"
"os"
"strings"
"testing"
"github.com/versenilvis/iris/internal/config"
)
// captureStdout redirects os.Stdout for the duration of fn and returns
// everything written to it. writeStdout writes to os.Stdout directly, so
// this is the only way to observe it without a real terminal.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
original := os.Stdout
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = original
out, _ := io.ReadAll(r)
return string(out)
}
func TestDecideAutoUpdateActionOff(t *testing.T) {
st := config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 5}
if got := decideAutoUpdateAction(0, "v1.1.0", st); got != autoUpdateNotifyOnly {
t.Errorf("mode 0: expected notifyOnly, got %v", got)
}
}
func TestDecideAutoUpdateActionDeclinedVersionNeverRePrompted(t *testing.T) {
st := config.UpdaterState{DeclinedVersion: "v1.1.0"}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateNotifyOnly {
t.Errorf("mode 1, declined: expected notifyOnly, got %v", got)
}
if got := decideAutoUpdateAction(2, "v1.1.0", st); got != autoUpdateNotifyOnly {
t.Errorf("mode 2, declined: expected notifyOnly, got %v", got)
}
}
func TestDecideAutoUpdateActionModeTwoAlwaysConfirms(t *testing.T) {
cases := []config.UpdaterState{
{},
{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 0},
{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 1},
{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 3},
}
for _, st := range cases {
if got := decideAutoUpdateAction(2, "v1.1.0", st); got != autoUpdateConfirm {
t.Errorf("mode 2, state %+v: expected confirm, got %v", st, got)
}
}
}
func TestDecideAutoUpdateActionModeOneEscalationLadder(t *testing.T) {
// a different or absent target: this is a fresh version, try #1
if got := decideAutoUpdateAction(1, "v1.1.0", config.UpdaterState{}); got != autoUpdateInstallSilently {
t.Errorf("fresh target: expected installSilently, got %v", got)
}
if got := decideAutoUpdateAction(1, "v1.1.0", config.UpdaterState{AutoUpdateTarget: "v1.0.5", AutoUpdateAttempt: 2}); got != autoUpdateInstallSilently {
t.Errorf("different target: expected installSilently (attempt resets), got %v", got)
}
// same target, one attempt already made: this is try #2
st := config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 1}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateConfirm {
t.Errorf("attempt 1 stored: expected confirm, got %v", got)
}
// same target, two attempts already made: this is try #3, give up
st = config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 2}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateGiveUp {
t.Errorf("attempt 2 stored: expected giveUp, got %v", got)
}
// already given up: stays given up, doesn't loop back to installing
st = config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 3}
if got := decideAutoUpdateAction(1, "v1.1.0", st); got != autoUpdateGiveUp {
t.Errorf("attempt 3 stored: expected giveUp (stays given up), got %v", got)
}
}
func TestDecideAutoUpdateActionNewerVersionResetsLadder(t *testing.T) {
// gave up on v1.1.0, but v1.2.0 is a different target entirely - fresh start
st := config.UpdaterState{AutoUpdateTarget: "v1.1.0", AutoUpdateAttempt: 3}
if got := decideAutoUpdateAction(1, "v1.2.0", st); got != autoUpdateInstallSilently {
t.Errorf("newer target after giving up on an older one: expected installSilently, got %v", got)
}
}
func TestPrintAutoUpdateInstalledNotice(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
out := captureStdout(t, func() { printAutoUpdateInstalledNotice("v1.1.0") })
if !strings.Contains(out, "v1.0.0") || !strings.Contains(out, "v1.1.0") {
t.Errorf("expected the version transition, got %q", out)
}
if !strings.Contains(out, "restart your terminal") {
t.Errorf("expected a restart notice, got %q", out)
}
}
func TestPrintAutoUpdateConfirmPrompt(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
out := captureStdout(t, func() {
printAutoUpdateConfirmPrompt("v1.1.0", "## Changelog\n### Bug fixes\n* abc1234 fix something\n")
})
if !strings.Contains(out, "v1.1.0") {
t.Errorf("expected the target version, got %q", out)
}
if !strings.Contains(out, "fix something") {
t.Errorf("expected the changelog summary, got %q", out)
}
if !strings.Contains(out, "install now?") {
t.Errorf("expected the confirm prompt text, got %q", out)
}
}
func TestPrintAutoUpdateGiveUpNotice(t *testing.T) {
out := captureStdout(t, func() { printAutoUpdateGiveUpNotice("v1.1.0") })
if !strings.Contains(out, "v1.1.0") {
t.Errorf("expected the target version, got %q", out)
}
if !strings.Contains(out, "iris update") {
t.Errorf("expected a pointer to manual iris update, got %q", out)
}
}
func TestDeclineAutoUpdateRecordsState(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
t.Setenv("XDG_DATA_HOME", tmpDir+"/.local/share")
state := config.LoadState()
state.Updater.AutoUpdateTarget = "v1.1.0"
state.Updater.AutoUpdateAttempt = 2
if err := config.SaveState(state); err != nil {
t.Fatalf("SaveState: %v", err)
}
captureStdout(t, func() { declineAutoUpdate("v1.1.0") })
got := config.LoadState()
if got.Updater.DeclinedVersion != "v1.1.0" {
t.Errorf("expected DeclinedVersion v1.1.0, got %q", got.Updater.DeclinedVersion)
}
if got.Updater.AutoUpdateTarget != "" || got.Updater.AutoUpdateAttempt != 0 {
t.Errorf("expected the escalation ladder to be reset, got target=%q attempt=%d",
got.Updater.AutoUpdateTarget, got.Updater.AutoUpdateAttempt)
}
}
func TestHandleAutoUpdateConfirmKeyNotArmedIsNoop(t *testing.T) {
pendingAutoUpdateConfirm.Store(false)
if handleAutoUpdateConfirmKey('y') {
t.Error("expected handleAutoUpdateConfirmKey to report unhandled when nothing is pending")
}
}
func TestHandleAutoUpdateConfirmKeyDeclineFlow(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
t.Setenv("XDG_DATA_HOME", tmpDir+"/.local/share")
t.Cleanup(func() { pendingAutoUpdateConfirm.Store(false) })
armAutoUpdateConfirm("v1.1.0")
captureStdout(t, func() {
if !handleAutoUpdateConfirmKey('n') {
t.Error("expected 'n' to be reported as handled while a confirm is pending")
}
})
if pendingAutoUpdateConfirm.Load() {
t.Error("expected the pending confirm flag to clear after declining")
}
if got := config.LoadState().Updater.DeclinedVersion; got != "v1.1.0" {
t.Errorf("expected the decline to be recorded, got %q", got)
}
}
func TestHandleAutoUpdateConfirmKeySwallowsUnrecognizedBytes(t *testing.T) {
t.Cleanup(func() { pendingAutoUpdateConfirm.Store(false) })
armAutoUpdateConfirm("v1.1.0")
if !handleAutoUpdateConfirmKey('x') {
t.Error("expected an unrecognized byte to still be reported as consumed while pending")
}
if !pendingAutoUpdateConfirm.Load() {
t.Error("expected the prompt to stay armed until y/n/Esc/Ctrl+C")
}
}
+312
View File
@@ -0,0 +1,312 @@
package root
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/x/ansi"
"github.com/spf13/cobra"
"github.com/versenilvis/iris/internal/config"
"golang.org/x/term"
)
type Release struct {
TagName string `json:"tag_name"`
PublishedAt time.Time `json:"published_at"`
Body string `json:"body"`
Prerelease bool `json:"prerelease"`
}
type changelogCache struct {
FetchedAt time.Time `json:"fetched_at"`
Channel string `json:"channel"`
Releases []Release `json:"releases"`
}
const changelogCacheTTL = time.Hour
func changelogCachePath() (string, error) {
dir, err := config.CachePath()
if err != nil {
return "", err
}
return filepath.Join(dir, "changelog-cache.json"), nil
}
func loadChangelogCache() (*changelogCache, error) {
path, err := changelogCachePath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cache changelogCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, err
}
return &cache, nil
}
func saveChangelogCache(releases []Release) {
path, err := changelogCachePath()
if err != nil {
return
}
data, err := json.Marshal(changelogCache{FetchedAt: time.Now(), Channel: config.Get().Updater.Channel, Releases: releases})
if err != nil {
return
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, data, 0o644)
}
func FetchReleases(limit int) ([]Release, error) {
ctx, cancel := newGitHubRequestContext()
defer cancel()
endpoint := os.Getenv("IRIS_CHANGELOG_URL")
if endpoint == "" {
endpoint = "https://api.github.com/repos/versenilvis/iris/releases?per_page=100"
}
body, err := fetchGitHubBody(ctx, endpoint)
if err != nil {
return nil, err
}
var releases []Release
if err := json.Unmarshal(body, &releases); err != nil {
return nil, err
}
if config.Get().Updater.Channel != "nightly" {
filtered := releases[:0]
for _, r := range releases {
if !r.Prerelease {
filtered = append(filtered, r)
}
}
releases = filtered
}
return truncateReleases(releases, limit), nil
}
func truncateReleases(releases []Release, limit int) []Release {
if limit > 0 && len(releases) > limit {
return releases[:limit]
}
return releases
}
// falls back to a stale cache rather than failing outright when rate limited
func FetchReleasesCached(limit int, refresh bool) (releases []Release, rateLimited bool, err error) {
channel := config.Get().Updater.Channel
if !refresh {
if cache, cacheErr := loadChangelogCache(); cacheErr == nil && cache.Channel == channel && time.Since(cache.FetchedAt) < changelogCacheTTL {
return truncateReleases(cache.Releases, limit), false, nil
}
}
fresh, fetchErr := FetchReleases(100)
if fetchErr != nil {
if errors.Is(fetchErr, ErrRateLimited) {
if cache, cacheErr := loadChangelogCache(); cacheErr == nil && cache.Channel == channel && len(cache.Releases) > 0 {
return truncateReleases(cache.Releases, limit), true, nil
}
}
return nil, false, fetchErr
}
saveChangelogCache(fresh)
return truncateReleases(fresh, limit), false, nil
}
func releaseURL(tag string) string {
return "https://github.com/versenilvis/iris/releases/tag/" + tag
}
var (
changelogRenderer *glamour.TermRenderer
changelogRendererOnce sync.Once
errChangelogRenderer error
)
func changelogRenderWidth() int {
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 && w < 120 {
return w
}
return 120
}
func renderChangelogMarkdown(body string) (string, error) {
changelogRendererOnce.Do(func() {
changelogRenderer, errChangelogRenderer = glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(changelogRenderWidth()),
)
})
if errChangelogRenderer != nil {
return "", errChangelogRenderer
}
return changelogRenderer.Render(body)
}
func stripRedundantHeading(body string) string {
lines := strings.Split(body, "\n")
filtered := lines[:0]
for _, line := range lines {
if strings.TrimSpace(line) == "## Changelog" {
continue
}
filtered = append(filtered, line)
}
return strings.Join(filtered, "\n")
}
// judged post-strip: glamour's margin padding has color codes but no glyphs
func isBlankLine(line string) bool {
return strings.TrimSpace(ansi.Strip(line)) == ""
}
func squeezeBlankLines(s string) string {
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
prevBlank := false
for _, line := range lines {
blank := isBlankLine(line)
if blank && prevBlank {
continue
}
out = append(out, line)
prevBlank = blank
}
return strings.Join(out, "\n")
}
func trimBlankLines(s string) string {
lines := strings.Split(s, "\n")
start := 0
for start < len(lines) && isBlankLine(lines[start]) {
start++
}
end := len(lines)
for end > start && isBlankLine(lines[end-1]) {
end--
}
return strings.Join(lines[start:end], "\n")
}
// the CTA is rendered separately by printRelease based on IsNewer, so the
// GoReleaser footer is cut before handing the body to glamour
func printChangelogBody(out io.Writer, body string) {
if idx := strings.Index(body, "## Update"); idx != -1 {
body = body[:idx]
}
body = strings.TrimSpace(stripRedundantHeading(body))
if body == "" {
return
}
rendered, err := renderChangelogMarkdown(body)
if err != nil {
fmt.Fprintln(out, body)
return
}
fmt.Fprintln(out, trimBlankLines(squeezeBlankLines(rendered)))
}
func printRelease(out io.Writer, release Release, showUpdateCTA bool) {
label := release.TagName
if strings.TrimPrefix(release.TagName, "v") == strings.TrimPrefix(Version, "v") {
label += " (current)"
}
date := release.PublishedAt.Format("2006-01-02")
fmt.Fprintf(out, "\033[1;36mIRIS %s - %s\033[0m\n", label, date)
fmt.Fprintf(out, "\033[2m%s\033[0m\n\n", releaseURL(release.TagName))
printChangelogBody(out, release.Body)
if showUpdateCTA && IsNewer(Version, release.TagName) {
fmt.Fprintln(out)
fmt.Fprintln(out, "run `iris update` to install")
}
}
var (
changelogCount int
changelogRefresh bool
)
var ChangelogCmd = &cobra.Command{
Use: "changelog [version]",
Short: "show what changed in recent iris releases",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
limit := changelogCount
if len(args) == 1 {
limit = 0 // searching a specific version, not just the most recent N
}
releases, rateLimited, err := FetchReleasesCached(limit, changelogRefresh)
if err != nil {
if errors.Is(err, ErrRateLimited) {
fmt.Fprintln(cmd.ErrOrStderr(), "\033[31m[IRIS] rate limited by GitHub API, try again later\033[0m")
return
}
fmt.Fprintf(cmd.ErrOrStderr(), "\033[31m[IRIS] could not fetch changelog: %v\033[0m\n", err)
return
}
if len(releases) == 0 {
cmd.Println("no releases found")
return
}
out := cmd.OutOrStdout()
if len(args) == 1 {
target := strings.TrimPrefix(args[0], "v")
for _, release := range releases {
if strings.TrimPrefix(release.TagName, "v") == target {
printRelease(out, release, true)
if rateLimited {
fmt.Fprintln(out)
fmt.Fprintln(out, "\033[33m[IRIS] rate limited by GitHub API, showing cached data\033[0m")
}
return
}
}
fmt.Fprintf(cmd.ErrOrStderr(), "\033[31m[IRIS] release %s not found\033[0m\n", args[0])
return
}
for i, release := range releases {
if i > 0 {
fmt.Fprintln(out)
}
printRelease(out, release, i == 0)
}
if rateLimited {
fmt.Fprintln(out)
fmt.Fprintln(out, "\033[33m[IRIS] rate limited by GitHub API, showing cached data\033[0m")
}
},
}
func init() {
ChangelogCmd.Flags().IntVarP(&changelogCount, "count", "n", 1, "number of releases to show")
ChangelogCmd.Flags().BoolVar(&changelogRefresh, "refresh", false, "bypass the cache and fetch fresh data")
rootCmd.AddCommand(ChangelogCmd)
}
+383
View File
@@ -0,0 +1,383 @@
package root
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/charmbracelet/x/ansi"
"github.com/versenilvis/iris/internal/config"
)
func TestFetchReleasesFiltersPrereleasesOnStableChannel(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
cfg := config.DefaultConfig()
cfg.Updater.Channel = "stable"
config.Init(cfg)
releases := []Release{
{TagName: "v0.6.0-nightly.abc", Prerelease: true, Body: "nightly"},
{TagName: "v0.5.2", Prerelease: false, Body: "stable"},
{TagName: "v0.5.1", Prerelease: false, Body: "stable"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(releases)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, err := FetchReleases(0)
if err != nil {
t.Fatalf("FetchReleases: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 stable releases, got %d: %+v", len(got), got)
}
for _, r := range got {
if r.Prerelease {
t.Errorf("prerelease %q leaked into stable channel results", r.TagName)
}
}
}
func TestFetchReleasesLimitsCount(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
config.Init(config.DefaultConfig())
releases := []Release{
{TagName: "v0.5.2"}, {TagName: "v0.5.1"}, {TagName: "v0.5.0"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(releases)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, err := FetchReleases(2)
if err != nil {
t.Fatalf("FetchReleases: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected limit of 2, got %d", len(got))
}
}
func seedChangelogCache(t *testing.T, fetchedAt time.Time, releases []Release) {
t.Helper()
path, err := changelogCachePath()
if err != nil {
t.Fatalf("changelogCachePath: %v", err)
}
data, err := json.Marshal(changelogCache{FetchedAt: fetchedAt, Channel: config.Get().Updater.Channel, Releases: releases})
if err != nil {
t.Fatalf("marshal cache: %v", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir cache dir: %v", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write cache: %v", err)
}
}
func TestFetchReleasesCachedServesWithinTTL(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
seedChangelogCache(t, time.Now(), []Release{{TagName: "v1.0.0", Body: "cached"}})
// point at an address nothing listens on: a network call here is a test failure
t.Setenv("IRIS_CHANGELOG_URL", "http://127.0.0.1:1/unreachable")
got, rateLimited, err := FetchReleasesCached(1, false)
if err != nil {
t.Fatalf("FetchReleasesCached: %v", err)
}
if rateLimited {
t.Error("expected rateLimited=false for a fresh cache hit")
}
if len(got) != 1 || got[0].TagName != "v1.0.0" {
t.Fatalf("expected cached release, got %+v", got)
}
}
func TestFetchReleasesCachedChannelChangeBypassesStaleCache(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
cfg := config.DefaultConfig()
cfg.Updater.Channel = "nightly"
config.Init(cfg)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
seedChangelogCache(t, time.Now(), []Release{{TagName: "v1.0.0-nightly.abc", Body: "nightly cache", Prerelease: true}})
cfg.Updater.Channel = "stable"
config.Init(cfg)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]Release{{TagName: "v1.0.0", Body: "stable release"}})
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, _, err := FetchReleasesCached(1, false)
if err != nil {
t.Fatalf("FetchReleasesCached: %v", err)
}
if len(got) != 1 || got[0].TagName != "v1.0.0" {
t.Fatalf("expected the channel switch to bypass the nightly-channel cache and fetch fresh, got %+v", got)
}
}
func TestFetchReleasesCachedRefreshBypassesTTL(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
config.Init(config.DefaultConfig())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
seedChangelogCache(t, time.Now(), []Release{{TagName: "v1.0.0", Body: "stale-but-fresh"}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]Release{{TagName: "v2.0.0", Body: "fresh"}})
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, _, err := FetchReleasesCached(1, true)
if err != nil {
t.Fatalf("FetchReleasesCached: %v", err)
}
if len(got) != 1 || got[0].TagName != "v2.0.0" {
t.Fatalf("expected --refresh to bypass the cache, got %+v", got)
}
}
func TestFetchReleasesCachedRateLimitFallsBackToStaleCache(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
seedChangelogCache(t, time.Now().Add(-2*changelogCacheTTL), []Release{{TagName: "v1.0.0", Body: "stale"}})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
got, rateLimited, err := FetchReleasesCached(1, false)
if err != nil {
t.Fatalf("FetchReleasesCached: %v", err)
}
if !rateLimited {
t.Error("expected rateLimited=true when the API 403s and a stale cache exists")
}
if len(got) != 1 || got[0].TagName != "v1.0.0" {
t.Fatalf("expected stale cached release, got %+v", got)
}
}
func TestFetchReleasesCachedRateLimitNoCacheReturnsError(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
_, _, err := FetchReleasesCached(1, false)
if !errors.Is(err, ErrRateLimited) {
t.Fatalf("expected ErrRateLimited, got %v", err)
}
}
func TestPrintReleaseSuppressesUpdateCTAWhenCurrent(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
var buf bytes.Buffer
printRelease(&buf, Release{TagName: "v1.0.0", Body: "## Changelog\n### Bug fixes\n* abc1234 fix something\n"}, true)
if strings.Contains(buf.String(), "run `iris update`") {
t.Error("expected no update CTA when already on the latest version")
}
if !strings.Contains(buf.String(), "(current)") {
t.Error("expected the version label to be marked (current)")
}
}
func TestPrintReleaseShowsUpdateCTAWhenNewer(t *testing.T) {
originalVersion := Version
Version = "v0.9.0"
t.Cleanup(func() { Version = originalVersion })
var buf bytes.Buffer
printRelease(&buf, Release{TagName: "v1.0.0", Body: "## Changelog\n### Bug fixes\n* abc1234 fix something\n"}, true)
if !strings.Contains(buf.String(), "run `iris update`") {
t.Error("expected an update CTA when a newer release is shown")
}
}
func TestPrintChangelogBodyStopsAtUpdateFooter(t *testing.T) {
var buf bytes.Buffer
body := "## Changelog\n### Bug fixes\n* abc1234 fix something\n## Update\n```bash\niris update\n```\n"
printChangelogBody(&buf, body)
plain := ansi.Strip(buf.String())
if strings.Contains(plain, "Update") {
t.Error("expected the GoReleaser footer to be excluded from rendered body")
}
if !strings.Contains(plain, "fix something") {
t.Errorf("expected the changelog entry to be rendered, got %q", plain)
}
}
func TestPrintChangelogBodyRendersArbitraryMarkdown(t *testing.T) {
var buf bytes.Buffer
body := "# 🌙 **Nightly build** - `v0.6.0-nightly.abc123`\nNightly release from commit: abc123\n"
printChangelogBody(&buf, body)
plain := ansi.Strip(buf.String())
if !strings.Contains(plain, "Nightly build") {
t.Errorf("expected the body rendered via glamour, got %q", plain)
}
}
func TestPrintChangelogBodyEmptyBodyPrintsNothing(t *testing.T) {
var buf bytes.Buffer
printChangelogBody(&buf, "")
if buf.Len() != 0 {
t.Errorf("expected no output for an empty body, got %q", buf.String())
}
}
func TestPrintChangelogBodyStripsRedundantHeadingAndBlankRuns(t *testing.T) {
var buf bytes.Buffer
body := "## Changelog\n### Bug fixes\n* abc1234 fix something\n"
printChangelogBody(&buf, body)
plain := ansi.Strip(buf.String())
if strings.Contains(plain, "Changelog") {
t.Errorf("expected the redundant ## Changelog heading to be stripped, got %q", plain)
}
if strings.Contains(buf.String(), "\n\n\n") {
t.Errorf("expected consecutive blank lines to be squeezed, got %q", buf.String())
}
}
func TestStripRedundantHeadingRemovesExactGoReleaserLabelOnly(t *testing.T) {
got := stripRedundantHeading("## Changelog\n### Bug fixes\n* item\n")
want := "### Bug fixes\n* item\n"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestStripRedundantHeadingPreservesHandWrittenHeadings(t *testing.T) {
body := "## Theme is here\n<img src=\"...\" />\n"
got := stripRedundantHeading(body)
if got != body {
t.Errorf("expected a hand-written ## heading to be preserved, got %q", got)
}
}
func TestPrintChangelogBodyPreservesHandWrittenAnnouncement(t *testing.T) {
var buf bytes.Buffer
body := "## Theme is here\r\n<img width=\"1818\" src=\"...\" />\r\n\r\n## Update\r\n```bash\r\niris update\r\n```\r\n"
printChangelogBody(&buf, body)
plain := ansi.Strip(buf.String())
if !strings.Contains(plain, "Theme is here") {
t.Errorf("expected the hand-written release heading to be rendered, got %q", plain)
}
}
func TestSqueezeBlankLinesCollapsesRuns(t *testing.T) {
got := squeezeBlankLines("a\n\n\n\nb\n")
want := "a\n\nb\n"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestChangelogCmdShowsSpecificVersion(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
config.Init(config.DefaultConfig())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
releases := []Release{
{TagName: "v0.5.2", Body: "## Changelog\n### Bug fixes\n* xyz9999 unrelated fix\n"},
{TagName: "v0.5.1", Body: "## Changelog\n### Bug fixes\n* abc1234 fix mode\n"},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(releases)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
var out, errOut bytes.Buffer
ChangelogCmd.SetOut(&out)
ChangelogCmd.SetErr(&errOut)
t.Cleanup(func() {
ChangelogCmd.SetOut(nil)
ChangelogCmd.SetErr(nil)
})
ChangelogCmd.Run(ChangelogCmd, []string{"v0.5.1"})
got := ansi.Strip(out.String())
if !strings.Contains(got, "v0.5.1") {
t.Fatalf("expected the requested version in output, got %q", got)
}
if strings.Contains(got, "v0.5.2") || strings.Contains(got, "unrelated fix") {
t.Errorf("expected only the requested version, got %q", got)
}
if !strings.Contains(got, "fix mode") {
t.Errorf("expected the matched release's body, got %q", got)
}
}
func TestChangelogCmdVersionNotFound(t *testing.T) {
originalConfig := config.Get()
t.Cleanup(func() { config.Init(originalConfig) })
config.Init(config.DefaultConfig())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
releases := []Release{{TagName: "v0.5.2", Body: "latest"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(releases)
}))
defer srv.Close()
t.Setenv("IRIS_CHANGELOG_URL", srv.URL)
var out, errOut bytes.Buffer
ChangelogCmd.SetOut(&out)
ChangelogCmd.SetErr(&errOut)
t.Cleanup(func() {
ChangelogCmd.SetOut(nil)
ChangelogCmd.SetErr(nil)
})
ChangelogCmd.Run(ChangelogCmd, []string{"v9.9.9"})
if !strings.Contains(errOut.String(), "not found") {
t.Errorf("expected a not-found error, got stdout=%q stderr=%q", out.String(), errOut.String())
}
}
+3
View File
@@ -98,6 +98,9 @@ channel = "stable"
# interval between update checks, e.g. "24h", "6h", "30m"
check-interval = "24h"
# 0 = off (default, notify only), 1 = auto-install, 2 = always confirm first
auto-update = 0
[keybindings]
toggle-mode = "ctrl+r"
toggle-menu = "shift+tab"
+3
View File
@@ -282,6 +282,9 @@ channel = "stable"
# interval between update checks, e.g. "24h", "6h", "30m"
check-interval = "24h"
# 0 = off (default, notify only), 1 = auto-install, 2 = always confirm first
auto-update = 0
[keybindings]
toggle-mode = "ctrl+r"
toggle-menu = "shift+tab"
+145 -54
View File
@@ -3,11 +3,11 @@ package root
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"time"
@@ -16,18 +16,59 @@ import (
"github.com/versenilvis/iris/internal/config"
)
type updateResultKind int
const (
updateResultNotify updateResultKind = iota
updateResultAutoInstalled
updateResultConfirm
updateResultGiveUp
)
// updateResult is passed from the async checker to the main loop
type updateResult struct {
kind updateResultKind
latestVersion string
notes string
hasUpdate bool
}
// pendingUpdate is set by the background goroutine and consumed once after the first IRIS_CMD_STOP
var pendingUpdate chan updateResult
// FetchLatestVersion hits the GitHub Releases API and returns the latest tag name
func FetchLatestVersion() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// ErrRateLimited is returned when the GitHub API responds 403/429.
var ErrRateLimited = errors.New("rate limited by GitHub API")
func newGitHubRequestContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
func fetchGitHubBody(ctx context.Context, endpoint string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
return nil, ErrRateLimited
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// FetchLatestRelease hits the GitHub Releases API and returns the latest release
func FetchLatestRelease() (Release, error) {
ctx, cancel := newGitHubRequestContext()
defer cancel()
endpoint := os.Getenv("IRIS_UPDATE_URL")
@@ -39,50 +80,39 @@ func FetchLatestVersion() (string, error) {
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
body, err := fetchGitHubBody(ctx, endpoint)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
return Release{}, err
}
if config.Get().Updater.Channel == "nightly" && os.Getenv("IRIS_UPDATE_URL") == "" {
var releases []struct {
TagName string `json:"tag_name"`
}
var releases []Release
if err := json.Unmarshal(body, &releases); err != nil {
return "", err
return Release{}, err
}
if len(releases) == 0 {
return "", fmt.Errorf("no releases found")
return Release{}, fmt.Errorf("no releases found")
}
return releases[0].TagName, nil
return releases[0], nil
}
var result struct {
TagName string `json:"tag_name"`
var release Release
if err := json.Unmarshal(body, &release); err != nil {
return Release{}, err
}
if err := json.Unmarshal(body, &result); err != nil {
if release.TagName == "" {
return Release{}, fmt.Errorf("no tag_name in response")
}
return release, nil
}
// FetchLatestVersion hits the GitHub Releases API and returns the latest tag name
func FetchLatestVersion() (string, error) {
release, err := FetchLatestRelease()
if err != nil {
return "", err
}
if result.TagName == "" {
return "", fmt.Errorf("no tag_name in response")
}
return result.TagName, nil
return release.TagName, nil
}
// IsNewer returns true if latest is a newer semantic version than current.
@@ -175,40 +205,111 @@ func startBackgroundUpdateCheck() chan updateResult {
return
}
latest, err := FetchLatestVersion()
release, err := FetchLatestRelease()
if err != nil {
// no network or API error: silently do nothing
return
}
latest := release.TagName
// update the last check time regardless of result
state.Updater.LastCheckTime = time.Now()
if IsNewer(Version, latest) {
mode := config.Get().Updater.AutoUpdate
switch decideAutoUpdateAction(mode, latest, state.Updater) {
case autoUpdateNotifyOnly:
// only notify if user hasn't already seen this specific version notification
if state.Updater.SeenVersion != latest {
ch <- updateResult{latestVersion: latest, hasUpdate: true}
ch <- updateResult{kind: updateResultNotify, latestVersion: latest, notes: release.Body, hasUpdate: true}
}
// save the latest as seen_version so future sessions don't re-notify
// unless a NEWER version comes out (different tag)
state.Updater.SeenVersion = latest
} else {
// up to date: clear the seen_version flag so the next update triggers a fresh notification
_ = config.SaveState(state)
return
case autoUpdateInstallSilently:
state.Updater.AutoUpdateTarget = latest
state.Updater.AutoUpdateAttempt = 1
// write before installing so a crash still counts as an attempt
_ = config.SaveState(state)
if _, installErr := performUpdate(latest, false); installErr == nil {
state.Updater.AutoUpdateTarget = ""
state.Updater.AutoUpdateAttempt = 0
state.Updater.SeenVersion = ""
_ = config.SaveState(state)
ch <- updateResult{kind: updateResultAutoInstalled, latestVersion: latest, notes: release.Body, hasUpdate: true}
}
// on failure: state already recorded attempt 1 for this
// target, so the next check escalates to a confirm prompt
return
case autoUpdateConfirm:
nextAttempt := 1
if state.Updater.AutoUpdateTarget == latest {
nextAttempt = state.Updater.AutoUpdateAttempt + 1
}
state.Updater.AutoUpdateTarget = latest
state.Updater.AutoUpdateAttempt = nextAttempt
_ = config.SaveState(state)
ch <- updateResult{kind: updateResultConfirm, latestVersion: latest, notes: release.Body, hasUpdate: true}
return
case autoUpdateGiveUp:
// only announce the exact transition into giving up, not
// every cycle after
announce := state.Updater.AutoUpdateAttempt == 2
state.Updater.AutoUpdateAttempt = 3
_ = config.SaveState(state)
if announce {
ch <- updateResult{kind: updateResultGiveUp, latestVersion: latest, hasUpdate: true}
}
return
}
}
// up to date, or nothing left to do: clear update-related state so
// the next detected version starts a fresh notify/attempt cycle
state.Updater.SeenVersion = ""
state.Updater.AutoUpdateTarget = ""
state.Updater.AutoUpdateAttempt = 0
_ = config.SaveState(state)
}()
return ch
}
// printUpdateNotice writes the one-time update message to stdout
func printUpdateNotice(latest string) {
fmt.Printf(
func changelogSummaryLines(body string, max int) []string {
var lines []string
for line := range strings.SplitSeq(body, "\n") {
line = strings.TrimRight(line, "\r")
if !strings.HasPrefix(line, "* ") {
continue
}
entry := strings.TrimPrefix(line, "* ")
_, msg, ok := strings.Cut(entry, " ")
if !ok {
msg = entry
}
lines = append(lines, msg)
if len(lines) >= max {
break
}
}
return lines
}
func printUpdateNotice(latest, notes string) {
var b strings.Builder
fmt.Fprintf(&b,
"\r\033[K\033[33m[IRIS] new version %s → %s available, run \033[1miris update\033[0m\033[33m to upgrade\033[0m\n",
Version, latest,
)
for _, line := range changelogSummaryLines(notes, 2) {
fmt.Fprintf(&b, "\033[33m - %s\033[0m\n", line)
}
writeStdout([]byte(b.String()))
}
func init() {
@@ -247,23 +348,13 @@ var updateCmd = &cobra.Command{
fmt.Printf("\033[36m[IRIS] updating %s → %s\033[0m\n", Version, latest)
// download and replace the binary using the install script
installScript := "https://raw.githubusercontent.com/versenilvis/iris/main/scripts/install.sh"
command := "curl -sSL " + installScript + " | sh"
runningPrefix := ""
if config.Get().Updater.Channel == "nightly" {
runningPrefix = fmt.Sprintf("IRIS_RELEASE_TAG=%s ", latest)
}
fmt.Printf("running: %s%s\n\n", runningPrefix, command)
fmt.Printf("running: %scurl -sSL %s | sh\n\n", runningPrefix, resolveInstallScriptURL())
cmdRun := exec.Command("sh", "-c", command)
if config.Get().Updater.Channel == "nightly" {
cmdRun.Env = append(os.Environ(), "IRIS_RELEASE_TAG="+latest)
}
cmdRun.Stdout = os.Stdout
cmdRun.Stderr = os.Stderr
cmdRun.Stdin = os.Stdin
if err := cmdRun.Run(); err != nil {
if _, err := performUpdate(latest, true); err != nil {
fmt.Printf("\n\033[31m[IRIS] update failed: %v\033[0m\n", err)
return
}
+77
View File
@@ -1,8 +1,10 @@
package root
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -76,3 +78,78 @@ func TestUpdateState(t *testing.T) {
t.Errorf("Expected LastCheck %v, got %v", state.Updater.LastCheckTime, loaded.Updater.LastCheckTime)
}
}
func TestChangelogSummaryLines(t *testing.T) {
body := "## Changelog\n### Bug fixes\n* abc1234 fix something\n* def5678 fix another thing\n* ghi9012 a third fix\n## Update\n```bash\niris update\n```\n"
got := changelogSummaryLines(body, 2)
want := []string{"fix something", "fix another thing"}
if len(got) != len(want) {
t.Fatalf("expected %d lines, got %d: %v", len(want), len(got), got)
}
for i, line := range want {
if got[i] != line {
t.Errorf("line %d: expected %q, got %q", i, line, got[i])
}
}
}
func TestChangelogSummaryLinesEmptyBody(t *testing.T) {
if got := changelogSummaryLines("", 2); len(got) != 0 {
t.Errorf("expected no lines for an empty body, got %v", got)
}
}
func TestPrintUpdateNoticeIncludesChangelogSummary(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
originalStdout := os.Stdout
os.Stdout = w
printUpdateNotice("v1.1.0", "## Changelog\n### Bug fixes\n* abc1234 fix something\n* def5678 fix another thing\n* ghi9012 a third fix\n")
_ = w.Close()
os.Stdout = originalStdout
out, _ := io.ReadAll(r)
got := string(out)
if !strings.Contains(got, "v1.0.0") || !strings.Contains(got, "v1.1.0") {
t.Errorf("expected the version transition in the notice, got %q", got)
}
if !strings.Contains(got, "fix something") || !strings.Contains(got, "fix another thing") {
t.Errorf("expected the first two changelog entries in the notice, got %q", got)
}
if strings.Contains(got, "a third fix") {
t.Errorf("expected the summary to cap at 2 entries, got %q", got)
}
}
func TestPrintUpdateNoticeWithoutNotes(t *testing.T) {
originalVersion := Version
Version = "v1.0.0"
t.Cleanup(func() { Version = originalVersion })
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
originalStdout := os.Stdout
os.Stdout = w
printUpdateNotice("v1.1.0", "")
_ = w.Close()
os.Stdout = originalStdout
out, _ := io.ReadAll(r)
got := string(out)
if !strings.Contains(got, "v1.1.0") {
t.Errorf("expected the notice to still show without changelog notes, got %q", got)
}
}
+17 -1
View File
@@ -555,7 +555,17 @@ func runWrapper() {
select {
case result, ok := <-pendingUpdate:
if ok && result.hasUpdate {
printUpdateNotice(result.latestVersion)
switch result.kind {
case updateResultAutoInstalled:
printAutoUpdateInstalledNotice(result.latestVersion)
case updateResultConfirm:
printAutoUpdateConfirmPrompt(result.latestVersion, result.notes)
armAutoUpdateConfirm(result.latestVersion)
case updateResultGiveUp:
printAutoUpdateGiveUpNotice(result.latestVersion)
default:
printUpdateNotice(result.latestVersion, result.notes)
}
updatePrinted = true
}
default:
@@ -778,6 +788,12 @@ func runWrapper() {
b := inputSlice[i]
intercepted = false
// while an auto-update confirm prompt is pending, every
// byte goes to it instead of normal key handling
if handleAutoUpdateConfirmKey(b) {
continue
}
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMenu); matched {
intercepted = true
suggestionsEnabled = !suggestionsEnabled