diff --git a/.gitignore b/.gitignore index a0fb7f7f5a..76da22b35f 100644 --- a/.gitignore +++ b/.gitignore @@ -101,5 +101,6 @@ opencode.json /.opencode/plans /.opencode/reports /.opencode/prompts +/.ci-logs /.codex/ /tools/__pycache__ diff --git a/AGENTS.md b/AGENTS.md index b6aae1f32d..05284c1c73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,4 +109,5 @@ precision while maintaining a strong focus on maintainability and performance. - `scripts/nrepl-eval.mjs` — Evaluate Clojure code via nREPL (backend + frontend). - `scripts/check-commit` — Validate commit messages against Penpot's commit guidelines. - `scripts/check-fmt-clj` — Check Clojure formatting without modifying files. +- `scripts/ci` — CI orchestration script for running lint, tests, and format checks across modules. See `scripts/ci --help`. diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 98a6ec0ec7..e10c85a7bd 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -156,11 +156,13 @@ (assoc mfile :permissions perms))) (defn get-file-etag - [{:keys [::rpc/profile-id]} {:keys [modified-at revn vern permissions]}] + [{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}] (str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/" (ct/format-inst modified-at :iso) "/" - (uri/map->query-string permissions))) + (uri/map->query-string permissions) + "/" + (some-> deleted-at (ct/format-inst :iso)))) (sv/defmethod ::get-file "Retrieve a file by its ID. Only authenticated users." @@ -1102,6 +1104,13 @@ (check-edition-permissions! conn profile-id file-id) (check-edition-permissions! conn profile-id library-id) + + (let [transitive-deps (bfc/get-libraries cfg [library-id])] + (when (contains? transitive-deps file-id) + (ex/raise :type :validation + :code :circular-library-reference + :hint "linking this library would create a circular dependency"))) + (link-file-to-library conn params) (bfc/get-libraries cfg [library-id])) diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 2681b51c09..46a4bc04ac 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -374,61 +374,6 @@ ;; --- MUTATION COMMAND: create-file-thumbnail -(defn- create-file-thumbnail - [{:keys [::db/conn ::sto/storage] :as cfg} {:keys [file-id revn props media] :as params}] - (media/validate-media-type! media) - (media/validate-media-size! media) - - (let [file (bfc/get-file cfg file-id - :include-deleted? true - :load-data? false) - - props (db/tjson (or props {})) - path (:path media) - mtype (:mtype media) - hash (sto/calculate-hash path) - data (-> (sto/content path) - (sto/wrap-with-hash hash)) - tnow (ct/now) - - media (sto/put-object! storage - {::sto/content data - ::sto/deduplicate? true - ::sto/touched-at tnow - :content-type mtype - :bucket "file-thumbnail"}) - - thumb (db/get* conn :file-thumbnail - {:file-id file-id - :revn revn} - {::db/remove-deleted false - ::sql/for-update true})] - - (if (some? thumb) - (do - ;; We mark the old media id as touched if it does not match - (when (not= (:id media) (:media-id thumb)) - (sto/touch-object! storage (:media-id thumb))) - - (db/update! conn :file-thumbnail - {:media-id (:id media) - :deleted-at (:deleted-at file) - :updated-at tnow - :props props} - {:file-id file-id - :revn revn})) - - (db/insert! conn :file-thumbnail - {:file-id file-id - :revn revn - :created-at tnow - :updated-at tnow - :deleted-at (:deleted-at file) - :props props - :media-id (:id media)})) - - media)) - (def ^:private schema:create-file-thumbnail [:map {:title "create-file-thumbnail"} @@ -448,12 +393,57 @@ ::rtry/when rtry/conflict-exception? ::sm/params schema:create-file-thumbnail} - ;; FIXME: do not run the thumbnail upload inside a transaction - [cfg {:keys [::rpc/profile-id file-id] :as params}] - (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (files/check-edition-permissions! conn profile-id file-id) - (when-not (db/read-only? conn) - (let [media (create-file-thumbnail cfg params)] - {:uri (files/resolve-public-uri (:id media)) - :id (:id media)}))))) + (media/validate-media-type! (:media params)) + (media/validate-media-size! (:media params)) + + (db/run! cfg files/check-edition-permissions! profile-id file-id) + + (when-not (db/read-only? (::db/pool cfg)) + (let [storage (::sto/storage cfg) + file (bfc/get-file cfg file-id :include-deleted? true :load-data? false) + props (db/tjson (or (:props params) {})) + {:keys [path mtype]} (:media params) + hash (sto/calculate-hash path) + data (-> (sto/content path) + (sto/wrap-with-hash hash)) + tnow (ct/now) + + media (sto/put-object! storage + {::sto/content data + ::sto/deduplicate? true + ::sto/touched-at tnow + :content-type mtype + :bucket "file-thumbnail"}) + + revn (:revn params) + + result (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (let [thumb (db/get* conn :file-thumbnail + {:file-id file-id :revn revn} + {::db/remove-deleted false + ::sql/for-update true})] + (if (some? thumb) + (do + (when (not= (:id media) (:media-id thumb)) + (sto/touch-object! storage (:media-id thumb))) + (db/update! conn :file-thumbnail + {:media-id (:id media) + :deleted-at (:deleted-at file) + :updated-at tnow + :props props} + {:file-id file-id :revn revn})) + (db/insert! conn :file-thumbnail + {:file-id file-id + :revn revn + :created-at tnow + :updated-at tnow + :deleted-at (:deleted-at file) + :props props + :media-id (:id media)})) + media)))] + + (when result + {:uri (files/resolve-public-uri (:id result)) + :id (:id result)})))) diff --git a/backend/src/app/rpc/commands/search.clj b/backend/src/app/rpc/commands/search.clj index 796ddf4810..7b60e6db30 100644 --- a/backend/src/app/rpc/commands/search.clj +++ b/backend/src/app/rpc/commands/search.clj @@ -6,9 +6,11 @@ (ns app.rpc.commands.search (:require + [app.common.data.macros :as dm] [app.common.schema :as sm] [app.db :as db] [app.rpc :as-alias rpc] + [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.util.services :as sv])) @@ -66,11 +68,13 @@ (def ^:private schema:search-files [:map {:title "search-files"} [:team-id ::sm/uuid] - [:search-term {:optional true} :string]]) + [:search-term {:optional true} [:string {:max 250}]]]) (sv/defmethod ::search-files {::doc/added "1.17" ::doc/module :files ::sm/params schema:search-files} [{:keys [::db/pool]} {:keys [::rpc/profile-id team-id search-term]}] - (some->> search-term (search-files pool profile-id team-id))) + (dm/with-open [conn (db/open pool)] + (teams/check-read-permissions! conn profile-id team-id) + (some->> search-term (search-files conn profile-id team-id)))) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 5460b4143f..1c07f35971 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2319,3 +2319,75 @@ (t/is (not (nil? (:error out)))) (let [edata (-> out :error ex-data)] (t/is (= :not-found (:type edata)))))) + +;; --- Security Fix Tests --- + +(t/deftest link-file-to-library-circular-reference + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + file2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true}) + file3 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + (th/link-file-to-library* {:file-id (:id file3) :library-id (:id file2)}) + (th/link-file-to-library* {:file-id (:id file2) :library-id (:id file1)}) + (let [data {::th/type :link-file-to-library + ::rpc/profile-id (:id profile) + :file-id (:id file1) + :library-id (:id file3)} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :validation)) + (let [edata (-> out :error ex-data)] + (t/is (= :circular-library-reference (:code edata))))))) + +(t/deftest get-file-etag-includes-deleted-at + (let [profile-id (uuid/random) + file1 {:modified-at (ct/now) + :revn 1 + :vern 0 + :deleted-at nil + :permissions {:can-edit true}} + file2 (assoc file1 :deleted-at (ct/now))] + (t/is (not= (files/get-file-etag {::rpc/profile-id profile-id} file1) + (files/get-file-etag {::rpc/profile-id profile-id} file2))))) + +(t/deftest search-files-with-permission + (let [profile (th/create-profile* 1) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + data {::th/type :search-files + ::rpc/profile-id (:id profile) + :team-id (:default-team-id profile) + :search-term "test"} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (vector? (:result out))))) + +(t/deftest search-files-forbidden + (let [profile (th/create-profile* 1) + other (th/create-profile* 2) + data {::th/type :search-files + ::rpc/profile-id (:id other) + :team-id (:default-team-id profile) + :search-term "test"} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (let [edata (-> out :error ex-data)] + (t/is (= :not-found (:type edata)))))) + +(t/deftest search-files-term-too-long + (let [profile (th/create-profile* 1) + data {::th/type :search-files + ::rpc/profile-id (:id profile) + :team-id (:default-team-id profile) + :search-term (apply str (repeat 300 "x"))} + out (th/command! data)] + (t/is (th/ex-info? (:error out))) + (let [edata (-> out :error ex-data)] + (t/is (= :validation (:type edata)))))) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 08f812e45e..d39d839ad7 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1573,7 +1573,12 @@ (as-> libraries-to-load $ (remove loaded-libraries $) (conj $ library-id) - (map #(load-library-file file-id %) $)))))) + (map #(load-library-file file-id %) $)))) + (rx/catch (fn [cause] + (let [error (ex-data cause)] + (if (= (:code error) :circular-library-reference) + (rx/of (ntf/error (tr "errors.circular-library-reference"))) + (rx/throw cause))))))) (rx/of (ptk/reify ::attach-library-finished)) (when (pos? variants-count) (->> (rp/cmd! :get-library-usage {:file-id library-id}) diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.cljs b/frontend/src/app/main/ui/components/context_menu_a11y.cljs index 75c97554ce..e2e824c19e 100644 --- a/frontend/src/app/main/ui/components/context_menu_a11y.cljs +++ b/frontend/src/app/main/ui/components/context_menu_a11y.cljs @@ -18,6 +18,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [app.util.timers :as tm] + [beicon.v2.core :as rx] [rumext.v2 :as mf])) (def ^:private xf:options @@ -229,8 +230,11 @@ (partial ug/unlisten "penpot:context-menu:open" on-event))) (mf/with-effect [ids] - (tm/schedule-on-idle - #(dom/focus! (dom/get-element (first ids))))) + (let [handle (tm/schedule + (fn [] + (some-> (dom/get-element (first ids)) + (dom/focus!))))] + #(rx/dispose! handle))) (when (some? levels) [:> dropdown-content* props diff --git a/frontend/src/app/main/ui/components/dropdown.cljs b/frontend/src/app/main/ui/components/dropdown.cljs index 894d9ef204..4a0a1b0590 100644 --- a/frontend/src/app/main/ui/components/dropdown.cljs +++ b/frontend/src/app/main/ui/components/dropdown.cljs @@ -11,6 +11,7 @@ [app.util.globals :as globals] [app.util.keyboard :as kbd] [app.util.timers :as tm] + [beicon.v2.core :as rx] [goog.events :as events] [rumext.v2 :as mf]) (:import goog.events.EventType)) @@ -45,9 +46,10 @@ (fn [] (let [keys [(events/listen globals/document EventType.CLICK on-click) (events/listen globals/document EventType.CONTEXTMENU on-click) - (events/listen globals/document EventType.KEYUP on-keyup)]] - (tm/schedule #(mf/set-ref-val! listening-ref true)) - #(run! events/unlistenByKey keys)))] + (events/listen globals/document EventType.KEYUP on-keyup)] + timer (tm/schedule #(mf/set-ref-val! listening-ref true))] + #(do (rx/dispose! timer) + (run! events/unlistenByKey keys))))] (mf/use-effect on-mount) children)) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 04df6cd534..88a3b8275a 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -40,7 +40,7 @@ [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] - [app.main.ui.hooks :as hooks] + [app.main.ui.hooks :as hooks :refer [use-focus-timer-ref]] [app.main.ui.icons :as deprecated-icon] [app.main.ui.nitrate.nitrate-form] [app.util.dom :as dom] @@ -95,6 +95,14 @@ (def ^:private ^:svg-id penpot-logo-icon "penpot-logo-icon") (def ^:private ^:svg-id penpot-logo-icon-subtle "penpot-logo-subtle") +(defn schedule-focus-by-id! + [ref element-id] + (when-let [h (mf/ref-val ref)] + (ts/dispose! h)) + (mf/set-ref-val! ref + (ts/schedule + #(dom/focus-and-untabbable! (dom/get-element element-id))))) + (mf/defc sidebar-project* {::mf/private true} [{:keys [item is-selected]}] @@ -113,6 +121,8 @@ project-id (get item :id) + focus-timer-ref (use-focus-timer-ref) + on-click (mf/use-fn (mf/deps project-id) @@ -124,14 +134,9 @@ (mf/deps project-id) (fn [event] (when (kbd/enter? event) - (st/emit! - (dcm/go-to-dashboard-files :project-id project-id)) - (ts/schedule - (fn [] - (when-let [title (dom/get-element (str project-id))] - (dom/set-attribute! title "tabindex" "0") - (dom/focus! title) - (dom/set-attribute! title "tabindex" "-1"))))))) + (schedule-focus-by-id! focus-timer-ref (str project-id)) + (st/emit! (dcm/go-to-dashboard-files :project-id project-id))))) + on-menu-click (mf/use-fn @@ -229,6 +234,8 @@ focused? (mf/use-state false) emit! (mf/use-memo #(f/debounce st/emit! 500)) + focus-timer-ref (use-focus-timer-ref) + on-search-blur (mf/use-fn (fn [_] @@ -255,13 +262,7 @@ (mf/use-fn (fn [e] (when (kbd/enter? e) - (ts/schedule - (fn [] - (let [search-title (dom/get-element (str "dashboard-search-title"))] - (when search-title - (dom/set-attribute! search-title "tabindex" "0") - (dom/focus! search-title) - (dom/set-attribute! search-title "tabindex" "-1"))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-search-title") (dom/prevent-default e) (dom/stop-propagation e)))) @@ -1003,6 +1004,8 @@ nitrate? (contains? cf/flags :nitrate) + focus-timer-ref (use-focus-timer-ref) + go-projects (mf/use-fn #(st/emit! (dcm/go-to-dashboard-recent))) @@ -1012,12 +1015,7 @@ (fn [] (st/emit! (dcm/go-to-dashboard-recent :team-id team-id)) - (ts/schedule - (fn [] - (when-let [projects-title (dom/get-element "dashboard-projects-title")] - (dom/set-attribute! projects-title "tabindex" "0") - (dom/focus! projects-title) - (dom/set-attribute! projects-title "tabindex" "-1")))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-projects-title"))) go-fonts (mf/use-fn @@ -1030,13 +1028,7 @@ (fn [] (st/emit! (dcm/go-to-dashboard-fonts :team-id team-id)) - (ts/schedule - (fn [] - (let [font-title (dom/get-element "dashboard-fonts-title")] - (when font-title - (dom/set-attribute! font-title "tabindex" "0") - (dom/focus! font-title) - (dom/set-attribute! font-title "tabindex" "-1"))))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-fonts-title"))) go-drafts (mf/use-fn @@ -1049,12 +1041,7 @@ (mf/deps team-id default-project-id) (fn [] (st/emit! (dcm/go-to-dashboard-files :team-id team-id :project-id default-project-id)) - (ts/schedule - (fn [] - (when-let [title (dom/get-element "dashboard-drafts-title")] - (dom/set-attribute! title "tabindex" "0") - (dom/focus! title) - (dom/set-attribute! title "tabindex" "-1")))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-drafts-title"))) go-libs (mf/use-fn @@ -1067,13 +1054,7 @@ (fn [] (st/emit! (dcm/go-to-dashboard-libraries :team-id team-id)) - (ts/schedule - (fn [] - (let [libs-title (dom/get-element "dashboard-libraries-title")] - (when libs-title - (dom/set-attribute! libs-title "tabindex" "0") - (dom/focus! libs-title) - (dom/set-attribute! libs-title "tabindex" "-1"))))))) + (schedule-focus-by-id! focus-timer-ref "dashboard-libraries-title"))) pinned-projects (mf/with-memo [projects] diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index bc20c517c9..e3d6b153fb 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -322,25 +322,26 @@ (let [trigger-el (mf/ref-val trigger-ref) tooltip-el (mf/ref-val tooltip-ref)] (when (and trigger-el tooltip-el) - (ts/raf - (fn [] - (let [origin-brect (dom/get-bounding-rect trigger-el) - tooltip-brect (dom/get-bounding-rect tooltip-el) - window-size (dom/get-window-size)] - (when-let [[new-placement placement-rect] - (find-matching-placement - placement - tooltip-brect - origin-brect - window-size - offset)] - (dom/set-css-property! tooltip-el "inset-block-start" - (str (:top placement-rect) "px")) - (dom/set-css-property! tooltip-el "inset-inline-start" - (str (:left placement-rect) "px")) + (let [raf-id (ts/raf + (fn [] + (let [origin-brect (dom/get-bounding-rect trigger-el) + tooltip-brect (dom/get-bounding-rect tooltip-el) + window-size (dom/get-window-size)] + (when-let [[new-placement placement-rect] + (find-matching-placement + placement + tooltip-brect + origin-brect + window-size + offset)] + (dom/set-css-property! tooltip-el "inset-block-start" + (str (:top placement-rect) "px")) + (dom/set-css-property! tooltip-el "inset-inline-start" + (str (:left placement-rect) "px")) - (when (not= new-placement placement) - (reset! placement* new-placement))))))))))) + (when (not= new-placement placement) + (reset! placement* new-placement))))))] + #(ts/cancel-af! raf-id))))))) [:> :div props children diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index 1c7478868c..83b86d932d 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -282,6 +282,16 @@ (mf/set-ref-val! ref val)) (mf/ref-val ref))) +;; FIXME: replace with rumext +(defn use-focus-timer-ref + "Returns a ref for scheduling focus timers and disposes any pending + timer on component unmount." + [] + (let [ref (mf/use-ref nil)] + (mf/with-effect [] + #(some-> (mf/ref-val ref) ts/dispose!)) + ref)) + ;; FIXME: rename to use-focus-objects (defn with-focus-objects ([objects] diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index e2406a959c..6bdc663f42 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -707,6 +707,13 @@ (when (some? node) (.setAttribute node attr value))) +(defn focus-and-untabbable! + [^js node] + (when (some? node) + (set-attribute! node "tabindex" "0") + (focus! node) + (set-attribute! node "tabindex" "-1"))) + (defn set-style! [^js node ^string style value] (when (some? node) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fe5aa1e2e2..243d3298f8 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1504,6 +1504,9 @@ msgstr "The fonts %s could not be loaded" msgid "errors.cannot-upload" msgstr "Cannot upload the media file." +msgid "errors.circular-library-reference" +msgstr "Cannot add library: this would create a circular dependency" + #: src/app/main/ui/comments.cljs:737, src/app/main/ui/comments.cljs:767, src/app/main/ui/comments.cljs:864 msgid "errors.character-limit-exceeded" msgstr "Character limit exceeded" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 0ba6b9c1db..9f80519eea 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1515,6 +1515,9 @@ msgstr "No se han podido cargar las fuentes %s" msgid "errors.cannot-upload" msgstr "No se puede cargar el archivo multimedia." +msgid "errors.circular-library-reference" +msgstr "No se puede añadir la biblioteca: crearía una dependencia circular" + #: src/app/main/ui/comments.cljs:737, src/app/main/ui/comments.cljs:767, src/app/main/ui/comments.cljs:864 msgid "errors.character-limit-exceeded" msgstr "Se ha superado el límite de caracteres" diff --git a/scripts/ci b/scripts/ci new file mode 100755 index 0000000000..df693a9cd0 --- /dev/null +++ b/scripts/ci @@ -0,0 +1,478 @@ +#!/usr/bin/env bash +# scripts/ci - CI orchestration script for Penpot monorepo + +set -euo pipefail + +# Constants +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +LOG_DIR="$PROJECT_ROOT/.ci-logs" + +# Color codes +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# Available modules +ALL_MODULES=("frontend" "backend" "common" "render-wasm" "exporter" "mcp" "plugins" "library") + +# Module commands +declare -A LINT_CMD=( + [frontend]="pnpm run lint:clj && pnpm run lint:js && pnpm run lint:scss" + [backend]="pnpm run lint" + [common]="pnpm run lint:clj" + [render-wasm]="./lint" + [exporter]="pnpm run lint" + [mcp]="" + [plugins]="pnpm run lint" + [library]="pnpm run lint" +) + +declare -A TEST_CMD=( + [frontend]="pnpm run test:quiet" + [backend]="clojure -M:dev:test" + [common]="clojure -M:dev:test && pnpm run test:quiet" + [render-wasm]="./test" + [exporter]="" + [mcp]="pnpm run test" + [plugins]="pnpm run test" + [library]="pnpm run test" +) + +declare -A FMT_CHECK_CMD=( + [frontend]="pnpm run check-fmt:clj && pnpm run check-fmt:js && pnpm run check-fmt:scss" + [backend]="pnpm run check-fmt" + [common]="pnpm run check-fmt:clj && pnpm run check-fmt:js" + [render-wasm]="cargo fmt --check" + [exporter]="pnpm run check-fmt" + [mcp]="pnpm run fmt:check" + [plugins]="pnpm run format:check" + [library]="pnpm run check-fmt" +) + +declare -A FMT_FIX_CMD=( + [frontend]="pnpm run fmt" + [backend]="pnpm run fmt" + [common]="pnpm run fmt:clj && pnpm run fmt:js" + [render-wasm]="cargo fmt" + [exporter]="pnpm run fmt" + [mcp]="pnpm run fmt" + [plugins]="pnpm run format" + [library]="pnpm run fmt" +) + +# Default options +MODULES=() +TASKS=("lint" "test" "fmt") +FIX_MODE=false +FAIL_FAST=false +VERBOSE=true +DRY_RUN=false + +# Results tracking +declare -A RESULTS=() +declare -a FAILED_LOGS=() + +timestamp() { + date "+%H:%M:%S" +} + +log_info() { + echo -e "${BLUE}[$(timestamp)]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[$(timestamp)] ✓${NC} $1" +} + +log_error() { + echo -e "${RED}[$(timestamp)] ✗${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[$(timestamp)] ⚠${NC} $1" +} + +log_header() { + local module=$1 + local current=$2 + local total=$3 + echo "" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${CYAN} ${BOLD}[$current/$total] $module${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +} + +run_task() { + local module=$1 + local task=$2 + local cmd=$3 + local logfile="$LOG_DIR/${module}-${task}.log" + + if [[ -z "$cmd" ]]; then + log_warning "$task: not defined for $module, skipping" + RESULTS["$module:$task"]="SKIPPED" + return 0 + fi + + if [[ "$DRY_RUN" == "true" ]]; then + log_info "$task: would run '$cmd' in $module/" + RESULTS["$module:$task"]="DRY-RUN" + return 0 + fi + + log_info "Running ${BOLD}$task${NC} for $module..." + + mkdir -p "$LOG_DIR" + local start_time + start_time=$(date +%s) + + if [[ "$VERBOSE" == "true" ]]; then + echo -e "${BLUE} cmd: $cmd${NC}" + fi + + local exit_code=0 + (cd "$PROJECT_ROOT/$module" && eval "$cmd") > "$logfile" 2>&1 || exit_code=$? + + local end_time + end_time=$(date +%s) + local duration=$((end_time - start_time)) + + if [[ $exit_code -eq 0 ]]; then + log_success "$task passed for $module ${BLUE}(${duration}s)${NC}" + RESULTS["$module:$task"]="PASSED" + return 0 + else + log_error "$task failed for $module ${BLUE}(${duration}s)${NC}" + echo -e " ${RED}log: $logfile${NC}" + RESULTS["$module:$task"]="FAILED" + FAILED_LOGS+=("$logfile") + if [[ "$VERBOSE" == "true" ]]; then + echo -e "${YELLOW} --- last 30 lines ---${NC}" + tail -n 30 "$logfile" | sed 's/^/ /' + echo -e "${YELLOW} ---------------------${NC}" + fi + return 1 + fi +} + +run_module() { + local module=$1 + local failed=false + + for task in "${TASKS[@]}"; do + local cmd="" + case $task in + lint) + cmd="${LINT_CMD[$module]:-}" + ;; + test) + cmd="${TEST_CMD[$module]:-}" + ;; + fmt) + if [[ "$FIX_MODE" == "true" ]]; then + cmd="${FMT_FIX_CMD[$module]:-}" + else + cmd="${FMT_CHECK_CMD[$module]:-}" + fi + ;; + esac + + if ! run_task "$module" "$task" "$cmd"; then + failed=true + if [[ "$FAIL_FAST" == "true" ]]; then + return 1 + fi + fi + done + + if [[ "$failed" == "true" ]]; then + return 1 + fi + return 0 +} + +print_summary() { + echo "" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${CYAN} ${BOLD}SUMMARY${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + local total=0 + local passed=0 + local failed=0 + local skipped=0 + + for module in "${MODULES[@]}"; do + for task in "${TASKS[@]}"; do + local result="${RESULTS[$module:$task]:-N/A}" + total=$((total + 1)) + case $result in + PASSED) + passed=$((passed + 1)) + echo -e " ${GREEN}✓${NC} $module:$task" + ;; + FAILED) + failed=$((failed + 1)) + echo -e " ${RED}✗${NC} $module:$task" + ;; + SKIPPED) + skipped=$((skipped + 1)) + echo -e " ${YELLOW}○${NC} $module:$task" + ;; + DRY-RUN) + echo -e " ${BLUE}~${NC} $module:$task" + ;; + esac + done + done + + echo "" + echo -e " ${BOLD}Total:${NC} $total ${GREEN}Passed:${NC} $passed ${RED}Failed:${NC} $failed ${YELLOW}Skipped:${NC} $skipped" + + if [[ $failed -gt 0 ]]; then + echo "" + echo -e " ${RED}${BOLD}Failed logs:${NC}" + for logfile in "${FAILED_LOGS[@]}"; do + echo -e " ${RED}•${NC} $logfile" + done + return 1 + fi + return 0 +} + +validate_module() { + local mod=$1 + for valid in "${ALL_MODULES[@]}"; do + if [[ "$valid" == "$mod" ]]; then + return 0 + fi + done + return 1 +} + +usage() { + cat <&2 + echo "Run '$(basename "$0") --help' for usage." >&2 + exit 1 + ;; + *) + if validate_module "$1"; then + MODULES+=("$1") + else + echo -e "${RED}Error: Unknown module '$1'${NC}" >&2 + echo "Valid modules: ${ALL_MODULES[*]}" >&2 + exit 1 + fi + shift + ;; + esac + done + + # Clean empty entries from TASKS after removal + TASKS=("${TASKS[@]// /}") + TASKS=("${TASKS[@]/#/}") + local clean_tasks=() + for t in "${TASKS[@]}"; do + if [[ -n "$t" ]]; then + clean_tasks+=("$t") + fi + done + TASKS=("${clean_tasks[@]}") + + # Set modules + if [[ "$run_all" == "true" ]]; then + MODULES=("${ALL_MODULES[@]}") + fi + + # Apply exclusions + if [[ ${#exclude_modules[@]} -gt 0 ]]; then + local filtered=() + for mod in "${MODULES[@]}"; do + local excluded=false + for ex in "${exclude_modules[@]}"; do + if [[ "$mod" == "$ex" ]]; then + excluded=true + break + fi + done + if [[ "$excluded" == "false" ]]; then + filtered+=("$mod") + fi + done + MODULES=("${filtered[@]}") + fi + + # Validate + if [[ ${#MODULES[@]} -eq 0 ]]; then + echo -e "${RED}Error: No modules specified.${NC}" >&2 + echo "Use --all or specify modules: ${ALL_MODULES[*]}" >&2 + exit 1 + fi + + if [[ ${#TASKS[@]} -eq 0 ]]; then + echo -e "${RED}Error: No tasks selected.${NC}" >&2 + exit 1 + fi + + # Print configuration + echo "" + echo -e "${CYAN}${BOLD}Penpot CI Orchestrator${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " ${BOLD}Modules:${NC} ${MODULES[*]}" + echo -e " ${BOLD}Tasks:${NC} ${TASKS[*]}" + echo -e " ${BOLD}Fix mode:${NC} $FIX_MODE" + echo -e " ${BOLD}Fail-fast:${NC} $FAIL_FAST" + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${BOLD}Mode:${NC} ${YELLOW}DRY RUN${NC}" + fi + echo "" + + # Run + local total_modules=${#MODULES[@]} + local current=0 + local modules_failed=0 + local start_time + start_time=$(date +%s) + + for module in "${MODULES[@]}"; do + current=$((current + 1)) + log_header "$module" "$current" "$total_modules" + + if ! run_module "$module"; then + modules_failed=$((modules_failed + 1)) + if [[ "$FAIL_FAST" == "true" ]]; then + log_error "Fail-fast: stopping due to failure in $module" + break + fi + fi + done + + local end_time + end_time=$(date +%s) + local total_duration=$((end_time - start_time)) + + print_summary || true + + echo "" + echo -e " ${BOLD}Duration:${NC} ${total_duration}s" + echo "" + + if [[ $modules_failed -gt 0 ]]; then + exit 1 + fi + exit 0 +} + +main "$@"