From d06b42613f73c4a7b31c990be22b0c97efab2666 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:35:28 +0100 Subject: [PATCH] feat(cli): generate commented wmill.yaml and add config reference command (#8546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: generate commented wmill.yaml template and add config reference command Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add missing options to config reference (promotion, skipBranchValidation, commonSpecificItems) Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: generate YAML template from CONFIG_REFERENCE instead of handwritten string Co-Authored-By: Claude Opus 4.6 (1M context) * fix: preserve YAML comments when binding workspace profile during init Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: simplify to `wmill config` and reorder table columns Co-Authored-By: Claude Opus 4.6 (1M context) * feat: generate JSON Schema for wmill.yaml editor autocomplete and validation Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove redundant templateValue fields and make specificItemsSchema data-driven Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: use native JSON Schema types in CONFIG_REFERENCE, strip non-schema keys for generation Eliminates typeToJsonSchema, specificItemsSchema, codebaseItemSchema, branchConfigSchema, and the complex generateJsonSchema body. Each CONFIG_REFERENCE entry is now a JSON Schema property with extra metadata. Schema generation just iterates and strips non-schema keys. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove typeLabel and displayType — use schema types directly Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove hidden entries, auto-expand nested schemas in reference table Sub-fields (codebases[], gitBranches..*) are now derived from the parent's inline schema instead of being maintained as duplicate hidden entries. Removes 29 entries and the hidden field entirely. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use console.log for JSON output and quote YAML-special branch names Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate system prompts to include new config command Co-Authored-By: Claude Opus 4.6 (1M context) * fix: review feedback + add tests for template, schema, and config reference - Use console.log for --json output (no ANSI escape codes) - Quote branch names with YAML-special characters - Add 28 tests covering template generation, JSON Schema validation, config reference formatting, and CONFIG_REFERENCE integrity Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add generate-schema script and commit wmill.schema.json to repo Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: remove schema.json generation from wmill init Co-Authored-By: Claude Opus 4.6 (1M context) * fix: eliminate read-back cycle, harden yamlKey, fix triple negation Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/generate-schema.ts | 16 + cli/src/commands/config/config.ts | 26 ++ cli/src/commands/init/init.ts | 87 ++-- cli/src/commands/init/template.ts | 395 ++++++++++++++++ cli/src/guidance/skills.ts | 7 + cli/src/main.ts | 3 + cli/test/init_template.test.ts | 244 ++++++++++ cli/wmill.schema.json | 439 ++++++++++++++++++ .../auto-generated/cli/cli-commands.md | 7 + system_prompts/auto-generated/prompts.ts | 7 + .../skills/cli-commands/SKILL.md | 7 + 11 files changed, 1180 insertions(+), 58 deletions(-) create mode 100644 cli/generate-schema.ts create mode 100644 cli/src/commands/config/config.ts create mode 100644 cli/src/commands/init/template.ts create mode 100644 cli/test/init_template.test.ts create mode 100644 cli/wmill.schema.json diff --git a/cli/generate-schema.ts b/cli/generate-schema.ts new file mode 100644 index 0000000000..a5925968d6 --- /dev/null +++ b/cli/generate-schema.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env npx tsx +/** + * Regenerate cli/wmill.schema.json from CONFIG_REFERENCE. + * + * Run after adding or modifying config options in src/commands/init/template.ts: + * npx tsx generate-schema.ts + */ +import { writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { generateJsonSchema } from "./src/commands/init/template.ts"; + +const dir = dirname(fileURLToPath(import.meta.url)); +const out = join(dir, "wmill.schema.json"); +writeFileSync(out, JSON.stringify(generateJsonSchema(), null, 2) + "\n"); +console.log(`Wrote ${out}`); diff --git a/cli/src/commands/config/config.ts b/cli/src/commands/config/config.ts new file mode 100644 index 0000000000..500e65a113 --- /dev/null +++ b/cli/src/commands/config/config.ts @@ -0,0 +1,26 @@ +import { Command } from "@cliffy/command"; +import * as log from "../../core/log.ts"; +import { + formatConfigReference, + formatConfigReferenceJson, +} from "../init/template.ts"; + +interface ConfigOptions { + json?: boolean; +} + +async function configAction(opts: ConfigOptions) { + if (opts.json) { + console.log(formatConfigReferenceJson()); + } else { + log.info(formatConfigReference()); + } +} + +const command = new Command() + .name("config") + .description("Show all available wmill.yaml configuration options") + .option("--json", "Output as JSON for programmatic consumption") + .action(configAction as any); + +export default command; diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index 5883967b77..dcd146b7e4 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -3,13 +3,14 @@ import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; import * as log from "../../core/log.ts"; -import { stringify as yamlStringify } from "yaml"; +import { type BranchBinding } from "./template.ts"; import { GlobalOptions } from "../../types.ts"; import { readLockfile } from "../../utils/metadata.ts"; import { getActiveWorkspaceOrFallback } from "../workspace/workspace.ts"; import { generateRTNamespace } from "../resource-type/resource-type.ts"; import { SKILLS, SKILL_CONTENT, SCHEMAS, SCHEMA_MAPPINGS } from "../../guidance/skills.ts"; import { generateAgentsMdContent } from "../../guidance/core.ts"; +import { generateCommentedTemplate } from "./template.ts"; /** * Format a YAML schema for inclusion in skill markdown files. @@ -44,59 +45,35 @@ async function initAction(opts: InitOptions) { if (await 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("../../core/conf.ts"); - - // Create initial config with defaults - const initialConfig = { ...DEFAULT_SYNC_OPTIONS } as any; - - // Add branch structure + // Detect current git branch for template const { isGitRepository, getCurrentGitBranch } = await import( "../../utils/git.ts" ); + let branchName: string | undefined; + let binding: BranchBinding | undefined; if (isGitRepository()) { - const currentBranch = getCurrentGitBranch(); - if (currentBranch) { - initialConfig.gitBranches = { - [currentBranch]: { overrides: {} }, - }; - } else { - initialConfig.gitBranches = {}; - } - } else { - initialConfig.gitBranches = {}; + branchName = getCurrentGitBranch() ?? undefined; } - initialConfig.nonDottedPaths = true; - await writeFile("wmill.yaml", yamlStringify(initialConfig), "utf-8"); - log.info(colors.green("wmill.yaml created with default settings")); - - // Create lock file - await readLockfile(); - - // Offer to bind workspace profile to current branch - if (isGitRepository()) { + // Determine workspace binding before writing the template + if (isGitRepository() && branchName) { const activeWorkspace = await getActiveWorkspaceOrFallback( opts as GlobalOptions ); - const currentBranch = getCurrentGitBranch(); - if (activeWorkspace && currentBranch) { - // Determine binding behavior based on flags + if (activeWorkspace) { const shouldBind = opts.bindProfile === true; const shouldPrompt = opts.bindProfile === undefined && !!process.stdin.isTTY && !opts.useDefault; - const shouldSkip = opts.bindProfile != true && - (opts.useDefault || !!!process.stdin.isTTY); + (opts.useDefault || !process.stdin.isTTY); if (!shouldSkip) { - // Show workspace info if we're binding or prompting if (shouldBind || shouldPrompt) { log.info( - colors.yellow(`\nCurrent Git branch: ${colors.bold(currentBranch)}`) + colors.yellow(`\nCurrent Git branch: ${colors.bold(branchName)}`) ); log.info( colors.yellow( @@ -118,37 +95,31 @@ async function initAction(opts: InitOptions) { default: true, }))) ) { - // Update the config with workspace binding - const currentConfig = await import("../../core/conf.ts").then((m) => - m.readConfigFile() - ); - if (!currentConfig.gitBranches) { - currentConfig.gitBranches = {}; - } - if (!currentConfig.gitBranches[currentBranch]) { - currentConfig.gitBranches[currentBranch] = { overrides: {} }; - } - log.info( - `binding branch ${currentBranch} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}` - ); - currentConfig.gitBranches[currentBranch].baseUrl = - activeWorkspace.remote; - currentConfig.gitBranches[currentBranch].workspaceId = - activeWorkspace.workspaceId; - - await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8"); - - log.info( - colors.green( - `✓ Bound branch '${currentBranch}' to workspace '${activeWorkspace.name}'` - ) + `binding branch ${branchName} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}` ); + binding = { + baseUrl: activeWorkspace.remote, + workspaceId: activeWorkspace.workspaceId, + }; } } } } + await writeFile("wmill.yaml", generateCommentedTemplate(branchName, binding), "utf-8"); + log.info(colors.green("wmill.yaml created with default settings")); + if (binding) { + log.info( + colors.green( + `✓ Bound branch '${branchName}' to workspace` + ) + ); + } + + // Create lock file + await readLockfile(); + // Check for backend git-sync settings unless --use-default is specified if (!opts.useDefault) { try { diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts new file mode 100644 index 0000000000..0684b7ca14 --- /dev/null +++ b/cli/src/commands/init/template.ts @@ -0,0 +1,395 @@ +/** + * Configuration option descriptor — each entry IS a JSON Schema property + * with extra metadata for template rendering and reference table display. + * + * To generate the JSON Schema: iterate entries, strip NON_SCHEMA_KEYS, done. + * Sub-fields of complex types (codebases items, gitBranches branch config) + * are defined inline in the parent's schema — no duplicate entries needed. + * The reference table auto-expands nested schemas into rows. + * + * Adding a new option: + * 1. Add an entry to CONFIG_REFERENCE with JSON Schema type fields + description + * 2. Add template rendering hints (section, commented, templateValue, etc.) + * 3. `wmill init` (YAML template), `wmill config` (table), and wmill.schema.json all update automatically + */ +export interface ConfigOption { + // --- JSON Schema fields (kept when generating schema) --- + type: string; + description: string; + enum?: string[]; + items?: Record; + properties?: Record; + additionalProperties?: Record | boolean; + required?: string[]; + + // --- Non-schema metadata (stripped when generating schema) --- + name: string; + default: string; + + // --- Template rendering hints (also stripped) --- + section?: string; + sectionNote?: string; + commented?: boolean; + templateValue?: string; + example?: string; + inlineComment?: string; + groupNote?: string; +} + +/** Keys to strip from ConfigOption entries when generating JSON Schema. */ +const NON_SCHEMA_KEYS = new Set([ + "name", "default", + "section", "sectionNote", "commented", "templateValue", + "example", "inlineComment", "groupNote", +]); + +// Reusable sub-schemas for nested types +const SPECIFIC_ITEMS_SCHEMA = { + type: "object", + description: "Sync only specific items", + properties: { + variables: { type: "array", items: { type: "string" }, description: "Specific variable paths to sync" }, + resources: { type: "array", items: { type: "string" }, description: "Specific resource paths to sync" }, + triggers: { type: "array", items: { type: "string" }, description: "Specific trigger paths to sync" }, + folders: { type: "array", items: { type: "string" }, description: "Specific folder paths to sync" }, + settings: { type: "boolean", description: "Whether to sync settings" }, + }, + additionalProperties: false, +} as const; + +const BRANCH_CONFIG_SCHEMA = { + type: "object", + properties: { + baseUrl: { type: "string", description: "Windmill instance URL for this branch" }, + workspaceId: { type: "string", description: "Workspace ID to sync with for this branch" }, + overrides: { type: "object", description: "Override any top-level sync option for this branch" }, + promotionOverrides: { type: "object", description: "Overrides applied when using --promotion flag" }, + specificItems: SPECIFIC_ITEMS_SCHEMA, + }, + additionalProperties: false, +} as const; + +/** + * All wmill.yaml configuration options — single source of truth. + * Each entry is a JSON Schema property with extra metadata. + */ +export const CONFIG_REFERENCE: ConfigOption[] = [ + // ── Core ────────────────────────────────────────────────────────────── + { name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "Default TypeScript runtime for new scripts" }, + { name: "includes", type: "array", items: { type: "string" }, default: '["f/**"]', description: "Glob patterns for files to include in sync", + templateValue: '\n - "f/**"' }, + { name: "extraIncludes", type: "array", items: { type: "string" }, default: "[]", description: "Additional glob patterns merged with includes (useful in branch overrides)", + commented: true }, + { name: "excludes", type: "array", items: { type: "string" }, default: "[]", description: "Glob patterns for files to exclude from sync" }, + + // ── What to sync ────────────────────────────────────────────────────── + { name: "skipVariables", type: "boolean", default: "false", description: "Skip syncing variables", + section: "What to sync", sectionNote: '"skip" options default to false (synced), "include" options default to false (not synced)' }, + { name: "skipResources", type: "boolean", default: "false", description: "Skip syncing resources" }, + { name: "skipResourceTypes", type: "boolean", default: "false", description: "Skip syncing resource types" }, + { name: "skipSecrets", type: "boolean", default: "true", description: "Skip syncing secrets (true by default for security)", + inlineComment: "true by default — secrets are not synced for security" }, + { name: "skipScripts", type: "boolean", default: "false", description: "Skip syncing scripts" }, + { name: "skipFlows", type: "boolean", default: "false", description: "Skip syncing flows" }, + { name: "skipApps", type: "boolean", default: "false", description: "Skip syncing apps" }, + { name: "skipFolders", type: "boolean", default: "false", description: "Skip syncing folders" }, + { name: "skipWorkspaceDependencies", type: "boolean", default: "false", description: "Skip syncing workspace dependencies" }, + + { name: "includeSchedules", type: "boolean", default: "false", description: "Include schedules in sync", + commented: true, templateValue: "true", groupNote: "Uncomment to include these (excluded by default):" }, + { name: "includeTriggers", type: "boolean", default: "false", description: "Include triggers (http, websocket, kafka, etc.) in sync", + commented: true, templateValue: "true" }, + { name: "includeUsers", type: "boolean", default: "false", description: "Include workspace users in sync", + commented: true, templateValue: "true" }, + { name: "includeGroups", type: "boolean", default: "false", description: "Include workspace groups in sync", + commented: true, templateValue: "true" }, + { name: "includeSettings", type: "boolean", default: "false", description: "Include workspace settings in sync", + commented: true, templateValue: "true" }, + { name: "includeKey", type: "boolean", default: "false", description: "Include encryption key in sync", + commented: true, templateValue: "true" }, + + // ── Sync behavior ───────────────────────────────────────────────────── + { name: "parallel", type: "integer", default: "(unset)", description: "Number of parallel operations during sync", + section: "Sync behavior", commented: true, templateValue: "4" }, + { name: "locksRequired", type: "boolean", default: "false", description: "Require lock files for all scripts", + commented: true, templateValue: "true" }, + { name: "lint", type: "boolean", default: "false", description: "Run linting before push", + commented: true, templateValue: "true" }, + { name: "plainSecrets", type: "boolean", default: "false", description: "Handle secrets as plain text (not recommended)", + commented: true }, + { name: "message", type: "string", default: "(unset)", description: "Default commit message for sync operations", + commented: true, templateValue: '"my commit message"' }, + { name: "promotion", type: "string", default: "(unset)", description: "Branch name to use promotion overrides from during sync", + commented: true, templateValue: "staging" }, + { name: "skipBranchValidation", type: "boolean", default: "false", description: "Skip validation that current git branch matches a configured branch", + commented: true }, + { name: "nonDottedPaths", type: "boolean", default: "true", description: "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app" }, + + // ── Codebase bundling ───────────────────────────────────────────────── + { name: "codebases", type: "array", default: "[]", description: "Codebase bundling configurations for shared libraries", + items: { + type: "object", + properties: { + relative_path: { type: "string", description: "Path to the codebase directory" }, + includes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to include in bundle" }, + excludes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to exclude from bundle" }, + format: { type: "string", enum: ["cjs", "esm"], description: "Bundle output format" }, + external: { type: "array", items: { type: "string" }, description: "Dependencies to leave unbundled (externals)" }, + assets: { type: "array", items: { type: "object", properties: { from: { type: "string" }, to: { type: "string" } }, required: ["from", "to"] }, description: "Static files to copy into the bundle" }, + customBundler: { type: "string", description: "Path to a custom bundler script (replaces esbuild)" }, + inject: { type: "array", items: { type: "string" }, description: "Files to inject into every entry point" }, + define: { type: "object", additionalProperties: { type: "string" }, description: "Compile-time constant definitions" }, + banner: { type: "object", additionalProperties: { type: "string" }, description: "Text to prepend to output files by type" }, + loader: { type: "object", additionalProperties: { type: "string" }, description: "esbuild loader overrides by extension" }, + }, + required: ["relative_path"], + additionalProperties: false, + }, + section: "Codebase bundling (shared libraries)", + sectionNote: "Bundle TypeScript/JavaScript codebases that scripts import from.\nEach entry is bundled and uploaded so scripts can import shared code.", + example: [ + "# codebases:", + '# - relative_path: ./shared # path to the codebase', + '# includes: ["**/*.ts"] # files to include in bundle', + '# excludes: ["node_modules/**"] # files to exclude', + '# format: esm # bundle format: "cjs" or "esm"', + '# external: ["pg", "axios"] # dependencies to leave unbundled', + "# assets: # static files to copy into bundle", + "# - from: ./static", + "# to: ./dist", + "# # customBundler: ./build.ts # custom bundler script (replaces esbuild)", + '# # inject: ["./polyfills.ts"] # files to inject into every entry point', + "# # define: # compile-time constants", + "# # API_URL: '\"https://api.example.com\"'", + "# # banner: # text prepended to output files", + '# # js: "/* bundled by windmill */"', + "# # loader: # esbuild loader overrides", + '# # ".png": "dataurl"', + ].join("\n"), + }, + + // ── Git branches ────────────────────────────────────────────────────── + { name: "gitBranches", type: "object", default: "{}", description: "Map git branches to workspaces and per-branch sync overrides", + properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, + additionalProperties: BRANCH_CONFIG_SCHEMA, + section: "Git branch / environment bindings", + sectionNote: "Map git branches to Windmill workspaces and override settings per branch.\nUse \"environments\" as an alias if you prefer environment-based terminology.", + templateValue: "\n {{BRANCH}}:\n overrides: {}", + example: [ + "{{BASEURL_LINE}}", + "{{WORKSPACE_ID_LINE}}", + " # promotionOverrides: # overrides applied during --promotion", + " # skipSecrets: false", + " # specificItems: # only sync these specific items", + ' # variables: ["f/my_folder/my_var"]', + ' # resources: ["f/my_folder/my_res"]', + ' # triggers: ["f/my_folder/my_trigger"]', + ' # folders: ["my_folder"]', + " # settings: true", + "", + " # Example: staging branch bound to a different workspace", + " # staging:", + " # baseUrl: https://staging.windmill.dev", + " # workspaceId: staging-workspace", + " # overrides:", + " # skipSecrets: false", + " # includeSchedules: true", + "", + " # Items shared across ALL branches", + " # commonSpecificItems:", + ' # variables: ["f/shared/api_key"]', + ' # resources: ["f/shared/db_conn"]', + ' # folders: ["shared"]', + ].join("\n"), + }, + + { name: "environments", type: "object", default: "{}", description: "Alias for gitBranches — use if you prefer environment-based terminology", + properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, + additionalProperties: BRANCH_CONFIG_SCHEMA, + commented: true }, +]; + +// ─── Template generator ───────────────────────────────────────────────────── + +export interface BranchBinding { + baseUrl: string; + workspaceId: string; +} + +/** Quote a string for use as a YAML key if it contains special characters. */ +function yamlKey(s: string): string { + if ( + /^[a-zA-Z0-9_/.@-]+$/.test(s) && + !/^(true|false|yes|no|on|off|null|~)$/i.test(s) && + !/^\d+(\.\d+)?$/.test(s) + ) { + return s; + } + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function generateCommentedTemplate(branchName?: string, binding?: BranchBinding): string { + const branch = yamlKey(branchName ?? "main"); + const lines: string[] = [ + "# yaml-language-server: $schema=wmill.schema.json", + "# wmill.yaml — Windmill CLI configuration", + '# Full reference: run "wmill config"', + "", + ]; + + for (const opt of CONFIG_REFERENCE) { + if (opt.section) { + const ruler = "-".repeat(Math.max(0, 65 - opt.section.length)); + lines.push(`# --- ${opt.section} ${ruler}`); + if (opt.sectionNote) { + for (const noteLine of opt.sectionNote.split("\n")) { + lines.push(`# ${noteLine}`); + } + } + lines.push(""); + } + + if (opt.groupNote) { + lines.push(`# ${opt.groupNote}`); + } + + const value = opt.templateValue ?? opt.default; + const resolvedValue = value.replace("{{BRANCH}}", branch); + + if (opt.commented) { + lines.push(`# ${opt.description}`); + lines.push(`# ${opt.name}: ${resolvedValue}`); + } else { + lines.push(`# ${opt.description}`); + if (opt.inlineComment) { + const base = `${opt.name}: ${resolvedValue}`; + const pad = " ".repeat(Math.max(1, 32 - base.length)); + lines.push(`${base}${pad}# ${opt.inlineComment}`); + } else { + lines.push(`${opt.name}: ${resolvedValue}`); + } + } + + if (opt.example) { + let resolvedExample = opt.example.replace(/\{\{BRANCH\}\}/g, branch); + if (binding) { + resolvedExample = resolvedExample + .replace("{{BASEURL_LINE}}", ` baseUrl: ${binding.baseUrl}`) + .replace("{{WORKSPACE_ID_LINE}}", ` workspaceId: ${binding.workspaceId}`); + } else { + resolvedExample = resolvedExample + .replace("{{BASEURL_LINE}}", " # baseUrl: https://app.windmill.dev # Windmill instance URL for this branch") + .replace("{{WORKSPACE_ID_LINE}}", " # workspaceId: my-workspace # workspace to sync with"); + } + for (const exLine of resolvedExample.split("\n")) { + lines.push(exLine); + } + } + + lines.push(""); + } + + return lines.join("\n"); +} + +// ─── Reference formatters ─────────────────────────────────────────────────── + +/** Recursively expand a schema's properties into flat reference rows. */ +function expandSchema( + prefix: string, + schema: Record, + rows: { name: string; description: string; default: string }[] +): void { + if (schema.properties) { + for (const [key, prop] of Object.entries(schema.properties) as [string, Record][]) { + const name = prefix ? `${prefix}.${key}` : key; + rows.push({ name, description: prop.description ?? "", default: "" }); + // Recurse into nested object properties (e.g., specificItems) + if (prop.properties && prop.type === "object") { + expandSchema(name, prop, rows); + } + } + } +} + +export function formatConfigReference(): string { + const nameWidth = 48; + const descWidth = 70; + + const header = [ + "OPTION".padEnd(nameWidth), + "DESCRIPTION".padEnd(descWidth), + "DEFAULT", + ].join(" "); + + const separator = "-".repeat(header.length + 10); + + const allRows: { name: string; description: string; default: string }[] = []; + for (const opt of CONFIG_REFERENCE) { + allRows.push({ name: opt.name, description: opt.description, default: opt.default }); + + // Auto-expand array item properties (e.g., codebases[].*) + if (opt.items?.properties) { + expandSchema(`${opt.name}[]`, opt.items, allRows); + } + // Auto-expand additionalProperties (e.g., gitBranches..*) + if (opt.additionalProperties && typeof opt.additionalProperties === "object" && opt.additionalProperties.properties) { + expandSchema(`${opt.name}.`, opt.additionalProperties as Record, allRows); + } + // Auto-expand named properties (e.g., gitBranches.commonSpecificItems) + if (opt.properties) { + expandSchema(opt.name, opt, allRows); + } + } + + const rows = allRows.map((r) => + [r.name.padEnd(nameWidth), r.description.padEnd(descWidth), r.default].join(" ") + ); + + return [ + "wmill.yaml — Configuration Reference", + "", + "Full documentation: https://www.windmill.dev/docs/advanced/cli", + "", + separator, + header, + separator, + ...rows, + separator, + "", + 'Run "wmill init" to generate a wmill.yaml with commented examples.', + ].join("\n"); +} + +export function formatConfigReferenceJson(): string { + const clean = CONFIG_REFERENCE.map((opt) => ({ + name: opt.name, type: opt.type, default: opt.default, description: opt.description, + })); + return JSON.stringify(clean, null, 2); +} + +// ─── JSON Schema generator ────────────────────────────────────────────────── + +/** + * Generate a JSON Schema for wmill.yaml by stripping non-schema keys from CONFIG_REFERENCE. + */ +export function generateJsonSchema(): Record { + const properties: Record = {}; + for (const opt of CONFIG_REFERENCE) { + const entry: Record = {}; + for (const [k, v] of Object.entries(opt)) { + if (!NON_SCHEMA_KEYS.has(k) && k !== "name") { + entry[k] = v; + } + } + properties[opt.name] = entry; + } + return { + $schema: "http://json-schema.org/draft-07/schema#", + title: "wmill.yaml", + description: "Windmill CLI configuration file. Full reference: wmill config", + type: "object", + properties, + additionalProperties: false, + }; +} diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 81c87dd05a..7da9abd72e 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -4999,6 +4999,13 @@ app related commands - \`--dry-run\` - Perform a dry run without making changes - \`--default-ts \` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- \`--json\` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands diff --git a/cli/src/main.ts b/cli/src/main.ts index 23e921d0b8..5126bc6c92 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -41,6 +41,7 @@ import init from "./commands/init/init.ts"; import jobs from "./commands/jobs/jobs.ts"; import generateMetadata from "./commands/generate-metadata/generate-metadata.ts"; import docs from "./commands/docs/docs.ts"; +import config from "./commands/config/config.ts"; import { fetchVersion } from "./core/context.ts"; export { @@ -62,6 +63,7 @@ export { instance, dev, docs, + config, hubPull, pull, push, @@ -132,6 +134,7 @@ const command = new Command() .command("jobs", jobs) .command("generate-metadata", generateMetadata) .command("docs", docs) + .command("config", config) .command("version --version", "Show version information") .action(async (opts: any) => { console.log("CLI version: " + VERSION); diff --git a/cli/test/init_template.test.ts b/cli/test/init_template.test.ts new file mode 100644 index 0000000000..efb6d09891 --- /dev/null +++ b/cli/test/init_template.test.ts @@ -0,0 +1,244 @@ +/** + * Unit tests for wmill.yaml template generation, config reference, and JSON Schema. + */ + +import { expect, test, describe } from "bun:test"; +import { parse } from "yaml"; +import Ajv from "ajv"; +import { + generateCommentedTemplate, + generateJsonSchema, + formatConfigReference, + formatConfigReferenceJson, + CONFIG_REFERENCE, +} from "../src/commands/init/template.ts"; + +// ============================================================================= +// generateCommentedTemplate +// ============================================================================= + +describe("generateCommentedTemplate", () => { + test("produces valid YAML that parses without errors", () => { + const yaml = generateCommentedTemplate("main"); + const config = parse(yaml); + expect(config).toBeDefined(); + expect(typeof config).toBe("object"); + }); + + test("uses provided branch name in gitBranches", () => { + const config = parse(generateCommentedTemplate("my-feature")); + expect(config.gitBranches["my-feature"]).toBeDefined(); + expect(config.gitBranches["my-feature"].overrides).toEqual({}); + }); + + test("defaults to 'main' when no branch name given", () => { + const config = parse(generateCommentedTemplate()); + expect(config.gitBranches["main"]).toBeDefined(); + }); + + test("quotes branch names with YAML-special characters", () => { + const specialBranches = ["fix: something", "feat/my branch", "release#1"]; + for (const branch of specialBranches) { + const yaml = generateCommentedTemplate(branch); + const config = parse(yaml); + expect(config.gitBranches[branch]).toBeDefined(); + } + }); + + test("contains yaml-language-server schema directive", () => { + const yaml = generateCommentedTemplate("main"); + expect(yaml.startsWith("# yaml-language-server: $schema=wmill.schema.json")).toBe(true); + }); + + test("includes all non-commented CONFIG_REFERENCE entries as active YAML keys", () => { + const config = parse(generateCommentedTemplate("main")); + for (const opt of CONFIG_REFERENCE) { + if (!opt.commented) { + expect(config).toHaveProperty(opt.name); + } + } + }); + + test("does not include commented entries as active YAML keys", () => { + const config = parse(generateCommentedTemplate("main")); + for (const opt of CONFIG_REFERENCE) { + if (opt.commented && opt.name !== "environments") { + expect(config[opt.name]).toBeUndefined(); + } + } + }); + + test("default values match expected defaults", () => { + const config = parse(generateCommentedTemplate("main")); + expect(config.defaultTs).toBe("bun"); + expect(config.skipSecrets).toBe(true); + expect(config.nonDottedPaths).toBe(true); + expect(config.codebases).toEqual([]); + expect(config.excludes).toEqual([]); + expect(config.includes).toEqual(["f/**"]); + }); +}); + +// ============================================================================= +// generateJsonSchema +// ============================================================================= + +describe("generateJsonSchema", () => { + const schema = generateJsonSchema(); + + test("is a valid JSON Schema draft-07", () => { + expect(schema.$schema).toBe("http://json-schema.org/draft-07/schema#"); + expect(schema.type).toBe("object"); + expect(schema.properties).toBeDefined(); + }); + + test("validates the generated YAML template", () => { + const config = parse(generateCommentedTemplate("main")); + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate(config)).toBe(true); + }); + + test("rejects unknown keys", () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate({ unknownOption: true })).toBe(false); + }); + + test("rejects invalid enum values", () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate({ defaultTs: "python" })).toBe(false); + }); + + test("rejects wrong types", () => { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schema); + expect(validate({ skipSecrets: "yes" })).toBe(false); + }); + + test("includes codebases array schema with item properties", () => { + expect(schema.properties.codebases.type).toBe("array"); + expect(schema.properties.codebases.items.properties.relative_path).toBeDefined(); + expect(schema.properties.codebases.items.required).toContain("relative_path"); + }); + + test("includes gitBranches with branch config schema", () => { + const branchSchema = schema.properties.gitBranches.additionalProperties; + expect(branchSchema.properties.baseUrl).toBeDefined(); + expect(branchSchema.properties.workspaceId).toBeDefined(); + expect(branchSchema.properties.specificItems).toBeDefined(); + expect(branchSchema.properties.specificItems.properties.variables).toBeDefined(); + }); + + test("includes environments as alias for gitBranches", () => { + expect(schema.properties.environments).toBeDefined(); + expect(schema.properties.environments.additionalProperties).toEqual( + schema.properties.gitBranches.additionalProperties + ); + }); + + test("does not contain template-only keys in schema output", () => { + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + const json = JSON.stringify(schema); + for (const key of templateKeys) { + expect(json).not.toContain(`"${key}"`); + } + }); +}); + +// ============================================================================= +// formatConfigReference +// ============================================================================= + +describe("formatConfigReference", () => { + const output = formatConfigReference(); + + test("includes header row", () => { + expect(output).toContain("OPTION"); + expect(output).toContain("DESCRIPTION"); + expect(output).toContain("DEFAULT"); + }); + + test("includes all top-level CONFIG_REFERENCE entries", () => { + for (const opt of CONFIG_REFERENCE) { + expect(output).toContain(opt.name); + } + }); + + test("auto-expands codebases sub-fields", () => { + expect(output).toContain("codebases[].relative_path"); + expect(output).toContain("codebases[].format"); + expect(output).toContain("codebases[].external"); + }); + + test("auto-expands gitBranches sub-fields", () => { + expect(output).toContain("gitBranches..baseUrl"); + expect(output).toContain("gitBranches..workspaceId"); + expect(output).toContain("gitBranches..specificItems.variables"); + }); + + test("auto-expands commonSpecificItems sub-fields", () => { + expect(output).toContain("gitBranches.commonSpecificItems.variables"); + expect(output).toContain("gitBranches.commonSpecificItems.settings"); + }); +}); + +// ============================================================================= +// formatConfigReferenceJson +// ============================================================================= + +describe("formatConfigReferenceJson", () => { + test("produces valid JSON", () => { + const parsed = JSON.parse(formatConfigReferenceJson()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBe(CONFIG_REFERENCE.length); + }); + + test("each entry has name, type, default, description", () => { + const parsed = JSON.parse(formatConfigReferenceJson()); + for (const entry of parsed) { + expect(entry).toHaveProperty("name"); + expect(entry).toHaveProperty("type"); + expect(entry).toHaveProperty("default"); + expect(entry).toHaveProperty("description"); + } + }); + + test("does not contain template-only keys", () => { + const parsed = JSON.parse(formatConfigReferenceJson()); + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + for (const entry of parsed) { + for (const key of templateKeys) { + expect(entry).not.toHaveProperty(key); + } + } + }); +}); + +// ============================================================================= +// CONFIG_REFERENCE integrity +// ============================================================================= + +describe("CONFIG_REFERENCE integrity", () => { + test("all entries have required fields", () => { + for (const opt of CONFIG_REFERENCE) { + expect(opt.name).toBeTruthy(); + expect(opt.type).toBeTruthy(); + expect(opt.description).toBeTruthy(); + expect(opt.default).toBeDefined(); + } + }); + + test("no duplicate names", () => { + const names = CONFIG_REFERENCE.map((o) => o.name); + expect(new Set(names).size).toBe(names.length); + }); + + test("type field uses valid JSON Schema types", () => { + const validTypes = new Set(["boolean", "string", "integer", "number", "array", "object"]); + for (const opt of CONFIG_REFERENCE) { + expect(validTypes.has(opt.type)).toBe(true); + } + }); +}); diff --git a/cli/wmill.schema.json b/cli/wmill.schema.json new file mode 100644 index 0000000000..7129563f3f --- /dev/null +++ b/cli/wmill.schema.json @@ -0,0 +1,439 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "wmill.yaml", + "description": "Windmill CLI configuration file. Full reference: wmill config", + "type": "object", + "properties": { + "defaultTs": { + "type": "string", + "enum": [ + "bun", + "deno" + ], + "description": "Default TypeScript runtime for new scripts" + }, + "includes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to include in sync" + }, + "extraIncludes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional glob patterns merged with includes (useful in branch overrides)" + }, + "excludes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to exclude from sync" + }, + "skipVariables": { + "type": "boolean", + "description": "Skip syncing variables" + }, + "skipResources": { + "type": "boolean", + "description": "Skip syncing resources" + }, + "skipResourceTypes": { + "type": "boolean", + "description": "Skip syncing resource types" + }, + "skipSecrets": { + "type": "boolean", + "description": "Skip syncing secrets (true by default for security)" + }, + "skipScripts": { + "type": "boolean", + "description": "Skip syncing scripts" + }, + "skipFlows": { + "type": "boolean", + "description": "Skip syncing flows" + }, + "skipApps": { + "type": "boolean", + "description": "Skip syncing apps" + }, + "skipFolders": { + "type": "boolean", + "description": "Skip syncing folders" + }, + "skipWorkspaceDependencies": { + "type": "boolean", + "description": "Skip syncing workspace dependencies" + }, + "includeSchedules": { + "type": "boolean", + "description": "Include schedules in sync" + }, + "includeTriggers": { + "type": "boolean", + "description": "Include triggers (http, websocket, kafka, etc.) in sync" + }, + "includeUsers": { + "type": "boolean", + "description": "Include workspace users in sync" + }, + "includeGroups": { + "type": "boolean", + "description": "Include workspace groups in sync" + }, + "includeSettings": { + "type": "boolean", + "description": "Include workspace settings in sync" + }, + "includeKey": { + "type": "boolean", + "description": "Include encryption key in sync" + }, + "parallel": { + "type": "integer", + "description": "Number of parallel operations during sync" + }, + "locksRequired": { + "type": "boolean", + "description": "Require lock files for all scripts" + }, + "lint": { + "type": "boolean", + "description": "Run linting before push" + }, + "plainSecrets": { + "type": "boolean", + "description": "Handle secrets as plain text (not recommended)" + }, + "message": { + "type": "string", + "description": "Default commit message for sync operations" + }, + "promotion": { + "type": "string", + "description": "Branch name to use promotion overrides from during sync" + }, + "skipBranchValidation": { + "type": "boolean", + "description": "Skip validation that current git branch matches a configured branch" + }, + "nonDottedPaths": { + "type": "boolean", + "description": "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app" + }, + "codebases": { + "type": "array", + "description": "Codebase bundling configurations for shared libraries", + "items": { + "type": "object", + "properties": { + "relative_path": { + "type": "string", + "description": "Path to the codebase directory" + }, + "includes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to include in bundle" + }, + "excludes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files to exclude from bundle" + }, + "format": { + "type": "string", + "enum": [ + "cjs", + "esm" + ], + "description": "Bundle output format" + }, + "external": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Dependencies to leave unbundled (externals)" + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + }, + "required": [ + "from", + "to" + ] + }, + "description": "Static files to copy into the bundle" + }, + "customBundler": { + "type": "string", + "description": "Path to a custom bundler script (replaces esbuild)" + }, + "inject": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Files to inject into every entry point" + }, + "define": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Compile-time constant definitions" + }, + "banner": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Text to prepend to output files by type" + }, + "loader": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "esbuild loader overrides by extension" + } + }, + "required": [ + "relative_path" + ], + "additionalProperties": false + } + }, + "gitBranches": { + "type": "object", + "description": "Map git branches to workspaces and per-branch sync overrides", + "properties": { + "commonSpecificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this branch" + }, + "workspaceId": { + "type": "string", + "description": "Workspace ID to sync with for this branch" + }, + "overrides": { + "type": "object", + "description": "Override any top-level sync option for this branch" + }, + "promotionOverrides": { + "type": "object", + "description": "Overrides applied when using --promotion flag" + }, + "specificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "environments": { + "type": "object", + "description": "Alias for gitBranches — use if you prefer environment-based terminology", + "properties": { + "commonSpecificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this branch" + }, + "workspaceId": { + "type": "string", + "description": "Workspace ID to sync with for this branch" + }, + "overrides": { + "type": "object", + "description": "Override any top-level sync option for this branch" + }, + "promotionOverrides": { + "type": "object", + "description": "Overrides applied when using --promotion flag" + }, + "specificItems": { + "type": "object", + "description": "Sync only specific items", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific variable paths to sync" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific resource paths to sync" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific trigger paths to sync" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specific folder paths to sync" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index d76e31ded0..c582a277b4 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -41,6 +41,13 @@ app related commands - `--dry-run` - Perform a dry run without making changes - `--default-ts ` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- `--json` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index dc47b66eca..106c629021 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1568,6 +1568,13 @@ app related commands - \`--dry-run\` - Perform a dry run without making changes - \`--default-ts \` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- \`--json\` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 8a9f231fc2..30c31c4bcb 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -46,6 +46,13 @@ app related commands - `--dry-run` - Perform a dry run without making changes - `--default-ts ` - Default TypeScript runtime (bun or deno) +### config + +Show all available wmill.yaml configuration options + +**Options:** +- `--json` - Output as JSON for programmatic consumption + ### dependencies workspace dependencies related commands