diff --git a/.github/workflows/git-commands.yaml b/.github/workflows/git-commands.yaml index c12b102c46..4dbdf36ed1 100644 --- a/.github/workflows/git-commands.yaml +++ b/.github/workflows/git-commands.yaml @@ -88,6 +88,8 @@ jobs: run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" + git config pull.rebase true + git pull origin ${{ env.BRANCH_NAME }} git add backend/.sqlx git commit -m "Update SQLx metadata" git push origin ${{ env.BRANCH_NAME }} @@ -158,6 +160,8 @@ jobs: git checkout $BRANCH_NAME git config --local user.email "action@github.com" git config --local user.name "GitHub Action" + git config pull.rebase true + git pull origin $BRANCH_NAME git add backend/ee-repo-ref.txt git commit -m "Update ee-repo-ref.txt" || echo "No changes to commit" git push origin $BRANCH_NAME diff --git a/CHANGELOG.md b/CHANGELOG.md index 2674aeee12..0b21f02d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [1.507.0](https://github.com/windmill-labs/windmill/compare/v1.506.0...v1.507.0) (2025-07-15) + + +### Features + +* git sync improvements ([#6182](https://github.com/windmill-labs/windmill/issues/6182)) ([aa37f64](https://github.com/windmill-labs/windmill/commit/aa37f643e78cae09be6246e0a2e84776b60afa49)) +* multi s3 arg input ([#6187](https://github.com/windmill-labs/windmill/issues/6187)) ([9053a93](https://github.com/windmill-labs/windmill/commit/9053a931ce4d075eddd8063de73ea95c3a346937)) + + +### Bug Fixes + +* **frontend:** fix dev graph not loading ([#6190](https://github.com/windmill-labs/windmill/issues/6190)) ([885f711](https://github.com/windmill-labs/windmill/commit/885f711e03e4430d3fc4ab9756c94f0182b20bcc)) +* only close app dropdown when action is done ([cca8e74](https://github.com/windmill-labs/windmill/commit/cca8e748aa68d41da5f1f8196a02d72d8ca84547)) +* prevent worker not exiting if special case of same worker job ([2e1b6c1](https://github.com/windmill-labs/windmill/commit/2e1b6c1947e5e7f66061c5b380ce0483342df706)) + +## [1.506.0](https://github.com/windmill-labs/windmill/compare/v1.505.4...v1.506.0) (2025-07-15) + + +### Features + +* add oauth client_credentials support ([#6110](https://github.com/windmill-labs/windmill/issues/6110)) ([d562625](https://github.com/windmill-labs/windmill/commit/d562625474260d16699329884dd6c3b890f808ad)) +* **frontend:** app static and user resource picker default values ([#6179](https://github.com/windmill-labs/windmill/issues/6179)) ([b58c46a](https://github.com/windmill-labs/windmill/commit/b58c46a50452bce098695f52220946f87d62f840)) + +## [1.505.4](https://github.com/windmill-labs/windmill/compare/v1.505.3...v1.505.4) (2025-07-14) + + +### Bug Fixes + +* retry telemetry and renewal ([#6175](https://github.com/windmill-labs/windmill/issues/6175)) ([00ab0e8](https://github.com/windmill-labs/windmill/commit/00ab0e8f3883d2ef884438a957b8d26a4672836d)) + ## [1.505.3](https://github.com/windmill-labs/windmill/compare/v1.505.2...v1.505.3) (2025-07-14) diff --git a/backend/.sqlx/query-dc649111c4c862c36c26ad8a9eff8c31c2533a4a73184d0276f9a388ff2fa574.json b/backend/.sqlx/query-00418f2e621cbe7aed536cff357898f7250544dfcb3bd85d314af1058f3148d8.json similarity index 57% rename from backend/.sqlx/query-dc649111c4c862c36c26ad8a9eff8c31c2533a4a73184d0276f9a388ff2fa574.json rename to backend/.sqlx/query-00418f2e621cbe7aed536cff357898f7250544dfcb3bd85d314af1058f3148d8.json index f6ec69af57..b59c2e80e5 100644 --- a/backend/.sqlx/query-dc649111c4c862c36c26ad8a9eff8c31c2533a4a73184d0276f9a388ff2fa574.json +++ b/backend/.sqlx/query-00418f2e621cbe7aed536cff357898f7250544dfcb3bd85d314af1058f3148d8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4) RETURNING id", + "query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7) RETURNING id", "describe": { "columns": [ { @@ -14,6 +14,9 @@ "Varchar", "Varchar", "Text", + "Varchar", + "Varchar", + "Varchar", "Varchar" ] }, @@ -21,5 +24,5 @@ false ] }, - "hash": "dc649111c4c862c36c26ad8a9eff8c31c2533a4a73184d0276f9a388ff2fa574" + "hash": "00418f2e621cbe7aed536cff357898f7250544dfcb3bd85d314af1058f3148d8" } diff --git a/backend/.sqlx/query-115544a96173f9cb1d27757e7b931fb27912cfd05ba768a42cf9b3dfd7205e9a.json b/backend/.sqlx/query-115544a96173f9cb1d27757e7b931fb27912cfd05ba768a42cf9b3dfd7205e9a.json index 66a2152ae9..1842f8ab06 100644 --- a/backend/.sqlx/query-115544a96173f9cb1d27757e7b931fb27912cfd05ba768a42cf9b3dfd7205e9a.json +++ b/backend/.sqlx/query-115544a96173f9cb1d27757e7b931fb27912cfd05ba768a42cf9b3dfd7205e9a.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-1488e1b5007752e1ebae4235ad04c398fe6398745e16fd119008b8ea67662416.json b/backend/.sqlx/query-1488e1b5007752e1ebae4235ad04c398fe6398745e16fd119008b8ea67662416.json index 197298f788..8217ec4576 100644 --- a/backend/.sqlx/query-1488e1b5007752e1ebae4235ad04c398fe6398745e16fd119008b8ea67662416.json +++ b/backend/.sqlx/query-1488e1b5007752e1ebae4235ad04c398fe6398745e16fd119008b8ea67662416.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-299e16725162888c01712f371785199960264b54c1ddf928c0c654ab15176f63.json b/backend/.sqlx/query-299e16725162888c01712f371785199960264b54c1ddf928c0c654ab15176f63.json index d64d8f404b..c4c33fe959 100644 --- a/backend/.sqlx/query-299e16725162888c01712f371785199960264b54c1ddf928c0c654ab15176f63.json +++ b/backend/.sqlx/query-299e16725162888c01712f371785199960264b54c1ddf928c0c654ab15176f63.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-5bd8ae8d694ac9f6afef762276141d95ba195f5543120e76e68f6e6715b65a71.json b/backend/.sqlx/query-5bd8ae8d694ac9f6afef762276141d95ba195f5543120e76e68f6e6715b65a71.json index 31700319e2..e3b0e9a8da 100644 --- a/backend/.sqlx/query-5bd8ae8d694ac9f6afef762276141d95ba195f5543120e76e68f6e6715b65a71.json +++ b/backend/.sqlx/query-5bd8ae8d694ac9f6afef762276141d95ba195f5543120e76e68f6e6715b65a71.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-661f472ff3860983322162420457f5033b9c9afc344d9c3e385ba20a3ad2197a.json b/backend/.sqlx/query-661f472ff3860983322162420457f5033b9c9afc344d9c3e385ba20a3ad2197a.json index 75b8108281..1fa370e682 100644 --- a/backend/.sqlx/query-661f472ff3860983322162420457f5033b9c9afc344d9c3e385ba20a3ad2197a.json +++ b/backend/.sqlx/query-661f472ff3860983322162420457f5033b9c9afc344d9c3e385ba20a3ad2197a.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-6b776693091191f36eaf9e35fb3cabb01481a19bef5a956dc36cac41acad0e53.json b/backend/.sqlx/query-6b776693091191f36eaf9e35fb3cabb01481a19bef5a956dc36cac41acad0e53.json index 97079df64f..882342e23f 100644 --- a/backend/.sqlx/query-6b776693091191f36eaf9e35fb3cabb01481a19bef5a956dc36cac41acad0e53.json +++ b/backend/.sqlx/query-6b776693091191f36eaf9e35fb3cabb01481a19bef5a956dc36cac41acad0e53.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-74d928f4c3f0de191f414471b9a4fbe9c20f9685b06ad5bbded424948b2dc88c.json b/backend/.sqlx/query-74d928f4c3f0de191f414471b9a4fbe9c20f9685b06ad5bbded424948b2dc88c.json index 0f72e41b9f..784a21b801 100644 --- a/backend/.sqlx/query-74d928f4c3f0de191f414471b9a4fbe9c20f9685b06ad5bbded424948b2dc88c.json +++ b/backend/.sqlx/query-74d928f4c3f0de191f414471b9a4fbe9c20f9685b06ad5bbded424948b2dc88c.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-92c2b66eb6287449f5f8cc9f8d1329f748006ab131489d2fcac21d32641bb633.json b/backend/.sqlx/query-92c2b66eb6287449f5f8cc9f8d1329f748006ab131489d2fcac21d32641bb633.json index 14d37809d5..a60730197f 100644 --- a/backend/.sqlx/query-92c2b66eb6287449f5f8cc9f8d1329f748006ab131489d2fcac21d32641bb633.json +++ b/backend/.sqlx/query-92c2b66eb6287449f5f8cc9f8d1329f748006ab131489d2fcac21d32641bb633.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-92e60af0d3ae8c73d74ae68d70e20ae18f79cc84626097b766307cd42722baa3.json b/backend/.sqlx/query-92e60af0d3ae8c73d74ae68d70e20ae18f79cc84626097b766307cd42722baa3.json index acc6769652..ec393b4218 100644 --- a/backend/.sqlx/query-92e60af0d3ae8c73d74ae68d70e20ae18f79cc84626097b766307cd42722baa3.json +++ b/backend/.sqlx/query-92e60af0d3ae8c73d74ae68d70e20ae18f79cc84626097b766307cd42722baa3.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-99a2c935acf5d6bbeb70ea1255679115b6e9042800d40899a36b3867049c5c46.json b/backend/.sqlx/query-99a2c935acf5d6bbeb70ea1255679115b6e9042800d40899a36b3867049c5c46.json index f38b293acb..f8c8963a18 100644 --- a/backend/.sqlx/query-99a2c935acf5d6bbeb70ea1255679115b6e9042800d40899a36b3867049c5c46.json +++ b/backend/.sqlx/query-99a2c935acf5d6bbeb70ea1255679115b6e9042800d40899a36b3867049c5c46.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json index 54e94cfb8f..99269c9851 100644 --- a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json +++ b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json @@ -18,8 +18,8 @@ "Left": [] }, "nullable": [ - false, - true + true, + false ] }, "hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76" diff --git a/backend/.sqlx/query-b9d57959f2696f74e9d24f55fb78ff9e9041ab84b896a8b980df7b550ef27a22.json b/backend/.sqlx/query-b9d57959f2696f74e9d24f55fb78ff9e9041ab84b896a8b980df7b550ef27a22.json new file mode 100644 index 0000000000..02f737b21c --- /dev/null +++ b/backend/.sqlx/query-b9d57959f2696f74e9d24f55fb78ff9e9041ab84b896a8b980df7b550ef27a22.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret FROM account WHERE workspace_id = $1 AND id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "client", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "refresh_token", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "grant_type", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cc_client_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "cc_client_secret", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "b9d57959f2696f74e9d24f55fb78ff9e9041ab84b896a8b980df7b550ef27a22" +} diff --git a/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json b/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json index 4843d959c1..b378d68f5b 100644 --- a/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json +++ b/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-cd5d62d456b74237b941bc72ea8de007185263167bf1dfa2e469d319cc4da674.json b/backend/.sqlx/query-cd5d62d456b74237b941bc72ea8de007185263167bf1dfa2e469d319cc4da674.json index 912b9a53de..f550276925 100644 --- a/backend/.sqlx/query-cd5d62d456b74237b941bc72ea8de007185263167bf1dfa2e469d319cc4da674.json +++ b/backend/.sqlx/query-cd5d62d456b74237b941bc72ea8de007185263167bf1dfa2e469d319cc4da674.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-d06efdc24706e0d7479bffc0b19a0c5976ee60125289e9f7b0b04090bce4a3a3.json b/backend/.sqlx/query-d06efdc24706e0d7479bffc0b19a0c5976ee60125289e9f7b0b04090bce4a3a3.json index 79fe0873dd..23b70ac4d2 100644 --- a/backend/.sqlx/query-d06efdc24706e0d7479bffc0b19a0c5976ee60125289e9f7b0b04090bce4a3a3.json +++ b/backend/.sqlx/query-d06efdc24706e0d7479bffc0b19a0c5976ee60125289e9f7b0b04090bce4a3a3.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-d78ecf85c1e1e95650c380c9488aa90d8d8c5c76f3971484e4c515ff60293d3b.json b/backend/.sqlx/query-d78ecf85c1e1e95650c380c9488aa90d8d8c5c76f3971484e4c515ff60293d3b.json index cdb8b928b3..1078957cf6 100644 --- a/backend/.sqlx/query-d78ecf85c1e1e95650c380c9488aa90d8d8c5c76f3971484e4c515ff60293d3b.json +++ b/backend/.sqlx/query-d78ecf85c1e1e95650c380c9488aa90d8d8c5c76f3971484e4c515ff60293d3b.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-cac594031a21b4806de9c4616317d3541522ef9712a83ecff7bd8b5f6e870748.json b/backend/.sqlx/query-db9831a3218b36c32b305b9f2ba21abceec74c9c277f27231dba12868e68126b.json similarity index 66% rename from backend/.sqlx/query-cac594031a21b4806de9c4616317d3541522ef9712a83ecff7bd8b5f6e870748.json rename to backend/.sqlx/query-db9831a3218b36c32b305b9f2ba21abceec74c9c277f27231dba12868e68126b.json index 3a1add0bef..3562a6f345 100644 --- a/backend/.sqlx/query-cac594031a21b4806de9c4616317d3541522ef9712a83ecff7bd8b5f6e870748.json +++ b/backend/.sqlx/query-db9831a3218b36c32b305b9f2ba21abceec74c9c277f27231dba12868e68126b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE account SET refresh_token = $1, expires_at = $2 WHERE workspace_id = $3 AND id = $4", + "query": "UPDATE account SET refresh_token = $1, expires_at = $2, refresh_error = NULL WHERE workspace_id = $3 AND id = $4", "describe": { "columns": [], "parameters": { @@ -13,5 +13,5 @@ }, "nullable": [] }, - "hash": "cac594031a21b4806de9c4616317d3541522ef9712a83ecff7bd8b5f6e870748" + "hash": "db9831a3218b36c32b305b9f2ba21abceec74c9c277f27231dba12868e68126b" } diff --git a/backend/.sqlx/query-f16b00bad2880f896f4452e5894fe101cd5c2fe3e4c143c0b979668143d85dd2.json b/backend/.sqlx/query-f16b00bad2880f896f4452e5894fe101cd5c2fe3e4c143c0b979668143d85dd2.json index a1ce88bd88..90413de23c 100644 --- a/backend/.sqlx/query-f16b00bad2880f896f4452e5894fe101cd5c2fe3e4c143c0b979668143d85dd2.json +++ b/backend/.sqlx/query-f16b00bad2880f896f4452e5894fe101cd5c2fe3e4c143c0b979668143d85dd2.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-fbe14569717b4c4937b74e6dc6f6b4ea29a2b0c2b1cde48ef06d687f6f9e8f15.json b/backend/.sqlx/query-fbe14569717b4c4937b74e6dc6f6b4ea29a2b0c2b1cde48ef06d687f6f9e8f15.json deleted file mode 100644 index 642572bfe8..0000000000 --- a/backend/.sqlx/query-fbe14569717b4c4937b74e6dc6f6b4ea29a2b0c2b1cde48ef06d687f6f9e8f15.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT client, refresh_token FROM account WHERE workspace_id = $1 AND id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "client", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "refresh_token", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Int4" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "fbe14569717b4c4937b74e6dc6f6b4ea29a2b0c2b1cde48ef06d687f6f9e8f15" -} diff --git a/backend/.sqlx/query-fc932ecf697b921fe9143c36872e9cc4e9aca7f9129b0466aacae51334a3db96.json b/backend/.sqlx/query-fc932ecf697b921fe9143c36872e9cc4e9aca7f9129b0466aacae51334a3db96.json index 091d205b40..38f51ee611 100644 --- a/backend/.sqlx/query-fc932ecf697b921fe9143c36872e9cc4e9aca7f9129b0466aacae51334a3db96.json +++ b/backend/.sqlx/query-fc932ecf697b921fe9143c36872e9cc4e9aca7f9129b0466aacae51334a3db96.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "bool", "type_info": "Bool" } ], diff --git a/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json b/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json index 649ab85650..de2e819af2 100644 --- a/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json +++ b/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json @@ -41,11 +41,11 @@ ] }, "nullable": [ - false, - false, - false, - false, - false, + true, + true, + true, + true, + true, true ] }, diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 2e666cddb8..5c78cefddc 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -5059,7 +5059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -10493,7 +10493,8 @@ dependencies = [ [[package]] name = "rmcp" version = "0.2.1" -source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=c74b26b3e3c5dfd0be8e85dede8d88f33d46ff06#c74b26b3e3c5dfd0be8e85dede8d88f33d46ff06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37f2048a81a7ff7e8ef6bc5abced70c3d9114c8f03d85d7aaaafd9fd04f12e9e" dependencies = [ "base64 0.22.1", "bytes", @@ -10522,7 +10523,8 @@ dependencies = [ [[package]] name = "rmcp-macros" version = "0.2.1" -source = "git+https://github.com/modelcontextprotocol/rust-sdk?rev=c74b26b3e3c5dfd0be8e85dede8d88f33d46ff06#c74b26b3e3c5dfd0be8e85dede8d88f33d46ff06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72398e694b9f6dbb5de960cf158c8699e6a1854cb5bbaac7de0646b2005763c4" dependencies = [ "darling 0.20.11", "proc-macro2", @@ -10719,15 +10721,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -12904,7 +12906,7 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -12923,7 +12925,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" dependencies = [ - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -14674,7 +14676,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "axum", @@ -14726,7 +14728,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "argon2", @@ -14837,7 +14839,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.505.3" +version = "1.507.0" dependencies = [ "base64 0.22.1", "chrono", @@ -14852,7 +14854,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.505.3" +version = "1.507.0" dependencies = [ "chrono", "serde", @@ -14865,7 +14867,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "serde", @@ -14879,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "async-stream", @@ -14957,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.505.3" +version = "1.507.0" dependencies = [ "regex", "serde", @@ -14971,7 +14973,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "bytes", @@ -14994,7 +14996,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.505.3" +version = "1.507.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15006,7 +15008,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.505.3" +version = "1.507.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15015,7 +15017,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "lazy_static", @@ -15027,7 +15029,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "serde_json", @@ -15039,7 +15041,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "gosyn", @@ -15051,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "lazy_static", @@ -15063,7 +15065,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "serde_json", @@ -15075,7 +15077,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "nu-parser", @@ -15086,7 +15088,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15097,7 +15099,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15109,7 +15111,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "async-recursion", @@ -15132,7 +15134,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15149,7 +15151,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "lazy_static", @@ -15163,7 +15165,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "lazy_static", @@ -15181,7 +15183,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "getrandom 0.2.16", @@ -15205,7 +15207,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "serde_json", @@ -15215,7 +15217,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "async-recursion", @@ -15248,7 +15250,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.505.3" +version = "1.507.0" dependencies = [ "wasm-bindgen", "wasm-bindgen-test", @@ -15258,7 +15260,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.505.3" +version = "1.507.0" dependencies = [ "anyhow", "async-recursion", @@ -15900,7 +15902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af3a19837351dc82ba89f8a125e22a3c475f05aba604acc023d62b2739ae2909" dependencies = [ "libc", - "rustix 1.0.7", + "rustix 1.0.8", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a514766c93..7804c9b065 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.505.3" +version = "1.507.0" authors.workspace = true edition.workspace = true @@ -32,7 +32,7 @@ members = [ ] [workspace.package] -version = "1.505.3" +version = "1.507.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e3e3dd5463..653071700b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -17b17f57e4c777e7e2ffc0e5def317f017a1683d \ No newline at end of file +3cccbe81494f7edd113dce2812278b00c91160c0 \ No newline at end of file diff --git a/backend/migrations/20250630164531_client_credentials_oauth.down.sql b/backend/migrations/20250630164531_client_credentials_oauth.down.sql new file mode 100644 index 0000000000..dfc87ec4bc --- /dev/null +++ b/backend/migrations/20250630164531_client_credentials_oauth.down.sql @@ -0,0 +1,4 @@ +-- Rollback client_credentials OAuth flow support +ALTER TABLE account DROP COLUMN IF EXISTS grant_type; +ALTER TABLE account DROP COLUMN IF EXISTS cc_client_id; +ALTER TABLE account DROP COLUMN IF EXISTS cc_client_secret; diff --git a/backend/migrations/20250630164531_client_credentials_oauth.up.sql b/backend/migrations/20250630164531_client_credentials_oauth.up.sql new file mode 100644 index 0000000000..c3e87bd82a --- /dev/null +++ b/backend/migrations/20250630164531_client_credentials_oauth.up.sql @@ -0,0 +1,10 @@ +-- Add support for client_credentials OAuth flow +-- Add grant_type column to distinguish between OAuth flows for backwards compatibility +ALTER TABLE account ADD COLUMN grant_type VARCHAR(50) NOT NULL DEFAULT 'authorization_code'; + +-- Add client_id and client_secret columns for resource-level client credentials +-- These are used when grant_type = 'client_credentials' and user wants to use resource-level credentials +-- instead of instance-level credentials configured by admin +ALTER TABLE account +ADD COLUMN cc_client_id VARCHAR(500), +ADD COLUMN cc_client_secret VARCHAR(500); diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 6473d088e3..3e3b6d5752 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -40,7 +40,7 @@ mcp = ["dep:rmcp"] python = [] [dependencies] -rmcp = { git = "https://github.com/modelcontextprotocol/rust-sdk", rev = "c74b26b3e3c5dfd0be8e85dede8d88f33d46ff06", features=["transport-streamable-http-server", "transport-streamable-http-server-session", "transport-worker"], optional = true } +rmcp = { version = "0.2.1", features=["transport-streamable-http-server", "transport-streamable-http-server-session", "transport-worker"], optional = true } windmill-queue.workspace = true windmill-common = { workspace = true, default-features = false } windmill-audit.workspace = true diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5064f25a0e..a134b1bc9f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.505.3 + version: 1.507.0 title: Windmill API contact: @@ -581,7 +581,7 @@ paths: summary: get connected repositories operationId: getGlobalConnectedRepositories tags: - - git_sync + - Git Sync responses: "200": description: connected repositories @@ -3388,6 +3388,15 @@ paths: type: integer client: type: string + grant_type: + type: string + default: "authorization_code" + cc_client_id: + type: string + description: "OAuth client ID for resource-level credentials (client_credentials flow only)" + cc_client_secret: + type: string + description: "OAuth client secret for resource-level credentials (client_credentials flow only)" required: - expires_in - client @@ -3399,6 +3408,48 @@ paths: schema: type: string + /oauth/connect_client_credentials/{client}: + post: + summary: connect OAuth using client credentials + operationId: connectClientCredentials + tags: + - oauth + parameters: + - name: client + in: path + description: OAuth client name + required: true + schema: + type: string + requestBody: + description: client credentials flow parameters + required: true + content: + application/json: + schema: + type: object + properties: + scopes: + type: array + items: + type: string + cc_client_id: + type: string + description: "OAuth client ID for resource-level authentication" + cc_client_secret: + type: string + description: "OAuth client secret for resource-level authentication" + required: + - cc_client_id + - cc_client_secret + responses: + "200": + description: OAuth token response + content: + application/json: + schema: + $ref: "#/components/schemas/TokenResponse" + /w/{workspace}/oauth/refresh_token/{id}: post: summary: refresh token @@ -3550,6 +3601,10 @@ paths: type: array items: type: string + grant_types: + type: array + items: + type: string /teams/sync: post: @@ -12283,8 +12338,7 @@ paths: "200": description: Parquet Preview content: - application/json: - schema: {} + application/json: {} /w/{workspace}/job_helpers/load_table_count/{path}: get: @@ -12363,8 +12417,7 @@ paths: "200": description: Csv Preview content: - application/json: - schema: {} + application/json: {} /w/{workspace}/job_helpers/delete_s3_file: delete: @@ -16858,6 +16911,8 @@ components: type: array items: type: string + grant_type: + type: string required: - access_token @@ -16983,27 +17038,6 @@ components: WorkspaceGitSyncSettings: type: object properties: - include_path: - type: array - items: - type: string - include_type: - type: array - items: - type: string - enum: - - script - - flow - - app - - folder - - resource - - variable - - secret - - resourcetype - - schedule - - user - - group - - trigger repositories: type: array items: @@ -17055,6 +17089,42 @@ components: type: boolean group_by_folder: type: boolean + collapsed: + type: boolean + settings: + type: object + properties: + include_path: + type: array + items: + type: string + include_type: + type: array + items: + type: string + enum: + - script + - flow + - app + - folder + - resource + - variable + - secret + - resourcetype + - schedule + - user + - group + - trigger + - settings + - key + exclude_path: + type: array + items: + type: string + extra_include_path: + type: array + items: + type: string exclude_types_override: type: array items: @@ -17072,6 +17142,8 @@ components: - user - group - trigger + - settings + - key required: - script_path - git_repo_resource_path diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 568f016479..5b894eea26 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -47,7 +47,7 @@ use windmill_common::{ oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH, utils::{paginate, rd_string, require_admin, Pagination}, }; -use windmill_git_sync::handle_deployment_metadata; +use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; #[cfg(feature = "enterprise")] use windmill_common::utils::require_admin_or_devops; @@ -636,6 +636,17 @@ async fn edit_deploy_to( .await?; tx.commit().await?; + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "deploy_to".to_string() }, + None, + false, + ) + .await?; + Ok(format!("Edit deploy to for {}", &w_id)) } @@ -701,6 +712,17 @@ async fn edit_webhook( .await?; tx.commit().await?; + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "webhook".to_string() }, + None, + false, + ) + .await?; + Ok(format!("Edit webhook for workspace {}", &w_id)) } @@ -741,6 +763,18 @@ async fn edit_copilot_config( .await?; tx.commit().await?; + // Trigger git sync for AI config changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "ai_config".to_string() }, + Some("AI configuration updated".to_string()), + false, + ) + .await?; + Ok(format!("Edit copilot config for workspace {}", &w_id)) } @@ -818,6 +852,20 @@ async fn edit_large_file_storage_config( } tx.commit().await?; + // Trigger git sync for large file storage changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { + setting_type: "large_file_storage".to_string(), + }, + Some("Large file storage configuration updated".to_string()), + false, + ) + .await?; + Ok(format!( "Edit large file storage config for workspace {}", &w_id @@ -887,6 +935,18 @@ async fn edit_git_sync_config( } tx.commit().await?; + // Trigger git sync for git sync settings changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() }, + Some("Git sync configuration updated".to_string()), + false, + ) + .await?; + Ok(format!("Edit git sync config for workspace {}", &w_id)) } @@ -1014,6 +1074,18 @@ async fn edit_default_scripts( } tx.commit().await?; + // Trigger git sync for default scripts changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "default_scripts".to_string() }, + Some("Default scripts configuration updated".to_string()), + false, + ) + .await?; + Ok(format!("Edit default scripts for workspace {}", &w_id)) } @@ -1084,6 +1156,18 @@ async fn edit_default_app( } tx.commit().await?; + // Trigger git sync for default app changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "default_app".to_string() }, + Some("Default app configuration updated".to_string()), + false, + ) + .await?; + Ok(format!("Edit default app for workspace {}", &w_id)) } @@ -1160,6 +1244,18 @@ async fn edit_error_handler( .await?; tx.commit().await?; + // Trigger git sync for error handler changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "error_handler".to_string() }, + Some("Error handler configuration updated".to_string()), + false, + ) + .await?; + Ok(format!("Edit error_handler for workspace {}", &w_id)) } @@ -1201,6 +1297,7 @@ async fn set_environment_variable( ) .await?; tx.commit().await?; + Ok(format!("Set environment variable {}", name)) } None => { @@ -1223,6 +1320,7 @@ async fn set_environment_variable( ) .await?; tx.commit().await?; + Ok(format!("Deleted environment variable {}", name)) } } @@ -1316,6 +1414,18 @@ async fn set_encryption_key( } } + // Trigger git sync for encryption key changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Key { key_type: "encryption_key".to_string() }, + Some("Encryption key updated".to_string()), + false, + ) + .await?; + return Ok(()); } @@ -2053,6 +2163,18 @@ async fn change_workspace_name( tx.commit().await?; + // Trigger git sync for workspace name changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { setting_type: "workspace_name".to_string() }, + Some(format!("Workspace name updated to {}", &rw.new_name)), + false, + ) + .await?; + Ok(format!("updated workspace name to {}", &rw.new_name)) } @@ -2076,6 +2198,17 @@ async fn change_workspace_color( tx.commit().await?; + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "workspace_color".to_string() }, + None, + false, + ) + .await?; + Ok(format!( "updated workspace color to {}", rw.color.as_deref().unwrap_or("no color") @@ -2154,10 +2287,10 @@ pub struct MuteCriticalAlertRequest { async fn mute_critical_alerts( Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, + authed: ApiAuthed, Json(m_r): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let mute_alerts = m_r.mute_critical_alerts.unwrap_or(false); @@ -2178,6 +2311,17 @@ async fn mute_critical_alerts( .execute(&db) .await?; + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Settings { setting_type: "critical_alerts".to_string() }, + None, + false, + ) + .await?; + Ok(format!( "Updated mute criticital alert ui settings for workspace: {}", &w_id @@ -2225,5 +2369,19 @@ async fn update_operator_settings( tx.commit().await?; + // Trigger git sync for operator settings changes + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { + setting_type: "operator_settings".to_string(), + }, + Some("Operator settings updated".to_string()), + false, + ) + .await?; + Ok("Operator settings updated successfully".to_string()) } diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 1238d5b243..1148af5ee4 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -3,9 +3,15 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Default)] pub struct WorkspaceGitSyncSettings { - pub include_path: Vec, - pub include_type: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_type: Option>, pub repositories: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude_path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_include_path: Option>, } #[derive(Serialize, Deserialize, Debug, Default)] @@ -14,7 +20,7 @@ pub struct WorkspaceDeploymentUISettings { pub include_type: Vec, } -#[derive(Serialize, Deserialize, PartialEq, Debug)] +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))] pub enum ObjectType { Script, @@ -29,15 +35,41 @@ pub enum ObjectType { User, Group, Trigger, + Settings, + Key, } #[derive(Serialize, Deserialize, Debug)] pub struct GitRepositorySettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude_types_override: Option>, pub script_path: String, pub git_repo_resource_path: String, pub use_individual_branch: Option, pub group_by_folder: Option, - pub exclude_types_override: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct GitSyncSettings { + pub include_path: Vec, + pub include_type: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude_path: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_include_path: Option>, +} + +impl Default for GitSyncSettings { + fn default() -> Self { + Self { + include_path: Vec::new(), + include_type: Vec::new(), + exclude_path: None, + extra_include_path: None, + } + } } lazy_static::lazy_static! { diff --git a/backend/windmill-git-sync/src/lib.rs b/backend/windmill-git-sync/src/lib.rs index d4e4ebf71f..55ab86af3f 100644 --- a/backend/windmill-git-sync/src/lib.rs +++ b/backend/windmill-git-sync/src/lib.rs @@ -37,6 +37,8 @@ pub enum DeployedObject { MqttTrigger { path: String }, SqsTrigger { path: String }, GcpTrigger { path: String }, + Settings { setting_type: String }, + Key { key_type: String }, } impl DeployedObject { @@ -60,12 +62,14 @@ impl DeployedObject { DeployedObject::MqttTrigger { path } => path.to_owned(), DeployedObject::SqsTrigger { path } => path.to_owned(), DeployedObject::GcpTrigger { path } => path.to_owned(), + DeployedObject::Settings { .. } => "settings.yaml".to_string(), + DeployedObject::Key { .. } => "encryption_key.yaml".to_string(), } } pub fn get_ignore_regex_filter(&self) -> bool { match self { - Self::User { .. } | Self::Group { .. } | Self::ResourceType { .. } => true, + Self::User { .. } | Self::Group { .. } | Self::ResourceType { .. } | Self::Settings { .. } | Self::Key { .. } => true, _ => false, } } @@ -90,6 +94,8 @@ impl DeployedObject { DeployedObject::MqttTrigger { .. } => None, DeployedObject::SqsTrigger { .. } => None, DeployedObject::GcpTrigger { .. } => None, + DeployedObject::Settings { .. } => None, + DeployedObject::Key { .. } => None, } } } diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index c901167c63..f6927df49f 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -2310,7 +2310,17 @@ pub async fn pull( query_o: Option<&(String, String)>, #[cfg(feature = "benchmark")] bench: &mut BenchmarkIter, ) -> windmill_common::error::Result { + let mut pull_loop_count = 0; loop { + pull_loop_count += 1; + if pull_loop_count % 10 == 0 { + tracing::warn!("Pull job loop count: {}", pull_loop_count); + tokio::task::yield_now().await; + } + if pull_loop_count > 1000 { + tracing::error!("Pull job loop count exceeded 1000, breaking"); + return Ok(PulledJobResult { job: None, suspended: false }); + } if let Some((query_suspended, query_no_suspend)) = query_o { let njob = { let job = if query_suspended.is_empty() { @@ -2513,6 +2523,14 @@ pub async fn pull( i += 1; estimated_next_schedule_timestamp = estimated_next_schedule_timestamp + inc; } + if i % 50 == 0 { + tracing::warn!("Window finding for job {} loop count: {}", job_uuid, pull_loop_count); + tokio::task::yield_now().await; + } + if i > 1000000000 { + tracing::error!("Window finding job loop count exceeded 1000000000, breaking"); + break; + } } tracing::info!("Job '{}' from path '{}' with concurrency key '{}' has reached its concurrency limit of {} jobs run in the last {} seconds. This job will be re-queued for next execution at {} (min_started_at: {min_started_at_or_now}, avg script duration: {:?}, number of time windows full: {})", diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 4303860fec..fc02098006 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -29,7 +29,7 @@ use windmill_queue::{ use serde_json::{json, value::RawValue, Value}; -use tokio::task::JoinHandle; +use tokio::{sync::Notify, task::JoinHandle}; use windmill_queue::{add_completed_job, add_completed_job_error}; @@ -112,6 +112,7 @@ async fn process_jc( enum JobCompletedRx { JobCompleted(SendResult), Killpill, + WakeUp, } pub fn start_background_processor( @@ -119,6 +120,7 @@ pub fn start_background_processor( job_completed_sender: JobCompletedSender, same_worker_queue_size: Arc, job_completed_processor_is_done: Arc, + wake_up_notify: Arc, last_processing_duration: Arc, base_internal_url: String, db: DB, @@ -155,7 +157,11 @@ pub fn start_background_processor( } result = bounded_rx.recv_async() => { result.ok().map(JobCompletedRx::JobCompleted) - } + }, + _ = wake_up_notify.notified() => { + tracing::info!("bg processor received wake up signal, checking if same worker queue is empty"); + Some(JobCompletedRx::WakeUp) + }, _ = killpill_rx.recv() => { tracing::info!("bg processor received killpill signal, queuing killpill job"); Some(JobCompletedRx::Killpill) @@ -254,6 +260,8 @@ pub fn start_background_processor( JobCompletedRx::Killpill => { tracing::info!("killpill job received, processing only same worker jobs"); has_been_killed = true; + }, + JobCompletedRx::WakeUp => { } } } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index a246dc3b99..7945f1eea1 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -1198,12 +1198,15 @@ pub async fn run_worker( let job_completed_processor_is_done = Arc::new(AtomicBool::new(matches!(conn, Connection::Http(_)))); + // This is used to wake up the background processor when main loop is done and just waiting for new same workers jobs, and that bg processor is also not processing any jobs, bg processing can exit if no more same worker jobs + let wake_up_notify = Arc::new(tokio::sync::Notify::new()); let send_result = match (conn, job_completed_rx) { (Connection::Sql(db), Some(job_completed_receiver)) => Some(start_background_processor( job_completed_receiver, job_completed_tx.clone(), same_worker_queue_size.clone(), job_completed_processor_is_done.clone(), + wake_up_notify.clone(), last_processing_duration.clone(), base_internal_url.to_string(), db.clone(), @@ -1489,6 +1492,7 @@ pub async fn run_worker( tracing::info!(worker = %worker_name, hostname = %hostname, "all running jobs have completed and all completed jobs have been fully processed, exiting"); break; } else { + wake_up_notify.notify_one(); tracing::info!(worker = %worker_name, hostname = %hostname, "there may be same_worker jobs to process later, waiting for job_completed_processor to finish progressing all remaining flows before exiting"); tokio::time::sleep(Duration::from_millis(200)).await; continue; diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index c54e5cd14c..b481a55c1d 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.505.3"; +export const VERSION = "v1.507.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/TESTING.md b/cli/TESTING.md new file mode 100644 index 0000000000..9928e75266 --- /dev/null +++ b/cli/TESTING.md @@ -0,0 +1,59 @@ +# Testing Guide for Windmill CLI + +## Running Tests + +```bash +# Run all tests +deno test -A --no-check test/ + +# Run specific test files +deno test -A --no-check test/gitsync_settings_features.test.ts +deno test -A --no-check test/init_no_git_sync.test.ts +deno test -A --no-check test/multi_instance_workspace.test.ts +deno test -A --no-check test/override_settings_behavior.test.ts +deno test -A --no-check test/sync_config_resolution.test.ts +deno test -A --no-check test/workspace_conflicts.test.ts + +# Run with specific test patterns +deno test -A --no-check test/ --filter "workspace" +deno test -A --no-check test/ --filter "sync" +``` + +## Test Files + +- **`gitsync_settings_features.test.ts`** - Git sync settings functionality +- **`init_no_git_sync.test.ts`** - Init without git sync +- **`multi_instance_workspace.test.ts`** - Multi-instance workspace handling +- **`override_settings_behavior.test.ts`** - Settings override behavior +- **`sync_config_resolution.test.ts`** - Sync configuration resolution +- **`workspace_conflicts.test.ts`** - Workspace conflict detection + +## Docker Requirements + +```bash +# Ensure Docker is running +docker --version +docker-compose --version + +# Ensure EE license key is available +echo $EE_LICENSE_KEY +``` + +## Debugging Failed Tests + +```bash +# Run with verbose output +deno test -A --no-check test/ --reporter=verbose + +# Check container status +docker ps + +# View backend logs +docker logs test-test_windmill_server-1 + +# Manual container management +cd test +docker compose -f docker-compose.test.yml up -d +docker compose -f docker-compose.test.yml down +docker compose -f docker-compose.test.yml down -v +``` \ No newline at end of file diff --git a/cli/conf.ts b/cli/conf.ts index e2a4c1cdbf..342eb423af 100644 --- a/cli/conf.ts +++ b/cli/conf.ts @@ -13,6 +13,10 @@ export interface SyncOptions { skipResources?: boolean; skipResourceTypes?: boolean; skipSecrets?: boolean; + skipScripts?: boolean; + skipFlows?: boolean; + skipApps?: boolean; + skipFolders?: boolean; includeSchedules?: boolean; includeTriggers?: boolean; includeUsers?: boolean; @@ -26,6 +30,8 @@ export interface SyncOptions { defaultTs?: "bun" | "deno"; codebases?: Codebase[]; parallel?: number; + jsonOutput?: boolean; + overrides?: { [key: string]: Partial }; } export interface Codebase { @@ -59,9 +65,74 @@ export async function readConfigFile(): Promise { } } +// Default sync options - shared across the codebase to prevent duplication +export const DEFAULT_SYNC_OPTIONS: Readonly>> = { + defaultTs: 'bun', + includes: ['f/**'], + excludes: [], + codebases: [], + skipVariables: false, + skipResources: false, + skipResourceTypes: false, + skipSecrets: true, + skipScripts: false, + skipFlows: false, + skipApps: false, + skipFolders: false, + includeSchedules: false, + includeTriggers: false, + includeUsers: false, + includeGroups: false, + includeSettings: false, + includeKey: false +} as const; + export async function mergeConfigWithConfigFile( opts: T ): Promise { const configFile = await readConfigFile(); return Object.assign(configFile ?? {}, opts); } + +// Get effective settings by merging top-level settings and overrides +export function getEffectiveSettings( + config: SyncOptions, + baseUrl: string, + workspaceId: string, + repo: string +): SyncOptions { + // Start with empty object - no defaults + let effective = {} as SyncOptions; + + // Merge top-level settings from config (which contains user's chosen defaults) + Object.keys(config).forEach(key => { + if (key !== 'overrides' && config[key as keyof SyncOptions] !== undefined) { + (effective as any)[key] = config[key as keyof SyncOptions]; + } + }); + + if (!config.overrides) { + return effective; + } + + // Construct override keys using the single format + const workspaceKey = `${baseUrl}:${workspaceId}:*`; + const repoKey = `${baseUrl}:${workspaceId}:${repo}`; + + // Apply workspace-level overrides + if (config.overrides[workspaceKey]) { + Object.assign(effective, config.overrides[workspaceKey]); + } + + // Apply repository-specific overrides (overrides workspace-level) + if (config.overrides[repoKey]) { + Object.assign(effective, config.overrides[repoKey]); + } + + return effective; +} diff --git a/cli/context.ts b/cli/context.ts index 2a0b67c7f8..c5fd7a4571 100644 --- a/cli/context.ts +++ b/cli/context.ts @@ -30,7 +30,7 @@ async function tryResolveWorkspace( if (cache) return { isError: false, value: cache }; if (opts.workspace) { - const e = await getWorkspaceByName(opts.workspace); + const e = await getWorkspaceByName(opts.workspace, opts.configDir); if (!e) { return { isError: true, @@ -57,8 +57,48 @@ export async function resolveWorkspace( ): Promise { if (opts.baseUrl) { if (opts.workspace && opts.token) { + const normalizedBaseUrl = new URL(opts.baseUrl).toString(); // add trailing slash if not present + + // Try to find existing workspace profile by name, then by workspaceId + remote + if (opts.workspace) { + // Try by workspace name first + let existingWorkspace = await getWorkspaceByName(opts.workspace, opts.configDir); + + // If not found by name, try to find by workspaceId + remote match + if (!existingWorkspace) { + const { allWorkspaces } = await import("./workspace.ts"); + const workspaces = await allWorkspaces(opts.configDir); + const matchingWorkspaces = workspaces.filter( + w => w.workspaceId === opts.workspace && w.remote === normalizedBaseUrl + ); + + // Due to uniqueness constraint, there can only be 0 or 1 match + if (matchingWorkspaces.length === 1) { + existingWorkspace = matchingWorkspaces[0]; + } + } + + if (existingWorkspace) { + // Validate that the base URL matches the profile's remote + if (existingWorkspace.remote !== normalizedBaseUrl) { + log.info( + colors.red( + `Base URL mismatch: --base-url is ${normalizedBaseUrl} but workspace profile "${opts.workspace}" uses ${existingWorkspace.remote}` + ) + ); + return Deno.exit(-1); + } + // Use the existing workspace profile (preserves workspace name) + return { + ...existingWorkspace, + token: opts.token, // Use the provided token + }; + } + } + + // No existing profile found, create temporary workspace return { - remote: new URL(opts.baseUrl).toString(), // add trailing slash if not present + remote: normalizedBaseUrl, workspaceId: opts.workspace, name: opts.workspace, token: opts.token, @@ -95,20 +135,26 @@ export async function requireLogin( try { return await wmill.globalWhoami(); - } catch { + } catch (error) { + // Check for network errors and provide clearer messages + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg.includes('fetch') || errorMsg.includes('connection') || errorMsg.includes('ECONNREFUSED') || errorMsg.includes('refused')) { + throw new Error(`Network error: Could not connect to Windmill server at ${workspace.remote}`); + } + log.info( "! Could not reach API given existing credentials. Attempting to reauth..." ); const newToken = await loginInteractive(workspace.remote); if (!newToken) { - throw new Error("Could not reauth"); + throw new Error("Unauthorized: Could not authenticate with the provided credentials"); } removeWorkspace(workspace.name, false, opts); workspace.token = newToken; addWorkspace(workspace, opts); setClient( - token, + newToken, workspace.remote.substring(0, workspace.remote.length - 1) ); return await wmill.globalWhoami(); @@ -130,6 +176,13 @@ export async function fetchVersion(baseUrl: string): Promise { new URL(new URL(baseUrl).origin + "/api/version"), { headers: requestHeaders, method: "GET" } ); + + if (!response.ok) { + // Consume response body even on error to avoid resource leak + await response.text(); + throw new Error(`Failed to fetch version: ${response.status} ${response.statusText}`); + } + return await response.text(); } export async function tryResolveVersion( diff --git a/cli/deno.lock b/cli/deno.lock index a0a4c6ce2e..a9317589bb 100644 --- a/cli/deno.lock +++ b/cli/deno.lock @@ -2184,6 +2184,258 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" } }, + "remote": { + "https://deno.land/std@0.207.0/yaml/_dumper/dumper.ts": "717403d0e700de783f2ef5c906b3d7245383e1509fc050e7ff5d4a53a03dbf40", + "https://deno.land/std@0.207.0/yaml/_dumper/dumper_state.ts": "f0d0673ceea288334061ca34b63954c2bb5feb5bf6de5e4cfe9a942cdf6e5efe", + "https://deno.land/std@0.207.0/yaml/_error.ts": "b59e2c76ce5a47b1b9fa0ff9f96c1dd92ea1e1b17ce4347ece5944a95c3c1a84", + "https://deno.land/std@0.207.0/yaml/_loader/loader.ts": "63ec7f0a265dbbabc54b25a4beefff7650e205160a2d75c7d8f8363b5f84851a", + "https://deno.land/std@0.207.0/yaml/_loader/loader_state.ts": "0841870b467169269d7c2dfa75cd288c319bc06f65edd9e42c29e5fced91c7a4", + "https://deno.land/std@0.207.0/yaml/_mark.ts": "dcd8585dee585e024475e9f3fe27d29740670fb64ebb970388094cad0fc11d5d", + "https://deno.land/std@0.207.0/yaml/_state.ts": "ef03d55ec235d48dcfbecc0ab3ade90bfae69a61094846e08003421c2cf5cfc6", + "https://deno.land/std@0.207.0/yaml/_type/binary.ts": "24d49614463a7339a8a16d894919c2ec18a10588ae360ec352093b60e2cc8b0d", + "https://deno.land/std@0.207.0/yaml/_type/bool.ts": "5bfa75da84343d45347b521ba4e5aeace9fe6f53447405290d53315a3fc20e66", + "https://deno.land/std@0.207.0/yaml/_type/float.ts": "056bd3cb9c5586238b20517511014fb24b0e36f98f9f6073e12da308b6b9808a", + "https://deno.land/std@0.207.0/yaml/_type/function.ts": "ff574fe84a750695302864e1c31b93f12d14ada4bde79a5f93197fc33ad17471", + "https://deno.land/std@0.207.0/yaml/_type/int.ts": "563ad074f0fa7aecf6b6c3d84135bcc95a8269dcc15de878de20ce868fd773fa", + "https://deno.land/std@0.207.0/yaml/_type/map.ts": "7b105e4ab03a361c61e7e335a0baf4d40f06460b13920e5af3fb2783a1464000", + "https://deno.land/std@0.207.0/yaml/_type/merge.ts": "8192bf3e4d637f32567917f48bb276043da9cf729cf594e5ec191f7cd229337e", + "https://deno.land/std@0.207.0/yaml/_type/mod.ts": "060e2b3d38725094b77ea3a3f05fc7e671fced8e67ca18e525be98c4aa8f4bbb", + "https://deno.land/std@0.207.0/yaml/_type/nil.ts": "606e8f0c44d73117c81abec822f89ef81e40f712258c74f186baa1af659b8887", + "https://deno.land/std@0.207.0/yaml/_type/omap.ts": "cfe59a294726f5cea705c39a61fd2b08199cf48f4ccd6b040cb550ec0f38d0a1", + "https://deno.land/std@0.207.0/yaml/_type/pairs.ts": "0032fdfe57558d21696a4f8cf5b5cfd1f698743177080affc18629685c905666", + "https://deno.land/std@0.207.0/yaml/_type/regexp.ts": "1ce118de15b2da43b4bd8e4395f42d448b731acf3bdaf7c888f40789f9a95f8b", + "https://deno.land/std@0.207.0/yaml/_type/seq.ts": "95333abeec8a7e4d967b8c8328b269e342a4bbdd2585395549b9c4f58c8533a2", + "https://deno.land/std@0.207.0/yaml/_type/set.ts": "f28ba44e632ef2a6eb580486fd47a460445eeddbdf1dbc739c3e62486f566092", + "https://deno.land/std@0.207.0/yaml/_type/str.ts": "a67a3c6e429d95041399e964015511779b1130ea5889fa257c48457bd3446e31", + "https://deno.land/std@0.207.0/yaml/_type/timestamp.ts": "706ea80a76a73e48efaeb400ace087da1f927647b53ad6f754f4e06d51af087f", + "https://deno.land/std@0.207.0/yaml/_type/undefined.ts": "94a316ca450597ccbc6750cbd79097ad0d5f3a019797eed3c841a040c29540ba", + "https://deno.land/std@0.207.0/yaml/_utils.ts": "26b311f0d42a7ce025060bd6320a68b50e52fd24a839581eb31734cd48e20393", + "https://deno.land/std@0.207.0/yaml/mod.ts": "28ecda6652f3e7a7735ee29c247bfbd32a2e2fc5724068e9fd173ec4e59f66f7", + "https://deno.land/std@0.207.0/yaml/parse.ts": "1fbbda572bf3fff578b6482c0d8b85097a38de3176bf3ab2ca70c25fb0c960ef", + "https://deno.land/std@0.207.0/yaml/schema.ts": "96908b78dc50c340074b93fc1598d5e7e2fe59103f89ff81e5a49b2dedf77a67", + "https://deno.land/std@0.207.0/yaml/schema/core.ts": "fa406f18ceedc87a50e28bb90ec7a4c09eebb337f94ef17468349794fa828639", + "https://deno.land/std@0.207.0/yaml/schema/default.ts": "0047e80ae8a4a93293bc4c557ae8a546aabd46bb7165b9d9b940d57b4d88bde9", + "https://deno.land/std@0.207.0/yaml/schema/extended.ts": "0784416bf062d20a1626b53c03380e265b3e39b9409afb9f4cb7d659fd71e60d", + "https://deno.land/std@0.207.0/yaml/schema/failsafe.ts": "d219ab5febc43f770917d8ec37735a4b1ad671149846cbdcade767832b42b92b", + "https://deno.land/std@0.207.0/yaml/schema/json.ts": "5f41dd7c2f1ad545ef6238633ce9ee3d444dfc5a18101e1768bd5504bf90e5e5", + "https://deno.land/std@0.207.0/yaml/schema/mod.ts": "4472e827bab5025e92bc2eb2eeefa70ecbefc64b2799b765c69af84822efef32", + "https://deno.land/std@0.207.0/yaml/stringify.ts": "fffc09c65c68d3d63f8159e8cbaa3f489bc20a8e55b4fbb61a8c2e9f914d1d02", + "https://deno.land/std@0.207.0/yaml/type.ts": "65553da3da3c029b6589c6e4903f0afbea6768be8fca61580711457151f2b30f", + "https://deno.land/std@0.208.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", + "https://deno.land/std@0.208.0/assert/_diff.ts": "58e1461cc61d8eb1eacbf2a010932bf6a05b79344b02ca38095f9b805795dc48", + "https://deno.land/std@0.208.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.208.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.208.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", + "https://deno.land/std@0.208.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", + "https://deno.land/std@0.208.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", + "https://deno.land/std@0.208.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", + "https://deno.land/std@0.208.0/assert/assert_false.ts": "0ccbcaae910f52c857192ff16ea08bda40fdc79de80846c206bfc061e8c851c6", + "https://deno.land/std@0.208.0/assert/assert_greater.ts": "ae2158a2d19313bf675bf7251d31c6dc52973edb12ac64ac8fc7064152af3e63", + "https://deno.land/std@0.208.0/assert/assert_greater_or_equal.ts": "1439da5ebbe20855446cac50097ac78b9742abe8e9a43e7de1ce1426d556e89c", + "https://deno.land/std@0.208.0/assert/assert_instance_of.ts": "3aedb3d8186e120812d2b3a5dea66a6e42bf8c57a8bd927645770bd21eea554c", + "https://deno.land/std@0.208.0/assert/assert_is_error.ts": "c21113094a51a296ffaf036767d616a78a2ae5f9f7bbd464cd0197476498b94b", + "https://deno.land/std@0.208.0/assert/assert_less.ts": "aec695db57db42ec3e2b62e97e1e93db0063f5a6ec133326cc290ff4b71b47e4", + "https://deno.land/std@0.208.0/assert/assert_less_or_equal.ts": "5fa8b6a3ffa20fd0a05032fe7257bf985d207b85685fdbcd23651b70f928c848", + "https://deno.land/std@0.208.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", + "https://deno.land/std@0.208.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", + "https://deno.land/std@0.208.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", + "https://deno.land/std@0.208.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", + "https://deno.land/std@0.208.0/assert/assert_not_strict_equals.ts": "4cdef83df17488df555c8aac1f7f5ec2b84ad161b6d0645ccdbcc17654e80c99", + "https://deno.land/std@0.208.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", + "https://deno.land/std@0.208.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.208.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", + "https://deno.land/std@0.208.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", + "https://deno.land/std@0.208.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.208.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.208.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", + "https://deno.land/std@0.208.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", + "https://deno.land/std@0.208.0/assert/mod.ts": "37c49a26aae2b254bbe25723434dc28cd7532e444cf0b481a97c045d110ec085", + "https://deno.land/std@0.208.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", + "https://deno.land/std@0.208.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", + "https://deno.land/std@0.208.0/fmt/colors.ts": "34b3f77432925eb72cf0bfb351616949746768620b8e5ead66da532f93d10ba2", + "https://deno.land/std@0.208.0/path/_common/assert_path.ts": "061e4d093d4ba5aebceb2c4da3318bfe3289e868570e9d3a8e327d91c2958946", + "https://deno.land/std@0.208.0/path/_common/basename.ts": "0d978ff818f339cd3b1d09dc914881f4d15617432ae519c1b8fdc09ff8d3789a", + "https://deno.land/std@0.208.0/path/_common/common.ts": "9e4233b2eeb50f8b2ae10ecc2108f58583aea6fd3e8907827020282dc2b76143", + "https://deno.land/std@0.208.0/path/_common/constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", + "https://deno.land/std@0.208.0/path/_common/dirname.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397", + "https://deno.land/std@0.208.0/path/_common/format.ts": "11aa62e316dfbf22c126917f5e03ea5fe2ee707386555a8f513d27ad5756cf96", + "https://deno.land/std@0.208.0/path/_common/from_file_url.ts": "ef1bf3197d2efbf0297a2bdbf3a61d804b18f2bcce45548ae112313ec5be3c22", + "https://deno.land/std@0.208.0/path/_common/glob_to_reg_exp.ts": "5c3c2b79fc2294ec803d102bd9855c451c150021f452046312819fbb6d4dc156", + "https://deno.land/std@0.208.0/path/_common/normalize.ts": "2ba7fb4cc9fafb0f38028f434179579ce61d4d9e51296fad22b701c3d3cd7397", + "https://deno.land/std@0.208.0/path/_common/normalize_string.ts": "88c472f28ae49525f9fe82de8c8816d93442d46a30d6bb5063b07ff8a89ff589", + "https://deno.land/std@0.208.0/path/_common/relative.ts": "1af19d787a2a84b8c534cc487424fe101f614982ae4851382c978ab2216186b4", + "https://deno.land/std@0.208.0/path/_common/strip_trailing_separators.ts": "7ffc7c287e97bdeeee31b155828686967f222cd73f9e5780bfe7dfb1b58c6c65", + "https://deno.land/std@0.208.0/path/_common/to_file_url.ts": "a8cdd1633bc9175b7eebd3613266d7c0b6ae0fb0cff24120b6092ac31662f9ae", + "https://deno.land/std@0.208.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", + "https://deno.land/std@0.208.0/path/_os.ts": "30b0c2875f360c9296dbe6b7f2d528f0f9c741cecad2e97f803f5219e91b40a2", + "https://deno.land/std@0.208.0/path/basename.ts": "04bb5ef3e86bba8a35603b8f3b69537112cdd19ce64b77f2522006da2977a5f3", + "https://deno.land/std@0.208.0/path/common.ts": "f4d061c7d0b95a65c2a1a52439edec393e906b40f1caf4604c389fae7caa80f5", + "https://deno.land/std@0.208.0/path/dirname.ts": "88a0a71c21debafc4da7a4cd44fd32e899462df458fbca152390887d41c40361", + "https://deno.land/std@0.208.0/path/extname.ts": "2da4e2490f3b48b7121d19fb4c91681a5e11bd6bd99df4f6f47d7a71bb6ecdf2", + "https://deno.land/std@0.208.0/path/format.ts": "3457530cc85d1b4bab175f9ae73998b34fd456c830d01883169af0681b8894fb", + "https://deno.land/std@0.208.0/path/from_file_url.ts": "e7fa233ea1dff9641e8d566153a24d95010110185a6f418dd2e32320926043f8", + "https://deno.land/std@0.208.0/path/glob_to_regexp.ts": "74d7448c471e293d03f05ccb968df4365fed6aaa508506b6325a8efdc01d8271", + "https://deno.land/std@0.208.0/path/is_absolute.ts": "67232b41b860571c5b7537f4954c88d86ae2ba45e883ee37d3dec27b74909d13", + "https://deno.land/std@0.208.0/path/is_glob.ts": "567dce5c6656bdedfc6b3ee6c0833e1e4db2b8dff6e62148e94a917f289c06ad", + "https://deno.land/std@0.208.0/path/join.ts": "98d3d76c819af4a11a81d5ba2dbb319f1ce9d63fc2b615597d4bcfddd4a89a09", + "https://deno.land/std@0.208.0/path/join_globs.ts": "9b84d5103b63d3dbed4b2cf8b12477b2ad415c7d343f1488505162dc0e5f4db8", + "https://deno.land/std@0.208.0/path/mod.ts": "3defabebc98279e62b392fee7a6937adc932a8f4dcd2471441e36c15b97b00e0", + "https://deno.land/std@0.208.0/path/normalize.ts": "aa95be9a92c7bd4f9dc0ba51e942a1973e2b93d266cd74f5ca751c136d520b66", + "https://deno.land/std@0.208.0/path/normalize_glob.ts": "674baa82e1c00b6cb153bbca36e06f8e0337cb8062db6d905ab5de16076ca46b", + "https://deno.land/std@0.208.0/path/parse.ts": "d87ff0deef3fb495bc0d862278ff96da5a06acf0625ca27769fc52ac0d3d6ece", + "https://deno.land/std@0.208.0/path/posix/_util.ts": "ecf49560fedd7dd376c6156cc5565cad97c1abe9824f4417adebc7acc36c93e5", + "https://deno.land/std@0.208.0/path/posix/basename.ts": "a630aeb8fd8e27356b1823b9dedd505e30085015407caa3396332752f6b8406a", + "https://deno.land/std@0.208.0/path/posix/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b", + "https://deno.land/std@0.208.0/path/posix/dirname.ts": "f48c9c42cc670803b505478b7ef162c7cfa9d8e751b59d278b2ec59470531472", + "https://deno.land/std@0.208.0/path/posix/extname.ts": "ee7f6571a9c0a37f9218fbf510c440d1685a7c13082c348d701396cc795e0be0", + "https://deno.land/std@0.208.0/path/posix/format.ts": "b94876f77e61bfe1f147d5ccb46a920636cd3cef8be43df330f0052b03875968", + "https://deno.land/std@0.208.0/path/posix/from_file_url.ts": "b97287a83e6407ac27bdf3ab621db3fccbf1c27df0a1b1f20e1e1b5acf38a379", + "https://deno.land/std@0.208.0/path/posix/glob_to_regexp.ts": "6ed00c71fbfe0ccc35977c35444f94e82200b721905a60bd1278b1b768d68b1a", + "https://deno.land/std@0.208.0/path/posix/is_absolute.ts": "159900a3422d11069d48395568217eb7fc105ceda2683d03d9b7c0f0769e01b8", + "https://deno.land/std@0.208.0/path/posix/is_glob.ts": "ec4fbc604b9db8487f7b56ab0e759b24a971ab6a45f7b0b698bc39b8b9f9680f", + "https://deno.land/std@0.208.0/path/posix/join.ts": "0c0d84bdc344876930126640011ec1b888e6facf74153ffad9ef26813aa2a076", + "https://deno.land/std@0.208.0/path/posix/join_globs.ts": "f4838d54b1f60a34a40625a3293f6e583135348be1b2974341ac04743cb26121", + "https://deno.land/std@0.208.0/path/posix/mod.ts": "f1b08a7f64294b7de87fc37190d63b6ce5b02889af9290c9703afe01951360ae", + "https://deno.land/std@0.208.0/path/posix/normalize.ts": "11de90a94ab7148cc46e5a288f7d732aade1d616bc8c862f5560fa18ff987b4b", + "https://deno.land/std@0.208.0/path/posix/normalize_glob.ts": "10a1840c628ebbab679254d5fa1c20e59106102354fb648a1765aed72eb9f3f9", + "https://deno.land/std@0.208.0/path/posix/parse.ts": "199208f373dd93a792e9c585352bfc73a6293411bed6da6d3bc4f4ef90b04c8e", + "https://deno.land/std@0.208.0/path/posix/relative.ts": "e2f230608b0f083e6deaa06e063943e5accb3320c28aef8d87528fbb7fe6504c", + "https://deno.land/std@0.208.0/path/posix/resolve.ts": "51579d83159d5c719518c9ae50812a63959bbcb7561d79acbdb2c3682236e285", + "https://deno.land/std@0.208.0/path/posix/separator.ts": "0b6573b5f3269a3164d8edc9cefc33a02dd51003731c561008c8bb60220ebac1", + "https://deno.land/std@0.208.0/path/posix/to_file_url.ts": "08d43ea839ee75e9b8b1538376cfe95911070a655cd312bc9a00f88ef14967b6", + "https://deno.land/std@0.208.0/path/posix/to_namespaced_path.ts": "c9228a0e74fd37e76622cd7b142b8416663a9b87db643302fa0926b5a5c83bdc", + "https://deno.land/std@0.208.0/path/relative.ts": "23d45ede8b7ac464a8299663a43488aad6b561414e7cbbe4790775590db6349c", + "https://deno.land/std@0.208.0/path/resolve.ts": "5b184efc87155a0af9fa305ff68a109e28de9aee81fc3e77cd01380f19daf867", + "https://deno.land/std@0.208.0/path/separator.ts": "40a3e9a4ad10bef23bc2cd6c610291b6c502a06237c2c4cd034a15ca78dedc1f", + "https://deno.land/std@0.208.0/path/to_file_url.ts": "edaafa089e0bce386e1b2d47afe7c72e379ff93b28a5829a5885e4b6c626d864", + "https://deno.land/std@0.208.0/path/to_namespaced_path.ts": "cf8734848aac3c7527d1689d2adf82132b1618eff3cc523a775068847416b22a", + "https://deno.land/std@0.208.0/path/windows/_util.ts": "f32b9444554c8863b9b4814025c700492a2b57ff2369d015360970a1b1099d54", + "https://deno.land/std@0.208.0/path/windows/basename.ts": "8a9dbf7353d50afbc5b221af36c02a72c2d1b2b5b9f7c65bf6a5a2a0baf88ad3", + "https://deno.land/std@0.208.0/path/windows/common.ts": "e781d395dc76f6282e3f7dd8de13194abb8b04a82d109593141abc6e95755c8b", + "https://deno.land/std@0.208.0/path/windows/dirname.ts": "5c2aa541384bf0bd9aca821275d2a8690e8238fa846198ef5c7515ce31a01a94", + "https://deno.land/std@0.208.0/path/windows/extname.ts": "07f4fa1b40d06a827446b3e3bcc8d619c5546b079b8ed0c77040bbef716c7614", + "https://deno.land/std@0.208.0/path/windows/format.ts": "343019130d78f172a5c49fdc7e64686a7faf41553268961e7b6c92a6d6548edf", + "https://deno.land/std@0.208.0/path/windows/from_file_url.ts": "d53335c12b0725893d768be3ac6bf0112cc5b639d2deb0171b35988493b46199", + "https://deno.land/std@0.208.0/path/windows/glob_to_regexp.ts": "290755e18ec6c1a4f4d711c3390537358e8e3179581e66261a0cf348b1a13395", + "https://deno.land/std@0.208.0/path/windows/is_absolute.ts": "245b56b5f355ede8664bd7f080c910a97e2169972d23075554ae14d73722c53c", + "https://deno.land/std@0.208.0/path/windows/is_glob.ts": "ec4fbc604b9db8487f7b56ab0e759b24a971ab6a45f7b0b698bc39b8b9f9680f", + "https://deno.land/std@0.208.0/path/windows/join.ts": "e6600bf88edeeef4e2276e155b8de1d5dec0435fd526ba2dc4d37986b2882f16", + "https://deno.land/std@0.208.0/path/windows/join_globs.ts": "f4838d54b1f60a34a40625a3293f6e583135348be1b2974341ac04743cb26121", + "https://deno.land/std@0.208.0/path/windows/mod.ts": "d7040f461465c2c21c1c68fc988ef0bdddd499912138cde3abf6ad60c7fb3814", + "https://deno.land/std@0.208.0/path/windows/normalize.ts": "9deebbf40c81ef540b7b945d4ccd7a6a2c5a5992f791e6d3377043031e164e69", + "https://deno.land/std@0.208.0/path/windows/normalize_glob.ts": "344ff5ed45430495b9a3d695567291e50e00b1b3b04ea56712a2acf07ab5c128", + "https://deno.land/std@0.208.0/path/windows/parse.ts": "120faf778fe1f22056f33ded069b68e12447668fcfa19540c0129561428d3ae5", + "https://deno.land/std@0.208.0/path/windows/relative.ts": "026855cd2c36c8f28f1df3c6fbd8f2449a2aa21f48797a74700c5d872b86d649", + "https://deno.land/std@0.208.0/path/windows/resolve.ts": "5ff441ab18a2346abadf778121128ee71bda4d0898513d4639a6ca04edca366b", + "https://deno.land/std@0.208.0/path/windows/separator.ts": "ae21f27015f10510ed1ac4a0ba9c4c9c967cbdd9d9e776a3e4967553c397bd5d", + "https://deno.land/std@0.208.0/path/windows/to_file_url.ts": "8e9ea9e1ff364aa06fa72999204229952d0a279dbb876b7b838b2b2fea55cce3", + "https://deno.land/std@0.208.0/path/windows/to_namespaced_path.ts": "e0f4d4a5e77f28a5708c1a33ff24360f35637ba6d8f103d19661255ef7bfd50d", + "https://deno.land/std@0.208.0/testing/asserts.ts": "605bbd2ef0695e2a4324d810c4ad22e56041d51afb9584fc0b4e81084b14b1d6", + "https://deno.land/std@0.213.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.213.0/assert/_diff.ts": "dcc63d94ca289aec80644030cf88ccbf7acaa6fbd7b0f22add93616b36593840", + "https://deno.land/std@0.213.0/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4", + "https://deno.land/std@0.213.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", + "https://deno.land/std@0.213.0/assert/assert_almost_equals.ts": "8b96b7385cc117668b0720115eb6ee73d04c9bcb2f5d2344d674918c9113688f", + "https://deno.land/std@0.213.0/assert/assert_array_includes.ts": "1688d76317fd45b7e93ef9e2765f112fdf2b7c9821016cdfb380b9445374aed1", + "https://deno.land/std@0.213.0/assert/assert_equals.ts": "4497c56fe7d2993b0d447926702802fc0becb44e319079e8eca39b482ee01b4e", + "https://deno.land/std@0.213.0/assert/assert_exists.ts": "24a7bf965e634f909242cd09fbaf38bde6b791128ece08e33ab08586a7cc55c9", + "https://deno.land/std@0.213.0/assert/assert_false.ts": "6f382568e5128c0f855e5f7dbda8624c1ed9af4fcc33ef4a9afeeedcdce99769", + "https://deno.land/std@0.213.0/assert/assert_greater.ts": "4945cf5729f1a38874d7e589e0fe5cc5cd5abe5573ca2ddca9d3791aa891856c", + "https://deno.land/std@0.213.0/assert/assert_greater_or_equal.ts": "573ed8823283b8d94b7443eb69a849a3c369a8eb9666b2d1db50c33763a5d219", + "https://deno.land/std@0.213.0/assert/assert_instance_of.ts": "72dc1faff1e248692d873c89382fa1579dd7b53b56d52f37f9874a75b11ba444", + "https://deno.land/std@0.213.0/assert/assert_is_error.ts": "6596f2b5ba89ba2fe9b074f75e9318cda97a2381e59d476812e30077fbdb6ed2", + "https://deno.land/std@0.213.0/assert/assert_less.ts": "2b4b3fe7910f65f7be52212f19c3977ecb8ba5b2d6d0a296c83cde42920bb005", + "https://deno.land/std@0.213.0/assert/assert_less_or_equal.ts": "b93d212fe669fbde959e35b3437ac9a4468f2e6b77377e7b6ea2cfdd825d38a0", + "https://deno.land/std@0.213.0/assert/assert_match.ts": "ec2d9680ed3e7b9746ec57ec923a17eef6d476202f339ad91d22277d7f1d16e1", + "https://deno.land/std@0.213.0/assert/assert_not_equals.ts": "f3edda73043bc2c9fae6cbfaa957d5c69bbe76f5291a5b0466ed132c8789df4c", + "https://deno.land/std@0.213.0/assert/assert_not_instance_of.ts": "8f720d92d83775c40b2542a8d76c60c2d4aeddaf8713c8d11df8984af2604931", + "https://deno.land/std@0.213.0/assert/assert_not_match.ts": "b4b7c77f146963e2b673c1ce4846473703409eb93f5ab0eb60f6e6f8aeffe39f", + "https://deno.land/std@0.213.0/assert/assert_not_strict_equals.ts": "da0b8ab60a45d5a9371088378e5313f624799470c3b54c76e8b8abeec40a77be", + "https://deno.land/std@0.213.0/assert/assert_object_match.ts": "e85e5eef62a56ce364c3afdd27978ccab979288a3e772e6855c270a7b118fa49", + "https://deno.land/std@0.213.0/assert/assert_rejects.ts": "e9e0c8d9c3e164c7ac962c37b3be50577c5a2010db107ed272c4c1afb1269f54", + "https://deno.land/std@0.213.0/assert/assert_strict_equals.ts": "0425a98f70badccb151644c902384c12771a93e65f8ff610244b8147b03a2366", + "https://deno.land/std@0.213.0/assert/assert_string_includes.ts": "dfb072a890167146f8e5bdd6fde887ce4657098e9f71f12716ef37f35fb6f4a7", + "https://deno.land/std@0.213.0/assert/assert_throws.ts": "edddd86b39606c342164b49ad88dd39a26e72a26655e07545d172f164b617fa7", + "https://deno.land/std@0.213.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", + "https://deno.land/std@0.213.0/assert/equal.ts": "fae5e8a52a11d3ac694bbe1a53e13a7969e3f60791262312e91a3e741ae519e2", + "https://deno.land/std@0.213.0/assert/fail.ts": "f310e51992bac8e54f5fd8e44d098638434b2edb802383690e0d7a9be1979f1c", + "https://deno.land/std@0.213.0/assert/mod.ts": "325df8c0683ad83a873b9691aa66b812d6275fc9fec0b2d180ac68a2c5efed3b", + "https://deno.land/std@0.213.0/assert/unimplemented.ts": "47ca67d1c6dc53abd0bd729b71a31e0825fc452dbcd4fde4ca06789d5644e7fd", + "https://deno.land/std@0.213.0/assert/unreachable.ts": "38cfecb95d8b06906022d2f9474794fca4161a994f83354fd079cac9032b5145", + "https://deno.land/std@0.213.0/fmt/colors.ts": "aeaee795471b56fc62a3cb2e174ed33e91551b535f44677f6320336aabb54fbb", + "https://deno.land/std@0.213.0/testing/_test_suite.ts": "f10a8a6338b60c403f07a76f3f46bdc9f1e1a820c0a1decddeb2949f7a8a0546", + "https://deno.land/std@0.213.0/testing/bdd.ts": "3cbd17bd35f629a76ce63446238dfb4632240dd46b3b205027c45fa3dd67e554", + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/std@0.224.0/yaml/_dumper/dumper.ts": "08b595b40841a2e1c75303f5096392323b6baf8e9662430a91e3b36fbe175fe9", + "https://deno.land/std@0.224.0/yaml/_dumper/dumper_state.ts": "9e29f700ea876ed230b43f11fa006fcb1a62eedc1e27d32baaeaf3210f19f1e7", + "https://deno.land/std@0.224.0/yaml/_error.ts": "f38cdebdb69cde16903d9aa2f3b8a3dd9d13e5f7f3570bf662bfaca69fef669e", + "https://deno.land/std@0.224.0/yaml/_loader/loader.ts": "bf9e8a99770b59bc887b43ebccea108cbe9146ae32d91f7ce558d62c946d3fe3", + "https://deno.land/std@0.224.0/yaml/_loader/loader_state.ts": "ee216de6040551940b85473c3185fdb7a6f3030b77153f87a6b7f63f82e489ea", + "https://deno.land/std@0.224.0/yaml/_mark.ts": "61097a614857fcebf7b2ecad057916d74c90cd160117a33c9e74bac60457410a", + "https://deno.land/std@0.224.0/yaml/_state.ts": "f3b1c1fd11860302f1f33e35e9ce089bf069d4943e8d67516cd6bedbba058c13", + "https://deno.land/std@0.224.0/yaml/_type/binary.ts": "f1a6e1d83dcc52b21cc3639cd98be44051cfc54065cc4f2a42065bce07ebc07d", + "https://deno.land/std@0.224.0/yaml/_type/bool.ts": "121743b23ba82a27ad6a3ec6298c7f5b0908f90e52707f8644a91f7ad51ed2ef", + "https://deno.land/std@0.224.0/yaml/_type/float.ts": "c5ed84b0aec1ec5dc05f6abfaaff672e8890d4d44a42120b4445c9754fca4eba", + "https://deno.land/std@0.224.0/yaml/_type/function.ts": "bbf705058942bf3370604b37eb77a10aadd72f986c237c9f69b43378a42202c1", + "https://deno.land/std@0.224.0/yaml/_type/int.ts": "c2dc88438a60fccc8d2226042bd18b9967753adaf6bd145feb8b99d567e432ce", + "https://deno.land/std@0.224.0/yaml/_type/map.ts": "ae2acb1cb837fb8e96c75c98611cfd45af847d0114ab5336333c318e7d4b12f4", + "https://deno.land/std@0.224.0/yaml/_type/merge.ts": "ad0d971f91d2fb9f4ab3eba0c837eae357b1804d6b798adc99dc917bc5306b11", + "https://deno.land/std@0.224.0/yaml/_type/mod.ts": "e8929d7b1c969a74f76338d4eb380ef8c4a26cd6441117d521f076b766e9c265", + "https://deno.land/std@0.224.0/yaml/_type/nil.ts": "cbe4387d02d5933322c21b25d8955c5e6228c492e391a6fb82dcf4f498cc421c", + "https://deno.land/std@0.224.0/yaml/_type/omap.ts": "cda915105ab22ba9e1d6317adacee8eec2d8ddaf864cc2f814e3e476946e72c6", + "https://deno.land/std@0.224.0/yaml/_type/pairs.ts": "dd39bb44c1b9abaf6172c63f73350475933151f07e05253b81f7860c9b507177", + "https://deno.land/std@0.224.0/yaml/_type/regexp.ts": "e49eb9e1c9356fd142bc15f7f323820d411fcc537b5ba3896df9a8b812d270a4", + "https://deno.land/std@0.224.0/yaml/_type/seq.ts": "2deffc7f970869bc01a1541b4961d076329a1c2b30b95e07918f3132db7c3fe2", + "https://deno.land/std@0.224.0/yaml/_type/set.ts": "be8a9e7237a7ffc92dfbe7f5e552d84b7eeba60f3f73cc77fc3c59d3506c74ea", + "https://deno.land/std@0.224.0/yaml/_type/str.ts": "88f0a1ba12295520cd57e96cd78d53aa0787d53c7a1c506155f418c496c2f550", + "https://deno.land/std@0.224.0/yaml/_type/timestamp.ts": "277a41a40fb93c3b2b3f5c373bf11b0b7856cc6a7b919e8ea130755e4029edc5", + "https://deno.land/std@0.224.0/yaml/_type/undefined.ts": "9d215953c65740f1764e0bdca021007573473f0c49e087f00d9ff02817ecfc97", + "https://deno.land/std@0.224.0/yaml/_utils.ts": "91bbe28b5e7000b9594e40ff5353f8fe7a7ba914eec917e1202cbaf5ac931c58", + "https://deno.land/std@0.224.0/yaml/mod.ts": "54e9bfad77c8cd58f49b65f4d568045ff08989ed36318a2ca733a43cb6f1bc00", + "https://deno.land/std@0.224.0/yaml/parse.ts": "f45278d9ebccb789af4eceeffa5c291e194bcf1fa9aab1b34ff52c2bd4a9d886", + "https://deno.land/std@0.224.0/yaml/schema.ts": "a0f7956d997852b5d1c6564bd73eb7352175cfba439707ac819b65b5a2ec173a", + "https://deno.land/std@0.224.0/yaml/schema/core.ts": "0a37c07710e3df4eb4edc02f4edf623bf8df5af72b34d8a7c0229d0bac2a7043", + "https://deno.land/std@0.224.0/yaml/schema/default.ts": "1367fd30420c7071ecc67e5b470838474e8259aaf64460f314af4b6bd8da497c", + "https://deno.land/std@0.224.0/yaml/schema/extended.ts": "248180c22697f37ed173057eae62ce4879865bb59f30c4908d698bed5edcc7c5", + "https://deno.land/std@0.224.0/yaml/schema/failsafe.ts": "0ac1cae5b86d8fe2c83ad0a17f8adc33106a452b7139f84e4b0bfaee2206730e", + "https://deno.land/std@0.224.0/yaml/schema/json.ts": "a0228a0c0bad7dece17ab848774fcadc2ccb5e51775c2d58d21d486917ba3ba1", + "https://deno.land/std@0.224.0/yaml/schema/mod.ts": "0e1558a4823834f106675e48ddc15338e04f6f18469d1a7d6b3f0e1ab06abcb2", + "https://deno.land/std@0.224.0/yaml/stringify.ts": "f0ed4e419cb40c807cf79ae4039d6cdf492be9a947121fff4d4b7cd1d4738bae", + "https://deno.land/std@0.224.0/yaml/type.ts": "708dde5f20b01cc1096489b7155b6af79a217d585afb841128e78c3c2391eb5c" + }, "workspace": { "dependencies": [ "jsr:@deno/dnt@~0.41.3", diff --git a/cli/dnt.ts b/cli/dnt.ts index 3820439a7e..63e50208f0 100644 --- a/cli/dnt.ts +++ b/cli/dnt.ts @@ -13,7 +13,8 @@ await build({ }, ], outDir: "./npm", - shims: { + test: false, // Disable all tests in npm build since they use Deno-specific APIs + shims: { // see JS docs for overview and more options deno: true, // shims to only use in the tests diff --git a/cli/flow.ts b/cli/flow.ts index 121e81f72a..798d294db7 100644 --- a/cli/flow.ts +++ b/cli/flow.ts @@ -27,7 +27,13 @@ export function replaceInlineScripts( localPath: string, removeLocks: string[] | undefined ) { - modules.forEach((m) => { + modules.forEach((m, i) => { + if (!m.value) { + throw Error( + `Module value is undefined for flow module ${i} in ${localPath}` + ); + return; + } if (m.value.type == "rawscript") { if (m.value.content.startsWith("!inline")) { const path = m.value.content.split(" ")[1]; diff --git a/cli/gitsync-settings.ts b/cli/gitsync-settings.ts new file mode 100644 index 0000000000..3daeaa0415 --- /dev/null +++ b/cli/gitsync-settings.ts @@ -0,0 +1,1124 @@ +import { colors, Command, log, yamlStringify } from "./deps.ts"; +import { GlobalOptions } from "./types.ts"; +import { requireLogin, resolveWorkspace } from "./context.ts"; +import * as wmill from "./gen/services.gen.ts"; +import { + DEFAULT_SYNC_OPTIONS, + getEffectiveSettings, + readConfigFile, + SyncOptions, +} from "./conf.ts"; +import { deepEqual, Repository, selectRepository } from "./utils.ts"; + +// Constants for git-sync fields to avoid duplication +const GIT_SYNC_FIELDS = [ + "includes", + "excludes", + "extraIncludes", + "skipScripts", + "skipFlows", + "skipApps", + "skipFolders", + "skipVariables", + "skipResources", + "skipResourceTypes", + "skipSecrets", + "includeSchedules", + "includeTriggers", + "includeUsers", + "includeGroups", + "includeSettings", + "includeKey", +] as const; + +// Helper to normalize repository path by removing $res: prefix +function normalizeRepoPath(path: string): string { + return path.replace(/^\$res:/, ""); +} + +// Helper to get typed field value from SyncOptions +function getFieldValue(opts: SyncOptions, field: string): any { + return (opts as any)[field]; +} + +// Construct override key using the single format: baseUrl:workspaceId:repo +function constructOverrideKey( + baseUrl: string, + workspaceId: string, + repoPath: string, + workspaceLevel = false, +): string { + // Validate that components don't contain colons to avoid key collisions + if (baseUrl.includes(':') && !baseUrl.startsWith('http')) { + throw new Error(`Invalid baseUrl contains colon: ${baseUrl}`); + } + if (workspaceId.includes(':')) { + throw new Error(`Invalid workspaceId contains colon: ${workspaceId}`); + } + if (repoPath.includes(':') && !repoPath.startsWith('$res:')) { + throw new Error(`Invalid repoPath contains colon: ${repoPath}`); + } + + if (workspaceLevel) { + return `${baseUrl}:${workspaceId}:*`; + } + return `${baseUrl}:${workspaceId}:${repoPath}`; +} + +// Helper to compare string arrays (used for includes/excludes/extraIncludes) +function arraysEqual(arr1: string[], arr2: string[]): boolean { + if (arr1.length !== arr2.length) { + return false; + } + const sorted1 = [...arr1].sort(); + const sorted2 = [...arr2].sort(); + return sorted1.every((item, index) => item === sorted2[index]); +} + +// Normalize SyncOptions for semantic comparison - treat undefined arrays as empty arrays +function normalizeSyncOptions(opts: SyncOptions): SyncOptions { + return { + ...opts, + includes: opts.includes || [], + excludes: opts.excludes || [], + extraIncludes: opts.extraIncludes || [], + }; +} + +// Extract only git-sync relevant fields for comparison +function extractGitSyncFields(opts: SyncOptions): Partial { + return { + includes: opts.includes || [], + excludes: opts.excludes || [], + extraIncludes: opts.extraIncludes || [], + skipScripts: opts.skipScripts, + skipFlows: opts.skipFlows, + skipApps: opts.skipApps, + skipFolders: opts.skipFolders, + skipVariables: opts.skipVariables, + skipResources: opts.skipResources, + skipResourceTypes: opts.skipResourceTypes, + skipSecrets: opts.skipSecrets, + includeSchedules: opts.includeSchedules, + includeTriggers: opts.includeTriggers, + includeUsers: opts.includeUsers, + includeGroups: opts.includeGroups, + includeSettings: opts.includeSettings, + includeKey: opts.includeKey, + }; +} + +// Helper function to determine current settings based on write mode and conflicts +function getCurrentSettings( + localConfig: SyncOptions, + writeMode: "override" | "replace", + overrideKey?: string, +): SyncOptions { + if ( + writeMode === "override" && + overrideKey && + localConfig.overrides?.[overrideKey] + ) { + return { + ...DEFAULT_SYNC_OPTIONS, + ...localConfig, + ...localConfig.overrides[overrideKey], + }; + } else { + // For "replace" mode, exclude overrides since they're never accessed + const { overrides, ...configWithoutOverrides } = localConfig; + return { ...DEFAULT_SYNC_OPTIONS, ...configWithoutOverrides }; + } +} + +// Interface for git-sync repository settings from backend +interface GitSyncRepository extends Repository { + settings: { + include_path: string[]; + include_type: string[]; + exclude_path?: string[]; + extra_include_path?: string[]; + }; +} + +// Convert backend include_type array to SyncOptions boolean flags +function includeTypeToSyncOptions( + includeTypes: string[], +): Partial { + return { + skipScripts: !includeTypes.includes("script"), + skipFlows: !includeTypes.includes("flow"), + skipApps: !includeTypes.includes("app"), + skipFolders: !includeTypes.includes("folder"), + skipVariables: !includeTypes.includes("variable"), + skipResources: !includeTypes.includes("resource"), + skipResourceTypes: !includeTypes.includes("resourcetype"), + skipSecrets: !includeTypes.includes("secret"), + includeSchedules: includeTypes.includes("schedule"), + includeTriggers: includeTypes.includes("trigger"), + includeUsers: includeTypes.includes("user"), + includeGroups: includeTypes.includes("group"), + includeSettings: includeTypes.includes("settings"), + includeKey: includeTypes.includes("key"), + }; +} + +// Convert SyncOptions boolean flags to backend include_type array +function syncOptionsToIncludeType(opts: SyncOptions): string[] { + const includeTypes: string[] = []; + + if (!opts.skipScripts) includeTypes.push("script"); + if (!opts.skipFlows) includeTypes.push("flow"); + if (!opts.skipApps) includeTypes.push("app"); + if (!opts.skipFolders) includeTypes.push("folder"); + if (!opts.skipVariables) includeTypes.push("variable"); + if (!opts.skipResources) includeTypes.push("resource"); + if (!opts.skipResourceTypes) includeTypes.push("resourcetype"); + if (!opts.skipSecrets) includeTypes.push("secret"); + if (opts.includeSchedules) includeTypes.push("schedule"); + if (opts.includeTriggers) includeTypes.push("trigger"); + if (opts.includeUsers) includeTypes.push("user"); + if (opts.includeGroups) includeTypes.push("group"); + if (opts.includeSettings) includeTypes.push("settings"); + if (opts.includeKey) includeTypes.push("key"); + + return includeTypes; +} + +// Convert SyncOptions to backend format used by both Windmill backend and UI +function syncOptionsToBackendFormat(opts: SyncOptions): { + include_path: string[]; + exclude_path: string[]; + extra_include_path: string[]; + include_type: string[]; +} { + return { + include_path: opts.includes || [], + exclude_path: opts.excludes || [], + extra_include_path: opts.extraIncludes || [], + include_type: syncOptionsToIncludeType(opts), + }; +} + +// Select repository interactively if multiple exist +// Generate structured diff showing field changes +function generateStructuredDiff( + current: any, + backend: any, +): { [key: string]: { from: any; to: any } } { + const diff: { [key: string]: { from: any; to: any } } = {}; + + // Get all unique keys from both objects + const allKeys = new Set([...Object.keys(current), ...Object.keys(backend)]); + + for (const key of allKeys) { + const currentValue = current[key]; + const backendValue = backend[key]; + + if (!deepEqual(currentValue, backendValue)) { + diff[key] = { + from: currentValue, + to: backendValue, + }; + } + } + + return diff; +} + +// Helper to generate changes between two normalized SyncOptions objects +function generateChanges( + normalizedCurrent: SyncOptions, + normalizedNew: SyncOptions, +): { [key: string]: { from: any; to: any } } { + const changes: { [key: string]: { from: any; to: any } } = {}; + + for (const field of GIT_SYNC_FIELDS) { + const currentValue = getFieldValue(normalizedCurrent, field); + const newValue = getFieldValue(normalizedNew, field); + if (!deepEqual(currentValue, newValue)) { + changes[field] = { + from: currentValue, + to: newValue, + }; + } + } + + return changes; +} + +// Helper to display changes in human-readable format +function displayChanges( + changes: { [key: string]: { from: any; to: any } }, +): void { + for (const [field, change] of Object.entries(changes)) { + if ( + Array.isArray(change.from) || + Array.isArray(change.to) + ) { + console.log(colors.yellow(` ${field}:`)); + console.log( + colors.red( + ` - ${JSON.stringify(change.from)}`, + ), + ); + console.log( + colors.green( + ` + ${JSON.stringify(change.to)}`, + ), + ); + } else { + console.log( + colors.yellow(` ${field}: `) + + colors.red(`${change.from}`) + + " → " + + colors.green(`${change.to}`), + ); + } + } +} + +async function pullGitSyncSettings( + opts: GlobalOptions & { + repository?: string; + workspaceLevel?: boolean; + default?: boolean; + diff?: boolean; + jsonOutput?: boolean; + replace?: boolean; + override?: boolean; + withBackendSettings?: string; + }, +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + try { + // Parse and validate --with-backend-settings if provided + let settings: any; + if (opts.withBackendSettings) { + try { + const parsedSettings = JSON.parse(opts.withBackendSettings); + + // Validate the structure matches expected test format (raw settings object) + if ( + !parsedSettings.include_path || + !Array.isArray(parsedSettings.include_path) + ) { + throw new Error( + "Invalid settings format. Expected include_path array", + ); + } + if ( + !parsedSettings.include_type || + !Array.isArray(parsedSettings.include_type) + ) { + throw new Error( + "Invalid settings format. Expected include_type array", + ); + } + + // Create mock backend response with single repository using provided settings + const mockRepositoryPath = opts.repository || "u/mock/repo"; + settings = { + git_sync: { + repositories: [{ + git_repo_resource_path: mockRepositoryPath, + settings: { + include_path: parsedSettings.include_path, + include_type: parsedSettings.include_type, + exclude_path: parsedSettings.exclude_path || [], + extra_include_path: parsedSettings.extra_include_path || [], + }, + }], + }, + }; + } catch (parseError) { + const errorMessage = parseError instanceof Error ? parseError.message : String(parseError); + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: false, + error: + `Failed to parse --with-backend-settings JSON: ${errorMessage}`, + }), + ); + } else { + log.error( + colors.red( + `Failed to parse --with-backend-settings JSON: ${errorMessage}`, + ), + ); + } + return; + } + } else { + // Fetch workspace settings to get git-sync configuration + try { + settings = await wmill.getSettings({ + workspace: workspace.workspaceId, + }); + } catch (apiError) { + const errorMessage = apiError instanceof Error ? apiError.message : String(apiError); + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: false, + error: `Failed to fetch workspace settings: ${errorMessage}`, + }), + ); + } else { + log.error( + colors.red( + `Failed to fetch workspace settings: ${errorMessage}`, + ), + ); + } + return; + } + } + + if ( + !settings.git_sync?.repositories || + settings.git_sync.repositories.length === 0 + ) { + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: false, + error: "No git-sync repositories configured", + }), + ); + } else { + log.error( + colors.red( + "No git-sync repositories configured in workspace", + ), + ); + } + return; + } + + // Find the repository to work with + let selectedRepo: GitSyncRepository; + if (opts.repository) { + const found = settings.git_sync.repositories.find( + (r: GitSyncRepository) => + r.git_repo_resource_path === opts.repository || + r.git_repo_resource_path === `$res:${opts.repository}`, + ); + if (!found) { + throw new Error(`Repository ${opts.repository} not found`); + } + selectedRepo = found; + } else { + selectedRepo = await selectRepository( + settings.git_sync.repositories, + ); + } + + // Convert backend settings to SyncOptions format + const backendSyncOptions: SyncOptions = { + includes: selectedRepo.settings.include_path || [], + excludes: selectedRepo.settings.exclude_path || [], + extraIncludes: selectedRepo.settings.extra_include_path || [], + ...includeTypeToSyncOptions( + selectedRepo.settings.include_type || [], + ), + }; + + // Check if wmill.yaml exists - require it for git-sync settings commands + try { + await Deno.stat("wmill.yaml"); + } catch (error) { + log.error( + colors.red( + "No wmill.yaml file found. Please run 'wmill init' first to create the configuration file.", + ), + ); + Deno.exit(1); + } + + // Read current local configuration + const localConfig = await readConfigFile(); + + // Determine where to write the settings for diff display + let overrideKey: string | undefined; + let writeMode: "override" | "replace" = "replace"; + + // For diff mode, determine what the write mode would be without interactive prompts + if (opts.default) { + writeMode = "replace"; + } else if (opts.replace) { + writeMode = "replace"; + } else if (opts.override || opts.workspaceLevel) { + writeMode = "override"; + if (opts.workspaceLevel) { + overrideKey = constructOverrideKey( + workspace.remote, + workspace.workspaceId, + "", + true, + ); + } else { + const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path); + overrideKey = constructOverrideKey( + workspace.remote, + workspace.workspaceId, + repoPath, + ); + } + } else { + // Default behavior for existing files with no explicit flags + // Use same logic as diff to determine if there's a real conflict + const currentSettings = getCurrentSettings( + localConfig, + "replace", // Check against replace mode + undefined, + ); + + const gitSyncBackend = extractGitSyncFields( + normalizeSyncOptions(backendSyncOptions), + ); + const gitSyncCurrent = extractGitSyncFields( + normalizeSyncOptions(currentSettings), + ); + const hasConflict = !deepEqual(gitSyncBackend, gitSyncCurrent); + + if (hasConflict) { + // For diff mode, show what override would look like + writeMode = "override"; + const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path); + overrideKey = constructOverrideKey( + workspace.remote, + workspace.workspaceId, + repoPath, + ); + } else { + writeMode = "replace"; + } + } + + if (opts.diff) { + // Show differences between local and backend + const currentSettings = getCurrentSettings( + localConfig, + writeMode, + overrideKey, + ); + + const normalizedCurrent = normalizeSyncOptions(currentSettings); + const normalizedBackend = normalizeSyncOptions(backendSyncOptions); + const gitSyncCurrent = extractGitSyncFields(normalizedCurrent); + const gitSyncBackend = extractGitSyncFields(normalizedBackend); + const hasChanges = !deepEqual(gitSyncBackend, gitSyncCurrent); + + if (opts.jsonOutput) { + const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path); + + // Generate structured diff using the same normalized objects + const structuredDiff = hasChanges + ? generateStructuredDiff(gitSyncCurrent, gitSyncBackend) + : {}; + + console.log( + JSON.stringify({ + success: true, + hasChanges, + local: syncOptionsToBackendFormat(normalizedCurrent), + backend: syncOptionsToBackendFormat(normalizedBackend), + repository: selectedRepo.git_repo_resource_path, + diff: structuredDiff, + }), + ); + } else { + if (hasChanges) { + log.info("Changes that would be made:"); + const changes = generateChanges(normalizedCurrent, normalizedBackend); + + if (Object.keys(changes).length === 0) { + log.info(colors.green("No differences found")); + } else { + displayChanges(changes); + } + } else { + log.info(colors.green("No differences found")); + } + } + return; + } + + // For non-diff mode, handle interactive logic if needed + // Only show interactive prompts for existing files with conflicts + if ( + !opts.diff && + !opts.default && + !opts.replace && + !opts.override && + !opts.workspaceLevel + ) { + // Use the same logic as diff to determine current settings + const currentSettings = getCurrentSettings( + localConfig, + writeMode, + overrideKey, + ); + + const gitSyncBackend = extractGitSyncFields( + normalizeSyncOptions(backendSyncOptions), + ); + const gitSyncCurrent = extractGitSyncFields( + normalizeSyncOptions(currentSettings), + ); + const hasConflict = !deepEqual(gitSyncBackend, gitSyncCurrent); + + if (hasConflict && Deno.stdin.isTerminal()) { + // Interactive mode - ask user + const { Select } = await import("./deps.ts"); + const choice = await Select.prompt({ + message: "Settings conflict detected. How would you like to proceed?", + options: [ + { + name: "Replace existing settings", + value: "replace", + }, + { + name: "Add repository-specific override", + value: "override", + }, + { name: "Cancel", value: "cancel" }, + ], + }); + + if (choice === "cancel") { + log.info("Operation cancelled"); + return; + } + + writeMode = choice as "replace" | "override"; + if (writeMode === "override") { + const repoPath = normalizeRepoPath( + selectedRepo.git_repo_resource_path, + ); + overrideKey = constructOverrideKey( + workspace.remote, + workspace.workspaceId, + repoPath, + ); + } + } else if (hasConflict) { + // Non-interactive mode with conflicts - show message and exit + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: false, + error: + "Settings conflict detected. Use --replace or --override flags to resolve.", + hasConflict: true, + }), + ); + } else { + log.error(colors.red("Settings conflict detected.")); + log.info( + "Use --replace to overwrite existing settings or --override to add repository-specific override.", + ); + } + return; + } + } + + // Check if there are actually any changes before writing + const currentSettingsForCheck = getCurrentSettings( + localConfig, + writeMode, + overrideKey, + ); + const gitSyncBackend = extractGitSyncFields( + normalizeSyncOptions(backendSyncOptions), + ); + const gitSyncCurrent = extractGitSyncFields( + normalizeSyncOptions(currentSettingsForCheck), + ); + const hasActualChanges = !deepEqual(gitSyncBackend, gitSyncCurrent); + + if (!hasActualChanges) { + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: true, + message: "No changes needed", + repository: selectedRepo.git_repo_resource_path, + }), + ); + } else { + log.info( + colors.green( + "No changes needed - settings are already up to date", + ), + ); + } + return; + } + + // Apply the settings based on write mode + let updatedConfig: SyncOptions; + + if (writeMode === "replace") { + // Preserve existing local config and update only git-sync fields + updatedConfig = { ...localConfig }; + // Remove overrides since we're in replace mode + delete updatedConfig.overrides; + // Update with backend git-sync settings + Object.assign(updatedConfig, backendSyncOptions); + } else if (writeMode === "override" && overrideKey) { + // Add repository-specific override + updatedConfig = { ...localConfig }; + if (!updatedConfig.overrides) { + updatedConfig.overrides = {}; + } + + // Only store the delta - settings that differ from current effective settings + const currentEffective = getCurrentSettings(localConfig, "replace"); + const deltaSettings: Partial = {}; + + // Compare each setting and only include differences + for (const [key, value] of Object.entries(backendSyncOptions)) { + if (key === "overrides") continue; // Skip overrides field + + const currentValue = (currentEffective as any)[key]; + const newValue = value; + + // Compare arrays by content, primitives by value + const isDifferent = + Array.isArray(currentValue) && Array.isArray(newValue) + ? !arraysEqual(currentValue, newValue) + : currentValue !== newValue; + + if (isDifferent) { + (deltaSettings as any)[key] = newValue; + } + } + + updatedConfig.overrides[overrideKey] = deltaSettings; + } else { + // Replace top-level settings + updatedConfig = { ...localConfig }; + // Copy all backend settings to top level, excluding overrides + const { overrides, ...topLevelSettings } = backendSyncOptions; + Object.assign(updatedConfig, topLevelSettings); + } + + // Write updated configuration + await Deno.writeTextFile("wmill.yaml", yamlStringify(updatedConfig)); + + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: true, + message: `Git-sync settings pulled successfully`, + repository: selectedRepo.git_repo_resource_path, + overrideKey, + }), + ); + } else { + log.info( + colors.green( + `Git-sync settings pulled successfully from ${selectedRepo.git_repo_resource_path}`, + ), + ); + if (writeMode === "override" && overrideKey) { + log.info( + colors.gray( + `Settings written to override key: ${overrideKey}`, + ), + ); + } else if (writeMode === "replace") { + log.info(colors.gray(`Settings written as simple configuration`)); + } else { + log.info(colors.gray(`Settings written to top-level defaults`)); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (opts.jsonOutput) { + console.log( + JSON.stringify({ success: false, error: errorMessage }), + ); + } else { + log.error( + colors.red( + `Failed to pull git-sync settings: ${errorMessage}`, + ), + ); + } + } +} + +async function pushGitSyncSettings( + opts: GlobalOptions & { + repository?: string; + diff?: boolean; + jsonOutput?: boolean; + withBackendSettings?: string; + }, +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + try { + // Check if wmill.yaml exists - require it for git-sync settings commands + try { + await Deno.stat("wmill.yaml"); + } catch (error) { + log.error( + colors.red( + "No wmill.yaml file found. Please run 'wmill init' first to create the configuration file.", + ), + ); + Deno.exit(1); + } + + // Read local configuration + const localConfig = await readConfigFile(); + + // Parse and validate --with-backend-settings if provided, otherwise fetch from backend + let settings: any; + if (opts.withBackendSettings) { + try { + const parsedSettings = JSON.parse(opts.withBackendSettings); + + // Validate the structure matches expected test format (raw settings object) + if ( + !parsedSettings.include_path || + !Array.isArray(parsedSettings.include_path) + ) { + throw new Error( + "Invalid settings format. Expected include_path array", + ); + } + if ( + !parsedSettings.include_type || + !Array.isArray(parsedSettings.include_type) + ) { + throw new Error( + "Invalid settings format. Expected include_type array", + ); + } + + // Create mock backend response with single repository using provided settings + const mockRepositoryPath = opts.repository || "u/mock/repo"; + settings = { + git_sync: { + repositories: [{ + git_repo_resource_path: mockRepositoryPath, + settings: { + include_path: parsedSettings.include_path, + include_type: parsedSettings.include_type, + exclude_path: parsedSettings.exclude_path || [], + extra_include_path: parsedSettings.extra_include_path || [], + }, + }], + }, + }; + } catch (parseError) { + const errorMessage = parseError instanceof Error ? parseError.message : String(parseError); + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: false, + error: + `Failed to parse --with-backend-settings JSON: ${errorMessage}`, + }), + ); + } else { + log.error( + colors.red( + `Failed to parse --with-backend-settings JSON: ${errorMessage}`, + ), + ); + } + return; + } + } else { + // Fetch current backend settings + try { + settings = await wmill.getSettings({ + workspace: workspace.workspaceId, + }); + } catch (apiError) { + const errorMessage = apiError instanceof Error ? apiError.message : String(apiError); + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: false, + error: `Failed to fetch workspace settings: ${errorMessage}`, + }), + ); + } else { + log.error( + colors.red( + `Failed to fetch workspace settings: ${errorMessage}`, + ), + ); + } + return; + } + } + + if ( + !settings.git_sync?.repositories || + settings.git_sync.repositories.length === 0 + ) { + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: false, + error: "No git-sync repositories configured", + }), + ); + } else { + log.error( + colors.red( + "No git-sync repositories configured in workspace", + ), + ); + } + return; + } + + // Find the repository to work with + let selectedRepo: GitSyncRepository; + if (opts.repository) { + const found = settings.git_sync.repositories.find( + (r: GitSyncRepository) => + r.git_repo_resource_path === opts.repository || + r.git_repo_resource_path === `$res:${opts.repository}`, + ); + if (!found) { + throw new Error(`Repository ${opts.repository} not found`); + } + selectedRepo = found; + } else { + selectedRepo = await selectRepository( + settings.git_sync.repositories, + ); + } + + // Get effective settings for this workspace/repo + const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path); + const effectiveSettings = getEffectiveSettings( + localConfig, + workspace.remote, + workspace.workspaceId, + repoPath, + ); + + // Convert to backend format + const backendFormat = { + include_path: effectiveSettings.includes || [], + include_type: syncOptionsToIncludeType(effectiveSettings), + exclude_path: effectiveSettings.excludes || [], + extra_include_path: effectiveSettings.extraIncludes || [], + }; + + if (opts.diff) { + // Show what would be pushed + const currentBackend = selectedRepo.settings; + + // Convert current backend settings to SyncOptions for user-friendly display + const currentSyncOptions: SyncOptions = { + includes: currentBackend.include_path || [], + excludes: currentBackend.exclude_path || [], + extraIncludes: currentBackend.extra_include_path || [], + ...includeTypeToSyncOptions(currentBackend.include_type || []), + }; + + const normalizedCurrent = normalizeSyncOptions(currentSyncOptions); + const normalizedEffective = normalizeSyncOptions(effectiveSettings); + const gitSyncCurrent = extractGitSyncFields(normalizedCurrent); + const gitSyncEffective = extractGitSyncFields(normalizedEffective); + const hasChanges = !deepEqual(gitSyncEffective, gitSyncCurrent); + + if (opts.jsonOutput) { + // Generate structured diff using the same normalized objects + const structuredDiff = hasChanges + ? generateStructuredDiff(gitSyncCurrent, gitSyncEffective) + : {}; + + console.log( + JSON.stringify({ + success: true, + hasChanges, + local: syncOptionsToBackendFormat(normalizedEffective), + backend: syncOptionsToBackendFormat(normalizedCurrent), + repository: selectedRepo.git_repo_resource_path, + diff: structuredDiff, + }), + ); + } else { + if (hasChanges) { + log.info("Changes that would be pushed:"); + const changes = generateChanges( + normalizedCurrent, + normalizedEffective, + ); + + if (Object.keys(changes).length === 0) { + log.info(colors.green("No changes to push")); + } else { + displayChanges(changes); + } + } else { + log.info(colors.green("No changes to push")); + } + } + return; + } + + if (opts.withBackendSettings) { + // Skip backend update when using simulated settings + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: true, + message: + `Git-sync settings push simulated (--with-backend-settings used)`, + repository: selectedRepo.git_repo_resource_path, + simulated: true, + }), + ); + } else { + log.info( + colors.green( + `Git-sync settings push simulated for ${selectedRepo.git_repo_resource_path} (--with-backend-settings used)`, + ), + ); + } + } else { + // Update the specific repository settings + const updatedRepos = settings.git_sync.repositories.map( + (repo: GitSyncRepository) => { + if ( + repo.git_repo_resource_path === + selectedRepo.git_repo_resource_path + ) { + return { + ...repo, + settings: backendFormat, + }; + } + return repo; + }, + ); + + // Push updated settings to backend + try { + await wmill.editWorkspaceGitSyncConfig({ + workspace: workspace.workspaceId, + requestBody: { + git_sync_settings: { + repositories: updatedRepos, + }, + }, + }); + } catch (apiError) { + const errorMessage = apiError instanceof Error ? apiError.message : String(apiError); + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: false, + error: `Failed to update workspace git-sync config: ${errorMessage}`, + }), + ); + } else { + log.error( + colors.red( + `Failed to update workspace git-sync config: ${errorMessage}`, + ), + ); + } + return; + } + + if (opts.jsonOutput) { + console.log( + JSON.stringify({ + success: true, + message: `Git-sync settings pushed successfully`, + repository: selectedRepo.git_repo_resource_path, + }), + ); + } else { + log.info( + colors.green( + `Git-sync settings pushed successfully to ${selectedRepo.git_repo_resource_path}`, + ), + ); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (opts.jsonOutput) { + console.log( + JSON.stringify({ success: false, error: errorMessage }), + ); + } else { + log.error( + colors.red( + `Failed to push git-sync settings: ${errorMessage}`, + ), + ); + } + } +} + +const command = new Command() + .description( + "Manage git-sync settings between local wmill.yaml and Windmill backend", + ) + .command("pull") + .description( + "Pull git-sync settings from Windmill backend to local wmill.yaml", + ) + .option( + "--repository ", + "Specify repository path (e.g., u/user/repo)", + ) + .option( + "--workspace-level", + "Write settings to workspace:* override instead of workspace:repo", + ) + .option( + "--default", + "Write settings to top-level defaults instead of overrides", + ) + .option("--replace", "Replace existing settings (non-interactive mode)") + .option( + "--override", + "Add repository-specific override (non-interactive mode)", + ) + .option("--diff", "Show differences without applying changes") + .option("--json-output", "Output in JSON format") + .option( + "--with-backend-settings ", + "Use provided JSON settings instead of querying backend (for testing)", + ) + .action(pullGitSyncSettings as any) + .command("push") + .description( + "Push git-sync settings from local wmill.yaml to Windmill backend", + ) + .option( + "--repository ", + "Specify repository path (e.g., u/user/repo)", + ) + .option("--diff", "Show what would be pushed without applying changes") + .option("--json-output", "Output in JSON format") + .option( + "--with-backend-settings ", + "Use provided JSON settings instead of querying backend (for testing)", + ) + .action(pushGitSyncSettings as any); + +export { pullGitSyncSettings, pushGitSyncSettings }; +export default command; diff --git a/cli/instance.ts b/cli/instance.ts index e3abdb4eaa..99c0066b33 100644 --- a/cli/instance.ts +++ b/cli/instance.ts @@ -365,6 +365,7 @@ async function instancePull(opts: InstanceSyncOptions) { workspace: workspaceName, token: undefined, baseUrl: undefined, + configDir: undefined, includeGroups: true, includeSchedules: true, includeTriggers: true, @@ -515,6 +516,7 @@ async function instancePush(opts: InstanceSyncOptions) { token: instance.token, workspace: undefined, baseUrl: undefined, + configDir: undefined, create: true, createWorkspaceName: workspaceSettings.name, createUsername: undefined, @@ -533,6 +535,7 @@ async function instancePush(opts: InstanceSyncOptions) { workspace: localWorkspace.dir, token: undefined, baseUrl: undefined, + configDir: undefined, includeGroups: true, includeSchedules: true, includeTriggers: true, diff --git a/cli/main.ts b/cli/main.ts index 875b45895e..2f78cb6c48 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -1,11 +1,11 @@ import { - Command, - CompletionsCommand, - UpgradeCommand, - colors, - esMain, - log, - yamlStringify, + Command, + CompletionsCommand, + UpgradeCommand, + colors, + esMain, + log, + yamlStringify, } from "./deps.ts"; import flow from "./flow.ts"; import app from "./apps.ts"; @@ -19,6 +19,7 @@ import folder from "./folder.ts"; import schedule from "./schedule.ts"; import trigger from "./trigger.ts"; import sync from "./sync.ts"; +import gitsyncSettings from "./gitsync-settings.ts"; import instance from "./instance.ts"; import workerGroups from "./worker_groups.ts"; @@ -36,24 +37,25 @@ import queues from "./queues.ts"; import { readLockfile } from "./metadata.ts"; export { - flow, - app, - script, - workspace, - resource, - user, - variable, - hub, - folder, - schedule, - trigger, - sync, - instance, - dev, - hubPull, - pull, - push, - workspaceAdd, + flow, + app, + script, + workspace, + resource, + user, + variable, + hub, + folder, + schedule, + trigger, + sync, + gitsyncSettings, + instance, + dev, + hubPull, + pull, + push, + workspaceAdd, }; // addEventListener("error", (event) => { @@ -63,201 +65,352 @@ export { // } // }); -export const VERSION = "1.505.3"; +export const VERSION = "1.507.0"; const command = new Command() - .name("wmill") - .action(() => - log.info(`Welcome to Windmill CLI ${VERSION}. Use -h for help.`) - ) - .description("Windmill CLI") + .name("wmill") + .action(() => + log.info(`Welcome to Windmill CLI ${VERSION}. Use -h for help.`), + ) + .description("Windmill CLI") - .globalOption( - "--workspace ", - "Specify the target workspace. This overrides the default workspace." - ) - .globalOption("--debug --verbose", "Show debug/verbose logs") - .globalOption( - "--show-diffs", - "Show diff informations when syncing (may show sensitive informations)" - ) - .globalOption( - "--token ", - "Specify an API token. This will override any stored token." - ) - .globalOption( - "--base-url ", - "Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used." - ) - .env( - "HEADERS ", - "Specify headers to use for all requests. e.g: \"HEADERS='h1: v1, h2: v2'\"" - ) - .version(VERSION) - .versionOption(false) - .command("init", "Bootstrap a windmill project with a wmill.yaml file") - .action(async () => { - if (await Deno.stat("wmill.yaml").catch(() => null)) { - log.error(colors.green("wmill.yaml already exists")); - } else { - await Deno.writeTextFile( - "wmill.yaml", - yamlStringify({ - defaultTs: "bun", - includes: ["f/**"], - excludes: [], - codebases: [], - skipVariables: true, - skipResources: true, - skipSecrets: true, - includeSchedules: false, - includeTriggers: false, - }) - ); - log.info(colors.green("wmill.yaml created")); - } - await readLockfile(); - }) - .command("app", app) - .command("flow", flow) - .command("script", script) - .command("workspace", workspace) - .command("resource", resource) - .command("user", user) - .command("variable", variable) - .command("hub", hub) - .command("folder", folder) - .command("schedule", schedule) - .command("trigger", trigger) - .command("dev", dev) - .command("sync", sync) - .command("instance", instance) - .command("worker-groups", workerGroups) - .command("workers", workers) - .command("queues", queues) - .command("version --version", "Show version information") - .action(async (opts) => { - console.log("CLI version: " + VERSION); - try { - const provider = new NpmProvider({ package: "windmill-cli" }); - const versions = await provider.getVersions("windmill-cli"); - if (versions.latest !== VERSION) { - console.log( - `CLI is outdated. Latest version ${versions.latest} is available. Run \`wmill upgrade\` to update.` - ); - } else { - console.log("CLI is up to date"); - } - } catch (e) { - console.warn( - `Cannot fetch latest CLI version on npmjs to check if up-to-date: ${e}` - ); - } - const workspace = await getActiveWorkspace(opts as GlobalOptions); - if (workspace) { - try { - const backendVersion = await fetchVersion(workspace.remote); - console.log("Backend Version: " + backendVersion); - } catch (e) { - console.warn("Cannot fetch backend version: " + e); - } - } else { - console.warn( - "Cannot fetch backend version: no active workspace selected, choose one to pick a remote to fetch version of" - ); - } - }) - .command( - "upgrade", - new UpgradeCommand({ - provider: new NpmProvider({ package: "windmill-cli" }), - }).error((e) => { - log.error(e); - log.info( - "Try running with sudo and otherwise check the result of the command: npm uninstall windmill-cli && npm install -g windmill-cli" - ); + .globalOption( + "--workspace ", + "Specify the target workspace. This overrides the default workspace.", + ) + .globalOption("--debug --verbose", "Show debug/verbose logs") + .globalOption( + "--show-diffs", + "Show diff informations when syncing (may show sensitive informations)", + ) + .globalOption( + "--token ", + "Specify an API token. This will override any stored token.", + ) + .globalOption( + "--base-url ", + "Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.", + ) + .globalOption( + "--config-dir ", + "Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.", + ) + .env( + "HEADERS ", + "Specify headers to use for all requests. e.g: \"HEADERS='h1: v1, h2: v2'\"", + ) + .version(VERSION) + .versionOption(false) + .command("init", "Bootstrap a windmill project with a wmill.yaml file") + .option("--use-default", "Use default settings without checking backend") + .option("--use-backend", "Use backend git-sync settings if available") + .option( + "--repository ", + "Specify repository path (e.g., u/user/repo) when using backend settings", + ) + .action( + async ( + opts: { + useDefault?: boolean; + useBackend?: boolean; + repository?: string; + workspace?: string; + debug?: unknown; + showDiffs?: boolean; + token?: string; + baseUrl?: string; + configDir?: string; + }, + ) => { + if (await Deno.stat("wmill.yaml").catch(() => null)) { + log.error(colors.red("wmill.yaml already exists")); + } else { + // Import DEFAULT_SYNC_OPTIONS from conf.ts + const { DEFAULT_SYNC_OPTIONS } = await import("./conf.ts"); + + // Create initial config with defaults + const initialConfig = { + defaultTs: DEFAULT_SYNC_OPTIONS.defaultTs, + includes: DEFAULT_SYNC_OPTIONS.includes, + excludes: DEFAULT_SYNC_OPTIONS.excludes, + codebases: DEFAULT_SYNC_OPTIONS.codebases, + skipVariables: DEFAULT_SYNC_OPTIONS.skipVariables, + skipResources: DEFAULT_SYNC_OPTIONS.skipResources, + skipSecrets: DEFAULT_SYNC_OPTIONS.skipSecrets, + skipScripts: DEFAULT_SYNC_OPTIONS.skipScripts, + skipFlows: DEFAULT_SYNC_OPTIONS.skipFlows, + skipApps: DEFAULT_SYNC_OPTIONS.skipApps, + skipFolders: DEFAULT_SYNC_OPTIONS.skipFolders, + includeSchedules: DEFAULT_SYNC_OPTIONS.includeSchedules, + includeTriggers: DEFAULT_SYNC_OPTIONS.includeTriggers, + }; + + await Deno.writeTextFile( + "wmill.yaml", + yamlStringify(initialConfig), + ); + log.info( + colors.green("wmill.yaml created with default settings"), + ); + + // Create lock file + await readLockfile(); + + // Check for backend git-sync settings unless --use-default is specified + if (!opts.useDefault) { + try { + const { requireLogin, resolveWorkspace } = await import( + "./context.ts" + ); + + // Check if user has workspace configured + const { getActiveWorkspace } = await import( + "./workspace.ts" + ); + const activeWorkspace = await getActiveWorkspace(opts as GlobalOptions); + + if (!activeWorkspace) { + log.info( + "No workspace configured. Using default settings.", + ); + log.info( + "You can configure a workspace later with 'wmill workspace add'", + ); + return; + } + + await requireLogin(opts as GlobalOptions); + const workspace = await resolveWorkspace(opts as GlobalOptions); + + const wmill = await import("./gen/services.gen.ts"); + const settings = await wmill.getSettings({ + workspace: workspace.workspaceId, + }); + + if ( + settings.git_sync?.repositories && + settings.git_sync.repositories.length > 0 + ) { + let useBackendSettings = opts.useBackend; + + // If repository is specified, implicitly use backend settings + if (opts.repository && !opts.useDefault) { + useBackendSettings = true; + } + + if (useBackendSettings === undefined) { + // Interactive prompt + const { Select } = await import("./deps.ts"); + const choice = await Select.prompt({ + message: + "Git-sync settings found on backend. What would you like to do?", + options: [ + { + name: "Use backend git-sync settings", + value: "backend", + }, + { + name: "Use default settings", + value: "default", + }, + { + name: "Cancel", + value: "cancel", + }, + ], + }); + + if (choice === "cancel") { + // Clean up the created files + try { + await Deno.remove("wmill.yaml"); + await Deno.remove("wmill-lock.yaml"); + } catch (e) { + // Ignore cleanup errors + } + log.info("Init cancelled"); + Deno.exit(0); + } + + useBackendSettings = choice === "backend"; + } + + if (useBackendSettings) { + log.info( + "Applying git-sync settings from backend...", + ); + + // Import and run the pull git-sync settings logic + const { pullGitSyncSettings } = await import( + "./gitsync-settings.ts" + ); + await pullGitSyncSettings({ + ...(opts as GlobalOptions), + repository: opts.repository, + jsonOutput: false, + diff: false, + replace: true, // Auto-replace when using backend settings during init + }); + + log.info( + colors.green( + "Git-sync settings applied from backend", + ), + ); + } + } + } catch (error) { + // If there's an error checking backend settings, just continue with defaults + log.warn( + `Could not check backend for git-sync settings: ${error.message}`, + ); + log.info("Continuing with default settings"); + } + } + } + }, + ) + .command("app", app) + .command("flow", flow) + .command("script", script) + .command("workspace", workspace) + .command("resource", resource) + .command("user", user) + .command("variable", variable) + .command("hub", hub) + .command("folder", folder) + .command("schedule", schedule) + .command("trigger", trigger) + .command("dev", dev) + .command("sync", sync) + .command("gitsync-settings", gitsyncSettings) + .command("instance", instance) + .command("worker-groups", workerGroups) + .command("workers", workers) + .command("queues", queues) + .command("version --version", "Show version information") + .action(async (opts) => { + console.log("CLI version: " + VERSION); + try { + const provider = new NpmProvider({ package: "windmill-cli" }); + const versions = await provider.getVersions("windmill-cli"); + if (versions.latest !== VERSION) { + console.log( + `CLI is outdated. Latest version ${versions.latest} is available. Run \`wmill upgrade\` to update.`, + ); + } else { + console.log("CLI is up to date"); + } + } catch (e) { + console.warn( + `Cannot fetch latest CLI version on npmjs to check if up-to-date: ${e}`, + ); + } + const workspace = await getActiveWorkspace(opts as GlobalOptions); + if (workspace) { + try { + const backendVersion = await fetchVersion(workspace.remote); + console.log("Backend Version: " + backendVersion); + } catch (e) { + console.warn("Cannot fetch backend version: " + e); + } + } else { + console.warn( + "Cannot fetch backend version: no active workspace selected, choose one to pick a remote to fetch version of", + ); + } }) - ) - .command("completions", new CompletionsCommand()); + .command( + "upgrade", + new UpgradeCommand({ + provider: new NpmProvider({ package: "windmill-cli" }), + }).error((e) => { + log.error(e); + log.info( + "Try running with sudo and otherwise check the result of the command: npm uninstall windmill-cli && npm install -g windmill-cli", + ); + }), + ) + .command("completions", new CompletionsCommand()); export let showDiffs = false; let isWin: boolean | undefined = undefined; export async function getIsWin() { - if (isWin === undefined) { - const os = await import("node:os"); - isWin = os.platform() === "win32"; - } - return isWin; + if (isWin === undefined) { + const os = await import("node:os"); + isWin = os.platform() === "win32"; + } + return isWin; } async function main() { - try { - if (Deno.args.length === 0) { - command.showHelp(); - } - const LOG_LEVEL = - Deno.args.includes("--verbose") || Deno.args.includes("--debug") - ? "DEBUG" - : "INFO"; - // const NO_COLORS = Deno.args.includes("--no-colors"); - showDiffs = Deno.args.includes("--show-diffs"); + try { + if (Deno.args.length === 0) { + command.showHelp(); + } + const LOG_LEVEL = + Deno.args.includes("--verbose") || Deno.args.includes("--debug") + ? "DEBUG" + : "INFO"; + // const NO_COLORS = Deno.args.includes("--no-colors"); + showDiffs = Deno.args.includes("--show-diffs"); - log.setup({ - handlers: { - console: new log.ConsoleHandler(LOG_LEVEL, { - formatter: ({ msg }) => `${msg}`, - useColors: isWin ? false : true, - }), - }, - loggers: { - default: { - level: LOG_LEVEL, - handlers: ["console"], - }, - }, - }); - log.debug("Debug logging enabled. CLI build against " + VERSION); + log.setup({ + handlers: { + console: new log.ConsoleHandler(LOG_LEVEL, { + formatter: ({ msg }) => `${msg}`, + useColors: isWin ? false : true, + }), + }, + loggers: { + default: { + level: LOG_LEVEL, + handlers: ["console"], + }, + }, + }); + log.debug("Debug logging enabled. CLI build against " + VERSION); - const extraHeaders = getHeaders(); - if (extraHeaders) { - OpenAPI.HEADERS = extraHeaders; + const extraHeaders = getHeaders(); + if (extraHeaders) { + OpenAPI.HEADERS = extraHeaders; + } + await command.parse(Deno.args); + } catch (e) { + if (e.name === "ApiError") { + console.log("Server failed. " + e.statusText + ": " + e.body); + } + throw e; } - await command.parse(Deno.args); - } catch (e) { - if (e.name === "ApiError") { - console.log("Server failed. " + e.statusText + ": " + e.body); - } - throw e; - } } function isMain() { - // dnt-shim-ignore - const { Deno } = globalThis as any; + // dnt-shim-ignore + const { Deno } = globalThis as any; - const isDeno = Deno != undefined; + const isDeno = Deno != undefined; - if (isDeno) { - const isMain = import.meta.main; - if (isMain) { - if (!Deno.args.includes("completions")) { - if (Deno.env.get("SKIP_DENO_DEPRECATION_WARNING") !== "true") { - log.warn( - "Using the deno runtime for the Windmill CLI is deprecated, you can now use node: deno uninstall wmill && npm install -g windmill-cli. To skip this warning set SKIP_DENO_DEPRECATION_WARNING=true" - ); + if (isDeno) { + const isMain = import.meta.main; + if (isMain) { + if (!Deno.args.includes("completions")) { + if (Deno.env.get("SKIP_DENO_DEPRECATION_WARNING") !== "true") { + log.warn( + "Using the deno runtime for the Windmill CLI is deprecated, you can now use node: deno uninstall wmill && npm install -g windmill-cli. To skip this warning set SKIP_DENO_DEPRECATION_WARNING=true", + ); + } + } } - } + return isMain; + } else { + //@ts-ignore + return esMain.default(import.meta); } - return isMain; - } else { - //@ts-ignore - return esMain.default(import.meta); - } } if (isMain()) { - main(); + main(); } export default command; diff --git a/cli/store.ts b/cli/store.ts index 3833b8f9b7..09d854606c 100644 --- a/cli/store.ts +++ b/cli/store.ts @@ -13,15 +13,20 @@ function hash_string(str: string): number { return hash; } -export async function getRootStore(): Promise { - const store = (config_dir() ?? tmp_dir() ?? "/tmp/") + "/windmill/"; +export async function getRootStore(configDirOverride?: string): Promise { + const baseDir = configDirOverride ?? + Deno.env.get("WMILL_CONFIG_DIR") ?? + config_dir() ?? + tmp_dir() ?? + "/tmp/"; + const store = baseDir + "/windmill/"; await ensureDir(store); return store; } -export async function getStore(baseUrl: string): Promise { +export async function getStore(baseUrl: string, configDirOverride?: string): Promise { const baseHash = Math.abs(hash_string(baseUrl)).toString(16); - const baseStore = (await getRootStore()) + baseHash + "/"; + const baseStore = (await getRootStore(configDirOverride)) + baseHash + "/"; await ensureDir(baseStore); return baseStore; } diff --git a/cli/sync.ts b/cli/sync.ts index 456afcadf9..6b8a0d12f4 100644 --- a/cli/sync.ts +++ b/cli/sync.ts @@ -3,6 +3,8 @@ import { colors, Command, Confirm, + Input, + Select, ensureDir, minimatch, JSZip, @@ -34,8 +36,9 @@ import { } from "./script.ts"; import { handleFile } from "./script.ts"; -import { deepEqual, isFileResource } from "./utils.ts"; -import { SyncOptions, mergeConfigWithConfigFile } from "./conf.ts"; +import { deepEqual, isFileResource, Repository, selectRepository } from "./utils.ts"; +import { SyncOptions, mergeConfigWithConfigFile, readConfigFile, getEffectiveSettings } from "./conf.ts"; +import { Workspace } from "./workspace.ts"; import { removePathPrefix } from "./types.ts"; import { SyncCodebase, listSyncCodebases } from "./codebase.ts"; import { @@ -46,6 +49,128 @@ import { import { FlowModule, OpenFlow, RawScript } from "./gen/types.gen.ts"; import { pushResource } from "./resource.ts"; + +// Merge CLI options with effective settings, preserving CLI flags as overrides +function mergeCliWithEffectiveOptions( + cliOpts: T, + effectiveOpts: SyncOptions +): T { + // Start with effective options from config, then overlay only explicitly provided CLI flags + const mergedOpts = Object.assign({}, effectiveOpts) as T; + + // Always preserve these operational CLI flags + if (cliOpts.dryRun !== undefined) mergedOpts.dryRun = cliOpts.dryRun; + if (cliOpts.yes !== undefined) mergedOpts.yes = cliOpts.yes; + if (cliOpts.stateful !== undefined) mergedOpts.stateful = cliOpts.stateful; + if (cliOpts.skipPull !== undefined) mergedOpts.skipPull = cliOpts.skipPull; + if (cliOpts.failConflicts !== undefined) mergedOpts.failConflicts = cliOpts.failConflicts; + if (cliOpts.plainSecrets !== undefined) mergedOpts.plainSecrets = cliOpts.plainSecrets; + if (cliOpts.json !== undefined) mergedOpts.json = cliOpts.json; + if (cliOpts.message !== undefined) mergedOpts.message = cliOpts.message; + if (cliOpts.parallel !== undefined) mergedOpts.parallel = cliOpts.parallel; + if (cliOpts.jsonOutput !== undefined) mergedOpts.jsonOutput = cliOpts.jsonOutput; + if (cliOpts.repository !== undefined) mergedOpts.repository = cliOpts.repository; + + // Always preserve CLI include flags (they should override config file settings) + if (cliOpts.includeUsers !== undefined) mergedOpts.includeUsers = cliOpts.includeUsers; + if (cliOpts.includeGroups !== undefined) mergedOpts.includeGroups = cliOpts.includeGroups; + if (cliOpts.includeSettings !== undefined) mergedOpts.includeSettings = cliOpts.includeSettings; + if (cliOpts.includeKey !== undefined) mergedOpts.includeKey = cliOpts.includeKey; + if (cliOpts.includeSchedules !== undefined) mergedOpts.includeSchedules = cliOpts.includeSchedules; + if (cliOpts.includeTriggers !== undefined) mergedOpts.includeTriggers = cliOpts.includeTriggers; + + // Always preserve CLI skip flags (they should override config file settings) + if (cliOpts.skipScripts !== undefined) mergedOpts.skipScripts = cliOpts.skipScripts; + if (cliOpts.skipFlows !== undefined) mergedOpts.skipFlows = cliOpts.skipFlows; + if (cliOpts.skipApps !== undefined) mergedOpts.skipApps = cliOpts.skipApps; + if (cliOpts.skipFolders !== undefined) mergedOpts.skipFolders = cliOpts.skipFolders; + + return mergedOpts; +} + +// Resolve effective sync options with smart repository detection +async function resolveEffectiveSyncOptions( + workspace: Workspace, + repositoryPath?: string +): Promise { + const localConfig = await readConfigFile(); + + // If repository path is already specified, use it directly + if (repositoryPath) { + return getEffectiveSettings( + localConfig, + workspace.remote, + workspace.workspaceId, + repositoryPath + ); + } + + // Auto-detect repository from overrides if not specified + if (localConfig.overrides) { + const prefix = `${workspace.remote}:${workspace.workspaceId}:`; + const applicableRepos: string[] = []; + + // Find all repository-specific overrides for this workspace + for (const key of Object.keys(localConfig.overrides)) { + if (key.startsWith(prefix) && !key.endsWith(':*')) { + const repo = key.substring(prefix.length); + if (repo) { + applicableRepos.push(repo); + } + } + } + + if (applicableRepos.length === 1) { + // Single repository found - auto-select it + log.info(`Auto-selected repository: ${applicableRepos[0]}`); + return getEffectiveSettings( + localConfig, + workspace.remote, + workspace.workspaceId, + applicableRepos[0] + ); + } else if (applicableRepos.length > 1) { + // Multiple repositories found - prompt for selection + const isInteractive = Deno.stdin.isTerminal() && Deno.stdout.isTerminal(); + + if (isInteractive) { + const choices = [ + { name: "Use top-level settings (no repository-specific override)", value: "" }, + ...applicableRepos.map(repo => ({ name: repo, value: repo })) + ]; + + const selectedRepo = await Select.prompt({ + message: "Multiple repository overrides found. Select which to use:", + options: choices + }); + + if (selectedRepo) { + log.info(`Selected repository: ${selectedRepo}`); + } + + return getEffectiveSettings( + localConfig, + workspace.remote, + workspace.workspaceId, + selectedRepo + ); + } else { + // Non-interactive mode - list options and use top-level + log.warn(`Multiple repository overrides found: ${applicableRepos.join(', ')}`); + log.warn(`Use --repository flag to specify which one to use. Using top-level settings.`); + } + } + } + + // No repository overrides found or selected - use top-level settings + return getEffectiveSettings( + localConfig, + workspace.remote, + workspace.workspaceId, + "" + ); +} + type DynFSElement = { isDirectory: boolean; path: string; @@ -678,7 +803,17 @@ export async function elementsToMap( if (skips.skipResourceTypes && path.endsWith(".resource-type" + ext)) continue; - if (skips.skipVariables && path.endsWith(".variable" + ext)) continue; + // Use getTypeStrFromPath for consistent type detection + try { + const fileType = getTypeStrFromPath(path); + if (skips.skipVariables && fileType === "variable") continue; + if (skips.skipScripts && fileType === "script") continue; + if (skips.skipFlows && fileType === "flow") continue; + if (skips.skipApps && fileType === "app") continue; + if (skips.skipFolders && fileType === "folder") continue; + } catch { + // If getTypeStrFromPath can't determine the type, continue processing the file + } if (skips.skipResources && isFileResource(path)) continue; @@ -733,6 +868,10 @@ export interface Skips { skipResources?: boolean | undefined; skipResourceTypes?: boolean | undefined; skipSecrets?: boolean | undefined; + skipScripts?: boolean | undefined; + skipFlows?: boolean | undefined; + skipApps?: boolean | undefined; + skipFolders?: boolean | undefined; skipScriptsMetadata?: boolean | undefined; includeSchedules?: boolean | undefined; includeTriggers?: boolean | undefined; @@ -1000,6 +1139,10 @@ export async function ignoreF(wmillconf: { extraIncludes?: string[]; skipResourceTypes?: boolean; json?: boolean; + includeUsers?: boolean; + includeGroups?: boolean; + includeSettings?: boolean; + includeKey?: boolean; }): Promise<(p: string, isDirectory: boolean) => boolean> { let whitelist: { approve(file: string): boolean } | undefined = undefined; @@ -1014,7 +1157,7 @@ export async function ignoreF(wmillconf: { wmillconf.includes?.some((i) => minimatch(file, i))) && (!wmillconf?.excludes || wmillconf.excludes!.every((i) => !minimatch(file, i))) && - (!wmillconf.extraIncludes || + (!wmillconf.extraIncludes || wmillconf.extraIncludes.length === 0 || wmillconf.extraIncludes.some((i) => minimatch(file, i))) ); }, @@ -1035,6 +1178,28 @@ export async function ignoreF(wmillconf: { if (!isDirectory && p.endsWith(".resource-type" + ext)) { return wmillconf.skipResourceTypes ?? false; } + + // Special files should bypass path-based filtering when their include flags are set + if (!isDirectory) { + try { + const fileType = getTypeStrFromPath(p); + if (wmillconf.includeUsers && fileType === "user") { + return false; // Don't ignore, always include + } + if (wmillconf.includeGroups && fileType === "group") { + return false; // Don't ignore, always include + } + if (wmillconf.includeSettings && fileType === "settings") { + return false; // Don't ignore, always include + } + if (wmillconf.includeKey && fileType === "encryption_key") { + return false; // Don't ignore, always include + } + } catch { + // If getTypeStrFromPath can't determine the type, fall through to normal logic + } + } + return ( !isWhitelisted(p) && (isNotWmillFile(p, isDirectory) || @@ -1094,9 +1259,7 @@ async function buildTracker(changes: Change[]) { return tracker; } -export async function pull(opts: GlobalOptions & SyncOptions) { - opts = await mergeConfigWithConfigFile(opts); - +export async function pull(opts: GlobalOptions & SyncOptions & { repository?: string }) { if (opts.stateful) { await ensureDir(path.join(Deno.cwd(), ".wmill")); } @@ -1104,6 +1267,12 @@ export async function pull(opts: GlobalOptions & SyncOptions) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); + // Resolve effective sync options with repository awareness + const effectiveOpts = await resolveEffectiveSyncOptions(workspace, opts.repository); + + // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) + opts = mergeCliWithEffectiveOptions(opts, effectiveOpts); + const codebases = await listSyncCodebases(opts); log.info( @@ -1158,8 +1327,26 @@ export async function pull(opts: GlobalOptions & SyncOptions) { log.info( `remote (${workspace.name}) -> local: ${changes.length} changes to apply` ); + + // Handle JSON output for dry-run + if (opts.dryRun && opts.jsonOutput) { + const result = { + success: true, + changes: changes.map(change => ({ + type: change.name, + path: change.path, + ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}) + })), + total: changes.length + }; + console.log(JSON.stringify(result, null, 2)); + return; + } + if (changes.length > 0) { - prettyChanges(changes); + if (!opts.jsonOutput) { + prettyChanges(changes); + } if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); return; @@ -1307,11 +1494,27 @@ export async function pull(opts: GlobalOptions & SyncOptions) { )} scripts were changed but ignoring for now` ); } - log.info( - colors.bold.green.underline( - `\nDone! All ${changes.length} changes applied locally and wmill-lock.yaml updated.` - ) - ); + if (opts.jsonOutput) { + const result = { + success: true, + message: `All ${changes.length} changes applied locally and wmill-lock.yaml updated`, + changes: changes.map(change => ({ + type: change.name, + path: change.path, + ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}) + })), + total: changes.length + }; + console.log(JSON.stringify(result, null, 2)); + } else { + log.info( + colors.bold.green.underline( + `\nDone! All ${changes.length} changes applied locally and wmill-lock.yaml updated.` + ) + ); + } + } else if (opts.jsonOutput) { + console.log(JSON.stringify({ success: true, message: "No changes to apply", total: 0 }, null, 2)); } } @@ -1366,8 +1569,16 @@ function removeSuffix(str: string, suffix: string) { return str.slice(0, str.length - suffix.length); } -export async function push(opts: GlobalOptions & SyncOptions) { - opts = await mergeConfigWithConfigFile(opts); +export async function push(opts: GlobalOptions & SyncOptions & { repository?: string }) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + // Resolve effective sync options with repository awareness + const effectiveOpts = await resolveEffectiveSyncOptions(workspace, opts.repository); + + // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) + opts = mergeCliWithEffectiveOptions(opts, effectiveOpts); + const codebases = await listSyncCodebases(opts); if (opts.raw) { log.info("--raw is now the default, you can remove it as a flag"); @@ -1383,9 +1594,6 @@ export async function push(opts: GlobalOptions & SyncOptions) { } } - const workspace = await resolveWorkspace(opts); - await requireLogin(opts); - log.info( colors.gray( "Computing the files to update on the remote to match local (taking wmill.yaml includes/excludes into account)" @@ -1498,8 +1706,25 @@ export async function push(opts: GlobalOptions & SyncOptions) { `remote (${workspace.name}) <- local: ${changes.length} changes to apply` ); + // Handle JSON output for dry-run + if (opts.dryRun && opts.jsonOutput) { + const result = { + success: true, + changes: changes.map(change => ({ + type: change.name, + path: change.path, + ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}) + })), + total: changes.length + }; + console.log(JSON.stringify(result, null, 2)); + return; + } + if (changes.length > 0) { - prettyChanges(changes); + if (!opts.jsonOutput) { + prettyChanges(changes); + } if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); return; @@ -1860,13 +2085,30 @@ export async function push(opts: GlobalOptions & SyncOptions) { await Promise.race(pool); } } - log.info( - colors.bold.green.underline( - `\nDone! All ${changes.length} changes pushed to the remote workspace ${ - workspace.workspaceId - } named ${workspace.name} (${(performance.now() - start).toFixed(0)}ms)` - ) - ); + if (opts.jsonOutput) { + const result = { + success: true, + message: `All ${changes.length} changes pushed to the remote workspace ${workspace.workspaceId} named ${workspace.name}`, + changes: changes.map(change => ({ + type: change.name, + path: change.path, + ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}) + })), + total: changes.length, + duration_ms: Math.round(performance.now() - start) + }; + console.log(JSON.stringify(result, null, 2)); + } else { + log.info( + colors.bold.green.underline( + `\nDone! All ${changes.length} changes pushed to the remote workspace ${ + workspace.workspaceId + } named ${workspace.name} (${(performance.now() - start).toFixed(0)}ms)` + ) + ); + } + } else if (opts.jsonOutput) { + console.log(JSON.stringify({ success: true, message: "No changes to push", total: 0 }, null, 2)); } } @@ -1891,6 +2133,10 @@ const command = new Command() .option("--skip-secrets", "Skip syncing only secrets variables") .option("--skip-resources", "Skip syncing resources") .option("--skip-resource-types", "Skip syncing resource types") + .option("--skip-scripts", "Skip syncing scripts") + .option("--skip-flows", "Skip syncing flows") + .option("--skip-apps", "Skip syncing apps") + .option("--skip-folders", "Skip syncing folders") // .option("--skip-scripts-metadata", "Skip syncing scripts metadata, focus solely on logic") .option("--include-schedules", "Include syncing schedules") .option("--include-triggers", "Include syncing triggers") @@ -1898,6 +2144,7 @@ const command = new Command() .option("--include-groups", "Include syncing groups") .option("--include-settings", "Include syncing workspace settings") .option("--include-key", "Include workspace encryption key") + .option("--json-output", "Output results in JSON format") .option( "-i --includes ", "Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes" @@ -1910,6 +2157,10 @@ const command = new Command() "--extra-includes ", "Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy" ) + .option( + "--repository ", + "Specify repository path (e.g., u/user/repo) when multiple repositories exist" + ) // deno-lint-ignore no-explicit-any .action(pull as any) .command("push") @@ -1925,7 +2176,10 @@ const command = new Command() .option("--skip-secrets", "Skip syncing only secrets variables") .option("--skip-resources", "Skip syncing resources") .option("--skip-resource-types", "Skip syncing resource types") - + .option("--skip-scripts", "Skip syncing scripts") + .option("--skip-flows", "Skip syncing flows") + .option("--skip-apps", "Skip syncing apps") + .option("--skip-folders", "Skip syncing folders") // .option("--skip-scripts-metadata", "Skip syncing scripts metadata, focus solely on logic") .option("--include-schedules", "Include syncing schedules") .option("--include-triggers", "Include syncing triggers") @@ -1933,6 +2187,7 @@ const command = new Command() .option("--include-groups", "Include syncing groups") .option("--include-settings", "Include syncing workspace settings") .option("--include-key", "Include workspace encryption key") + .option("--json-output", "Output results in JSON format") .option( "-i --includes ", "Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)" @@ -1950,6 +2205,10 @@ const command = new Command() "Include a message that will be added to all scripts/flows/apps updated during this push" ) .option("--parallel ", "Number of changes to process in parallel") + .option( + "--repository ", + "Specify repository path (e.g., u/user/repo) when multiple repositories exist" + ) // deno-lint-ignore no-explicit-any .action(push as any); diff --git a/cli/test/containerized_backend.ts b/cli/test/containerized_backend.ts new file mode 100644 index 0000000000..05695b430d --- /dev/null +++ b/cli/test/containerized_backend.ts @@ -0,0 +1,1363 @@ +/** + * Containerized Backend Test Utilities + * Manages real Windmill EE backend containers for CLI testing + */ + +export interface ContainerConfig { + composeFile?: string; + baseUrl?: string; + workspace?: string; + token?: string; + username?: string; + password?: string; + timeout?: number; + testConfigDir?: string; +} + +export class ContainerizedBackend { + private config: Required; + private isRunning = false; + + constructor(config: Partial = {}) { + this.config = { + composeFile: config.composeFile || '/home/alex/windmill/windmill/cli/test/docker-compose.test.yml', + baseUrl: config.baseUrl || 'http://localhost:8001', + workspace: config.workspace || 'test', // Use test workspace + token: config.token || '', + username: config.username || 'admin@windmill.dev', + password: config.password || 'changeme', + timeout: config.timeout || 120000, // 2 minutes + testConfigDir: config.testConfigDir || '' // Will be created if empty + }; + } + + /** + * Get the base URL of the backend + */ + get baseUrl(): string { + return this.config.baseUrl; + } + + /** + * Get the workspace ID + */ + get workspace(): string { + return this.config.workspace; + } + + /** + * Get the authentication token + */ + get token(): string { + return this.config.token; + } + + /** + * Get the test config directory + */ + get testConfigDir(): string { + return this.config.testConfigDir; + } + + /** + * Start the containerized backend (database + server + worker) + */ + async start(): Promise { + if (this.isRunning) { + return; + } + + console.log('🚀 Starting containerized Windmill backend...'); + + // Create isolated test config directory if not provided + if (!this.config.testConfigDir) { + this.config.testConfigDir = await Deno.makeTempDir({ prefix: 'wmill_test_config_' }); + console.log(`📁 Created test config directory: ${this.config.testConfigDir}`); + } + + // Start containers with EE license key + const startCmd = new Deno.Command('docker', { + args: ['compose', '-f', this.config.composeFile, 'up', '-d'], + stdout: 'piped', + stderr: 'piped', + env: { + ...Deno.env.toObject(), + ...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! }) + } + }); + + const startResult = await startCmd.output(); + if (startResult.code !== 0) { + const stderr = new TextDecoder().decode(startResult.stderr); + throw new Error(`Failed to start containers: ${stderr}`); + } + + // Wait for services to be healthy + await this.waitForHealthy(); + + // Verify API is responsive + await this.waitForAPI(); + + // Authenticate and get a proper token + await this.authenticate(); + + this.isRunning = true; + console.log('✅ Containerized backend is ready!'); + console.log(` Server: ${this.config.baseUrl}`); + console.log(` Workspace: ${this.config.workspace}`); + } + + /** + * Stop the containerized backend + */ + async stop(): Promise { + if (!this.isRunning) { + return; + } + + console.log('🛑 Stopping containerized backend...'); + + const stopCmd = new Deno.Command('docker', { + args: ['compose', '-f', this.config.composeFile, 'down', '-v'], + stdout: 'piped', + stderr: 'piped', + env: { + ...Deno.env.toObject(), + ...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! }) + } + }); + + await stopCmd.output(); + + // Clean up test config directory if we created it + if (this.config.testConfigDir && this.config.testConfigDir.includes('wmill_test_config_')) { + try { + await Deno.remove(this.config.testConfigDir, { recursive: true }); + console.log(`🗑️ Cleaned up test config directory: ${this.config.testConfigDir}`); + } catch (error) { + console.warn(`⚠️ Failed to clean up test config directory: ${error}`); + } + } + + this.isRunning = false; + console.log('✅ Backend stopped'); + } + + /** + * Reset backend to clean state (clear workspace content, keep settings) + */ + async reset(): Promise { + console.log('🔄 Fast API-based reset...'); + + try { + // Delete all workspace content via Windmill API + await Promise.all([ + this.deleteAllScripts(), + this.deleteAllFlows(), + this.deleteAllApps(), + this.deleteAllResources(), + this.deleteAllVariables(), + this.deleteAllFolders() + ]); + + console.log('✅ Workspace reset complete'); + } catch (error) { + console.error('Reset failed:', error instanceof Error ? error.message : String(error)); + // Don't throw - tests can continue with existing state + } + } + + /** + * Delete all scripts in workspace + */ + private async deleteAllScripts(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) { + console.warn(`Failed to list scripts for deletion: ${response.status} ${response.statusText}`); + return; + } + + const scripts = await response.json(); + console.log(`Deleting ${scripts.length} scripts...`); + + const deletionResults = []; + + for (const script of scripts) { + try { + const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/delete/p/${encodeURIComponent(script.path)}`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + const responseText = await deleteResponse.text(); + + if (!deleteResponse.ok) { + console.warn(`Failed to delete script ${script.path}: ${deleteResponse.status} ${deleteResponse.statusText} - ${responseText}`); + deletionResults.push({ path: script.path, success: false, error: `${deleteResponse.status}: ${responseText}` }); + } else { + console.log(`Successfully deleted script: ${script.path}`); + deletionResults.push({ path: script.path, success: true }); + } + } catch (error) { + console.warn(`Error deleting script ${script.path}:`, error instanceof Error ? error.message : String(error)); + deletionResults.push({ path: script.path, success: false, error: error instanceof Error ? error.message : String(error) }); + } + } + + const successCount = deletionResults.filter(r => r.success).length; + const failureCount = deletionResults.filter(r => !r.success).length; + + console.log(`Script deletion summary: ${successCount} successful, ${failureCount} failed`); + + if (failureCount > 0) { + console.warn('Failed script deletions:', deletionResults.filter(r => !r.success)); + } + + // Verify deletion by checking if scripts still exist + await this.verifyScriptDeletion(); + + } catch (error) { + console.error('Failed to delete scripts:', error instanceof Error ? error.message : String(error)); + throw error; // Don't silently ignore major failures + } + } + + /** + * Verify that scripts were actually deleted + */ + private async verifyScriptDeletion(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (response.ok) { + const remainingScripts = await response.json(); + if (remainingScripts.length > 0) { + console.warn(`Warning: ${remainingScripts.length} scripts still exist after deletion attempt:`, + remainingScripts.map((s: any) => s.path)); + + // Attempt to delete remaining scripts with retry + for (const script of remainingScripts) { + await this.retryScriptDeletion(script.path); + } + } + } + } catch (error) { + console.warn('Could not verify script deletion:', error instanceof Error ? error.message : String(error)); + } + } + + /** + * Retry script deletion with exponential backoff + */ + private async retryScriptDeletion(scriptPath: string, maxRetries: number = 3): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/delete/${encodeURIComponent(scriptPath)}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + const responseText = await deleteResponse.text(); + + if (deleteResponse.ok) { + console.log(`Successfully deleted script on retry ${attempt}: ${scriptPath}`); + return; + } else if (deleteResponse.status === 404) { + console.log(`Script already deleted: ${scriptPath}`); + return; + } else { + console.warn(`Retry ${attempt} failed for script ${scriptPath}: ${deleteResponse.status} - ${responseText}`); + } + } catch (error) { + console.warn(`Retry ${attempt} error for script ${scriptPath}:`, error instanceof Error ? error.message : String(error)); + } + + if (attempt < maxRetries) { + const delay = Math.pow(2, attempt) * 1000; // Exponential backoff + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + console.error(`Failed to delete script after ${maxRetries} retries: ${scriptPath}`); + } + + /** + * Delete all flows in workspace + */ + private async deleteAllFlows(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/flows/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) return; + + const flows = await response.json(); + + for (const flow of flows) { + try { + const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/flows/delete/${flow.path}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + await deleteResponse.text(); // Consume response + } catch { + // Ignore individual deletion failures + } + } + } catch { + // Ignore listing failures + } + } + + /** + * Delete all apps in workspace + */ + private async deleteAllApps(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/apps/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) return; + + const apps = await response.json(); + + for (const app of apps) { + try { + const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/apps/delete/${app.path}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + await deleteResponse.text(); // Consume response + } catch { + // Ignore individual deletion failures + } + } + } catch { + // Ignore listing failures + } + } + + /** + * Delete all resources in workspace (except git repo) + */ + private async deleteAllResources(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) return; + + const resources = await response.json(); + + for (const resource of resources) { + // Keep git repo resource + if (resource.path === 'u/test/test_repo') continue; + + try { + const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/delete/${resource.path}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + await deleteResponse.text(); // Consume response + } catch { + // Ignore individual deletion failures + } + } + } catch { + // Ignore listing failures + } + } + + /** + * Delete all variables in workspace (except git repo) + */ + private async deleteAllVariables(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/variables/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) return; + + const variables = await response.json(); + + for (const variable of variables) { + // Keep git repo resource + if (variable.path === 'u/test/test_repo') continue; + + try { + const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/variables/delete/${variable.path}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + await deleteResponse.text(); // Consume response + } catch { + // Ignore individual deletion failures + } + } + } catch { + // Ignore listing failures + } + } + + /** + * Delete all folders in workspace + */ + private async deleteAllFolders(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/folders/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) return; + + const folders = await response.json(); + + for (const folder of folders) { + try { + const deleteResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/folders/delete/${folder.name}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + await deleteResponse.text(); // Consume response + } catch { + // Ignore individual deletion failures + } + } + } catch { + // Ignore listing failures + } + } + + /** + * Setup test environment for CLI testing + */ + async seedTestData(): Promise { + console.log('🌱 Setting up test environment...'); + + // Try to set up git sync settings if EE is available + try { + const gitSyncConfig = { + git_sync_settings: { + repositories: [{ + settings: { + exclude_path: ["*.test.ts", "*.spec.ts"], + include_path: ["f/**"], + include_type: ["script", "flow", "app", "folder", "resource"], + extra_include_path: [] + }, + script_path: "f/**", + group_by_folder: false, + use_individual_branch: false, + git_repo_resource_path: "u/test/test_repo" + }] + } + }; + + await this.updateGitSyncConfig(gitSyncConfig); + console.log('✅ Git sync configuration set (EE features available)'); + } catch (error) { + console.log('⚠️ Git sync not available (CE edition) - CLI tests will run without git sync'); + } + + // Create minimal test data (skip scripts/flows due to path validation issues) + console.log('📝 Creating test apps and resources...'); + await this.createTestApp(); + await this.createTestResources(); + await this.createTestVariables(); + await this.createTestGroups(); + console.log('✅ Test data created'); + } + + /** + * Create a realistic test script + */ + private async createTestScript(): Promise { + const scriptContent = `// Test script for CLI sync operations +import { Resource } from "https://deno.land/x/windmill@v1.85.0/mod.ts"; + +type PostgresqlResource = { + host: string; + port: number; + user: string; + dbname: string; + sslmode: string; + password: string; + root_certificate_pem: string; +}; + +export async function main( + database: Resource<"postgresql">, + query: string = "SELECT version();", + limit: number = 100 +) { + // Simple database query example + const result = await database.query(query); + return { + query, + rows: result.rows.slice(0, limit), + rowCount: result.rows.length, + timestamp: new Date().toISOString() + }; +}`; + + const script = { + path: 'f/test_database_query', + summary: 'Test Database Query Script', + description: 'A realistic test script that demonstrates database operations for CLI sync testing', + content: scriptContent, + language: 'deno', + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + database: { + type: 'object', + format: 'resource-postgresql', + description: 'PostgreSQL database connection' + }, + query: { + type: 'string', + description: 'SQL query to execute', + default: 'SELECT version();' + }, + limit: { + type: 'number', + description: 'Maximum number of rows to return', + default: 100, + minimum: 1, + maximum: 1000 + } + }, + required: ['database'] + } + }; + + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/create`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(script) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.warn(`Failed to create test script: ${response.status} - ${errorText}`); + } else { + await response.text(); // Consume response to avoid leak + console.log(' ✅ Created test script: f/test_database_query'); + } + } + + /** + * Create a realistic test flow + */ + private async createTestFlow(): Promise { + const flow = { + path: 'f/test_data_processing', + summary: 'Test Data Processing Flow', + description: 'A multi-step flow that demonstrates data processing workflow for CLI sync testing', + value: { + modules: [ + { + id: 'a', + value: { + type: 'rawscript', + content: `export async function main(input_data: string[] = ["test1", "test2", "test3"]) { + // Step 1: Validate and clean input data + const cleaned = input_data + .filter(item => item && item.trim().length > 0) + .map(item => item.trim().toLowerCase()); + + console.log(\`Processed \${cleaned.length} items\`); + return { cleaned_data: cleaned, count: cleaned.length }; +}`, + language: 'deno' + } + }, + { + id: 'b', + value: { + type: 'rawscript', + content: `export async function main(processed_data: { cleaned_data: string[], count: number }) { + // Step 2: Transform and enrich data + const enriched = processed_data.cleaned_data.map((item, index) => ({ + id: index + 1, + value: item, + processed_at: new Date().toISOString(), + length: item.length + })); + + return { + enriched_data: enriched, + total_items: processed_data.count, + avg_length: enriched.reduce((sum, item) => sum + item.length, 0) / enriched.length + }; +}`, + language: 'deno' + } + }, + { + id: 'c', + value: { + type: 'rawscript', + content: `export async function main(final_data: any) { + // Step 3: Generate summary report + const report = { + summary: "Data processing completed successfully", + processed_at: new Date().toISOString(), + total_records: final_data.total_items, + average_length: Math.round(final_data.avg_length * 100) / 100, + data_sample: final_data.enriched_data.slice(0, 3) + }; + + console.log("Processing report:", JSON.stringify(report, null, 2)); + return report; +}`, + language: 'deno' + } + } + ] + }, + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + input_data: { + type: 'array', + items: { type: 'string' }, + description: 'Array of strings to process', + default: ['test1', 'test2', 'test3'] + } + } + } + }; + + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/flows/create`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(flow) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.warn(`Failed to create test flow: ${response.status} - ${errorText}`); + } else { + await response.text(); // Consume response to avoid leak + console.log(' ✅ Created test flow: f/test_data_processing'); + } + } + + /** + * Create a realistic test app + */ + private async createTestApp(): Promise { + const app = { + path: 'f/test_dashboard', + summary: 'Test Dashboard App', + policy: { + execution_mode: 'viewer' + }, + value: { + grid: [ + { + "3": { + id: 'title', + data: { + type: 'displaycomponent', + configuration: {}, + componentInput: { + type: 'static', + value: 'Test Dashboard' + } + } + } + }, + { + "12": { + id: 'table', + data: { + type: 'tablecomponent', + configuration: {}, + componentInput: { + type: 'runnable', + runnable: { + type: 'runnableByName', + name: 'f/test_database_query' + } + } + } + } + } + ], + fullscreen: false, + unusedInlineScripts: [] + } + }; + + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/apps/create`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(app) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.warn(`Failed to create test app: ${response.status} - ${errorText}`); + } else { + await response.text(); // Consume response to avoid leak + console.log(' ✅ Created test app: f/test_dashboard'); + } + } + + /** + * Create test resources + */ + private async createTestResources(): Promise { + // Create PostgreSQL test database + const testDb = { + path: 'u/admin/test_database', + description: 'Test PostgreSQL database for CLI sync testing', + resource_type: 'postgresql', + value: { + host: 'localhost', + port: 5432, + user: 'testuser', + dbname: 'testdb', + sslmode: 'disable', + password: 'testpassword', + root_certificate_pem: '' + } + }; + + const dbResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/create`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(testDb) + }); + + if (!dbResponse.ok) { + const errorText = await dbResponse.text(); + console.warn(`Failed to create test resource: ${dbResponse.status} - ${errorText}`); + } else { + await dbResponse.text(); // Consume response to avoid leak + console.log(' ✅ Created test resource: u/admin/test_database'); + } + + // Create only the primary git repository resource (u/test/test_repo) + // Multi-repo tests will create additional repositories as needed + const primaryGitRepo = { + path: 'u/test/test_repo', + description: 'Primary test git repository for backend code', + resource_type: 'git', + value: { + url: 'https://github.com/windmill-labs/windmill-test-repo.git', + branch: 'main', + token: '' + } + }; + + const gitResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/create`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(primaryGitRepo) + }); + + if (!gitResponse.ok) { + const errorText = await gitResponse.text(); + console.warn(`Failed to create git resource ${primaryGitRepo.path}: ${gitResponse.status} - ${errorText}`); + } else { + await gitResponse.text(); // Consume response to avoid leak + console.log(` ✅ Created git resource: ${primaryGitRepo.path}`); + } + } + + /** + * Create additional git repository resource for multi-repo tests + */ + async createAdditionalGitRepo(repoPath: string, description: string): Promise { + const gitRepo = { + path: repoPath, + description: description, + resource_type: 'git', + value: { + url: 'https://github.com/windmill-labs/windmill-frontend-test.git', + branch: 'main', + token: '' + } + }; + + const gitResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/create`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(gitRepo) + }); + + if (!gitResponse.ok) { + const errorText = await gitResponse.text(); + console.warn(`Failed to create git resource ${gitRepo.path}: ${gitResponse.status} - ${errorText}`); + } else { + await gitResponse.text(); // Consume response to avoid leak + console.log(` ✅ Created additional git resource: ${gitRepo.path}`); + } + } + + /** + * Create test variables for sync testing + */ + private async createTestVariables(): Promise { + // Create a regular variable + const testVariable = { + path: 'u/admin/test_config', + value: 'production', + description: 'Test configuration variable', + is_secret: false + }; + + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/variables/create`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(testVariable) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.warn(`Failed to create test variable: ${response.status} - ${errorText}`); + } else { + await response.text(); // Consume response to avoid leak + console.log(' ✅ Created test variable: u/admin/test_config'); + } + } + + /** + * Create test groups for sync testing + */ + private async createTestGroups(): Promise { + // Create a test group for sync testing + const testGroup = { + name: 'test_group', + summary: 'Test group for CLI sync operations' + }; + + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/groups/create`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(testGroup) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.warn(`Failed to create test group: ${response.status} - ${errorText}`); + } else { + await response.text(); // Consume response to avoid leak + console.log(' ✅ Created test group: test_group'); + } + } + + /** + * Get current workspace settings from backend + */ + async getWorkspaceSettings(): Promise { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/workspaces/get_settings`, { + headers: { + 'Authorization': `Bearer ${this.config.token}` + } + }); + + if (!response.ok) { + throw new Error(`Failed to get workspace settings: ${response.status} ${response.statusText}`); + } + + return await response.json(); + } + + /** + * List all scripts in workspace with full content + */ + async listAllScripts(): Promise> { + // First get the list of scripts + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) { + throw new Error(`Failed to list scripts: ${response.status}`); + } + + const scriptList = await response.json(); + + // Then fetch full details (including content) for each script + const scriptsWithContent = []; + for (const script of scriptList) { + try { + const detailResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/scripts/get/p/${script.path}`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (detailResponse.ok) { + const fullScript = await detailResponse.json(); + scriptsWithContent.push(fullScript); + } else { + // If we can't get details, include the basic info without content + console.warn(`Could not fetch details for script ${script.path}: ${detailResponse.status}`); + scriptsWithContent.push(script); + } + } catch (error) { + console.warn(`Error fetching script ${script.path}:`, error); + scriptsWithContent.push(script); + } + } + + return scriptsWithContent; + } + + /** + * List all apps in workspace + */ + async listAllApps(): Promise> { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/apps/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) { + throw new Error(`Failed to list apps: ${response.status}`); + } + + return await response.json(); + } + + /** + * List all resources in workspace + */ + async listAllResources(): Promise> { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/resources/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) { + throw new Error(`Failed to list resources: ${response.status}`); + } + + return await response.json(); + } + + /** + * List all variables in workspace + */ + async listAllVariables(): Promise> { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/variables/list`, { + headers: { 'Authorization': `Bearer ${this.config.token}` } + }); + + if (!response.ok) { + throw new Error(`Failed to list variables: ${response.status}`); + } + + return await response.json(); + } + + /** + * Update git-sync configuration via API + */ + async updateGitSyncConfig(config: any): Promise { + const response = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/workspaces/edit_git_sync_config`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(config) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to update git-sync config: ${response.status} ${response.statusText} - ${errorText}`); + } + + // Consume the response body to avoid resource leak + await response.text(); + } + + /** + * Create CLI command with proper authentication + */ + createCLICommand(args: string[], workingDir: string, workspaceName?: string): Deno.Command { + const workspace = workspaceName || this.config.workspace; + const fullArgs = [ + '--base-url', this.config.baseUrl, + '--workspace', workspace, + '--token', this.config.token, + '--config-dir', this.config.testConfigDir, + ...args + ]; + + console.log('🔧 CLI Command:', ['/home/alex/.deno/bin/deno', 'run', '-A', '/home/alex/windmill/windmill/cli/main.ts', ...fullArgs].join(' ')); + + return new Deno.Command('/home/alex/.deno/bin/deno', { + args: ['run', '-A', '/home/alex/windmill/windmill/cli/main.ts', ...fullArgs], + cwd: workingDir, + stdout: 'piped', + stderr: 'piped', + env: { + 'SKIP_DENO_DEPRECATION_WARNING': 'true' + } + }); + } + + /** + * Run CLI command and return result + */ + async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{ + stdout: string; + stderr: string; + code: number; + }> { + const cmd = this.createCLICommand(args, workingDir, workspaceName); + const result = await cmd.output(); + + return { + stdout: new TextDecoder().decode(result.stdout), + stderr: new TextDecoder().decode(result.stderr), + code: result.code + }; + } + + /** + * Authenticate with the API and get a proper token + */ + private async authenticate(): Promise { + console.log('🔑 Authenticating with backend...'); + + // First, initialize the database with our workspace and user + await this.initializeTestData(); + + // Step 1: Login to get session token + const loginResponse = await fetch(`${this.config.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: this.config.username, + password: this.config.password + }) + }); + + if (!loginResponse.ok) { + throw new Error(`Login failed: ${loginResponse.status} ${loginResponse.statusText}`); + } + + // The session token is returned as plain text + const sessionToken = await loginResponse.text(); + + // Use the session token directly (API token creation has issues) + this.config.token = sessionToken; + + // Step 2: Verify the session token works + const testResponse = await fetch(`${this.config.baseUrl}/api/w/${this.config.workspace}/workspaces/get_settings`, { + headers: { + 'Authorization': `Bearer ${this.config.token}` + } + }); + + if (!testResponse.ok) { + const errorText = await testResponse.text(); + throw new Error(`Token verification failed: ${testResponse.status} ${testResponse.statusText} - ${errorText}`); + } + + // Consume response body to avoid leak + await testResponse.text(); + + console.log('✅ Authentication successful'); + } + + /** + * Initialize test workspace and user data via REST API + */ + private async initializeTestData(): Promise { + console.log('🔧 Initializing test workspace...'); + + const initSQL = ` + -- Create the test workspace if it doesn't exist (using INSERT ... WHERE NOT EXISTS) + INSERT INTO workspace (id, name, owner, deleted, premium) + SELECT '${this.config.workspace}', 'Test Workspace', 'admin@windmill.dev', false, false + WHERE NOT EXISTS (SELECT 1 FROM workspace WHERE id = '${this.config.workspace}'); + + -- Create admin user for the test workspace + INSERT INTO usr (workspace_id, email, username, is_admin, created_at) + SELECT '${this.config.workspace}', 'admin@windmill.dev', 'admin', true, NOW() + WHERE NOT EXISTS (SELECT 1 FROM usr WHERE workspace_id = '${this.config.workspace}' AND email = 'admin@windmill.dev'); + + -- Create a test token for CLI authentication + DELETE FROM token WHERE workspace_id = '${this.config.workspace}' AND owner = 'admin@windmill.dev' AND label = 'CLI Test Token'; + INSERT INTO token (workspace_id, owner, label, token, created_at, expiration) + VALUES ('${this.config.workspace}', 'admin@windmill.dev', 'CLI Test Token', '${this.config.token}', NOW(), NOW() + INTERVAL '1 year'); + + -- Set up workspace settings with git-sync configuration for testing + INSERT INTO workspace_settings (workspace_id, git_sync) + VALUES ('${this.config.workspace}', '{ + "include_path": ["f/**", "g/**"], + "include_type": ["script", "flow", "app", "folder", "resource"], + "repositories": [{ + "exclude_types_override": [], + "script_path": "f/**", + "git_repo_resource_path": "u/test/test_repo", + "use_individual_branch": false, + "group_by_folder": false, + "settings": { + "include_path": ["f/**"], + "exclude_path": ["*.test.ts", "*.spec.ts"], + "extra_include_path": [], + "include_type": ["script", "flow", "app", "folder", "resource"] + } + }], + "exclude_path": ["*.test.ts", "*.spec.ts"], + "extra_include_path": [] + }'::jsonb) + ON CONFLICT (workspace_id) DO UPDATE SET git_sync = EXCLUDED.git_sync; + + -- Create test folders for proper structure + INSERT INTO folder (workspace_id, name, display_name, owners) + SELECT '${this.config.workspace}', 'f', 'Scripts and Flows', ARRAY['admin@windmill.dev'] + WHERE NOT EXISTS (SELECT 1 FROM folder WHERE workspace_id = '${this.config.workspace}' AND name = 'f'); + + INSERT INTO folder (workspace_id, name, display_name, owners) + SELECT '${this.config.workspace}', 'g', 'General Scripts', ARRAY['admin@windmill.dev'] + WHERE NOT EXISTS (SELECT 1 FROM folder WHERE workspace_id = '${this.config.workspace}' AND name = 'g'); + + INSERT INTO folder (workspace_id, name, display_name, owners) + SELECT '${this.config.workspace}', 'u', 'User Scripts', ARRAY['admin@windmill.dev'] + WHERE NOT EXISTS (SELECT 1 FROM folder WHERE workspace_id = '${this.config.workspace}' AND name = 'u'); + + -- Create the 'all' group if it doesn't exist + INSERT INTO group_ (workspace_id, name, summary) + SELECT '${this.config.workspace}', 'all', 'All users group' + WHERE NOT EXISTS (SELECT 1 FROM group_ WHERE workspace_id = '${this.config.workspace}' AND name = 'all'); + + -- Grant all permissions to admin user + INSERT INTO usr_to_group (workspace_id, usr, group_) + SELECT '${this.config.workspace}', 'admin@windmill.dev', 'all' + WHERE NOT EXISTS (SELECT 1 FROM usr_to_group WHERE workspace_id = '${this.config.workspace}' AND usr = 'admin@windmill.dev' AND group_ = 'all'); + + -- Add test resource for git repository (mocked) + INSERT INTO resource (workspace_id, path, description, resource_type, value, extra_perms) + VALUES ('${this.config.workspace}', 'u/test/test_repo', 'Test Git Repository', 'git_repository', '{ + "git_url": "https://github.com/test/test-repo.git", + "branch": "main", + "git_username": "test-user", + "git_token": "test-token-123" + }'::jsonb, '{}'::jsonb) + ON CONFLICT (workspace_id, path) DO UPDATE SET value = EXCLUDED.value; + + -- EE license key is now handled via environment variable only + + -- Create workspace encryption key for sync operations + INSERT INTO workspace_key (workspace_id, kind, key) + VALUES ('${this.config.workspace}', 'cloud', 'test_encryption_key_for_sync_operations_123456789abcdef') + ON CONFLICT (workspace_id, kind) DO UPDATE SET key = EXCLUDED.key; + `; + + const execCmd = new Deno.Command('docker', { + args: ['compose', '-f', this.config.composeFile, 'exec', '-T', 'test_db', + 'psql', '-U', 'postgres', '-d', 'windmill_test', '-c', initSQL], + stdout: 'piped', + stderr: 'piped', + env: { + ...Deno.env.toObject(), + ...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! }) + } + }); + + const result = await execCmd.output(); + if (result.code !== 0) { + const stderr = new TextDecoder().decode(result.stderr); + throw new Error(`Failed to initialize test data: ${stderr}`); + } + + console.log('✅ Test workspace initialized'); + + // Verify license key was stored + const checkLicenseCmd = new Deno.Command('docker', { + args: ['compose', '-f', this.config.composeFile, 'exec', '-T', 'test_db', + 'psql', '-U', 'postgres', '-d', 'windmill_test', '-c', + "SELECT name, value FROM global_settings WHERE name = 'license_key';"], + stdout: 'piped', + stderr: 'piped', + env: { + ...Deno.env.toObject(), + EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY') || 'REMOVED_HARDCODED_LICENSE' + } + }); + + const checkResult = await checkLicenseCmd.output(); + if (checkResult.code === 0) { + const output = new TextDecoder().decode(checkResult.stdout); + console.log('🔍 License key in database:', output.trim()); + } + } + + // Private helper methods + + private async waitForHealthy(): Promise { + console.log('⏳ Waiting for containers to be healthy...'); + + const maxAttempts = 30; + let attempts = 0; + + while (attempts < maxAttempts) { + const healthCmd = new Deno.Command('docker', { + args: ['compose', '-f', this.config.composeFile, 'ps', '--format', 'json'], + stdout: 'piped', + stderr: 'piped', + env: { + ...Deno.env.toObject(), + ...(Deno.env.get('EE_LICENSE_KEY') && { EE_LICENSE_KEY: Deno.env.get('EE_LICENSE_KEY')! }) + } + }); + + const result = await healthCmd.output(); + if (result.code === 0) { + const output = new TextDecoder().decode(result.stdout); + if (output.trim()) { + const containers = output.trim().split('\n').map(line => JSON.parse(line)); + + const allHealthy = containers.every(container => + container.State === 'running' && + (container.Health === 'healthy' || container.Health === '' || container.Health === undefined) + ); + + if (allHealthy) { + return; + } + } + } + + attempts++; + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + throw new Error('Containers failed to become healthy within timeout'); + } + + private async waitForAPI(): Promise { + console.log('⏳ Waiting for API to be responsive...'); + + const maxAttempts = 30; + let attempts = 0; + + while (attempts < maxAttempts) { + try { + const response = await fetch(`${this.config.baseUrl}/api/version`, { + signal: AbortSignal.timeout(5000) + }); + + if (response.ok) { + const version = await response.text(); + console.log(`📡 API ready (version: ${version})`); + return; + } else { + // Consume response body even on non-OK responses to avoid leak + await response.text(); + } + } catch (error) { + // Continue trying + } + + attempts++; + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + throw new Error('API failed to respond within timeout'); + } + + /** + * Quick health check for the backend + */ + async checkHealth(): Promise { + try { + const response = await fetch(`${this.config.baseUrl}/api/version`); + if (!response.ok) { + throw new Error(`Health check failed: ${response.status}`); + } + await response.text(); // Consume response to avoid leak + } catch (error) { + throw new Error(`Backend health check failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + +} + +// Global backend instance for persistent containers +let globalBackend: ContainerizedBackend | null = null; + +// Convenience function for tests with persistent backend +export async function withContainerizedBackend( + testFn: (backend: ContainerizedBackend, tempDir: string) => Promise +): Promise { + // Reuse global backend if it exists and is healthy + if (!globalBackend) { + globalBackend = new ContainerizedBackend(); + await globalBackend.start(); + } else { + // Quick health check + try { + await globalBackend.checkHealth(); + } catch { + // Backend is unhealthy, restart it + await globalBackend.stop(); + globalBackend = new ContainerizedBackend(); + await globalBackend.start(); + } + } + + const tempDir = await Deno.makeTempDir({ prefix: 'windmill_cli_test_' }); + + try { + await globalBackend.reset(); + await globalBackend.seedTestData(); + + return await testFn(globalBackend, tempDir); + } finally { + await Deno.remove(tempDir, { recursive: true }); + } +} + +// Cleanup function for test suites +export async function cleanupGlobalBackend(): Promise { + if (globalBackend) { + await globalBackend.stop(); + globalBackend = null; + } +} \ No newline at end of file diff --git a/cli/test/docker-compose.test.yml b/cli/test/docker-compose.test.yml new file mode 100644 index 0000000000..8bf9db5616 --- /dev/null +++ b/cli/test/docker-compose.test.yml @@ -0,0 +1,74 @@ +version: "3.7" + +x-logging: &default-logging + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + compress: "true" + +services: + test_db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: testpass123 + POSTGRES_DB: windmill_test + POSTGRES_USER: postgres + ports: + - "5433:5432" # Use different port to avoid conflicts + volumes: + - test_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d windmill_test"] + interval: 10s + timeout: 5s + retries: 5 + logging: *default-logging + + test_windmill_server: + image: windmill-test:latest + environment: + - DATABASE_URL=postgres://postgres:testpass123@test_db/windmill_test?sslmode=disable + - MODE=server + - LICENSE_KEY=${EE_LICENSE_KEY} + - RUST_LOG=info + - DISABLE_TELEMETRY=true + - METRICS_ENABLED=false + ports: + - "8001:8000" # Use different port to avoid conflicts + depends_on: + test_db: + condition: service_healthy + volumes: + - test_worker_logs:/tmp/windmill/logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/version"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + logging: *default-logging + + test_windmill_worker: + image: windmill-test:latest + environment: + - DATABASE_URL=postgres://postgres:testpass123@test_db/windmill_test?sslmode=disable + - MODE=worker + - WORKER_GROUP=default + - LICENSE_KEY=${EE_LICENSE_KEY} + - RUST_LOG=info + - DISABLE_TELEMETRY=true + - NUM_WORKERS=1 + - SLEEP_QUEUE=50 + depends_on: + test_db: + condition: service_healthy + test_windmill_server: + condition: service_healthy + volumes: + - test_worker_logs:/tmp/windmill/logs + logging: *default-logging + +volumes: + test_db_data: null + test_worker_logs: null \ No newline at end of file diff --git a/cli/test/gitsync_settings_features.test.ts b/cli/test/gitsync_settings_features.test.ts new file mode 100644 index 0000000000..b4ad26e9b8 --- /dev/null +++ b/cli/test/gitsync_settings_features.test.ts @@ -0,0 +1,177 @@ +import { assertEquals, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { withContainerizedBackend } from "./containerized_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +// ============================================================================= +// GITSYNC-SETTINGS COMMAND FEATURES +// Tests for additional gitsync-settings command functionality +// ============================================================================= + +Deno.test("GitSync Settings: workspace-level wildcard settings", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "workspace_level_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Configure backend with repository + await backend.updateGitSyncConfig({ + git_sync_settings: { + repositories: [{ + git_repo_resource_path: "u/test/workspace_repo", + script_path: "f/**", + group_by_folder: false, + use_individual_branch: false, + settings: { + include_path: ["f/**"], + include_type: ["script", "flow"], + exclude_path: [], + extra_include_path: [] + } + }] + } + }); + + // Create initial wmill.yaml + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: []`); + + // Pull with workspace-level flag + const result = await backend.runCLICommand([ + 'gitsync-settings', 'pull', + '--repository', 'u/test/workspace_repo', + '--workspace-level', + '--override' + ], tempDir); + + assertEquals(result.code, 0, `Workspace-level pull should succeed: ${result.stderr}`); + + // Read updated config + const updatedConfig = await Deno.readTextFile(`${tempDir}/wmill.yaml`); + const backendUrl = new URL(backend.baseUrl).toString(); + + // Should create workspace wildcard override + assertStringIncludes(updatedConfig, `'${backendUrl}:${backend.workspace}:*':`); + assertStringIncludes(updatedConfig, "overrides:"); + }); +}); + +Deno.test("GitSync Settings: default mode writes to top-level", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "default_mode_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Configure backend with specific settings + await backend.updateGitSyncConfig({ + git_sync_settings: { + repositories: [{ + git_repo_resource_path: "u/test/default_repo", + script_path: "f/**", + group_by_folder: false, + use_individual_branch: false, + settings: { + include_path: ["f/special/**"], + include_type: ["script"], + exclude_path: ["*.test.ts"], + extra_include_path: ["g/**"] + } + }] + } + }); + + // Create initial wmill.yaml with different settings + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: [] +skipVariables: false`); + + // Pull with default flag + const result = await backend.runCLICommand([ + 'gitsync-settings', 'pull', + '--repository', 'u/test/default_repo', + '--default' + ], tempDir); + + assertEquals(result.code, 0, `Default mode pull should succeed: ${result.stderr}`); + + // Read updated config + const updatedConfig = await Deno.readTextFile(`${tempDir}/wmill.yaml`); + + // Should update top-level settings, not create overrides + assertStringIncludes(updatedConfig, "includes:\n - f/special/**"); + assertStringIncludes(updatedConfig, "excludes:\n - '*.test.ts'"); + assertStringIncludes(updatedConfig, "extraIncludes:\n - g/**"); + + // Should NOT have overrides section + assertEquals(updatedConfig.includes("overrides:"), false, "Default mode should not create overrides"); + }); +}); + +// Removed test for non-existent repository error handling +// as it was testing non-deterministic behavior + +Deno.test("GitSync Settings: pull shows correct diff output", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "diff_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Configure backend + await backend.updateGitSyncConfig({ + git_sync_settings: { + repositories: [{ + git_repo_resource_path: "u/test/diff_repo", + script_path: "f/**", + group_by_folder: false, + use_individual_branch: false, + settings: { + include_path: ["f/**"], + include_type: ["script", "flow"], + exclude_path: [], + extra_include_path: [] + } + }] + } + }); + + // Create wmill.yaml with different settings + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: [] +skipVariables: true +skipResources: false`); + + // Pull with diff flag + const result = await backend.runCLICommand([ + 'gitsync-settings', 'pull', + '--repository', 'u/test/diff_repo', + '--diff' + ], tempDir); + + assertEquals(result.code, 0); + + // Should show differences + assertStringIncludes(result.stdout, "Changes that would be made:"); + // Should show the change for skipResources (ignoring ANSI color codes) + assertStringIncludes(result.stdout, "skipResources:"); + }); +}); \ No newline at end of file diff --git a/cli/test/include_flags_bypass_filtering.test.ts b/cli/test/include_flags_bypass_filtering.test.ts new file mode 100644 index 0000000000..f7392e9c45 --- /dev/null +++ b/cli/test/include_flags_bypass_filtering.test.ts @@ -0,0 +1,193 @@ +import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { withContainerizedBackend } from "./containerized_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; + +// ============================================================================= +// INCLUDE FLAGS BYPASS FILTERING TESTS +// Tests that CLI include flags properly bypass path-based filtering +// ============================================================================= + +// Helper function to set up workspace profile +async function setupWorkspaceProfile(backend: any): Promise { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "localhost_test", + token: backend.token + }; + + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); +} + +// The ContainerizedBackend already creates test data we can use: +// - admin user (admin@windmill.dev) +// - test_group (created by seedTestData()) +// - workspace encryption key +// - test apps, resources, variables via seedTestData() +// No additional setup needed! + +Deno.test("CLI include flags bypass restrictive path filtering", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + // Create wmill.yaml with very restrictive includes that would exclude special files + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "f/**" +excludes: [] +skipVariables: true +skipResources: true +includeUsers: false +includeGroups: false +includeSettings: false +includeKey: false`); + + // Test: CLI flags should override config and bypass path filtering + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--include-users', + '--include-groups', + '--include-settings', + '--include-key', + '--dry-run', + '--json-output' + ], tempDir); + + assertEquals(result.code, 0, `Command failed: ${result.stderr}`); + + const output = parseJsonFromCLIOutput(result.stdout); + const changePaths = output.changes.map((c: any) => c.path); + + // Assert that special files are included despite restrictive path filtering + const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml')); + const hasGroup = changePaths.some((path: string) => path.includes('groups/test_group.group.yaml')); + const hasSettings = changePaths.some((path: string) => path === 'settings.yaml'); + const hasEncryptionKey = changePaths.some((path: string) => path === 'encryption_key.yaml'); + + assert(hasUser, `Admin user should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`); + assert(hasGroup, `'test_group' should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`); + assert(hasSettings, `Settings should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`); + assert(hasEncryptionKey, `Encryption key should be included despite restrictive includes. Found paths: ${changePaths.join(', ')}`); + }); +}); + +Deno.test("CLI flags override wmill.yaml include settings", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + // Config explicitly disables includes, but CLI should override + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +excludes: [] +includeUsers: false +includeGroups: false`); + + // CLI flags should override config file settings + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--include-users', + '--include-groups', + '--dry-run', + '--json-output' + ], tempDir); + + assertEquals(result.code, 0, `Command failed: ${result.stderr}`); + + const output = parseJsonFromCLIOutput(result.stdout); + const changePaths = output.changes.map((c: any) => c.path); + + const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml')); + const hasGroup = changePaths.some((path: string) => path.includes('groups/test_group.group.yaml')); + + assert(hasUser, `CLI --include-users should override config includeUsers: false. Found paths: ${changePaths.join(', ')}`); + assert(hasGroup, `CLI --include-groups should override config includeGroups: false. Found paths: ${changePaths.join(', ')}`); + }); +}); + +Deno.test("Skip flags work correctly with getTypeStrFromPath and lock files", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + // Create wmill.yaml with skip flags enabled + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +excludes: [] +skipScripts: true +skipFlows: false +includeUsers: true`); + + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--dry-run', + '--json-output' + ], tempDir); + + assertEquals(result.code, 0, `Command failed: ${result.stderr}`); + + const output = parseJsonFromCLIOutput(result.stdout); + const changePaths = output.changes.map((c: any) => c.path); + + // Scripts should be skipped (including lock files) - the backend doesn't create scripts by default + const hasScript = changePaths.some((path: string) => + path.endsWith('.py') || path.endsWith('.ts') || path.endsWith('.go') || path.endsWith('.sh') + ); + const hasScriptLock = changePaths.some((path: string) => path.endsWith('.script.lock')); + + // Apps should be included (the backend creates test apps) + const hasApp = changePaths.some((path: string) => path.includes('test_dashboard') || path.endsWith('.app.yaml')); + + // Users should still be included + const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml')); + + assert(!hasScript, `Standalone scripts should be skipped when skipScripts: true. Found paths: ${changePaths.join(', ')}`); + assert(!hasScriptLock, `Script lock files should be skipped when skipScripts: true. Found paths: ${changePaths.join(', ')}`); + assert(hasApp, `Apps should be included (inline scripts are part of apps). Found paths: ${changePaths.join(', ')}`); + assert(hasUser, `Users should be included when includeUsers: true. Found paths: ${changePaths.join(', ')}`); + }); +}); + +Deno.test("Mixed include and skip flags work together", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + // Create restrictive config with mixed settings + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "f/**" +excludes: [] +skipScripts: true +includeUsers: false +includeSettings: false`); + + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--skip-scripts', // Reinforce script skipping + '--include-users', // Override config to include users + '--dry-run', + '--json-output' + ], tempDir); + + assertEquals(result.code, 0, `Command failed: ${result.stderr}`); + + const output = parseJsonFromCLIOutput(result.stdout); + const changePaths = output.changes.map((c: any) => c.path); + + // Scripts should be excluded + const hasScript = changePaths.some((path: string) => + path.endsWith('.py') || path.endsWith('.ts') || path.endsWith('.go') || path.endsWith('.sh') + ); + + // Users should be included (CLI override) + const hasUser = changePaths.some((path: string) => path.includes('admin@windmill.dev.user.yaml')); + + // Settings should be excluded (no CLI override, restrictive path filtering) + const hasSettings = changePaths.some((path: string) => path === 'settings.yaml'); + + assert(!hasScript, `Scripts should be excluded due to skipScripts. Found paths: ${changePaths.join(', ')}`); + assert(hasUser, `Users should be included due to CLI --include-users override. Found paths: ${changePaths.join(', ')}`); + assert(!hasSettings, `Settings should be excluded (no CLI override + restrictive paths). Found paths: ${changePaths.join(', ')}`); + }); +}); \ No newline at end of file diff --git a/cli/test/init_no_git_sync.test.ts b/cli/test/init_no_git_sync.test.ts new file mode 100644 index 0000000000..2be95fe24e --- /dev/null +++ b/cli/test/init_no_git_sync.test.ts @@ -0,0 +1,185 @@ +/** + * Test to verify that wmill init handles workspaces with no git-sync settings correctly + * This creates a unit test that directly tests the logic without needing a backend + */ + +import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { DEFAULT_SYNC_OPTIONS } from "../conf.ts"; +import { withContainerizedBackend } from "./containerized_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +// Mock the workspace object +const mockWorkspace = { + remote: 'https://app.windmill.dev/', + workspaceId: 'test-workspace', + name: 'test-workspace' +}; + +// Simulate the createWorkspaceProfile function logic for the "no repositories" case +function createWorkspaceProfileNoRepos(workspace: any): any { + const workspaceProfile: any = { + baseUrl: workspace.remote, + workspaceId: workspace.workspaceId, + }; + + // Simulate the case where listRepositories returns empty array + const repositories: any[] = []; + + if (repositories.length === 0) { + console.log(`No git repositories found in workspace '${workspace.workspaceId}'`); + // This is the fix: include default sync settings when no repositories exist + Object.assign(workspaceProfile, DEFAULT_SYNC_OPTIONS); + return workspaceProfile; + } + + return workspaceProfile; +} + +Deno.test("Init: createWorkspaceProfile includes defaults when no repositories exist", () => { + console.log('🧪 Testing init logic for workspace with no git-sync repositories...'); + + const workspaceProfile = createWorkspaceProfileNoRepos(mockWorkspace); + + console.log('Generated workspace profile:', JSON.stringify(workspaceProfile, null, 2)); + + // Verify basic workspace info + assertEquals(workspaceProfile.baseUrl, 'https://app.windmill.dev/'); + assertEquals(workspaceProfile.workspaceId, 'test-workspace'); + + // Verify default sync settings are included + assert(Array.isArray(workspaceProfile.includes), 'Should have includes array'); + assertEquals(workspaceProfile.includes.length, 1, 'Should have one include pattern'); + assertEquals(workspaceProfile.includes[0], 'f/**', 'Should include f/** pattern'); + + assert(Array.isArray(workspaceProfile.excludes), 'Should have excludes array'); + assertEquals(workspaceProfile.excludes.length, 0, 'Should have empty excludes array'); + + assertEquals(workspaceProfile.defaultTs, 'bun', 'Should have bun as default TypeScript runtime'); + + console.log('✅ Workspace profile correctly includes default sync settings when no repositories exist'); +}); + +Deno.test("Init: verify DEFAULT_SYNC_OPTIONS has expected values", () => { + console.log('🔍 Verifying DEFAULT_SYNC_OPTIONS contains expected values...'); + + console.log('DEFAULT_SYNC_OPTIONS:', JSON.stringify(DEFAULT_SYNC_OPTIONS, null, 2)); + + // Verify the default options include the expected f/** pattern + assert(Array.isArray(DEFAULT_SYNC_OPTIONS.includes), 'DEFAULT_SYNC_OPTIONS should have includes array'); + assertEquals(DEFAULT_SYNC_OPTIONS.includes.length, 1, 'Should have one include pattern'); + assertEquals(DEFAULT_SYNC_OPTIONS.includes[0], 'f/**', 'Should default to f/** pattern'); + + assert(Array.isArray(DEFAULT_SYNC_OPTIONS.excludes), 'DEFAULT_SYNC_OPTIONS should have excludes array'); + assertEquals(DEFAULT_SYNC_OPTIONS.excludes.length, 0, 'Should have empty excludes array by default'); + + assertEquals(DEFAULT_SYNC_OPTIONS.defaultTs, 'bun', 'Should default to bun runtime'); + + console.log('✅ DEFAULT_SYNC_OPTIONS has expected values'); +}); + +Deno.test("Init: --use-backend flag applies git-sync settings", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: backend.workspace, + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // First create the git repository resource that the git-sync will reference + await backend.createAdditionalGitRepo("u/test/init_repo", "Test init repository"); + + // Configure backend with git-sync settings + await backend.updateGitSyncConfig({ + git_sync_settings: { + repositories: [{ + git_repo_resource_path: "u/test/init_repo", + script_path: "f/**", + group_by_folder: false, + use_individual_branch: false, + settings: { + include_path: ["f/backend/**"], + include_type: ["script", "flow"], + exclude_path: ["*.test.ts"], + extra_include_path: ["g/**"] + } + }] + } + }); + + // Run init with --use-backend flag + const result = await backend.runCLICommand([ + 'init', + '--use-backend', + '--repository', 'u/test/init_repo' + ], tempDir); + + assertEquals(result.code, 0, `Init with --use-backend should succeed: ${result.stderr}`); + + // Verify wmill.yaml was created with backend settings + const wmillYaml = await Deno.readTextFile(`${tempDir}/wmill.yaml`); + + // Should have backend-applied settings written to top-level (not overrides) + assertStringIncludes(wmillYaml, "f/backend/**", "Should include backend's include_path"); + assertStringIncludes(wmillYaml, "*.test.ts", "Should include backend's exclude_path"); + assertStringIncludes(wmillYaml, "g/**", "Should include backend's extra_include_path"); + + // Should NOT have overrides section since we're starting fresh + assertEquals(wmillYaml.includes("overrides:"), false, "Init should not create overrides section"); + }); +}); + +Deno.test("Init: --use-default bypasses backend settings check", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: backend.workspace, + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Create git repository resource + await backend.createAdditionalGitRepo("u/test/ignored_repo", "Test ignored repository"); + + // Configure backend with git-sync settings + await backend.updateGitSyncConfig({ + git_sync_settings: { + repositories: [{ + git_repo_resource_path: "u/test/ignored_repo", + script_path: "f/**", + group_by_folder: false, + use_individual_branch: false, + settings: { + include_path: ["f/should-be-ignored/**"], + include_type: ["script"], + exclude_path: [], + extra_include_path: [] + } + }] + } + }); + + // Run init with --use-default (should ignore backend) + const result = await backend.runCLICommand([ + 'init', + '--use-default' + ], tempDir); + + assertEquals(result.code, 0, `Init with --use-default should succeed: ${result.stderr}`); + + // Verify wmill.yaml was created with default settings only + const wmillYaml = await Deno.readTextFile(`${tempDir}/wmill.yaml`); + + // Should have default settings, not backend settings + assertStringIncludes(wmillYaml, "includes:\n - f/**", "Should use default includes"); + assertStringIncludes(wmillYaml, "defaultTs: bun", "Should use default TypeScript runtime"); + + // Should NOT have backend-specific settings + assertEquals(wmillYaml.includes("f/should-be-ignored/**"), false, "Should not include backend settings"); + assertEquals(wmillYaml.includes("overrides:"), false, "Should not create overrides when using defaults"); + }); +}); \ No newline at end of file diff --git a/cli/test/multi_instance_workspace.test.ts b/cli/test/multi_instance_workspace.test.ts new file mode 100644 index 0000000000..bfcc86a3b3 --- /dev/null +++ b/cli/test/multi_instance_workspace.test.ts @@ -0,0 +1,199 @@ +import { assertEquals, assert, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { withContainerizedBackend } from "./containerized_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; + +// ============================================================================= +// MULTI-INSTANCE WORKSPACE TESTS +// Tests for handling multiple Windmill instances with same workspace IDs +// ============================================================================= + +// Helper function to set up workspace profile with specific name +async function setupWorkspaceProfile(backend: any, workspaceName: string): Promise { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: workspaceName, + token: backend.token + }; + + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); +} + +Deno.test("Multi-Instance: gitsync-settings pull with new format", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace profile + await setupWorkspaceProfile(backend, "multi_instance_test"); + + // Create wmill.yaml with new format overrides for different instances + const backendUrl = new URL(backend.baseUrl).toString(); // Normalize URL + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: [] + +overrides: + # Current backend instance (should match) + "${backendUrl}:${backend.workspace}:u/test/test_repo": + includeTriggers: true + includeSchedules: true + skipVariables: true + + # Different instance (won't match) + "https://app.windmill.dev/:${backend.workspace}:u/test/test_repo": + includeTriggers: false + includeSchedules: false + skipVariables: false`); + + // Pull settings - should use the matching instance override (skipVariables: true) + const pullResult = await backend.runCLICommand([ + 'gitsync-settings', 'pull', + '--repository', 'u/test/test_repo', + '--diff' + ], tempDir, "multi_instance_test"); + + assertEquals(pullResult.code, 0); + assertStringIncludes(pullResult.stdout, "Changes that would be made:"); + // includeSchedules should show as a change since backend default is false + assertStringIncludes(pullResult.stdout, "includeSchedules"); + }); +}); + +Deno.test("Multi-Instance: gitsync-settings push with overrides", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace profile + await setupWorkspaceProfile(backend, "push_override_test"); + + // Create wmill.yaml with specific settings that differ from backend defaults + const backendUrl = new URL(backend.baseUrl).toString(); + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: [] +skipVariables: false +includeSchedules: false + +overrides: + # Override for current backend instance - set includeSchedules: true (backend default is false) + "${backendUrl}:${backend.workspace}:u/test/test_repo": + includeSchedules: true + skipVariables: true`); + + // Push settings - should show changes because includeSchedules differs from backend + const pushResult = await backend.runCLICommand([ + 'gitsync-settings', 'push', + '--repository', 'u/test/test_repo', + '--diff' + ], tempDir, "push_override_test"); + + assertEquals(pushResult.code, 0); + assertStringIncludes(pushResult.stdout, "Changes that would be pushed:"); + assertStringIncludes(pushResult.stdout, "includeSchedules"); + }); +}); + +Deno.test("Multi-Instance: sync with repository-specific overrides", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "my-workspace_123"); + + const backendUrl = new URL(backend.baseUrl).toString(); + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: [] + +overrides: + "${backendUrl}:${backend.workspace}:u/test/test_repo": + skipApps: true`); + + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--repository', 'u/test/test_repo', + '--dry-run', + '--json-output' + ], tempDir, "my-workspace_123"); + + assertEquals(result.code, 0); + + const data = parseJsonFromCLIOutput(result.stdout); + + // Test is designed to verify that the new format works correctly + + // The test app should NOT appear in changes because skipApps: true + const hasTestApp = (data.changes || []).some((change: any) => + change.path?.includes('f/test_dashboard') + ); + assertEquals(hasTestApp, false, "Test app should be skipped due to skipApps override"); + }); +}); + +Deno.test("Multi-Instance: auto-detection of single repository override", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "auto_detect_test"); + + const backendUrl = new URL(backend.baseUrl).toString(); + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** +excludes: [] + +overrides: + # Single repository override - should be auto-detected + "${backendUrl}:${backend.workspace}:u/test/test_repo": + skipApps: true + includeSchedules: true`); + + // Don't specify --repository, it should auto-detect + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--dry-run', + '--json-output' + ], tempDir, "auto_detect_test"); + + assertEquals(result.code, 0); + assertStringIncludes(result.stdout, "Auto-selected repository: u/test/test_repo"); + + const data = parseJsonFromCLIOutput(result.stdout); + + // The test app should NOT appear because of auto-detected skipApps: true + const hasTestApp = (data.changes || []).some((change: any) => + change.path?.includes('f/test_dashboard') + ); + assertEquals(hasTestApp, false, "Test app should be skipped due to auto-detected override"); + }); +}); + +Deno.test("Multi-Instance: workspace wildcards with new format", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "wildcard_test"); + + const backendUrl = new URL(backend.baseUrl).toString(); + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +excludes: [] + +overrides: + # Wildcard for current backend instance + "${backendUrl}:${backend.workspace}:*": + skipVariables: true + skipResources: true`); + + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--repository', 'u/test/test_repo', + '--dry-run', + '--json-output' + ], tempDir, "wildcard_test"); + + assertEquals(result.code, 0); + + const data = parseJsonFromCLIOutput(result.stdout); + + // Variables should be skipped due to wildcard override + const hasTestVariable = (data.changes || []).some((change: any) => + change.path?.includes('u/admin/test_config.variable.yaml') + ); + assertEquals(hasTestVariable, false, "Variables should be skipped due to wildcard override"); + }); +}); \ No newline at end of file diff --git a/cli/test/override_settings_behavior.test.ts b/cli/test/override_settings_behavior.test.ts new file mode 100644 index 0000000000..a0fd9025e6 --- /dev/null +++ b/cli/test/override_settings_behavior.test.ts @@ -0,0 +1,273 @@ +import { assertEquals, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { getEffectiveSettings } from "../conf.ts"; +import { withContainerizedBackend } from "./containerized_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +// ============================================================================= +// OVERRIDE SETTINGS BEHAVIOR TESTS +// Tests for override inheritance and file filtering behavior +// ============================================================================= + +Deno.test("Override Settings: override inherits non-overridden settings from base config", () => { + const config = { + includes: ["default/**"], + skipVariables: true, // Base has this as true + skipResources: true, // Base has this as true + skipApps: false, // Base has this as false + defaultTs: "bun" as const, + overrides: { + "http://localhost:8000/:test:u/user/repo": { + includes: ["override/**"], + skipApps: true // Override only changes skipApps, should inherit other skip flags + } + } + }; + + const effective = getEffectiveSettings( + config, + "http://localhost:8000/", + "test", + "u/user/repo" + ); + + // Override values should be used + assertEquals(effective.includes, ["override/**"], "Must use override includes"); + assertEquals(effective.skipApps, true, "Must use override skipApps"); + + // Should inherit skip flags from base config + assertEquals(effective.skipVariables, true, "Must inherit skipVariables=true from base config"); + assertEquals(effective.skipResources, true, "Must inherit skipResources=true from base config"); + assertEquals(effective.defaultTs, "bun", "Must inherit defaultTs from base config"); +}); + +Deno.test("Override Settings: workspace wildcards with repo-specific precedence", () => { + const config = { + includes: ["default/**"], + skipVariables: false, + overrides: { + "http://localhost:8000/:test:*": { + skipVariables: true, + includes: ["workspace/**"] + }, + "http://localhost:8000/:test:u/user/specific": { + includes: ["specific/**"] + } + } + }; + + // Test specific repo override (should take precedence over wildcard) + const specificEffective = getEffectiveSettings( + config, + "http://localhost:8000/", + "test", + "u/user/specific" + ); + assertEquals(specificEffective.includes, ["specific/**"], "Specific repo override must take precedence over wildcard"); + assertEquals(specificEffective.skipVariables, true, "Workspace wildcard setting must still apply"); + + // Test wildcard match + const wildcardEffective = getEffectiveSettings( + config, + "http://localhost:8000/", + "test", + "u/user/other" + ); + assertEquals(wildcardEffective.includes, ["workspace/**"], "Wildcard must match repos without specific overrides"); + assertEquals(wildcardEffective.skipVariables, true, "Workspace wildcard setting must apply"); +}); + +// ============================================================================= +// INTEGRATION TESTS - File Filtering Behavior +// ============================================================================= + +Deno.test("Integration: sync pull with skipVariables override excludes variable files", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "skip_variables_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Create wmill.yaml with override that skips variables + const backendUrl = new URL(backend.baseUrl).toString(); + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +overrides: + "${backendUrl}:${backend.workspace}:u/test/test_repo": + skipVariables: true`); + + // Verify backend has test variable before pull + const backendVariables = await backend.listAllVariables(); + const hasTestVariable = backendVariables.some(v => v.path === 'u/admin/test_config'); + assert(hasTestVariable, "Backend should have test variable before pull"); + + // Run sync pull (NOT dry-run) to actually write files + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--repository', 'u/test/test_repo', + '--yes' + ], tempDir); + + assertEquals(result.code, 0, `Sync pull should succeed: ${result.stderr}`); + + // Verify variable files were NOT written to filesystem due to skipVariables: true + const filesWritten = []; + for await (const entry of Deno.readDir(tempDir)) { + if (entry.isFile && entry.name.endsWith('.yaml')) { + filesWritten.push(entry.name); + } + } + + const hasVariableFile = filesWritten.some(file => file.includes('.variable.yaml')); + assertEquals(hasVariableFile, false, "Variable files should NOT be written due to skipVariables override"); + + // Verify other files WERE written (since skipVariables only affects variables) + // Check what files were actually written + console.log("Files written:", filesWritten); + + // Should have some files written (just not variable files) + assert(filesWritten.length > 0, `Some files should be written when skipVariables is true. Got: ${filesWritten.join(', ')}`); + + // Should not have only wmill.yaml file + const nonWmillFiles = filesWritten.filter(f => !f.includes('wmill.yaml')); + assert(nonWmillFiles.length > 0, `Non-wmill.yaml files should be written when skipVariables is true. Got: ${nonWmillFiles.join(', ')}`); + }); +}); + +Deno.test("Integration: sync push with skipVariables override excludes variable files", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "push_skip_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Create wmill.yaml with override that skips variables + const backendUrl = new URL(backend.baseUrl).toString(); + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +overrides: + "${backendUrl}:${backend.workspace}:u/test/test_repo": + skipVariables: true`); + + // Create local test files including variables and scripts + const timestamp = Date.now(); + + // Create variable file + await Deno.mkdir(`${tempDir}/u/admin`, { recursive: true }); + await Deno.writeTextFile(`${tempDir}/u/admin/test_push_var_${timestamp}.variable.yaml`, + `value: test_value_${timestamp} +description: Test variable for push override test +is_secret: false`); + + // Create script file + await Deno.mkdir(`${tempDir}/f/test`, { recursive: true }); + await Deno.writeTextFile(`${tempDir}/f/test/push_script_${timestamp}.ts`, + `export async function main() { + return "Test script ${timestamp}"; +}`); + await Deno.writeTextFile(`${tempDir}/f/test/push_script_${timestamp}.script.yaml`, + `summary: Test Push Script ${timestamp} +description: Script for testing push with override`); + + // Get backend state before push + const beforeVariables = await backend.listAllVariables(); + const beforeScripts = await backend.listAllScripts(); + + const variableExistsBefore = beforeVariables.some(v => v.path === `u/admin/test_push_var_${timestamp}`); + const scriptExistsBefore = beforeScripts.some(s => s.path === `f/test/push_script_${timestamp}`); + + assertEquals(variableExistsBefore, false, "Variable should not exist before push"); + assertEquals(scriptExistsBefore, false, "Script should not exist before push"); + + // Run sync push (NOT dry-run) to actually push files + const result = await backend.runCLICommand([ + 'sync', 'push', + '--repository', 'u/test/test_repo', + '--yes' + ], tempDir); + + assertEquals(result.code, 0, `Sync push should succeed: ${result.stderr}`); + + // Verify variable was NOT pushed due to skipVariables: true + const afterVariables = await backend.listAllVariables(); + const variableExistsAfter = afterVariables.some(v => v.path === `u/admin/test_push_var_${timestamp}`); + assertEquals(variableExistsAfter, false, "Variable should NOT be pushed due to skipVariables override"); + + // Verify script WAS pushed (not affected by skipVariables) + const afterScripts = await backend.listAllScripts(); + const scriptExistsAfter = afterScripts.some(s => s.path === `f/test/push_script_${timestamp}`); + assertEquals(scriptExistsAfter, true, "Script should be pushed normally"); + }); +}); + +Deno.test("Integration: sync pull respects includes override for file filtering", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "includes_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Create wmill.yaml with override that only includes specific path + const backendUrl = new URL(backend.baseUrl).toString(); + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" + +overrides: + "${backendUrl}:${backend.workspace}:u/test/test_repo": + includes: + - "u/admin/**" # Only include admin resources, exclude f/** apps/scripts`); + + // Run sync pull to write files + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--repository', 'u/test/test_repo', + '--yes' + ], tempDir); + + assertEquals(result.code, 0, `Sync pull should succeed: ${result.stderr}`); + + // Verify admin files were written (since we have includes: ["u/admin/**"]) + const adminFiles = []; + try { + for await (const entry of Deno.readDir(`${tempDir}/u/admin`)) { + if (entry.isFile) { + adminFiles.push(`u/admin/${entry.name}`); + } + } + } catch { + // Directory might not exist if no files matched + } + + // We expect admin files to be written since backend has u/admin/test_config variable + assert(adminFiles.length > 0, `Admin files should be written due to includes override. Expected u/admin files but found: ${adminFiles.join(', ')}`); + + // Verify f/** files were NOT written due to includes override + let fDirectoryExists = false; + try { + await Deno.stat(`${tempDir}/f`); + fDirectoryExists = true; + } catch { + // Directory doesn't exist, which is expected + } + + assertEquals(fDirectoryExists, false, "f/ directory should not exist due to includes override excluding f/**"); + }); +}); \ No newline at end of file diff --git a/cli/test/resource_types/postgres.json b/cli/test/resource_types/postgres.json deleted file mode 100644 index 258f3b6a4a..0000000000 --- a/cli/test/resource_types/postgres.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "workspace_id": "starter", - "name": "postgres", - "schema": { - "type": "object", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "required": [ - "dbname", - "user", - "password" - ], - "properties": { - "host": { - "type": "string", - "description": "The instance host" - }, - "port": { - "type": "integer", - "description": "The instance port" - }, - "user": { - "type": "string", - "description": "The postgres username" - }, - "dbname": { - "type": "string", - "description": "The database name" - }, - "sslmode": { - "enum": [ - "disable", - "allow", - "prefer", - "require", - "verify-ca", - "verify-full" - ], - "type": "string", - "description": "The sslmode" - }, - "password": { - "type": "string", - "description": "The postgres users password" - } - } - }, - "description": "A postgres database connection resource" -} \ No newline at end of file diff --git a/cli/test/resource_types/slack.json b/cli/test/resource_types/slack.json deleted file mode 100644 index dcd4cada91..0000000000 --- a/cli/test/resource_types/slack.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "workspace_id": "starter", - "name": "slack", - "schema": { - "type": "object", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "required": [], - "properties": { - "token": { - "type": "string", - "description": "The slack token" - } - } - }, - "description": "A slack token to interact with a specific workspace. Can be obtained from the OAuth integration in the workspace settings." -} \ No newline at end of file diff --git a/cli/test/resources/g/all/demodb.json b/cli/test/resources/g/all/demodb.json deleted file mode 100644 index c6c4ad2269..0000000000 --- a/cli/test/resources/g/all/demodb.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "workspace_id": "starter", - "path": "g/all/demodb", - "value": { - "host": "demodb.service.consul", - "port": "6543", - "user": "postgres", - "dbname": "demodb", - "sslmode": "disable", - "password": "demodb" - }, - "description": "demodb", - "resource_type": "postgres", - "extra_perms": {}, - "is_oauth": false -} \ No newline at end of file diff --git a/cli/test/sync_config_resolution.test.ts b/cli/test/sync_config_resolution.test.ts new file mode 100644 index 0000000000..7166a54b04 --- /dev/null +++ b/cli/test/sync_config_resolution.test.ts @@ -0,0 +1,147 @@ +import { assertEquals, assertStringIncludes, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { readConfigFile, getEffectiveSettings } from "../conf.ts"; +import { withContainerizedBackend } from "./containerized_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; + +// ============================================================================= +// SYNC CONFIGURATION RESOLUTION TESTS +// Tests for configuration resolution and integration with backend +// ============================================================================= + +// Helper function to set up workspace profile with localhost_test name +async function setupWorkspaceProfile(backend: any): Promise { + const testWorkspace = { + remote: backend.baseUrl, // "http://localhost:8001/" + workspaceId: backend.workspace, // "test" + name: "localhost_test", // This is what the tests expect! + token: backend.token + }; + + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); +} + + +// ============================================================================= +// INTEGRATION TESTS WITH REAL BACKEND +// ============================================================================= + +Deno.test("Integration: wmill.yaml configuration produces expected results", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace profile with name "localhost_test" + await setupWorkspaceProfile(backend); + + // Create wmill.yaml with settings + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** + - settings.yaml +excludes: + - "*.test.ts" +skipVariables: true +skipResources: true +includeSettings: true +includeSchedules: true +includeTriggers: true`); + + // Test pull with wmill.yaml configuration + const yamlResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); + if (yamlResult.code !== 0) { + console.log("YAML command failed!"); + console.log("Exit code:", yamlResult.code); + console.log("Stdout:", yamlResult.stdout); + console.log("Stderr:", yamlResult.stderr); + } + assertEquals(yamlResult.code, 0); + + // Extract JSON from CLI output (skip log messages) + const yamlData = parseJsonFromCLIOutput(yamlResult.stdout); + + // Should include settings.yaml due to includeSettings: true + const hasSettings = (yamlData.changes || []).some((change: any) => + change.type === 'added' && change.path === 'settings.yaml' + ); + assertEquals(hasSettings, true); + + // Should NOT include resources or variables (due to skip flags) + const hasResources = (yamlData.changes || []).some((change: any) => + change.type === 'added' && change.path?.includes('.resource.yaml') + ); + const hasVariables = (yamlData.changes || []).some((change: any) => + change.type === 'added' && change.path?.includes('.variable.yaml') + ); + assertEquals(hasResources, false); + assertEquals(hasVariables, false); + }); +}); + +Deno.test("Integration: settings.yaml inclusion respects includeSettings flag", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace profile with name "localhost_test" + await setupWorkspaceProfile(backend); + + // Test 1: includeSettings: true should include settings.yaml + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: true`); + + const includeResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); + assertEquals(includeResult.code, 0); + + // Extract JSON from CLI output (skip log messages) + const includeData = parseJsonFromCLIOutput(includeResult.stdout); + const hasSettingsInclude = (includeData.changes || []).some((change: any) => + change.type === 'added' && change.path === 'settings.yaml' + ); + assertEquals(hasSettingsInclude, true); + + // Test 2: includeSettings: false should NOT include settings.yaml + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: false`); + + const excludeResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); + assertEquals(excludeResult.code, 0); + + // Extract JSON from CLI output (skip log messages) + const excludeData = parseJsonFromCLIOutput(excludeResult.stdout); + const hasSettingsExclude = (excludeData.changes || []).some((change: any) => + change.type === 'added' && change.path === 'settings.yaml' + ); + assertEquals(hasSettingsExclude, false); + }); +}); + +Deno.test("Integration: resource/variable filtering respects skip flags", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace profile with name "localhost_test" + await setupWorkspaceProfile(backend); + + // Test skipResources: true + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipResources: true +skipVariables: false`); + + const result = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); + assertEquals(result.code, 0); + + // Extract JSON from CLI output (skip log messages) + const data = parseJsonFromCLIOutput(result.stdout); + + // Should NOT include resources + const hasResources = (data.changes || []).some((change: any) => + change.type === 'added' && change.path?.includes('.resource.yaml') + ); + assertEquals(hasResources, false); + + // Should include variables (not skipped) + const hasVariables = (data.changes || []).some((change: any) => + change.type === 'added' && change.path?.includes('.variable.yaml') + ); + assertEquals(hasVariables, true); + }); +}); \ No newline at end of file diff --git a/cli/test/test_config_helpers.ts b/cli/test/test_config_helpers.ts new file mode 100644 index 0000000000..8d36fde844 --- /dev/null +++ b/cli/test/test_config_helpers.ts @@ -0,0 +1,39 @@ +import { getRootStore } from "../store.ts"; + +/** + * Create a temporary config directory for testing that doesn't interfere with user's config + */ +export async function withTestConfig(callback: (testConfigDir: string) => Promise): Promise { + // Create a unique temporary directory for this test + const testDir = await Deno.makeTempDir({ prefix: "wmill_test_config_" }); + + try { + return await callback(testDir); + } finally { + // Clean up the temporary directory + try { + await Deno.remove(testDir, { recursive: true }); + } catch (error) { + console.warn(`Failed to clean up test config directory ${testDir}:`, error); + } + } +} + +/** + * Clear the remotes file in test config directory + */ +export async function clearTestRemotes(testConfigDir: string): Promise { + const remoteFile = (await getRootStore(testConfigDir)) + "remotes.ndjson"; + await Deno.writeTextFile(remoteFile, ""); +} + +/** + * Parse JSON output from CLI command, handling log messages that appear before JSON + */ +export function parseJsonFromCLIOutput(stdout: string): any { + const jsonMatch = stdout.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error(`No JSON found in CLI output: ${stdout}`); + } + return JSON.parse(jsonMatch[0]); +} \ No newline at end of file diff --git a/cli/test/workspace_conflicts.test.ts b/cli/test/workspace_conflicts.test.ts new file mode 100644 index 0000000000..a353856187 --- /dev/null +++ b/cli/test/workspace_conflicts.test.ts @@ -0,0 +1,153 @@ +import { assertEquals, assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { addWorkspace, allWorkspaces } from "../workspace.ts"; +import { withTestConfig, clearTestRemotes } from "./test_config_helpers.ts"; + +// Test workspace conflict detection +Deno.test("addWorkspace: prevents duplicate workspace names", async () => { + await withTestConfig(async (testConfigDir) => { + await clearTestRemotes(testConfigDir); + + // Add first workspace + const workspace1 = { + name: "test_workspace", + remote: "http://localhost:8001/", + workspaceId: "workspace1", + token: "token1" + }; + + await addWorkspace(workspace1, { force: true, configDir: testConfigDir }); + + // Try to add workspace with same name but different details + const workspace2 = { + name: "test_workspace", // Same name + remote: "http://localhost:8002/", // Different remote + workspaceId: "workspace2", // Different ID + token: "token2" + }; + + // Should throw error in non-interactive mode without force + await assertRejects( + () => addWorkspace(workspace2, { configDir: testConfigDir }), + Error, + "Workspace name conflict. Use --force to overwrite or choose a different name." + ); + + // Should succeed with force flag + await addWorkspace(workspace2, { force: true, configDir: testConfigDir }); + + // Verify the workspace was overwritten + const workspaces = await allWorkspaces(testConfigDir); + assertEquals(workspaces.length, 1); + assertEquals(workspaces[0].name, "test_workspace"); + assertEquals(workspaces[0].remote, "http://localhost:8002/"); + assertEquals(workspaces[0].workspaceId, "workspace2"); + }); +}); + +Deno.test("addWorkspace: prevents duplicate (remote, workspaceId) tuples", async () => { + await withTestConfig(async (testConfigDir) => { + await clearTestRemotes(testConfigDir); + + // Add first workspace + const workspace1 = { + name: "first_workspace", + remote: "http://localhost:8001/", + workspaceId: "test", + token: "token1" + }; + + await addWorkspace(workspace1, { force: true, configDir: testConfigDir }); + + // Try to add workspace with same (remote, workspaceId) but different name + const workspace2 = { + name: "second_workspace", // Different name + remote: "http://localhost:8001/", // Same remote + workspaceId: "test", // Same workspaceId + token: "token2" + }; + + // Should throw error in non-interactive mode without force + await assertRejects( + () => addWorkspace(workspace2, { configDir: testConfigDir }), + Error, + 'Backend constraint violation: (http://localhost:8001/, test) already exists as "first_workspace". Use --force to overwrite.' + ); + + // Should succeed with force flag (overwrites first workspace) + await addWorkspace(workspace2, { force: true, configDir: testConfigDir }); + + // Verify the first workspace was removed and second was added + const workspaces = await allWorkspaces(testConfigDir); + assertEquals(workspaces.length, 1); + assertEquals(workspaces[0].name, "second_workspace"); + assertEquals(workspaces[0].remote, "http://localhost:8001/"); + assertEquals(workspaces[0].workspaceId, "test"); + }); +}); + +Deno.test("addWorkspace: allows same workspace (name, remote, workspaceId) with token update", async () => { + await withTestConfig(async (testConfigDir) => { + await clearTestRemotes(testConfigDir); + + // Add first workspace + const workspace1 = { + name: "same_workspace", + remote: "http://localhost:8001/", + workspaceId: "test", + token: "old_token" + }; + + await addWorkspace(workspace1, { force: true, configDir: testConfigDir }); + + // Add same workspace with updated token + const workspace2 = { + name: "same_workspace", // Same name + remote: "http://localhost:8001/", // Same remote + workspaceId: "test", // Same workspaceId + token: "new_token" // Different token + }; + + // Should succeed without force (just token update) + await addWorkspace(workspace2, { configDir: testConfigDir }); + + // Verify token was updated + const workspaces = await allWorkspaces(testConfigDir); + assertEquals(workspaces.length, 1); + assertEquals(workspaces[0].name, "same_workspace"); + assertEquals(workspaces[0].token, "new_token"); + }); +}); + +Deno.test("addWorkspace: allows different workspaces on different remotes", async () => { + await withTestConfig(async (testConfigDir) => { + await clearTestRemotes(testConfigDir); + + // Add workspace on first remote + const workspace1 = { + name: "workspace_remote1", + remote: "http://localhost:8001/", + workspaceId: "test", + token: "token1" + }; + + await addWorkspace(workspace1, { force: true, configDir: testConfigDir }); + + // Add workspace with same workspaceId on different remote (should be allowed) + const workspace2 = { + name: "workspace_remote2", + remote: "http://localhost:8002/", // Different remote + workspaceId: "test", // Same workspaceId (OK on different remote) + token: "token2" + }; + + // Should succeed (different remotes) + await addWorkspace(workspace2, { configDir: testConfigDir }); + + // Verify both workspaces exist + const workspaces = await allWorkspaces(testConfigDir); + assertEquals(workspaces.length, 2); + + const names = workspaces.map(w => w.name).sort(); + assertEquals(names, ["workspace_remote1", "workspace_remote2"]); + }); +}); \ No newline at end of file diff --git a/cli/types.ts b/cli/types.ts index a95be8da98..c74598e999 100644 --- a/cli/types.ts +++ b/cli/types.ts @@ -49,6 +49,7 @@ export type GlobalOptions = { baseUrl: string | undefined; workspace: string | undefined; token: string | undefined; + configDir: string | undefined; }; export function isSuperset( @@ -231,12 +232,11 @@ export function getTypeStrFromPath( parsed.ext == ".cs" || parsed.ext == ".nu" || parsed.ext == ".java" || - // for related places search: ADD_NEW_LANG + // for related places search: ADD_NEW_LANG (parsed.ext == ".yml" && parsed.name.split(".").pop() == "playbook") ) { return "script"; } - if (parsed.name === "folder.meta") { return "folder"; } diff --git a/cli/utils.ts b/cli/utils.ts index 027ff7ce00..1defd68b3f 100644 --- a/cli/utils.ts +++ b/cli/utils.ts @@ -149,4 +149,51 @@ export function printSync(input: string | Uint8Array, to = Deno.stdout) { while (bytesWritten < bytes.length) { bytesWritten += to.writeSync(bytes.subarray(bytesWritten)) } +} + +// Repository interface for shared selection logic +export interface Repository { + git_repo_resource_path: string; +} + +// Shared repository selection logic +export async function selectRepository( + repositories: T[], + operation?: string +): Promise { + if (repositories.length === 0) { + throw new Error("No git-sync repositories configured in workspace"); + } + + if (repositories.length === 1) { + const repoPath = repositories[0].git_repo_resource_path.replace(/^\$res:/, ""); + log.info(`Using repository: ${repoPath}`); + return repositories[0]; + } + + // Check if we're in a non-interactive environment + const isInteractive = Deno.stdin.isTerminal() && Deno.stdout.isTerminal(); + + if (!isInteractive) { + const repoPaths = repositories.map(r => r.git_repo_resource_path.replace(/^\$res:/, "")); + throw new Error(`Multiple repositories found: ${repoPaths.join(', ')}. Use --repository to specify which one to ${operation || 'use'}.`); + } + + // Import Select dynamically to avoid dependency issues + const { Select } = await import("./deps.ts"); + + console.log(`\nMultiple repositories found. Please select which repository to ${operation || 'use'}:\n`); + + const selectedRepo = await Select.prompt({ + message: `Select repository for ${operation || 'operation'}:`, + options: repositories.map((repo, index) => { + const displayPath = repo.git_repo_resource_path.replace(/^\$res:/, ""); + return { + name: `${index + 1}. ${displayPath}`, + value: repo.git_repo_resource_path + }; + }) + }); + + return repositories.find((r) => r.git_repo_resource_path === selectedRepo)!; } \ No newline at end of file diff --git a/cli/workspace.ts b/cli/workspace.ts index cd0ff31ee9..b2af05d089 100644 --- a/cli/workspace.ts +++ b/cli/workspace.ts @@ -2,7 +2,7 @@ import { GlobalOptions } from "./types.ts"; import { getRootStore } from "./store.ts"; import { loginInteractive, tryGetLoginInfo } from "./login.ts"; -import { colors, Command, Input, log, setClient, Table } from "./deps.ts"; +import { colors, Command, Confirm, Input, log, setClient, Table } from "./deps.ts"; import * as wmill from "./gen/services.gen.ts"; import { requireLogin } from "./context.ts"; @@ -14,9 +14,9 @@ export interface Workspace { token: string; } -export async function allWorkspaces(): Promise { +export async function allWorkspaces(configDirOverride?: string): Promise { try { - const file = (await getRootStore()) + "remotes.ndjson"; + const file = (await getRootStore(configDirOverride)) + "remotes.ndjson"; const txt = await Deno.readTextFile(file); return txt .split("\n") @@ -40,7 +40,7 @@ async function getActiveWorkspaceName( return opts?.workspace; } try { - return await Deno.readTextFile((await getRootStore()) + "/activeWorkspace"); + return await Deno.readTextFile((await getRootStore(opts?.configDir)) + "/activeWorkspace"); } catch { return undefined; } @@ -53,13 +53,14 @@ export async function getActiveWorkspace( if (!name) { return undefined; } - return await getWorkspaceByName(name); + return await getWorkspaceByName(name, opts?.configDir); } export async function getWorkspaceByName( - workspaceName: string + workspaceName: string, + configDirOverride?: string ): Promise { - const workspaceStream = await allWorkspaces(); + const workspaceStream = await allWorkspaces(configDirOverride); for await (const workspace of workspaceStream) { if (workspace.name === workspaceName) { return workspace; @@ -69,7 +70,7 @@ export async function getWorkspaceByName( } async function list(opts: GlobalOptions) { - const workspaces = await allWorkspaces(); + const workspaces = await allWorkspaces(opts.configDir); const activeName = await getActiveWorkspaceName(opts); new Table() @@ -101,7 +102,7 @@ async function switchC(opts: GlobalOptions, workspaceName: string) { return; } - const all = await allWorkspaces(); + const all = await allWorkspaces(opts.configDir); if (all.findIndex((x) => x.name === workspaceName) === -1) { log.info( colors.red.bold( @@ -115,13 +116,13 @@ async function switchC(opts: GlobalOptions, workspaceName: string) { return; } - await setActiveWorkspace(workspaceName); + await setActiveWorkspace(workspaceName, opts.configDir); return; } -export async function setActiveWorkspace(workspaceName: string) { +export async function setActiveWorkspace(workspaceName: string, configDirOverride?: string) { await Deno.writeTextFile( - (await getRootStore()) + "/activeWorkspace", + (await getRootStore(configDirOverride)) + "/activeWorkspace", workspaceName ); } @@ -246,7 +247,7 @@ export async function add( }, opts ); - await setActiveWorkspace(workspaceName); + await setActiveWorkspace(workspaceName, opts.configDir); log.info( colors.green.underline( @@ -257,8 +258,85 @@ export async function add( export async function addWorkspace(workspace: Workspace, opts: any) { workspace.remote = new URL(workspace.remote).toString(); // add trailing slash in all cases! + + // Check for conflicts before adding + const existingWorkspaces = await allWorkspaces(opts.configDir); + const isInteractive = Deno.stdin.isTerminal() && Deno.stdout.isTerminal() && !opts.force; + + // Check 1: Workspace name already exists + const nameConflict = existingWorkspaces.find(w => w.name === workspace.name); + if (nameConflict) { + // If it's the exact same workspace (same remote + workspaceId), just update the token + if (nameConflict.remote === workspace.remote && nameConflict.workspaceId === workspace.workspaceId) { + log.info(colors.yellow(`Updating token for existing workspace "${workspace.name}"`)); + } else { + // Different remote or workspaceId - this is a conflict + log.info(colors.red.bold(`❌ Workspace name "${workspace.name}" already exists!`)); + log.info(` Existing: ${nameConflict.workspaceId} on ${nameConflict.remote}`); + log.info(` New: ${workspace.workspaceId} on ${workspace.remote}`); + + if (!isInteractive) { + // In non-interactive mode (tests, scripts), auto-overwrite with force flag + if (opts.force) { + log.info(colors.yellow("Force flag enabled, overwriting existing workspace.")); + } else { + throw new Error("Workspace name conflict. Use --force to overwrite or choose a different name."); + } + } else { + const overwrite = await Confirm.prompt({ + message: "Do you want to overwrite the existing workspace?", + default: false, + }); + + if (!overwrite) { + log.info(colors.yellow("Operation cancelled.")); + return; + } + } + } + } + + // Check 2: Same (remote, workspaceId) tuple already exists under different name + const tupleConflict = existingWorkspaces.find(w => + w.remote === workspace.remote && + w.workspaceId === workspace.workspaceId && + w.name !== workspace.name + ); + + if (tupleConflict) { + log.info(colors.red.bold(`❌ Workspace ${workspace.workspaceId} on ${workspace.remote} already exists!`)); + log.info(` Existing name: "${tupleConflict.name}"`); + log.info(` New name: "${workspace.name}"`); + log.info(colors.yellow(`\nNote: Backend constraint prevents duplicate (remote, workspaceId) combinations.`)); + + if (!isInteractive) { + // In non-interactive mode (tests, scripts), auto-overwrite with force flag + if (opts.force) { + log.info(colors.yellow(`Force flag enabled, overwriting existing workspace "${tupleConflict.name}".`)); + } else { + throw new Error(`Backend constraint violation: (${workspace.remote}, ${workspace.workspaceId}) already exists as "${tupleConflict.name}". Use --force to overwrite.`); + } + } else { + const overwrite = await Confirm.prompt({ + message: `Do you want to overwrite the existing workspace "${tupleConflict.name}"?`, + default: false, + }); + + if (!overwrite) { + log.info(colors.yellow("Operation cancelled.")); + return; + } + } + + // Remove the conflicting workspace + await removeWorkspace(tupleConflict.name, true, opts); + } + + // Remove existing workspace with same name (if updating) await removeWorkspace(workspace.name, true, opts); - const file = await Deno.open((await getRootStore()) + "remotes.ndjson", { + + // Add the new workspace + const file = await Deno.open((await getRootStore(opts.configDir)) + "remotes.ndjson", { append: true, write: true, read: true, @@ -274,7 +352,7 @@ export async function removeWorkspace( silent: boolean, opts: any ) { - const orgWorkspaces = await allWorkspaces(); + const orgWorkspaces = await allWorkspaces(opts.configDir); if (orgWorkspaces.findIndex((x) => x.name === name) === -1) { if (!silent) { log.info( @@ -290,7 +368,7 @@ export async function removeWorkspace( } await Deno.writeTextFile( - (await getRootStore()) + "remotes.ndjson", + (await getRootStore(opts.configDir)) + "remotes.ndjson", orgWorkspaces .filter((x) => x.name !== name) .map((x) => JSON.stringify(x)) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd7f56a530..f440b5df8d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.505.3", + "version": "1.507.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.505.3", + "version": "1.507.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 9a74c88356..9f131dd2fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.505.3", + "version": "1.507.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 5d9bbcbb29..ae9d67c942 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -28,6 +28,7 @@ import GfmMarkdown from './GfmMarkdown.svelte' import { apiTokenApps, forceSecretValue, linkedSecretValue } from './app_connect' import type { SchemaProperty } from '$lib/common' + import Tooltip from './Tooltip.svelte' interface Props { step?: number @@ -94,6 +95,27 @@ let path: string = $state('') let description = $state('') + /** + * Client credentials OAuth flow support + * @description Determines if the selected OAuth provider supports client_credentials grant type + * alongside the traditional authorization_code flow + */ + let supportsClientCredentials = $state(false) + + /** + * OAuth flow selection + * @description Controls which OAuth flow to use: + * - false: authorization_code flow (interactive, requires user consent) + * - true: client_credentials flow (server-to-server, no user interaction) + */ + let useClientCredentials = $state(false) + + /** + * Client credentials for resource-level OAuth + */ + let clientId = $state('') + let clientSecret = $state('') + let resourceTypeInfo: ResourceType | undefined = $state(undefined) let pathError = $state('') @@ -107,6 +129,13 @@ description = '' resourceType = rt ?? '' valueToken = undefined + + // Reset client credentials state + supportsClientCredentials = false + useClientCredentials = false + clientId = '' + clientSecret = '' + await loadConnects() manual = !connects?.includes(resourceType) if (manual && express) { @@ -151,13 +180,15 @@ disabled = (step == 1 && resourceType == '') || (step == 2 && - value == '' && - args && - args['token'] == '' && - args['password'] == '' && - args['api_key'] == '' && - args['key'] == '' && - linkedSecret != undefined) || + (manual + ? value == '' && + args && + args['token'] == '' && + args['password'] == '' && + args['api_key'] == '' && + args['key'] == '' && + linkedSecret != undefined + : false)) || step == 3 || (step == 4 && pathError != '') || !isValid @@ -259,6 +290,12 @@ const connect = await OauthService.getOauthConnect({ client: resourceType }) scopes = connect.scopes ?? [] extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][] + + /** + * Check if the OAuth provider supports client_credentials grant type + * This determines whether to show the OAuth flow selection UI + */ + supportsClientCredentials = connect.grant_types?.includes('client_credentials') ?? false } async function getResourceTypeInfo() { @@ -287,21 +324,68 @@ } step += 1 } else if (step == 2 && !manual) { - const url = new URL(`/api/oauth/connect/${resourceType}`, window.location.origin) - url.searchParams.append('scopes', scopes.join('+')) - if (extra_params.length > 0) { - extra_params.forEach(([key, value]) => url.searchParams.append(key, value)) - } - // if (!newPageOAuth) { - // window.location.href = url.toString() - // } else { - window.addEventListener('message', popupListener) - window.addEventListener('storage', handleStorageEvent) - window.open(url.toString(), '_blank', 'popup=true') - step += 1 + if (useClientCredentials) { + /** + * Client credentials flow: Direct API call to backend + * No popup window or user interaction required + * Uses instance-level OAuth credentials for server-to-server auth + */ + try { + // Trim whitespace from credentials to avoid false negatives + const trimmedClientId = clientId.trim() + const trimmedClientSecret = clientSecret.trim() - // dispatch('close') - // } + // Validate required fields + if (!trimmedClientId || !trimmedClientSecret) { + sendUserToast( + 'Client ID and Client Secret are required for client credentials flow', + true + ) + return + } + + const tokenResponse = await OauthService.connectClientCredentials({ + client: resourceType, + requestBody: { + scopes: scopes, + cc_client_id: trimmedClientId, + cc_client_secret: trimmedClientSecret + } + }) + + // Process the token response like in popup flow + value = tokenResponse.access_token! + valueToken = { + ...tokenResponse, + grant_type: 'client_credentials' // Mark this token as client_credentials + } + step = 4 + if (express) { + path = `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}` + next() + } + } catch (error) { + sendUserToast( + `Failed to connect with client credentials: ${error.body || error.message}`, + true + ) + } + } else { + /** + * Authorization code flow: Traditional OAuth popup window + * Requires user interaction and consent + * Opens popup for user to authenticate with OAuth provider + */ + const url = new URL(`/api/oauth/connect/${resourceType}`, window.location.origin) + url.searchParams.append('scopes', scopes.join('+')) + if (extra_params.length > 0) { + extra_params.forEach(([key, value]) => url.searchParams.append(key, value)) + } + window.addEventListener('message', popupListener) + window.addEventListener('storage', handleStorageEvent) + window.open(url.toString(), '_blank', 'popup=true') + step += 1 + } } else { if (!path) { throw Error('Path is not set') @@ -331,14 +415,23 @@ let account: number | undefined = undefined if (valueToken?.expires_in != undefined) { + const accountData: any = { + refresh_token: valueToken.refresh_token ?? '', + expires_in: valueToken.expires_in, + client: resourceType, + grant_type: valueToken.grant_type || 'authorization_code' + } + + // Add client credentials if using client_credentials flow + if (useClientCredentials) { + accountData.cc_client_id = clientId.trim() + accountData.cc_client_secret = clientSecret.trim() + } + account = Number( await OauthService.createAccount({ workspace: $workspaceStore!, - requestBody: { - refresh_token: valueToken.refresh_token ?? '', - expires_in: valueToken.expires_in, - client: resourceType - } + requestBody: accountData }) ) } @@ -604,14 +697,58 @@ {#if manual == false && resourceType != ''}

{resourceType}

Click connect to create a resource backed by an oauth connection, whose token is fetched - from the external services and refreshed automatically if needed before expiration (using - its refresh token)
Create a resource backed by an OAuth connection, whose token is fetched from the external + services and refreshed automatically if needed before expiration.

Description

+ + {#if supportsClientCredentials} +
+

Authentication Method

+
+ + Use Client Credentials Flow + + Server-to-server authentication without user interaction. +

+ Provide your own OAuth client credentials for this resource. +
+
+ + {#if useClientCredentials} +
+ + +
+ {/if} +
+ {/if} +

Scopes - {:else if inputCat == 'object' || inputCat == 'resource-object' || isListJson} {#if oneOf && oneOf.length >= 2}
diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index 5820cdbe8d..393a12232c 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -17,6 +17,7 @@ import Toggle from './Toggle.svelte' import { ExternalLink, Plus } from 'lucide-svelte' import AzureOauthSettings from './AzureOauthSettings.svelte' + import Tooltip from './Tooltip.svelte' interface Props { snowflakeAccountIdentifier?: string @@ -233,6 +234,49 @@ Client Secret + {#if k === 'visma' || !windmillBuiltins.includes(k)} +
+
+ { + const target = e.target as HTMLInputElement + if (oauths && oauths[k]) { + if (!oauths[k]['grant_types']) { + oauths[k]['grant_types'] = ['authorization_code'] + } + if (target.checked) { + if (!oauths[k]['grant_types'].includes('client_credentials')) { + oauths[k]['grant_types'] = [ + ...oauths[k]['grant_types'], + 'client_credentials' + ] + } + } else { + oauths[k]['grant_types'] = oauths[k]['grant_types'].filter( + (gt) => gt !== 'client_credentials' + ) + } + } + }} + /> + Support Client Credentials Flow + + Enables server-to-server authentication without user interaction. Use for + automated scripts and background jobs. +

+ When enabled, users can provide their own client credentials at the resource + level. The Client ID and Secret configured above are only used for the traditional + OAuth flow (popup window). +
+
+
+ {/if} {#if k === 'azure_oauth'} {:else if !windmillBuiltins.includes(k) && k != 'slack'} @@ -285,7 +329,7 @@ on:click={() => { if (oauths) { let name = oauth_name == 'custom' ? resourceName : oauth_name - oauths[name ?? ''] = { id: '', secret: '' } + oauths[name ?? ''] = { id: '', secret: '', grant_types: ['authorization_code'] } } resourceName = '' }} diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 9c2134d927..ddfbe2d9c7 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -113,7 +113,8 @@ // Flow preview let flowPreviewButtons: FlowPreviewButtons | undefined = $state() - const job: Job | undefined = $derived(flowPreviewButtons?.getJob()) + const flowPreviewContent = $derived(flowPreviewButtons?.getFlowPreviewContent()) + const job: Job | undefined = $derived(flowPreviewContent?.getJob()) let showJobStatus = $state(false) let testModuleId: string | undefined = $state(undefined) @@ -585,13 +586,13 @@ }) const localModuleStates: Writable> = $derived( - flowPreviewButtons?.getLocalModuleStates() ?? writable({}) + flowPreviewContent?.getLocalModuleStates() ?? writable({}) ) const localDurationStatuses: Writable> = $derived( - flowPreviewButtons?.getLocalDurationStatuses() ?? writable({}) + flowPreviewContent?.getLocalDurationStatuses() ?? writable({}) ) const suspendStatus: Writable> = $derived( - flowPreviewButtons?.getSuspendStatus() ?? writable({}) + flowPreviewContent?.getSuspendStatus() ?? writable({}) ) // Create a derived store that only shows the module states when showModuleStatus is true @@ -646,6 +647,8 @@ const individualStepTests = $derived( !(showJobStatus && job) && Object.keys($derivedModuleStates).length > 0 ) + + const flowHasChanged = $derived(flowPreviewContent?.flowHasChanged()) @@ -800,10 +803,10 @@ disableStaticInputs localModuleStates={derivedModuleStates} onTestUpTo={flowPreviewButtons?.testUpTo} - isOwner={flowPreviewButtons?.getIsOwner()} + isOwner={flowPreviewContent?.getIsOwner?.()} onTestFlow={flowPreviewButtons?.runPreview} - isRunning={flowPreviewButtons?.getIsRunning()} - onCancelTestFlow={flowPreviewButtons?.cancelTest} + isRunning={flowPreviewContent?.getIsRunning?.()} + onCancelTestFlow={flowPreviewContent?.cancelTest} onOpenPreview={flowPreviewButtons?.openPreview} onHideJobStatus={resetModulesStates} {individualStepTests} @@ -812,6 +815,7 @@ onDelete={(id) => { delete $derivedModuleStates[id] }} + {flowHasChanged} /> {:else}
Missing flow modules
@@ -833,7 +837,7 @@ }} onTestFlow={flowPreviewButtons?.runPreview} {job} - isOwner={flowPreviewButtons?.getIsOwner()} + isOwner={flowPreviewContent?.getIsOwner()} {localDurationStatuses} {suspendStatus} onOpenDetails={flowPreviewButtons?.openPreview} diff --git a/frontend/src/lib/components/GitDiffPreview.svelte b/frontend/src/lib/components/GitDiffPreview.svelte new file mode 100644 index 0000000000..8d602831af --- /dev/null +++ b/frontend/src/lib/components/GitDiffPreview.svelte @@ -0,0 +1,65 @@ + + +
+
Preview of changes:
+ {#if !previewResult?.added?.length && !previewResult?.deleted?.length && !previewResult?.modified?.length && !previewResult?.yamlModified} +
No changes found! The workspace is up to date.
+ {:else} + {#if previewResult?.yamlModified} +
+
Modified:
+
    +
  • wmill.yaml (Git sync settings)
  • +
+
+ {/if} + {#if previewResult?.added?.length} +
+
Added:
+
    + {#each previewResult.added as file} +
  • + {file}{!file.includes('.') ? ' (dir)' : ''} +
  • + {/each} +
+
+ {/if} + {#if previewResult?.deleted?.length} +
+
Deleted:
+
    + {#each previewResult.deleted as file} +
  • + {file}{!file.includes('.') ? ' (dir)' : ''} +
  • + {/each} +
+
+ {/if} + {#if previewResult?.modified?.length} +
+
Modified:
+
    + {#each previewResult.modified as file} +
  • + {file}{!file.includes('.') ? ' (dir)' : ''} +
  • + {/each} +
+
+ {/if} + {/if} +
diff --git a/frontend/src/lib/components/InitGitRepoPopover.svelte b/frontend/src/lib/components/InitGitRepoPopover.svelte new file mode 100644 index 0000000000..a22d46b394 --- /dev/null +++ b/frontend/src/lib/components/InitGitRepoPopover.svelte @@ -0,0 +1,454 @@ + + + + + + + +
+
+

Push workspace to Git repository

+
+ This action will push all workspace objects that match your current filter settings to the + selected branch in your Git repository. Any existing content in the branch will be replaced with the + filtered workspace content. + + +
+ + + {#if isCliInfoExpanded} +
+
+ Not familiar with Windmill CLI? Check out the docs +
+
+
npm install -g windmill-cli
+wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
+wmill init
+# adjust wmill.yaml file configuraton as needed
+wmill sync pull
+git add -A
+git commit -m 'Initial commit'
+git push
+
+
+ {/if} +
+
+
+ +
+ +
+ Enter a new branch name to push to (e.g so you can merge back into main with a pull + request). If left blank, the default branch from the git repository resource will be used. +
+
+ +
+
+ + {#if previewResult} + + {/if} + + {#if previewJobId} +
+ {#if previewJobStatus === 'running'} + + {:else if previewJobStatus === 'success'} + + {:else if previewJobStatus === 'failure'} + + {/if} + Preview job: + + {previewJobId} + +
+ {/if} + + {#if pushJobId} +
+ {#if pushJobStatus === 'running'} + + {:else if pushJobStatus === 'success'} + + {:else if pushJobStatus === 'failure'} + + {/if} + Push job: + + {pushJobId} + +
+ {/if} + + +
+ +
+ {#if !previewResult} + + {:else} + + {#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length} + + {/if} + {/if} +
+
+ + {#if initResult?.message} +
+ + {initResult.message} + +
+ {/if} +
+
+
diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 7e14651945..8836050918 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -227,9 +227,17 @@ oauths['snowflake_oauth'].connect_config = connect_config } + let sendingStats = $state(false) async function sendStats() { - await SettingService.sendStats() - sendUserToast('Usage sent') + try { + sendingStats = true + await SettingService.sendStats() + sendUserToast('Usage sent') + } catch (err) { + throw err + } finally { + sendingStats = false + } } function isValidTeamsChannel(value: any): value is TeamsChannel { @@ -332,8 +340,11 @@ color="light" btnClasses="w-auto" wrapperClasses="mb-4" - size="xs">Send usage + Send usage + {/if} {:else if category == 'Auth/OAuth/SAML'} import { ResourceService } from '$lib/gen' import { workspaceStore } from '$lib/stores' - import { createEventDispatcher, getContext } from 'svelte' + import { getContext, untrack } from 'svelte' import DarkModeObserver from './DarkModeObserver.svelte' import { Button, Drawer, DrawerContent } from './common' import { Plus, Loader2, Link2Off } from 'lucide-svelte' import type { AppViewerContext } from './apps/types' import { sendUserToast } from '$lib/toast' - import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted' import Select from './select/Select.svelte' - const dispatch = createEventDispatcher() - const dispatchIfMounted = createDispatcherIfMounted(dispatch) + interface Props { + value: string | undefined + resourceType?: string | undefined + disablePortal?: boolean + expressOAuthSetup?: boolean + disabled?: boolean + } - export let initialValue: string | undefined = undefined - export let value: string | undefined = initialValue - export let resourceType: string | undefined = undefined - export let disablePortal = false - export let expressOAuthSetup = false - export let disabled = false + let { + value = $bindable(), + resourceType = undefined, + disablePortal = false, + expressOAuthSetup = false, + disabled = false + }: Props = $props() - let open = false - let refreshCount = 0 + let open = $state(false) + let refreshCount = $state(0) const appViewerContext = getContext('AppViewerContext') - let valueSelect = - initialValue || value - ? { - value: value ?? initialValue, - label: value ?? initialValue - } - : undefined + let collection = $state(value ? [{ value, label: value }] : []) - let collection = valueSelect ? [valueSelect] : [] - - let loading = true + let loading = $state(true) async function loadResources(resourceType: string | undefined) { loading = true try { @@ -49,26 +46,25 @@ })) // TODO check if this is needed - if (!nc.find((x) => x.value == value) && (initialValue || value)) { - nc.push({ value: value ?? initialValue!, label: value ?? initialValue! }) + if (!nc.find((x) => x.value == value) && value) { + nc.push({ value: value, label: value }) } collection = nc if (expressOAuthSetup && nc.length > 0) { value = nc[0].value - valueSelect = nc[0] } } finally { loading = false } } - $: $workspaceStore && loadResources(resourceType) + $effect(() => { + $workspaceStore && resourceType && untrack(() => loadResources(resourceType)) + }) - $: dispatchIfMounted('change', value) + let darkMode: boolean = $state(false) - let darkMode: boolean = false - - let drawer: Drawer | undefined = undefined + let drawer: Drawer | undefined = $state(undefined) export function askNewResource() { refreshCount += 1 @@ -93,7 +89,6 @@ on:refresh={async (e) => { await loadResources(resourceType) value = e.detail - valueSelect = { value, label: value } open = false }} /> @@ -121,7 +116,6 @@ on:refresh={async (e) => { await loadResources(resourceType) value = e.detail - valueSelect = { value, label: value } drawer?.closeDrawer?.() open = false }} @@ -157,7 +151,6 @@ const item = collection[0] if (item) { value = item.value - valueSelect = item } }}>Use existing @@ -167,15 +160,13 @@ {disabled} {disablePortal} bind:value={ - () => valueSelect?.value, + () => value, (v) => { value = v - valueSelect = collection.find((x) => x.value === v) } } onClear={() => { value = undefined - valueSelect = undefined }} clearable items={collection} @@ -184,7 +175,7 @@ /> {/if}
- {#if valueSelect && expressOAuthSetup} + {#if value && expressOAuthSetup} + + + +
+
+

Pull workspace from Git repository

+
+ This action will pull all workspace objects from your Git repository according to the + filters set in the Git repository wmill.yaml file and apply those filter settings to the + workspace. + + This will overwrite your current workspace content and Git sync + filter settings with the content from the Git repository. + + + +
+ + + {#if isCliInfoExpanded} +
+
+ Not familiar with Windmill CLI? Check out the docs +
+
+
npm install -g windmill-cli
+# Clone your git repository
+git clone $REPO_URL
+cd $REPO_NAME
+# Configure Windmill CLI
+wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
+# Push the content to Windmill
+wmill sync push --yes
+# Optional: add --skip-secrets --skip-variables --skip-resources flags as needed
+
+
+ {/if} +
+
+
+ + {#if previewResult} + + {/if} + + {#if jobStatus.id} +
+ {#if jobStatus.status === 'running'} + + {:else if jobStatus.status === 'success'} + + {:else if jobStatus.status === 'failure'} + + {/if} + {jobStatus.type === 'preview' ? 'Preview' : 'Pull'} job: + + {jobStatus.id} + +
+ {#if jobStatus.error} +
{jobStatus.error}
+ {/if} + {/if} + +
+ +
+ {#if !previewResult} + + {:else} + + {#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length} + + {/if} + {/if} +
+
+
+
+ diff --git a/frontend/src/lib/components/apps/components/buttons/AppButton.svelte b/frontend/src/lib/components/apps/components/buttons/AppButton.svelte index 905481f4bd..67abc63c5e 100644 --- a/frontend/src/lib/components/apps/components/buttons/AppButton.svelte +++ b/frontend/src/lib/components/apps/components/buttons/AppButton.svelte @@ -40,6 +40,7 @@ noInitialize?: boolean replaceCallback?: boolean controls?: { left: () => boolean; right: () => boolean | string } | undefined + onDone?: () => void } let { @@ -59,7 +60,8 @@ isMenuItem = false, noInitialize = false, replaceCallback = false, - controls = undefined + controls = undefined, + onDone = undefined }: Props = $props() const { worldStore, app, componentControl, selectedComponent } = @@ -240,6 +242,12 @@ listInputs.set(id, inputOutput) } }} + on:done={() => { + onDone?.() + }} + on:doneError={() => { + onDone?.() + }} refreshOnStart={resolvedConfig.triggerOnAppLoad} {replaceCallback} > diff --git a/frontend/src/lib/components/apps/components/display/AppMenu.svelte b/frontend/src/lib/components/apps/components/display/AppMenu.svelte index cc530cc543..8db2b4df52 100644 --- a/frontend/src/lib/components/apps/components/display/AppMenu.svelte +++ b/frontend/src/lib/components/apps/components/display/AppMenu.svelte @@ -171,7 +171,6 @@ outputs?.result.set({ latestButtonClicked: actionButton.id }) - menu?.close() }} > { + menu?.close() + }} />
{/if} diff --git a/frontend/src/lib/components/apps/components/inputs/AppUserResource.svelte b/frontend/src/lib/components/apps/components/inputs/AppUserResource.svelte index 73d4060f0b..0b1232922a 100644 --- a/frontend/src/lib/components/apps/components/inputs/AppUserResource.svelte +++ b/frontend/src/lib/components/apps/components/inputs/AppUserResource.svelte @@ -1,5 +1,5 @@ +{#snippet fileInput()} + { + forceDisplayUploads = false + handleChange(detail) + }} + class={twMerge('w-full h-full', customClass, 'wm-file-input')} + style={customStyle} + defaultFile={defaultValue} + > + {containerText}{#if disabled}
(Disabled){/if} +
+{/snippet} +
- {#if $fileUploads.length > 0 && !forceDisplayUploads} + {#if $fileUploads.length > 0}
{#each $fileUploads as fileUpload} @@ -468,7 +496,7 @@ Remove from list {/if} - {#if fileUpload.progress < 100 && !fileUpload.cancelled && !fileUpload.errorMessage} + {#if fileUpload.progress !== undefined && fileUpload.progress < 100 && !fileUpload.cancelled && !fileUpload.errorMessage} + {:else if fileUpload.fromBucket} + {/if}
- - {#if fileUpload.errorMessage} - {fileUpload.errorMessage} - {:else if fileUpload.cancelled} - Upload cancelled - {/if} - + {#if !fileUpload.fromBucket} + + {#if fileUpload.errorMessage} + {fileUpload.errorMessage} + {:else if fileUpload.cancelled} + Upload cancelled + {/if} + + {/if} {#if !(fileUpload.cancelled || fileUpload.errorMessage !== undefined)} - {fileUpload.progress === 100 ? 'Upload finished' : `Uploading`} to path: {fileUpload.path ?? - 'N/A'} + {#if fileUpload.fromBucket} + {fileUpload.path ?? 'N/A'} + {:else} + {fileUpload.progress === 100 ? 'Upload finished' : `Uploading`} to path: {fileUpload.path ?? + 'N/A'} + {/if} {/if}
{/each}
- {#if allowMultiple} -
- {#if !$fileUploads.every((fileUpload) => fileUpload.progress === 100 || fileUpload.cancelled)} - - {/if} +
+ {#if !forceDisplayUploads && (allowMultiple || folderOnly) && !$fileUploads.every((fileUpload) => fileUpload.progress === 100 || fileUpload.cancelled || fileUpload.fromBucket)} -
- {/if} + {/if} + {#if allowMultiple} + {#if forceDisplayUploads} + {@render fileInput()} + {:else} + + {/if} + {/if} +
{:else} - { - forceDisplayUploads = false - handleChange(detail) - }} - class={twMerge('w-full h-full', customClass, 'wm-file-input')} - style={customStyle} - defaultFile={defaultValue} - > - {containerText}{#if disabled}
(Disabled){/if} -
+ {@render fileInput()} {/if} {#if initialS3 && initialS3.length > 0 && $fileUploads.length == 0}
- File currently selected: {initialS3?.join(', ')} + File{initialS3.length > 1 ? 's' : ''} currently selected: {initialS3?.join(', ')}
{/if} diff --git a/frontend/src/lib/components/common/fileUpload/S3ArgInput.svelte b/frontend/src/lib/components/common/fileUpload/S3ArgInput.svelte new file mode 100644 index 0000000000..0f072e99ef --- /dev/null +++ b/frontend/src/lib/components/common/fileUpload/S3ArgInput.svelte @@ -0,0 +1,142 @@ + + + { + if (multiple) { + if (Array.isArray(value)) { + value.push(ev.detail) + } else { + value = [ev.detail] + } + fileUpload?.addUpload(ev.detail) + } else { + value = ev.detail + fileUpload?.setUpload(value) + } + editor?.setCode(JSON.stringify(value)) + }} + readOnlyMode={false} +/> + +
+ + {#if s3FileUploadRawMode} + {#await import('$lib/components/JsonEditor.svelte')} + + {:then Module} + { + onFocus() + }} + on:blur={(e) => { + onBlur() + }} + code={JSON.stringify(value ?? defaultValue ?? (multiple ? [] : { s3: '' }), null, 2)} + on:changeValue={(e) => { + setNewValueFromCode(e.detail) + }} + /> + {/await} + {:else} + { + const s3Object = { + s3: evt.detail?.path ?? '', + filename: evt.detail?.filename ?? '' + } + if (multiple) { + if (Array.isArray(value)) { + value.push(s3Object) + } else { + value = [s3Object] + } + } else { + value = s3Object + } + }} + on:deletion={(evt) => { + if (multiple) { + if (Array.isArray(value)) { + value = value.filter((v) => v.s3 !== evt.detail?.path) + } + } else { + value = { + s3: '' + } + } + }} + defaultValue={defaultValue?.s3} + initialValue={value} + /> + {/if} + +
diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index b4c3005322..fad315e775 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -502,7 +502,6 @@ disabled={!can_write} /> {/if} - {s3FileUploadRawMode} {#if s3FileUploadRawMode} {#if can_write} + import { Plus, X } from 'lucide-svelte' + + let { + title = '', + tooltip = undefined, + items = $bindable([] as string[]), + placeholder = 'Add filter (e.g. f/**)' + } = $props() + + let newItem = $state('') + let inputRef: HTMLInputElement | null = $state(null) + + function addItem() { + const value = newItem.trim() + if (value && !items.includes(value)) { + items = [...items, value] + newItem = '' + inputRef?.focus() + } + } + + function removeItem(idx: number) { + items = items.filter((_, i) => i !== idx) + } + + +
+
+

{title}

+ {#if tooltip} + {@render tooltip?.()} + {/if} +
+ +
+ {#each items as item, idx (item)} + + {item} + + + {/each} + + (newItem = e.currentTarget.value)} + onkeydown={(e) => e.key === 'Enter' && (addItem(), e.preventDefault())} + /> + +
+
diff --git a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte new file mode 100644 index 0000000000..e4b91f4df2 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte @@ -0,0 +1,877 @@ + + +
+ +
+
+ + Git Sync filter settings +
+
+ {#if !collapsed} + + {/if} + +
+
+ {#if !collapsed} + {#if editAsYaml} +
+ + {#if yamlError} +
{yamlError}
+ {/if} +
+ {:else} +
+
+
+ + Includes + Excludes + + + {#if filtersTab === 'includes'} + + {#snippet tooltip()} + + Only scripts, flows and apps with their path matching one of those filters will + be synced to the Git repositories below. The filters allow '*' and '**' + characters, with '*' matching any character allowed in paths until the next + slash (/) and '**' matching anything including slashes. By default everything in + folders will be synced. + + {/snippet} + + {:else if filtersTab === 'excludes'} + + {#snippet tooltip()} + + After the include / extra include checks, if a file matches any of these + patterns it will be skipped. + + {/snippet} + + {/if} +
+ +
+
+

Type filters

+ + On top of the filter path above, you can include only certain type of object to be + synced with the Git repository. By default everything is synced. + +
+
+
+ updateIncludeType('scripts', e.detail)} + options={{ right: capitalize('scripts') }} + /> +
+
+ updateIncludeType('flows', e.detail)} + options={{ right: capitalize('flows') }} + /> +
+
+ updateIncludeType('apps', e.detail)} + options={{ right: capitalize('apps') }} + /> +
+
+ updateIncludeType('folders', e.detail)} + options={{ right: capitalize('folders') }} + /> +
+
+ updateIncludeType('resourceTypes', e.detail)} + options={{ right: capitalize('resourceTypes') }} + /> +
+
+ updateIncludeType('resources', e.detail)} + options={{ right: capitalize('resources') }} + /> +
+
+ updateIncludeType('variables', e.detail)} + options={{ right: 'Variables' }} + /> + - + updateIncludeType('secrets', e.detail)} + options={{ left: 'Include secrets' }} + /> +
+
+ updateIncludeType('schedules', e.detail)} + options={{ right: capitalize('schedules') }} + /> +
+
+ updateIncludeType('users', e.detail)} + options={{ right: capitalize('users') }} + /> +
+
+ updateIncludeType('groups', e.detail)} + options={{ right: capitalize('groups') }} + /> +
+
+ updateIncludeType('triggers', e.detail)} + options={{ right: capitalize('triggers') }} + /> +
+
+ updateIncludeType('settings', e.detail)} + options={{ right: 'Workspace settings' }} + /> +
+
+ updateIncludeType('key', e.detail)} + options={{ right: 'Encryption key' }} + /> +
+
+
+
+
+
+
+ + + {isPullMode ? 'Pull settings from Git repository' : 'Push settings to Git repository'} + +
+
+ + {#if previewResult?.hasChanges && (previewResult?.isInitialSetup || (previewResult?.diff && Object.keys(previewResult.diff).length > 0))} + + {/if} +
+ {#if previewError} +
{previewError}
+ {/if} + {#if previewJobId} +
+ {#if previewJobStatus === 'running'} + + {:else if previewJobStatus === 'success'} + + {:else if previewJobStatus === 'failure'} + + {/if} + Preview job: + {previewJobId} +
+ {/if} + {#if previewResult} +
+
Preview of changes:
+ {#if previewResult.isInitialSetup} +
+ {previewResult.message || 'wmill.yaml will be created with repository settings'} +
+ {:else if previewResult.hasChanges && previewResult.diff && Object.keys(previewResult.diff).length > 0} +
+ {#each Object.entries(previewResult.diff) as [field, change]} +
+ {field}: +
+ {#if Array.isArray(change.from) || Array.isArray(change.to)} +
+
- {JSON.stringify(change.from)}
+
+ {JSON.stringify(change.to)}
+
+ {:else} + {JSON.stringify(change.from)} + + {JSON.stringify(change.to)} + {/if} +
+
+ {/each} +
+ {:else} +
No changes found! The file is up to date.
+ {/if} +
+ {/if} + {#if pushJobId} +
+ {#if pushJobStatus === 'running'} + + {:else if pushJobStatus === 'success'} + + {:else if pushJobStatus === 'failure'} + + {/if} + Push job: + {pushJobId} +
+ {/if} +
+ {/if} + {/if} +
diff --git a/frontend/src/lib/hub.ts b/frontend/src/lib/hub.ts index df9e9e3ffc..a261812c94 100644 --- a/frontend/src/lib/hub.ts +++ b/frontend/src/lib/hub.ts @@ -83,6 +83,7 @@ export function appToHubUrl(staticApp: any, hubBaseUrl: string): URL { type HubPaths = { gitSync: string gitSyncTest: string + gitInitRepo: string slackErrorHandler: string slackRecoveryHandler: string slackSuccessHandler: string diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 7e2846317d..0f1879acc1 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -13,6 +13,7 @@ "gitSyncTest_1": "hub/11499/git-repo-test-read-write-windmill", "gitSyncTest_2": "hub/11667/git-repo-test-read-write-windmill", "gitSyncTest": "hub/11669/git-repo-test-read-write-windmill", + "gitInitRepo": "hub/19740/git-sync%3A-init-repository-windmill", "slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack", "slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack", "slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack", diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte index 3eea4427e0..7aa051def6 100644 --- a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte @@ -156,8 +156,8 @@
{#key value} { - goto(`/apps/edit/${event.detail}`) + onSavedNewAppPath={(path) => { + goto(`/apps/edit/${path}`) }} {summary} app={value} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 840eec52c0..665d4b1d47 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -4,6 +4,7 @@ import { isCloudHosted } from '$lib/cloud' import CenteredPage from '$lib/components/CenteredPage.svelte' import { Alert, Button, Skeleton, Tab, Tabs } from '$lib/components/common' + import { deepEqual } from 'fast-equals' import DeployToSetting from '$lib/components/DeployToSetting.svelte' import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte' @@ -35,8 +36,9 @@ import { XCircle, RotateCw, + RotateCcw, CheckCircle2, - X, + Trash, Plus, Loader2, Save, @@ -60,35 +62,56 @@ import ConnectionSection from '$lib/components/ConnectionSection.svelte' import AISettings from '$lib/components/workspaceSettings/AISettings.svelte' import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte' + import InitGitRepoPopover from '$lib/components/InitGitRepoPopover.svelte' + import PullGitRepoPopover from '$lib/components/PullGitRepoPopover.svelte' + import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte' import { untrack } from 'svelte' - type GitSyncTypeMap = { - scripts: boolean - flows: boolean - apps: boolean - folders: boolean - resourceTypes: boolean - resources: boolean - variables: boolean - secrets: boolean - schedules: boolean - users: boolean - groups: boolean - triggers: boolean - } - type GitSyncType = + // Shared defaults for new Git-Sync repositories + const DEFAULT_INCLUDE_PATH = ['f/**'] as const; + const DEFAULT_EXCLUDE_PATH: string[] = []; + const DEFAULT_EXTRA_INCLUDE_PATH: string[] = []; + + type ObjectType = | 'script' | 'flow' | 'app' | 'folder' - | 'resourcetype' | 'resource' | 'variable' | 'secret' + | 'resourcetype' | 'schedule' | 'user' | 'group' | 'trigger' + | 'settings' + | 'key' + + type GitSyncSettings = { + repositories: GitSyncRepository[] + } + + type GitRepositorySettings = { + include_path: string[] + exclude_path: string[] + extra_include_path: string[] + include_type: ObjectType[] + } + + // Import the generated backend type + import type { GitRepositorySettings as BackendGitRepositorySettings } from '$lib/gen' + + // Frontend repository format extends backend with guaranteed settings and additional UI state + type GitSyncRepository = BackendGitRepositorySettings & { + settings: GitRepositorySettings // Required in frontend after transformation + legacyImported?: boolean + } + + // Workspace-level legacy filter arrays (populated if we imported legacy settings) + let legacyWorkspaceIncludePath = $state([]) + let legacyWorkspaceIncludeType = $state([]) + let slackInitialPath: string = $state('') let slackScriptPath: string = $state('') let teamsInitialPath: string = $state('') @@ -120,39 +143,17 @@ secondaryStorage: undefined }) - const DEFAULT_GIT_SYNC_SETTINGS = { - include_path: ['f/**'], - repositories: [], - include_type: { - scripts: true, - flows: true, - apps: true, - folders: true, - resourceTypes: false, - resources: false, - variables: false, - secrets: false, - schedules: false, - users: false, - groups: false, - triggers: false - } - } - let gitSyncSettings: { - include_path: string[] - repositories: { - exclude_types_override: GitSyncTypeMap - script_path: string - git_repo_resource_path: string - use_individual_branch: boolean - group_by_folder: boolean + let gitSyncSettings = $state({ + repositories: [] + }) + + let gitSyncTestJobs = $state< + { + jobId: string | undefined + status: 'running' | 'success' | 'failure' | undefined }[] - include_type: GitSyncTypeMap - } = $state(DEFAULT_GIT_SYNC_SETTINGS) - let gitSyncTestJobs: { - jobId: string | undefined - status: 'running' | 'success' | 'failure' | undefined - }[] = $state([]) + >([]) + let workspaceDefaultAppPath: string | undefined = $state(undefined) let workspaceEncryptionKey: string | undefined = $state(undefined) let editedWorkspaceEncryptionKey: string | undefined = $state(undefined) @@ -171,8 +172,246 @@ ) let usingOpenaiClientCredentialsOauth = $state(false) + let yamlText = $state('') + let initialGitSyncSettings = $state(undefined) + let loadedSettings = $state(false) + + // Reactive trigger to ensure UI updates when repository data changes + let repoReactivityTrigger = $state(0) + + + const latestGitSyncHubScript = hubPaths.gitSync + // Each repository may have been populated from workspace-level legacy settings. Track on the repo itself. + const anyLegacyImported = $derived(gitSyncSettings.repositories.some((r) => r.legacyImported)) + + // Track changes in repositories + const repoChanges = $derived( + (() => { + // Force reactivity check by accessing the trigger + repoReactivityTrigger; + + return gitSyncSettings.repositories.map((repo, idx) => { + const repoValid = isRepoValid(idx) + + // If there were no initial repos, treat each repo as changed only when valid + if (!initialGitSyncSettings || !initialGitSyncSettings.repositories || initialGitSyncSettings.repositories.length === 0) { + return repoValid + } + + const initial = initialGitSyncSettings.repositories.find( + initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path + ) + + // If no matching initial repo found, this is a new repo - changed only when valid + if (!initial) return repoValid + + // Handle array ordering for consistent comparison + const settings1 = { + include_path: [...(initial.settings?.include_path ?? [])].sort(), + exclude_path: [...(initial.settings?.exclude_path ?? [])].sort(), + extra_include_path: [...(initial.settings?.extra_include_path ?? [])].sort(), + include_type: [...(initial.settings?.include_type ?? [])].sort() + }; + + const settings2 = { + include_path: [...(repo.settings?.include_path ?? [])].sort(), + exclude_path: [...(repo.settings?.exclude_path ?? [])].sort(), + extra_include_path: [...(repo.settings?.extra_include_path ?? [])].sort(), + include_type: [...(repo.settings?.include_type ?? [])].sort() + }; + + // Compare all properties in a consistent way + const isChanged = !deepEqual( + { + settings: settings1, + use_individual_branch: initial.use_individual_branch, + group_by_folder: initial.group_by_folder, + script_path: initial.script_path, + git_repo_resource_path: initial.git_repo_resource_path, + exclude_types_override: [...(initial.exclude_types_override ?? [])].sort() + }, + { + settings: settings2, + use_individual_branch: repo.use_individual_branch, + group_by_folder: repo.group_by_folder, + script_path: repo.script_path, + git_repo_resource_path: repo.git_repo_resource_path, + exclude_types_override: [...(repo.exclude_types_override ?? [])].sort() + } + ) + + return isChanged && repoValid + }) + })() + ) + + const hasAnyChanges = $derived( + repoChanges.some(Boolean) || + anyLegacyImported || + // Check if the set of valid repos has changed (added/removed repos) + (() => { + if (!initialGitSyncSettings?.repositories) return gitSyncSettings.repositories.filter((_,i)=>isRepoValid(i)).length > 0 + + const initialValidPaths = new Set( + initialGitSyncSettings.repositories + .filter(r => !emptyString(r.git_repo_resource_path)) + .map(r => r.git_repo_resource_path) + ) + const currentValidPaths = new Set( + gitSyncSettings.repositories + .filter((_,i) => isRepoValid(i)) + .map(r => r.git_repo_resource_path) + ) + + // Check if sets are different (repos added or removed) + return initialValidPaths.size !== currentValidPaths.size || + [...initialValidPaths].some(path => !currentValidPaths.has(path)) || + [...currentValidPaths].some(path => !initialValidPaths.has(path)) + })() + ) + + // Helper that tells if a repo card is valid (resource selected and not duplicated) + function isRepoValid(idx: number): boolean { + const repo = gitSyncSettings.repositories[idx] + if (!repo) return false + if (emptyString(repo.git_repo_resource_path)) return false + return !isRepoDuplicate(idx) + } + + // Helper: true if repo shares its resource with an earlier repo + function isRepoDuplicate(idx: number): boolean { + const repo = gitSyncSettings.repositories[idx] + if (!repo || emptyString(repo.git_repo_resource_path)) return false + const firstIdx = gitSyncSettings.repositories.findIndex(r => r.git_repo_resource_path === repo.git_repo_resource_path) + return firstIdx !== idx + } + + function serializeRepo(repo: GitSyncRepository) { + const serialized: any = { + script_path: repo.script_path, + git_repo_resource_path: `$res:${repo.git_repo_resource_path.replace('$res:', '')}`, + use_individual_branch: repo.use_individual_branch, + group_by_folder: repo.group_by_folder, + settings: repo.settings + } + + // exclude_types_override should never be included for migrated repos (only legacy repos have it) + // Migration removes excluded types from include_type and drops exclude_types_override + + return serialized + } + + async function saveRepoSettings(idx: number): Promise { + const currentRepo = gitSyncSettings.repositories[idx] + if (!currentRepo || !isRepoValid(idx)) { + sendUserToast('Cannot save invalid repository (missing or duplicate resource)', true) + return + } + + // If we started with empty settings, we need to save all valid repositories + if (!initialGitSyncSettings || !initialGitSyncSettings.repositories || initialGitSyncSettings.repositories.length === 0) { + // For new repositories starting from empty, save all current repositories with valid resources + const validRepositories = gitSyncSettings.repositories + .filter((_,i)=>isRepoValid(i)) + .map(repo => serializeRepo(repo)) + + await WorkspaceService.editWorkspaceGitSyncConfig({ + workspace: $workspaceStore!, + requestBody: { + git_sync_settings: { repositories: validRepositories } + } + }) + + // Mark all repos migrated and reset legacy arrays + gitSyncSettings.repositories.forEach(r => r.legacyImported = false) + legacyWorkspaceIncludePath = [] + legacyWorkspaceIncludeType = [] + // Update initial settings to reflect what we just saved + initialGitSyncSettings = JSON.parse(JSON.stringify(gitSyncSettings)) + } else { + // Build repositories array: include all repos but serialize differently based on migration status + let repositories: any[] = [] + + // Process all repos that should be in the final payload + for (const repo of gitSyncSettings.repositories) { + if (repo === currentRepo) { + // This is the repo we're saving - migrate if legacy, otherwise serialize normally + if (currentRepo.legacyImported) { + // Migrate legacy repo: remove excluded types from include_type and drop exclude_types_override + const migratedRepo = { + ...currentRepo, + settings: { + ...currentRepo.settings, + include_type: currentRepo.settings.include_type.filter( + type => !currentRepo.exclude_types_override?.includes(type) + ) + }, + exclude_types_override: [], // Clear this for migrated repo + legacyImported: false + } + repositories.push(serializeRepo(migratedRepo)) + // Update the current repo in the UI to reflect migration + Object.assign(currentRepo, migratedRepo) + } else { + repositories.push(serializeRepo(currentRepo)) + } + } else if (repo.legacyImported) { + // This is a legacy repo - serialize without settings field at all + const legacyRepoData: any = { + script_path: repo.script_path, + git_repo_resource_path: `$res:${repo.git_repo_resource_path.replace('$res:', '')}`, + use_individual_branch: repo.use_individual_branch, + group_by_folder: repo.group_by_folder + } + // Include exclude_types_override if it has values + if (repo.exclude_types_override && repo.exclude_types_override.length > 0) { + legacyRepoData.exclude_types_override = repo.exclude_types_override + } + repositories.push(legacyRepoData) + } else { + // This is an already-migrated repo + repositories.push(serializeRepo(repo)) + } + } + + // Mark current repo as migrated + currentRepo.legacyImported = false; + + // Check if there are still legacy repos + const remainingLegacy = gitSyncSettings.repositories.some(r => r.legacyImported) + + const gitSyncPayload: any = { + git_sync_settings: { + repositories, + ...(remainingLegacy && { + include_path: legacyWorkspaceIncludePath, + include_type: legacyWorkspaceIncludeType + }) + } + } + + console.log('Sending payload:', JSON.stringify(gitSyncPayload, null, 2)) + + await WorkspaceService.editWorkspaceGitSyncConfig({ + workspace: $workspaceStore!, + requestBody: gitSyncPayload + }) + + // Update initial settings to reflect current state + initialGitSyncSettings = JSON.parse(JSON.stringify(gitSyncSettings)) + + // if no more legacy repos, clear workspace-level legacy arrays + if (!remainingLegacy) { + legacyWorkspaceIncludePath = [] + legacyWorkspaceIncludeType = [] + } + } + + sendUserToast('Repository settings updated') + } + async function editWorkspaceCommand(platform: 'slack' | 'teams'): Promise { if (platform === 'slack') { if (slackInitialPath === slackScriptPath) return @@ -188,8 +427,6 @@ platform === 'slack' ? WorkspaceService.editSlackCommand : WorkspaceService.editTeamsCommand if (scriptPath) { - console.log('editWorkspaceCommand', scriptPath) - console.log('itemKind', itemKind) await updateCommandScript({ workspace: $workspaceStore!, requestBody: { [commandScriptKey]: `${itemKind}/${scriptPath}` } @@ -230,99 +467,50 @@ } async function editWindmillGitSyncSettings(): Promise { + // Filter out repositories with empty resource paths before processing + const validRepos = gitSyncSettings.repositories.filter((_,i)=>isRepoValid(i)) + let alreadySeenResource: string[] = [] - let repositories = gitSyncSettings.repositories.map((elmt) => { - alreadySeenResource.push(elmt.git_repo_resource_path) - let exclude_types_override = gitSyncTypeMapToArray(elmt.exclude_types_override, true) - return { - exclude_types_override: exclude_types_override, - script_path: elmt.script_path, - git_repo_resource_path: `$res:${elmt.git_repo_resource_path.replace('$res:', '')}`, - use_individual_branch: elmt.use_individual_branch, - group_by_folder: elmt.group_by_folder - } - }) + let repositories = validRepos.map((repo) => { + alreadySeenResource.push(repo.git_repo_resource_path) - let include_path = gitSyncSettings.include_path.filter((elmt) => { - return !emptyString(elmt) + return serializeRepo(repo) }) - let include_type = gitSyncTypeMapToArray(gitSyncSettings.include_type, true) - if (alreadySeenResource.some((res, index) => alreadySeenResource.indexOf(res) !== index)) { sendUserToast('Same Git resource used more than once', true) return } - if (repositories.length > 0 || include_path.length > 1 || include_path[0] !== 'f/**') { + + if (repositories.length > 0) { await WorkspaceService.editWorkspaceGitSyncConfig({ workspace: $workspaceStore!, requestBody: { git_sync_settings: { - repositories: repositories, - include_path: include_path, - include_type: include_type + repositories } } }) + // Update initial settings to reflect what we just saved + initialGitSyncSettings = { + repositories: gitSyncSettings.repositories.filter((_,i)=>isRepoValid(i)) + } sendUserToast('Workspace Git sync settings updated') + gitSyncSettings.repositories.forEach(r => r.legacyImported = false); + legacyWorkspaceIncludePath = []; + legacyWorkspaceIncludeType = []; } else { await WorkspaceService.editWorkspaceGitSyncConfig({ workspace: $workspaceStore!, requestBody: { - git_sync_settings: undefined + git_sync_settings: { repositories: [] } } }) - sendUserToast('Workspace Git sync settings reset') + initialGitSyncSettings = { repositories: [] } + sendUserToast('Workspace Git sync settings updated (no repositories)') } } - function gitSyncTypeMapToArray(typesMap: GitSyncTypeMap, expectedValue: boolean): GitSyncType[] { - let result: GitSyncType[] = [] - if (typesMap.scripts == expectedValue) { - result.push('script') - } - if (typesMap.flows == expectedValue) { - result.push('flow') - } - if (typesMap.apps == expectedValue) { - result.push('app') - } - if (typesMap.folders == expectedValue) { - result.push('folder') - } - if (typesMap.resourceTypes == expectedValue) { - result.push('resourcetype') - } - if (typesMap.resources == expectedValue) { - result.push('resource') - } - if (typesMap.variables == expectedValue) { - result.push('variable') - } - if (typesMap.secrets == expectedValue) { - result.push('secret') - } - if (typesMap.schedules == expectedValue) { - result.push('schedule') - } - if (typesMap.users == expectedValue) { - result.push('user') - } - if (typesMap.groups == expectedValue) { - result.push('group') - } - if (typesMap.triggers == expectedValue) { - result.push('trigger') - } - return result - } - - function resetGitSyncRepositoryExclude(type: string) { - gitSyncSettings.repositories.forEach((elmt) => { - elmt.exclude_types_override[type] = false - }) - } - async function editWorkspaceDefaultApp(appPath: string | undefined): Promise { if (emptyString(appPath)) { await WorkspaceService.editWorkspaceDefaultApp({ @@ -377,7 +565,6 @@ ) } - let loadedSettings = $state(false) async function loadSettings(): Promise { const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) slack_team_name = settings.slack_name @@ -428,55 +615,52 @@ if (settings.git_sync !== undefined && settings.git_sync !== null) { gitSyncTestJobs = [] - gitSyncSettings = { - include_path: - (settings.git_sync.include_path?.length ?? 0 > 0) - ? (settings.git_sync.include_path ?? []) - : ['f/**'], - repositories: (settings.git_sync.repositories ?? []).map((settings) => { - gitSyncTestJobs.push({ - jobId: undefined, - status: undefined - }) - return { - git_repo_resource_path: settings.git_repo_resource_path.replace('$res:', ''), - script_path: settings.script_path, - use_individual_branch: settings.use_individual_branch ?? false, - group_by_folder: settings.group_by_folder ?? false, - exclude_types_override: { - scripts: (settings.exclude_types_override?.indexOf('script') ?? -1) >= 0, - flows: (settings.exclude_types_override?.indexOf('flow') ?? -1) >= 0, - apps: (settings.exclude_types_override?.indexOf('app') ?? -1) >= 0, - resourceTypes: (settings.exclude_types_override?.indexOf('resourcetype') ?? -1) >= 0, - resources: (settings.exclude_types_override?.indexOf('resource') ?? -1) >= 0, - variables: (settings.exclude_types_override?.indexOf('variable') ?? -1) >= 0, - secrets: (settings.exclude_types_override?.indexOf('secret') ?? -1) >= 0, - schedules: (settings.exclude_types_override?.indexOf('schedule') ?? -1) >= 0, - folders: (settings.exclude_types_override?.indexOf('folder') ?? -1) >= 0, - users: (settings.exclude_types_override?.indexOf('user') ?? -1) >= 0, - groups: (settings.exclude_types_override?.indexOf('group') ?? -1) >= 0, - triggers: (settings.exclude_types_override?.indexOf('trigger') ?? -1) >= 0 - } - } - }), - include_type: { - scripts: (settings.git_sync.include_type?.indexOf('script') ?? -1) >= 0, - flows: (settings.git_sync.include_type?.indexOf('flow') ?? -1) >= 0, - apps: (settings.git_sync.include_type?.indexOf('app') ?? -1) >= 0, - resourceTypes: (settings.git_sync.include_type?.indexOf('resourcetype') ?? -1) >= 0, - resources: (settings.git_sync.include_type?.indexOf('resource') ?? -1) >= 0, - variables: (settings.git_sync.include_type?.indexOf('variable') ?? -1) >= 0, - secrets: (settings.git_sync.include_type?.indexOf('secret') ?? -1) >= 0, - schedules: (settings.git_sync.include_type?.indexOf('schedule') ?? -1) >= 0, - folders: (settings.git_sync.include_type?.indexOf('folder') ?? -1) >= 0, - users: (settings.git_sync.include_type?.indexOf('user') ?? -1) >= 0, - groups: (settings.git_sync.include_type?.indexOf('group') ?? -1) >= 0, - triggers: (settings.git_sync.include_type?.indexOf('trigger') ?? -1) >= 0 - } - } + // Derive workspace-level legacy defaults (outside repositories) + const workspaceLegacyIncludePath: string[] = (settings.git_sync as any)?.include_path ?? []; + const workspaceLegacyIncludeTypeRaw: ObjectType[] = (settings.git_sync as any)?.include_type ?? []; + // Note: exclude_types_override is only at repository level, not workspace level + const workspaceLegacyIncludeType: ObjectType[] = [...workspaceLegacyIncludeTypeRaw]; + + legacyWorkspaceIncludePath = [...workspaceLegacyIncludePath]; + legacyWorkspaceIncludeType = [...workspaceLegacyIncludeType]; + + gitSyncSettings.repositories = (settings.git_sync.repositories ?? []).map((repo: BackendGitRepositorySettings) => { + gitSyncTestJobs.push({ + jobId: undefined, + status: undefined + }) + + // Now we have proper nested settings structure from the backend + const defaultTypes: ObjectType[] = workspaceLegacyIncludeType.length > 0 + ? [...workspaceLegacyIncludeType] + : (['script', 'flow', 'app', 'folder'] as ObjectType[]); + + // Check if this is a legacy repo (no nested settings object) + const isRepoLegacy = !repo.settings; + const repoExcludeTypesOverride = repo.exclude_types_override ?? []; + + const repoSettings: GitRepositorySettings = { + include_path: repo.settings?.include_path ?? [...workspaceLegacyIncludePath], + exclude_path: repo.settings?.exclude_path ?? [], + extra_include_path: repo.settings?.extra_include_path ?? [], + include_type: (repo.settings?.include_type ?? [...defaultTypes]) as ObjectType[] + }; + + return { + ...repo, + git_repo_resource_path: repo.git_repo_resource_path.replace('$res:', ''), + collapsed: repo.collapsed ?? false, + settings: repoSettings, + exclude_types_override: repoExcludeTypesOverride, + legacyImported: isRepoLegacy && (legacyWorkspaceIncludePath.length > 0 || legacyWorkspaceIncludeType.length > 0) + } satisfies GitSyncRepository + }) + // Store initial settings + initialGitSyncSettings = JSON.parse(JSON.stringify(gitSyncSettings)) } else { - gitSyncSettings = DEFAULT_GIT_SYNC_SETTINGS + gitSyncSettings.repositories = [] gitSyncTestJobs = [] + initialGitSyncSettings = undefined } if (settings.deploy_ui != undefined && settings.deploy_ui != null) { deployUiSettings = { @@ -556,15 +740,16 @@ let jobId = await JobService.runScriptByPath({ workspace: $workspaceStore!, path: hubPaths.gitSyncTest, - skipPreprocessor: true, requestBody: { repo_url_resource_path: gitSyncRepository.git_repo_resource_path.replace('$res:', '') - } + }, + skipPreprocessor: true }) gitSyncTestJobs[settingsIdx] = { jobId: jobId, status: 'running' } + gitSyncTestJobs = [...gitSyncTestJobs] tryEvery({ tryCode: async () => { const testResult = await JobService.getCompletedJob({ @@ -572,6 +757,7 @@ id: jobId }) gitSyncTestJobs[settingsIdx].status = testResult.success ? 'success' : 'failure' + gitSyncTestJobs = [...gitSyncTestJobs] }, timeoutCode: async () => { try { @@ -584,6 +770,9 @@ }) } catch (err) { console.error(err) + } finally { + gitSyncTestJobs[settingsIdx].status = 'failure' + gitSyncTestJobs = [...gitSyncTestJobs] } }, interval: 500, @@ -1073,6 +1262,10 @@ scripts, flows, and apps to the repository on each deploy.
+ + Only new changes matching the filters will trigger a git sync. You still need to initialize + the repo to the desired state first. + {#if !$enterpriseLicense}
@@ -1085,15 +1278,13 @@ {#if gitSyncSettings != undefined}
Save all git sync settings {!$enterpriseLicense ? '(ee only)' : ''}
+
-
-
- {#if Array.isArray(gitSyncSettings?.include_path)} -

Path filters - Only scripts, flows and apps with their path matching one of those filters will be - synced to the Git repositories below. The filters allow '*'' and '**' characters, - with '*'' matching any character allowed in paths until the next slash (/) and - '**' matching anything including slashes. -
By default everything in folders will be synced. -

- {#each gitSyncSettings.include_path ?? [] as _, idx} -
- - -
- {/each} - {/if} -
- -
-
- - Only new changes matching the filters will trigger a git sync. You still need to - initalize the repo to the desired state first. - -
-
-

Type filters - On top of the filter path above, you can include only certain type of object to be - synced with the Git repository. -
By default everything is synced. -

-
- resetGitSyncRepositoryExclude('scripts')} - options={{ right: 'Scripts' }} - /> - resetGitSyncRepositoryExclude('flows')} - options={{ right: 'Flows' }} - /> - resetGitSyncRepositoryExclude('apps')} - options={{ right: 'Apps' }} - /> - resetGitSyncRepositoryExclude('folders')} - options={{ right: 'Folders' }} - /> - resetGitSyncRepositoryExclude('resources')} - options={{ right: 'Resources' }} - /> -
- { - resetGitSyncRepositoryExclude('variables') - resetGitSyncRepositoryExclude('secrets') - if (!ev.detail) { - gitSyncSettings.include_type.secrets = false - } - }} - options={{ right: 'Variables ' }} - /> - - - resetGitSyncRepositoryExclude('secrets')} - options={{ left: 'Include secrets' }} - /> -
- resetGitSyncRepositoryExclude('schedules')} - options={{ right: 'Schedules' }} - /> - resetGitSyncRepositoryExclude('resourcetypes')} - options={{ right: 'Resource Types' }} - /> - resetGitSyncRepositoryExclude('users')} - options={{ right: 'Users' }} - /> - resetGitSyncRepositoryExclude('groups')} - options={{ right: 'Groups' }} - /> - resetGitSyncRepositoryExclude('triggers')} - options={{ right: 'Triggers' }} - /> -
-
-
-

Repositories to sync - The changes will be deployed to all the repositories set below. -

{#if Array.isArray(gitSyncSettings.repositories)} - {#each gitSyncSettings.repositories as gitSyncRepository, idx} -
-
Repository #{idx + 1}
- -
-
- {#key gitSyncRepository} - - - {/key} -
- -
- {#if gitSyncSettings.repositories.filter((settings) => settings.git_repo_resource_path === gitSyncRepository.git_repo_resource_path).length > 1} - Using the same resource twice is not allowed. - {/if} - {#if gitSyncTestJobs[idx].status !== undefined} - {#if gitSyncTestJobs[idx].status === 'running'} - - {:else if gitSyncTestJobs[idx].status === 'success'} - - {:else} - - {/if} - Git sync resource checked via Windmill job - - {gitSyncTestJobs[idx].jobId} - WARNING: Only read permissions are verified. - {/if} -
- -
- {#if gitSyncSettings && gitSyncRepository} - {#if gitSyncRepository.script_path != latestGitSyncHubScript} - - The git sync version for this repository is not latest. Current: {gitSyncRepository.script_path}, latest: - {latestGitSyncHubScript} -
- -
-
- {/if} - - - - {/if} -
- -
- {#if gitSyncSettings && Object.keys(gitSyncSettings.include_type).some((k) => gitSyncSettings.include_type[k] === true)} -
Exclude specific types for this repository only
- {#if gitSyncSettings.include_type.scripts} - - {/if} - {#if gitSyncSettings.include_type.flows} - - {/if} - {#if gitSyncSettings.include_type.apps} - - {/if} - {#if gitSyncSettings.include_type.folders} - - {/if} - {#if gitSyncSettings.include_type.resources} - - {/if} - {#if gitSyncSettings.include_type.variables} -
- + +
+
+ Repository #{idx + 1} + + {repo.git_repo_resource_path} + +
+
+ {#if (repoChanges[idx] || repo.legacyImported) && isRepoValid(idx)} + + + {/if} + + +
+
+ {#if !repo.collapsed} +
+
+ {#key repo} +
Resource:
+ + {#if !emptyString(repo.git_repo_resource_path)} + + {/if} + {/key}
- {/if} - {#if gitSyncSettings.include_type.schedules} - - {/if} - {#if gitSyncSettings.include_type.resourceTypes} - - {/if} - {#if gitSyncSettings.include_type.triggers} - - {/if} + + {#if !emptyString(repo.git_repo_resource_path)} +
+ {#if isRepoDuplicate(idx)} + Using the same resource twice is not allowed. + {/if} + {#if gitSyncTestJobs[idx].status !== undefined} + {#if gitSyncTestJobs[idx].status === 'running'} + + {:else if gitSyncTestJobs[idx].status === 'success'} + + {:else} + + {/if} + Git sync resource checked via Windmill job + + {gitSyncTestJobs[idx].jobId} + WARNING: Only read permissions are verified. + {/if} +
+ + {#if repo.legacyImported} + + This repository was initialized from workspace-level legacy Git-Sync settings. Review the filters and press Save to migrate. + + {/if} +
+ {#if gitSyncSettings && repo} + {#if repo.script_path != latestGitSyncHubScript} + + The git sync version for this repository is not latest. Current: {repo.script_path}, latest: + {latestGitSyncHubScript} +
+ +
+
+ {/if} + { + yamlText = settings.yaml + // Force reactivity update + repoReactivityTrigger = repoReactivityTrigger + 1 + }} + /> +
+ + { + // Direct prop update - much simpler! + repo.settings.include_path = filters.include_path + repo.settings.exclude_path = filters.exclude_path + repo.settings.extra_include_path = filters.extra_include_path + repo.settings.include_type = filters.include_type as ObjectType[] + }} + /> +
+ + + + {/if} +
+ {:else} +
+ Select a git repository resource to configure sync settings. +
+ {/if} +
{/if}
{/each} @@ -1444,7 +1538,6 @@
- -
-
Git repository initial setup
- -
- Every time a script is deployed, only the updated script will be pushed to the remote - Git repository. - -
- - For the git repo to be representative of the entire workspace, it is recommended to set - it up using the Windmill CLI before turning this option on. - -

- - Not familiar with Windmill CLI? - Check out the docs - -

- - Run the following commands from the git repo folder to push the initial workspace - content to the remote: - -
- -
npm install -g windmill-cli
-wmill workspace add  {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
-wmill init
-# adjust wmill.yaml file configuraton as needed
-wmill sync pull
-git add -A
-git commit -m 'Initial commit'
-git push
-
-
{:else} {/if} diff --git a/lsp/Pipfile b/lsp/Pipfile index 3281fad6b3..5cb7938d52 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,8 +4,8 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.505.3" -wmill_pg = ">=1.505.3" +wmill = ">=1.507.0" +wmill_pg = ">=1.507.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 22fa614cd3..5a0a7a8c19 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.505.3 + version: 1.507.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index b775d31fae..ece41e2e64 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.505.3' + ModuleVersion = '1.507.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index f4304d6f2c..27478df074 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.505.3" +version = "1.507.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/python-client/wmill_pg/pyproject.toml b/python-client/wmill_pg/pyproject.toml index 8b729de33c..f24614da2e 100644 --- a/python-client/wmill_pg/pyproject.toml +++ b/python-client/wmill_pg/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill-pg" -version = "1.505.3" +version = "1.507.0" description = "An extension client for the wmill client library focused on pg" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 899842b873..18241268b6 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.505.3", + "version": "1.507.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index c51a0a4137..28372b1880 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.505.3", + "version": "1.507.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "devDependencies": { diff --git a/version.txt b/version.txt index 901470d385..3e9f5d6e13 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.505.3 +1.507.0