#!/bin/sh # Konvenient — the one line (spec 029, contracts/get-sh.md). # # curl -fsSL https://konvenient.ai/install.sh | bash # # THIS FILE IS THE SOURCE. `agentic-os/brain/scripts/build-install-script.ts` writes # `install.sh` from it, and puts the beta agreement of `src/setup/agreement.ts` in place of # its two placeholders. `site/package.sh` runs that build in the run that makes the tarball, # so the script and the release carry the same agreement. The source itself stops at the # first check. # # STDIN IS THE SCRIPT. Under `curl … | bash` the shell reads this file from stdin. So every # question takes its answer from the terminal (`< /dev/tty`), `claude auth login` runs on # the terminal, and every other program runs `< /dev/null`. The whole run is one function, # called on the last line, so a download that stops half way runs nothing. # # NO ROOT (D-000-138). It runs no `sudo` and no package tool. A missing tmux is named with # the line of the package tool that `/etc/os-release` names, printed and never run. On Linux # x86_64 the release ships a tmux, so the line is printed only when install.sh placed none # (F-534). On a Mac, install.sh runs Homebrew for tmux, with no root (spec 031 FR-021). # # ONE LINE PER STEP ON THE SCREEN (F-538). The person sees "==> Node 22", "==> Claude Code", # "==> Downloading", "==> Checked", "==> Konvenient", then the backup line, the log's name and # the browser line. What the programs print (nvm, Node, Claude's installer, curl, tar, # install.sh, update.sh) goes to ~/.local/share/konvenient/install.log, at mode 600: it holds # the sign-in link. Each run adds to the log under a dated line. A failed step prints the last # 20 lines of the log under a line that names it, then the reason. # # LINUX AND macOS (spec 031 FR-017). Step 0 accepts both kernels. Any other kernel gets one # sentence that names the Windows line. The script names no container product (FR-005). # # SETTINGS (contracts/get-sh.md § 2), each with a default: # KONVENIENT_HOME the install folder (~/konvenient) # KONVENIENT_FEED the tarball, its .sha256, its .version (https://konvenient.ai/download) # KONVENIENT_STORE passed to install.sh (embedded) # KONVENIENT_PORT, KONVENIENT_PREFIX, KONVENIENT_SKIP_PLUGIN passed through to install.sh # # EXIT CODES: 0 finished, or stopped at the agreement; 1 anything else. The last line # names the reason. KONVENIENT_HOME="${KONVENIENT_HOME:-$HOME/konvenient}" KONVENIENT_FEED="${KONVENIENT_FEED:-https://konvenient.ai/download}" KONVENIENT_STORE="${KONVENIENT_STORE:-embedded}" KONVENIENT_PORT="${KONVENIENT_PORT:-8787}" KONVENIENT_PREFIX="${KONVENIENT_PREFIX:-agentic}" # The file that names the distribution. A test points it at a fixture. OS_RELEASE="${KONVENIENT_OS_RELEASE:-/etc/os-release}" # Where nvm lives: the folder nvm's own installer picks when NVM_DIR is not set. NVM_DIR="${NVM_DIR:-${XDG_CONFIG_HOME:+$XDG_CONFIG_HOME/nvm}}" NVM_DIR="${NVM_DIR:-$HOME/.nvm}" NVM_INSTALLER='https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh' CLAUDE_INSTALLER='https://claude.ai/install.sh' CONFIG_DIR="$HOME/.config/agentic-os" LOCK="$CONFIG_DIR/get.lock" DESK="http://127.0.0.1:$KONVENIENT_PORT" # Spec 031: the prefix of a Windows program on WSL (M16), and the opener of research R7. WIN_MOUNT="${KONVENIENT_WIN_MOUNT:-/mnt/}" OPENER="$HOME/.local/bin/konvenient-open" ON_WSL=0 # The log of what the programs print (F-538). install.sh keeps its data beside it. LOG="$HOME/.local/share/konvenient/install.log" # The agreement the Enter of step 4 accepts. The build writes the version here. AGREEMENT_VERSION='beta-4' # What the run has done. `before`: nothing is changed yet, so a Ctrl-C says so. STAGE=before MODE=install WORK='' ACCEPTED_AT='' URL='' say() { printf '%s\n' "$*"; } refuse() { printf '%s\n' "$*" >&2 exit 1 } nothing_installed() { say "Nothing was installed." exit 0 } # A folder under the home folder, written with a tilde. shown() { case "$1" in "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac } # --------------------------------------------------------------------------------------- # Spec 031: the host. This file runs before the download, so it cannot source # deploy/lib/platform.sh. It carries its own copies of platform_kind, open_url, # sha256_check, svc_logs, svc_start, svc_stop and svc_unit_exists (contracts/mac.md § 1). # test/platform-sh.test.ts asserts that the text between each pair of markers equals the # text between the same markers in platform.sh. Change both files together. # --------------------------------------------------------------------------------------- # >>> platform.sh copy: platform_kind platform_kind() { case "$(uname -s 2>/dev/null)" in Darwin) echo mac return 0 ;; esac if [ -n "${KONVENIENT_WSL_MARKER:-}" ]; then if [ -e "$KONVENIENT_WSL_MARKER" ]; then echo wsl; else echo linux; fi return 0 fi if [ -n "${WSL_DISTRO_NAME:-}" ]; then echo wsl return 0 fi for _pf_f in "${KONVENIENT_WSL_BINFMT_DIR:-/proc/sys/fs/binfmt_misc}"/WSLInterop*; do if [ -e "$_pf_f" ]; then echo wsl return 0 fi done if grep -qi microsoft "${KONVENIENT_WSL_OSRELEASE:-/proc/sys/kernel/osrelease}" 2>/dev/null; then echo wsl else echo linux fi } # <<< platform.sh copy: platform_kind # >>> platform.sh copy: open_url open_url() { case "$(platform_kind)" in mac) open "$1" < /dev/null > /dev/null 2>&1 ;; wsl) [ -x "$HOME/.local/bin/konvenient-open" ] || return 1 "$HOME/.local/bin/konvenient-open" "$1" < /dev/null > /dev/null 2>&1 ;; *) if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then return 1; fi command -v xdg-open > /dev/null 2>&1 || return 1 xdg-open "$1" < /dev/null > /dev/null 2>&1 ;; esac } # <<< platform.sh copy: open_url # >>> platform.sh copy: sha256_check sha256_check() { case "$(uname -s 2>/dev/null)" in Darwin) shasum -a 256 -c "$1" ;; *) sha256sum -c "$1" ;; esac } # <<< platform.sh copy: sha256_check # >>> platform.sh copy: svc_logs svc_logs() { case "$(uname -s 2>/dev/null)" in Darwin) _pf_n=${1%.service} _pf_n=${_pf_n%.timer} tail -n "$2" "$HOME/Library/Logs/konvenient/$_pf_n.log" ;; *) case "$1" in *.service | *.timer) _pf_u=$1 ;; *) _pf_u=$1.service ;; esac journalctl --user -u "$_pf_u" -n "$2" --no-pager ;; esac } # <<< platform.sh copy: svc_logs # >>> platform.sh copy: svc_start svc_start() { case "$(uname -s 2>/dev/null)" in Darwin) _pf_n=${1%.service} _pf_n=${_pf_n%.timer} _pf_uid=$(id -u) if _pf_err=$(launchctl bootstrap "gui/$_pf_uid" "$HOME/Library/LaunchAgents/ai.konvenient.$_pf_n.plist" 2>&1); then return 0 fi if launchctl print "gui/$_pf_uid/ai.konvenient.$_pf_n" > /dev/null 2>&1; then return 0 fi printf '%s\n' "$_pf_err" >&2 return 1 ;; *) case "$1" in *.service | *.timer) _pf_u=$1 ;; *) _pf_u=$1.service ;; esac systemctl --user start "$_pf_u" ;; esac } # <<< platform.sh copy: svc_start # >>> platform.sh copy: svc_stop svc_stop() { case "$(uname -s 2>/dev/null)" in Darwin) _pf_n=${1%.service} _pf_n=${_pf_n%.timer} launchctl bootout "gui/$(id -u)/ai.konvenient.$_pf_n" ;; *) case "$1" in *.service | *.timer) _pf_u=$1 ;; *) _pf_u=$1.service ;; esac systemctl --user stop "$_pf_u" ;; esac } # <<< platform.sh copy: svc_stop # >>> platform.sh copy: svc_unit_exists svc_unit_exists() { case "$(uname -s 2>/dev/null)" in Darwin) _pf_n=${1%.service} _pf_n=${_pf_n%.timer} [ -f "$HOME/Library/LaunchAgents/ai.konvenient.$_pf_n.plist" ] ;; *) case "$1" in *.service | *.timer) _pf_u=$1 ;; *) _pf_u=$1.service ;; esac systemctl --user cat "$_pf_u" < /dev/null > /dev/null 2>&1 ;; esac } # <<< platform.sh copy: svc_unit_exists # have — 0 when the program is on the PATH. On WSL, a program under the Windows # mount is a Windows program (M16), so it counts as absent. have() { where=$(command -v "$1" 2>/dev/null) || return 1 if [ "$ON_WSL" = 1 ]; then case "$where" in "$WIN_MOUNT"*) return 1 ;; esac fi return 0 } # Step 5a, WSL only: the two-line opener of research R7 (M18). It never uses File Explorer: # File Explorer mangled a long address. write_opener() { [ "$ON_WSL" = 1 ] || return 0 mkdir -p "$HOME/.local/bin" || refuse "The folder ~/.local/bin could not be made." printf '#!/bin/sh\nexec %sc/Windows/System32/rundll32.exe url.dll,FileProtocolHandler "$1"\n' "$WIN_MOUNT" > "$OPENER" || refuse "The file $(shown "$OPENER") could not be written." chmod 0755 "$OPENER" || refuse "The file $(shown "$OPENER") could not be made executable." } # The log (F-538). It is made after the agreement, so a refusal leaves nothing behind. # --------------------------------------------------------------------------------------- start_log() { mkdir -p "$(dirname "$LOG")" || refuse "The folder $(shown "$(dirname "$LOG")") could not be made." (umask 077 && printf '\n==== %s, the one line: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$LOG") || refuse "The log $(shown "$LOG") could not be written." chmod 600 "$LOG" 2>/dev/null } # Runs a program with no input, and with what it prints in the log. logged() { "$@" < /dev/null >> "$LOG" 2>&1 } # The last 20 lines of the log, under a line that names it. The reason comes after them. show_log_tail() { [ -s "$LOG" ] || return 0 say "" say "The last 20 lines of $(shown "$LOG"):" tail -n 20 "$LOG" say "" } # A step whose program failed: the log's last lines, then the reason as the last line. fail() { show_log_tail refuse "$@" } # --------------------------------------------------------------------------------------- # The lock (step 1). It holds the pid of its owner. A lock whose pid is dead is taken over. # --------------------------------------------------------------------------------------- on_exit() { if [ "$(cat "$LOCK/pid" 2>/dev/null)" = "$$" ]; then rm -rf "$LOCK"; fi if [ -n "$WORK" ]; then rm -rf "$WORK"; fi } on_signal() { printf '\n' if [ "$STAGE" = before ]; then nothing_installed; fi refuse "The run stopped before it finished. Run the line again to go on." } take_lock() { mkdir -p "$CONFIG_DIR" || refuse "The folder $(shown "$CONFIG_DIR") could not be made." if ! mkdir "$LOCK" 2>/dev/null; then other=$(cat "$LOCK/pid" 2>/dev/null) if [ -n "$other" ] && kill -0 "$other" 2>/dev/null; then refuse "Another install is running." fi rm -rf "$LOCK" mkdir "$LOCK" 2>/dev/null || refuse "Another install is running." fi printf '%s\n' "$$" > "$LOCK/pid" trap on_exit EXIT trap on_signal INT TERM HUP } # --------------------------------------------------------------------------------------- # What is on this computer (step 3) # --------------------------------------------------------------------------------------- node_ok() { have node || return 1 v=$(node -v < /dev/null 2>/dev/null) || return 1 v=${v#v} v=${v%%.*} case "$v" in '' | *[!0-9]*) return 1 ;; esac [ "$v" -ge 22 ] } nvm_has_22() { for d in "$NVM_DIR"/versions/node/v22.*; do if [ -x "$d/bin/node" ]; then return 0; fi done return 1 } # Sets TMUX_STATE to ok, missing or old, and TMUX_VERSION to what `tmux -V` names. look_at_tmux() { TMUX_VERSION='' if ! command -v tmux >/dev/null 2>&1; then TMUX_STATE=missing return fi TMUX_VERSION=$(tmux -V < /dev/null 2>/dev/null) TMUX_VERSION=${TMUX_VERSION#tmux } v=${TMUX_VERSION#next-} major=${v%%.*} minor=$(printf '%s' "${v#*.}" | sed 's/[^0-9].*//') case "$minor" in '' | *[!0-9]*) minor=0 ;; esac case "$major" in '' | *[!0-9]*) TMUX_STATE=old ;; *) if [ "$major" -gt 3 ] || { [ "$major" -eq 3 ] && [ "$minor" -ge 4 ]; }; then TMUX_STATE=ok; else TMUX_STATE=old; fi ;; esac } # Spec 030 (contracts/install-tmux.md § 6): on x86_64 the release ships a tmux, and # install.sh places it in ~/.local/share/konvenient/bin. Elsewhere the person installs it. ships_tmux() { # Linux only: the static tmux is a Linux program, so an Intel Mac never gets it (031 H5). [ "$(uname -s < /dev/null 2>/dev/null)" = Linux ] && [ "$(uname -m < /dev/null 2>/dev/null)" = x86_64 ] } PLACED_TMUX="$HOME/.local/share/konvenient/bin/tmux" look() { NEED_NODE=0 NEED_CLAUDE=0 if ! node_ok && ! nvm_has_22; then NEED_NODE=1; fi if ! have claude; then NEED_CLAUDE=1; fi look_at_tmux say "This will install:" # On WSL the person sits at Windows and never sees these Linux folders (the ski-trip walk, # 2026-10-07), so the list says what each thing is. if [ "$ON_WSL" = 1 ]; then if [ "$NEED_NODE" = 1 ]; then say " Node 22, the program the desk runs on"; fi if [ "$NEED_CLAUDE" = 1 ]; then say " Claude Code, the agent that does the work"; fi else if [ "$NEED_NODE" = 1 ]; then say " Node 22, with nvm, into $(shown "$NVM_DIR")"; fi if [ "$NEED_CLAUDE" = 1 ]; then say " Claude Code, with Claude's own installer, into ~/.local/bin"; fi fi # Spec 030 (contracts/install-tmux.md § 6): on Linux x86_64 the release ships a tmux, and # install.sh places it. On other Linux machines the person installs it with the package # tool. On a Mac, install.sh installs it with Homebrew (spec 031 FR-021): the static tmux # is a Linux program, so an Intel Mac never gets it. if [ "$TMUX_STATE" != ok ]; then if [ "$(uname -s < /dev/null 2>/dev/null)" = Darwin ]; then if have brew; then say " tmux, with Homebrew" else say " tmux 3.4 or later (install it with Homebrew)" fi elif ships_tmux; then say " tmux, shipped with the install" else say " tmux 3.4 or later (install it with your package tool)" fi fi if [ "$ON_WSL" = 1 ]; then say " Konvenient, your desk"; else say " Konvenient into $(shown "$KONVENIENT_HOME")"; fi } # --------------------------------------------------------------------------------------- # The agreement (step 4). An empty line accepts; any other line, or Ctrl-C, stops. # --------------------------------------------------------------------------------------- agree() { say "" say "Beta agreement $AGREEMENT_VERSION" say "" cat <<'KONVENIENT_AGREEMENT' What the agent does on this machine - It reads the mailbox you connect, and it writes a short summary of each message into the database on this machine. - It proposes a reply or an action. It does not send anything until you press the button that sends it. - It runs Claude Code or Codex on this machine, in the folder it works in (~/konvenient/work unless you choose another), and it can read and change the files in that folder. - It stops at the limits you set. Money and messages never move without you. We collect nothing - Your mail, your files and your keys stay on this machine. This software sends them nowhere. - There is no account with us, no telemetry and no usage report. - The one thing that leaves this machine is the text of a message you let the agent judge, and it goes to your own AI provider under your own key or subscription. - If you send us a problem report, you build it yourself with Report a problem at the bottom of the menu, and you send it yourself. Nothing is sent for you. What you promise - You tell us what breaks. This is a beta. It still has bugs. - You read a proposal before you send it. The agent is wrong sometimes. - You keep your own backup of anything you cannot lose. There is no warranty - This software is supplied as it is. We promise no result and no uptime. - We are not liable for lost mail, lost work, lost money or a wrong reply. - You may stop at any time. Delete the folder and the data is gone with it. KONVENIENT_AGREEMENT say "" say "Press Enter to accept version $AGREEMENT_VERSION, or Ctrl-C to stop." answer='' if ! IFS= read -r answer < /dev/tty; then nothing_installed; fi if [ -n "$answer" ]; then nothing_installed; fi ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) } # --------------------------------------------------------------------------------------- # Node 22 on PATH (step 5). The PATH export runs on every path, so `claude`, the `npm` # that install.sh looks for, and the plugin step find the Node 22 of this run. # --------------------------------------------------------------------------------------- node_path() { if node_ok; then NODE_BIN=$(dirname "$(command -v node)") elif [ -s "$NVM_DIR/nvm.sh" ]; then export NVM_DIR . "$NVM_DIR/nvm.sh" nvm use 22 < /dev/null > /dev/null 2>&1 || return 1 NODE_BIN=$(dirname "$(command -v node)") else return 1 fi PATH="$HOME/.local/bin:$NODE_BIN:$PATH" export PATH } # Node 22 through nvm when the PATH has no Node 22 and nvm has none either (step 5). A # Node that is there already is never changed. step_node() { if ! node_ok && ! nvm_has_22; then say "==> Node 22" export NVM_DIR if [ ! -s "$NVM_DIR/nvm.sh" ]; then work_folder mkdir -p "$NVM_DIR" || refuse "The folder $(shown "$NVM_DIR") could not be made." logged curl -fsSL -o "$WORK/nvm-install.sh" "$NVM_INSTALLER" || fail "nvm did not download. Check the connection, then run the line again." logged bash "$WORK/nvm-install.sh" || fail "nvm did not install. The lines above name the cause." fi . "$NVM_DIR/nvm.sh" logged nvm install 22 || fail "Node 22 did not install. The lines above name the cause." fi node_path || refuse "Node 22 is not on the PATH after its install. Run the line again." } # Claude Code through Claude's own installer, into ~/.local/bin, when it is absent (step 6). step_claude() { if have claude; then return; fi say "==> Claude Code" work_folder logged curl -fsSL -o "$WORK/claude-install.sh" "$CLAUDE_INSTALLER" || fail "Claude Code did not download. Check the connection, then run the line again." logged bash "$WORK/claude-install.sh" || fail "Claude Code did not install. The lines above name the cause." have claude || refuse "Claude Code is not on the PATH after its install. Run the line again." } # The Claude sign-in, on the person's terminal, when there is none (step 7). step_signin() { if claude auth status < /dev/null > /dev/null 2>&1; then return; fi say "Your browser opens on Claude. Sign in, then paste the code it shows back here." if [ "$ON_WSL" = 1 ]; then BROWSER="$OPENER" claude auth login < /dev/tty || refuse "The Claude sign-in did not finish. Nothing was downloaded." return fi claude auth login < /dev/tty || refuse "The Claude sign-in did not finish. Nothing was downloaded." } # The person's address, for the desk's first start (the one-click install, 2026-10-02). The desk # opens with no setup page only when its answer file names an address. install.sh reads the # Claude login's ~/.claude.json, then git, then KONVENIENT_OPERATOR_EMAIL, which the Windows line # sets from the Microsoft account. When none of them names one, this asks one line here. operator_email_known() { if [ -n "${KONVENIENT_OPERATOR_EMAIL:-}" ]; then return 0; fi if [ -f "$HOME/.claude.json" ] && grep -q '"emailAddress"[[:space:]]*:[[:space:]]*"[^"]*@[^"]*"' "$HOME/.claude.json" 2>/dev/null; then return 0 fi if have git; then case "$(git config user.email < /dev/null 2>/dev/null)" in *@*) return 0 ;; esac fi return 1 } step_operator() { if [ -z "$ACCEPTED_AT" ] || operator_email_known; then return; fi tries=0 while [ "$tries" -lt 3 ]; do say "Your email address, for your name on the desk:" answer='' IFS= read -r answer < /dev/tty || return 0 answer=$(printf '%s' "$answer" | tr -d '[:space:]') case "$answer" in ?*@?*.?*) KONVENIENT_OPERATOR_EMAIL=$answer export KONVENIENT_OPERATOR_EMAIL return 0 ;; esac say "That is not an email address." tries=$((tries + 1)) done } # The package tool's line for tmux, by the ID and ID_LIKE of /etc/os-release. On a Mac: # Homebrew (spec 031). tmux_install_line() { if [ "$(uname -s < /dev/null 2>/dev/null)" = Darwin ]; then echo "brew install tmux" return 0 fi [ -r "$OS_RELEASE" ] || return 0 ids=$(sed -n -e 's/^ID=//p' -e 's/^ID_LIKE=//p' "$OS_RELEASE" | tr -d "\"'" | tr '\n' ' ') for id in $ids; do case "$id" in debian | ubuntu | linuxmint | pop | raspbian | neon) echo "sudo apt install tmux"; return 0 ;; fedora | rhel | centos | rocky | almalinux | ol | amzn) echo "sudo dnf install tmux"; return 0 ;; arch | manjaro | endeavouros | garuda) echo "sudo pacman -S tmux"; return 0 ;; opensuse* | suse | sles) echo "sudo zypper install tmux"; return 0 ;; esac done return 0 } # No sudo and no package tool (step 8, D-000-138). The line is printed, never run, and the # run goes on: the desk names a missing tmux when the first plan needs it. # # NOTHING ON x86_64 (F-534). There the release ships a tmux, and the first screen says so. A # package line here told the person to type a root command that the install does not need. # `tmux_after_install` prints the line after step 11 only when no tmux was placed. step_tmux() { if [ "$TMUX_STATE" = ok ]; then return; fi # On a Mac, install.sh runs Homebrew when it is there. Without it, FR-021's sentence. if [ "$(uname -s < /dev/null 2>/dev/null)" = Darwin ]; then if ! have brew; then say "tmux 3.4 or later is not on PATH. Install Homebrew from https://brew.sh, then run: $(tmux_install_line)" fi return fi if ships_tmux; then return; fi tmux_package_note } # Step 8 on x86_64, after step 11: the package line, when install.sh placed no tmux (a # release without one, or a copy that failed). A tmux of 3.4 or later needs no line. tmux_after_install() { if [ "$TMUX_STATE" = ok ] || ! ships_tmux || [ -x "$PLACED_TMUX" ]; then return; fi tmux_package_note } # The package tool's line for a missing or an old tmux. tmux_package_note() { line=$(tmux_install_line) if [ "$TMUX_STATE" = missing ]; then if [ -n "$line" ]; then say "tmux is not installed. Install it with your package tool: $line" else say "tmux is not installed. Install tmux 3.4 or later with your package tool." fi elif [ -n "$line" ]; then say "tmux $TMUX_VERSION is older than 3.4. Install a newer one with your package tool: $line" else say "tmux $TMUX_VERSION is older than 3.4. Install tmux 3.4 or later with your package tool." fi } # --------------------------------------------------------------------------------------- # The download (step 9) and the unpack (step 10) # --------------------------------------------------------------------------------------- work_folder() { if [ -z "$WORK" ]; then WORK=$(mktemp -d "${TMPDIR:-/tmp}/konvenient.XXXXXX") || refuse "A temporary folder could not be made." fi } fetch() { logged curl -fsSL -o "$WORK/$1" "$KONVENIENT_FEED/$1" } drop_download() { rm -f "$WORK/konvenient-latest.tar.gz" "$WORK/konvenient-latest.tar.gz.sha256" "$WORK/konvenient-latest.version" } download() { work_folder say "==> Downloading" tries=0 while :; do tries=$((tries + 1)) why=fetch if fetch konvenient-latest.tar.gz && fetch konvenient-latest.tar.gz.sha256 && fetch konvenient-latest.version; then why=sum if (cd "$WORK" && logged sha256_check konvenient-latest.tar.gz.sha256); then break; fi fi drop_download if [ "$tries" -ge 2 ]; then if [ "$why" = sum ]; then fail "The download is damaged: its SHA-256 check failed twice. Both files were deleted. Run the line again later." fi fail "The download from $KONVENIENT_FEED did not finish, twice. Check the connection, then run the line again." fi done say "==> Checked" } unpack() { partial="$KONVENIENT_HOME.partial" mkdir -p "$partial" || refuse "The folder $(shown "$partial") could not be made." if ! logged tar -xzf "$WORK/konvenient-latest.tar.gz" -C "$partial" --strip-components=1; then rm -rf "$partial" fail "The download did not unpack. Nothing was installed. Run the line again." fi if [ ! -f "$partial/agentic-os/deploy/install.sh" ]; then rm -rf "$partial" refuse "The download holds no agentic-os/deploy/install.sh. Nothing was installed." fi if ! mv "$partial" "$KONVENIENT_HOME"; then rm -rf "$partial" refuse "The folder $(shown "$KONVENIENT_HOME") could not be written. Nothing was installed." fi } # --------------------------------------------------------------------------------------- # install.sh (step 11) and the browser (step 12) # --------------------------------------------------------------------------------------- install_sh() { cd "$KONVENIENT_HOME" || return 1 if [ -n "$ACCEPTED_AT" ]; then KONVENIENT_AGREEMENT_VERSION="$AGREEMENT_VERSION" KONVENIENT_AGREEMENT_ACCEPTED_AT="$ACCEPTED_AT" \ KONVENIENT_STORE="$KONVENIENT_STORE" KONVENIENT_NO_BROWSER=1 KONVENIENT_HOME="$KONVENIENT_HOME" \ bash "$KONVENIENT_HOME/agentic-os/deploy/install.sh" < /dev/null else KONVENIENT_STORE="$KONVENIENT_STORE" KONVENIENT_NO_BROWSER=1 KONVENIENT_HOME="$KONVENIENT_HOME" \ bash "$KONVENIENT_HOME/agentic-os/deploy/install.sh" < /dev/null fi } # install.sh prints a line for each thing it does. All of it goes to the log, and # $WORK/install.log keeps this run's part, where the `link: ` line is read. run_install() { work_folder say "==> Konvenient" { install_sh 2>&1; echo $? > "$WORK/install.status"; } | tee "$WORK/install.log" >> "$LOG" code=$(cat "$WORK/install.status" 2>/dev/null) if [ "$code" != 0 ]; then # The screen gets the log's last lines. The desk's log goes into the install log after them: # when the desk did not answer, install.sh has already put that log in those lines. show_log_tail { echo "install.sh stopped. The last ten lines of the desk's log:" svc_logs "$KONVENIENT_PREFIX-brain.service" 10 < /dev/null 2>&1 } >> "$LOG" refuse "The install did not finish. The desk's address is $DESK/app/" fi URL=$(sed -n 's/^link: //p' "$WORK/install.log" | tail -n 1) if [ -z "$URL" ]; then URL="$DESK/app/"; fi # On WSL, the Windows browser reaches the desk on localhost only (M11, FR-012). if [ "$ON_WSL" = 1 ]; then URL=$(printf '%s' "$URL" | sed 's#^http://127\.0\.0\.1:#http://localhost:#'); fi } # The end of an install (F-538): the backup line between empty lines, the sentence of a # sign-in link that install.sh could not make, the log's name, and the browser line last. # The backup line is printed on every install that has the passphrase file, so a run that # finishes a stopped one still shows it. finish_install() { backup="$HOME/.config/$KONVENIENT_PREFIX/backup.env" if [ -f "$backup" ]; then say "" say "KEEP A COPY OFF THIS MACHINE. Without it no backup can be restored:" say " cat $(shown "$backup")" fi say "" say "The install log: $(shown "$LOG")" if grep -qx 'Run konvenient link for a sign-in link.' "$WORK/install.log" 2>/dev/null; then say "Run konvenient link for a sign-in link." fi open_desk } # On WSL, open_url opens through the opener, and the DISPLAY gate does not apply. open_desk() { if open_url "$URL"; then say "Your desk is open in your browser." return fi case "$URL" in */app/signin/link[?]*) say "Open this within 10 minutes: $URL" ;; *) say "Open this: $URL" ;; esac } # --------------------------------------------------------------------------------------- # The update path (contracts/get-sh.md § 6): an install and its marker. It asks nothing. # --------------------------------------------------------------------------------------- # The version the install runs: the pin in brain/.env, else the literal of its files. installed_version() { v='' if [ -f "$BRAIN/.env" ]; then v=$(sed -n 's/^APP_VERSION=\([^#[:space:]]*\).*/\1/p' "$BRAIN/.env" | tail -n 1) fi if [ -z "$v" ] && [ -f "$BRAIN/src/version.ts" ]; then v=$(sed -n "s/^const FALLBACK = '\(.*\)'\$/\1/p" "$BRAIN/src/version.ts" | head -n 1) fi printf '%s' "$v" } # Rewrites the APP_VERSION line of brain/.env in place, so the file keeps its mode. pin_version() { [ -f "$BRAIN/.env" ] || return 0 PIN="$1" awk ' /^APP_VERSION=/ { if (!done) print "APP_VERSION=" ENVIRON["PIN"]; done = 1; next } { print } END { if (!done) print "APP_VERSION=" ENVIRON["PIN"] } ' "$BRAIN/.env" > "$WORK/env.pinned" || return 1 cat "$WORK/env.pinned" > "$BRAIN/.env" } # A fingerprint of the package lock. The packages are installed again only when it changed. lock_sum() { if [ -f "$BRAIN/package-lock.json" ]; then cksum < "$BRAIN/package-lock.json"; else echo none; fi } # The files of the release that runs now, where `update.sh --back` looks for them. keep_previous() { mkdir -p "$STACK/.releases" || return 1 if ! tar -czf "$STACK/.releases/previous.tar.gz.tmp" -C "$STACK" \ --exclude=./.releases --exclude=./.env --exclude=./brain/.env \ --exclude=./brain/node_modules --exclude=./brain/data --exclude=./data . < /dev/null; then rm -f "$STACK/.releases/previous.tar.gz.tmp" return 1 fi mv -f "$STACK/.releases/previous.tar.gz.tmp" "$STACK/.releases/previous.tar.gz" && printf '%s\n' "$A" > "$STACK/.releases/previous.version" } go_back() { say " To put release $A back: bash $STACK/deploy/customer/update.sh --back" say " The backup taken before this update is in $(shown "$BACKUP_DIR")." } # The EXIT trap of the steps by hand. It replaces the trap of step 1, so it also removes # the lock. After the stop, a failed step starts the desk again, except after a failed # database change: the old files cannot be trusted on a half-changed database. on_update_exit() { case "$UPDATE_PHASE" in migrate_failed) show_log_tail say "The database change of $B did not apply, so the desk stays stopped." go_back ;; stopping | stopped) show_log_tail logged svc_start "$KONVENIENT_PREFIX-brain.service" logged svc_start "$KONVENIENT_PREFIX-watch.timer" say "The update to $B did not finish. The desk was started again." go_back ;; esac on_exit } update_by_hand() { download say "==> Backup" pass='' if [ -f "$HOME/.config/$KONVENIENT_PREFIX/backup.env" ]; then pass=$(sed -n 's/^BACKUP_PASSPHRASE=//p' "$HOME/.config/$KONVENIENT_PREFIX/backup.env" | head -n 1) fi if [ -n "$pass" ]; then BACKUP_PASSPHRASE="$pass"; export BACKUP_PASSPHRASE; fi if ! logged env BACKUP_DIR="$BACKUP_DIR" PG_CONTAINER="$KONVENIENT_PREFIX-postgres" \ bash "$STACK/deploy/customer/backup.sh"; then fail "The backup before the update failed, so release $B was not installed. Release $A still runs. The lines above name the cause." fi keep_previous || refuse "The files of release $A could not be kept for the way back, so nothing was unpacked. Release $A still runs." say "==> Stopping the desk" UPDATE_PHASE=stopping trap on_update_exit EXIT logged svc_stop "$KONVENIENT_PREFIX-watch.timer" || exit 1 logged svc_stop "$KONVENIENT_PREFIX-brain.service" || exit 1 UPDATE_PHASE=stopped before=$(lock_sum) say "==> Unpacking $B" logged tar -xzf "$WORK/konvenient-latest.tar.gz" -C "$KONVENIENT_HOME" --strip-components=1 || exit 1 if [ "$(lock_sum)" != "$before" ]; then say "==> Installing the packages of $B" logged npm --prefix "$BRAIN" ci --no-audit --no-fund || exit 1 fi say "==> Applying the database changes of $B" if ! logged npm --prefix "$BRAIN" run migrate; then UPDATE_PHASE=migrate_failed exit 1 fi # The pin comes before the start, so the desk starts with the version it runs. A step # that fails before this line leaves A pinned, and the next run tries the update again. pin_version "$B" || exit 1 say "==> Starting the desk" logged svc_start "$KONVENIENT_PREFIX-brain.service" || exit 1 logged svc_start "$KONVENIENT_PREFIX-watch.timer" || exit 1 UPDATE_PHASE=done trap on_exit EXIT } update() { STACK="$KONVENIENT_HOME/agentic-os" BRAIN="$STACK/brain" BACKUP_DIR="$HOME/backups/$KONVENIENT_PREFIX-os" UPDATE_PHASE='' start_log update # Step 5's PATH export first, so npm, update.sh and install.sh find Node 22. node_path A=$(installed_version) work_folder fetch konvenient-latest.version || refuse "The download feed at $KONVENIENT_FEED did not answer, so nothing was changed. Run the line again later." B=$(head -n 1 "$WORK/konvenient-latest.version" | tr -d '[:space:]') [ -n "$B" ] || refuse "The download feed named no version, so nothing was changed." if [ "$A" = "$B" ]; then if svc_unit_exists "$KONVENIENT_PREFIX-brain.service" < /dev/null > /dev/null 2>&1 && curl -fsS -m 10 -o /dev/null "$DESK/health" < /dev/null 2>/dev/null; then say "Already at $B. Nothing to do." exit 0 fi # The folder and the marker are here, the desk is not: an uninstall kept the folder. say "Release $B is here, but its desk does not answer. Installing it again." STAGE=install run_install finish_install exit 0 fi STAGE=install RELEASE_FEED="${AGENTIC_RELEASE_FEED:-https://konvenient.ai/releases}" if curl -fsSL -m 20 -o /dev/null "$RELEASE_FEED/release.json" < /dev/null 2>/dev/null; then say "==> Updating with update.sh" logged bash "$STACK/deploy/customer/update.sh" --install host --stack "$STACK" --mode tarball --feed "$RELEASE_FEED" || fail "update.sh did not finish. The lines above name the cause and the way back." else update_by_hand fi say "" say "The install log: $(shown "$LOG")" say "Updated from $A to $B" exit 0 } # --------------------------------------------------------------------------------------- # The run # --------------------------------------------------------------------------------------- main() { case "$AGREEMENT_VERSION" in *@) refuse "This is the source of the install script. Build it with: npx tsx scripts/build-install-script.ts --out " ;; esac # Step 0 (spec 031 FR-017). case "$(uname -s < /dev/null)" in Linux | Darwin) ;; *) refuse "This installer is for Linux and macOS. On Windows, open PowerShell and run: irm https://konvenient.ai/install.ps1 | iex" ;; esac if [ "$(id -u < /dev/null)" = 0 ]; then refuse "Run this as yourself, not with sudo. A root install reads root's home folder and finds nothing of yours." fi if [ "$(platform_kind)" = wsl ]; then ON_WSL=1 DESK="http://localhost:$KONVENIENT_PORT" if [ "$(ps -p 1 -o comm= < /dev/null 2>/dev/null)" != systemd ]; then refuse "Systemd is off in this WSL distribution. Run the Windows line in PowerShell instead: irm https://konvenient.ai/install.ps1 | iex" fi fi if ! (: < /dev/tty) 2>/dev/null; then refuse "This installer needs a terminal to ask you one question. Run it in a terminal window." fi case "$KONVENIENT_HOME" in / | "$HOME" | "$HOME"/) refuse "KONVENIENT_HOME must name a folder of its own, such as ~/konvenient." ;; /*) ;; *) refuse "KONVENIENT_HOME must be a full path, such as $HOME/konvenient." ;; esac # Step 1. take_lock # Step 2. A leftover .partial is from a run that was stopped while it unpacked. rm -rf "$KONVENIENT_HOME.partial" if [ -e "$KONVENIENT_HOME" ]; then if [ ! -f "$KONVENIENT_HOME/agentic-os/deploy/install.sh" ]; then refuse "The folder $(shown "$KONVENIENT_HOME") exists and is not an install. Move it, or set KONVENIENT_HOME." fi if [ -f "$CONFIG_DIR/installed-$KONVENIENT_PORT" ]; then MODE=update; else MODE=resume; fi fi if [ "$MODE" = update ]; then update; fi # Steps 3 and 4. Claude's installer puts `claude` in ~/.local/bin, which a terminal of a # stopped run may not have on its PATH: put it there before `look` asks for `claude`. PATH="$HOME/.local/bin:$PATH" export PATH look # On WSL, the Windows line passes the agreement it showed (FR-002a). Only the same # version skips the prompt here. if [ "$ON_WSL" = 1 ] && [ "${KONVENIENT_AGREEMENT_VERSION:-}" = "$AGREEMENT_VERSION" ] && [ -n "${KONVENIENT_AGREEMENT_ACCEPTED_AT:-}" ]; then ACCEPTED_AT=$KONVENIENT_AGREEMENT_ACCEPTED_AT else agree fi STAGE=install start_log install # Steps 5 to 8. Each is a no-op when it finds its program, so a run that did not finish # passes through them again. step_node step_claude write_opener step_signin step_operator step_tmux # Steps 9 and 10. A run that did not finish has its files, and runs install.sh again. if [ "$MODE" = install ]; then download mkdir -p "$(dirname "$KONVENIENT_HOME")" unpack fi # Steps 11 and 12. run_install tmux_after_install finish_install exit 0 } main "$@"