2026-04-15 10:02:00 +02:00
|
|
|
import shutil
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
feat(catalog): on-box apps catalog synced independently of core version
New `furtka catalog sync` pulls the latest daniel/furtka-apps release,
verifies its sha256, extracts under /var/lib/furtka/catalog/, and
atomically swaps into place — so apps can ship without cutting a new
Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min
post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a
manual "Sync apps catalog" button that kicks the same code path via a
detached systemd-run unit.
Layout of the new on-box tree:
/var/lib/furtka/catalog/ synced catalog (survives self-updates)
├── VERSION
└── apps/<name>/ ...
/var/lib/furtka/catalog-state.json sync stage + last version, UI-polled
/run/furtka/catalog.lock flock so timer + manual click can't race
Resolver precedence (furtka/sources.py): catalog wins over the bundled
seed (/opt/furtka/current/apps/, carried by the core release for offline
first-boot). Installed apps under /var/lib/furtka/apps/ are never auto-
swapped — user clicks Reinstall to move an existing install onto a
newer catalog version; settings merge-preserved via the existing
installer.install_from path.
New files:
- furtka/_release_common.py — shared Forgejo/tarball primitives lifted
from furtka/updater.py. Both modules now import from here; updater's
behaviour and public API unchanged.
- furtka/catalog.py — check_catalog(), sync_catalog() with staging +
manifest validation + atomic rename. Refuses bad sha256 / broken
manifests and leaves the live catalog intact on any failure path.
- furtka/sources.py — resolve_app_name() / list_available() abstraction
used by installer.resolve_source and api._list_available.
- assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service
+ daily timer. Timer auto-enables on self-update via a one-line
addition to _link_new_units (fresh installs get enabled via the
webinstaller's _FURTKA_UNITS list).
API + UI:
- /api/bundled renamed internally to _list_available; endpoint stays as
a backcompat alias; /api/apps/available is the new canonical name.
Each list entry carries a `source` field ("catalog" | "bundled").
- POST /api/catalog/sync/check + /apply + GET /api/catalog/status.
- /apps page grows a catalog-status row + Sync button; poll loop
mirrors the Furtka self-update flow.
CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both
support --json). Old `furtka app install` / `reconcile` / `update` /
`rollback` surfaces are unchanged.
Test gate: 194/170 baseline + 24 new tests covering catalog sync
(happy path, sha256 mismatch, invalid manifest, lock contention,
preserves-on-failure) + resolver precedence + api renames. ruff
check + format clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:02 +02:00
|
|
|
from furtka import sources
|
feat(manifest): add 'path' setting type with server-side validation
Apps can now declare a setting with "type": "path" whose value is an
absolute host filesystem path. Compose bind-mounts it via standard .env
substitution (${MEDIA_PATH}:/media) — no reconciler changes needed.
Unlocks media/data-heavy apps (Jellyfin, later Paperless, Nextcloud,
Immich) that point at existing user data instead of copying it into a
Docker volume.
Install/update refuses values that aren't absolute, don't exist, aren't
directories, or resolve into a system-path deny-list (/, /etc, /root,
/boot, /proc, /sys, /dev, /bin, /sbin, /usr/bin, /usr/sbin,
/var/lib/furtka). Path.resolve() is applied before the deny-list check
so /mnt/../etc traversal is caught too. Error messages surface in the
existing install/edit modal.
UI: path settings render as a text input with a /mnt/… placeholder.
The manifest's `description` field carries the actual hint ("Absoluter
Pfad zu deinem Filme-Ordner, z.B. /mnt/media"). No new form
components, no new API routes.
Tests: 9 new cases for install + update path validation; 1 new case
for manifest schema accepting the path type. 211 total passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:39:15 +02:00
|
|
|
from furtka.manifest import Manifest, ManifestError, load_manifest
|
feat(catalog): on-box apps catalog synced independently of core version
New `furtka catalog sync` pulls the latest daniel/furtka-apps release,
verifies its sha256, extracts under /var/lib/furtka/catalog/, and
atomically swaps into place — so apps can ship without cutting a new
Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min
post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a
manual "Sync apps catalog" button that kicks the same code path via a
detached systemd-run unit.
Layout of the new on-box tree:
/var/lib/furtka/catalog/ synced catalog (survives self-updates)
├── VERSION
└── apps/<name>/ ...
/var/lib/furtka/catalog-state.json sync stage + last version, UI-polled
/run/furtka/catalog.lock flock so timer + manual click can't race
Resolver precedence (furtka/sources.py): catalog wins over the bundled
seed (/opt/furtka/current/apps/, carried by the core release for offline
first-boot). Installed apps under /var/lib/furtka/apps/ are never auto-
swapped — user clicks Reinstall to move an existing install onto a
newer catalog version; settings merge-preserved via the existing
installer.install_from path.
New files:
- furtka/_release_common.py — shared Forgejo/tarball primitives lifted
from furtka/updater.py. Both modules now import from here; updater's
behaviour and public API unchanged.
- furtka/catalog.py — check_catalog(), sync_catalog() with staging +
manifest validation + atomic rename. Refuses bad sha256 / broken
manifests and leaves the live catalog intact on any failure path.
- furtka/sources.py — resolve_app_name() / list_available() abstraction
used by installer.resolve_source and api._list_available.
- assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service
+ daily timer. Timer auto-enables on self-update via a one-line
addition to _link_new_units (fresh installs get enabled via the
webinstaller's _FURTKA_UNITS list).
API + UI:
- /api/bundled renamed internally to _list_available; endpoint stays as
a backcompat alias; /api/apps/available is the new canonical name.
Each list entry carries a `source` field ("catalog" | "bundled").
- POST /api/catalog/sync/check + /apply + GET /api/catalog/status.
- /apps page grows a catalog-status row + Sync button; poll loop
mirrors the Furtka self-update flow.
CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both
support --json). Old `furtka app install` / `reconcile` / `update` /
`rollback` surfaces are unchanged.
Test gate: 194/170 baseline + 24 new tests covering catalog sync
(happy path, sha256 mismatch, invalid manifest, lock contention,
preserves-on-failure) + resolver precedence + api renames. ruff
check + format clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:02 +02:00
|
|
|
from furtka.paths import apps_dir
|
2026-04-15 10:02:00 +02:00
|
|
|
|
2026-04-15 10:17:00 +02:00
|
|
|
# Values that an app's .env.example may use as obvious "fill me in" markers.
|
|
|
|
|
# If any of these reach the live .env, install refuses — otherwise we'd ship
|
|
|
|
|
# an SMB share with password "changeme" out of the box, which is the kind of
|
|
|
|
|
# default that ends up screenshotted on Hacker News.
|
|
|
|
|
PLACEHOLDER_SECRETS: frozenset[str] = frozenset({"changeme"})
|
|
|
|
|
|
feat(manifest): add 'path' setting type with server-side validation
Apps can now declare a setting with "type": "path" whose value is an
absolute host filesystem path. Compose bind-mounts it via standard .env
substitution (${MEDIA_PATH}:/media) — no reconciler changes needed.
Unlocks media/data-heavy apps (Jellyfin, later Paperless, Nextcloud,
Immich) that point at existing user data instead of copying it into a
Docker volume.
Install/update refuses values that aren't absolute, don't exist, aren't
directories, or resolve into a system-path deny-list (/, /etc, /root,
/boot, /proc, /sys, /dev, /bin, /sbin, /usr/bin, /usr/sbin,
/var/lib/furtka). Path.resolve() is applied before the deny-list check
so /mnt/../etc traversal is caught too. Error messages surface in the
existing install/edit modal.
UI: path settings render as a text input with a /mnt/… placeholder.
The manifest's `description` field carries the actual hint ("Absoluter
Pfad zu deinem Filme-Ordner, z.B. /mnt/media"). No new form
components, no new API routes.
Tests: 9 new cases for install + update path validation; 1 new case
for manifest schema accepting the path type. 211 total passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:39:15 +02:00
|
|
|
# System paths that must never be accepted as a user-supplied `path`-type
|
|
|
|
|
# setting. The user is root on their own box, so this is about preventing
|
|
|
|
|
# accidental footguns (typing `/etc` when they meant `/mnt/etc`), not
|
|
|
|
|
# defending against an attacker. Matches exact paths and their subtrees
|
|
|
|
|
# after `Path.resolve()` — so `/mnt/../etc` also lands here.
|
|
|
|
|
DENIED_PATH_PREFIXES: tuple[str, ...] = (
|
|
|
|
|
"/etc",
|
|
|
|
|
"/root",
|
|
|
|
|
"/boot",
|
|
|
|
|
"/proc",
|
|
|
|
|
"/sys",
|
|
|
|
|
"/dev",
|
|
|
|
|
"/bin",
|
|
|
|
|
"/sbin",
|
|
|
|
|
"/usr/bin",
|
|
|
|
|
"/usr/sbin",
|
|
|
|
|
"/var/lib/furtka",
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
|
|
|
|
|
class InstallError(RuntimeError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 10:17:00 +02:00
|
|
|
def _placeholder_keys(env_path: Path) -> list[str]:
|
|
|
|
|
if not env_path.exists():
|
|
|
|
|
return []
|
|
|
|
|
bad: list[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().strip('"').strip("'")
|
|
|
|
|
if value in PLACEHOLDER_SECRETS:
|
|
|
|
|
bad.append(key.strip())
|
|
|
|
|
return bad
|
|
|
|
|
|
|
|
|
|
|
feat(manifest): add 'path' setting type with server-side validation
Apps can now declare a setting with "type": "path" whose value is an
absolute host filesystem path. Compose bind-mounts it via standard .env
substitution (${MEDIA_PATH}:/media) — no reconciler changes needed.
Unlocks media/data-heavy apps (Jellyfin, later Paperless, Nextcloud,
Immich) that point at existing user data instead of copying it into a
Docker volume.
Install/update refuses values that aren't absolute, don't exist, aren't
directories, or resolve into a system-path deny-list (/, /etc, /root,
/boot, /proc, /sys, /dev, /bin, /sbin, /usr/bin, /usr/sbin,
/var/lib/furtka). Path.resolve() is applied before the deny-list check
so /mnt/../etc traversal is caught too. Error messages surface in the
existing install/edit modal.
UI: path settings render as a text input with a /mnt/… placeholder.
The manifest's `description` field carries the actual hint ("Absoluter
Pfad zu deinem Filme-Ordner, z.B. /mnt/media"). No new form
components, no new API routes.
Tests: 9 new cases for install + update path validation; 1 new case
for manifest schema accepting the path type. 211 total passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:39:15 +02:00
|
|
|
def _is_denied_system_path(resolved: str) -> bool:
|
|
|
|
|
if resolved == "/":
|
|
|
|
|
return True
|
|
|
|
|
for bad in DENIED_PATH_PREFIXES:
|
|
|
|
|
if resolved == bad or resolved.startswith(bad + "/"):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]:
|
|
|
|
|
"""Validate the filesystem paths named by `path`-type settings.
|
|
|
|
|
|
|
|
|
|
Returns one human-readable message per offending setting. Empty values
|
|
|
|
|
on non-required settings are allowed — the required-field check in the
|
|
|
|
|
caller already refuses blanks on required fields before write.
|
|
|
|
|
"""
|
|
|
|
|
if not env_path.exists():
|
|
|
|
|
return []
|
|
|
|
|
values = _read_env(env_path)
|
|
|
|
|
errors: list[str] = []
|
|
|
|
|
for s in m.settings:
|
|
|
|
|
if s.type != "path":
|
|
|
|
|
continue
|
|
|
|
|
value = values.get(s.name, "")
|
|
|
|
|
if not value:
|
|
|
|
|
continue
|
|
|
|
|
p = Path(value)
|
|
|
|
|
if not p.is_absolute():
|
|
|
|
|
errors.append(f"{s.name}={value!r} must be an absolute path (start with /)")
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
resolved = p.resolve(strict=False)
|
|
|
|
|
except (OSError, RuntimeError) as e:
|
|
|
|
|
errors.append(f"{s.name}={value!r} cannot be resolved: {e}")
|
|
|
|
|
continue
|
|
|
|
|
if _is_denied_system_path(str(resolved)):
|
|
|
|
|
errors.append(f"{s.name}={value!r} resolves into a system path and is not allowed")
|
|
|
|
|
continue
|
|
|
|
|
if not resolved.exists():
|
|
|
|
|
errors.append(f"{s.name}={value!r} does not exist on this box")
|
|
|
|
|
continue
|
|
|
|
|
if not resolved.is_dir():
|
|
|
|
|
errors.append(f"{s.name}={value!r} is not a directory")
|
|
|
|
|
continue
|
|
|
|
|
return errors
|
|
|
|
|
|
|
|
|
|
|
feat(furtka): in-browser app settings + ISO recovery-path fixes
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>
2026-04-15 13:00:02 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
def resolve_source(source: str) -> Path:
|
|
|
|
|
"""Resolve a `furtka app install <source>` arg to a real source folder.
|
|
|
|
|
|
|
|
|
|
If `source` looks like a path (or exists on disk), use it. Otherwise treat
|
feat(catalog): on-box apps catalog synced independently of core version
New `furtka catalog sync` pulls the latest daniel/furtka-apps release,
verifies its sha256, extracts under /var/lib/furtka/catalog/, and
atomically swaps into place — so apps can ship without cutting a new
Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min
post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a
manual "Sync apps catalog" button that kicks the same code path via a
detached systemd-run unit.
Layout of the new on-box tree:
/var/lib/furtka/catalog/ synced catalog (survives self-updates)
├── VERSION
└── apps/<name>/ ...
/var/lib/furtka/catalog-state.json sync stage + last version, UI-polled
/run/furtka/catalog.lock flock so timer + manual click can't race
Resolver precedence (furtka/sources.py): catalog wins over the bundled
seed (/opt/furtka/current/apps/, carried by the core release for offline
first-boot). Installed apps under /var/lib/furtka/apps/ are never auto-
swapped — user clicks Reinstall to move an existing install onto a
newer catalog version; settings merge-preserved via the existing
installer.install_from path.
New files:
- furtka/_release_common.py — shared Forgejo/tarball primitives lifted
from furtka/updater.py. Both modules now import from here; updater's
behaviour and public API unchanged.
- furtka/catalog.py — check_catalog(), sync_catalog() with staging +
manifest validation + atomic rename. Refuses bad sha256 / broken
manifests and leaves the live catalog intact on any failure path.
- furtka/sources.py — resolve_app_name() / list_available() abstraction
used by installer.resolve_source and api._list_available.
- assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service
+ daily timer. Timer auto-enables on self-update via a one-line
addition to _link_new_units (fresh installs get enabled via the
webinstaller's _FURTKA_UNITS list).
API + UI:
- /api/bundled renamed internally to _list_available; endpoint stays as
a backcompat alias; /api/apps/available is the new canonical name.
Each list entry carries a `source` field ("catalog" | "bundled").
- POST /api/catalog/sync/check + /apply + GET /api/catalog/status.
- /apps page grows a catalog-status row + Sync button; poll loop
mirrors the Furtka self-update flow.
CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both
support --json). Old `furtka app install` / `reconcile` / `update` /
`rollback` surfaces are unchanged.
Test gate: 194/170 baseline + 24 new tests covering catalog sync
(happy path, sha256 mismatch, invalid manifest, lock contention,
preserves-on-failure) + resolver precedence + api renames. ruff
check + format clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:02 +02:00
|
|
|
it as an app name and look it up via `furtka.sources.resolve_app_name` —
|
|
|
|
|
which checks the synced catalog first and falls back to the bundled seed.
|
2026-04-15 10:02:00 +02:00
|
|
|
"""
|
|
|
|
|
p = Path(source)
|
|
|
|
|
if p.is_dir():
|
|
|
|
|
return p
|
|
|
|
|
if "/" in source or source.startswith("."):
|
|
|
|
|
raise InstallError(f"{source!r} is not a directory")
|
feat(catalog): on-box apps catalog synced independently of core version
New `furtka catalog sync` pulls the latest daniel/furtka-apps release,
verifies its sha256, extracts under /var/lib/furtka/catalog/, and
atomically swaps into place — so apps can ship without cutting a new
Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min
post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a
manual "Sync apps catalog" button that kicks the same code path via a
detached systemd-run unit.
Layout of the new on-box tree:
/var/lib/furtka/catalog/ synced catalog (survives self-updates)
├── VERSION
└── apps/<name>/ ...
/var/lib/furtka/catalog-state.json sync stage + last version, UI-polled
/run/furtka/catalog.lock flock so timer + manual click can't race
Resolver precedence (furtka/sources.py): catalog wins over the bundled
seed (/opt/furtka/current/apps/, carried by the core release for offline
first-boot). Installed apps under /var/lib/furtka/apps/ are never auto-
swapped — user clicks Reinstall to move an existing install onto a
newer catalog version; settings merge-preserved via the existing
installer.install_from path.
New files:
- furtka/_release_common.py — shared Forgejo/tarball primitives lifted
from furtka/updater.py. Both modules now import from here; updater's
behaviour and public API unchanged.
- furtka/catalog.py — check_catalog(), sync_catalog() with staging +
manifest validation + atomic rename. Refuses bad sha256 / broken
manifests and leaves the live catalog intact on any failure path.
- furtka/sources.py — resolve_app_name() / list_available() abstraction
used by installer.resolve_source and api._list_available.
- assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service
+ daily timer. Timer auto-enables on self-update via a one-line
addition to _link_new_units (fresh installs get enabled via the
webinstaller's _FURTKA_UNITS list).
API + UI:
- /api/bundled renamed internally to _list_available; endpoint stays as
a backcompat alias; /api/apps/available is the new canonical name.
Each list entry carries a `source` field ("catalog" | "bundled").
- POST /api/catalog/sync/check + /apply + GET /api/catalog/status.
- /apps page grows a catalog-status row + Sync button; poll loop
mirrors the Furtka self-update flow.
CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both
support --json). Old `furtka app install` / `reconcile` / `update` /
`rollback` surfaces are unchanged.
Test gate: 194/170 baseline + 24 new tests covering catalog sync
(happy path, sha256 mismatch, invalid manifest, lock contention,
preserves-on-failure) + resolver precedence + api renames. ruff
check + format clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:02 +02:00
|
|
|
resolved = sources.resolve_app_name(source)
|
|
|
|
|
if resolved is None:
|
|
|
|
|
raise InstallError(f"{source!r} not found as a path, catalog app, or bundled app")
|
|
|
|
|
return resolved.path
|
2026-04-15 10:02:00 +02:00
|
|
|
|
|
|
|
|
|
feat(furtka): in-browser app settings + ISO recovery-path fixes
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>
2026-04-15 13:00:02 +02:00
|
|
|
def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
|
2026-04-15 10:02:00 +02:00
|
|
|
"""Copy a validated app folder into /var/lib/furtka/apps/<name>/.
|
|
|
|
|
|
feat(furtka): in-browser app settings + ISO recovery-path fixes
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>
2026-04-15 13:00:02 +02:00
|
|
|
If `settings` is provided, the .env is written from those values (this is
|
|
|
|
|
what the Web UI / API does — user fills in a form, values land here).
|
|
|
|
|
Otherwise, preserves an existing .env on upgrade and bootstraps from
|
|
|
|
|
.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.
|
2026-04-15 10:17:00 +02:00
|
|
|
|
|
|
|
|
Returns the target folder on success.
|
2026-04-15 10:02:00 +02:00
|
|
|
"""
|
|
|
|
|
manifest_path = src / "manifest.json"
|
|
|
|
|
if not manifest_path.exists():
|
|
|
|
|
raise InstallError(f"{src} has no manifest.json")
|
|
|
|
|
try:
|
|
|
|
|
m = load_manifest(manifest_path)
|
|
|
|
|
except ManifestError as e:
|
|
|
|
|
raise InstallError(str(e)) from e
|
|
|
|
|
|
feat(furtka): in-browser app settings + ISO recovery-path fixes
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>
2026-04-15 13:00:02 +02:00
|
|
|
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}")
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
target = apps_dir() / m.name
|
|
|
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
for item in src.iterdir():
|
|
|
|
|
if not item.is_file():
|
|
|
|
|
continue
|
feat(furtka): in-browser app settings + ISO recovery-path fixes
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>
2026-04-15 13:00:02 +02:00
|
|
|
# Never overwrite an existing user .env — either settings-driven write
|
|
|
|
|
# or previous manual edit has authority.
|
2026-04-15 10:02:00 +02:00
|
|
|
if item.name == ".env" and (target / ".env").exists():
|
|
|
|
|
continue
|
|
|
|
|
shutil.copy2(item, target / item.name)
|
|
|
|
|
|
|
|
|
|
env = target / ".env"
|
|
|
|
|
env_example = target / ".env.example"
|
feat(furtka): in-browser app settings + ISO recovery-path fixes
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>
2026-04-15 13:00:02 +02:00
|
|
|
|
|
|
|
|
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.
|
2026-04-15 10:02:00 +02:00
|
|
|
shutil.copy2(env_example, env)
|
|
|
|
|
|
2026-04-15 10:17:00 +02:00
|
|
|
# .env carries app secrets — lock to root-only. Done before the placeholder
|
|
|
|
|
# check so even the half-installed state is at least not world-readable.
|
|
|
|
|
if env.exists():
|
|
|
|
|
env.chmod(0o600)
|
|
|
|
|
|
|
|
|
|
bad = _placeholder_keys(env)
|
|
|
|
|
if bad:
|
|
|
|
|
raise InstallError(
|
|
|
|
|
f"{m.name}: {env} still has placeholder values for {', '.join(bad)}. "
|
feat(furtka): in-browser app settings + ISO recovery-path fixes
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>
2026-04-15 13:00:02 +02:00
|
|
|
f"Open the app in the Furtka UI to fill in real values, or edit the "
|
|
|
|
|
f"file and re-run `furtka app install {m.name}`."
|
2026-04-15 10:17:00 +02:00
|
|
|
)
|
|
|
|
|
|
feat(manifest): add 'path' setting type with server-side validation
Apps can now declare a setting with "type": "path" whose value is an
absolute host filesystem path. Compose bind-mounts it via standard .env
substitution (${MEDIA_PATH}:/media) — no reconciler changes needed.
Unlocks media/data-heavy apps (Jellyfin, later Paperless, Nextcloud,
Immich) that point at existing user data instead of copying it into a
Docker volume.
Install/update refuses values that aren't absolute, don't exist, aren't
directories, or resolve into a system-path deny-list (/, /etc, /root,
/boot, /proc, /sys, /dev, /bin, /sbin, /usr/bin, /usr/sbin,
/var/lib/furtka). Path.resolve() is applied before the deny-list check
so /mnt/../etc traversal is caught too. Error messages surface in the
existing install/edit modal.
UI: path settings render as a text input with a /mnt/… placeholder.
The manifest's `description` field carries the actual hint ("Absoluter
Pfad zu deinem Filme-Ordner, z.B. /mnt/media"). No new form
components, no new API routes.
Tests: 9 new cases for install + update path validation; 1 new case
for manifest schema accepting the path type. 211 total passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:39:15 +02:00
|
|
|
path_errors = _path_setting_errors(m, env)
|
|
|
|
|
if path_errors:
|
|
|
|
|
raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
return target
|
|
|
|
|
|
|
|
|
|
|
feat(furtka): in-browser app settings + ISO recovery-path fixes
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>
2026-04-15 13:00:02 +02:00
|
|
|
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)}.")
|
feat(manifest): add 'path' setting type with server-side validation
Apps can now declare a setting with "type": "path" whose value is an
absolute host filesystem path. Compose bind-mounts it via standard .env
substitution (${MEDIA_PATH}:/media) — no reconciler changes needed.
Unlocks media/data-heavy apps (Jellyfin, later Paperless, Nextcloud,
Immich) that point at existing user data instead of copying it into a
Docker volume.
Install/update refuses values that aren't absolute, don't exist, aren't
directories, or resolve into a system-path deny-list (/, /etc, /root,
/boot, /proc, /sys, /dev, /bin, /sbin, /usr/bin, /usr/sbin,
/var/lib/furtka). Path.resolve() is applied before the deny-list check
so /mnt/../etc traversal is caught too. Error messages surface in the
existing install/edit modal.
UI: path settings render as a text input with a /mnt/… placeholder.
The manifest's `description` field carries the actual hint ("Absoluter
Pfad zu deinem Filme-Ordner, z.B. /mnt/media"). No new form
components, no new API routes.
Tests: 9 new cases for install + update path validation; 1 new case
for manifest schema accepting the path type. 211 total passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:39:15 +02:00
|
|
|
path_errors = _path_setting_errors(m, env)
|
|
|
|
|
if path_errors:
|
|
|
|
|
raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
|
feat(furtka): in-browser app settings + ISO recovery-path fixes
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>
2026-04-15 13:00:02 +02:00
|
|
|
return target
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
def remove(name: str) -> Path:
|
|
|
|
|
"""Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched.
|
|
|
|
|
|
|
|
|
|
Caller is responsible for stopping the compose project first.
|
|
|
|
|
"""
|
|
|
|
|
target = apps_dir() / name
|
|
|
|
|
if not target.exists():
|
|
|
|
|
raise InstallError(f"{name!r} is not installed")
|
|
|
|
|
shutil.rmtree(target)
|
|
|
|
|
return target
|