2026-04-15 10:02:00 +02:00
|
|
|
import shutil
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from furtka.manifest import ManifestError, load_manifest
|
|
|
|
|
from furtka.paths import apps_dir, bundled_apps_dir
|
|
|
|
|
|
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"})
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
it as a bundled app name and look up under /opt/furtka/apps/<name>.
|
|
|
|
|
"""
|
|
|
|
|
p = Path(source)
|
|
|
|
|
if p.is_dir():
|
|
|
|
|
return p
|
|
|
|
|
if "/" in source or source.startswith("."):
|
|
|
|
|
raise InstallError(f"{source!r} is not a directory")
|
|
|
|
|
bundled = bundled_apps_dir() / source
|
|
|
|
|
if bundled.is_dir():
|
|
|
|
|
return bundled
|
|
|
|
|
raise InstallError(f"{source!r} not found as a path or bundled app")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def install_from(src: Path) -> Path:
|
|
|
|
|
"""Copy a validated app folder into /var/lib/furtka/apps/<name>/.
|
|
|
|
|
|
|
|
|
|
Preserves an existing .env on upgrade. Bootstraps .env from .env.example
|
2026-04-15 10:17:00 +02:00
|
|
|
on first install if .env wasn't shipped. 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.
|
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
|
|
|
|
|
|
|
|
|
|
target = apps_dir() / m.name
|
|
|
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
for item in src.iterdir():
|
|
|
|
|
if not item.is_file():
|
|
|
|
|
continue
|
|
|
|
|
# Never overwrite an existing user .env.
|
|
|
|
|
if item.name == ".env" and (target / ".env").exists():
|
|
|
|
|
continue
|
|
|
|
|
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_example = target / ".env.example"
|
|
|
|
|
if not env.exists() and env_example.exists():
|
|
|
|
|
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)}. "
|
|
|
|
|
f"Edit the file (set real values), then re-run "
|
|
|
|
|
f"`furtka app install {m.name}`."
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
return target
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|