#!/usr/bin/env bash # Canvas Notebook installer. # Thin entrypoint; implementation lives in install/lib and install/bin. set -euo pipefail REPO="https://github.com/canvascoding/canvas-notebook.git" ARCHIVE_URL="https://github.com/canvascoding/canvas-notebook/archive/refs/heads/main.tar.gz" COMPOSE_URL="https://raw.githubusercontent.com/canvascoding/canvas-notebook/main/install/compose.hub.yaml" IMAGE="ghcr.io/canvascoding/canvas-notebook:latest" DEST="canvas-notebook" COMPOSE_FILE_NAME="canvas-notebook-compose.yaml" SYSTEMD_SERVICE="canvas-notebook.service" INSTALL_DIR="${CANVAS_INSTALL_DIR:-/opt/canvas-notebook}" INSTALL_USER="${SUDO_USER:-${USER:-$(id -un)}}" INSTALL_USER_HOME="$(getent passwd "$INSTALL_USER" 2>/dev/null | cut -d: -f6 || true)" INSTALL_USER_HOME="${INSTALL_USER_HOME:-${HOME:-/opt}}" DATA_DIR="${CANVAS_DATA_DIR:-${INSTALL_USER_HOME}/canvas-notebook-data}" CONFIG_JSON_PATH="${CANVAS_CONFIG_JSON:-${INSTALL_DIR}/canvas-notebook-config.json}" CONFIG_JSON_WAS_PRESENT=false [[ -f "$CONFIG_JSON_PATH" ]] && CONFIG_JSON_WAS_PRESENT=true COMPOSE_FILE="${CANVAS_COMPOSE_FILE:-${INSTALL_DIR}/canvas-notebook-compose.yaml}" SERVICE="${CANVAS_SERVICE:-canvas-notebook}" LOG_DIR="${CANVAS_MANAGER_LOG_DIR:-/var/log/canvas-notebook}" LOG_FILE="${LOG_DIR}/manager.log" DOCKER_COMPOSE="docker compose" CANVAS_SWAP_ENABLED_WAS_SET="${CANVAS_SWAP_ENABLED+x}" CANVAS_SWAP_SIZE_WAS_SET="${CANVAS_SWAP_SIZE+x}" CANVAS_SWAP_FILE_WAS_SET="${CANVAS_SWAP_FILE+x}" CANVAS_SWAP_SWAPPINESS_WAS_SET="${CANVAS_SWAP_SWAPPINESS+x}" CANVAS_SWAP_ENABLED="${CANVAS_SWAP_ENABLED:-false}" CANVAS_SWAP_SIZE="${CANVAS_SWAP_SIZE:-2G}" CANVAS_SWAP_FILE="${CANVAS_SWAP_FILE:-/swapfile}" CANVAS_SWAP_SWAPPINESS="${CANVAS_SWAP_SWAPPINESS:-10}" CANVAS_AUTO_UPDATE_ENABLED_WAS_SET="${CANVAS_AUTO_UPDATE_ENABLED+x}" CANVAS_AUTO_UPDATE_SCHEDULE_WAS_SET="${CANVAS_AUTO_UPDATE_SCHEDULE+x}" CANVAS_AUTO_UPDATE_ENABLED="${CANVAS_AUTO_UPDATE_ENABLED:-false}" CANVAS_AUTO_UPDATE_SCHEDULE="${CANVAS_AUTO_UPDATE_SCHEDULE:-*-*-* 04:00:00}" LEGACY_COMPOSE_PATH="" LEGACY_DATA_PATH="" SUPPORT_TMP_DIR="" cleanup_support_tmp() { if [[ -n "$SUPPORT_TMP_DIR" ]]; then rm -rf "$SUPPORT_TMP_DIR" fi } trap cleanup_support_tmp EXIT load_existing_manager_config() { local manager_env="/etc/canvas-notebook/manager.env" if [[ ! -f "$manager_env" ]]; then return 0 fi if [[ -z "$CANVAS_SWAP_ENABLED_WAS_SET" ]]; then CANVAS_SWAP_ENABLED="$(awk -F= '/^CANVAS_SWAP_ENABLED=/ {gsub(/'\''|"/, "", $2); print $2; exit}' "$manager_env")" fi if [[ -z "$CANVAS_SWAP_SIZE_WAS_SET" ]]; then CANVAS_SWAP_SIZE="$(awk -F= '/^CANVAS_SWAP_SIZE=/ {gsub(/'\''|"/, "", $2); print $2; exit}' "$manager_env")" fi if [[ -z "$CANVAS_SWAP_FILE_WAS_SET" ]]; then CANVAS_SWAP_FILE="$(awk -F= '/^CANVAS_SWAP_FILE=/ {gsub(/'\''|"/, "", $2); print $2; exit}' "$manager_env")" fi if [[ -z "$CANVAS_SWAP_SWAPPINESS_WAS_SET" ]]; then CANVAS_SWAP_SWAPPINESS="$(awk -F= '/^CANVAS_SWAP_SWAPPINESS=/ {gsub(/'\''|"/, "", $2); print $2; exit}' "$manager_env")" fi CANVAS_SWAP_ENABLED="${CANVAS_SWAP_ENABLED:-false}" CANVAS_SWAP_SIZE="${CANVAS_SWAP_SIZE:-2G}" CANVAS_SWAP_FILE="${CANVAS_SWAP_FILE:-/swapfile}" CANVAS_SWAP_SWAPPINESS="${CANVAS_SWAP_SWAPPINESS:-10}" if [[ -z "$CANVAS_AUTO_UPDATE_ENABLED_WAS_SET" ]]; then CANVAS_AUTO_UPDATE_ENABLED="$(awk -F= '/^CANVAS_AUTO_UPDATE_ENABLED=/ {gsub(/'\''|"/, "", $2); print $2; exit}' "$manager_env")" fi if [[ -z "$CANVAS_AUTO_UPDATE_SCHEDULE_WAS_SET" ]]; then CANVAS_AUTO_UPDATE_SCHEDULE="$(awk -F= '/^CANVAS_AUTO_UPDATE_SCHEDULE=/ {gsub(/'\''|"/, "", $2); print $2; exit}' "$manager_env")" fi CANVAS_AUTO_UPDATE_ENABLED="${CANVAS_AUTO_UPDATE_ENABLED:-false}" CANVAS_AUTO_UPDATE_SCHEDULE="${CANVAS_AUTO_UPDATE_SCHEDULE:-*-*-* 04:00:00}" } load_existing_swap_config() { [[ -f "$CONFIG_JSON_PATH" ]] || return 0 local value if [[ -z "$CANVAS_SWAP_ENABLED_WAS_SET" ]]; then value="$(jq -r '.swap | if type == "object" and has("enabled") then .enabled else empty end' "$CONFIG_JSON_PATH")" [[ -n "$value" ]] && CANVAS_SWAP_ENABLED="$value" fi if [[ -z "$CANVAS_SWAP_SIZE_WAS_SET" ]]; then value="$(jq -r '.swap.size // empty' "$CONFIG_JSON_PATH")" [[ -n "$value" ]] && CANVAS_SWAP_SIZE="$value" fi if [[ -z "$CANVAS_SWAP_FILE_WAS_SET" ]]; then value="$(jq -r '.swap.file // empty' "$CONFIG_JSON_PATH")" [[ -n "$value" ]] && CANVAS_SWAP_FILE="$value" fi if [[ -z "$CANVAS_SWAP_SWAPPINESS_WAS_SET" ]]; then value="$(jq -r '.swap.swappiness // empty' "$CONFIG_JSON_PATH")" [[ -n "$value" ]] && CANVAS_SWAP_SWAPPINESS="$value" fi export CANVAS_SWAP_ENABLED CANVAS_SWAP_SIZE CANVAS_SWAP_FILE CANVAS_SWAP_SWAPPINESS } resolve_support_dir() { local script_dir script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P || true)" if [[ -n "$script_dir" && -f "${script_dir}/install/linux-cli.sh" ]]; then SUPPORT_DIR="${script_dir}/install" return 0 fi SUPPORT_TMP_DIR="$(mktemp -d)" if ! command -v curl >/dev/null 2>&1; then echo "curl is required to bootstrap installer support files." >&2 exit 1 fi curl -fsSL "$ARCHIVE_URL" -o "${SUPPORT_TMP_DIR}/canvas-notebook.tar.gz" tar -xzf "${SUPPORT_TMP_DIR}/canvas-notebook.tar.gz" -C "$SUPPORT_TMP_DIR" SUPPORT_DIR="${SUPPORT_TMP_DIR}/canvas-notebook-main/install" if [[ ! -f "${SUPPORT_DIR}/linux-cli.sh" ]]; then echo "Installer support files are missing from ${ARCHIVE_URL}." >&2 exit 1 fi } source_libs() { # shellcheck source=install/lib/common.sh . "${SUPPORT_DIR}/lib/common.sh" # Shared libs are sourced by their respective installer modules: # common.sh -> shared/output.sh, shared/utils.sh, shared/config_json.sh # docker.sh -> shared/docker.sh # swap.sh -> shared/swap.sh # caddy.sh -> shared/caddy.sh # compose.sh -> shared/container.sh # shellcheck source=install/lib/docker.sh . "${SUPPORT_DIR}/lib/docker.sh" # shellcheck source=install/lib/swap.sh . "${SUPPORT_DIR}/lib/swap.sh" # shellcheck source=install/lib/caddy.sh . "${SUPPORT_DIR}/lib/caddy.sh" # shellcheck source=install/lib/compose.sh . "${SUPPORT_DIR}/lib/compose.sh" # shellcheck source=install/lib/systemd.sh . "${SUPPORT_DIR}/lib/systemd.sh" } is_inside_container() { [[ -f /.dockerenv ]] && return 0 grep -qaE '(docker|kubepods|containerd|lxc)' /proc/1/cgroup 2>/dev/null } ensure_host_install() { if is_inside_container; then fail "This installer must run on the VM host, not inside a container. The management CLI and systemd service need host-level access." fi } print_banner() { local _bi='\033[1m' _ri='\033[0m' echo echo -e "${_bi}╔══════════════════════════════════════════╗${_ri}" echo -e "${_bi}║ Canvas Notebook — Installer ║${_ri}" echo -e "${_bi}╚══════════════════════════════════════════╝${_ri}" echo } detect_mode() { NONINTERACTIVE=false if [[ -n "${INSTALL_MODE:-}" || -n "${ADMIN_EMAIL:-}" || -n "${BASE_URL:-}" || "${CLI_UPDATE_ONLY:-false}" == "true" ]]; then NONINTERACTIVE=true fi if [[ "$NONINTERACTIVE" == "true" ]]; then MODE_CHOICE="${INSTALL_MODE:-1}" if [[ -z "${SETUP_CADDY:-}" ]]; then local _domain_candidate _domain_candidate="${BASE_URL:-}" _domain_candidate="$(printf '%s' "$_domain_candidate" | sed -E 's|^https?://||' | cut -d/ -f1 | cut -d: -f1)" if is_real_domain "$_domain_candidate"; then SETUP_CADDY=true info "Auto-enabled Caddy setup (domain detected: ${_domain_candidate})" else SETUP_CADDY=false fi fi info "Non-interactive mode — INSTALL_MODE=${MODE_CHOICE}, SETUP_CADDY=${SETUP_CADDY}" else echo "How would you like to install Canvas Notebook?" echo echo " 1) Pre-built image (recommended — fast, no build required)" echo " 2) From source (for developers or custom builds)" echo ask "Choice [1/2, default 1]: " MODE_CHOICE "1" SETUP_CADDY=false if [[ "$MODE_CHOICE" == "1" ]]; then echo ask "Set up Caddy for public HTTPS access? [y/N]: " CADDY_ANSWER "n" if [[ "${CADDY_ANSWER,,}" == "y" || "${CADDY_ANSWER,,}" == "yes" || "${CADDY_ANSWER}" == "1" ]]; then SETUP_CADDY=true fi fi fi export SETUP_CADDY MODE_CHOICE } install_compose_file() { section "Compose file" if [[ -f "$COMPOSE_FILE" ]]; then ok "${COMPOSE_FILE} already exists — keeping existing config" else curl -fsSL "$COMPOSE_URL" -o "$COMPOSE_FILE" ok "Downloaded ${COMPOSE_FILE}" fi } configure_secrets() { local auth_secret internal_key section "Secrets" if [[ -f "$CONFIG_JSON_PATH" ]]; then auth_secret="$(_read_config_file "$CONFIG_JSON_PATH" | jq -r '.env.BETTER_AUTH_SECRET // empty')" internal_key="$(_read_config_file "$CONFIG_JSON_PATH" | jq -r '.env.CANVAS_INTERNAL_API_KEY // empty')" fi if [[ -z "$auth_secret" ]]; then auth_secret="$(openssl rand -base64 32)" config_json_write env.BETTER_AUTH_SECRET "$auth_secret" ok "Generated BETTER_AUTH_SECRET" else ok "BETTER_AUTH_SECRET already set" fi if [[ -z "$internal_key" ]]; then internal_key="$(openssl rand -base64 32)" config_json_write env.CANVAS_INTERNAL_API_KEY "$internal_key" ok "Generated CANVAS_INTERNAL_API_KEY" else ok "CANVAS_INTERNAL_API_KEY already set" fi } configure_compose_values() { if [[ -n "${BASE_URL:-}" ]]; then config_json_write env.BETTER_AUTH_BASE_URL "$BASE_URL" config_json_write env.BASE_URL "$BASE_URL" ok "Set BASE_URL / BETTER_AUTH_BASE_URL" fi local managed_env_key for managed_env_key in \ CANVAS_MANAGED_SERVICES_ENABLED \ CANVAS_CONTROL_PLANE_URL \ CANVAS_LICENSE_CONTROL_PLANE_URL \ NEXT_PUBLIC_CANVAS_CONTROL_PLANE_URL \ CANVAS_INSTANCE_ID \ CANVAS_INSTANCE_TOKEN \ CANVAS_LICENSE_CERT \ CANVAS_LICENSE_PUBLIC_KEY \ CANVAS_LICENSE_TRUSTED_PUBLIC_KEY_FINGERPRINTS \ CANVAS_RUNTIME_SCOPE \ CANVAS_ORGANIZATION_ID \ CANVAS_TEAM_FEATURES_ENABLED \ CANVAS_MULTI_USER_ENABLED \ CANVAS_PERSONAL_WORKSPACES_ENABLED \ CANVAS_TEAM_WORKSPACE_ENABLED \ CANVAS_TEAM_KNOWLEDGE_BASE_ENABLED \ CANVAS_AUDIT_TRAIL_ENABLED \ CANVAS_MANAGED_BACKUPS_ENABLED; do if [[ -n "${!managed_env_key:-}" ]]; then config_json_write "env.${managed_env_key}" "${!managed_env_key}" ok "Set ${managed_env_key}" fi done local has_placeholders=false local url_val domain_val url_val="$(_read_config_file "$CONFIG_JSON_PATH" | jq -r '.env.BETTER_AUTH_BASE_URL // empty')" domain_val="$(_read_config_file "$CONFIG_JSON_PATH" | jq -r '.domain // empty')" if [[ -z "$url_val" && -z "$domain_val" ]]; then has_placeholders=true fi if [[ "$has_placeholders" == "true" ]]; then if [[ "$NONINTERACTIVE" == "true" ]]; then fail "Config still contains placeholder values. Required: BASE_URL" fi section "Configuration" echo info "Set at minimum:" info " domain — public domain (e.g. app.example.com)" echo info "The first admin can be created in the setup UI after launch." info "For automation, pass ADMIN_EMAIL and ADMIN_PASSWORD to this installer; they will be applied once and not stored." echo info "Config file: ${CONFIG_JSON_PATH}" EDITOR_CMD="${EDITOR:-nano}" command -v "$EDITOR_CMD" >/dev/null 2>&1 || EDITOR_CMD="vi" ask " Press Enter to open ${CONFIG_JSON_PATH} in ${EDITOR_CMD}, or Ctrl+C to abort: " _dummy "" "$EDITOR_CMD" "$CONFIG_JSON_PATH" /dev/null)" configure_caddy "$domain" echo echo -e "${_OUT_GREEN}${_OUT_BOLD}Canvas Notebook image pulled and CLI installed.${_OUT_RESET}" echo if [[ "${SETUP_CADDY:-false}" != "true" ]]; then info "If you set a domain with SETUP_CADDY=true, run: canvas-notebook caddy-reload" fi echo echo -e "${_OUT_BOLD}Canvas Notebook service is installed.${_OUT_RESET}" echo info " canvas-notebook status Check container and health" echo echo -e "${_OUT_BOLD}Useful commands:${_OUT_RESET}" info " canvas-notebook logs Follow container logs" info " canvas-notebook env Show configuration" info " canvas-notebook config-show Show config.json" info " canvas-notebook update Pull latest image and restart" info " canvas-notebook auto-update-status" echo } run_source_install() { if ! command -v git >/dev/null 2>&1; then run_root apt-get update -qq run_root apt-get install -y git fi if [[ -d "$DEST/.git" ]]; then ok "Repo already exists — pulling latest changes" git -C "$DEST" pull else section "Cloning repository" git clone "$REPO" "$DEST" ok "Cloned into ./${DEST}" fi cd "$DEST" bash scripts/server-setup.sh } if [[ "$(uname -s)" != "Linux" ]]; then echo "This script is for Linux servers only." >&2 echo "On macOS or Windows, install Docker Desktop and run: npm run setup" >&2 exit 1 fi load_existing_manager_config resolve_support_dir source_libs if [[ "${CLI_UPDATE_ONLY:-false}" == "true" ]]; then if ! command -v jq >/dev/null 2>&1; then echo "jq is required before a verified host CLI update can acquire its operation lock." >&2 exit 1 fi canvas_operation_lock_acquire cli-update-installer fi require_jq load_existing_swap_config print_banner detect_mode if [[ "${CLI_UPDATE_ONLY:-false}" == "true" ]]; then run_cli_update_only elif [[ "$MODE_CHOICE" == "1" ]]; then run_prebuilt_install elif [[ "$MODE_CHOICE" == "2" ]]; then run_source_install else fail "Invalid choice. Run the script again and enter 1 or 2." fi exit 0