diff --git a/cli/conf.ts b/cli/conf.ts index 342eb423af..6c4e37e3c8 100644 --- a/cli/conf.ts +++ b/cli/conf.ts @@ -108,31 +108,45 @@ export function getEffectiveSettings( ): 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) { + if (repo) { + log.info(`No overrides found in wmill.yaml, using top-level settings (repository flag ignored)`); + } return effective; } - + // Construct override keys using the single format const workspaceKey = `${baseUrl}:${workspaceId}:*`; const repoKey = `${baseUrl}:${workspaceId}:${repo}`; - + + let appliedOverrides: string[] = []; + // Apply workspace-level overrides if (config.overrides[workspaceKey]) { Object.assign(effective, config.overrides[workspaceKey]); + appliedOverrides.push("workspace-level"); } - + // Apply repository-specific overrides (overrides workspace-level) if (config.overrides[repoKey]) { Object.assign(effective, config.overrides[repoKey]); + appliedOverrides.push("repository-specific"); + } else if (repo) { + // Repository was specified but no override found + log.info(`Repository override not found for "${repo}", using ${appliedOverrides.length > 0 ? appliedOverrides.join(" + ") : "top-level"} settings`); } - + + if (appliedOverrides.length > 0) { + log.info(`Applied ${appliedOverrides.join(" + ")} overrides${repo ? ` for repository "${repo}"` : ""}`); + } + return effective; } diff --git a/cli/gitsync-settings.ts b/cli/gitsync-settings.ts index d48105c9f3..27de3b7056 100644 --- a/cli/gitsync-settings.ts +++ b/cli/gitsync-settings.ts @@ -1,4 +1,4 @@ -import { colors, Command, log, yamlStringify } from "./deps.ts"; +import { colors, Command, Confirm, log, yamlStringify } from "./deps.ts"; import { GlobalOptions } from "./types.ts"; import { requireLogin } from "./auth.ts"; import { resolveWorkspace } from "./context.ts"; @@ -164,6 +164,147 @@ function includeTypeToSyncOptions( }; } +// Shared migration function for legacy repositories +async function handleLegacyRepositoryMigration( + selectedRepo: any, + gitSyncSettings: any, + workspace: any, + opts: { yes?: boolean }, + operationName: string = "operation" +): Promise { + if (selectedRepo.settings) { + return selectedRepo; // Already migrated + } + + // This repository is in legacy format - handle migration + if (!gitSyncSettings.include_path || !gitSyncSettings.include_type) { + throw new Error( + `Repository "${selectedRepo.git_repo_resource_path}" has legacy format but workspace-level include_path or include_type is missing. This indicates corrupted git-sync settings.` + ); + } + + const workspaceIncludePath = gitSyncSettings.include_path; + const workspaceIncludeType = gitSyncSettings.include_type; + + if (Deno.stdout.isTerminal() && !opts.yes) { + // Interactive mode - show migration prompt + console.log(colors.yellow('\n⚠️ Legacy git-sync settings detected!')); + console.log(`\nRepository "${selectedRepo.git_repo_resource_path}" has legacy settings format.`); + console.log('The new format allows per-repository filter configuration.'); + if (operationName === "push") { + console.log('This repository must be migrated before pushing settings.\n'); + } else { + console.log('\n'); + } + + console.log(colors.bold('Current workspace-level settings:')); + console.log(` Include paths: ${workspaceIncludePath.join(', ')}`); + console.log(` Include types: ${workspaceIncludeType.join(', ')}\n`); + + // Show what the migration will do + let finalIncludeType = [...workspaceIncludeType]; + if (selectedRepo.exclude_types_override && selectedRepo.exclude_types_override.length > 0) { + const originalCount = finalIncludeType.length; + finalIncludeType = finalIncludeType.filter( + type => !selectedRepo.exclude_types_override.includes(type) + ); + const excludedCount = originalCount - finalIncludeType.length; + console.log(colors.yellow(`Repository excludes ${excludedCount} types: ${selectedRepo.exclude_types_override.join(', ')}`)); + } + + console.log(colors.bold('\nAfter migration, repository will have:')); + console.log(` Include paths: ${workspaceIncludePath.join(', ')}`); + console.log(` Include types: ${finalIncludeType.join(', ')}\n`); + + const confirm = await Confirm.prompt({ + message: operationName === "push" + ? 'Do you want to migrate this repository before pushing?' + : 'Do you want to migrate this repository?', + default: true + }); + + if (!confirm) { + const message = operationName === "push" + ? '\n⚠️ Migration skipped. Cannot push to legacy repository.' + : '\n⚠️ Migration skipped. You can migrate later via the UI.'; + console.log(colors.yellow(message)); + if (operationName === "push") { + return null; // Signal to exit push operation + } + throw new Error('Migration cancelled by user'); + } + + // Perform the migration + let migratedIncludeType = [...workspaceIncludeType]; + if (selectedRepo.exclude_types_override && selectedRepo.exclude_types_override.length > 0) { + migratedIncludeType = migratedIncludeType.filter( + type => !selectedRepo.exclude_types_override.includes(type) + ); + } + + const migratedRepo = { + ...selectedRepo, + settings: { + include_path: [...workspaceIncludePath], + include_type: migratedIncludeType, + exclude_path: [], + extra_include_path: [] + } + }; + + // Remove the old field + delete migratedRepo.exclude_types_override; + + // Update the backend with migrated repository + const updatedRepositories = gitSyncSettings.repositories.map((repo: any) => { + if (repo.git_repo_resource_path === selectedRepo.git_repo_resource_path) { + return migratedRepo; + } + return repo; + }); + + await wmill.editWorkspaceGitSyncConfig({ + workspace: workspace.workspaceId, + requestBody: { + git_sync_settings: { + repositories: updatedRepositories, + // Keep workspace-level settings if other repos are still legacy + ...(gitSyncSettings.repositories.some((r: any) => r.git_repo_resource_path !== selectedRepo.git_repo_resource_path && !r.settings) && { + include_path: workspaceIncludePath, + include_type: workspaceIncludeType + }) + } + } + }); + + console.log(colors.green('\n✓ Repository migration completed successfully!')); + if (operationName === "push") { + console.log('Now proceeding with push operation...\n'); + } + return migratedRepo; + + } else { + // Non-interactive mode - show error + console.error(colors.red('\n❌ Legacy git-sync settings detected!')); + console.error(`\nRepository "${selectedRepo.git_repo_resource_path}" has legacy settings format.`); + if (operationName === "push") { + console.error('This repository must be migrated before pushing settings.'); + } + console.error('Please choose one of the following options:\n'); + console.error('1. Go to the Windmill UI > Workspace Settings > Git Sync'); + console.error(' Review and save this repository to migrate to the new format.\n'); + console.error('2. Run this command in interactive mode (with TTY) to migrate.'); + console.error(` Example: wmill gitsync-settings ${operationName}\n`); + if (operationName === "push") { + console.error('3. Pull settings first to migrate: wmill gitsync-settings pull\n'); + } else { + console.error('3. Push local settings to override backend settings:'); + console.error(' wmill gitsync-settings push\n'); + } + Deno.exit(1); + } +} + // Convert SyncOptions boolean flags to backend include_type array function syncOptionsToIncludeType(opts: SyncOptions): string[] { const includeTypes: string[] = []; @@ -279,6 +420,31 @@ function displayChanges( } } +async function selectAndLogRepository( + repositories: GitSyncRepository[], + repository?: string, +): Promise { + let selectedRepo: GitSyncRepository; + + if (repository) { + const found = repositories.find( + (r: GitSyncRepository) => + r.git_repo_resource_path === repository || + r.git_repo_resource_path === `$res:${repository}`, + ); + if (!found) { + throw new Error(`Repository ${repository} not found`); + } + selectedRepo = found; + const repoPath = selectedRepo.git_repo_resource_path.replace(/^\$res:/, ""); + log.info(colors.cyan(`Using repository: ${colors.bold(repoPath)}`)); + } else { + selectedRepo = await selectRepository(repositories); + } + + return selectedRepo; +} + async function pullGitSyncSettings( opts: GlobalOptions & { repository?: string; @@ -289,6 +455,7 @@ async function pullGitSyncSettings( replace?: boolean; override?: boolean; withBackendSettings?: string; + yes?: boolean; }, ) { const workspace = await resolveWorkspace(opts); @@ -401,22 +568,19 @@ async function pullGitSyncSettings( } // Find the repository to work with - let selectedRepo: GitSyncRepository; - if (opts.repository) { - const found = settings.git_sync.repositories.find( - (r: GitSyncRepository) => - r.git_repo_resource_path === opts.repository || - r.git_repo_resource_path === `$res:${opts.repository}`, - ); - if (!found) { - throw new Error(`Repository ${opts.repository} not found`); - } - selectedRepo = found; - } else { - selectedRepo = await selectRepository( - settings.git_sync.repositories, - ); - } + let selectedRepo = await selectAndLogRepository( + settings.git_sync.repositories, + opts.repository, + ); + + // Check if the selected repository needs migration and handle it + selectedRepo = await handleLegacyRepositoryMigration( + selectedRepo, + settings.git_sync, + workspace, + opts, + "pull" + ); // Convert backend settings to SyncOptions format const backendSyncOptions: SyncOptions = { @@ -534,7 +698,7 @@ async function pullGitSyncSettings( ); } else { if (hasChanges) { - log.info("Changes that would be made:"); + log.info("Changes that would be applied locally:"); const changes = generateChanges(normalizedCurrent, normalizedBackend); if (Object.keys(changes).length === 0) { @@ -573,7 +737,14 @@ async function pullGitSyncSettings( ); const hasConflict = !deepEqual(gitSyncBackend, gitSyncCurrent); - if (hasConflict && Deno.stdin.isTerminal()) { + if (hasConflict && !opts.yes && Deno.stdin.isTerminal()) { + // Show the diff first + log.info("Changes that would be applied locally:"); + const changes = generateChanges(currentSettings, backendSyncOptions); + if (Object.keys(changes).length > 0) { + displayChanges(changes); + } + // Interactive mode - ask user const { Select } = await import("./deps.ts"); const choice = await Select.prompt({ @@ -607,6 +778,16 @@ async function pullGitSyncSettings( repoPath, ); } + } else if (hasConflict && opts.yes) { + // --yes flag: default to override behavior for conflicts + writeMode = "override"; + const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path); + overrideKey = constructOverrideKey( + workspace.remote, + workspace.workspaceId, + repoPath, + ); + log.info(colors.yellow("Settings conflict detected. Using --override behavior (default for --yes).")); } else if (hasConflict) { // Non-interactive mode with conflicts - show message and exit if (opts.jsonOutput) { @@ -664,11 +845,19 @@ async function pullGitSyncSettings( // Apply the settings based on write mode let updatedConfig: SyncOptions; + // Log which settings mode is being used + const repoPath = selectedRepo.git_repo_resource_path.replace(/^\$res:/, ""); + if (writeMode === "override") { + log.info(`Applied repository-specific overrides for repository "${repoPath}"`); + } else { + log.info(`Applied settings for repository "${repoPath}"`); + } + if (writeMode === "replace") { // Preserve existing local config and update only git-sync fields updatedConfig = { ...localConfig }; - // Remove overrides since we're in replace mode - delete updatedConfig.overrides; + // Clear overrides since we're in replace mode, but keep empty object for consistency + updatedConfig.overrides = {}; // Update with backend git-sync settings Object.assign(updatedConfig, backendSyncOptions); } else if (writeMode === "override" && overrideKey) { @@ -761,6 +950,7 @@ async function pushGitSyncSettings( diff?: boolean; jsonOutput?: boolean; withBackendSettings?: string; + yes?: boolean; }, ) { const workspace = await resolveWorkspace(opts); @@ -888,21 +1078,23 @@ async function pushGitSyncSettings( } // Find the repository to work with - let selectedRepo: GitSyncRepository; - if (opts.repository) { - const found = settings.git_sync.repositories.find( - (r: GitSyncRepository) => - r.git_repo_resource_path === opts.repository || - r.git_repo_resource_path === `$res:${opts.repository}`, - ); - if (!found) { - throw new Error(`Repository ${opts.repository} not found`); - } - selectedRepo = found; - } else { - selectedRepo = await selectRepository( - settings.git_sync.repositories, - ); + let selectedRepo = await selectAndLogRepository( + settings.git_sync.repositories, + opts.repository, + ); + + // Check if the selected repository needs migration and handle it + selectedRepo = await handleLegacyRepositoryMigration( + selectedRepo, + settings.git_sync, + workspace, + opts, + "push" + ); + + // If migration was cancelled, exit + if (selectedRepo === null) { + return; } // Get effective settings for this workspace/repo @@ -922,24 +1114,25 @@ async function pushGitSyncSettings( extra_include_path: effectiveSettings.extraIncludes || [], }; + // Calculate diff for all modes + const currentBackend = selectedRepo.settings; + + // Convert current backend settings to SyncOptions for user-friendly display + const currentSyncOptions: SyncOptions = { + includes: currentBackend.include_path || [], + excludes: currentBackend.exclude_path || [], + extraIncludes: currentBackend.extra_include_path || [], + ...includeTypeToSyncOptions(currentBackend.include_type || []), + }; + + const normalizedCurrent = normalizeSyncOptions(currentSyncOptions); + const normalizedEffective = normalizeSyncOptions(effectiveSettings); + const gitSyncCurrent = extractGitSyncFields(normalizedCurrent); + const gitSyncEffective = extractGitSyncFields(normalizedEffective); + const hasChanges = !deepEqual(gitSyncEffective, gitSyncCurrent); + if (opts.diff) { - // Show what would be pushed - const currentBackend = selectedRepo.settings; - - // Convert current backend settings to SyncOptions for user-friendly display - const currentSyncOptions: SyncOptions = { - includes: currentBackend.include_path || [], - excludes: currentBackend.exclude_path || [], - extraIncludes: currentBackend.extra_include_path || [], - ...includeTypeToSyncOptions(currentBackend.include_type || []), - }; - - const normalizedCurrent = normalizeSyncOptions(currentSyncOptions); - const normalizedEffective = normalizeSyncOptions(effectiveSettings); - const gitSyncCurrent = extractGitSyncFields(normalizedCurrent); - const gitSyncEffective = extractGitSyncFields(normalizedEffective); - const hasChanges = !deepEqual(gitSyncEffective, gitSyncCurrent); - + // --diff flag: show differences and exit if (opts.jsonOutput) { // Generate structured diff using the same normalized objects const structuredDiff = hasChanges @@ -958,7 +1151,7 @@ async function pushGitSyncSettings( ); } else { if (hasChanges) { - log.info("Changes that would be pushed:"); + log.info("Changes that would be pushed to Windmill:"); const changes = generateChanges( normalizedCurrent, normalizedEffective, @@ -976,6 +1169,39 @@ async function pushGitSyncSettings( return; } + // Default behavior: show changes and ask for confirmation (unless --yes is passed) + if (hasChanges) { + if (!opts.jsonOutput) { + const changes = generateChanges( + normalizedCurrent, + normalizedEffective, + ); + + if (Object.keys(changes).length === 0) { + log.info(colors.green("No changes to push")); + return; + } else { + log.info("Changes that would be pushed to Windmill:"); + displayChanges(changes); + } + } + + // Ask for confirmation unless --yes is passed or not in TTY + if (!opts.yes && Deno.stdin.isTerminal()) { + const confirmed = await Confirm.prompt({ + message: `Do you want to apply these changes to the remote?`, + default: true, + }); + + if (!confirmed) { + return; + } + } + } else { + log.info(colors.green("No changes to push")); + return; + } + if (opts.withBackendSettings) { // Skip backend update when using simulated settings if (opts.jsonOutput) { @@ -1104,6 +1330,7 @@ const command = new Command() "--with-backend-settings ", "Use provided JSON settings instead of querying backend (for testing)", ) + .option("--yes", "Skip interactive prompts and use default behavior") .action(pullGitSyncSettings as any) .command("push") .description( @@ -1119,6 +1346,7 @@ const command = new Command() "--with-backend-settings ", "Use provided JSON settings instead of querying backend (for testing)", ) + .option("--yes", "Skip interactive prompts and use default behavior") .action(pushGitSyncSettings as any); export { pullGitSyncSettings, pushGitSyncSettings }; diff --git a/cli/main.ts b/cli/main.ts index df61a212e2..0229c65b83 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -143,6 +143,7 @@ const command = new Command() skipFolders: DEFAULT_SYNC_OPTIONS.skipFolders, includeSchedules: DEFAULT_SYNC_OPTIONS.includeSchedules, includeTriggers: DEFAULT_SYNC_OPTIONS.includeTriggers, + overrides: {}, }; await Deno.writeTextFile( diff --git a/cli/sync.ts b/cli/sync.ts index 162c48282e..4f9cb8a715 100644 --- a/cli/sync.ts +++ b/cli/sync.ts @@ -56,43 +56,8 @@ function mergeCliWithEffectiveOptions { 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"); + // Should have empty overrides section for consistency + assertStringIncludes(wmillYaml, "overrides: {}"); }); }); @@ -180,6 +180,6 @@ Deno.test("Init: --use-default bypasses backend settings check", async () => { // 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"); + assertStringIncludes(wmillYaml, "overrides: {}", "Should have empty overrides section for consistency"); }); }); \ No newline at end of file diff --git a/cli/test/multi_instance_workspace.test.ts b/cli/test/multi_instance_workspace.test.ts index bfcc86a3b3..97ad4f3ec4 100644 --- a/cli/test/multi_instance_workspace.test.ts +++ b/cli/test/multi_instance_workspace.test.ts @@ -24,7 +24,7 @@ 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 @@ -38,22 +38,22 @@ overrides: 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:"); + assertStringIncludes(pullResult.stdout, "Changes that would be applied locally:"); // includeSchedules should show as a change since backend default is false assertStringIncludes(pullResult.stdout, "includeSchedules"); }); @@ -61,9 +61,9 @@ overrides: Deno.test("Multi-Instance: gitsync-settings push with overrides", async () => { await withContainerizedBackend(async (backend, tempDir) => { - // Set up workspace profile + // 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 @@ -78,16 +78,16 @@ overrides: "${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, "Changes that would be pushed to Windmill:"); assertStringIncludes(pushResult.stdout, "includeSchedules"); }); }); @@ -95,7 +95,7 @@ overrides: 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: @@ -105,22 +105,22 @@ 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) => + 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"); @@ -130,7 +130,7 @@ overrides: 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: @@ -142,21 +142,21 @@ overrides: "${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) => + 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"); @@ -166,7 +166,7 @@ overrides: 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: @@ -178,22 +178,22 @@ overrides: "${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) => + const hasTestVariable = (data.changes || []).some((change: any) => change.path?.includes('u/admin/test_config.variable.yaml') ); assertEquals(hasTestVariable, false, "Variables should be skipped due to wildcard override"); }); -}); \ No newline at end of file +}); diff --git a/cli/test/sync_config_resolution.test.ts b/cli/test/sync_config_resolution.test.ts index 7166a54b04..0ec1cc733f 100644 --- a/cli/test/sync_config_resolution.test.ts +++ b/cli/test/sync_config_resolution.test.ts @@ -30,7 +30,7 @@ Deno.test("Integration: wmill.yaml configuration produces expected results", asy 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: @@ -43,7 +43,7 @@ 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) { @@ -53,21 +53,21 @@ includeTriggers: true`); 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) => + 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) => + const hasResources = (yamlData.changes || []).some((change: any) => change.type === 'added' && change.path?.includes('.resource.yaml') ); - const hasVariables = (yamlData.changes || []).some((change: any) => + const hasVariables = (yamlData.changes || []).some((change: any) => change.type === 'added' && change.path?.includes('.variable.yaml') ); assertEquals(hasResources, false); @@ -79,35 +79,35 @@ Deno.test("Integration: settings.yaml inclusion respects includeSettings flag", await withContainerizedBackend(async (backend, tempDir) => { // Set up workspace profile with name "localhost_test" await setupWorkspaceProfile(backend); - - // Test 1: includeSettings: true should include settings.yaml + + // 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) => + 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) => + const hasSettingsExclude = (excludeData.changes || []).some((change: any) => change.type === 'added' && change.path === 'settings.yaml' ); assertEquals(hasSettingsExclude, false); @@ -118,30 +118,86 @@ 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 + + // 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) => + 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) => + const hasVariables = (data.changes || []).some((change: any) => change.type === 'added' && change.path?.includes('.variable.yaml') ); assertEquals(hasVariables, true); }); -}); \ No newline at end of file +}); + +// ============================================================================= +// CLI FLAG OVERRIDE TESTS +// Tests for CLI flags overriding configuration file settings +// ============================================================================= + +Deno.test("CLI skip flags override wmill.yaml configuration", async () => { + await withContainerizedBackend(async (backend, tempDir) => { + // Set up workspace profile with name "localhost_test" + await setupWorkspaceProfile(backend); + + // Create wmill.yaml that INCLUDES resources by default (skipResources: false) + await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - f/** + - u/** +skipResources: false +skipResourceTypes: false +includeSettings: true`); + + // Test 1: Without CLI flags - should respect wmill.yaml (include resources) + const configResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir); + assertEquals(configResult.code, 0); + + const configData = parseJsonFromCLIOutput(configResult.stdout); + + + // Should include resources (not skipped by config) + const hasResources = (configData.changes || []).some((change: any) => + change.type === 'added' && change.path?.includes('.resource.yaml') + ); + assertEquals(hasResources, true, "Resources should be included by wmill.yaml config"); + + // Test 2: With CLI --skip-resources flag - should override wmill.yaml to skip resources + const overrideResult = await backend.runCLICommand([ + 'sync', 'pull', '--dry-run', '--json-output', + '--skip-resources', // CLI flag should override config to skip resources + '--skip-resource-types' // CLI flag should override config to skip resource types + ], tempDir); + assertEquals(overrideResult.code, 0); + + const overrideData = parseJsonFromCLIOutput(overrideResult.stdout); + + // Should NOT include resources (CLI flag overrides config) + const hasResourcesOverride = (overrideData.changes || []).some((change: any) => + change.type === 'added' && change.path?.includes('.resource.yaml') + ); + assertEquals(hasResourcesOverride, false, "CLI --skip-resources flag should override wmill.yaml to exclude resources"); + + // Should NOT include resource types (CLI flag overrides config) + const hasResourceTypesOverride = (overrideData.changes || []).some((change: any) => + change.type === 'added' && change.path?.includes('.resource-type.yaml') + ); + assertEquals(hasResourceTypesOverride, false, "CLI --skip-resource-types flag should override wmill.yaml to exclude resource types"); + }); +}); diff --git a/cli/utils.ts b/cli/utils.ts index 561cb6504b..af5548330c 100644 --- a/cli/utils.ts +++ b/cli/utils.ts @@ -2,7 +2,7 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck This file is copied from a JS project, so it's not type-safe. -import { log, encodeHex, SEP } from "./deps.ts"; +import { colors, log, encodeHex, SEP } from "./deps.ts"; import crypto from "node:crypto"; export function deepEqual(a: T, b: T): boolean { @@ -167,7 +167,7 @@ export async function selectRepository( if (repositories.length === 1) { const repoPath = repositories[0].git_repo_resource_path.replace(/^\$res:/, ""); - log.info(`Using repository: ${repoPath}`); + log.info(colors.cyan(`Auto-selected repository: ${colors.bold(repoPath)}`)); return repositories[0]; }