From 5a6299202c89e8d4853772313ee91a21f5ebe866 Mon Sep 17 00:00:00 2001 From: tommy gingras Date: Thu, 19 Feb 2026 21:29:09 -0500 Subject: [PATCH 1/2] feat: add inline error markers in YAML editor Structured error objects with line numbers replace plain strings. Gutter dots (red/orange) and line highlights show exactly where YAML syntax errors and validation warnings occur. Heuristic adjusts js-yaml's reported line to the actual cause for missing colons and unclosed quotes. Theme choice persists across reloads. Co-Authored-By: Claude --- css/styles.css | 40 +++++++++ index.html | 3 +- js/app.mjs | 20 +++-- js/editor.mjs | 27 +++++- js/parser.mjs | 236 +++++++++++++++++++++++++++++++++++++++---------- 5 files changed, 270 insertions(+), 56 deletions(-) diff --git a/css/styles.css b/css/styles.css index 90e5561..d571880 100644 --- a/css/styles.css +++ b/css/styles.css @@ -207,6 +207,46 @@ body { background: var(--surface); } +/* Error gutter and line markers */ + +.error-gutter { + width: 0.5rem; +} + +.gutter-error, +.gutter-warning { + width: 6px; + height: 6px; + border-radius: 50%; + margin: 0.55rem auto 0; +} + +.gutter-error { + background: var(--error); +} + +.gutter-warning { + background: #d97706; +} + +[data-theme="dark"] .gutter-warning { + background: #f59e0b; +} + +.cm-error-line { + background: var(--error-bg) !important; +} + +.cm-warning-line { + background: #fffbeb !important; +} + +[data-theme="dark"] .cm-warning-line { + background: #1a1500 !important; +} + +/* Error bar */ + .error-bar { background: var(--error-bg); color: var(--error); diff --git a/index.html b/index.html index ecd65ea..53c5cbe 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,11 @@ - + Firegen + diff --git a/js/app.mjs b/js/app.mjs index 304d624..c60b739 100644 --- a/js/app.mjs +++ b/js/app.mjs @@ -43,12 +43,13 @@ function init() { // Process the initial sample handleYamlChange(SAMPLE_YAML); - // Theme toggle + // Theme toggle with persistence themeToggle.addEventListener("click", () => { const html = document.documentElement; const isDark = html.getAttribute("data-theme") === "dark"; const newTheme = isDark ? "light" : "dark"; html.setAttribute("data-theme", newTheme); + localStorage.setItem("firegen-theme", newTheme); editor.setTheme(newTheme === "dark"); setOutputTheme(newTheme === "dark"); }); @@ -87,11 +88,12 @@ function init() { /** * Show errors/warnings in the collapsible error bar. + * Accepts structured objects with .message property. */ function showErrorBar(messages, isError) { const count = messages.length; const label = isError ? "error" : "warning"; - const first = messages[0]; + const first = messages[0].message; if (count === 1) { errorText.textContent = first; @@ -101,7 +103,7 @@ function init() { errorToggle.hidden = false; } - errorDetails.textContent = messages.join("\n"); + errorDetails.textContent = messages.map((m) => m.message).join("\n"); errorDetails.hidden = true; errorToggle.setAttribute("aria-expanded", "false"); errorBar.hidden = false; @@ -109,11 +111,19 @@ function init() { /** * Handle YAML editor content changes. - * Parse, expand templates, generate commands, update output tabs. + * Parse, expand templates, generate commands, update output tabs and markers. */ function handleYamlChange(yamlString) { + editor.clearMarkers(); + const { config, errors, warnings } = parseConfig(yamlString); + // Collect markers from errors and warnings with known line numbers + const markers = [...errors, ...warnings].filter((m) => m.line !== null); + if (markers.length > 0) { + editor.setMarkers(markers); + } + // Display errors if (errors.length > 0) { showErrorBar(errors, true); @@ -125,7 +135,7 @@ function init() { // Display warnings let warningHeader = ""; if (warnings.length > 0) { - warningHeader = warnings.map((w) => `# WARNING: ${w}`).join("\n") + "\n\n"; + warningHeader = warnings.map((w) => `# WARNING: ${w.message}`).join("\n") + "\n\n"; showErrorBar(warnings, false); } else { errorBar.hidden = true; diff --git a/js/editor.mjs b/js/editor.mjs index 42635f0..9ed980d 100644 --- a/js/editor.mjs +++ b/js/editor.mjs @@ -28,7 +28,8 @@ export function initEditor(container, { value = "", onChange = null, debounceMs tabSize: 2, indentWithTabs: false, indentUnit: 2, - placeholder: "# Enter your firewalld YAML configuration here...", + gutters: ["CodeMirror-linenumbers", "error-gutter"], + placeholder: "# Enter your firegen YAML configuration here...", extraKeys: { "Ctrl-Space": "autocomplete", // Tab inserts spaces instead of tab character @@ -79,6 +80,9 @@ export function initEditor(container, { value = "", onChange = null, debounceMs } }); + // Track lines with classes so clearMarkers can remove them + let markedLines = []; + return { getValue() { return editorInstance.getValue(); @@ -92,5 +96,26 @@ export function initEditor(container, { value = "", onChange = null, debounceMs refresh() { editorInstance.refresh(); }, + /** Set gutter markers and line highlights for errors/warnings. */ + setMarkers(markers) { + for (const { line, severity } of markers) { + const dot = document.createElement("div"); + dot.className = severity === "error" ? "gutter-error" : "gutter-warning"; + dot.title = markers.filter((m) => m.line === line).map((m) => m.message).join("\n"); + editorInstance.setGutterMarker(line, "error-gutter", dot); + + const lineClass = severity === "error" ? "cm-error-line" : "cm-warning-line"; + editorInstance.addLineClass(line, "wrap", lineClass); + markedLines.push({ line, lineClass }); + } + }, + /** Remove all gutter markers and line highlights. */ + clearMarkers() { + editorInstance.clearGutter("error-gutter"); + for (const { line, lineClass } of markedLines) { + editorInstance.removeLineClass(line, "wrap", lineClass); + } + markedLines = []; + }, }; } diff --git a/js/parser.mjs b/js/parser.mjs index 7f1cd35..3603170 100644 --- a/js/parser.mjs +++ b/js/parser.mjs @@ -8,6 +8,129 @@ import { VALID_RULE_GROUP_RULE_KEYS, VALID_TARGETS, } from "./schema.mjs"; +/** + * Find the 0-based line number of a key in raw YAML text. + * Searches for `key:` (mapping) or `- key` (sequence item) patterns. + * Uses optional parentKey to narrow scope to the correct section. + * Returns 0-based line number or null if not found. + */ +function findKeyLine(yamlString, key, parentKey) { + const lines = yamlString.split("\n"); + + let searchStart = 0; + let parentIndent = -1; + + // If parentKey provided, find it first and search below it + if (parentKey) { + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trimStart(); + if (trimmed.startsWith(`${parentKey}:`) || trimmed.startsWith(`${parentKey} :`)) { + searchStart = i + 1; + parentIndent = lines[i].length - trimmed.length; + break; + } + } + } + + const keyPattern = new RegExp(`^(\\s*(-\\s+)?)(${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})\\s*:`); + + for (let i = searchStart; i < lines.length; i++) { + const match = lines[i].match(keyPattern); + if (match) { + // If scoped by parent, ensure this line is indented deeper + if (parentKey && parentIndent >= 0) { + const lineIndent = match[1].replace(/-\s+$/, "").length; + if (lineIndent <= parentIndent) break; // Left parent scope + if (lineIndent > parentIndent) return i; + } else { + return i; + } + } + // If scoped by parent, stop if we reach another key at same/lesser indent + if (parentKey && parentIndent >= 0 && i > searchStart) { + const trimmed = lines[i].trimStart(); + if (trimmed.length > 0 && !trimmed.startsWith("#")) { + const lineIndent = lines[i].length - trimmed.length; + if (lineIndent <= parentIndent && trimmed.includes(":")) break; + } + } + } + + return null; +} + +/** + * Adjust js-yaml syntax error line to point at the actual cause. + * + * js-yaml reports the detection point, not where the mistake is: + * - Missing colon: mark points to the NEXT line (unexpected indentation) + * - Missing quote: mark points past EOF (stream ended inside literal) + * + * This heuristic corrects the line for common cases. + */ +function adjustSyntaxErrorLine(line, reason, yamlString) { + if (line === null || !reason) return line; + + const lines = yamlString.split("\n"); + const lastLine = lines.length - 1; + + // Missing quote: error detected at EOF. Find the line with the unclosed quote. + // Patterns: "double quoted scalar", "single quoted scalar" + if (reason.includes("quoted scalar")) { + const quoteChar = reason.includes("double") ? '"' : "'"; + for (let i = lastLine; i >= 0; i--) { + const count = lines[i].split(quoteChar).length - 1; + if (count % 2 !== 0) return i; + } + } + + // Colon / mapping errors — covers missing colon AND unclosed quotes that + // masquerade as colon errors (js-yaml consumes lines into the string, + // then reports "colon is missed" or "implicit mapping" at a later line). + if ( + reason.includes("document separator") || + reason.includes("bad indentation") || + reason.includes("implicit key") || + reason.includes("implicit mapping") || + reason.includes("colon is missed") + ) { + // First check: unclosed quote before the error line (masked quote error) + const searchTo = Math.min(line, lastLine); + for (let i = searchTo; i >= 0; i--) { + for (const q of ['"', "'"]) { + const count = lines[i].split(q).length - 1; + if (count % 2 !== 0) return i; + } + } + + // No unclosed quote — walk backwards to find a bare key missing its colon + for (let i = searchTo - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + if (!trimmed.includes(":")) return i; + break; + } + // Fallback: one line back if in range + if (line > 0) return line - 1; + } + + return line; +} + +/** + * Create a structured error object. + */ +function err(message, line) { + return { message, line: line ?? null, severity: "error" }; +} + +/** + * Create a structured warning object. + */ +function warn(message, line) { + return { message, line: line ?? null, severity: "warning" }; +} + /** * Levenshtein distance between two strings. * Used to suggest corrections for misspelled keys. @@ -68,17 +191,23 @@ export function parseConfig(yamlString) { const warnings = []; if (!yamlString || !yamlString.trim()) { - return { config: null, variables: {}, errors: ["Empty configuration"], warnings }; + return { config: null, variables: {}, errors: [err("Empty configuration")], warnings }; } let raw; try { raw = jsyaml.load(yamlString); } catch (e) { + let line = e.mark ? e.mark.line : null; + const lastLine = yamlString.split("\n").length - 1; + // Clamp to valid line range — js-yaml sometimes reports past EOF + if (line !== null && line > lastLine) line = lastLine; + // Adjust line to point at the actual cause, not the detection point + line = adjustSyntaxErrorLine(line, e.reason, yamlString); return { config: null, variables: {}, - errors: [`YAML syntax error: ${e.message}`], + errors: [err(`YAML syntax error: ${e.message}`, line)], warnings, }; } @@ -87,7 +216,7 @@ export function parseConfig(yamlString) { return { config: null, variables: {}, - errors: ["Configuration must be a YAML mapping (object)"], + errors: [err("Configuration must be a YAML mapping (object)")], warnings, }; } @@ -95,41 +224,43 @@ export function parseConfig(yamlString) { // Warn on unknown top-level keys with typo suggestions for (const key of Object.keys(raw)) { if (!VALID_TOP_KEYS.has(key)) { + const line = findKeyLine(yamlString, key); const suggestion = suggestKey(key, VALID_TOP_KEYS); if (suggestion) { - warnings.push(`Unknown top-level key '${key}' — did you mean '${suggestion}'?`); + warnings.push(warn(`Unknown top-level key '${key}' — did you mean '${suggestion}'?`, line)); } else { - warnings.push(`Unknown top-level key: '${key}'`); + warnings.push(warn(`Unknown top-level key: '${key}'`, line)); } } } // Validate variables block if (raw.variables !== undefined && (typeof raw.variables !== "object" || Array.isArray(raw.variables))) { - errors.push("'variables' must be a mapping"); + errors.push(err("'variables' must be a mapping", findKeyLine(yamlString, "variables"))); } // Validate zones block if (raw.zones !== undefined) { if (typeof raw.zones !== "object" || Array.isArray(raw.zones)) { - errors.push("'zones' must be a mapping of zone names to zone configs"); + errors.push(err("'zones' must be a mapping of zone names to zone configs", findKeyLine(yamlString, "zones"))); } else { for (const [zoneName, zone] of Object.entries(raw.zones)) { if (typeof zone !== "object" || Array.isArray(zone) || zone === null) { - errors.push(`Zone '${zoneName}' must be a mapping`); + errors.push(err(`Zone '${zoneName}' must be a mapping`, findKeyLine(yamlString, zoneName, "zones"))); continue; } for (const key of Object.keys(zone)) { if (!VALID_ZONE_KEYS.has(key)) { + const line = findKeyLine(yamlString, key, zoneName); const suggestion = suggestKey(key, VALID_ZONE_KEYS); if (suggestion) { - warnings.push(`Zone '${zoneName}': unknown key '${key}' — did you mean '${suggestion}'?`); + warnings.push(warn(`Zone '${zoneName}': unknown key '${key}' — did you mean '${suggestion}'?`, line)); } else { - warnings.push(`Zone '${zoneName}': unknown key '${key}'`); + warnings.push(warn(`Zone '${zoneName}': unknown key '${key}'`, line)); } } } - validateZone(zoneName, zone, errors, warnings); + validateZone(zoneName, zone, yamlString, errors, warnings); } } } @@ -137,19 +268,20 @@ export function parseConfig(yamlString) { // Validate direct block if (raw.direct !== undefined) { if (typeof raw.direct !== "object" || Array.isArray(raw.direct) || raw.direct === null) { - errors.push("'direct' must be a mapping"); + errors.push(err("'direct' must be a mapping", findKeyLine(yamlString, "direct"))); } else { for (const key of Object.keys(raw.direct)) { if (!VALID_DIRECT_KEYS.has(key)) { + const line = findKeyLine(yamlString, key, "direct"); const suggestion = suggestKey(key, VALID_DIRECT_KEYS); if (suggestion) { - warnings.push(`Direct: unknown key '${key}' — did you mean '${suggestion}'?`); + warnings.push(warn(`Direct: unknown key '${key}' — did you mean '${suggestion}'?`, line)); } else { - warnings.push(`Direct: unknown key '${key}'`); + warnings.push(warn(`Direct: unknown key '${key}'`, line)); } } } - validateDirect(raw.direct, errors, warnings); + validateDirect(raw.direct, yamlString, errors, warnings); } } @@ -159,7 +291,9 @@ export function parseConfig(yamlString) { // Process templates (variable substitution + loop expansion) const { config, variables, warnings: templateWarnings } = processTemplate(raw); - warnings.push(...templateWarnings); + for (const tw of templateWarnings) { + warnings.push(typeof tw === "string" ? warn(tw) : tw); + } return { config, variables, errors: [], warnings }; } @@ -169,29 +303,33 @@ export function parseConfig(yamlString) { * @param {string} context - label for warnings (e.g., "Zone 'public' port") * @param {object} obj - object to check * @param {Set} validKeys - allowed keys - * @param {string[]} warnings - collector + * @param {string} yamlString - raw YAML for line lookup + * @param {string} parentKey - parent key for scoped line search + * @param {object[]} warnings - collector */ -function warnUnknownKeys(context, obj, validKeys, warnings) { +function warnUnknownKeys(context, obj, validKeys, yamlString, parentKey, warnings) { if (!obj || typeof obj !== "object" || Array.isArray(obj)) return; for (const key of Object.keys(obj)) { if (!validKeys.has(key)) { + const line = findKeyLine(yamlString, key, parentKey); const suggestion = suggestKey(key, validKeys); if (suggestion) { - warnings.push(`${context}: unknown key '${key}' — did you mean '${suggestion}'?`); + warnings.push(warn(`${context}: unknown key '${key}' — did you mean '${suggestion}'?`, line)); } else { - warnings.push(`${context}: unknown key '${key}'`); + warnings.push(warn(`${context}: unknown key '${key}'`, line)); } } } } -function validateZone(name, zone, errors, warnings) { +function validateZone(name, zone, yamlString, errors, warnings) { const ctx = `Zone '${name}'`; if (zone.target !== undefined) { const targetStr = String(zone.target); if (!VALID_TARGETS.includes(targetStr)) { - warnings.push(`${ctx}: target '${targetStr}' is not one of ${VALID_TARGETS.join(", ")}`); + const line = findKeyLine(yamlString, "target", name); + warnings.push(warn(`${ctx}: target '${targetStr}' is not one of ${VALID_TARGETS.join(", ")}`, line)); } } @@ -202,7 +340,7 @@ function validateZone(name, zone, errors, warnings) { for (const field of arrayFields) { if (zone[field] !== undefined && !Array.isArray(zone[field])) { - errors.push(`${ctx}: '${field}' must be an array`); + errors.push(err(`${ctx}: '${field}' must be an array`, findKeyLine(yamlString, field, name))); } } @@ -210,9 +348,9 @@ function validateZone(name, zone, errors, warnings) { if (Array.isArray(zone.ports)) { for (const p of zone.ports) { if (p !== null && typeof p === "object" && !Array.isArray(p)) { - warnUnknownKeys(`${ctx} port`, p, VALID_PORT_KEYS, warnings); + warnUnknownKeys(`${ctx} port`, p, VALID_PORT_KEYS, yamlString, "ports", warnings); if (p.port === undefined) { - warnings.push(`${ctx}: port entry missing 'port' field`); + warnings.push(warn(`${ctx}: port entry missing 'port' field`, findKeyLine(yamlString, "ports", name))); } } } @@ -222,9 +360,9 @@ function validateZone(name, zone, errors, warnings) { if (Array.isArray(zone.source_ports)) { for (const sp of zone.source_ports) { if (sp !== null && typeof sp === "object" && !Array.isArray(sp)) { - warnUnknownKeys(`${ctx} source_port`, sp, VALID_PORT_KEYS, warnings); + warnUnknownKeys(`${ctx} source_port`, sp, VALID_PORT_KEYS, yamlString, "source_ports", warnings); if (sp.port === undefined) { - warnings.push(`${ctx}: source_port entry missing 'port' field`); + warnings.push(warn(`${ctx}: source_port entry missing 'port' field`, findKeyLine(yamlString, "source_ports", name))); } } } @@ -234,9 +372,9 @@ function validateZone(name, zone, errors, warnings) { if (Array.isArray(zone.forward_ports)) { for (const fp of zone.forward_ports) { if (fp !== null && typeof fp === "object" && !Array.isArray(fp)) { - warnUnknownKeys(`${ctx} forward_port`, fp, VALID_FORWARD_PORT_KEYS, warnings); + warnUnknownKeys(`${ctx} forward_port`, fp, VALID_FORWARD_PORT_KEYS, yamlString, "forward_ports", warnings); if (fp.port === undefined) { - warnings.push(`${ctx}: forward_port entry missing 'port' field`); + warnings.push(warn(`${ctx}: forward_port entry missing 'port' field`, findKeyLine(yamlString, "forward_ports", name))); } } } @@ -246,26 +384,26 @@ function validateZone(name, zone, errors, warnings) { if (Array.isArray(zone.rich_rules)) { for (const rule of zone.rich_rules) { if (rule !== null && typeof rule === "object" && !Array.isArray(rule)) { - warnUnknownKeys(`${ctx} rich_rule`, rule, VALID_RICH_RULE_KEYS, warnings); + warnUnknownKeys(`${ctx} rich_rule`, rule, VALID_RICH_RULE_KEYS, yamlString, "rich_rules", warnings); if (!rule.action && !rule.log && !rule.audit && !rule.masquerade) { - warnings.push(`${ctx}: rich_rule has no action, log, audit, or masquerade`); + warnings.push(warn(`${ctx}: rich_rule has no action, log, audit, or masquerade`, findKeyLine(yamlString, "rich_rules", name))); } } } } } -function validateDirect(direct, errors, warnings) { +function validateDirect(direct, yamlString, errors, warnings) { // Chains if (direct.chains !== undefined) { if (!Array.isArray(direct.chains)) { - errors.push("'direct.chains' must be an array"); + errors.push(err("'direct.chains' must be an array", findKeyLine(yamlString, "chains", "direct"))); } else { for (const chain of direct.chains) { if (chain !== null && typeof chain === "object") { - warnUnknownKeys("Direct chain", chain, VALID_DIRECT_CHAIN_KEYS, warnings); + warnUnknownKeys("Direct chain", chain, VALID_DIRECT_CHAIN_KEYS, yamlString, "chains", warnings); if (!chain.ipv || !chain.table || !chain.chain) { - warnings.push("Direct chain entry missing required fields (ipv, table, chain)"); + warnings.push(warn("Direct chain entry missing required fields (ipv, table, chain)", findKeyLine(yamlString, "chains", "direct"))); } } } @@ -275,13 +413,13 @@ function validateDirect(direct, errors, warnings) { // Rules if (direct.rules !== undefined) { if (!Array.isArray(direct.rules)) { - errors.push("'direct.rules' must be an array"); + errors.push(err("'direct.rules' must be an array", findKeyLine(yamlString, "rules", "direct"))); } else { for (const rule of direct.rules) { if (rule !== null && typeof rule === "object") { - warnUnknownKeys("Direct rule", rule, VALID_DIRECT_RULE_KEYS, warnings); + warnUnknownKeys("Direct rule", rule, VALID_DIRECT_RULE_KEYS, yamlString, "rules", warnings); if (!rule.ipv || !rule.table || !rule.chain || rule.priority === undefined || !rule.args) { - warnings.push("Direct rule entry missing required fields (ipv, table, chain, priority, args)"); + warnings.push(warn("Direct rule entry missing required fields (ipv, table, chain, priority, args)", findKeyLine(yamlString, "rules", "direct"))); } } } @@ -291,13 +429,13 @@ function validateDirect(direct, errors, warnings) { // Passthroughs if (direct.passthroughs !== undefined) { if (!Array.isArray(direct.passthroughs)) { - errors.push("'direct.passthroughs' must be an array"); + errors.push(err("'direct.passthroughs' must be an array", findKeyLine(yamlString, "passthroughs", "direct"))); } else { for (const pt of direct.passthroughs) { if (pt !== null && typeof pt === "object") { - warnUnknownKeys("Direct passthrough", pt, VALID_PASSTHROUGH_KEYS, warnings); + warnUnknownKeys("Direct passthrough", pt, VALID_PASSTHROUGH_KEYS, yamlString, "passthroughs", warnings); if (!pt.ipv || !pt.args) { - warnings.push("Direct passthrough entry missing required fields (ipv, args)"); + warnings.push(warn("Direct passthrough entry missing required fields (ipv, args)", findKeyLine(yamlString, "passthroughs", "direct"))); } } } @@ -307,30 +445,30 @@ function validateDirect(direct, errors, warnings) { // Rule groups if (direct.rule_groups !== undefined) { if (!Array.isArray(direct.rule_groups)) { - errors.push("'direct.rule_groups' must be an array"); + errors.push(err("'direct.rule_groups' must be an array", findKeyLine(yamlString, "rule_groups", "direct"))); } else { for (const group of direct.rule_groups) { if (group !== null && typeof group === "object") { - warnUnknownKeys("Direct rule_group", group, VALID_RULE_GROUP_KEYS, warnings); + warnUnknownKeys("Direct rule_group", group, VALID_RULE_GROUP_KEYS, yamlString, "rule_groups", warnings); if (!group.ipv) { - warnings.push("Direct rule_group missing 'ipv' field"); + warnings.push(warn("Direct rule_group missing 'ipv' field", findKeyLine(yamlString, "rule_groups", "direct"))); } if (!group.table) { - warnings.push("Direct rule_group missing 'table' field"); + warnings.push(warn("Direct rule_group missing 'table' field", findKeyLine(yamlString, "rule_groups", "direct"))); } if (!group.chain) { - warnings.push("Direct rule_group missing 'chain' field"); + warnings.push(warn("Direct rule_group missing 'chain' field", findKeyLine(yamlString, "rule_groups", "direct"))); } // Validate nested rules within group if (group.rules !== undefined && Array.isArray(group.rules)) { for (const rule of group.rules) { if (rule !== null && typeof rule === "object") { - warnUnknownKeys("Direct rule_group rule", rule, VALID_RULE_GROUP_RULE_KEYS, warnings); + warnUnknownKeys("Direct rule_group rule", rule, VALID_RULE_GROUP_RULE_KEYS, yamlString, "rules", warnings); if (rule.priority === undefined) { - warnings.push("Direct rule_group rule missing 'priority' field"); + warnings.push(warn("Direct rule_group rule missing 'priority' field", findKeyLine(yamlString, "rules", "rule_groups"))); } if (!rule.args) { - warnings.push("Direct rule_group rule missing 'args' field"); + warnings.push(warn("Direct rule_group rule missing 'args' field", findKeyLine(yamlString, "rules", "rule_groups"))); } } } From 20f265d5367593c8cba1283f4911dd99161de16a Mon Sep 17 00:00:00 2001 From: tommy gingras Date: Thu, 19 Feb 2026 21:58:13 -0500 Subject: [PATCH 2/2] feat: add bash-to-YAML reverse import Parse pasted firewall-cmd commands into YAML configuration. Supports all zone commands, direct rules, rich rules, forward ports, and ICMP blocks. Adds Paste Commands button with modal overlay in the editor toolbar. Co-Authored-By: Claude --- CHANGELOG.md | 14 +- css/styles.css | 85 +++++ index.html | 17 + js/app.mjs | 18 +- js/import-export.mjs | 80 +++++ js/reverse-parser.mjs | 706 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 js/reverse-parser.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b63b104..5e41a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-02-19 + +### Added + +- Bash-to-YAML reverse import: paste `firewall-cmd` commands and convert them to YAML configuration +- Reverse parser supporting all zone commands, direct rules, rich rules, forward ports, and ICMP blocks +- "Paste Commands" button in editor toolbar with modal overlay for pasting shell scripts +- Handles both `--add-*` and `--remove-*` command variants +- Skips comments, shebang lines, blank lines, and reload commands +- Strips `sudo` prefix automatically + ## [1.0.0] - 2026-02-16 ### Added @@ -27,5 +38,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Responsive layout for desktop and mobile - Docker deployment with nginx -[Unreleased]: https://github.com/studiowebux/firegen/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/studiowebux/firegen/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/studiowebux/firegen/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/studiowebux/firegen/releases/tag/v1.0.0 diff --git a/css/styles.css b/css/styles.css index d571880..fceacfa 100644 --- a/css/styles.css +++ b/css/styles.css @@ -404,6 +404,91 @@ li.CodeMirror-hint-active { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } +/* Modal */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +.modal-overlay[hidden] { + display: none; +} + +.modal-dialog { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + width: min(40rem, 90vw); + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); +} + +[data-theme="dark"] .modal-dialog { + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-block-end: 1px solid var(--border); + flex-shrink: 0; +} + +.modal-title { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text); +} + +.modal-close { + border-color: transparent; +} + +.modal-textarea { + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.6; + background: var(--bg-editor); + color: var(--text); + border: none; + border-block-end: 1px solid var(--border); + padding: 0.75rem 1rem; + resize: vertical; + outline: none; + flex: 1; + min-height: 12rem; +} + +.modal-textarea::placeholder { + color: var(--text-tertiary); +} + +.modal-status { + padding: 0.375rem 1rem; + font-family: var(--font-mono); + font-size: 0.6875rem; + color: var(--text-secondary); + border-block-end: 1px solid var(--border); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + padding: 0.625rem 1rem; + gap: 0.375rem; +} + /* Responsive */ @media (max-width: 768px) { diff --git a/index.html b/index.html index 53c5cbe..5231824 100644 --- a/index.html +++ b/index.html @@ -35,6 +35,7 @@

Firegen

+ @@ -71,6 +72,22 @@

Firegen

+ + diff --git a/js/app.mjs b/js/app.mjs index c60b739..30cec32 100644 --- a/js/app.mjs +++ b/js/app.mjs @@ -4,7 +4,7 @@ import { initEditor } from "./editor.mjs"; import { parseConfig } from "./parser.mjs"; import { generateApply, generateRemove } from "./generator.mjs"; import { initTabs, setTabContent, getActiveTabContent, setOutputTheme } from "./tabs.mjs"; -import { exportYaml, setupImport } from "./import-export.mjs"; +import { exportYaml, setupImport, setupBashImport } from "./import-export.mjs"; import { SAMPLE_YAML } from "./sample.mjs"; function init() { @@ -17,6 +17,7 @@ function init() { const btnExample = document.getElementById("btn-example"); const btnImport = document.getElementById("btn-import"); const btnExport = document.getElementById("btn-export"); + const btnPasteCommands = document.getElementById("btn-paste-commands"); const btnCopy = document.getElementById("btn-copy"); const fileImport = document.getElementById("file-import"); @@ -68,6 +69,21 @@ function init() { editor.setValue(content); }); + // Bash import + setupBashImport( + { + btnOpen: btnPasteCommands, + modal: document.getElementById("bash-import-modal"), + textarea: document.getElementById("bash-import-textarea"), + btnImport: document.getElementById("bash-import-submit"), + btnCancel: document.getElementById("bash-import-cancel"), + status: document.getElementById("bash-import-status"), + }, + (yaml) => { + editor.setValue(yaml); + } + ); + // Export btnExport.addEventListener("click", () => { const yaml = editor.getValue(); diff --git a/js/import-export.mjs b/js/import-export.mjs index 97e62aa..52af539 100644 --- a/js/import-export.mjs +++ b/js/import-export.mjs @@ -1,5 +1,7 @@ // Pattern: Command — import/export actions encapsulated as functions +import { parseCommands } from "./reverse-parser.mjs"; + /** * Download a string as a .yaml file. * @param {string} content - YAML string to export @@ -41,3 +43,81 @@ export function setupImport(fileInput, onLoad) { fileInput.value = ""; }); } + +/** + * Set up the bash import modal flow. + * @param {object} elements - DOM elements for the modal + * @param {HTMLButtonElement} elements.btnOpen - button that opens the modal + * @param {HTMLElement} elements.modal - the modal overlay + * @param {HTMLTextAreaElement} elements.textarea - the paste textarea + * @param {HTMLButtonElement} elements.btnImport - the import/submit button + * @param {HTMLButtonElement} elements.btnCancel - the cancel/close button + * @param {HTMLElement} elements.status - status message area + * @param {function} onImport - callback(yamlString) when import succeeds + */ +export function setupBashImport(elements, onImport) { + const { btnOpen, modal, textarea, btnImport, btnCancel, status } = elements; + + function openModal() { + textarea.value = ""; + status.hidden = true; + status.textContent = ""; + modal.hidden = false; + textarea.focus(); + } + + function closeModal() { + modal.hidden = true; + } + + btnOpen.addEventListener("click", openModal); + btnCancel.addEventListener("click", closeModal); + + // Close on backdrop click + modal.addEventListener("click", (e) => { + if (e.target === modal) { + closeModal(); + } + }); + + // Close on Escape + modal.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + closeModal(); + } + }); + + btnImport.addEventListener("click", () => { + const text = textarea.value.trim(); + if (!text) { + return; + } + + const { config, errors, skipped } = parseCommands(text); + + // Show status with skipped/error counts + const parts = []; + if (skipped.length > 0) { + parts.push(`${skipped.length} skipped`); + } + if (errors.length > 0) { + parts.push(`${errors.length} error${errors.length > 1 ? "s" : ""}`); + } + + if (parts.length > 0) { + status.textContent = parts.join(", ") + (errors.length > 0 ? ": " + errors.join("; ") : ""); + status.hidden = false; + } + + // Only import if we got something + if (Object.keys(config).length === 0) { + status.textContent = "No valid firewall-cmd commands found."; + status.hidden = false; + return; + } + + const yaml = jsyaml.dump(config, { lineWidth: -1, noRefs: true }); + onImport(yaml); + closeModal(); + }); +} diff --git a/js/reverse-parser.mjs b/js/reverse-parser.mjs new file mode 100644 index 0000000..dd3c039 --- /dev/null +++ b/js/reverse-parser.mjs @@ -0,0 +1,706 @@ +// Pattern: Parser — transforms firewall-cmd command text into a config object + +/** + * Tokenize a firewall-cmd command line, preserving single-quoted strings. + * Returns an array of tokens (flags, values, positional args). + */ +function tokenizeLine(line) { + const tokens = []; + let i = 0; + const len = line.length; + + while (i < len) { + // Skip whitespace + while (i < len && line[i] === " ") { + i++; + } + if (i >= len) { + break; + } + + // Single-quoted string + if (line[i] === "'") { + i++; + let str = ""; + while (i < len && line[i] !== "'") { + str += line[i]; + i++; + } + i++; // skip closing quote + tokens.push(str); + continue; + } + + // Regular token (until whitespace), with inline single-quote support + let token = ""; + while (i < len && line[i] !== " ") { + if (line[i] === "'") { + // Inline quoted string (e.g. --add-rich-rule='rule ...') + i++; // skip opening quote + let quoted = ""; + while (i < len && line[i] !== "'") { + quoted += line[i]; + i++; + } + if (i < len) { + i++; // skip closing quote + } + token += quoted; + } else { + token += line[i]; + i++; + } + } + tokens.push(token); + } + + return tokens; +} + +/** + * Parse tokens into a flags map and positional args. + * Flags: --key=value -> { key: value }, --flag -> { flag: true } + */ +function parseFlags(tokens) { + const flags = {}; + const args = []; + + for (const token of tokens) { + if (token.startsWith("--")) { + const eqIdx = token.indexOf("="); + if (eqIdx !== -1) { + const key = token.slice(2, eqIdx); + const value = token.slice(eqIdx + 1); + flags[key] = value; + } else { + flags[token.slice(2)] = true; + } + } else if (token !== "firewall-cmd" && token !== "sudo") { + args.push(token); + } + } + + return { flags, args }; +} + +/** + * Parse a port/protocol string "80/tcp" into { port, protocol }. + */ +function parsePort(value) { + const slashIdx = value.indexOf("/"); + if (slashIdx === -1) { + return { port: value, protocol: "tcp" }; + } + return { port: value.slice(0, slashIdx), protocol: value.slice(slashIdx + 1) }; +} + +/** + * Parse a forward-port value "port=P:proto=PR[:toport=T][:toaddr=A]". + */ +function parseForwardPort(value) { + const result = {}; + const parts = value.split(":"); + + for (const part of parts) { + const eqIdx = part.indexOf("="); + if (eqIdx === -1) { + continue; + } + const key = part.slice(0, eqIdx); + const val = part.slice(eqIdx + 1); + + if (key === "port") { + result.port = val; + } else if (key === "proto") { + result.protocol = val; + } else if (key === "toport") { + result.to_port = val; + } else if (key === "toaddr") { + result.to_addr = val; + } + } + + return result; +} + +/** + * Parse a rich rule string into an object matching the YAML schema. + * + * Tokenizes the string respecting key="value" pairs and walks tokens + * left-to-right matching known keywords. + */ +function parseRichRule(ruleStr) { + // Tokenize: split on spaces but keep key="value" together + const rawTokens = []; + let i = 0; + const len = ruleStr.length; + + while (i < len) { + while (i < len && ruleStr[i] === " ") { + i++; + } + if (i >= len) { + break; + } + + let token = ""; + while (i < len && ruleStr[i] !== " ") { + if (ruleStr[i] === '"') { + // Include the quoted value as part of this token + token += ruleStr[i]; + i++; + while (i < len && ruleStr[i] !== '"') { + token += ruleStr[i]; + i++; + } + if (i < len) { + token += ruleStr[i]; // closing quote + i++; + } + } else { + token += ruleStr[i]; + i++; + } + } + rawTokens.push(token); + } + + const rule = {}; + + /** + * Extract a quoted value from a token like key="value". + */ + function extractValue(token) { + const eqIdx = token.indexOf("="); + if (eqIdx === -1) { + return token; + } + let val = token.slice(eqIdx + 1); + if (val.startsWith('"') && val.endsWith('"')) { + val = val.slice(1, -1); + } + return val; + } + + /** + * Get the attribute name from key="value" token. + */ + function attrName(token) { + const eqIdx = token.indexOf("="); + return eqIdx === -1 ? token : token.slice(0, eqIdx); + } + + let pos = 0; + + // Skip the "rule" keyword + if (pos < rawTokens.length && rawTokens[pos] === "rule") { + pos++; + } + + // family="..." + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "family") { + rule.family = extractValue(rawTokens[pos]); + pos++; + } + + // source address="..." [invert="true"] + if (pos < rawTokens.length && rawTokens[pos] === "source") { + pos++; + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "address") { + rule.source = extractValue(rawTokens[pos]); + pos++; + } + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "invert") { + if (extractValue(rawTokens[pos]) === "true") { + rule.source_invert = true; + } + pos++; + } + } + + // destination address="..." [invert="true"] + if (pos < rawTokens.length && rawTokens[pos] === "destination") { + pos++; + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "address") { + rule.destination = extractValue(rawTokens[pos]); + pos++; + } + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "invert") { + if (extractValue(rawTokens[pos]) === "true") { + rule.destination_invert = true; + } + pos++; + } + } + + // Element: service, port, protocol, icmp-block, icmp-type, masquerade, forward-port, source-port + if (pos < rawTokens.length) { + const keyword = rawTokens[pos]; + + if (keyword === "service") { + pos++; + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "name") { + rule.service = extractValue(rawTokens[pos]); + pos++; + } + } else if (keyword === "port") { + pos++; + while (pos < rawTokens.length) { + const attr = attrName(rawTokens[pos]); + if (attr === "port") { + rule.port = extractValue(rawTokens[pos]); + } else if (attr === "protocol") { + rule.protocol = extractValue(rawTokens[pos]); + } else { + break; + } + pos++; + } + } else if (keyword === "protocol") { + pos++; + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "value") { + rule.protocol = extractValue(rawTokens[pos]); + pos++; + } + } else if (keyword === "icmp-block") { + pos++; + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "name") { + rule.icmp_block = extractValue(rawTokens[pos]); + pos++; + } + } else if (keyword === "icmp-type") { + pos++; + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "name") { + rule.icmp_type = extractValue(rawTokens[pos]); + pos++; + } + } else if (keyword === "masquerade") { + rule.masquerade = true; + pos++; + } else if (keyword === "forward-port") { + pos++; + const fp = {}; + while (pos < rawTokens.length) { + const attr = attrName(rawTokens[pos]); + if (attr === "port") { + fp.port = extractValue(rawTokens[pos]); + } else if (attr === "protocol") { + fp.protocol = extractValue(rawTokens[pos]); + } else if (attr === "to-port") { + fp.to_port = extractValue(rawTokens[pos]); + } else if (attr === "to-addr") { + fp.to_addr = extractValue(rawTokens[pos]); + } else { + break; + } + pos++; + } + rule.forward_port = fp; + } else if (keyword === "source-port") { + pos++; + const sp = {}; + while (pos < rawTokens.length) { + const attr = attrName(rawTokens[pos]); + if (attr === "port") { + sp.port = extractValue(rawTokens[pos]); + } else if (attr === "protocol") { + sp.protocol = extractValue(rawTokens[pos]); + } else { + break; + } + pos++; + } + rule.source_port = sp; + } + } + + // log [prefix="..." level="..." limit value="..."] + if (pos < rawTokens.length && rawTokens[pos] === "log") { + pos++; + const logObj = {}; + while (pos < rawTokens.length) { + const attr = attrName(rawTokens[pos]); + if (attr === "prefix") { + logObj.prefix = extractValue(rawTokens[pos]); + pos++; + } else if (attr === "level") { + logObj.level = extractValue(rawTokens[pos]); + pos++; + } else if (attr === "limit") { + pos++; + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "value") { + logObj.limit = extractValue(rawTokens[pos]); + pos++; + } + } else { + break; + } + } + rule.log = logObj; + } + + // audit + if (pos < rawTokens.length && rawTokens[pos] === "audit") { + rule.audit = true; + pos++; + } + + // Action: accept, reject [type="..."], drop, mark [set="..."] + if (pos < rawTokens.length) { + const action = rawTokens[pos]; + if (action === "accept" || action === "drop") { + rule.action = action; + pos++; + } else if (action === "reject") { + rule.action = "reject"; + pos++; + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "type") { + rule.reject_type = extractValue(rawTokens[pos]); + pos++; + } + } else if (action === "mark") { + rule.action = "mark"; + pos++; + if (pos < rawTokens.length && attrName(rawTokens[pos]) === "set") { + rule.mark_set = extractValue(rawTokens[pos]); + pos++; + } + } + } + + return rule; +} + +/** + * Ensure a zone exists in the config and return it. + */ +function ensureZone(config, zoneName) { + if (!config.zones[zoneName]) { + config.zones[zoneName] = {}; + } + return config.zones[zoneName]; +} + +/** + * Normalize an --add-* or --remove-* flag name to the base name. + * e.g. "add-service" -> "service", "remove-port" -> "port" + */ +function normalizeFlag(flag) { + if (flag.startsWith("add-")) { + return flag.slice(4); + } + if (flag.startsWith("remove-")) { + return flag.slice(7); + } + return flag; +} + +/** + * Process a zone command given the parsed flags. + */ +function processZoneCommand(zone, flags) { + // Find the action flag (add-* or remove-* or set-target) + for (const [key, value] of Object.entries(flags)) { + if (key === "zone" || key === "permanent" || key === "direct") { + continue; + } + + const base = normalizeFlag(key); + + if (key === "set-target") { + zone.target = value; + return true; + } + + if (base === "interface") { + if (!zone.interfaces) { + zone.interfaces = []; + } + zone.interfaces.push(value); + return true; + } + + if (base === "source") { + if (!zone.sources) { + zone.sources = []; + } + zone.sources.push(value); + return true; + } + + if (base === "service") { + if (!zone.services) { + zone.services = []; + } + zone.services.push(value); + return true; + } + + if (base === "port") { + if (!zone.ports) { + zone.ports = []; + } + zone.ports.push(parsePort(value)); + return true; + } + + if (base === "protocol") { + if (!zone.protocols) { + zone.protocols = []; + } + zone.protocols.push(value); + return true; + } + + if (base === "source-port") { + if (!zone.source_ports) { + zone.source_ports = []; + } + zone.source_ports.push(parsePort(value)); + return true; + } + + if (base === "rich-rule") { + if (!zone.rich_rules) { + zone.rich_rules = []; + } + zone.rich_rules.push(parseRichRule(value)); + return true; + } + + if (base === "forward" && value === true) { + zone.forward = true; + return true; + } + + if (base === "masquerade" && value === true) { + zone.masquerade = true; + return true; + } + + if (base === "forward-port") { + if (!zone.forward_ports) { + zone.forward_ports = []; + } + zone.forward_ports.push(parseForwardPort(value)); + return true; + } + + if (base === "icmp-block" && typeof value === "string") { + if (!zone.icmp_blocks) { + zone.icmp_blocks = []; + } + zone.icmp_blocks.push(value); + return true; + } + + if (base === "icmp-block-inversion") { + zone.icmp_block_inversion = true; + return true; + } + } + + return false; +} + +/** + * Process a direct command. + * Direct commands use positional args after the --add-chain/rule/passthrough flag. + */ +function processDirectCommand(config, flags, tokens) { + // Find the direct action flag + const directAction = Object.keys(flags).find( + (k) => + k === "add-chain" || + k === "remove-chain" || + k === "add-rule" || + k === "remove-rule" || + k === "add-passthrough" || + k === "remove-passthrough" + ); + + if (!directAction) { + return false; + } + + // Find the position of the direct action flag in the original tokens + // to extract positional args after it + const flagToken = `--${directAction}`; + const flagIdx = tokens.indexOf(flagToken); + if (flagIdx === -1) { + return false; + } + + const positional = tokens.slice(flagIdx + 1); + const base = normalizeFlag(directAction); + + if (base === "chain") { + // ipv table chain + if (positional.length >= 3) { + config.direct.chains.push({ + ipv: positional[0], + table: positional[1], + chain: positional[2], + }); + return true; + } + } + + if (base === "rule") { + // ipv table chain priority args... + if (positional.length >= 5) { + config.direct.rules.push({ + ipv: positional[0], + table: positional[1], + chain: positional[2], + priority: parseInt(positional[3], 10), + args: positional.slice(4).join(" "), + }); + return true; + } + } + + if (base === "passthrough") { + // ipv args... + if (positional.length >= 2) { + config.direct.passthroughs.push({ + ipv: positional[0], + args: positional.slice(1).join(" "), + }); + return true; + } + } + + return false; +} + +/** + * Deduplicate direct chains (same ipv+table+chain). + */ +function deduplicateChains(chains) { + const seen = new Set(); + return chains.filter((c) => { + const key = `${c.ipv}|${c.table}|${c.chain}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +/** + * Remove empty sections from the config to produce clean YAML output. + */ +function cleanConfig(config) { + const result = {}; + + if (Object.keys(config.zones).length > 0) { + result.zones = config.zones; + } + + const hasChains = config.direct.chains.length > 0; + const hasRules = config.direct.rules.length > 0; + const hasPassthroughs = config.direct.passthroughs.length > 0; + + if (hasChains || hasRules || hasPassthroughs) { + result.direct = {}; + if (hasChains) { + result.direct.chains = deduplicateChains(config.direct.chains); + } + if (hasRules) { + result.direct.rules = config.direct.rules; + } + if (hasPassthroughs) { + result.direct.passthroughs = config.direct.passthroughs; + } + } + + return result; +} + +/** + * Parse a block of firewall-cmd commands into a config object. + * + * @param {string} text - multiline text containing firewall-cmd commands + * @returns {{ config: object, errors: string[], skipped: string[] }} + */ +export function parseCommands(text) { + const config = { + zones: {}, + direct: { chains: [], rules: [], passthroughs: [] }, + }; + + const errors = []; + const skipped = []; + + const lines = text.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const trimmed = raw.trim(); + + // Skip blank lines + if (trimmed === "") { + continue; + } + + // Skip comments and shebang + if (trimmed.startsWith("#") || trimmed.startsWith("#!/")) { + skipped.push(`Line ${i + 1}: ${trimmed}`); + continue; + } + + // Skip reload commands + if (trimmed === "firewall-cmd --reload" || trimmed === "sudo firewall-cmd --reload") { + skipped.push(`Line ${i + 1}: ${trimmed}`); + continue; + } + + // Strip sudo prefix + let line = trimmed; + if (line.startsWith("sudo ")) { + line = line.slice(5); + } + + // Must start with firewall-cmd + if (!line.startsWith("firewall-cmd")) { + skipped.push(`Line ${i + 1}: ${trimmed}`); + continue; + } + + const tokens = tokenizeLine(line); + const { flags } = parseFlags(tokens); + + // Remove --permanent (irrelevant for parsing) + delete flags.permanent; + + // Direct commands + if (flags.direct) { + if (!processDirectCommand(config, flags, tokens)) { + errors.push(`Line ${i + 1}: could not parse direct command: ${trimmed}`); + } + continue; + } + + // Zone commands + const zoneName = flags.zone; + if (zoneName) { + delete flags.zone; + const zone = ensureZone(config, zoneName); + if (!processZoneCommand(zone, flags)) { + errors.push(`Line ${i + 1}: could not parse zone command: ${trimmed}`); + } + continue; + } + + // Unknown command structure + errors.push(`Line ${i + 1}: unrecognized command: ${trimmed}`); + } + + return { + config: cleanConfig(config), + errors, + skipped, + }; +}