diff --git a/README.md b/README.md index a789327..474644f 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,21 @@ uvx --from git+https://gitea.shujk.top/shujakuin/shusub2.git shusub2 \ --api-url https://codex.server2.shujk.top/1232131231313123/api/tui/accounts ``` +Bootstrap a new machine from `uvx`: save the API URL, install `shusub2` as a +user command, then run it later as `shusub2`: + +```bash +uvx --from git+https://gitea.shujk.top/shujakuin/shusub2.git shusub2 \ + --api-url https://codex.server2.shujk.top/1232131231313123/api/tui/accounts \ + --install +shusub2 +``` + Install as a user command: ```bash uv tool install git+https://gitea.shujk.top/shujakuin/shusub2.git -shusub2 --api-url https://codex.server2.shujk.top/1232131231313123/api/tui/accounts +shusub2 --api-url https://codex.server2.shujk.top/1232131231313123/api/tui/accounts --save-config ``` Configure the default public API URL for `shusub2`: @@ -58,6 +68,8 @@ Configuration: - `--api-url` / `SUB2API_QUOTA_TUI_API_URL` - `--status-url` / `SHUSUB2_STATUS_URL` +- `--save-config` +- `--install` - `--refresh-seconds` / `SUB2API_QUOTA_TUI_REFRESH_SECONDS` - `--timeout` / `SUB2API_QUOTA_TUI_TIMEOUT` diff --git a/pyproject.toml b/pyproject.toml index 61fbbbd..96879fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shusub2" -version = "0.1.6" +version = "0.1.7" description = "Terminal UI for Sub2API account quota and daily usage" readme = "README.md" requires-python = ">=3.11" diff --git a/sub2api_quota_tui.py b/sub2api_quota_tui.py index 3ba5972..279fefe 100644 --- a/sub2api_quota_tui.py +++ b/sub2api_quota_tui.py @@ -8,6 +8,7 @@ import importlib.metadata import json import os import re +import subprocess import sys import urllib.parse import urllib.request @@ -16,7 +17,7 @@ from typing import Any APP_NAME = "shusub2" -FALLBACK_VERSION = "0.1.6" +FALLBACK_VERSION = "0.1.7" DEFAULT_API_URL = "http://127.0.0.1:18318/api/tui/accounts" DEFAULT_CONFIG_FILE = "~/.config/shusub2/api-url" DEFAULT_STATUS_CONFIG_FILE = "~/.config/shusub2/status-url" @@ -28,6 +29,7 @@ MONITOR_OK_STATUSES = {"operational", "ok", "success"} MONITOR_FAILED_STATUSES = {"error", "failed", "failure"} MONITOR_STOPWORDS = {"response", "responses", "monitor"} INSTALL_COMMAND = "uv tool install --force git+https://gitea.shujk.top/shujakuin/shusub2.git" +INSTALL_COMMAND_ARGS = ["uv", "tool", "install", "--force", "git+https://gitea.shujk.top/shujakuin/shusub2.git"] def env_int(name: str, default: int, *, minimum: int = 1) -> int: @@ -68,6 +70,36 @@ def default_status_url() -> str: ) +def config_file_path() -> Path: + return Path(os.environ.get("SHUSUB2_API_URL_FILE", DEFAULT_CONFIG_FILE)).expanduser() + + +def write_api_url_config(api_url: str) -> Path: + value = str(api_url or "").strip() + if not value: + raise ValueError("api url is empty") + path = config_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + try: + path.parent.chmod(0o700) + except OSError: + pass + path.write_text(value + "\n", encoding="utf-8") + try: + path.chmod(0o600) + except OSError: + pass + return path + + +def run_install_command() -> int: + try: + return subprocess.run(INSTALL_COMMAND_ARGS, check=False).returncode + except FileNotFoundError: + print("uv command not found; install uv first, then run:", INSTALL_COMMAND, file=sys.stderr) + return 127 + + def inferred_status_url(api_url: str) -> str: parsed = urllib.parse.urlparse(str(api_url or "").strip()) if not parsed.scheme or not parsed.netloc: @@ -624,6 +656,8 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Sub2API quota and daily usage TUI") parser.add_argument("--api-url", default=default_api_url()) parser.add_argument("--status-url", default=default_status_url(), help="optional sub2api-status /api/status URL for channel monitor health") + parser.add_argument("--save-config", action="store_true", help="persist --api-url to ~/.config/shusub2/api-url before running") + parser.add_argument("--install", action="store_true", help="persist --api-url, install shusub2 as a uv tool, then exit") parser.add_argument("--version-check-url", default=os.environ.get("SHUSUB2_VERSION_CHECK_URL", DEFAULT_VERSION_CHECK_URL)) parser.add_argument( "--version-check-timeout", @@ -645,6 +679,12 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) status_url = str(args.status_url or "").strip() or inferred_status_url(args.api_url) + if args.save_config or args.install: + config_path = write_api_url_config(args.api_url) + print(f"saved api url to {config_path}") + if args.install: + print("installing shusub2 with uv tool...") + return run_install_command() version_message = check_version_update( args.version_check_url, max(1, args.version_check_timeout), diff --git a/tests/test_payload.py b/tests/test_payload.py index a973c5f..8a105c9 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -2,9 +2,11 @@ from __future__ import annotations import contextlib import io +import os from pathlib import Path import importlib.util import sys +import tempfile import unittest @@ -98,10 +100,27 @@ class Sub2APIQuotaTUITests(unittest.TestCase): def test_version_update_message_only_for_newer_versions(self) -> None: mod = load_module() - self.assertEqual(mod.latest_version_from_text('name = "shusub2"\nversion = "0.1.7"\n'), "0.1.7") - self.assertIn("0.1.6 -> 0.1.7", mod.version_update_message("0.1.7", "0.1.6")) - self.assertEqual(mod.version_update_message("0.1.6", "0.1.6"), "") - self.assertEqual(mod.version_update_message("0.1.5", "0.1.6"), "") + self.assertEqual(mod.latest_version_from_text('name = "shusub2"\nversion = "0.1.8"\n'), "0.1.8") + self.assertIn("0.1.7 -> 0.1.8", mod.version_update_message("0.1.8", "0.1.7")) + self.assertEqual(mod.version_update_message("0.1.7", "0.1.7"), "") + self.assertEqual(mod.version_update_message("0.1.6", "0.1.7"), "") + + def test_write_api_url_config_uses_config_file_env(self) -> None: + mod = load_module() + + with tempfile.TemporaryDirectory() as tmp: + config_file = Path(tmp) / "shusub2" / "api-url" + old_value = os.environ.get("SHUSUB2_API_URL_FILE") + os.environ["SHUSUB2_API_URL_FILE"] = str(config_file) + try: + written = mod.write_api_url_config("https://example.com/api/tui/accounts") + self.assertEqual(written, config_file) + self.assertEqual(config_file.read_text(encoding="utf-8"), "https://example.com/api/tui/accounts\n") + finally: + if old_value is None: + os.environ.pop("SHUSUB2_API_URL_FILE", None) + else: + os.environ["SHUSUB2_API_URL_FILE"] = old_value def test_once_output_is_name_first_and_includes_daily_quota(self) -> None: mod = load_module() diff --git a/uv.lock b/uv.lock index 31e0bbb..c922fbf 100644 --- a/uv.lock +++ b/uv.lock @@ -85,7 +85,7 @@ wheels = [ [[package]] name = "shusub2" -version = "0.1.6" +version = "0.1.7" source = { editable = "." } dependencies = [ { name = "textual" },