# Server Installation & Updates Install SynaptoMind as a systemd service from a published release tarball (`DIST=binary`) using the `deploy/` framework, and update it in place. This is the supported install path; for containers see [DOCKER.md](DOCKER.md), and for an overview of the project see the [README](../README.md). --- ## Quick Start ```bash curl -fsSL https://raw.githubusercontent.com/zumik3-del/synaptomind/main/deploy/install.sh | bash ``` A piped script has no sibling files, so the shared helpers and config are fetched from the published base by default — no environment variables are needed. `DEPLOY_RAW_URL` overrides that base (forks/mirrors); an explicit `APP_ENV_URL` or `LIB_RAW_URL` still wins. The installer downloads the release tarball, extracts it into a staging directory, verifies the payload, and swaps it into `/opt/synaptomind`. The server starts on `http://127.0.0.1:3105` (API) and `http://127.0.0.1:3106/mcp` (MCP). Config lives in `/var/lib/synaptomind/config.json`, reached via the `/opt/synaptomind/config.json` symlink. --- ## Installation ### One-line install ```bash curl -fsSL https://raw.githubusercontent.com/zumik3-del/synaptomind/main/deploy/install.sh | bash ``` A piped script has no sibling files, so the shared helpers (`deploy/lib/common.sh`) and config (`deploy/app.env`) are fetched from the published base by default. `APP_ENV_URL` and `LIB_RAW_URL` are optional overrides for those two URLs, and `DEPLOY_RAW_URL` moves the base used for both (forks/mirrors). To pass flags in the piped form, append them after `bash -s --`: ```bash curl -fsSL https://raw.githubusercontent.com/zumik3-del/synaptomind/main/deploy/install.sh | bash -s -- --port 3105 ``` ### From a checkout From a local checkout, run the installer directly instead: ```bash git clone https://github.com/zumik3-del/synaptomind.git && cd synaptomind sudo bash deploy/install.sh ``` ### Install flags Options (append after `--` in the piped form, e.g. `bash -s -- --port 3105`): | Flag | Effect | |------|--------| | `--dir DIR` | Install directory (default `INSTALL_DIR` in `deploy/app.env`) | | `--port PORT` | Port written to the seeded `config.json` and used by the health check | | `--version TAG` | Pin a version instead of resolving the latest | | `--force` | Reinstall even when the same version is already present; also the reinstall-over-a-running-service path, which now restarts the unit | | `--no-service` | Skip systemd unit installation and start/restart | | `--help`, `-h` | Show usage | ### What the installer does The installer resolves the release tag, downloads the release tarball (`synaptomind--linux-x86_64.tar.gz`), extracts it into a staging directory, and verifies that the payload contains the required files (`synaptomind`, `vec0.so`, `lib/libonnxruntime.so.1`). It then: 1. Copies each rollback-critical file to `.prev` (skipped when the bytes are identical, so a `.prev` is never a false rollback point). 2. Swaps the payload into `/opt/synaptomind` in a fixed order — data files first, the executable last — so an interrupted swap leaves old-executable + new-data, which is detectable as "old version" rather than "new binary, old library". 3. Seeds `config.json` into `/var/lib/synaptomind/` (not the payload tree, which is swapped on every update) and `.env` (generating `SYNAPTOMIND_SECRET`) into `/opt/synaptomind/`. 4. Creates the `/opt/synaptomind/config.json` → `/var/lib/synaptomind/config.json` symlink so the app's cwd-relative config resolver finds it. 5. Links `/opt/synaptomind/data` → `/var/lib/synaptomind`. 6. Installs the helper scripts into `${HOME}/.synaptomind/scripts` and the update hooks into `${HOME}/.synaptomind/hooks`. 7. Installs the systemd unit, **restarts** it, and polls `/health`. The service step is a `restart`, not a `start`, and that is load-bearing: `systemctl start` on an already-active unit is a no-op, so a re-install over a running service (`install.sh --force`) used to leave the **old** process serving while the new payload and unit sat on disk unused. The run then failed its own health gate with `/health reports version 0.8.0, expected 0.9.0` — the gate was right, the install had simply not taken effect. `restart` brings the replaced unit up whether or not it was already running, so the same verb is correct for a first install and a re-install. The secret is written to `/opt/synaptomind/.env`; read it with `sudo grep SYNAPTOMIND_SECRET /opt/synaptomind/.env`. See [CONFIG.md](CONFIG.md) for the full configuration reference. --- ## Channels `CHECKOUT_POLICY` in `deploy/app.env` selects what install and a direct `update.sh` run resolve. The frozen `updater.sh` (below) always resolves stable releases and ignores `CHECKOUT_POLICY`. | Value | Resolves | |-------|----------| | `stable` (default) | Newest tag without `-` | | `latest` | Newest tag of any kind | | `prerelease` | Newest `-alpha.` / `-beta.` / `-rc.` tag | | `` | **Rejected in binary mode** — a branch has no release asset | --- ## Updating The installer drops a bootstrap `updater.sh` next to the other helpers. It is the primary update entry point — run it as the user that owns the install; it needs no root itself. ```bash bash ${HOME}/.synaptomind/scripts/updater.sh # interactive: pick from stable releases bash ${HOME}/.synaptomind/scripts/updater.sh --yes # non-interactive: newest stable bash ${HOME}/.synaptomind/scripts/updater.sh --version v0.9.0 # pin a stable tag bash ${HOME}/.synaptomind/scripts/updater.sh --help ``` | Flag | Effect | |------|--------| | `--version TAG` | Update to a specific stable tag (skips the menu) | | `--yes`, `-y` | Non-interactive: pick the newest stable and forward `--yes` | | `--help`, `-h` | Show usage | `updater.sh` (installed as `${RUN_DIR}/scripts/updater.sh`, i.e. `${HOME}/.synaptomind/scripts/updater.sh` by default) is **stable-only**: it always resolves the newest tag without a `-` (prereleases and branches are ignored). It is a *frozen bootstrap* — on every run it shallow-clones the chosen release into a temporary directory and executes **that release's own `deploy/update.sh`** (together with its `lib/common.sh` and a copy of your installed `app.env`), so fixes to the update process ship with ordinary releases instead of requiring a reinstall, and your port/`RUN_DIR` configuration is left untouched. ### Exit codes - `0` on success (including "already up to date" and an aborted menu). - `1` for a bootstrap pre-flight failure: no stable tags, an unknown or non-stable `--version`, or a release whose `update.sh` does not accept the frozen `--version`/`--yes`/`--help` flags. - Otherwise the exit code of the release's `update.sh` is propagated verbatim. ### Runtime model The updater itself calls no `sudo` and writes nothing to the install directory. It runs as the service/installing user; the only privileged step is the systemd service restart, which the release's `update.sh` performs via `sudo` when it is not already root. > **Note:** the frozen updater takes effect only from the first **stable** > release that ships the `deploy/` directory (the framework was adopted after > `v0.7.3`; expected from `v0.8.0`). Older stable tags such as `v0.7.3` contain > no `deploy/`, so the updater aborts with an explicit message rather than > falling back to a branch or prerelease. ### Data retention There is no automatic age-based deletion of thoughts. Retention review is an operator-driven workflow: inspect candidates with `retention_review action=list`, then explicitly protect, delete (preview, then `confirm: true`), or compact them. Configure candidate age, importance threshold, and page size with `retentionReview.*` settings; see [CONFIG.md](CONFIG.md#retention-review) and [docs/mcp-tools.md](mcp-tools.md#retention_review--retention-candidates) (the HTTP routes that used to expose this went with the 0.20 ops-plane shrink — [API.md](API.md)). The database migration installs indexes used by candidate lookup. ### Multi-user deployments `config.json` may list `users[]`: one SQLite database and one bearer token per user, all served by the single process. Upgrading changes nothing while the list is empty; switching an existing install, adding users and rotating tokens are in [CONFIG.md](CONFIG.md#users-multi-user). Operationally: - `/health` probes every configured database and returns HTTP 503 `degraded` when any is unavailable. The update gate accepts an expected-version 503 as a warning (see [Upgrade safety](#upgrade-safety)); containers report unhealthy. - The pre-update backup covers each `users[].dbPath` and prints one snapshot per database; [Rollback](#rollback) restores each database separately. - `mcp.maxSessions` is a per-user cap on legacy (2025-era) sessions; the 2026-07-28 era keeps none, its listen streams are bounded by `mcp.maxSubscriptions`. - MCP stdio stays single-user: it opens `db.path` only and refuses to start when that path aliases a user database. ### Advanced: direct `update.sh` The installed `update.sh` stays available and is required on hosts that track a prerelease (the updater is stable-only; a branch is rejected in binary mode — see Channels): ```bash bash ${HOME}/.synaptomind/scripts/update.sh # target from CHECKOUT_POLICY bash ${HOME}/.synaptomind/scripts/update.sh --version v0.9.0 bash ${HOME}/.synaptomind/scripts/update.sh --yes # non-interactive (required for downgrades) ``` ### Upgrade safety `update.sh` is upgrade-safe and never reverts automatically: 1. Backs up every database the service will open with a WAL-safe `sqlite3 .backup` into `.backup/`, and aborts before the payload is downloaded or swapped when a database it found cannot be copied or verified. The step is built into `update.sh` and `lib/common.sh`, not delegated to the hook — see [Pre-update backup](#pre-update-backup). 2. Runs the installed `pre-update` hook. Its failure is fatal too, but it is an extension point: the shipped hook skips its own copy when step 1 already ran. 3. Downloads the release tarball, extracts it, verifies the payload, copies `.prev` rollback points, and swaps the new payload into `/opt/synaptomind`. 4. Refreshes the systemd unit (full re-render in binary mode) and restarts it. 5. Polls `/health` until it reports the target version; on timeout or version mismatch it exits non-zero and prints recovery instructions (the `.prev` rollback points + the DB restore command) without reverting. If no systemd service is running, health verification is skipped with a notice. The health URL resolves from `config.json` `server.port` / `PORT` (default `http://127.0.0.1:3105/health`); override it with `HEALTH_URL` in `deploy/app.env`. If a configured database is unavailable, `/health` returns HTTP 503. The deploy gate accepts this as **running but degraded**, not healthy, only when the body passes strict JSON and health-contract validation, its version matches the expected version, and the embedder has not failed. It warns the operator to investigate the database. This 503 path requires `curl` and `python3` and a known expected version; without them it fails closed as an unverified timeout. Wrong versions, malformed responses, and embedder failure do not pass. A timeout can also mean the service did not respond; inspect the endpoint and logs before taking recovery action. No recovery is automatic. The Dockerfile and Compose health checks also use `curl -f`: HTTP 503 counts as a failed probe, so partial or total database unavailability eventually makes the container unhealthy after the configured retries. Docker health status does not itself restart the container; investigate the unavailable database rather than assuming the health check repaired it. ### Pre-update backup The backup is taken by the code every update fetches, not by the installed hook. `deploy/update.sh:954` calls `backup_databases` (`deploy/lib/common.sh:2637`) before the pre-update hook and before the release payload is downloaded or swapped; a failure aborts the run, so no code moves without a copy. On the default path that is the **target release's** own copy of both files: the frozen bootstrap stages `update.sh`, `lib/common.sh` and your `app.env` from the tag it is installing (`deploy/updater.sh:116-118`). **What is covered.** Every database the service actually opens: `db.path`, each `users[].dbPath`, `SYNAPTOMIND_DB_PATH`, any `SYNAPTOMIND_CONFIG` / `SYNAPTOMIND_DB_PATH` declared in the payload `.env` files (read in addition to the service environment), plus a bounded discovery pass (`-maxdepth 2`, SQLite magic number) over `DATA_DIR` (`/var/lib/synaptomind` by default, falling back to `${INSTALL_DIR}/data` when the key is empty), the unit's working directory's `data/`, and the parent directories of the resolved paths. Candidates are deduplicated by canonical path and inode, so a symlink and its target are one database. The configuration is read from the running service where possible (`/proc//environ`, `/proc//cwd`), then from the unit, then from the installed `app.env`. **Snapshots.** `sqlite3 -cmd ".timeout 10000" ".backup ''"`, written next to the real file as `.backup/.--.bak`, where `` is a per-run counter over the candidate paths — e.g. `/var/lib/synaptomind/synaptomind.db.backup/synaptomind.db.20261103-111142-417429-1.bak` for the default database (`/opt/synaptomind/data` is a symlink, and a symlinked database is resolved first). The `.backup` API includes WAL content. Each copy is then verified: `PRAGMA quick_check` must answer `ok`, and the copy's `PRAGMA user_version` must equal the source's. **Skip and abort rules.** A path that does not exist, is not a regular file, or does not begin with `SQLite format 3` is skipped with an `info` line naming it — a fresh install has nothing to copy, and a 0-byte or foreign file at a configured path is reported rather than copied. Everything that would leave a database uncopied fails closed and aborts before the swap: `sqlite3` missing while at least one database exists, a backup directory that cannot be created, a backup destination that already exists, a failed `.backup`, a failed `quick_check` or `user_version` check (the partial file is removed), and a run where the service is active and a config file was resolved but no database could be enumerated at all. **Retention.** After a successful backup the same step prunes, per database and oldest first, everything beyond the newest `BACKUP_KEEP` copies — default `5`, set it in `deploy/app.env` or the environment. `BACKUP_KEEP=0` disables pruning (a non-numeric value also disables it, silently). The pattern is `.[0-9]{8}-[0-9]{6}*.bak`, which also matches the older hook-written `..bak`: on a host that has been updated before, the first run of this code counts every historical pre-update backup of a database and deletes all but the newest `BACKUP_KEEP` of them, without a prompt. Copy anything older you mean to keep before that first update. **The hook is an extension point.** `update.sh` still runs `${HOOKS_DIR}/pre-update` after the built-in backup and still treats a non-zero exit as fatal, but the guarantee does not depend on it: `update.sh` never installs or refreshes hooks (`install_hook_scripts()` is called only from `deploy/install.sh:856`), so an existing host keeps whatever hook it installed — possibly a release-old copy with its own `grep`-based resolver. The shipped hook exits 0 with `pre-update: built-in backup already performed` when `APP_BACKUP_ALREADY_DONE=1` (`deploy/hooks/pre-update:118`), which `backup_databases` and `deploy/update.sh:957` export before the hook runs. A custom hook that backs up on its own should honour that variable as well; otherwise it adds a second set of copies, subject to the same retention. Re-running `install.sh` is what refreshes the installed hook. **Dry run.** `update.sh --check-backup` prints what the backup step would protect — no backup taken, nothing downloaded or swapped, no unit touched — and exits non-zero when resolution would have refused. Typical output on a running host with the default single database: ```text CONTEXT_SOURCE=observed ENV_SOURCE=observed RESOLVER_AGREEMENT=bash-only EFFECTIVE_CONFIG_PATH=/opt/synaptomind/config.json DATABASES: /var/lib/synaptomind/synaptomind.db ``` `CONTEXT_SOURCE` and `ENV_SOURCE` name the layer that answered (`observed` = the running process, `derived` = `systemctl show` or the unit file, `assumed` = `app.env` only), `RESOLVER_AGREEMENT` is always `bash-only` because nothing inside the application is probed, and the `DATABASES:` lines are absolute paths with an existing parent directory canonicalised, so `/opt/synaptomind/data/…` prints as `/var/lib/synaptomind/…`. The flag is handled before the version guards, so it works on a host that is already current. `updater.sh` rejects unknown flags, so run it on an `update.sh` directly: ```bash bash ${HOME}/.synaptomind/scripts/update.sh --check-backup ``` The installed `update.sh` in `${HOME}/.synaptomind/scripts/` learns the flag only when a re-install refreshes it; until then run the same command from a checkout of the release you intend to deploy — `bash deploy/update.sh --check-backup`, where the checkout's `app.env` supplies `INSTALL_DIR`/`DATA_DIR` just as the installed copy does. ### Rollback Schema migrations are **forward-only** (`src/db/init.ts:166`): the server applies every migration it has not seen and never reverses one. Running an older binary against a database that a newer version has already migrated is therefore **unsafe**. The pre-update backup ([above](#pre-update-backup)) has already copied every database, so the supported rollback restores those copies and the `.prev` payload files: ```bash sudo systemctl stop synaptomind # Restore EVERY database the backup step copied. It prints one # "Database backed up: ..." line per database (the main db.path plus each # users[].dbPath); run the printed cp/rm pair for each of them. Example for # the default single database: sudo cp -p /opt/synaptomind/data/synaptomind.db.backup/synaptomind.db..bak \ /opt/synaptomind/data/synaptomind.db sudo rm -f /opt/synaptomind/data/synaptomind.db-wal /opt/synaptomind/data/synaptomind.db-shm # Repeat the two lines above for each per-user database, using the path the # backup step printed for it (e.g. .../alice.db.backup/alice.db..bak # -> alice.db). # Restore the previous payload from .prev sudo mv -f /opt/synaptomind/vec0.so.prev /opt/synaptomind/vec0.so sudo mv -f /opt/synaptomind/lib/libonnxruntime.so.1.prev /opt/synaptomind/lib/libonnxruntime.so.1 sudo mv -f /opt/synaptomind/synaptomind.prev /opt/synaptomind/synaptomind sudo systemctl start synaptomind ``` The backup step prints the exact path (`Database backed up: …`), and `update.sh` repeats the per-database `cp`/`rm` pair and the `.prev` restore commands in its recovery block — for the copies it made itself and for any an operator hook reported. `/opt/synaptomind/data` is a symlink to `/var/lib/synaptomind`, so the real database file is `/var/lib/synaptomind/synaptomind.db` — there is no `data/` under `/var/lib/synaptomind`. ### Logging Application logs now route to stdout/stderr (info/debug to stdout, warning/error to stderr) and, when configured, an optional size-rotated file via `log.file`/`SYNAPTOMIND_LOG_FILE` (with `log.fileMaxBytes`/`SYNAPTOMIND_LOG_FILE_MAX_BYTES` and `log.fileMaxFiles`/`SYNAPTOMIND_LOG_FILE_MAX_FILES`). --- ## Troubleshooting ### ERR_DLOPEN_FAILED / libonnxruntime.so.1 The compiled binary dlopens an embedded addon (`libonnxruntime.so.1`) whose RUNPATH resolves inside `/$bunfs`. The systemd unit sets `Environment=LD_LIBRARY_PATH=/opt/synaptomind/lib` so the loader finds it. If the unit is hand-edited or the `lib/` directory is missing, the embedder child dies on `ERR_DLOPEN_FAILED` in a loop. Verify: ```bash ls -l /opt/synaptomind/lib/libonnxruntime.so.1 systemctl cat synaptomind.service | grep LD_LIBRARY_PATH ``` If the file is missing, re-run the installer (`sudo bash deploy/install.sh --force`) to restore the payload. If the unit lacks the `LD_LIBRARY_PATH` line, re-render it by re-running `install.sh` or `update.sh`. ### Health check fails after update If `/health` does not report the expected version after an update, the unit may not have been refreshed. Check: ```bash systemctl cat synaptomind.service # should match a fresh render curl http://127.0.0.1:3105/health # should report the target version ``` Re-run `bash ${HOME}/.synaptomind/scripts/update.sh --yes` to re-refresh and restart. ### Update aborts with `pre-update backup failed` `[synaptomind] ERROR: pre-update backup failed — aborting before switching code` means the backup step refused to continue: the payload was not downloaded, nothing was swapped, and the service is still running the code it ran a minute ago. Find out what the step resolved and from where: ```bash bash ${HOME}/.synaptomind/scripts/update.sh --check-backup ``` The usual causes are a missing `sqlite3` while a database exists, a `.backup` directory the update user cannot write, a config file that cannot be read or parsed, and a host where the service is active but no database could be enumerated — the step refuses rather than backing up nothing. Also check the filesystem: retention keeps up to `BACKUP_KEEP` copies of every database, so the `.backup/` directories are part of the disk budget. Fix the cause and re-run; a copy that fails verification is deleted, so nothing partial stays behind. --- ## Uninstall ```bash bash ${HOME}/.synaptomind/scripts/uninstall.sh # keeps code + data bash ${HOME}/.synaptomind/scripts/uninstall.sh --purge # also removes them ``` Stops and disables the systemd unit (`/etc/systemd/system/synaptomind.service`), removes it, and removes the helper scripts (`${HOME}/.synaptomind/scripts`). By default it keeps the install directory (`/opt/synaptomind`) and data (`/var/lib/synaptomind`); pass `--purge` to remove them as well. Use `--yes` in non-interactive shells. --- ## Docker alternative See [DOCKER.md](DOCKER.md). The `deploy/` framework is the supported install path; Docker is provided as a convenience.