diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index edab9672..62525cc3 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -5,12 +5,15 @@ name: Build Release # # matrix: # macos-15(arm64) → EMRG--macos-arm64.pkg -# ubuntu-24.04(x86_64) → EMRG--linux-x86_64.AppImage + tar.gz -# ubuntu-24.04-arm(aarch64) → EMRG--linux-aarch64.AppImage + tar.gz +# ubuntu-24.04(x86_64) → EMRG--linux-x86_64.AppImage + tar.gz + .run +# ubuntu-24.04-arm(aarch64) → EMRG--linux-aarch64.AppImage + tar.gz + .run # windows-2025(x64) → EMRG--windows-x64.exe # # 冒烟在无 Python runner 上跑(R111 验证干净机器承诺);离线冒烟(R96) # 在 Linux job 用 iptables 断网执行。 +# +# .run = Linux 无头服务器离线一键安装器(rant 2026-08-17T10:16:54): +# bash 自解压头 + runtime tarball payload,零网络零依赖。 on: push: @@ -356,6 +359,7 @@ jobs: dist/artifacts/*.AppImage dist/artifacts/*.tar.gz dist/artifacts/*.zip + dist/artifacts/*.run release: needs: build diff --git a/README.cn.md b/README.cn.md index 209984fd..d8c58200 100644 --- a/README.cn.md +++ b/README.cn.md @@ -87,7 +87,7 @@ EMRG 不只是一个工具——它是一个**会听吐槽、会自我改进** |------|---------| | macOS | `EMRG--macos-arm64.pkg` / `-x64.pkg`(用户级安装,无需管理员密码) | | Windows | `EMRG--windows-x64.exe`(免 UAC,PATH 自动注册) | -| Linux | `EMRG--linux-x86_64.AppImage` / `-aarch64.AppImage` | +| Linux | `EMRG--linux-x86_64.run` / `-aarch64.run`(无头服务器,一条命令)· `-x86_64.AppImage` / `-aarch64.AppImage`(桌面) | > **Windows SmartScreen 提示**:安装包未做 Authenticode 签名——如 SmartScreen 提示,点**保留** / **更多信息 → 仍要运行**。EMRG 完全开源,源码可审计。 diff --git a/README.md b/README.md index 4744360a..844d83b0 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Download from [GitHub Releases](https://github.com/argszero/emrg/releases) and d |----------|-----------| | macOS | `EMRG--macos-arm64.pkg` / `-x64.pkg` (user-level, no admin password) | | Windows | `EMRG--windows-x64.exe` (no UAC, PATH auto-registered) | -| Linux | `EMRG--linux-x86_64.AppImage` / `-aarch64.AppImage` | +| Linux | `EMRG--linux-x86_64.run` / `-aarch64.run` (headless server, one command) · `-x86_64.AppImage` / `-aarch64.AppImage` (desktop) | > **Windows SmartScreen notice**: the installer isn't Authenticode-signed — if SmartScreen prompts, click **Keep** / **More info → Run anyway**. EMRG is fully open source and auditable. diff --git a/packaging/make-installer.sh b/packaging/make-installer.sh index a8990b30..ec9fbe31 100755 --- a/packaging/make-installer.sh +++ b/packaging/make-installer.sh @@ -176,7 +176,7 @@ EOF ;; linux) - echo "==> Linux tar.gz + AppImage (AppImage built by electron-builder, collected R116)" + echo "==> Linux tar.gz + AppImage + .run (AppImage built by electron-builder, collected R116)" tar -czf "$DIST/artifacts/EMRG-$VERSION-linux-$(uname -m).tar.gz" -C "$(dirname "$RUNTIME")" runtime # R116: 收集 electron-builder 产出的 AppImage(emrg/gui/dist/*.AppImage)到 # dist/artifacts/ —— 之前只生成 tar.gz,release 缺 linux AppImage(rant #13 Step 5)。 @@ -188,6 +188,9 @@ EOF else echo "!! AppImage not found in emrg/gui/dist — Linux release 缺 AppImage(有 tar.gz 兜底)" >&2 fi + # .run 自解压离线安装器(rant 2026-08-17T10:16:54):Linux 无头服务器 + # SSH/无桌面/普通用户场景一键安装。payload = 同一 runtime/ 目录,零网络。 + bash "$ROOT/packaging/make-run-installer.sh" ;; windows|win32|mingw*|msys*) diff --git a/packaging/make-run-installer.sh b/packaging/make-run-installer.sh new file mode 100644 index 00000000..958a6e3c --- /dev/null +++ b/packaging/make-run-installer.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# EMRG — Linux .run self-extracting offline installer builder +# (rant 2026-08-17T10:16:54: Linux headless server offline one-click install) +# +# Produces dist/artifacts/EMRG--linux-.run +# +# Structure (classic self-extracting archive): +# #!/usr/bin/env bash ← installer header script (this file's heredoc) +# __EMRG_PAYLOAD_MARKER__ ← single marker line +# ← tarball of the runtime/ directory +# +# The header script is pure bash + standard coreutils (grep/tail/tar/mkdir/ln) +# — zero external dependencies, zero network at install time (the payload is +# the fully offline runtime: bundled CPython + source + lib deps + launchers). +# +# Idempotent/upgrade: re-running overwrites the previous install (same +# semantics as the AppImage self-extract), symlinks are refreshed. +# +# Usage: bash packaging/make-run-installer.sh +# (needs dist/runtime/ built by build-runtime.sh first) +# ARCH=x86_64|aarch64 env override (default: uname -m) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST="$ROOT/dist" +RUNTIME="$DIST/runtime" +VERSION="$(cat "$RUNTIME/version.txt" 2>/dev/null || echo 0.2.41)" +ARCH="${ARCH:-$(uname -m)}" # x86_64 / aarch64 +OUT="$DIST/artifacts/EMRG-$VERSION-linux-$ARCH.run" + +if [ ! -d "$RUNTIME" ]; then + echo "error: runtime not built — run bash packaging/build-runtime.sh first" >&2 + exit 1 +fi +mkdir -p "$DIST/artifacts" + +PAYLOAD="$(mktemp)" +HEADER="$(mktemp)" +trap 'rm -f "$PAYLOAD" "$HEADER"' EXIT + +# ── payload: same layout as the tar.gz artifact (runtime/ dir) ── +echo "==> packing runtime payload" +tar -czf "$PAYLOAD" -C "$(dirname "$RUNTIME")" runtime + +# ── installer header (VERSION injected via placeholder) ── +cat > "$HEADER" <<'HEADER_EOF' +#!/usr/bin/env bash +# EMRG __EMRG_VERSION__ — Linux self-extracting offline installer +# One command, fully offline (bundled Python 3.13 + deps + launchers). +# Works on headless servers (SSH, no desktop, no uv/gh, normal user). +set -euo pipefail + +VERSION="__EMRG_VERSION__" +# Default from HOME unconditionally — never inherit an ambient PREFIX env var +# (common on build servers / containers: make install PREFIX=..., SDKs, CI +# images). --prefix= is the only override. +PREFIX="$HOME/.emrg/install" +WRITE_PROFILE=1 +START=0 + +usage() { + cat <<'EOF' +Usage: ./EMRG--linux-.run [options] + +One-command offline install of EMRG (bundled Python + deps, no network). + +Options: + --prefix= Install directory (default: ~/.emrg/install) + --no-profile Do not append ~/.local/bin to the shell profile PATH + --start Start the background daemon (emrgd) after install + -h, --help Show this help + +After install: + emrg → ~/.local/bin/emrg (TUI; ~/.local/bin added to PATH in ~/.bashrc + emrgd → ~/.local/bin/emrgd when missing — skip with --no-profile) + uninstall → ~/.emrg/install/bin/emrg-uninstall +EOF +} + +for arg in "$@"; do + case "$arg" in + --prefix=*) PREFIX="${arg#--prefix=}" ;; + --no-profile) WRITE_PROFILE=0 ;; + --start) START=1 ;; + -h|--help) usage; exit 0 ;; + *) + echo "error: unknown option: $arg" >&2 + usage >&2 + exit 1 + ;; + esac +done + +# ── self-extract payload (marker line + everything after) ── +# -a: the .run is "binary" (gzip payload) — grep must treat it as text to +# report line numbers (GNU/BSD grep otherwise prints "Binary file ... matches"). +MARKER_LINE="$(grep -an '^__EMRG_PAYLOAD_MARKER__$' "$0" | tail -1 | cut -d: -f1 || true)" +if [ -z "${MARKER_LINE:-}" ]; then + echo "error: corrupt installer (payload marker not found)" >&2 + exit 1 +fi + +echo "==> EMRG $VERSION — installing to $PREFIX (offline)" +mkdir -p "$PREFIX" +tail -n +$((MARKER_LINE + 1)) "$0" | tar -xzf - -C "$PREFIX" --strip-components=1 +chmod +x "$PREFIX/bin/emrg" "$PREFIX/bin/emrgd" "$PREFIX/bin/emrg-uninstall" + +# ── symlinks (idempotent: -f refresh) ── +mkdir -p "$HOME/.local/bin" +ln -sfn "$PREFIX/bin/emrg" "$HOME/.local/bin/emrg" +ln -sfn "$PREFIX/bin/emrgd" "$HOME/.local/bin/emrgd" + +# ── PATH handling (idempotent dedup; skip with --no-profile) ── +if [ "$WRITE_PROFILE" = "1" ]; then + RC="$HOME/.bashrc" + [ -f "$RC" ] || RC="$HOME/.profile" + if ! printf '%s' "$PATH" | tr ':' '\n' | grep -Fqx "$HOME/.local/bin"; then + if [ ! -f "$RC" ] || ! grep -Fq 'export PATH="$HOME/.local/bin:$PATH"' "$RC" 2>/dev/null; then + { + echo '' + echo '# EMRG launcher path' + echo 'export PATH="$HOME/.local/bin:$PATH"' + } >> "$RC" + fi + echo "==> added $HOME/.local/bin to PATH ($RC) — new shell or: export PATH=\"\$HOME/.local/bin:\$PATH\"" + fi +fi + +# ── optional daemon start ── +if [ "$START" = "1" ]; then + echo "==> starting emrgd (daemon)" + (nohup "$HOME/.local/bin/emrgd" >/dev/null 2>&1 &) +fi + +echo "==> EMRG $VERSION installed" +echo " install : $PREFIX" +echo " emrg : $HOME/.local/bin/emrg (TUI — run: emrg)" +echo " emrgd : $HOME/.local/bin/emrgd (daemon)" +echo " uninstall: $PREFIX/bin/emrg-uninstall" +# MUST exit before the payload marker — otherwise bash would keep executing +# the payload bytes as shell commands (classic self-extracting pitfall). +exit 0 +HEADER_EOF + +# portable in-place sed (GNU: -i; BSD/macOS: -i.bak) +sed -i.bak "s/__EMRG_VERSION__/$VERSION/g" "$HEADER" +rm -f "$HEADER.bak" + +# ── assemble .run ── +echo "==> assembling $OUT" +cat "$HEADER" > "$OUT" +printf '__EMRG_PAYLOAD_MARKER__\n' >> "$OUT" +cat "$PAYLOAD" >> "$OUT" +chmod +x "$OUT" + +# ── build-time sanity: header must not contain a 2nd marker; payload readable ── +MARKERS="$(grep -ac '^__EMRG_PAYLOAD_MARKER__$' "$OUT" || true)" +if [ "$MARKERS" != "1" ]; then + echo "error: payload marker count != 1 ($MARKERS) — .run corrupted" >&2 + exit 1 +fi +LINE="$(grep -an '^__EMRG_PAYLOAD_MARKER__$' "$OUT" | cut -d: -f1)" +tail -n +$((LINE + 1)) "$OUT" | tar -tzf - >/dev/null +echo "==> $(basename "$OUT") — $(du -h "$OUT" | cut -f1) (payload OK)" diff --git a/packaging/smoke-test.sh b/packaging/smoke-test.sh index b32c165d..03d0eef8 100755 --- a/packaging/smoke-test.sh +++ b/packaging/smoke-test.sh @@ -159,6 +159,32 @@ if [ -f "$HOME/.emrg/install/bin/emrg-uninstall" ]; then ok "10. emrg-uninstall ok "11. read-only install — local manual (R61/R47)" ok "12. offline install — Linux CI iptables/unshare (R47/R96)" +# 13. .run 自解压安装器(rant 2026-08-17T10:16:54):独立临时 HOME 一键安装 +say "13. .run self-extracting installer (offline one-click)" +RUN_FILE="$(ls "$ROOT"/dist/artifacts/EMRG-*-linux-*.run 2>/dev/null | head -1 || true)" +if [ -z "$RUN_FILE" ]; then + ok "13. .run — no artifact in dist/artifacts (installer build skipped; audit-degraded)" +else + run_home="$(mktemp -d)" + if (cd "$run_home" && HOME="$run_home" bash "$RUN_FILE" --no-profile >/dev/null 2>&1) \ + && [ -x "$run_home/.emrg/install/bin/emrg" ] \ + && [ -L "$run_home/.local/bin/emrg" ] \ + && [ -L "$run_home/.local/bin/emrgd" ] \ + && [ -x "$run_home/.emrg/install/bin/emrg-uninstall" ] \ + && HOME="$run_home" "$run_home/.emrg/install/bin/emrg" --version 2>&1 | grep -q "emrg "; then + ok "13. .run install (extract + symlink + version)" + # 幂等重跑(覆盖旧安装) + if (cd "$run_home" && HOME="$run_home" bash "$RUN_FILE" --no-profile >/dev/null 2>&1); then + ok "13. .run re-run idempotent" + else + fail "13. .run re-run idempotent" + fi + else + fail "13. .run install" + fi + rm -rf "$run_home" +fi + # 清理临时 daemon emrg server stop >/dev/null 2>&1 || true diff --git a/tests/test_version_sync.py b/tests/test_version_sync.py index 97b0ae5a..eaee2256 100644 --- a/tests/test_version_sync.py +++ b/tests/test_version_sync.py @@ -50,7 +50,7 @@ def _uv_lock_version() -> str: def _shell_fallback_versions() -> list[tuple[str, str]]: """各 shell 脚本的版本 fallback(打包时无法读取 __version__ 时使用)。""" results: list[tuple[str, str]] = [] - for rel in ("packaging/make-installer.sh", "packaging/build-runtime.sh"): + for rel in ("packaging/make-installer.sh", "packaging/build-runtime.sh", "packaging/make-run-installer.sh"): content = (REPO_ROOT / rel).read_text(encoding="utf-8") m = re.search(r'\|\| echo "?(\d+\.\d+\.\d+)"?', content) assert m, f"{rel} 中找不到版本 fallback"