import * as log from "./log.ts"; import { yamlParseFile } from "../utils/yaml.ts"; import { Confirm } from "@cliffy/prompt/confirm"; import { stringify as yamlStringify } from "yaml"; import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, isGitRepository, } from "../utils/git.ts"; import { join, dirname, resolve, relative } from "node:path"; import { existsSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { execSync } from "node:child_process"; import { setNonDottedPaths } from "../utils/resource_folders.ts"; export let showDiffs = false; export function setShowDiffs(value: boolean) { showDiffs = value; } export interface SpecificItemsConfig_Yaml { variables?: string[]; resources?: string[]; triggers?: string[]; schedules?: string[]; folders?: string[]; settings?: boolean; } export interface WorkspaceEntryConfig extends SyncOptions { gitBranch?: string; workspaceId?: string; baseUrl?: string; overrides?: Partial; promotionOverrides?: Partial; specificItems?: SpecificItemsConfig_Yaml; } export type WorkspacesConfig = { commonSpecificItems?: SpecificItemsConfig_Yaml; } & { [workspaceName: string]: WorkspaceEntryConfig; }; // Legacy type alias for backward compat type LegacyBranchesConfig = { commonSpecificItems?: SpecificItemsConfig_Yaml; } & { [branchName: string]: SyncOptions & { overrides?: Partial; promotionOverrides?: Partial; baseUrl?: string; workspaceId?: string; specificItems?: SpecificItemsConfig_Yaml; }; }; export const SUPPORTED_SYNC_BEHAVIOR_VERSION = 1; export function parseSyncBehavior(value?: string | number): number { if (!value && value !== 0) return 0; const s = String(value); const match = s.match(/^v(\d+)$/); return match ? parseInt(match[1], 10) : 0; } export interface SyncOptions { stateful?: boolean; raw?: boolean; yes?: boolean; dryRun?: boolean; skipPull?: boolean; failConflicts?: boolean; plainSecrets?: boolean; json?: boolean; skipVariables?: boolean; skipResources?: boolean; skipResourceTypes?: boolean; skipSecrets?: boolean; skipWorkspaceDependencies?: boolean; skipDatatableMigrations?: boolean; skipScripts?: boolean; skipFlows?: boolean; skipApps?: boolean; skipFolders?: boolean; includeSchedules?: boolean; includeTriggers?: boolean; includeUsers?: boolean; includeGroups?: boolean; includeSettings?: boolean; includeKey?: boolean; skipReencryptOnKeyChange?: boolean; skipBranchValidation?: boolean; message?: string; includes?: string[]; extraIncludes?: string[]; excludes?: string[]; defaultTs?: "bun" | "deno"; codebases?: Codebase[]; parallel?: number; jsonOutput?: boolean; nonDottedPaths?: boolean; // Primary config key — maps workspace names to workspace configurations workspaces?: WorkspacesConfig; // Deprecated aliases — normalized to `workspaces` in readConfigFile gitBranches?: LegacyBranchesConfig; environments?: LegacyBranchesConfig; git_branches?: LegacyBranchesConfig; promotion?: string; lint?: boolean; locksRequired?: boolean; dedupeLockfiles?: boolean; syncBehavior?: string; } export interface Codebase { relative_path: string; includes?: string[]; excludes?: string[]; assets?: { from: string; to: string; }[]; customBundler?: string; external?: string[]; define?: { [key: string]: string }; inject?: string[]; loader?: any; format?: "cjs" | "esm"; banner?: { [type: string]: string; }; } function getGitRepoRoot(): string | null { try { const result = execSync("git rev-parse --show-toplevel", { encoding: "utf8", stdio: "pipe", }); return result.trim(); } catch (error) { return null; } } export const GLOBAL_CONFIG_OPT = { noCdToRoot: false }; // Pure upward search: no chdir, no logging. findWmillYaml() adds the chdir. function locateWmillYaml(): string | null { const startDir = resolve(process.cwd()); const isInGitRepo = isGitRepository(); const gitRoot = isInGitRepo ? getGitRepoRoot() : null; let currentDir = startDir; let foundPath: string | null = null; // Search upward for wmill.yaml until we find it, reach git root, or reach filesystem root while (true) { const wmillYamlPath = join(currentDir, "wmill.yaml"); if (existsSync(wmillYamlPath)) { foundPath = wmillYamlPath; break; } // Check if we've reached the git repository root if (gitRoot && resolve(currentDir) === resolve(gitRoot)) { break; } // Check if we've reached the filesystem root const parentDir = dirname(currentDir); if (parentDir === currentDir) { break; } currentDir = parentDir; } return foundPath; } function findWmillYaml(): string | null { const startDir = resolve(process.cwd()); const foundPath = locateWmillYaml(); // If wmill.yaml was found in a parent directory, warn the user and change working directory if ( !GLOBAL_CONFIG_OPT.noCdToRoot && foundPath && resolve(dirname(foundPath)) !== resolve(startDir) ) { const configDir = dirname(foundPath); const relativePath = relative(startDir, foundPath); log.warn(`⚠️ wmill.yaml found in parent directory: ${relativePath}`); // Change working directory to where wmill.yaml was found process.chdir(configDir); log.info(`📁 Changed working directory to: ${configDir}`); } return foundPath; } export function getWmillYamlPath(): string | null { return findWmillYaml(); } /** * Look up one `workspaces` entry, for diagnostics only. readConfigFile() must * not be used for that: it chdirs to the config's directory, exits on an * unsupported syncBehavior and throws on a malformed file. A diagnostic may * never fail or relocate the command it is diagnosing. */ export async function peekWorkspaceEntry( workspaceName: string ): Promise { if (RESERVED_WORKSPACE_KEYS.has(workspaceName)) { return undefined; } const wmillYamlPath = locateWmillYaml(); if (!wmillYamlPath) { return undefined; } try { const conf = (await yamlParseFile(wmillYamlPath)) as SyncOptions; const workspaces = conf?.workspaces ?? conf?.gitBranches ?? conf?.environments ?? conf?.git_branches; const entry = (workspaces as any)?.[workspaceName]; return typeof entry === "object" && entry !== null ? entry : undefined; } catch (e) { log.debug(`Failed to parse ${wmillYamlPath} for workspace lookup: ${e}`); return undefined; } } let legacyConfigWarned = false; export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promise { const warnIfMissing = opts?.warnIfMissing ?? true; try { // First, try to find wmill.yaml recursively const wmillYamlPath = findWmillYaml(); if (!wmillYamlPath) { if (warnIfMissing) { log.warn( "No wmill.yaml found. Use 'wmill init' to bootstrap it." ); } return {}; } const conf = (await yamlParseFile(wmillYamlPath)) as SyncOptions; // Handle obsolete overrides format if (conf && "overrides" in conf) { const overrides = conf.overrides as any; const hasSettings = overrides && typeof overrides === "object" && Object.keys(overrides).length > 0; if (hasSettings) { throw new Error( "❌ The 'overrides' field is no longer supported.\n" + " The configuration system now uses workspace-based configuration.\n" + " Please delete your wmill.yaml and run 'wmill init' to recreate it with the new format." ); } else { delete conf.overrides; } } // Normalize legacy formats to `workspaces` in memory (no file rewrite). // Priority: workspaces > gitBranches > environments > git_branches if (conf && !conf.workspaces) { let legacyKey: string | null = null; let legacyData: LegacyBranchesConfig | undefined; if (conf.gitBranches) { legacyKey = "gitBranches"; legacyData = conf.gitBranches; } else if (conf.environments) { legacyKey = "environments"; legacyData = conf.environments; } else if (conf.git_branches) { legacyKey = "git_branches"; legacyData = conf.git_branches; } if (legacyKey && legacyData) { conf.workspaces = convertGitBranchesToWorkspaces(legacyData); if (!legacyConfigWarned) { log.warn( `⚠️ '${legacyKey}' in wmill.yaml is deprecated. Use 'workspaces' instead.\n` + ` Run 'wmill config migrate' to update your configuration automatically.` ); legacyConfigWarned = true; } } } else if (conf?.workspaces) { // If both workspaces and any legacy key exist, warn and use workspaces for (const legacyKey of ["gitBranches", "environments", "git_branches"] as const) { if ((conf as any)[legacyKey]) { log.warn( `⚠️ Both 'workspaces' and '${legacyKey}' found in wmill.yaml. Using 'workspaces' and ignoring '${legacyKey}'.` ); } } } // Clean legacy keys from in-memory config delete conf?.gitBranches; delete (conf as any)?.environments; delete conf?.git_branches; if (conf?.defaultTs == undefined) { log.warn( "No defaultTs defined in your wmill.yaml. Using 'bun' as default." ); } // Initialize global nonDottedPaths setting from config setNonDottedPaths(conf?.nonDottedPaths ?? false); const syncBehaviorVersion = parseSyncBehavior(conf?.syncBehavior); if (syncBehaviorVersion > SUPPORTED_SYNC_BEHAVIOR_VERSION) { log.error( `Your wmill.yaml specifies syncBehavior: ${conf!.syncBehavior}, but this CLI only supports up to v${SUPPORTED_SYNC_BEHAVIOR_VERSION}. Run 'wmill upgrade' to update.` ); process.exit(1); } return typeof conf == "object" ? conf : ({} as SyncOptions); } catch (e) { if ( e instanceof Error && (e.message.includes("overrides") || e.message.includes("Obsolete configuration format")) ) { throw e; // Re-throw the specific obsolete format error } // Since we already found the file path, this is likely a parsing or access error if (e instanceof Error && e.message.includes("Error parsing yaml")) { const yamlError = e.cause instanceof Error ? e.cause.message : String(e.cause); throw new Error( "❌ YAML syntax error in wmill.yaml:\n" + " " + yamlError + "\n" + " Please fix the YAML syntax in wmill.yaml or delete the file to start fresh." ); } else { // File exists but has other issues (permissions, etc.) throw new Error( "❌ Failed to read wmill.yaml:\n" + " " + (e instanceof Error ? e.message : String(e)) + "\n" + " Please check file permissions or fix the syntax." ); } } } // 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" | "skipWorkspaceDependencies" | "skipDatatableMigrations" | "skipScripts" | "skipFlows" | "skipApps" | "skipFolders" | "includeUsers" | "includeGroups" | "includeSettings" | "includeKey" | "nonDottedPaths" | "syncBehavior" > > > = { 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, skipWorkspaceDependencies: false, skipDatatableMigrations: false, nonDottedPaths: false, syncBehavior: "v1", } as const; export async function mergeConfigWithConfigFile( opts: T ): Promise { const configFile = await readConfigFile(); return Object.assign(configFile ?? {}, opts); } // Validate workspace configuration early in the process export async function validateBranchConfiguration( opts: Pick, workspaceNameOverride?: string ): Promise { // When workspace override is provided, skip validation - user is explicitly specifying the workspace if (opts.skipBranchValidation || workspaceNameOverride || !isGitRepository()) { return; } const config = await readConfigFile(); const { workspaces } = config; const rawBranch = getCurrentGitBranch(); const originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch); let currentBranch: string | null; if (originalBranchIfForked) { // The fork targeting itself is announced by tryResolveBranchWorkspace; // this validation detail stays at debug to avoid a near-duplicate line. log.debug( `Workspace fork detected from branch name \`${rawBranch}\`. Validating workspace configuration using original branch \`${originalBranchIfForked}\`` ); currentBranch = originalBranchIfForked; } else { currentBranch = rawBranch; } // In a git repository, workspaces section is recommended if (!workspaces || getWorkspaceNames(workspaces).length === 0) { log.warn( "⚠️ WARNING: In a Git repository, the 'workspaces' section is recommended in wmill.yaml.\n" + " Consider adding a workspaces section to map workspace names to Windmill instances.\n" + " Run 'wmill init' to recreate the configuration file with proper workspace setup." ); return; } // Warn if multiple workspaces map to the same git branch const branchToWsNames = new Map(); for (const name of getWorkspaceNames(workspaces)) { const entry = workspaces[name] as WorkspaceEntryConfig; const branch = getEffectiveGitBranch(name, entry); const existing = branchToWsNames.get(branch); if (existing) { existing.push(name); } else { branchToWsNames.set(branch, [name]); } } for (const [branch, names] of branchToWsNames) { if (names.length > 1) { log.warn( `⚠️ WARNING: Multiple workspaces map to git branch '${branch}': ${names.join(", ")}.\n` + ` Only the first ('${names[0]}') will be used during auto-detection. Use --workspace to select explicitly.` ); } } // Current branch must match a configured workspace's gitBranch if (currentBranch && !findWorkspaceByGitBranch(workspaces, currentBranch)) { const wsNames = getWorkspaceNames(workspaces); const availableInfo = wsNames .map((n) => { const entry = workspaces[n] as WorkspaceEntryConfig; const branch = getEffectiveGitBranch(n, entry); return branch !== n ? `${n} (gitBranch: ${branch})` : n; }) .join(", "); // In interactive mode, offer to create a workspace entry if (!!process.stdin.isTTY) { log.info( `Current Git branch '${currentBranch}' does not match any workspace in the configuration.\n` + `Available workspaces: ${availableInfo}` ); const shouldCreate = opts.yes || (await Confirm.prompt({ message: `Create empty workspace configuration for branch '${currentBranch}'?`, default: true, })); if (shouldCreate) { // Warn if branch name contains filesystem-unsafe characters if (/[\/\\:*?"<>|.]/.test(currentBranch)) { const sanitizedBranchName = currentBranch.replace( /[\/\\:*?"<>|.]/g, "_" ); log.warn( `⚠️ WARNING: Branch name "${currentBranch}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .).` ); log.warn( ` Branch-specific files will be saved with sanitized name: "${sanitizedBranchName}"` ); log.warn( ` Example: "file.variable.yaml" → "file.${sanitizedBranchName}.variable.yaml"` ); } // Read current config, add workspace entry, and write it back const currentConfig = await readConfigFile(); if (!currentConfig.workspaces) { currentConfig.workspaces = {} as WorkspacesConfig; } (currentConfig.workspaces as any)[currentBranch] = {}; await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8"); log.info( `✅ Created empty workspace configuration for '${currentBranch}'` ); } else { log.warn( "⚠️ WARNING: Workspace creation cancelled. You can manually add a workspace to the 'workspaces' section in wmill.yaml or use 'wmill gitsync-settings pull' to pull configuration from an existing windmill workspace git-sync configuration." ); return; } } else { // Warn about filesystem-unsafe characters in branch name if (/[\/\\:*?"<>|.]/.test(currentBranch)) { const sanitizedBranchName = currentBranch.replace( /[\/\\:*?"<>|.]/g, "_" ); log.warn( `⚠️ WARNING: Branch name "${currentBranch}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .).` ); log.warn( ` Branch-specific files will use sanitized name: "${sanitizedBranchName}"` ); } log.warn( `⚠️ WARNING: Current Git branch '${currentBranch}' does not match any workspace in the configuration.\n` + ` Consider adding a workspace entry for branch '${currentBranch}' in the 'workspaces' section of wmill.yaml.\n` + ` Available workspaces: ${availableInfo}` ); return; } } } // Get effective settings by merging top-level settings with workspace-specific overrides. // workspaceNameOverride selects a workspace by name directly. // When not provided, auto-detects from the current git branch. export async function getEffectiveSettings( config: SyncOptions, promotion?: string, skipBranchValidation?: boolean, suppressLogs?: boolean, workspaceNameOverride?: string ): Promise { // Start with top-level settings from config const { workspaces, ...topLevelSettings } = config; const effective = { ...topLevelSettings }; // Resolve the workspace entry to use let resolvedWsName: string | null = null; let resolvedWsEntry: WorkspaceEntryConfig | null = null; let originalBranchIfForked: string | null = null; let rawGitBranch: string | null = null; if (workspaceNameOverride) { // Direct lookup by workspace name if (workspaces && (workspaces as any)[workspaceNameOverride]) { resolvedWsName = workspaceNameOverride; resolvedWsEntry = (workspaces as any)[workspaceNameOverride] as WorkspaceEntryConfig; } } else if (isGitRepository()) { rawGitBranch = getCurrentGitBranch(); originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawGitBranch); const branch = originalBranchIfForked ?? rawGitBranch; if (originalBranchIfForked) { log.debug( `Using overrides from original branch \`${originalBranchIfForked}\`` ); } if (branch) { const match = findWorkspaceByGitBranch(workspaces, branch); if (match) { [resolvedWsName, resolvedWsEntry] = match; } } } else { log.debug("Not in a Git repository and no workspace override provided, using top-level settings"); } // If promotion is specified, use that branch's promotionOverrides or overrides if (promotion && workspaces) { const promotionMatch = findWorkspaceByGitBranch(workspaces, promotion); if (promotionMatch) { const [, targetWs] = promotionMatch; // First try promotionOverrides, then fall back to overrides if (targetWs.promotionOverrides) { Object.assign(effective, targetWs.promotionOverrides); if (!suppressLogs) { log.info(`Applied promotion settings from workspace for branch: ${promotion}`); } } else if (targetWs.overrides) { Object.assign(effective, targetWs.overrides); if (!suppressLogs) { log.info( `Applied settings from workspace for branch: ${promotion} (no promotionOverrides found)` ); } } else { log.debug( `No promotion or regular overrides found for '${promotion}', using top-level settings` ); } } } // Otherwise use resolved workspace's overrides else if (resolvedWsEntry?.overrides) { Object.assign(effective, resolvedWsEntry.overrides); if (!suppressLogs) { const extraLog = originalBranchIfForked ? ` (because it is the origin of the workspace fork branch \`${rawGitBranch}\`)` : ""; log.info( `Applied settings for workspace '${resolvedWsName}'${extraLog}` ); } } else if (resolvedWsName) { log.debug( `No overrides found for workspace '${resolvedWsName}', using top-level settings` ); } return effective; } // Resolve workspace name from a --branch override (git branch → workspace name). // Falls back to using the branch value as-is (backward compat: old key = branch name). function resolveWsNameFromBranch(opts: SyncOptions, branchName: string): string { const match = findWorkspaceByGitBranch(opts.workspaces, branchName); return match ? match[0] : branchName; } // Resolve wsNameForConfig from CLI flags. Prefers --branch → matching config key, // then --workspace → matching config key (incl. when --base-url is set). Returns // undefined when no flag-based resolution applies; callers then fall back to // inferWsNameFromProfile on the resolved workspace profile. export function resolveWsNameForConfigFromFlags( opts: SyncOptions & { branch?: string; workspace?: string } ): string | undefined { if (opts.branch) { return resolveWsNameFromBranch(opts, opts.branch); } if (opts.workspace) { // Use getWorkspaceNames so reserved keys (e.g. commonSpecificItems) are filtered out, // matching the behavior of findWorkspaceByGitBranch / inferWsNameFromProfile. const validKeys = getWorkspaceNames(opts.workspaces); if (validKeys.includes(opts.workspace)) { return opts.workspace; } } return undefined; } /** * Match a workspace config entry to a resolved workspace profile by remote + * workspace id. The fallback for when no flag names the entry outright. */ export function inferWsNameFromProfile( opts: SyncOptions, profile: { remote: string; workspaceId: string } ): string | undefined { if (!opts.workspaces) return undefined; for (const name of getWorkspaceNames(opts.workspaces)) { const entry = (opts.workspaces as any)[name] as WorkspaceEntryConfig; if (!entry?.baseUrl) continue; try { const entryUrl = new URL(entry.baseUrl).toString(); const profileUrl = new URL(profile.remote).toString(); const entryWsId = entry.workspaceId ?? name; if (entryUrl === profileUrl && entryWsId === profile.workspaceId) { return name; } } catch { continue; } } return undefined; } /** * `syncBehavior` as the workspace being pushed to sees it. The top level alone * misses a `workspaces..overrides.syncBehavior`, which is where a repo * that varies settings per workspace puts it, and the entry to read is the one * `--workspace` names — falling back to the profile, then to the git branch — * the same order `sync push` resolves it in. */ export async function readEffectiveSyncBehavior( opts: { workspace?: string }, profile?: { remote: string; workspaceId: string } ): Promise { const config = await readConfigFile({ warnIfMissing: false }); const named = resolveWsNameForConfigFromFlags({ ...config, ...opts }); const effective = await getEffectiveSettings( config, undefined, false, true, named ?? (profile ? inferWsNameFromProfile(config, profile) : undefined) ); return effective.syncBehavior; } const RESERVED_WORKSPACE_KEYS = new Set(["commonSpecificItems"]); /** * Find a workspace config entry whose effective git branch matches branchName. * Returns [workspaceName, config] or undefined. */ export function findWorkspaceByGitBranch( workspaces: WorkspacesConfig | undefined, branchName: string ): [string, WorkspaceEntryConfig] | undefined { if (!workspaces) return undefined; for (const [name, entry] of Object.entries(workspaces)) { if (RESERVED_WORKSPACE_KEYS.has(name)) continue; const effectiveBranch = (entry as WorkspaceEntryConfig).gitBranch ?? name; if (effectiveBranch === branchName) { return [name, entry as WorkspaceEntryConfig]; } } return undefined; } /** Get the effective workspaceId for a workspace entry (defaults to workspace name). */ export function getEffectiveWorkspaceId( workspaceName: string, config: WorkspaceEntryConfig ): string { return config.workspaceId ?? workspaceName; } /** Get the effective git branch for a workspace entry (defaults to workspace name). */ export function getEffectiveGitBranch( workspaceName: string, config: WorkspaceEntryConfig ): string { return config.gitBranch ?? workspaceName; } /** Get all workspace names from config, excluding reserved keys like commonSpecificItems. */ export function getWorkspaceNames( workspaces: WorkspacesConfig | undefined ): string[] { if (!workspaces) return []; return Object.keys(workspaces).filter((k) => !RESERVED_WORKSPACE_KEYS.has(k)); } /** * Convert legacy gitBranches/environments format to the new workspaces format. * Since old keys were branch names, workspace name = branch name. * gitBranch is not set (defaults to key name). All existing fields are preserved. */ export function convertGitBranchesToWorkspaces( gitBranches: LegacyBranchesConfig ): WorkspacesConfig { const workspaces: Record = {}; for (const [key, value] of Object.entries(gitBranches)) { if (key === "commonSpecificItems") { workspaces.commonSpecificItems = value; continue; } // Copy the entire entry as-is. Old format keys = branch names, so gitBranch // doesn't need to be set (defaults to key name). All SyncOptions fields, // overrides, promotionOverrides, specificItems, baseUrl, workspaceId are preserved. workspaces[key] = { ...value }; } return workspaces as WorkspacesConfig; }