From e9d66de1284a4d9b09a4c9eb33eb031ea1f180d5 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 18 May 2026 10:28:29 +0800 Subject: [PATCH] fix(electron): install CLI launcher in local bin (#12664) * fix(electron): install CLI launcher in local bin * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/electron/electron/cli_install.cljs | 83 +++++++++++++++++++ src/electron/electron/core.cljs | 102 +++++++---------------- src/resources/dicts/en.edn | 2 + src/resources/dicts/zh-cn.edn | 2 + src/test/electron/cli_install_test.cljs | 105 ++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 73 deletions(-) create mode 100644 src/electron/electron/cli_install.cljs create mode 100644 src/test/electron/cli_install_test.cljs diff --git a/src/electron/electron/cli_install.cljs b/src/electron/electron/cli_install.cljs new file mode 100644 index 0000000000..617ef9cab2 --- /dev/null +++ b/src/electron/electron/cli_install.cljs @@ -0,0 +1,83 @@ +(ns electron.cli-install + (:require [clojure.string :as string])) + +(def CLI_LAUNCHER_MARKER "logseq-cli-managed") + +(defn split-path-env + [path-env windows?] + (let [separator (if windows? ";" ":")] + (->> (string/split (or path-env "") (re-pattern separator)) + (remove string/blank?) + distinct))) + +(defn preferred-unix-cli-dir + [{:keys [home-dir path-join ensure-dir! writable-dir?]}] + (let [user-bin (path-join home-dir ".local" "bin")] + (ensure-dir! user-bin) + (when (writable-dir? user-bin) + user-bin))) + +(defn- render-unix-cli-launcher + [exe-path cli-path] + (str "#!/usr/bin/env sh\n" + "# " CLI_LAUNCHER_MARKER "\n" + "set -eu\n" + "ELECTRON_RUN_AS_NODE=1 exec \"" exe-path "\" \"" cli-path "\" \"$@\"\n")) + +(defn- render-win-cli-launcher + [exe-path cli-path] + (str "@echo off\r\n" + "REM " CLI_LAUNCHER_MARKER "\r\n" + "set ELECTRON_RUN_AS_NODE=1\r\n" + "\"" exe-path "\" \"" cli-path "\" %*\r\n")) + +(defn- write-cli-launcher! + [{:keys [target-path content windows? exists? read-file! write-file! chmod!]}] + (let [should-write? (if (exists? target-path) + (let [existing (read-file! target-path)] + (and (string/includes? existing CLI_LAUNCHER_MARKER) + (not= existing content))) + true)] + (when should-write? + (write-file! target-path content) + (when-not windows? + (chmod! target-path "755")) + true))) + +(defn- error-message + [error] + (or (some-> error .-message) + (str error))) + +(defn install-cli-launcher! + [{:keys [windows? cli-path cli-dir cli-dir! exe-path path-join exists? show-message-box! + show-error-box! t log-info! log-warn!] + :as deps}] + (try + (let [cli-dir (if cli-dir! (cli-dir!) cli-dir)] + (cond + (not (exists? cli-path)) + (throw (js/Error. (str "Missing CLI script at " cli-path))) + + (nil? cli-dir) + (throw (js/Error. + (if windows? + "No CLI install directory found. The configured install directory could not be selected or is not writable." + "No CLI install directory found. Expected to install the launcher in ~/.local/bin, but that directory could not be created or is not writable."))) + + :else + (let [target-path (path-join cli-dir (if windows? "logseq.cmd" "logseq")) + content (if windows? + (render-win-cli-launcher exe-path cli-path) + (render-unix-cli-launcher exe-path cli-path)) + display-dir (if windows? cli-dir "~/.local/bin")] + (when (write-cli-launcher! (assoc deps + :target-path target-path + :content content)) + (log-info! :cli/install (str "Installed launcher at " target-path)) + (show-message-box! {:title "Logseq" + :message (t :electron/cli-installed display-dir)}))))) + (catch :default error + (let [message (error-message error)] + (log-warn! :cli/install "Failed to install logseq launcher" error) + (show-error-box! "Logseq" (t :electron/cli-install-failed message)))))) diff --git a/src/electron/electron/core.cljs b/src/electron/electron/core.cljs index 75070ec981..945898a63b 100644 --- a/src/electron/electron/core.cljs +++ b/src/electron/electron/core.cljs @@ -7,6 +7,7 @@ ["path" :as node-path] [cljs-bean.core :as bean] [clojure.string :as string] + [electron.cli-install :as cli-install] [electron.db :as db] [electron.exceptions :as exceptions] [electron.handler :as handler] @@ -35,7 +36,6 @@ (defonce *setup-fn (volatile! nil)) (defonce *teardown-fn (volatile! nil)) (defonce *quit-dirty? (volatile! true)) -(defonce CLI_LAUNCHER_MARKER "logseq-cli-managed") (defn setup-updater! [^js win] ;; manual/auto updater @@ -276,16 +276,6 @@ (fn [error] (logger/warn :electron/wrong-release-warning-failed error)))))) -(defn- path-separator - [] - (if utils/win32? ";" ":")) - -(defn- split-path-env - [path-env] - (->> (string/split (or path-env "") (re-pattern (path-separator))) - (remove string/blank?) - distinct)) - (defn- writable-dir? [dir] (try @@ -297,29 +287,27 @@ (catch :default _ false))) -(defn- find-first-writable-dir - [dirs] - (some #(when (writable-dir? %) %) dirs)) - (defn- ensure-dir! [dir] (when (and (string? dir) (not (fs/existsSync dir))) (fs/mkdirSync dir #js {:recursive true}))) +(defn- path-join + [& paths] + (apply node-path/join paths)) + (defn- preferred-unix-cli-dir [] - (let [path-dirs (split-path-env (.-PATH js/process.env)) - user-bin (node-path/join (.homedir os) ".local" "bin")] - (or (find-first-writable-dir path-dirs) - (do - (ensure-dir! user-bin) - (when (writable-dir? user-bin) - user-bin))))) + (cli-install/preferred-unix-cli-dir + {:home-dir (.homedir os) + :path-join path-join + :ensure-dir! ensure-dir! + :writable-dir? writable-dir?})) (defn- preferred-win-cli-dir [] (let [path-env (or (.-PATH js/process.env) (.-Path js/process.env)) - path-dirs (split-path-env path-env) + path-dirs (cli-install/split-path-env path-env utils/win32?) local-appdata (.-LOCALAPPDATA js/process.env) windows-apps-dir (when local-appdata (node-path/join local-appdata "Microsoft" "WindowsApps"))] @@ -327,7 +315,7 @@ (ensure-dir! windows-apps-dir) (when (writable-dir? windows-apps-dir) windows-apps-dir)) - (find-first-writable-dir path-dirs)))) + (some #(when (writable-dir? %) %) path-dirs)))) (defn- cli-script-path [] @@ -335,59 +323,27 @@ (node-path/join js/process.resourcesPath "app.asar" "js" "logseq-cli.js") (node-path/join js/__dirname "logseq-cli.js"))) -(defn- render-unix-cli-launcher - [exe-path cli-path] - (str "#!/usr/bin/env sh\n" - "# " CLI_LAUNCHER_MARKER "\n" - "set -eu\n" - "ELECTRON_RUN_AS_NODE=1 exec \"" exe-path "\" \"" cli-path "\" \"$@\"\n")) - -(defn- render-win-cli-launcher - [exe-path cli-path] - (str "@echo off\r\n" - "REM " CLI_LAUNCHER_MARKER "\r\n" - "set ELECTRON_RUN_AS_NODE=1\r\n" - "\"" exe-path "\" \"" cli-path "\" %*\r\n")) - -(defn- write-cli-launcher! - [path content windows?] - (let [should-write? (if (fs/existsSync path) - (let [existing (.readFileSync fs path "utf8")] - (and (string/includes? existing CLI_LAUNCHER_MARKER) - (not= existing content))) - true)] - (when should-write? - (.writeFileSync fs path content "utf8") - (when-not windows? - (fs/chmodSync path "755")) - true))) - (defn- install-cli-launcher! [] - (try - (let [cli-path (cli-script-path) - cli-dir (if utils/win32? + (let [cli-path (cli-script-path) + cli-dir! #(if utils/win32? (preferred-win-cli-dir) (preferred-unix-cli-dir))] - (cond - (not (fs/existsSync cli-path)) - (logger/warn :cli/install (str "Missing CLI script at " cli-path ", skip installing launcher")) - - (nil? cli-dir) - (logger/warn :cli/install "No writable PATH directory found; skip installing logseq launcher") - - :else - (let [target-path (if utils/win32? - (node-path/join cli-dir "logseq.cmd") - (node-path/join cli-dir "logseq")) - exe-path (.getPath app "exe") - content (if utils/win32? - (render-win-cli-launcher exe-path cli-path) - (render-unix-cli-launcher exe-path cli-path))] - (when (write-cli-launcher! target-path content utils/win32?) - (logger/info :cli/install (str "Installed launcher at " target-path)))))) - (catch :default e - (logger/warn :cli/install "Failed to install logseq launcher" e)))) + (cli-install/install-cli-launcher! + {:windows? utils/win32? + :cli-path cli-path + :cli-dir! cli-dir! + :exe-path (.getPath app "exe") + :path-join path-join + :exists? #(fs/existsSync %) + :read-file! #(.readFileSync fs % "utf8") + :write-file! #(.writeFileSync fs %1 %2 "utf8") + :chmod! #(fs/chmodSync %1 %2) + :show-message-box! #(.showMessageBox dialog (clj->js %)) + :show-error-box! #(.showErrorBox dialog %1 %2) + :t t + :log-info! logger/info + :log-warn! logger/warn}))) (defn- on-app-ready! [^js app'] diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index 25f35018a3..46a2dae63d 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -529,6 +529,8 @@ :electron/add-to-dictionary "Add to dictionary" :electron/block-not-exist "Open link failed. Block-id `{1}` doesn't exist in the graph." :electron/cancel "Cancel" + :electron/cli-install-failed "Failed to install Logseq CLI.\n{1}" + :electron/cli-installed "Logseq CLI was installed to {1}" :electron/copy-image "Copy Image" :electron/link-open-confirm "Are you sure you want to open this link? \n{1}" :electron/link-open-failed-missing-graph "Failed to open link. Missing graph identifier after `logseq://graph/`." diff --git a/src/resources/dicts/zh-cn.edn b/src/resources/dicts/zh-cn.edn index d94601ed57..b533af8bd6 100644 --- a/src/resources/dicts/zh-cn.edn +++ b/src/resources/dicts/zh-cn.edn @@ -526,6 +526,8 @@ :electron/add-to-dictionary "添加到字典" :electron/block-not-exist "打开链接失败。块 ID `{1}` 在当前图谱中不存在。" :electron/cancel "取消" + :electron/cli-install-failed "安装 Logseq CLI 失败。\n{1}" + :electron/cli-installed "Logseq CLI 已安装到 {1}" :electron/copy-image "复制图片" :electron/link-open-confirm "确定要打开此链接吗?\n{1}" :electron/link-open-failed-missing-graph "打开链接失败。在 `logseq://graph/` 后缺少图谱标识符。" diff --git a/src/test/electron/cli_install_test.cljs b/src/test/electron/cli_install_test.cljs new file mode 100644 index 0000000000..a3e8905b55 --- /dev/null +++ b/src/test/electron/cli_install_test.cljs @@ -0,0 +1,105 @@ +(ns electron.cli-install-test + (:require [cljs.test :refer [deftest is testing]] + [clojure.string :as string] + [electron.cli-install :as cli-install])) + +(defn- path-join + [& parts] + (string/join "/" parts)) + +(defn- t + [k & args] + (case k + :electron/cli-installed (str "Logseq CLI was installed to " (first args)) + :electron/cli-install-failed (str "Failed to install Logseq CLI.\n" (first args)))) + +(deftest preferred-unix-cli-dir-prefers-local-bin + (testing "macOS/Linux CLI launcher directory is ~/.local/bin, not the first writable PATH directory" + (let [created (atom [])] + (is (= "/home/me/.local/bin" + (cli-install/preferred-unix-cli-dir + {:home-dir "/home/me" + :path-join path-join + :ensure-dir! #(swap! created conj %) + :writable-dir? (fn [dir] + (#{"first-writable-path-dir" "/home/me/.local/bin"} dir))}))) + (is (= ["/home/me/.local/bin"] @created))))) + +(defn- run-install! + [opts] + (let [writes (atom []) + chmods (atom []) + messages (atom []) + errors (atom []) + files (atom (set (:existing-files opts))) + contents (atom (:existing-contents opts))] + (cli-install/install-cli-launcher! + (merge + {:windows? false + :cli-path "/app/logseq-cli.js" + :cli-dir "/home/me/.local/bin" + :exe-path "/Applications/Logseq.app/Contents/MacOS/Logseq" + :path-join path-join + :exists? #(contains? @files %) + :read-file! #(get @contents %) + :write-file! (fn [path content] + (swap! writes conj [path content]) + (swap! files conj path)) + :chmod! #(swap! chmods conj [%1 %2]) + :show-message-box! #(swap! messages conj %) + :show-error-box! (fn [title content] + (swap! errors conj {:title title :content content})) + :t t + :log-info! (fn [& _]) + :log-warn! (fn [& _])} + opts)) + {:writes @writes + :chmods @chmods + :messages @messages + :errors @errors})) + +(deftest install-cli-launcher-shows-success-dialog + (testing "successful Unix install writes to ~/.local/bin and reports the user-facing directory" + (let [result (run-install! {:existing-files #{"/app/logseq-cli.js"}})] + (is (= "/home/me/.local/bin/logseq" (ffirst (:writes result)))) + (is (= [["/home/me/.local/bin/logseq" "755"]] (:chmods result))) + (is (= [] (:errors result))) + (is (= [{:title "Logseq" + :message "Logseq CLI was installed to ~/.local/bin"}] + (:messages result)))))) + +(deftest install-cli-launcher-keeps-windows-path + (testing "Windows keeps the existing Windows install path behavior and reports that directory" + (let [windows-dir "C:/Users/me/AppData/Local/Microsoft/WindowsApps" + result (run-install! {:windows? true + :cli-dir windows-dir + :existing-files #{"/app/logseq-cli.js"}})] + (is (= (str windows-dir "/logseq.cmd") (ffirst (:writes result)))) + (is (= [] (:chmods result))) + (is (= [] (:errors result))) + (is (= [{:title "Logseq" + :message (str "Logseq CLI was installed to " windows-dir)}] + (:messages result)))))) + +(deftest install-cli-launcher-shows-error-dialog-on-failure + (testing "installer failures are visible through an Electron error dialog" + (let [result (run-install! {:existing-files #{"/app/logseq-cli.js"} + :write-file! (fn [& _] + (throw (js/Error. "disk full")))})] + (is (= [] (:messages result))) + (is (= "Logseq" (:title (first (:errors result))))) + (is (string/includes? (:content (first (:errors result))) + "Failed to install Logseq CLI")) + (is (string/includes? (:content (first (:errors result))) + "disk full"))))) + +(deftest install-cli-launcher-shows-error-dialog-when-directory-selection-fails + (testing "directory selection failures are visible through an Electron error dialog" + (let [result (run-install! {:existing-files #{"/app/logseq-cli.js"} + :cli-dir nil + :cli-dir! (fn [] + (throw (js/Error. "permission denied")))})] + (is (= [] (:messages result))) + (is (= "Logseq" (:title (first (:errors result))))) + (is (string/includes? (:content (first (:errors result))) + "permission denied")))))