Improve desktop UI settings defaults and env inference
All checks were successful
Build Windows App / build-windows (push) Successful in 16m23s

This commit is contained in:
MrSphay
2026-05-01 16:09:27 +02:00
parent 0ee5a59f3d
commit 1d1afafd1e
4 changed files with 1073 additions and 292 deletions

104
README.md
View File

@@ -1,14 +1,88 @@
# EnvHelper
EnvHelper ist eine Windows-Desktop-App zum Ersetzen von `CHANGE_ME` Platzhaltern in `.env` Dateien.
**EnvHelper** ist eine lokale Windows-Desktop-App zum Ausfüllen von `.env` Vorlagen. Sie erkennt `CHANGE_ME` Platzhalter, erzeugt passende Werte im erwarteten Format und gibt eine fertige `.env` aus.
## Funktionen
```text
Status: Desktop App
Plattform: Windows
Stack: Electron, React, Vite, TypeScript
Build: Gitea Actions Runner
```
## Highlights
- `.env` Datei laden oder reinen Text einfügen
- `CHANGE_ME...` Platzhalter erkennen
- passende Werte anhand von Variablenname und Platzhalter ableiten
- gleiche Platzhalter konsistent mit demselben Wert ersetzen
- neue `.env` als Text kopieren oder speichern
- `CHANGE_ME...` Platzhalter automatisch erkennen
- Passwörter, Secrets, Base64-Keys, UUIDs, Ports, URLs und E-Mails heuristisch erzeugen
- Gleiche Placeholder konsistent mit demselben Wert ersetzen
- Standardwerte pro Key definieren und im Output überschreiben lassen
- Hell/Dunkel/System Theme
- Einstellungen mit Sprache, Version und Projektinfos
- Ausgabe kopieren oder als neue `.env` speichern
## Vorschau
Screenshots können später im Repository ergänzt werden.
```text
┌─────────────────────────────────────────────────────────────┐
│ EnvHelper Datei laden .env speichern Theme Settings │
├─────────────────────────────┬───────────────────────────────┤
│ Input │ Output │
│ DATABASE_URL=...CHANGE_ME │ DATABASE_URL=...generated... │
├─────────────────────────────┴───────────────────────────────┤
│ Standardwerte Erkannte Anforderungen │
└─────────────────────────────────────────────────────────────┘
```
## Beispiel
Eingabe:
```env
DATABASE_URL=postgresql://hilden_app:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/hilden_directory
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
SESSION_SECRET=CHANGE_ME_AT_LEAST_32_RANDOM_CHARACTERS
ENCRYPTION_KEY_BASE64=CHANGE_ME_32_RANDOM_BYTES_AS_BASE64
BOOTSTRAP_ADMIN_EMAIL=admin@example.local
```
Ausgabe:
```env
DATABASE_URL=postgresql://hilden_app:generated-postgres-password@postgres:5432/hilden_directory
POSTGRES_PASSWORD=generated-postgres-password
SESSION_SECRET=generated-url-safe-secret
ENCRYPTION_KEY_BASE64=generated-base64-key
BOOTSTRAP_ADMIN_EMAIL=admin@example.local
```
Die tatsächlichen Werte werden zufällig lokal erzeugt.
## Standardwerte
Standardwerte werden in einer eigenen Sektion gepflegt. Wenn ein Key dort eingetragen ist, überschreibt der Wert den entsprechenden Output-Key.
Beispiel:
```text
BOOTSTRAP_ADMIN_EMAIL = admin@example.local
```
Dann wird im Output immer dieser Wert gesetzt, auch wenn die Vorlage bereits eine andere E-Mail enthält.
## Sicherheit
EnvHelper arbeitet lokal. Die Werte werden im Renderer mit Web-Crypto erzeugt und nicht an externe Dienste gesendet. Die App ist ein Helfer für Vorlagen, ersetzt aber keine zentrale Secret-Verwaltung für produktive Infrastruktur.
## Download und Artefakte
Der Windows-Build erzeugt zwei Dateien:
- `EnvHelper-0.1.0-setup-x64.exe`
- `EnvHelper-0.1.0-portable-x64.exe`
Die Dateien werden vom Gitea Runner als Actions-Artefakt und als Generic Package veröffentlicht.
## Entwicklung
@@ -17,7 +91,7 @@ npm install
npm run dev
```
In einem zweiten Terminal:
In einem zweiten Terminal kann der Electron-Build geprüft werden:
```bash
npm run build
@@ -25,10 +99,22 @@ npm run build
## Windows Build
Der Windows-Build läuft über Gitea Actions:
Der Windows-Build läuft über Gitea Actions und nutzt den Linux Runner mit Wine:
```bash
npm run dist:win
```
Die erzeugten Dateien liegen im Runner unter `release/` und werden als Workflow-Artefakt hochgeladen.
Workflow:
```text
.gitea/workflows/build-windows.yml
```
Der Runner installiert Node.js, Wine, baut die App mit Electron Builder und lädt die fertigen `.exe` Artefakte hoch.
## Projektinfos
- Autor: `MrSphay`
- Repository: `MrSphay/envHelper`
- App-ID: `de.wilkensxl.envhelper`

View File

@@ -1,6 +1,25 @@
import { Check, Clipboard, FileDown, FileInput, RefreshCcw, ShieldCheck, Sparkles } from "lucide-react";
import { useMemo, useState } from "react";
import {
Check,
Clipboard,
FileDown,
FileInput,
Languages,
Plus,
RefreshCcw,
Settings,
ShieldCheck,
SunMoon,
Trash2,
X
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import packageInfo from "../package.json";
import { transformEnv } from "./env";
import type { EnvDefault } from "./env";
type ThemeMode = "system" | "light" | "dark";
type Language = "de" | "en" | "es" | "fr" | "nl";
const sampleEnv = `APP_IMAGE=git.wilkensxl.de/mrsphay/hilden-directory-gateway:latest
APP_PORT=3000
@@ -18,12 +37,264 @@ ENCRYPTION_KEY_BASE64=CHANGE_ME_32_RANDOM_BYTES_AS_BASE64
BOOTSTRAP_ADMIN_EMAIL=admin@example.local
BOOTSTRAP_ADMIN_PASSWORD=CHANGE_ME_LONG_INITIAL_ADMIN_PASSWORD`;
const initialDefaults: EnvDefault[] = [{ key: "BOOTSTRAP_ADMIN_EMAIL", value: "admin@example.local" }];
const languageNames: Record<Language, string> = {
de: "Deutsch",
en: "English",
es: "Español",
fr: "Français",
nl: "Nederlands"
};
const translations = {
de: {
appSubtitle: "Lokaler Helfer für .env Vorlagen",
loadFile: "Datei laden",
saveFile: ".env speichern",
settings: "Einstellungen",
input: "Input",
output: "Output",
inputHint: "Datei laden oder Text direkt bearbeiten",
outputHint: "Generierte .env, bereit zum Kopieren",
copy: "Kopieren",
copied: "Kopiert",
regenerate: "Neu erzeugen",
sample: "Beispiel laden",
hits: "Fundstellen",
values: "Werte",
defaultsApplied: "Defaults",
source: "Quelle",
text: "Text",
defaults: "Standardwerte",
defaultsHint: "Diese Werte überschreiben passende Keys im Output.",
addDefault: "Standardwert hinzufügen",
key: "Key",
value: "Wert",
detected: "Erkannte Anforderungen",
detectedHint: "Placeholder, betroffene Keys und erzeugtes Format.",
placeholder: "Placeholder",
keys: "Keys",
format: "Format",
noPlaceholders: "Keine CHANGE_ME Platzhalter gefunden.",
language: "Sprache",
theme: "Darstellung",
system: "System",
light: "Hell",
dark: "Dunkel",
version: "Version",
author: "Autor",
repository: "Repository"
},
en: {
appSubtitle: "Local helper for .env templates",
loadFile: "Load file",
saveFile: "Save .env",
settings: "Settings",
input: "Input",
output: "Output",
inputHint: "Load a file or edit text directly",
outputHint: "Generated .env, ready to copy",
copy: "Copy",
copied: "Copied",
regenerate: "Regenerate",
sample: "Load sample",
hits: "Matches",
values: "Values",
defaultsApplied: "Defaults",
source: "Source",
text: "Text",
defaults: "Defaults",
defaultsHint: "These values override matching keys in the output.",
addDefault: "Add default",
key: "Key",
value: "Value",
detected: "Detected requirements",
detectedHint: "Placeholder, affected keys, and generated format.",
placeholder: "Placeholder",
keys: "Keys",
format: "Format",
noPlaceholders: "No CHANGE_ME placeholders found.",
language: "Language",
theme: "Appearance",
system: "System",
light: "Light",
dark: "Dark",
version: "Version",
author: "Author",
repository: "Repository"
},
es: {
appSubtitle: "Ayudante local para plantillas .env",
loadFile: "Cargar archivo",
saveFile: "Guardar .env",
settings: "Ajustes",
input: "Entrada",
output: "Salida",
inputHint: "Carga un archivo o edita el texto",
outputHint: ".env generado, listo para copiar",
copy: "Copiar",
copied: "Copiado",
regenerate: "Regenerar",
sample: "Cargar ejemplo",
hits: "Coincidencias",
values: "Valores",
defaultsApplied: "Defaults",
source: "Origen",
text: "Texto",
defaults: "Valores predeterminados",
defaultsHint: "Estos valores sobrescriben claves iguales en la salida.",
addDefault: "Añadir valor",
key: "Clave",
value: "Valor",
detected: "Requisitos detectados",
detectedHint: "Placeholder, claves afectadas y formato generado.",
placeholder: "Placeholder",
keys: "Claves",
format: "Formato",
noPlaceholders: "No se encontraron placeholders CHANGE_ME.",
language: "Idioma",
theme: "Apariencia",
system: "Sistema",
light: "Claro",
dark: "Oscuro",
version: "Versión",
author: "Autor",
repository: "Repositorio"
},
fr: {
appSubtitle: "Assistant local pour modèles .env",
loadFile: "Charger",
saveFile: "Enregistrer .env",
settings: "Réglages",
input: "Entrée",
output: "Sortie",
inputHint: "Chargez un fichier ou modifiez le texte",
outputHint: ".env généré, prêt à copier",
copy: "Copier",
copied: "Copié",
regenerate: "Régénérer",
sample: "Charger exemple",
hits: "Occurrences",
values: "Valeurs",
defaultsApplied: "Défauts",
source: "Source",
text: "Texte",
defaults: "Valeurs par défaut",
defaultsHint: "Ces valeurs remplacent les clés correspondantes dans la sortie.",
addDefault: "Ajouter",
key: "Clé",
value: "Valeur",
detected: "Exigences détectées",
detectedHint: "Placeholder, clés concernées et format généré.",
placeholder: "Placeholder",
keys: "Clés",
format: "Format",
noPlaceholders: "Aucun placeholder CHANGE_ME trouvé.",
language: "Langue",
theme: "Apparence",
system: "Système",
light: "Clair",
dark: "Sombre",
version: "Version",
author: "Auteur",
repository: "Dépôt"
},
nl: {
appSubtitle: "Lokale helper voor .env sjablonen",
loadFile: "Bestand laden",
saveFile: ".env opslaan",
settings: "Instellingen",
input: "Input",
output: "Output",
inputHint: "Laad een bestand of bewerk tekst direct",
outputHint: "Gegenereerde .env, klaar om te kopiëren",
copy: "Kopiëren",
copied: "Gekopieerd",
regenerate: "Opnieuw maken",
sample: "Voorbeeld laden",
hits: "Treffers",
values: "Waarden",
defaultsApplied: "Defaults",
source: "Bron",
text: "Tekst",
defaults: "Standaardwaarden",
defaultsHint: "Deze waarden overschrijven gelijke keys in de output.",
addDefault: "Waarde toevoegen",
key: "Key",
value: "Waarde",
detected: "Gedetecteerde eisen",
detectedHint: "Placeholder, betrokken keys en gegenereerd formaat.",
placeholder: "Placeholder",
keys: "Keys",
format: "Formaat",
noPlaceholders: "Geen CHANGE_ME placeholders gevonden.",
language: "Taal",
theme: "Weergave",
system: "Systeem",
light: "Licht",
dark: "Donker",
version: "Versie",
author: "Auteur",
repository: "Repository"
}
} satisfies Record<Language, Record<string, string>>;
const themeLabels: ThemeMode[] = ["system", "light", "dark"];
function detectLanguage(): Language {
const stored = localStorage.getItem("envhelper-language") as Language | null;
if (stored && stored in languageNames) {
return stored;
}
const browserLanguage = navigator.language.slice(0, 2) as Language;
return browserLanguage in languageNames ? browserLanguage : "de";
}
function readDefaults(): EnvDefault[] {
const stored = localStorage.getItem("envhelper-defaults");
if (!stored) {
return initialDefaults;
}
try {
const parsed = JSON.parse(stored) as EnvDefault[];
return Array.isArray(parsed) ? parsed : initialDefaults;
} catch {
return initialDefaults;
}
}
function readTheme(): ThemeMode {
const stored = localStorage.getItem("envhelper-theme") as ThemeMode | null;
return stored && themeLabels.includes(stored) ? stored : "system";
}
export default function App() {
const [input, setInput] = useState(sampleEnv);
const [loadedPath, setLoadedPath] = useState<string | null>(null);
const [copyLabel, setCopyLabel] = useState("Kopieren");
const [copyLabel, setCopyLabel] = useState<"copy" | "copied">("copy");
const [generation, setGeneration] = useState(0);
const result = useMemo(() => transformEnv(input), [input, generation]);
const [settingsOpen, setSettingsOpen] = useState(false);
const [themeMode, setThemeMode] = useState<ThemeMode>(readTheme);
const [language, setLanguage] = useState<Language>(detectLanguage);
const [defaults, setDefaults] = useState<EnvDefault[]>(readDefaults);
const t = translations[language];
const result = useMemo(() => transformEnv(input, defaults), [input, defaults, generation]);
useEffect(() => {
document.documentElement.dataset.theme = themeMode;
localStorage.setItem("envhelper-theme", themeMode);
}, [themeMode]);
useEffect(() => {
localStorage.setItem("envhelper-language", language);
}, [language]);
useEffect(() => {
localStorage.setItem("envhelper-defaults", JSON.stringify(defaults));
}, [defaults]);
async function openFile() {
const file = await window.envHelper?.openFile();
@@ -39,112 +310,219 @@ export default function App() {
async function copyOutput() {
await navigator.clipboard.writeText(result.output);
setCopyLabel("Kopiert");
window.setTimeout(() => setCopyLabel("Kopieren"), 1400);
setCopyLabel("copied");
window.setTimeout(() => setCopyLabel("copy"), 1400);
}
function regenerate() {
setGeneration((current) => current + 1);
function updateDefault(index: number, field: keyof EnvDefault, value: string) {
setDefaults((current) => current.map((entry, entryIndex) => (entryIndex === index ? { ...entry, [field]: value } : entry)));
}
function addDefault() {
setDefaults((current) => [...current, { key: "", value: "" }]);
}
function removeDefault(index: number) {
setDefaults((current) => current.filter((_, entryIndex) => entryIndex !== index));
}
return (
<main className="shell">
<section className="hero">
<main className="appShell">
<header className="toolbar">
<div className="brand">
<div className="brandMark">EH</div>
<div>
<p className="eyebrow"><Sparkles size={16} /> EnvHelper</p>
<h1>.env Platzhalter sauber ersetzen</h1>
<p className="subline">
Erkennt CHANGE_ME Werte, erzeugt passende Secrets im richtigen Format und hält gleiche Platzhalter synchron.
</p>
<h1>EnvHelper</h1>
<p>{t.appSubtitle}</p>
</div>
<div className="heroActions">
</div>
<div className="toolbarActions">
<button className="secondary" onClick={openFile} type="button">
<FileInput size={18} /> Datei laden
<FileInput size={17} /> {t.loadFile}
</button>
<button className="secondary" onClick={saveFile} type="button" disabled={!result.output}>
<FileDown size={18} /> .env speichern
<FileDown size={17} /> {t.saveFile}
</button>
<div className="segmented" aria-label={t.theme}>
{themeLabels.map((mode) => (
<button
className={themeMode === mode ? "active" : ""}
key={mode}
onClick={() => setThemeMode(mode)}
type="button"
title={t[mode]}
>
{mode === "system" ? <SunMoon size={15} /> : t[mode]}
</button>
))}
</div>
<button className="iconButton" onClick={() => setSettingsOpen(true)} title={t.settings} type="button">
<Settings size={18} />
</button>
</div>
</header>
<section className="statusStrip">
<Stat label={t.hits} value={String(result.changedCount)} />
<Stat label={t.values} value={String(result.replacements.length)} />
<Stat label={t.defaultsApplied} value={String(result.defaultedKeys.length)} />
<Stat label={t.source} value={loadedPath ? "Datei" : t.text} />
</section>
<section className="stats">
<div>
<span>{result.changedCount}</span>
<p>Fundstellen</p>
</div>
<div>
<span>{result.replacements.length}</span>
<p>eindeutige Werte</p>
</div>
<div>
<span>{loadedPath ? "Datei" : "Text"}</span>
<p>Quelle</p>
</div>
</section>
<section className="workspace">
<div className="panel">
<div className="panelHeader">
<div>
<h2>Input</h2>
<p>{loadedPath ?? "Direkt einfügen oder Beispieldaten ersetzen"}</p>
</div>
<button className="iconButton" onClick={() => setInput(sampleEnv)} title="Beispiel laden" type="button">
<RefreshCcw size={18} />
<section className="workbench">
<EditorPanel
actions={
<button className="iconButton" onClick={() => setInput(sampleEnv)} title={t.sample} type="button">
<RefreshCcw size={17} />
</button>
</div>
<textarea
spellCheck={false}
value={input}
onChange={(event) => setInput(event.target.value)}
aria-label="Env input"
/>
</div>
}
hint={loadedPath ?? t.inputHint}
title={t.input}
>
<textarea spellCheck={false} value={input} onChange={(event) => setInput(event.target.value)} aria-label="Env input" />
</EditorPanel>
<div className="panel">
<div className="panelHeader">
<div>
<h2>Output</h2>
<p>Bereit für Deployment oder Secret Store</p>
</div>
<div className="buttonRow">
<button className="iconButton" onClick={regenerate} title="Neu erzeugen" type="button">
<RefreshCcw size={18} />
<EditorPanel
actions={
<>
<button className="iconButton" onClick={() => setGeneration((current) => current + 1)} title={t.regenerate} type="button">
<RefreshCcw size={17} />
</button>
<button className="primary" onClick={copyOutput} type="button">
{copyLabel === "Kopiert" ? <Check size={18} /> : <Clipboard size={18} />} {copyLabel}
{copyLabel === "copied" ? <Check size={17} /> : <Clipboard size={17} />} {copyLabel === "copied" ? t.copied : t.copy}
</button>
</div>
</div>
</>
}
hint={t.outputHint}
title={t.output}
>
<textarea spellCheck={false} value={result.output} readOnly aria-label="Env output" />
</div>
</EditorPanel>
</section>
<section className="replacementPanel">
<div className="panelHeader compact">
<div>
<h2>Erkannte Anforderungen</h2>
<p>Formate werden aus Variablennamen und Platzhaltertext abgeleitet.</p>
<section className="lowerGrid">
<section className="dataPanel">
<PanelHeading icon={<Plus size={18} />} title={t.defaults} hint={t.defaultsHint} />
<div className="defaultsList">
{defaults.map((entry, index) => (
<div className="defaultRow" key={index}>
<input
aria-label={t.key}
placeholder={t.key}
value={entry.key}
onChange={(event) => updateDefault(index, "key", event.target.value)}
/>
<input
aria-label={t.value}
placeholder={t.value}
value={entry.value}
onChange={(event) => updateDefault(index, "value", event.target.value)}
/>
<button className="iconButton subtle" onClick={() => removeDefault(index)} title="Remove" type="button">
<Trash2 size={16} />
</button>
</div>
<ShieldCheck size={22} />
))}
</div>
<button className="secondary fullWidth" onClick={addDefault} type="button">
<Plus size={17} /> {t.addDefault}
</button>
</section>
<section className="dataPanel">
<PanelHeading icon={<ShieldCheck size={18} />} title={t.detected} hint={t.detectedHint} />
{result.replacements.length === 0 ? (
<div className="empty">Keine CHANGE_ME Platzhalter gefunden.</div>
<div className="empty">{t.noPlaceholders}</div>
) : (
<div className="replacementGrid">
{result.replacements.map((replacement) => (
<article className="replacement" key={replacement.placeholder}>
<div>
<strong>{replacement.placeholder}</strong>
<p>{replacement.keys.join(", ")}</p>
<div className="requirementsTable">
<div className="tableHeader">
<span>{t.placeholder}</span>
<span>{t.keys}</span>
<span>{t.format}</span>
</div>
{result.replacements.map((replacement) => (
<div className="tableRow" key={replacement.placeholder}>
<code>{replacement.placeholder}</code>
<span>{replacement.keys.join(", ")}</span>
<strong>{replacement.format}</strong>
</div>
<span>{replacement.format}</span>
</article>
))}
</div>
)}
</section>
</section>
{settingsOpen ? (
<aside className="settingsOverlay" aria-label={t.settings}>
<div className="settingsPanel">
<div className="settingsHeader">
<div>
<h2>{t.settings}</h2>
<p>{t.appSubtitle}</p>
</div>
<button className="iconButton" onClick={() => setSettingsOpen(false)} title="Close" type="button">
<X size={18} />
</button>
</div>
<div className="settingsGroup">
<label>
<Languages size={18} /> {t.language}
</label>
<div className="languageGrid">
{(Object.keys(languageNames) as Language[]).map((code) => (
<button className={language === code ? "selected" : ""} key={code} onClick={() => setLanguage(code)} type="button">
{languageNames[code]}
</button>
))}
</div>
</div>
<div className="settingsMeta">
<span>{t.version}: {packageInfo.version}</span>
<span>{t.author}: {packageInfo.author}</span>
<span>{t.repository}: MrSphay/envHelper</span>
</div>
</div>
</aside>
) : null}
</main>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="statItem">
<span>{value}</span>
<p>{label}</p>
</div>
);
}
function EditorPanel({ actions, children, hint, title }: { actions: ReactNode; children: ReactNode; hint: string; title: string }) {
return (
<section className="editorPanel">
<div className="panelHeader">
<div>
<h2>{title}</h2>
<p>{hint}</p>
</div>
<div className="buttonRow">{actions}</div>
</div>
{children}
</section>
);
}
function PanelHeading({ hint, icon, title }: { hint: string; icon: ReactNode; title: string }) {
return (
<div className="panelHeading">
<div>
<h2>{title}</h2>
<p>{hint}</p>
</div>
{icon}
</div>
);
}

View File

@@ -5,10 +5,16 @@ export interface Replacement {
keys: string[];
}
export interface EnvDefault {
key: string;
value: string;
}
export interface TransformResult {
output: string;
replacements: Replacement[];
changedCount: number;
defaultedKeys: string[];
}
interface PlaceholderHit {
@@ -18,13 +24,16 @@ interface PlaceholderHit {
const changeMePattern = /CHANGE_ME[A-Z0-9_]*/g;
export function transformEnv(input: string): TransformResult {
const hits = collectHits(input);
export function transformEnv(input: string, defaults: EnvDefault[] = []): TransformResult {
const defaultMap = normalizeDefaults(defaults);
const defaultedKeys: string[] = [];
const inputWithDefaults = applyDefaults(input, defaultMap, defaultedKeys);
const hits = collectHits(inputWithDefaults);
const generated = new Map<string, Replacement>();
for (const hit of hits) {
if (!generated.has(hit.placeholder)) {
const requirement = inferRequirement(hit.key, hit.placeholder);
const requirement = inferRequirement(hit.placeholder, hit.key);
generated.set(hit.placeholder, {
placeholder: hit.placeholder,
value: generateValue(requirement),
@@ -39,7 +48,7 @@ export function transformEnv(input: string): TransformResult {
}
}
let output = input;
let output = inputWithDefaults;
const orderedReplacements = [...generated.values()].sort((a, b) => b.placeholder.length - a.placeholder.length);
for (const replacement of orderedReplacements) {
output = output.replaceAll(replacement.placeholder, replacement.value);
@@ -48,10 +57,43 @@ export function transformEnv(input: string): TransformResult {
return {
output,
replacements: [...generated.values()].sort((a, b) => a.placeholder.localeCompare(b.placeholder)),
changedCount: hits.length
changedCount: hits.length + defaultedKeys.length,
defaultedKeys
};
}
function normalizeDefaults(defaults: EnvDefault[]): Map<string, string> {
const defaultMap = new Map<string, string>();
for (const entry of defaults) {
const key = entry.key.trim();
if (key && entry.value.length > 0) {
defaultMap.set(key, entry.value);
}
}
return defaultMap;
}
function applyDefaults(input: string, defaults: Map<string, string>, defaultedKeys: string[]): string {
if (defaults.size === 0) {
return input;
}
return input
.split(/\r?\n/)
.map((line) => {
const parsed = parseAssignment(line);
if (!parsed || !defaults.has(parsed.key)) {
return line;
}
defaultedKeys.push(parsed.key);
return `${parsed.prefix}${parsed.key}=${defaults.get(parsed.key)}`;
})
.join("\n");
}
function collectHits(input: string): PlaceholderHit[] {
const hits: PlaceholderHit[] = [];
@@ -73,13 +115,17 @@ function collectHits(input: string): PlaceholderHit[] {
return hits;
}
function parseAssignment(line: string): { key: string; value: string } | null {
function parseAssignment(line: string): { key: string; value: string; prefix: string } | null {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
return null;
}
const normalized = trimmed.startsWith("export ") ? trimmed.slice(7).trimStart() : trimmed;
const leadingWhitespace = line.match(/^\s*/)?.[0] ?? "";
const withoutLeading = line.slice(leadingWhitespace.length);
const hasExport = withoutLeading.startsWith("export ");
const prefix = hasExport ? `${leadingWhitespace}export ` : leadingWhitespace;
const normalized = hasExport ? withoutLeading.slice(7).trimStart() : withoutLeading;
const equalsIndex = normalized.indexOf("=");
if (equalsIndex <= 0) {
return null;
@@ -87,7 +133,8 @@ function parseAssignment(line: string): { key: string; value: string } | null {
return {
key: normalized.slice(0, equalsIndex).trim(),
value: normalized.slice(equalsIndex + 1).trim()
value: normalized.slice(equalsIndex + 1).trim(),
prefix
};
}
@@ -101,12 +148,18 @@ type Requirement =
| { type: "secret"; length: number; label: string }
| { type: "password"; length: number; urlSafe: boolean; label: string };
function inferRequirement(key: string, placeholder: string): Requirement {
const signal = `${key}_${placeholder}`.toUpperCase();
function inferRequirement(placeholder: string, key: string): Requirement {
const placeholderSignal = placeholder.toUpperCase();
const keySignal = key.toUpperCase();
const combinedSignal = `${placeholderSignal}_${keySignal}`;
const byteCount = extractNumberBefore(signal, "RANDOM_BYTES") ?? extractNumberBefore(signal, "BYTES");
const byteCount =
extractNumberBefore(placeholderSignal, "RANDOM_BYTES") ??
extractNumberBefore(placeholderSignal, "BYTES") ??
extractNumberBefore(combinedSignal, "RANDOM_BYTES") ??
extractNumberBefore(combinedSignal, "BYTES");
if (signal.includes("BASE64")) {
if (placeholderSignal.includes("BASE64") || keySignal.includes("BASE64")) {
return {
type: "base64",
bytes: byteCount ?? 32,
@@ -114,38 +167,49 @@ function inferRequirement(key: string, placeholder: string): Requirement {
};
}
if (signal.includes("UUID")) {
if (placeholderSignal.includes("UUID") || keySignal.includes("UUID")) {
return { type: "uuid", label: "UUID v4" };
}
if (signal.includes("HEX")) {
if (placeholderSignal.includes("HEX") || keySignal.includes("HEX")) {
return { type: "hex", bytes: byteCount ?? 32, label: `${byteCount ?? 32} random bytes as hex` };
}
if (signal.includes("PORT")) {
if (placeholderSignal.includes("PORT") || keySignal.includes("PORT")) {
return { type: "integer", min: 1024, max: 49151, label: "TCP port number" };
}
if (signal.includes("EMAIL")) {
if (placeholderSignal.includes("EMAIL") || keySignal.includes("EMAIL")) {
return { type: "email", label: "email address" };
}
if (signal.includes("URL") || signal.includes("URI") || signal.includes("ORIGIN")) {
return { type: "url", label: "HTTPS URL" };
}
if (signal.includes("PASSWORD")) {
const length = signal.includes("LONG") || signal.includes("ADMIN") ? 28 : 24;
if (placeholderSignal.includes("PASSWORD") || keySignal.includes("PASSWORD")) {
const length = placeholderSignal.includes("LONG") || keySignal.includes("ADMIN") ? 28 : 24;
return {
type: "password",
length,
urlSafe: signal.includes("DATABASE") || signal.includes("POSTGRES") || signal.includes("MYSQL"),
urlSafe: combinedSignal.includes("DATABASE") || combinedSignal.includes("POSTGRES") || combinedSignal.includes("MYSQL"),
label: `${length} character strong password`
};
}
const minimumLength = extractNumberAfter(signal, "AT_LEAST") ?? 48;
if (signal.includes("SECRET") || signal.includes("TOKEN") || signal.includes("KEY")) {
if (placeholderSignal.includes("URL") || placeholderSignal.includes("URI") || placeholderSignal.includes("ORIGIN")) {
return { type: "url", label: "HTTPS URL" };
}
if (keySignal.includes("URL") || keySignal.includes("URI") || keySignal.includes("ORIGIN")) {
return { type: "url", label: "HTTPS URL" };
}
const minimumLength = extractNumberAfter(placeholderSignal, "AT_LEAST") ?? extractNumberAfter(combinedSignal, "AT_LEAST") ?? 48;
if (
placeholderSignal.includes("SECRET") ||
placeholderSignal.includes("TOKEN") ||
placeholderSignal.includes("KEY") ||
keySignal.includes("SECRET") ||
keySignal.includes("TOKEN") ||
keySignal.includes("KEY")
) {
return {
type: "secret",
length: Math.max(minimumLength, 32),

View File

@@ -1,28 +1,95 @@
:root {
color: #17211c;
background: #f5f5ef;
color-scheme: light;
--bg: #eef1ed;
--bg-soft: #f7f8f5;
--surface: rgba(255, 255, 255, 0.92);
--surface-solid: #ffffff;
--surface-subtle: #f0f4ef;
--text: #18231e;
--muted: #62706a;
--border: #d8dfd6;
--accent: #1f665a;
--accent-strong: #154d44;
--accent-soft: #e2eee9;
--danger: #9f3e34;
--editor-bg: #0d1714;
--editor-text: #eef8f3;
--shadow: 0 18px 45px rgba(42, 56, 49, 0.12);
background: var(--bg);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
@media (prefers-color-scheme: dark) {
:root[data-theme="system"] {
color-scheme: dark;
--bg: #111614;
--bg-soft: #151d1a;
--surface: rgba(26, 35, 31, 0.94);
--surface-solid: #1a231f;
--surface-subtle: #202c27;
--text: #e8eee9;
--muted: #a2afa9;
--border: #34413b;
--accent: #65c0ad;
--accent-strong: #8bd8c7;
--accent-soft: #233c36;
--danger: #ef8b7d;
--editor-bg: #07100d;
--editor-text: #f1fbf7;
--shadow: 0 18px 45px rgba(0, 0, 0, 0.28);
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #111614;
--bg-soft: #151d1a;
--surface: rgba(26, 35, 31, 0.94);
--surface-solid: #1a231f;
--surface-subtle: #202c27;
--text: #e8eee9;
--muted: #a2afa9;
--border: #34413b;
--accent: #65c0ad;
--accent-strong: #8bd8c7;
--accent-soft: #233c36;
--danger: #ef8b7d;
--editor-bg: #07100d;
--editor-text: #f1fbf7;
--shadow: 0 18px 45px rgba(0, 0, 0, 0.28);
}
:root[data-theme="light"] {
color-scheme: light;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
min-width: 940px;
min-height: 100vh;
min-width: 0;
overflow: hidden;
background:
linear-gradient(135deg, rgba(41, 94, 82, 0.12), transparent 36%),
linear-gradient(315deg, rgba(190, 78, 61, 0.1), transparent 34%),
#f5f5ef;
radial-gradient(circle at 0 0, color-mix(in srgb, var(--accent) 16%, transparent), transparent 30%),
var(--bg);
}
button,
textarea {
input,
textarea,
select {
font: inherit;
}
@@ -32,15 +99,17 @@ button {
border-radius: 8px;
cursor: pointer;
display: inline-flex;
font-weight: 700;
font-size: 0.9rem;
font-weight: 750;
gap: 8px;
justify-content: center;
min-height: 42px;
padding: 0 16px;
min-height: 36px;
padding: 0 13px;
transition:
transform 140ms ease,
box-shadow 140ms ease,
background 140ms ease;
background 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
button:hover:not(:disabled) {
@@ -49,37 +118,64 @@ button:hover:not(:disabled) {
button:disabled {
cursor: not-allowed;
opacity: 0.5;
opacity: 0.55;
}
.shell {
display: flex;
flex-direction: column;
gap: 18px;
margin: 0 auto;
max-width: 1420px;
min-height: 100vh;
padding: 28px;
input {
background: var(--surface-solid);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
min-height: 36px;
min-width: 0;
outline: none;
padding: 0 11px;
}
.hero {
align-items: flex-end;
display: flex;
gap: 24px;
justify-content: space-between;
padding: 10px 2px 4px;
input:focus,
textarea:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.eyebrow {
.appShell {
display: grid;
gap: 12px;
grid-template-rows: auto auto minmax(260px, 1fr) minmax(160px, 0.52fr);
height: 100%;
padding: 14px;
}
.toolbar {
align-items: center;
color: #2d6f62;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
display: flex;
font-size: 0.85rem;
font-weight: 800;
gap: 7px;
letter-spacing: 0;
margin: 0 0 10px;
text-transform: uppercase;
gap: 16px;
justify-content: space-between;
min-height: 64px;
padding: 10px 12px;
}
.brand {
align-items: center;
display: flex;
gap: 11px;
min-width: 220px;
}
.brandMark {
align-items: center;
background: var(--accent);
border-radius: 8px;
color: #fff;
display: flex;
font-size: 0.78rem;
font-weight: 850;
height: 38px;
justify-content: center;
width: 38px;
}
h1,
@@ -89,212 +185,369 @@ p {
}
h1 {
color: #13201b;
font-size: 2.8rem;
font-size: 1.15rem;
letter-spacing: 0;
line-height: 1.02;
line-height: 1.1;
}
h2 {
font-size: 1rem;
font-size: 0.98rem;
letter-spacing: 0;
}
.subline {
color: #53635d;
font-size: 1.04rem;
line-height: 1.5;
margin-top: 12px;
max-width: 720px;
.brand p,
.panelHeader p,
.panelHeading p,
.settingsHeader p,
.statItem p {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.35;
margin-top: 3px;
}
.heroActions,
.toolbarActions,
.buttonRow {
align-items: center;
display: flex;
gap: 10px;
gap: 8px;
}
.primary {
background: #1f5d53;
color: #ffffff;
box-shadow: 0 10px 24px rgba(31, 93, 83, 0.18);
background: var(--accent);
color: #fff;
box-shadow: 0 10px 22px color-mix(in srgb, var(--accent) 18%, transparent);
}
.secondary {
background: #ffffff;
color: #1f332d;
box-shadow: inset 0 0 0 1px #d9ded5;
.primary:hover:not(:disabled) {
background: var(--accent-strong);
}
.secondary,
.iconButton,
.segmented {
background: var(--surface-solid);
border: 1px solid var(--border);
color: var(--text);
}
.iconButton {
background: #eef2ec;
color: #26372f;
min-width: 42px;
min-width: 36px;
padding: 0;
}
.stats {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
.iconButton.subtle {
color: var(--muted);
}
.stats div {
background: rgba(255, 255, 255, 0.78);
border: 1px solid #dde3da;
.segmented {
border-radius: 8px;
padding: 14px 16px;
display: flex;
overflow: hidden;
padding: 2px;
}
.stats span {
color: #9b3f31;
.segmented button {
background: transparent;
border-radius: 6px;
color: var(--muted);
min-height: 30px;
min-width: 42px;
padding: 0 10px;
}
.segmented button.active {
background: var(--accent-soft);
color: var(--accent-strong);
}
.statusStrip {
display: grid;
gap: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.statItem {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
}
.statItem span {
color: var(--danger);
display: block;
font-size: 1.35rem;
font-size: 1.08rem;
font-weight: 850;
line-height: 1;
}
.stats p,
.panelHeader p,
.replacement p {
color: #64726d;
font-size: 0.86rem;
line-height: 1.4;
margin-top: 5px;
}
.workspace {
.workbench {
display: grid;
flex: 1;
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: 430px;
gap: 12px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.panel,
.replacementPanel {
background: rgba(255, 255, 255, 0.86);
border: 1px solid #dbe2d8;
.editorPanel,
.dataPanel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 20px 54px rgba(54, 67, 61, 0.1);
box-shadow: var(--shadow);
min-width: 0;
}
.panel {
.editorPanel {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.panelHeader {
.panelHeader,
.panelHeading {
align-items: center;
border-bottom: 1px solid #e2e6de;
border-bottom: 1px solid var(--border);
display: flex;
gap: 16px;
gap: 12px;
justify-content: space-between;
padding: 16px;
}
.panelHeader.compact {
border-bottom: 0;
padding-bottom: 10px;
min-height: 62px;
padding: 11px 12px;
}
textarea {
background: #101815;
background: var(--editor-bg);
border: 0;
color: #e7eee8;
color: var(--editor-text);
flex: 1;
font-family: "Cascadia Code", "Fira Code", Consolas, monospace;
font-size: 0.92rem;
line-height: 1.55;
min-height: 360px;
min-height: 0;
outline: none;
padding: 18px;
padding: 15px;
resize: none;
white-space: pre;
}
textarea::selection {
background: #be4e3d;
color: #ffffff;
background: var(--accent);
color: #fff;
}
.replacementPanel {
padding-bottom: 16px;
}
.replacementGrid {
.lowerGrid {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 0 16px;
}
.replacement {
align-items: flex-start;
background: #f7f8f4;
border: 1px solid #e1e6dd;
border-radius: 8px;
display: flex;
gap: 12px;
justify-content: space-between;
min-height: 88px;
padding: 13px;
grid-template-columns: minmax(320px, 0.72fr) minmax(0, 1.28fr);
min-height: 0;
}
.replacement strong {
color: #26372f;
display: block;
font-size: 0.86rem;
line-height: 1.35;
.dataPanel {
display: flex;
flex-direction: column;
min-height: 0;
padding-bottom: 12px;
}
.panelHeading {
border-bottom: 0;
}
.defaultsList {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: auto;
padding: 0 12px 10px;
}
.defaultRow {
display: grid;
gap: 8px;
grid-template-columns: minmax(120px, 0.9fr) minmax(140px, 1.1fr) 36px;
}
.fullWidth {
margin: 0 12px;
}
.requirementsTable {
border: 1px solid var(--border);
border-radius: 8px;
margin: 0 12px;
min-height: 0;
overflow: auto;
}
.tableHeader,
.tableRow {
display: grid;
gap: 12px;
grid-template-columns: minmax(180px, 0.95fr) minmax(160px, 1fr) minmax(150px, 0.8fr);
min-width: 620px;
}
.tableHeader {
background: var(--surface-subtle);
color: var(--muted);
font-size: 0.75rem;
font-weight: 850;
padding: 9px 12px;
text-transform: uppercase;
}
.tableRow {
align-items: center;
border-top: 1px solid var(--border);
padding: 10px 12px;
}
.tableRow code {
color: var(--text);
font-family: "Cascadia Code", Consolas, monospace;
font-size: 0.82rem;
overflow-wrap: anywhere;
}
.replacement span {
background: #e6efe8;
border-radius: 999px;
color: #23594f;
flex: 0 0 auto;
font-size: 0.75rem;
font-weight: 800;
max-width: 42%;
padding: 6px 9px;
.tableRow span {
color: var(--muted);
font-size: 0.84rem;
overflow-wrap: anywhere;
}
.tableRow strong {
color: var(--accent-strong);
font-size: 0.82rem;
text-align: right;
}
.empty {
color: #66736d;
padding: 0 16px 4px;
color: var(--muted);
padding: 0 12px 12px;
}
@media (max-width: 1050px) {
body {
min-width: 0;
}
.shell {
padding: 18px;
}
.hero,
.workspace {
grid-template-columns: 1fr;
}
.hero {
.settingsOverlay {
align-items: stretch;
background: rgba(0, 0, 0, 0.2);
display: flex;
inset: 0;
justify-content: flex-end;
padding: 14px;
position: fixed;
z-index: 10;
}
.settingsPanel {
background: var(--surface-solid);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
max-width: 380px;
min-width: 320px;
padding: 14px;
width: 28vw;
}
.settingsHeader {
align-items: flex-start;
display: flex;
justify-content: space-between;
}
.settingsGroup {
border-top: 1px solid var(--border);
margin-top: 18px;
padding-top: 18px;
}
.settingsGroup label {
align-items: center;
display: flex;
font-size: 0.9rem;
font-weight: 800;
gap: 8px;
margin-bottom: 10px;
}
.languageGrid {
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr;
}
.languageGrid button {
background: var(--surface-subtle);
border: 1px solid var(--border);
color: var(--text);
}
.languageGrid button.selected {
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
color: var(--accent-strong);
}
.settingsMeta {
border-top: 1px solid var(--border);
color: var(--muted);
display: flex;
flex-direction: column;
font-size: 0.78rem;
gap: 6px;
margin-top: auto;
padding-top: 14px;
}
@media (max-width: 1100px) {
.appShell {
grid-template-rows: auto auto minmax(420px, 1fr) minmax(320px, auto);
overflow: auto;
}
.heroActions {
body {
overflow: auto;
}
.toolbar,
.toolbarActions {
align-items: stretch;
flex-wrap: wrap;
}
h1 {
font-size: 2.25rem;
}
.replacementGrid,
.stats {
.workbench,
.lowerGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.appShell {
padding: 10px;
}
.statusStrip {
grid-template-columns: 1fr 1fr;
}
.toolbarActions {
width: 100%;
}
.toolbarActions .secondary,
.toolbarActions .segmented {
flex: 1;
}
.defaultRow {
grid-template-columns: 1fr 1fr 36px;
}
.settingsPanel {
max-width: none;
width: 100%;
}
}