From f0d92ddee341bb8129a3c4601a1215e973c000f2 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Mon, 27 Jul 2026 07:50:37 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=B0=EB=8A=A5:=20hwp=20update=20=EC=9E=90?= =?UTF-8?q?=EC=B2=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20+=20brew=20?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20=ED=95=9C=20=EC=A4=84=20=EC=84=A4=EC=B9=98?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리스 아카이브를 직접 받아 쓰는 사용자는 갱신 수단이 손밖에 없었다. 설치와 갱신을 둘 다 명령 하나로 끝낸다. - `hwp update [--check] [--tag vX.Y.Z] [--force] [--json]` 설치 방식을 스스로 판별한다. 직접 설치본(스크립트·아카이브·cargo install)은 릴리스 아카이브를 받아 실행 중인 바이너리를 제자리 교체하고, Homebrew 설치본은 `brew upgrade hwp`에 위임한다(Cellar를 덮어쓰면 brew 매니페스트와 어긋나 다음 upgrade가 조용히 되돌린다). brew는 버전 고정이 안 되므로 --tag는 거부한다. - `scripts/install.sh` — curl 한 줄 설치(기본 ~/.local/bin, --dir·--tag 지원). 설치본은 이후 `hwp update`로 갱신된다. 설계: 새 런타임 크레이트 0개. 네트워크는 curl, 압축 해제는 tar에 위임하고 전부 인자 벡터로 실행한다(셸 문자열 조립 없음). sha2만 직접 의존으로 올렸는데 zip이 이미 끌어오던 크레이트라 의존성 그래프는 그대로다. 안전장치: 태그는 URL·파일명에 닿기 전 `v?X.Y.Z` 형식 검증(경로 탈출 차단), 아카이브는 sha256 대조 후에만 해제, 교체는 대상과 같은 디렉터리에서 rename(실패 시 원본 복원), 교체 후 새 바이너리의 --version으로 확인. 테스트 10종(전부 오프라인): 버전 비교(프리릴리스·두 자리 버전 포함), 타깃 매핑, 자산 이름, 태그 검증, 설치 방식 판별, 바이너리 교체·실패 복원, 체크섬 대조, 아카이브 해제, install.sh↔update 자산 규칙 동기화(한쪽만 바뀌면 릴리스 후 404로 드러나는 것 방지). 실기 확인: --check/--json, v0.2.0 롤백, --force 재설치, 이미 최신, brew 감지·--tag 거부, install.sh 설치 후 자가 업데이트까지 실제 릴리스로 통과. --- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 40 ++ crates/hwp-cli/Cargo.toml | 1 + crates/hwp-cli/src/cli.rs | 16 + crates/hwp-cli/src/commands/mod.rs | 1 + crates/hwp-cli/src/commands/update.rs | 633 ++++++++++++++++++++++++++ crates/hwp-cli/src/main.rs | 6 + docs/manual/cli-reference.md | 14 + scripts/install.sh | 99 ++++ 10 files changed, 814 insertions(+) create mode 100644 crates/hwp-cli/src/commands/update.rs create mode 100755 scripts/install.sh diff --git a/Cargo.lock b/Cargo.lock index 2456179..6699b4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,6 +574,7 @@ dependencies = [ "hwp5", "hwpx", "serde_json", + "sha2", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index ef67cf8..e8d34be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ cfb = "0.14" flate2 = "1" quick-xml = "0.40" zip = "8" +# 자체 업데이트(hwp update)의 릴리스 아카이브 체크섬 대조 — zip이 이미 끌어오는 크레이트라 +# 의존성 그래프가 늘지 않는다. +sha2 = "0.11" pulldown-cmark = { version = "0.13", default-features = false } # 렌더링 diff --git a/README.md b/README.md index 17be22c..f5996e7 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,23 @@ | `render` / `diff` / `mcp` | `--font-dir ` 플래그(반복 지정 가능) | | `convert` / 테스트 | 환경변수 `HWP_FONT_DIR`(미설정 시 프로젝트 `fonts/` 자동 사용) | +### 설치 (한 줄 스크립트 — macOS / Linux) + +Homebrew 없이 릴리스 바이너리를 바로 설치한다. Rust 툴체인이 필요 없고, 설치 후에는 `hwp update`로 자체 갱신된다. + +```sh +curl -fsSL https://raw.githubusercontent.com/STAIxBWLB/hwp-cli/main/scripts/install.sh | sh +``` + +기본 설치 위치는 `~/.local/bin`이다(PATH에 없으면 추가 안내를 출력한다). 위치·버전은 인자나 환경변수로 바꾼다: + +```sh +curl -fsSL .../install.sh | sh -s -- --dir /usr/local/bin --tag v0.3.0 +HWP_INSTALL_DIR=~/bin sh scripts/install.sh +``` + +아카이브는 `.sha256` 자산과 대조한 뒤에만 설치한다. Windows는 아래 [사전 빌드 바이너리](#다운로드-사전-빌드-바이너리)를 쓴다. + ### 설치 (Homebrew — macOS / Linux) 저장소 자체가 tap이다(별도 `homebrew-*` 저장소 없음). 릴리스 바이너리를 받으므로 Rust 툴체인이 필요 없다. @@ -104,6 +121,24 @@ cargo install --path crates/hwp-cli # `hwp` 바이너리 설치 압축을 풀어 `hwp`를 PATH에 두면 된다(체크섬 검증: `shasum -a 256 -c hwp-*.sha256`). 렌더/PDF엔 CJK 폰트가 필요하고(위 폰트 지정 참고), 텍스트 추출·변환은 폰트 없이 동작한다. +### 업데이트 + +```sh +hwp update # 최신 릴리스로 자체 교체 (체크섬 대조 후 원자적 교체) +hwp update --check # 교체 없이 현재/최신 버전만 확인 +hwp update --tag v0.2.0 # 특정 버전으로 되돌리기 +``` + +설치 방식을 스스로 판별한다: + +| 설치 방식 | `hwp update` 동작 | +|---|---| +| 한 줄 스크립트·릴리스 아카이브·`cargo install` | 릴리스 아카이브를 받아 **실행 중인 바이너리를 제자리 교체**(sha256 대조 → 같은 디렉터리에 임시 파일 → rename, 실패 시 원본 복원) | +| Homebrew(Cellar) | `brew upgrade hwp`에 위임 — Cellar를 직접 덮어써 brew 상태가 어긋나는 것을 막는다. brew는 버전 고정이 안 되므로 `--tag`는 거부한다 | + +내려받기는 `curl`, 압축 해제는 `tar`에 위임한다(macOS·Linux·Windows 10 1803+ 기본 탑재 — 새 런타임 의존성 없음). +`hwp update`는 0.4.0부터 있으므로 그 이전 버전은 위 설치 스크립트로 한 번만 갱신하면 된다. + ### 릴리스 (메인테이너) 버전은 워크스페이스 `Cargo.toml`의 `[workspace.package] version`이 단일 기준(SSOT)이다. @@ -175,6 +210,10 @@ hwp diff report.hwp --ref hancom_p1.png --page 1 --dpi 150 --font-dir ./fonts # MCP stdio 서버 hwp mcp --font-dir ./fonts + +# 자체 업데이트 (brew 설치본은 brew upgrade에 위임) +hwp update --check +hwp update ``` ## 명령 레퍼런스 @@ -196,6 +235,7 @@ hwp mcp --font-dir ./fonts | `validate ` | `--json` | 구조 검증(mimetype·필수 엔트리·XML 파싱) — 유효 시 종료코드 0 | | `diff --ref ` | `--page `(기본 1), `--dpi `(기본 96), `-o/--out `, `--font-dir `(반복), `--tolerance `(기본 16) | 렌더 결과를 한글 기준 PNG와 비교(잉크 적용률·dx/dy 오프셋·픽셀 차이율·MAE) | | `mcp` | `--font-dir `(반복) | MCP stdio 서버 실행 | +| `update` | `--check`, `--tag `, `--force`, `--json` | 자체 업데이트 — 릴리스 아카이브를 받아(체크섬 대조) 실행 중인 바이너리 교체. brew 설치본은 `brew upgrade`에 위임 | | `dump ` | `--stream `, `--raw`, `--json` | [개발자용] 레코드/패키지 구조 덤프 | > 출력 포맷은 대부분 출력 파일의 확장자(`.hwp` / `.hwpx` / `.md` / `.json` / `.png` / `.svg`)에서 diff --git a/crates/hwp-cli/Cargo.toml b/crates/hwp-cli/Cargo.toml index 3000c01..b86fad4 100644 --- a/crates/hwp-cli/Cargo.toml +++ b/crates/hwp-cli/Cargo.toml @@ -19,6 +19,7 @@ hwp-render = { workspace = true } anyhow = { workspace = true } clap = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } [dev-dependencies] zip = { workspace = true } diff --git a/crates/hwp-cli/src/cli.rs b/crates/hwp-cli/src/cli.rs index eaa6ebe..423a7f1 100644 --- a/crates/hwp-cli/src/cli.rs +++ b/crates/hwp-cli/src/cli.rs @@ -258,6 +258,22 @@ pub enum Cmd { font_dir: Vec, }, + /// 자체 업데이트 — GitHub 릴리스에서 최신 `hwp`를 받아 실행 중인 바이너리를 교체 + Update { + /// 교체 없이 현재/최신 버전만 확인 + #[arg(long)] + check: bool, + /// 특정 릴리스로 고정 (예: "v0.2.0" — 이전 버전으로 되돌릴 때) + #[arg(long)] + tag: Option, + /// 같은 버전이어도 다시 받아 교체 (손상된 설치 복구용) + #[arg(long)] + force: bool, + /// JSON으로 출력 + #[arg(long)] + json: bool, + }, + /// [개발자용] 레코드/패키지 구조 덤프 Dump { file: PathBuf, diff --git a/crates/hwp-cli/src/commands/mod.rs b/crates/hwp-cli/src/commands/mod.rs index 1a1bf04..3154b78 100644 --- a/crates/hwp-cli/src/commands/mod.rs +++ b/crates/hwp-cli/src/commands/mod.rs @@ -11,4 +11,5 @@ pub mod mcp; pub mod new; pub mod render; pub mod slots; +pub mod update; pub mod validate; diff --git a/crates/hwp-cli/src/commands/update.rs b/crates/hwp-cli/src/commands/update.rs new file mode 100644 index 0000000..afb1c6d --- /dev/null +++ b/crates/hwp-cli/src/commands/update.rs @@ -0,0 +1,633 @@ +//! `hwp update` — 자체 업데이트. +//! +//! GitHub 릴리스에 올라간 플랫폼별 아카이브(`release.yml`의 `upload-assets` 산출물)를 받아 +//! 실행 중인 바이너리를 교체한다. **새 런타임 크레이트를 들이지 않으려고** 네트워크는 +//! `curl`, 압축 해제는 `tar`에 위임한다(둘 다 macOS·Linux·Windows 10 1803+ 기본 탑재). +//! 외부 명령은 전부 인자 벡터로 실행한다 — 셸 문자열 조립 없음(주입 방지). +//! +//! Homebrew 설치본은 **덮어쓰지 않고** `brew upgrade hwp`에 위임한다. Cellar 바이너리를 +//! 직접 갈아끼우면 brew 매니페스트와 어긋나 다음 `brew upgrade`가 조용히 되돌린다. +//! +//! 순수 로직(버전 비교·타깃 매핑·자산 이름·설치 종류 판별·파일 교체)과 부작용(curl/tar +//! 호출)을 나눠 뒀다 — 앞쪽은 네트워크 없이 단위 테스트한다. + +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +const REPO: &str = "STAIxBWLB/hwp-cli"; +const BIN: &str = "hwp"; + +/// 릴리스 아카이브 형식 — 타깃별로 `release.yml`이 만드는 확장자. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Archive { + TarGz, + Zip, +} + +impl Archive { + fn ext(self) -> &'static str { + match self { + Archive::TarGz => "tar.gz", + Archive::Zip => "zip", + } + } +} + +/// 설치 경로가 말해 주는 설치 방식 — 교체 전략이 갈린다. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallKind { + /// Homebrew Cellar 아래 — brew에 위임한다. + Brew, + /// 그 외(직접 내려받기·`cargo install`) — 파일을 직접 교체해도 안전하다. + Plain, +} + +/// (os, arch) → 릴리스가 실제로 게시하는 타깃 트리플. 지원 밖이면 None. +/// 매트릭스 정본은 `.github/workflows/release.yml`의 `upload-assets`다. +pub fn target_triple(os: &str, arch: &str) -> Option<(&'static str, Archive)> { + match (os, arch) { + ("macos", "aarch64") => Some(("aarch64-apple-darwin", Archive::TarGz)), + ("macos", "x86_64") => Some(("x86_64-apple-darwin", Archive::TarGz)), + ("linux", "x86_64") => Some(("x86_64-unknown-linux-gnu", Archive::TarGz)), + ("windows", "x86_64") => Some(("x86_64-pc-windows-msvc", Archive::Zip)), + _ => None, + } +} + +/// 자산 이름 — `release.yml`의 `archive: $bin-$tag-$target` 규칙과 대칭. +/// 예: `hwp-v0.3.0-aarch64-apple-darwin.tar.gz` + 같은 이름의 `.sha256`. +pub fn asset_names(tag: &str, triple: &str, archive: Archive) -> (String, String) { + let stem = format!("{BIN}-{tag}-{triple}"); + ( + format!("{stem}.{}", archive.ext()), + format!("{stem}.sha256"), + ) +} + +/// 태그가 `v?X.Y.Z[-pre]` 꼴인지 확인한다. URL·파일명에 그대로 들어가므로 +/// 경로 탈출(`../`)·질의 문자열 주입을 여기서 막는다. +pub fn validate_tag(tag: &str) -> Result<()> { + let body = tag.strip_prefix('v').unwrap_or(tag); + let (core, pre) = match body.split_once('-') { + Some((c, p)) => (c, Some(p)), + None => (body, None), + }; + let parts: Vec<&str> = core.split('.').collect(); + let core_ok = parts.len() == 3 + && parts + .iter() + .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())); + let pre_ok = pre.is_none_or(|p| { + !p.is_empty() + && p.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') + }); + if core_ok && pre_ok { + Ok(()) + } else { + bail!("릴리스 태그 형식이 아닙니다: {tag:?} (예: v0.3.0, v0.3.0-rc1)") + } +} + +/// 버전 문자열을 (major, minor, patch, 릴리스여부)로. 파싱 실패분은 0으로 취급한다. +fn parse_version(v: &str) -> (u64, u64, u64, bool) { + let body = v.trim().trim_start_matches('v'); + let (core, pre) = match body.split_once('-') { + Some((c, p)) => (c, Some(p)), + None => (body, None), + }; + let mut it = core.split('.').map(|p| p.parse::().unwrap_or(0)); + ( + it.next().unwrap_or(0), + it.next().unwrap_or(0), + it.next().unwrap_or(0), + pre.is_none(), // 프리릴리스는 같은 (X.Y.Z) 정식판보다 낮다 + ) +} + +/// `latest`가 `current`보다 새 버전인가. +pub fn is_newer(current: &str, latest: &str) -> bool { + parse_version(latest) > parse_version(current) +} + +/// 실행 파일 경로로 설치 방식을 판별한다. brew는 심볼릭 링크(`bin/hwp` → `Cellar/…`)를 +/// 쓰므로 호출부가 canonical 경로를 넘긴다. +pub fn install_kind(exe: &Path) -> InstallKind { + let p = exe.to_string_lossy().replace('\\', "/"); + if p.contains("/Cellar/") || p.contains("/homebrew/") || p.contains("/linuxbrew/") { + InstallKind::Brew + } else { + InstallKind::Plain + } +} + +/// 새 바이너리를 제자리 교체한다. 임시 파일은 **대상과 같은 디렉터리**에 만든다 +/// (rename은 같은 파일시스템 안에서만 원자적). 실패하면 백업으로 되돌린다. +pub fn replace_binary(target: &Path, new_file: &Path) -> Result<()> { + let dir = target + .parent() + .ok_or_else(|| anyhow!("설치 경로에 상위 디렉터리가 없습니다: {}", target.display()))?; + let staged = dir.join(format!(".{BIN}-update-{}", std::process::id())); + let backup = dir.join(format!(".{BIN}-backup-{}", std::process::id())); + + std::fs::copy(new_file, &staged).with_context(|| { + format!( + "새 바이너리를 설치 위치에 놓지 못했습니다: {} (권한 확인: sudo가 필요할 수 있습니다)", + staged.display() + ) + })?; + copy_exec_mode(target, &staged)?; + + // 원본을 백업으로 옮긴 뒤 새 것을 제자리로. Windows는 실행 중인 exe를 덮어쓸 수 없어 + // 이 "먼저 비켜 놓기"가 필수고, unix에서도 실패 시 복원 경로가 된다. + let _ = std::fs::remove_file(&backup); + std::fs::rename(target, &backup) + .with_context(|| format!("기존 바이너리를 비켜 놓지 못했습니다: {}", target.display()))?; + if let Err(e) = std::fs::rename(&staged, target) { + let _ = std::fs::rename(&backup, target); // 되돌리기 + let _ = std::fs::remove_file(&staged); + return Err(e).context("새 바이너리를 제자리에 놓지 못했습니다(원본 복원됨)"); + } + // 실행 중인 파일이라 지우지 못할 수 있다(Windows) — 남아도 무해하다. + let _ = std::fs::remove_file(&backup); + Ok(()) +} + +#[cfg(unix)] +fn copy_exec_mode(from: &Path, to: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt as _; + // 기존 바이너리 권한을 승계하고, 없으면 0755. + let mode = std::fs::metadata(from) + .map(|m| m.permissions().mode()) + .unwrap_or(0o755); + std::fs::set_permissions(to, std::fs::Permissions::from_mode(mode)) + .with_context(|| format!("실행 권한 설정 실패: {}", to.display())) +} + +#[cfg(not(unix))] +fn copy_exec_mode(_from: &Path, _to: &Path) -> Result<()> { + Ok(()) // Windows는 확장자로 실행 여부가 정해진다. +} + +/// curl로 한 파일을 받는다. HTTPS·TLS1.2 이상만 허용하고 HTTP 오류는 실패로 만든다(-f). +fn download(url: &str, dest: &Path) -> Result<()> { + let out = Command::new("curl") + .args([ + "-fsSL", + "--proto", + "=https", + "--tlsv1.2", + "--retry", + "2", + "-o", + ]) + .arg(dest) + .arg(url) + .output() + .map_err(|e| anyhow!("curl 실행 실패({e}) — curl이 설치돼 있어야 합니다: {url}"))?; + if !out.status.success() { + bail!( + "내려받기 실패: {url}\n{}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(()) +} + +/// curl로 받은 본문을 문자열로 돌려준다(GitHub API 조회용). +fn fetch_text(url: &str) -> Result { + let out = Command::new("curl") + .args([ + "-fsSL", + "--proto", + "=https", + "--tlsv1.2", + "-H", + "Accept: application/vnd.github+json", + url, + ]) + .output() + .map_err(|e| anyhow!("curl 실행 실패({e}) — curl이 설치돼 있어야 합니다"))?; + if !out.status.success() { + bail!( + "릴리스 정보를 가져오지 못했습니다: {url}\n{}", + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// 최신 릴리스 태그(`vX.Y.Z`). +fn fetch_latest_tag() -> Result { + let url = format!("https://api.github.com/repos/{REPO}/releases/latest"); + let body = fetch_text(&url)?; + let v: Value = serde_json::from_str(&body).context("릴리스 JSON 파싱 실패")?; + let tag = v + .get("tag_name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("릴리스 응답에 tag_name이 없습니다"))?; + validate_tag(tag)?; + Ok(tag.to_string()) +} + +/// `.sha256` 자산 본문(" <파일명>")과 실제 파일 해시를 대조한다. +fn verify_sha256(file: &Path, sha_line: &str) -> Result<()> { + let want = sha_line + .split_whitespace() + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if want.len() != 64 { + bail!("체크섬 자산 형식이 이상합니다: {sha_line:?}"); + } + let bytes = std::fs::read(file) + .with_context(|| format!("내려받은 파일을 읽지 못했습니다: {}", file.display()))?; + let got: String = Sha256::digest(&bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + if got != want { + bail!( + "체크섬 불일치 — 내려받기가 손상됐거나 자산이 바뀌었습니다\n 기대: {want}\n 실제: {got}" + ); + } + Ok(()) +} + +/// 아카이브에서 `hwp` 실행 파일을 꺼낸다. tar는 zip도 풀 수 있어(bsdtar/Windows tar.exe) +/// 형식과 무관하게 같은 커맨드를 쓴다. 아카이브 루트에 바이너리가 있는 구조 +/// (`taiki-e/upload-rust-binary-action` 산출물)를 전제한다. +fn extract(archive: &Path, dir: &Path) -> Result { + let out = Command::new("tar") + .arg("-xf") + .arg(archive) + .arg("-C") + .arg(dir) + .output() + .map_err(|e| anyhow!("tar 실행 실패({e}) — tar가 설치돼 있어야 합니다"))?; + if !out.status.success() { + bail!( + "압축 해제 실패: {}\n{}", + archive.display(), + String::from_utf8_lossy(&out.stderr).trim() + ); + } + let exe = dir.join(if cfg!(windows) { + format!("{BIN}.exe") + } else { + BIN.to_string() + }); + if !exe.exists() { + bail!( + "아카이브에 {} 실행 파일이 없습니다: {}", + BIN, + archive.display() + ); + } + Ok(exe) +} + +/// 교체된 바이너리가 실제로 기대 버전으로 실행되는지 확인한다. +fn verify_installed(exe: &Path, want: &str) -> Result { + let out = Command::new(exe) + .arg("--version") + .output() + .with_context(|| format!("교체된 바이너리 실행 실패: {}", exe.display()))?; + let ver = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !ver.contains(want) { + bail!("교체된 바이너리가 기대 버전이 아닙니다: {ver:?} (기대 {want})"); + } + Ok(ver) +} + +/// `hwp update` 진입점. +pub fn run(check: bool, tag: Option<&str>, force: bool, json: bool) -> Result<()> { + let current = env!("CARGO_PKG_VERSION"); + let exe = std::env::current_exe().context("실행 파일 경로를 찾지 못했습니다")?; + let exe = std::fs::canonicalize(&exe).unwrap_or(exe); + let kind = install_kind(&exe); + + if let Some(t) = tag { + validate_tag(t)?; + } + let target_tag = match tag { + Some(t) => t.to_string(), + None => fetch_latest_tag()?, + }; + let latest = target_tag.trim_start_matches('v').to_string(); + let available = is_newer(current, &latest); + + if check { + return report( + json, + json!({ + "current": current, "latest": latest, "update_available": available, + "install": if kind == InstallKind::Brew { "brew" } else { "binary" }, + "path": exe.display().to_string(), + }), + &if available { + format!("현재 {current} → 최신 {latest} (업데이트 있음)") + } else { + format!("현재 {current} — 최신입니다") + }, + ); + } + + if latest == current && !force { + return report( + json, + json!({"current": current, "latest": latest, "updated": false, "reason": "already-latest"}), + &format!("이미 {current} 입니다 (다시 받으려면 --force)"), + ); + } + + if kind == InstallKind::Brew { + if tag.is_some() { + bail!( + "Homebrew 설치본은 버전 고정을 지원하지 않습니다 ({}).\n\ + 특정 버전이 필요하면 릴리스 아카이브를 직접 받아 쓰세요:\n \ + https://github.com/{REPO}/releases", + exe.display() + ); + } + eprintln!("Homebrew 설치본입니다 — brew에 위임합니다: brew upgrade {BIN}"); + let status = Command::new("brew") + .args(["upgrade", BIN]) + .status() + .map_err(|e| anyhow!("brew 실행 실패({e}) — 직접 `brew upgrade {BIN}`을 실행하세요"))?; + if !status.success() { + bail!("brew upgrade {BIN} 실패 — 위 출력을 확인하세요"); + } + return report( + json, + json!({"current": current, "latest": latest, "updated": true, "via": "brew"}), + &format!("brew로 {latest} 설치 완료"), + ); + } + + let (triple, archive) = target_triple(std::env::consts::OS, std::env::consts::ARCH) + .ok_or_else(|| { + anyhow!( + "이 플랫폼({}/{})용 사전 빌드 바이너리가 없습니다 — 소스에서 설치하세요:\n \ + cargo install --git https://github.com/{REPO} hwp-cli", + std::env::consts::OS, + std::env::consts::ARCH, + ) + })?; + let (archive_name, sha_name) = asset_names(&target_tag, triple, archive); + let base = format!("https://github.com/{REPO}/releases/download/{target_tag}"); + + let work = std::env::temp_dir().join(format!("hwp-update-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&work); + std::fs::create_dir_all(&work).context("임시 작업 디렉터리 생성 실패")?; + + eprintln!("{current} → {latest} 내려받는 중… ({archive_name})"); + let archive_path = work.join(&archive_name); + download(&format!("{base}/{archive_name}"), &archive_path)?; + let sha_path = work.join(&sha_name); + download(&format!("{base}/{sha_name}"), &sha_path)?; + verify_sha256(&archive_path, &std::fs::read_to_string(&sha_path)?)?; + + let new_exe = extract(&archive_path, &work)?; + replace_binary(&exe, &new_exe)?; + let _ = std::fs::remove_dir_all(&work); + + let ver = verify_installed(&exe, &latest)?; + report( + json, + json!({ + "current": current, "latest": latest, "updated": true, "via": "binary", + "path": exe.display().to_string(), + }), + &format!("{ver} 설치 완료 ({})", exe.display()), + ) +} + +fn report(json: bool, value: Value, text: &str) -> Result<()> { + let mut out = std::io::stdout().lock(); + if json { + writeln!(out, "{}", serde_json::to_string_pretty(&value)?)?; + } else { + writeln!(out, "{text}")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn 버전_비교() { + assert!(is_newer("0.3.0", "0.4.0")); + assert!(is_newer("0.3.0", "0.3.1")); + assert!(is_newer("0.9.0", "1.0.0")); + assert!(is_newer("0.3.0", "v0.4.0")); // v 접두 허용 + assert!(!is_newer("0.3.0", "0.3.0")); + assert!(!is_newer("0.4.0", "0.3.9")); + // 프리릴리스는 같은 정식판보다 낮다 — rc를 최신으로 오인해 내려깔지 않게. + assert!(is_newer("0.3.0-rc1", "0.3.0")); + assert!(!is_newer("0.3.0", "0.3.0-rc1")); + // 두 자리 이상 버전을 문자열로 비교하면 뒤집힌다("10" < "9"). + assert!(is_newer("0.9.0", "0.10.0")); + } + + #[test] + fn 타깃_매핑() { + assert_eq!( + target_triple("macos", "aarch64"), + Some(("aarch64-apple-darwin", Archive::TarGz)) + ); + assert_eq!( + target_triple("macos", "x86_64"), + Some(("x86_64-apple-darwin", Archive::TarGz)) + ); + assert_eq!( + target_triple("linux", "x86_64"), + Some(("x86_64-unknown-linux-gnu", Archive::TarGz)) + ); + assert_eq!( + target_triple("windows", "x86_64"), + Some(("x86_64-pc-windows-msvc", Archive::Zip)) + ); + // 릴리스가 없는 조합은 소스 설치로 안내해야 하므로 None이어야 한다. + assert_eq!(target_triple("linux", "aarch64"), None); + assert_eq!(target_triple("freebsd", "x86_64"), None); + } + + /// 실제 v0.3.0 릴리스에 올라간 자산 이름과 한 글자도 다르면 안 된다. + #[test] + fn 자산_이름() { + let (a, s) = asset_names("v0.3.0", "aarch64-apple-darwin", Archive::TarGz); + assert_eq!(a, "hwp-v0.3.0-aarch64-apple-darwin.tar.gz"); + assert_eq!(s, "hwp-v0.3.0-aarch64-apple-darwin.sha256"); + let (a, _) = asset_names("v0.3.0", "x86_64-pc-windows-msvc", Archive::Zip); + assert_eq!(a, "hwp-v0.3.0-x86_64-pc-windows-msvc.zip"); + } + + /// 설치 스크립트와 자체 업데이트가 **같은 자산 이름 규칙**을 써야 한다. 한쪽만 + /// 바뀌면 설치나 업데이트가 404로 죽는데, 그건 릴리스 후에야 드러난다. + #[test] + fn 설치_스크립트_타깃_동기화() { + let script = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../scripts/install.sh"), + ) + .expect("scripts/install.sh 없음"); + for (os, arch) in [ + ("macos", "aarch64"), + ("macos", "x86_64"), + ("linux", "x86_64"), + ] { + let (triple, _) = target_triple(os, arch).unwrap(); + assert!( + script.contains(triple), + "install.sh에 {triple} 없음 (update와 자산 이름 규칙 불일치)" + ); + } + // 아카이브 이름 조립 규칙도 같은 모양이어야 한다. + assert!( + script.contains("$BIN-$TAG-$target.tar.gz"), + "install.sh 아카이브 이름 규칙이 asset_names와 다릅니다" + ); + } + + #[test] + fn 태그_검증() { + for ok in ["v0.3.0", "0.3.0", "v10.20.30", "v0.3.0-rc1", "v0.3.0-rc.1"] { + assert!(validate_tag(ok).is_ok(), "{ok} 는 통과해야 함"); + } + // URL·파일명에 그대로 들어가므로 경로 탈출·질의 주입을 막아야 한다. + for bad in [ + "../../etc/passwd", + "v0.3.0/../x", + "v0.3", + "latest", + "v0.3.0?x=1", + "v0.3.0 rc", + "", + ] { + assert!(validate_tag(bad).is_err(), "{bad:?} 는 거부해야 함"); + } + } + + #[test] + fn 설치_방식_판별() { + assert_eq!( + install_kind(Path::new("/opt/homebrew/Cellar/hwp/0.3.0/bin/hwp")), + InstallKind::Brew + ); + assert_eq!( + install_kind(Path::new( + "/home/linuxbrew/.linuxbrew/Cellar/hwp/0.3.0/bin/hwp" + )), + InstallKind::Brew + ); + assert_eq!( + install_kind(Path::new("/Users/x/.cargo/bin/hwp")), + InstallKind::Plain + ); + assert_eq!( + install_kind(Path::new("/usr/local/bin/hwp")), + InstallKind::Plain + ); + } + + fn tmp(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("hwp-update-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) + } + + #[test] + fn 바이너리_교체() { + let target = tmp("bin_old"); + let new = tmp("bin_new"); + std::fs::write(&target, b"OLD").unwrap(); + std::fs::write(&new, b"NEW").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + replace_binary(&target, &new).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&target).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111, "실행 권한 승계 실패: {mode:o}"); + } + // 임시/백업 잔재가 남으면 안 된다. + let dir = target.parent().unwrap(); + let leftovers: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with(".hwp-update-") || n.starts_with(".hwp-backup-")) + .collect(); + assert!(leftovers.is_empty(), "잔재: {leftovers:?}"); + let _ = std::fs::remove_file(&target); + let _ = std::fs::remove_file(&new); + } + + #[test] + fn 바이너리_교체_실패시_원본_보존() { + let target = tmp("bin_keep"); + std::fs::write(&target, b"OLD").unwrap(); + // 없는 파일을 새 바이너리로 주면 첫 단계에서 실패해야 하고 원본은 그대로여야 한다. + let err = replace_binary(&target, Path::new("/nonexistent/hwp")).unwrap_err(); + assert!(format!("{err:#}").contains("놓지 못했습니다"), "{err:#}"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let _ = std::fs::remove_file(&target); + } + + #[test] + fn 체크섬_대조() { + let f = tmp("sha_target"); + std::fs::write(&f, b"hello").unwrap(); + // sha256("hello") + let sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + verify_sha256(&f, &format!("{sha} hwp-v0.3.0-x.tar.gz")).unwrap(); + assert!(verify_sha256(&f, &format!("{} x", "0".repeat(64))).is_err()); + assert!(verify_sha256(&f, "짧음 x").is_err()); + let _ = std::fs::remove_file(&f); + } + + /// 릴리스 아카이브 구조(루트에 바이너리) 가정을 실제 tar로 고정한다. + #[test] + fn 아카이브_해제() { + if Command::new("tar").arg("--version").output().is_err() { + eprintln!("스킵: tar 없음"); + return; + } + let dir = tmp("extract").with_extension("d"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let name = if cfg!(windows) { "hwp.exe" } else { "hwp" }; + std::fs::write(dir.join(name), b"#!/bin/sh\necho hwp 9.9.9\n").unwrap(); + let archive = dir.join("hwp-v9.9.9-test.tar.gz"); + let ok = Command::new("tar") + .arg("-czf") + .arg(&archive) + .arg("-C") + .arg(&dir) + .arg(name) + .status() + .unwrap() + .success(); + assert!(ok, "테스트 아카이브 생성 실패"); + + let out = dir.join("out"); + std::fs::create_dir_all(&out).unwrap(); + let exe = extract(&archive, &out).unwrap(); + assert_eq!(exe.file_name().unwrap(), name); + assert!(std::fs::read(&exe).unwrap().starts_with(b"#!/bin/sh")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/hwp-cli/src/main.rs b/crates/hwp-cli/src/main.rs index 732259d..ad23f7a 100644 --- a/crates/hwp-cli/src/main.rs +++ b/crates/hwp-cli/src/main.rs @@ -87,6 +87,12 @@ fn main() -> anyhow::Result<()> { tolerance, ), Cmd::Mcp { font_dir } => commands::mcp::run(font_dir), + Cmd::Update { + check, + tag, + force, + json, + } => commands::update::run(check, tag.as_deref(), force, json), Cmd::New { output, from, diff --git a/docs/manual/cli-reference.md b/docs/manual/cli-reference.md index 95fc0d6..2a9f766 100644 --- a/docs/manual/cli-reference.md +++ b/docs/manual/cli-reference.md @@ -19,6 +19,7 @@ - [`hwp fill`](#hwp-fill) - [`hwp validate`](#hwp-validate) - [`hwp mcp`](#hwp-mcp) +- [`hwp update`](#hwp-update) - [`hwp dump`](#hwp-dump) ## `hwp info` @@ -209,6 +210,19 @@ MCP(Model Context Protocol) stdio 서버 — AI 에이전트용 도구 인터페 |---|---|---|---| | `--font-dir` | `` | | 렌더/diff 도구의 기본 폰트 디렉터리 (반복 가능) | +## `hwp update` + +자체 업데이트 — GitHub 릴리스에서 최신 `hwp`를 받아 실행 중인 바이너리를 교체 + +**사용법:** `hwp update [OPTIONS]` + +| 인자/플래그 | 값 | 기본값 | 설명 | +|---|---|---|---| +| `--check` | | | 교체 없이 현재/최신 버전만 확인 | +| `--tag` | `` | | 특정 릴리스로 고정 (예: "v0.2.0" — 이전 버전으로 되돌릴 때) | +| `--force` | | | 같은 버전이어도 다시 받아 교체 (손상된 설치 복구용) | +| `--json` | | | JSON으로 출력 | + ## `hwp dump` [개발자용] 레코드/패키지 구조 덤프 diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..d9b4235 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# hwp 설치 스크립트 (Homebrew 없이 — macOS·Linux). +# +# curl -fsSL https://raw.githubusercontent.com/STAIxBWLB/hwp-cli/main/scripts/install.sh | sh +# curl -fsSL .../install.sh | sh -s -- --tag v0.2.0 --dir ~/bin +# +# GitHub 릴리스의 사전 빌드 아카이브를 받아(sha256 대조) 설치 디렉터리에 `hwp`를 놓는다. +# Rust 툴체인이 필요 없다. 설치 후에는 `hwp update`로 자체 갱신된다(같은 자산·같은 규칙). +# Windows는 릴리스 페이지의 .zip을 받아 PATH에 두면 된다(이 스크립트는 POSIX 셸 전용). +# +# 환경변수: HWP_INSTALL_DIR(설치 위치, 기본 ~/.local/bin), HWP_TAG(설치 버전) +set -eu + +REPO="STAIxBWLB/hwp-cli" +BIN="hwp" +DIR="${HWP_INSTALL_DIR:-$HOME/.local/bin}" +TAG="${HWP_TAG:-}" + +while [ $# -gt 0 ]; do + case "$1" in + -t|--tag) TAG="${2:-}"; shift 2 ;; + -d|--dir) DIR="${2:-}"; shift 2 ;; + -h|--help) + echo "사용법: install.sh [--tag vX.Y.Z] [--dir <설치경로>]" + exit 0 ;; + *) echo "알 수 없는 인자: $1" >&2; exit 2 ;; + esac +done + +die() { echo "오류: $*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "$1 이(가) 필요합니다"; } + +need curl +need tar + +# 타깃 트리플 — release.yml 의 upload-assets 매트릭스와 대칭(하나라도 어긋나면 404). +os="$(uname -s)" +arch="$(uname -m)" +case "$os/$arch" in + Darwin/arm64) target="aarch64-apple-darwin" ;; + Darwin/x86_64) target="x86_64-apple-darwin" ;; + Linux/x86_64) target="x86_64-unknown-linux-gnu" ;; + *) die "사전 빌드 바이너리가 없는 플랫폼입니다: $os/$arch + 소스에서 설치하세요: cargo install --git https://github.com/$REPO hwp-cli" ;; +esac + +# 버전 결정: --tag 없으면 최신 릴리스로 리다이렉트되는 URL에서 태그를 뽑는다 +# (GitHub API 미사용 — 비인증 호출 한도에 걸리지 않게). +if [ -z "$TAG" ]; then + latest_url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + "https://github.com/$REPO/releases/latest")" \ + || die "최신 릴리스를 조회하지 못했습니다" + TAG="${latest_url##*/}" +fi +case "$TAG" in + v[0-9]*.[0-9]*.[0-9]*) : ;; + *) die "릴리스 태그 형식이 아닙니다: '$TAG' (예: v0.3.0)" ;; +esac + +asset="$BIN-$TAG-$target.tar.gz" +base="https://github.com/$REPO/releases/download/$TAG" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "hwp $TAG ($target) 내려받는 중..." +curl -fsSL --proto '=https' --tlsv1.2 -o "$tmp/$asset" "$base/$asset" \ + || die "내려받기 실패: $base/$asset" +curl -fsSL --proto '=https' --tlsv1.2 -o "$tmp/$asset.sha256" "$base/$BIN-$TAG-$target.sha256" \ + || die "체크섬 자산을 받지 못했습니다" + +# 체크섬 대조 — 전송 손상·잘린 파일을 여기서 걸러낸다. +want="$(awk '{print $1; exit}' "$tmp/$asset.sha256")" +if command -v sha256sum >/dev/null 2>&1; then + got="$(sha256sum "$tmp/$asset" | awk '{print $1}')" +elif command -v shasum >/dev/null 2>&1; then + got="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')" +else + die "sha256sum 또는 shasum 이(가) 필요합니다" +fi +[ "$want" = "$got" ] || die "체크섬 불일치 (기대 $want / 실제 $got)" + +tar -xf "$tmp/$asset" -C "$tmp" || die "압축 해제 실패" +[ -f "$tmp/$BIN" ] || die "아카이브에 $BIN 실행 파일이 없습니다" + +mkdir -p "$DIR" || die "설치 디렉터리를 만들지 못했습니다: $DIR" +# 실행 중인 바이너리를 덮어쓸 수 있게 임시 파일 → mv 로 교체한다(같은 파일시스템). +install_tmp="$DIR/.$BIN.install.$$" +cp "$tmp/$BIN" "$install_tmp" || die "설치 실패(권한 확인): $DIR" +chmod 755 "$install_tmp" +mv -f "$install_tmp" "$DIR/$BIN" || { rm -f "$install_tmp"; die "설치 실패: $DIR/$BIN"; } + +echo "설치 완료: $DIR/$BIN ($("$DIR/$BIN" --version))" +case ":$PATH:" in + *":$DIR:"*) ;; + *) echo + echo "PATH에 없습니다. 셸 설정에 추가하세요:" + echo " export PATH=\"$DIR:\$PATH\"" ;; +esac +echo "이후 업데이트: hwp update"