diff --git a/libs/kit-generator/.dir-locals.el b/libs/kit-generator/.dir-locals.el new file mode 100644 index 00000000..ce256cfc --- /dev/null +++ b/libs/kit-generator/.dir-locals.el @@ -0,0 +1,5 @@ +;;; Directory Local Variables -*- no-byte-compile: t -*- +;;; For more information see (info "(emacs) Directory Variables") + +((nil . ((cider-jack-in-cmd . "clojure -M:dev:cider") + (cider-repl-init-code . ("(ns user)"))))) diff --git a/libs/kit-generator/deps.edn b/libs/kit-generator/deps.edn index b8b9123b..91a4ace9 100644 --- a/libs/kit-generator/deps.edn +++ b/libs/kit-generator/deps.edn @@ -10,17 +10,21 @@ mvxcvi/cljstyle {:mvn/version "0.17.642"} cljfmt/cljfmt {:mvn/version "0.9.2"} clj-fuzzy/clj-fuzzy {:mvn/version "0.4.1"} - clojure-deep-merge/clojure-deep-merge {:mvn/version "0.1.2"}} - :aliases {:cider + clojure-deep-merge/clojure-deep-merge {:mvn/version "0.1.2"} + babashka/process {:mvn/version "0.6.23"} + com.stuartsierra/dependency {:mvn/version "1.0.0"}} + :aliases {:dev + {:extra-paths ["test"] + :extra-deps {nubank/matcher-combinators {:mvn/version "3.9.2"}}} + :cider {:extra-deps {nrepl/nrepl {:mvn/version "1.5.1"} cider/cider-nrepl {:mvn/version "0.58.0"}} :main-opts ["-m" "nrepl.cmdline" "--middleware" "[cider.nrepl/cider-middleware]" "-i"]} :test - {:extra-paths ["test"] - :extra-deps {io.github.cognitect-labs/test-runner - {:git/tag "v0.5.1" :git/sha "dfb30dd"} - babashka/fs {:mvn/version "0.5.27"} - babashka/process {:mvn/version "0.6.23"}} - :main-opts ["-m" "cognitect.test-runner"] - :exec-fn cognitect.test-runner.api/test}}} + {:extra-deps {io.github.cognitect-labs/test-runner + {:git/tag "v0.5.1" :git/sha "dfb30dd"} + nubank/matcher-combinators {:mvn/version "3.9.2"} + babashka/fs {:mvn/version "0.5.27"}} + :main-opts ["-m" "cognitect.test-runner"] + :exec-fn cognitect.test-runner.api/test}}} diff --git a/libs/kit-generator/src/kit/api.clj b/libs/kit-generator/src/kit/api.clj index 9d3c3fb9..b63c5a68 100644 --- a/libs/kit-generator/src/kit/api.clj +++ b/libs/kit-generator/src/kit/api.clj @@ -1,23 +1,29 @@ (ns kit.api + "Public API for kit-generator." (:require [clojure.string :as str] + [clojure.test :as t] + [kit.generator.hooks :as hooks] [kit.generator.io :as io] [kit.generator.modules :as modules] + [kit.generator.modules-log :refer [track-installation installed-modules + module-installed?]] [kit.generator.modules.dependencies :as deps] [kit.generator.modules.generator :as generator] - [kit.generator.snippets :as snippets] - [clojure.test :as t])) + [kit.generator.snippets :as snippets])) ;; TODO: Add docstrings (def default-edn "kit.edn") -(defn- read-ctx - [path] - (assert (not (str/blank? path))) - (-> path - (slurp) - (io/str->edn))) +(defn read-ctx + ([] + (read-ctx default-edn)) + ([path] + (assert (not (str/blank? path))) + (-> path + (slurp) + (io/str->edn)))) (defn- log-install-dependency [module-key feature-flag deps] (print "Installing module" module-key) @@ -30,36 +36,13 @@ (defn- log-missing-module [module-key] (println "ERROR: no module found with name:" module-key)) -(defn- install-dependency - "Installs a module and its dependencies recursively. Asumes ctx has loaded :modules. - Note that `opts` have a different schema than the one passed to `install-module`, - the latter being preserved for backwards compatibility. Here `opts` is a map of - module-key to module-specific options. - - For example, let's say `:html` is the main module. It would still be on the same level - as `:auth`, its dependency: - - ```clojure - {:html {:feature-flag :default} - :auth {:feature-flag :oauth}} - ``` - - See flat-module-options for more details." - [{:keys [modules] :as ctx} module-key opts] - (if (modules/module-exists? ctx module-key) - (let [{:keys [module-config]} (generator/read-module-config ctx modules module-key) - {:keys [feature-flag] :or {feature-flag :default} :as module-opts} (get opts module-key {}) - deps (deps/resolve-dependencies module-config feature-flag)] - (log-install-dependency module-key feature-flag deps) - (doseq [module-key deps] - (install-dependency ctx module-key opts)) - (generator/generate ctx module-key module-opts)) - (log-missing-module module-key)) - :done) - (defn- flat-module-options - "Converts options map passed to install-module into a flat map - of module-key to module-specific options." + "Converts options map passed to install-module into a flat map of module-key to + module-specific options. A module-specific option is an option that will be + applied to the primary module, identified by module-key, when installing that + module. For example, `:feature-flag` is a module-specific option, while + `:dry?` is not, because the latter applies to the installation process as a + whole. See the test below for examples." {:test (fn [] (t/are [opts module-key output] (flat-module-options opts module-key) {} :meta {} @@ -77,37 +60,147 @@ (defn sync-modules "Downloads modules for the current project." [] - (modules/sync-modules! (read-ctx default-edn)) + (modules/sync-modules! (read-ctx)) :done) (defn list-modules "List modules available for the current project." [] - (let [ctx (modules/load-modules (read-ctx default-edn))] + (let [ctx (modules/load-modules (read-ctx))] (modules/list-modules ctx)) :done) +(defn- report-install-module-error + [module-key e] + (println "ERROR: Failed to install module" module-key) + (.printStackTrace e)) + +(defn- report-install-module-success + [module-key {:keys [success-message require-restart?]}] + (println (or success-message + (str "module " module-key " installed successfully!"))) + (when require-restart? + (println "restart required!"))) + +(defn- report-already-installed + [installed-modules] + (doseq [{:module/keys [key]} installed-modules] + (println "WARNING: Module" key "was already installed successfully. Skipping installation."))) + +(defn installation-plan + "Loads and resolves modules in preparation for installation, as + well as determining which modules are already installed vs which + need to be installed." + [module-key kit-edn-path opts] + (let [opts (flat-module-options opts module-key) + ctx (modules/load-modules (read-ctx kit-edn-path) opts) + {installed true pending false} (->> (deps/dependency-list ctx module-key opts) + (group-by #(module-installed? ctx (:module/key %))))] + {:ctx ctx + :installed-modules installed + :pending-modules pending + :opts opts})) + +(defn print-installation-plan + "Prints a detailed installation plan for a module and its dependencies." + ([module-key] + (print-installation-plan module-key {:feature-flag :default})) + ([module-key opts] + (print-installation-plan module-key "kit.edn" opts)) + ([module-key kit-edn-path opts] + (let [{:keys [opts installed-modules pending-modules]} (installation-plan module-key kit-edn-path opts)] + (when (seq installed-modules) + (println "ALREADY INSTALLED (skipped)") + (doseq [{:module/keys [key]} installed-modules] + (println "" key)) + (println)) + + (when (seq pending-modules) + (println "INSTALLATION PLAN") + (doseq [{:module/keys [key doc] :as module} pending-modules] + (let [module-feature-flag (get-in opts [key :feature-flag] :default)] + (println "" key (if (not= :default module-feature-flag) + (str "@" (name module-feature-flag)) + "")) + (when doc + (println " " doc)) + + (doseq [description (concat (generator/describe-actions module) + (hooks/describe-hooks module))] + (println " -" description)) + (println)))) + + (println "SUMMARY") + (let [pending-count (count pending-modules) + installed-count (count installed-modules)] + (when (pos? installed-count) + (println " " installed-count "module(s) already installed (skipped)")) + (if (pos? pending-count) + (println " " pending-count "module(s) to install") + (println " " "Nothing to install!"))) + (println)))) + +(defn- prompt-y-n-all + "Prompts the user to accept actions with a yes/no/all question. + If the user answers 'all', the accept-hooks-atom is set to true + and all subsequent calls will return true without prompting." + [prompt accept-hooks-atom] + (if (nil? @accept-hooks-atom) + (let [answers ["y" "n" "all"]] + (print prompt (str " (" (str/join "/" answers) "): ")) + (loop [] + (flush) + (let [response (str/trim (str/lower-case (read-line)))] + (case response + "y" true + "n" false + "all" (reset! accept-hooks-atom true) + (do (println "\nPlease answer one of:" (str/join ", " answers)) + (recur)))))) + @accept-hooks-atom)) + +(defn- prompt-run-hooks + "Prompts the user to accept running hooks defined in a module. + See prompt-y-n-all for details." + [accept-hooks-atom hooks] + (println "The following hook actions will be performed:") + (doseq [hook hooks] + (println " $" hook)) + (prompt-y-n-all "Run the hook?" accept-hooks-atom)) + (defn install-module "Installs a kit module into the current project or the project specified by a path to kit.edn file. - > NOTE: When adding new options, update flat-module-options." + > NOTE: When adding new module-specific options, update flat-module-options. + See the function for more details." ([module-key] (install-module module-key {:feature-flag :default})) ([module-key opts] (install-module module-key "kit.edn" opts)) - ([module-key kit-edn-path opts] - (let [ctx (modules/load-modules (read-ctx kit-edn-path))] - (install-dependency ctx module-key (flat-module-options opts module-key))))) + ([module-key kit-edn-path {:keys [accept-hooks? dry?] :as opts}] + (if dry? + (print-installation-plan module-key kit-edn-path opts) + (let [{:keys [ctx pending-modules installed-modules]} (installation-plan module-key kit-edn-path opts) + accept-hooks-atom (atom accept-hooks?)] + (report-already-installed installed-modules) + (doseq [{:module/keys [key resolved-config] :as module} pending-modules] + (try + (track-installation ctx key + (generator/generate ctx module) + (hooks/run-hooks :post-install resolved-config + {:confirm (partial prompt-run-hooks accept-hooks-atom)}) + (report-install-module-success key resolved-config)) + (catch Exception e + (report-install-module-error key e)))))) + :done)) (defn list-installed-modules "Lists installed modules and modules that failed to install, for the current project." [] - (doseq [[id status] (-> (read-ctx default-edn) - :modules - :root - (generator/read-modules-log))] + (doseq [[id status] (-> (read-ctx) + (installed-modules))] (println id (if (= status :success) "installed successfully" "failed to install"))) @@ -121,25 +214,25 @@ @db)))) (defn sync-snippets [] - (let [ctx (read-ctx default-edn)] + (let [ctx (read-ctx)] (snippets/sync-snippets! ctx) (snippets-db ctx true) :done)) (defn find-snippets [query] - (snippets/print-snippets (snippets-db (read-ctx default-edn)) query) + (snippets/print-snippets (snippets-db (read-ctx)) query) :done) (defn find-snippet-ids [query] - (println (str/join ", " (map :id (snippets/match-snippets (snippets-db (read-ctx default-edn)) query)))) + (println (str/join ", " (map :id (snippets/match-snippets (snippets-db (read-ctx)) query)))) :done) (defn list-snippets [] - (println (str/join "\n" (keys (snippets-db (read-ctx default-edn))))) + (println (str/join "\n" (keys (snippets-db (read-ctx))))) :done) (defn snippet [id & args] - (snippets/gen-snippet (snippets-db (read-ctx default-edn)) id args)) + (snippets/gen-snippet (snippets-db (read-ctx)) id args)) (comment (t/run-tests 'kit.api)) diff --git a/libs/kit-generator/src/kit/generator/features.clj b/libs/kit-generator/src/kit/generator/features.clj new file mode 100644 index 00000000..6b43d659 --- /dev/null +++ b/libs/kit-generator/src/kit/generator/features.clj @@ -0,0 +1,49 @@ +(ns kit.generator.features + "Feature flag resolution based on :feature-requires." + (:require + [deep.merge :as deep-merge])) + +(defn- check-feature-not-found + "Throw exception unless the feature was defined in the config." + [module-config feature-flag] + (when-not (contains? module-config feature-flag) + (throw (ex-info (str "Feature not found: " feature-flag) + {:error ::feature-not-found + :feature-flag feature-flag})))) + +(defn resolve-module-config + "Return module config resolved using the feature flag and :feature-requires fields. + Handles cyclic dependencies by not following them." + [module-config feature-flag] + (let [full-config module-config + result (feature-flag module-config)] + (loop [result result module-config module-config] + (if-let [feature-requires (seq (:feature-requires result))] + (recur (apply deep-merge/concat-merge + (dissoc result :feature-requires) + (mapv #(or (get module-config %) + (check-feature-not-found full-config %) + {}) + feature-requires)) + (apply dissoc module-config feature-requires)) + result)))) + +(comment (resolve-module-config + {:default {:foo :bar + :actions {:assets [:assetA]} + :hooks {:post-install [":default installed"]} + :feature-requires [:base] + :requires [:1] + :success-message ":default installed"} + :base {:baz :qux + :actions {:assets [:asset1 :asset2]} + :injections [:inj1] + :hooks {:post-install [":base post install"]} + :feature-requires [:extras] + :requires [:2] + :success-message ":base installed"} + :extras {:actions {:assets [:extra-asset1]} + :feature-requires [:default]}} + :default) +; + ) diff --git a/libs/kit-generator/src/kit/generator/git.clj b/libs/kit-generator/src/kit/generator/git.clj index f498b457..d6cb0848 100644 --- a/libs/kit-generator/src/kit/generator/git.clj +++ b/libs/kit-generator/src/kit/generator/git.clj @@ -1,9 +1,11 @@ (ns kit.generator.git (:require - [clojure.string :as string] - [clj-jgit.porcelain :as git]) + [clj-jgit.porcelain :as git] + [clojure.java.io :as jio] + [clojure.string :as string] + [kit.generator.io :as io]) (:import - [java.io File FileNotFoundException])) + [java.io FileNotFoundException])) (defn repo-root [name git-url] (or name @@ -14,10 +16,10 @@ (first)))) (defn repo-path [root name git-url] - (str root File/separator (repo-root name git-url))) + (io/concat-path root (repo-root name git-url))) (defn git-config [] - (if (.exists (clojure.java.io/file "kit.git-config.edn")) + (if (.exists (jio/file "kit.git-config.edn")) (read-string (slurp "kit.git-config.edn")) {:name "~/.ssh/id_rsa"})) @@ -32,10 +34,10 @@ (git/git-pull repo)) (catch FileNotFoundException _e (git/git-clone url :dir path - :remote "origin" - :branch (or tag "master") - :bare? false - :clone-all? false))) + :remote "origin" + :branch (or tag "master") + :bare? false + :clone-all? false))) (when callback (callback path)))) (catch org.eclipse.jgit.api.errors.TransportException e (println (.getMessage e) diff --git a/libs/kit-generator/src/kit/generator/hooks.clj b/libs/kit-generator/src/kit/generator/hooks.clj new file mode 100644 index 00000000..9bc33ff2 --- /dev/null +++ b/libs/kit-generator/src/kit/generator/hooks.clj @@ -0,0 +1,36 @@ +(ns kit.generator.hooks + "Execute script hooks defined in module configuration." + (:require + [babashka.process :refer [sh]])) + +(defmulti run-hooks (fn [hook _ _] hook)) + +(defmethod run-hooks :post-install + [hook module-config {:keys [confirm] :or {confirm (fn [] true)}}] + (when-let [actions (seq (get-in module-config [:hooks hook]))] + (if (confirm actions) + (doseq [action actions] + (println "$" action) + (let [{:keys [exit out]} (sh {:continue true + :out :string + :err :out} "sh" "-c" action)] + (println out) + (when (not (zero? exit)) + (throw (ex-info (str "Hook command failed: " action) + {:error ::hook-failed + :action action + :exit exit + :out out}))))) + (println "Skipping hooks for" hook)))) + +(defmethod run-hooks :default + [hook _ _] + (throw (ex-info (str "Unsupported hook type: " hook) {:hook hook}))) + +(defn describe-hooks + "A sequence of strings describing the hooks defined by the module." + [{:module/keys [resolved-config]}] + (->> resolved-config + :hooks + keys + (map #(str "run " (name %) " hook")))) diff --git a/libs/kit-generator/src/kit/generator/io.clj b/libs/kit-generator/src/kit/generator/io.clj index b00bf64f..da86ea92 100644 --- a/libs/kit-generator/src/kit/generator/io.clj +++ b/libs/kit-generator/src/kit/generator/io.clj @@ -1,6 +1,9 @@ (ns kit.generator.io + "I/O utility functions." (:require - [clojure.edn :as edn])) + [clojure.edn :as edn]) + (:import + java.io.File)) (defn str->edn [config] (edn/read-string {:default tagged-literal} config)) @@ -11,9 +14,20 @@ (defn update-edn-file [path f] (spit - path - (-> (slurp path) - (str->edn) - (f) - (edn->str)))) + path + (-> (slurp path) + (str->edn) + (f) + (edn->str)))) +(defn concat-path + "Joins `head` and one or more `parts` using path separators specific to the particular + operating system. Ignores parts that are `nil`." + [head & parts] + (->> parts + (reduce (fn [path p] + (if p + (File. path p) + (File. path))) + head) + .getPath)) diff --git a/libs/kit-generator/src/kit/generator/modules.clj b/libs/kit-generator/src/kit/generator/modules.clj index ab6f60c5..85c4058c 100644 --- a/libs/kit-generator/src/kit/generator/modules.clj +++ b/libs/kit-generator/src/kit/generator/modules.clj @@ -1,10 +1,14 @@ (ns kit.generator.modules + "Module loading and resolution." (:require [clojure.java.io :as jio] - [deep.merge :as deep-merge] - [kit.generator.git :as git]) - (:import - java.io.File)) + [kit.generator.features :as features] + [kit.generator.git :as git] + [kit.generator.io :as io] + [kit.generator.renderer :as renderer])) + +(defn root [ctx] + (get-in ctx [:modules :root])) (defn sync-modules! "Clones or pulls modules from git repositories. @@ -16,40 +20,105 @@ :name - the name which will be used as the path locally :url - the git repository URL :tag - the branch to pull from" - [{:keys [modules]}] - (doseq [{:keys [name url] :as repository} (-> modules :repositories)] + [{:keys [modules] :as ctx}] + (doseq [repository (-> modules :repositories)] (git/sync-repository! - (:root modules) + (root ctx) repository))) -(defn set-module-path [module-config base-path] - (update module-config :path #(str base-path File/separator %))) +(defn- set-module-path [module-config base-path] + (update module-config :path #(io/concat-path base-path %))) -(defn set-module-paths [root {:keys [module-root modules]}] +(defn- set-module-paths [root {:keys [module-root modules]}] (reduce (fn [modules [id config]] - (assoc modules id (set-module-path config (str root File/separator module-root)))) + (assoc modules id (set-module-path config (io/concat-path root module-root)))) {} modules)) -(defn load-modules [{:keys [modules] :as ctx}] - (let [root (:root modules)] - (->> root - (jio/file) - (file-seq) - (keep #(when (= "modules.edn" (.getName %)) - (set-module-paths root (assoc - (read-string (slurp %)) - :module-root (-> % .getParentFile .getName))))) - (apply merge) - (assoc-in ctx [:modules :modules])))) +(defn- render-module-config [ctx path] + (some->> path + (slurp) + (renderer/render-template ctx))) + +(defn- read-module-config [ctx module-path] + (let [path (io/concat-path module-path "config.edn")] + (try + (-> (render-module-config ctx path) + (io/str->edn)) + (catch Exception e + (throw (ex-info (str "Failed to read and render module config at " path) + {:error ::read-module-config + :path path + :ctx ctx} + e)))))) + +(defn- module-info + [module-key module-path module-doc module-config] + {:module/key module-key + :module/path module-path + :module/doc module-doc + :module/config module-config}) + +(defn- load-module + [ctx [key {:keys [path doc]}]] + (let [config (read-module-config ctx path)] + [key (module-info key path doc config)])) + +(defn- resolve-module + [opts [key {:module/keys [config] :as module}]] + (let [feature-flag (get-in opts [key :feature-flag] :default) + resolved-config (features/resolve-module-config config feature-flag)] + [key (merge module {:module/resolved-config resolved-config})])) + +(defn resolve-modules + "Updates context by resolving all loaded modules using feature flags provided + in opts map." + [ctx opts] + (update-in ctx [:modules :modules] + (fn [existing-modules] + (->> existing-modules + (map (partial resolve-module opts)) + (into {}))))) + +(defn load-modules + "Updates context by loading all modules found under the modules root. + The two argument version resolves module configs using feature flags provided + in opts map." + ([ctx] + (let [root (root ctx) + ctx (->> root + (jio/file) + (file-seq) + (keep #(when (= "modules.edn" (.getName %)) + (set-module-paths root (assoc + (read-string (slurp %)) + :module-root (-> % .getParentFile .getName))))) + ;; TODO: Warn if there are modules with the same key from different repositories. + (apply merge) + (assoc-in ctx [:modules :modules]))] + (update-in ctx [:modules :modules] #(into {} (map (partial load-module ctx) %))))) + ([ctx opts] + (-> ctx + (load-modules) + (resolve-modules opts)))) (defn list-modules [ctx] (let [modules (-> ctx :modules :modules)] (if (empty? modules) (println "No modules installed, maybe run `(kit/sync-modules)`") - (doseq [[id {:keys [doc]}] modules] + (doseq [[id {:module/keys [doc]}] modules] (println id "-" doc))))) (defn module-exists? [ctx module-key] (contains? (-> ctx :modules :modules) module-key)) + +(defn modules + [ctx] + (vals (get-in ctx [:modules :modules]))) + +(defn lookup-module [ctx module-key] + (or (get-in ctx [:modules :modules module-key]) + (throw (ex-info (str "Module not found: " module-key) + {:error ::module-not-found + :module-key module-key})))) diff --git a/libs/kit-generator/src/kit/generator/modules/dependencies.clj b/libs/kit-generator/src/kit/generator/modules/dependencies.clj index 5ec2f3f9..4e52a323 100644 --- a/libs/kit-generator/src/kit/generator/modules/dependencies.clj +++ b/libs/kit-generator/src/kit/generator/modules/dependencies.clj @@ -1,11 +1,53 @@ -(ns kit.generator.modules.dependencies) - -(defn resolve-dependencies - ([module-config feature-flag] - (resolve-dependencies module-config feature-flag #{})) - ([module-config feature-flag reqs] - (let [requires (get-in module-config [feature-flag :requires] []) - feature-requires (get-in module-config [feature-flag :feature-requires])] - (if feature-requires - (into #{} (mapcat #(resolve-dependencies (dissoc module-config feature-flag) % requires) feature-requires)) - (into reqs requires))))) +(ns kit.generator.modules.dependencies + "Module dependency order resolution." + (:require + [kit.generator.modules :as modules] + [com.stuartsierra.dependency :as dep])) + +(defn- build-dependency-tree + [ancestors ctx module-key opts] + (when (contains? ancestors module-key) + (throw (ex-info (str "Cyclic dependency detected for module " module-key) {:ancestors ancestors + :module-key module-key}))) + (if (modules/module-exists? ctx module-key) + (let [{:module/keys [resolved-config]} (modules/lookup-module ctx module-key) + {:keys [requires]} resolved-config] + {:key module-key + :dependencies (map #(build-dependency-tree (conj ancestors module-key) ctx % opts) requires)}) + (throw (ex-info (str "Module " module-key " not found.") + {:module-key module-key})))) + +(defn- dependency-tree + "A tree of module keys and their dependencies. + > NOTE: opts must be flat options. See kit.api/flat-module-options for more details." + [ctx module-key opts] + (build-dependency-tree #{} ctx module-key opts)) + +(defn- dependency-order + "List of module keys in topological order based on dependency tree." + [dep-tree] + (->> dep-tree + (tree-seq #(seq (:dependencies %)) :dependencies) + (reduce (fn [graph node] + (-> (reduce (fn [g dep] + (dep/depend g (:key node) (:key dep))) + graph + (:dependencies node)) + ;; add an artificial root node to handle a single node graph + ;; (only one module, no dependencies). + (dep/depend ::root (:key node)))) + (dep/graph)) + (dep/topo-sort) + (remove #(= % ::root)))) ;; remove the artificial root node + +(defn dependency-list + "Flat list of modules, comprising the main module, identified by `module-key`, + and all its dependencies, topologically sorted based on `:requires`, with + duplicates removed. The order is guaranteed to be correct for installing modules + and their dependencies." + [ctx module-key opts] + ;; TODO: This can be optimized to avoid building the full tree first. + ;; This will also avoid having to pass ancestors around. + (->> (dependency-tree ctx module-key opts) + (dependency-order) + (map #(modules/lookup-module ctx %)))) diff --git a/libs/kit-generator/src/kit/generator/modules/generator.clj b/libs/kit-generator/src/kit/generator/modules/generator.clj index 87459ec7..51e01908 100644 --- a/libs/kit-generator/src/kit/generator/modules/generator.clj +++ b/libs/kit-generator/src/kit/generator/modules/generator.clj @@ -1,20 +1,13 @@ (ns kit.generator.modules.generator + "Module responsible for generating assets and injecting data into context, + based on `:actions` defined in module configuration." (:require + [clojure.java.io :as jio] [kit.generator.io :as io] - [kit.generator.modules :as modules] [kit.generator.modules.injections :as ij] - [kit.generator.renderer :as renderer] - [clojure.java.io :as jio] - [clojure.pprint :refer [pprint]] - [deep.merge :as deep-merge] - [rewrite-clj.zip :as z]) - (:import java.io.File - java.nio.file.Files)) - -(defn concat-path [base-path asset-path] - (str base-path File/separator (if (.startsWith asset-path "/") - (subs asset-path 1) - asset-path))) + [kit.generator.renderer :as renderer]) + (:import + java.nio.file.Files)) (defn template? [asset-path] (->> [".txt" ".md" "Dockerfile" "gitignore" ".html" ".edn" ".clj" ".cljs"] @@ -43,12 +36,15 @@ (println "WARNING: Asset already exists:" path) ((if (string? asset) write-string write-binary) asset path))) -(defmulti handle-action (fn [_ [id]] id)) +;; IMPORTANT: When adding new action types, be sure +;; to update `describe-action` multimethods below. +(defmulti handle-action (fn [_ _ [id]] id)) +(defmulti describe-actions-by-type (fn [type _] type)) (comment (ns-unmap 'kit.generator.modules.generator 'handle-action)) -(defmethod handle-action :assets [{:keys [module-path] :as ctx} [_ assets]] +(defmethod handle-action :assets [ctx module-path [_ assets]] (doseq [asset assets] (cond ;; if asset is a string assume it's a directory to be created @@ -57,104 +53,47 @@ ;; otherwise asset should be a tuple of [source target] path strings (and (sequential? asset) (contains? #{2 3} (count asset))) (let [[asset-path target-path force?] asset] + (println "rendering asset to:" target-path) (write-asset - (->> (read-asset (concat-path module-path asset-path)) + (->> (read-asset (io/concat-path module-path asset-path)) (renderer/render-asset ctx)) (renderer/render-template ctx target-path) force?)) :else (println "ERROR: Unrecognized asset type:" asset)))) -(defmethod handle-action :injections [ctx [_ injections]] +(defmethod handle-action :injections [ctx _ [_ injections]] (ij/inject-data ctx injections)) -(defmethod handle-action :default [_ [id]] +(defmethod handle-action :default [_ _ [id]] (println "ERROR: Undefined action:" id)) -(defn- render-module-config [ctx module-path] - (some->> (str module-path File/separator "config.edn") - (slurp) - (renderer/render-template ctx))) - -(defn modules-log-path [modules-root] - (str modules-root File/separator "install-log.edn")) - -(defn read-modules-log [modules-root] - (let [log-path (modules-log-path modules-root)] - (if (.exists (jio/file log-path)) - (io/str->edn (slurp log-path)) - {}))) - -(defn write-modules-log [modules-root log] - (spit (modules-log-path modules-root) log)) - -(defn read-module-config [ctx modules module-key] - (let [module-path (get-in modules [:modules module-key :path]) - ctx (assoc ctx :module-path module-path) - config-str (render-module-config ctx module-path)] - {:config-str config-str - :module-config (io/str->edn config-str) - :module-path module-path})) - -(defn get-throw-on-not-found - [m k] - (or (get m k) - (throw (ex-info "Key not found or nil" {:key k - :available-keys (keys m)})))) - -(defn apply-features - [edn-config {:keys [feature-requires] :as config}] - (if (some? feature-requires) - (do - (apply deep-merge/concat-merge - (conj (mapv #(get-throw-on-not-found edn-config %) feature-requires) - config))) - config)) - -(defn generate [{:keys [modules] :as ctx} module-key {:keys [feature-flag] - :or {feature-flag :default}}] - (let [modules-root (:root modules) - module-log (read-modules-log modules-root)] - (if (= :success (module-log module-key)) - (println "WARNING: Module" module-key "is already installed!") - (try - (let [{:keys [module-path module-config config-str]} (read-module-config ctx modules module-key) - ctx (assoc ctx :module-path module-path) - config (get module-config feature-flag) - zip-config (z/of-string config-str)] - (cond - (nil? module-config) - (do - (println "ERROR: Module" module-key "not found, available modules:") - (pprint (modules/list-modules ctx))) - - (nil? config) - (do - (println "ERROR: Feature" feature-flag "not found for module" module-key ", available features:") - (pprint (keys module-config))) - - :else - (let [{:keys [actions success-message require-restart?]} (apply-features module-config config) - ctx (assoc ctx :zip-config zip-config)] - (doseq [action actions] - (handle-action ctx action)) - (write-modules-log modules-root (assoc module-log module-key :success)) - (println (or success-message - (str "Module " module-key " installed successfully!"))) - (when require-restart? - (println "restart required!"))))) - (catch Exception e - (println "ERROR: Failed to install module" module-key) - (write-modules-log modules-root (assoc module-log module-key :error)) - (.printStackTrace e)))))) +(defn generate [ctx {:module/keys [path resolved-config]}] + (let [{:keys [actions]} resolved-config] + (doseq [action actions] + (handle-action ctx path action)))) -(comment - (let [ctx {:ns-name "myapp" - :sanitized "myapp" - :name "myapp" - :modules {:root "test/resources/modules" - :repositories [{:url "git@github.com:nikolap/kit.git" - :tag "master" - :name "kit"}] - :modules {:html {:path "html"}}}}] - (generate ctx :html {:feature-flag :default}))) +(defn describe-asset-action [[_ target]] + (str "create " target)) + +(defn describe-injection-action [injection] + (ij/describe-injection injection)) + +(defmethod describe-actions-by-type :assets [_ {:module/keys [resolved-config]}] + (map describe-asset-action (get-in resolved-config [:actions :assets]))) + +(defmethod describe-actions-by-type :injections [_ {:module/keys [resolved-config]}] + (->> (get-in resolved-config [:actions :injections]) + (map describe-injection-action) + (distinct))) + +(defmethod describe-actions-by-type :default [type _] + (throw (ex-info (str "Undefined action type: " type) {:error ::undefined-action + :type type}))) + +(defn describe-actions + "A sequence of strings describing the asset actions defined by the module." + [{:module/keys [resolved-config] :as module}] + (->> (get-in resolved-config [:actions]) + (keys) + (mapcat #(describe-actions-by-type % module)))) diff --git a/libs/kit-generator/src/kit/generator/modules/injections.clj b/libs/kit-generator/src/kit/generator/modules/injections.clj index ad95c166..8b71a3a2 100644 --- a/libs/kit-generator/src/kit/generator/modules/injections.clj +++ b/libs/kit-generator/src/kit/generator/modules/injections.clj @@ -1,19 +1,29 @@ (ns kit.generator.modules.injections + "Low-level helpers for injecting content into files of various types." (:require - [kit.generator.renderer :as renderer] - [kit.generator.io :as io] - [borkdude.rewrite-edn :as rewrite-edn] - [clojure.pprint :refer [pprint]] - [clojure.walk :refer [prewalk]] - [cljstyle.config :as fmt-config] - [net.cgrand.enlive-html :as html] - [rewrite-clj.node :as n] - [rewrite-clj.parser :as parser] - [rewrite-clj.zip :as z] - [cljfmt.core :as cljfmt]) - (:import org.jsoup.Jsoup)) - + [borkdude.rewrite-edn :as rewrite-edn] + [cljfmt.core :as cljfmt] + [cljstyle.config :as fmt-config] + [clojure.pprint :refer [pprint]] + [clojure.walk :refer [prewalk]] + [kit.generator.io :as io] + [kit.generator.renderer :as renderer] + [net.cgrand.enlive-html :as html] + [rewrite-clj.node :as n] + [rewrite-clj.parser :as parser] + [rewrite-clj.zip :as z]) + (:import + org.jsoup.Jsoup)) + +;; When adding new injection types, be sure to make sure `describe-injection` +;; multimethod implementations are still correct. Currently they assume there is +;; always a `:path` key. (defmulti inject :type) +(defmulti describe-injection (fn [{:keys [type]}] type)) + +(defmethod describe-injection :default + [{:keys [path]}] + (str "modify " path)) (defn topmost [z-loc] (loop [z-loc z-loc] @@ -23,11 +33,11 @@ (defn format-str [s] (cljfmt/reformat-string - s - {:indentation? true - :split-keypairs-over-multiple-lines? true - :insert-missing-whitespace? true - :remove-multiple-non-indenting-spaces? true})) + s + {:indentation? true + :split-keypairs-over-multiple-lines? true + :insert-missing-whitespace? true + :remove-multiple-non-indenting-spaces? true})) (defn reformat-string [form-string rules-config] (-> form-string @@ -51,15 +61,15 @@ (if-not (and (some? k) (some? v)) (format-zloc (z/up zloc)) (recur - (-> zloc - (z/insert-right (z/node k)) - (z/right) - (z/insert-newline-left) - (z/insert-right (z/node v)) - (z/right)) - (-> kw-zipper - (z/right) - (z/right)))))) + (-> zloc + (z/insert-right (z/node k)) + (z/right) + (z/insert-newline-left) + (z/insert-right (z/node v)) + (z/right)) + (-> kw-zipper + (z/right) + (z/right)))))) (defn spaces-of-zloc [zloc] @@ -78,20 +88,20 @@ (comment (z/root-string ((edn-merge-value - {:c {:d 1 - {:e 3} 4} - :d 3}) + {:c {:d 1 + {:e 3} 4} + :d 3}) (z/of-string "{:a 1 :b 2}"))) (z/root-string ((edn-merge-value - {:c {:d 1 - {:e 3} 4} - :d 3}) + {:c {:d 1 + {:e 3} 4} + :d 3}) (z/of-string "{}"))) (z/root-string ((edn-merge-value - (io/str->edn "{:reitit.routes/pages\n {:base-path \"\"\n :env #ig/ref :system/env}}")) + (io/str->edn "{:reitit.routes/pages\n {:base-path \"\"\n :env #ig/ref :system/env}}")) (z/of-string "{:a 1}")))) (defn edn-safe-merge [zloc value] @@ -140,19 +150,19 @@ (defmethod inject :edn [{:keys [data target action value ctx]}] (let [value (normalize-value value)] (-> - (case action - :append - (if (empty? target) - (zloc-conj data value) - (or (z-update-in data target #(zloc-conj % value)) - (println "could not find injection target:" target "in data:" (z/node data)))) - :merge - (if-let [zloc (zloc-get-in data target)] - (edn-safe-merge zloc value) - (println "could not find injection target:" target "in data:" (z/node data)))) + (case action + :append + (if (empty? target) + (zloc-conj data value) + (or (z-update-in data target #(zloc-conj % value)) + (println "could not find injection target:" target "in data:" (z/node data)))) + :merge + (if-let [zloc (zloc-get-in data target)] + (edn-safe-merge zloc value) + (println "could not find injection target:" target "in data:" (z/node data)))) ;;TODO find a better way to do this - z/root-string - z/of-string))) + z/root-string + z/of-string))) (comment @@ -160,15 +170,14 @@ (let [data (z/of-string "{:foo :bar}") value (io/str->edn "{:db.sql/connection #profile\n {:prod {:jdbc-url #env JDBC_URL}}}")] (clojure.walk/postwalk - (fn [node] - (if (and (seq? node) #_(= 'read-string (first node))) - (println ">>>" (first node)) - node)) - (z/assoc data :z (n/sexpr value))) + (fn [node] + (if (and (seq? node) #_(= 'read-string (first node))) + (println ">>>" (first node)) + node)) + (z/assoc data :z (n/sexpr value))) #_(-> (z/assoc data :z (n/sexpr value)) z/node - str)) - ) + str))) (let [data (z/of-string "{:foo :bar}") value (io/str->edn "{:db.sql/connection #profile\n {:prod {:jdbc-url #env JDBC_URL}}}")] @@ -187,45 +196,43 @@ (z/node)))) (println "could not find injection target:" target "in data:" data)) - (z/root-string (z/edit - (z/of-string "{:z :r :deps {:wooo :waaa} :paths [\"foo\"]}") - (fn [x] (update x :paths conj "bar")))) + (z/of-string "{:z :r :deps {:wooo :waaa} :paths [\"foo\"]}") + (fn [x] (update x :paths conj "bar")))) (type (clojure.edn/read-string "foo")) (zloc-get-in (z/of-string "{:z :r :deps {:wooo :waaa}}") []) - (let [data (z/of-string "{:z :r :deps {:foo :bar}}") updated (inject - {:type :edn - :data data - :target [] - :action :merge - :value (io/str->edn "{:db.sql/connection #profile\n {:prod {:jdbc-url #env JDBC_URL}}}")})] + {:type :edn + :data data + :target [] + :action :merge + :value (io/str->edn "{:db.sql/connection #profile\n {:prod {:jdbc-url #env JDBC_URL}}}")})] #_(z/root-string updated) (z/root-string - (inject - {:type :edn - :data updated - :target [] - :action :merge - :value (io/str->edn "{:x :y}")}))) + (inject + {:type :edn + :data updated + :target [] + :action :merge + :value (io/str->edn "{:x :y}")}))) ;; get-in test (z/root-string (edn-safe-merge - (zloc-get-in (z/of-string "{:a 1 + (zloc-get-in (z/of-string "{:a 1 :b 2 :q {:jj 1}}") [:q]) - "{:c {:d 1 + "{:c {:d 1 {:e 3} 4} :d 3}")) ;; get-in empty map test (z/root-string (edn-safe-merge - (zloc-get-in (z/of-string "{:a 1 + (zloc-get-in (z/of-string "{:a 1 :b 2 :q {}}") [:q]) - "{:c {:d 1 + "{:c {:d 1 {:e 3} 4} :d 3}"))) @@ -236,23 +243,23 @@ (let [zloc-ns (z/find-value zloc z/next 'ns) zloc-require (z/up (z/find-value zloc-ns z/next :require))] (reduce - (fn [zloc child] - (let [child-data (io/str->edn child)] - (if (require-exists? (z/sexpr zloc) child-data) - (do - (println "require" child-data "already exists, skipping") - zloc) + (fn [zloc child] + (let [child-data (io/str->edn child)] + (if (require-exists? (z/sexpr zloc) child-data) + (do + (println "require" child-data "already exists, skipping") + zloc) ;; TODO: formatting - (-> zloc + (-> zloc ;; change #1: I might replace this line: ;; (z/insert-newline-right) ;; with this line: - (z/append-child (n/newline-node "\n")) + (z/append-child (n/newline-node "\n")) ;; change #2: and now indent to first existing require - (z/append-child* (n/spaces (-> zloc (z/down) (spaces-of-zloc)))) - (z/append-child child-data #_(format-zloc child-data)))))) - zloc-require - requires))) + (z/append-child* (n/spaces (-> zloc (z/down) (spaces-of-zloc)))) + (z/append-child child-data #_(format-zloc child-data)))))) + zloc-require + requires))) (defn append-build-task [zloc child] (let [ns-loc (z/up (z/find-value zloc z/next 'ns))] @@ -286,11 +293,11 @@ (defmethod inject :clj [{:keys [data action value]}] (println "applying\n action:" action "\n value:" (pr-str value)) (topmost - ((case action - :append-requires append-requires - :append-build-task append-build-task - :append-build-task-call append-build-task-call) - data value))) + ((case action + :append-requires append-requires + :append-build-task append-build-task + :append-build-task-call append-build-task-call) + data value))) (defmethod inject :html [{:keys [data action target value]}] (case action @@ -298,7 +305,7 @@ [] target (html/append - (html/html value))))))) + (html/html value))))))) (defmethod inject :default [{:keys [type] :as injection}] (println "unrecognized injection type" type "for injection\n" @@ -339,31 +346,33 @@ (defn read-files [ctx paths] (reduce - (fn [path->data path] - (try - (->> (slurp path) - (renderer/render-template ctx) - (read-file path) - (assoc path->data path)) - (catch Exception e - (println "failed to read asset in project:" path - "\nerror:" (.getMessage e))))) - {} paths)) + (fn [path->data path] + (try + (->> (slurp path) + (renderer/render-template ctx) + (read-file path) + (assoc path->data path)) + (catch Exception e + (throw (ex-info (str "Failed to read asset:" path) + {:error ::read-asset + :path :path} + e))))) + {} paths)) (defn inject-at-path [ctx data path injections] {:type (-> injections first :type) :path path :data (reduce - (fn [data injection] - (inject (assoc injection :ctx ctx :data data))) - data injections)}) + (fn [data injection] + (inject (assoc injection :ctx ctx :data data))) + data injections)}) (defn group-by-path [xs] (reduce - (fn [m {:keys [path] :as item}] - (update m path (fnil conj []) item)) - {} - xs)) + (fn [m {:keys [path] :as item}] + (update m path (fnil conj []) item)) + {} + xs)) (defn inject-data [ctx injections] (let [injections (->> injections @@ -375,13 +384,6 @@ (->> (inject-at-path ctx (path->data path) path injections) (serialize))))) - - - - - - - (comment (let [zloc (z/of-string "(ns foo) (defn cljs-build [])")] @@ -399,35 +401,33 @@ #_(-> uber-loc (z/insert-right child) (z/insert-space-right) - (z/insert-right (n/newline-node "\n"))) - - ) + (z/insert-right (n/newline-node "\n")))) (z/root-string - (inject - {:type :clj - :data (z/of-string "(ns foo (:require [bar.baz] [web.routes.pages]))") - :action :append-requires - :value ["[web.routes.pages]"]})) + (inject + {:type :clj + :data (z/of-string "(ns foo (:require [bar.baz] [web.routes.pages]))") + :action :append-requires + :value ["[web.routes.pages]"]})) (println - (str - (inject - {:type :edn - :data (z/of-string "{:z :r :deps {:wooo :waaa}}") - :target [] - :action :merge - :value "{:foo #ig/ref :bar :baz \"\"}"}))) + (str + (inject + {:type :edn + :data (z/of-string "{:z :r :deps {:wooo :waaa}}") + :target [] + :action :merge + :value "{:foo #ig/ref :bar :baz \"\"}"}))) (let [zloc (-> #_(slurp "test/resources/sample-system.edn") - "{:z :r :deps {:wooo :waaa}}" - (rewrite-edn/parse-string)) + "{:z :r :deps {:wooo :waaa}}" + (rewrite-edn/parse-string)) child (->> (io/str->edn "{:x {:foo #ig/ref :bar}}") (prewalk - (fn [node] - (if (string? node) - (renderer/render-template {} node) - node))))] + (fn [node] + (if (string? node) + (renderer/render-template {} node) + node))))] (str (rewrite-edn/assoc zloc [:deps] (-> child (io/edn->str) (rewrite-edn/parse-string)))) #_(str (rewrite-edn/assoc-in zloc [:deps] (-> child (io/edn->str) (rewrite-edn/parse-string))))) @@ -437,16 +437,14 @@ (rewrite-edn/sexpr (rewrite-edn/parse-string "{:foo #ig/ref :bar}")) (append-requires - "(ns wake.guestbook.core\n (:require\n [clojure.tools.logging :as log]\n [integrant.core :as ig]\n [wake.guestbook.config :as config]\n [wake.guestbook.env :refer [defaults]]\n\n ;; Edges\n\n\n\n\n\n\n\n [kit.edge.utils.repl]\n [kit.edge.server.undertow]\n [wake.guestbook.web.handler]\n\n ;; Routes\n [wake.guestbook.web.routes.api]\n [wake.guestbook.web.routes.pages] )\n (:gen-class))" - ['[myapp.core :as foo] - '[myapp.core.roures :as routes]]) - + "(ns wake.guestbook.core\n (:require\n [clojure.tools.logging :as log]\n [integrant.core :as ig]\n [wake.guestbook.config :as config]\n [wake.guestbook.env :refer [defaults]]\n\n ;; Edges\n\n\n\n\n\n\n\n [kit.edge.utils.repl]\n [kit.edge.server.undertow]\n [wake.guestbook.web.handler]\n\n ;; Routes\n [wake.guestbook.web.routes.api]\n [wake.guestbook.web.routes.pages] )\n (:gen-class))" + ['[myapp.core :as foo] + '[myapp.core.roures :as routes]]) (let [child "(defn build-cljs [_]\n (println \"npx shadow-cljs release app...\")\n (let [{:keys [exit] :as s} (sh \"npx\" \"shadow-cljs\" \"release\" \"app\")]\n (when-not (zero? exit)\n (throw (ex-info \"could not compile cljs\" s)))))" ctx {}] (io/str->edn (template-value ctx child))) - {:default {:require-restart? true :actions @@ -472,7 +470,6 @@ :action :append-build-task-call :value (build-cljs)}]}}} - (defn uber [_] (b/compile-clj {:basis basis :src-dirs ["src/clj" "env/prod/clj"] @@ -482,5 +479,4 @@ (b/uber {:class-dir class-dir :uber-file uber-file :main main-cls - :basis basis})) - ) + :basis basis}))) diff --git a/libs/kit-generator/src/kit/generator/modules_log.clj b/libs/kit-generator/src/kit/generator/modules_log.clj new file mode 100644 index 00000000..a685d2d6 --- /dev/null +++ b/libs/kit-generator/src/kit/generator/modules_log.clj @@ -0,0 +1,49 @@ +(ns kit.generator.modules-log + "Keeps track of installed modules." + (:require + [clojure.java.io :as jio] + [kit.generator.io :as io] + [kit.generator.modules :as modules])) + +(defn- modules-log-path [modules-root] + (io/concat-path modules-root "install-log.edn")) + +(defn read-modules-log [modules-root] + (let [log-path (modules-log-path modules-root)] + (if (.exists (jio/file log-path)) + (io/str->edn (slurp log-path)) + {}))) + +(defn write-modules-log [modules-root log] + (spit (modules-log-path modules-root) log)) + +(defn module-installed? + "True if the module identified by module-key was installed successfully." + [ctx module-key] + (let [modules-root (modules/root ctx) + install-log (read-modules-log modules-root)] + (= :success (get install-log module-key)))) + +(defmacro track-installation + "Records the installation status of a module identified by module-key. + If the installation body throws an exception, the status is recorded as :failed. + If it completes successfully, the status is recorded as :success." + [ctx module-key & body] + `(let [modules-root# (modules/root ~ctx) + install-log# (read-modules-log modules-root#)] + (try + (let [result# (do ~@body) + updated-log# (assoc install-log# ~module-key :success)] + (write-modules-log modules-root# updated-log#) + result#) + (catch Exception e# + (let [updated-log# (assoc install-log# ~module-key :failed)] + (write-modules-log modules-root# updated-log#) + (throw e#)))))) + +(defn installed-modules + "A list of keys of modules that were installed successfully." + [ctx] + (let [modules-root (modules/root ctx) + install-log (read-modules-log modules-root)] + (keys (filter (fn [[_ status]] (= status :success)) install-log)))) diff --git a/libs/kit-generator/src/kit/generator/renderer.clj b/libs/kit-generator/src/kit/generator/renderer.clj index a8d7b89f..6107cfda 100644 --- a/libs/kit-generator/src/kit/generator/renderer.clj +++ b/libs/kit-generator/src/kit/generator/renderer.clj @@ -1,20 +1,21 @@ (ns kit.generator.renderer + "Template rendering helpers." (:require - [clojure.string :as string] - [selmer.parser :as selmer])) + [clojure.string :as string] + [selmer.parser :as selmer])) (defn render-template [ctx template] (selmer/render - (str "<% safe %>" template "<% endsafe %>") - ctx - {:tag-open \< :tag-close \> :filter-open \< :filter-close \>})) + (str "<% safe %>" template "<% endsafe %>") + ctx + {:tag-open \< :tag-close \> :filter-open \< :filter-close \>})) (selmer/add-tag! - :include - (fn [args context-map] - (-> (render-template context-map (slurp (first args))) - (string/replace #"^\n+" "") - (string/replace #"\n+$" "")))) + :include + (fn [args context-map] + (-> (render-template context-map (slurp (first args))) + (string/replace #"^\n+" "") + (string/replace #"\n+$" "")))) (defn render-asset [ctx asset] (if (string? asset) diff --git a/libs/kit-generator/test/kit/features_test.clj b/libs/kit-generator/test/kit/features_test.clj new file mode 100644 index 00000000..c625062d --- /dev/null +++ b/libs/kit-generator/test/kit/features_test.clj @@ -0,0 +1,69 @@ +(ns kit.features-test + (:require [clojure.test :refer [deftest is testing]] + [kit.generator.features :as features])) + +(deftest resolve-feature-requires + (testing "empty requires" + (is (= {} (features/resolve-module-config {:default {}} + :default)))) + (testing "simple default requires" + (is (= {:requires [:a]} (features/resolve-module-config {:default {:requires [:a]}} + :default)))) + (testing "simple feature requires" + (is (= {:requires [:b]} (features/resolve-module-config {:default {:feature-requires [:base]} + :base {:requires [:b]}} + :default)))) + (testing "double feature require" + (is (= {:requires [:a :b]} (features/resolve-module-config {:default {:feature-requires [:base :tool]} + :base {:requires [:a]} + :tool {:requires [:b]}} + :default)))) + (testing "feature requires another feature" + (is (= {:requires [:a :b]} (features/resolve-module-config {:default {:feature-requires [:base :tool]} + :base {:requires [:a]} + :tool {:requires [:b] + :feature-requires [:base]}} + :default)))) + (testing "transitive feature require" + (is (= {:requires [:b :a]} (features/resolve-module-config {:default {:feature-requires [:tool]} + :base {:requires [:a]} + :tool {:requires [:b] + :feature-requires [:base]}} + :default)))) + (testing "cyclic feature require" + (is (= {:requires [:a :b]} (features/resolve-module-config {:default {:feature-requires [:base :tool]} + :base {:requires [:a] :feature-requires [:tool]} + :tool {:requires [:b] :feature-requires [:base]}} + :default)))) + (testing "feature require with :actions, :success-message, etc." + (is (= {:requires [:a :b] + ;; TODO: Should it be a vector instead so all success messages are merged? + :success-message ":tool installed" + :actions {:assets [:asset1 + :asset2 + :asset3 + :asset4] + :injections [:injection1 + :injection2 + :injection3 + :injection4]} + :hooks {:post-install [":default post-install" + ":tool post-install"]}} + (features/resolve-module-config {:default {:feature-requires [:base :tool] + :success-message ":default installed" + :actions {:assets [:asset1 :asset2] + :injections [:injection1 :injection2]} + :hooks {:post-install [":default post-install"]}} + :base {:requires [:a] + :success-message ":base installed" + :actions {:assets [:asset3] + :injections [:injection3]}} + :tool {:requires [:b] + :success-message ":tool installed" + :actions {:assets [:asset4] + :injections [:injection4]} + :hooks {:post-install [":tool post-install"]}}} + :default)))) + +; + ) diff --git a/libs/kit-generator/test/kit/generator/modules/dependencies_test.clj b/libs/kit-generator/test/kit/generator/modules/dependencies_test.clj deleted file mode 100644 index 1ea732d7..00000000 --- a/libs/kit-generator/test/kit/generator/modules/dependencies_test.clj +++ /dev/null @@ -1,26 +0,0 @@ -(ns kit.generator.modules.dependencies-test - (:require [kit.generator.modules.dependencies :as deps] - [clojure.test :refer :all])) - -(deftest resolve-requires - (testing "empty requires" - (is (= #{} (deps/resolve-dependencies {:default {}} :default)))) - (testing "simple default requires" - (is (= #{:a} (deps/resolve-dependencies {:default {:requires [:a]}} :default))))) - -(deftest resolve-feature-requires - (testing "simple feature requires" - (is (= #{:b} (deps/resolve-dependencies {:default {:feature-requires [:base]} - :base {:requires [:b]}} :default)))) - (testing "double feature require" - (is (= #{:a :b} (deps/resolve-dependencies {:default {:feature-requires [:base :tool]} - :base {:requires [:a]} - :tool {:requires [:b]}} :default)))) - (testing "feature requires another feature" - (is (= #{:a :b} (deps/resolve-dependencies {:default {:feature-requires [:base :tool]} - :base {:requires [:a]} - :tool {:requires [:b] :feature-requires [:base]}} :default)))) - (testing "ciclic feature require" - (is (= #{:a :b} (deps/resolve-dependencies {:default {:feature-requires [:base :tool]} - :base {:requires [:a] :feature-requires [:tool]} - :tool {:requires [:b] :feature-requires [:base]}} :default))))) diff --git a/libs/kit-generator/test/kit/modules_test.clj b/libs/kit-generator/test/kit/modules_test.clj new file mode 100644 index 00000000..37a84586 --- /dev/null +++ b/libs/kit-generator/test/kit/modules_test.clj @@ -0,0 +1,32 @@ +(ns kit.modules-test + (:require [clojure.test :refer [deftest is]] + [matcher-combinators.test :refer [match?]] + [kit.generator.modules :as modules] + [kit-generator.project :as project])) + +(deftest load-modules + (let [kit-edn-path (project/prepare-project "test/resources/modules") + ctx (modules/load-modules (project/read-ctx kit-edn-path))] + + (is (= 6 (count (modules/modules ctx)))) + (let [html-module (modules/lookup-module ctx :html)] + (is (match? {:module/key :html + :module/path "test/resources/generated/modules/kit/html" + :module/doc "adds support for HTML templating using Selmer" + :module/config map?} + html-module))))) + +(deftest load-modules-resolve + (let [kit-edn-path (project/prepare-project "test/resources/modules") + ctx (modules/load-modules (project/read-ctx kit-edn-path) + {:meta {:feature-flag :extras}})] + + (is (= 6 (count (modules/modules ctx)))) + (let [meta-module (modules/lookup-module ctx :meta)] + (is (match? {:module/key :meta + :module/path "test/resources/generated/modules/kit/meta" + :module/doc string? + :module/config map? + :module/resolved-config map?} + meta-module)) + (is (= [:db] (get-in meta-module [:module/resolved-config :requires])))))) diff --git a/libs/kit-generator/test/kit_generator/core_test.clj b/libs/kit-generator/test/kit_generator/core_test.clj index 94cc16c4..4793cd15 100644 --- a/libs/kit-generator/test/kit_generator/core_test.clj +++ b/libs/kit-generator/test/kit_generator/core_test.clj @@ -1,47 +1,17 @@ (ns kit-generator.core-test (:require [clojure.string :as str] - [clojure.test :refer [deftest is testing are]] - [kit-generator.generator-test] + [clojure.test :refer [deftest is are]] [kit-generator.injections] [kit-generator.io :as io] + [kit-generator.project :refer [project-root module-installed? prepare-project]] [kit.api :as kit])) -(def source-folder "test/resources") -;; It must be this path because module asset paths are relative to the current -;; working directory and modules under test/resources/modules/ write to -;; test/resources/generated/** -(def project-root "test/resources/generated") - -(defn module-installed? [module-key] - (when-let [install-log (io/read-edn-safe (str project-root "/modules/install-log.edn"))] - (= :success (get install-log module-key)))) - -(defn prepare-project - "Sets up a test project in `project-root` and returns the path to the kit.edn file. - The project has already synced modules and kit.edn but is otherwise empty." - [] - (let [project-modules (str project-root "/modules/") - ctx {:ns-name "myapp" - :sanitized "myapp" - :name "myapp" - :project-root project-root - :modules {:root project-modules - :repositories {:root (str project-root "/modules") - :url "https://github.com/foo/bar/never/used" - :tag "master" - :name "kit"}}} - kit-edn-path (str project-root "/kit.edn")] - (io/delete-folder project-root) - (io/clone-folder (str source-folder "/modules/") - project-modules - {:filter #(not (str/ends-with? % "install-log.edn"))}) - (io/write-edn ctx kit-edn-path) - kit-edn-path)) +(def module-repo-path "test/resources/modules") (defn test-install-module* [module-key opts expected-files] - (let [kit-edn-path (prepare-project)] + (let [kit-edn-path (prepare-project module-repo-path)] (is (not (module-installed? module-key))) (is (= :done (kit/install-module module-key kit-edn-path opts))) (is (module-installed? module-key)) @@ -49,7 +19,7 @@ expected-files {:filter #(not (str/starts-with? % "modules/"))}))))) -(deftest test-install-meta-module +(deftest test-install-module (are [module-key opts expected-files] (test-install-module* module-key opts expected-files) :meta {} {"resources/public/css/app.css" [] "kit.edn" []} @@ -72,11 +42,41 @@ "src/clj/myapp/db/migratus.clj" [] "src/clj/myapp/db/migrations/001.clj" [] "kit.edn" []} + :meta {:accept-hooks? true + :feature-flag :with-hooks} {"post-install.txt" [] + "kit.edn" []} ;; )) +;; TODO: accept-hooks? works +(deftest test-install-module-cyclic-dependency + (let [kit-edn-path (prepare-project module-repo-path)] + (is (thrown? Exception + (kit/install-module :meta kit-edn-path {:feature-flag :extras + :db {:feature-flag :cyclic}}))) + (is (not (module-installed? :meta))))) ;; TODO: Should feature-requires be transient? If so, add tests for that. (comment - (clojure.test/run-tests 'kit-generator.core-test)) + (def dep-tree {:module/key :meta + :module/config map? + :module/opts {:feature-flag :full} + :module/dependencies [{:module/key :db + :module/config map? + :module/opts {:feature-flag :migrations} + :module/dependencies [{:module/key :migratus + :module/config map? + :module/opts {} + :module/dependencies []}]}]}) + + (map :module/key (tree-seq #(contains? % :module/dependencies) + :module/dependencies + dep-tree)) + (clojure.test/run-tests 'kit-generator.core-test) + (require '[kit.generator.modules.dependencies :as deps]) + (deps/dependency-list :meta (kit/read-kit-edn (prepare-project module-repo-path)) {:feature-flag :full + :db {:feature-flag :migrations}}) + +; + ) diff --git a/libs/kit-generator/test/kit_generator/generator_test.clj b/libs/kit-generator/test/kit_generator/generator_test.clj index 0f1eea94..5c7f69cb 100644 --- a/libs/kit-generator/test/kit_generator/generator_test.clj +++ b/libs/kit-generator/test/kit_generator/generator_test.clj @@ -1,14 +1,16 @@ (ns kit-generator.generator-test (:require [clojure.java.io :as jio] - [clojure.test :refer [use-fixtures deftest testing is]] - [kit-generator.io :refer [delete-folder folder-mismatches clone-file read-edn-safe]] - [kit.generator.modules :as m] + [clojure.test :refer [deftest is testing use-fixtures]] + [kit-generator.io :refer [clone-file delete-folder folder-mismatches + read-edn-safe]] + [kit.api :as kit] + [kit.generator.io :as io] [kit.generator.modules.generator :as g])) (def source-folder "test/resources") (def target-folder "test/resources/generated") -(def ctx (read-string (slurp "test/resources/kit.edn"))) +(def kit-edn-path "test/resources/kit.edn") (defn module-installed? [module-key] (when-let [install-log (read-edn-safe (str source-folder "/modules/install-log.edn"))] @@ -37,15 +39,31 @@ (.delete install-log)) (delete-folder target-folder) (doseq [[source target] seeded-files] - (clone-file (str source-folder "/" source) (str target-folder "/" target))) + (clone-file (io/concat-path source-folder source) (io/concat-path target-folder target))) (f)))) +(defn prepare-install + "Generates an installation plan and returns the context and module info for the specified module." + [module-key opts] + (let [{:keys [ctx pending-modules]} (kit/installation-plan module-key kit-edn-path opts) + module (first (filter #(= (:module/key %) module-key) pending-modules))] + {:ctx ctx + :module module})) + +(defn generate + [module-key opts] + (let [{:keys [ctx module]} (prepare-install module-key opts)] + (g/generate ctx module))) + +(deftest test-edn-injection-with-feature-flag + (testing "testing injection with a feature flag" + (generate :html {:feature-flag :empty}) + (let [expected-files {}] + (is (empty? (target-folder-mismatches expected-files)))))) + (deftest test-edn-injection (testing "testing EDN injection" - (is (not (module-installed? :html))) - (let [ctx (m/load-modules ctx)] - (g/generate ctx :html {:feature-flag :default})) - (is (module-installed? :html)) + (generate :html {:html {:feature-flag :default}}) (let [expected-files {"resources/system.edn" [#"^\{:system/env" #":templating/selmer \{}}$"] "src/myapp/core.clj" [#"^\(ns myapp.core"] @@ -54,21 +72,9 @@ "src/clj/myapp/web/routes/pages.clj" [#"^\(ns resources\.modules"]}] (is (empty? (target-folder-mismatches expected-files)))))) -(deftest test-edn-injection-with-feature-flag - (testing "testing injection with a feature flag" - (is (not (module-installed? :html))) - (let [ctx (m/load-modules ctx)] - (g/generate ctx :html {:feature-flag :empty})) - (is (module-installed? :html)) - (let [expected-files {}] - (is (empty? (target-folder-mismatches expected-files)))))) - (deftest test-edn-injection-with-feature-requires (testing "testing injection with a feature flag + feature-requires" - (is (not (module-installed? :meta))) - (let [ctx (m/load-modules ctx)] - (g/generate ctx :meta {:feature-flag :full})) - (is (module-installed? :meta)) + (generate :meta {:feature-flag :full}) (let [expected-files {"resources/public/css/styles.css" [#".body"] "resources/public/css/app.css" [#".app"]}] (is (empty? (target-folder-mismatches expected-files)))))) diff --git a/libs/kit-generator/test/kit_generator/project.clj b/libs/kit-generator/test/kit_generator/project.clj new file mode 100644 index 00000000..6423568d --- /dev/null +++ b/libs/kit-generator/test/kit_generator/project.clj @@ -0,0 +1,38 @@ +(ns kit-generator.project + (:require + [clojure.string :as str] + [kit-generator.io :as io] + [kit.api :as kit])) + +;; It must be this path because module asset paths are relative to the current +;; working directory and modules under test/resources/modules/ write to +;; test/resources/generated/** +(def project-root "test/resources/generated") + +(defn module-installed? [module-key] + (when-let [install-log (io/read-edn-safe (str project-root "/modules/install-log.edn"))] + (= :success (get install-log module-key)))) + +(defn prepare-project + "Sets up a test project in `project-root` and returns the path to the kit.edn file. + The project has already synced modules and kit.edn but is otherwise empty." + [module-repo-path] + (let [project-modules (str project-root "/modules/") + ctx {:ns-name "myapp" + :sanitized "myapp" + :name "myapp" + :project-root project-root + :modules {:root project-modules + :repositories {:root (str project-root "/modules") + :url "https://github.com/foo/bar/never/used" + :tag "master" + :name "kit"}}} + kit-edn-path (str project-root "/kit.edn")] + (io/delete-folder project-root) + (io/clone-folder module-repo-path + project-modules + {:filter #(not (str/ends-with? % "install-log.edn"))}) + (io/write-edn ctx kit-edn-path) + kit-edn-path)) + +(def read-ctx kit/read-ctx) diff --git a/libs/kit-generator/test/resources/modules/kit/cljs/assets/build.clj b/libs/kit-generator/test/resources/modules/kit/cljs/assets/build.clj index b8533f94..9570b17f 100644 --- a/libs/kit-generator/test/resources/modules/kit/cljs/assets/build.clj +++ b/libs/kit-generator/test/resources/modules/kit/cljs/assets/build.clj @@ -2,13 +2,14 @@ (:require [clojure.tools.build.api :as b] [clojure.string :as string] [clojure.java.shell :refer [sh]] - [deps-deploy.deps-deploy :as deploy])) + [deps-deploy.deps-deploy :as deploy] + [kit.generator.io :as io])) (def lib 'com.example/test) (def main-cls (string/join "." (filter some? [(namespace lib) (name lib) "core"]))) (def version (format "0.0.1-SNAPSHOT")) (def target-dir "target") -(def class-dir (str target-dir "/" "classes")) +(def class-dir (io/concat-path target-dir "classes")) (def uber-file (format "%s/%s-standalone.jar" target-dir (name lib))) (def basis (b/create-basis {:project "deps.edn"})) diff --git a/libs/kit-generator/test/resources/modules/kit/db/config.edn b/libs/kit-generator/test/resources/modules/kit/db/config.edn index be387f90..cf8b7fbd 100644 --- a/libs/kit-generator/test/resources/modules/kit/db/config.edn +++ b/libs/kit-generator/test/resources/modules/kit/db/config.edn @@ -5,5 +5,7 @@ :feature-requires [:default]} :migrations {:actions {:assets [["assets/migrations/001.clj" "test/resources/generated/src/clj/<>/db/migrations/001.clj"]]} - :requires [:migratus] - :feature-requires [:postgres :default]}} + :requires [:migratus] + :feature-requires [:postgres :default]} + ;; Creates cyclic dependency to test cycle detection + :cyclic {:requires [:meta]}} diff --git a/libs/kit-generator/test/resources/modules/kit/hooks/assets/install.sh b/libs/kit-generator/test/resources/modules/kit/hooks/assets/install.sh new file mode 100644 index 00000000..94b11043 --- /dev/null +++ b/libs/kit-generator/test/resources/modules/kit/hooks/assets/install.sh @@ -0,0 +1 @@ +# install.sh diff --git a/libs/kit-generator/test/resources/modules/kit/hooks/config.edn b/libs/kit-generator/test/resources/modules/kit/hooks/config.edn new file mode 100644 index 00000000..4e147bcf --- /dev/null +++ b/libs/kit-generator/test/resources/modules/kit/hooks/config.edn @@ -0,0 +1,2 @@ +{:default {:actions {:assets [["assets/install.sh" "test/resources/generated/tmp-install.sh"]]} + :hooks {:post-install ["sh -c 'mv test/resources/generated/tmp-install.sh test/resources/generated/install.sh'"]}}} diff --git a/libs/kit-generator/test/resources/modules/kit/meta/config.edn b/libs/kit-generator/test/resources/modules/kit/meta/config.edn index 9c8be667..c616c528 100644 --- a/libs/kit-generator/test/resources/modules/kit/meta/config.edn +++ b/libs/kit-generator/test/resources/modules/kit/meta/config.edn @@ -6,4 +6,9 @@ :requires [:db]} :full - {:feature-requires [:default :extras]}} + {:feature-requires [:default :extras]} + + :with-hooks + ;; Demonstrates that dependency hooks are run first. + {:hooks {:post-install ["mv test/resources/generated/install.sh test/resources/generated/post-install.txt"]} + :requires [:hooks]}} diff --git a/libs/kit-generator/test/resources/modules/kit/modules.edn b/libs/kit-generator/test/resources/modules/kit/modules.edn index e449c721..f7bc0068 100644 --- a/libs/kit-generator/test/resources/modules/kit/modules.edn +++ b/libs/kit-generator/test/resources/modules/kit/modules.edn @@ -14,4 +14,7 @@ :doc "adds support for database access"} :migratus {:path "migratus" - :doc "adds support for migrations"}}} + :doc "adds support for migrations"} + :hooks + {:path "hooks" + :doc "test hooks"}}}