Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
e484adb
feat(cli): give each install its own container and volumes with INSTA…
aliasunder Oct 19, 2026
21f8924
feat(remote): add a read-only check for another vault's Sync setup
aliasunder Oct 19, 2026
aebff96
feat(deploy): name the local Compose project, container and volume af…
aliasunder Oct 19, 2026
a356c0c
docs: describe INSTANCE_NAME and the other-vault check outside the de…
aliasunder Oct 19, 2026
f459ac2
feat(deploy): name the remote Compose project, container and volumes …
aliasunder Oct 20, 2026
374897b
feat(remote): stop the boot before syncing another vault's Sync setup
aliasunder Oct 20, 2026
30886d6
docs: add "Run more than one vault" to the local and remote guides
aliasunder Oct 20, 2026
24877a8
test: expect ShellCheck to cover check-sync-vault
aliasunder Oct 20, 2026
707ba03
fix(remote): give check-sync-vault's by-hand command the s6 PATH and …
aliasunder Oct 20, 2026
b37f13c
fix(review): cover PUBLIC_URL and single-volume layouts in the second…
aliasunder Oct 21, 2026
b52eecb
fix(cli): give a remote install the device name of a free --instance-…
aliasunder Oct 21, 2026
0f91875
style: clarify instance identity code, Sync vault check comments and …
aliasunder Oct 21, 2026
6c01f45
fix: name an unreadable Sync setup answer, reject an empty --instance…
aliasunder Oct 21, 2026
c26d41f
test(remote): cover the vault check's remaining branches
aliasunder Oct 21, 2026
22f6dbe
test(cli): pin instance identity exactly and cover its untested paths
aliasunder Oct 21, 2026
d8b5a67
fix(cli): let init's start replace an existing container with the sam…
aliasunder Oct 21, 2026
b2abed0
fix: match containers by exact name and fail closed on an unreadable …
aliasunder Oct 21, 2026
eddbb8a
fix(cli): offer the next port to a second install instead of 8000
aliasunder Oct 21, 2026
5e852a5
fix(cli): read an empty PORT line as the default port
aliasunder Oct 21, 2026
79b4d4e
fix: name an unreadable Sync setup after sync-setup, and follow a car…
aliasunder Oct 21, 2026
db3e7c5
fix: show the Sync client's reason when the vault check can't read it…
aliasunder Oct 21, 2026
a1c358a
test: say the init-setup-vault harness copies the script
aliasunder Oct 21, 2026
d45c1e4
fix(cli): read .env values without their quotes, and name a reused re…
aliasunder Oct 21, 2026
e965052
fix(cli): read an empty memory or daily-notes folder as unset in the …
aliasunder Oct 21, 2026
7532507
fix(cli): refuse a re-run of init that renames the install
aliasunder Oct 21, 2026
72c0a76
fix(cli): quote the rename hint's folder and say the new name must be…
aliasunder Oct 21, 2026
524035c
fix(cli): report an invalid kept INSTANCE_NAME on a re-run, and say a…
aliasunder Oct 21, 2026
bbe16b1
fix(cli): stop an init re-run as soon as the kept INSTANCE_NAME is in…
aliasunder Oct 21, 2026
0117ff5
fix(cli): warn when init can't check names, ask a reused install's po…
aliasunder Oct 21, 2026
c9c7ed7
fix(cli): patch every Sync token line, drop a remedy init can't keep,…
aliasunder Oct 21, 2026
d65c7d9
fix: let an empty last .env line win, mention --port for a scripted r…
aliasunder Oct 21, 2026
2657d9a
fix(cli): read an empty last PORT line as the default port, and a quo…
aliasunder Oct 21, 2026
042a82d
fix(cli): stop start on an empty PUBLIC_URL line, and correct the set…
aliasunder Oct 21, 2026
2913267
fix(cli): treat a quoted whitespace-only PUBLIC_URL as empty, and cor…
aliasunder Oct 21, 2026
75c0a91
fix(cli): refuse an invalid PORT, check a remote PUBLIC_URL, and let …
aliasunder Oct 21, 2026
50399b4
fix(cli): keep a localhost PUBLIC_URL with a replaced invalid PORT, a…
aliasunder Oct 21, 2026
735024c
fix(cli): check init's .env on disk the way start does, warn about a …
aliasunder Oct 21, 2026
6d1adb3
fix(cli): name init among resolveDeployment's callers and say when th…
aliasunder Oct 21, 2026
5261257
fix(cli): skip the stranded-PUBLIC_URL warning when the localhost URL…
aliasunder Oct 21, 2026
6297291
fix(cli): check a new install's port against the ports other containe…
aliasunder Oct 21, 2026
4133ec0
docs: point multi-vault setup at the settings reference and split the…
aliasunder Oct 21, 2026
4e1eb1e
docs: point README readers who run more than one vault to the deploy …
aliasunder Oct 21, 2026
7a3abce
docs: spell out the full CLI commands for updating a second install
aliasunder Oct 21, 2026
1a3dc28
docs: drop the advice to give each install its own DEVICE_NAME
aliasunder Oct 21, 2026
c3958af
docs: say that init suggests the first free port for a second install
aliasunder Oct 21, 2026
d505f83
docs: keep Compose commands out of the settings text the CLI copies i…
aliasunder Oct 21, 2026
fe0912d
fix(cli): refuse init over the other mode's install, and fix four rev…
aliasunder Oct 21, 2026
b1e3cf8
test(cli): pin the remote daemon-down start gate instead of a removed…
aliasunder Oct 21, 2026
f068932
fix(cli): refuse a port another container publishes on init re-runs a…
aliasunder Oct 21, 2026
60f5429
fix(cli): leave a container that runs another image in place
aliasunder Oct 21, 2026
aea2ef3
fix(cli): treat the pre-rename vault-mcp image as Vault Cortex
aliasunder Oct 21, 2026
5d72a54
fix(cli): keep every container that publishes a host port
aliasunder Oct 21, 2026
d3dbbe3
fix(cli): name a free port when --yes stops on a taken one
aliasunder Oct 21, 2026
c869498
fix(cli): check the image before logs streams a container
aliasunder Oct 21, 2026
c9518b8
fix(cli): fixes from the pre-merge delta review
aliasunder Oct 21, 2026
a8035a4
docs: say why a docker run install keeps PORT at 8000
aliasunder Oct 21, 2026
d53c947
docs: label the docker run paragraph for a second vault like the Upda…
aliasunder Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -953,9 +953,10 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal):
`src/vault-mcp/__tests__/` (`init-check-auth.test.ts`,
`init-obsidian-login.test.ts`, `init-first-sync.test.ts`,
`init-setup-user.test.ts`, `init-setup-vault.test.ts`,
`print-derived-env.test.ts`, which covers the derivation that
`init-derive-env` publishes), and the hook's and release scripts'
tests in `src/__tests__/` (`install-deps.test.ts`,
`check-sync-vault.test.ts`, which covers the vault check
`init-setup-vault` runs, and `print-derived-env.test.ts`, which covers
the derivation that `init-derive-env` publishes), and the hook's and
release scripts' tests in `src/__tests__/` (`install-deps.test.ts`,
`generate-notes.test.ts`, `update-changelog.test.ts`). These script
tests run the real script under its own shell (`sh` for the init
scripts, `bash` for the others) with stub binaries on `PATH` or, for
Expand Down Expand Up @@ -1194,10 +1195,22 @@ of `obsidian-headless/package.json` as a potential regression and
re-verify each contract against the new source before merging:

- Verbs and flags the scripts call: `login`, `sync-config` (including
`--mode` and `--json`), `sync`, `sync --continuous`, and
`sync-setup --vault --device-name`. `sync-config --json` prints `vaultId`
`--mode` and `--json`), `sync`, `sync --continuous`,
`sync-setup --vault --device-name`, `sync-list-remote --json` and
`sync-list-local --json`. `sync-config --json` prints `vaultId`
and `syncMode` (`bidirectional` when unset), and an unknown `--mode`
value exits non-zero, which `init-setup-vault` treats as fatal.
- Each vault's setup is `obsidian-headless/sync/<vaultId>/config.json`, and
every command uses the first setup, in folder-name order, whose
`path.resolve(vaultPath)` is the working folder. `check-sync-vault` relies
on these outputs, captured as vectors in its test file:
- `sync-config --json` exits 3 when no setup matches the folder and 1 when
a setup without `vaultPath` sorts first, and it skips a setup file it
can't read or parse;
- `sync-list-local --json` lists `{ id, path, host }` per setup;
- `sync-list-remote --json` lists owned and shared vaults with `id` and
`name`, and needs the token;
- `sync-setup` resolves `--vault` by ID first, then by a unique name.
- `sync-setup` rewrites the whole config on every boot without the filter
fields or `syncMode`. `sync-config --file-types` / `--excluded-folders` /
`--configs` then queue only the recorded server files that the new value
Expand Down
15 changes: 12 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -927,13 +927,22 @@ graph LR
`init-check-auth` (looks for the Sync token in the env var, then in the
Sync client's own credential file on the config volume; with neither,
the boot switches to [setup mode](#setup-mode) instead of stopping) →
`init-obsidian-login` (`ob login`) → `init-setup-vault` (`ob sync-setup`
with `--device-name`, plus optional sync-config; fails fast when
`VAULT_NAME` is missing or `SYNC_MODE` fails to apply) → `init-first-sync`
`init-obsidian-login` (`ob login`) → `init-setup-vault` (sets up the
vault's Sync in the steps listed after this chain) → `init-first-sync`
(one-shot `ob sync` run to _completion_, with retries; its four steps,
failure branches included, are listed below). Any
fatal init failure stops the container
(`S6_BEHAVIOUR_IF_STAGE2_FAILS=2`) — the restart policy owns retry.

`init-setup-vault` fails fast when `VAULT_NAME` is missing or `SYNC_MODE`
fails to apply. Its steps, in order:

1. `check-sync-vault` stops the boot, before anything is written, when the
volume's Sync setup is for a different vault than `VAULT_NAME`.
2. `ob sync-setup` with `--device-name`, then a check that the Sync client
now uses `VAULT_NAME`'s vault.
3. Optional sync-config settings.

2. **`svc-obsidian-sync`** — bidirectional Obsidian Sync
(`ob sync --continuous`). Stores sync state in the config volume at
`/home/obsidian/.config` (persists across restarts for incremental sync —
Expand Down
2 changes: 2 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ That runs, in order:

On startup, Compose runs one `vault-cortex` container (the `:remote` image). Inside it, s6-overlay runs the init chain (Obsidian login → vault setup → first sync to completion) and then supervises two processes in order: the sync process (mirrors your vault) → the MCP server. Both drop to the same UID (1000, adjustable via PUID/PGID) so they share the `/vault` volume. See [ARCHITECTURE.md § Container Startup](./ARCHITECTURE.md#container-startup) for the full diagram.

The repo-root `docker-compose.yml` names its volumes explicitly (`vault-cortex_vault_data`, `vault-cortex_mcp_index_data`, `vault-cortex_obsidian_config`) and does not read `INSTANCE_NAME`, so this deployment's data stays on those volumes whatever `.env` sets.

## Verify

```bash
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,8 @@ Every path runs the same image, `ghcr.io/aliasunder/vault-cortex` — `:latest`

**Cost:** A remote setup needs a VPS or a hosted platform plan, plus $4 USD/mo for [Obsidian Sync](https://obsidian.md/sync). A 2 GiB instance handles semantic search fine for a typical vault; 4 GiB adds headroom for concurrent search and larger vaults. Skip semantic search entirely to go smaller still. Local-only is free. The [reference AWS deployment](./ARCHITECTURE.md#cost) runs ~$17–29 USD/mo all-in.

**More than one vault?** Each vault runs as its own install, with its own container, data and connector. On your own machine or server, give each install its own name: see **Run more than one vault** in the [local](./deploy/local/README.md#run-more-than-one-vault) and [remote](./deploy/remote/README.md#run-more-than-one-vault) guides. On Render or Railway, deploy a separate service for each vault.

### One-click deploy

Buttons and prerequisites are in [Quick Start → Remote](#remote-access-from-anywhere). Each guide walks through the deploy, where to find your URL and token, how to update, and how to delete: [`deploy/render/`](./deploy/render/) (from the [`render.yaml`](./render.yaml) Blueprint at the repo root) · [`deploy/railway/`](./deploy/railway/) (from a published template).
Expand Down
8 changes: 8 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ mechanism-level detail.
synced `.obsidian/` settings), the container refuses to start,
preventing the sync engine from interpreting the empty vault as mass
local deletions
- Sync vault check (remote image): before `ob sync-setup` writes anything,
the init chain asks the Sync client which vault it would sync into the
vault folder. When that is a vault other than `VAULT_NAME`, or a Sync
setup file there is not a valid setup, the container refuses to start
rather than merge two vaults through Obsidian Sync. It checks the Sync
setup, not which vault the files in the folder came from, so notes left in
the folder after their vault's setup was removed still sync into
`VAULT_NAME`'s vault
- Trash sweep identity gate: the retention sweep deletes a recorded
`.trash/` file only while it still has the identity recorded when the
server trashed it and has not changed since, so a different note that
Expand Down
55 changes: 47 additions & 8 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,56 @@ What it does:
5. Prints your connection details — the MCP URL, your auth token, and how to
connect your client

Re-running init where a setup already exists asks first — declining leaves
everything unchanged and points you at [`configure`](#configure), the right
tool for changing settings in place. Existing files are never overwritten
without asking. During a remote setup, init offers to generate your
[Obsidian Sync token](#get-sync-token) as part of the flow.
**Remote setups:** init offers to generate your
[Obsidian Sync token](#get-sync-token) during the flow. If you skip it, init
still offers to start the server, which then shows a sign-in page at your
`PUBLIC_URL`. Set up HTTPS first: the page sends your Obsidian password to the
server.

**Re-running init** where a setup already exists:

- Stops when the folder holds an install of the other mode (a remote install
for a local setup, or the reverse), and points you to [`start`](#start).
- Asks before changing anything. Declining leaves everything unchanged and
points you to [`configure`](#configure), the command for changing settings
in place.
- Never overwrites existing files without asking.
- Keeps the install's name, and stops if `--instance-name` gives a different
one.
- Keeps the install's port, unless you change it with `--port` or in the
optional settings.

**Running a second vault** on the same machine:

1. Start Docker, so init can check which names and ports are in use.
2. Run init again with a new `--dir`:

```bash
npx vault-cortex@latest init --dir ./vault-cortex-work
```

3. Give the new install its own name and port when init asks. It asks when
the machine already has a `vault-cortex` container or data volume, and
suggests the first port no other container uses. The name is saved as
`INSTANCE_NAME` in the install's `.env` and names its container and data.
4. Pass that `--dir` to every command for the install, such as `upgrade` or
`logs`. Each command acts only on that install.
5. Add the install to your MCP client as its own connector.

Flags:

- `--mode local|remote` — skip the mode prompt
- `--vault-path <path>` — absolute path to your vault (local mode)
- `--dir <path>` — directory to write config files into (default
`./vault-cortex`)
- `--yes` — non-interactive local setup with defaults; requires `--vault-path`
- `--instance-name <name>` — name for this install's container and data
(default `vault-cortex`); lowercase letters, digits, `-` and `_`. Naming an
install that already exists reuses its data, so use it only for the same
vault set up again
- `--port <port>` — host port for this install (default `8000`); a port
another container already publishes is refused
- `--yes` — non-interactive local setup with defaults; requires `--vault-path`,
and `--instance-name` when the name is already in use

Non-interactive example:

Expand Down Expand Up @@ -131,8 +168,10 @@ npx vault-cortex@latest start
This is the command the CLI's own guidance points to whenever the server
isn't running — after [`down`](#down), or an `init` where the server wasn't
started. It's the same cycle as [`restart`](#restart) under the name you'd
look for: any existing container is replaced, and Docker pulls the server
image automatically on a first start.
look for: the install's existing container is replaced, and Docker pulls the
server image automatically on a first start. A container of another app that
holds the install's name is left alone, and `start` stops with an error that
names it.

Use `--dir <path>` if your config isn't in `./vault-cortex`.

Expand Down
62 changes: 59 additions & 3 deletions cli/src/__tests__/command-stubs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { DockerRunner } from "../docker.js"
import {
LOCAL_IMAGE,
type DockerLogsParams,
type DockerRunner,
type DockerRunParams,
} from "../docker.js"
import type { Prompts, SelectOption } from "../prompts.js"

/**
Expand Down Expand Up @@ -167,17 +172,68 @@ export const dockerReady: DockerRunner = {
dockerRun: () => true,
pullImage: () => true,
stopAndRemoveContainer: () => true,
containerExists: () => true,
containerImage: () => LOCAL_IMAGE,
volumeExists: () => true,
publishedHostPorts: () => [],
streamLogs: async () => 0,
}

/**
* Daemon up on a host with no vault-cortex install yet: no container and no
* volume under any name, so init never finds a name in use.
*/
export const dockerReadyEmptyHost: DockerRunner = {
...dockerReady,
containerImage: () => undefined,
volumeExists: () => false,
}

/**
* A ready daemon whose container exists, recording the name every inspect,
* remove, run and logs call targets, in call order, as `<operation>:<name>`.
*/
export const createRecordingDocker = (): {
docker: DockerRunner
calls: string[]
runParams: DockerRunParams[]
logsParams: DockerLogsParams[]
} => {
const calls: string[] = []
const runParams: DockerRunParams[] = []
const logsParams: DockerLogsParams[] = []
const docker: DockerRunner = {
...dockerReady,
containerImage: (instanceName) => {
calls.push(`inspect:${instanceName}`)
return LOCAL_IMAGE
},
stopAndRemoveContainer: (instanceName) => {
calls.push(`rm:${instanceName}`)
return true
},
dockerRun: (params) => {
calls.push(`run:${params.instanceName}`)
runParams.push(params)
return true
},
streamLogs: async (params) => {
calls.push(`logs:${params.instanceName}`)
logsParams.push(params)
return 0
},
}
return { docker, calls, runParams, logsParams }
}

/** Daemon installed but not running — every operation fails. */
export const dockerDown: DockerRunner = {
daemonStatus: () => "not-running",
dockerRun: () => false,
pullImage: () => false,
stopAndRemoveContainer: () => false,
containerExists: () => false,
containerImage: () => undefined,
volumeExists: () => false,
publishedHostPorts: () => [],
streamLogs: async () => 1,
}

Expand Down
Loading
Loading