|
| 1 | +--- |
| 2 | +title: "fix: update 커맨드 안정성 및 UX 개선" |
| 3 | +type: fix |
| 4 | +status: completed |
| 5 | +date: 2026-03-10 |
| 6 | +origin: docs/brainstorms/2026-03-10-update-command-improvements-brainstorm.md |
| 7 | +--- |
| 8 | + |
| 9 | +# fix: update 커맨드 안정성 및 UX 개선 |
| 10 | + |
| 11 | +## Overview |
| 12 | + |
| 13 | +`chromaport update`의 세 가지 문제를 수정한다: |
| 14 | + |
| 15 | +1. **CWD 미존재 시 brew 실패**: 삭제된 디렉토리에서 실행하면 brew가 `getcwd` 에러로 실패 |
| 16 | +2. **Formula 미동기화**: `brew update` 없이 `brew upgrade`만 실행하여 오래된 formula로 "already installed" 반환 |
| 17 | +3. **`-v` 미지원**: `chromaport -v`가 에러 발생 |
| 18 | + |
| 19 | +(see brainstorm: docs/brainstorms/2026-03-10-update-command-improvements-brainstorm.md) |
| 20 | + |
| 21 | +## Acceptance Criteria |
| 22 | + |
| 23 | +- [x] `brew update` → `brew upgrade chromaport` 순서로 실행 |
| 24 | +- [x] `brew update` 실패 시 경고 출력 후 `brew upgrade` 계속 진행 (best-effort) |
| 25 | +- [x] `brew update` stdout 억제, stderr는 실패 시에만 출력 |
| 26 | +- [x] 업그레이드 전 Y/n 확인 프롬프트 표시 (기본값: Yes) |
| 27 | +- [x] `Update` 서브커맨드에 `-y/--yes` 플래그 추가하여 프롬프트 스킵 |
| 28 | +- [x] Non-TTY 환경에서 `--yes` 없이 실행 시 명령어 안내만 출력 후 종료 |
| 29 | +- [x] CWD 미존재 시 brew 실행 전에 감지하여 친절한 에러 메시지 출력 |
| 30 | +- [x] `chromaport -v`가 `chromaport --version`과 동일하게 동작 |
| 31 | +- [x] Cargo install 경로에도 확인 프롬프트 적용 |
| 32 | +- [x] `Unknown` 경로는 기존대로 수동 안내만 (프롬프트 없음) |
| 33 | +- [x] 기존 테스트 통과 + 새 테스트 추가 |
| 34 | + |
| 35 | +## MVP |
| 36 | + |
| 37 | +### 1. `src/cli.rs` — `-v` 플래그 + Update 서브커맨드 확장 |
| 38 | + |
| 39 | +```rust |
| 40 | +#[derive(Parser)] |
| 41 | +#[command( |
| 42 | + version, |
| 43 | + about = "Migrate VS Code / Cursor themes to Superset, Warp, Ghostty, and more", |
| 44 | + long_about = None |
| 45 | +)] |
| 46 | +pub struct Cli { |
| 47 | + #[command(subcommand)] |
| 48 | + pub command: Option<Command>, |
| 49 | + |
| 50 | + #[arg(short, long, value_enum)] |
| 51 | + pub editor: Option<Editor>, |
| 52 | + |
| 53 | + #[arg(short, long, value_enum)] |
| 54 | + pub target: Option<Target>, |
| 55 | + |
| 56 | + #[arg(short = 'y', long)] |
| 57 | + pub yes: bool, |
| 58 | + |
| 59 | + #[arg(long)] |
| 60 | + pub activate: bool, |
| 61 | + |
| 62 | + #[arg(long, hide = true)] |
| 63 | + pub no_activate: bool, |
| 64 | +} |
| 65 | + |
| 66 | +#[derive(Subcommand)] |
| 67 | +pub enum Command { |
| 68 | + /// Check for updates and upgrade chromaport |
| 69 | + Update { |
| 70 | + /// Skip confirmation prompt |
| 71 | + #[arg(short = 'y', long)] |
| 72 | + yes: bool, |
| 73 | + }, |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +`-v` 플래그: clap 4에서 `--version`의 short flag를 `-v`로 변경하려면 기본 `-V` 대신 커스텀 arg를 사용해야 할 수 있음. 구현 시 clap 문서 확인 필요. 우선순위가 낮으므로 별도 커밋으로 분리. |
| 78 | + |
| 79 | +### 2. `src/interactive.rs` — `confirm_update()` 추가 |
| 80 | + |
| 81 | +```rust |
| 82 | +/// 업데이트 실행 전 사용자 확인 |
| 83 | +pub fn confirm_update(current: &str, latest: &str, method: &str) -> Result<bool> { |
| 84 | + let message = format!( |
| 85 | + "Upgrade chromaport {} → {} via {}?", |
| 86 | + current, latest, method |
| 87 | + ); |
| 88 | + match inquire::Confirm::new(&message) |
| 89 | + .with_default(true) // Y/n — 사용자가 명시적으로 update를 실행했으므로 기본 Yes |
| 90 | + .prompt() |
| 91 | + { |
| 92 | + Ok(answer) => Ok(answer), |
| 93 | + Err(e) => handle_inquire_error(e), |
| 94 | + } |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +### 3. `src/update.rs` — `run_update()` 개선 |
| 99 | + |
| 100 | +```rust |
| 101 | +pub fn run_update(yes: bool) -> Result<()> { |
| 102 | + println!("Checking for updates..."); |
| 103 | + |
| 104 | + let latest_str = fetch_latest_version()?; |
| 105 | + let current = current_version(); |
| 106 | + |
| 107 | + let cur = Version::parse(current).context("invalid current version")?; |
| 108 | + let latest = Version::parse(&latest_str).context("invalid latest version")?; |
| 109 | + |
| 110 | + if latest <= cur { |
| 111 | + println!("chromaport is already up to date (v{current})."); |
| 112 | + return Ok(()); |
| 113 | + } |
| 114 | + |
| 115 | + // Update cache |
| 116 | + let _ = write_cache(&UpdateCache { |
| 117 | + last_checked_at: now_iso8601(), |
| 118 | + latest_version: latest_str.clone(), |
| 119 | + }); |
| 120 | + |
| 121 | + match detect_install_method() { |
| 122 | + InstallMethod::Homebrew => { |
| 123 | + println!("A new version is available: {current} → {latest_str}"); |
| 124 | + |
| 125 | + // CWD 존재 여부 체크 |
| 126 | + if std::env::current_dir().is_err() { |
| 127 | + eprintln!("Error: 현재 디렉토리가 존재하지 않습니다."); |
| 128 | + eprintln!("유효한 디렉토리로 이동한 후 다시 시도해주세요:"); |
| 129 | + eprintln!(" cd ~ && chromaport update"); |
| 130 | + std::process::exit(1); |
| 131 | + } |
| 132 | + |
| 133 | + // 확인 프롬프트 |
| 134 | + if !yes { |
| 135 | + if atty::isnt(atty::Stream::Stdin) { |
| 136 | + // Non-TTY: 안내만 출력 |
| 137 | + println!("\nRun the following command to upgrade:"); |
| 138 | + println!(" brew update && brew upgrade chromaport"); |
| 139 | + return Ok(()); |
| 140 | + } |
| 141 | + if !interactive::confirm_update(current, &latest_str, "Homebrew")? { |
| 142 | + return Ok(()); |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + // brew update (best-effort) |
| 147 | + let brew_update = std::process::Command::new("brew") |
| 148 | + .arg("update") |
| 149 | + .stdout(std::process::Stdio::null()) |
| 150 | + .stderr(std::process::Stdio::piped()) |
| 151 | + .status(); |
| 152 | + |
| 153 | + match brew_update { |
| 154 | + Ok(status) if !status.success() => { |
| 155 | + eprintln!("Warning: `brew update` failed. Proceeding with upgrade..."); |
| 156 | + } |
| 157 | + Err(e) => { |
| 158 | + eprintln!("Warning: `brew update` failed ({e}). Proceeding with upgrade..."); |
| 159 | + } |
| 160 | + _ => {} |
| 161 | + } |
| 162 | + |
| 163 | + // brew upgrade |
| 164 | + println!("Upgrading chromaport via Homebrew..."); |
| 165 | + let status = std::process::Command::new("brew") |
| 166 | + .args(["upgrade", "chromaport"]) |
| 167 | + .status() |
| 168 | + .context("failed to run `brew upgrade chromaport`")?; |
| 169 | + |
| 170 | + if status.success() { |
| 171 | + println!("✔ Updated successfully!"); |
| 172 | + } else { |
| 173 | + anyhow::bail!( |
| 174 | + "`brew upgrade chromaport` failed (exit code {})", |
| 175 | + status.code().unwrap_or(1) |
| 176 | + ); |
| 177 | + } |
| 178 | + } |
| 179 | + InstallMethod::Cargo => { |
| 180 | + println!("A new version is available: {current} → {latest_str}"); |
| 181 | + |
| 182 | + // 확인 프롬프트 (Homebrew와 동일한 패턴) |
| 183 | + if !yes { |
| 184 | + if atty::isnt(atty::Stream::Stdin) { |
| 185 | + println!("\nRun the following command to upgrade:"); |
| 186 | + println!(" cargo install chromaport"); |
| 187 | + return Ok(()); |
| 188 | + } |
| 189 | + if !interactive::confirm_update(current, &latest_str, "Cargo")? { |
| 190 | + return Ok(()); |
| 191 | + } |
| 192 | + } |
| 193 | + |
| 194 | + println!("Upgrading chromaport via Cargo..."); |
| 195 | + let status = std::process::Command::new("cargo") |
| 196 | + .args(["install", "chromaport"]) |
| 197 | + .status() |
| 198 | + .context("failed to run `cargo install chromaport`")?; |
| 199 | + |
| 200 | + if status.success() { |
| 201 | + println!("✔ Updated successfully!"); |
| 202 | + } else { |
| 203 | + anyhow::bail!( |
| 204 | + "`cargo install chromaport` failed (exit code {})", |
| 205 | + status.code().unwrap_or(1) |
| 206 | + ); |
| 207 | + } |
| 208 | + } |
| 209 | + InstallMethod::Unknown => { |
| 210 | + // 기존 동작 유지 — 수동 안내만, 프롬프트 없음 |
| 211 | + println!("A new release is available: {current} → {latest_str}\n"); |
| 212 | + println!("Could not detect install method. Update manually:"); |
| 213 | + println!(" brew update && brew upgrade chromaport"); |
| 214 | + println!(" # or"); |
| 215 | + println!(" cargo install chromaport"); |
| 216 | + println!("\nhttps://github.com/hamsurang/chromaport/releases/tag/v{latest_str}"); |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + Ok(()) |
| 221 | +} |
| 222 | +``` |
| 223 | + |
| 224 | +### 4. `src/main.rs` — 서브커맨드 디스패치 업데이트 |
| 225 | + |
| 226 | +```rust |
| 227 | +if let Some(Command::Update { yes }) = cli.command { |
| 228 | + return update::run_update(yes); |
| 229 | +} |
| 230 | +``` |
| 231 | + |
| 232 | +### 5. `tests/cli.rs` — 테스트 추가 |
| 233 | + |
| 234 | +```rust |
| 235 | +#[test] |
| 236 | +fn update_accepts_yes_flag() { |
| 237 | + cmd().args(["update", "--yes"]).assert().success(); |
| 238 | + // 실제 GitHub API 호출하므로 네트워크 필요 |
| 239 | +} |
| 240 | + |
| 241 | +#[test] |
| 242 | +fn update_accepts_short_yes_flag() { |
| 243 | + cmd().args(["update", "-y"]).assert().success(); |
| 244 | +} |
| 245 | + |
| 246 | +#[test] |
| 247 | +fn short_version_flag() { |
| 248 | + cmd() |
| 249 | + .arg("-v") // 또는 -V, clap 구현에 따라 |
| 250 | + .assert() |
| 251 | + .success() |
| 252 | + .stdout(predicates::str::contains("chromaport")); |
| 253 | +} |
| 254 | +``` |
| 255 | + |
| 256 | +### 6. `Cargo.toml` — 의존성 추가 (필요 시) |
| 257 | + |
| 258 | +TTY 감지를 위한 `atty` 크레이트 추가가 필요할 수 있음. 또는 `std::io::IsTerminal` (Rust 1.70+)을 사용하면 외부 의존성 불필요. |
| 259 | + |
| 260 | +```toml |
| 261 | +# Rust 1.70+ 라면 std::io::IsTerminal 사용 권장 (의존성 추가 불필요) |
| 262 | +# 그렇지 않으면: |
| 263 | +# atty = "0.2" |
| 264 | +``` |
| 265 | + |
| 266 | +## Implementation Order |
| 267 | + |
| 268 | +1. `-v` 플래그 추가 (`src/cli.rs`) — 독립적, 작은 변경 |
| 269 | +2. `Update` 서브커맨드에 `yes` 필드 추가 + `main.rs` 디스패치 수정 |
| 270 | +3. `confirm_update()` 추가 (`src/interactive.rs`) |
| 271 | +4. `run_update()` 개선 (`src/update.rs`) — CWD 체크, 확인 프롬프트, brew update 추가 |
| 272 | +5. 테스트 추가 및 검증 |
| 273 | + |
| 274 | +## Sources |
| 275 | + |
| 276 | +- **Origin brainstorm:** [docs/brainstorms/2026-03-10-update-command-improvements-brainstorm.md](docs/brainstorms/2026-03-10-update-command-improvements-brainstorm.md) |
| 277 | + - Key decisions: `update` 이름 유지, brew update 추가, Y/n 확인, CWD 에러 메시지 개선 |
| 278 | +- **Prior plan:** [docs/plans/2026-03-09-feat-cli-update-notifier-plan.md](docs/plans/2026-03-09-feat-cli-update-notifier-plan.md) |
| 279 | +- **ureq 3.x migration:** [docs/solutions/build-errors/ureq-3x-api-migration.md](docs/solutions/build-errors/ureq-3x-api-migration.md) |
| 280 | +- Existing patterns: `src/interactive.rs:57-63` (`confirm_activate()`) |
0 commit comments