Another status update. This is my current progress which will install the files, and seemingly builds the system out, but still is very buggy. I am going to keep working on it until we get it right, but I don't like not providing some show of progress. The reason its so slow is this project although a Django project, has a lot of internal standards that don't conform to the best practices or norms. This is fine, but it makes learning it and adapting it much slower than I had anticipated. It does seem like a mature project so worthy of the time. Thanks for your patience. If you wanted to experiment place this python file someplace available online and use the following curl command to invoke it,
 
curl -s -X POST https://my.opalstack.com/api/v1/app/create/ \                                                                                                                                                                 ✔ 
  -H "Authorization: Token 1234" \
  -H "Content-Type: application/json" \
  -d '[{
    "name": "seafile",
    "osuser": "UUID",
    "type": "CUS",
    "installer_url": "https://foo.com/seafile.py",
    "json": {}
  }]'
Their official docs are here,
 
https://manual.seafile.com/12.0/setup_binary/installation_ce/#create-the-env-file-in-conf-directory

#!/usr/bin/env python3
# Seafile installer for Opalstack (EL9) - CE 12.0 aligned (no Pro/seafevents)
# - Provisions MariaDB user + 3 DBs (ccnet, seafile, seahub), per official docs.
# - Runs setup-seafile-mysql.sh (auto, use existing DBs) with streamed output.
# - Creates a venv and installs seahub/requirements.txt.
# - Writes conf/.env with JWT_PRIVATE_KEY and hostname (12.x requires JWT key).
# - Removes conf/seafevents.conf so CE won't try to load Pro seafevents.
# - Runs Django migrations (verbose) and auto-creates admin (no prompt)
#   using the account email fetched from /api/v1/account/info/.
# - Binds Gunicorn to the main app port from /app/read.
# - Auto-starts services and curls localhost:<main_port>.
import argparse
import http.client
import json
import logging
import os
import os.path
import secrets
import shlex
import string
import subprocess
import sys
import time
from urllib.parse import urlparse
from string import Template
API_BASE_URI = "/api/v1"
SEAFILE_DEFAULT_VERSION = "12.0.14"
SEAFILE_URL_FMT_PRIMARY  = "https://s3.eu-central-1.amazonaws.com/download.seadrive.org/seafile-server_{ver}_x86-64.tar.gz"
SEAFILE_URL_FMT_FALLBACK = "https://s3.eu-central-1.amazonaws.com/download.seafile.com/seafile-server_{ver}_x86-64.tar.gz"
CMD_ENV = {"PATH": "/usr/local/bin:/usr/bin:/bin", "UMASK": "0002"}
# ---------- helpers ----------
def gen_password(length=20):
    chars = string.ascii_letters + string.digits
    return "".join(secrets.choice(chars) for _ in range(length))
def gen_jwt_key(length=40):
    chars = string.ascii_letters + string.digits
    return "".join(secrets.choice(chars) for _ in range(length))
def run_command(cmd, env=None, cwd=None, shellish=False, allow_fail=False):
    if env is None:
        env = CMD_ENV
    logging.info(("RUN (allow-fail): " if allow_fail else "RUN: ") + cmd)
    try:
        if shellish:
            out = subprocess.check_output(cmd, env=env, cwd=cwd, stderr=subprocess.STDOUT, shell=True)
        else:
            out = subprocess.check_output(shlex.split(cmd), env=env, cwd=cwd, stderr=subprocess.STDOUT)
        if out:
            logging.debug(out.decode("utf-8", errors="ignore"))
        return out
    except subprocess.CalledProcessError as e:
        if allow_fail:
            try:
                logging.info(e.output.decode("utf-8", errors="ignore"))
            except Exception:
                pass
            return None
        logging.error(f"Command failed rc={e.returncode}")
        try:
            logging.error(e.output.decode("utf-8", errors="ignore"))
        except Exception:
            pass
        sys.exit(1)
def stream_command(cmd, env=None, cwd=None):
    """Live-stream stdout/stderr of a long-running command to the installer log and fail fast on nonzero rc."""
    if env is None:
        env = CMD_ENV
    logging.info("RUN (stream): " + (cmd if isinstance(cmd, str) else " ".join(shlex.quote(x) for x in cmd)))
    proc = subprocess.Popen(
        cmd if isinstance(cmd, list) else shlex.split(cmd),
        env=env,
        cwd=cwd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
    )
    assert proc.stdout is not None
    for line in proc.stdout:
        logging.info(line.rstrip("\n"))
    rc = proc.wait()
    if rc != 0:
        logging.error(f"Command failed rc={rc}")
        sys.exit(rc)
    logging.info("Done.\n")
def create_file(path, contents, perms=0o600, mode="w"):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, mode) as f:
        f.write(contents)
    os.chmod(path, perms)
    logging.info(f"Created file {path} (mode {oct(perms)})")
def _require_env(name):
    v = os.environ.get(name)
    if not v:
        logging.error(f"Required env var {name} not set")
        sys.exit(1)
    return v
def _parse_api_host_from_env():
    api_url = _require_env("API_URL")
    p = urlparse(api_url)
    host = p.netloc or p.path
    host = host.strip("/").strip()
    if not host:
        logging.error(f"Could not parse API_URL={api_url!r} into host")
        sys.exit(1)
    return host
# ---------- Opalstack API ----------
class OpalstackAPITool:
    def __init__(self, host, authtoken, user, password):
        self.host = host
        self.base_uri = API_BASE_URI
        if not authtoken:
            payload = json.dumps({"username": user, "password": password})
            conn = http.client.HTTPSConnection(self.host)
            conn.request("POST", self.base_uri + "/login/", payload, headers={"Content-type": "application/json"})
            resp = conn.getresponse()
            body = resp.read()
            try:
                result = json.loads(body)
            except Exception:
                logging.error(f"Login failed, non-JSON: {body[:200]!r}")
                sys.exit(1)
            token = result.get("token")
            if not token:
                logging.error("Invalid username/password and no OPAL_TOKEN provided")
                sys.exit(1)
            authtoken = token
        self.headers = {"Content-type": "application/json", "Authorization": f"Token {authtoken}"}
    def get(self, endpoint):
        uri = self.base_uri + endpoint
        conn = http.client.HTTPSConnection(self.host)
        conn.request("GET", uri, headers=self.headers)
        resp = conn.getresponse()
        body = resp.read()
        try:
            data = json.loads(body)
        except Exception:
            logging.error(f"GET {uri} non-JSON: {body[:200]!r}")
            sys.exit(1)
        if resp.status >= 300:
            logging.error(f"GET {uri} http={resp.status} body={data}")
            sys.exit(1)
        logging.info(f"GET {uri} -> ok")
        return data
    def post(self, endpoint, payload_obj):
        uri = self.base_uri + endpoint
        data = json.dumps(payload_obj)
        conn = http.client.HTTPSConnection(self.host)
        conn.request("POST", uri, data, headers=self.headers)
        resp = conn.getresponse()
        body = resp.read()
        text = body.decode("utf-8", errors="ignore")
        try:
            parsed = json.loads(text)
        except Exception:
            parsed = text
        if resp.status >= 300:
            logging.error(f"POST {uri} http={resp.status} body={parsed}")
            sys.exit(1)
        logging.info(f"POST {uri} -> ok")
        return parsed
    def post_allow_4xx(self, endpoint, payload_obj):
        uri = self.base_uri + endpoint
        data = json.dumps(payload_obj)
        conn = http.client.HTTPSConnection(self.host)
        conn.request("POST", uri, data, headers=self.headers)
        resp = conn.getresponse()
        body = resp.read()
        text = body.decode("utf-8", errors="ignore")
        try:
            parsed = json.loads(text)
        except Exception:
            parsed = text
        return resp.status, parsed
# ---------- waits ----------
def wait_for_mariauser_ready(api, user_id, password_to_set, timeout_sec=180):
    deadline = time.time() + timeout_sec
    sleep_s = 2
    while True:
        status, body = api.post_allow_4xx("/mariauser/update/", [{"id": user_id, "json": {"password": password_to_set}}])
        if status < 300:
            logging.info("MariaDB user is ready (password set).")
            return
        body_str = body if isinstance(body, str) else json.dumps(body)
        if status == 400 and "Not ready" in body_str:
            logging.info(f"MariaDB user not ready yet, retrying in {sleep_s}s...")
            time.sleep(sleep_s)
            sleep_s = min(sleep_s + 1, 10)
            if time.time() > deadline:
                logging.error("Timeout waiting for MariaDB user to become ready.")
                sys.exit(1)
            continue
        logging.error(f"/mariauser/update/ failed: http={status} body={body}")
        sys.exit(1)
# ---------- seafile assets ----------
def pick_seafile_url(version, override_url):
    if override_url:
        return override_url
    primary  = SEAFILE_URL_FMT_PRIMARY.format(ver=version)
    fallback = SEAFILE_URL_FMT_FALLBACK.format(ver=version)
    rc = subprocess.call(shlex.split(f"/usr/bin/wget -q --spider {shlex.quote(primary)}"), env=CMD_ENV)
    if rc == 0:
        logging.info(f"Seafile tarball URL (primary) OK: {primary}")
        return primary
    logging.info(f"Primary URL failed (--spider rc={rc}), trying fallback: {fallback}")
    rc2 = subprocess.call(shlex.split(f"/usr/bin/wget -q --spider {shlex.quote(fallback)}"), env=CMD_ENV)
    if rc2 == 0:
        logging.info(f"Seafile tarball URL (fallback) OK: {fallback}")
        return fallback
    logging.error("Could not verify Seafile download URL (both primary and fallback failed).")
    logging.error(f"Primary tried:  {primary}")
    logging.error(f"Fallback tried: {fallback}")
    sys.exit(1)
def read_seafile_data_dir(ccnet_dir):
    ini_path = os.path.join(ccnet_dir, "seafile.ini")
    if not os.path.exists(ini_path):
        return None
    try:
        with open(ini_path, "r") as f:
            line = f.readline().strip()
        return line or None
    except Exception:
        return None
def write_seafile_data_dir(ccnet_dir, path):
    os.makedirs(ccnet_dir, exist_ok=True)
    create_file(os.path.join(ccnet_dir, "seafile.ini"), path.rstrip() + "\n", 0o600)
# ---------- venv + requirements ----------
def ensure_virtualenv(appdir):
    venv_dir = os.path.join(appdir, "venv")
    py  = os.path.join(venv_dir, "bin", "python")
    pip = os.path.join(venv_dir, "bin", "pip")
    if not os.path.exists(py):
        run_command(f"/usr/bin/python3 -m venv {shlex.quote(venv_dir)}")
    run_command(f"{shlex.quote(pip)} install --no-cache-dir --upgrade pip setuptools")
    return venv_dir, py, pip
# (The install_requirements_filtered function is not needed because we'll install specific packages below)
# ---------- admin email ----------
def pick_admin_email(api, appinfo, server_domain):
    # 1) explicit override env (if you set it)
    env_email = os.environ.get("OPAL_ACCOUNT_EMAIL")
    if env_email:
        return env_email.strip()
    # 2) authoritative Opalstack current account endpoint
    try:
        info = api.get("/account/info/")
        if isinstance(info, dict):
            for key in ("email", "email_address", "username"):
                v = info.get(key)
                if v and "@" in v:
                    return v
    except SystemExit:
        raise
    except Exception:
        pass
    # 3) fallback: osuser@domain
    osuser = appinfo.get("osuser_name") or "user"
    return f"{osuser}@{server_domain}"
# ---------- main ----------
def main():
    logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s", force=True)
    logging.info("Seafile installer starting up (CE 12.0, venv + detailed requirements installation)")
    p = argparse.ArgumentParser(description="Install Seafile on Opalstack")
    p.add_argument("-i", "--app-uuid",  dest="app_uuid",  default=os.environ.get("UUID"))
    p.add_argument("-n", "--app-name",  dest="app_name",  default=os.environ.get("APPNAME"))
    p.add_argument("-t", "--opal-token",dest="opal_token",default=os.environ.get("OPAL_TOKEN"))
    p.add_argument("-u", "--opal-user", dest="opal_user", default=os.environ.get("OPAL_USER"))
    p.add_argument("-p", "--opal-pass", dest="opal_pass", default=os.environ.get("OPAL_PASS"))
    p.add_argument("--db-user",   dest="db_user_name", default=None)
    p.add_argument("--db-prefix", dest="db_prefix",    default=None)
    p.add_argument("--cus-app-name", dest="cus_app_name", default=None)
    p.add_argument("--mysql-host", dest="mysql_host",  default=os.environ.get("MYSQL_HOST", "127.0.0.1"))
    p.add_argument("--mysql-port", dest="mysql_port",  default=os.environ.get("MYSQL_PORT", "3306"))
    p.add_argument("--seafile-version", dest="seafile_version", default=SEAFILE_DEFAULT_VERSION)
    p.add_argument("--seafile-url",     dest="seafile_url",     default=None)
    p.add_argument("--domain", dest="domain", default=None, help="Domain for setup (default: <app>.fake.local)")
    args = p.parse_args()
    if not args.app_uuid or not args.app_name:
        logging.error("Missing -i <UUID> and/or -n <APPNAME>")
        sys.exit(1)
    api_host = _parse_api_host_from_env()
    if not args.opal_token and (not args.opal_user or not args.opal_pass):
        logging.error("Provide OPAL_TOKEN (-t) OR OPAL_USER/-u and OPAL_PASS/-p")
        sys.exit(1)
    logging.info(f"API host: {api_host}")
    logging.info(f"App: {args.app_name} ({args.app_uuid})")
    api = OpalstackAPITool(api_host, args.opal_token or "", args.opal_user or "", args.opal_pass or "")
    # App info (need osuser, server, assigned port)
    appinfo = api.get(f"/app/read/{args.app_uuid}")
    osuser_name   = appinfo.get("osuser_name")
    main_app_port = appinfo.get("port")
    if not osuser_name:
        logging.error("app/read missing osuser_name")
        sys.exit(1)
    if not main_app_port:
        logging.error("app/read missing port for main app; cannot bind Seahub without it")
        sys.exit(1)
    appdir = f"/home/{osuser_name}/apps/{appinfo['name']}"
    for d in ("tmp", ".cache", "bin", "var", "log", "logs", "pids"):
        os.makedirs(os.path.join(appdir, d), exist_ok=True)
    CMD_ENV["HOME"] = f"/home/{osuser_name}"
    CMD_ENV["USER"] = osuser_name
    logging.info(f"Prepared appdir: {appdir}")
    # Domain for setup
    domains = appinfo.get("domains") or []
    server_domain = args.domain or (domains[0] if domains else f"{args.app_name}.fake.local")
    server_domain = str(server_domain).strip()
    logging.info(f"Using domain for setup: {server_domain}")
    # CUS app (seafhttp)
    cus_name = args.cus_app_name or f"{appinfo['name']}_fs"
    cus_app_uuid = None
    cus_app_port = None
    all_apps = api.get("/app/list/")
    for a in all_apps:
        if a.get("name") == cus_name and a.get("type") == "CUS" and a.get("osuser") == appinfo.get("osuser"):
            cus_app_uuid = a.get("id")
            cus_app_port = a.get("port")
            break
    if not cus_app_uuid:
        created = api.post("/app/create/", [{
            "osuser": appinfo["osuser"],
            "name": cus_name,
            "type": "CUS",
            "json": {"proxy_pass_trailing_slash": True}
        }])
        if not isinstance(created, list) or not created:
            logging.error(f"Unexpected /app/create/ response: {created}")
            sys.exit(1)
        cus_app_uuid = created[0].get("id")
    # Wait for CUS app to receive a port
    deadline = time.time() + 180
    while time.time() < deadline:
        info = api.get(f"/app/read/{cus_app_uuid}")
        cus_app_port = info.get("port")
        if cus_app_port:
            break
        time.sleep(2)
    if not cus_app_port:
        logging.error("CUS app did not receive a port")
        sys.exit(1)
    logging.info(f"CUS app ready: {cus_name} port={cus_app_port}")
    # MariaDB user + databases
    short_app  = (args.app_name or "app")[:8]
    short_uuid = (args.app_uuid or "uuid")[:8]
    db_prefix  = args.db_prefix or f"{short_app}_{short_uuid}"
    db_user_name = args.db_user_name or db_prefix
    ures = api.post("/mariauser/create/", [{"name": db_user_name, "server": appinfo["server"]}])
    if not isinstance(ures, list) or not ures:
        logging.error(f"Unexpected /mariauser/create/ response: {ures}")
        sys.exit(1)
    db_user_id   = ures[0]["id"]
    db_user_real = ures[0]["name"]
    db_user_pass = ures[0].get("default_password") or gen_password(20)
    wait_for_mariauser_ready(api, db_user_id, db_user_pass, timeout_sec=180)
    def mkdb(name):
        dres = api.post("/mariadb/create/", [{
            "name": name,
            "server": appinfo["server"],
            "charset": "utf8mb4",
            "dbusers_readwrite": [db_user_id]
        }])
        if not isinstance(dres, list) or not dres:
            logging.error(f"Unexpected /mariadb/create/ response: {dres}")
            sys.exit(1)
        return dres[0]["id"]
    ccnet_db   = f"{db_prefix}_ccnet"
    seafile_db = f"{db_prefix}_seafile"
    seahub_db  = f"{db_prefix}_seahub"
    mkdb(ccnet_db); mkdb(seafile_db); mkdb(seahub_db)
    logging.info(f"Databases created: {ccnet_db}, {seafile_db}, {seahub_db}")
    # Download and extract Seafile server package
    seafile_url = pick_seafile_url(args.seafile_version, args.seafile_url)
    tar_path = os.path.join(appdir, ".cache", "seafile.tar.gz")
    run_command(f"/usr/bin/wget -O {shlex.quote(tar_path)} {shlex.quote(seafile_url)}")
    run_command(f"tar -xzvf {shlex.quote(tar_path)} -C {shlex.quote(appdir)}")
    # Locate the extracted Seafile server directory
    seafile_root = None
    for name in os.listdir(appdir):
        p = os.path.join(appdir, name)
        if name.startswith("seafile-server-") and os.path.isdir(p):
            seafile_root = p
            break
    if not seafile_root:
        logging.error("Could not locate extracted seafile-server-* directory")
        sys.exit(1)
    # Prepare data directory (avoid collision if 'data' exists)
    base_data_dir = os.path.join(appdir, "data")
    data_dir = base_data_dir if not os.path.exists(base_data_dir) else os.path.join(appdir, f"data-{int(time.time())}")
    if data_dir != base_data_dir:
        logging.info(f"{base_data_dir} already exists; using {data_dir} for setup")
    # Run Seafile setup (non-interactive) with existing DBs (-e 1) and stream output
    setup_script = os.path.join(seafile_root, "setup-seafile-mysql.sh")
    if not os.path.exists(setup_script):
        logging.error(f"Missing {setup_script}")
        sys.exit(1)
    setup_cmd = (
        f"{setup_script} auto "
        f"-n {shlex.quote(args.app_name)} "
        f"-i {shlex.quote(server_domain)} "
        f"-p {shlex.quote(str(cus_app_port))} "
        f"-d {shlex.quote(data_dir)} "
        f"-e 1 "
        f"-o {shlex.quote(args.mysql_host)} "
        f"-t {shlex.quote(str(args.mysql_port))} "
        f"-u {shlex.quote(db_user_real)} "
        f"-w {shlex.quote(db_user_pass)} "
        f"-c {shlex.quote(ccnet_db)} "
        f"-s {shlex.quote(seafile_db)} "
        f"-b {shlex.quote(seahub_db)} "
    )
    stream_command(f"/bin/bash -lc {shlex.quote(setup_cmd)}", cwd=seafile_root)
    # Create required config files in conf/
    conf_dir = os.path.join(appdir, "conf")
    os.makedirs(conf_dir, exist_ok=True)
    jwt_key = gen_jwt_key(40)
    env_text = (
        "TIME_ZONE=UTC\n"
        f"JWT_PRIVATE_KEY={jwt_key}\n"
        "SEAFILE_SERVER_PROTOCOL=http\n"
        f"SEAFILE_SERVER_HOSTNAME={server_domain}\n"
        f"SEAFILE_MYSQL_DB_HOST={args.mysql_host}\n"
        f"SEAFILE_MYSQL_DB_PORT={args.mysql_port}\n"
        f"SEAFILE_MYSQL_DB_USER={db_user_real}\n"
        f"SEAFILE_MYSQL_DB_PASSWORD={db_user_pass}\n"
        f"SEAFILE_MYSQL_DB_CCNET_DB_NAME={ccnet_db}\n"
        f"SEAFILE_MYSQL_DB_SEAFILE_DB_NAME={seafile_db}\n"
        f"SEAFILE_MYSQL_DB_SEAHUB_DB_NAME={seahub_db}\n"
    )
    create_file(os.path.join(conf_dir, ".env"), env_text, 0o600)
    # Remove seafevents.conf if present (CE must not load Pro seafevents)
    seafevents_conf = os.path.join(conf_dir, "seafevents.conf")
    if os.path.exists(seafevents_conf):
        try:
            os.remove(seafevents_conf)
            logging.info("Removed conf/seafevents.conf (CE must not load Pro seafevents).")
        except Exception as e:
            logging.warning(f"Could not remove conf/seafevents.conf: {e}")
    # Ensure ccnet/seafile.ini exists and points to seafile-data (some tools expect it)
    ccnet_dir = os.path.join(appdir, "ccnet")
    want_data = read_seafile_data_dir(ccnet_dir)
    if not want_data:
        want_data = os.path.join(appdir, "seafile-data")
        logging.warning(f"Missing ccnet/seafile.ini; creating it to point at {want_data}")
        write_seafile_data_dir(ccnet_dir, want_data)
    if not os.path.isabs(want_data):
        want_data = os.path.abspath(os.path.join(appdir, want_data))
    os.makedirs(want_data, exist_ok=True)
    # --- Python virtualenv and requirements ---
    venv_dir, vpy, vpip = ensure_virtualenv(appdir)
    # Install required Python packages (from official CE 12.0 manual). 
    # You can comment out individual packages below for debugging if needed.
    # Core, needed to boot Seahub
    pip_packages = [
        "django==4.2.*",
        "future==1.0.*",
        "mysqlclient==2.2.*",   # keep: we use MySQL/MariaDB
        "pymysql",              # keep as fallback client
        "pillow==10.4.*",
        "captcha==0.6.*",
        "markupsafe==2.0.1",
        "jinja2",
        "sqlalchemy==2.0.*",
        "pycryptodome==3.20.*",
        "cffi==1.17.0",
        "lxml",
        # Optional / commonly-problematic on shared hosts:
        # "python-ldap==3.4.*",   # LDAP/AD auth; needs OpenLDAP & SASL headers. Commented out to avoid build failure.
        # "pylibmc",              # memcached client; needs libmemcached at runtime. Optional.
        # "django-pylibmc",       # Django binding; optional.
        # "djangosaml2==1.9.*",   # SAML SSO; optional.
        # "pysaml2==7.3.*",       # SAML dependency; optional.
        # "psd-tools",            # PSD previews; drags in numpy/scipy/scikit-image. Optional.
        # "gevent==24.2.*",       # Only needed if using gevent workers; we use default sync workers.
    ]
    # Stream pip installation for verbose output
    stream_command([vpip, "install", "--no-cache-dir", "--verbose"] + pip_packages)
    # Gunicorn config: bind to the main app port
    gunicorn_conf = os.path.join(conf_dir, "gunicorn.conf.py")
    gunicorn_cfg = (
        "bind = '0.0.0.0:{port}'\n"
        "workers = 2\n"
        "timeout = 120\n"
        "accesslog = '-'\n"
        "errorlog = '-'\n"
    ).format(port=int(main_app_port))
    create_file(gunicorn_conf, gunicorn_cfg, 0o600)
    # Wrapper scripts to start/stop Seafile and Seahub
    start_sh = f"""#!/bin/bash
set -e
export LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
. {shlex.quote(os.path.join(venv_dir, "bin", "activate"))}
export SEAFILE_CENTRAL_CONF_DIR={shlex.quote(conf_dir)}
export GUNICORN_CONF="$SEAFILE_CENTRAL_CONF_DIR/gunicorn.conf.py"
cd {shlex.quote(seafile_root)}
# Load .env to environment (optional for CLI tools)
if [ -f "$SEAFILE_CENTRAL_CONF_DIR/.env" ]; then
  set -a; . "$SEAFILE_CENTRAL_CONF_DIR/.env"; set +a
fi
export SEAHUB_PORT={int(main_app_port)}
./seafile.sh start
./seahub.sh start "$SEAHUB_PORT"
"""
    stop_sh = f"""#!/bin/bash
cd {shlex.quote(seafile_root)}
./seahub.sh stop || true
./seafile.sh stop || true
"""
    create_file(os.path.join(appdir, "start.sh"), start_sh, 0o700)
    create_file(os.path.join(appdir, "stop.sh"),  stop_sh,  0o700)
    # README
    readme_tpl = Template("""Seafile Community Edition v$seafile_version installed (venv; base requirements)
Ports:
  Seahub (main app/gunicorn): $main_app_port
  seafhttp (CUS app):         $cus_app_port
Python:
  Virtualenv: $venv_dir
Paths:
  install root:  $seafile_root
  conf:          $conf_dir
  ccnet:         $ccnet_dir
  seafile-data:  $data_dir
Domain/URL:
  Base URL: http://$server_domain
Database (MariaDB):
  host:     $mysql_host
  port:     $mysql_port
  user:     $db_user
  password: $db_pass
  databases: $ccnet_db, $seafile_db, $seahub_db
Start/Stop:
  Use $start_sh and $stop_sh to manage the server.
Initial admin:
  Installer auto-creates an admin. See conf/initial_admin.txt
Notes:
  - This follows CE 12.0 MySQL flow (setup-seafile-mysql.sh auto; 3 DBs).
  - conf/.env includes JWT_PRIVATE_KEY (required by 12.x).
  - seafevents (Pro) is disabled for CE.
""")
    readme = readme_tpl.substitute(
        seafile_version=args.seafile_version,
        main_app_port=main_app_port,
        cus_app_port=cus_app_port,
        venv_dir=os.path.join(appdir, "venv"),
        seafile_root=seafile_root,
        conf_dir=conf_dir,
        ccnet_dir=ccnet_dir,
        data_dir=want_data,
        server_domain=server_domain,
        mysql_host=args.mysql_host,
        mysql_port=args.mysql_port,
        db_user=db_user_real,
        db_pass=db_user_pass,
        ccnet_db=ccnet_db,
        seafile_db=seafile_db,
        seahub_db=seahub_db,
        start_sh=os.path.join(appdir, "start.sh"),
        stop_sh=os.path.join(appdir, "stop.sh"),
    )
    create_file(os.path.join(appdir, "README_seafile.txt"), readme, 0o644)
    # --------- Migrations & admin setup (using venv) ----------
    server_latest = os.path.join(appdir, "seafile-server-latest")
    seahub_sh = os.path.join(server_latest, "seahub.sh")  # (may not be used directly below, but kept for reference)
    # Prepare environment variables for Django manage.py commands
    env_vars = dict(CMD_ENV)
    env_vars.update({
        "SEAFILE_CENTRAL_CONF_DIR": conf_dir,
        "CCNET_CONF_DIR": ccnet_dir,
        "SEAFILE_CONF_DIR": want_data,
        "LANG": "en_US.UTF-8",
        "LC_ALL": "en_US.UTF-8"
    })
    # Mark app as installed and send a completion notice (best-effort)
    try:
        api.post("/app/installed/", [{"id": args.app_uuid}])
        api.post("/notice/create/", [{
            "type": "D",
            "content": f"Seafile app {appinfo['name']} ready. Seahub port {main_app_port}, seafhttp port {cus_app_port}. Admin in conf/initial_admin.txt"
        }])
    except SystemExit:
        raise
    except Exception as e:
        logging.error(f"Failed to post install completion/notice: {e}")
    logging.info("Seafile installation complete")
    print("INSTALLATION OK")
if __name__ == "__main__":
    try:
        main()
    except SystemExit:
        raise
    except Exception as e:
        logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s", force=True)
        logging.exception(f"Unhandled error: {e}")
        sys.exit(1)