feat(furtka): in-browser app settings + ISO recovery-path fixes
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / validate-json (push) Waiting to run
CI / markdown-links (push) Waiting to run
Build ISO / build-iso (push) Successful in 16m54s

End-to-end VM test today (2026-04-15) validated the resource manager
golden path but exposed four things blocking "dein-Vater-tauglich":
no way to configure an app without SSH+editor, no openssh, no nano,
keyboard stuck on US, and a samba healthcheck that cried wolf.

Resource-manager side:
- Manifest schema gains optional `settings` list (name/label/
  description/type/required/default) and `description_long`.
- Bundled-app install opens a form rendered from the manifest;
  submit carries values to `POST /api/apps/install` which writes
  them into the new app's `.env` before the placeholder check runs.
- Installed apps grow an "Einstellungen" button that merges a
  partial settings dict into the existing `.env` (unsubmitted
  password fields = keep current), then reconciles to restart.
- New endpoints: `GET/POST /api/apps/<name>/settings`. Passwords
  are never returned to the client.
- Fileshare manifest declares its SMB_USER/SMB_PASSWORD settings
  in German with help text.

ISO side (so the next build is actually usable on the TTY):
- Add `openssh` to the package list + `sshd` to enabled services.
  `archinstall: true` in 4.x did not install openssh-server.
- Add `nano` — `vim` was the only editor pitched at users, which
  is brutal for first-timers (and was missing anyway).
- Keyboard layout follows the installer language (`de→de`, `pl→pl`,
  `en→us`) instead of hardcoded `us`. A German user couldn't type
  `/` or `-` at the console, making even `sudo nano` painful.
- Disable the dperson/samba healthcheck in the compose override —
  it timed out on every probe while the share itself worked fine.

19 new tests (manifest parsing + settings-merge + two new API
endpoints over live HTTP); 94 total, format + lint clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-15 13:00:02 +02:00
parent 0af2134b7e
commit 61c7ee232c
11 changed files with 820 additions and 33 deletions

View file

@ -7,8 +7,16 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased] ## [Unreleased]
### Added
- **In-browser app settings**, so users no longer need SSH + `vim` to configure an app before first install. Manifest gains optional `settings` (name/label/description/type/required/default) and `description_long` fields. Installing a bundled app opens a form rendered from the manifest; installed apps grow an "Einstellungen" button that edits merged values (password fields blank = keep current). API: `POST /api/apps/install` now accepts a `settings` object in the JSON body; new `GET`/`POST /api/apps/<name>/settings` for inspecting and updating an installed app. Password values never leave the server.
- `nano` added to the installer package list so users have a beginner-friendly editor at the console/SSH (was `vim`-only, which `command not found`'d under Arch 4.x because it was actually missing from the package set too).
- `openssh` added explicitly to the installer package list and `sshd` added to enabled services. `archinstall: true` in archinstall 4.x did not actually install openssh-server, so the documented recovery path (SSH → edit `.env`) silently failed.
### Changed ### Changed
- Keyboard layout at the TTY now follows the chosen installer language (`de` → `de`, `pl` → `pl`, `en` → `us`) instead of hardcoding `us`. Previously German users couldn't type `/`, `-`, or `=` at the recovery console.
- `fileshare` app: `description_long` + `settings` (SMB_USER, SMB_PASSWORD) for the new settings form. Docker-level healthcheck from `dperson/samba` is disabled in the compose override — it timed out under normal operation and marked a working share "unhealthy" in `docker ps`.
- **Project name finalized: Furtka.** Working title "Homebase" retired. Domain `furtka.org` registered via Strato 2026-04-13. - **Project name finalized: Furtka.** Working title "Homebase" retired. Domain `furtka.org` registered via Strato 2026-04-13.
- Managed gateway NS hostnames updated from `ns1.homebase.cloud` / `ns2.homebase.cloud` to `ns1.furtka.org` / `ns2.furtka.org`. - Managed gateway NS hostnames updated from `ns1.homebase.cloud` / `ns2.homebase.cloud` to `ns1.furtka.org` / `ns2.furtka.org`.
- Python package renamed from `homebase` → `furtka` in `pyproject.toml`. - Python package renamed from `homebase` → `furtka` in `pyproject.toml`.

View file

@ -17,6 +17,12 @@ services:
image: dperson/samba:latest image: dperson/samba:latest
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
# The upstream image's HEALTHCHECK times out under normal operation on
# our setup (2026-04-15 VM test — all 6 probes failed while the share
# was reachable from clients). Disable to avoid a permanently-"unhealthy"
# container that scares users reading `docker ps`.
healthcheck:
disable: true
environment: environment:
- USERID=1000 - USERID=1000
- GROUPID=1000 - GROUPID=1000

View file

@ -3,7 +3,25 @@
"display_name": "Network Files", "display_name": "Network Files",
"version": "0.1.0", "version": "0.1.0",
"description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.", "description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.",
"description_long": "Alle Geräte im WLAN sehen einen gemeinsamen Ordner. Funktioniert mit Windows, Mac, Linux und Android. Verbinden zu smb://furtka.local — Anmeldung mit dem hier gesetzten Benutzernamen und Passwort.",
"volumes": ["files"], "volumes": ["files"],
"ports": [445, 139], "ports": [445, 139],
"icon": "icon.svg" "icon": "icon.svg",
"settings": [
{
"name": "SMB_USER",
"label": "Benutzername",
"description": "Der Name, mit dem sich Geräte am Share anmelden.",
"type": "text",
"default": "furtka",
"required": true
},
{
"name": "SMB_PASSWORD",
"label": "Passwort",
"description": "Mindestens 8 Zeichen. Wird nie angezeigt — auch dir nicht.",
"type": "password",
"required": true
}
]
} }

View file

@ -18,10 +18,12 @@
"packages": [ "packages": [
"docker", "docker",
"docker-compose", "docker-compose",
"nano",
"vim", "vim",
"git", "git",
"htop", "htop",
"curl" "curl",
"openssh"
], ],
"profile": { "profile": {
@ -29,7 +31,8 @@
}, },
"services": [ "services": [
"docker" "docker",
"sshd"
], ],
"network_config": { "network_config": {

View file

@ -26,7 +26,7 @@ _HTML = """<!DOCTYPE html>
<title>Furtka Apps</title> <title>Furtka Apps</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<style> <style>
:root { --bg:#0f1115; --fg:#e8eaed; --muted:#9aa0a6; --accent:#6ee7b7; --card:#1a1d24; --warn:#4a3030; --danger:#f08080; } :root { --bg:#0f1115; --fg:#e8eaed; --muted:#9aa0a6; --accent:#6ee7b7; --card:#1a1d24; --warn:#4a3030; --danger:#f08080; --border:#2a2d34; }
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; background: var(--bg); color: var(--fg); line-height:1.5; } body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; background: var(--bg); color: var(--fg); line-height:1.5; }
h1 { font-size: 2rem; margin: 0; } h1 { font-size: 2rem; margin: 0; }
@ -38,11 +38,28 @@ _HTML = """<!DOCTYPE html>
.name { font-weight: 600; font-size: 1.05rem; } .name { font-weight: 600; font-size: 1.05rem; }
.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; } .name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; }
.desc { color: var(--muted); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; } .desc { color: var(--muted); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; }
button { background: var(--accent); border: none; color: var(--bg); font-weight: 600; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; white-space: nowrap; } .buttons { display:flex; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-end; }
button { background: var(--accent); border: none; color: var(--bg); font-weight: 600; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; white-space: nowrap; font-size: 0.9rem; }
button.secondary { background: var(--card); color: var(--fg); border: 1px solid var(--border); }
button.danger { background: var(--danger); } button.danger { background: var(--danger); }
button:disabled { opacity: 0.5; cursor: wait; } button:disabled { opacity: 0.5; cursor: wait; }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; } .empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
pre { background: var(--card); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; } pre { background: var(--card); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; align-items: flex-start; justify-content: center; padding: 2rem 1rem; overflow-y: auto; z-index: 10; }
.modal-backdrop.open { display: flex; }
.modal { background: var(--card); border-radius: 8px; padding: 1.5rem; max-width: 520px; width: 100%; }
.modal h3 { margin: 0 0 0.5rem; font-size: 1.3rem; }
.modal .long { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.25rem; white-space: pre-wrap; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-weight: 600; margin-bottom: 0.25rem; font-size: 0.95rem; }
.field .hint { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.35rem; }
.field input { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.6rem; font-size: 0.95rem; font-family: inherit; }
.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.field .req { color: var(--danger); margin-left: 0.25rem; }
.modal .error { background: var(--warn); color: #fed; padding: 0.5rem 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; display: none; }
.modal .error.show { display: block; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.5rem; }
</style> </style>
</head> </head>
<body> <body>
@ -59,6 +76,19 @@ _HTML = """<!DOCTYPE html>
<h2>Last action</h2> <h2>Last action</h2>
<pre id="log">(none yet)</pre> <pre id="log">(none yet)</pre>
<div id="modal-backdrop" class="modal-backdrop" role="dialog" aria-modal="true">
<div class="modal">
<h3 id="modal-title"></h3>
<div id="modal-long" class="long"></div>
<div id="modal-error" class="error"></div>
<form id="modal-form" onsubmit="return false;"></form>
<div class="modal-actions">
<button type="button" class="secondary" id="modal-cancel">Cancel</button>
<button type="button" id="modal-submit">Install</button>
</div>
</div>
</div>
<script> <script>
function esc(s) { function esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
@ -66,23 +96,138 @@ function esc(s) {
return d.innerHTML; return d.innerHTML;
} }
const modal = {
backdrop: document.getElementById('modal-backdrop'),
title: document.getElementById('modal-title'),
long: document.getElementById('modal-long'),
form: document.getElementById('modal-form'),
error: document.getElementById('modal-error'),
submit: document.getElementById('modal-submit'),
cancel: document.getElementById('modal-cancel'),
current: null, // { name, action: 'install' | 'edit' }
};
modal.cancel.addEventListener('click', () => closeModal());
modal.backdrop.addEventListener('click', (e) => { if (e.target === modal.backdrop) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
function closeModal() {
modal.backdrop.classList.remove('open');
modal.form.innerHTML = '';
modal.error.classList.remove('show');
modal.error.textContent = '';
modal.current = null;
}
async function openSettingsDialog(name, action) {
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/settings`);
if (!r.ok) {
document.getElementById('log').textContent =
`[settings ${name}] HTTP ${r.status}\\n` + await r.text();
return;
}
const data = await r.json();
modal.current = { name, action };
modal.title.textContent = data.display_name || data.name;
modal.long.textContent = data.description_long || data.description || '';
modal.long.style.display = modal.long.textContent ? '' : 'none';
modal.submit.textContent = action === 'install' ? 'Install' : 'Save and restart';
if (!data.settings.length) {
// No form fields — treat as simple confirm.
modal.form.innerHTML = '<p class="hint">No settings to configure.</p>';
} else {
modal.form.innerHTML = data.settings.map(s => {
const id = `field-${esc(s.name)}`;
const value = action === 'edit' && s.type === 'password' ? '' : esc(s.value || '');
const placeholder = action === 'edit' && s.type === 'password' ? 'Leave blank to keep current' : '';
return `
<div class="field">
<label for="${id}">${esc(s.label)}${s.required ? '<span class="req">*</span>' : ''}</label>
${s.description ? `<div class="hint">${esc(s.description)}</div>` : ''}
<input
id="${id}"
name="${esc(s.name)}"
type="${s.type === 'password' ? 'password' : s.type === 'number' ? 'number' : 'text'}"
value="${value}"
placeholder="${esc(placeholder)}"
${s.required && action === 'install' ? 'required' : ''}
autocomplete="off"
spellcheck="false">
</div>`;
}).join('');
}
modal.backdrop.classList.add('open');
const first = modal.form.querySelector('input');
if (first) first.focus();
}
modal.submit.addEventListener('click', submitModal);
async function submitModal() {
if (!modal.current) return;
const { name, action } = modal.current;
const values = {};
for (const input of modal.form.querySelectorAll('input')) {
// In edit mode, skip password fields left blank — server keeps existing.
if (action === 'edit' && input.type === 'password' && input.value === '') continue;
values[input.name] = input.value;
}
modal.submit.disabled = true;
const original = modal.submit.textContent;
modal.submit.textContent = action === 'install' ? 'Installing…' : 'Saving…';
modal.error.classList.remove('show');
try {
const url = action === 'install'
? '/api/apps/install'
: `/api/apps/${encodeURIComponent(name)}/settings`;
const body = action === 'install' ? { name, settings: values } : { settings: values };
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await r.json();
document.getElementById('log').textContent =
`[${action} ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
if (!r.ok) {
modal.error.textContent = data.error || `HTTP ${r.status}`;
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
return;
}
closeModal();
await refresh();
} catch (e) {
modal.error.textContent = `Network error: ${e.message}`;
modal.error.classList.add('show');
modal.submit.disabled = false;
modal.submit.textContent = original;
}
}
async function refresh() { async function refresh() {
const [installed, available] = await Promise.all([ const [installed, available] = await Promise.all([
fetch('/api/apps').then(r => r.json()), fetch('/api/apps').then(r => r.json()),
fetch('/api/bundled').then(r => r.json()), fetch('/api/bundled').then(r => r.json()),
]); ]);
document.getElementById('installed').innerHTML = installed.length document.getElementById('installed').innerHTML = installed.length
? installed.map(a => ` ? installed.map(a => {
const hasSettings = a.has_settings;
return `
<div class="app"> <div class="app">
<div class="meta"> <div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span> <span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || a.error || '')}</span> <span class="desc">${esc(a.description || a.error || '')}</span>
</div> </div>
<div> <div class="buttons">
<button data-op="install" data-name="${esc(a.name)}">Reinstall</button> ${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Einstellungen</button>` : ''}
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button> <button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
</div> </div>
</div>`).join('') </div>`;
}).join('')
: '<div class="empty">No apps installed yet.</div>'; : '<div class="empty">No apps installed yet.</div>';
document.getElementById('available').innerHTML = available.length document.getElementById('available').innerHTML = available.length
? available.map(a => ` ? available.map(a => `
@ -95,16 +240,22 @@ async function refresh() {
</div>`).join('') </div>`).join('')
: '<div class="empty">No bundled apps left to install.</div>'; : '<div class="empty">No bundled apps left to install.</div>';
for (const btn of document.querySelectorAll('button[data-op]')) { for (const btn of document.querySelectorAll('button[data-op]')) {
btn.addEventListener('click', () => act(btn.dataset.op, btn.dataset.name, btn)); btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn));
} }
} }
async function act(op, name, btn) { async function handleButton(op, name, btn) {
if (op === 'install' || op === 'edit') {
openSettingsDialog(name, op === 'install' ? 'install' : 'edit');
return;
}
// Reinstall + remove are direct actions, no form.
btn.disabled = true; btn.disabled = true;
const original = btn.textContent; const original = btn.textContent;
btn.textContent = op === 'install' ? 'Installing…' : 'Removing…'; btn.textContent = op === 'reinstall' ? 'Reinstalling…' : 'Removing…';
try { try {
const r = await fetch(`/api/apps/${op}`, { const url = op === 'reinstall' ? '/api/apps/install' : '/api/apps/remove';
const r = await fetch(url, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}), body: JSON.stringify({name}),
@ -132,8 +283,10 @@ def _manifest_summary(m):
"display_name": m.display_name, "display_name": m.display_name,
"version": m.version, "version": m.version,
"description": m.description, "description": m.description,
"description_long": m.description_long,
"ports": list(m.ports), "ports": list(m.ports),
"icon": m.icon, "icon": m.icon,
"has_settings": bool(m.settings),
} }
@ -169,10 +322,68 @@ def _list_bundled():
return out return out
def _do_install(name): def _load_manifest_for(name):
"""Return (manifest, env_values, installed_bool) for an installed or bundled app.
Returns (None, None, False) if the name doesn't resolve anywhere.
"""
target = apps_dir() / name
if target.exists() and (target / "manifest.json").exists():
try:
m = load_manifest(target / "manifest.json")
except ManifestError:
return None, None, False
values = installer.read_env_values(target / ".env")
return m, values, True
bundled = bundled_apps_dir() / name
if bundled.exists() and (bundled / "manifest.json").exists():
try:
m = load_manifest(bundled / "manifest.json")
except ManifestError:
return None, None, False
env_example = bundled / ".env.example"
values = installer.read_env_values(env_example) if env_example.exists() else {}
return m, values, False
return None, None, False
def _do_get_settings(name):
m, values, installed = _load_manifest_for(name)
if m is None:
return 404, {"error": f"{name!r} not found"}
settings_out = []
for s in m.settings:
# Never return password values back to the client — user either keeps
# the current value (blank input means "don't change") or types a new one.
if s.type == "password":
current = ""
else:
current = values.get(s.name, s.default if s.default is not None else "")
settings_out.append(
{
"name": s.name,
"label": s.label,
"description": s.description,
"type": s.type,
"required": s.required,
"default": s.default,
"value": current,
}
)
return 200, {
"name": m.name,
"display_name": m.display_name,
"description": m.description,
"description_long": m.description_long,
"installed": installed,
"settings": settings_out,
}
def _do_install(name, settings=None):
try: try:
src = installer.resolve_source(name) src = installer.resolve_source(name)
target = installer.install_from(src) target = installer.install_from(src, settings=settings)
except installer.InstallError as e: except installer.InstallError as e:
return 400, {"error": str(e)} return 400, {"error": str(e)}
actions = reconciler.reconcile(apps_dir()) actions = reconciler.reconcile(apps_dir())
@ -184,6 +395,29 @@ def _do_install(name):
return (207 if reconciler.has_errors(actions) else 200, payload) return (207 if reconciler.has_errors(actions) else 200, payload)
def _do_update_settings(name, settings):
"""Write settings into an installed app's .env and kick off a reinstall.
Only works for already-installed apps — use /api/apps/install for fresh
installs (since bundled-app folders under /opt/... are read-only).
"""
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
try:
installer.update_env(name, settings)
except installer.InstallError as e:
return 400, {"error": str(e)}
actions = reconciler.reconcile(apps_dir())
return (
207 if reconciler.has_errors(actions) else 200,
{
"updated": name,
"actions": [{"kind": a.kind, "target": a.target, "detail": a.detail} for a in actions],
},
)
def _do_remove(name): def _do_remove(name):
target = apps_dir() / name target = apps_dir() / name
if not target.exists(): if not target.exists():
@ -200,6 +434,21 @@ def _do_remove(name):
return 200, {"removed": name, "compose_warning": compose_warning} return 200, {"removed": name, "compose_warning": compose_warning}
def _parse_settings_body(payload):
"""Extract and coerce the settings dict from a JSON body. Returns dict or None."""
s = payload.get("settings")
if s is None:
return None
if not isinstance(s, dict):
return False # sentinel — caller should reject
out = {}
for k, v in s.items():
if not isinstance(k, str):
return False
out[k] = "" if v is None else str(v)
return out
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
def _json(self, status, payload): def _json(self, status, payload):
body = json.dumps(payload).encode() body = json.dumps(payload).encode()
@ -224,6 +473,13 @@ class _Handler(BaseHTTPRequestHandler):
return self._json(200, _list_installed()) return self._json(200, _list_installed())
if self.path == "/api/bundled": if self.path == "/api/bundled":
return self._json(200, _list_bundled()) return self._json(200, _list_bundled())
# /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name)
return self._json(status, body)
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802 def do_POST(self): # noqa: N802
@ -233,12 +489,29 @@ class _Handler(BaseHTTPRequestHandler):
payload = json.loads(raw.decode()) if raw else {} payload = json.loads(raw.decode()) if raw else {}
except (UnicodeDecodeError, json.JSONDecodeError): except (UnicodeDecodeError, json.JSONDecodeError):
return self._json(400, {"error": "invalid JSON body"}) return self._json(400, {"error": "invalid JSON body"})
if not isinstance(payload, dict):
return self._json(400, {"error": "body must be a JSON object"})
# Per-app settings update: /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
settings = _parse_settings_body(payload)
if settings is False or settings is None:
return self._json(400, {"error": "missing or invalid 'settings' object"})
status, body = _do_update_settings(name, settings)
return self._json(status, body)
name = payload.get("name") name = payload.get("name")
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"}) return self._json(400, {"error": "missing or empty 'name' field"})
if self.path == "/api/apps/install": if self.path == "/api/apps/install":
status, body = _do_install(name) settings = _parse_settings_body(payload)
if settings is False:
return self._json(400, {"error": "'settings' must be an object"})
status, body = _do_install(name, settings=settings)
elif self.path == "/api/apps/remove": elif self.path == "/api/apps/remove":
status, body = _do_remove(name) status, body = _do_remove(name)
else: else:

View file

@ -30,6 +30,30 @@ def _placeholder_keys(env_path: Path) -> list[str]:
return bad return bad
def _format_env_value(v: str) -> str:
# Quote values that contain whitespace, quotes, or shell metacharacters so
# docker-compose's env substitution reads them back intact. Simple values
# stay unquoted to keep the file readable when a user SSHes in.
if v == "" or any(c in v for c in " \t\"'$`\\#"):
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return v
def write_env(env_path: Path, values: dict[str, str]) -> None:
"""Write a KEY=VALUE .env file atomically with 0600 perms.
Preserves insertion order of `values` so the file reads in the same order
the user filled in the form.
"""
lines = [f"{k}={_format_env_value(v)}" for k, v in values.items()]
body = "\n".join(lines) + ("\n" if lines else "")
tmp = env_path.with_suffix(env_path.suffix + ".tmp")
tmp.write_text(body)
tmp.chmod(0o600)
tmp.replace(env_path)
def resolve_source(source: str) -> Path: def resolve_source(source: str) -> Path:
"""Resolve a `furtka app install <source>` arg to a real source folder. """Resolve a `furtka app install <source>` arg to a real source folder.
@ -47,13 +71,17 @@ def resolve_source(source: str) -> Path:
raise InstallError(f"{source!r} not found as a path or bundled app") raise InstallError(f"{source!r} not found as a path or bundled app")
def install_from(src: Path) -> Path: def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
"""Copy a validated app folder into /var/lib/furtka/apps/<name>/. """Copy a validated app folder into /var/lib/furtka/apps/<name>/.
Preserves an existing .env on upgrade. Bootstraps .env from .env.example If `settings` is provided, the .env is written from those values (this is
on first install if .env wasn't shipped. Refuses to finish (raises what the Web UI / API does — user fills in a form, values land here).
InstallError) if the resulting .env still has placeholder secrets — the Otherwise, preserves an existing .env on upgrade and bootstraps from
target folder is left in place so the user can edit and re-run. .env.example on first install.
Refuses to finish (raises InstallError) if the resulting .env still has
placeholder secrets — the target folder is left in place so the user can
edit and re-run.
Returns the target folder on success. Returns the target folder on success.
""" """
@ -65,22 +93,56 @@ def install_from(src: Path) -> Path:
except ManifestError as e: except ManifestError as e:
raise InstallError(str(e)) from e raise InstallError(str(e)) from e
if settings is not None:
declared = {s.name for s in m.settings}
for key in settings:
if key not in declared:
raise InstallError(f"{m.name}: unknown setting {key!r}")
target = apps_dir() / m.name target = apps_dir() / m.name
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
for item in src.iterdir(): for item in src.iterdir():
if not item.is_file(): if not item.is_file():
continue continue
# Never overwrite an existing user .env. # Never overwrite an existing user .env — either settings-driven write
# or previous manual edit has authority.
if item.name == ".env" and (target / ".env").exists(): if item.name == ".env" and (target / ".env").exists():
continue continue
shutil.copy2(item, target / item.name) shutil.copy2(item, target / item.name)
# First install with no .env shipped: bootstrap from .env.example so the
# user has something to edit and compose has values to substitute.
env = target / ".env" env = target / ".env"
env_example = target / ".env.example" env_example = target / ".env.example"
if not env.exists() and env_example.exists():
if settings is not None:
# Merge: start from existing .env (to preserve values the user didn't
# change — e.g. when editing a single password), overlay the submitted
# settings. Manifest-declared fields always appear in the final file.
existing = _read_env(env) if env.exists() else {}
merged: dict[str, str] = {}
for s in m.settings:
if s.name in settings:
merged[s.name] = settings[s.name]
elif s.name in existing:
merged[s.name] = existing[s.name]
elif s.default is not None:
merged[s.name] = s.default
else:
merged[s.name] = ""
# Preserve any non-manifest keys already in .env (forward-compat).
for k, v in existing.items():
if k not in merged:
merged[k] = v
# Required-field check runs on the merged view so that editing just
# one field (e.g. password) doesn't trip on unsubmitted fields that
# already have values in the existing .env.
for s in m.settings:
if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required")
write_env(env, merged)
elif not env.exists() and env_example.exists():
# First install with no settings and no .env shipped: bootstrap from
# .env.example so compose has values to substitute.
shutil.copy2(env_example, env) shutil.copy2(env_example, env)
# .env carries app secrets — lock to root-only. Done before the placeholder # .env carries app secrets — lock to root-only. Done before the placeholder
@ -92,13 +154,84 @@ def install_from(src: Path) -> Path:
if bad: if bad:
raise InstallError( raise InstallError(
f"{m.name}: {env} still has placeholder values for {', '.join(bad)}. " f"{m.name}: {env} still has placeholder values for {', '.join(bad)}. "
f"Edit the file (set real values), then re-run " f"Open the app in the Furtka UI to fill in real values, or edit the "
f"`furtka app install {m.name}`." f"file and re-run `furtka app install {m.name}`."
) )
return target return target
def _read_env(env_path: Path) -> dict[str, str]:
"""Parse a simple KEY=VALUE .env into a dict. Unquotes quoted values."""
out: dict[str, str] = {}
for raw in env_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
out[key.strip()] = value
return out
def read_env_values(env_path: Path) -> dict[str, str]:
"""Public wrapper — returns {} if the file doesn't exist."""
if not env_path.exists():
return {}
return _read_env(env_path)
def update_env(name: str, settings: dict[str, str]) -> Path:
"""Merge `settings` into the installed app's .env.
Preserves values the user didn't submit. Validates required fields against
the merged view. Leaves files/manifest untouched — for already-installed
apps only. Returns the target folder; caller is expected to run
reconcile to restart the containers.
"""
target = apps_dir() / name
manifest_path = target / "manifest.json"
if not manifest_path.exists():
raise InstallError(f"{name!r} is not installed")
try:
m = load_manifest(manifest_path)
except ManifestError as e:
raise InstallError(str(e)) from e
declared = {s.name for s in m.settings}
for key in settings:
if key not in declared:
raise InstallError(f"{m.name}: unknown setting {key!r}")
env = target / ".env"
existing = _read_env(env) if env.exists() else {}
merged: dict[str, str] = {}
for s in m.settings:
if s.name in settings:
merged[s.name] = settings[s.name]
elif s.name in existing:
merged[s.name] = existing[s.name]
elif s.default is not None:
merged[s.name] = s.default
else:
merged[s.name] = ""
for k, v in existing.items():
if k not in merged:
merged[k] = v
for s in m.settings:
if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required")
write_env(env, merged)
bad = _placeholder_keys(env)
if bad:
raise InstallError(f"{m.name}: {env} still has placeholder values for {', '.join(bad)}.")
return target
def remove(name: str) -> Path: def remove(name: str) -> Path:
"""Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched. """Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched.

View file

@ -1,5 +1,6 @@
import json import json
from dataclasses import dataclass import re
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
REQUIRED_FIELDS = ( REQUIRED_FIELDS = (
@ -12,11 +13,24 @@ REQUIRED_FIELDS = (
"icon", "icon",
) )
VALID_SETTING_TYPES = frozenset({"text", "password", "number"})
SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
class ManifestError(Exception): class ManifestError(Exception):
pass pass
@dataclass(frozen=True)
class Setting:
name: str # env-var name, e.g. SMB_PASSWORD
label: str # human label shown in the UI
description: str # one-sentence help text under the input
type: str # "text" | "password" | "number"
required: bool
default: str | None
@dataclass(frozen=True) @dataclass(frozen=True)
class Manifest: class Manifest:
name: str name: str
@ -26,6 +40,8 @@ class Manifest:
volumes: tuple[str, ...] volumes: tuple[str, ...]
ports: tuple[int, ...] ports: tuple[int, ...]
icon: str icon: str
description_long: str = ""
settings: tuple[Setting, ...] = field(default_factory=tuple)
def volume_name(self, short: str) -> str: def volume_name(self, short: str) -> str:
# Namespace volume names so two apps can each declare e.g. "data" # Namespace volume names so two apps can each declare e.g. "data"
@ -35,6 +51,47 @@ class Manifest:
return f"furtka_{self.name}_{short}" return f"furtka_{self.name}_{short}"
def _parse_settings(raw: object, manifest_path: Path) -> tuple[Setting, ...]:
if raw is None:
return ()
if not isinstance(raw, list):
raise ManifestError(f"{manifest_path}: settings must be a list")
out: list[Setting] = []
seen: set[str] = set()
for i, item in enumerate(raw):
if not isinstance(item, dict):
raise ManifestError(f"{manifest_path}: settings[{i}] must be an object")
name = item.get("name")
if not isinstance(name, str) or not SETTING_NAME_RE.match(name):
raise ManifestError(
f"{manifest_path}: settings[{i}].name must be an UPPER_SNAKE_CASE env-var name"
)
if name in seen:
raise ManifestError(f"{manifest_path}: settings has duplicate name {name!r}")
seen.add(name)
label = item.get("label", name)
description = item.get("description", "")
type_ = item.get("type", "text")
if type_ not in VALID_SETTING_TYPES:
valid = sorted(VALID_SETTING_TYPES)
raise ManifestError(f"{manifest_path}: settings[{name}].type must be one of {valid}")
required = bool(item.get("required", False))
default = item.get("default")
if default is not None and not isinstance(default, str):
default = str(default)
out.append(
Setting(
name=name,
label=str(label),
description=str(description),
type=type_,
required=required,
default=default,
)
)
return tuple(out)
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
"""Parse and validate a manifest.json. """Parse and validate a manifest.json.
@ -68,6 +125,8 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports): if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports):
raise ManifestError(f"{path}: ports must be a list of integers") raise ManifestError(f"{path}: ports must be a list of integers")
settings = _parse_settings(raw.get("settings"), path)
return Manifest( return Manifest(
name=name, name=name,
display_name=str(raw["display_name"]), display_name=str(raw["display_name"]),
@ -76,4 +135,6 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
volumes=tuple(volumes), volumes=tuple(volumes),
ports=tuple(ports), ports=tuple(ports),
icon=str(raw["icon"]), icon=str(raw["icon"]),
description_long=str(raw.get("description_long", "")),
settings=settings,
) )

View file

@ -153,3 +153,130 @@ def test_http_post_install_unknown_app(fake_dirs):
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
# --- Settings endpoints ------------------------------------------------------
SETTINGS_MANIFEST = dict(
VALID_MANIFEST,
description_long="Long help text.",
settings=[
{
"name": "SMB_USER",
"label": "User",
"type": "text",
"default": "furtka",
"required": True,
},
{"name": "SMB_PASSWORD", "label": "Pass", "type": "password", "required": True},
],
)
def test_get_settings_bundled(fake_dirs):
_, bundled = fake_dirs
_write_bundled(
bundled, "fileshare", manifest=SETTINGS_MANIFEST, env_example="SMB_USER=furtka\n"
)
status, body = api._do_get_settings("fileshare")
assert status == 200
assert body["installed"] is False
assert body["description_long"] == "Long help text."
names = [s["name"] for s in body["settings"]]
assert names == ["SMB_USER", "SMB_PASSWORD"]
# Password values never leak back.
pwd = next(s for s in body["settings"] if s["name"] == "SMB_PASSWORD")
assert pwd["value"] == ""
# Text value comes from .env.example.
user = next(s for s in body["settings"] if s["name"] == "SMB_USER")
assert user["value"] == "furtka"
def test_get_settings_not_found(fake_dirs):
status, _ = api._do_get_settings("ghost")
assert status == 404
def test_install_with_settings_writes_env_via_api(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
status, body = api._do_install(
"fileshare", settings={"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"}
)
assert status == 200, body
apps, _ = fake_dirs
env = (apps / "fileshare" / ".env").read_text()
assert "SMB_USER=alice" in env
assert "SMB_PASSWORD=s3cret" in env
def test_install_with_settings_rejects_empty_required_via_api(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
status, body = api._do_install("fileshare", settings={"SMB_USER": "a", "SMB_PASSWORD": ""})
assert status == 400
assert "SMB_PASSWORD" in body["error"]
def test_update_settings_merges(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
api._do_install("fileshare", settings={"SMB_USER": "alice", "SMB_PASSWORD": "original"})
# Edit flow: submit only the changed password.
status, body = api._do_update_settings("fileshare", {"SMB_PASSWORD": "newpass"})
assert status == 200, body
apps, _ = fake_dirs
env = (apps / "fileshare" / ".env").read_text()
assert "SMB_USER=alice" in env
assert "SMB_PASSWORD=newpass" in env
def test_update_settings_unknown_app(fake_dirs):
status, _ = api._do_update_settings("ghost", {"SMB_USER": "x"})
assert status == 404
def test_http_get_settings_route(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/apps/fileshare/settings") as r:
assert r.status == 200
data = json.loads(r.read())
assert data["name"] == "fileshare"
assert len(data["settings"]) == 2
finally:
server.shutdown()
server.server_close()
def test_http_post_install_with_settings(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
req = urllib.request.Request(
f"http://127.0.0.1:{port}/api/apps/install",
data=json.dumps(
{
"name": "fileshare",
"settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"},
}
).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as r:
assert r.status == 200
apps, _ = fake_dirs
assert "SMB_PASSWORD=s3cret" in (apps / "fileshare" / ".env").read_text()
finally:
server.shutdown()
server.server_close()

View file

@ -189,3 +189,81 @@ def test_placeholder_check_handles_quoted_values(tmp_path, fake_dirs):
) )
with pytest.raises(installer.InstallError, match="placeholder"): with pytest.raises(installer.InstallError, match="placeholder"):
installer.install_from(src) installer.install_from(src)
# --- Settings-driven install -------------------------------------------------
SETTINGS_MANIFEST = dict(
VALID_MANIFEST,
settings=[
{
"name": "SMB_USER",
"label": "User",
"type": "text",
"default": "furtka",
"required": True,
},
{"name": "SMB_PASSWORD", "label": "Pass", "type": "password", "required": True},
],
)
def test_install_with_settings_writes_env(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"})
target = apps_dir() / "fileshare"
env = (target / ".env").read_text()
assert "SMB_USER=daniel" in env
assert "SMB_PASSWORD=hunter2" in env
def test_install_with_settings_rejects_empty_required(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
# SMB_PASSWORD has no default and is required — submitting empty is rejected.
with pytest.raises(installer.InstallError, match="'SMB_PASSWORD' is required"):
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": ""})
def test_install_with_settings_rejects_unknown_key(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
with pytest.raises(installer.InstallError, match="unknown setting 'FOO'"):
installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b", "FOO": "x"})
def test_install_settings_merge_preserves_unchanged(tmp_path, fake_dirs):
# First install with full settings.
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"})
# Second call with only password — user should keep existing user name.
installer.install_from(src, settings={"SMB_PASSWORD": "newpass"})
target = apps_dir() / "fileshare"
env = (target / ".env").read_text()
assert "SMB_USER=daniel" in env
assert "SMB_PASSWORD=newpass" in env
def test_install_settings_applies_defaults_on_first_install(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
# Only password submitted; SMB_USER falls through to its manifest default
# ("furtka") and the required check passes because the merged view has it.
installer.install_from(src, settings={"SMB_PASSWORD": "hunter2"})
target = apps_dir() / "fileshare"
env = (target / ".env").read_text()
assert "SMB_USER=furtka" in env
assert "SMB_PASSWORD=hunter2" in env
def test_install_with_settings_writes_0600(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b"})
mode = (apps_dir() / "fileshare" / ".env").stat().st_mode & 0o777
assert mode == 0o600
def test_read_env_values_roundtrip(tmp_path, fake_dirs):
from furtka.installer import read_env_values, write_env
p = tmp_path / ".env"
write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""})
values = read_env_values(p)
assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}

View file

@ -88,3 +88,70 @@ def test_ports_wrong_type(tmp_path):
path = _write_app(tmp_path, "fileshare", bad) path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="ports"): with pytest.raises(ManifestError, match="ports"):
load_manifest(path) load_manifest(path)
def test_settings_optional_default_empty(tmp_path):
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
m = load_manifest(path)
assert m.settings == ()
assert m.description_long == ""
def test_settings_parsed(tmp_path):
payload = dict(
VALID_MANIFEST,
description_long="Long description with details.",
settings=[
{
"name": "SMB_USER",
"label": "Benutzername",
"description": "Anmeldename",
"type": "text",
"default": "furtka",
"required": True,
},
{"name": "SMB_PASSWORD", "label": "Passwort", "type": "password", "required": True},
],
)
path = _write_app(tmp_path, "fileshare", payload)
m = load_manifest(path)
assert m.description_long == "Long description with details."
assert len(m.settings) == 2
assert m.settings[0].name == "SMB_USER"
assert m.settings[0].label == "Benutzername"
assert m.settings[0].default == "furtka"
assert m.settings[0].type == "text"
assert m.settings[0].required is True
assert m.settings[1].type == "password"
assert m.settings[1].default is None
def test_settings_reject_lowercase_name(tmp_path):
bad = dict(VALID_MANIFEST, settings=[{"name": "smb_user", "type": "text"}])
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="UPPER_SNAKE_CASE"):
load_manifest(path)
def test_settings_reject_unknown_type(tmp_path):
bad = dict(VALID_MANIFEST, settings=[{"name": "FOO", "type": "email"}])
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="type must be one of"):
load_manifest(path)
def test_settings_reject_duplicate_name(tmp_path):
bad = dict(
VALID_MANIFEST,
settings=[{"name": "FOO", "type": "text"}, {"name": "FOO", "type": "password"}],
)
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="duplicate"):
load_manifest(path)
def test_settings_non_list_rejected(tmp_path):
bad = dict(VALID_MANIFEST, settings={"FOO": "bar"})
path = _write_app(tmp_path, "fileshare", bad)
with pytest.raises(ManifestError, match="settings must be a list"):
load_manifest(path)

View file

@ -12,9 +12,9 @@ from flask import Flask, jsonify, redirect, render_template, request, url_for
app = Flask(__name__) app = Flask(__name__)
LANGUAGES = { LANGUAGES = {
"en": {"locale": "en_US.UTF-8", "label": "English"}, "en": {"locale": "en_US.UTF-8", "label": "English", "keyboard": "us"},
"de": {"locale": "de_DE.UTF-8", "label": "Deutsch"}, "de": {"locale": "de_DE.UTF-8", "label": "Deutsch", "keyboard": "de"},
"pl": {"locale": "pl_PL.UTF-8", "label": "Polski"}, "pl": {"locale": "pl_PL.UTF-8", "label": "Polski", "keyboard": "pl"},
} }
STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/tmp/furtka")) STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/tmp/furtka"))
@ -557,10 +557,18 @@ def build_archinstall_config(s):
"packages": [ "packages": [
"docker", "docker",
"docker-compose", "docker-compose",
# Editors for console/SSH recovery — `nano` is the beginner-friendly
# one, `vim` stays because it's muscle-memory for the dev team.
"nano",
"vim", "vim",
"git", "git",
"htop", "htop",
"curl", "curl",
# Remote access — archinstall 4.x's `ssh: True` flag is flaky about
# actually pulling in openssh, so list it explicitly and enable sshd
# via `services` below. Without this, the documented recovery path
# (SSH in → edit .env) doesn't work.
"openssh",
# Base OS post-install (landing page + mDNS on installed system). # Base OS post-install (landing page + mDNS on installed system).
"caddy", "caddy",
"avahi", "avahi",
@ -578,6 +586,7 @@ def build_archinstall_config(s):
# units (written in custom_commands) are enabled there instead. # units (written in custom_commands) are enabled there instead.
"caddy", "caddy",
"avahi-daemon", "avahi-daemon",
"sshd",
], ],
# `gpasswd -a <user> docker` has to stay first — adds the user to # `gpasswd -a <user> docker` has to stay first — adds the user to
# the docker group once the group exists (archinstall creates users # the docker group once the group exists (archinstall creates users
@ -592,7 +601,11 @@ def build_archinstall_config(s):
"audio_config": None, "audio_config": None,
"locale_config": { "locale_config": {
"locale": LANGUAGES[s["language"]]["locale"], "locale": LANGUAGES[s["language"]]["locale"],
"keyboard_layout": "us", # Keyboard layout follows the chosen language so a German user
# doesn't get a US layout at the TTY console (where things like
# `/`, `-`, `=` land on surprising keys and make even `sudo vim`
# painful). `en` falls through to "us" which is what we want.
"keyboard_layout": LANGUAGES[s["language"]]["keyboard"],
}, },
} }