diff --git a/frontend/src/components/CopyField.jsx b/frontend/src/components/CopyField.jsx
new file mode 100644
index 00000000..605e6695
--- /dev/null
+++ b/frontend/src/components/CopyField.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+import { Eye, EyeOff } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { CopyButton } from './CopyButton';
+
+const MASK = '••••••••••••';
+
+/**
+ * The one way to show a value someone will copy: commands, IPs, URLs,
+ * connection strings, tokens, passwords.
+ *
+ *
+ *
+ *
+ *
+ * `secret` hides the value until the eye is pressed; copying works either way,
+ * so a secret never has to be shown to be used. Use the bare CopyButton only
+ * when the copied text is not on screen (a "Copy command" action).
+ */
+export default function CopyField({
+ value,
+ label,
+ secret = false,
+ multiline = false,
+ className,
+ onCopy,
+}) {
+ const { t } = useTranslation();
+ const [revealed, setRevealed] = useState(false);
+ const text = value == null ? '' : String(value);
+ const hidden = secret && !revealed;
+
+ return (
+
+ {label &&
{label} }
+
+ {multiline
+ ?
{hidden ? MASK : text}
+ :
{hidden ? MASK : text}}
+
+ {secret && (
+ setRevealed((v) => !v)}
+ aria-label={revealed ? t('app.copyField.hide', 'Hide') : t('app.copyField.show', 'Show')}
+ title={revealed ? t('app.copyField.hide', 'Hide') : t('app.copyField.show', 'Show')}
+ >
+ {revealed ? : }
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/EnvEditor.jsx b/frontend/src/components/EnvEditor.jsx
new file mode 100644
index 00000000..94e31ed6
--- /dev/null
+++ b/frontend/src/components/EnvEditor.jsx
@@ -0,0 +1,119 @@
+import { useState } from 'react';
+import { Eye, EyeOff, Plus, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { parseDotenv, isValidEnvKey, looksSecret } from '../utils/dotenv';
+
+/**
+ * The one KEY=value editor for forms (run a container, deploy a template,
+ * shared variable groups). The per-app variables table with history and
+ * import/export is EnvironmentVariables; this is its in-form sibling.
+ *
+ *
+ *
+ * `value` is [{ key, value }]; convert with envToObject / envFromObject from
+ * utils/dotenv. Pasting a multi-line .env into any key box splits it into
+ * rows. Values whose key looks like a credential are masked until revealed.
+ */
+export default function EnvEditor({
+ value = [],
+ onChange,
+ disabled = false,
+ className,
+}) {
+ const { t } = useTranslation();
+ const [revealed, setRevealed] = useState(() => new Set());
+ const rows = value.length ? value : [{ key: '', value: '' }];
+
+ const emit = (next) => onChange?.(next);
+ const update = (index, patch) => emit(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
+ const remove = (index) => emit(rows.filter((_, i) => i !== index));
+ const add = () => emit([...rows, { key: '', value: '' }]);
+
+ const onPaste = (index, event) => {
+ const text = event.clipboardData?.getData('text') || '';
+ if (!text.includes('\n') && !text.includes('=')) return;
+ const parsed = parseDotenv(text);
+ if (!parsed.length) return;
+ event.preventDefault();
+ const before = rows.slice(0, index).filter((r) => r.key || r.value);
+ const after = rows.slice(index + 1).filter((r) => r.key || r.value);
+ emit([...before, ...parsed, ...after]);
+ };
+
+ const counts = rows.reduce((acc, row) => {
+ const k = row.key.trim();
+ if (k) acc[k] = (acc[k] || 0) + 1;
+ return acc;
+ }, {});
+
+ const toggle = (index) => setRevealed((prev) => {
+ const next = new Set(prev);
+ if (next.has(index)) next.delete(index); else next.add(index);
+ return next;
+ });
+
+ return (
+
+ {rows.map((row, index) => {
+ const key = row.key.trim();
+ const badKey = key && !isValidEnvKey(key);
+ const duplicate = key && counts[key] > 1;
+ const masked = looksSecret(key) && !revealed.has(index);
+ return (
+ // Rows have no identity beyond their position while being typed.
+
+
update(index, { key: e.target.value })}
+ onPaste={(e) => onPaste(index, e)}
+ placeholder="KEY"
+ disabled={disabled}
+ spellCheck={false}
+ autoComplete="off"
+ aria-invalid={Boolean(badKey || duplicate)}
+ aria-label={t('app.envEditor.key', 'Key')}
+ className="sk-env-editor__key"
+ />
+
update(index, { value: e.target.value })}
+ placeholder="value"
+ type={masked ? 'password' : 'text'}
+ disabled={disabled}
+ spellCheck={false}
+ autoComplete="off"
+ aria-label={t('app.envEditor.value', 'Value')}
+ className="sk-env-editor__value"
+ />
+ {looksSecret(key) ? (
+
toggle(index)}
+ aria-label={masked ? t('app.copyField.show', 'Show') : t('app.copyField.hide', 'Hide')}>
+ {masked ? : }
+
+ ) :
}
+
remove(index)}
+ disabled={disabled} aria-label={t('app.envEditor.remove', 'Remove {{key}}', { key: key || t('app.envEditor.row', 'row') })}>
+
+
+ {(badKey || duplicate) && (
+
+ {badKey
+ ? t('app.envEditor.badKey', 'Use letters, digits and _, not starting with a digit.')
+ : t('app.envEditor.duplicate', '{{key}} is set more than once; the last one wins.', { key })}
+
+ )}
+
+ );
+ })}
+
+
+ {t('app.envEditor.add', 'Add variable')}
+
+
{t('app.envEditor.pasteHint', 'Paste a .env file into a key box to fill several rows.')}
+
+
+ );
+}
diff --git a/frontend/src/components/PortField.jsx b/frontend/src/components/PortField.jsx
new file mode 100644
index 00000000..61d716ad
--- /dev/null
+++ b/frontend/src/components/PortField.jsx
@@ -0,0 +1,126 @@
+import { useEffect, useId, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import api from '../services/api';
+
+const MIN_PORT = 1;
+const MAX_PORT = 65535;
+const PRIVILEGED_LIMIT = 1024;
+// Wait for typing to settle before asking the server about a port.
+const CHECK_DELAY_MS = 350;
+
+/**
+ * The one port input.
+ *
+ * // host port, checked
+ * // container port
+ *
+ * Host ports (the default) are checked against the server as you type and
+ * say who holds a taken one ("Used by shop-api"), with a one-click next free
+ * port. `ownerAppId` marks the app being edited, so its own port reads as
+ * its own rather than taken. Container ports only get range checks: they
+ * live inside the container and never collide on the host.
+ *
+ * onChange receives a number, or '' while the field is empty.
+ */
+export default function PortField({
+ value,
+ onChange,
+ host = true,
+ ownerAppId = null,
+ allowPrivileged = false,
+ suggestFrom = 8000,
+ placeholder = '8080',
+ disabled = false,
+ required = false,
+ id,
+ className,
+}) {
+ const { t } = useTranslation();
+ const fallbackId = useId();
+ const [check, setCheck] = useState(null);
+ const [suggestion, setSuggestion] = useState(null);
+
+ const raw = value === '' || value == null ? '' : String(value);
+ const port = raw === '' ? null : Number(raw);
+ const inRange = port != null && Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT;
+ const privileged = inRange && port < PRIVILEGED_LIMIT;
+
+ useEffect(() => {
+ setCheck(null);
+ setSuggestion(null);
+ if (!host || !inRange || (privileged && !allowPrivileged)) return undefined;
+ let cancelled = false;
+ const timer = setTimeout(async () => {
+ try {
+ const res = await api.checkPort(port);
+ if (cancelled) return;
+ setCheck(res);
+ if (!res.free && !(ownerAppId != null && res.holder?.id === ownerAppId)) {
+ const next = await api.suggestPort(Math.max(port + 1, suggestFrom));
+ if (!cancelled) setSuggestion(next?.port ?? null);
+ }
+ } catch {
+ // The check is advisory; the deploy still validates the port.
+ }
+ }, CHECK_DELAY_MS);
+ return () => { cancelled = true; clearTimeout(timer); };
+ }, [host, port, inRange, privileged, allowPrivileged, ownerAppId, suggestFrom]);
+
+ const ownPort = check?.holder && ownerAppId != null && check.holder.id === ownerAppId;
+ let status = null;
+ if (raw !== '' && !inRange) {
+ status = { tone: 'error', text: t('app.portField.range', 'Ports run from 1 to 65535.') };
+ } else if (privileged && !allowPrivileged) {
+ status = { tone: 'error', text: t('app.portField.privileged', 'Ports below 1024 need root. Use 1024 or higher.') };
+ } else if (check && ownPort) {
+ status = { text: t('app.portField.ownPort', 'This app already uses {{port}}.', { port }) };
+ } else if (check?.holder?.kind === 'app') {
+ status = { tone: 'error', text: t('app.portField.usedByApp', 'Used by {{name}}.', { name: check.holder.name }) };
+ } else if (check?.holder) {
+ status = { tone: 'error', text: t('app.portField.usedByContainer', 'A running container already maps {{port}}.', { port }) };
+ } else if (check && !check.free) {
+ status = { tone: 'error', text: t('app.portField.bound', 'Something outside ServerKit is listening on {{port}}.', { port }) };
+ } else if (check?.free) {
+ status = { text: t('app.portField.free', '{{port}} is free.', { port }) };
+ }
+
+ return (
+
+
onChange?.(e.target.value === '' ? '' : Number(e.target.value))}
+ placeholder={placeholder}
+ disabled={disabled}
+ required={required}
+ aria-invalid={status?.tone === 'error'}
+ className="sk-port-field__input"
+ />
+ {status && (
+
+ {status.text}
+ {suggestion && !ownPort && (
+ onChange?.(suggestion)}
+ disabled={disabled}
+ >
+ {t('app.portField.useSuggestion', 'Use {{port}}', { port: suggestion })}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ServerPicker.jsx b/frontend/src/components/ServerPicker.jsx
new file mode 100644
index 00000000..03d4524d
--- /dev/null
+++ b/frontend/src/components/ServerPicker.jsx
@@ -0,0 +1,174 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Server } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import ResourcePicker from './ResourcePicker';
+import api from '../services/api';
+import { formatBytes } from '../utils/formatBytes';
+import { LOCAL_SERVER_ID } from '../utils/serverTarget';
+
+// The panel's server list rarely changes while a page is open, and several
+// pickers can mount at once, so they share one request per page load.
+let serversRequest = null;
+function loadServers() {
+ if (!serversRequest) {
+ serversRequest = api.getAvailableServers()
+ .then((data) => (Array.isArray(data) ? data : []))
+ .catch(() => {
+ serversRequest = null;
+ return [];
+ });
+ }
+ return serversRequest;
+}
+
+const enabledCapabilities = (server) => {
+ const caps = Object.entries(server?.capabilities || {})
+ .filter(([, enabled]) => enabled === true)
+ .map(([name]) => name);
+ if ((server?.is_local || server?.has_docker) && !caps.includes('docker')) caps.push('docker');
+ return caps;
+};
+
+function capacityLabel(server) {
+ const parts = [];
+ if (server.cpu_cores) parts.push(`${server.cpu_cores} vCPU`);
+ if (server.total_memory) parts.push(formatBytes(server.total_memory, { decimals: 0 }));
+ return parts.join(' · ');
+}
+
+/**
+ * The one server picker: "which machine does this run on".
+ *
+ * …} capability="docker" />
+ *
+ * `value` is a server id, or LOCAL_SERVER_ID for the panel host. `onChange`
+ * gets the id and the full server row from /servers/available (allowed_paths,
+ * os_type, agent dirs, …) or null for an `extraOptions` entry. Offline
+ * servers are hidden unless `onlineOnly` is false; `capability` narrows the
+ * list to servers that can do the job.
+ */
+export default function ServerPicker({
+ value = LOCAL_SERVER_ID,
+ onChange,
+ capability,
+ includeLocal = true,
+ onlineOnly = true,
+ extraOptions = [],
+ label,
+ disabled = false,
+ className,
+}) {
+ const { t } = useTranslation();
+ const [servers, setServers] = useState([]);
+
+ useEffect(() => {
+ let cancelled = false;
+ loadServers().then((rows) => { if (!cancelled) setServers(rows); });
+ return () => { cancelled = true; };
+ }, []);
+
+ const serverById = useMemo(
+ () => new Map(servers.map((server) => [String(server.id), server])),
+ [servers],
+ );
+
+ const localServer = servers.find((server) => server.is_local) || null;
+
+ const describe = (server) => {
+ const location = server.is_local
+ ? t('app.serverPicker.thisPanel', 'this panel')
+ : (server.group_name || t('app.serverPicker.remote', 'remote'));
+ return [server.os_type ? `${location} · ${server.os_type}` : location, capacityLabel(server)]
+ .filter(Boolean).join(' · ');
+ };
+
+ const localOption = useMemo(() => ({
+ type: 'server-target',
+ id: LOCAL_SERVER_ID,
+ label: t('app.targetPicker.localThisServer', 'Local (this server)'),
+ sublabel: localServer ? describe(localServer) : '',
+ path: '/servers',
+ scope: {},
+ status: 'online',
+ capabilities: localServer ? enabledCapabilities(localServer) : ['docker'],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }), [localServer, t]);
+
+ const staticOptions = useMemo(() => [
+ ...(includeLocal ? [localOption] : []),
+ ...extraOptions.map((option) => ({
+ type: 'server-target',
+ id: String(option.value),
+ label: option.label,
+ sublabel: option.sublabel || '',
+ path: '/servers',
+ scope: {},
+ status: null,
+ capabilities: [],
+ })),
+ ], [extraOptions, includeLocal, localOption]);
+
+ const selected = useMemo(() => {
+ const id = String(value ?? LOCAL_SERVER_ID);
+ const fromStatic = staticOptions.find((option) => option.id === id);
+ if (fromStatic) return fromStatic;
+ const server = serverById.get(id);
+ if (!server) return null;
+ return {
+ type: 'server',
+ id,
+ label: server.name || server.hostname || id,
+ sublabel: describe(server),
+ path: `/servers/${id}`,
+ scope: {},
+ status: server.status || null,
+ capabilities: enabledCapabilities(server),
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value, staticOptions, serverById]);
+
+ const decorateOption = (resource) => {
+ const server = serverById.get(resource.id);
+ if (!server || resource.type !== 'server') return resource;
+ return {
+ ...resource,
+ sublabel: describe(server),
+ capabilities: [...new Set([...resource.capabilities, ...enabledCapabilities(server)])],
+ };
+ };
+
+ const filterOption = (resource) => {
+ if (resource.type !== 'server') return true;
+ if (serverById.get(resource.id)?.is_local) return false; // shown as "Local"
+ if (onlineOnly && resource.status && resource.status !== 'online') return false;
+ return true;
+ };
+
+ const pickerLabel = label || t('app.serverPicker.server', 'Server');
+
+ return (
+ {
+ if (resource.type === 'server-target') {
+ onChange?.(resource.id, resource.id === LOCAL_SERVER_ID ? localServer : null);
+ return;
+ }
+ onChange?.(resource.id, serverById.get(resource.id) || null);
+ }}
+ types={['server']}
+ capabilities={capability ? [capability] : []}
+ staticOptions={staticOptions}
+ filterOption={filterOption}
+ decorateOption={decorateOption}
+ icon={Server}
+ disabled={disabled}
+ label={pickerLabel}
+ placeholder={pickerLabel}
+ searchPlaceholder={t('app.serverPicker.findAServer', 'Find a server…')}
+ emptyMessage={t('app.serverPicker.noServersMatch', 'No servers match.')}
+ className={['sk-server-picker', className].filter(Boolean).join(' ')}
+ />
+ );
+}
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 0c47c801..5045a5ff 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1323,6 +1323,13 @@
"newChat": "New chat",
"noPastConversations": "No past conversations"
},
+ "copyField": {
+ "copied": "Copied",
+ "copy": "Copy",
+ "copyLabel": "Copy {{label}}",
+ "hide": "Hide",
+ "show": "Show"
+ },
"createTableModal": {
"aWholeNumberKeyBecomes": "A whole-number key becomes",
"copyThisInto": "Copy this into",
@@ -2113,6 +2120,16 @@
"theDeployPipelineIsPullingThe": "The deploy pipeline is pulling the image and provisioning the volume. The engine joins the tree once it reports healthy.",
"usePort": "Use port"
},
+ "envEditor": {
+ "add": "Add variable",
+ "badKey": "Use letters, digits and _, not starting with a digit.",
+ "duplicate": "{{key}} is set more than once; the last one wins.",
+ "key": "Key",
+ "pasteHint": "Paste a .env file into a key box to fill several rows.",
+ "remove": "Remove {{key}}",
+ "row": "row",
+ "value": "Value"
+ },
"environmentVariables": {
"addEnvironmentVariable": "Add Environment Variable",
"addOneHereOrImportAn": "Add one here, or import an existing .env file.",
@@ -3841,6 +3858,16 @@
"title": "Connection pooling",
"tradeoff": "Transaction pooling breaks session features: prepared statements in some drivers, LISTEN/NOTIFY, advisory locks and session SET. Apps keep the direct connection unless they switch."
},
+ "portField": {
+ "bound": "Something outside ServerKit is listening on {{port}}.",
+ "free": "{{port}} is free.",
+ "ownPort": "This app already uses {{port}}.",
+ "privileged": "Ports below 1024 need root. Use 1024 or higher.",
+ "range": "Ports run from 1 to 65535.",
+ "useSuggestion": "Use {{port}}",
+ "usedByApp": "Used by {{name}}.",
+ "usedByContainer": "A running container already maps {{port}}."
+ },
"previewDrawer": {
"copyPath": "Copy path",
"downloadFile": "Download File",
@@ -4811,7 +4838,10 @@
"deployToServer": "Deploy to server",
"filterServers": "Filter servers",
"findAServer": "Find a server…",
- "noServersMatch": "No servers match."
+ "noServersMatch": "No servers match.",
+ "remote": "remote",
+ "server": "Server",
+ "thisPanel": "this panel"
},
"serverRestorePoints": {
"application": "Application",
diff --git a/frontend/src/services/api/apps.js b/frontend/src/services/api/apps.js
index c8858052..69cb48e8 100644
--- a/frontend/src/services/api/apps.js
+++ b/frontend/src/services/api/apps.js
@@ -603,3 +603,13 @@ export async function previewAppComposeSplit(appId) {
export async function applyAppComposeSplit(appId) {
return this.request(`/apps/${appId}/slots/compose-split`, { method: 'POST', body: { confirm: true } });
}
+
+// Host-port checks behind the shared PortField: is it valid, free, and who
+// holds it; and the next free port at or above `start`.
+export async function checkPort(port) {
+ return this.request(`/ports/check?port=${encodeURIComponent(port)}`);
+}
+
+export async function suggestPort(start = 8000) {
+ return this.request(`/ports/suggest?start=${encodeURIComponent(start)}`);
+}
diff --git a/frontend/src/styles/components/_fields.scss b/frontend/src/styles/components/_fields.scss
new file mode 100644
index 00000000..4512e245
--- /dev/null
+++ b/frontend/src/styles/components/_fields.scss
@@ -0,0 +1,104 @@
+// Shared field components: CopyField, PortField, EnvEditor, ServerPicker.
+// One look per job; pages do not restyle these.
+
+.sk-copy-field {
+ display: flex;
+ flex-direction: column;
+ gap: $space-1;
+ min-width: 0;
+
+ &__label { font-size: $font-size-xs; color: var(--text-dim); }
+
+ &__box {
+ display: flex;
+ align-items: flex-start;
+ gap: $space-1;
+ min-width: 0;
+ padding: 4px 4px 4px $space-3;
+ background: var(--surface-2);
+ border: 1px solid var(--border-soft);
+ border-radius: var(--radius-md);
+ }
+
+ &__value {
+ flex: 1;
+ min-width: 0;
+ align-self: center;
+ margin: 0;
+ padding: 0;
+ background: none;
+ font-family: var(--mono);
+ font-size: $font-size-sm;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &--multiline &__value {
+ padding-block: $space-1;
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 240px;
+ overflow: auto;
+ }
+
+ &__actions { display: inline-flex; flex-shrink: 0; }
+}
+
+.sk-port-field {
+ display: flex;
+ flex-direction: column;
+ gap: $space-1;
+ min-width: 0;
+
+ &__input { max-width: 160px; font-family: var(--mono); }
+
+ &__status {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: $space-2;
+ margin: 0;
+ font-size: $font-size-xs;
+ color: var(--text-dim);
+
+ &.is-error { color: var(--red); }
+ }
+
+ &__suggest { height: auto; padding: 0; font-size: $font-size-xs; }
+}
+
+.sk-env-editor {
+ display: flex;
+ flex-direction: column;
+ gap: $space-2;
+
+ &__row {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) auto auto;
+ align-items: center;
+ gap: $space-2;
+ }
+
+ &__key, &__value { font-family: var(--mono); }
+ &__spacer { width: 32px; }
+
+ &__error {
+ grid-column: 1 / -1;
+ margin: 0;
+ font-size: $font-size-xs;
+ color: var(--red);
+ }
+
+ &__foot {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: $space-3;
+ }
+
+ &__hint { font-size: $font-size-xs; color: var(--text-faint); }
+}
+
+.sk-server-picker { min-width: 220px; }
diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss
index 6f04402d..0f2abf57 100644
--- a/frontend/src/styles/main.scss
+++ b/frontend/src/styles/main.scss
@@ -69,6 +69,7 @@
@import 'components/_detail-tabs';
@import 'components/_server-selector';
@import 'components/_domain-field'; // shared domain input (DomainField.jsx)
+@import 'components/_fields'; // CopyField, PortField, EnvEditor, ServerPicker
@import 'components/_schedule-picker'; // shared cron picker (CronJobs, Backups, server-detail)
@import 'components/_contribution-graph';
@import 'components/_command-palette';
diff --git a/frontend/src/utils/__tests__/dotenv.test.mjs b/frontend/src/utils/__tests__/dotenv.test.mjs
new file mode 100644
index 00000000..9a57bc6c
--- /dev/null
+++ b/frontend/src/utils/__tests__/dotenv.test.mjs
@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { parseDotenv, envToObject, envFromObject, isValidEnvKey, looksSecret } from '../dotenv.js';
+
+test('parseDotenv handles comments, export, quotes and = in values', () => {
+ const rows = parseDotenv('# db\nexport DB_HOST=localhost\nDB_PASS="p=ss word"\n\nEMPTY=\nnot a line\nURL=\'https://x/?a=1\'');
+ assert.deepEqual(rows, [
+ { key: 'DB_HOST', value: 'localhost' },
+ { key: 'DB_PASS', value: 'p=ss word' },
+ { key: 'EMPTY', value: '' },
+ { key: 'URL', value: 'https://x/?a=1' },
+ ]);
+});
+
+test('object round trip keeps the last duplicate and drops blank keys', () => {
+ assert.deepEqual(envToObject([{ key: 'A', value: '1' }, { key: ' ', value: 'x' }, { key: 'A', value: '2' }]), { A: '2' });
+ assert.deepEqual(envFromObject({ A: 1, B: null }), [{ key: 'A', value: '1' }, { key: 'B', value: '' }]);
+});
+
+test('key validation and secret detection', () => {
+ assert.equal(isValidEnvKey('DB_HOST'), true);
+ assert.equal(isValidEnvKey('1BAD'), false);
+ assert.equal(isValidEnvKey('has-dash'), false);
+ assert.equal(looksSecret('STRIPE_SECRET_KEY'), true);
+ assert.equal(looksSecret('NODE_ENV'), false);
+});
diff --git a/frontend/src/utils/dotenv.js b/frontend/src/utils/dotenv.js
new file mode 100644
index 00000000..471ced55
--- /dev/null
+++ b/frontend/src/utils/dotenv.js
@@ -0,0 +1,46 @@
+// KEY=value parsing shared by EnvEditor and anything that imports a .env.
+
+const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
+export function isValidEnvKey(key) {
+ return KEY.test(String(key || ''));
+}
+
+function unquote(value) {
+ const v = value.trim();
+ if (v.length >= 2 && ((v[0] === '"' && v.at(-1) === '"') || (v[0] === "'" && v.at(-1) === "'"))) {
+ return v.slice(1, -1);
+ }
+ return v;
+}
+
+// "export A=1\n# note\nB='two'" -> [{ key: 'A', value: '1' }, { key: 'B', value: 'two' }]
+export function parseDotenv(text) {
+ return String(text || '')
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#') && line.includes('='))
+ .map((line) => {
+ const body = line.replace(/^export\s+/, '');
+ const at = body.indexOf('=');
+ return { key: body.slice(0, at).trim(), value: unquote(body.slice(at + 1)) };
+ })
+ .filter((row) => row.key);
+}
+
+export function envToObject(rows) {
+ const out = {};
+ for (const { key, value } of rows || []) {
+ if (key && key.trim()) out[key.trim()] = value ?? '';
+ }
+ return out;
+}
+
+export function envFromObject(obj) {
+ return Object.entries(obj || {}).map(([key, value]) => ({ key, value: value == null ? '' : String(value) }));
+}
+
+// Names that look like credentials, masked by default in editors.
+export function looksSecret(key) {
+ return /(PASS|SECRET|TOKEN|KEY|PRIVATE|CREDENTIAL|AUTH)/i.test(String(key || ''));
+}
diff --git a/frontend/src/utils/serverTarget.js b/frontend/src/utils/serverTarget.js
new file mode 100644
index 00000000..7ea796f8
--- /dev/null
+++ b/frontend/src/utils/serverTarget.js
@@ -0,0 +1,28 @@
+// What ServerPicker reports, turned into the operational "target" object the
+// file manager and terminal tools pass around: { kind: 'local' } for the
+// panel host, { kind: 'agent', server_id, … } for a remote server, or
+// { kind: } for a non-server source such as an S3 bucket.
+
+export const LOCAL_SERVER_ID = 'local';
+
+export function serverTarget(id, server) {
+ const key = String(id ?? LOCAL_SERVER_ID);
+ if (key === LOCAL_SERVER_ID) return { kind: 'local' };
+ if (!server) return { kind: key };
+ return {
+ kind: 'agent',
+ server_id: key,
+ name: server.name || server.hostname || key,
+ allowedPaths: server.allowed_paths || [],
+ os_type: server.os_type || null,
+ agentInstallDir: server.agent_install_dir || null,
+ agentConfigDir: server.agent_config_dir || null,
+ };
+}
+
+// The ServerPicker value for a target object.
+export function targetServerId(target) {
+ if (!target || target.kind === 'local') return LOCAL_SERVER_ID;
+ if (target.kind === 'agent') return String(target.server_id);
+ return String(target.kind);
+}