diff --git a/AGENTS.md b/AGENTS.md index 3df874e02..0a18c16ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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//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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 12ae7df46..94c997de2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 — diff --git a/DEPLOY.md b/DEPLOY.md index fffa40992..d208cb41f 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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 diff --git a/README.md b/README.md index da50de2fb..eb5023b43 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/SECURITY.md b/SECURITY.md index fd7bbcd0a..ca407eb2f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/cli/README.md b/cli/README.md index edae3cb23..5b8543820 100644 --- a/cli/README.md +++ b/cli/README.md @@ -57,11 +57,41 @@ 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: @@ -69,7 +99,14 @@ Flags: - `--vault-path ` — absolute path to your vault (local mode) - `--dir ` — directory to write config files into (default `./vault-cortex`) -- `--yes` — non-interactive local setup with defaults; requires `--vault-path` +- `--instance-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 ` — 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: @@ -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 ` if your config isn't in `./vault-cortex`. diff --git a/cli/src/__tests__/command-stubs.ts b/cli/src/__tests__/command-stubs.ts index f51369655..297909888 100644 --- a/cli/src/__tests__/command-stubs.ts +++ b/cli/src/__tests__/command-stubs.ts @@ -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" /** @@ -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 `:`. + */ +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, } diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index c503e50ee..f76d9f946 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -1,11 +1,12 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { describe, expect, it, onTestFinished } from "vitest" +import { describe, expect, it, onTestFinished, vi } from "vitest" import { runConfigure } from "../configure.js" -import type { DockerRunParams, DockerRunner } from "../docker.js" +import type { DockerRunner } from "../docker.js" import { + createRecordingDocker, createScriptedPrompts, dockerDown, dockerNotInstalled, @@ -14,20 +15,6 @@ import { fetchOk, } from "./command-stubs.js" -/** A ready daemon that records dockerRun calls for start-parameter asserts. */ -const createRecordingDocker = () => { - const dockerRunCalls: DockerRunParams[] = [] - const docker: DockerRunner = { - ...dockerReady, - containerExists: () => false, - dockerRun: (params) => { - dockerRunCalls.push(params) - return true - }, - } - return { docker, dockerRunCalls } -} - // Creates a temp target dir and registers its removal at creation time, so // the suite self-cleans even when an assertion throws mid-test. const makeTempTargetDir = (): string => { @@ -110,6 +97,35 @@ describe("runConfigure with picked settings", () => { ]) }) + it("asks Docker again before offering the restart, in case it stopped during the prompts", async () => { + const targetDir = makeTempTargetDir() + writeLocalEnv(targetDir) + const daemonStatus = vi + .fn() + .mockReturnValueOnce("running") + .mockReturnValue("not-running") + const scripted = createScriptedPrompts([ + ["MEMORY_ENABLED"], + false, // disable the memory layer + ]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker: { ...dockerReady, daemonStatus }, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(daemonStatus).toHaveBeenCalledTimes(2) + expect(scripted.warnings).toEqual([ + `Container runtime not running — settings saved.\nApply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"`, + ]) + // The restart question is never asked. + expect(scripted.asked).toEqual([ + "Any optional settings to change? (press enter to skip)", + "Enable the memory layer (About Me/ folder + memory tools)?", + ]) + }) + it("saves the changes and names the missing runtime when Docker is not installed", async () => { const targetDir = makeTempTargetDir() const envFilePath = writeLocalEnv(targetDir) @@ -139,7 +155,7 @@ describe("runConfigure with picked settings", () => { it("saves the changes and prints the restart hint when the restart is declined", async () => { const targetDir = makeTempTargetDir() const envFilePath = writeLocalEnv(targetDir) - const { docker, dockerRunCalls } = createRecordingDocker() + const { docker, runParams } = createRecordingDocker() const scripted = createScriptedPrompts([ ["MEMORY_ENABLED"], false, // disable the memory layer @@ -155,7 +171,7 @@ describe("runConfigure with picked settings", () => { expect(readFileSync(envFilePath, "utf8")).toBe( LOCAL_ENV_CONTENT.replace("MEMORY_ENABLED=true", "MEMORY_ENABLED=false"), ) - expect(dockerRunCalls).toEqual([]) + expect(runParams).toEqual([]) expect(scripted.logs).toEqual([ `Updated MEMORY_ENABLED in ${targetDir}/.env.`, `Apply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"`, @@ -163,10 +179,38 @@ describe("runConfigure with picked settings", () => { expect(scripted.outros).toEqual(["Done."]) }) + it("re-creates the container a custom INSTANCE_NAME names when the restart is accepted", async () => { + const targetDir = makeTempTargetDir() + const envFilePath = join(targetDir, ".env") + writeFileSync(envFilePath, `${LOCAL_ENV_CONTENT}INSTANCE_NAME=work\n`) + const { docker, runParams } = createRecordingDocker() + const scripted = createScriptedPrompts([ + ["MEMORY_ENABLED"], + false, // disable the memory layer + true, // restart now + ]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(0) + expect(runParams).toEqual([ + { + mode: "local", + instanceName: "work", + envFilePath, + port: 8000, + vaultPath: "/home/user/MyVault", + }, + ]) + }) + it("re-creates the container on the new PORT when the restart is accepted", async () => { const targetDir = makeTempTargetDir() const envFilePath = writeLocalEnv(targetDir) - const { docker, dockerRunCalls } = createRecordingDocker() + const { docker, runParams } = createRecordingDocker() const fetchedUrls: string[] = [] const fetchRecorder: typeof fetch = async (url) => { fetchedUrls.push(String(url)) @@ -186,9 +230,10 @@ describe("runConfigure with picked settings", () => { expect(exitCode).toBe(0) // The restart resolves the deployment from the .env written moments // before, so the new port drives both the port mapping and health URL. - expect(dockerRunCalls).toEqual([ + expect(runParams).toEqual([ { mode: "local", + instanceName: "vault-cortex", envFilePath, port: 9100, vaultPath: "/home/user/MyVault", @@ -207,6 +252,41 @@ describe("runConfigure with picked settings", () => { expect(scripted.outros).toEqual(["Configure complete."]) }) + it("asks again for a PORT another container publishes, and accepts this install's own", async () => { + const targetDir = makeTempTargetDir() + const envFilePath = writeLocalEnv(targetDir) + const docker: DockerRunner = { + ...createRecordingDocker().docker, + publishedHostPorts: () => { + return [ + { hostPort: 8000, containerName: "vault-cortex" }, + { hostPort: 9100, containerName: "other-app" }, + ] + }, + } + const scripted = createScriptedPrompts([ + ["PORT"], + "9100", // taken by "other-app" + "8000", // this install's own port + false, // decline the restart + ]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([ + 'Port 9100 is already used by the "other-app" container on this machine. Pick another port.', + ]) + expect(scripted.textCalls).toEqual([ + { message: "Host port for the server:", defaultValue: "8000", placeholder: "8000" }, + { message: "Host port for the server:", defaultValue: "8000", placeholder: "8000" }, + ]) + expect(readFileSync(envFilePath, "utf8")).toBe(LOCAL_ENV_CONTENT) + }) + it("appends SYNC_MODE on a remote .env that predates the setting", async () => { const targetDir = makeTempTargetDir() const envFilePath = join(targetDir, ".env") @@ -316,7 +396,7 @@ describe("runConfigure with picked settings", () => { envFilePath, "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/MyVault\nMEMORY_ENABLED=true\n", ) - const { docker, dockerRunCalls } = createRecordingDocker() + const { docker, runParams } = createRecordingDocker() const scripted = createScriptedPrompts([ ["MEMORY_ENABLED"], false, // disable the memory layer @@ -330,10 +410,10 @@ describe("runConfigure with picked settings", () => { expect(exitCode).toBe(1) expect(scripted.errors).toEqual([ - `PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` + - `Add this line to your .env:\n PUBLIC_URL=http://localhost:8000`, + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + `Set this line in your .env:\n PUBLIC_URL=http://localhost:8000`, ]) - expect(dockerRunCalls).toEqual([]) + expect(runParams).toEqual([]) // The settings edit itself succeeded before the restart failed — and the // user is told so, with the way to apply once the .env issue is fixed. expect(readFileSync(envFilePath, "utf8")).toBe( @@ -407,7 +487,7 @@ describe("runConfigure with picked settings", () => { writeLocalEnv(targetDir) const dockerRunFails: DockerRunner = { ...dockerReady, - containerExists: () => false, + containerImage: () => undefined, dockerRun: () => false, } const scripted = createScriptedPrompts([ diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index 57ecb2f92..584642484 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -4,10 +4,13 @@ import { buildDockerLogsArgs, buildDockerRunArgs, classifyDaemonStatus, - CONTAINER_NAME, healthPollTimeoutMs, healthTimeoutMessage, + isContainerNamed, + isVaultCortexImage, LOCAL_IMAGE, + parseContainerImage, + parsePublishedHostPorts, pollHealth, probeHealth, REMOTE_IMAGE, @@ -16,10 +19,15 @@ import { const okResponse = new Response(null, { status: 200 }) const failResponse = new Response(null, { status: 500 }) +/** The container health check: an in-container fetch of /healthz on the container's own port. */ +const HEALTH_CMD = + "node -e \"fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" + describe("buildDockerRunArgs", () => { it("produces the correct args for local mode", () => { const args = buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/home/user/vault-cortex/.env", port: 8000, vaultPath: "/home/user/MyVault", @@ -29,7 +37,7 @@ describe("buildDockerRunArgs", () => { "run", "-d", "--name", - CONTAINER_NAME, + "vault-cortex", "--restart", "unless-stopped", "--env-file", @@ -49,7 +57,7 @@ describe("buildDockerRunArgs", () => { "-v", "vault-cortex_mcp_data:/data", "--health-cmd", - expect.stringContaining("healthz"), + HEALTH_CMD, "--health-interval", "15s", "--health-timeout", @@ -65,6 +73,7 @@ describe("buildDockerRunArgs", () => { it("produces the correct args for remote mode", () => { const args = buildDockerRunArgs({ mode: "remote", + instanceName: "vault-cortex", envFilePath: "/opt/vault-cortex/.env", port: 8000, }) @@ -73,7 +82,7 @@ describe("buildDockerRunArgs", () => { "run", "-d", "--name", - CONTAINER_NAME, + "vault-cortex", "--restart", "unless-stopped", "--env-file", @@ -89,7 +98,7 @@ describe("buildDockerRunArgs", () => { "-p", "8000:8000", "--hostname", - CONTAINER_NAME, + "vault-cortex", "-v", "vault-cortex_vault_data:/vault", "-v", @@ -97,7 +106,7 @@ describe("buildDockerRunArgs", () => { "-v", "vault-cortex_obsidian_config:/home/obsidian/.config", "--health-cmd", - expect.stringContaining("healthz"), + HEALTH_CMD, "--health-interval", "15s", "--health-timeout", @@ -119,6 +128,7 @@ describe("buildDockerRunArgs", () => { it("uses a custom port for the host-side mapping", () => { const args = buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 9000, vaultPath: "/vault", @@ -132,6 +142,7 @@ describe("buildDockerRunArgs", () => { it("overrides VAULT_PATH so the host path does not leak into the container", () => { const args = buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, vaultPath: "/Users/me/My Vault", @@ -146,6 +157,7 @@ describe("buildDockerRunArgs", () => { expect(() => buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, }), @@ -155,6 +167,7 @@ describe("buildDockerRunArgs", () => { it("uses compose-prefixed volume names for backward compatibility", () => { const localArgs = buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, vaultPath: "/vault", @@ -163,6 +176,7 @@ describe("buildDockerRunArgs", () => { const remoteArgs = buildDockerRunArgs({ mode: "remote", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, }) @@ -171,9 +185,111 @@ describe("buildDockerRunArgs", () => { expect(remoteArgs).toContain("vault-cortex_obsidian_config:/home/obsidian/.config") }) + it("names the container and every volume after a custom instance in local mode", () => { + const args = buildDockerRunArgs({ + mode: "local", + instanceName: "work", + envFilePath: "/home/user/work/.env", + port: 8001, + vaultPath: "/home/user/WorkVault", + }) + + expect(args).toEqual([ + "run", + "-d", + "--name", + "work", + "--restart", + "unless-stopped", + "--env-file", + "/home/user/work/.env", + "-e", + "VAULT_PATH=/vault", + "-e", + "PORT=8000", + "-e", + "HOST=0.0.0.0", + "-e", + "INDEX_DB_PATH=/data/index.db", + "-p", + "8001:8000", + "-v", + "/home/user/WorkVault:/vault:rw", + "-v", + "work_mcp_data:/data", + "--health-cmd", + HEALTH_CMD, + "--health-interval", + "15s", + "--health-timeout", + "5s", + "--health-retries", + "3", + "--health-start-period", + "20s", + LOCAL_IMAGE, + ]) + }) + + it("names the container, hostname and every volume after a custom instance in remote mode", () => { + const args = buildDockerRunArgs({ + mode: "remote", + instanceName: "work", + envFilePath: "/opt/work/.env", + port: 8001, + }) + + expect(args).toEqual([ + "run", + "-d", + "--name", + "work", + "--restart", + "unless-stopped", + "--env-file", + "/opt/work/.env", + "-e", + "VAULT_PATH=/vault", + "-e", + "PORT=8000", + "-e", + "HOST=0.0.0.0", + "-e", + "INDEX_DB_PATH=/data/index.db", + "-p", + "8001:8000", + "--hostname", + "work", + "-v", + "work_vault_data:/vault", + "-v", + "work_mcp_data:/data", + "-v", + "work_obsidian_config:/home/obsidian/.config", + "--health-cmd", + HEALTH_CMD, + "--health-interval", + "15s", + "--health-timeout", + "5s", + "--health-retries", + "5", + "--health-start-period", + "180s", + "--log-driver", + "json-file", + "--log-opt", + "max-size=10m", + "--log-opt", + "max-file=3", + REMOTE_IMAGE, + ]) + }) + it("includes remote-specific log rotation and longer healthcheck timings", () => { const args = buildDockerRunArgs({ mode: "remote", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, }) @@ -202,18 +318,28 @@ describe("healthPollTimeoutMs", () => { describe("healthTimeoutMessage", () => { it("renders the local budget as 2 minutes", () => { - expect(healthTimeoutMessage("local", 120_000)).toBe( - "Server did not respond within 2 minutes — check: docker logs vault-cortex", - ) + expect( + healthTimeoutMessage({ mode: "local", timeoutMs: 120_000, instanceName: "vault-cortex" }), + ).toBe("Server did not respond within 2 minutes — check: docker logs vault-cortex") }) it("adds the first-sync hint to the remote 4-minute message", () => { - expect(healthTimeoutMessage("remote", 240_000)).toBe( + expect( + healthTimeoutMessage({ mode: "remote", timeoutMs: 240_000, instanceName: "vault-cortex" }), + ).toBe( "Server did not respond within 4 minutes — check: docker logs vault-cortex (a long first sync may still be running — the container keeps starting in the background)", ) }) }) +describe("healthTimeoutMessage with a custom instance", () => { + it("points the logs command at the instance's container", () => { + expect(healthTimeoutMessage({ mode: "local", timeoutMs: 120_000, instanceName: "work" })).toBe( + "Server did not respond within 2 minutes — check: docker logs work", + ) + }) +}) + describe("pollHealth", () => { it("returns true as soon as the endpoint responds ok", async () => { const fetchStub = async (): Promise => okResponse @@ -268,29 +394,39 @@ describe("pollHealth", () => { describe("buildDockerLogsArgs", () => { it("targets the container with no flags by default", () => { - expect(buildDockerLogsArgs({ follow: false })).toEqual(["logs", CONTAINER_NAME]) + expect(buildDockerLogsArgs({ instanceName: "vault-cortex", follow: false })).toEqual([ + "logs", + "vault-cortex", + ]) }) it("adds --follow when requested", () => { - expect(buildDockerLogsArgs({ follow: true })).toEqual(["logs", "--follow", CONTAINER_NAME]) + expect(buildDockerLogsArgs({ instanceName: "vault-cortex", follow: true })).toEqual([ + "logs", + "--follow", + "vault-cortex", + ]) }) it("adds --since with its value when provided", () => { - expect(buildDockerLogsArgs({ follow: false, since: "10m" })).toEqual([ - "logs", - "--since", - "10m", - CONTAINER_NAME, - ]) + expect( + buildDockerLogsArgs({ instanceName: "vault-cortex", follow: false, since: "10m" }), + ).toEqual(["logs", "--since", "10m", "vault-cortex"]) }) it("combines --follow and --since in flag order", () => { - expect(buildDockerLogsArgs({ follow: true, since: "2h" })).toEqual([ + expect( + buildDockerLogsArgs({ instanceName: "vault-cortex", follow: true, since: "2h" }), + ).toEqual(["logs", "--follow", "--since", "2h", "vault-cortex"]) + }) +}) + +describe("buildDockerLogsArgs with a custom instance", () => { + it("targets the instance's container", () => { + expect(buildDockerLogsArgs({ instanceName: "work", follow: true })).toEqual([ "logs", "--follow", - "--since", - "2h", - CONTAINER_NAME, + "work", ]) }) }) @@ -323,6 +459,102 @@ describe("classifyDaemonStatus", () => { }) }) +describe("parsePublishedHostPorts", () => { + it("lists each published host port with its container, with or without a leading slash", () => { + const inspectOutput = [ + "/vault-cortex 8000", + "/work 8001 9443", + "podman-app 7000", + "/no-ports", + "/runtime-picked ", + "", + ].join("\n") + + expect(parsePublishedHostPorts(inspectOutput)).toEqual([ + { hostPort: 8000, containerName: "vault-cortex" }, + { hostPort: 8001, containerName: "work" }, + { hostPort: 9443, containerName: "work" }, + { hostPort: 7000, containerName: "podman-app" }, + ]) + }) + + it("counts every port of a published host-port range", () => { + expect(parsePublishedHostPorts("/ranged 18690-18692\n")).toEqual([ + { hostPort: 18690, containerName: "ranged" }, + { hostPort: 18691, containerName: "ranged" }, + { hostPort: 18692, containerName: "ranged" }, + ]) + }) + + it("keeps every container that publishes the same host port", () => { + const inspectOutput = ["/vault-cortex 8000", "/stopped-test 8000"].join("\n") + + expect(parsePublishedHostPorts(inspectOutput)).toEqual([ + { hostPort: 8000, containerName: "vault-cortex" }, + { hostPort: 8000, containerName: "stopped-test" }, + ]) + }) +}) + +describe("isContainerNamed", () => { + it.each([ + { label: "Docker's slash-prefixed name", inspectedName: "/2025\n" }, + { label: "Podman's bare name", inspectedName: "2025\n" }, + ])("matches $label", ({ inspectedName }) => { + expect(isContainerNamed({ inspectedName, instanceName: "2025" })).toBe(true) + }) + + it("rejects a container that inspect found by an ID prefix", () => { + expect( + isContainerNamed({ inspectedName: "/remote-boot-relative-index-db\n", instanceName: "2025" }), + ).toBe(false) + }) +}) + +describe("parseContainerImage", () => { + it("reads the image of the container with the exact name", () => { + expect( + parseContainerImage({ + inspectOutput: "/work ghcr.io/aliasunder/vault-cortex:remote\n", + instanceName: "work", + }), + ).toBe("ghcr.io/aliasunder/vault-cortex:remote") + }) + + it("returns undefined for a container that inspect found by an ID prefix", () => { + expect( + parseContainerImage({ + inspectOutput: "/remote-boot-relative-index-db postgres:16\n", + instanceName: "2025", + }), + ).toBeUndefined() + }) +}) + +describe("isVaultCortexImage", () => { + it.each([ + { label: "the local tag on GHCR", image: "ghcr.io/aliasunder/vault-cortex:latest" }, + { label: "the remote tag on GHCR", image: "ghcr.io/aliasunder/vault-cortex:remote" }, + { label: "an untagged GHCR name", image: "ghcr.io/aliasunder/vault-cortex" }, + { label: "a Docker Hub tag", image: "aliasunder/vault-cortex:remote" }, + { label: "a fully named Docker Hub tag", image: "docker.io/aliasunder/vault-cortex:0.54.16" }, + { label: "a digest", image: "ghcr.io/aliasunder/vault-cortex@sha256:0123abcd" }, + ])("accepts $label", ({ image }) => { + expect(isVaultCortexImage(image)).toBe(true) + }) + + it.each([ + { label: "another image", image: "postgres:16" }, + { + label: "a repository that only starts with the name", + image: "aliasunder/vault-cortex-fork:1", + }, + { label: "another owner's image", image: "ghcr.io/someone/vault-cortex:latest" }, + ])("rejects $label", ({ image }) => { + expect(isVaultCortexImage(image)).toBe(false) + }) +}) + describe("probeHealth", () => { it("returns true when the endpoint responds ok", async () => { const probeResult = await probeHealth( diff --git a/cli/src/__tests__/env.test.ts b/cli/src/__tests__/env.test.ts index 3ea58853e..10a08cce7 100644 --- a/cli/src/__tests__/env.test.ts +++ b/cli/src/__tests__/env.test.ts @@ -91,12 +91,15 @@ describe("buildRemoteEnv", () => { expect(lines).toContain("VAULT_PASSWORD=hunter2") }) - it("writes an empty OBSIDIAN_AUTH_TOKEN line with a fill-this-in warning when the token was skipped", () => { + it("writes an empty OBSIDIAN_AUTH_TOKEN line under the sign-in options when the token was skipped", () => { const env = buildRemoteEnv({ ...baseAnswers, obsidianAuthToken: "" }) - expect(env).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) - expect(env).toContain("FILL THIS IN") - expect(env).toContain("npx vault-cortex@latest get-sync-token") + expect(env).toContain( + "# Obsidian Sync auth token. Leave empty to sign in on the server's sign-in\n" + + "# page at your PUBLIC_URL once it starts (over HTTPS), or generate one with:\n" + + "# npx vault-cortex@latest get-sync-token\n" + + "OBSIDIAN_AUTH_TOKEN=\n", + ) }) it("states defaulted sync settings as uncommented lines", () => { diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 924598fe1..48c8d56d2 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -4,17 +4,20 @@ import { join } from "node:path" import { describe, expect, it, onTestFinished, vi } from "vitest" import { runInit, validatePublicUrl } from "../init.js" -import { pollHealth } from "../docker.js" -import { buildDockerNotInstalledMessage } from "../messages.js" +import { LOCAL_IMAGE, pollHealth, type DockerRunner } from "../docker.js" +import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage } from "../messages.js" +import { readEnvInstanceName } from "../scaffold.js" vi.mock("../docker.js", { spy: true }) import { + createRecordingDocker, createScriptedPrompts, dockerDaemonOnly, dockerDown, dockerNotInstalled, - dockerReady, + dockerReadyEmptyHost, fetchNever, + fetchOk, } from "./command-stubs.js" const makeVault = (): string => { @@ -444,8 +447,12 @@ describe("runInit interactive local flow", () => { expect(exitCode).toBe(0) expect(scripted.asked).not.toContain("Start the server now?") - expect(scripted.warnings).toHaveLength(1) - expect(scripted.warnings[0]).toContain("Container runtime not running") + expect(scripted.warnings).toEqual([ + nameCheckSkippedWarning("vault-cortex"), + buildDaemonNotRunningMessage( + `, then run:\n npx vault-cortex@latest start --dir "${targetDir}"`, + ), + ]) }) // Message content per platform is pinned test-owned in messages.test.ts — @@ -471,6 +478,7 @@ describe("runInit interactive local flow", () => { expect(exitCode).toBe(0) expect(scripted.asked).not.toContain("Start the server now?") expect(scripted.warnings).toEqual([ + nameCheckSkippedWarning("vault-cortex"), buildDockerNotInstalledMessage({ nextStep: `\nThen start the server with:\n npx vault-cortex@latest start --dir "${targetDir}"`, }), @@ -512,6 +520,7 @@ describe("runInit remote flow", () => { false, // don't generate the token now (declined auto-capture) false, // no end-to-end encryption [], // no optional settings + false, // don't start the server ]) const exitCode = await runInit( { mode: "remote", dir: targetDir }, @@ -529,6 +538,7 @@ describe("runInit remote flow", () => { "Generate the token now?", "Does your vault use end-to-end encryption?", "Any optional settings to change? (press enter to skip)", + "Start the server now?", ]) expect(existsSync(join(targetDir, "docker-compose.yml"))).toBe(false) const envContent = readFileSync(join(targetDir, ".env"), "utf8") @@ -536,7 +546,7 @@ describe("runInit remote flow", () => { expect(envContent).toContain("VAULT_NAME=MyVault\n") expect(envContent).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) expect(scripted.logs).toContain( - "No token yet — run this later to add it to your .env:\n" + + "No Sync token in .env — sign in on the server's sign-in page once it starts, or add a token with:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) }) @@ -559,8 +569,8 @@ describe("runInit remote flow", () => { }, ) - // Token generation is always offered (uses the API, not Docker). With a - // blank token (capture declined), no start offer is shown. + // Token generation is always offered (uses the API, not Docker). Without + // Docker installed, no start offer is shown. expect(exitCode).toBe(0) expect(scripted.asked).toEqual([ "Public base URL clients will use to reach this server (no /mcp — it's added for you):", @@ -605,7 +615,7 @@ describe("runInit remote flow", () => { { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchRecorder, }, ) @@ -619,6 +629,49 @@ describe("runInit remote flow", () => { ]) }) + it("starts the server without a Sync token and points at its sign-in page", async () => { + const targetDir = makeTargetDir() + const fetchedUrls: string[] = [] + const fetchRecorder: typeof fetch = async (input) => { + fetchedUrls.push(String(input)) + return new Response(null, { status: 200 }) + } + const scripted = createScriptedPrompts([ + "https://vault.example.com", // public URL + "MyVault", // vault name + false, // don't generate the token now + false, // no end-to-end encryption + [], // no optional settings + true, // start the server now + ]) + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerReadyEmptyHost, + fetchFn: fetchRecorder, + }, + ) + + expect(exitCode).toBe(0) + // The health poll and the public URL probe only run after docker run. + expect(fetchedUrls).toEqual([ + "http://127.0.0.1:8000/healthz", + "https://vault.example.com/healthz", + ]) + const connectMessage = scripted.prints.find((message) => { + return message.includes("Connect your MCP client") + }) + + expect(connectMessage).toContain( + "The server is running.\n" + + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + "syncing. Open the page:\n" + + " https://vault.example.com\n" + + "It asks for the auth token below and your Obsidian password.\n\n", + ) + }) + it("keeps a successful start at exit 0 when the public URL does not answer", async () => { const targetDir = makeTargetDir() const fetchedUrls: string[] = [] @@ -654,7 +707,7 @@ describe("runInit remote flow", () => { { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchPublicUrlDownWithSignin, }, ) @@ -720,10 +773,10 @@ describe("runInit remote flow", () => { expect(envContent.split("\n")).toContain("OBSIDIAN_AUTH_TOKEN=captured-token") }) - it("skips the start offer when the sync token was left blank", async () => { + it("warns and skips the start offer when the Docker daemon is down", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ - "http://203.0.113.10:8000", + "https://vault.example.com", "MyVault", false, // don't generate the token now (declined auto-capture) false, // no encryption @@ -741,7 +794,12 @@ describe("runInit remote flow", () => { expect(exitCode).toBe(0) expect(scripted.asked).not.toContain("Start the server now?") - expect(readFileSync(join(targetDir, ".env"), "utf8")).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) + expect(scripted.warnings).toEqual([ + nameCheckSkippedWarning("vault-cortex"), + buildDaemonNotRunningMessage( + `, then run:\n npx vault-cortex@latest start --dir "${targetDir}"`, + ), + ]) }) it("preserves existing token and offers start when capture is declined on re-init", async () => { @@ -815,7 +873,8 @@ describe("runInit re-init guard", () => { it("backs out of remote init before any mode-specific question when the dir already holds a deployment", async () => { const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=existing\n") + const existingEnv = "MCP_AUTH_TOKEN=existing\nOBSIDIAN_AUTH_TOKEN=\n" + writeFileSync(join(targetDir, ".env"), existingEnv) const scripted = createScriptedPrompts([ false, // existing deployment found — do not re-run setup (default) ]) @@ -844,7 +903,7 @@ describe("runInit re-init guard", () => { `Nothing changed. To adjust settings instead: npx vault-cortex@latest configure --dir "${targetDir}"`, ]) expect(scripted.outros).toEqual(["Done."]) - expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe("MCP_AUTH_TOKEN=existing\n") + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) }) it("backs out of local init after the dir prompt when it already holds a deployment", async () => { @@ -883,6 +942,83 @@ describe("runInit re-init guard", () => { expect(scripted.outros).toEqual(["Done."]) expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe("MCP_AUTH_TOKEN=existing\n") }) + + it("stops local init on a folder that holds a remote install, before asking about the existing install", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const existingEnv = "MCP_AUTH_TOKEN=existing\nOBSIDIAN_AUTH_TOKEN=\nVAULT_NAME=Personal\n" + writeFileSync(join(targetDir, ".env"), existingEnv) + const dockerRun = vi.fn(() => true) + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReadyEmptyHost, dockerRun }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `${targetDir} holds a remote install, and this run sets up a local one.\n` + + `To start the existing install: npx vault-cortex@latest start --dir "${targetDir}"\n` + + "To set up a local install, run init again with another --dir.", + ]) + expect(scripted.asked).toEqual([]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + expect(dockerRun).not.toHaveBeenCalled() + }) + + it("asks the usual re-run question for a .env that init did not write", async () => { + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const otherAppEnv = "DATABASE_URL=postgres://localhost/app\n" + writeFileSync(join(targetDir, ".env"), otherAppEnv) + const scripted = createScriptedPrompts([ + false, // do not re-run setup + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { prompts: scripted.prompts, docker: dockerReadyEmptyHost, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([]) + expect(scripted.asked).toEqual(["Re-run setup for this directory anyway?"]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(otherAppEnv) + }) + + it("stops remote init on a folder that holds a local install, before asking about the existing install", async () => { + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const existingEnv = `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${makeVault()}\n` + writeFileSync(join(targetDir, ".env"), existingEnv) + const dockerRun = vi.fn(() => true) + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReadyEmptyHost, dockerRun }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `${targetDir} holds a local install, and this run sets up a remote one.\n` + + `To start the existing install: npx vault-cortex@latest start --dir "${targetDir}"\n` + + "To set up a remote install, run init again with another --dir.", + ]) + expect(scripted.asked).toEqual([]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + expect(dockerRun).not.toHaveBeenCalled() + }) }) describe("runInit with a kept existing .env", () => { @@ -899,7 +1035,10 @@ describe("runInit with a kept existing .env", () => { const vaultDir = makeVault() const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=existing\n") + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${vaultDir}\nPUBLIC_URL=http://localhost:8000\n`, + ) const scripted = createScriptedPrompts(keepEnvAnswers(vaultDir, targetDir)) const exitCode = await runInit( @@ -923,7 +1062,10 @@ describe("runInit with a kept existing .env", () => { const vaultDir = makeVault() const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=existing\nPORT=9000\n") + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${vaultDir}\nPORT=9000\nPUBLIC_URL=http://localhost:9000\n`, + ) const scripted = createScriptedPrompts([ ...keepEnvAnswers(vaultDir, targetDir), true, // start the server now @@ -938,7 +1080,7 @@ describe("runInit with a kept existing .env", () => { {}, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchRecorder, }, ) @@ -947,6 +1089,55 @@ describe("runInit with a kept existing .env", () => { expect(fetchedUrls).toEqual(["http://127.0.0.1:9000/healthz"]) expect(scripted.prints[0]).toContain("http://localhost:9000/mcp") }) + + it("stops instead of starting a kept .env with no PUBLIC_URL, as start does", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync(join(targetDir, ".env"), `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${vaultDir}\n`) + const scripted = createScriptedPrompts(keepEnvAnswers(vaultDir, targetDir)) + const recording = createRecordingDocker() + + const exitCode = await runInit( + {}, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + "Set this line in your .env:\n PUBLIC_URL=http://localhost:8000", + ]) + expect(recording.runParams).toEqual([]) + expect(scripted.prints).toEqual([]) + }) + + it("mounts the kept file's vault folder, not this run's, as start does", async () => { + const promptedVaultDir = makeVault() + const keptVaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${keptVaultDir}\nPUBLIC_URL=http://localhost:8000\n`, + ) + const scripted = createScriptedPrompts([ + ...keepEnvAnswers(promptedVaultDir, targetDir), + true, // start the server now + ]) + const recording = createRecordingDocker() + const fetchHealthy: typeof fetch = async () => { + return new Response(null, { status: 200 }) + } + + const exitCode = await runInit( + {}, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchHealthy }, + ) + + expect(exitCode).toBe(0) + expect(recording.runParams.map((params) => params.vaultPath)).toEqual([keptVaultDir]) + }) }) describe("runInit --vault-path flag in interactive mode", () => { @@ -986,6 +1177,7 @@ describe("runInit remote encryption password", () => { true, // vault uses end-to-end encryption "hunter2", // password (masked prompt) [], // no optional settings + false, // don't start the server ]) const exitCode = await runInit( { mode: "remote", dir: targetDir }, @@ -1048,7 +1240,7 @@ describe("runInit remote with a kept existing .env", () => { { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchRecorder, }, ) @@ -1063,6 +1255,78 @@ describe("runInit remote with a kept existing .env", () => { expect(scripted.prints[0]).toContain("https://persisted.example.com/mcp") expect(scripted.prints[0]).not.toContain("prompted.example.com") }) + + /** Captures a Sync token this run, then keeps an existing .env that has none, and starts. */ + const runInitCapturingTokenThenKeepingEnv = async ( + targetDir: string, + ): Promise<{ exitCode: number; scripted: ReturnType }> => { + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=existing\n" + + "OBSIDIAN_AUTH_TOKEN=\n" + + "VAULT_NAME=MyVault\n" + + "PUBLIC_URL=https://persisted.example.com\n", + ) + const fetchSigninSuccess: typeof fetch = async (input) => { + if (!String(input).includes("api.obsidian.md")) return new Response(null, { status: 200 }) + return new Response( + JSON.stringify({ token: "sync-token-xyz", name: "User", email: "user@example.com" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + const scripted = createScriptedPrompts([ + true, // existing deployment found — re-run setup anyway + "https://prompted.example.com", // public URL + "MyVault", // vault name + true, // generate the token now + "user@example.com", // email + "password", // password + false, // no end-to-end encryption + [], // no optional settings + false, // .env differs — keep the existing file + true, // start the server now + ]) + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerReadyEmptyHost, + fetchFn: fetchSigninSuccess, + }, + ) + return { exitCode, scripted } + } + + it("warns that this run's Sync token wasn't saved when the existing .env is kept", async () => { + const targetDir = makeTargetDir() + + const { exitCode, scripted } = await runInitCapturingTokenThenKeepingEnv(targetDir) + + expect(exitCode).toBe(0) + expect(scripted.warnings).toEqual([ + `The Sync token from this sign-in wasn't saved, because ${targetDir}/.env was kept. To add it, run:\n` + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, + ]) + }) + + it("points at the sign-in page when the kept file has no Sync token, despite this run's capture", async () => { + const targetDir = makeTargetDir() + + const { exitCode, scripted } = await runInitCapturingTokenThenKeepingEnv(targetDir) + + expect(exitCode).toBe(0) + expect(readFileSync(join(targetDir, ".env"), "utf8").split("\n")).toContain( + "OBSIDIAN_AUTH_TOKEN=", + ) + expect(scripted.prints[0]).toContain( + "The server is running.\n" + + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + "syncing. Open the page:\n" + + " https://persisted.example.com\n" + + "It asks for the auth token below and your Obsidian password.\n\n", + ) + }) }) describe("runInit sync-token auto-capture fallback", () => { @@ -1081,6 +1345,7 @@ describe("runInit sync-token auto-capture fallback", () => { "bad-password", // password false, // no encryption [], // no optional settings + false, // don't start the server ]) await runInit( { mode: "remote", dir: targetDir }, @@ -1094,7 +1359,7 @@ describe("runInit sync-token auto-capture fallback", () => { expect(scripted.warnings[0]).toBe("Could not sign in: Invalid email or password") expect(readFileSync(join(targetDir, ".env"), "utf8")).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) expect(scripted.logs).toContain( - "No token yet — run this later to add it to your .env:\n" + + "No Sync token in .env — sign in on the server's sign-in page once it starts, or add a token with:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) }) @@ -1197,7 +1462,8 @@ describe("runInit guided optional settings", () => { const vaultDir = makeVault() const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=existing\n") + const existingEnv = `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${vaultDir}\nPUBLIC_URL=http://localhost:8000\n` + writeFileSync(join(targetDir, ".env"), existingEnv) const scripted = createScriptedPrompts([ "local", vaultDir, @@ -1229,7 +1495,7 @@ describe("runInit guided optional settings", () => { ]) // Keeping at the conflict prompt still protects the file (the write // report states the discard); no configure-pointer log remains. - expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe("MCP_AUTH_TOKEN=existing\n") + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) }) it("applies chooser answers when a consented re-init overwrites the existing .env", async () => { @@ -1321,7 +1587,7 @@ describe("runInit health-timeout returns starting status", () => { {}, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchNever, }, ) @@ -1367,7 +1633,7 @@ describe("runInit health-timeout returns starting status", () => { { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchWithSignin, }, ) @@ -1493,3 +1759,1338 @@ describe("validatePublicUrl", () => { }) }) }) + +// ── Instance names ──────────────────────────────────────────────────────────── + +/** A running daemon on a host where these container and volume names already exist. */ +const dockerWithExisting = (existingNames: ReadonlyArray): DockerRunner => { + return { + ...dockerDaemonOnly, + containerImage: (instanceName) => { + return existingNames.includes(instanceName) ? LOCAL_IMAGE : undefined + }, + volumeExists: (volumeName) => existingNames.includes(volumeName), + } +} + +/** dockerWithExisting, plus host ports other containers publish. */ +const dockerWithPorts = (params: { + existingNames: ReadonlyArray + publishedPorts: ReadonlyArray +}): DockerRunner => { + return { + ...dockerWithExisting(params.existingNames), + publishedHostPorts: () => { + return params.publishedPorts.map(([hostPort, containerName]) => ({ hostPort, containerName })) + }, + } +} + +const readEnvLines = (targetDir: string): string[] => { + return readFileSync(join(targetDir, ".env"), "utf8").split("\n") +} + +const IDENTITY_KEYS = ["PUBLIC_URL", "INSTANCE_NAME", "PORT", "DEVICE_NAME"] + +/** + * Every active identity line in the written .env, in file order. The file also + * holds a random token, so these lines stand in for the whole file; listing + * them all catches a duplicate or leftover default line. + */ +const readIdentityLines = (targetDir: string): string[] => { + return readEnvLines(targetDir).filter((line) => { + return IDENTITY_KEYS.some((key) => line.startsWith(`${key}=`)) + }) +} + +const IN_USE_QUESTION = + 'An install named "vault-cortex" already exists on this machine (its container or data volumes are here). What should this install do?' +const NAME_QUESTION = "Name for this install (lowercase letters, digits, - and _):" +const PORT_QUESTION = "Host port for the server:" +const nameCheckSkippedWarning = (instanceName: string): string => { + return ( + `Docker isn't running, so init can't check whether another install on this machine already uses the name "${instanceName}".\n` + + "If one does, give this install its own INSTANCE_NAME and PORT in its .env before you start it." + ) +} +const REPLACE_START_QUESTION = + 'Start the server now? This stops and replaces the existing "vault-cortex" container. Its data volumes are kept.' + +describe("runInit when the instance name is already in use", () => { + it("gives a local install its own name and port, with PUBLIC_URL on that port", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "work", + "8001", + [], // no optional settings + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [[8000, "vault-cortex"]], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).toEqual([ + IN_USE_QUESTION, + NAME_QUESTION, + PORT_QUESTION, + "Any optional settings to change? (press enter to skip)", + "Start the server now?", + ]) + // The install using the default name publishes port 8000. + expect(scripted.textCalls[1]).toEqual({ + message: PORT_QUESTION, + defaultValue: "8001", + placeholder: "8001", + }) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + // The chooser reads the identity, so a picked Host port starts from 8001. + const portOption = scripted.multiselectCalls[0].options.find( + (option) => option.value === "PORT", + ) + expect(portOption?.hint).toBe("PORT · currently 8001") + expect(scripted.prints[0]).toContain( + "claude mcp add --scope user --transport http work http://localhost:8001/mcp\n", + ) + }) + + it("gives a remote install its own name, port and device name before asking the URL", async () => { + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "work", + "8001", + "https://work.example.com", // public URL + "Work", // vault name + false, // don't generate the token now + false, // no encryption + [], // no optional settings + false, // don't start the server + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked.slice(0, 4)).toEqual([ + IN_USE_QUESTION, + NAME_QUESTION, + PORT_QUESTION, + "Public base URL clients will use to reach this server (no /mcp — it's added for you):", + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://work.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + "DEVICE_NAME=work", + ]) + }) + + it("gives a remote install the device name of a free --instance-name", async () => { + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "https://work.example.com", // public URL + "Work", // vault name + false, // don't generate the token now + false, // no encryption + [], // no optional settings + false, // don't start the server + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir, instanceName: "work", port: "8001" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked[0]).toBe( + "Public base URL clients will use to reach this server (no /mcp — it's added for you):", + ) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://work.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + "DEVICE_NAME=work", + ]) + }) + + it("offers the first free port and asks again for one another container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "second", + "8001", // taken by the "work" container + "8003", + [], // no optional settings + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [ + [8000, "vault-cortex"], + [8001, "work"], + ], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls[1]).toEqual({ + message: PORT_QUESTION, + defaultValue: "8002", + placeholder: "8002", + }) + expect(scripted.errors).toEqual([ + 'Port 8001 is already used by the "work" container on this machine. Pick another port.', + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8003", + "INSTANCE_NAME=second", + "PORT=8003", + ]) + }) + + it("suggests a free port after the answer is one another container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "second", + "9000", // the --port suggestion, taken by "other-app" + "8001", + [], // no optional settings + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [ + [8000, "vault-cortex"], + [9000, "other-app"], + ], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls.slice(1)).toEqual([ + { message: PORT_QUESTION, defaultValue: "9000", placeholder: "9000" }, + { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + ]) + expect(scripted.errors).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine. Pick another port.', + ]) + }) + + it("suggests the first free port for a reuse when another container publishes the default port", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", "8001", [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [[8000, "other-app"]], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls).toEqual([ + { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + ]) + expect(scripted.errors).toEqual([]) + }) + + it("asks again when a re-run's --port is one another container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\n`, + ) + const scripted = createScriptedPrompts([ + true, // re-run setup + "8001", // the install's own port + [], // no optional settings + true, // overwrite .env + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["work"], + publishedPorts: [ + [8001, "work"], + [9000, "other-app"], + ], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.warnings).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine.', + ]) + expect(scripted.textCalls).toEqual([ + { message: PORT_QUESTION, defaultValue: "8000", placeholder: "8000" }, + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("stops --yes on a re-run whose --port another container publishes, before writing", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const existingEnv = `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\n` + writeFileSync(join(targetDir, ".env"), existingEnv) + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { vaultPath: vaultDir, dir: targetDir, port: "9000", yes: true }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["work"], + publishedPorts: [[9000, "other-app"]], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine. Re-run with a free port, such as --port 8000.', + ]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + }) + + it("asks again when the port picked in the optional settings is one another container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + ["PORT"], + "9000", // taken by "other-app" + "8001", + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ existingNames: [], publishedPorts: [[9000, "other-app"]] }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls).toEqual([ + { message: PORT_QUESTION, defaultValue: "8000", placeholder: "8000" }, + { message: PORT_QUESTION, defaultValue: "8000", placeholder: "8000" }, + ]) + expect(scripted.errors).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine. Pick another port.', + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=vault-cortex", + "PORT=8001", + ]) + }) + + it("lets a reuse keep the port the reused install's own container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", "8001", [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [[8001, "vault-cortex"]], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=vault-cortex", + "PORT=8001", + ]) + }) + + it.each([ + { label: "the default port", port: "8000" }, + { label: "another port", port: "8001" }, + ])("asks the port of the install whose data is reused, and writes $label", async ({ port }) => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", port, [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).toEqual([ + IN_USE_QUESTION, + PORT_QUESTION, + "Any optional settings to change? (press enter to skip)", + REPLACE_START_QUESTION, + ]) + expect(readIdentityLines(targetDir)).toEqual([ + `PUBLIC_URL=http://localhost:${port}`, + "INSTANCE_NAME=vault-cortex", + `PORT=${port}`, + ]) + }) + + it("asks the port when --instance-name reuses an install without --yes or --port", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["8002", [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "work" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked[0]).toBe(PORT_QUESTION) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8002", + "INSTANCE_NAME=work", + "PORT=8002", + ]) + }) + + it("replaces the existing container when the user reuses its data and starts now", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", "8000", [], true]) + const recording = createRecordingDocker() + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked.at(-1)).toBe(REPLACE_START_QUESTION) + expect(recording.calls).toEqual([ + "inspect:vault-cortex", + "inspect:vault-cortex", + "rm:vault-cortex", + "run:vault-cortex", + ]) + }) + + it("starts nothing when the existing container can't be removed", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", "8000", [], true]) + const recording = createRecordingDocker() + const docker: DockerRunner = { ...recording.docker, stopAndRemoveContainer: () => false } + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([ + "Could not remove the existing container — check: docker rm -f vault-cortex", + ]) + expect(recording.calls).toEqual(["inspect:vault-cortex", "inspect:vault-cortex"]) + }) + + it("asks for a new name, without offering reuse, when another app's container holds the default name", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "work", + "8001", + [], // no optional settings + false, // don't start now + ]) + const docker: DockerRunner = { + ...dockerWithExisting([]), + containerImage: (instanceName) => { + return instanceName === "vault-cortex" ? "postgres:16" : undefined + }, + } + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).toEqual([ + NAME_QUESTION, + PORT_QUESTION, + "Any optional settings to change? (press enter to skip)", + "Start the server now?", + ]) + expect(scripted.warnings).toEqual([ + 'A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this install needs another name.', + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("stops --instance-name when another app's container holds that name", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts() + const docker: DockerRunner = { + ...dockerWithExisting([]), + containerImage: (instanceName) => { + return instanceName === "work" ? "postgres:16" : undefined + }, + } + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, yes: true, instanceName: "work" }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + 'A container named "work" runs postgres:16, not Vault Cortex, so this install needs another name. Re-run with --instance-name and a name no other container uses.', + ]) + expect(existsSync(join(targetDir, ".env"))).toBe(false) + }) + + it("leaves a re-run's container that runs another image in place and does not offer to start", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nPUBLIC_URL=http://localhost:8001\nINSTANCE_NAME=work\nPORT=8001\n`, + ) + // No answer for a start question: asking one fails the test. + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + [], // no optional settings + true, // overwrite the existing file + ]) + const recording = createRecordingDocker() + const docker: DockerRunner = { + ...recording.docker, + containerImage: (instanceName) => { + recording.calls.push(`inspect:${instanceName}`) + return "postgres:16" + }, + } + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([ + `A container named "work" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + "To keep this install's data, rename that container: docker rename work \n" + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, + ]) + expect(recording.calls).toEqual(["inspect:work"]) + }) + + it("counts a remote install as in use when only its vault volume is left", async () => { + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "reuse", + "8000", + "https://vault.example.com", + "MyVault", + false, + false, + [], + false, + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex_vault_data"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked[0]).toBe(IN_USE_QUESTION) + }) + + it("suggests the folder name and re-asks for an invalid or in-use name", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "Work Vault", // invalid + "out-taken", // in use + "work", + "8001", + [], + false, + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "out-taken"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls[0]).toEqual({ + message: NAME_QUESTION, + defaultValue: "out", + placeholder: "out", + }) + expect(scripted.errors).toEqual([ + '"Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.', + '"out-taken" is already in use on this machine — pick another name.', + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("suggests no name when the folder's name is the one in use", async () => { + const vaultDir = makeVault() + const targetDir = join(makeTargetDir(), "vault-cortex") + const scripted = createScriptedPrompts(["own-name", "work", "8001", [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls[0]).toEqual({ + message: NAME_QUESTION, + defaultValue: undefined, + placeholder: "work", + }) + }) + + it("skips the check when the Docker daemon is not running", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([[]]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerWithExisting(["vault-cortex"]), daemonStatus: () => "not-running" }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).toEqual(["Any optional settings to change? (press enter to skip)"]) + }) +}) + +describe("runInit when another container publishes the new install's port", () => { + it("stops --yes on a --port another container publishes, before writing anything", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts() + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, yes: true, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ existingNames: [], publishedPorts: [[9000, "other-app"]] }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine. Re-run with a free port, such as --port 8000.', + ]) + expect(existsSync(join(targetDir, ".env"))).toBe(false) + }) + + it("stops --yes when the default port is taken, naming the first free one", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts() + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, yes: true }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ existingNames: [], publishedPorts: [[8000, "other-app"]] }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + 'Port 8000 is already used by the "other-app" container on this machine. Re-run with a free port, such as --port 8001.', + ]) + expect(existsSync(join(targetDir, ".env"))).toBe(false) + }) + + it("warns and asks for another port when the default one is taken", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["", [], false]) // accept the suggested port + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ existingNames: [], publishedPorts: [[8000, "other-app"]] }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.warnings).toEqual([ + 'Port 8000 is already used by the "other-app" container on this machine.', + ]) + expect(scripted.textCalls).toEqual([ + { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=vault-cortex", + "PORT=8001", + ]) + }) +}) + +describe("runInit --yes when the instance name is already in use", () => { + it("exits 1 without --instance-name and writes nothing", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { yes: true, vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(existsSync(targetDir)).toBe(false) + expect(scripted.errors).toEqual([ + 'An install named "vault-cortex" already exists on this machine (its container or data volumes are here).\n' + + "Re-run with --instance-name (and --port if vault-cortex's port is taken) to give this install its own name,\n" + + "or with --instance-name vault-cortex (and --port unless that install uses 8000) to reuse its data for the same vault.", + ]) + }) + + it("gives the install the free name from --instance-name, with --port and PUBLIC_URL on it", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { yes: true, vaultPath: vaultDir, dir: targetDir, instanceName: "work", port: "8001" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.logs).not.toContain('Reusing the data of the existing "work" install.') + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it.each([ + { label: "the default name", instanceName: "vault-cortex" }, + { label: "a custom name", instanceName: "work" }, + ])("reuses an occupied $label named by --instance-name", async ({ instanceName }) => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { yes: true, vaultPath: vaultDir, dir: targetDir, instanceName }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([instanceName]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.logs).toContain(`Reusing the data of the existing "${instanceName}" install.`) + expect(readEnvInstanceName(join(targetDir, ".env"))).toEqual({ kind: "ok", instanceName }) + }) + + it("gives a remote install reusing a name from --instance-name that name as its device name", async () => { + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "https://work.example.com", // public URL + "Work", // vault name + false, // don't generate the token now + false, // no encryption + [], // no optional settings + false, // don't start the server + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir, instanceName: "work", port: "8001" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.logs).toContain('Reusing the data of the existing "work" install.') + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://work.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + "DEVICE_NAME=work", + ]) + }) + + it.each([ + { + label: "an invalid --instance-name", + flags: { instanceName: "Work" }, + expectedError: + '--instance-name: "Work" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.', + }, + { + label: "an empty --instance-name", + flags: { instanceName: "" }, + expectedError: + '--instance-name: "" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.', + }, + { + label: "an invalid --port", + flags: { port: "80a" }, + expectedError: "--port must be a whole number between 1 and 65535.", + }, + { + label: "an empty --port", + flags: { port: "" }, + expectedError: "--port must be a whole number between 1 and 65535.", + }, + ])("exits 1 on $label", async ({ flags, expectedError }) => { + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { yes: true, vaultPath: makeVault(), dir: makeTargetDir(), ...flags }, + { prompts: scripted.prompts, docker: dockerReadyEmptyHost, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([expectedError]) + }) +}) + +describe("runInit re-run keeps the instance identity", () => { + const writeExistingLocalEnv = (targetDir: string, vaultDir: string): void => { + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\nPUBLIC_URL=http://localhost:8001\n`, + ) + } + + it("keeps the name, port and URL of a local install when the new file overwrites it", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + writeExistingLocalEnv(targetDir, vaultDir) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + [], // no optional settings + true, // overwrite the existing file + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).not.toContain(IN_USE_QUESTION) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + expect(readEnvLines(targetDir)).not.toContain("MCP_AUTH_TOKEN=old") + }) + + it("points a generated PUBLIC_URL at the carried port when the old file has none", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\n`, + ) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + [], // no optional settings + true, // overwrite the existing file + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("points a generated PUBLIC_URL at a quoted carried port without its quotes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT="8001"\n`, + ) + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("warns when a re-run's chosen port leaves a custom PUBLIC_URL as it was", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\nPUBLIC_URL=https://vault.example.com\n`, + ) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + ["PORT"], // change the host port + "9001", + true, // overwrite the existing file + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://vault.example.com", + "INSTANCE_NAME=work", + "PORT=9001", + ]) + expect(scripted.warnings).toEqual([ + "PORT changed — make sure PUBLIC_URL (https://vault.example.com) still reaches the server.", + ]) + }) + + it("keeps a custom PUBLIC_URL carried beside a changed port", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\nPUBLIC_URL=https://vault.example.com\n`, + ) + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://vault.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("refuses a re-run whose --instance-name renames the install, and keeps its file", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + writeExistingLocalEnv(targetDir, vaultDir) + const existingEnv = readFileSync(join(targetDir, ".env"), "utf8") + const scripted = createScriptedPrompts([true]) // re-run setup anyway + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "other" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `--instance-name: the install in ${targetDir} is named "work", and init keeps that name.\n` + + `To rename it, stop it first (npx vault-cortex@latest down --dir "${targetDir}"), then change INSTANCE_NAME in its .env\n` + + "to a name no other install uses: it starts on new, empty volumes under the new name.", + ]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + }) + + it("reports an invalid kept name instead of the rename steps when --instance-name differs", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=Work Vault\n`, + ) + const scripted = createScriptedPrompts([true]) // re-run setup anyway + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "work" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `INSTANCE_NAME "Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit. Fix it in ${targetDir}/.env.`, + ]) + }) + + it("stops a re-run whose kept PORT is not a port, before asking anything more", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const existingEnv = `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nPORT=80a\n` + writeFileSync(join(targetDir, ".env"), existingEnv) + const scripted = createScriptedPrompts([true]) // re-run setup anyway + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PORT "80a" is not a valid port — use a whole number between 1 and 65535. Fix it in ${targetDir}/.env.`, + ]) + expect(scripted.asked).toHaveLength(1) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + }) + + it("warns when --port moves a re-run off the port its custom PUBLIC_URL serves", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nPUBLIC_URL=https://vault.example.com\n`, + ) + // re-run anyway, no optional settings, overwrite .env, don't start + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.warnings).toEqual([ + "PORT changed — make sure PUBLIC_URL (https://vault.example.com) still reaches the server.", + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://vault.example.com", + "INSTANCE_NAME=vault-cortex", + "PORT=9000", + ]) + }) + + it("lets --port replace a kept PORT that is not a port, moving the localhost PUBLIC_URL with it", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nPORT=80a\nPUBLIC_URL=http://localhost:80a\n`, + ) + // re-run anyway, no optional settings, overwrite .env, don't start + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:9000", + "INSTANCE_NAME=vault-cortex", + "PORT=9000", + ]) + }) + + it("accepts a re-run whose --instance-name repeats the install's name", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + writeExistingLocalEnv(targetDir, vaultDir) + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "work" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("prints commands for the kept file's port, not this run's --port", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + writeExistingLocalEnv(targetDir, vaultDir) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + [], // no optional settings + false, // keep the existing file + false, // don't start now + ]) + + // --port makes this run's generated .env differ from the kept one, so the + // printed commands show which file they were read from. + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "8002" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readEnvLines(targetDir)).toContain("MCP_AUTH_TOKEN=old") + expect(scripted.prints[0]).toContain( + "claude mcp add --scope user --transport http work http://localhost:8001/mcp\n", + ) + }) + + it("keeps the name, port and device name of a remote install", async () => { + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=old\nOBSIDIAN_AUTH_TOKEN=sync-token\nVAULT_NAME=Work\nPUBLIC_URL=https://work.example.com\nINSTANCE_NAME=work\nPORT=8001\nDEVICE_NAME=work-sync\n", + ) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + "https://new.example.com", // a remote PUBLIC_URL is asked again, not carried + "Work", + false, // don't generate a new token + false, // no encryption + [], // no optional settings + true, // overwrite the existing file + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).not.toContain(IN_USE_QUESTION) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://new.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + "DEVICE_NAME=work-sync", + ]) + }) + + it("stops before asking anything more when the kept file's INSTANCE_NAME is invalid", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const existingEnv = `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=Work Vault\n` + writeFileSync(join(targetDir, ".env"), existingEnv) + const dockerRun = vi.fn(() => true) + const scripted = createScriptedPrompts([true]) // re-run setup anyway + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReadyEmptyHost, dockerRun }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `INSTANCE_NAME "Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit. Fix it in ${targetDir}/.env.`, + ]) + expect(scripted.asked).toEqual(["Re-run setup for this directory anyway?"]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + expect(scripted.prints).toEqual([]) + expect(dockerRun).not.toHaveBeenCalled() + }) +}) diff --git a/cli/src/__tests__/instance-identity.test.ts b/cli/src/__tests__/instance-identity.test.ts new file mode 100644 index 000000000..f16ddd8fc --- /dev/null +++ b/cli/src/__tests__/instance-identity.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" + +import { applyInstanceIdentity } from "../instance-identity.js" + +const GENERATED_LOCAL_ENV = + "MCP_AUTH_TOKEN=new\nVAULT_PATH=/vault\nPUBLIC_URL=http://localhost:8000\nPORT=8000\nINSTANCE_NAME=vault-cortex\n" + +describe("applyInstanceIdentity", () => { + it("writes carried values verbatim, keeping a custom PUBLIC_URL beside a carried port", () => { + const envContent = applyInstanceIdentity(GENERATED_LOCAL_ENV, { + carried: { + INSTANCE_NAME: "work", + PORT: "8001", + PUBLIC_URL: "https://work.example.com", + }, + chosen: {}, + }) + + expect(envContent).toBe( + "MCP_AUTH_TOKEN=new\nVAULT_PATH=/vault\nPUBLIC_URL=https://work.example.com\nPORT=8001\nINSTANCE_NAME=work\n", + ) + }) + + it("moves the generated localhost PUBLIC_URL to a chosen port", () => { + const envContent = applyInstanceIdentity(GENERATED_LOCAL_ENV, { + carried: {}, + chosen: { INSTANCE_NAME: "work", PORT: "8001" }, + }) + + expect(envContent).toBe( + "MCP_AUTH_TOKEN=new\nVAULT_PATH=/vault\nPUBLIC_URL=http://localhost:8001\nPORT=8001\nINSTANCE_NAME=work\n", + ) + }) + + it("lets a chosen value win over a carried one", () => { + const envContent = applyInstanceIdentity(GENERATED_LOCAL_ENV, { + carried: { INSTANCE_NAME: "old-name", PORT: "8001", PUBLIC_URL: "http://localhost:8001" }, + chosen: { PORT: "8002" }, + }) + + expect(envContent).toBe( + "MCP_AUTH_TOKEN=new\nVAULT_PATH=/vault\nPUBLIC_URL=http://localhost:8002\nPORT=8002\nINSTANCE_NAME=old-name\n", + ) + }) +}) diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index fda99a7ae..9d9b4956c 100644 --- a/cli/src/__tests__/integration/cli-pty.test.ts +++ b/cli/src/__tests__/integration/cli-pty.test.ts @@ -19,6 +19,9 @@ import { // Down arrow in terminal escape sequences const DOWN = "\x1b[B" +/** Matches an active .env line that sets a local install's name, port or URL. */ +const LOCAL_IDENTITY_LINE = /^(PUBLIC_URL|INSTANCE_NAME|PORT)=/ + describe("init local", () => { it("completes the happy path (decline start)", async () => { const { vaultDir, configDir } = createPtyWorkDir() @@ -62,6 +65,59 @@ describe("init local", () => { expect(envContent).toMatch(/^MCP_AUTH_TOKEN=\S+$/m) }) + it("gives a second install its own name and port when the default name is in use", async () => { + const { vaultDir, configDir } = createPtyWorkDir() + + const prompts: PtyPrompt[] = [ + { match: "How do you want to run", send: "\r", label: "mode → local" }, + { + match: "Path to your Obsidian vault", + send: `${vaultDir}\r`, + label: "vault path", + }, + { + match: "Where should I put the config", + send: `${configDir}\r`, + label: "config dir", + }, + { + match: 'An install named "vault-cortex" already exists', + send: "\r", + label: "name in use → own name", + }, + { match: "Name for this install", send: "work\r", label: "name → work" }, + { match: "Host port", send: "8001\r", label: "port → 8001" }, + { + match: "Any optional settings", + send: "\r", + label: "optional settings → skip", + }, + { match: "Start the server now", send: "n\r", label: "start → no" }, + ] + + const result = await drivePty({ + args: ["init"], + workDir: vaultDir, + prompts, + env: { DOCKER_SHIM_PRESENT: "vault-cortex vault-cortex_mcp_data" }, + }) + + expect(result.exitCode).toBe(0) + expect(result.promptsAnswered).toBe(result.totalPrompts) + expect(result.transcript).toContain("--transport http work http://localhost:8001/mcp") + + // The .env also holds a random token, so every active identity line + // stands in for the whole file. + const identityLines = readFileSync(join(configDir, ".env"), "utf8") + .split("\n") + .filter((line) => LOCAL_IDENTITY_LINE.test(line)) + expect(identityLines).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + it("accepts start and passes health check", async () => { const { vaultDir, configDir } = createPtyWorkDir() const pidFile = join(configDir, "health-server.pid") @@ -182,6 +238,7 @@ describe("init remote", () => { send: "\r", label: "optional settings → skip", }, + { match: "Start the server now", send: "n\r", label: "start → no" }, ] const result = await drivePty({ @@ -193,7 +250,7 @@ describe("init remote", () => { expect(result.exitCode).toBe(0) expect(result.promptsAnswered).toBe(result.totalPrompts) expect(result.transcript).toContain("Done.") - expect(result.transcript).toContain("No token yet") + expect(result.transcript).toContain("No Sync token in .env") const envContent = readFileSync(join(configDir, ".env"), "utf8") expect(envContent).toContain("PUBLIC_URL=https://vault.example.com") @@ -337,7 +394,11 @@ describe("non-interactive commands", () => { args: ["upgrade", "--dir", configDir], workDir: vaultDir, prompts: [], - env: { DOCKER_SHIM_PID_FILE: pidFile, DOCKER_SHIM_HEALTH_PORT: "8000" }, + env: { + DOCKER_SHIM_PID_FILE: pidFile, + DOCKER_SHIM_HEALTH_PORT: "8000", + DOCKER_SHIM_PRESENT: "vault-cortex", + }, }) expect(result.exitCode).toBe(0) @@ -352,7 +413,6 @@ describe("non-interactive commands", () => { args: ["down", "--dir", configDir], workDir: vaultDir, prompts: [], - env: { DOCKER_SHIM_NO_CONTAINER: "1" }, }) expect(result.exitCode).toBe(0) @@ -367,6 +427,7 @@ describe("non-interactive commands", () => { args: ["down", "--dir", configDir], workDir: vaultDir, prompts: [], + env: { DOCKER_SHIM_PRESENT: "vault-cortex" }, }) expect(result.exitCode).toBe(0) @@ -383,7 +444,11 @@ describe("non-interactive commands", () => { args: ["restart", "--dir", configDir], workDir: vaultDir, prompts: [], - env: { DOCKER_SHIM_PID_FILE: pidFile, DOCKER_SHIM_HEALTH_PORT: "8000" }, + env: { + DOCKER_SHIM_PID_FILE: pidFile, + DOCKER_SHIM_HEALTH_PORT: "8000", + DOCKER_SHIM_PRESENT: "vault-cortex", + }, }) expect(result.exitCode).toBe(0) @@ -486,6 +551,7 @@ describe("input validation re-prompts", () => { send: "\r", label: "optional settings → skip", }, + { match: "Start the server now", send: "n\r", label: "start → no" }, ] const result = await drivePty({ @@ -539,6 +605,7 @@ describe("input validation re-prompts", () => { send: "\r", label: "optional settings → skip", }, + { match: "Start the server now", send: "n\r", label: "start → no" }, ] const result = await drivePty({ diff --git a/cli/src/__tests__/integration/fixtures/docker b/cli/src/__tests__/integration/fixtures/docker index 8c0662ae3..2311c66bb 100755 --- a/cli/src/__tests__/integration/fixtures/docker +++ b/cli/src/__tests__/integration/fixtures/docker @@ -2,13 +2,26 @@ # Fake docker binary for CLI PTY integration tests. # Shadows the real docker when its directory is prepended to PATH. # Behavior is controllable via environment variables: -# DOCKER_SHIM_NO_CONTAINER=1 — `container inspect` exits 1 (no container) +# DOCKER_SHIM_PRESENT — space-separated container and volume names that +# exist; `container ls` lists them, and +# `container inspect` and `volume inspect` +# exit 1 for any other name (default: none exist) +# DOCKER_SHIM_IMAGE — the image `container inspect` reports for a +# present container (default: the local image) # DOCKER_SHIM_RUN_FAIL=1 — `run` exits 1 # DOCKER_SHIM_HEALTH_PORT — port for the background health server (default 8000) # DOCKER_SHIM_PID_FILE — where to write the health server PID set -euo pipefail +# Exits 0 when the name is in DOCKER_SHIM_PRESENT. +is_present() { + case " ${DOCKER_SHIM_PRESENT:-} " in + *" $1 "*) return 0 ;; + *) return 1 ;; + esac +} + case "${1:-}" in info) echo '{"ServerVersion":"27.0.0","OSType":"linux"}' @@ -35,11 +48,42 @@ case "${1:-}" in ;; container) - # docker container inspect — check if container exists - if [ "${DOCKER_SHIM_NO_CONTAINER:-}" = "1" ]; then + # docker container ls -aq — one line per present name, standing in for the + # container IDs; the inspect below prints no ports for them + if [ "${2:-}" = "ls" ]; then + for present_name in ${DOCKER_SHIM_PRESENT:-}; do + echo "$present_name" + done + exit 0 + fi + # docker container inspect --format ... — one line per present + # container: its name with Docker's leading slash, then its image when the + # format asks for it. Like docker, exits 1 when any name is missing, after + # printing the others. + inspect_status=0 + for container_name in "${@:5}"; do + if ! is_present "$container_name"; then + inspect_status=1 + continue + fi + case "${4:-}" in + *.Config.Image*) + echo "/$container_name ${DOCKER_SHIM_IMAGE:-ghcr.io/aliasunder/vault-cortex:latest}" + ;; + *) + echo "/$container_name" + ;; + esac + done + exit "$inspect_status" + ;; + + volume) + # docker volume inspect — check if volume exists + if ! is_present "${3:-}"; then exit 1 fi - echo '[{"Id":"abc123","State":{"Status":"running"}}]' + echo '[{"Name":"'"${3:-}"'"}]' ;; stop) diff --git a/cli/src/__tests__/lifecycle.test.ts b/cli/src/__tests__/lifecycle.test.ts index 9d1f45b16..004459644 100644 --- a/cli/src/__tests__/lifecycle.test.ts +++ b/cli/src/__tests__/lifecycle.test.ts @@ -1,12 +1,13 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { describe, expect, it, onTestFinished } from "vitest" +import { describe, expect, it, onTestFinished, vi } from "vitest" import type { DockerLogsParams, DockerRunner } from "../docker.js" import { runDown, runLogs, runRestart, runStart } from "../lifecycle.js" import { buildDockerNotInstalledMessage } from "../messages.js" import { + createRecordingDocker, createScriptedPrompts, dockerDown, dockerNotInstalled, @@ -94,7 +95,7 @@ describe("runDown", () => { const removeCalls: string[] = [] const dockerNoContainer: DockerRunner = { ...dockerReady, - containerExists: () => false, + containerImage: () => undefined, stopAndRemoveContainer: () => { removeCalls.push("called") return true @@ -226,7 +227,7 @@ describe("runLogs", () => { const streamCalls: DockerLogsParams[] = [] const dockerNoContainer: DockerRunner = { ...dockerReady, - containerExists: () => false, + containerImage: () => undefined, streamLogs: async (params) => { streamCalls.push(params) return 0 @@ -265,7 +266,7 @@ describe("runLogs", () => { ) expect(exitCode).toBe(0) - expect(streamCalls).toEqual([{ follow: true, since: "10m" }]) + expect(streamCalls).toEqual([{ instanceName: "vault-cortex", follow: true, since: "10m" }]) }) it("defaults to a non-following stream with no since bound", async () => { @@ -287,7 +288,7 @@ describe("runLogs", () => { ) expect(exitCode).toBe(0) - expect(streamCalls).toEqual([{ follow: false, since: undefined }]) + expect(streamCalls).toEqual([{ instanceName: "vault-cortex", follow: false, since: undefined }]) }) it("passes the stream's exit code through as its own", async () => { @@ -392,6 +393,26 @@ describe("runRestart", () => { ]) }) + it("exits 1 when local .env has an empty PUBLIC_URL line", async () => { + const targetDir = makeTempTargetDir("vault-cli-restart-") + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/MyVault\nPUBLIC_URL=\n", + ) + const scripted = createScriptedPrompts() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: dockerReady, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + "Set this line in your .env:\n PUBLIC_URL=http://localhost:8000", + ]) + }) + it("exits 1 when local .env has no PUBLIC_URL", async () => { const targetDir = makeTempTargetDir("vault-cli-restart-") writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/MyVault\n") @@ -404,9 +425,53 @@ describe("runRestart", () => { expect(exitCode).toBe(1) expect(scripted.errors).toEqual([ - `PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` + - "Add this line to your .env:\n PUBLIC_URL=http://localhost:8000", + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + "Set this line in your .env:\n PUBLIC_URL=http://localhost:8000", + ]) + }) + + it("exits 1 when remote .env has an empty PUBLIC_URL line", async () => { + const targetDir = makeTempTargetDir("vault-cli-restart-") + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nOBSIDIAN_AUTH_TOKEN=sync-token\nVAULT_NAME=MyVault\nPUBLIC_URL=\n", + ) + const scripted = createScriptedPrompts() + const recording = createRecordingDocker() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + "Set it to the address your MCP clients reach this server at, for example:\n" + + " PUBLIC_URL=https://vault.example.com", ]) + expect(recording.runParams).toEqual([]) + }) + + it("exits 1 when .env's PORT is not a port", async () => { + const targetDir = makeTempTargetDir("vault-cli-restart-") + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/MyVault\nPORT=80a\nPUBLIC_URL=http://localhost:8000\n", + ) + const scripted = createScriptedPrompts() + const recording = createRecordingDocker() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PORT "80a" is not a valid port — use a whole number between 1 and 65535. Fix it in ${targetDir}/.env.`, + ]) + expect(recording.runParams).toEqual([]) }) it("exits 1 when Docker daemon is not running", async () => { @@ -471,6 +536,7 @@ describe("runRestart", () => { [ { mode: "local", + instanceName: "vault-cortex", envFilePath: join(targetDir, ".env"), port: 8000, vaultPath: "/home/user/MyVault", @@ -523,6 +589,7 @@ describe("runRestart", () => { [ { mode: "remote", + instanceName: "vault-cortex", envFilePath: join(targetDir, ".env"), port: 8000, vaultPath: undefined, @@ -659,7 +726,7 @@ describe("runRestart", () => { const removeCalls: string[] = [] const dockerFirstStart: DockerRunner = { ...dockerReady, - containerExists: () => false, + containerImage: () => undefined, stopAndRemoveContainer: () => { removeCalls.push("called") return false @@ -728,3 +795,262 @@ describe("runRestart", () => { expect(fetchedUrls).toEqual(["http://127.0.0.1:9000/healthz"]) }) }) + +describe("commands on an install with a custom INSTANCE_NAME", () => { + const writeNamedLocalEnv = (targetDir: string): void => { + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/WorkVault\nPORT=8001\nPUBLIC_URL=http://localhost:8001\nINSTANCE_NAME=work\n", + ) + } + + it("down removes only the instance's own container", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runDown( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker }, + ) + + expect(exitCode).toBe(0) + expect(recording.calls).toEqual(["inspect:work", "rm:work"]) + }) + + it("down names the instance when its container is absent", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const scripted = createScriptedPrompts() + + const dockerNoContainer: DockerRunner = { + ...dockerReady, + containerImage: () => undefined, + } + + const exitCode = await runDown( + { dir: targetDir }, + { prompts: scripted.prompts, docker: dockerNoContainer }, + ) + + expect(exitCode).toBe(0) + expect(scripted.logs).toEqual(["No work container found — nothing to stop."]) + }) + + it("down names the instance in the remove hint when removal fails", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const scripted = createScriptedPrompts() + + const exitCode = await runDown( + { dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReady, stopAndRemoveContainer: () => false }, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual(["Could not remove the container — check: docker rm -f work"]) + }) + + it("logs streams the instance's own container", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runLogs( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker }, + ) + + expect(exitCode).toBe(0) + expect(recording.calls).toEqual(["inspect:work", "logs:work"]) + expect(recording.logsParams).toEqual([ + { instanceName: "work", follow: false, since: undefined }, + ]) + }) + + it("logs names the instance and its start command when its container is absent", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const scripted = createScriptedPrompts() + + const exitCode = await runLogs( + { dir: targetDir }, + { prompts: scripted.prompts, docker: { ...dockerReady, containerImage: () => undefined } }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `No work container — start it with: npx vault-cortex@latest start --dir "${targetDir}"`, + ]) + }) + + it("restart names the instance in the remove hint when removal fails", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const dockerRun = vi.fn(() => true) + const scripted = createScriptedPrompts() + + const exitCode = await runRestart( + { dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReady, stopAndRemoveContainer: () => false, dockerRun }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + "Could not remove the existing container — check: docker rm -f work", + ]) + expect(dockerRun).not.toHaveBeenCalled() + }) + + it.each([ + { label: "start", run: runStart }, + { label: "restart", run: runRestart }, + ])("$label re-creates the instance's own container", async ({ run }) => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await run( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(0) + expect(recording.calls).toEqual(["inspect:work", "rm:work", "run:work"]) + expect(recording.runParams).toEqual([ + { + mode: "local", + instanceName: "work", + envFilePath: join(targetDir, ".env"), + port: 8001, + vaultPath: "/home/user/WorkVault", + }, + ]) + }) + + it("restart names the instance in the logs hint when the health check times out", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const scripted = createScriptedPrompts() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: dockerReady, fetchFn: fetchNever, healthTimeoutMs: 0 }, + ) + + expect(exitCode).toBe(1) + expect(scripted.spinnerMessages).toEqual([ + "start: Waiting for the server to come up", + "stop: Server did not respond within 0 minutes — check: docker logs work", + ]) + }) + + it.each([ + { label: "down", run: runDown }, + { label: "logs", run: runLogs }, + { label: "start", run: runStart }, + ])("$label stops on an invalid INSTANCE_NAME before any docker call", async ({ run }) => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/WorkVault\nPUBLIC_URL=http://localhost:8000\nINSTANCE_NAME=Work Vault\n", + ) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await run( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(1) + expect(recording.calls).toEqual([]) + expect(scripted.errors).toEqual([ + `INSTANCE_NAME "Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit. Fix it in ${targetDir}/.env.`, + ]) + }) +}) + +describe("a container under the install's name that runs another image", () => { + const createForeignContainerDocker = (): { docker: DockerRunner; calls: string[] } => { + const recording = createRecordingDocker() + const docker: DockerRunner = { + ...recording.docker, + containerImage: (instanceName) => { + recording.calls.push(`inspect:${instanceName}`) + return "postgres:16" + }, + } + return { docker, calls: recording.calls } + } + + it("down leaves it in place and exits 1", async () => { + const targetDir = makeTempTargetDir("vault-cli-foreign-") + writeLocalEnv(targetDir) + const foreign = createForeignContainerDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runDown( + { dir: targetDir }, + { prompts: scripted.prompts, docker: foreign.docker }, + ) + + expect(exitCode).toBe(1) + expect(foreign.calls).toEqual(["inspect:vault-cortex"]) + expect(scripted.errors).toEqual([ + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + `To keep this install's data, rename that container: docker rename vault-cortex \n` + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, + ]) + }) + + it("logs refuses to stream it", async () => { + const targetDir = makeTempTargetDir("vault-cli-foreign-") + writeLocalEnv(targetDir) + const foreign = createForeignContainerDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runLogs( + { dir: targetDir }, + { prompts: scripted.prompts, docker: foreign.docker }, + ) + + expect(exitCode).toBe(1) + expect(foreign.calls).toEqual(["inspect:vault-cortex"]) + expect(scripted.errors).toEqual([ + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + `To keep this install's data, rename that container: docker rename vault-cortex \n` + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, + ]) + }) + + it("restart leaves it in place and starts nothing", async () => { + const targetDir = makeTempTargetDir("vault-cli-foreign-") + writeLocalEnv(targetDir) + const foreign = createForeignContainerDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: foreign.docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(foreign.calls).toEqual(["inspect:vault-cortex"]) + expect(scripted.errors).toEqual([ + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + `To keep this install's data, rename that container: docker rename vault-cortex \n` + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, + ]) + }) +}) diff --git a/cli/src/__tests__/messages.test.ts b/cli/src/__tests__/messages.test.ts index de3ec4990..4b19fa4aa 100644 --- a/cli/src/__tests__/messages.test.ts +++ b/cli/src/__tests__/messages.test.ts @@ -24,6 +24,7 @@ const expectedSectionRule = (label: string): string => const localDefaults = { targetDir: "/home/user/vault-cortex", + instanceName: "vault-cortex", token: "abc123deadbeef", startStatus: "not-started" as const, port: 8000, @@ -32,6 +33,7 @@ const localDefaults = { const remoteDefaults = { targetDir: "/home/user/vault-cortex", + instanceName: "vault-cortex", token: "abc123deadbeef", publicUrl: "https://vault.example.com", startStatus: "not-started" as const, @@ -183,6 +185,24 @@ describe("buildLocalConnectMessage", () => { expect(message).toContain('curl -H "Authorization: Bearer "') expect(message).not.toMatch(/\n\n\n/) }) + + it("names a custom instance in the logs command and both connector entries", () => { + const message = buildLocalConnectMessage({ + ...localDefaults, + instanceName: "work", + port: 8001, + startStatus: "starting", + }) + + expect(message).toContain("check progress:\n docker logs work\n") + expect(message).toContain( + "claude mcp add --scope user --transport http work http://localhost:8001/mcp\n", + ) + expect(message).toContain(' "work": {\n') + expect(message).not.toContain("docker logs vault-cortex") + expect(message).not.toContain("transport http vault-cortex") + expect(message).not.toContain('"vault-cortex": {') + }) }) // ── buildRemoteConnectMessage ────────────────────────────────────────────────── @@ -220,9 +240,9 @@ describe("buildRemoteConnectMessage", () => { startStatus: "running", }) - expect(message).toContain("The server is running.") + expect(message).toContain("The server is running.\n\n") expect(message).not.toContain("Start the server:") - expect(message).not.toContain("Fill in OBSIDIAN_AUTH_TOKEN") + expect(message).not.toContain("sign-in page") }) it("shows 'starting in the background' when startStatus is starting", () => { @@ -237,6 +257,21 @@ describe("buildRemoteConnectMessage", () => { expect(message).not.toContain("npx vault-cortex@latest start") }) + it("names a custom instance in the logs command and the connector entry", () => { + const message = buildRemoteConnectMessage({ + ...remoteDefaults, + instanceName: "work", + startStatus: "starting", + }) + + expect(message).toContain("check progress:\n docker logs work\n") + expect(message).toContain( + "claude mcp add --scope user --transport http work https://vault.example.com/mcp\n", + ) + expect(message).not.toContain("docker logs vault-cortex") + expect(message).not.toContain("transport http vault-cortex") + }) + it("shows the health check block when startStatus is starting", () => { const message = buildRemoteConnectMessage({ ...remoteDefaults, @@ -247,15 +282,53 @@ describe("buildRemoteConnectMessage", () => { expect(message).not.toContain("Smoke test:") }) - it("shows 'Fill in OBSIDIAN_AUTH_TOKEN' when obsidianTokenMissing and not started", () => { + it("follows the start command with the sign-in steps when .env has no Sync token", () => { const message = buildRemoteConnectMessage({ ...remoteDefaults, + publicUrl: "https://vault.example.com", startStatus: "not-started", obsidianTokenMissing: true, }) expect(message).toContain( - `Fill in OBSIDIAN_AUTH_TOKEN in ${remoteDefaults.targetDir}/.env, then start the server:\n npx vault-cortex@latest start --dir "${remoteDefaults.targetDir}"`, + `Start the server:\n npx vault-cortex@latest start --dir "${remoteDefaults.targetDir}"\n` + + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + "syncing. Open the page:\n" + + " https://vault.example.com\n" + + "It asks for the auth token below and your Obsidian password.\n\n", + ) + }) + + it("follows the running line with the sign-in steps when .env has no Sync token", () => { + const message = buildRemoteConnectMessage({ + ...remoteDefaults, + publicUrl: "https://vault.example.com", + startStatus: "running", + obsidianTokenMissing: true, + }) + + expect(message).toContain( + "The server is running.\n" + + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + "syncing. Open the page:\n" + + " https://vault.example.com\n" + + "It asks for the auth token below and your Obsidian password.\n\n", + ) + }) + + it("asks for HTTPS before the sign-in page when PUBLIC_URL is http", () => { + const message = buildRemoteConnectMessage({ + ...remoteDefaults, + publicUrl: "http://203.0.113.5:8000", + startStatus: "running", + obsidianTokenMissing: true, + }) + + expect(message).toContain( + "syncing. Open the page:\n" + + " http://203.0.113.5:8000\n" + + "It asks for the auth token below and your Obsidian password.\n" + + "Set up HTTPS first: the page sends your Obsidian password to the server.\n\n", ) }) diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 37ee0a0d2..2be7e6c2c 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest" import { applyOptionalSettings, askOptionalSettings, - derivePublicUrlOverride, + describeStrandedPublicUrl, + portsOfOtherContainers, readOptionalValue, + withPublicUrlOverride, } from "../optional-settings.js" import { createScriptedPrompts } from "./command-stubs.js" @@ -24,6 +26,17 @@ describe("readOptionalValue", () => { it("returns undefined for a missing line", () => { expect(readOptionalValue("PORT=9000\n", "TZ")).toBeUndefined() }) + + it.each([ + { label: "double quotes", envContent: 'PORT="9000"\n' }, + { label: "single quotes", envContent: "PORT='9000'\n" }, + ])("drops one pair of surrounding $label, as the server reads the value", ({ envContent }) => { + expect(readOptionalValue(envContent, "PORT")).toBe("9000") + }) + + it("returns an empty string for a line with no value", () => { + expect(readOptionalValue("PORT=\n", "PORT")).toBe("") + }) }) describe("applyOptionalSettings", () => { @@ -82,44 +95,153 @@ describe("applyOptionalSettings", () => { }) }) -describe("derivePublicUrlOverride", () => { +describe("withPublicUrlOverride", () => { it("follows a PORT change when PUBLIC_URL is the derived localhost form", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=8000\n", { - PORT: "9000", + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=8000\n", }) - expect(derived).toEqual({ + expect(overrides).toEqual({ PORT: "9000", PUBLIC_URL: "http://localhost:9000", }) }) - it("derives from the current PORT, not the default", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:9100\nPORT=9100\n", { - PORT: "9200", + it("follows the current PORT, not the default", () => { + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9200" }, + envContent: "PUBLIC_URL=http://localhost:9100\nPORT=9100\n", }) - expect(derived).toEqual({ + expect(overrides).toEqual({ PORT: "9200", PUBLIC_URL: "http://localhost:9200", }) }) - it("never touches a custom PUBLIC_URL", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=https://vault.example.com\nPORT=8000\n", { + it("reads an empty PORT line as the default port", () => { + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=\n", + }) + expect(overrides).toEqual({ + PORT: "9000", + PUBLIC_URL: "http://localhost:9000", + }) + }) + + it("follows a PORT change from a value that isn't a port when PUBLIC_URL is on the default port", () => { + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=80a0\n", + }) + expect(overrides).toEqual({ PORT: "9000", + PUBLIC_URL: "http://localhost:9000", }) - expect(derived).toEqual({ PORT: "9000" }) + }) + + it("leaves a default-port PUBLIC_URL beside a valid PORT on another port", () => { + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9200" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=9100\n", + }) + expect(overrides).toEqual({ PORT: "9200" }) + }) + + it("never touches a custom PUBLIC_URL", () => { + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PUBLIC_URL=https://vault.example.com\nPORT=8000\n", + }) + expect(overrides).toEqual({ PORT: "9000" }) }) it("returns the overrides unchanged without a PORT override", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=8000\n", { - MEMORY_ENABLED: "false", + const overrides = withPublicUrlOverride({ + overrides: { MEMORY_ENABLED: "false" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=8000\n", }) - expect(derived).toEqual({ MEMORY_ENABLED: "false" }) + expect(overrides).toEqual({ MEMORY_ENABLED: "false" }) }) it("returns the overrides unchanged when PUBLIC_URL is absent", () => { - const derived = derivePublicUrlOverride("PORT=8000\n", { PORT: "9000" }) - expect(derived).toEqual({ PORT: "9000" }) + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PORT=8000\n", + }) + expect(overrides).toEqual({ PORT: "9000" }) + }) +}) + +describe("portsOfOtherContainers", () => { + it("leaves out the install's own ports", () => { + const portsInUse = portsOfOtherContainers( + [ + { hostPort: 8000, containerName: "vault-cortex" }, + { hostPort: 9100, containerName: "other-app" }, + ], + "vault-cortex", + ) + + expect(portsInUse).toEqual(new Map([[9100, "other-app"]])) + }) + + it.each([ + { label: "after", containerNames: ["vault-cortex", "stopped-test"] }, + { label: "before", containerNames: ["stopped-test", "vault-cortex"] }, + ])( + "keeps a port another container also publishes, listed $label the install's own", + ({ containerNames }) => { + const publishedPorts = containerNames.map((containerName) => { + return { hostPort: 8000, containerName } + }) + + expect(portsOfOtherContainers(publishedPorts, "vault-cortex")).toEqual( + new Map([[8000, "stopped-test"]]), + ) + }, + ) +}) + +describe("describeStrandedPublicUrl", () => { + const CUSTOM_URL_ENV = "PUBLIC_URL=https://vault.example.com\nPORT=8000\n" + + it("warns when a new PORT leaves a custom PUBLIC_URL as it was", () => { + const warning = describeStrandedPublicUrl({ + envContent: CUSTOM_URL_ENV, + pickedOverrides: { PORT: "9000" }, + appliedOverrides: { PORT: "9000" }, + }) + + expect(warning).toBe( + "PORT changed — make sure PUBLIC_URL (https://vault.example.com) still reaches the server.", + ) + }) + + it.each([ + { label: "the current port", envContent: CUSTOM_URL_ENV }, + { + label: "the default port behind an empty PORT line", + envContent: "PUBLIC_URL=https://vault.example.com\nPORT=\n", + }, + ])("stays quiet when the picked PORT is $label", ({ envContent }) => { + const warning = describeStrandedPublicUrl({ + envContent, + pickedOverrides: { PORT: "8000" }, + appliedOverrides: { PORT: "8000" }, + }) + + expect(warning).toBeUndefined() + }) + + it("stays quiet when the localhost PUBLIC_URL already names the new port", () => { + const warning = describeStrandedPublicUrl({ + envContent: "PUBLIC_URL=http://localhost:9000\nPORT=8000\n", + pickedOverrides: { PORT: "9000" }, + appliedOverrides: { PORT: "9000" }, + }) + + expect(warning).toBeUndefined() }) }) @@ -127,7 +249,10 @@ describe("askOptionalSettings chooser", () => { it("offers the local settings without SYNC_MODE in local mode", async () => { const scripted = createScriptedPrompts([[]]) - await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.multiselectCalls[0].options.map((option) => option.value)).toEqual([ "MEMORY_ENABLED", @@ -145,7 +270,10 @@ describe("askOptionalSettings chooser", () => { it("adds SYNC_MODE to the chooser in remote mode", async () => { const scripted = createScriptedPrompts([[]]) - await askOptionalSettings({ mode: "remote", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "remote", envContent: "" }, + scripted.prompts, + ) expect(scripted.multiselectCalls[0].options.map((option) => option.value)).toEqual([ "MEMORY_ENABLED", @@ -165,7 +293,7 @@ describe("askOptionalSettings chooser", () => { const scripted = createScriptedPrompts([[]]) await askOptionalSettings( - { mode: "local", envContent: "MEMORY_ENABLED=false\nPORT=9000\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=false\nPORT=9000\n" }, scripted.prompts, ) @@ -187,6 +315,7 @@ describe("askOptionalSettings chooser", () => { await askOptionalSettings( { + portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=true\nMEMORY_DIR=About Me\n", }, @@ -202,7 +331,10 @@ describe("askOptionalSettings chooser", () => { it('adds the dependency note for the server-valid disabled spelling "0"', async () => { const scripted = createScriptedPrompts([[]]) - await askOptionalSettings({ mode: "local", envContent: "MEMORY_ENABLED=0\n" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=0\n" }, + scripted.prompts, + ) const memoryFolderOption = scripted.multiselectCalls[0].options.find( (option) => option.value === "MEMORY_DIR", @@ -215,7 +347,10 @@ describe("askOptionalSettings chooser", () => { it("returns no overrides and asks nothing further when nothing is picked", async () => { const scripted = createScriptedPrompts([[]]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(overrides).toEqual({}) expect(scripted.asked).toEqual(["Any optional settings to change? (press enter to skip)"]) @@ -227,7 +362,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["MEMORY_ENABLED"], true]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "MEMORY_ENABLED=false\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=false\n" }, scripted.prompts, ) @@ -245,7 +380,10 @@ describe("askOptionalSettings per-setting prompts", () => { // toggle) — the confirm must start on Yes, not fall to false. const scripted = createScriptedPrompts([["MEMORY_ENABLED"], true]) - await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.confirmCalls).toEqual([ { @@ -260,7 +398,10 @@ describe("askOptionalSettings per-setting prompts", () => { // the confirm at No, not inherit the enabled-unless-"false" heuristic. const scripted = createScriptedPrompts([["READONLY_MODE"], false]) - await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.confirmCalls).toEqual([ { @@ -275,7 +416,10 @@ describe("askOptionalSettings per-setting prompts", () => { // ${VAR:-default} and env-var's .default() — the CLI must match. const scripted = createScriptedPrompts([["READONLY_MODE"], false]) - await askOptionalSettings({ mode: "local", envContent: "READONLY_MODE=\n" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "READONLY_MODE=\n" }, + scripted.prompts, + ) expect(scripted.confirmCalls).toEqual([ { @@ -289,7 +433,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["READONLY_MODE"], true]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "READONLY_MODE=true\n" }, + { portsInUse: new Map(), mode: "local", envContent: "READONLY_MODE=true\n" }, scripted.prompts, ) @@ -305,7 +449,10 @@ describe("askOptionalSettings per-setting prompts", () => { it("offers the three Sync modes the client accepts, seeded with the default when the var is absent", async () => { const scripted = createScriptedPrompts([["SYNC_MODE"], "pull-only"]) - await askOptionalSettings({ mode: "remote", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "remote", envContent: "" }, + scripted.prompts, + ) // The values are what `ob sync-config --mode` accepts; a chooser entry the // client rejects would only surface at the image's first boot. @@ -339,7 +486,10 @@ describe("askOptionalSettings per-setting prompts", () => { // the confirm must not misrepresent MEMORY_ENABLED=0 as enabled. const scripted = createScriptedPrompts([["MEMORY_ENABLED"], false]) - await askOptionalSettings({ mode: "local", envContent: "MEMORY_ENABLED=0\n" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=0\n" }, + scripted.prompts, + ) expect(scripted.confirmCalls).toEqual([ { @@ -354,7 +504,10 @@ describe("askOptionalSettings per-setting prompts", () => { // silently fall back to the default port on every later read. const scripted = createScriptedPrompts([["PORT"], "1e4", "9000"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["PORT must be a whole number between 1 and 65535."]) expect(overrides).toEqual({ PORT: "9000" }) @@ -364,7 +517,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["MEMORY_DIR"], "Memory Bank"]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "MEMORY_DIR=About Me\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_DIR=About Me\n" }, scripted.prompts, ) @@ -378,13 +531,34 @@ describe("askOptionalSettings per-setting prompts", () => { expect(overrides).toEqual({ MEMORY_DIR: "Memory Bank" }) }) + it.each([ + { label: "an empty line", envContent: "MEMORY_DIR=\n" }, + { label: "empty quotes", envContent: 'MEMORY_DIR=""\n' }, + ])( + "offers the default memory folder when MEMORY_DIR is $label, as the server reads it", + async ({ envContent }) => { + const scripted = createScriptedPrompts([["MEMORY_DIR"], ""]) + + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent }, + scripted.prompts, + ) + + expect(scripted.errors).toEqual([]) + expect(overrides).toEqual({ MEMORY_DIR: "About Me" }) + }, + ) + it("re-prompts on an empty memory folder until a name is given", async () => { // No current value and no typed answer: the stub returns the prompt's // defaultValue ("About Me"), so a genuinely empty submission needs the // default suppressed — send whitespace, which trims to empty. const scripted = createScriptedPrompts([["MEMORY_DIR"], " ", "Notes"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["The folder name can't be empty."]) expect(overrides).toEqual({ MEMORY_DIR: "Notes" }) @@ -393,8 +567,40 @@ describe("askOptionalSettings per-setting prompts", () => { it("uses the default port on an empty submission", async () => { const scripted = createScriptedPrompts([["PORT"], ""]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) + + expect(overrides).toEqual({ PORT: "8000" }) + }) + + it("offers the default port when the PORT line is empty", async () => { + const scripted = createScriptedPrompts([["PORT"], ""]) + + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "PORT=\n" }, + scripted.prompts, + ) + expect(scripted.textCalls).toEqual([ + { message: "Host port for the server:", defaultValue: "8000", placeholder: "8000" }, + ]) + expect(overrides).toEqual({ PORT: "8000" }) + }) + + it("offers the default port when the PORT line is not a port", async () => { + const scripted = createScriptedPrompts([["PORT"], ""]) + + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "PORT=80a0\n" }, + scripted.prompts, + ) + + expect(scripted.textCalls).toEqual([ + { message: "Host port for the server:", defaultValue: "8000", placeholder: "8000" }, + ]) + expect(scripted.errors).toEqual([]) expect(overrides).toEqual({ PORT: "8000" }) }) @@ -402,7 +608,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["SYNC_MODE"], "bidirectional"]) const overrides = await askOptionalSettings( - { mode: "remote", envContent: "SYNC_MODE=pull-only\n" }, + { portsInUse: new Map(), mode: "remote", envContent: "SYNC_MODE=pull-only\n" }, scripted.prompts, ) @@ -417,7 +623,10 @@ describe("askOptionalSettings per-setting prompts", () => { "Europe/London", // accepted ]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ '"Not/AZone" is not a recognized IANA timezone (e.g. America/New_York, Europe/London).', @@ -428,7 +637,10 @@ describe("askOptionalSettings per-setting prompts", () => { it("skips an unset daily notes folder left blank, writing nothing", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) // No pre-filled default when unset — the real default is the vault's // own config, so the prompt must not offer a concrete value to accept. @@ -445,6 +657,33 @@ describe("askOptionalSettings per-setting prompts", () => { ]) }) + it.each([ + { label: "an empty line", envContent: "DAILY_NOTES_FOLDER=\n" }, + { label: "empty quotes", envContent: 'DAILY_NOTES_FOLDER=""\n' }, + ])( + "treats a daily notes folder set to $label as unset, as the server reads it", + async ({ envContent }) => { + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) + + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent }, + scripted.prompts, + ) + + expect(scripted.textCalls).toEqual([ + { + message: "Vault folder for daily notes:", + defaultValue: "", + placeholder: "blank = use your vault's daily notes settings", + }, + ]) + expect(overrides).toEqual({}) + expect(scripted.logs).toEqual([ + "Left unset — the server reads this setting from your vault's own config.", + ]) + }, + ) + it("keeps a set daily notes folder on a blank submit without recording a no-op", async () => { // The prompt resolves an empty submit to its defaultValue (the current // value), so blank never destroys an existing setting — and the unchanged @@ -453,7 +692,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -471,7 +710,10 @@ describe("askOptionalSettings per-setting prompts", () => { it("collects a typed daily notes format, trimmed", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], " DD-MM-YYYY "]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.textCalls).toEqual([ { @@ -491,7 +733,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], " "]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -503,7 +745,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "Planner"]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -521,7 +763,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "Journal"]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -536,7 +778,10 @@ describe("askOptionalSettings per-setting prompts", () => { "YYYY/MM/DD", // format typed ]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY/MM/DD" }) }) @@ -545,7 +790,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([[]]) await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -568,7 +813,7 @@ describe("askFolder validation", () => { ]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "MEMORY_DIR=About Me\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_DIR=About Me\n" }, scripted.prompts, ) @@ -580,7 +825,7 @@ describe("askFolder validation", () => { const scripted = createScriptedPrompts([["MEMORY_DIR"], "/var/data", "My Notes"]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "MEMORY_DIR=About Me\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_DIR=About Me\n" }, scripted.prompts, ) @@ -595,7 +840,10 @@ describe("DAILY_NOTES_FOLDER validate callback", () => { it("rejects path traversal", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "../etc", "Journal"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["Path traversal (..) is not allowed in folder names."]) expect(overrides).toEqual({ DAILY_NOTES_FOLDER: "Journal" }) @@ -604,7 +852,10 @@ describe("DAILY_NOTES_FOLDER validate callback", () => { it("rejects absolute paths", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "/var/notes", "Journal"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ "Absolute paths are not allowed — use a vault-relative folder name.", @@ -615,7 +866,10 @@ describe("DAILY_NOTES_FOLDER validate callback", () => { it("accepts / as the vault root", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "/"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([]) expect(overrides).toEqual({ DAILY_NOTES_FOLDER: "/" }) @@ -626,7 +880,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects path traversal", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "../YYYY-MM-DD", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["Date format must not contain path traversal (..)."]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) @@ -635,7 +892,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects a leading path separator", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "/YYYY-MM-DD", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["Date format must not start with a path separator."]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) @@ -644,7 +904,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects a trailing path separator", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "YYYY-MM-DD/", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["Date format must not end with a path separator."]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) @@ -653,7 +916,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects digits outside bracket escapes", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "2024-MM-DD", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.", @@ -664,7 +930,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects digits outside brackets even when brackets contain digits too", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "2024 [Day 2]", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.", @@ -675,7 +944,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects trailing digits in a format string", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "YYYY-2024", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.", @@ -686,7 +958,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("accepts digits inside bracket escapes", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "YYYY-MM-DD [Day 1]"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD [Day 1]" }) @@ -695,7 +970,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("accepts a valid format without digits", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "YYYY/MM/DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY/MM/DD" }) diff --git a/cli/src/__tests__/program.test.ts b/cli/src/__tests__/program.test.ts index 7ed552b17..11694e0e7 100644 --- a/cli/src/__tests__/program.test.ts +++ b/cli/src/__tests__/program.test.ts @@ -73,14 +73,34 @@ describe("buildProgram init", () => { const { program, initCalls } = buildCapturingProgram() await program.parseAsync( - ["init", "--mode", "remote", "--vault-path", "/vaults/Mine", "--dir", "./out", "--yes"], + [ + "init", + "--mode", + "remote", + "--vault-path", + "/vaults/Mine", + "--dir", + "./out", + "--instance-name", + "work", + "--port", + "8001", + "--yes", + ], { from: "user", }, ) expect(initCalls).toEqual([ - { mode: "remote", vaultPath: "/vaults/Mine", dir: "./out", yes: true }, + { + mode: "remote", + vaultPath: "/vaults/Mine", + dir: "./out", + instanceName: "work", + port: "8001", + yes: true, + }, ]) }) diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index e83f8a0d3..6e1408de5 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -7,6 +7,7 @@ import { buildFilesToWrite, detectMode, patchEnvObsidianToken, + readEnvInstanceName, readEnvObsidianToken, readEnvPort, readEnvPublicUrl, @@ -119,7 +120,7 @@ describe("readEnvPort", () => { it("returns the default 8000 when no .env exists", () => { const missingPath = join(tmpdir(), "vault-cli-no-such-env", ".env") - expect(readEnvPort(missingPath)).toBe(8000) + expect(readEnvPort(missingPath)).toEqual({ kind: "ok", port: 8000 }) }) it("returns the default 8000 when PORT is only present as a comment", () => { @@ -127,7 +128,7 @@ describe("readEnvPort", () => { const envPath = join(targetDir, ".env") writeFileSync(envPath, "MCP_AUTH_TOKEN=abc\n# PORT=9000\n") - expect(readEnvPort(envPath)).toBe(8000) + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 8000 }) }) it("returns an uncommented PORT override", () => { @@ -135,7 +136,7 @@ describe("readEnvPort", () => { const envPath = join(targetDir, ".env") writeFileSync(envPath, "MCP_AUTH_TOKEN=abc\nPORT=9000\n") - expect(readEnvPort(envPath)).toBe(9000) + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 9000 }) }) it("strips surrounding double quotes from the port value", () => { @@ -143,7 +144,107 @@ describe("readEnvPort", () => { const envPath = join(targetDir, ".env") writeFileSync(envPath, 'MCP_AUTH_TOKEN=abc\nPORT="9000"\n') - expect(readEnvPort(envPath)).toBe(9000) + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 9000 }) + }) + + it("takes the last active PORT line, as docker --env-file does", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, "PORT=8000\nMCP_AUTH_TOKEN=abc\nPORT=8001\n") + + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 8001 }) + }) + + it("reads an empty last PORT line as the default port, not an earlier line's port", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, "PORT=9000\nMCP_AUTH_TOKEN=abc\nPORT=\n") + + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 8000 }) + }) + + it.each([ + { label: "letters", portValue: "abc", reportedValue: "abc" }, + { label: "digits then letters", portValue: "80a", reportedValue: "80a" }, + { label: "zero", portValue: "0", reportedValue: "0" }, + { label: "a number above 65535", portValue: "70000", reportedValue: "70000" }, + { label: "a quoted value padded with spaces", portValue: '" 9000 "', reportedValue: " 9000 " }, + ])( + "reports $label as an invalid port instead of using the default", + ({ portValue, reportedValue }) => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, `PORT=9000\nPORT=${portValue}\n`) + + expect(readEnvPort(envPath)).toEqual({ + kind: "invalid", + message: `PORT "${reportedValue}" is not a valid port — use a whole number between 1 and 65535.`, + }) + }, + ) +}) + +describe("readEnvInstanceName", () => { + const writeEnv = (content: string): string => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync(envPath, content) + return envPath + } + + it("returns the default name when no .env exists", () => { + const missingPath = join(tmpdir(), "vault-cli-no-such-env", ".env") + + expect(readEnvInstanceName(missingPath)).toEqual({ kind: "ok", instanceName: "vault-cortex" }) + }) + + it.each([ + { label: "no INSTANCE_NAME line", content: "MCP_AUTH_TOKEN=abc\n" }, + { label: "a commented-out line", content: "# INSTANCE_NAME=work\n" }, + { label: "an empty value", content: "INSTANCE_NAME=\n" }, + { label: "an empty quoted value", content: 'INSTANCE_NAME=""\n' }, + ])("returns the default name for $label", ({ content }) => { + expect(readEnvInstanceName(writeEnv(content))).toEqual({ + kind: "ok", + instanceName: "vault-cortex", + }) + }) + + it.each([ + { label: "a plain name", content: "INSTANCE_NAME=work_2\n", expected: "work_2" }, + { + label: "a double-quoted name", + content: 'INSTANCE_NAME="work-vault"\n', + expected: "work-vault", + }, + { + label: "a single-quoted name", + content: "INSTANCE_NAME='work-vault'\n", + expected: "work-vault", + }, + { label: "a name with trailing spaces", content: "INSTANCE_NAME=work \n", expected: "work" }, + ])("returns $label as written, without quotes or spaces", ({ content, expected }) => { + expect(readEnvInstanceName(writeEnv(content))).toEqual({ kind: "ok", instanceName: expected }) + }) + + it("takes the last active line, as docker --env-file does", () => { + const envPath = writeEnv("INSTANCE_NAME=vault-cortex\nPORT=8001\nINSTANCE_NAME=work\n") + + expect(readEnvInstanceName(envPath)).toEqual({ kind: "ok", instanceName: "work" }) + }) + + it.each([ + { label: "a one-character name", value: "a" }, + { label: "uppercase letters", value: "Work" }, + { label: "a space", value: "my vault" }, + { label: "a leading hyphen", value: "-work" }, + { label: "a dot", value: "work.vault" }, + ])("rejects $label with the rule in the message", ({ value }) => { + const envPath = writeEnv(`INSTANCE_NAME=${value}\n`) + + expect(readEnvInstanceName(envPath)).toEqual({ + kind: "invalid", + message: `INSTANCE_NAME "${value}" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.`, + }) }) }) @@ -193,6 +294,25 @@ describe("readEnvVaultPath", () => { expect(readEnvVaultPath(envPath)).toBe("/Users/me/My Vault") }) + + it("returns undefined when the value is an empty pair of quotes", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, 'MCP_AUTH_TOKEN=abc\nVAULT_PATH=""\n') + + expect(readEnvVaultPath(envPath)).toBeUndefined() + }) + + it("takes the last active line, as docker --env-file does", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync( + envPath, + "VAULT_PATH=/Users/me/OldVault\nMCP_AUTH_TOKEN=abc\nVAULT_PATH=/Users/me/NewVault\n", + ) + + expect(readEnvVaultPath(envPath)).toBe("/Users/me/NewVault") + }) }) describe("readEnvPublicUrl", () => { @@ -204,6 +324,17 @@ describe("readEnvPublicUrl", () => { expect(readEnvPublicUrl(envPath)).toBe("https://vault.example.com") }) + it("takes the last active line, as docker --env-file does", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync( + envPath, + "PUBLIC_URL=https://old.example.com\nMCP_AUTH_TOKEN=abc\nPUBLIC_URL=https://new.example.com\n", + ) + + expect(readEnvPublicUrl(envPath)).toBe("https://new.example.com") + }) + it("strips trailing slashes so consumers can append paths cleanly", () => { // A hand-edited `https://host/` must not become `https://host//mcp` in // the connect message — mirror askPublicUrl's prompt-side normalization. @@ -237,8 +368,6 @@ describe("readEnvPublicUrl", () => { }) it("returns undefined for an empty PUBLIC_URL= line", () => { - // Contrast with hasEnvPublicUrl, which deliberately matches the empty - // line for old-compose detection — the value reader must not. const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) const envPath = join(targetDir, ".env") writeFileSync(envPath, "MCP_AUTH_TOKEN=abc\nPUBLIC_URL=\n") @@ -271,6 +400,22 @@ describe("readEnvPublicUrl", () => { expect(readEnvPublicUrl(envPath)).toBe("https://vault.example.com") }) + + it("returns undefined when the quotes hold only whitespace", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, 'MCP_AUTH_TOKEN=abc\nPUBLIC_URL=" "\n') + + expect(readEnvPublicUrl(envPath)).toBeUndefined() + }) + + it("trims whitespace inside the quotes before stripping trailing slashes", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, 'MCP_AUTH_TOKEN=abc\nPUBLIC_URL=" https://vault.example.com/ "\n') + + expect(readEnvPublicUrl(envPath)).toBe("https://vault.example.com") + }) }) describe("detectMode", () => { @@ -364,6 +509,23 @@ describe("patchEnvObsidianToken", () => { ) }) + it("replaces every active token line, so the last one docker reads is the new token", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) + const envPath = join(targetDir, ".env") + writeFileSync( + envPath, + "OBSIDIAN_AUTH_TOKEN=old-token\nVAULT_NAME=MyVault\nOBSIDIAN_AUTH_TOKEN=stale-token\n", + ) + + const result = patchEnvObsidianToken(envPath, "new-token") + + expect(result).toBe(true) + expect(readFileSync(envPath, "utf8")).toBe( + "OBSIDIAN_AUTH_TOKEN=new-token\nVAULT_NAME=MyVault\nOBSIDIAN_AUTH_TOKEN=new-token\n", + ) + expect(readEnvObsidianToken(envPath)).toBe("new-token") + }) + it("replaces an empty token value", () => { const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) const envPath = join(targetDir, ".env") @@ -439,6 +601,30 @@ describe("readEnvObsidianToken", () => { expect(readEnvObsidianToken(envPath)).toBeUndefined() }) + it("returns undefined when the value is an empty pair of quotes", () => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync(envPath, 'OBSIDIAN_AUTH_TOKEN=old-token\nOBSIDIAN_AUTH_TOKEN=""\n') + + expect(readEnvObsidianToken(envPath)).toBeUndefined() + }) + + it("keeps the quotes around a quoted token", () => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync(envPath, 'OBSIDIAN_AUTH_TOKEN="my-token"\n') + + expect(readEnvObsidianToken(envPath)).toBe('"my-token"') + }) + + it("takes the last active line, as docker --env-file does", () => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync( + envPath, + "OBSIDIAN_AUTH_TOKEN=old-token\nVAULT_NAME=Test\nOBSIDIAN_AUTH_TOKEN=new-token\n", + ) + + expect(readEnvObsidianToken(envPath)).toBe("new-token") + }) + it("returns undefined when the file has no OBSIDIAN_AUTH_TOKEN line", () => { const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") writeFileSync(envPath, "MCP_AUTH_TOKEN=abc\nVAULT_NAME=Test\n") @@ -533,3 +719,31 @@ describe("stripEnvQuotedValues", () => { expect(readFileSync(envPath, "utf8")).toBe("VAULT_NAME=My 'Cool' Vault\n") }) }) + +describe("value readers with an empty last line", () => { + it.each([ + { + label: "VAULT_PATH", + envContent: "VAULT_PATH=/Users/me/OldVault\nVAULT_PATH=\n", + readValue: readEnvVaultPath, + }, + { + label: "PUBLIC_URL", + envContent: "PUBLIC_URL=https://old.example.com\nPUBLIC_URL=\n", + readValue: readEnvPublicUrl, + }, + { + label: "OBSIDIAN_AUTH_TOKEN", + envContent: "OBSIDIAN_AUTH_TOKEN=old-token\nOBSIDIAN_AUTH_TOKEN=\n", + readValue: readEnvObsidianToken, + }, + ])( + "read an empty last $label line as unset, as docker --env-file does", + ({ envContent, readValue }) => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync(envPath, envContent) + + expect(readValue(envPath)).toBeUndefined() + }, + ) +}) diff --git a/cli/src/__tests__/templates.test.ts b/cli/src/__tests__/templates.test.ts index ba51f8673..5bbe04375 100644 --- a/cli/src/__tests__/templates.test.ts +++ b/cli/src/__tests__/templates.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest" import { parse as parseYaml } from "yaml" import { loadConfig } from "../../../src/vault-mcp/config.js" -import { REMOTE_IMAGE } from "../docker.js" +import { instanceVolumeNames, REMOTE_IMAGE } from "../docker.js" import { buildLocalEnv, buildRemoteEnv } from "../env.js" const readRepoFile = (repoRelativePath: string): string => @@ -45,11 +45,25 @@ const optionalSection = (content: string): string => { return parts.length > 1 ? parts[1] : "" } -/** Extracts commented var names from the optional section only. */ +/** Matches env var assignments in the optional section, commented or active. */ +const OPTIONAL_VAR_LINE = /^(?:# )?([A-Z_]+)=/gm + +/** + * Extracts var names from the optional section only. Active lines count too: + * a setting written as `INSTANCE_NAME=vault-cortex` must reach the CLI's + * generated .env just like a commented one. + */ const optionalVarNames = (content: string): string[] => [ - ...new Set([...optionalSection(content).matchAll(COMMENTED_VAR_LINE)].map((match) => match[1])), + ...new Set([...optionalSection(content).matchAll(OPTIONAL_VAR_LINE)].map((match) => match[1])), ] +/** + * Variables Compose sets itself, so a compose file can read them without the + * user documenting them: the container name follows the project name through + * COMPOSE_PROJECT_NAME. + */ +const COMPOSE_PROVIDED_VARS = new Set(["COMPOSE_PROJECT_NAME"]) + /** Extracts all var names from a .env.example file (required + optional). */ const allEnvExampleVarNames = (envExampleContent: string): string[] => { const required = [...envExampleContent.matchAll(UNCOMMENTED_VAR_LINE)].map((match) => match[1]) @@ -139,6 +153,38 @@ describe("image constants", () => { }) }) +/** The parts of a compose file that name an install's project, container and volumes. */ +type ComposeNaming = { + name: string + services: { "vault-cortex": { container_name: string } } + volumes: Record +} + +describe("instance naming in the compose templates", () => { + it.each([{ mode: "local" as const }, { mode: "remote" as const }])( + "$mode names the project and container after INSTANCE_NAME and declares the volumes the CLI mounts", + ({ mode }) => { + const composeFile: ComposeNaming = parseYaml( + readRepoFile(`deploy/${mode}/docker-compose.yml`), + ) + // Compose prefixes each volume with the project name, so one INSTANCE_NAME + // gives a CLI install and a Compose install the same data. + const composeVolumeNames = Object.keys(composeFile.volumes).map((volume) => `work_${volume}`) + const cliVolumeNames = instanceVolumeNames({ mode, instanceName: "work" }) + + expect({ + name: composeFile.name, + containerName: composeFile.services["vault-cortex"].container_name, + volumeNames: composeVolumeNames.sort(), + }).toEqual({ + name: "${INSTANCE_NAME:-vault-cortex}", + containerName: "${COMPOSE_PROJECT_NAME:-${INSTANCE_NAME:-vault-cortex}}", + volumeNames: cliVolumeNames.sort(), + }) + }, + ) +}) + describe("hosted platform templates", () => { describe("render.yaml", () => { it("runs the image with the single-volume boot variables fixed", () => { @@ -285,7 +331,9 @@ describe("env var consistency across deploy surfaces", () => { const composeVars = new Set(interpolatedVars(readRepoFile(`deploy/${mode}/docker-compose.yml`))) const exampleVars = new Set(allEnvExampleVarNames(readRepoFile(`deploy/${mode}/.env.example`))) - const undocumented = [...composeVars].filter((varName) => !exampleVars.has(varName)).sort() + const undocumented = [...composeVars] + .filter((varName) => !exampleVars.has(varName) && !COMPOSE_PROVIDED_VARS.has(varName)) + .sort() expect(undocumented).toEqual([]) }) diff --git a/cli/src/__tests__/upgrade.test.ts b/cli/src/__tests__/upgrade.test.ts index 7e5e14b80..68d31886c 100644 --- a/cli/src/__tests__/upgrade.test.ts +++ b/cli/src/__tests__/upgrade.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest" import type { DockerRunner } from "../docker.js" import { runUpgrade } from "../upgrade.js" import { + createRecordingDocker, createScriptedPrompts, dockerDown, dockerReady, @@ -93,6 +94,7 @@ describe("runUpgrade", () => { [ { mode: "local", + instanceName: "vault-cortex", envFilePath: join(targetDir, ".env"), port: 8000, vaultPath: "/home/user/MyVault", @@ -101,6 +103,26 @@ describe("runUpgrade", () => { ]) }) + it("re-creates the container a custom INSTANCE_NAME names", async () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-upgrade-")) + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nOBSIDIAN_AUTH_TOKEN=sync-token\nVAULT_NAME=Work\nPUBLIC_URL=https://work.example.com\nINSTANCE_NAME=work\n", + ) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runUpgrade( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(0) + // Inspected before the pull, then again by the re-create step. + expect(recording.calls).toEqual(["inspect:work", "inspect:work", "rm:work", "run:work"]) + expect(recording.runParams.map((params) => params.instanceName)).toEqual(["work"]) + }) + it("detects remote mode and runs without a vault path", async () => { const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-upgrade-")) writeRemoteEnv(targetDir) @@ -124,6 +146,7 @@ describe("runUpgrade", () => { [ { mode: "remote", + instanceName: "vault-cortex", envFilePath: join(targetDir, ".env"), port: 8000, vaultPath: undefined, @@ -194,6 +217,34 @@ describe("runUpgrade", () => { ]) }) + it("refuses a container of another app before downloading the image", async () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-upgrade-")) + writeLocalEnv(targetDir) + const pulledImages: string[] = [] + const docker: DockerRunner = { + ...dockerReady, + containerImage: () => "postgres:16", + pullImage: (image) => { + pulledImages.push(image) + return true + }, + } + const scripted = createScriptedPrompts() + + const exitCode = await runUpgrade( + { dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(pulledImages).toEqual([]) + expect(scripted.errors).toEqual([ + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + "To keep this install's data, rename that container: docker rename vault-cortex \n" + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, + ]) + }) + it("exits 1 when image pull fails", async () => { const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-upgrade-")) writeLocalEnv(targetDir) diff --git a/cli/src/configure.ts b/cli/src/configure.ts index 5be4da217..7f7e16f14 100644 --- a/cli/src/configure.ts +++ b/cli/src/configure.ts @@ -4,8 +4,9 @@ import { recreateContainer, requireInitializedDir, resolveDeployment } from "./l import { applyOptionalSettings, askOptionalSettings, - derivePublicUrlOverride, - readOptionalValue, + describeStrandedPublicUrl, + portsOfOtherContainers, + withPublicUrlOverride, } from "./optional-settings.js" import type { DockerRunner } from "./docker.js" import type { Prompts } from "./prompts.js" @@ -35,14 +36,19 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): prompts.intro("vault-cortex configure") // Editing settings only needs an init'd .env — the full start validation - // (VAULT_PATH, PUBLIC_URL) runs later, only when a restart is requested. + // (PORT, VAULT_PATH, PUBLIC_URL) runs later, only when a restart is requested. const initialized = requireInitializedDir(flags.dir, prompts) if (!initialized) return 1 - const { targetDir, envFilePath, mode } = initialized + const { targetDir, envFilePath, mode, instanceName } = initialized const envContent = readFileSync(envFilePath, "utf8") - const pickedOverrides = await askOptionalSettings({ mode, envContent }, prompts) + const daemonStatus = docker.daemonStatus() + const portsInUse = portsOfOtherContainers( + daemonStatus === "running" ? docker.publishedHostPorts() : [], + instanceName, + ) + const pickedOverrides = await askOptionalSettings({ mode, envContent, portsInUse }, prompts) if (Object.keys(pickedOverrides).length === 0) { // Covers both empty-overrides paths: nothing picked in the chooser, and @@ -53,30 +59,28 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): return 0 } - const overrides = derivePublicUrlOverride(envContent, pickedOverrides) + const overrides = withPublicUrlOverride({ overrides: pickedOverrides, envContent }) const changedNames = Object.keys(overrides) writeFileSync(envFilePath, applyOptionalSettings(envContent, overrides)) prompts.log(`Updated ${changedNames.join(", ")} in ${targetDir}/.env.`) - // A custom PUBLIC_URL is never rewritten (see derivePublicUrlOverride), but - // a port change can still strand it — surface the consequence non-blocking. - const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL") + const publicUrlWarning = describeStrandedPublicUrl({ + envContent, + pickedOverrides, + appliedOverrides: overrides, + }) - if (pickedOverrides.PORT && !overrides.PUBLIC_URL && currentPublicUrl) { - prompts.warn( - `PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.`, - ) - } + if (publicUrlWarning) prompts.warn(publicUrlWarning) const restartHint = `Apply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"` - const daemonStatus = docker.daemonStatus() + // Asked again: Docker may have stopped or started while the prompts were open. + const restartDaemonStatus = docker.daemonStatus() - if (daemonStatus !== "running") { - // Settings are already saved — the runtime state only affects the restart - // offer, so both non-running states degrade to the hint; restart itself - // prints the full install/start guidance when run. + if (restartDaemonStatus !== "running") { + // Settings are already saved, so both non-running states degrade to the + // hint; restart itself prints the full install/start guidance when run. prompts.warn( - daemonStatus === "not-installed" + restartDaemonStatus === "not-installed" ? `No container runtime found — settings saved.\n${restartHint}` : `Container runtime not running — settings saved.\n${restartHint}`, ) diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 25b2478c1..67dafff6e 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -4,10 +4,29 @@ import type { Mode } from "./scaffold.js" export const LOCAL_IMAGE = "ghcr.io/aliasunder/vault-cortex:latest" export const REMOTE_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote" -export const CONTAINER_NAME = "vault-cortex" + +/** The image on GHCR and on its Docker Hub mirror, which Docker also reports without docker.io/. */ +const VAULT_CORTEX_IMAGE_REPOSITORIES = [ + "ghcr.io/aliasunder/vault-cortex", + "aliasunder/vault-cortex", + "docker.io/aliasunder/vault-cortex", +] + +/** True for any tag or digest of the Vault Cortex image. */ +export const isVaultCortexImage = (image: string): boolean => { + return VAULT_CORTEX_IMAGE_REPOSITORIES.some((repository) => { + return ( + image === repository || + image.startsWith(`${repository}:`) || + image.startsWith(`${repository}@`) + ) + }) +} export type DockerRunParams = { mode: Mode + /** The install's INSTANCE_NAME: the container name and the volume prefix. */ + instanceName: string envFilePath: string port: number /** Host vault path — local mode only (bind mount). */ @@ -15,12 +34,43 @@ export type DockerRunParams = { } export type DockerLogsParams = { + instanceName: string /** Stream new output until interrupted (`docker logs --follow`). */ follow: boolean /** Passed through to `docker logs --since` (e.g. "10m", "2h", or a timestamp). */ since?: string } +/** + * Named volumes per mode, mounted in this order. The volume names are + * `_`, the names Compose gives a project's volumes, so a + * CLI install and a Compose install with the same name share their data. + */ +const VOLUME_MOUNTS: Readonly>> = + { + local: [{ volume: "mcp_data", mountPath: "/data" }], + remote: [ + { volume: "vault_data", mountPath: "/vault" }, + { volume: "mcp_data", mountPath: "/data" }, + { volume: "obsidian_config", mountPath: "/home/obsidian/.config" }, + ], + } + +const volumeName = (instanceName: string, volume: string): string => `${instanceName}_${volume}` + +/** The named volumes an install of this mode and name mounts. */ +export const instanceVolumeNames = (params: { mode: Mode; instanceName: string }): string[] => { + const { mode, instanceName } = params + return VOLUME_MOUNTS[mode].map(({ volume }) => volumeName(instanceName, volume)) +} + +const volumeMountArgs = (mode: Mode, instanceName: string): string[] => { + return VOLUME_MOUNTS[mode].flatMap(({ volume, mountPath }) => [ + "-v", + `${volumeName(instanceName, volume)}:${mountPath}`, + ]) +} + /** * Container-runtime reachability: "running" (daemon answered), "not-running" * (binary exists but the daemon didn't answer), or "not-installed" (no docker @@ -29,6 +79,8 @@ export type DockerLogsParams = { */ export type DaemonStatus = "running" | "not-running" | "not-installed" +export type PublishedPort = { hostPort: number; containerName: string } + export type DockerRunner = { /** Whether the container runtime is reachable, stopped, or absent. */ daemonStatus: () => DaemonStatus @@ -36,10 +88,20 @@ export type DockerRunner = { dockerRun: (params: DockerRunParams) => boolean /** Pulls the latest image from the registry. */ pullImage: (image: string) => boolean - /** Stops and removes the vault-cortex container (idempotent). */ - stopAndRemoveContainer: () => boolean - /** True when a vault-cortex container exists, running or stopped. */ - containerExists: () => boolean + /** Stops and removes the named container (idempotent). */ + stopAndRemoveContainer: (instanceName: string) => boolean + /** + * The image the container with this exact name was created from, running + * or stopped, or undefined when no container has the name. + */ + containerImage: (instanceName: string) => string | undefined + /** True when a named volume exists. */ + volumeExists: (volumeName: string) => boolean + /** + * Host ports that existing containers publish, stopped ones included: one + * entry per container and port. Empty when the runtime can't list them. + */ + publishedHostPorts: () => ReadonlyArray /** * Streams `docker logs` to the terminal; resolves with the docker * process's exit code once the stream ends. @@ -68,14 +130,14 @@ const HEALTH_CMD = * testable without spawning processes. */ export const buildDockerRunArgs = (params: DockerRunParams): string[] => { - const { mode, envFilePath, port, vaultPath } = params + const { mode, instanceName, envFilePath, port, vaultPath } = params const image = mode === "local" ? LOCAL_IMAGE : REMOTE_IMAGE const args = [ "run", "-d", "--name", - CONTAINER_NAME, + instanceName, "--restart", "unless-stopped", "--env-file", @@ -90,17 +152,17 @@ export const buildDockerRunArgs = (params: DockerRunParams): string[] => { throw new Error("vaultPath is required for local mode") } args.push("-v", `${vaultPath}:/vault:rw`) - args.push("-v", "vault-cortex_mcp_data:/data") + args.push(...volumeMountArgs(mode, instanceName)) args.push("--health-cmd", HEALTH_CMD) args.push("--health-interval", "15s") args.push("--health-timeout", "5s") args.push("--health-retries", "3") args.push("--health-start-period", "20s") } else { - args.push("--hostname", CONTAINER_NAME) - args.push("-v", "vault-cortex_vault_data:/vault") - args.push("-v", "vault-cortex_mcp_data:/data") - args.push("-v", "vault-cortex_obsidian_config:/home/obsidian/.config") + // The init chain names the Sync device after the hostname when the .env + // sets no DEVICE_NAME; a fixed name keeps it from being a container ID. + args.push("--hostname", instanceName) + args.push(...volumeMountArgs(mode, instanceName)) args.push("--health-cmd", HEALTH_CMD) args.push("--health-interval", "15s") args.push("--health-timeout", "5s") @@ -123,12 +185,12 @@ export const buildDockerRunArgs = (params: DockerRunParams): string[] => { * testable without spawning processes. */ export const buildDockerLogsArgs = (params: DockerLogsParams): string[] => { - const { follow, since } = params + const { instanceName, follow, since } = params return [ "logs", ...(follow ? ["--follow"] : []), ...(since ? ["--since", since] : []), - CONTAINER_NAME, + instanceName, ] } @@ -149,27 +211,127 @@ export const classifyDaemonStatus = (spawnResult: { return spawnErrorCode === "ENOENT" ? "not-installed" : "not-running" } +/** + * Whether the name `docker container inspect` printed is this name. Docker + * prints it with a leading slash; Podman and nerdctl print it bare. + */ +export const isContainerNamed = (params: { + inspectedName: string + instanceName: string +}): boolean => { + const { inspectedName, instanceName } = params + return inspectedName.trim().replace(/^\//, "") === instanceName +} + +/** Go template that prints a container's name and image: "/work ghcr.io/aliasunder/vault-cortex:latest". */ +const NAME_AND_IMAGE_FORMAT = "{{.Name}} {{.Config.Image}}" + +/** Reads NAME_AND_IMAGE_FORMAT output into the image, or undefined when it names another container. */ +export const parseContainerImage = (params: { + inspectOutput: string + instanceName: string +}): string | undefined => { + const [inspectedName, image] = params.inspectOutput.trim().split(/\s+/) + + if (!image || !isContainerNamed({ inspectedName, instanceName: params.instanceName })) { + return undefined + } + return image +} + +/** + * Go template that prints a container's name, then each host port it + * publishes: "/work 8001". PortBindings is the container's run setting, so a + * stopped container's ports print too. + */ +const PUBLISHED_PORTS_FORMAT = + "{{.Name}}{{range $containerPort, $bindings := .HostConfig.PortBindings}}{{range $bindings}} {{.HostPort}}{{end}}{{end}}" + +/** + * A published host port ("8000"), or a range ("8000-8010") the runtime picks one + * port from at start. An empty HostPort means the runtime picks any port. + */ +const HOST_PORT_PATTERN = /^(\d+)(?:-(\d+))?$/ + +/** Every port a HostPort value can claim; a range counts whole, since any of its ports may be the one in use. */ +const expandHostPort = (hostPort: string): number[] => { + const match = HOST_PORT_PATTERN.exec(hostPort) + + if (!match) return [] + const firstPort = Number(match[1]) + const lastPort = match[2] ? Number(match[2]) : firstPort + return Array.from({ length: lastPort - firstPort + 1 }, (_, offset) => firstPort + offset) +} + +/** + * Reads PUBLISHED_PORTS_FORMAT output into one entry per container and port, + * so a port two containers publish keeps both. + */ +export const parsePublishedHostPorts = (inspectOutput: string): ReadonlyArray => { + return inspectOutput.split("\n").flatMap((line) => { + const [inspectedName, ...hostPorts] = line.trim().split(/\s+/) + + if (!inspectedName) return [] + // Docker prints the name with a leading slash; Podman and nerdctl print it bare. + const containerName = inspectedName.replace(/^\//, "") + return hostPorts.flatMap(expandHostPort).map((hostPort) => ({ hostPort, containerName })) + }) +} + export const createDockerRunner = (): DockerRunner => ({ daemonStatus: () => classifyDaemonStatus(spawnSync("docker", ["info"], { timeout: 5_000 })), // stdout is discarded: `docker run -d` prints only the container ID there, // which lands as a raw hex line between the wizard's prompts. stderr stays // inherited — image-pull progress and error output print live, which the // "see output above" failure messages rely on. - dockerRun: (params) => - spawnSync("docker", buildDockerRunArgs(params), { - stdio: ["ignore", "ignore", "inherit"], - }).status === 0, + dockerRun: (params) => { + return ( + spawnSync("docker", buildDockerRunArgs(params), { + stdio: ["ignore", "ignore", "inherit"], + }).status === 0 + ) + }, pullImage: (image) => spawnSync("docker", ["pull", image], { stdio: "inherit" }).status === 0, - stopAndRemoveContainer: () => spawnSync("docker", ["rm", "-f", CONTAINER_NAME]).status === 0, - // `docker rm -f` on a missing container exits 1 on engines < 23 and 0 on - // >= 23, so stopAndRemoveContainer's status can't distinguish "already - // gone" from "failed" — callers needing idempotent messaging probe - // existence first. Output stays piped (discarded): this is a boolean probe. - containerExists: () => spawnSync("docker", ["container", "inspect", CONTAINER_NAME]).status === 0, + stopAndRemoveContainer: (instanceName) => { + return spawnSync("docker", ["rm", "-f", instanceName]).status === 0 + }, + // The printed name is compared because Docker falls back to container-ID + // prefixes, so an all-hex name ("2025") could match another container. + containerImage: (instanceName) => { + const inspectResult = spawnSync( + "docker", + ["container", "inspect", "--format", NAME_AND_IMAGE_FORMAT, instanceName], + { encoding: "utf8" }, + ) + + if (inspectResult.status !== 0) return undefined + return parseContainerImage({ inspectOutput: inspectResult.stdout, instanceName }) + }, + volumeExists: (volumeName) => { + return spawnSync("docker", ["volume", "inspect", volumeName]).status === 0 + }, + publishedHostPorts: () => { + const listResult = spawnSync("docker", ["container", "ls", "-aq"], { encoding: "utf8" }) + + if (listResult.status !== 0) return [] + const containerIds = listResult.stdout.split("\n").filter(Boolean) + + if (containerIds.length === 0) return [] + const inspectResult = spawnSync( + "docker", + ["container", "inspect", "--format", PUBLISHED_PORTS_FORMAT, ...containerIds], + { encoding: "utf8" }, + ) + + if (inspectResult.error) return [] + // Read whatever the exit status: a container removed after the listing + // makes inspect exit 1, but it still prints the others. + return parsePublishedHostPorts(inspectResult.stdout) + }, // Async spawn, not spawnSync: --follow streams until interrupted, and the // exit code must be observable after the stream closes. - streamLogs: (params) => - new Promise((resolveExitCode) => { + streamLogs: (params) => { + return new Promise((resolveExitCode) => { const child = spawn("docker", buildDockerLogsArgs(params), { stdio: ["ignore", "inherit", "inherit"], }) @@ -188,7 +350,8 @@ export const createDockerRunner = (): DockerRunner => ({ // A null code means the child died to a signal — report the shell // convention for ctrl-C (128 + SIGINT = 130). child.once("close", (code) => resolveExitCode(code ?? 130)) - }), + }) + }, }) /** Default bound on a single health request (shared by probe and poll). */ @@ -233,8 +396,13 @@ export const healthPollTimeoutMs = (mode: Mode): number => { * adds a first-sync hint: the container's init chain retries the first * sync with no upper time bound, so an expired poll does not mean the * server failed — it may flip healthy after the CLI stops waiting. */ -export const healthTimeoutMessage = (mode: Mode, timeoutMs: number): string => { - const baseMessage = `Server did not respond within ${timeoutMs / 60_000} minutes — check: docker logs ${CONTAINER_NAME}` +export const healthTimeoutMessage = (params: { + mode: Mode + timeoutMs: number + instanceName: string +}): string => { + const { mode, timeoutMs, instanceName } = params + const baseMessage = `Server did not respond within ${timeoutMs / 60_000} minutes — check: docker logs ${instanceName}` if (mode === "remote") { return `${baseMessage} (a long first sync may still be running — the container keeps starting in the background)` diff --git a/cli/src/env.ts b/cli/src/env.ts index c9dad9eeb..0e9496d1e 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -30,6 +30,7 @@ const LOCAL_OPTIONAL_BLOCK = `# Optional ───────────── # Public URL for OAuth issuer and access-token binding (default: http://localhost:8000). # Override if you expose the server on a different URL (e.g. via a reverse proxy). +# When you change PORT, change the port here too, or MCP clients can't sign in. PUBLIC_URL=http://localhost:8000 # Largest file vault_read_file will read, in bytes (default: 52428800 = 50 MiB). @@ -117,8 +118,7 @@ MEMORY_DIR=About Me # setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. -# To apply environment changes, run the CLI's restart command -# or recreate the Compose container. +# Environment changes apply when the container is re-created. # Obsidian settings apply on the next query; daily-notes.json is read # directly from your bind-mounted vault. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me @@ -127,6 +127,18 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork +# Name of this install's container and search-index volume +# (_mcp_data). To run more than one vault on this machine, give each +# install its own INSTANCE_NAME and PORT, with PUBLIC_URL on that port. +# Valid values: two or more lowercase letters, digits, hyphens or underscores, +# starting with a letter or digit. +# Default: vault-cortex +# Set it before the first start. To change it later, stop the container first, +# then change it to a name no other install uses: the install starts on a new, +# empty index volume, and the old one stays until you remove it +# (docker volume rm _mcp_data). +INSTANCE_NAME=vault-cortex + # Host port to expose (default: 8000). PORT=8000 @@ -158,8 +170,8 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: none). # The container's own log (what \`docker logs\` shows) is always written, but -# Docker discards it whenever the container is recreated — on image updates -# or compose changes. Set a path (e.g. /data/logs) to also write date-stamped +# Docker discards it whenever the container is re-created — on image updates +# or settings changes. Set a path (e.g. /data/logs) to also write date-stamped # log files there; the /data volume keeps them. LOG_DIR=none @@ -271,8 +283,7 @@ MEMORY_DIR=About Me # setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. -# To apply environment changes, run the CLI's restart command -# or recreate the Compose container. +# Environment changes apply when the container is re-created. # Obsidian settings apply on the next query once daily-notes.json reaches the server. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me @@ -280,6 +291,19 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork +# Name of this install's container and data volumes +# (_vault_data, _mcp_data and _obsidian_config). To run more +# than one vault on this server, give each install its own INSTANCE_NAME, PORT +# and PUBLIC_URL. +# Valid values: two or more lowercase letters, digits, hyphens or underscores, +# starting with a letter or digit. +# Default: vault-cortex +# Set it before the first start. To change it later, stop the container first, +# then change it to a name no other install uses: the install starts on new, +# empty volumes and downloads the vault again as a new Obsidian Sync device, +# while the old volumes stay until you remove them. +INSTANCE_NAME=vault-cortex + # Host port to expose (default: 8000). PORT=8000 @@ -311,8 +335,8 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: /data/logs). # The container's own log (what \`docker logs\` shows) is always written, but -# Docker discards it whenever the container is recreated — on image updates -# or compose changes. Log files under LOG_DIR live on the data volume and +# Docker discards it whenever the container is re-created — on image updates +# or settings changes. Log files under LOG_DIR live on the data volume and # survive. Set to "none" to keep only the container log. LOG_DIR=/data/logs @@ -395,8 +419,8 @@ VAULT_PASSWORD=${answers.vaultPassword}` const obsidianTokenComment = answers.obsidianAuthToken ? `# Obsidian Sync auth token.` - : `# Obsidian Sync auth token — FILL THIS IN before starting the server. -# Generate once with: + : `# Obsidian Sync auth token. Leave empty to sign in on the server's sign-in +# page at your PUBLIC_URL once it starts (over HTTPS), or generate one with: # npx vault-cortex@latest get-sync-token` return `# vault-cortex — remote quickstart (Obsidian Sync) diff --git a/cli/src/init.ts b/cli/src/init.ts index 8ae37dc9d..e31f8cc1b 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { join, resolve } from "node:path" import { buildLocalEnv, buildRemoteEnv } from "./env.js" @@ -17,17 +17,23 @@ import { pollHealth, type DockerRunner, } from "./docker.js" -import { reportPublicUrlProbe } from "./lifecycle.js" +import { + applyInstanceIdentity, + describeStrandedCarriedPublicUrl, + resolveInstanceIdentity, +} from "./instance-identity.js" +import { checkInstanceContainer, reportPublicUrlProbe, resolveDeployment } from "./lifecycle.js" import { applyOptionalSettings, askOptionalSettings, - derivePublicUrlOverride, + describeStrandedPublicUrl, + readOptionalValue, + withPublicUrlOverride, } from "./optional-settings.js" import { buildFilesToWrite, + detectMode, readEnvObsidianToken, - readEnvPort, - readEnvPublicUrl, stripEnvQuotedValues, writeFiles, type FileWriteResult, @@ -41,6 +47,10 @@ export type InitFlags = { mode?: string vaultPath?: string dir?: string + /** INSTANCE_NAME for this install, skipping the name-in-use prompt. */ + instanceName?: string + /** Host PORT for this install. */ + port?: string yes?: boolean } @@ -191,6 +201,13 @@ export const validatePublicUrl = (input: string): PublicUrlValidation => { return { kind: "ok", url: trimmed.replace(/\/+$/, "") } } +const askTargetDir = async (prompts: Prompts): Promise => { + return await prompts.text("Where should I put the config files?", { + defaultValue: DEFAULT_TARGET_DIR, + placeholder: DEFAULT_TARGET_DIR, + }) +} + /** Re-prompts until the answer is a valid base http(s) URL (no /mcp path). */ const askPublicUrl = async (prompts: Prompts): Promise => { const answer = await prompts.text( @@ -228,6 +245,30 @@ const confirmOverwrite = (name: string): Promise => prompts.confirm(`${name} already exists and differs — overwrite?`, false) +/** + * The refusal for a folder whose .env belongs to the other mode's install: + * start, restart and upgrade read the mode from that file, so a re-run must + * not write the other mode's settings over it or start the other image. + */ +const describeModeMismatch = (params: { targetDir: string; mode: Mode }): string | undefined => { + const { targetDir, mode } = params + const envFilePath = join(targetDir, ".env") + const existingMode = detectMode(envFilePath) + + if (!existingMode || existingMode === mode) return undefined + // detectMode reads any .env without a Sync token as local, but only a file + // init wrote has MCP_AUTH_TOKEN; another app's .env gets the usual re-run question. + const writtenByInit = + readOptionalValue(readFileSync(envFilePath, "utf8"), "MCP_AUTH_TOKEN") !== undefined + + if (!writtenByInit) return undefined + return ( + `${targetDir} holds a ${existingMode} install, and this run sets up a ${mode} one.\n` + + `To start the existing install: ${startCommand(targetDir)}\n` + + `To set up a ${mode} install, run init again with another --dir.` + ) +} + /** * Re-init guard, fired the moment the target dir is known (prompt answer or * --dir flag): an existing .env there means a live deployment, so the flow @@ -271,16 +312,17 @@ const reportWrites = ( /** * Offers to start the container, walking a gate ladder where each failed - * gate degrades to instructions instead of an error: daemon running → user - * consents → docker run succeeds → health check passes. Returns "running" - * when the server is confirmed up, "starting" when the container launched - * but the health check timed out, or "not-started" when a gate failed. + * gate degrades to instructions instead of an error: daemon running → no + * other image's container holds the name → user consents → any container with + * this name is removed → docker run succeeds → health check passes. Returns + * "running" when the server is confirmed up, "starting" when the container + * launched but the health check timed out, or "not-started" when a gate failed. */ const offerDockerRun = async ( - params: { targetDir: string; port: number; mode: Mode; vaultPath?: string }, + params: { targetDir: string; instanceName: string; port: number; mode: Mode; vaultPath?: string }, deps: InitDeps, ): Promise => { - const { targetDir, port, mode, vaultPath } = params + const { targetDir, instanceName, port, mode, vaultPath } = params const { prompts, docker, fetchFn } = deps const daemonStatus = docker.daemonStatus() @@ -295,13 +337,30 @@ const offerDockerRun = async ( ) return "not-started" } - const startNow = await prompts.confirm("Start the server now?", true) + const envFilePath = join(targetDir, ".env") + const instanceContainer = checkInstanceContainer( + { instanceName, envFilePath }, + { prompts, docker }, + ) + + if (instanceContainer === "foreign") return "not-started" + // docker run refuses a name an existing container already holds, so a yes + // replaces that container the way `start` does, and the question says so. + const containerExists = instanceContainer === "own" + const startQuestion = containerExists + ? `Start the server now? This stops and replaces the existing "${instanceName}" container. Its data volumes are kept.` + : "Start the server now?" + const startNow = await prompts.confirm(startQuestion, true) if (!startNow) return "not-started" - const envFilePath = join(targetDir, ".env") + if (containerExists && !docker.stopAndRemoveContainer(instanceName)) { + prompts.error(`Could not remove the existing container — check: docker rm -f ${instanceName}`) + return "not-started" + } stripEnvQuotedValues(envFilePath) const containerStarted = docker.dockerRun({ mode, + instanceName, envFilePath, port, vaultPath, @@ -318,7 +377,7 @@ const offerDockerRun = async ( const healthy = await pollHealth({ url: `http://127.0.0.1:${port}/healthz`, timeoutMs }, fetchFn) if (!healthy) { - spinner.stop(healthTimeoutMessage(mode, timeoutMs)) + spinner.stop(healthTimeoutMessage({ mode, timeoutMs, instanceName })) return "starting" } spinner.stop("Server is up — health check passed.") @@ -339,6 +398,8 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = if (flags.yes) { if (!vaultPathResult || vaultPathResult.kind === "error") { + // runInit rejects --yes without --vault-path before calling this, so the + // fallback message is unreachable; it covers vaultPathResult's undefined type. prompts.error(vaultPathResult?.message ?? "--yes requires --vault-path.") return 1 } @@ -356,19 +417,17 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = ? vaultPathResult.path : await askVaultPath(prompts) + const requestedTargetDir = + flags.dir ?? (flags.yes ? DEFAULT_TARGET_DIR : await askTargetDir(prompts)) // expandTilde before resolve: resolve() treats a leading `~` as a literal // path segment, so a quoted "~/path" would create a directory named "~". - const targetDir = resolve( - expandTilde( - flags.dir ?? - (flags.yes - ? DEFAULT_TARGET_DIR - : await prompts.text("Where should I put the config files?", { - defaultValue: DEFAULT_TARGET_DIR, - placeholder: DEFAULT_TARGET_DIR, - })), - ), - ) + const targetDir = resolve(expandTilde(requestedTargetDir)) + const modeMismatch = describeModeMismatch({ targetDir, mode: "local" }) + + if (modeMismatch) { + prompts.error(modeMismatch) + return 1 + } // --yes skips the guard: it's non-interactive by contract, and its own // conflict policy (refuse to overwrite, exit 1) already protects the dir. @@ -378,6 +437,10 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = if (!continueReinit) return 0 } + const identityResult = await resolveInstanceIdentity({ mode: "local", targetDir, flags }, deps) + + if (identityResult.kind === "exit") return 1 + const token = generateToken() // Guided optional settings: the chooser reads current values from the @@ -387,14 +450,29 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = // user asked for a full re-run — the answers land in the regenerated file // when they overwrite at the conflict prompt (keeping it discards them, // which the write report states). In-place edits stay configure's job. - const defaultEnvContent = buildLocalEnv({ mcpAuthToken: token, vaultPath }) + const templateEnvContent = buildLocalEnv({ mcpAuthToken: token, vaultPath }) + const generatedEnvContent = applyInstanceIdentity(templateEnvContent, identityResult.identity) const optionalOverrides = flags.yes ? {} - : await askOptionalSettings({ mode: "local", envContent: defaultEnvContent }, prompts) - const envContent = applyOptionalSettings( - defaultEnvContent, - derivePublicUrlOverride(defaultEnvContent, optionalOverrides), - ) + : await askOptionalSettings( + { + mode: "local", + envContent: generatedEnvContent, + portsInUse: identityResult.portsInUse, + }, + prompts, + ) + const appliedOverrides = withPublicUrlOverride({ + overrides: optionalOverrides, + envContent: generatedEnvContent, + }) + const envContent = applyOptionalSettings(generatedEnvContent, appliedOverrides) + const publicUrlWarning = + describeStrandedPublicUrl({ + envContent: generatedEnvContent, + pickedOverrides: optionalOverrides, + appliedOverrides, + }) ?? describeStrandedCarriedPublicUrl(templateEnvContent, identityResult.identity) // Conflict policy: identical existing files are skipped silently; // differing ones prompt per file (default keep). --yes never overwrites — @@ -420,15 +498,26 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = const tokenWritten = envResult?.status === "created" || envResult?.status === "overwritten" if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") - const port = readEnvPort(join(targetDir, ".env")) + // Only a written .env carries the picked port, so a kept one needs no warning. + if (tokenWritten && publicUrlWarning) prompts.warn(publicUrlWarning) + // The .env on disk decides what starts, with start's own checks: a kept + // file may hold another name, port, vault folder or PUBLIC_URL than this run's. + const deployment = resolveDeployment(targetDir, prompts) + + if (!deployment) return 1 + const { instanceName, port } = deployment // --yes is for scripts/CI, so it never starts Docker. const startStatus: StartStatus = flags.yes ? "not-started" - : await offerDockerRun({ targetDir, port, mode: "local", vaultPath }, deps) + : await offerDockerRun( + { targetDir, instanceName, port, mode: "local", vaultPath: deployment.vaultPath }, + deps, + ) prompts.print( buildLocalConnectMessage({ targetDir, + instanceName, token, startStatus, port, @@ -446,36 +535,41 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise => { const { prompts, fetchFn } = deps + const requestedTargetDir = flags.dir ?? (await askTargetDir(prompts)) // expandTilde before resolve: resolve() treats a leading `~` as a literal // path segment, so a quoted "~/path" would create a directory named "~". - const targetDir = resolve( - expandTilde( - flags.dir ?? - (await prompts.text("Where should I put the config files?", { - defaultValue: DEFAULT_TARGET_DIR, - placeholder: DEFAULT_TARGET_DIR, - })), - ), - ) + const targetDir = resolve(expandTilde(requestedTargetDir)) + const modeMismatch = describeModeMismatch({ targetDir, mode: "remote" }) + + if (modeMismatch) { + prompts.error(modeMismatch) + return 1 + } const continueReinit = await confirmReinitOverExistingEnv(targetDir, prompts) if (!continueReinit) return 0 + // Before the public URL prompt, so a second install's port is chosen before + // the user types the URL that reaches it. + const identityResult = await resolveInstanceIdentity({ mode: "remote", targetDir, flags }, deps) + + if (identityResult.kind === "exit") return 1 + const publicUrl = await askPublicUrl(prompts) const vaultName = await askVaultName(prompts) // Sign in to Obsidian and capture the Sync token directly via the API. // When the user declines or capture fails, fall back to any token already // in the on-disk .env (a re-init over an existing deployment). Only show - // the "run get-sync-token later" guidance when neither source has a token. + // the sign-in guidance when neither source has a token. const capturedToken = await offerSyncTokenCapture(prompts, fetchFn) const existingEnvToken = readEnvObsidianToken(join(targetDir, ".env")) - const hasExistingToken = Boolean(capturedToken ?? existingEnvToken) + const hasSyncToken = Boolean(capturedToken ?? existingEnvToken) - if (!hasExistingToken) { + if (!hasSyncToken) { prompts.log( - "No token yet — run this later to add it to your .env:\n" + + "No Sync token in .env — sign in on the server's sign-in page once it starts, or add a token with:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) } @@ -492,21 +586,30 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise // .env passed the re-init guard — a consented re-run gets the full setup, // chooser included (see the local flow's comment for the overwrite/keep // semantics). - const defaultEnvContent = buildRemoteEnv({ - mcpAuthToken: token, - publicUrl, - obsidianAuthToken: capturedToken ?? existingEnvToken, - vaultName, - vaultPassword, - }) + const generatedEnvContent = applyInstanceIdentity( + buildRemoteEnv({ + mcpAuthToken: token, + publicUrl, + obsidianAuthToken: capturedToken ?? existingEnvToken, + vaultName, + vaultPassword, + }), + identityResult.identity, + ) const optionalOverrides = await askOptionalSettings( - { mode: "remote", envContent: defaultEnvContent }, + { mode: "remote", envContent: generatedEnvContent, portsInUse: identityResult.portsInUse }, prompts, ) - const envContent = applyOptionalSettings( - defaultEnvContent, - derivePublicUrlOverride(defaultEnvContent, optionalOverrides), - ) + const appliedOverrides = withPublicUrlOverride({ + overrides: optionalOverrides, + envContent: generatedEnvContent, + }) + const envContent = applyOptionalSettings(generatedEnvContent, appliedOverrides) + const publicUrlWarning = describeStrandedPublicUrl({ + envContent: generatedEnvContent, + pickedOverrides: optionalOverrides, + appliedOverrides, + }) const files = buildFilesToWrite(envContent) const results = await writeFiles({ targetDir, files }, confirmOverwrite(prompts)) reportWrites({ targetDir, results }, prompts) @@ -519,18 +622,23 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise const tokenWritten = envResult?.status === "created" || envResult?.status === "overwritten" if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") - const port = readEnvPort(join(targetDir, ".env")) - // Like PORT above, PUBLIC_URL comes from the .env actually on disk — a kept - // existing file may hold a different URL than this run's prompt, and the - // server only reads the file. The prompted value is the fallback for a kept - // legacy .env that predates PUBLIC_URL. - const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl - - // Without the sync token the container can't start (init-check-auth fails - // and s6 stops it), so only offer docker run when it was provided. - const startStatus: StartStatus = !hasExistingToken - ? "not-started" - : await offerDockerRun({ targetDir, port, mode: "remote" }, deps) + if (tokenWritten && publicUrlWarning) prompts.warn(publicUrlWarning) + if (capturedToken && !tokenWritten) { + prompts.warn( + `The Sync token from this sign-in wasn't saved, because ${targetDir}/.env was kept. To add it, run:\n` + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, + ) + } + // Same on-disk checks as the local flow; the server reads only the file, + // so a kept file's PUBLIC_URL wins over this run's prompt. + const deployment = resolveDeployment(targetDir, prompts) + + if (!deployment) return 1 + const { instanceName, port, publicUrl: effectivePublicUrl } = deployment + + // Started without a Sync token in .env, the container keeps a login saved + // on its volume, or serves its sign-in page; the closing message covers both. + const startStatus = await offerDockerRun({ targetDir, instanceName, port, mode: "remote" }, deps) // The container check above hit localhost on this machine; the public URL // is the ingress path clients actually use — probe it too, informationally. @@ -543,10 +651,12 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise prompts.print( buildRemoteConnectMessage({ targetDir, + instanceName, token, publicUrl: effectivePublicUrl, startStatus, - obsidianTokenMissing: !hasExistingToken, + // From the file, like PUBLIC_URL: a token captured this run is not in a kept .env. + obsidianTokenMissing: !readEnvObsidianToken(join(targetDir, ".env")), tokenWritten, }), ) diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts new file mode 100644 index 000000000..8b91f5cdf --- /dev/null +++ b/cli/src/instance-identity.ts @@ -0,0 +1,441 @@ +import { existsSync, readFileSync } from "node:fs" +import { basename, join } from "node:path" + +import { instanceVolumeNames, isVaultCortexImage, type DockerRunner } from "./docker.js" +import { + applyOptionalSettings, + askFreePort, + buildPortsInUse, + describeStrandedPublicUrl, + portInUseMessage, + portsOfOtherContainers, + readOptionalValue, + suggestFreePort, + withPublicUrlOverride, + type PortsInUse, +} from "./optional-settings.js" +import { + DEFAULT_INSTANCE_NAME, + DEFAULT_PORT, + describeInvalidEnvPort, + describeInvalidInstanceName, + isValidPort, + type Mode, +} from "./scaffold.js" +import type { Prompts } from "./prompts.js" + +/** The .env values that tell one install on a machine apart from another. */ +export type InstanceIdentity = { + /** + * Read from the folder's existing .env, without surrounding quotes, when + * init runs again over it. + */ + carried: Record + /** + * Chosen in this run, by flag or prompt. A `http://localhost:` + * PUBLIC_URL follows a chosen PORT. + */ + chosen: Record +} + +export type InstanceIdentityFlags = { + instanceName?: string + port?: string + yes?: boolean +} + +type InstanceIdentityResult = + | { + kind: "ok" + identity: InstanceIdentity + /** Ports other containers publish, which the optional settings' port prompt refuses. */ + portsInUse: PortsInUse + } + | { kind: "exit" } + +/** + * Identity lines an existing install keeps through a re-run of init. A local + * PUBLIC_URL is the OAuth issuer and follows its port; a remote one is asked + * again in the same run, so it is not carried. + */ +const CARRIED_SETTINGS: Readonly>> = { + local: ["INSTANCE_NAME", "PORT", "PUBLIC_URL"], + remote: ["INSTANCE_NAME", "PORT", "DEVICE_NAME"], +} + +const readCarriedIdentity = (envFilePath: string, mode: Mode): Record => { + const envContent = readFileSync(envFilePath, "utf8") + const carriedEntries = CARRIED_SETTINGS[mode].flatMap((name) => { + const currentValue = readOptionalValue(envContent, name) + return currentValue ? [[name, currentValue] as const] : [] + }) + return Object.fromEntries(carriedEntries) +} + +/** A leftover volume counts as in use, because a new install with that name would mount the old data. */ +const hasInstanceVolumes = ( + params: { mode: Mode; instanceName: string }, + docker: DockerRunner, +): boolean => { + return instanceVolumeNames(params).some((volumeName) => docker.volumeExists(volumeName)) +} + +/** Whether another install already uses this name on the machine: its container or one of its volumes exists. */ +const isInstanceNameInUse = ( + params: { mode: Mode; instanceName: string }, + docker: DockerRunner, +): boolean => { + return Boolean(docker.containerImage(params.instanceName)) || hasInstanceVolumes(params, docker) +} + +const EXAMPLE_INSTANCE_NAME = "work" + +/** Re-prompts until the answer is a valid name no other install on the machine uses. */ +const askFreeInstanceName = async ( + params: { mode: Mode; suggestedName: string | undefined }, + deps: { prompts: Prompts; docker: DockerRunner }, +): Promise => { + const { mode, suggestedName } = params + const { prompts, docker } = deps + const answer = ( + await prompts.text("Name for this install (lowercase letters, digits, - and _):", { + defaultValue: suggestedName, + placeholder: suggestedName ?? EXAMPLE_INSTANCE_NAME, + }) + ).trim() + const invalidReason = describeInvalidInstanceName(answer) + + if (invalidReason) { + prompts.error(invalidReason) + return askFreeInstanceName(params, deps) + } + if (isInstanceNameInUse({ mode, instanceName: answer }, docker)) { + prompts.error(`"${answer}" is already in use on this machine — pick another name.`) + return askFreeInstanceName(params, deps) + } + return answer +} + +/** + * The target folder's name, offered as the new install's name when it is a + * valid name nobody uses yet (a `--dir ./work` install suggests `work`). + */ +const suggestInstanceName = ( + params: { mode: Mode; targetDir: string }, + docker: DockerRunner, +): string | undefined => { + const folderName = basename(params.targetDir).toLowerCase() + + if (describeInvalidInstanceName(folderName)) return undefined + if (isInstanceNameInUse({ mode: params.mode, instanceName: folderName }, docker)) return undefined + return folderName +} + +/** Labels a new remote install's changes in its vault's sync log with the install's name. */ +const buildDeviceNameSetting = (mode: Mode, instanceName: string): Record => { + return mode === "remote" ? { DEVICE_NAME: instanceName } : {} +} + +const renameOnRerunMessage = (params: { carriedName: string; targetDir: string }): string => { + const { carriedName, targetDir } = params + return ( + `--instance-name: the install in ${targetDir} is named "${carriedName}", and init keeps that name.\n` + + `To rename it, stop it first (npx vault-cortex@latest down --dir "${targetDir}"), then change INSTANCE_NAME in its .env\n` + + `to a name no other install uses: it starts on new, empty volumes under the new name.` + ) +} + +// The message offers no "run init again" fix: once this run writes .env, a +// re-run keeps its name and skips the check. +const nameCheckSkippedMessage = (instanceName: string): string => { + return ( + `Docker isn't running, so init can't check whether another install on this machine already uses the name "${instanceName}".\n` + + `If one does, give this install its own INSTANCE_NAME and PORT in its .env before you start it.` + ) +} + +type PortSettingResult = { kind: "ok"; settings: Record } | { kind: "exit" } + +/** + * Checks the port the install will publish (--port, or the default) against + * the ports other containers publish: a taken port stops --yes, and is asked + * again otherwise. + */ +const checkRequestedPort = async ( + params: { flags: InstanceIdentityFlags; portsInUse: PortsInUse }, + prompts: Prompts, +): Promise => { + const { flags, portsInUse } = params + const requestedPort = flags.port ?? String(DEFAULT_PORT) + const portUser = portsInUse.get(Number(requestedPort)) + + if (!portUser) return { kind: "ok", settings: flags.port ? { PORT: flags.port } : {} } + const message = portInUseMessage({ port: requestedPort, containerName: portUser }) + + if (flags.yes) { + prompts.error( + `${message} Re-run with a free port, such as --port ${suggestFreePort(portsInUse)}.`, + ) + return { kind: "exit" } + } + prompts.warn(message) + const port = await askFreePort( + { suggestedPort: suggestFreePort(portsInUse), portsInUse }, + prompts, + ) + return { kind: "ok", settings: { PORT: port } } +} + +/** + * A reused install's .env is in another folder, so its port is asked for + * rather than assumed to be the default; --yes cannot ask and keeps --port + * or the default. + */ +const askReusedPort = async ( + params: { flags: InstanceIdentityFlags; portsInUse: PortsInUse }, + prompts: Prompts, +): Promise => { + const { flags, portsInUse } = params + + if (flags.port || flags.yes) return checkRequestedPort(params, prompts) + const port = await askFreePort( + { suggestedPort: suggestFreePort(portsInUse), portsInUse }, + prompts, + ) + return { kind: "ok", settings: { PORT: port } } +} + +const nameInUseWithYesFlagMessage = (instanceName: string): string => { + return ( + `An install named "${instanceName}" already exists on this machine (its container or data volumes are here).\n` + + `Re-run with --instance-name (and --port if ${instanceName}'s port is taken) to give this install its own name,\n` + + `or with --instance-name ${instanceName} (and --port unless that install uses ${DEFAULT_PORT}) to reuse its data for the same vault.` + ) +} + +/** + * Decides this install's name and port before init writes .env: + * - a re-run over an existing .env keeps its name and port, plus a local + * PUBLIC_URL or a remote DEVICE_NAME (`carried`), and exits when the name + * or port is invalid (--port replaces the port) or --instance-name gives a + * different name; + * - --instance-name and --port set them directly; + * - a new install whose name is already in use on the machine asks for its own + * name and port, or confirms reuse of the existing data for the same vault. + * --yes cannot ask, so it exits unless --instance-name settles it; + * - a new install's port, and a re-run's --port, must not be one another + * container publishes. + * The checks need a running Docker daemon; without one a new install's checks + * are skipped with a warning, and a re-run's --port is not checked. + */ +export const resolveInstanceIdentity = async ( + params: { mode: Mode; targetDir: string; flags: InstanceIdentityFlags }, + deps: { prompts: Prompts; docker: DockerRunner }, +): Promise => { + const { mode, targetDir, flags } = params + const { prompts, docker } = deps + + // Compared with undefined so an empty flag is an error: an empty + // --instance-name would skip the default name's in-use check, and an empty + // --port would silently become the default port. + const flagNameError = + flags.instanceName === undefined ? undefined : describeInvalidInstanceName(flags.instanceName) + + if (flagNameError) { + prompts.error(`--instance-name: ${flagNameError}`) + return { kind: "exit" } + } + if (flags.port !== undefined && !isValidPort(flags.port)) { + prompts.error("--port must be a whole number between 1 and 65535.") + return { kind: "exit" } + } + const flagOverrides = { + ...(flags.instanceName ? { INSTANCE_NAME: flags.instanceName } : {}), + ...(flags.port ? { PORT: flags.port } : {}), + } + + const existingEnvPath = join(targetDir, ".env") + + // An existing .env makes this folder an install already, and its own + // container and volumes would always count as in use, so a re-run skips the name check. + if (existsSync(existingEnvPath)) { + const carried = readCarriedIdentity(existingEnvPath, mode) + const carriedName = carried.INSTANCE_NAME || DEFAULT_INSTANCE_NAME + const carriedNameError = describeInvalidInstanceName(carriedName) + + // An invalid kept name gets the error every other command gives it before + // init asks anything more, since the re-run would carry it into the new .env. + if (carriedNameError) { + prompts.error(`INSTANCE_NAME ${carriedNameError} Fix it in ${targetDir}/.env.`) + return { kind: "exit" } + } + // The same holds for a kept port, unless --port replaces it. + const carriedPortError = + carried.PORT && !isValidPort(carried.PORT) ? describeInvalidEnvPort(carried.PORT) : undefined + + if (carriedPortError && !flags.port) { + prompts.error(`${carriedPortError} Fix it in ${targetDir}/.env.`) + return { kind: "exit" } + } + // Renaming here would leave the old container running and could point + // the folder at another install's volumes, so the name stays. + if (flags.instanceName && flags.instanceName !== carriedName) { + prompts.error(renameOnRerunMessage({ carriedName, targetDir })) + return { kind: "exit" } + } + const daemonRunning = docker.daemonStatus() === "running" + const portsInUse = portsOfOtherContainers( + daemonRunning ? docker.publishedHostPorts() : [], + carriedName, + ) + // A kept port is the install's own; only a new --port can collide. + const portSetting: PortSettingResult = flags.port + ? await checkRequestedPort({ flags, portsInUse }, prompts) + : { kind: "ok", settings: {} } + + if (portSetting.kind === "exit") return { kind: "exit" } + const chosen = { ...flagOverrides, ...portSetting.settings } + return { kind: "ok", identity: { carried, chosen }, portsInUse } + } + + const requestedName = flags.instanceName ?? DEFAULT_INSTANCE_NAME + const daemonRunning = docker.daemonStatus() === "running" + + // Without Docker the check can't see another install's container or + // volumes, and a shared name would only surface when start replaces them. + if (!daemonRunning) prompts.warn(nameCheckSkippedMessage(requestedName)) + const requestedNameImage = daemonRunning ? docker.containerImage(requestedName) : undefined + const requestedNameInUse = + daemonRunning && + (Boolean(requestedNameImage) || + hasInstanceVolumes({ mode, instanceName: requestedName }, docker)) + const publishedPorts = daemonRunning ? docker.publishedHostPorts() : [] + const portsInUse = buildPortsInUse(publishedPorts) + // A new folder's .env starts from the template's device name, so a name + // given by --instance-name sets it too, whether the name is new or reused. + const flagSettings = { + ...flagOverrides, + ...(flags.instanceName ? buildDeviceNameSetting(mode, flags.instanceName) : {}), + } + + // A name not in use, or one Docker could not check, starts a new install. + if (!requestedNameInUse) { + const portSetting = await checkRequestedPort({ flags, portsInUse }, prompts) + + if (portSetting.kind === "exit") return { kind: "exit" } + const chosen = { ...flagSettings, ...portSetting.settings } + return { kind: "ok", identity: { carried: {}, chosen }, portsInUse } + } + const portsOutsideReusedInstall = portsOfOtherContainers(publishedPorts, requestedName) + // Another app's container holds no install's data, so its name can't be + // reused: --yes and --instance-name stop, and the prompt asks for a new name. + const otherAppImage = + requestedNameImage && !isVaultCortexImage(requestedNameImage) ? requestedNameImage : undefined + + if (otherAppImage) { + const nameTakenMessage = `A container named "${requestedName}" runs ${otherAppImage}, not Vault Cortex, so this install needs another name.` + + if (flags.instanceName || flags.yes) { + prompts.error( + `${nameTakenMessage} Re-run with --instance-name and a name no other container uses.`, + ) + return { kind: "exit" } + } + prompts.warn(nameTakenMessage) + } + if (flags.instanceName) { + prompts.log(`Reusing the data of the existing "${requestedName}" install.`) + const reusedPort = await askReusedPort( + { flags, portsInUse: portsOutsideReusedInstall }, + prompts, + ) + + if (reusedPort.kind === "exit") return { kind: "exit" } + const chosen = { ...flagSettings, ...reusedPort.settings } + return { kind: "ok", identity: { carried: {}, chosen }, portsInUse: portsOutsideReusedInstall } + } + if (flags.yes) { + prompts.error(nameInUseWithYesFlagMessage(requestedName)) + return { kind: "exit" } + } + + const choice = otherAppImage + ? "own-name" + : await prompts.select( + `An install named "${requestedName}" already exists on this machine (its container or data volumes are here). What should this install do?`, + [ + { + value: "own-name", + label: "Give this install its own name", + hint: "a second vault, with its own container, data and port", + }, + { + value: "reuse", + label: "Reuse the existing data — this is the same vault set up again", + hint: `replaces the "${requestedName}" container`, + }, + ], + "own-name", + ) + + if (choice === "reuse") { + const reusedPort = await askReusedPort( + { flags, portsInUse: portsOutsideReusedInstall }, + prompts, + ) + + if (reusedPort.kind === "exit") return { kind: "exit" } + const chosen = { ...flagOverrides, ...reusedPort.settings } + return { kind: "ok", identity: { carried: {}, chosen }, portsInUse: portsOutsideReusedInstall } + } + const suggestedName = suggestInstanceName({ mode, targetDir }, docker) + const instanceName = await askFreeInstanceName({ mode, suggestedName }, deps) + const suggestedPort = flags.port ?? suggestFreePort(portsInUse) + const port = await askFreePort({ suggestedPort, portsInUse }, prompts) + const chosen = { + INSTANCE_NAME: instanceName, + PORT: port, + ...buildDeviceNameSetting(mode, instanceName), + } + return { kind: "ok", identity: { carried: {}, chosen }, portsInUse } +} + +/** + * Writes the identity into freshly generated .env content, before the + * optional-settings chooser reads it, so the chooser shows the identity's port + * as current. A `http://localhost:` PUBLIC_URL follows a carried or a + * chosen port; a carried PUBLIC_URL is the user's own and lands verbatim. + */ +export const applyInstanceIdentity = (envContent: string, identity: InstanceIdentity): string => { + const carriedSettings = identity.carried.PUBLIC_URL + ? identity.carried + : withPublicUrlOverride({ overrides: identity.carried, envContent }) + const withCarried = applyOptionalSettings(envContent, carriedSettings) + const chosenSettings = withPublicUrlOverride({ + overrides: identity.chosen, + envContent: withCarried, + }) + return applyOptionalSettings(withCarried, chosenSettings) +} + +/** + * Warns when --port changes a re-run's port but its carried PUBLIC_URL stays: + * applyInstanceIdentity moves only a `http://localhost:` URL, never a + * custom one. Returns undefined when there is nothing to warn about. + */ +export const describeStrandedCarriedPublicUrl = ( + envContent: string, + identity: InstanceIdentity, +): string | undefined => { + if (!identity.carried.PUBLIC_URL) return undefined + const withCarried = applyOptionalSettings(envContent, identity.carried) + + return describeStrandedPublicUrl({ + envContent: withCarried, + pickedOverrides: identity.chosen, + appliedOverrides: withPublicUrlOverride({ + overrides: identity.chosen, + envContent: withCarried, + }), + }) +} diff --git a/cli/src/lifecycle.ts b/cli/src/lifecycle.ts index 203794441..917962a1e 100644 --- a/cli/src/lifecycle.ts +++ b/cli/src/lifecycle.ts @@ -1,9 +1,9 @@ import { join, resolve } from "node:path" import { - CONTAINER_NAME, healthPollTimeoutMs, healthTimeoutMessage, + isVaultCortexImage, pollHealth, probeHealth, type DockerRunner, @@ -11,7 +11,7 @@ import { import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage } from "./messages.js" import { detectMode, - hasEnvPublicUrl, + readEnvInstanceName, readEnvPort, readEnvPublicUrl, readEnvVaultPath, @@ -56,26 +56,28 @@ export type RestartDeps = { /** An init'd deployment on disk, reconstructed from its .env file. */ export type Deployment = { mode: Mode + /** The .env's INSTANCE_NAME: the container this deployment owns and its volume prefix. */ + instanceName: string targetDir: string envFilePath: string port: number /** Present only in local mode. */ vaultPath?: string - /** The .env's PUBLIC_URL, when set. */ - publicUrl?: string + publicUrl: string } const DEFAULT_TARGET_DIR = "./vault-cortex" /** - * Light gate for commands that operate on an existing deployment without - * starting a container: resolves --dir and confirms an init'd .env exists. + * Light gate for commands that use an existing deployment without starting a container: + * - resolves --dir and confirms an init'd .env exists; + * - reads INSTANCE_NAME, so an invalid name stops every command before it touches Docker. * Full start-readiness validation lives in resolveDeployment. */ export const requireInitializedDir = ( dirFlag: string | undefined, prompts: Prompts, -): { targetDir: string; envFilePath: string; mode: Mode } | undefined => { +): { targetDir: string; envFilePath: string; mode: Mode; instanceName: string } | undefined => { const targetDir = resolve(expandTilde(dirFlag ?? DEFAULT_TARGET_DIR)) const envFilePath = join(targetDir, ".env") @@ -85,14 +87,26 @@ export const requireInitializedDir = ( prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex@latest init\` first.`) return undefined } - return { targetDir, envFilePath, mode } + const instanceNameRead = readEnvInstanceName(envFilePath) + + if (instanceNameRead.kind === "invalid") { + prompts.error(`${instanceNameRead.message} Fix it in ${targetDir}/.env.`) + return undefined + } + return { targetDir, envFilePath, mode, instanceName: instanceNameRead.instanceName } +} + +/** A local PUBLIC_URL is this machine's own address; a remote one is the user's domain. */ +const missingPublicUrlFix = (mode: Mode, port: number): string => { + if (mode === "local") return `Set this line in your .env:\n PUBLIC_URL=http://localhost:${port}` + return "Set it to the address your MCP clients reach this server at, for example:\n PUBLIC_URL=https://vault.example.com" } /** * Resolves --dir to the on-disk deployment and validates its .env for a - * container start: mode detection, VAULT_PATH (local), and PUBLIC_URL - * (local). Reports each failure via prompts.error and returns undefined. - * Shared by upgrade and restart so their precondition checks can't drift. + * container start: mode detection, PORT, VAULT_PATH (local), and PUBLIC_URL. + * Reports each failure via prompts.error and returns undefined. + * Shared by init, start, restart, upgrade and configure so their precondition checks can't drift. */ export const resolveDeployment = ( dirFlag: string | undefined, @@ -101,9 +115,15 @@ export const resolveDeployment = ( const initialized = requireInitializedDir(dirFlag, prompts) if (!initialized) return undefined - const { targetDir, envFilePath, mode } = initialized + const { targetDir, envFilePath, mode, instanceName } = initialized + + const portRead = readEnvPort(envFilePath) - const port = readEnvPort(envFilePath) + if (portRead.kind === "invalid") { + prompts.error(`${portRead.message} Fix it in ${targetDir}/.env.`) + return undefined + } + const { port } = portRead const vaultPath = mode === "local" ? readEnvVaultPath(envFilePath) : undefined if (mode === "local" && !vaultPath) { @@ -112,16 +132,16 @@ export const resolveDeployment = ( ) return undefined } - if (mode === "local" && !hasEnvPublicUrl(envFilePath)) { + const publicUrl = readEnvPublicUrl(envFilePath) + + if (!publicUrl) { prompts.error( - `PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` + - `Add this line to your .env:\n PUBLIC_URL=http://localhost:${port}`, + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + missingPublicUrlFix(mode, port), ) return undefined } - - const publicUrl = readEnvPublicUrl(envFilePath) - return { mode, targetDir, envFilePath, port, vaultPath, publicUrl } + return { mode, instanceName, targetDir, envFilePath, port, vaultPath, publicUrl } } /** @@ -140,18 +160,6 @@ export const ensureDaemonRunning = (docker: DockerRunner, prompts: Prompts): boo return false } -/** - * Whether the post-start public-URL probe should run — and, when it should, - * proof that publicUrl is set. Local's PUBLIC_URL is the derived localhost - * URL, so probing it would only duplicate the health check the start cycle - * just ran. - */ -const shouldRunPostStartProbe = ( - deployment: Deployment, -): deployment is Deployment & { publicUrl: string } => { - return deployment.mode === "remote" && deployment.publicUrl !== undefined -} - /** * One-shot informational probe of the public /healthz after a confirmed * container start. Never a gate: a failure warns and the command still @@ -186,6 +194,28 @@ export const reportPublicUrlProbe = async ( ) } +/** + * Looks up the container that holds the install's name. One running another + * image is someone else's, so no command may stop, remove or stream it: this + * reports the refusal, and callers stop on "foreign". + */ +export const checkInstanceContainer = ( + params: { instanceName: string; envFilePath: string }, + deps: { prompts: Prompts; docker: DockerRunner }, +): "none" | "own" | "foreign" => { + const { instanceName, envFilePath } = params + const image = deps.docker.containerImage(instanceName) + + if (!image) return "none" + if (isVaultCortexImage(image)) return "own" + deps.prompts.error( + `A container named "${instanceName}" runs ${image}, not Vault Cortex, so this command leaves it alone.\n` + + `To keep this install's data, rename that container: docker rename ${instanceName} \n` + + `Or change INSTANCE_NAME in ${envFilePath}: the install then starts on new, empty volumes.`, + ) + return "foreign" +} + /** * Stop-and-remove → docker run → health poll, with the shared messaging. * Returns a process exit code. Callers own the daemon check and any @@ -197,14 +227,20 @@ export const recreateContainer = async ( ): Promise => { const { deployment, healthTimeoutMs } = params const { prompts, docker, fetchFn } = deps + const { instanceName } = deployment + const instanceContainer = checkInstanceContainer( + { instanceName, envFilePath: deployment.envFilePath }, + { prompts, docker }, + ) + if (instanceContainer === "foreign") return 1 // `docker rm -f` on a missing container exits non-zero on engines < 23, // so the bare return value can't distinguish "nothing to remove" from a // real failure — only treat removal as failed when a container exists. // Proceeding after a genuine failure would surface as a confusing // "container name already in use" from docker run. - if (docker.containerExists() && !docker.stopAndRemoveContainer()) { - prompts.error(`Could not remove the existing container — check: docker rm -f ${CONTAINER_NAME}`) + if (instanceContainer === "own" && !docker.stopAndRemoveContainer(instanceName)) { + prompts.error(`Could not remove the existing container — check: docker rm -f ${instanceName}`) return 1 } @@ -212,6 +248,7 @@ export const recreateContainer = async ( prompts.log("Starting container...") const containerStarted = docker.dockerRun({ mode: deployment.mode, + instanceName, envFilePath: deployment.envFilePath, port: deployment.port, vaultPath: deployment.vaultPath, @@ -234,14 +271,15 @@ export const recreateContainer = async ( ) if (!healthy) { - spinner.stop(healthTimeoutMessage(deployment.mode, timeoutMs)) + spinner.stop(healthTimeoutMessage({ mode: deployment.mode, timeoutMs, instanceName })) return 1 } spinner.stop("Server is up — health check passed.") // Informational only — the container is confirmed healthy above, so the - // public-URL result never changes the exit code. - if (shouldRunPostStartProbe(deployment)) { + // public-URL result never changes the exit code. A local PUBLIC_URL is the + // localhost address the health check just reached, so only remote probes it. + if (deployment.mode === "remote") { await reportPublicUrlProbe(deployment.publicUrl, { prompts, fetchFn }) } return 0 @@ -258,32 +296,38 @@ export const runDown = async (flags: DownFlags, deps: DownDeps): Promise prompts.intro("vault-cortex down") // Teardown only needs to confirm this is an init'd directory — the full - // .env validation (VAULT_PATH, PUBLIC_URL) guards container starts. + // .env validation (PORT, VAULT_PATH, PUBLIC_URL) guards container starts. const initialized = requireInitializedDir(flags.dir, prompts) if (!initialized) return 1 if (!ensureDaemonRunning(docker, prompts)) return 1 + const { instanceName, envFilePath, targetDir } = initialized + const instanceContainer = checkInstanceContainer( + { instanceName, envFilePath }, + { prompts, docker }, + ) - if (!docker.containerExists()) { - prompts.log("No vault-cortex container found — nothing to stop.") + if (instanceContainer === "foreign") return 1 + if (instanceContainer === "none") { + prompts.log(`No ${instanceName} container found — nothing to stop.`) prompts.outro("Done.") return 0 } - if (!docker.stopAndRemoveContainer()) { - prompts.error(`Could not remove the container — check: docker rm -f ${CONTAINER_NAME}`) + if (!docker.stopAndRemoveContainer(instanceName)) { + prompts.error(`Could not remove the container — check: docker rm -f ${instanceName}`) return 1 } prompts.log( "Container stopped and removed. Your vault data, search index, and settings are untouched.", ) - prompts.outro(`Start again with: npx vault-cortex@latest start --dir "${initialized.targetDir}"`) + prompts.outro(`Start again with: npx vault-cortex@latest start --dir "${targetDir}"`) return 0 } /** - * Streams `docker logs` for the vault-cortex container. The stream's exit + * Streams `docker logs` for the deployment's container. The stream's exit * code passes through as the command's exit code; no outro follows the raw * docker output. */ @@ -296,15 +340,22 @@ export const runLogs = async (flags: LogsFlags, deps: LogsDeps): Promise if (!initialized) return 1 if (!ensureDaemonRunning(docker, prompts)) return 1 + const { instanceName, envFilePath, targetDir } = initialized + const instanceContainer = checkInstanceContainer( + { instanceName, envFilePath }, + { prompts, docker }, + ) - if (!docker.containerExists()) { + if (instanceContainer === "foreign") return 1 + if (instanceContainer === "none") { prompts.error( - `No vault-cortex container — start it with: npx vault-cortex@latest start --dir "${initialized.targetDir}"`, + `No ${instanceName} container — start it with: npx vault-cortex@latest start --dir "${targetDir}"`, ) return 1 } return await docker.streamLogs({ + instanceName, follow: Boolean(flags.follow), since: flags.since, }) diff --git a/cli/src/messages.ts b/cli/src/messages.ts index a66eadd7b..72d648c04 100644 --- a/cli/src/messages.ts +++ b/cli/src/messages.ts @@ -1,7 +1,5 @@ import { styleText } from "node:util" -import { CONTAINER_NAME } from "./docker.js" - export type StartStatus = "running" | "starting" | "not-started" // Connect instructions are printed as plain text (not a clack note box) so the @@ -21,13 +19,19 @@ const paint = (style: TextStyle, text: string): string => const RULE_WIDTH = 56 -const topRule = (label: string): string => - paint("dim", `╭── ${label} ${"─".repeat(Math.max(0, RULE_WIDTH - label.length - 6))}╮`) +const topRule = (label: string): string => { + const labelPart = `╭── ${label} ` + const fillWidth = Math.max(0, RULE_WIDTH - labelPart.length - "╮".length) + return paint("dim", `${labelPart}${"─".repeat(fillWidth)}╮`) +} -const bottomRule = (): string => paint("dim", `╰${"─".repeat(RULE_WIDTH - 2)}╯`) +const bottomRule = (): string => paint("dim", `╰${"─".repeat(RULE_WIDTH - "╰╯".length)}╯`) -const sectionRule = (label: string): string => - paint("dim", `── ${label} ${"─".repeat(Math.max(0, RULE_WIDTH - label.length - 4))}`) +const sectionRule = (label: string): string => { + const labelPart = `── ${label} ` + const fillWidth = Math.max(0, RULE_WIDTH - labelPart.length) + return paint("dim", `${labelPart}${"─".repeat(fillWidth)}`) +} /** * Daemon-stopped guidance shared by every command that needs the container @@ -84,23 +88,41 @@ export const startCommand = (targetDir: string): string => const startServerLine = (targetDir: string): string => `Start the server:\n ${startCommand(targetDir)}` -const startingInBackgroundLine = (): string => - `The server is starting in the background — check progress:\n docker logs ${CONTAINER_NAME}` +const startingInBackgroundLine = (instanceName: string): string => + `The server is starting in the background — check progress:\n docker logs ${instanceName}` + +const isHttpsUrl = (url: string): boolean => url.toLowerCase().startsWith("https://") -/** Remote start line: running, starting, blocked on the missing sync token, or ready to start. */ +/** + * Without a Sync token in .env, a container that has never signed in serves + * its sign-in page at PUBLIC_URL; one that signed in there before keeps that + * login. The page asks for the MCP auth token and the Obsidian password. + */ +const syncSignInLines = (publicUrl: string): string => { + const signInLines = + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + `syncing. Open the page:\n ${publicUrl}\n` + + "It asks for the auth token below and your Obsidian password." + + if (isHttpsUrl(publicUrl)) return signInLines + return `${signInLines}\nSet up HTTPS first: the page sends your Obsidian password to the server.` +} + +/** Remote start line: running, starting, or ready to start, plus sign-in steps when .env has no Sync token. */ const remoteStartLine = (params: { targetDir: string + instanceName: string + publicUrl: string startStatus: StartStatus obsidianTokenMissing: boolean }): string => { - const { targetDir, startStatus, obsidianTokenMissing } = params + const { targetDir, instanceName, publicUrl, startStatus, obsidianTokenMissing } = params + const notRunningLine = + startStatus === "starting" ? startingInBackgroundLine(instanceName) : startServerLine(targetDir) + const statusLine = startStatus === "running" ? "The server is running." : notRunningLine - if (startStatus === "running") return "The server is running." - if (startStatus === "starting") return startingInBackgroundLine() - if (obsidianTokenMissing) { - return `Fill in OBSIDIAN_AUTH_TOKEN in ${targetDir}/.env, then start the server:\n ${startCommand(targetDir)}` - } - return startServerLine(targetDir) + if (!obsidianTokenMissing) return statusLine + return `${statusLine}\n${syncSignInLines(publicUrl)}` } /** @@ -138,11 +160,12 @@ const connectUrlBlock = (mcpUrl: string, tokenLine: string): string => // a CLI (`claude mcp add`, `opencode mcp add`), a connector dialog, or a // config file — then approve the consent page. Claude Code is the worked // example. The per-mode caveats (which clients need https, the localhost -// bridge) are appended by the builders. -const connectGuidance = (mcpUrl: string): string => +// bridge) are appended by the builders. The connector is named after the +// instance, so a second instance gets its own entry instead of replacing the first. +const connectGuidance = (mcpUrl: string, instanceName: string): string => `Add the URL above as a remote MCP server (leave Client ID/Secret empty), then approve the consent page with the token. For example, Claude Code: - 1. claude mcp add --scope user --transport http vault-cortex ${mcpUrl} + 1. claude mcp add --scope user --transport http ${instanceName} ${mcpUrl} 2. approve the browser consent page with the token above 3. done — the client holds auto-refreshing access tokens; the token never sits in client config` @@ -181,17 +204,18 @@ const updateGuidance = (targetDir: string): string => */ export const buildLocalConnectMessage = (params: { targetDir: string + instanceName: string token: string startStatus: StartStatus port: number tokenWritten: boolean }): string => { - const { targetDir, token, startStatus, port, tokenWritten } = params + const { targetDir, instanceName, token, startStatus, port, tokenWritten } = params const baseUrl = `http://localhost:${port}` const nonRunningLine = - startStatus === "starting" ? startingInBackgroundLine() : startServerLine(targetDir) + startStatus === "starting" ? startingInBackgroundLine(instanceName) : startServerLine(targetDir) const startLine = startStatus === "running" ? "The server is running." : nonRunningLine const tokenLine = tokenBlock({ targetDir, token, tokenWritten }) @@ -220,13 +244,13 @@ ${sectionRule("MCP client")} ${connectUrlBlock(`${baseUrl}/mcp`, tokenLine)} -${connectGuidance(`${baseUrl}/mcp`)} +${connectGuidance(`${baseUrl}/mcp`, instanceName)} Other clients (opencode, Cursor, …) take the URL above too. claude.ai (web) can't reach localhost — connect from a client on this machine. Claude Desktop only accepts https URLs in its connector dialog, so bridge it with mcp-remote: - "vault-cortex": { + "${instanceName}": { "command": "npx", "args": ["-y", "mcp-remote", "${baseUrl}/mcp", "--header", "Authorization: Bearer "] @@ -262,16 +286,27 @@ ${bottomRule()}` */ export const buildRemoteConnectMessage = (params: { targetDir: string + instanceName: string token: string publicUrl: string startStatus: StartStatus obsidianTokenMissing: boolean tokenWritten: boolean }): string => { - const { targetDir, token, publicUrl, startStatus, obsidianTokenMissing, tokenWritten } = params + const { + targetDir, + instanceName, + token, + publicUrl, + startStatus, + obsidianTokenMissing, + tokenWritten, + } = params const startLine = remoteStartLine({ targetDir, + instanceName, + publicUrl, startStatus, obsidianTokenMissing, }) @@ -284,13 +319,12 @@ export const buildRemoteConnectMessage = (params: { // know which case it is, so the http branch states it rather than asking. // Case-insensitive: askPublicUrl stores the scheme as typed, so an HTTPS:// // input is valid and must still route to the https branch. - const isHttps = publicUrl.toLowerCase().startsWith("https://") - const clientGuidance = isHttps - ? `${connectGuidance(`${publicUrl}/mcp`)} + const clientGuidance = isHttpsUrl(publicUrl) + ? `${connectGuidance(`${publicUrl}/mcp`, instanceName)} Reachable over https from any MCP client — Claude Desktop, claude.ai (web and mobile), opencode, Cursor — from any device.` - : `${connectGuidance(`${publicUrl}/mcp`)} + : `${connectGuidance(`${publicUrl}/mcp`, instanceName)} Other clients (opencode, Cursor, …) work over http too. claude.ai and Claude Desktop only accept https URLs — set up HTTPS for those clients diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 308d77832..94ac488d8 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -1,4 +1,5 @@ -import { DEFAULT_PORT, type Mode } from "./scaffold.js" +import { DEFAULT_PORT, isValidPort, stripSurroundingQuotes, type Mode } from "./scaffold.js" +import type { PublishedPort } from "./docker.js" import type { Prompts, SelectOption } from "./prompts.js" type OptionalSettingBase = { @@ -172,13 +173,16 @@ const commentedLinePattern = (name: string): RegExp => new RegExp(`^# ${name}=.* * Reads a var's current value from .env content. A commented-out or missing * line returns undefined — both mean "the server uses its built-in default", * which is exactly what the prompts need to distinguish. Duplicate lines - * report the LAST value, matching docker --env-file precedence — hints, - * prompt seeding, and the PUBLIC_URL derivation must reason from the value - * that actually takes effect. + * report the LAST value, matching docker --env-file precedence. The value + * loses one pair of surrounding quotes, as stripEnvQuotedValues removes them + * before docker run. Hints, prompt seeding, and the PUBLIC_URL derivation + * must reason from the value that actually takes effect. */ export const readOptionalValue = (envContent: string, name: string): string | undefined => { const matches = [...envContent.matchAll(new RegExp(`^${name}=(.*)$`, "gm"))] - return matches.at(-1)?.[1].trim() + const lastMatch = matches.at(-1) + + return lastMatch && stripSurroundingQuotes(lastMatch[1].trim()) } /** @@ -216,20 +220,54 @@ export const applyOptionalSettings = ( * point at a port nothing listens on. A custom PUBLIC_URL (reverse proxy, * remote domain) is the user's own and is never touched. */ -export const derivePublicUrlOverride = ( - envContent: string, - overrides: Record, -): Record => { +export const withPublicUrlOverride = (params: { + overrides: Record + envContent: string +}): Record => { + const { overrides, envContent } = params const newPort = overrides.PORT if (!newPort) return overrides - const currentPort = readOptionalValue(envContent, "PORT") ?? String(DEFAULT_PORT) + // An empty PORT= line leaves the default port in effect, as readEnvPort reads it. + const currentPort = readOptionalValue(envContent, "PORT") || String(DEFAULT_PORT) const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL") + // A generated .env starts with PUBLIC_URL on the default port, so beside a + // PORT edited into a value that isn't a port, that URL follows too. + const followedPorts = isValidPort(currentPort) + ? [currentPort] + : [currentPort, String(DEFAULT_PORT)] + const publicUrlFollowsPort = followedPorts.some( + (followedPort) => currentPublicUrl === `http://localhost:${followedPort}`, + ) - if (currentPublicUrl !== `http://localhost:${currentPort}`) return overrides + if (!publicUrlFollowsPort) return overrides return { ...overrides, PUBLIC_URL: `http://localhost:${newPort}` } } +/** + * Warns about a picked PORT change that left PUBLIC_URL as it was: + * withPublicUrlOverride never rewrites a custom URL, so it may still name + * the old port. Returns undefined when there is nothing to warn about. + */ +export const describeStrandedPublicUrl = (params: { + envContent: string + pickedOverrides: Record + appliedOverrides: Record +}): string | undefined => { + const { envContent, pickedOverrides, appliedOverrides } = params + const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL") + + // Picking PORT and accepting its pre-filled current value changes nothing. + const currentPort = readOptionalValue(envContent, "PORT") || String(DEFAULT_PORT) + const portChanged = Boolean(pickedOverrides.PORT) && pickedOverrides.PORT !== currentPort + // A localhost URL that already names the new port still reaches the server. + const publicUrlNamesNewPort = currentPublicUrl === `http://localhost:${pickedOverrides.PORT}` + + if (!portChanged || appliedOverrides.PUBLIC_URL || !currentPublicUrl) return undefined + if (publicUrlNamesNewPort) return undefined + return `PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.` +} + /** * True unless the .env value is an explicit "off" spelling — env-var's asBool * accepts 0/1 alongside true/false. An absent or unrecognized value falls to @@ -239,17 +277,6 @@ export const derivePublicUrlOverride = ( const isEnabledToggleValue = (value: string | undefined): boolean => !["false", "0"].includes((value ?? "").toLowerCase()) -/** - * Plain digits in the TCP port range. Number() coercion is not enough: - * it accepts "1e4"/"0x1F40"/"+9000", which readEnvPort's /^PORT=(\d+)/ - * would later fail to read back — silently falling to the default port. - */ -const isValidPort = (value: string): boolean => { - if (!/^\d+$/.test(value)) return false - const port = Number(value) - return port >= 1 && port <= 65535 -} - /** * The engine's own IANA zone validation: Intl.DateTimeFormat throws a * RangeError for an unknown timeZone, so no hand-rolled zone list to drift. @@ -263,18 +290,66 @@ const isValidTimezone = (value: string): boolean => { } } -/** Re-prompts until the answer is a valid port number. */ -const askPort = async (currentValue: string | undefined, prompts: Prompts): Promise => { +/** Host ports that containers on this machine publish, mapped to each container's name. */ +export type PortsInUse = ReadonlyMap + +export const portInUseMessage = (params: { port: string; containerName: string }): string => { + return `Port ${params.port} is already used by the "${params.containerName}" container on this machine.` +} + +/** The first port, counting up from the default, that no container publishes. */ +export const suggestFreePort = (portsInUse: PortsInUse): string => { + const findFreePort = (candidatePort: number): number => { + return portsInUse.has(candidatePort) ? findFreePort(candidatePort + 1) : candidatePort + } + return String(findFreePort(DEFAULT_PORT)) +} + +/** Re-prompts until the answer is a valid port no other container on the machine publishes. */ +export const askFreePort = async ( + params: { suggestedPort: string | undefined; portsInUse: PortsInUse }, + prompts: Prompts, +): Promise => { + const { suggestedPort, portsInUse } = params + // An empty PORT= line leaves the default port in effect, as readEnvPort reads + // it; a value that isn't a port is never offered, since it would be rejected. + const offeredPort = + suggestedPort && isValidPort(suggestedPort) ? suggestedPort : String(DEFAULT_PORT) const answer = ( await prompts.text("Host port for the server:", { - defaultValue: currentValue ?? String(DEFAULT_PORT), - placeholder: String(DEFAULT_PORT), + defaultValue: offeredPort, + placeholder: offeredPort, }) ).trim() - if (isValidPort(answer)) return answer - prompts.error("PORT must be a whole number between 1 and 65535.") - return askPort(currentValue, prompts) + if (!isValidPort(answer)) { + prompts.error("PORT must be a whole number between 1 and 65535.") + return askFreePort(params, prompts) + } + const portUser = portsInUse.get(Number(answer)) + + if (!portUser) return answer + prompts.error(`${portInUseMessage({ port: answer, containerName: portUser })} Pick another port.`) + // Suggest a free port, so pressing Enter can't pick the taken one again. + return askFreePort({ ...params, suggestedPort: suggestFreePort(portsInUse) }, prompts) +} + +export const buildPortsInUse = (publishedPorts: ReadonlyArray): PortsInUse => { + return new Map(publishedPorts.map(({ hostPort, containerName }) => [hostPort, containerName])) +} + +/** + * Start and restart replace the install's own container, so only other + * containers' ports are taken. A port another container also publishes stays taken. + */ +export const portsOfOtherContainers = ( + publishedPorts: ReadonlyArray, + containerName: string, +): PortsInUse => { + const otherContainersPorts = publishedPorts.filter((publishedPort) => { + return publishedPort.containerName !== containerName + }) + return buildPortsInUse(otherContainersPorts) } /** Re-prompts until the answer is a zone the runtime recognizes. */ @@ -305,7 +380,8 @@ const askFolder = async ( const { question, currentValue, defaultValue } = params const answer = ( await prompts.text(question, { - defaultValue: currentValue ?? defaultValue, + // An empty .env value leaves the server's default folder in effect. + defaultValue: currentValue || defaultValue, placeholder: defaultValue, }) ).trim() @@ -344,12 +420,13 @@ const askOptionalText = async ( const answer = ( await prompts.text(question, { defaultValue: currentValue, - placeholder: currentValue === undefined ? placeholder : "blank = keep the current value", + // The server reads an empty .env value as unset. + placeholder: currentValue ? "blank = keep the current value" : placeholder, }) ).trim() if (answer !== "" && answer !== currentValue) return answer - if (currentValue === undefined) { + if (!currentValue) { prompts.log("Left unset — the server reads this setting from your vault's own config.") return undefined } @@ -362,10 +439,10 @@ const askOptionalText = async ( * or undefined when an optionalText prompt was left blank (nothing to write). */ const askSettingValue = async ( - params: { setting: OptionalSetting; currentValue: string | undefined }, + params: { setting: OptionalSetting; currentValue: string | undefined; portsInUse: PortsInUse }, prompts: Prompts, ): Promise => { - const { setting, currentValue } = params + const { setting, currentValue, portsInUse } = params switch (setting.kind) { case "toggle": { // An unset or empty var means the server default applies — seed the @@ -380,7 +457,7 @@ const askSettingValue = async ( return String(enabled) } case "port": - return askPort(currentValue, prompts) + return askFreePort({ suggestedPort: currentValue, portsInUse }, prompts) case "timezone": return askTimezone(currentValue, prompts) case "folder": @@ -418,16 +495,22 @@ const askSettingValue = async ( } /** - * The guided optional-settings flow shared by init and configure: one - * multiselect chooser (enter with nothing picked = change nothing), then one - * prompt per picked setting. Returns the chosen values keyed by var name — - * the caller owns applying them via applyOptionalSettings. + * The guided optional-settings flow shared by init and configure: + * - one multiselect chooser (enter with nothing picked = change nothing), then + * one prompt per picked setting; + * - returns the chosen values keyed by var name, which the caller applies with + * applyOptionalSettings. */ export const askOptionalSettings = async ( - params: { mode: Mode; envContent: string }, + params: { + mode: Mode + envContent: string + /** Ports other containers publish, which the port prompt refuses; empty when Docker can't be asked. */ + portsInUse: PortsInUse + }, prompts: Prompts, ): Promise> => { - const { mode, envContent } = params + const { mode, envContent, portsInUse } = params const offeredSettings = OPTIONAL_SETTINGS.filter( (setting) => !setting.remoteOnly || mode === "remote", ) @@ -459,7 +542,7 @@ export const askOptionalSettings = async ( for (const setting of offeredSettings) { if (!pickedNames.includes(setting.name)) continue const value = await askSettingValue( - { setting, currentValue: readOptionalValue(envContent, setting.name) }, + { setting, currentValue: readOptionalValue(envContent, setting.name), portsInUse }, prompts, ) diff --git a/cli/src/program.ts b/cli/src/program.ts index 84eb34e98..a968a8a23 100644 --- a/cli/src/program.ts +++ b/cli/src/program.ts @@ -34,6 +34,11 @@ export const buildProgram = (options: ProgramOptions): Command => { .option("--mode ", 'deployment mode: "local" (default) or "remote"') .option("--vault-path ", "absolute path to your Obsidian vault (local mode)") .option("--dir ", "directory to write config files into (default: ./vault-cortex)") + .option( + "--instance-name ", + "name for this install's container and data (default: vault-cortex); give each vault on a machine its own", + ) + .option("--port ", "host port for this install (default: 8000)") .option("--yes", "non-interactive local setup with defaults; requires --vault-path") .action(async (flags: InitFlags) => { process.exitCode = await options.runInit(flags) diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index 231af59a1..f06fdf3d1 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -19,17 +19,35 @@ export type FileWriteResult = { /** Default host port — matches the container's internal port. */ export const DEFAULT_PORT = 8000 -/** Matches an active (uncommented) PORT line, with optional surrounding quotes. */ -const ENV_PORT_LINE = /^PORT=["']?(\d+)["']?\s*$/m +/** + * Used when .env sets no INSTANCE_NAME: the container name and volume prefix + * every install had before the setting existed, so those installs keep their data. + */ +export const DEFAULT_INSTANCE_NAME = "vault-cortex" -/** Matches an active (uncommented) VAULT_PATH line in a .env file. */ -const ENV_VAULT_PATH_LINE = /^VAULT_PATH=(.+)\s*$/m +/** + * Matches every active (uncommented) PORT line, capturing its raw value. + * Global, because `docker run --env-file` lets the last duplicate win. + */ +const ENV_PORT_LINES = /^PORT=(.*)$/gm -/** Matches an active (uncommented) PUBLIC_URL line. */ -const ENV_PUBLIC_URL_LINE = /^PUBLIC_URL=/m +/** Matches every active (uncommented) INSTANCE_NAME line, capturing its raw value. */ +const ENV_INSTANCE_NAME_LINES = /^INSTANCE_NAME=(.*)$/gm -/** Matches an active (uncommented) PUBLIC_URL line, capturing a non-empty value. */ -const ENV_PUBLIC_URL_VALUE_LINE = /^PUBLIC_URL=(.+)\s*$/m +/** + * Compose's project-name rule plus Docker's two-character minimum for container + * names, so the container, the Compose project and the volume prefix are one string. + */ +const INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]+$/ + +/** + * Match every active (uncommented) line of their variable, empty values + * included: `docker run --env-file` lets the last duplicate win, even an + * empty one, and the readers below treat an empty value as unset. + */ +const ENV_VAULT_PATH_LINES = /^VAULT_PATH=(.*)$/gm +const ENV_PUBLIC_URL_VALUE_LINES = /^PUBLIC_URL=(.*)$/gm +const ENV_OBSIDIAN_TOKEN_LINES = /^OBSIDIAN_AUTH_TOKEN=(.*)$/gm /** Matches an active (uncommented) OBSIDIAN_AUTH_TOKEN line. */ const OBSIDIAN_AUTH_TOKEN_LINE = /^OBSIDIAN_AUTH_TOKEN=/m @@ -42,7 +60,7 @@ const QUOTED_ENV_VALUE = /^([A-Za-z_][A-Za-z0-9_]*=)(["'])(.*)\2(\s*)$/gm * `'bar'` → `bar`, `unquoted` → `unquoted`. Only strips when the * opening and closing quote characters match. */ -const stripSurroundingQuotes = (value: string): string => { +export const stripSurroundingQuotes = (value: string): string => { const quoteMatch = /^(["'])(.*)\1$/.exec(value) return quoteMatch ? quoteMatch[2] : value } @@ -53,54 +71,97 @@ export const buildFilesToWrite = (envContent: string): FileToWrite[] => [ ] /** - * Reads the host port from the .env that is actually on disk — which may be - * a pre-existing file this run chose to keep, with a PORT override the - * generated default doesn't have. Falls back to DEFAULT_PORT when the file - * or an uncommented PORT line is absent. + * Plain digits in the TCP port range. Number() coercion is not enough: it + * accepts "1e4", "0x1F40" and "+9000". */ -export const readEnvPort = (envFilePath: string): number => { - if (!existsSync(envFilePath)) return DEFAULT_PORT - const match = ENV_PORT_LINE.exec(readFileSync(envFilePath, "utf8")) - return match ? Number(match[1]) : DEFAULT_PORT +export const isValidPort = (value: string): boolean => { + if (!/^\d+$/.test(value)) return false + const port = Number(value) + return port >= 1 && port <= 65535 +} + +/** The error for a .env PORT value that isn't a port; callers add where to fix it. */ +export const describeInvalidEnvPort = (portValue: string): string => { + return `PORT "${portValue}" is not a valid port — use a whole number between 1 and 65535.` +} + +export type PortRead = { kind: "ok"; port: number } | { kind: "invalid"; message: string } + +/** + * Reads the host port from a .env file: + * - the last active line wins, as it does for `docker run --env-file`; + * - a missing file, line or empty value means DEFAULT_PORT; + * - any other value that is not a port is reported, never corrected, because + * the CLI would map one port while PUBLIC_URL names another. + */ +export const readEnvPort = (envFilePath: string): PortRead => { + if (!existsSync(envFilePath)) return { kind: "ok", port: DEFAULT_PORT } + const portLines = [...readFileSync(envFilePath, "utf8").matchAll(ENV_PORT_LINES)] + const rawValue = portLines.at(-1)?.[1].trim() + const portValue = rawValue ? stripSurroundingQuotes(rawValue) : "" + + if (!portValue) return { kind: "ok", port: DEFAULT_PORT } + if (!isValidPort(portValue)) { + return { kind: "invalid", message: describeInvalidEnvPort(portValue) } + } + return { kind: "ok", port: Number(portValue) } +} + +/** Returns why a candidate instance name is invalid, or undefined when it is valid. */ +export const describeInvalidInstanceName = (candidateName: string): string | undefined => { + if (INSTANCE_NAME_PATTERN.test(candidateName)) return undefined + return `"${candidateName}" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.` +} + +export type InstanceNameRead = + { kind: "ok"; instanceName: string } | { kind: "invalid"; message: string } + +/** + * Reads INSTANCE_NAME from a .env file: + * - the last active line wins, as it does for `docker run --env-file`; + * - a missing file, line or empty value means DEFAULT_INSTANCE_NAME; + * - an invalid value is reported, never corrected, because the name decides + * which volumes hold the install's data. + */ +export const readEnvInstanceName = (envFilePath: string): InstanceNameRead => { + if (!existsSync(envFilePath)) return { kind: "ok", instanceName: DEFAULT_INSTANCE_NAME } + const instanceNameLines = [...readFileSync(envFilePath, "utf8").matchAll(ENV_INSTANCE_NAME_LINES)] + const rawValue = instanceNameLines.at(-1)?.[1].trim() + const instanceName = rawValue ? stripSurroundingQuotes(rawValue) : "" + + if (!instanceName) return { kind: "ok", instanceName: DEFAULT_INSTANCE_NAME } + const invalidReason = describeInvalidInstanceName(instanceName) + + if (invalidReason) return { kind: "invalid", message: `INSTANCE_NAME ${invalidReason}` } + return { kind: "ok", instanceName } } /** * Reads the host vault path from a .env file. Returns undefined when the - * file is missing or has no uncommented VAULT_PATH line. + * file is missing, has no uncommented VAULT_PATH line, or the last one is empty. */ export const readEnvVaultPath = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined - const match = ENV_VAULT_PATH_LINE.exec(readFileSync(envFilePath, "utf8")) + const match = [...readFileSync(envFilePath, "utf8").matchAll(ENV_VAULT_PATH_LINES)].at(-1) const rawValue = match?.[1].trim() - return rawValue ? stripSurroundingQuotes(rawValue) : undefined -} + const vaultPath = rawValue ? stripSurroundingQuotes(rawValue) : "" -/** - * Returns true when the .env file has an active (uncommented) PUBLIC_URL line. - * It's the FALSE result that carries the signal: the old compose-based CLI's - * generated .env never held a PUBLIC_URL line (the compose file's environment - * defaults supplied it), so a local .env without one predates the docker-run - * migration — callers use the negation to ask the user to add the line - * instead of starting a server missing a required variable. - */ -export const hasEnvPublicUrl = (envFilePath: string): boolean => { - if (!existsSync(envFilePath)) return false - return ENV_PUBLIC_URL_LINE.test(readFileSync(envFilePath, "utf8")) + return vaultPath || undefined } /** * Reads the public URL value from a .env file. Returns undefined when the - * file is missing or has no uncommented, non-empty PUBLIC_URL line — - * deliberately stricter than hasEnvPublicUrl, whose job is old-compose - * detection and so matches an empty `PUBLIC_URL=` line too. + * file is missing or its last active PUBLIC_URL line is empty or absent, + * which is how start spots a .env the server would refuse (including one + * from the old compose-based CLI, which never wrote the line). */ export const readEnvPublicUrl = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined - const match = ENV_PUBLIC_URL_VALUE_LINE.exec(readFileSync(envFilePath, "utf8")) - // A whitespace-only line matches the regex and trims to "" — normalize to - // undefined so the non-empty contract holds ("" is never a legitimate URL). + const match = [...readFileSync(envFilePath, "utf8").matchAll(ENV_PUBLIC_URL_VALUE_LINES)].at(-1) + // A whitespace-only value, quoted or not, counts as empty, as it does in + // the server's check of the required variable. const rawValue = match?.[1].trim() - const unquotedValue = rawValue ? stripSurroundingQuotes(rawValue) : undefined + const unquotedValue = rawValue ? stripSurroundingQuotes(rawValue).trim() : undefined // Strip trailing slashes (mirroring askPublicUrl's prompt-side // normalization): consumers append paths to this base, and a hand-edited // `https://host/` would otherwise print broken `//mcp` connect URLs. @@ -120,20 +181,20 @@ export const detectMode = (envFilePath: string): Mode | undefined => { } /** - * Patches the OBSIDIAN_AUTH_TOKEN value in an existing .env file. - * Returns true when the patch succeeded, false when the file is missing - * or has no active OBSIDIAN_AUTH_TOKEN line (e.g. a local-mode .env). + * Patches every active OBSIDIAN_AUTH_TOKEN line in an existing .env, since + * `docker run --env-file` lets the last duplicate win. Returns false when the + * file is missing or has no active line (e.g. a local-mode .env). */ export const patchEnvObsidianToken = (envFilePath: string, token: string): boolean => { if (!existsSync(envFilePath)) return false const content = readFileSync(envFilePath, "utf8") - /** Matches the full OBSIDIAN_AUTH_TOKEN line for replacement. */ - const fullTokenLine = /^OBSIDIAN_AUTH_TOKEN=.*$/m + /** Matches every full OBSIDIAN_AUTH_TOKEN line for replacement. */ + const everyTokenLine = /^OBSIDIAN_AUTH_TOKEN=.*$/gm - if (!fullTokenLine.test(content)) return false + if (!OBSIDIAN_AUTH_TOKEN_LINE.test(content)) return false // Function replacement avoids $ pattern interpretation ($&, $', etc.) // that String.prototype.replace applies to string replacements. - const patched = content.replace(fullTokenLine, () => `OBSIDIAN_AUTH_TOKEN=${token}`) + const patched = content.replace(everyTokenLine, () => `OBSIDIAN_AUTH_TOKEN=${token}`) writeFileSync(envFilePath, patched) return true } @@ -141,12 +202,17 @@ export const patchEnvObsidianToken = (envFilePath: string, token: string): boole /** * Reads the OBSIDIAN_AUTH_TOKEN value from an existing .env file. Returns * undefined when the file is missing, has no active line, or the value is - * empty — an empty `OBSIDIAN_AUTH_TOKEN=` line is not a valid token. + * empty — `OBSIDIAN_AUTH_TOKEN=""` included, since the CLI strips quotes + * before docker run. Quotes stay in a returned value, because init writes it + * back into the regenerated .env. */ export const readEnvObsidianToken = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined - const match = /^OBSIDIAN_AUTH_TOKEN=(.+)$/m.exec(readFileSync(envFilePath, "utf8")) - return match?.[1].trim() || undefined + const match = [...readFileSync(envFilePath, "utf8").matchAll(ENV_OBSIDIAN_TOKEN_LINES)].at(-1) + const rawValue = match?.[1].trim() + const unquotedValue = rawValue ? stripSurroundingQuotes(rawValue).trim() : "" + + return unquotedValue ? rawValue : undefined } /** diff --git a/cli/src/upgrade.ts b/cli/src/upgrade.ts index bafcf4677..c0fb9519f 100644 --- a/cli/src/upgrade.ts +++ b/cli/src/upgrade.ts @@ -1,5 +1,10 @@ import { LOCAL_IMAGE, REMOTE_IMAGE, type DockerRunner } from "./docker.js" -import { ensureDaemonRunning, recreateContainer, resolveDeployment } from "./lifecycle.js" +import { + checkInstanceContainer, + ensureDaemonRunning, + recreateContainer, + resolveDeployment, +} from "./lifecycle.js" import type { Prompts } from "./prompts.js" export type UpgradeFlags = { @@ -31,6 +36,13 @@ export const runUpgrade = async (flags: UpgradeFlags, deps: UpgradeDeps): Promis const image = deployment.mode === "local" ? LOCAL_IMAGE : REMOTE_IMAGE if (!ensureDaemonRunning(docker, prompts)) return 1 + // Checked before the download, which recreateContainer's own check follows. + const instanceContainer = checkInstanceContainer( + { instanceName: deployment.instanceName, envFilePath: deployment.envFilePath }, + { prompts, docker }, + ) + + if (instanceContainer === "foreign") return 1 const spinner = prompts.spinner() spinner.start(`Pulling ${image}`) diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 14abdb262..77ed95ac5 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -20,6 +20,7 @@ VAULT_PATH= # Public URL for OAuth issuer and access-token binding (default: http://localhost:8000). # Override if you expose the server on a different URL (e.g. via a reverse proxy). +# When you change PORT, change the port here too, or MCP clients can't sign in. PUBLIC_URL=http://localhost:8000 # Largest file vault_read_file will read, in bytes (default: 52428800 = 50 MiB). @@ -107,8 +108,7 @@ MEMORY_DIR=About Me # setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. -# To apply environment changes, run the CLI's restart command -# or recreate the Compose container. +# Environment changes apply when the container is re-created. # Obsidian settings apply on the next query; daily-notes.json is read # directly from your bind-mounted vault. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me @@ -117,6 +117,18 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork +# Name of this install's container and search-index volume +# (_mcp_data). To run more than one vault on this machine, give each +# install its own INSTANCE_NAME and PORT, with PUBLIC_URL on that port. +# Valid values: two or more lowercase letters, digits, hyphens or underscores, +# starting with a letter or digit. +# Default: vault-cortex +# Set it before the first start. To change it later, stop the container first, +# then change it to a name no other install uses: the install starts on a new, +# empty index volume, and the old one stays until you remove it +# (docker volume rm _mcp_data). +INSTANCE_NAME=vault-cortex + # Host port to expose (default: 8000). PORT=8000 @@ -148,8 +160,8 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: none). # The container's own log (what `docker logs` shows) is always written, but -# Docker discards it whenever the container is recreated — on image updates -# or compose changes. Set a path (e.g. /data/logs) to also write date-stamped +# Docker discards it whenever the container is re-created — on image updates +# or settings changes. Set a path (e.g. /data/logs) to also write date-stamped # log files there; the /data volume keeps them. LOG_DIR=none diff --git a/deploy/local/README.md b/deploy/local/README.md index b4fbe69af..68b86e389 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -3,7 +3,7 @@ Run Vault Cortex on your machine against a local Obsidian vault. No cloud, no Obsidian Sync — just Docker and a folder of `.md` files. -**Contents** — [Prerequisites](#prerequisites) · [Setup](#setup) · [Connect](#connect-your-mcp-client) · [Verify](#verify) · [Monitoring](#monitoring) · [Updating](#updating) · [Restart](#restart) · [Stop](#stop) · [Windows](#windows-docker-desktop) · [Memory](#memory) · [File Tools](#file-tools) · [Read-only](#read-only-mode) · [Deleting notes](#deleting-notes) · [Config](#configuration) · [Troubleshooting](#troubleshooting) +**Contents** — [Prerequisites](#prerequisites) · [Setup](#setup) · [Connect](#connect-your-mcp-client) · [Verify](#verify) · [Monitoring](#monitoring) · [Updating](#updating) · [Restart](#restart) · [Stop](#stop) · [More than one vault](#run-more-than-one-vault) · [Windows](#windows-docker-desktop) · [Memory](#memory) · [File Tools](#file-tools) · [Read-only](#read-only-mode) · [Deleting notes](#deleting-notes) · [Config](#configuration) · [Troubleshooting](#troubleshooting) ## Prerequisites @@ -199,8 +199,8 @@ The server runs these startup tasks on every boot: or deleted while the server started. Restarting the container re-runs this flow (useful when testing bootstrap -behavior). The command is the same for both setup methods, -since both name the container `vault-cortex`: +behavior). The command is the same for both setup methods, since both name +the container after `INSTANCE_NAME` (`vault-cortex` unless you set it): ```bash docker restart vault-cortex @@ -237,6 +237,56 @@ docker stop vault-cortex reference). Otherwise resume with `docker compose up -d` or `docker start vault-cortex`. +## Run more than one vault + +Each vault gets its own install, with its own folder, `.env`, container, +search index, port and connector. Give every install after the first its own +`INSTANCE_NAME` before its first start. Docker uses that name for the +install's container and for the volume that holds its search index +(`_mcp_data`); the first install keeps the default, `vault-cortex`. + +**Set up with the CLI?** Start Docker, so `init` can check which names and +ports are in use. Then, from the folder you ran `init` in the first time, run it again +with a new `--dir`: + +```bash +npx vault-cortex@latest init --dir ./vault-cortex-work +``` + +When this machine already has a `vault-cortex` container or search index +volume, `init` asks for the new install's name and port, for example `work` +and `8001`. It suggests the first port no other container uses, and points +the install's `PUBLIC_URL` at the port you pick. For a scripted setup, +add `--yes --vault-path --instance-name work --port 8001`. Then pass +the same `--dir` to every command for that install: + +```bash +npx vault-cortex@latest upgrade --dir ./vault-cortex-work +npx vault-cortex@latest logs --dir ./vault-cortex-work +``` + +**Set up with Docker Compose?** Set up a new folder as in **Manual setup** +under [Setup](#setup), with its own `MCP_AUTH_TOKEN` and `VAULT_PATH`. In its +`.env`, also set a new `INSTANCE_NAME` and a free `PORT`, and point +`PUBLIC_URL` at that port, or MCP clients can't sign in. +[`.env.example`](./.env.example) describes each setting. Run each install's +`docker compose` commands from its own folder. + +For each install: + +- **Connect it as its own server** in your MCP client, under its own name and + port. For Claude Code: + `claude mcp add --scope user --transport http work http://localhost:8001/mcp`. +- **Update and restart it on its own:** + - With the CLI: `npx vault-cortex@latest upgrade --dir ./vault-cortex-work` + to update, or `npx vault-cortex@latest restart --dir ./vault-cortex-work` + to restart. + - With Compose: `docker compose pull && docker compose up -d` in its folder. +- **Use its name in `docker` commands.** The commands in this guide that name + the container (`docker logs vault-cortex`, `docker restart vault-cortex`, + `docker stop vault-cortex`) take the install's `INSTANCE_NAME` instead, for + example `docker logs work`. + ## Windows (Docker Desktop) Vault Cortex runs in a Linux container, so on Windows it reaches your vault through @@ -338,7 +388,7 @@ longer exists and `/authorize` rejects it. Clear the client's stored authorization for this server and reconnect so it registers fresh: - **Claude Code:** `claude mcp remove `, then - `claude mcp add --scope user --transport http http://localhost:8000/mcp`. + `claude mcp add --scope user --transport http http://localhost:/mcp`. - **Claude Desktop / mcp-remote:** delete `~/.mcp-auth` and restart the client. - **Other clients:** remove and re-add the server. @@ -353,7 +403,9 @@ usual cause. container from a different management method is still running. The CLI (`npx vault-cortex@latest upgrade`) and Docker Compose (`docker compose up -d`) manage the container independently — stop the existing one first with -`docker rm -f vault-cortex`, then retry with your preferred method. +`docker rm -f vault-cortex`, then retry with your preferred method. Setting +up a second vault? Give it its own name instead — see +[Run more than one vault](#run-more-than-one-vault). ## Building from source diff --git a/deploy/local/docker-compose.yml b/deploy/local/docker-compose.yml index 5d7020a8d..845817fc2 100644 --- a/deploy/local/docker-compose.yml +++ b/deploy/local/docker-compose.yml @@ -11,12 +11,15 @@ # # Full docs: https://github.com/aliasunder/vault-cortex -name: vault-cortex +name: ${INSTANCE_NAME:-vault-cortex} services: vault-cortex: image: ghcr.io/aliasunder/vault-cortex:latest - container_name: vault-cortex + # Follows the project name, so -p and COMPOSE_PROJECT_NAME rename the + # container too. Older Compose versions leave COMPOSE_PROJECT_NAME empty + # here, and the INSTANCE_NAME fallback covers them. + container_name: ${COMPOSE_PROJECT_NAME:-${INSTANCE_NAME:-vault-cortex}} restart: unless-stopped environment: PORT: "8000" diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 60d9f9e58..bfdac742e 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -347,7 +347,28 @@ again, like a first deploy. **A redeploy took as long as the first deploy and downloaded the whole vault again.** The volume was replaced, or `/persist/config` was removed, so the container started over with a fresh Obsidian Sync connection. Nothing needs -cleaning up. +cleaning up. To switch to another vault, don't remove `/persist/config` on +its own: follow the last step of the next entry. + +**`This install is set up to sync a different vault than VAULT_NAME.`** The +container refused to start because the volume holds the Obsidian Sync setup +for another vault, usually after `VAULT_NAME` was changed. Starting would +merge the two vaults' notes. Nothing was changed, and the log line after it +names both vaults. Then do one of these: + +- If `VAULT_NAME` is a mistake, set it back under **Variables** and apply the + staged change. +- To serve the other vault as well, deploy it as a separate Railway service + with its own volume, and leave this one on its original vault. The new + service has its own URL and `MCP_AUTH_TOKEN`, so connect it as its own + server in your MCP client. +- To switch this service to the new vault, delete the volume + (**Settings → Volumes**), add a new one at `/persist`, then **Redeploy**. + The container downloads the new vault like a first deploy, and the old + vault stays in Obsidian Sync. The new volume has no record of your + signed-in MCP clients, so remove and re-add the server in each client. + Removing only `/persist/config` would upload the old vault's notes into the + new one. **`The vault is empty but this device has previously synced.`** The container refused to start because the vault directory on the volume is diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index be4a767c2..24a18191a 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -114,8 +114,7 @@ MEMORY_DIR=About Me # setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. -# To apply environment changes, run the CLI's restart command -# or recreate the Compose container. +# Environment changes apply when the container is re-created. # Obsidian settings apply on the next query once daily-notes.json reaches the server. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me @@ -123,6 +122,19 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork +# Name of this install's container and data volumes +# (_vault_data, _mcp_data and _obsidian_config). To run more +# than one vault on this server, give each install its own INSTANCE_NAME, PORT +# and PUBLIC_URL. +# Valid values: two or more lowercase letters, digits, hyphens or underscores, +# starting with a letter or digit. +# Default: vault-cortex +# Set it before the first start. To change it later, stop the container first, +# then change it to a name no other install uses: the install starts on new, +# empty volumes and downloads the vault again as a new Obsidian Sync device, +# while the old volumes stay until you remove them. +INSTANCE_NAME=vault-cortex + # Host port to expose (default: 8000). PORT=8000 @@ -154,8 +166,8 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: /data/logs). # The container's own log (what `docker logs` shows) is always written, but -# Docker discards it whenever the container is recreated — on image updates -# or compose changes. Log files under LOG_DIR live on the data volume and +# Docker discards it whenever the container is re-created — on image updates +# or settings changes. Log files under LOG_DIR live on the data volume and # survive. Set to "none" to keep only the container log. LOG_DIR=/data/logs diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 2cfb91999..4adbf5298 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -15,7 +15,7 @@ configuration. Both produce an identical container — restart policy, log rotation, and health check included — and Podman or any OCI-compatible container runtime works in place of Docker. -**Contents** — [Prerequisites](#prerequisites) · [Setup](#setup) · [HTTPS access](#https-access) · [Sign in](#sign-in-to-obsidian-sync) · [Connect](#connect-your-mcp-client) · [Verify](#verify) · [Monitoring](#monitoring) · [Updating](#updating) · [Restart](#restart) · [Stop](#stop) · [Memory](#memory) · [File Tools](#file-tools) · [Read-only](#read-only-mode) · [Deleting notes](#deleting-notes) · [Daily Notes](#daily-notes) · [Config](#configuration) · [Hardening](#hardening-recommended) · [Troubleshooting](#troubleshooting) +**Contents** — [Prerequisites](#prerequisites) · [Setup](#setup) · [HTTPS access](#https-access) · [Sign in](#sign-in-to-obsidian-sync) · [Connect](#connect-your-mcp-client) · [Verify](#verify) · [Monitoring](#monitoring) · [Updating](#updating) · [Restart](#restart) · [Stop](#stop) · [More than one vault](#run-more-than-one-vault) · [Memory](#memory) · [File Tools](#file-tools) · [Read-only](#read-only-mode) · [Deleting notes](#deleting-notes) · [Daily Notes](#daily-notes) · [Config](#configuration) · [Hardening](#hardening-recommended) · [Troubleshooting](#troubleshooting) ## Prerequisites @@ -443,8 +443,8 @@ starts. The server then runs these startup tasks: or deleted while the server started. Restarting the container re-runs this flow (useful when testing bootstrap -behavior). The command is the same for every setup -method: +behavior). The command is the same for every setup method; an install with +its own `INSTANCE_NAME` uses that name in place of `vault-cortex`: ```bash # Sync and the MCP server both restart; the startup steps re-run cleanly: @@ -493,6 +493,141 @@ docker stop vault-cortex reference). Otherwise resume with `docker compose up -d` or `docker start vault-cortex`. +## Run more than one vault + +Each vault gets its own install, with its own folder, `.env`, container, +volumes, port, Obsidian Sync device and connector. Give every install after +the first its own `INSTANCE_NAME` before its first start. Docker uses that +name for the install's container and volumes (`_vault_data`, +`_mcp_data` and `_obsidian_config`); the first install keeps the +default, `vault-cortex`. A second vault must never use the first install's +volumes: the container refuses to start rather than merge the two vaults (see +[Another vault's Sync setup](#another-vaults-sync-setup)). + +**Set up with the CLI?** Start Docker, so `init` can check which names and +ports are in use. Then, from the folder you ran `init` in the first time, run it again +with a new `--dir`: + +```bash +npx vault-cortex@latest init --mode remote --dir ./vault-cortex-work +``` + +When this server already has a `vault-cortex` container or volumes, `init` +asks for the new install's name and port, for example `work` and `8001`. It +suggests the first port no other container uses. Then pass the same `--dir` +to every command for that install: + +```bash +npx vault-cortex@latest upgrade --dir ./vault-cortex-work +npx vault-cortex@latest logs --dir ./vault-cortex-work +``` + +**Set up with Docker Compose?** Set up a new folder as in **Manual setup** +under [Setup](#setup), with its own `MCP_AUTH_TOKEN`, `VAULT_NAME` and +`PUBLIC_URL`. In its `.env`, also set a new `INSTANCE_NAME` (see +[Configuration](#configuration)) and a free `PORT`. Run each install's +`docker compose` commands from its own folder. + +**Set up with `docker run` (no Compose)?** Set up a new folder with its own `.env` as +above, but keep `PORT` at `8000`. With `docker run`, `PORT` is the port the +server listens on inside the container, and the command's `-p` mapping and +health check both expect `8000`. Then run the command from [Setup](#setup) +with `vault-cortex` replaced by the new name in `--name`, `--hostname` and the +volume names, and with the new host port on the left of `-p` (`-p 8001:8000`). + +For each install: + +- **Give it its own address.** In your [HTTPS access](#https-access) setup, + route a separate address to its port, and use that address as its + `PUBLIC_URL`: + - Cloudflare Tunnel: add a public hostname that points at + `http://localhost:8001`. + - API Gateway: create another HTTP API that proxies to + `http://:8001/{proxy+}`. + - Reverse proxy: add a site block for another domain with + `reverse_proxy localhost:8001`. + - Direct access: `http://:8001`. +- **Connect it as its own server** in your MCP client, under its own name. + For Claude Code: + `claude mcp add --scope user --transport http work /mcp`. +- **Update and restart it on its own:** + - With the CLI: `npx vault-cortex@latest upgrade --dir ./vault-cortex-work` + to update, or `npx vault-cortex@latest restart --dir ./vault-cortex-work` + to restart. + - With Compose: `docker compose pull && docker compose up -d` in its folder. + - With `docker run`: the steps under [Updating](#updating), using its name. +- **Use its name in `docker` commands.** The commands in this guide that name + the container or its volumes (`docker logs vault-cortex`, + `docker restart vault-cortex`, `docker rm -f vault-cortex`, + `vault-cortex_vault_data`) take the install's name instead, for example + `docker logs work`. + +### Another vault's Sync setup + +The container keeps an Obsidian Sync setup in its `obsidian_config` volume +(the `config` folder with a single volume): the record of which vault in your +account it syncs. When that setup is for a different vault than `VAULT_NAME`, +the container stops before it syncs or changes anything, because starting +would merge the two vaults' notes through Obsidian Sync. `docker logs` then +shows: + +```text +[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME. +[obsidian-sync] VAULT_NAME is 'Work' (), but its Sync setup is for 'Personal' (). +``` + +It usually means `VAULT_NAME` was changed, or a second install is using the +first one's volumes. Fix it one of these ways: + +- **`VAULT_NAME` is a mistake:** set it back and re-create the container. The + **Changed `.env`?** note under [Restart](#restart) gives the command for + each setup method. +- **You want to sync both vaults:** set this install's `VAULT_NAME` back, and + give the second vault its own install, as described above. On Render or + Railway, deploy it as a separate service. +- **You want this install to sync the new vault instead:** delete all of its + volumes together (the **Stop and delete all volumes** commands under + [Stop](#stop)), then start it again. The container downloads the new vault + like a first start, and the old vault stays in Obsidian Sync. Deleting the + volumes also deletes the server's record of signed-in clients, so remove + and re-add the server in each MCP client. Deleting only `obsidian_config` + would upload the old vault's notes into the new one. On Render or Railway, + follow the entry in that platform's guide. +- **You re-created the same vault in Obsidian**, the second log line ends + with "which is no longer in your account", and the notes on the server are + that vault's notes: remove only the old setup folder the log names, then + start the container: + + ```bash + docker run --rm -v vault-cortex_obsidian_config:/home/obsidian/.config \ + --entrypoint rm ghcr.io/aliasunder/vault-cortex:remote \ + -r /home/obsidian/.config/obsidian-headless/sync/ + ``` + + With a single volume (`STORAGE_ROOT`), mount `vault-cortex_storage:/persist` + instead, and remove the full path the log names, which starts with + `/persist/config`. + +A **WARNING** that the install "also holds Sync setups for other vaults" +means the container started, because it uses the right vault, but the Sync +client would use one of those setups if this vault's setup were removed. +Remove each one's folder with the same command, using the vault ID from the +warning. + +An **ERROR** that "a Sync setup file here is not a valid setup" means the +Sync client can't read the file the log names and would ignore it. That file +may be another vault's setup, whose notes are in this install's vault. If the +notes on the server are `VAULT_NAME`'s notes, remove the folder that holds +the file with the command above, using that folder's name in place of +``. Otherwise delete all of the install's volumes, as in the fix +for syncing the new vault instead. + +An **ERROR** that "the Sync client could not read its own setup" means the +Sync client's answer about which vault it syncs failed or named no vault, so +the container stopped before syncing any notes. Restart it. If the error +comes back and the lines just above it hold a message from the Sync client, +that message says why. + ## Memory The memory layer is enabled by default. Set `MEMORY_ENABLED=false` in your @@ -605,6 +740,7 @@ These optional settings are worth knowing about: | `READONLY_MODE` | `false` | Set `true` to hide every tool that changes the vault and skip memory folder auto-creation — read and search only | | `DISABLED_TOOLS` | none hidden | Hide individual tools by name, comma-separated; names match the [README tools table](https://github.com/aliasunder/vault-cortex#tools) | | `SYNC_CONFIGS` | daily notes, plugins, and app | Obsidian settings categories synced to the server (see [Daily notes](#daily-notes)); `none` disables | +| `INSTANCE_NAME` | `vault-cortex` | Names this install's container, Compose project and volumes. Set it before the first start to run another vault on the same server (see [Run more than one vault](#run-more-than-one-vault)) | | `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log | | `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | | `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | @@ -625,7 +761,7 @@ The setup above is authenticated — every request requires your token or an OAuth session. These optional measures add defense-in-depth: - **Close port 8000** — once a tunnel or reverse proxy handles HTTPS, close - direct access to port 8000 wherever you manage your server's firewall + direct access to port 8000, and to each other install's `PORT`, wherever you manage your server's firewall (provider panel, AWS security groups, infrastructure-as-code). All traffic then flows through the encrypted path. @@ -697,16 +833,23 @@ container, because `docker start` keeps the old settings. The **Changed `.env`?** note under [Restart](#restart) gives the command for each setup method. +**"This install is set up to sync a different vault than VAULT_NAME" in +`docker logs`, and the container stops.** The container's volumes hold the +Obsidian Sync setup for another vault, and starting would merge the two. +[Another vault's Sync setup](#another-vaults-sync-setup) gives the fixes. + **"container name vault-cortex already in use" on start or upgrade.** A container from a different management method is still running. The CLI (`npx vault-cortex@latest upgrade`) and Docker Compose (`docker compose up -d`) manage the container independently — stop the existing one first with -`docker rm -f vault-cortex`, then retry with your preferred method. +`docker rm -f vault-cortex`, then retry with your preferred method. Setting +up a second vault? Give it its own name instead — see +[Run more than one vault](#run-more-than-one-vault). **Vault re-syncs from scratch (or the search index is empty) after switching between the CLI, Compose, and `docker run`.** Nothing is lost — your data is -still in the old volumes. All three methods use the same named volumes, so -data normally carries over — but only when the names match exactly, and a +still in the old volumes. All three methods use the same named volumes for +the same `INSTANCE_NAME`, so data normally carries over — but only when the names match exactly, and a mismatch produces **no error**. Docker silently creates fresh, empty volumes and the container starts with a clean slate: Obsidian Sync registers a new device and re-syncs the vault, and the search index rebuilds. diff --git a/deploy/remote/docker-compose.yml b/deploy/remote/docker-compose.yml index aa81c49e1..54745985c 100644 --- a/deploy/remote/docker-compose.yml +++ b/deploy/remote/docker-compose.yml @@ -13,12 +13,15 @@ # # Full docs: https://github.com/aliasunder/vault-cortex -name: vault-cortex +name: ${INSTANCE_NAME:-vault-cortex} services: vault-cortex: image: ghcr.io/aliasunder/vault-cortex:remote - container_name: vault-cortex + # Follows the project name, so -p and COMPOSE_PROJECT_NAME rename the + # container too. Older Compose versions leave COMPOSE_PROJECT_NAME empty + # here, and the INSTANCE_NAME fallback covers them. + container_name: ${COMPOSE_PROJECT_NAME:-${INSTANCE_NAME:-vault-cortex}} hostname: ${DEVICE_NAME:-vault-cortex} restart: unless-stopped environment: diff --git a/deploy/render/README.md b/deploy/render/README.md index cfdef84bf..36cca914d 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -320,7 +320,26 @@ for the first deploy and remove it once the vault has synced. **A redeploy took as long as the first deploy and downloaded the whole vault again.** The disk was replaced, or `/persist/config` was removed, so the container started over with a fresh Obsidian Sync connection. Nothing needs -cleaning up. +cleaning up. To switch to another vault, don't remove `/persist/config` on +its own: follow the last step of the next entry. + +**`This install is set up to sync a different vault than VAULT_NAME.`** The +container refused to start because the disk holds the Obsidian Sync setup +for another vault, usually after `VAULT_NAME` was changed. Starting would +merge the two vaults' notes. Nothing was changed, and the log line after it +names both vaults. Then do one of these: + +- If `VAULT_NAME` is a mistake, set it back under **Environment** and save. +- To serve the other vault as well, deploy the Blueprint again for it, as a + separate service with its own disk, and leave this one on its original + vault. The new service has its own URL and `MCP_AUTH_TOKEN`, so connect it + as its own server in your MCP client. +- To switch to the new vault, deploy the Blueprint again for it, then delete + this service (see [Restart, stop, delete](#restart-stop-delete)). The new + service downloads the new vault like a first deploy, and the old vault + stays in Obsidian Sync. Connect your MCP client to the new service's URL + with its `MCP_AUTH_TOKEN`. Removing only `/persist/config` would upload the + old vault's notes into the new one. **`The vault is empty but this device has previously synced.`** The container refused to start because the vault directory on the disk is empty diff --git a/llms-install.md b/llms-install.md index 32ca46056..6d49828dc 100644 --- a/llms-install.md +++ b/llms-install.md @@ -163,6 +163,15 @@ docker compose down -v # stop and delete the index (rebuilds on next sta docker compose pull && docker compose up -d --force-recreate # update to the latest image ``` +To serve a second vault from the same machine: + +1. In a separate folder, repeat Step 1 and Step 2's download and `.env` edit, with a new `MCP_AUTH_TOKEN` and that vault's `VAULT_PATH`. Don't start it yet: started under the default name, it would replace the first install's container. +2. In that folder's `.env`, also set `INSTANCE_NAME` (for example `INSTANCE_NAME=work`), a free `PORT`, and `PUBLIC_URL` on that port (for example `http://localhost:8001`). +3. Start it from that folder with Step 2's `docker compose pull` and `docker compose up -d`. +4. Register it in the client (Step 4) under its own name and URL. + +`INSTANCE_NAME` names that install's container and index volume, so the `docker exec` commands in this guide use that name in place of `vault-cortex` (`docker exec work …`), and its URLs use its `PORT`. The `docker compose` commands keep the service name `vault-cortex` and run from that install's folder. + ## More - Full configuration (memory folder, protected paths, timezone, logging): [README → Configuration](./README.md#configuration) diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault new file mode 100644 index 000000000..3e3fba682 --- /dev/null +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -0,0 +1,240 @@ +#!/command/with-contenv sh +# shellcheck shell=sh # ShellCheck cannot read the with-contenv shebang +# Stops a boot that would sync a vault other than VAULT_NAME. +# +# Each vault's Sync setup is /obsidian-headless/sync//config.json. +# Every ob command uses the first setup, in folder-name order, whose vaultPath +# is the vault folder, whatever VAULT_NAME says. So a volume holding another vault's +# setup (two installs sharing volumes, or VAULT_NAME changed on one install) +# would sync that vault instead, or merge the two through Obsidian Sync. +# +# This check only reads, and runs before ob sync-setup writes anything: +# 1. resolve VAULT_NAME to a vault ID the way ob sync-setup does; +# 2. stop on a config.json the engine would silently ignore; +# 3. compare the vault the engine would sync now with that ID. +# Exit 0 lets the boot continue and prints the vault ID from step 1 on stdout +# (nothing when VAULT_NAME names no single vault), which init-setup-vault +# checks again after ob sync-setup. Exit 1 stops the boot. Every message goes +# to stderr. It can also run by hand inside a running container, which prints +# the same decision. docker exec leaves s6's /command off PATH, and +# with-contenv loads the environment the init chain published: +# docker exec -i -w /vault -e PATH=/command:/usr/bin:/bin \ +# /command/with-contenv sh -s < check-sync-vault +set -u + +if [ -z "${VAULT_NAME:-}" ]; then + echo "[obsidian-sync] ERROR: VAULT_NAME is not set." >&2 + exit 1 +fi + +VAULT_PATH="${VAULT_PATH:-/vault}" +SYNC_STATE_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/obsidian-headless/sync" +GUIDE_URL="https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup" + +# ob compares paths against its own working directory, so every call runs +# inside the vault folder, the way init-setup-vault runs ob sync-setup. +if ! cd "$VAULT_PATH"; then + echo "[obsidian-sync] ERROR: Failed to change directory to VAULT_PATH='$VAULT_PATH'." >&2 + exit 1 +fi + +run_ob() { + s6-setuidgid obsidian ob "$@" +} + +# --------------------------------------------------------------------------- +# 1. The requested vault +# --------------------------------------------------------------------------- + +if ! REMOTE_VAULTS=$(run_ob sync-list-remote --json); then + echo "[obsidian-sync] ERROR: Could not list the vaults in your Obsidian account." >&2 + echo "[obsidian-sync] Check OBSIDIAN_AUTH_TOKEN and the network, then restart." >&2 + exit 1 +fi + +# Prints "" for the vault VAULT_NAME names, matched the way ob +# sync-setup matches it: an exact vault ID first, otherwise exactly one vault +# with that name, across owned and shared vaults. Prints nothing when no vault +# or several match; ob sync-setup then reports that itself and writes nothing. +if ! REQUESTED_VAULT=$(printf '%s' "$REMOTE_VAULTS" | VAULT_NAME="$VAULT_NAME" node --no-warnings -e ' + const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const allVaults = [...(listed.vaults ?? []), ...(listed.shared ?? [])] + const requested = process.env.VAULT_NAME + const byId = allVaults.find((vault) => vault.id === requested) + const byName = allVaults.filter((vault) => vault.name === requested) + const match = byId ?? (byName.length === 1 ? byName[0] : undefined) + if (match) console.log(match.id + "\t" + match.name) +'); then + # ob sync-setup reads the list itself and could still set up VAULT_NAME's + # vault beside another vault's setup for this folder. + echo "[obsidian-sync] ERROR: Could not read the list of vaults in your Obsidian account." >&2 + echo "[obsidian-sync] Refusing to sync without knowing which vault VAULT_NAME names." >&2 + exit 1 +fi +if [ -z "$REQUESTED_VAULT" ]; then + echo "[obsidian-sync] Vault check: '$VAULT_NAME' does not name exactly one vault — ob sync-setup reports it." >&2 + exit 0 +fi +REQUESTED_ID=$(printf '%s' "$REQUESTED_VAULT" | cut -f1) + +# Prints the current name of a vault ID from the remote list, or nothing when +# the vault is no longer in the account. +remote_vault_name() { + printf '%s' "$REMOTE_VAULTS" | VAULT_ID="$1" node --no-warnings -e ' + const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const allVaults = [...(listed.vaults ?? []), ...(listed.shared ?? [])] + const match = allVaults.find((vault) => vault.id === process.env.VAULT_ID) + if (match) console.log(match.name) + ' +} + +# --------------------------------------------------------------------------- +# 2. Setups the engine would silently ignore +# --------------------------------------------------------------------------- + +# The engine skips a config.json it cannot read or parse. If that file is +# another vault's setup, this folder holds that vault's notes, and ob +# sync-setup would upload them into VAULT_NAME's vault. Prints one line per +# such file; a sync folder that can't be listed is a non-zero exit. +invalid_sync_configs() { + [ -d "$SYNC_STATE_ROOT" ] || return 0 + node --no-warnings -e ' + const { existsSync, readdirSync, readFileSync } = require("node:fs") + const { join } = require("node:path") + const syncRoot = process.argv[1] + for (const entry of readdirSync(syncRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const configPath = join(syncRoot, entry.name, "config.json") + if (!existsSync(configPath)) continue + try { + const config = JSON.parse(readFileSync(configPath, "utf8")) + const isConfig = + config !== null && + typeof config === "object" && + typeof config.vaultId === "string" && + config.vaultId !== "" && + typeof config.vaultPath === "string" + if (!isConfig) console.log(configPath) + } catch { + console.log(configPath) + } + } + ' "$SYNC_STATE_ROOT" +} + +if ! INVALID_CONFIGS=$(invalid_sync_configs); then + echo "[obsidian-sync] ERROR: Could not read the Sync setups under ${SYNC_STATE_ROOT}." >&2 + echo "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for." >&2 + exit 1 +fi +if [ -n "$INVALID_CONFIGS" ]; then + echo "[obsidian-sync] ERROR: A Sync setup file here is not a valid setup, and Obsidian Sync would ignore it:" >&2 + printf '%s\n' "$INVALID_CONFIGS" | sed 's/^/[obsidian-sync] /' >&2 + echo "[obsidian-sync] Refusing to sync, because it may belong to another vault whose notes are in this folder." >&2 + echo "[obsidian-sync] See: $GUIDE_URL" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# 3. The vault the engine would sync now +# --------------------------------------------------------------------------- + +refuse_unreadable_setup() { + echo "[obsidian-sync] ERROR: The Sync client could not read its own setup ($1)." >&2 + echo "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for." >&2 + echo "[obsidian-sync] See: $GUIDE_URL" >&2 + exit 1 +} + +PICKED_CONFIG=$(run_ob sync-config --json 2>/dev/null) +PICK_EXIT=$? + +# Exit 3: no setup for this folder yet (a first boot, or setups only for other +# paths), so ob sync-setup creates the first one. +if [ "$PICK_EXIT" -eq 3 ]; then + echo "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet." >&2 + printf '%s\n' "$REQUESTED_ID" + exit 0 +fi +if [ "$PICK_EXIT" -ne 0 ]; then + # The call above drops ob's message so a first boot's exit 3 stays quiet. + # Running this read-only call again puts the failure's reason in the log. + run_ob sync-config --json >/dev/null + refuse_unreadable_setup "ob sync-config exited $PICK_EXIT" +fi + +# Prints "" of the setup the engine picked, or nothing +# when its output holds no vault ID. +PICKED_VAULT=$(printf '%s' "$PICKED_CONFIG" | node --no-warnings -e ' + const config = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + if (typeof config.vaultId === "string" && config.vaultId) { + console.log(config.vaultId + "\t" + (config.vaultName ?? "")) + } +' 2>/dev/null) +PICKED_ID=$(printf '%s' "$PICKED_VAULT" | cut -f1) +PICKED_STORED_NAME=$(printf '%s' "$PICKED_VAULT" | cut -f2) + +if [ -z "$PICKED_ID" ]; then + refuse_unreadable_setup "ob sync-config printed no vault ID" +fi + +if [ "$PICKED_ID" = "$REQUESTED_ID" ]; then + # - A second setup for another vault is dormant while the engine picks this + # one, so the boot continues; removing it keeps it from being picked later. + # - The warning reports only setups stored for this folder: ob never picks + # one stored for another folder. + # - The listing only feeds this warning, so a failed listing is reported as + # a warning instead of stopping a boot the checks above already passed. + if ! LOCAL_SETUPS=$(run_ob sync-list-local --json 2>/dev/null) || + ! OTHER_IDS=$(printf '%s' "$LOCAL_SETUPS" | REQUESTED_ID="$REQUESTED_ID" node --no-warnings -e ' + const { resolve } = require("node:path") + const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const isOtherSetupForThisFolder = (vault) => + vault.id !== process.env.REQUESTED_ID && resolve(vault.path) === resolve(".") + const otherIds = listed.vaults.filter(isOtherSetupForThisFolder).map((vault) => vault.id) + if (otherIds.length > 0) console.log(otherIds.join(" ")) + ' 2>/dev/null); then + echo "[obsidian-sync] WARNING: Could not list this install's Sync setups, so a setup for another vault would not be reported here." >&2 + OTHER_IDS="" + fi + if [ -n "$OTHER_IDS" ]; then + echo "[obsidian-sync] WARNING: This install also holds Sync setups for other vaults: $OTHER_IDS" >&2 + echo "[obsidian-sync] They are not used now. Remove each one's folder under ${SYNC_STATE_ROOT} so it can't be picked later." >&2 + fi + echo "[obsidian-sync] Vault check: continue — set up for '$VAULT_NAME' ($REQUESTED_ID)." >&2 + printf '%s\n' "$REQUESTED_ID" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Refuse +# --------------------------------------------------------------------------- + +PICKED_CURRENT_NAME=$(remote_vault_name "$PICKED_ID") +# STORAGE_ROOT is set on single-volume hosts (Render, Railway), where the +# vault and the Sync config are folders on one volume instead of two volumes. +if [ -n "${STORAGE_ROOT:-}" ]; then + RESET_STEP="remove ${STORAGE_ROOT}/vault and ${STORAGE_ROOT}/config together" + SEPARATE_STEP="run it as a separate service" +else + RESET_STEP="remove this install's vault_data and obsidian_config volumes together" + SEPARATE_STEP="run it as a separate install with its own INSTANCE_NAME" +fi + +echo "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME." >&2 +if [ -n "$PICKED_CURRENT_NAME" ]; then + echo "[obsidian-sync] VAULT_NAME is '$VAULT_NAME' ($REQUESTED_ID), but its Sync setup is for '$PICKED_CURRENT_NAME' ($PICKED_ID)." >&2 +else + echo "[obsidian-sync] VAULT_NAME is '$VAULT_NAME' ($REQUESTED_ID), but its Sync setup is for '$PICKED_STORED_NAME' ($PICKED_ID), which is no longer in your account." >&2 +fi +echo "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:" >&2 +echo "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart." >&2 +echo "[obsidian-sync] - To sync '$VAULT_NAME' as well, $SEPARATE_STEP." >&2 +if [ -z "$PICKED_CURRENT_NAME" ]; then + echo "[obsidian-sync] - If you re-created this same vault in Obsidian and the notes here are its notes," >&2 + echo "[obsidian-sync] remove only the old setup folder: ${SYNC_STATE_ROOT}/${PICKED_ID}" >&2 +fi +echo "[obsidian-sync] - To switch this install to '$VAULT_NAME' on purpose, $RESET_STEP." >&2 +echo "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one." >&2 +echo "[obsidian-sync] See: $GUIDE_URL" >&2 +exit 1 diff --git a/rootfs/etc/s6-overlay/scripts/init-first-sync b/rootfs/etc/s6-overlay/scripts/init-first-sync index fa438255f..7eea797cf 100644 --- a/rootfs/etc/s6-overlay/scripts/init-first-sync +++ b/rootfs/etc/s6-overlay/scripts/init-first-sync @@ -66,11 +66,13 @@ fi # # The guard sums local_files across every store instead of resolving # . The image serves one vault, so a second store exists only -# after the device was registered against a different remote vault -# (one deleted and re-created, or a manual sync-setup). Counting that -# store too keeps the guard conservative; the cost is a stopped -# container until the device is re-registered. The queue step below reads -# only the active store, because it writes to the store the client syncs from. +# after the device was registered against a different remote vault (one +# deleted and re-created, or a manual sync-setup). init-setup-vault has +# already stopped any boot whose client would sync that other vault, so +# here the second store is unused. Counting it too keeps the guard +# conservative; the cost is a stopped container until the device is +# re-registered. The queue step below reads only the active store, because +# it writes to the store the client syncs from. SYNC_STATE_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/obsidian-headless/sync" # Prints the number of files the sync state records as present locally; @@ -81,6 +83,7 @@ SYNC_STATE_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/obsidian-headless/sync" # compares it as an integer, so the only output is the bare number. known_sync_files() { set -- "$SYNC_STATE_ROOT"/*/state.db + # sh leaves a glob that matches nothing as its literal text. [ -e "$1" ] || { echo 0; return 0; } node --no-warnings -e ' const { DatabaseSync } = require("node:sqlite") diff --git a/rootfs/etc/s6-overlay/scripts/init-setup-vault b/rootfs/etc/s6-overlay/scripts/init-setup-vault index 1a3b4cf57..b7db99c68 100644 --- a/rootfs/etc/s6-overlay/scripts/init-setup-vault +++ b/rootfs/etc/s6-overlay/scripts/init-setup-vault @@ -9,6 +9,10 @@ if [ "${SETUP_MODE:-}" = 1 ]; then exit 0 fi +# Resolved before the cd into VAULT_PATH, so a relative script path still +# finds check-sync-vault beside this script. +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) + VAULT_PATH="${VAULT_PATH:-/vault}" mkdir -p "$VAULT_PATH" if ! cd "$VAULT_PATH"; then @@ -52,6 +56,16 @@ fi if [ -n "$CONFIG_DIR_NAME" ]; then set -- "$@" --config-dir "$CONFIG_DIR_NAME" fi + +# check-sync-vault stops the boot before ob sync-setup writes anything when +# the volume holds another vault's Sync setup. It prints the vault ID that +# VAULT_NAME names, for the check after ob sync-setup. It runs under sh +# because this script has already loaded the environment its with-contenv +# line would load. +if ! REQUESTED_VAULT_ID=$(sh "$SCRIPT_DIR/check-sync-vault"); then + exit 1 +fi + if ! run_sync_setup "$@"; then echo "[obsidian-sync] ERROR: ob sync-setup failed." >&2 echo "[obsidian-sync] Check OBSIDIAN_AUTH_TOKEN and VAULT_NAME are correct." >&2 @@ -61,6 +75,45 @@ if ! run_sync_setup "$@"; then exit 1 fi +SYNC_SETUP_GUIDE_URL="https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup" + +# The check prints no vault ID when VAULT_NAME named no single vault. ob +# sync-setup then fails, so getting here means the vault changed mid-boot. +if [ -z "$REQUESTED_VAULT_ID" ]; then + echo "[obsidian-sync] ERROR: VAULT_NAME '$VAULT_NAME' named no single vault when the vault check ran, so this boot can't confirm which setup ob sync-setup used." >&2 + echo "[obsidian-sync] Restart to run the check again." >&2 + exit 1 +fi + +# ob sync-setup has just written VAULT_NAME's setup, so the Sync client must +# now use it for this folder. Every sync-config call below writes to the setup +# the client uses, so any other answer stops the boot before those writes. +# +# Prints the vault ID the Sync client now uses for this folder, or nothing +# when ob sync-config fails or prints no vault ID. It always returns 0, +# because set -e would end the script silently on a failed assignment. +picked_vault_id() { + picked_config=$(s6-setuidgid obsidian ob sync-config --json) || return 0 + printf '%s' "$picked_config" | node --no-warnings -e ' + const config = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + if (typeof config.vaultId === "string") console.log(config.vaultId) + ' 2>/dev/null || return 0 +} +PICKED_VAULT_ID=$(picked_vault_id) + +if [ -z "$PICKED_VAULT_ID" ]; then + echo "[obsidian-sync] ERROR: After ob sync-setup, the Sync client could not read its own setup." >&2 + echo "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for." >&2 + echo "[obsidian-sync] See: $SYNC_SETUP_GUIDE_URL" >&2 + exit 1 +fi +if [ "$PICKED_VAULT_ID" != "$REQUESTED_VAULT_ID" ]; then + echo "[obsidian-sync] ERROR: After ob sync-setup, the Sync client does not use the setup for '$VAULT_NAME' in this folder." >&2 + echo "[obsidian-sync] Refusing to sync a vault other than VAULT_NAME." >&2 + echo "[obsidian-sync] See: $SYNC_SETUP_GUIDE_URL" >&2 + exit 1 +fi + # --------------------------------------------------------------------------- # Apply optional sync config # --------------------------------------------------------------------------- diff --git a/scripts/__tests__/lint-shell.test.ts b/scripts/__tests__/lint-shell.test.ts index 65f524662..c82883b62 100644 --- a/scripts/__tests__/lint-shell.test.ts +++ b/scripts/__tests__/lint-shell.test.ts @@ -24,6 +24,7 @@ const ALL_SHELL_SCRIPTS = [ "rootfs/etc/s6-overlay/s6-rc.d/svc-obsidian-sync/run", "rootfs/etc/s6-overlay/s6-rc.d/svc-vault-mcp/finish", "rootfs/etc/s6-overlay/s6-rc.d/svc-vault-mcp/run", + "rootfs/etc/s6-overlay/scripts/check-sync-vault", "rootfs/etc/s6-overlay/scripts/init-check-auth", "rootfs/etc/s6-overlay/scripts/init-derive-env", "rootfs/etc/s6-overlay/scripts/init-first-sync", diff --git a/scripts/lint-shell.ts b/scripts/lint-shell.ts index 8fa8e6630..d03e09d24 100644 --- a/scripts/lint-shell.ts +++ b/scripts/lint-shell.ts @@ -29,7 +29,8 @@ import { fileURLToPath } from "node:url" const SHELL_SCRIPT_GLOBS = [ ".claude/hooks/*.sh", ".github/scripts/*.sh", - // The s6 init scripts, plus print-derived-env, which init-derive-env runs + // The s6 init scripts, plus the two they run: print-derived-env + // (init-derive-env) and check-sync-vault (init-setup-vault) "rootfs/etc/s6-overlay/scripts/*", "rootfs/etc/s6-overlay/s6-rc.d/*/run", "rootfs/etc/s6-overlay/s6-rc.d/*/finish", diff --git a/src/__tests__/docker/docker-harness.ts b/src/__tests__/docker/docker-harness.ts index 2ff32bea1..287b49414 100644 --- a/src/__tests__/docker/docker-harness.ts +++ b/src/__tests__/docker/docker-harness.ts @@ -69,11 +69,14 @@ export const docker = async (argv: string[]): Promise => { type ExecFileError = Error & { code: number; stdout: string; stderr: string } -const isExecFileError = (error: unknown): error is ExecFileError => - error instanceof Error && - typeof Reflect.get(error, "code") === "number" && - typeof Reflect.get(error, "stdout") === "string" && - typeof Reflect.get(error, "stderr") === "string" +const isExecFileError = (error: unknown): error is ExecFileError => { + return ( + error instanceof Error && + typeof Reflect.get(error, "code") === "number" && + typeof Reflect.get(error, "stdout") === "string" && + typeof Reflect.get(error, "stderr") === "string" + ) +} /** Run `docker ` and throw with the captured stderr when it fails. */ export const dockerOrThrow = async (argv: string[]): Promise => { @@ -301,6 +304,76 @@ export const seedSyncToken = async ({ ]) } +/** Seed a named config volume with one vault's Sync setup + * (`obsidian-headless/sync//config.json`) before any container + * mounts it — how a scenario fakes a volume set up for another vault. Runs + * `node` from the image under test with the entrypoint bypassed. */ +export const seedSyncSetup = async ({ + image, + volume, + mountPath, + vaultId, + configJson, +}: { + image: string + volume: string + /** Where the volume is mounted for the seeding run, which is also the + * Sync client's config home. */ + mountPath: string + vaultId: string + configJson: string +}): Promise => { + await dockerOrThrow([ + "run", + "--rm", + "--pull=never", + "--entrypoint", + "node", + "-v", + `${volume}:${mountPath}`, + image, + "--no-warnings", + "-e", + ` + const { mkdirSync, writeFileSync } = require("node:fs") + const { join } = require("node:path") + const setupDir = join(process.argv[1], "obsidian-headless", "sync", process.argv[2]) + mkdirSync(setupDir, { recursive: true }) + writeFileSync(join(setupDir, "config.json"), process.argv[3]) + `, + mountPath, + vaultId, + configJson, + ]) +} + +/** Raw bytes of a file on a named volume, read by a throwaway container from + * the image under test — how a scenario reads what a boot that stopped + * itself left on its volume. */ +export const readVolumeFile = ({ + image, + volume, + mountPath, + path, +}: { + image: string + volume: string + mountPath: string + path: string +}): Promise => { + return dockerOrThrow([ + "run", + "--rm", + "--pull=never", + "--entrypoint", + "cat", + "-v", + `${volume}:${mountPath}`, + image, + path, + ]) +} + /** Port the in-container stand-in for api.obsidian.md listens on; the * setup-mode scenarios point OBSIDIAN_API_URL at it. */ export const FAKE_OBSIDIAN_API_PORT = 9797 @@ -506,8 +579,9 @@ export const waitForStopped = async ({ /** Run a command inside the container and report its outcome — non-zero * exits are data here (`test -e` on an absent path), not errors. */ -export const execInContainer = (name: string, argv: string[]): Promise => - docker(["exec", name, ...argv]) +export const execInContainer = (name: string, argv: string[]): Promise => { + return docker(["exec", name, ...argv]) +} /** Raw bytes of a file inside the container — no trimming, so a stray * trailing newline in a `container_environment` value fails an exact diff --git a/src/__tests__/docker/fixtures/ob b/src/__tests__/docker/fixtures/ob index f0e331994..227258a35 100755 --- a/src/__tests__/docker/fixtures/ob +++ b/src/__tests__/docker/fixtures/ob @@ -33,10 +33,16 @@ # that this device has never downloaded (a server # row with no local row), as a filter or excluded # folder leaves behind +# OB_STUB_VAULT_ID — the ID of the account's vault named VAULT_NAME +# (default ci-vault-id) # -# `sync-config --json` prints the vault ID and the stored sync mode. -# `sync-setup` resets the mode to two-way, and `sync-config --mode` rejects -# an unknown value, as the real client does. +# Vault setups follow the real client's layout: `sync-setup` writes +# sync//config.json, and `sync-config --json` prints the first +# setup, by folder name, whose vaultPath is the working folder, or exits 3 +# when there is none. `sync-list-local --json` lists every setup, and +# `sync-list-remote --json` lists one vault, VAULT_NAME. `sync-setup` resets +# the mode to two-way, and `sync-config --mode` rejects an unknown value, as +# the real client does. # # Test-only verbs (not real ob commands) that stand in for what continuous # sync does to the record while the container runs: @@ -48,8 +54,37 @@ CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" CALL_LOG="$CONFIG_HOME/ob-calls.log" printf '%s\n' "$*" >> "$CALL_LOG" -STATE_DIR="$CONFIG_HOME/obsidian-headless/sync/ci-vault-id" +VAULT_ID="${OB_STUB_VAULT_ID:-ci-vault-id}" +SYNC_ROOT="$CONFIG_HOME/obsidian-headless/sync" +STATE_DIR="$SYNC_ROOT/$VAULT_ID" MODE_FILE="$CONFIG_HOME/ob-stub-sync-mode" +export SYNC_ROOT + +# Prints the setup the real client would use for the working folder: the +# first sync//config.json, in name order, whose vaultPath resolves to +# the working folder, with the stored sync mode. Exits 3 when none matches. +print_picked_setup() { + MODE="$1" node --no-warnings -e ' + const { existsSync, readdirSync, readFileSync } = require("node:fs") + const { join, resolve } = require("node:path") + const syncRoot = process.env.SYNC_ROOT + const folders = existsSync(syncRoot) ? readdirSync(syncRoot).sort() : [] + for (const folder of folders) { + const configPath = join(syncRoot, folder, "config.json") + if (!existsSync(configPath)) continue + let config + try { + config = JSON.parse(readFileSync(configPath, "utf8")) + } catch { + continue + } + if (!config || resolve(config.vaultPath) !== process.cwd()) continue + console.log(JSON.stringify({ ...config, syncMode: process.env.MODE })) + process.exit(0) + } + process.exit(3) + ' +} # The real client's table layout for the three tables init-first-sync reads, # exported so the node snippets below can read it from the environment. @@ -110,8 +145,46 @@ case "${1:-}" in fi exit 0 ;; + sync-list-remote) + VAULT_ID="$VAULT_ID" node --no-warnings -e ' + const vault = { id: process.env.VAULT_ID, name: process.env.VAULT_NAME, region: "us" } + console.log(JSON.stringify({ vaults: [vault], shared: [] })) + ' + exit 0 + ;; + sync-list-local) + node --no-warnings -e ' + const { existsSync, readdirSync, readFileSync } = require("node:fs") + const { join } = require("node:path") + const syncRoot = process.env.SYNC_ROOT + const folders = existsSync(syncRoot) ? readdirSync(syncRoot).sort() : [] + const vaults = folders.flatMap((folder) => { + const configPath = join(syncRoot, folder, "config.json") + if (!existsSync(configPath)) return [] + try { + const config = JSON.parse(readFileSync(configPath, "utf8")) + return config ? [{ id: folder, path: config.vaultPath, host: "stub" }] : [] + } catch { + return [] + } + }) + console.log(JSON.stringify({ vaults })) + ' + exit 0 + ;; sync-setup) rm -f "$MODE_FILE" + # The real client writes only config.json here; state.db appears once a + # sync runs. vaultPath is the physical working folder, as path.resolve(".") gives. + mkdir -p "$STATE_DIR" + VAULT_ID="$VAULT_ID" VAULT_FOLDER="$(pwd -P)" node --no-warnings -e ' + const config = { + vaultId: process.env.VAULT_ID, + vaultName: process.env.VAULT_NAME, + vaultPath: process.env.VAULT_FOLDER, + } + require("node:fs").writeFileSync(process.argv[1], JSON.stringify(config, null, 2)) + ' "$STATE_DIR/config.json" if [ "${OB_STUB_UNDOWNLOADED_FILE:-}" = "1" ]; then record_undownloaded_file fi @@ -123,8 +196,8 @@ case "${1:-}" in if [ -f "$MODE_FILE" ]; then mode=$(cat "$MODE_FILE") fi - printf '{"vaultId":"ci-vault-id","syncMode":"%s"}\n' "$mode" - exit 0 + print_picked_setup "$mode" + exit $? fi if [ "${2:-}" = "--mode" ]; then case "${3:-}" in diff --git a/src/__tests__/docker/remote-image-boot.test.ts b/src/__tests__/docker/remote-image-boot.test.ts index b304093d8..bd11b3709 100644 --- a/src/__tests__/docker/remote-image-boot.test.ts +++ b/src/__tests__/docker/remote-image-boot.test.ts @@ -35,8 +35,10 @@ import { pathExistsInContainer, publishedPort, readContainerFile, + readVolumeFile, runContainer, runSyncEngineOracle, + seedSyncSetup, seedSyncState, seedSyncToken, startContainer, @@ -93,30 +95,56 @@ const BASE_ENV = { EMBEDDING_ENABLED: "false", } -/** Every `ob` call that a single boot makes, in order, for BASE_ENV. The stub - * logs each call's arguments as "$*": +/** The `ob` calls check-sync-vault makes before `sync-setup`: the account's + * vault list, then the setup the client uses for the vault folder now. */ +const VAULT_CHECK_CALLS = ["sync-list-remote --json", "sync-config --json"] + +/** The `ob` calls from `sync-setup` to the first sync's settings read, for + * BASE_ENV. The stub logs each call's arguments as "$*": + * - The first `sync-config --json` is init-setup-vault confirming that the + * client uses the setup `sync-setup` just wrote. * - A `sync-config` flag whose variable is unset is skipped. * - The folder and file-type filters always run, so an emptied variable * clears the setting. The empty argument shows as a trailing space. * - `--configs` always runs, because SYNC_CONFIGS has a built-in default. - * - `sync-config --json` is init-first-sync reading the settings back. */ -const EXPECTED_BOOT_SEQUENCE = [ - "login", + * - The last `sync-config --json` is init-first-sync reading the settings back. */ +const SETUP_CALLS = [ "sync-setup --vault ci-vault --device-name ci-device", + "sync-config --json", "sync-config --device-name ci-device", "sync-config --excluded-folders ", "sync-config --file-types ", "sync-config --configs core-plugin-data,community-plugin-data,app", "sync-config --json", +] + +/** Every `ob` call that a first boot makes, in order: no setup exists yet. */ +const FIRST_BOOT_SEQUENCE = [ + "login", + ...VAULT_CHECK_CALLS, + ...SETUP_CALLS, + "sync", + "sync --continuous", +] + +/** A later boot finds this vault's setup, so the check also lists the local + * setups to warn about any for other vaults. */ +const REBOOT_SEQUENCE = [ + "login", + ...VAULT_CHECK_CALLS, + "sync-list-local --json", + ...SETUP_CALLS, "sync", "sync --continuous", ] -const callLogOf = (boots: number): string => - Array.from({ length: boots }, () => EXPECTED_BOOT_SEQUENCE) +/** The call log the stub writes across the given boots, one call per line. */ +const callLogOf = (...boots: ReadonlyArray>): string => { + return boots .flat() .map((call) => `${call}\n`) .join("") +} /** Test-owned copy of what the stub's one-shot sync writes. */ const REMOTE_BOOT_NOTE = `--- @@ -130,8 +158,9 @@ Delivered by the stubbed first sync. Links to [[Sync Log]]. const OWNERSHIP_LINE = "Ownership IDs changed" -const uniqueName = (scenario: string): string => - `remote-boot-${scenario}-${randomBytes(4).toString("hex")}` +const uniqueName = (scenario: string): string => { + return `remote-boot-${scenario}-${randomBytes(4).toString("hex")}` +} const bootedStartedAt = async (name: string): Promise => { const result = await docker(["inspect", "-f", "{{.State.StartedAt}}", name]) @@ -226,12 +255,12 @@ describe("remote image boot — three-volume layout (anonymous /vault, /data, /h expect(appliedIds).toBe("1000:1000\n") }) - it("invokes the Sync client in the documented order: login, sync-setup, sync-config ×5, sync, sync --continuous", async () => { + it("invokes the Sync client in the documented order, from login to sync --continuous", async () => { const callLog = await readContainerFile({ name, path: "/home/obsidian/.config/ob-calls.log", }) - expect(callLog).toBe(callLogOf(1)) + expect(callLog).toBe(callLogOf(FIRST_BOOT_SEQUENCE)) }) it("records the two synced notes in the sync state under /home/obsidian/.config", async () => { @@ -279,8 +308,15 @@ describe("remote image boot — three-volume layout (anonymous /vault, /data, /h const searchResponse: unknown = JSON.parse(textContent(result)) // `modified` is the boot-time mtime and `score` is an RRF float — every // other field of the response is fixed by the fixture, so pin those. - const isSearchResponse = (value: unknown): value is { results: Record[] } => - typeof value === "object" && value !== null && Array.isArray(Reflect.get(value, "results")) + const isSearchResponse = ( + value: unknown, + ): value is { results: Record[] } => { + return ( + typeof value === "object" && + value !== null && + Array.isArray(Reflect.get(value, "results")) + ) + } if (!isSearchResponse(searchResponse)) { throw new Error(`unexpected vault_search response: ${textContent(result)}`) @@ -479,7 +515,7 @@ describe("remote image boot — single-volume layout (STORAGE_ROOT=/persist)", ( it("runs the full Sync sequence again, appending to the first boot's log", async () => { expect(await readContainerFile({ name, path: "/persist/config/ob-calls.log" })).toBe( - callLogOf(2), + callLogOf(FIRST_BOOT_SEQUENCE, REBOOT_SEQUENCE), ) }) @@ -580,21 +616,15 @@ describe("remote image boot — first sync keeps failing (OB_STUB_SYNC_FAIL=1)", path: "/home/obsidian/.config/ob-calls.log", }) expect(callLog).toBe( - [ + callLogOf([ "login", - "sync-setup --vault ci-vault --device-name ci-device", - "sync-config --device-name ci-device", - "sync-config --excluded-folders ", - "sync-config --file-types ", - "sync-config --configs core-plugin-data,community-plugin-data,app", - "sync-config --json", + ...VAULT_CHECK_CALLS, + ...SETUP_CALLS, "sync", "sync", "sync", "sync --continuous", - ] - .map((call) => `${call}\n`) - .join(""), + ]), ) }) @@ -957,6 +987,60 @@ describe("remote image boot — safety checks in the init chain stop the contain expect(logs).not.toContain("First sync (attempt") }) + it("refuses to sync another vault's Sync setup before ob sync-setup writes anything", async () => { + // A config volume left set up for another vault: the Sync client would + // sync that vault into /vault, whatever VAULT_NAME says. The stub's + // account lists only VAULT_NAME's vault, so the other one reads as gone. + const configVolume = `${uniqueName("other-vault-config")}-volume` + const configHome = "/home/obsidian/.config" + const otherVaultSetup = JSON.stringify({ + vaultId: "other-vault-id", + vaultName: "Other Vault", + vaultPath: "/vault", + }) + await seedSyncSetup({ + image: IMAGE, + volume: configVolume, + mountPath: configHome, + vaultId: "other-vault-id", + configJson: otherVaultSetup, + }) + const logs = await logsAfterGuardStops({ + scenario: "other-vault-setup", + env: BASE_ENV, + volumes: [`${configVolume}:${configHome}`], + }) + expect(logs).toContain( + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + ) + expect(logs).toContain( + "[obsidian-sync] VAULT_NAME is 'ci-vault' (ci-vault-id), but its Sync setup is for 'Other Vault' (other-vault-id), which is no longer in your account.", + ) + expect(logs).toContain( + "s6-rc: warning: unable to start service init-setup-vault: command exited 1", + ) + expect(logs).not.toContain("First sync (attempt") + + // The volume shows what ran: the check's reads only, no sync-setup, and + // the other vault's setup unchanged. + const callLog = await readVolumeFile({ + image: IMAGE, + volume: configVolume, + mountPath: configHome, + path: `${configHome}/ob-calls.log`, + }) + const setupAfter = await readVolumeFile({ + image: IMAGE, + volume: configVolume, + mountPath: configHome, + path: `${configHome}/obsidian-headless/sync/other-vault-id/config.json`, + }) + expect({ callLog, setupAfter }).toEqual({ + callLog: callLogOf(["login", ...VAULT_CHECK_CALLS]), + setupAfter: otherVaultSetup, + }) + }) + it("refuses to sync an empty vault when the device's sync state records files", async () => { // A kept config volume (sync state recording files) with a wiped vault // volume would have the sync engine push every recorded file as a @@ -1182,12 +1266,13 @@ const setupModeEnv = (): Record => { } } -const postSetupForm = (port: number, fields: Record): Promise => - fetch(`http://127.0.0.1:${port}/setup`, { +const postSetupForm = (port: number, fields: Record): Promise => { + return fetch(`http://127.0.0.1:${port}/setup`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(fields), }) +} describe("remote image boot — setup mode (no Sync token anywhere)", () => { const name = uniqueName("setup-mode") @@ -1335,7 +1420,7 @@ describe("remote image boot — setup mode (no Sync token anywhere)", () => { expect({ token, mode: mode.stdout.trim(), callLog }).toEqual({ token: FAKE_OBSIDIAN_ACCOUNT.token, mode: "600", - callLog: callLogOf(1), + callLog: callLogOf(FIRST_BOOT_SEQUENCE), }) expect(logs).toContain("[obsidian-sync] Auth token found on the volume.") }) @@ -1479,7 +1564,7 @@ describe("remote image boot — env var token wins over a token file on the volu const logs = await containerLogs(name) expect({ setupModePublished, callLog }).toEqual({ setupModePublished: false, - callLog: callLogOf(1), + callLog: callLogOf(FIRST_BOOT_SEQUENCE), }) expect(logs).toContain("[obsidian-sync] Auth token present.") }) diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts new file mode 100644 index 000000000..dd7adca13 --- /dev/null +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -0,0 +1,704 @@ +import { spawnSync } from "node:child_process" +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +import { describe, expect, it, onTestFinished } from "vitest" + +import { runsAsRoot } from "../../__tests__/runs-as-root.js" + +/** + * Behavioral spec for the remote image's vault check + * (rootfs/etc/s6-overlay/scripts/check-sync-vault), which stops a boot that + * would sync a vault other than VAULT_NAME. The real script runs under `sh` + * with stub `ob` and `s6-setuidgid` executables on PATH. The stub replays + * the pinned engine's outputs below; the config tree the script validates + * itself is real files in a temp directory. + */ + +const SCRIPT_PATH = resolve(__dirname, "../../../rootfs/etc/s6-overlay/scripts/check-sync-vault") + +const GUIDE_URL = + "https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup" + +// Engine vectors: captured from obsidian-headless 0.0.14 inside the :remote +// image with hand-written config trees and no token. +// `ob sync-config --json` with no setup for the working folder (none at all, +// or only for another path) prints nothing on stdout and exits 3. +const NO_SETUP_EXIT = 3 +// A setup without vaultPath that sorts first makes `ob sync-config` throw. +const UNREADABLE_SETUP_EXIT = 1 + +/** `ob sync-config --json` output for a picked setup, in the engine's field order. */ +const pickedConfig = (vaultId: string, vaultName: string): string => { + return JSON.stringify( + { + vaultId, + vaultName, + vaultPath: "/vault", + syncMode: "bidirectional", + deviceName: "vault-cortex", + configDir: ".obsidian", + fileTypes: ["image", "audio", "pdf", "video"], + configs: [], + excludedFolders: [], + }, + null, + 2, + ) +} + +/** + * Stands for the run's vault folder in a listing. runCheck swaps in the + * folder's real path, the form ob sync-setup stores, since the temp folder + * sits under a symlink on macOS. + */ +const RUN_VAULT_FOLDER = "" + +/** `ob sync-list-local --json` output: one row per setup, raw stored path. */ +const localSetups = (vaultIds: ReadonlyArray): string => { + return JSON.stringify({ + vaults: vaultIds.map((id) => ({ id, path: RUN_VAULT_FOLDER, host: "sync-1.obsidian.md" })), + }) +} + +type RemoteVault = { id: string; name: string } + +/** `ob sync-list-remote --json` output for the account's owned and shared vaults. */ +const remoteVaults = ( + vaults: ReadonlyArray, + sharedVaults: ReadonlyArray = [], +): string => { + return JSON.stringify({ + vaults: vaults.map((vault) => ({ ...vault, region: "us" })), + shared: sharedVaults.map((vault) => ({ ...vault, region: "us" })), + }) +} + +/** Stub `ob`: logs each call and the folder it ran in, and replays the output + * and exit code the test sets per verb. */ +const OB_STUB = `#!/bin/sh +echo "$*" >> "$OB_CALL_LOG" +pwd >> "$OB_CWD_LOG" +case "$1" in + sync-list-remote) printf '%s' "$OB_REMOTE_JSON"; exit "$OB_REMOTE_EXIT" ;; + sync-config) printf '%s' "$OB_CONFIG_JSON"; printf '%s' "$OB_CONFIG_STDERR" >&2; exit "$OB_CONFIG_EXIT" ;; + sync-list-local) printf '%s' "$OB_LOCAL_JSON"; exit "$OB_LOCAL_EXIT" ;; + *) exit 0 ;; +esac +` + +/** Stub `s6-setuidgid`: drops the user argument and runs the command. */ +const SETUIDGID_STUB = `#!/bin/sh +shift +exec "$@" +` + +type CheckOptions = { + vaultName: string + remoteJson: string + remoteExit?: number + configJson?: string + configExit: number + /** What `ob sync-config` prints on stderr, as the engine prints its reason there. */ + configStderr?: string + localJson?: string + localExit?: number + /** When true, VAULT_PATH names a folder that does not exist. */ + vaultPathMissing?: boolean + /** When true, the sync folder exists but can't be listed (mode 000). */ + syncRootUnreadable?: boolean + /** When true, the Sync client has not created its sync folder yet. */ + syncRootMissing?: boolean + /** config.json files to create under the sync folder, keyed by folder name. */ + setupFiles?: Readonly> + /** Folders to create under the sync folder with no config.json in them. */ + foldersWithoutSetup?: ReadonlyArray + storageRoot?: string +} + +type CheckRun = { + status: number | null + stdout: string + stderr: string + obCalls: string[] + /** The working folder of each `ob` call, in call order. */ + obCwds: string[] + vaultPath: string + syncRoot: string + /** Every file under the sync folder with its content, read after the run. */ + setupTreeAfter: Record +} + +const readSetupTree = (syncRoot: string): Record => { + const tree: Record = {} + for (const folderName of readdirSync(syncRoot)) { + for (const fileName of readdirSync(join(syncRoot, folderName))) { + tree[`${folderName}/${fileName}`] = readFileSync(join(syncRoot, folderName, fileName), "utf8") + } + } + return tree +} + +const runCheck = (options: CheckOptions): CheckRun => { + const tempDir = mkdtempSync(join(tmpdir(), "check-sync-vault-")) + const stubBinDir = join(tempDir, "bin") + const vaultPath = join(tempDir, "vault") + const homeDir = join(tempDir, "home") + const syncRoot = join(homeDir, ".config", "obsidian-headless", "sync") + onTestFinished(() => { + // A mode-000 sync folder blocks the removal, so its mode comes back first. + if (options.syncRootUnreadable) chmodSync(syncRoot, 0o755) + rmSync(tempDir, { recursive: true, force: true }) + }) + mkdirSync(stubBinDir) + if (!options.vaultPathMissing) mkdirSync(vaultPath) + if (!options.syncRootMissing) mkdirSync(syncRoot, { recursive: true }) + writeFileSync(join(stubBinDir, "ob"), OB_STUB, { mode: 0o755 }) + writeFileSync(join(stubBinDir, "s6-setuidgid"), SETUIDGID_STUB, { mode: 0o755 }) + + for (const [folderName, content] of Object.entries(options.setupFiles ?? {})) { + mkdirSync(join(syncRoot, folderName)) + writeFileSync(join(syncRoot, folderName, "config.json"), content) + } + for (const folderName of options.foldersWithoutSetup ?? []) { + mkdirSync(join(syncRoot, folderName)) + } + + const callLogPath = join(tempDir, "ob-calls.log") + const cwdLogPath = join(tempDir, "ob-cwds.log") + writeFileSync(callLogPath, "") + writeFileSync(cwdLogPath, "") + + if (options.syncRootUnreadable) chmodSync(syncRoot, 0o000) + const storedVaultPath = options.vaultPathMissing ? vaultPath : realpathSync(vaultPath) + const localJson = (options.localJson ?? localSetups([])).replaceAll( + RUN_VAULT_FOLDER, + storedVaultPath, + ) + + const result = spawnSync("sh", [SCRIPT_PATH], { + encoding: "utf8", + env: { + PATH: `${stubBinDir}:${process.env.PATH ?? ""}`, + HOME: homeDir, + VAULT_PATH: vaultPath, + VAULT_NAME: options.vaultName, + OB_CALL_LOG: callLogPath, + OB_REMOTE_JSON: options.remoteJson, + OB_REMOTE_EXIT: String(options.remoteExit ?? 0), + OB_CONFIG_JSON: options.configJson ?? "", + OB_CONFIG_EXIT: String(options.configExit), + OB_CONFIG_STDERR: options.configStderr ?? "", + OB_LOCAL_JSON: localJson, + OB_LOCAL_EXIT: String(options.localExit ?? 0), + OB_CWD_LOG: cwdLogPath, + ...(options.storageRoot ? { STORAGE_ROOT: options.storageRoot } : {}), + }, + }) + + // Readable again, so setupTreeAfter can show the run wrote nothing. + if (options.syncRootUnreadable) chmodSync(syncRoot, 0o755) + + const readLogLines = (logPath: string): string[] => { + return readFileSync(logPath, "utf8") + .split("\n") + .filter((loggedLine) => loggedLine !== "") + } + + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + obCalls: readLogLines(callLogPath), + obCwds: readLogLines(cwdLogPath), + vaultPath, + syncRoot, + setupTreeAfter: options.syncRootMissing ? {} : readSetupTree(syncRoot), + } +} + +const VAULT_A = { id: "aaa111", name: "Personal" } +const VAULT_B = { id: "bbb222", name: "Work" } +const setupFile = (vault: { id: string; name: string }): string => { + return JSON.stringify({ vaultId: vault.id, vaultName: vault.name, vaultPath: "/vault" }) +} + +describe("check-sync-vault", () => { + it("continues on a first boot, when no setup exists for the vault folder", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configExit: NO_SETUP_EXIT, + configStderr: "No sync configuration found for /vault\nRun 'ob sync-setup' first.\n", + }) + + expect(run.status).toBe(0) + // stdout carries only the requested vault's ID, for init-setup-vault. + expect(run.stdout).toBe("bbb222\n") + // The engine's own first-boot message stays out of the log. + expect(run.stderr).toBe( + "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet.\n", + ) + expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) + }) + + it("continues on a first boot, before the Sync client has created its sync folder", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configExit: NO_SETUP_EXIT, + syncRootMissing: true, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("bbb222\n") + expect(run.stderr).toBe( + "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet.\n", + ) + }) + + it("continues on a reboot, when the engine already syncs the requested vault", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_B.id, VAULT_B.name), + configExit: 0, + localJson: localSetups([VAULT_B.id]), + setupFiles: { [VAULT_B.id]: setupFile(VAULT_B) }, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("bbb222\n") + expect(run.stderr).toBe("[obsidian-sync] Vault check: continue — set up for 'Work' (bbb222).\n") + }) + + it("matches VAULT_NAME as a vault ID before matching it as a name", () => { + const vaultNamedLikeAnId = { id: "ccc333", name: "bbb222" } + const run = runCheck({ + vaultName: "bbb222", + remoteJson: remoteVaults([VAULT_B, vaultNamedLikeAnId]), + configJson: pickedConfig(VAULT_B.id, VAULT_B.name), + configExit: 0, + localJson: localSetups([VAULT_B.id]), + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("bbb222\n") + expect(run.stderr).toBe( + "[obsidian-sync] Vault check: continue — set up for 'bbb222' (bbb222).\n", + ) + }) + + it("refuses a second vault on a volume set up for another vault still in the account", () => { + const setupFiles = { [VAULT_A.id]: setupFile(VAULT_A) } + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + setupFiles, + }) + + expect(run.status).toBe(1) + expect(run.stdout).toBe("") + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Personal' (aaa111).", + "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:", + "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart.", + "[obsidian-sync] - To sync 'Work' as well, run it as a separate install with its own INSTANCE_NAME.", + "[obsidian-sync] - To switch this install to 'Work' on purpose, remove this install's vault_data and obsidian_config volumes together.", + "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), + ) + // The check only reads: the existing setup is byte-for-byte unchanged. + expect(run.setupTreeAfter).toEqual({ [`${VAULT_A.id}/config.json`]: setupFiles[VAULT_A.id] }) + expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) + }) + + it("names a setup whose vault is gone from the account and offers removing only its folder", () => { + const deletedVault = { id: "ddd444", name: "Old Work" } + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configJson: pickedConfig(deletedVault.id, deletedVault.name), + configExit: 0, + setupFiles: { [deletedVault.id]: setupFile(deletedVault) }, + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Old Work' (ddd444), which is no longer in your account.", + "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:", + "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart.", + "[obsidian-sync] - To sync 'Work' as well, run it as a separate install with its own INSTANCE_NAME.", + "[obsidian-sync] - If you re-created this same vault in Obsidian and the notes here are its notes,", + `[obsidian-sync] remove only the old setup folder: ${run.syncRoot}/ddd444`, + "[obsidian-sync] - To switch this install to 'Work' on purpose, remove this install's vault_data and obsidian_config volumes together.", + "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), + ) + }) + + it("words the fixes for the single-volume hosted layout under STORAGE_ROOT", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + storageRoot: "/persist", + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Personal' (aaa111).", + "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:", + "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart.", + "[obsidian-sync] - To sync 'Work' as well, run it as a separate service.", + "[obsidian-sync] - To switch this install to 'Work' on purpose, remove /persist/vault and /persist/config together.", + "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), + ) + }) + + it("words the gone-vault fixes for the single-volume hosted layout under STORAGE_ROOT", () => { + const deletedVault = { id: "ddd444", name: "Old Work" } + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configJson: pickedConfig(deletedVault.id, deletedVault.name), + configExit: 0, + setupFiles: { [deletedVault.id]: setupFile(deletedVault) }, + storageRoot: "/persist", + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Old Work' (ddd444), which is no longer in your account.", + "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:", + "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart.", + "[obsidian-sync] - To sync 'Work' as well, run it as a separate service.", + "[obsidian-sync] - If you re-created this same vault in Obsidian and the notes here are its notes,", + `[obsidian-sync] remove only the old setup folder: ${run.syncRoot}/ddd444`, + "[obsidian-sync] - To switch this install to 'Work' on purpose, remove /persist/vault and /persist/config together.", + "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), + ) + }) + + it("names a picked vault that is shared with the account by its current name", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B], [VAULT_A]), + configJson: pickedConfig(VAULT_A.id, "Old name"), + configExit: 0, + }) + + expect(run.status).toBe(1) + expect(run.stderr.split("\n")[1]).toBe( + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Personal' (aaa111).", + ) + }) + + it("matches VAULT_NAME against vaults shared with the account", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A], [VAULT_B]), + configExit: NO_SETUP_EXIT, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("bbb222\n") + }) + + it("runs every ob call inside the vault folder", () => { + const run = runCheck({ + vaultName: "Personal", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + localJson: localSetups([VAULT_A.id]), + }) + + expect(run.status).toBe(0) + expect(run.obCalls).toEqual([ + "sync-list-remote --json", + "sync-config --json", + "sync-list-local --json", + ]) + expect(run.obCwds).toEqual([run.vaultPath, run.vaultPath, run.vaultPath]) + }) + + it("stops before any ob call when VAULT_PATH can't be entered", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configExit: NO_SETUP_EXIT, + vaultPathMissing: true, + }) + + expect(run.status).toBe(1) + // The shell prints its own cd error first, worded differently per shell. + expect(run.stderr.trimEnd().split("\n").at(-1)).toBe( + `[obsidian-sync] ERROR: Failed to change directory to VAULT_PATH='${run.vaultPath}'.`, + ) + expect(run.obCalls).toEqual([]) + }) + + it("continues with a warning when a dormant setup for another vault sits beside the picked one", () => { + const run = runCheck({ + vaultName: "Personal", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + localJson: localSetups([VAULT_A.id, VAULT_B.id]), + }) + + expect(run.status).toBe(0) + expect(run.stderr).toBe( + "[obsidian-sync] WARNING: This install also holds Sync setups for other vaults: bbb222\n" + + `[obsidian-sync] They are not used now. Remove each one's folder under ${run.syncRoot} so it can't be picked later.\n` + + "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).\n", + ) + expect(run.stdout).toBe("aaa111\n") + }) + + it("leaves out another vault's setup stored for a different folder, which ob never picks here", () => { + const run = runCheck({ + vaultName: "Personal", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + localJson: JSON.stringify({ + vaults: [ + { id: VAULT_A.id, path: RUN_VAULT_FOLDER, host: "sync-1.obsidian.md" }, + { id: VAULT_B.id, path: "/old-vault", host: "sync-1.obsidian.md" }, + ], + }), + }) + + expect(run.status).toBe(0) + expect(run.stderr).toBe( + "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).\n", + ) + expect(run.stdout).toBe("aaa111\n") + }) + + it.each([ + { label: "the listing fails", localJson: "", localExit: 1 }, + { label: "the listing is not JSON", localJson: "Fetching vaults...", localExit: 0 }, + ])("continues with a listing warning when $label", ({ localJson, localExit }) => { + const run = runCheck({ + vaultName: "Personal", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + localJson, + localExit, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("aaa111\n") + expect(run.stderr).toBe( + "[obsidian-sync] WARNING: Could not list this install's Sync setups, so a setup for another vault would not be reported here.\n" + + "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).\n", + ) + }) + + it("stops when the account's vault list can't be read", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: "", + remoteExit: 1, + configExit: NO_SETUP_EXIT, + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + "[obsidian-sync] ERROR: Could not list the vaults in your Obsidian account.\n" + + "[obsidian-sync] Check OBSIDIAN_AUTH_TOKEN and the network, then restart.\n", + ) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + }) + + it("stops when the account's vault list is not JSON", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: "Fetching vaults...", + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + setupFiles: { [VAULT_A.id]: setupFile(VAULT_A) }, + }) + + expect(run.status).toBe(1) + expect(run.stdout).toBe("") + // node's own parse error comes first. + expect(run.stderr.trimEnd().split("\n").slice(-2)).toEqual([ + "[obsidian-sync] ERROR: Could not read the list of vaults in your Obsidian account.", + "[obsidian-sync] Refusing to sync without knowing which vault VAULT_NAME names.", + ]) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + }) + + it.each([ + { label: "no vault", vaults: [VAULT_A] }, + { label: "several vaults", vaults: [{ ...VAULT_A, name: "Work" }, VAULT_B] }, + ])("leaves the error to ob sync-setup when VAULT_NAME matches $label", ({ vaults }) => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults(vaults), + configExit: NO_SETUP_EXIT, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("") + expect(run.stderr).toBe( + "[obsidian-sync] Vault check: 'Work' does not name exactly one vault — ob sync-setup reports it.\n", + ) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + }) + + it.each([ + { label: "an unparseable file", content: "{not json" }, + { label: "null", content: "null" }, + { label: "false", content: "false" }, + { label: "an empty string", content: '""' }, + { label: "an object without vaultId", content: '{"vaultPath":"/vault"}' }, + { label: "an empty vaultId", content: '{"vaultId":"","vaultPath":"/vault"}' }, + { label: "an object without vaultPath", content: '{"vaultId":"eee555"}' }, + ])("stops on a setup file holding $label, before asking the engine", ({ content }) => { + const setupFiles = { [VAULT_B.id]: setupFile(VAULT_B), eee555: content } + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configJson: pickedConfig(VAULT_B.id, VAULT_B.name), + configExit: 0, + setupFiles, + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: A Sync setup file here is not a valid setup, and Obsidian Sync would ignore it:", + `[obsidian-sync] ${run.syncRoot}/eee555/config.json`, + "[obsidian-sync] Refusing to sync, because it may belong to another vault whose notes are in this folder.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), + ) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + expect(run.setupTreeAfter).toEqual({ + [`${VAULT_B.id}/config.json`]: setupFiles[VAULT_B.id], + "eee555/config.json": content, + }) + }) + + it("ignores a setup folder without config.json, as the engine does", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configExit: NO_SETUP_EXIT, + foldersWithoutSetup: ["leftover"], + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("bbb222\n") + expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) + }) + + it.skipIf(runsAsRoot)( + "stops when the sync folder can't be listed, before asking the engine", + () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configExit: NO_SETUP_EXIT, + syncRootUnreadable: true, + }) + + expect(run.status).toBe(1) + // node's own error for the failed listing comes first. + expect(run.stderr.trimEnd().split("\n").slice(-2)).toEqual([ + `[obsidian-sync] ERROR: Could not read the Sync setups under ${run.syncRoot}.`, + "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for.", + ]) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + }, + ) + + it("stops with the engine's own reason when it can't read its own setup", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configExit: UNREADABLE_SETUP_EXIT, + configStderr: + 'Config update failed: TypeError: The "path" argument must be of type string.\n', + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + 'Config update failed: TypeError: The "path" argument must be of type string.\n' + + "[obsidian-sync] ERROR: The Sync client could not read its own setup (ob sync-config exited 1).\n" + + "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for.\n" + + `[obsidian-sync] See: ${GUIDE_URL}\n`, + ) + // The second, read-only call is the one whose message reaches the log. + expect(run.obCalls).toEqual([ + "sync-list-remote --json", + "sync-config --json", + "sync-config --json", + ]) + }) + + it.each([ + { label: "output that is not JSON", configJson: "Fetching vaults..." }, + { label: "JSON without a vault ID", configJson: '{"vaultName":"Work"}' }, + ])("stops when the engine's setup answer is $label", ({ configJson }) => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configJson, + configExit: 0, + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + "[obsidian-sync] ERROR: The Sync client could not read its own setup (ob sync-config printed no vault ID).\n" + + "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for.\n" + + `[obsidian-sync] See: ${GUIDE_URL}\n`, + ) + }) + + it("stops when VAULT_NAME is not set", () => { + const run = runCheck({ vaultName: "", remoteJson: remoteVaults([]), configExit: NO_SETUP_EXIT }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe("[obsidian-sync] ERROR: VAULT_NAME is not set.\n") + expect(run.obCalls).toEqual([]) + }) +}) diff --git a/src/vault-mcp/__tests__/init-setup-vault.test.ts b/src/vault-mcp/__tests__/init-setup-vault.test.ts index 12b5f8989..1d19f101a 100644 --- a/src/vault-mcp/__tests__/init-setup-vault.test.ts +++ b/src/vault-mcp/__tests__/init-setup-vault.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process" -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs" +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join, resolve } from "node:path" @@ -12,18 +12,27 @@ import { describe, expect, it, onTestFinished } from "vitest" * name reaches the first-sync stage unconfigured — so every branch is * exercised here by running the real script under `sh` with stub `ob` and * `s6-setuidgid` executables on PATH. + * + * The script runs check-sync-vault from its own folder, so each run copies the + * real script into a temp folder beside a stub check. The check's own + * branches are covered in check-sync-vault.test.ts; these tests cover when + * it runs and what happens on each of its answers. */ const SCRIPT_PATH = resolve(__dirname, "../../../rootfs/etc/s6-overlay/scripts/init-setup-vault") /** Stub `ob`: logs each invocation; `sync-setup` exits with the code the * test configures, `sync-config` fails when its flag matches - * `OB_SYNC_CONFIG_FAIL_FLAG`, every other invocation succeeds. */ + * `OB_SYNC_CONFIG_FAIL_FLAG`, `sync-config --json` prints + * `OB_PICKED_CONFIG`, and every other invocation succeeds. */ const OB_STUB = `#!/bin/sh echo "$*" >> "$OB_CALL_LOG" case "$1" in sync-setup) exit "$OB_SYNC_SETUP_EXIT" ;; - sync-config) [ "$2" = "$OB_SYNC_CONFIG_FAIL_FLAG" ] && exit 1; exit 0 ;; + sync-config) + [ "$2" = "$OB_SYNC_CONFIG_FAIL_FLAG" ] && exit 1 + [ "$2" = --json ] && printf '%s' "$OB_PICKED_CONFIG" + exit 0 ;; *) exit 0 ;; esac ` @@ -34,6 +43,22 @@ shift exec "$@" ` +/** Stub check-sync-vault: logs its run in the same call log, then refuses or + * prints the vault ID the test configures. */ +const VAULT_CHECK_STUB = `#!/bin/sh +echo "check-sync-vault" >> "$OB_CALL_LOG" +if [ "$VAULT_CHECK_REFUSES" = 1 ]; then + echo "[obsidian-sync] ERROR: stub vault check refused." >&2 + exit 1 +fi +printf '%s\\n' "$VAULT_CHECK_VAULT_ID" +` + +const REQUESTED_VAULT_ID = "aaa111" + +const VAULT_CHECK_CALL = "check-sync-vault" +/** The check after ob sync-setup that the Sync client uses the requested vault. */ +const PICKED_VAULT_CALL = "sync-config --json" const DEFAULT_SYNC_CONFIGS_CALL = "sync-config --configs core-plugin-data,community-plugin-data,app" /** The folder and attachment filters are applied on every boot, empty meaning @@ -42,12 +67,23 @@ const DEFAULT_SYNC_CONFIGS_CALL = "sync-config --configs core-plugin-data,commun const CLEAR_EXCLUDED_FOLDERS_CALL = "sync-config --excluded-folders " const CLEAR_FILE_TYPES_CALL = "sync-config --file-types " +const PICKED_VAULT_ERROR = + "[obsidian-sync] ERROR: After ob sync-setup, the Sync client does not use the setup for 'MyVault' in this folder.\n" + + "[obsidian-sync] Refusing to sync a vault other than VAULT_NAME.\n" + + "[obsidian-sync] See: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup\n" + +const UNREADABLE_SETUP_ERROR = + "[obsidian-sync] ERROR: After ob sync-setup, the Sync client could not read its own setup.\n" + + "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for.\n" + + "[obsidian-sync] See: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup\n" + type SetupRun = { status: number | null stdout: string stderr: string - /** Every `ob` invocation, as the script issued it (subcommand + args). */ - obCalls: string[] + /** Every `ob` invocation (subcommand + args) and vault check run, in the + * order the script issued them. */ + calls: string[] vaultPath: string } @@ -66,26 +102,51 @@ type SetupRunOptions = { syncSetupFails?: boolean /** The `ob sync-config` flag whose invocation fails (e.g. `--file-types`). */ syncConfigFailsFor?: string + /** When true, the vault check refuses. */ + vaultCheckRefuses?: boolean + /** The vault ID the vault check prints (default: the requested vault's; + * empty when VAULT_NAME names no single vault). */ + vaultCheckVaultId?: string + /** The vault ID `ob sync-config --json` reports after `ob sync-setup` + * (default: the requested vault's). */ + pickedVaultId?: string + /** Raw `ob sync-config --json` output, replacing the setup for pickedVaultId. */ + pickedConfigOutput?: string + /** When true, the script runs by a path relative to its own folder. */ + relativeScriptPath?: boolean } const runSetupScript = (options: SetupRunOptions): SetupRun => { const tempDir = mkdtempSync(join(tmpdir(), "init-setup-vault-")) onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })) const stubBinDir = join(tempDir, "bin") + const scriptsDir = join(tempDir, "scripts") const vaultPath = join(tempDir, "vault") mkdirSync(stubBinDir) + mkdirSync(scriptsDir) mkdirSync(vaultPath) writeFileSync(join(stubBinDir, "ob"), OB_STUB, { mode: 0o755 }) writeFileSync(join(stubBinDir, "s6-setuidgid"), SETUIDGID_STUB, { mode: 0o755, }) + copyFileSync(SCRIPT_PATH, join(scriptsDir, "init-setup-vault")) + writeFileSync(join(scriptsDir, "check-sync-vault"), VAULT_CHECK_STUB) - const callLogPath = join(tempDir, "ob-calls.log") + const callLogPath = join(tempDir, "calls.log") writeFileSync(callLogPath, "") - const result = spawnSync("sh", [SCRIPT_PATH], { + const pickedConfig = JSON.stringify({ + vaultId: options.pickedVaultId ?? REQUESTED_VAULT_ID, + syncMode: "bidirectional", + }) + const scriptArgument = options.relativeScriptPath + ? "./init-setup-vault" + : join(scriptsDir, "init-setup-vault") + + const result = spawnSync("sh", [scriptArgument], { encoding: "utf8", + cwd: scriptsDir, env: { PATH: `${stubBinDir}:${process.env.PATH ?? ""}`, HOME: join(tempDir, "home"), @@ -93,6 +154,9 @@ const runSetupScript = (options: SetupRunOptions): SetupRun => { OB_CALL_LOG: callLogPath, OB_SYNC_SETUP_EXIT: String(options.syncSetupFails ? 1 : 0), OB_SYNC_CONFIG_FAIL_FLAG: options.syncConfigFailsFor ?? "", + OB_PICKED_CONFIG: options.pickedConfigOutput ?? pickedConfig, + VAULT_CHECK_REFUSES: options.vaultCheckRefuses ? "1" : "0", + VAULT_CHECK_VAULT_ID: options.vaultCheckVaultId ?? REQUESTED_VAULT_ID, ...(options.setupMode ? { SETUP_MODE: "1" } : {}), ...(options.vaultName === undefined ? {} : { VAULT_NAME: options.vaultName }), ...(options.vaultPassword === undefined ? {} : { VAULT_PASSWORD: options.vaultPassword }), @@ -107,14 +171,14 @@ const runSetupScript = (options: SetupRunOptions): SetupRun => { }, }) - const obCalls = readFileSync(callLogPath, "utf8") + const calls = readFileSync(callLogPath, "utf8") .split("\n") .filter((loggedCall) => loggedCall !== "") return { status: result.status, stdout: result.stdout, stderr: result.stderr, - obCalls, + calls, vaultPath, } } @@ -128,7 +192,7 @@ describe("init-setup-vault script", () => { expect(run.status).toBe(0) expect(run.stdout).toBe("[obsidian-sync] Setup mode — skipping vault setup.\n") expect(run.stderr).toBe("") - expect(run.obCalls).toEqual([]) + expect(run.calls).toEqual([]) }) it("refuses to start when VAULT_NAME is unset", () => { @@ -140,32 +204,93 @@ describe("init-setup-vault script", () => { "[obsidian-sync] ERROR: VAULT_NAME is not set.\n" + "[obsidian-sync] Set VAULT_NAME to your exact Obsidian vault name (case-sensitive) in .env (or -e VAULT_NAME=...) and restart.\n", ) - expect(run.obCalls).toEqual([]) + expect(run.calls).toEqual([]) }) it("refuses to start when VAULT_NAME is empty", () => { const run = runSetupScript({ vaultName: "" }) expect(run.status).toBe(1) - expect(run.obCalls).toEqual([]) + expect(run.calls).toEqual([]) }) it("configures sync for the named vault", () => { const run = runSetupScript({ vaultName: "MyVault" }) expect(run.status).toBe(0) + // The vault ID the check prints is captured, so it never reaches the log. expect(run.stdout).toBe( `[obsidian-sync] Configuring sync for vault: 'MyVault' → ${run.vaultPath}\n`, ) expect(run.stderr).toBe("") - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL, ]) }) + it("stops before ob sync-setup when the vault check refuses", () => { + const run = runSetupScript({ vaultName: "MyVault", vaultCheckRefuses: true }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe("[obsidian-sync] ERROR: stub vault check refused.\n") + expect(run.calls).toEqual([VAULT_CHECK_CALL]) + }) + + it("stops before any sync-config write when the Sync client uses another vault after ob sync-setup", () => { + const run = runSetupScript({ vaultName: "MyVault", pickedVaultId: "bbb222" }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe(PICKED_VAULT_ERROR) + expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault", PICKED_VAULT_CALL]) + }) + + it.each<{ label: string; options: SetupRunOptions }>([ + { + label: "ob sync-config --json fails", + options: { vaultName: "MyVault", syncConfigFailsFor: "--json" }, + }, + { + label: "ob sync-config --json prints no JSON", + options: { vaultName: "MyVault", pickedConfigOutput: "Fetching vaults..." }, + }, + { + label: "ob sync-config --json prints no vault ID", + options: { vaultName: "MyVault", pickedConfigOutput: '{"syncMode":"bidirectional"}' }, + }, + ])("stops before any sync-config write when $label after ob sync-setup", ({ options }) => { + const run = runSetupScript(options) + + expect(run.status).toBe(1) + expect(run.stderr).toBe(UNREADABLE_SETUP_ERROR) + expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault", PICKED_VAULT_CALL]) + }) + + it("finds the vault check beside itself when started by a relative path", () => { + const run = runSetupScript({ vaultName: "MyVault", relativeScriptPath: true }) + + expect(run.status).toBe(0) + expect(run.stderr).toBe("") + expect(run.calls.slice(0, 2)).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault"]) + }) + + it("stops after ob sync-setup when the vault check named no vault", () => { + // ob sync-setup fails when VAULT_NAME names no single vault; if it ever + // succeeds anyway, the setup it wrote can't be checked, so the boot stops. + const run = runSetupScript({ vaultName: "MyVault", vaultCheckVaultId: "" }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + "[obsidian-sync] ERROR: VAULT_NAME 'MyVault' named no single vault when the vault check ran, so this boot can't confirm which setup ob sync-setup used.\n" + + "[obsidian-sync] Restart to run the check again.\n", + ) + expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault"]) + }) + it("exits 1 with the credential hint when ob sync-setup fails", () => { const run = runSetupScript({ vaultName: "MyVault", syncSetupFails: true }) @@ -175,7 +300,7 @@ describe("init-setup-vault script", () => { "[obsidian-sync] Check OBSIDIAN_AUTH_TOKEN and VAULT_NAME are correct.\n" + "[obsidian-sync] If your vault uses end-to-end encryption, set VAULT_PASSWORD.\n", ) - expect(run.obCalls).toEqual(["sync-setup --vault MyVault"]) + expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault"]) }) it("registers the first device under DEVICE_NAME", () => { @@ -184,8 +309,10 @@ describe("init-setup-vault script", () => { deviceName: "vault cortex box", }) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault --device-name vault cortex box", + PICKED_VAULT_CALL, "sync-config --device-name vault cortex box", CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, @@ -205,8 +332,10 @@ describe("init-setup-vault script", () => { "[obsidian-sync] WARNING: ob sync-config --file-types 'image,pdf' failed — continuing without it.\n", ) // The call after the failed one still runs. - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, "sync-config --file-types image,pdf", DEFAULT_SYNC_CONFIGS_CALL, @@ -218,8 +347,10 @@ describe("init-setup-vault script", () => { expect(run.status).toBe(0) expect(run.stderr).toBe("") - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --mode pull-only", @@ -241,8 +372,10 @@ describe("init-setup-vault script", () => { "[obsidian-sync] Set SYNC_MODE to bidirectional, pull-only, or mirror-remote and restart.\n", ) // Nothing after the failed mode call runs. - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --mode pull-onyl", @@ -256,8 +389,10 @@ describe("init-setup-vault script", () => { syncFileTypes: "image,pdf", }) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, "sync-config --excluded-folders Daily Notes,Private", "sync-config --file-types image,pdf", DEFAULT_SYNC_CONFIGS_CALL, @@ -271,8 +406,10 @@ describe("init-setup-vault script", () => { syncFileTypes: "", }) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL, @@ -287,8 +424,10 @@ describe("init-setup-vault script", () => { expect(run.status).toBe(0) expect(run.stderr).toBe("") - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL, @@ -299,8 +438,10 @@ describe("init-setup-vault script", () => { const run = runSetupScript({ vaultName: "MyVault", syncConfigs: "app,hotkey" }) expect(run.status).toBe(0) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --configs app,hotkey", @@ -310,8 +451,10 @@ describe("init-setup-vault script", () => { it("clears config-category sync when SYNC_CONFIGS is none", () => { const run = runSetupScript({ vaultName: "MyVault", syncConfigs: "none" }) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --configs ", @@ -329,8 +472,10 @@ describe("init-setup-vault script", () => { expect(run.stderr).toBe( "[obsidian-sync] WARNING: ob sync-config --configs '' failed — continuing without it.\n", ) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --configs ", @@ -344,8 +489,10 @@ describe("init-setup-vault script", () => { }) expect(run.status).toBe(0) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault --password s3cret", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL, @@ -359,8 +506,10 @@ describe("init-setup-vault script", () => { }) expect(run.status).toBe(0) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault --config-dir .obsidian-custom", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL,