feat: multi-format env var editor for deployment config
Replace simple key-value rows with EnvEditor component that supports editing variables as Table, Properties, YAML, or .env format. Switching views converts data seamlessly. Includes file import (drag-and-drop .properties/.yaml/.env) with auto-detect and merge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
130
ui/src/components/env-convert.ts
Normal file
130
ui/src/components/env-convert.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export type EnvEntry = { key: string; value: string };
|
||||
|
||||
export type Format = 'env' | 'properties' | 'yaml';
|
||||
|
||||
/** Convert KEY=value env pairs to Spring-style properties (lowercase, _ -> .) */
|
||||
export function envToProperties(entries: EnvEntry[]): string {
|
||||
return entries
|
||||
.filter((e) => e.key.trim())
|
||||
.map((e) => `${e.key.toLowerCase().replace(/_/g, '.')}=${e.value}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Convert KEY=value env pairs to nested YAML */
|
||||
export function envToYaml(entries: EnvEntry[]): string {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const { key, value } of entries) {
|
||||
if (!key.trim()) continue;
|
||||
const path = key.toLowerCase().replace(/_/g, '.').split('.');
|
||||
let cur: Record<string, unknown> = obj;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
if (typeof cur[path[i]] !== 'object' || cur[path[i]] === null) {
|
||||
cur[path[i]] = {};
|
||||
}
|
||||
cur = cur[path[i]] as Record<string, unknown>;
|
||||
}
|
||||
cur[path[path.length - 1]] = value;
|
||||
}
|
||||
return yaml.dump(obj, { lineWidth: -1, noRefs: true }).trimEnd();
|
||||
}
|
||||
|
||||
/** Convert KEY=value env pairs to .env format */
|
||||
export function envToEnvText(entries: EnvEntry[]): string {
|
||||
return entries
|
||||
.filter((e) => e.key.trim())
|
||||
.map((e) => `${e.key}=${e.value}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Normalize a key to UPPER_SNAKE_CASE */
|
||||
function toEnvKey(key: string): string {
|
||||
return key.replace(/[.\-]/g, '_').toUpperCase();
|
||||
}
|
||||
|
||||
/** Parse .properties text to env entries */
|
||||
export function parseProperties(text: string): EnvEntry[] {
|
||||
const entries: EnvEntry[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
entries.push({
|
||||
key: toEnvKey(trimmed.slice(0, eq).trim()),
|
||||
value: trimmed.slice(eq + 1).trim(),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Parse YAML text to env entries (flattens nested keys) */
|
||||
export function parseYaml(text: string): EnvEntry[] {
|
||||
const doc = yaml.load(text);
|
||||
if (!doc || typeof doc !== 'object') return [];
|
||||
const entries: EnvEntry[] = [];
|
||||
function walk(obj: Record<string, unknown>, prefix: string) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}_${k}` : k;
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||
walk(v as Record<string, unknown>, path);
|
||||
} else {
|
||||
entries.push({ key: toEnvKey(path), value: String(v ?? '') });
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc as Record<string, unknown>, '');
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Parse .env text to env entries */
|
||||
export function parseEnvText(text: string): EnvEntry[] {
|
||||
const entries: EnvEntry[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
entries.push({
|
||||
key: trimmed.slice(0, eq).trim(),
|
||||
value: trimmed.slice(eq + 1).trim(),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Auto-detect format from text content */
|
||||
export function detectFormat(text: string): Format {
|
||||
const lines = text.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#'));
|
||||
if (lines.length === 0) return 'env';
|
||||
|
||||
// YAML: has indented lines or lines ending with : (no =)
|
||||
const hasIndent = lines.some((l) => /^\s{2,}\S/.test(l));
|
||||
const hasColonNoEq = lines.some((l) => l.includes(':') && !l.includes('='));
|
||||
if (hasIndent || (hasColonNoEq && !lines.some((l) => /^[A-Z_]+=/.test(l)))) return 'yaml';
|
||||
|
||||
// Properties: lowercase keys with dots
|
||||
const hasPropertyKeys = lines.some((l) => /^[a-z][a-z0-9.\-]+=/.test(l));
|
||||
if (hasPropertyKeys) return 'properties';
|
||||
|
||||
return 'env';
|
||||
}
|
||||
|
||||
/** Parse text in a given format to env entries */
|
||||
export function parseText(text: string, format: Format): EnvEntry[] {
|
||||
switch (format) {
|
||||
case 'properties': return parseProperties(text);
|
||||
case 'yaml': return parseYaml(text);
|
||||
case 'env': return parseEnvText(text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert env entries to text in a given format */
|
||||
export function toText(entries: EnvEntry[], format: Format): string {
|
||||
switch (format) {
|
||||
case 'properties': return envToProperties(entries);
|
||||
case 'yaml': return envToYaml(entries);
|
||||
case 'env': return envToEnvText(entries);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user