feat: git sync improvements (#6182)

* init checkpoint

* ui second pass...

* round 1 backend + saving settings + detecting changes...

* checkpoint

* fix openapi

* saving + correct wmill.yaml diff

* cli refactor

* cli and tests refactor done

* cli multi workspace support

* cli support skip core types to align with ui

* new test framework

* sqlx

* openapi spec

* frontend

* sync + settings changes

* some fixes

* some fixes

* security: Remove hardcoded EE license key, use environment variable only

- Remove hardcoded license key from containerized test backend
- Environment variable EE_LICENSE_KEY now required for EE features
- License key no longer stored in database during tests

* sqlx

* tests

* fixing tests

* fix tests

* checkpoint

* checkpoint

* cli build

* frontend - cli exchange

* settings match

* ee repo ref

* npm check

* openapi

* tests

* checkpoint

* cli + tests

* reset to preview on changes

* merge issue ee

* cleanup

* hubscript

* simplifications

* ee repo ref

* cli fixes

* fix sync and add tests

* extra test

* git sync settings / key change aware

* ee-repo ref

* ee-repo ref

* ee repo ref

* ee ref

* review 1

* ee ref

* Update frontend/src/lib/components/PullGitRepoPopover.svelte

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* ee ref

* remove extra includes from ui

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Petric
2025-07-15 12:25:31 -04:00
committed by GitHub
parent 9053a931ce
commit aa37f643e7
41 changed files with 7892 additions and 924 deletions
@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
false,
true
true,
false
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
@@ -41,11 +41,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true
]
},
+1 -1
View File
@@ -1 +1 @@
22aa4778f97d034e5faea8b8d8c971c89ce74137
0e1db1beeac098e8f2ddd2a0abfe9f83751b2092
+41 -26
View File
@@ -581,7 +581,7 @@ paths:
summary: get connected repositories
operationId: getGlobalConnectedRepositories
tags:
- git_sync
- Git Sync
responses:
"200":
description: connected repositories
@@ -12304,8 +12304,7 @@ paths:
"200":
description: Parquet Preview
content:
application/json:
schema: {}
application/json: {}
/w/{workspace}/job_helpers/load_table_count/{path}:
get:
@@ -12384,8 +12383,7 @@ paths:
"200":
description: Csv Preview
content:
application/json:
schema: {}
application/json: {}
/w/{workspace}/job_helpers/delete_s3_file:
delete:
@@ -17006,27 +17004,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:
@@ -17078,6 +17055,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:
@@ -17095,6 +17108,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,
}
}
}
+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
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,
+347 -194
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) => {
@@ -66,198 +68,349 @@ export {
export const VERSION = "1.506.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))
@@ -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>
@@ -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>
@@ -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",
File diff suppressed because it is too large Load Diff