From 36a1ad51c8e43c905936034c5e72a12ff3286144 Mon Sep 17 00:00:00 2001 From: Kien Nguyen Date: Sun, 12 Apr 2026 20:41:40 -0700 Subject: [PATCH] Decouple module downloads from package version Allow native module downloads to target the minimum supported release or an explicit release tag without depending on the package version. Keep the GitHub release URL customizable for forked release hosting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 9 +-- ghostel.el | 77 ++++++++++++++----------- test/ghostel-test.el | 132 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 171 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 01bda5c..b869b5f 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ pre-built binary** or **compile from source** (controlled by `ghostel-module-auto-install`, default `ask`). You can also trigger these manually: -- `M-x ghostel-download-module` — download a pre-built binary from GitHub releases +- `M-x ghostel-download-module` — download the minimum supported pre-built binary +- `C-u M-x ghostel-download-module` — choose a specific release tag (leave blank for latest) - `M-x ghostel-module-compile` — build from source via `zig build` ## Building from source @@ -116,10 +117,10 @@ zig build -Doptimize=ReleaseFast When installed from MELPA, `M-x ghostel-module-compile` builds the native module from source using `zig build`. Zig's package manager fetches the -ghostty dependency automatically — no git submodule needed. +ghostty dependency automatically. -Alternatively, download a **pre-built binary** via -`M-x ghostel-download-module`. +Alternatively, download a **pre-built binary** via `M-x ghostel-download-module` +(or `C-u M-x ghostel-download-module` to pick a specific release). ## Shell Integration diff --git a/ghostel.el b/ghostel.el index ad71d6e..012e73f 100644 --- a/ghostel.el +++ b/ghostel.el @@ -374,9 +374,11 @@ before sending the input." ghostel-color-bright-white] "Color palette for the terminal (vector of 16 face names).") -(defvar ghostel-github-release-url +(defcustom ghostel-github-release-url "https://github.com/dakra/ghostel/releases" - "Base URL for ghostel GitHub releases.") + "Base URL for Ghostel GitHub releases. +Customize this when downloading pre-built modules from a fork or mirror." + :type 'string) (defconst ghostel--minimum-module-version "0.13.0" "Minimum native module version required by this Elisp version. @@ -423,22 +425,26 @@ Returns nil if the platform is not recognized." (when tag (format "ghostel-module-%s%s" tag module-file-suffix)))) -(defun ghostel--module-download-url () - "Return the download URL for the current platform's pre-built module." +(defun ghostel--module-download-url (&optional version) + "Return the download URL for the current platform's pre-built module. +When VERSION is nil, use the latest release download URL." (let ((asset-name (ghostel--module-asset-name))) (when asset-name - (let ((version (ghostel--package-version))) - (if version - (format "%s/download/v%s/%s" - ghostel-github-release-url version asset-name) - (format "%s/latest/download/%s" - ghostel-github-release-url asset-name)))))) - -(defun ghostel--download-module (dir) + (if version + (format "%s/download/v%s/%s" + ghostel-github-release-url version asset-name) + (format "%s/latest/download/%s" + ghostel-github-release-url asset-name))))) + +(defun ghostel--download-module (dir &optional version latest-release) "Download a pre-built module into DIR. +When VERSION is non-nil, download that release tag. +When LATEST-RELEASE is non-nil, use the latest release asset URL. Returns non-nil on success." (condition-case err - (let ((url (ghostel--module-download-url))) + (let* ((requested-version (unless latest-release + (or version ghostel--minimum-module-version))) + (url (ghostel--module-download-url requested-version))) (when url (unless (string-prefix-p "https://" url) (error "Refusing non-HTTPS download URL: %s" url)) @@ -484,16 +490,28 @@ Behavior is controlled by `ghostel-module-auto-install'." ('compile (ghostel--compile-module dir)) (_ nil)))) +(defun ghostel--read-module-download-version () + "Prompt for a release tag to download, or nil for the latest release." + (let ((version (read-string + (format "Ghostel module version (>= %s, empty for latest): " + ghostel--minimum-module-version)))) + (unless (string= version "") + (when (version< version ghostel--minimum-module-version) + (user-error "Version %s is older than minimum supported version %s" + version ghostel--minimum-module-version)) + version))) + (defun ghostel--ask-install-action (_dir) "Prompt the user to choose how to install the missing native module. Returns \\='download, \\='compile, or nil." - (let* ((url (or (ghostel--module-download-url) "GitHub releases")) + (let* ((url (or (ghostel--module-download-url ghostel--minimum-module-version) + "GitHub releases")) (choice (read-char-choice (format "Ghostel native module not found. [d] Download pre-built binary from: %s - [c] Compile from source (requires Zig) + [c] Compile from source via build.sh [s] Skip — install manually later Choice: " url) @@ -503,19 +521,6 @@ Choice: " url) (?c 'compile) (?s nil)))) -(defun ghostel--package-version () - "Return ghostel release version string, or nil. -Reads the Version header from ghostel.el so the download URL -matches the GitHub release tag even when MELPA rewrites the -version to a date-based string." - (require 'lisp-mnt nil t) - (when (fboundp 'lm-header) - (let ((lib (or load-file-name (locate-library "ghostel.el" t)))) - (when lib - (with-temp-buffer - (insert-file-contents lib nil 0 1024) - (lm-header "Version")))))) - (defun ghostel--download-file (url dest) "Download URL to DEST. Return non-nil on success." (condition-case nil @@ -539,18 +544,23 @@ version to a date-based string." (kill-buffer buf)))))) (error nil))) -(defun ghostel-download-module () - "Interactively download the pre-built native module for this platform." - (interactive) +(defun ghostel-download-module (&optional prompt-for-version) + "Interactively download the pre-built native module for this platform. +With PROMPT-FOR-VERSION, prompt for a release tag to download. +Leaving the prompt empty downloads the latest release." + (interactive "P") (let* ((dir (file-name-directory (or load-file-name (locate-library "ghostel") buffer-file-name))) (mod (expand-file-name - (concat "ghostel-module" module-file-suffix) dir))) + (concat "ghostel-module" module-file-suffix) dir)) + (version (when prompt-for-version + (ghostel--read-module-download-version))) + (latest-release (and prompt-for-version (null version)))) (when (and (file-exists-p mod) (not (yes-or-no-p "Module already exists. Re-download? "))) (user-error "Cancelled")) - (if (ghostel--download-module dir) + (if (ghostel--download-module dir version latest-release) (progn (module-load mod) (message "ghostel: module loaded successfully")) @@ -601,7 +611,6 @@ DIR is the module directory." (concat "Native module not found: " mod "\nRun M-x ghostel-download-module or M-x ghostel-module-compile"))))) - ;;; Internal variables (defvar-local ghostel--term nil diff --git a/test/ghostel-test.el b/test/ghostel-test.el index 9d8a9b1..0b4b659 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -1970,14 +1970,123 @@ buffer and hand nil to the native module." ;; ----------------------------------------------------------------------- ;; ----------------------------------------------------------------------- -;; Test: module version check -;; ----------------------------------------------------------------------- - -(ert-deftest ghostel-test-package-version () - "Test that `ghostel--package-version' returns a version string." - (let ((ver (ghostel--package-version))) - (should (stringp ver)) - (should (string-match-p "^[0-9]+\\.[0-9]+\\.[0-9]+" ver)))) +;; Test: module download version selection +;; ----------------------------------------------------------------------- + +(ert-deftest ghostel-test-module-download-url-uses-requested-version () + "Requested download versions are decoupled from the package version." + (let ((ghostel-github-release-url "https://example.invalid/releases")) + (cl-letf (((symbol-function 'ghostel--module-asset-name) + (lambda () "ghostel-module-x86_64-linux.so"))) + (should (equal "https://example.invalid/releases/download/v0.7.1/ghostel-module-x86_64-linux.so" + (ghostel--module-download-url "0.7.1")))))) + +(ert-deftest ghostel-test-module-download-url-uses-latest-release () + "A nil download version uses the latest release asset." + (let ((ghostel-github-release-url "https://example.invalid/releases")) + (cl-letf (((symbol-function 'ghostel--module-asset-name) + (lambda () "ghostel-module-x86_64-linux.so"))) + (should (equal "https://example.invalid/releases/latest/download/ghostel-module-x86_64-linux.so" + (ghostel--module-download-url nil)))))) + +(ert-deftest ghostel-test-download-module-defaults-to-minimum-version () + "Automatic downloads pin to the minimum supported native module version." + (let ((ghostel--minimum-module-version "0.7.1") + (captured-version :unset) + (download-dest nil)) + (cl-letf (((symbol-function 'ghostel--module-download-url) + (lambda (&optional version) + (setq captured-version version) + "https://example.invalid/releases/download/v0.7.1/ghostel-module-x86_64-linux.so")) + ((symbol-function 'ghostel--download-file) + (lambda (_url dest) + (setq download-dest dest) + t)) + ((symbol-function 'message) + (lambda (&rest _)))) + (should (ghostel--download-module "C:/ghostel/")) + (should (equal "0.7.1" captured-version)) + (should (equal (downcase (expand-file-name + (concat "ghostel-module" module-file-suffix) + "C:/ghostel/")) + (downcase download-dest)))))) + +(ert-deftest ghostel-test-download-module-prefix-uses-requested-version () + "Prefix downloads pass the requested release version through unchanged." + (let ((ghostel--minimum-module-version "0.7.1") + (captured-version :unset) + (captured-latest nil) + (loaded-module nil)) + (let ((comp-enable-subr-trampolines nil) + (native-comp-enable-subr-trampolines nil)) + (cl-letf (((symbol-function 'locate-library) + (lambda (_) "C:/ghostel/ghostel.el")) + ((symbol-function 'file-exists-p) + (lambda (_) nil)) + ((symbol-function 'read-string) + (lambda (&rest _) "0.8.0")) + ((symbol-function 'ghostel--download-module) + (lambda (_dir &optional version latest-release) + (setq captured-version version + captured-latest latest-release) + t)) + ((symbol-function 'module-load) + (lambda (path) + (setq loaded-module path))) + ((symbol-function 'message) + (lambda (&rest _)))) + (ghostel-download-module '(4)) + (should (equal "0.8.0" captured-version)) + (should-not captured-latest) + (should (equal (downcase (expand-file-name + (concat "ghostel-module" module-file-suffix) + "C:/ghostel/")) + (downcase loaded-module))))))) + +(ert-deftest ghostel-test-download-module-prefix-empty-uses-latest () + "Prefix download treats blank input as a request for the latest release." + (let ((captured-version :unset) + (captured-latest nil) + (loaded-module nil)) + (let ((comp-enable-subr-trampolines nil) + (native-comp-enable-subr-trampolines nil)) + (cl-letf (((symbol-function 'locate-library) + (lambda (_) "C:/ghostel/ghostel.el")) + ((symbol-function 'file-exists-p) + (lambda (_) nil)) + ((symbol-function 'read-string) + (lambda (&rest _) "")) + ((symbol-function 'ghostel--download-module) + (lambda (_dir &optional version latest-release) + (setq captured-version version + captured-latest latest-release) + t)) + ((symbol-function 'module-load) + (lambda (path) + (setq loaded-module path))) + ((symbol-function 'message) + (lambda (&rest _)))) + (ghostel-download-module '(4)) + (should (null captured-version)) + (should captured-latest) + (should (equal (downcase (expand-file-name + (concat "ghostel-module" module-file-suffix) + "C:/ghostel/")) + (downcase loaded-module))))))) + +(ert-deftest ghostel-test-download-module-prefix-rejects-too-old-version () + "Prefix download rejects versions below the minimum supported version." + (let ((ghostel--minimum-module-version "0.7.1")) + (let ((comp-enable-subr-trampolines nil) + (native-comp-enable-subr-trampolines nil)) + (cl-letf (((symbol-function 'locate-library) + (lambda (_) "C:/ghostel/ghostel.el")) + ((symbol-function 'file-exists-p) + (lambda (_) nil)) + ((symbol-function 'read-string) + (lambda (&rest _) "0.7.0"))) + (should-error (ghostel-download-module '(4)) + :type 'user-error))))) (ert-deftest ghostel-test-compile-module-invokes-zig-build () "Source compilation runs zig build directly." @@ -2787,9 +2896,14 @@ while :; do sleep 0.1; done'\n") ghostel-test-project-universal-arg ghostel-test-copy-all ghostel-test-copy-mode-buffer-navigation - ghostel-test-package-version ghostel-test-compile-module-invokes-zig-build ghostel-test-module-compile-command-uses-zig-build + ghostel-test-module-download-url-uses-requested-version + ghostel-test-module-download-url-uses-latest-release + ghostel-test-download-module-defaults-to-minimum-version + ghostel-test-download-module-prefix-uses-requested-version + ghostel-test-download-module-prefix-empty-uses-latest + ghostel-test-download-module-prefix-rejects-too-old-version ghostel-test-module-version-match ghostel-test-module-version-mismatch ghostel-test-module-version-newer-than-minimum