diff --git a/Cargo.lock b/Cargo.lock index 132d327..d8aa5e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -667,7 +667,7 @@ dependencies = [ [[package]] name = "openstock" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "encoding_rs", diff --git a/Cargo.toml b/Cargo.toml index 3042a65..302f153 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openstock" -version = "0.1.0" +version = "0.2.0" edition = "2021" [dependencies] diff --git a/README.md b/README.md index ca2205c..0be0869 100644 --- a/README.md +++ b/README.md @@ -50,15 +50,15 @@ openstock update curl -fsSL https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/update.sh | sh ``` -`openstock update`는 현재 실행 중인 바이너리의 디렉터리를 `OPENSTOCK_INSTALL_DIR`로 사용해 설치 스크립트를 다시 실행합니다. 설치 위치를 직접 지정하려면 `OPENSTOCK_INSTALL_DIR=/path openstock update`를 사용합니다. 설치 스크립트 URL은 `OPENSTOCK_INSTALL_SCRIPT_URL`로 바꿀 수 있습니다. +`openstock update`는 Gitea 최신 release를 조회하고, 현재 버전보다 최신이면 Linux x86_64 release asset을 내려받아 현재 실행 중인 바이너리의 디렉터리에 설치합니다. 설치 위치를 직접 지정하려면 `OPENSTOCK_INSTALL_DIR=/path openstock update`를 사용합니다. 같은 버전도 재설치하려면 `openstock update --force`를 사용합니다. ## Release Gitea Actions가 활성화된 저장소에서는 push 시 자동으로 CI가 실행됩니다. `main` branch push는 테스트와 release 빌드 검증만 수행하고, `v*` tag push는 Gitea Release를 만들고 Linux x86_64 바이너리를 asset으로 등록합니다. ```bash -git tag v0.1.0 -git push origin main v0.1.0 +git tag v0.2.0 +git push origin main v0.2.0 ``` 필요 조건: @@ -187,11 +187,17 @@ docker compose logs --tail=120 openstock-runner | Field | Type | Meaning | | --- | --- | --- | -| `installer_url` | string | 업데이트에 사용한 원격 설치 스크립트 URL입니다. | +| `release_api_url` | string | 최신 release 정보를 조회한 Gitea API URL입니다. | +| `current_version` | string | 현재 실행 중인 openstock 버전입니다. | +| `latest_version` | string | Gitea 최신 release tag에서 해석한 버전입니다. | +| `release_tag` | string | Gitea 최신 release tag입니다. | +| `release_url` | string | Gitea release 페이지 URL입니다. | +| `asset_name` | string | 설치에 사용한 release asset 이름입니다. | +| `asset_url` | string | 설치에 사용한 release asset 다운로드 URL입니다. | | `install_dir` | string | 업데이트 대상 바이너리를 설치한 디렉터리입니다. | -| `status` | string | 업데이트 명령 실행 결과입니다. 성공 시 `updated`입니다. | -| `stdout` | string | 설치 스크립트의 표준 출력입니다. | -| `stderr` | string | 설치 스크립트의 표준 오류 출력입니다. | +| `status` | string | 업데이트 명령 실행 결과입니다. `updated` 또는 `up_to_date`입니다. | +| `stdout` | string | release asset 설치 스크립트의 표준 출력입니다. | +| `stderr` | string | release asset 설치 스크립트의 표준 오류 출력입니다. | #### `api list` @@ -580,16 +586,16 @@ openstock cache prune ### `openstock update` -원격 설치 스크립트를 다시 실행해 현재 openstock 바이너리를 업데이트합니다. +Gitea 최신 release asset을 내려받아 현재 openstock 바이너리를 업데이트합니다. | Direction | Data | | --- | --- | -| Input | `OPENSTOCK_INSTALL_DIR`, `OPENSTOCK_INSTALL_SCRIPT_URL` 선택 지정 | -| External IO | `GET https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/install.sh`; install script가 source archive를 내려받을 수 있음 | +| Input | `--force`; `OPENSTOCK_INSTALL_DIR`, `OPENSTOCK_RELEASE_API_URL`, `OPENSTOCK_RELEASE_ASSET_SUFFIX` 선택 지정 | +| External IO | `GET https://git.hananakick.cc/api/v1/repos/Autotrade/openstock/releases/latest`; selected release asset download | | File write | 현재 실행 바이너리 디렉터리 또는 `OPENSTOCK_INSTALL_DIR`의 `openstock` 바이너리 | -| Output fields | `installer_url`, `install_dir`, `status`, `stdout`, `stderr` | +| Output fields | `release_api_url`, `current_version`, `latest_version`, `release_tag`, `release_url`, `asset_name`, `asset_url`, `install_dir`, `status`, `stdout`, `stderr` | | Raw | `null` | -| Side effect | release build 후 설치된 바이너리를 교체합니다. `~/.config/openstock` 설정/캐시는 보존합니다. | +| Side effect | 최신 release 버전이 현재 버전보다 크거나 `--force`이면 설치된 바이너리를 교체합니다. `~/.config/openstock` 설정/캐시는 보존합니다. | ### `openstock api list` diff --git a/scripts/update.sh b/scripts/update.sh index a192401..f29bce4 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -1,6 +1,11 @@ #!/usr/bin/env sh set -eu -SCRIPT_URL="${OPENSTOCK_INSTALL_SCRIPT_URL:-https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/install.sh}" +BIN_NAME="${OPENSTOCK_BIN_NAME:-openstock}" -curl -fsSL "$SCRIPT_URL" | sh +if ! command -v "$BIN_NAME" >/dev/null 2>&1; then + echo "$BIN_NAME is required for update. Install first with scripts/install.sh." >&2 + exit 1 +fi + +"$BIN_NAME" update "$@" diff --git a/src/commands/update.rs b/src/commands/update.rs index c097e0e..169c4b1 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -2,11 +2,14 @@ use crate::core::output; use std::path::PathBuf; use std::process::Command; -const DEFAULT_INSTALL_SCRIPT_URL: &str = - "https://git.hananakick.cc/Autotrade/openstock/raw/branch/main/scripts/install.sh"; +use crate::UpdateCommand; -pub fn handle_update() { - match update() { +const DEFAULT_RELEASE_API_URL: &str = + "https://git.hananakick.cc/api/v1/repos/Autotrade/openstock/releases/latest"; +const DEFAULT_ASSET_SUFFIX: &str = "-linux-x86_64.tar.gz"; + +pub fn handle_update(command: &UpdateCommand) { + match update(command.force) { Ok(result) => { println!( "{}", @@ -15,9 +18,39 @@ pub fn handle_update() { "openstock 바이너리 업데이트 실행 결과", vec![ output::field( - "installer_url", - "업데이트에 사용한 원격 설치 스크립트 URL", - serde_json::json!(result.installer_url), + "release_api_url", + "최신 release 정보를 조회한 Gitea API URL", + serde_json::json!(result.release_api_url), + ), + output::field( + "current_version", + "현재 실행 중인 openstock 버전", + serde_json::json!(result.current_version), + ), + output::field( + "latest_version", + "Gitea 최신 release tag에서 해석한 버전", + serde_json::json!(result.latest_version), + ), + output::field( + "release_tag", + "Gitea 최신 release tag", + serde_json::json!(result.release_tag), + ), + output::field( + "release_url", + "Gitea release 페이지 URL", + serde_json::json!(result.release_url), + ), + output::field( + "asset_name", + "설치에 사용한 release asset 이름", + serde_json::json!(result.asset_name), + ), + output::field( + "asset_url", + "설치에 사용한 release asset 다운로드 URL", + serde_json::json!(result.asset_url), ), output::field( "install_dir", @@ -27,16 +60,16 @@ pub fn handle_update() { output::field( "status", "업데이트 명령 실행 결과", - serde_json::json!("updated"), + serde_json::json!(result.status), ), output::field( "stdout", - "설치 스크립트 표준 출력", + "release asset 설치 스크립트 표준 출력", serde_json::json!(result.stdout), ), output::field( "stderr", - "설치 스크립트 표준 오류 출력", + "release asset 설치 스크립트 표준 오류 출력", serde_json::json!(result.stderr), ), ], @@ -50,35 +83,154 @@ pub fn handle_update() { } } -fn update() -> Result { +fn update(force: bool) -> Result { + let current_version = env!("CARGO_PKG_VERSION").to_string(); + let release_api_url = release_api_url(); + let release = latest_release(&release_api_url)?; + let latest_version = normalize_version(&release.tag_name); + let asset = release_asset(&release)?; let install_dir = install_dir()?; - let installer_url = installer_url(); - let command = format!("curl -fsSL {} | sh", shell_quote(&installer_url)); + + if !force && compare_versions(&latest_version, ¤t_version) != std::cmp::Ordering::Greater + { + return Ok(UpdateResult { + release_api_url, + current_version, + latest_version, + release_tag: release.tag_name, + release_url: release.html_url, + asset_name: asset.name, + asset_url: asset.browser_download_url, + install_dir, + status: "up_to_date".to_string(), + stdout: String::new(), + stderr: String::new(), + }); + } + + let script = format!( + "set -eu\n\ + tmp=\"$(mktemp -d \"${{TMPDIR:-/tmp}}/openstock-update.XXXXXX\")\"\n\ + cleanup() {{ rm -rf \"$tmp\"; }}\n\ + trap cleanup EXIT\n\ + curl -fsSL {} -o \"$tmp/openstock.tar.gz\"\n\ + tar -xzf \"$tmp/openstock.tar.gz\" -C \"$tmp\"\n\ + test -x \"$tmp/openstock\"\n\ + mkdir -p {}\n\ + target={}\n\ + tmp_target=\"{}/.openstock.tmp.$$\"\n\ + cp \"$tmp/openstock\" \"$tmp_target\"\n\ + chmod 755 \"$tmp_target\"\n\ + mv -f \"$tmp_target\" \"$target\"\n\ + printf 'installed: %s\\n' \"$target\"\n", + shell_quote(&asset.browser_download_url), + shell_quote(&install_dir.display().to_string()), + shell_quote(&install_dir.join("openstock").display().to_string()), + shell_quote(&install_dir.display().to_string()), + ); let output = Command::new("sh") .arg("-c") - .arg(command) - .env("OPENSTOCK_INSTALL_DIR", &install_dir) + .arg(script) .output() - .map_err(|err| format!("업데이트 스크립트 실행 실패: {}", err))?; + .map_err(|err| format!("release asset 설치 스크립트 실행 실패: {}", err))?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); if !output.status.success() { return Err(format!( - "업데이트 스크립트가 실패했습니다. status={}, stdout={}, stderr={}", + "release asset 설치 스크립트가 실패했습니다. status={}, stdout={}, stderr={}", output.status, stdout, stderr )); } Ok(UpdateResult { - installer_url, + release_api_url, + current_version, + latest_version, + release_tag: release.tag_name, + release_url: release.html_url, + asset_name: asset.name, + asset_url: asset.browser_download_url, install_dir, + status: "updated".to_string(), stdout, stderr, }) } +fn latest_release(api_url: &str) -> Result { + let response = crate::core::http::agent() + .get(api_url) + .call() + .map_err(|err| format!("최신 release 조회 실패: {}", err))?; + let status = response.status(); + let body = response + .into_body() + .read_to_string() + .map_err(|err| format!("최신 release 응답 읽기 실패: {}", err))?; + + if !status.is_success() { + return Err(format!("최신 release 조회 오류 ({}): {}", status, body)); + } + + let value = serde_json::from_str::(&body) + .map_err(|err| format!("최신 release 응답 파싱 실패: {} / 원문: {}", err, body))?; + let tag_name = value + .get("tag_name") + .and_then(|value| value.as_str()) + .ok_or_else(|| format!("최신 release 응답에 tag_name이 없습니다: {}", body))? + .to_string(); + let html_url = value + .get("html_url") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + let assets = value + .get("assets") + .and_then(|value| value.as_array()) + .ok_or_else(|| format!("최신 release 응답에 assets가 없습니다: {}", body))? + .iter() + .filter_map(|asset| { + let name = asset.get("name")?.as_str()?.to_string(); + let browser_download_url = asset + .get("browser_download_url") + .or_else(|| asset.get("download_url"))? + .as_str()? + .to_string(); + Some(ReleaseAsset { + name, + browser_download_url, + }) + }) + .collect::>(); + + Ok(Release { + tag_name, + html_url, + assets, + }) +} + +fn release_asset(release: &Release) -> Result { + let suffix = std::env::var("OPENSTOCK_RELEASE_ASSET_SUFFIX") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_ASSET_SUFFIX.to_string()); + + release + .assets + .iter() + .find(|asset| asset.name.ends_with(&suffix) && !asset.name.ends_with(".sha256")) + .cloned() + .ok_or_else(|| { + format!( + "release {}에서 suffix '{}'에 맞는 바이너리 asset을 찾지 못했습니다", + release.tag_name, suffix + ) + }) +} + fn install_dir() -> Result { if let Some(value) = std::env::var_os("OPENSTOCK_INSTALL_DIR") { return Ok(PathBuf::from(value)); @@ -94,20 +246,62 @@ fn install_dir() -> Result { }) } -fn installer_url() -> String { - std::env::var("OPENSTOCK_INSTALL_SCRIPT_URL") +fn release_api_url() -> String { + std::env::var("OPENSTOCK_RELEASE_API_URL") .ok() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_INSTALL_SCRIPT_URL.to_string()) + .unwrap_or_else(|| DEFAULT_RELEASE_API_URL.to_string()) } fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } +fn normalize_version(tag: &str) -> String { + tag.trim().trim_start_matches('v').to_string() +} + +fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering { + let left_parts = version_parts(left); + let right_parts = version_parts(right); + left_parts.cmp(&right_parts) +} + +fn version_parts(version: &str) -> Vec { + version + .split(|ch| ch == '.' || ch == '-' || ch == '+') + .map(|part| { + part.chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect::() + .parse::() + .unwrap_or(0) + }) + .collect() +} + +struct Release { + tag_name: String, + html_url: String, + assets: Vec, +} + +#[derive(Clone)] +struct ReleaseAsset { + name: String, + browser_download_url: String, +} + struct UpdateResult { - installer_url: String, + release_api_url: String, + current_version: String, + latest_version: String, + release_tag: String, + release_url: String, + asset_name: String, + asset_url: String, install_dir: PathBuf, + status: String, stdout: String, stderr: String, } diff --git a/src/main.rs b/src/main.rs index 80118fc..6c3a849 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use commands::account::AccountCommands; use commands::api::ApiCommands; use commands::cache::CacheCommands; @@ -30,7 +30,7 @@ enum Commands { Version, /// 설치된 openstock 바이너리 업데이트 - Update, + Update(UpdateCommand), /// 증권사 API 리스트 조회 및 API 설정 Api { @@ -83,7 +83,7 @@ fn main() { match &cli.command { Some(Commands::Version) => commands::handle_version(), - Some(Commands::Update) => commands::handle_update(), + Some(Commands::Update(command)) => commands::handle_update(command), Some(Commands::Api { sub }) => commands::handle_api(sub), Some(Commands::Dart { sub }) => commands::handle_dart(sub), Some(Commands::Account { sub }) => commands::handle_account(sub), @@ -115,3 +115,10 @@ fn main() { } } } + +#[derive(Args)] +struct UpdateCommand { + /// 현재 버전과 같아도 최신 release asset으로 재설치 + #[arg(long)] + force: bool, +}