From 93c085eb78339dfd69666bbe99cfad22114c6938 Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Thu, 10 Sep 2026 11:52:08 +0200 Subject: [PATCH] Keep project plugin installs additive across config layers Preserve inherited plugins when a project adds its own, with an explicit installMode replacement option for users who need the previous behavior. Limit uninstall to global declarations so inherited plugins are not reported as removed when they remain active. --- CHANGELOG.md | 1 + docs/config.json | 15 ++- docs/config/plugins.md | 20 ++++ src/eca/config.clj | 101 ++++++++++++-------- src/eca/features/plugins.clj | 41 ++++++-- test/eca/config_test.clj | 147 +++++++++++++++++++++++++++++ test/eca/features/plugins_test.clj | 108 ++++++++++++++++----- 7 files changed, 359 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daf902403..bde4b5679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Recover Anthropic streaming responses interrupted by transient TLS `bad_record_mac` failures. +- BREAKING: `plugins.install` now appends across config layers. Set `plugins.installMode` to `replace` beside the list to exclude inherited plugins as before. ## 0.159.0 diff --git a/docs/config.json b/docs/config.json index e4611b8e2..10d286445 100644 --- a/docs/config.json +++ b/docs/config.json @@ -408,8 +408,8 @@ }, "plugins": { "type": "object", - "description": "Plugin system for loading external configuration from git repos or local paths. Each key (except 'install') is a named plugin source with a 'source' URL or path. 'install' lists plugin names to install from any registered source.", - "markdownDescription": "Plugin system for loading external configuration from git repos or local paths. Each key (except `install`) is a named plugin source with a `source` URL or path. `install` lists plugin names to install from any registered source.", + "description": "Plugin system for loading external configuration from git repos or local paths. Each key except 'install' and 'installMode' is a named plugin source with a 'source' URL or path.", + "markdownDescription": "Plugin system for loading external configuration from git repos or local paths. Each key except `install` and `installMode` is a named plugin source with a `source` URL or path.", "examples": [ { "my-org": { @@ -419,10 +419,17 @@ } ], "properties": { + "installMode": { + "type": "string", + "enum": ["append", "replace"], + "default": "append", + "description": "Use 'replace' so this file's 'install' list stands alone instead of combining with lists from other config sources. Applies only within the same file.", + "markdownDescription": "Use `replace` so this file's `install` list stands alone instead of combining with lists from other config sources. Applies only within the same file." + }, "install": { "type": "array", - "description": "List of plugin names to install from registered sources.", - "markdownDescription": "List of plugin names to install from registered sources.", + "description": "List of plugins to install: 'name' or 'name@source'. Lists from all config sources are combined, not replaced.", + "markdownDescription": "List of plugins to install: `name` or `name@source`. Lists from all config sources are combined, not replaced.", "items": { "type": "string" } diff --git a/docs/config/plugins.md b/docs/config/plugins.md index cf15c0631..4988cc71f 100644 --- a/docs/config/plugins.md +++ b/docs/config/plugins.md @@ -41,6 +41,18 @@ flowchart TD 4. ECA matches `install` names against the marketplace, expands their declared [**dependencies**](#plugin-dependencies) transitively, then **discovers components** from each resolved plugin directory. 5. All components are **merged** into the config waterfall, in the order specified by the `install` key (later plugins override earlier plugins) — user config always takes precedence on conflicts. +## Install lists from multiple config sources + +Normally, an array from a higher-priority [config source](introduction.md#merge-order) replaces the lower-priority one. Install lists are the exception: they combine. Your global config can hold your personal plugins while a project's `.eca/config.json` adds its own; an empty list `[]` simply adds nothing. + +To make a source ignore what the others install, add `"installMode": "replace"` next to its `install` list: + +```javascript title=".eca/config.json" +{ + "plugins": { "installMode": "replace", "install": ["my-plugin"] } +} +``` + ## Commands ### `/plugins` @@ -64,6 +76,14 @@ Use `` to disambiguate when multiple sources provide a If the plugin declares [dependencies](#plugin-dependencies), they are resolved and loaded automatically on startup — no need to install each one individually. +### `/plugin-uninstall` + +``` +/plugin-uninstall +``` + +Removes the plugin from the `install` list in your global config file. If it was installed by another config source, edit that source instead. Plugins that other installed plugins [depend on](#plugin-dependencies) stay loaded. Restart ECA to apply. + ## Pointing to a plugin source / marketplace The official [plugins.eca.dev](https://plugins.eca.dev) marketplace is always available as the built-in `"eca"` source. To install plugins from it, just add their names to `install` — no source configuration needed. diff --git a/src/eca/config.clj b/src/eca/config.clj index f9d7b26c0..3d585a783 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -463,18 +463,28 @@ (some-> (safe-read-json-string (slurp config-file) (var *global-config-error*)) (parse-dynamic-string-values (shared/global-config-dir)))))) -(defn ^:private config-from-local-file [roots] - (reduce - (fn [final-config {:keys [uri]}] - (merge - final-config - (let [config-dir (io/file (shared/uri->filename uri) ".eca") - config-file (io/file config-dir "config.json")] - (when (.exists config-file) - (some-> (safe-read-json-string (slurp config-file) (var *local-config-error*)) - (parse-dynamic-string-values config-dir)))))) - {} - roots)) +(declare merge-config) + +(defn ^:private merge-config-layers + "Merges file config layers into config. Non-plugin keys stay aggregated + across layers with aggregate-merge (historical per-source behavior: shallow + for workspace roots, deep for extraConfigs), then each layer's plugins merge + one by one so install lists combine across layers." + [aggregate-merge config layers] + (reduce merge-config + config + (cons (reduce aggregate-merge {} (map #(dissoc % "plugins") layers)) + (map #(select-keys % ["plugins"]) layers)))) + +(defn ^:private config-from-local-file [roots config] + (let [layers (mapv (fn [{:keys [uri]}] + (let [config-dir (io/file (shared/uri->filename uri) ".eca") + config-file (io/file config-dir "config.json")] + (when (.exists config-file) + (some-> (safe-read-json-string (slurp config-file) (var *local-config-error*)) + (parse-dynamic-string-values config-dir))))) + roots)] + (merge-config-layers merge config layers))) (def initialization-config* (atom {})) @@ -517,24 +527,19 @@ listed order (later entries win). Missing paths are logged and skipped; parse errors are logged, surfaced via `*extra-config-error*` and skipped. Non-recursive: an `:extraConfigs` declared inside an extra file is ignored." - [paths roots] + [paths roots config] (let [paths (cond (string? paths) [paths] (sequential? paths) paths - :else [])] - (reduce - (fn [final-config path] - (let [^File config-file (resolve-extra-config-file path roots)] - (if (.exists config-file) - (deep-merge final-config - (or (some-> (safe-read-json-string (slurp config-file) (var *extra-config-error*)) + :else []) + layers (mapv (fn [path] + (let [^File config-file (resolve-extra-config-file path roots)] + (if (.exists config-file) + (some-> (safe-read-json-string (slurp config-file) (var *extra-config-error*)) (parse-dynamic-string-values (fs/file (fs/parent config-file)))) - {})) - (do - (logger/warn logger-tag (format "extraConfigs path not found, skipping: %s" (.getPath config-file))) - final-config)))) - {} - paths))) + (logger/warn logger-tag (format "extraConfigs path not found, skipping: %s" (.getPath config-file)))))) + paths)] + (merge-config-layers deep-merge config layers))) (defn ^:private resolve-agent-inheritance "Resolves :inherit keys in agent configs. When an agent has :inherit \"other\", @@ -703,11 +708,26 @@ (-> (assoc-in [:chat :defaultAgent] (migrate-legacy-agent-name (get-in config [:chat :defaultBehavior]))) (update :chat dissoc :defaultBehavior)))) +(defn ^:private merge-config + "Deep-merges a normalized config layer into config. The plugins install + lists combine across layers: a layer's entries append unless its own + installMode is \"replace\"." + [config layer] + (let [layer (normalize-fields normalization-rules layer) + plugins (:plugins layer) + {:strs [install installMode]} plugins + merged (deep-merge config layer)] + (if (contains? plugins "install") + (assoc-in merged [:plugins "install"] + (->> (concat (when-not (= "replace" installMode) + (get-in config [:plugins "install"])) + install) + reverse distinct reverse vec)) + merged))) + (defn ^:private all* [db] (let [initialization-config @initialization-config* pure-config? (:pureConfig initialization-config) - merge-config (fn [c1 c2] - (deep-merge c1 (normalize-fields normalization-rules c2))) plugin-data (when-not pure-config? @plugin-components*) plugin-config (when plugin-data (let [cfg (:config-fragment plugin-data)] @@ -723,13 +743,14 @@ (config-from-envvar))) (if-let [custom-config (config-from-custom)] (merge-config $ (when-not pure-config? custom-config)) - (-> $ - (merge-config (when-not pure-config? (config-from-global-file))) - (merge-config (when-not pure-config? (config-from-local-file (:workspace-folders db)))))) + (let [config (merge-config $ (when-not pure-config? (config-from-global-file)))] + (if pure-config? + config + (config-from-local-file (:workspace-folders db) config)))) ;; Plugin config merges after all file configs (user local config wins via later merge) (merge-config $ plugin-config) ;; extraConfigs merge last, overriding all previous sources - (merge-config $ (config-from-extra-configs (:extraConfigs $) (:workspace-folders db)))) + (config-from-extra-configs (:extraConfigs $) (:workspace-folders db) $)) ;; Append plugin commands/rules (vector concat, not deep-merge replace) (cond-> (seq plugin-commands) (update :commands #(vec (concat % plugin-commands))) @@ -773,14 +794,12 @@ needed before the server is fully initialized (e.g. network/TLS settings)." [] - (let [merge-config (fn [c1 c2] - (deep-merge c1 (normalize-fields normalization-rules c2)))] - (-> {} - (merge-config (initial-config)) - (merge-config (config-from-envvar)) - (merge-config (if (some? @custom-config-file-path*) - (config-from-custom) - (config-from-global-file)))))) + (-> {} + (merge-config (initial-config)) + (merge-config (config-from-envvar)) + (merge-config (if (some? @custom-config-file-path*) + (config-from-custom) + (config-from-global-file))))) (defn validation-error [] (cond @@ -994,4 +1013,4 @@ root (rj/assoc-in root ["$schema"] config-schema-url)] (io/make-parents file) (spit file (rj/to-string root)) - (clear-cache!))) + (clear-cache!))) \ No newline at end of file diff --git a/src/eca/features/plugins.clj b/src/eca/features/plugins.clj index eef5149dd..36872d351 100644 --- a/src/eca/features/plugins.clj +++ b/src/eca/features/plugins.clj @@ -11,6 +11,7 @@ [babashka.fs :as fs] [babashka.process :as p] [cheshire.core :as json] + [cheshire.factory :as json.factory] [clojure.java.io :as io] [clojure.string :as string] [eca.cache :as cache] @@ -375,11 +376,11 @@ components-list)) (defn ^:private parse-sources - "Extracts plugin sources from config, filtering out the install key. + "Extracts plugin sources from config, filtering out reserved install keys. Returns a seq of [source-name source-url] pairs." [plugins-config] (->> plugins-config - (remove (fn [[k _]] (= "install" (name k)))) + (remove (comp #{"install" "installMode"} name key)) (keep (fn [[source-name source-config]] (when-let [source-url (if (map? source-config) (get source-config :source) @@ -556,14 +557,38 @@ (str "Plugin `" plugin-name "` not found in any configured marketplace."))})))) (defn uninstall-plugin! - "Uninstalls a plugin by removing it from the global config install list. + "Removes an exact plugin reference from the global config install list only. Returns {:status :ok/:error, :message ...}." [plugins-config ^String plugin-name] - (let [current-install (set (get plugins-config "install" []))] - (if (contains? current-install plugin-name) - (let [new-install (vec (sort (disj current-install plugin-name)))] - (config/update-global-config! {:plugins {:install new-install}}) + (let [file (config/global-config-file) + global-config (when (.exists file) + (try + (binding [json.factory/*json-factory* (json.factory/make-json-factory + {:allow-comments true})] + (json/parse-string (slurp file))) + (catch Exception e + (logger/warn logger-tag "Error reading global config file:" (ex-message e)) + ::unreadable))) + global-install (get-in global-config ["plugins" "install"])] + (cond + (= ::unreadable global-config) + {:status :error + :message (str "Could not read the global config file at `" file "`. " + "Fix the JSON error, then retry.")} + + (some #{plugin-name} global-install) + (do + (config/update-global-config! + {:plugins {:install (filterv #(not= plugin-name %) global-install)}}) {:status :ok - :message (str "Plugin `" plugin-name "` uninstalled. Restart ECA to apply.")}) + :message (str "Global install entry for plugin `" plugin-name "` removed. " + "Other config sources can still install it; remove the entry there too. Restart ECA to apply.")}) + + (some #{plugin-name} (get plugins-config "install")) + {:status :error + :message (str "Plugin `" plugin-name "` has no global install entry. " + "Remove it from plugins.install in its source config (for example, ECA_CONFIG, initialization options, or project config).")} + + :else {:status :error :message (str "Plugin `" plugin-name "` is not installed.")}))) diff --git a/test/eca/config_test.clj b/test/eca/config_test.clj index ad72fe12a..db92898ae 100644 --- a/test/eca/config_test.clj +++ b/test/eca/config_test.clj @@ -115,6 +115,153 @@ (is (= "from-rel" (:defaultAgent (#'config/all* {:workspace-folders [{:uri (shared/filename->uri (str dir))}]}))))))) +(defn ^:private plugin-config-flow + [{:keys [global roots init env custom extras pure?]}] + (let [dir (fs/create-temp-dir)] + (try + (let [write-config! (fn [path config] + (io/make-parents path) + (spit path (json/generate-string config)) + path) + global-file (write-config! (fs/file dir "global.json") global) + custom-file (when custom (write-config! (fs/file dir "custom.json") custom)) + folders (mapv (fn [i layer] + (let [root (fs/file dir (str "root-" i))] + (write-config! (fs/file root ".eca" "config.json") layer) + {:uri (shared/filename->uri (str root))})) + (range) roots) + extra-files (mapv (fn [i layer] + (str (write-config! (fs/file dir (str "extra-" i ".json")) layer))) + (range) extras)] + (with-redefs-fn {#'config/initialization-config* (atom (cond-> (or init {}) + pure? (assoc :pureConfig true) + (seq extras) (assoc :extraConfigs extra-files))) + #'config/custom-config-file-path* (atom (some-> custom-file str)) + #'config/plugin-components* (atom nil) + #'config/global-config-file (constantly global-file) + #'config/config-from-envvar (constantly env) + ;; Read the actual custom file without its separate TTL cache. + #'config/config-from-custom #'config/config-from-custom*} + (fn [] + (config/clear-cache!) + {:all (config/all {:workspace-folders folders}) + :files (config/read-file-configs)}))) + (finally + (config/clear-cache!) + (fs/delete-tree dir))))) + +(deftest plugin-install-config-flow-test + (let [install #(get-in % [:all :plugins "install"]) + p (fn [refs] {:plugins {:install refs}})] + (testing "global and multiple roots append; exact duplicates move to their last position" + (let [result (:all (plugin-config-flow + {:global {:plugins {:company {:source "https://example.com/company.git"} + :install ["a" "b" "a" "same@company"]} + :disabledTools ["global"] + :chat {:global true}} + :roots [{:plugins {:first {:source "https://example.com/first.git"} + :install ["c" "a"]} + :disabledTools ["first"] + :chat {:first true}} + {:plugins {:install ["b" "same" "same@other" "d" "d"]} + :disabledTools ["last"] + :chat {:last true}}]}))] + (is (= ["same@company" "c" "a" "b" "same" "same@other" "d"] + (get-in result [:plugins "install"]))) + (is (= "https://example.com/company.git" (get-in result [:plugins "company" :source]))) + (is (= "https://example.com/first.git" (get-in result [:plugins "first" :source]))) + (is (some? (get-in result [:plugins "eca" :source]))) + (is (= ["last"] (:disabledTools result))) + (is (true? (get-in result [:chat :global]))) + (is (true? (get-in result [:chat :last]))) + (is (nil? (get-in result [:chat :first]))))) + (testing "empty append retains inherited plugins" + (is (= ["global"] (install (plugin-config-flow {:global (p ["global"]) + :roots [(p []) (p [])]}))))) + (testing "replace with empty or populated list resets earlier layers, not sources" + (doseq [refs [[] ["new" "new"]]] + (let [result (:all (plugin-config-flow + {:global {:plugins {:company {:source "https://example.com/company.git"} + :install ["global"]}} + :roots [(assoc-in (p refs) [:plugins :installMode] "replace")]}))] + (is (= (vec (distinct refs)) (get-in result [:plugins "install"]))) + (is (some? (get-in result [:plugins "company" :source])))))) + (testing "a reset survives aggregation, but its mode does not apply to later roots" + (doseq [refs [[] ["reset"]]] + (is (= (conj refs "later") + (install (plugin-config-flow + {:global (p ["global"]) + :roots [(assoc-in (p refs) [:plugins :installMode] "replace") + (p ["later"])]})))))) + (testing "mode without an install list does not reset or stick" + (is (= ["global" "later"] (install (plugin-config-flow + {:global (p ["global"]) + :roots [{:plugins {:installMode "replace"}} (p ["later"])]}))))) + (testing "existing order is initial config, init, env, global, roots, then extras" + (is (= ["init" "env" "global" "root" "extra-1" "extra-2"] + (install (plugin-config-flow {:init (p ["init"]) + :env {"plugins" {"install" ["env"]}} + :global (p ["global"]) + :roots [(p ["root"])] + :extras [(p ["extra-1"]) (p ["extra-2"])]}))))) + (testing "extra resets apply to all earlier layers and later extras append" + (doseq [refs [[] ["reset"]]] + (is (= (conj refs "later") + (install (plugin-config-flow + {:init (p ["init"]) + :global (p ["global"]) + :roots [(p ["root"])] + :extras [(assoc-in (p refs) [:plugins :installMode] "replace") + (p ["later"])]}))))) + (is (= [] (install (plugin-config-flow + {:global (p ["global"]) + :extras [(assoc-in (p []) [:plugins :installMode] "replace")]}))))) + (testing "custom file selection and layer-local resets, including early file config" + (doseq [[layers expected files] + [[{:env (p ["env"]) :custom (p ["custom"]) :extras [(p ["extra"])]} + ["init" "env" "custom" "extra"] ["env" "custom"]] + [{:env {"plugins" {"installMode" "replace" "install" ["env"]}}} + ["env" "global" "root"] ["env" "global"]] + [{:env (p ["env"]) + :custom (assoc-in (p []) [:plugins :installMode] "replace") + :extras [(p ["extra"])]} + ["extra"] []]]] + (let [result (plugin-config-flow + (merge {:init (p ["init"]) :global (p ["global"]) :roots [(p ["root"])]} + layers))] + (is (= expected (install result))) + (is (= files (get-in result [:files :plugins "install"])))))) + (testing "explicit append after replace, with unrelated extra config behavior unchanged" + (let [hook {:type "command" :command "echo extra"} + result (:all (plugin-config-flow + {:global {:plugins {:install ["global"]} + :chat {:retained true} + :hooks {:preToolCall [{:type "command" :command "echo global"}]} + :agent {"parent" {:disabledTools ["parent"]} + "child" {:inherit "parent" :disabledTools ["child"]}}} + :extras [{:plugins {:installMode "replace" :install ["reset"]} + :chat false + :disabledTools ["first"]} + {:plugins {:installMode "append" :install ["extra"]} + :chat {:added true} + :disabledTools ["last"] + :hooks {:preToolCall [hook]}}]}))] + (is (= ["reset" "extra"] (get-in result [:plugins "install"]))) + ;; Extras were already combined before merging with the global map. + (is (true? (get-in result [:chat :retained]))) + (is (true? (get-in result [:chat :added]))) + (is (= ["last"] (:disabledTools result))) + (is (= [hook] (get-in result [:hooks :preToolCall]))) + (is (= ["child"] (get-in result [:agent "child" :disabledTools]))))) + (testing "pureConfig still skips env and file layers, but applies extraConfigs" + (is (= ["init" "extra"] (install (plugin-config-flow + {:pure? true + :init (assoc-in (p ["init"]) [:plugins :installMode] "replace") + :env (p ["env"]) + :global (p ["global"]) + :roots [(p ["root"])] + :extras [(p ["extra"])]}))))))) + (deftest deep-merge-test (testing "basic merge" (is (match? diff --git a/test/eca/features/plugins_test.clj b/test/eca/features/plugins_test.clj index 4227b15fb..748921b0d 100644 --- a/test/eca/features/plugins_test.clj +++ b/test/eca/features/plugins_test.clj @@ -9,6 +9,7 @@ [eca.features.plugins :as plugins] [eca.features.rules :as rules] [eca.interpolation :as interpolation] + [eca.shared :as shared] [matcher-combinators.matchers :as m] [matcher-combinators.test :refer [match?]])) @@ -20,6 +21,13 @@ (finally (interpolation/reset-plugin-dirs!))))) +(deftest reserved-install-mode-test + (is (= [["company" "https://example.com/company.git"]] + (#'plugins/parse-sources + {"company" {:source "https://example.com/company.git"} + "install" {:source "not-a-source"} + "installMode" {:source "not-a-source"}})))) + (deftest sanitize-source-url-test (testing "HTTPS URL" (is (= "github.com-my-org-my-plugins" @@ -508,24 +516,82 @@ (fs/delete-tree tmp-dir))))) (deftest uninstall-plugin!-test - (testing "removes plugin from install list" - (let [updated (atom nil)] - (with-redefs [config/update-global-config! (fn [c] (reset! updated c))] - (let [result (plugins/uninstall-plugin! - {"install" ["alpha" "beta" "gamma"]} - "beta")] - (is (= :ok (:status result))) - (is (= ["alpha" "gamma"] (get-in @updated [:plugins :install]))))))) - - (testing "returns error when plugin is not installed" - (let [result (plugins/uninstall-plugin! - {"install" ["alpha"]} - "beta")] - (is (= :error (:status result))) - (is (re-find #"not installed" (:message result))))) - - (testing "returns error when install list is empty" - (let [result (plugins/uninstall-plugin! - {"install" []} - "beta")] - (is (= :error (:status result)))))) + (doseq [[label source global-install expected-install status] + [["environment only" :env [] ["alpha" "inherited"] :error] + ["initialization only" :init [] ["alpha" "inherited"] :error] + ["global only" nil ["zeta" "alpha" "beta" "alpha@company"] + ["zeta" "beta" "alpha@company"] :ok] + ["global and project" :project ["alpha"] ["alpha" "inherited"] :ok] + ["not installed" nil ["beta"] ["beta"] :error]]] + (testing label + (let [dir (fs/create-temp-dir) + global-file (fs/file dir "config.json") + project-dir (fs/file dir "project") + project-config (fs/file project-dir ".eca" "config.json") + inherited {"plugins" {"install" ["alpha" "inherited"]}} + raw (str "{\n// Keep this comment\n\"plugins\": " + (json/generate-string {"install" global-install + "installMode" "append" + "company" {"source" "unchanged"}}) + ", \"unrelated\": true\n}") + writes (atom []) + update-global! config/update-global-config!] + (try + (fs/create-dirs (fs/parent project-config)) + (spit global-file raw) + (spit project-config (json/generate-string (if (= :project source) inherited {}))) + (with-redefs [config/initialization-config* (atom (if (= :init source) inherited {})) + config/plugin-components* (atom nil) + shared/global-config-dir (constantly (str dir)) + config/update-global-config! (fn [c] + (swap! writes conj c) + (update-global! c))] + (with-redefs-fn {#'config/config-from-envvar (constantly (when (= :env source) inherited)) + #'config/config-from-custom (constantly nil)} + (fn [] + (let [db {:workspace-folders [{:uri (str (.toURI project-dir))}]} + before (:plugins (#'config/all* db)) + result (plugins/uninstall-plugin! before "alpha") + after (:plugins (#'config/all* db))] + (is (= status (:status result))) + (is (= expected-install (get after "install"))) + (is (= "append" (get after "installMode"))) + (is (= {:source "unchanged"} (get after "company"))) + (is (true? (:unrelated (#'config/all* db)))) + (is (string/includes? (slurp global-file) "// Keep this comment")) + (if (= :ok status) + (do + (is (= [{:plugins {:install (filterv #(not= "alpha" %) global-install)}}] + @writes)) + (is (string/includes? (:message result) "Global install entry")) + (is (string/includes? (:message result) "Other config sources can still install it"))) + (do + (is (= [] @writes)) + (is (= raw (slurp global-file))) + (is (= before after)) + (if source + (do + (is (string/includes? (:message result) "no global install entry")) + (is (string/includes? (:message result) "source config"))) + (is (string/includes? (:message result) "not installed"))))))))) + (finally + (config/clear-cache!) + (fs/delete-tree dir))))))) + +(deftest uninstall-plugin!-invalid-global-config-test + (testing "returns an error without writing when the global config file is invalid" + (let [dir (fs/create-temp-dir) + global-file (fs/file dir "config.json") + raw "{ invalid json" + writes (atom [])] + (try + (spit global-file raw) + (with-redefs [shared/global-config-dir (constantly (str dir)) + config/update-global-config! (fn [c] (swap! writes conj c))] + (let [result (plugins/uninstall-plugin! {"install" ["alpha"]} "alpha")] + (is (= :error (:status result))) + (is (string/includes? (:message result) "Could not read the global config file")) + (is (= [] @writes)) + (is (= raw (slurp global-file))))) + (finally + (fs/delete-tree dir))))))