Merge branch 'main' into claude/issue-6173-20250713_231304

This commit is contained in:
Ruben Fiszel
2025-07-16 09:15:16 +00:00
98 changed files with 8786 additions and 1299 deletions
+4
View File
@@ -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
+30
View File
@@ -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)
@@ -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"
}
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
false,
true
true,
false
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
@@ -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"
}
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -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"
}
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -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"
}
@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "?column?",
"name": "bool",
"type_info": "Bool"
}
],
@@ -41,11 +41,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true
]
},
+38 -36
View File
@@ -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]]
+2 -2
View File
@@ -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 <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
17b17f57e4c777e7e2ffc0e5def317f017a1683d
3cccbe81494f7edd113dce2812278b00c91160c0
@@ -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;
@@ -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);
+1 -1
View File
@@ -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
+99 -27
View File
@@ -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
+161 -3
View File
@@ -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<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
authed: ApiAuthed,
Json(m_r): Json<MuteCriticalAlertRequest>,
) -> Result<String> {
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())
}
+36 -4
View File
@@ -3,9 +3,15 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct WorkspaceGitSyncSettings {
pub include_path: Vec<String>,
pub include_type: Vec<ObjectType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_path: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_type: Option<Vec<ObjectType>>,
pub repositories: Vec<GitRepositorySettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude_path: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_include_path: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Default)]
@@ -14,7 +20,7 @@ pub struct WorkspaceDeploymentUISettings {
pub include_type: Vec<ObjectType>,
}
#[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<Vec<ObjectType>>,
pub script_path: String,
pub git_repo_resource_path: String,
pub use_individual_branch: Option<bool>,
pub group_by_folder: Option<bool>,
pub exclude_types_override: Option<Vec<ObjectType>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub settings: Option<GitSyncSettings>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GitSyncSettings {
pub include_path: Vec<String>,
pub include_type: Vec<ObjectType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude_path: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_include_path: Option<Vec<String>>,
}
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! {
+7 -1
View File
@@ -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,
}
}
}
+18
View File
@@ -2310,7 +2310,17 @@ pub async fn pull(
query_o: Option<&(String, String)>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) -> windmill_common::error::Result<PulledJobResult> {
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: {})",
@@ -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<AtomicU16>,
job_completed_processor_is_done: Arc<AtomicBool>,
wake_up_notify: Arc<Notify>,
last_processing_duration: Arc<AtomicU16>,
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 => {
}
}
}
+4
View File
@@ -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;
+1 -1
View File
@@ -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<string> {
return await windmill.UserService.login({
+59
View File
@@ -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
```
+71
View File
@@ -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<SyncOptions> };
}
export interface Codebase {
@@ -59,9 +65,74 @@ export async function readConfigFile(): Promise<SyncOptions> {
}
}
// Default sync options - shared across the codebase to prevent duplication
export const DEFAULT_SYNC_OPTIONS: Readonly<Required<Pick<SyncOptions,
'defaultTs' | 'includes' | 'excludes' | 'codebases' | 'skipVariables' | 'skipResources' |
'skipResourceTypes' | 'skipSecrets' | 'includeSchedules' | 'includeTriggers' |
'skipScripts' | 'skipFlows' | 'skipApps' | 'skipFolders' |
'includeUsers' | 'includeGroups' | 'includeSettings' | 'includeKey'
>>> = {
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<T>(
opts: T
): Promise<T & SyncOptions> {
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;
}
+58 -5
View File
@@ -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<Workspace> {
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<string> {
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(
Generated
+252
View File
@@ -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",
+2 -1
View File
@@ -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
+7 -1
View File
@@ -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];
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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,
+348 -195
View File
@@ -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 <workspace:string>",
"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 <token:string>",
"Specify an API token. This will override any stored token."
)
.globalOption(
"--base-url <baseUrl:string>",
"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 <headers:string>",
"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 <workspace:string>",
"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 <token:string>",
"Specify an API token. This will override any stored token.",
)
.globalOption(
"--base-url <baseUrl:string>",
"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 <configDir:string>",
"Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.",
)
.env(
"HEADERS <headers:string>",
"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 <repo:string>",
"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;
+9 -4
View File
@@ -13,15 +13,20 @@ function hash_string(str: string): number {
return hash;
}
export async function getRootStore(): Promise<string> {
const store = (config_dir() ?? tmp_dir() ?? "/tmp/") + "/windmill/";
export async function getRootStore(configDirOverride?: string): Promise<string> {
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<string> {
export async function getStore(baseUrl: string, configDirOverride?: string): Promise<string> {
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;
}
+286 -27
View File
@@ -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<T extends GlobalOptions & SyncOptions & { repository?: string }>(
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<SyncOptions> {
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 <patterns:file[]>",
"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 <patterns:file[]>",
"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 <repo:string>",
"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 <patterns:file[]>",
"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>", "Number of changes to process in parallel")
.option(
"--repository <repo:string>",
"Specify repository path (e.g., u/user/repo) when multiple repositories exist"
)
// deno-lint-ignore no-explicit-any
.action(push as any);
File diff suppressed because it is too large Load Diff
+74
View File
@@ -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
+177
View File
@@ -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:");
});
});
@@ -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<void> {
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(', ')}`);
});
});
+185
View File
@@ -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");
});
});
+199
View File
@@ -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<void> {
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");
});
});
+273
View File
@@ -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/**");
});
});
-48
View File
@@ -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"
}
-16
View File
@@ -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."
}
-16
View File
@@ -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
}
+147
View File
@@ -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<void> {
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);
});
});
+39
View File
@@ -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<T>(callback: (testConfigDir: string) => Promise<T>): Promise<T> {
// 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<void> {
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]);
}
+153
View File
@@ -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"]);
});
});
+2 -2
View File
@@ -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";
}
+47
View File
@@ -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<T extends Repository>(
repositories: T[],
operation?: string
): Promise<T> {
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)!;
}
+94 -16
View File
@@ -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<Workspace[]> {
export async function allWorkspaces(configDirOverride?: string): Promise<Workspace[]> {
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<Workspace | undefined> {
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))
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.505.3",
"version": "1.507.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -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 != ''}
<h1 class="mb-4">{resourceType}</h1>
<div class="my-4 text-secondary"
>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)</div
>Create a resource backed by an OAuth connection, whose token is fetched from the external
services and refreshed automatically if needed before expiration.</div
>
<h4 class="mb-2">Description</h4>
<div class="text-sm mb-8">
<Markdown md={urlize(resourceTypeInfo?.description ?? '', 'md')} />
</div>
{#if supportsClientCredentials}
<div style="margin-bottom: 16px;">
<h3 class="mb-4">Authentication Method</h3>
<div style="display: flex; align-items: center; gap: 8px;">
<input
type="checkbox"
style="width: 16px; height: 16px; margin: 0;"
bind:checked={useClientCredentials}
/>
<span style="font-size: 14px; font-weight: 600;">Use Client Credentials Flow</span>
<Tooltip>
Server-to-server authentication without user interaction.
<br /><br />
Provide your own OAuth client credentials for this resource.
</Tooltip>
</div>
{#if useClientCredentials}
<div style="margin-top: 16px;">
<label style="display: block; margin-bottom: 8px;">
<span style="font-weight: 600;">Client ID</span>
<input
type="text"
bind:value={clientId}
placeholder="Enter OAuth client ID"
class="w-full p-2 border border-gray-300 rounded mt-1"
required
/>
</label>
<label style="display: block;">
<span style="font-weight: 600;">Client Secret</span>
<input
type="password"
bind:value={clientSecret}
placeholder="Enter OAuth client secret"
class="w-full p-2 border border-gray-300 rounded mt-1"
required
/>
</label>
</div>
{/if}
</div>
{/if}
<h3 class="mb-4 flex gap-4"
>Scopes <button
onclick={() => {
@@ -631,7 +768,11 @@
{/if}
{/if}
{:else if step == 3 && !manual && !express}
Finish connection in popup window
{#if useClientCredentials}
Connecting with client credentials...
{:else}
Finish connection in popup window
{/if}
{:else}
<Path
initialPath=""
+18 -78
View File
@@ -9,7 +9,7 @@
emptyString,
getSchemaFromProperties
} from '$lib/utils'
import { DollarSign, Pipette, Plus, X, Check, Loader2 } from 'lucide-svelte'
import { DollarSign, Plus, X, Check, Loader2 } from 'lucide-svelte'
import { createEventDispatcher, onDestroy, onMount, tick, untrack } from 'svelte'
import { fade } from 'svelte/transition'
import { Button, SecondsInput } from './common'
@@ -25,7 +25,6 @@
import ArgEnum from './ArgEnum.svelte'
import DateTimeInput from './DateTimeInput.svelte'
import DateInput from './DateInput.svelte'
import S3FilePicker from './S3FilePicker.svelte'
import CurrencyInput from './apps/components/inputs/currency/CurrencyInput.svelte'
import FileUpload from './common/fileUpload/FileUpload.svelte'
import autosize from '$lib/autosize'
@@ -42,6 +41,7 @@
import type { ComponentCustomCSS } from './apps/types'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
import S3ArgInput from './common/fileUpload/S3ArgInput.svelte'
interface Props {
label?: string
@@ -227,8 +227,6 @@
let ignoreValueUndefined = $state(false)
let error: string = $state('')
let s3FilePicker: S3FilePicker | undefined = $state()
let s3FileUploadRawMode: boolean = $state(false)
let isListJson = $state(false)
let hasIsListJsonChanged = $state(false)
@@ -499,16 +497,6 @@
})
</script>
<S3FilePicker
bind:this={s3FilePicker}
bind:selectedFileKey={value}
on:close={() => {
rawValue = JSON.stringify(value, null, 2)
editor?.setCode(rawValue)
}}
readOnlyMode={false}
/>
<!-- svelte-ignore a11y_autofocus -->
<div
class={twMerge(
@@ -631,6 +619,22 @@
<span>&nbsp; Not set</span>
{/if}
</div>
{:else if (inputCat == 'resource-object' && format && format.split('-').length > 1 && format
.replace('resource-', '')
.replace('_', '')
.toLowerCase() == 's3object') || (inputCat == 'list' && itemsType?.resourceType === 's3_object')}
<S3ArgInput
multiple={inputCat == 'list'}
bind:value
{defaultValue}
{setNewValueFromCode}
{workspace}
onFocus={() => dispatch('focus')}
onBlur={() => dispatch('blur')}
bind:editor
{appPath}
{computeS3ForceViewerPolicies}
/>
{:else if inputCat == 'list' && !isListJson}
<div class="w-full flex gap-4">
<div class="w-full">
@@ -851,70 +855,6 @@
}}
{showSchemaExplorer}
/>
{:else if inputCat == 'resource-object' && format && format.split('-').length > 1 && format
.replace('resource-', '')
.replace('_', '')
.toLowerCase() == 's3object'}
<div class="flex flex-col w-full gap-1">
<Toggle
class="flex justify-end"
bind:checked={s3FileUploadRawMode}
size="xs"
options={{ left: 'Raw S3 object input' }}
/>
{#if s3FileUploadRawMode}
{#await import('$lib/components/JsonEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:editor
on:focus={(e) => {
dispatch('focus')
}}
on:blur={(e) => {
dispatch('blur')
}}
code={JSON.stringify(value ?? defaultValue ?? { s3: '' }, null, 2)}
on:changeValue={(e) => {
setNewValueFromCode(e.detail)
}}
/>
{/await}
{:else}
<FileUpload
{appPath}
computeForceViewerPolicies={computeS3ForceViewerPolicies}
{workspace}
allowMultiple={false}
randomFileKey={true}
on:addition={(evt) => {
value = {
s3: evt.detail?.path ?? '',
filename: evt.detail?.filename ?? ''
}
}}
on:deletion={(evt) => {
value = {
s3: ''
}
}}
defaultValue={defaultValue?.s3}
initialValue={value}
/>
{/if}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(value)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
</div>
{:else if inputCat == 'object' || inputCat == 'resource-object' || isListJson}
{#if oneOf && oneOf.length >= 2}
<div class="flex flex-col gap-2 w-full">
@@ -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 @@
<span class="text-primary font-semibold text-sm">Client Secret</span>
<input type="text" placeholder="Client Secret" bind:value={oauths[k]['secret']} />
</label>
{#if k === 'visma' || !windmillBuiltins.includes(k)}
<div style="margin-bottom: 8px;">
<div style="display: flex; align-items: center; gap: 8px;">
<input
type="checkbox"
style="width: 16px; height: 16px; margin: 0;"
checked={oauths?.[k]?.['grant_types']?.includes('client_credentials') ??
false}
onchange={(e) => {
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'
)
}
}
}}
/>
<span style="font-size: 14px; font-weight: 600;"
>Support Client Credentials Flow</span
>
<Tooltip>
Enables server-to-server authentication without user interaction. Use for
automated scripts and background jobs.
<br /><br />
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).
</Tooltip>
</div>
</div>
{/if}
{#if k === 'azure_oauth'}
<AzureOauthSettings bind:connect_config={oauths[k]['connect_config']} />
{: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 = ''
}}
+12 -8
View File
@@ -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<Record<string, GraphModuleState>> = $derived(
flowPreviewButtons?.getLocalModuleStates() ?? writable({})
flowPreviewContent?.getLocalModuleStates() ?? writable({})
)
const localDurationStatuses: Writable<Record<string, DurationStatus>> = $derived(
flowPreviewButtons?.getLocalDurationStatuses() ?? writable({})
flowPreviewContent?.getLocalDurationStatuses() ?? writable({})
)
const suspendStatus: Writable<Record<string, { job: Job; nb: number }>> = $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())
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -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}
<div class="text-red-400 mt-20">Missing flow modules</div>
@@ -833,7 +837,7 @@
}}
onTestFlow={flowPreviewButtons?.runPreview}
{job}
isOwner={flowPreviewButtons?.getIsOwner()}
isOwner={flowPreviewContent?.getIsOwner()}
{localDurationStatuses}
{suspendStatus}
onOpenDetails={flowPreviewButtons?.openPreview}
@@ -0,0 +1,65 @@
<script lang="ts">
type DiffResult = {
added: string[]
deleted: string[]
modified: string[]
repoWmillYaml?: string
yamlModified?: boolean
}
let { previewResult } = $props<{
previewResult: DiffResult | undefined
}>()
</script>
<div class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary">
<div class="font-semibold text-[11px] mb-1 text-tertiary">Preview of changes:</div>
{#if !previewResult?.added?.length && !previewResult?.deleted?.length && !previewResult?.modified?.length && !previewResult?.yamlModified}
<div class="mt-2 text-tertiary">No changes found! The workspace is up to date.</div>
{:else}
{#if previewResult?.yamlModified}
<div class="mt-2">
<div class="text-yellow-600">Modified:</div>
<ul class="list-disc list-inside">
<li>wmill.yaml (Git sync settings)</li>
</ul>
</div>
{/if}
{#if previewResult?.added?.length}
<div class="mt-2">
<div class="text-green-600">Added:</div>
<ul class="list-disc list-inside">
{#each previewResult.added as file}
<li>
{file}{!file.includes('.') ? ' (dir)' : ''}
</li>
{/each}
</ul>
</div>
{/if}
{#if previewResult?.deleted?.length}
<div class="mt-2">
<div class="text-red-600">Deleted:</div>
<ul class="list-disc list-inside">
{#each previewResult.deleted as file}
<li>
{file}{!file.includes('.') ? ' (dir)' : ''}
</li>
{/each}
</ul>
</div>
{/if}
{#if previewResult?.modified?.length}
<div class="mt-2">
<div class="text-yellow-600">Modified:</div>
<ul class="list-disc list-inside">
{#each previewResult.modified as file}
<li>
{file}{!file.includes('.') ? ' (dir)' : ''}
</li>
{/each}
</ul>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,454 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { Alert } from '$lib/components/common'
import {
Loader2,
Eye,
Save,
CheckCircle2,
XCircle,
UploadCloud,
AlertTriangle,
Terminal,
ChevronDown,
ChevronUp
} from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { JobService } from '$lib/gen'
import { tryEvery } from '$lib/utils'
// Types for git sync result
interface GitSyncChange {
type: 'added' | 'deleted' | 'modified'
path: string
}
interface GitSyncResult {
changes: GitSyncChange[]
}
import GitDiffPreview from './GitDiffPreview.svelte'
import { page } from '$app/stores'
let { gitRepoResourcePath, branchName, uiState } = $props<{
gitRepoResourcePath: string
branchName?: string
uiState: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: string[]
}
}>()
let _branchName = $state(branchName ?? '')
let previewResult = $state<
| {
added: string[]
deleted: string[]
modified: string[]
}
| undefined
>(undefined)
let isPreviewLoading = $state(false)
let isInitializing = $state(false)
let initResult = $state<{ success: boolean; message: string | undefined } | null>(null)
let initGitRepoPopover = $state<{ open: () => void; close: () => void } | null>(null)
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let pushJobId = $state<string | null>(null)
let pushJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isCliInfoExpanded = $state(false)
async function previewChanges() {
console.log('Previewing changes for repo:', gitRepoResourcePath)
isPreviewLoading = true
previewJobId = null
previewJobStatus = undefined
try {
const workspace = $workspaceStore
if (!workspace) {
previewResult = undefined
isPreviewLoading = false
return
}
// Pass UI state directly as JSON to CLI
const payloadObj = {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
branch_to_push: _branchName,
dry_run: true,
settings_json: JSON.stringify(uiState)
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payloadObj,
skipPreprocessor: true
})
previewJobId = jobId
previewJobStatus = 'running'
// Wait for job completion (polling)
let jobSuccess = false
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({
workspace,
id: jobId
})
jobSuccess = !!testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: {
reason: 'Preview job timed out after 15s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 15000
})
if (jobSuccess) {
const result = await JobService.getCompletedJobResult({
workspace,
id: jobId
})
console.log('Preview result:', result)
// Convert new CLI format to expected format
const added: string[] = []
const deleted: string[] = []
const modified: string[] = []
if (
result &&
(result as GitSyncResult).changes &&
Array.isArray((result as GitSyncResult).changes)
) {
for (const change of (result as GitSyncResult).changes) {
if (change.type === 'added') {
added.push(change.path)
} else if (change.type === 'deleted') {
deleted.push(change.path)
} else if (change.type === 'modified') {
modified.push(change.path)
}
}
}
previewResult = { added, deleted, modified }
previewJobStatus = 'success'
} else {
previewResult = undefined
previewJobStatus = 'failure'
}
} catch (error) {
console.error('Failed to preview changes:', error)
previewResult = undefined
previewJobStatus = 'failure'
} finally {
isPreviewLoading = false
}
}
async function initializeRepo() {
const workspace = $workspaceStore
if (!workspace) return
console.log('Initializing repo:', gitRepoResourcePath, 'in workspace:', workspace)
isInitializing = true
initResult = null
pushJobId = null
pushJobStatus = undefined
try {
// Pass UI state directly as JSON to CLI
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
branch_to_push: _branchName,
settings_json: JSON.stringify(uiState)
},
skipPreprocessor: true
})
pushJobId = jobId
pushJobStatus = 'running'
let jobSuccess = false
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({
workspace,
id: jobId
})
jobSuccess = !!testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: {
reason: 'Push job timed out after 5s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 10000
})
pushJobStatus = jobSuccess ? 'success' : 'failure'
initResult = {
success: jobSuccess,
message: jobSuccess ? undefined : 'Failed to initialize repository.'
}
// Reset popover state after successful push
if (jobSuccess) {
setTimeout(() => {
previewResult = undefined
pushJobId = null
pushJobStatus = undefined
initResult = null
initGitRepoPopover?.close()
}, 1500) // Small delay to show success state
}
} catch (error) {
console.error('Failed to initialize repo:', error)
pushJobStatus = 'failure'
initResult = { success: false, message: 'Failed to initialize repository.' }
} finally {
isInitializing = false
}
}
</script>
<Popover
bind:this={initGitRepoPopover}
floatingConfig={{
placement: 'top-start',
strategy: 'fixed',
flip: false,
shift: true
}}
contentClasses="p-4 w-1/3"
>
<svelte:fragment slot="trigger">
<Button
color="dark"
size="sm"
nonCaptureEvent
onclick={initGitRepoPopover?.open}
startIcon={{ icon: UploadCloud }}
>
Push workspace to Git repo
</Button>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-semibold">Push workspace to Git repository</h3>
<div class="prose max-w-none text-2xs text-tertiary">
This action will push all workspace objects that match your current filter settings to the
selected branch in your Git repository. <span
class="text-orange-600 flex items-center gap-1"
><AlertTriangle size={14} /> Any existing content in the branch will be replaced with the
filtered workspace content.</span
>
<!-- Collapsible CLI Info Section -->
<div class="mt-2 border rounded-md">
<button
class="w-full flex items-center justify-between p-1.5 bg-surface-secondary hover:bg-surface-hover"
onclick={() => (isCliInfoExpanded = !isCliInfoExpanded)}
>
<span class="font-medium flex items-center gap-2">
<Terminal size={14} />
Windmill CLI to pull from Windmill and push to git
</span>
{#if isCliInfoExpanded}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if isCliInfoExpanded}
<div class="p-1 bg-surface-tertiary">
<div class="text-2xs mb-2">
Not familiar with Windmill CLI? <a
href="https://www.windmill.dev/docs/advanced/cli/sync"
class="text-blue-500 hover:text-blue-600 underline"
target="_blank"
rel="noopener noreferrer">Check out the docs</a
>
</div>
<div class="font-mono text-2xs">
<pre class="overflow-auto max-h-60"
><code
>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</code
></pre
>
</div>
</div>
{/if}
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="branch-name" class="text-sm font-medium">Push to new branch (optional)</label>
<div class="prose max-w-none text-2xs text-tertiary">
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.
</div>
<div class="flex flex-col w-1/4">
<input
id="branch-name"
type="text"
bind:value={_branchName}
class="border rounded px-2 py-1"
/>
</div>
</div>
{#if previewResult}
<GitDiffPreview {previewResult} />
{/if}
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}
>
{previewJobId}
</a>
</div>
{/if}
{#if pushJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if pushJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if pushJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if pushJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Push job:
<a
target="_blank"
class="underline"
href={`/run/${pushJobId}?workspace=${$workspaceStore}`}
>
{pushJobId}
</a>
</div>
{/if}
<!-- Action row: Cancel on left, Preview/Confirm on right -->
<div class="flex justify-between items-center mt-4">
<Button
color="light"
size="xs"
on:click={() => {
previewResult = undefined
previewJobId = null
previewJobStatus = undefined
pushJobId = null
pushJobStatus = undefined
initResult = null
close()
}}
disabled={isPreviewLoading || isInitializing}
>
Cancel
</Button>
<div class="flex gap-2">
{#if !previewResult}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isInitializing}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
Preview
</Button>
{:else}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isInitializing}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
title="Preview changes again"
>
Preview
</Button>
{#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length}
<Button
color="red"
size="xs"
on:click={initializeRepo}
disabled={isPreviewLoading || isInitializing}
startIcon={{ icon: Save }}
title="Initialize Git Repo"
>
Push
</Button>
{/if}
{/if}
</div>
</div>
{#if initResult?.message}
<div class="mt-2">
<Alert
type={initResult.success ? 'success' : 'error'}
title={initResult.success ? 'Success' : 'Error'}
size="xs"
>
{initResult.message}
</Alert>
</div>
{/if}
</div>
</svelte:fragment>
</Popover>
@@ -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</Button
loading={sendingStats}
size="xs"
>
Send usage
</Button>
{/if}
{:else if category == 'Auth/OAuth/SAML'}
<AuthSettings
@@ -1,40 +1,37 @@
<script lang="ts">
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>('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</Button
>
@@ -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}
<div class="flex gap-1 items-center">
{#if valueSelect && expressOAuthSetup}
{#if value && expressOAuthSetup}
<Button
{disabled}
color="light"
@@ -193,15 +184,13 @@
btnClasses="w-8 px-0.5 py-1.5"
iconOnly
on:click={async () => {
if (valueSelect && valueSelect.label) {
value = undefined
if (value) {
await ResourceService.deleteResource({
workspace: appViewerContext?.workspace ?? $workspaceStore,
path: valueSelect.label
path: value
})
await loadResources(resourceType)
value = undefined
valueSelect = undefined
await loadResources(resourceType)
}
}}
startIcon={{ icon: Link2Off }}
@@ -0,0 +1,418 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import {
Loader2,
Eye,
Save,
CheckCircle2,
XCircle,
DownloadCloud,
AlertTriangle,
Terminal,
ChevronDown,
ChevronUp
} from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { JobService } from '$lib/gen'
import { tryEvery } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import GitDiffPreview from './GitDiffPreview.svelte'
import { page } from '$app/stores'
// Types for git sync result
interface GitSyncChange {
type: 'added' | 'deleted' | 'modified'
path: string
}
interface GitSyncResult {
changes: GitSyncChange[]
}
let { gitRepoResourcePath, uiState, onFilterUpdate } = $props<{
gitRepoResourcePath: string
uiState: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: string[]
}
onFilterUpdate: (filters: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: string[]
}) => void
}>()
type PreviewResult = {
added: string[]
deleted: string[]
modified: string[]
}
let previewResult = $state<PreviewResult | undefined>(undefined)
let isPreviewLoading = $state(false)
let isPulling = $state(false)
let pullGitRepoPopover = $state<{ open: () => void; close: () => void } | null>(null)
let jobStatus = $state<{
id: string | null
status: 'running' | 'success' | 'failure' | undefined
error?: string
type: 'preview' | 'pull'
}>({
id: null,
status: undefined,
type: 'preview'
})
let isCliInfoExpanded = $state(false)
async function handleJobCompletion(jobId: string, workspace: string): Promise<boolean> {
let success = false
await tryEvery({
tryCode: async () => {
const result = await JobService.getCompletedJob({
workspace,
id: jobId
})
success = !!result.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: {
reason: 'Job timed out after 5s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 10000
})
return success
}
async function previewChanges() {
const workspace = $workspaceStore
if (!workspace) return
console.log('Previewing changes for repo:', gitRepoResourcePath)
isPreviewLoading = true
jobStatus = { id: null, status: undefined, type: 'preview' }
try {
// Always use the simplified JSON approach
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: true,
pull: true,
only_wmill_yaml: false,
settings_json: JSON.stringify(uiState)
},
skipPreprocessor: true
})
jobStatus = { id: jobId, status: 'running', type: 'preview' }
const success = await handleJobCompletion(jobId, workspace)
if (success) {
const rawResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
console.log('Preview result:', rawResult)
// Convert new CLI format to expected format
const added: string[] = []
const deleted: string[] = []
const modified: string[] = []
if (
rawResult &&
(rawResult as GitSyncResult).changes &&
Array.isArray((rawResult as GitSyncResult).changes)
) {
for (const change of (rawResult as GitSyncResult).changes) {
if (change.type === 'added') {
added.push(change.path)
} else if (change.type === 'deleted') {
deleted.push(change.path)
} else if (change.type === 'modified') {
modified.push(change.path)
}
}
}
// For full sync mode, just use the CLI results directly
// The CLI already handles wmill.yaml changes with --include-wmill-yaml flag
previewResult = { added, deleted, modified }
jobStatus.status = 'success'
} else {
previewResult = undefined
jobStatus.status = 'failure'
}
} catch (error) {
console.error('Failed to preview changes:', error)
previewResult = undefined
jobStatus = {
...jobStatus,
status: 'failure',
error: error instanceof Error ? error.message : String(error)
}
} finally {
isPreviewLoading = false
}
}
async function pullFromRepo() {
const workspace = $workspaceStore
if (!workspace) return
console.log('Pulling from repo:', gitRepoResourcePath)
isPulling = true
jobStatus = { id: null, status: undefined, type: 'pull' }
try {
// Use init git repo script with dry_run: false (actual pull operation)
// The script will read wmill.yaml directly from the cloned repo, no need to pass settings
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: false,
branch_to_push: '',
only_wmill_yaml: false,
pull: true,
settings_json: undefined // Let script use wmill.yaml from repo
},
skipPreprocessor: true
})
jobStatus = { id: jobId, status: 'running', type: 'pull' }
const success = await handleJobCompletion(jobId, workspace)
jobStatus.status = success ? 'success' : 'failure'
if (success) {
// Get the result which should contain the local git repo settings as JSON
const result = (await JobService.getCompletedJobResult({ workspace, id: jobId })) as any
console.log('Pull result:', result)
// Apply the settings from the sync operation result to the UI
if (result?.settings_json) {
// Directly update the UI state with the JSON result - no YAML conversion needed!
const settingsJson = result.settings_json as {
include_path: string[]
exclude_path?: string[]
extra_include_path?: string[]
include_type: string[]
}
onFilterUpdate({
include_path: settingsJson.include_path || ['f/**'],
exclude_path: settingsJson.exclude_path || [],
extra_include_path: settingsJson.extra_include_path || [],
include_type: settingsJson.include_type || ['script', 'flow', 'app', 'folder']
})
sendUserToast('Successfully pulled workspace content from repository')
// Reset popover state after successful pull
previewResult = undefined
jobStatus = { id: null, status: undefined, type: 'preview' }
pullGitRepoPopover?.close()
} else {
console.warn('No settings_json returned from pull operation')
sendUserToast('Pull completed but could not update filter settings', true)
}
}
} catch (error) {
console.error('Failed to pull from repo:', error)
jobStatus = {
...jobStatus,
status: 'failure',
error: error instanceof Error ? error.message : String(error)
}
} finally {
isPulling = false
}
}
</script>
<Popover
bind:this={pullGitRepoPopover}
floatingConfig={{
placement: 'top-start',
strategy: 'fixed',
flip: false,
shift: true
}}
contentClasses="p-4 w-1/3"
>
<svelte:fragment slot="trigger">
<Button
color="dark"
size="sm"
nonCaptureEvent
onclick={pullGitRepoPopover?.open}
startIcon={{ icon: DownloadCloud }}
>
Pull workspace from Git repo
</Button>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-semibold">Pull workspace from Git repository</h3>
<div class="prose max-w-none text-2xs text-tertiary">
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.
<span class="text-orange-600 flex items-center gap-1">
<AlertTriangle size={14} /> This will overwrite your current workspace content and Git sync
filter settings with the content from the Git repository.
</span>
<!-- Collapsible CLI Info Section -->
<div class="mt-2 border rounded-md">
<button
class="w-full flex items-center justify-between p-1.5 bg-surface-secondary hover:bg-surface-hover"
onclick={() => (isCliInfoExpanded = !isCliInfoExpanded)}
>
<span class="font-medium flex items-center gap-2">
<Terminal size={14} />
Windmill CLI to push local files to Windmill
</span>
{#if isCliInfoExpanded}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if isCliInfoExpanded}
<div class="p-1 bg-surface-tertiary">
<div class="text-2xs mb-2">
Not familiar with Windmill CLI? <a
href="https://www.windmill.dev/docs/advanced/cli/sync"
class="text-blue-500 hover:text-blue-600 underline"
target="_blank"
rel="noopener noreferrer">Check out the docs</a
>
</div>
<div class="font-mono text-2xs">
<pre class="overflow-auto max-h-60"
><code
>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</code
></pre
>
</div>
</div>
{/if}
</div>
</div>
</div>
{#if previewResult}
<GitDiffPreview {previewResult} />
{/if}
{#if jobStatus.id}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if jobStatus.status === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if jobStatus.status === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if jobStatus.status === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
{jobStatus.type === 'preview' ? 'Preview' : 'Pull'} job:
<a
target="_blank"
class="underline"
href={`/run/${jobStatus.id}?workspace=${$workspaceStore}`}
>
{jobStatus.id}
</a>
</div>
{#if jobStatus.error}
<div class="text-xs text-red-600">{jobStatus.error}</div>
{/if}
{/if}
<div class="flex justify-between items-center mt-4">
<Button
color="light"
size="xs"
on:click={() => {
previewResult = undefined
jobStatus = { id: null, status: undefined, type: 'preview' }
close()
}}
disabled={isPreviewLoading || isPulling}
>
Cancel
</Button>
<div class="flex gap-2">
{#if !previewResult}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isPulling}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
Preview
</Button>
{:else}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isPulling}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
title="Preview changes again"
>
Preview
</Button>
{#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length}
<Button
color="red"
size="xs"
on:click={pullFromRepo}
disabled={isPreviewLoading || isPulling}
startIcon={{
icon: isPulling ? Loader2 : Save,
classes: isPulling ? 'animate-spin' : ''
}}
>
{isPulling ? 'Pulling...' : 'Pull'}
</Button>
{/if}
{/if}
</div>
</div>
</div>
</svelte:fragment>
</Popover>
@@ -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}
>
@@ -171,7 +171,6 @@
outputs?.result.set({
latestButtonClicked: actionButton.id
})
menu?.close()
}}
>
<AppButton
@@ -185,6 +184,9 @@
componentInput={actionButton.componentInput}
noWFull={false}
isMenuItem={true}
onDone={() => {
menu?.close()
}}
/>
</div>
{/if}
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte'
import { getContext, onDestroy, untrack } from 'svelte'
import { initConfig, initOutput } from '../../editor/appUtils'
import type {
AppViewerContext,
@@ -33,7 +33,8 @@
render
}: Props = $props()
const { app, worldStore, componentControl } = getContext<AppViewerContext>('AppViewerContext')
const { app, worldStore, componentControl, isEditor, mode } =
getContext<AppViewerContext>('AppViewerContext')
let resolvedConfig = $state(
initConfig(components['userresourcecomponent'].initialData.configuration, configuration)
@@ -57,9 +58,34 @@
)
)
let value: string | undefined = $state(outputs.result.peak()?.replace('$res:', ''))
function getDefaultValue(): string | undefined {
if (resolvedConfig.defaultValue && typeof resolvedConfig.defaultValue === 'string') {
const nval = resolvedConfig.defaultValue as string
return nval.replace('$res:', '')
}
return undefined
}
value && assignValue(outputs.result.peak())
let value: string | undefined = $state(
outputs.result.peak()?.replace('$res:', '') ?? getDefaultValue()
)
$effect(() => {
value
untrack(() => assignValue(value))
})
let lastDefaultValue = $state(getDefaultValue())
$effect(() => {
// when in dnd mode, we react to the default value being changed for better UX
if (isEditor && $mode === 'dnd') {
const currentDefaultValue = getDefaultValue() // reactive
if (lastDefaultValue !== currentDefaultValue) {
value = currentDefaultValue
lastDefaultValue = currentDefaultValue
}
}
})
onDestroy(() => {
listInputs?.remove(id)
@@ -74,7 +100,7 @@
}
}
function assignValue(value: string) {
function assignValue(value: string | undefined) {
let nval
if (!value || value === '') {
nval = undefined
@@ -119,10 +145,7 @@
<LightweightResourcePicker
expressOAuthSetup={resolvedConfig.expressOauthSetup}
bind:this={resourcePicker}
{value}
on:change={(e) => {
assignValue(e.detail)
}}
bind:value
disabled={resolvedConfig.disabled}
resourceType={resolvedConfig.resourceType}
/>
@@ -1648,8 +1648,8 @@ export const components = {
backgroundColor: ['#FF6384', '#4BC0C0', '#FFCE56']
}
]
},
},
}
}
}
},
chartjscomponentv2: {
@@ -1687,9 +1687,8 @@ export const components = {
backgroundColor: ['#FF6384', '#4BC0C0', '#FFCE56']
}
]
},
},
}
}
}
},
barchartcomponent: {
@@ -1859,8 +1858,7 @@ This is a paragraph.
width: 2.5
}
}
},
}
},
configuration: {
layout: {
@@ -1893,7 +1891,7 @@ This is a paragraph.
width: 2.5
}
}
},
}
},
configuration: {
layout: {
@@ -1904,8 +1902,7 @@ This is a paragraph.
'Layout options for the plot. See https://plotly.com/javascript/reference/layout/ for more information'
}
}
},
}
},
timeseriescomponent: {
name: 'Timeseries',
@@ -2431,6 +2428,12 @@ This is a paragraph.
allowTypeChange: false,
value: []
} as StaticAppInput,
defaultValue: {
type: 'static',
fieldType: 'text',
value: undefined,
tooltip: 'Format: $res:path/to/resource'
},
placeholder: {
type: 'static',
fieldType: 'text',
@@ -2468,6 +2471,12 @@ This is a paragraph.
fieldType: 'text',
value: 'postgresql'
},
defaultValue: {
type: 'static',
fieldType: 'text',
value: undefined,
tooltip: 'Format: $res:path/to/resource'
},
expressOauthSetup: {
type: 'static',
fieldType: 'boolean',
@@ -7,7 +7,7 @@
import { workspaceStore } from '$lib/stores'
import { AppService, HelpersService } from '$lib/gen'
import { writable, type Writable } from 'svelte/store'
import { Ban, CheckCheck, FileWarning, Files, RefreshCcw, Trash } from 'lucide-svelte'
import { Ban, CheckCheck, FileWarning, Files, RefreshCcw, Trash, XIcon } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { createEventDispatcher, onDestroy } from 'svelte'
import { emptyString } from '$lib/utils'
@@ -94,16 +94,15 @@
)
init()
function transform(initialValue: { s3: string; filename?: string }) {
return {
name: initialValue?.filename ?? initialValue?.s3 ?? 'Unknown file',
path: initialValue?.s3,
fromBucket: true
}
}
function init() {
if (initialS3) {
function transform(initialValue: { s3: string; filename: string }) {
return {
name: initialValue.filename,
size: 0,
progress: 100,
path: initialValue?.s3
}
}
for (const s3 of initialS3) {
if (!$fileUploads.find((fileUpload) => fileUpload.path === s3)) {
let initialFileUploads = initialValue
@@ -117,15 +116,24 @@
}
}
export function addUpload(upload: { s3: string; filename?: string }) {
$fileUploads = [...$fileUploads, transform(upload)]
}
export function setUpload(upload: { s3: string; filename?: string }) {
$fileUploads = [transform(upload)]
}
type FileUploadData = {
name: string
size: number
progress: number
size?: number
progress?: number
cancelled?: boolean
errorMessage?: string
path?: string
file?: File
deleteToken?: string
fromBucket?: boolean
}
async function handleChange(files: File[] | undefined) {
@@ -394,8 +402,28 @@
})
</script>
{#snippet fileInput()}
<FileInput
{folderOnly}
{disabled}
accept={acceptedFileTypes?.join(',')}
multiple={allowMultiple}
returnFileNames
{iconSize}
on:change={({ detail }) => {
forceDisplayUploads = false
handleChange(detail)
}}
class={twMerge('w-full h-full', customClass, 'wm-file-input')}
style={customStyle}
defaultFile={defaultValue}
>
{containerText}{#if disabled}<br />(Disabled){/if}
</FileInput>
{/snippet}
<div class="w-full h-full p-0 flex flex-col">
{#if $fileUploads.length > 0 && !forceDisplayUploads}
{#if $fileUploads.length > 0}
<div class="border rounded-md flex flex-col gap-1 divide-y h-full w-full p-1">
<div class="flex h-full overflow-y-auto flex-col">
{#each $fileUploads as fileUpload}
@@ -468,7 +496,7 @@
Remove from list
</Button>
{/if}
{#if fileUpload.progress < 100 && !fileUpload.cancelled && !fileUpload.errorMessage}
{#if fileUpload.progress !== undefined && fileUpload.progress < 100 && !fileUpload.cancelled && !fileUpload.errorMessage}
<Button
size="xs2"
color="light"
@@ -506,100 +534,115 @@
>
Delete
</Button>
{:else if fileUpload.fromBucket}
<Button
size="xs2"
color="red"
variant="border"
on:click={() => {
$fileUploads = $fileUploads.filter(
(_fileUpload) => _fileUpload.name !== fileUpload.name
)
if (fileUpload.path) {
dispatch('deletion', { path: fileUpload.path })
}
}}
startIcon={{
icon: XIcon
}}
>
Remove
</Button>
{/if}
</div>
</div>
<FileProgressBar
progress={fileUpload.progress}
color={fileUpload.errorMessage
? '#ef4444'
: fileUpload.cancelled
? '#eab308'
: fileUpload.progress === 100
? '#22c55e'
: '#3b82f6'}
ended={fileUpload.cancelled || fileUpload.errorMessage !== undefined}
>
{#if fileUpload.errorMessage}
<span class="text-xs text-red-600">{fileUpload.errorMessage}</span>
{:else if fileUpload.cancelled}
<span class="text-xs text-yellow-600">Upload cancelled</span>
{/if}
</FileProgressBar>
{#if !fileUpload.fromBucket}
<FileProgressBar
progress={fileUpload.progress}
color={fileUpload.errorMessage
? '#ef4444'
: fileUpload.cancelled
? '#eab308'
: fileUpload.progress === 100
? '#22c55e'
: '#3b82f6'}
ended={fileUpload.cancelled || fileUpload.errorMessage !== undefined}
>
{#if fileUpload.errorMessage}
<span class="text-xs text-red-600">{fileUpload.errorMessage}</span>
{:else if fileUpload.cancelled}
<span class="text-xs text-yellow-600">Upload cancelled</span>
{/if}
</FileProgressBar>
{/if}
{#if !(fileUpload.cancelled || fileUpload.errorMessage !== undefined)}
<span class="text-xs text-gray-500 dark:text-gray-200">
{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}
</span>
{/if}
</div>
{/each}
</div>
{#if allowMultiple}
<div class="flex flex-row gap-1 items-center justify-end p-1">
{#if !$fileUploads.every((fileUpload) => fileUpload.progress === 100 || fileUpload.cancelled)}
<Button
size="xs2"
color="light"
on:click={() => {
$fileUploads = $fileUploads.map((fileUpload) => {
if (fileUpload.progress === 100 || fileUpload.cancelled) {
return fileUpload
}
fileUpload.cancelled = true
fileUpload.progress = 0
return fileUpload
})
}}
startIcon={{
icon: Ban
}}
>
Cancel all uploads
</Button>
{/if}
<div class="flex flex-row gap-1 items-center justify-end p-1">
{#if !forceDisplayUploads && (allowMultiple || folderOnly) && !$fileUploads.every((fileUpload) => fileUpload.progress === 100 || fileUpload.cancelled || fileUpload.fromBucket)}
<Button
size="xs2"
color="light"
on:click={() => {
forceDisplayUploads = true
$fileUploads = $fileUploads.map((fileUpload) => {
if (fileUpload.progress === 100 || fileUpload.cancelled) {
return fileUpload
}
fileUpload.cancelled = true
fileUpload.progress = 0
return fileUpload
})
}}
startIcon={{
icon: Files
icon: Ban
}}
disabled={$fileUploads.some(
(fileUpload) => fileUpload.progress !== 100 && !fileUpload.cancelled
)}
>
Upload more files
Cancel all uploads
</Button>
</div>
{/if}
{/if}
{#if allowMultiple}
{#if forceDisplayUploads}
{@render fileInput()}
{:else}
<Button
size="xs2"
color="light"
on:click={() => {
forceDisplayUploads = true
}}
startIcon={{
icon: Files
}}
disabled={$fileUploads.some(
(fileUpload) =>
fileUpload.progress !== 100 && !fileUpload.cancelled && !fileUpload.fromBucket
)}
>
Upload more files
</Button>
{/if}
{/if}
</div>
</div>
{:else}
<FileInput
{folderOnly}
{disabled}
accept={acceptedFileTypes?.join(',')}
multiple={allowMultiple}
returnFileNames
{iconSize}
on:change={({ detail }) => {
forceDisplayUploads = false
handleChange(detail)
}}
class={twMerge('w-full h-full', customClass, 'wm-file-input')}
style={customStyle}
defaultFile={defaultValue}
>
{containerText}{#if disabled}<br />(Disabled){/if}
</FileInput>
{@render fileInput()}
{/if}
{#if initialS3 && initialS3.length > 0 && $fileUploads.length == 0}
<div class="flex flex-row gap-1 items-center p-1">
<span class="text-sm">
File currently selected: {initialS3?.join(', ')}
File{initialS3.length > 1 ? 's' : ''} currently selected: {initialS3?.join(', ')}
</span>
</div>
{/if}
@@ -0,0 +1,142 @@
<script lang="ts">
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { Loader2, Pipette } from 'lucide-svelte'
import FileUpload from './FileUpload.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
import Button from '../button/Button.svelte'
let {
multiple,
value = $bindable(),
defaultValue,
setNewValueFromCode,
onFocus,
onBlur,
computeS3ForceViewerPolicies,
workspace,
editor = $bindable(),
appPath
}: {
multiple: boolean
value: any
defaultValue: any
setNewValueFromCode: (v: any) => void
onFocus: () => void
onBlur: () => void
computeS3ForceViewerPolicies:
| (() =>
| {
allowed_resources: string[]
allow_user_resources: boolean
allow_workspace_resource: boolean
file_key_regex: string
}
| undefined)
| undefined
workspace: string | undefined
editor: SimpleEditor | undefined
appPath: string | undefined
} = $props()
let s3FileUploadRawMode: boolean = $state(false)
let s3FilePicker: S3FilePicker | undefined = $state(undefined)
let fileUpload: FileUpload | undefined = $state(undefined)
</script>
<S3FilePicker
bind:this={s3FilePicker}
on:selectAndClose={(ev) => {
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}
/>
<div class="flex flex-col w-full gap-1">
<Toggle
class="flex justify-end"
bind:checked={s3FileUploadRawMode}
size="xs"
options={{ left: `Raw S3 object${multiple ? 's' : ''} input` }}
/>
{#if s3FileUploadRawMode}
{#await import('$lib/components/JsonEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:editor
on:focus={(e) => {
onFocus()
}}
on:blur={(e) => {
onBlur()
}}
code={JSON.stringify(value ?? defaultValue ?? (multiple ? [] : { s3: '' }), null, 2)}
on:changeValue={(e) => {
setNewValueFromCode(e.detail)
}}
/>
{/await}
{:else}
<FileUpload
bind:this={fileUpload}
{appPath}
computeForceViewerPolicies={computeS3ForceViewerPolicies}
{workspace}
allowMultiple={multiple}
randomFileKey={true}
on:addition={(evt) => {
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}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(value)
}}
startIcon={{ icon: Pipette }}
>
{multiple ? 'Add' : 'Choose'} an object from the catalog
</Button>
</div>
@@ -502,7 +502,6 @@
disabled={!can_write}
/>
{/if}
{s3FileUploadRawMode}
{#if s3FileUploadRawMode}
{#if can_write}
<JsonEditor
@@ -0,0 +1,66 @@
<script lang="ts">
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)
}
</script>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="font-semibold text-sm">{title}</h4>
{#if tooltip}
{@render tooltip?.()}
{/if}
</div>
<div class="flex flex-wrap gap-2 items-center mb-1">
{#each items as item, idx (item)}
<span class="flex items-center bg-gray-100 rounded-full px-3 py-1 text-xs text-gray-700">
{item}
<button
class="ml-2 text-gray-400 hover:text-red-500 focus:outline-none"
onclick={() => removeItem(idx)}
aria-label="Remove filter"
>
<X size={14} />
</button>
</span>
{/each}
<input
bind:this={inputRef}
class="border border-gray-300 rounded-full px-3 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-primary"
{placeholder}
value={newItem}
oninput={(e) => (newItem = e.currentTarget.value)}
onkeydown={(e) => e.key === 'Enter' && (addItem(), e.preventDefault())}
/>
<button
class="ml-1 text-primary hover:bg-primary/10 rounded-full p-1"
onclick={addItem}
aria-label="Add filter"
>
<Plus size={14} />
</button>
</div>
</div>
@@ -0,0 +1,877 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import { Filter, Save, Eye, Loader2, CheckCircle2, XCircle, Check } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import yaml from 'js-yaml'
import hubPaths from '$lib/hubPaths.json'
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Button } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
import FilterList from './FilterList.svelte'
import { Tabs, Tab } from '$lib/components/common'
type ObjectType =
| 'script'
| 'flow'
| 'app'
| 'folder'
| 'resource'
| 'variable'
| 'secret'
| 'resourcetype'
| 'schedule'
| 'user'
| 'group'
| 'trigger'
| 'settings'
| 'key'
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
settings: boolean
key: boolean
}
type PreviewResult = {
diff?: { [key: string]: { from: any; to: any } }
hasChanges?: boolean
isInitialSetup?: boolean
message?: string
local?: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}
backend?: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}
}
let {
git_repo_resource_path = $bindable(''),
include_path = $bindable(['f/**']),
include_type = $bindable(['script', 'flow', 'app', 'folder'] as ObjectType[]),
exclude_types_override = $bindable([] as ObjectType[]),
isLegacyRepo = false,
yamlText = $bindable(''),
onSettingsChange = (settings: { yaml: string }) => {},
excludes = $bindable([] as string[]),
extraIncludes = $bindable([] as string[])
} = $props()
// Component state
let collapsed = $state(false)
let editAsYaml = $state(false)
let yamlError = $state('')
let isPullMode = $state(false)
// Preview/Push state
let previewResult = $state<PreviewResult | null>(null)
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let pushJobId = $state<string | null>(null)
let pushJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isPreviewLoading = $state(false)
let isPushing = $state(false)
let previewError = $state('')
let previewSettingsSnapshot = $state<string | null>(null)
// Compute effective include types (include_type minus exclude_types_override for legacy repos only)
const effectiveIncludeTypes = $derived(
isLegacyRepo
? include_type.filter((type) => !exclude_types_override.includes(type))
: include_type
)
// Compute type toggles from effective include types
const typeToggles = $derived({
scripts: effectiveIncludeTypes.includes('script'),
flows: effectiveIncludeTypes.includes('flow'),
apps: effectiveIncludeTypes.includes('app'),
folders: effectiveIncludeTypes.includes('folder'),
resourceTypes: effectiveIncludeTypes.includes('resourcetype'),
resources: effectiveIncludeTypes.includes('resource'),
variables: effectiveIncludeTypes.includes('variable'),
secrets: effectiveIncludeTypes.includes('secret'),
schedules: effectiveIncludeTypes.includes('schedule'),
users: effectiveIncludeTypes.includes('user'),
groups: effectiveIncludeTypes.includes('group'),
triggers: effectiveIncludeTypes.includes('trigger'),
settings: effectiveIncludeTypes.includes('settings'),
key: effectiveIncludeTypes.includes('key')
})
// Tab selection for filter kinds
let filtersTab = $state<'includes' | 'excludes'>('includes')
function updateIncludeType(key: keyof GitSyncTypeMap, value: boolean) {
const newTypes = new Set(include_type)
const typeMap: Record<keyof GitSyncTypeMap, ObjectType> = {
scripts: 'script',
flows: 'flow',
apps: 'app',
folders: 'folder',
resourceTypes: 'resourcetype',
resources: 'resource',
variables: 'variable',
secrets: 'secret',
schedules: 'schedule',
users: 'user',
groups: 'group',
triggers: 'trigger',
settings: 'settings',
key: 'key'
}
if (value) {
newTypes.add(typeMap[key])
} else {
newTypes.delete(typeMap[key])
if (key === 'variables') {
newTypes.delete('secret')
}
}
include_type = Array.from(newTypes)
}
function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
// Simple JSON-based UI state helper
function getUIState() {
return {
include_path,
exclude_path: excludes,
extra_include_path: extraIncludes,
include_type
}
}
// Apply settings from backend format (used by both local git repo and backend settings)
function fromBackendFormat(settings: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}) {
include_path = settings.include_path || []
excludes = settings.exclude_path || []
extraIncludes = settings.extra_include_path || []
include_type = settings.include_type || []
}
// Simplified YAML parsing for manual editing
function fromYaml(yamlStr: string) {
yamlError = ''
try {
const parsed = yaml.load(yamlStr)
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid YAML structure')
}
const obj: any = parsed
yamlText = yamlStr
// Extract includes - reset to default if not present
if (obj.includes && Array.isArray(obj.includes)) {
include_path = obj.includes.map((p: any) => {
if (typeof p !== 'string') {
throw new Error('includes must contain only strings')
}
// Handle quoted strings
if (/^['"].*['"]$/.test(p)) {
return p.slice(1, -1).replace(/''/g, "'")
}
return p
})
} else {
// Reset to default if includes is not present
include_path = ['f/**']
}
// Build the type set based on the YAML flags
const newTypes = new Set<ObjectType>()
// Always include core types (these are fundamental and not controlled by flags)
newTypes.add('script')
newTypes.add('flow')
newTypes.add('app')
newTypes.add('folder')
// Handle skip flags (if skipX is false or undefined, include the type)
if (obj.skipResourceTypes !== true) newTypes.add('resourcetype')
if (obj.skipResources !== true) newTypes.add('resource')
if (obj.skipVariables !== true) newTypes.add('variable')
if (obj.skipSecrets !== true) newTypes.add('secret')
// Handle include flags (if includeX is true, include the type)
if (obj.includeSchedules === true) newTypes.add('schedule')
if (obj.includeTriggers === true) newTypes.add('trigger')
if (obj.includeUsers === true) newTypes.add('user')
if (obj.includeGroups === true) newTypes.add('group')
if (obj.includeSettings === true) newTypes.add('settings')
if (obj.includeKey === true) newTypes.add('key')
// Apply business rule: secrets can only be included if variables are included
// This matches the UI behavior where turning off variables also turns off secrets
if (!newTypes.has('variable')) {
newTypes.delete('secret')
}
include_type = Array.from(newTypes)
} catch (e) {
yamlError = e.message || 'Invalid YAML'
console.error('Error parsing YAML:', e)
}
}
// Simple YAML generation for manual editing mode
function generateYamlFromUI() {
try {
const validIncludePath = include_path
const validExcludePath = excludes
const validExtraInclude = extraIncludes
// Basic YAML structure - let the CLI handle the proper normalization
let config: any = {
includes: validIncludePath,
excludes: validExcludePath,
extraIncludes: validExtraInclude,
codebases: []
}
// Let the CLI handle the optimization of skip/include flags
// Just convert the UI state directly
if (!include_type.includes('variable')) config.skipVariables = true
if (!include_type.includes('resource')) config.skipResources = true
if (!include_type.includes('secret')) config.skipSecrets = true
if (!include_type.includes('resourcetype')) config.skipResourceTypes = true
if (include_type.includes('schedule')) config.includeSchedules = true
if (include_type.includes('trigger')) config.includeTriggers = true
if (include_type.includes('user')) config.includeUsers = true
if (include_type.includes('group')) config.includeGroups = true
if (include_type.includes('settings')) config.includeSettings = true
if (include_type.includes('key')) config.includeKey = true
return yaml.dump(config, {
indent: 2,
lineWidth: -1,
quotingType: '"',
forceQuotes: false,
noRefs: true
})
} catch (e) {
console.warn('Failed to generate YAML:', e)
yamlError = e.message || 'Failed to generate YAML'
return `includes:
- f/**
excludes: []
extraIncludes: []
codebases: []`
}
}
function switchToYaml() {
yamlText = generateYamlFromUI()
yamlError = ''
editAsYaml = true
}
function switchToUI() {
fromYaml(yamlText)
if (!yamlError) {
editAsYaml = false
}
}
// Simplified preview function - always uses JSON approach
async function previewFiltersToGitRepo() {
isPreviewLoading = true
previewError = ''
previewResult = null
previewJobId = null
previewJobStatus = undefined
// Take a snapshot of current settings
previewSettingsSnapshot = JSON.stringify({
include_path,
excludes,
extraIncludes,
include_type
})
try {
const workspace = $workspaceStore
if (!workspace) return
// Always pass UI state as JSON - the backend now handles this uniformly
const payloadObj = {
workspace_id: workspace,
repo_url_resource_path: git_repo_resource_path,
only_wmill_yaml: true,
dry_run: true,
pull: isPullMode,
settings_json: JSON.stringify(getUIState())
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payloadObj,
skipPreprocessor: true
})
previewJobId = jobId
previewJobStatus = 'running'
let jobSuccess = false
let result: PreviewResult = {}
await (
await import('$lib/utils')
).tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
if (jobSuccess) {
const jobResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
result = jobResult as PreviewResult
}
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: 'Preview job timed out after 5s' }
})
} catch (err) {}
},
interval: 500,
timeout: 10000
})
previewJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
previewResult = result
} else {
previewError = 'Preview failed'
}
} catch (e) {
previewJobStatus = 'failure'
previewError = e?.message || 'Preview failed'
previewResult = null
} finally {
isPreviewLoading = false
}
}
// Simplified push function - always uses JSON approach
async function pushFiltersToGitRepo() {
if (isPullMode) {
// In pull mode, apply the local settings (from git repo) to UI
if (previewResult?.local) {
try {
fromBackendFormat(previewResult.local)
yamlText = generateYamlFromUI()
onSettingsChange({ yaml: yamlText })
sendUserToast('Changes applied - remember to save repository settings to persist changes')
// Clear the preview state after applying settings
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewError = ''
} catch (e) {
previewError = 'Failed to apply pulled settings: ' + e.message
}
}
return
}
// Push mode - send current UI state as JSON
isPushing = true
pushJobId = null
pushJobStatus = undefined
try {
const workspace = $workspaceStore
if (!workspace) return
const payloadObj = {
workspace_id: workspace,
repo_url_resource_path: git_repo_resource_path,
dry_run: false,
pull: isPullMode,
only_wmill_yaml: true,
settings_json: JSON.stringify(getUIState())
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payloadObj,
skipPreprocessor: true
})
pushJobId = jobId
pushJobStatus = 'running'
let jobSuccess = false
await (
await import('$lib/utils')
).tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: 'Push job timed out after 5s' }
})
} catch (err) {}
},
interval: 500,
timeout: 10000
})
pushJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
// Reset preview state after successful push
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewError = ''
}
} catch (e) {
pushJobStatus = 'failure'
} finally {
isPushing = false
}
}
// Simplified export function for backward compatibility
export function toYaml() {
return generateYamlFromUI()
}
export function setSettings(settings: { yaml: string }) {
yamlText = settings.yaml
fromYaml(settings.yaml)
}
$effect(() => {
// Reset preview state when switching modes
if (isPullMode !== undefined) {
previewResult = null
previewJobId = null
previewJobStatus = undefined
pushJobId = null
pushJobStatus = undefined
isPreviewLoading = false
isPushing = false
previewError = ''
}
})
// Reset preview state when settings change (making preview stale)
$effect(() => {
// Track all the settings that affect the preview
const currentSettings = JSON.stringify({
include_path,
excludes,
extraIncludes,
include_type
})
// If we have an existing preview result and settings have changed from snapshot, clear it
if (
previewResult !== null &&
previewSettingsSnapshot !== null &&
currentSettings !== previewSettingsSnapshot
) {
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewError = ''
previewSettingsSnapshot = null
}
})
</script>
<div class="rounded-lg shadow-sm border p-0 w-full">
<!-- Card Header -->
<div class="flex items-center justify-between min-h-10 px-4 py-1 border-b">
<div class="flex items-center gap-2">
<Filter size={18} class="text-primary" />
<span class="font-semibold text-sm">Git Sync filter settings</span>
</div>
<div class="flex items-center gap-2">
{#if !collapsed}
<button
class="text-xs px-2 py-1 rounded border border-gray-300 bg-surface-primary hover:bg-surface-secondary"
onclick={editAsYaml ? switchToUI : switchToYaml}
>
{editAsYaml ? 'Edit in UI' : 'Edit as YAML'}
</button>
{/if}
<button
class="text-gray-500 hover:text-primary focus:outline-none"
onclick={() => (collapsed = !collapsed)}
aria-label="Toggle collapse"
>
{#if collapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
{/if}
</button>
</div>
</div>
{#if !collapsed}
{#if editAsYaml}
<div class="px-4 py-4">
<textarea
class="w-full h-64 font-mono text-xs border rounded p-2 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary"
spellcheck="false"
bind:value={yamlText}
></textarea>
{#if yamlError}
<div class="text-xs text-red-600 mt-2">{yamlError}</div>
{/if}
</div>
{:else}
<div class="px-4 py-2">
<div class="grid grid-cols-1 md:grid-cols-2 md:gap-32">
<div class="flex flex-col gap-2">
<Tabs bind:selected={filtersTab}>
<Tab value="includes">Includes</Tab>
<Tab value="excludes">Excludes</Tab>
</Tabs>
{#if filtersTab === 'includes'}
<FilterList
title="Include path filters"
bind:items={include_path}
placeholder="Add filter (e.g. f/**)"
>
{#snippet tooltip()}
<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.
</Tooltip>
{/snippet}
</FilterList>
{:else if filtersTab === 'excludes'}
<FilterList
title="Exclude path filters"
bind:items={excludes}
placeholder="Add filter (e.g. f/**)"
>
{#snippet tooltip()}
<Tooltip>
After the include / extra include checks, if a file matches any of these
patterns it will be skipped.
</Tooltip>
{/snippet}
</FilterList>
{/if}
</div>
<!-- Type Filters Section (Right) -->
<div>
<div class="flex items-center gap-2 mb-3">
<h4 class="font-semibold text-sm">Type filters</h4>
<Tooltip>
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.
</Tooltip>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.scripts}
on:change={(e) => updateIncludeType('scripts', e.detail)}
options={{ right: capitalize('scripts') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.flows}
on:change={(e) => updateIncludeType('flows', e.detail)}
options={{ right: capitalize('flows') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.apps}
on:change={(e) => updateIncludeType('apps', e.detail)}
options={{ right: capitalize('apps') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.folders}
on:change={(e) => updateIncludeType('folders', e.detail)}
options={{ right: capitalize('folders') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.resourceTypes}
on:change={(e) => updateIncludeType('resourceTypes', e.detail)}
options={{ right: capitalize('resourceTypes') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.resources}
on:change={(e) => updateIncludeType('resources', e.detail)}
options={{ right: capitalize('resources') }}
/>
</div>
<div class="col-span-2 flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.variables}
on:change={(e) => updateIncludeType('variables', e.detail)}
options={{ right: 'Variables' }}
/>
<span class="text-gray-400">-</span>
<Toggle
size="xs"
disabled={!typeToggles.variables}
checked={typeToggles.secrets}
on:change={(e) => updateIncludeType('secrets', e.detail)}
options={{ left: 'Include secrets' }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.schedules}
on:change={(e) => updateIncludeType('schedules', e.detail)}
options={{ right: capitalize('schedules') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.users}
on:change={(e) => updateIncludeType('users', e.detail)}
options={{ right: capitalize('users') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.groups}
on:change={(e) => updateIncludeType('groups', e.detail)}
options={{ right: capitalize('groups') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.triggers}
on:change={(e) => updateIncludeType('triggers', e.detail)}
options={{ right: capitalize('triggers') }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.settings}
on:change={(e) => updateIncludeType('settings', e.detail)}
options={{ right: 'Workspace settings' }}
/>
</div>
<div class="flex items-center gap-2">
<Toggle
size="xs"
checked={typeToggles.key}
on:change={(e) => updateIncludeType('key', e.detail)}
options={{ right: 'Encryption key' }}
/>
</div>
</div>
</div>
</div>
</div>
<div class="mt-6 flex flex-col gap-2 p-2">
<div class="flex flex-col gap-2 mb-2">
<Toggle
size="sm"
bind:checked={isPullMode}
options={{
left: 'Push',
right: 'Pull'
}}
/>
<span class="text-xs text-tertiary">
{isPullMode ? 'Pull settings from Git repository' : 'Push settings to Git repository'}
</span>
</div>
<div class="flex gap-2 items-center">
<Button
size="sm"
on:click={previewFiltersToGitRepo}
disabled={isPreviewLoading || isPushing}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
{isPreviewLoading ? 'Previewing...' : 'Preview'}
</Button>
{#if previewResult?.hasChanges && (previewResult?.isInitialSetup || (previewResult?.diff && Object.keys(previewResult.diff).length > 0))}
<Button
size="sm"
on:click={pushFiltersToGitRepo}
disabled={isPushing || isPreviewLoading}
color={isPullMode ? 'dark' : 'red'}
startIcon={{
icon: isPushing ? Loader2 : isPullMode ? Check : Save,
classes: isPushing ? 'animate-spin' : ''
}}
>
{isPushing
? isPullMode
? 'Applying...'
: 'Pushing...'
: isPullMode
? 'Apply'
: 'Push Settings to Git'}
</Button>
{/if}
</div>
{#if previewError}
<div class="text-xs text-red-600 mt-2">{previewError}</div>
{/if}
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-tertiary mt-1">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}>{previewJobId}</a
>
</div>
{/if}
{#if previewResult}
<div
class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary mt-2"
>
<div class="font-semibold text-[11px] mb-1 text-tertiary">Preview of changes:</div>
{#if previewResult.isInitialSetup}
<div class="mt-2 text-green-600">
{previewResult.message || 'wmill.yaml will be created with repository settings'}
</div>
{:else if previewResult.hasChanges && previewResult.diff && Object.keys(previewResult.diff).length > 0}
<div class="mt-2 space-y-1">
{#each Object.entries(previewResult.diff) as [field, change]}
<div class="flex items-start gap-2 text-2xs">
<span class="font-mono text-tertiary min-w-0 flex-shrink-0">{field}:</span>
<div class="min-w-0 flex-1">
{#if Array.isArray(change.from) || Array.isArray(change.to)}
<div class="space-y-0.5">
<div class="text-red-600">- {JSON.stringify(change.from)}</div>
<div class="text-green-600">+ {JSON.stringify(change.to)}</div>
</div>
{:else}
<span class="text-red-600">{JSON.stringify(change.from)}</span>
<span class="text-tertiary"></span>
<span class="text-green-600">{JSON.stringify(change.to)}</span>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div class="mt-2 text-tertiary">No changes found! The file is up to date.</div>
{/if}
</div>
{/if}
{#if pushJobId}
<div class="flex items-center gap-2 text-xs text-tertiary mt-1">
{#if pushJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if pushJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if pushJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Push job:
<a
target="_blank"
class="underline"
href={`/run/${pushJobId}?workspace=${$workspaceStore}`}>{pushJobId}</a
>
</div>
{/if}
</div>
{/if}
{/if}
</div>
+1
View File
@@ -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
+1
View File
@@ -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",
@@ -156,8 +156,8 @@
<div class="h-screen">
{#key value}
<AppEditor
on:savedNewAppPath={(event) => {
goto(`/apps/edit/${event.detail}`)
onSavedNewAppPath={(path) => {
goto(`/apps/edit/${path}`)
}}
{summary}
app={value}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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 = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.505.3
version: 1.507.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.505.3'
ModuleVersion = '1.507.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -1 +1 @@
1.505.3
1.507.0