diff --git a/.gitignore b/.gitignore index ec27f1c..f3f9a02 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ __pycache__/ .ruff_cache/ *.egg-info/ build/ -dist/ +dist/* +!dist/ +!dist/ictlogin.pyz +!dist/ictlogin.pyz.sha256 diff --git a/README.md b/README.md index d04da36..1a92e2b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,31 @@ python3 -m pip install --user --no-deps --no-build-isolation . 如果不想安装,也可以直接运行 `python3 run_ictlogin.py ...`。 +## 单文件发布 + +构建可直接复制到其他 Linux 服务器的 zipapp: + +```bash +python3 scripts/build_release.py +./dist/ictlogin.pyz --version +(cd dist && sha256sum -c ictlogin.pyz.sha256) +``` + +在包含 `install.sh` 和 `dist/ictlogin.pyz` 的目录中执行: + +```bash +./install.sh +``` + +安装脚本默认写入 `~/.local/bin/ictlogin`,会自动校验同目录的 `.sha256` 文件。也可以从指定文件或 URL 安装: + +```bash +./install.sh --source /path/to/ictlogin.pyz --prefix "$HOME/.local/bin" +ICTLOGIN_PYZ_URL="https://example.invalid/ictlogin.pyz" ./install.sh +``` + +当前 Gitea 仓库是私有仓库;在没有仓库访问凭据的服务器上,请先通过 `scp` 复制 `install.sh`、`dist/ictlogin.pyz` 和 `dist/ictlogin.pyz.sha256`,再运行安装脚本。 + ## 使用 先检查当前 IP: diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b97175b --- /dev/null +++ b/install.sh @@ -0,0 +1,164 @@ +#!/bin/sh +# Install the portable ICT login zipapp for the current user. + +set -eu + +usage() { + cat <<'EOF' +Usage: install.sh [options] + +Options: + -s, --source PATH|URL install this .pyz file + -p, --prefix DIR install directory (default: $HOME/.local/bin) + --sha256 HEX expected SHA-256 of the .pyz + -h, --help show this help + +Environment overrides: + ICTLOGIN_PYZ local .pyz path or URL + ICTLOGIN_PYZ_URL download URL when no local artifact is present + ICTLOGIN_PYZ_SHA256 expected SHA-256 + ICTLOGIN_PREFIX install directory + ICTLOGIN_PYTHON Python 3 executable +EOF +} + +SOURCE="${ICTLOGIN_PYZ:-}" +URL="${ICTLOGIN_PYZ_URL:-}" +EXPECTED_SHA256="${ICTLOGIN_PYZ_SHA256:-}" +PREFIX="${ICTLOGIN_PREFIX:-${HOME}/.local/bin}" + +while [ "$#" -gt 0 ]; do + case "$1" in + -s|--source) + [ "$#" -ge 2 ] || { echo "--source requires a value" >&2; exit 2; } + SOURCE=$2 + shift 2 + ;; + -p|--prefix) + [ "$#" -ge 2 ] || { echo "--prefix requires a value" >&2; exit 2; } + PREFIX=$2 + shift 2 + ;; + --sha256) + [ "$#" -ge 2 ] || { echo "--sha256 requires a value" >&2; exit 2; } + EXPECTED_SHA256=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +PYTHON="${ICTLOGIN_PYTHON:-}" +if [ -z "$PYTHON" ]; then + PYTHON=$(command -v python3 2>/dev/null || true) +fi +if [ -z "$PYTHON" ]; then + PYTHON=$(command -v python 2>/dev/null || true) +fi +if [ -z "$PYTHON" ]; then + echo "Python 3 is required" >&2 + exit 1 +fi + +if ! "$PYTHON" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 8) else 1)' 2>/dev/null; then + echo "Python 3.8 or newer is required: $PYTHON" >&2 + exit 1 +fi + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +if [ -z "$SOURCE" ] && [ -z "$URL" ]; then + if [ -f "$SCRIPT_DIR/dist/ictlogin.pyz" ]; then + SOURCE=$SCRIPT_DIR/dist/ictlogin.pyz + elif [ -f "$SCRIPT_DIR/ictlogin.pyz" ]; then + SOURCE=$SCRIPT_DIR/ictlogin.pyz + else + URL="https://gitx.roy1994.top/mancitrus/ictlogin/raw/branch/main/dist/ictlogin.pyz" + fi +fi + +SOURCE_PATH="" +case "$SOURCE" in + http://*|https://*) URL=$SOURCE; SOURCE="" ;; + "") ;; + *) + SOURCE_PATH=$SOURCE + [ -f "$SOURCE_PATH" ] || { echo "file not found: $SOURCE_PATH" >&2; exit 1; } + ;; +esac + +TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/ictlogin.pyz.XXXXXX") +cleanup() { + rm -f "$TMP_FILE" +} +trap cleanup EXIT HUP INT TERM + +if [ -n "$URL" ]; then + if command -v curl >/dev/null 2>&1; then + curl --fail --location --silent --show-error -o "$TMP_FILE" "$URL" + elif command -v wget >/dev/null 2>&1; then + wget --quiet --output-document="$TMP_FILE" "$URL" + else + echo "curl or wget is required to download $URL" >&2 + exit 1 + fi +else + cp "$SOURCE_PATH" "$TMP_FILE" +fi + +if [ -z "$EXPECTED_SHA256" ] && [ -n "$SOURCE_PATH" ] && [ -f "${SOURCE_PATH}.sha256" ]; then + EXPECTED_SHA256=$(awk 'NF { print $1; exit }' "${SOURCE_PATH}.sha256") +fi + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + "$PYTHON" - "$1" <<'PY' +import hashlib +import sys + +digest = hashlib.sha256() +with open(sys.argv[1], "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) +print(digest.hexdigest()) +PY + fi +} + +if [ -n "$EXPECTED_SHA256" ]; then + ACTUAL_SHA256=$(sha256_file "$TMP_FILE") + if [ "$ACTUAL_SHA256" != "$EXPECTED_SHA256" ]; then + echo "SHA-256 verification failed" >&2 + echo "expected: $EXPECTED_SHA256" >&2 + echo "actual: $ACTUAL_SHA256" >&2 + exit 1 + fi +fi + +"$PYTHON" "$TMP_FILE" --version >/dev/null +mkdir -p "$PREFIX" +TARGET="$PREFIX/ictlogin" +if command -v install >/dev/null 2>&1; then + install -m 0755 "$TMP_FILE" "$TARGET" +else + cp "$TMP_FILE" "$TARGET" + chmod 0755 "$TARGET" +fi + +echo "installed $TARGET" +case ":${PATH:-}:" in + *":$PREFIX:"*) ;; + *) echo "add to PATH: export PATH=\"$PREFIX:\$PATH\"" ;; +esac +"$TARGET" --version diff --git a/scripts/build_release.py b/scripts/build_release.py new file mode 100755 index 0000000..86ec703 --- /dev/null +++ b/scripts/build_release.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Build the dependency-free ICT login zipapp and its checksum.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import shutil +import tempfile +from pathlib import Path +from zipapp import create_archive + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUTPUT = ROOT / "dist" / "ictlogin.pyz" + + +def _copy_source(source: Path, target: Path) -> None: + target.mkdir(parents=True, exist_ok=True) + shutil.copytree( + source, + target / "ictlogin", + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.egg-info"), + ) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build(output: Path, checksum_path: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + staging_root = Path(tempfile.mkdtemp(prefix="ictlogin-build-")) + temporary_output = output.with_name(output.name + ".tmp") + try: + _copy_source(ROOT / "src" / "ictlogin", staging_root) + create_archive( + staging_root, + temporary_output, + interpreter="/usr/bin/env python3", + main="ictlogin.cli:main", + compressed=True, + ) + os.chmod(temporary_output, 0o755) + os.replace(temporary_output, output) + digest = _sha256(output) + checksum_path.parent.mkdir(parents=True, exist_ok=True) + checksum_path.write_text(f"{digest} {output.name}\n", encoding="ascii") + os.chmod(checksum_path, 0o644) + finally: + if temporary_output.exists(): + temporary_output.unlink() + shutil.rmtree(staging_root, ignore_errors=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", type=Path, default=DEFAULT_OUTPUT, help="output .pyz path" + ) + parser.add_argument( + "--checksum", type=Path, help="checksum path (defaults to OUTPUT.sha256)" + ) + args = parser.parse_args() + checksum_path = args.checksum or Path(str(args.output) + ".sha256") + build(args.output.resolve(), checksum_path.resolve()) + print(f"built {args.output}") + print(f"sha256 {checksum_path}") + + +if __name__ == "__main__": + main()