#!/bin/sh # The Lyriks bootstrap, served at https://get.lyriks.io. Run it as: # # curl -fsSL https://get.lyriks.io | sh -s -- community --license-key lyk_... # # Windows runs the sibling script from PowerShell instead (docs 22): # # irm https://get.lyriks.io/ps | iex # # Why this exists: the appliance kit is distributed as an OCI image, so Docker # must already work before the kit (and its own `./lyriks doctor`) can even # reach the host. Everything that must happen BEFORE that point lives here: # # architecture check -> OS check (WSL1 refused, Git Bash redirected to # install.ps1) -> Docker present or offered -> Compose v2 present or offered # -> kit pulled -> ./lyriks doctor -> ./lyriks install -> ./lyriks smoke # # Contract: everything is CHECKED silently, nothing is INSTALLED silently. # Every missing dependency is proposed and needs a yes (or --yes up front). # POSIX sh on purpose: `curl | sh` lands in dash on Debian/Ubuntu. Re-running # is safe: every step reconciles, exactly like `./lyriks install` itself. set -eu EDITION=community LICENSE_KEY="${LYRIKS_LICENSE_KEY:-}" HTTP_PORT="${LYRIKS_PORT:-3000}" ORIGIN="" # Accepted for compatibility with commands people already have written down; # nothing is done with it. See section 5. ADMIN_EMAIL="" WORKSPACE_NAME="" REGISTRY_HOST="${LYRIKS_REGISTRY_HOST:-registry.lyriks.io}" LYRIKS_HOME="${LYRIKS_HOME:-$HOME/lyriks}" ASSUME_YES=0 REINSTALL=0 DRY_RUN=0 FROM_WINDOWS=0 usage() { cat <<'EOF' Lyriks bootstrap curl -fsSL https://get.lyriks.io | sh -s -- [community|enterprise] [options] Options: --license-key Activate during the install (community may also paste the key later at /activate) --port Local HTTP port (default 3000) --origin Public origin for a server install (default http://localhost:) --admin-email Accepted and ignored, kept so older commands and scripts still run: no account is created at install any more (the first visitor claims the install with their licence key) --workspace-name Name the first workspace --yes Assume consent for every proposed install --reinstall Destroy an existing install first (stack AND data), then install fresh; without it an existing install is repaired in place --dry-run Print every state-changing command instead of running it EOF } while [ $# -gt 0 ]; do case "$1" in community|enterprise) EDITION="$1" ;; --license-key) shift; LICENSE_KEY="${1:-}" ;; --port) shift; HTTP_PORT="${1:-}" ;; --origin) shift; ORIGIN="${1:-}" ;; --admin-email) shift; ADMIN_EMAIL="${1:-}" ;; --workspace-name) shift; WORKSPACE_NAME="${1:-}" ;; --yes|-y) ASSUME_YES=1 ;; --reinstall) REINSTALL=1 ;; --dry-run) DRY_RUN=1 ;; # Internal, passed by install.ps1: the MCP client hint must come from the # OS the AI client runs on (Windows), not from inside WSL where this # script executes; the PowerShell side owns that step there. --from-windows) FROM_WINDOWS=1 ;; -h|--help) usage; exit 0 ;; *) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 2 ;; esac shift done say() { printf '\033[36m>\033[0m %s\n' "$1"; } ok() { printf '\033[32mOK\033[0m %s\n' "$1"; } die() { printf '\033[31mERROR: %s\033[0m\n' "$1" >&2; exit 1; } # Consent gate for anything this script would install or start. `curl | sh` # leaves stdin holding the script itself, so prompts must go through the # terminal; without one (CI, provisioning), --yes is the only way to consent. confirm() { [ "$ASSUME_YES" -eq 1 ] && { say "$1 (consented via --yes)"; return 0; } if [ -r /dev/tty ]; then printf '%s [Y/n] ' "$1" > /dev/tty answer="" read -r answer < /dev/tty || answer="" case "$answer" in n*|N*) return 1 ;; *) return 0 ;; esac fi die "$2. No terminal to ask on: re-run with --yes to consent, or fix it yourself and re-run." } # Every state-changing command funnels through here so --dry-run can print the # exact plan without touching the host. Read-only probes run directly. run() { if [ "$DRY_RUN" -eq 1 ]; then printf '\033[33mdry-run:\033[0m %s\n' "$1"; return 0; fi sh -c "$1" } # Administrator rights. Every step that needs them goes through $SUDO: empty # for root (a root shell may not even have sudo installed), `sudo` otherwise, # which asks for the password on the terminal by itself. So nothing here has # to be prefixed with sudo by hand, and `curl | sudo sh` is not the way to run # this: Lyriks would land in root's home and the AI clients wired at the end # would be root's. Installs were being hand-patched with sudo in front of # docker; that situation (daemon up, user not allowed on its socket) is # handled in section 3 instead. SUDO=sudo; NO_ROOT=0 if [ "$(id -u)" -eq 0 ]; then SUDO="" if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != root ]; then say "Running as root (through sudo, from $SUDO_USER): Lyriks will live in $LYRIKS_HOME and belong to root. To have it in your own account, run this command as $SUDO_USER without sudo: it asks for the password itself where needed" fi elif ! command -v sudo >/dev/null 2>&1; then SUDO=""; NO_ROOT=1 fi # Before any step that needs administrator rights: name the step, die early # and clearly when neither sudo nor root is at hand. need_root() { [ "$NO_ROOT" -eq 0 ] || die "$1 needs administrator rights, and this shell has neither sudo nor root: install sudo (or run this command as root), then re-run" } # ── validate the request before touching anything ─────────────────────────── case "$HTTP_PORT" in ''|*[!0-9]*) die "--port needs a port number" ;; esac if [ -n "$LICENSE_KEY" ]; then case "$LICENSE_KEY" in lyk_*) ;; *) die "--license-key needs a lyk_... key (it is in your install pack)" ;; esac fi [ -n "$ORIGIN" ] || ORIGIN="http://localhost:$HTTP_PORT" case "$ORIGIN" in http://*|https://*) ;; *) die "--origin must start with http:// or https://" ;; esac case "$ORIGIN" in *[[:space:]]*) die "--origin must not contain whitespace" ;; esac # ── 1 · operating system ──────────────────────────────────────────────────── case "$(uname -s)" in Darwin) PLATFORM=macos ;; Linux) if grep -qi microsoft /proc/version 2>/dev/null; then case "$(uname -r)" in *microsoft-standard*|*WSL2*) PLATFORM=wsl2 ;; # WSL1 has no real Linux kernel, so no Docker daemon can ever run in # it; upgrading the distribution is the only fix, and it is one command. *) die "This is WSL1, which cannot run Docker. From PowerShell: wsl --set-version 2, then re-run" ;; esac else PLATFORM=linux fi ;; MINGW*|MSYS*|CYGWIN*) die "Use the Windows bootstrap from PowerShell instead: irm https://get.lyriks.io/ps | iex" ;; *) die "Unsupported OS: $(uname -s). Linux, macOS, or Windows via install.ps1" ;; esac # ── 2 · architecture ──────────────────────────────────────────────────────── ARCH="$(uname -m)" case "$ARCH" in x86_64|amd64) ;; arm64|aarch64) # Lyriks ships linux/amd64 images only. Docker Desktop on Apple Silicon # runs them through Rosetta, which works but is slower and not part of the # validated matrix; anywhere else there is no emulation story worth having. [ "$PLATFORM" = "macos" ] || die "Lyriks ships linux/amd64 images and this machine is $ARCH; arm64 is only supported on macOS (via Rosetta)" confirm "This Mac has an Apple Silicon chip, so Lyriks will run through Rosetta, a translation layer built into your Mac. Everything works the same, just a bit slower. Continue?" \ "This is an Apple Silicon Mac and emulation was not consented to" # Docker on this Mac asks for arm64 images by default and Lyriks has none, # so every pull would die with "no matching manifest". This one variable # makes docker AND compose request the amd64 images instead, for the whole # run: the kit fetch below and the ./lyriks calls after it. The kit CLI # exports it too, so day-2 commands typed without this bootstrap # (./lyriks update ...) keep working. export DOCKER_DEFAULT_PLATFORM=linux/amd64 ;; *) die "Unsupported architecture: $ARCH (Lyriks ships linux/amd64 images)" ;; esac ok "Machine: $ARCH on $PLATFORM" # ── 3 · Docker ────────────────────────────────────────────────────────────── # SKIP_PROBES: in --dry-run a proposed Docker install did not actually happen, # so later daemon probes would fail on a truth the plan already changed. The # plan keeps printing; the probes stand down. SKIP_PROBES=0 # DESKTOP_FRESH: Docker Desktop was installed by this very run, so opening it # below needs no second question and its first launch deserves a longer wait. DESKTOP_FRESH=0 # Docker Desktop straight from Docker's .dmg, the way Docker documents for the # command line (docs.docker.com/desktop/setup/install/mac-install, "Install # from the command line"): download, mount, run the installer the image # carries, unmount. --accept-license takes the Docker Subscription Service # Agreement here instead of at the first launch; --user does the privileged # setup now, so that launch does not ask for the password a second time. The # privileged commands go through $SUDO, which asks on the terminal by itself, # so this works under `curl | sh` too. $1 is the .dmg URL, $2 names the # version for the question. install_docker_desktop_dmg() { dmg_url="$1"; dmg_label="$2" confirm "Lyriks runs inside a free app called Docker Desktop, which is not installed yet. Download, install and open it now ($dmg_label, about 600 MB)? It will ask for your Mac password, and accepts Docker's terms for you (Docker Desktop is free for personal use and for companies under 250 people and 10 million dollars a year, see docker.com/pricing)" "Docker is missing" dmg_dir="${TMPDIR:-/tmp}/lyriks-docker-desktop" dmg_file="$dmg_dir/Docker.dmg" # The download lands under a .part name and is renamed once complete, so a # re-run resumes a cut one and reuses a finished one instead of fetching # 600 MB again; the directory goes away once the install succeeded. if [ -f "$dmg_file" ]; then say "Reusing the Docker Desktop download from a previous run ($dmg_file)" else say "Downloading Docker Desktop ..." run "mkdir -p '$dmg_dir' && curl -fL -C - --progress-bar -o '$dmg_file.part' '$dmg_url' && mv '$dmg_file.part' '$dmg_file'" \ || die "The Docker Desktop download did not complete: check the connection, then re-run this command (it resumes where it stopped)" fi say "Installing Docker Desktop (the password it asks for is your Mac login, for Docker's own installer) ..." # A "Docker" volume left mounted by an earlier attempt would push this one # to "Docker 1", and the path below would miss it. if [ -d /Volumes/Docker ]; then run "${SUDO:+$SUDO }hdiutil detach -quiet /Volumes/Docker || true"; fi run "${SUDO:+$SUDO }hdiutil attach -nobrowse -quiet '$dmg_file'" \ || die "Could not open the downloaded Docker.dmg: delete $dmg_file, then re-run this command to download it again" if ! run "${SUDO:+$SUDO }/Volumes/Docker/Docker.app/Contents/MacOS/install --accept-license --user='$(id -un)'"; then run "${SUDO:+$SUDO }hdiutil detach -quiet /Volumes/Docker || true" die "Docker's installer did not finish (its messages are above): re-run this command, or install the .dmg by hand from $dmg_url and re-run" fi run "${SUDO:+$SUDO }hdiutil detach -quiet /Volumes/Docker || true" run "rm -rf '$dmg_dir'" # The installer links docker into /usr/local/bin, which this shell may not # see yet: the copy inside the app serves the rest of this run. PATH="/Applications/Docker.app/Contents/Resources/bin:$PATH"; export PATH DESKTOP_FRESH=1 ok "Docker Desktop installed" } if ! command -v docker >/dev/null 2>&1; then case "$PLATFORM" in macos) # Docker Desktop needs macOS 14 (Sonoma) or newer, but Docker froze one # last build per macOS it dropped and still hosts those .dmg files, so # these numbers never change: 4.48 (build 207573) for Ventura, 4.41.2 # (build 191736) for Monterey. Both confirmed on real Intel Macs # (2026-09-04), and the bundles say why the split is where it is: their # Info.plist declares LSMinimumSystemVersion 13.0 for 4.48 and 11.0 for # 4.41.2 (read from the .dmg files on 2026-09-05), so macOS 12 refuses # to launch 4.48 at all. Monterey matters because that is where Macs # that cannot upgrade any further stop (a 2015 MacBook Pro tops out # there), so "upgrade macOS" is not always an answer and the frozen # build has to be installed, here, by this script: a link and a human # were the previous answer and that is where installs stalled. # Homebrew's cask refuses those systems with a confusing "try MacPorts" # error, so the version check comes before brew. Below Monterey no # frozen build is worth it. MACOS_MAJOR="$(sw_vers -productVersion 2>/dev/null | cut -d. -f1)" case "$ARCH" in arm64|aarch64) dmg_arch=arm64 ;; *) dmg_arch=amd64 ;; esac if [ "${MACOS_MAJOR:-14}" -lt 12 ] 2>/dev/null; then die "This Mac runs macOS $MACOS_MAJOR, which is too old for any Docker Desktop that can run Lyriks: upgrade macOS (or use another machine), then re-run this command" elif [ "${MACOS_MAJOR:-14}" -lt 14 ] 2>/dev/null; then if [ "$MACOS_MAJOR" -eq 12 ] 2>/dev/null; then dmg_name=Monterey; dmg_docker=4.41.2; dmg_build=191736 else dmg_name=Ventura; dmg_docker=4.48; dmg_build=207573 fi say "This Mac runs macOS $MACOS_MAJOR ($dmg_name). The current Docker Desktop no longer runs there, but Docker still hosts the last version that does ($dmg_docker), and Lyriks works with it." install_docker_desktop_dmg "https://desktop.docker.com/mac/main/$dmg_arch/$dmg_build/Docker.dmg" "version $dmg_docker, the last one for $dmg_name" say "Docker Desktop will offer updates it cannot apply on this macOS: decline them (or upgrade this Mac to macOS 14 or newer first, if it can; Macs from 2018 on can)" elif command -v brew >/dev/null 2>&1; then # Homebrew keeps Docker Desktop updated afterwards, so it wins when it # is there; without it, Docker's own .dmg route needs no human either. confirm "Lyriks runs inside a free app called Docker Desktop, which is not installed yet. Install it now?" "Docker is missing" run "brew install --cask docker" else install_docker_desktop_dmg "https://desktop.docker.com/mac/main/$dmg_arch/Docker.dmg" "current version" fi ;; wsl2) say "Docker is not visible inside WSL. Two ways to get it:" say " 1. Docker Desktop on Windows, then Settings > Resources > WSL integration for this distribution (recommended)" say " 2. Docker Engine directly inside this distribution (what this script can do now)" need_root "Installing Docker" confirm "Install Docker here now? It uses Docker's official install script and will ask for your password." "Docker is missing" run "curl -fsSL https://get.docker.com | ${SUDO:+$SUDO }sh" run "${SUDO:+$SUDO }usermod -aG docker \"${USER:-$(id -un)}\"" ;; linux) # Docker's script escalates by itself when it can, but its documented # form is `sudo sh get-docker.sh`, and an explicit sudo here is the # one that asks for the password up front, where the question above # announced it. need_root "Installing Docker" confirm "Lyriks needs Docker, which is not installed yet. Install it now? It uses Docker's official install script and will ask for your password." "Docker is missing" run "curl -fsSL https://get.docker.com | ${SUDO:+$SUDO }sh" run "${SUDO:+$SUDO }usermod -aG docker \"${USER:-$(id -un)}\"" ;; esac if ! command -v docker >/dev/null 2>&1; then [ "$DRY_RUN" -eq 1 ] || die "Docker is still not on PATH; open a new shell and re-run this command" SKIP_PROBES=1 fi fi # How to reach the daemon. A user freshly added to the docker group does not # hold it in this shell (membership is read at login), so `sg docker` bridges # exactly this run; from the next login plain docker works. Where sg cannot # do it, sudo in front of docker is the fallback (what people were patching # in by hand). Commands that need the daemon therefore go through as_docker(). DOCKER_VIA="" as_docker() { case "$DOCKER_VIA" in sg) run "sg docker -c \"$1\"" ;; sudo) run "${SUDO:+$SUDO }sh -c \"$1\"" ;; *) run "$1" ;; esac } start_daemon_hint() { if command -v systemctl >/dev/null 2>&1; then printf '%ssystemctl enable --now docker' "${SUDO:+$SUDO }" else printf '%sservice docker start' "${SUDO:+$SUDO }" fi } if [ "$SKIP_PROBES" -eq 0 ]; then if docker info >/dev/null 2>&1; then ok "Docker daemon reachable" elif [ "$DRY_RUN" -eq 1 ]; then # A plan preview must never die on fixable state: the real run diagnoses # (group membership, stopped daemon, Desktop integration) and proposes the # fix at this exact point. say "dry-run: the Docker daemon is not reachable right now; the real run diagnoses and proposes the fix here" SKIP_PROBES=1 elif [ "$PLATFORM" = "macos" ]; then # On macOS the daemon lives inside Docker Desktop: not running is the only # failure mode this probe can hit, and starting the app is the fix. Right # after an install by this run the consent to open it was already given, # and a first launch builds Docker's Linux VM, which takes minutes on an # older Mac. A freshly copied app may not be known to Launch Services yet, # hence the fallback on its path. DESKTOP_WAIT=120 if [ "$DESKTOP_FRESH" -eq 1 ]; then say "Opening Docker Desktop for the first time ..." DESKTOP_WAIT=300 else confirm "Docker Desktop, the app Lyriks runs in, is not open. Open it now?" "Docker daemon is not reachable" fi run "open -a Docker 2>/dev/null || open /Applications/Docker.app" if [ "$DRY_RUN" -eq 0 ]; then say "Waiting for Docker Desktop (a first launch shows a welcome screen you can close; the whale in the menu bar settles when it is ready) ..." waited=0 until docker info >/dev/null 2>&1; do [ "$waited" -ge "$DESKTOP_WAIT" ] && die "Docker Desktop did not come up in ${DESKTOP_WAIT}s: finish its first-run dialog, wait for the whale icon to settle, then re-run this command" sleep 3; waited=$((waited + 3)) done ok "Docker daemon reachable" fi elif docker info 2>&1 | grep -qi 'permission denied'; then # The daemon is up and answers root; this user is not allowed on its # socket. This is the case installs were hand-patched for, with sudo in # front of every docker. Docker's own answer is the docker group, and # `sg docker` bridges this run; where sg cannot (no sg, an odd socket # group), sudo in front of docker is the fallback, for this run only. me="${USER:-$(id -un)}" if ! getent group docker 2>/dev/null | grep -qw "$me"; then need_root "Giving this user access to Docker" confirm "Docker is running, but your user is not allowed to use it yet. Add you to the docker group now? It may ask for your password." "This user cannot use Docker" run "${SUDO:+$SUDO }usermod -aG docker '$me'" fi if sg docker -c "docker info" >/dev/null 2>&1; then DOCKER_VIA=sg ok "Docker reachable through 'sg docker' (plain docker works from your next login)" else need_root "Using Docker" say "Docker will be driven through sudo for this run (it may ask for your password); plain docker works from your next login" ${SUDO:+$SUDO }docker info >/dev/null 2>&1 || die "Docker daemon not reachable even with administrator rights: look at 'docker info', then re-run" DOCKER_VIA=sudo ok "Docker reachable through sudo" fi elif getent group docker 2>/dev/null | grep -qw "${USER:-$(id -un)}" && sg docker -c "docker info" >/dev/null 2>&1; then DOCKER_VIA=sg ok "Docker reachable through 'sg docker' (plain docker works from your next login)" elif [ "$PLATFORM" = "wsl2" ] && ! docker info >/dev/null 2>&1 && command -v docker.exe >/dev/null 2>&1; then die "Docker Desktop is on Windows but not wired into WSL: enable Settings > Resources > WSL integration for this distribution, Apply & restart, re-run" else confirm "Docker is installed but not running. Start it now? It may ask for your password." "Docker daemon is not reachable" run "$(start_daemon_hint)" if [ "$DRY_RUN" -eq 0 ] && ! docker info >/dev/null 2>&1 && ! { getent group docker 2>/dev/null | grep -qw "${USER:-$(id -un)}" && sg docker -c "docker info" >/dev/null 2>&1; }; then die "Docker daemon still not reachable. If you were just added to the docker group: log out and back in, then re-run" fi getent group docker 2>/dev/null | grep -qw "${USER:-$(id -un)}" && ! docker info >/dev/null 2>&1 && DOCKER_VIA=sg fi fi # ── 4 · Compose v2 and tar ────────────────────────────────────────────────── if [ "$SKIP_PROBES" -eq 0 ] && ! docker compose version >/dev/null 2>&1; then if [ "$PLATFORM" = "macos" ]; then die "Docker Desktop is present but 'docker compose' is not: update Docker Desktop (Compose v2 ships with it), then re-run" elif command -v apt-get >/dev/null 2>&1; then confirm "One piece of Docker is missing (the Compose plugin). Install it now? It will ask for your password." "Compose v2 is missing" need_root "Installing the Compose plugin" run "${SUDO:+$SUDO }apt-get update && ${SUDO:+$SUDO }apt-get install -y docker-compose-plugin" elif command -v dnf >/dev/null 2>&1; then confirm "One piece of Docker is missing (the Compose plugin). Install it now? It will ask for your password." "Compose v2 is missing" need_root "Installing the Compose plugin" run "${SUDO:+$SUDO }dnf install -y docker-compose-plugin" else die "Docker Compose v2 is missing and no known package manager was found: install the docker-compose-plugin for your distribution, then re-run" fi fi command -v tar >/dev/null 2>&1 || die "tar is required to unpack the kit: install it from your distribution, then re-run" # ── 5 · licence (interactive last chance) ─────────────────────────────────── # No account and no email are collected here any more. The appliance installs # with no identity at all: the first person to open it proves the licence key # was issued to their address and chooses their own password. An installer that # asks for nothing has nothing to mistype, nothing to print, and nothing to leak. if [ -z "$LICENSE_KEY" ] && [ "$ASSUME_YES" -eq 0 ] && [ "$DRY_RUN" -eq 0 ] && [ -r /dev/tty ]; then printf 'Your licence key, if you already have one (starts with lyk_; press Enter to skip, you can add it later in the app): ' > /dev/tty read -r LICENSE_KEY < /dev/tty || LICENSE_KEY="" if [ -n "$LICENSE_KEY" ]; then case "$LICENSE_KEY" in lyk_*) ;; *) die "That does not look like a licence key (lyk_...)" ;; esac fi fi # ── 6 · fetch the kit, install, smoke ─────────────────────────────────────── if [ "$EDITION" = "community" ]; then KIT_PROJECT=community; else KIT_PROJECT=enterprise; fi KIT_IMAGE="$REGISTRY_HOST/$KIT_PROJECT/appliance-kit:stable" # Outbound HTTPS to the registry is the one network dependency; probed here, a # blocked proxy or captive portal gets named instead of surfacing later as a # pull error that reads like a credentials problem. Any HTTP status proves # connectivity, so no -f: /v2/ answers 401 to anonymous callers by design. if [ "$DRY_RUN" -eq 0 ] && command -v curl >/dev/null 2>&1; then if curl -sSI --max-time 10 "https://$REGISTRY_HOST/v2/" >/dev/null 2>&1; then ok "Registry reachable ($REGISTRY_HOST)" else die "Cannot reach https://$REGISTRY_HOST over HTTPS: check your network or proxy, then re-run" fi fi # The kit directory must be this user's. A previous run driven with sudo by # hand leaves it root-owned, and a location outside the home (/opt/lyriks) # needs root to be created: both are settled here, once, instead of failing # later inside tar with a "permission denied" that reads like a bug. me="${USER:-$(id -un)}" if [ -d "$LYRIKS_HOME" ] && [ ! -w "$LYRIKS_HOME" ]; then need_root "Repairing the ownership of $LYRIKS_HOME" confirm "$LYRIKS_HOME exists but belongs to another user (a previous run with sudo?). Make it yours again? It may ask for your password." "The kit directory $LYRIKS_HOME is not writable" run "${SUDO:+$SUDO }chown -R '$me' '$LYRIKS_HOME'" elif [ ! -d "$LYRIKS_HOME" ] && [ ! -w "$(dirname "$LYRIKS_HOME")" ]; then need_root "Creating $LYRIKS_HOME" run "${SUDO:+$SUDO }mkdir -p '$LYRIKS_HOME' && ${SUDO:+$SUDO }chown '$me' '$LYRIKS_HOME'" fi # Re-running on an existing install is the supported repair path, but when a # human is present it must be a CHOICE: an operator wanting a clean slate used # to need to know './lyriks destroy --confirm' by heart, and the silent # reconcile read as "the installer ignored my broken install". Continue stays # the default everywhere (Enter, --yes, no terminal); destroying data takes an # explicit R here or an explicit --reinstall flag. if [ -f "$LYRIKS_HOME/.env" ]; then if [ "$REINSTALL" -eq 0 ] && [ "$ASSUME_YES" -eq 0 ] && [ "$DRY_RUN" -eq 0 ] && [ -r /dev/tty ]; then printf 'An install already exists in %s. [C]ontinue with it (repair/update, data preserved) or [R]einstall from scratch (DELETES its data)? [C/r] ' "$LYRIKS_HOME" > /dev/tty answer="" read -r answer < /dev/tty || answer="" case "$answer" in r*|R*) REINSTALL=1 ;; esac fi if [ "$REINSTALL" -eq 1 ]; then say "Reinstalling from scratch: destroying the existing stack and data in $LYRIKS_HOME" if [ -x "$LYRIKS_HOME/lyriks" ]; then as_docker "cd '$LYRIKS_HOME' && ./lyriks destroy --confirm" || die "Could not destroy the existing install; fix the messages above, then re-run" fi # A fresh install must not inherit the old secrets or answers; the kit # regenerates .env from its template on the next install step. run "rm -f '$LYRIKS_HOME/.env'" else say "Existing install detected in $LYRIKS_HOME: reconciling it (data lives in Docker volumes and is preserved)" fi fi say "Fetching the appliance kit into $LYRIKS_HOME ..." # Single quotes on purpose: these strings survive both execution paths (plain # `sh -c` and the `sg docker -c "..."` wrapper, where a double quote would # close the wrapper's own quoting). run "mkdir -p '$LYRIKS_HOME'" # Pull and extract are two commands on purpose. In `docker run | tar` the pipe # reports tar's status, not docker's (POSIX sh has no pipefail), and macOS tar # accepts empty input, so a failed pull sailed through here and only surfaced # later as "./lyriks: No such file or directory". Seen live on the first # Apple Silicon install. A separate pull owns its exit status. if ! as_docker "docker pull $KIT_IMAGE"; then if [ "$EDITION" = "enterprise" ]; then die "Could not pull the kit. Enterprise images need the registry login from your pack (docker login $REGISTRY_HOST -u 'robot\$'), then re-run" fi die "Could not pull the kit from $KIT_IMAGE: check outbound HTTPS to $REGISTRY_HOST, then re-run" fi if ! as_docker "docker run --rm $KIT_IMAGE | tar -x -C '$LYRIKS_HOME'"; then die "Could not unpack the kit into $LYRIKS_HOME: check the messages above, then re-run" fi # The extraction pipe above has the same blind spot as the old one, so prove # the outcome instead: the kit's entry point must exist before it is invoked. if [ "$DRY_RUN" -eq 0 ] && [ ! -x "$LYRIKS_HOME/lyriks" ]; then die "The kit did not unpack into $LYRIKS_HOME (no ./lyriks found): re-run this command, and report it if it happens again" fi INSTALL_ARGS="--origin $ORIGIN" if [ "$HTTP_PORT" != "3000" ]; then INSTALL_ARGS="$INSTALL_ARGS --http-port $HTTP_PORT"; fi if [ -n "$WORKSPACE_NAME" ]; then INSTALL_ARGS="$INSTALL_ARGS --workspace-name '$WORKSPACE_NAME'"; fi if [ -n "$LICENSE_KEY" ]; then INSTALL_ARGS="$INSTALL_ARGS --license-key $LICENSE_KEY"; fi # How this install came to exist, recorded with the host facts the kit reads. # A problem report from a one-line bootstrap and one typed by hand on a # prepared server are not the same story, and asking afterwards never works. INSTALL_METHOD=bootstrap [ "$FROM_WINDOWS" -eq 0 ] || INSTALL_METHOD=windows-bootstrap # The preflight checks the port the install is about to publish: without it a # host whose port 3000 is busy failed here although --port named another one. # A kit older than 2026.11-64 ignores the argument and checks 3000 as before. DOCTOR_ARGS="" if [ "$HTTP_PORT" != "3000" ]; then DOCTOR_ARGS="--http-port $HTTP_PORT"; fi as_docker "cd '$LYRIKS_HOME' && ./lyriks doctor $DOCTOR_ARGS" as_docker "cd '$LYRIKS_HOME' && LYRIKS_INSTALL_METHOD=$INSTALL_METHOD ./lyriks install $EDITION $INSTALL_ARGS" as_docker "cd '$LYRIKS_HOME' && ./lyriks smoke" # Driven through sudo, the kit steps left root-owned files in a directory # that is the user's: give them back, so that the next run and a plain # `./lyriks status` after the next login need no root. if [ "$DOCKER_VIA" = sudo ]; then run "${SUDO:+$SUDO }chown -R '$me' '$LYRIKS_HOME'"; fi # ── 7 · what now ──────────────────────────────────────────────────────────── # This block only runs when smoke passed: set -e aborts on any earlier step, # so success is never announced before the appliance proved it can be used. if [ "$DRY_RUN" -eq 1 ]; then say "dry-run complete: a real run executes the plan above" else printf '\n🚀 Lyriks is ready\n%s\n\n' "$ORIGIN" fi say "Open $ORIGIN and claim this install: your licence key, the address it was issued to, then a password you choose" if [ -z "$LICENSE_KEY" ]; then say "No licence key yet? A free Community key takes a minute: https://get.lyriks.io" say "Asked for one and it never arrived? https://get.lyriks.io/docs#nokey" fi # Offer to connect the AI clients that are ACTUALLY installed. Optional in the # strongest sense: we are past the smoke test, so Lyriks is already installed # and usable, and nothing below may change that. Hence no `confirm` here, which # dies without a terminal: an unanswerable question about an optional extra must # mean "no", never "abort a successful install". # # Skipped entirely when driven from Windows: install.ps1 wires the WINDOWS # client afterwards, and configuring the one inside the distro instead would # point the user's editor at a Lyriks their browser cannot see. if [ "$FROM_WINDOWS" -eq 0 ]; then mcp_found="" mcp_names="" command -v claude >/dev/null 2>&1 && { mcp_found="$mcp_found claude"; mcp_names="$mcp_names, Claude Code"; } command -v codex >/dev/null 2>&1 && { mcp_found="$mcp_found codex"; mcp_names="$mcp_names, OpenAI Codex"; } command -v gemini >/dev/null 2>&1 && { mcp_found="$mcp_found gemini"; mcp_names="$mcp_names, Gemini CLI"; } command -v code >/dev/null 2>&1 && { mcp_found="$mcp_found code"; mcp_names="$mcp_names, VS Code"; } mcp_names=${mcp_names#, } if [ -n "$mcp_found" ]; then wire_mcp=0 if [ "$ASSUME_YES" -eq 1 ]; then wire_mcp=1 # ASKING is the test, not `[ -r /dev/tty ]`. That test passes with no # controlling terminal (setsid, a CI runner, a service): the prompt then # fails to print, the read fails, `answer` is empty, and an empty answer on # a [Y/n] prompt means YES. The script would configure someone's editors # having never asked, which is the one thing this installer promises never # to do. So the prompt must succeed before the answer can mean anything, and # a question that cannot be asked is a NO. # `2>/dev/null` comes FIRST on purpose: redirections are applied left to # right, so silencing stderr after the failing one still lets the shell # print "cannot create /dev/tty", which reads like a broken install. elif printf 'Connect %s to Lyriks, so it can read and write your projects? [Y/n] ' "$mcp_names" 2>/dev/null > /dev/tty; then mcp_answer="" if read -r mcp_answer < /dev/tty 2>/dev/null; then case "$mcp_answer" in n*|N*) wire_mcp=0 ;; *) wire_mcp=1 ;; esac fi fi if [ "$wire_mcp" -eq 1 ]; then for client in $mcp_found; do case "$client" in claude) mcp_cmd="claude mcp add --scope user --transport http lyriks $ORIGIN/mcp" ;; codex) mcp_cmd="codex mcp add lyriks --url $ORIGIN/mcp" ;; gemini) mcp_cmd="gemini mcp add --transport http lyriks $ORIGIN/mcp" ;; code) mcp_cmd="code --add-mcp '{\"name\":\"lyriks\",\"type\":\"http\",\"url\":\"$ORIGIN/mcp\"}'" ;; esac if [ "$DRY_RUN" -eq 1 ]; then run "$mcp_cmd"; continue; fi # Registered for the MACHINE, not the current folder, so the server is # still there tomorrow in another project. Never fatal: a client that # already knows `lyriks` exits non-zero, which is not a problem. # # The OUTPUT is read, not just the exit code, because one client lies by # omission: VS Code's remote CLI (the `code` inside .vscode-server, what # a Remote-WSL or Remote-SSH session puts on PATH) answers "Ignoring # option 'add-mcp': not supported" and exits 0. Trusting the status # would print a cheerful success for a no-op, and the user would go # looking for a server that was never registered. if mcp_out=$(sh -c "$mcp_cmd" 2>&1); then case "$mcp_out" in *"Ignoring option"*|*"not supported"*) say "$client ignored it (its remote CLI cannot do this). From the desktop client, run: $mcp_cmd" ;; *) ok "$client can now reach Lyriks" ;; esac else say "$client already knows a 'lyriks' server, or refused: $mcp_cmd" fi done else say "AI client (optional): point it at $ORIGIN/mcp. Recipes per client: https://get.lyriks.io/docs#next" fi else say "AI client (optional): point any MCP-capable client at $ORIGIN/mcp. Recipes: https://get.lyriks.io/docs#next" fi fi say "Manage it: cd $LYRIKS_HOME && ./lyriks status | start | stop | update | backup create. Remove it: ./lyriks destroy --confirm" if [ -n "$DOCKER_VIA" ]; then say "Until you log out and back in, Docker commands (./lyriks included) need sudo in front: your docker group membership takes effect at your next login" fi # The first support question after every install, asked the morning after: the # answer is Docker, never Lyriks, because the containers are restart-policied. say "After a reboot: nothing to start. Lyriks comes back with Docker itself (macOS: open Docker Desktop, its whale sits in the menu bar; Linux: sudo systemctl enable --now docker). Details: https://get.lyriks.io/docs#reboot"