feat(service): variables editor with masked-secret preservation

PUT /services/{id}/variables replaced every row, and GET masks secrets as
******** — echoing the fetched list back would have overwritten real
secrets with asterisks. The backend now keeps the stored value when a
submitted secret still carries the mask.

Service detail gains a Variables section: add/edit/remove rows, secret
toggle, bulk .env paste (comments, export prefix, quoted values), and
inline validation for empty/invalid/duplicate keys. Parser and validator
live in variable-utils.ts with unit coverage.

Generated OpenAPI types drive the new updateServiceVariables client call.
ROADMAP: Variables UX marked done; per-environment values are already
structural — each environment owns its service instances.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 10:49:45 +02:00
1 parent e6e76c1084
commit 1d5b99e1ba
6 files changed
+354 -13

No files matched your search

@@ -0,0 +1,48 @@
export type VariableDraft = { key: string; value: string; isSecret: boolean };
export const VARIABLE_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
// parseDotenv handles KEY=VAL lines, '#' comments, 'export ' prefixes and
// single/double-quoted values — enough for standard .env pastes.
export function parseDotenv(text: string): VariableDraft[] {
const rows: VariableDraft[] = [];
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const stripped = line.startsWith('export ') ? line.slice(7).trim() : line;
const eq = stripped.indexOf('=');
if (eq <= 0) {
continue;
}
const key = stripped.slice(0, eq).trim();
let value = stripped.slice(eq + 1).trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
rows.push({ key, value, isSecret: false });
}
return rows;
}
export function validateVariableRows(rows: VariableDraft[]): Record<number, string> {
const errors: Record<number, string> = {};
const seen = new Set<string>();
rows.forEach((row, i) => {
const key = row.key.trim();
if (!key) {
errors[i] = 'Key is required';
} else if (!VARIABLE_KEY_RE.test(key)) {
errors[i] = 'Letters, digits, underscore; must not start with a digit';
} else if (seen.has(key)) {
errors[i] = 'Duplicate key';
} else {
seen.add(key);
}
});
return errors;
}