vault-cortex 0.4.4 → 0.4.6-beta.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,25 +8,40 @@ npx vault-cortex@latest init
8
8
  ```
9
9
 
10
10
  Vault Cortex is a standalone, remote-capable MCP server for Obsidian vaults —
11
- 26 tools for hybrid search (FTS5 + vector + cross-encoder reranking), notes, frontmatter, links, daily notes,
12
- task queries (Tasks-plugin emoji + Dataview inline-field formats), and
13
- a structured memory layer, plus 3 guided prompts (orientation, memory review,
14
- daily review). It runs as a Docker container; this CLI scaffolds the config so
15
- you don't have to.
11
+ Tools for hybrid search (FTS5 + vector + cross-encoder reranking, with
12
+ tag/folder/property/date filters), notes, frontmatter, links, daily notes,
13
+ Kanban-aware task management (query + complete/move/reprioritize, parsing Tasks-plugin emoji + Dataview inline-field formats), and
14
+ a structured memory layer with topic recall (the current take plus the
15
+ dated history behind it), plus guided prompts (orientation, memory
16
+ review, daily review). It runs as a Docker
17
+ container; this CLI scaffolds the config so you don't have to.
16
18
 
17
19
  ## What `init` does
18
20
 
19
21
  1. Asks how you want to run it:
20
22
  - **Local** — Docker on this machine, your vault folder bind-mounted
21
23
  - **Remote** — a VPS with [Obsidian Sync](https://obsidian.md/sync), reachable from any device
22
- 2. Generates a `docker-compose.yml` and `.env`, including a securely generated
23
- `MCP_AUTH_TOKEN`
24
- 3. Optionally runs `docker compose up -d` and waits for the health check
24
+ 2. Generates a `.env` file with a securely generated `MCP_AUTH_TOKEN`
25
+ 3. Optionally starts the container and waits for the health check
25
26
  4. Prints your connection details — the MCP URL, your auth token, and how to
26
27
  connect your client
27
28
 
28
29
  Existing files are never overwritten without asking.
29
30
 
31
+ ## Upgrade
32
+
33
+ Pull the latest image, re-create the container, and verify health:
34
+
35
+ ```bash
36
+ npx vault-cortex upgrade
37
+ ```
38
+
39
+ Also applies `.env` changes (`docker restart` does not re-read env files).
40
+
41
+ Prefer Docker Compose? The CLI uses `docker run` for simplicity, but the
42
+ [deploy guides](https://github.com/aliasunder/vault-cortex/blob/main/deploy/)
43
+ include Compose files you can use directly.
44
+
30
45
  ## Non-interactive
31
46
 
32
47
  ```bash
@@ -39,7 +54,7 @@ Flags: `--mode local|remote`, `--vault-path <path>`, `--dir <path>` (default
39
54
  ## Requirements
40
55
 
41
56
  - Node.js >= 20.12 (only for this CLI — the server itself runs in Docker)
42
- - [Docker](https://docs.docker.com/get-docker/) to run the server
57
+ - [Docker](https://docs.docker.com/get-docker/) (or any OCI-compatible runtime) to run the server
43
58
 
44
59
  ## Docs
45
60
 
package/dist/docker.js CHANGED
@@ -1,19 +1,80 @@
1
1
  import { spawnSync } from "node:child_process";
2
- /** The image whose `get-token` entrypoint issues Obsidian Sync auth tokens. */
3
- export const GET_TOKEN_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote";
2
+ export const LOCAL_IMAGE = "ghcr.io/aliasunder/vault-cortex:latest";
3
+ export const REMOTE_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote";
4
+ export const CONTAINER_NAME = "vault-cortex";
5
+ /**
6
+ * Container-internal env vars that must override the user's .env values.
7
+ * VAULT_PATH in .env is the host path (for the -v mount); the container
8
+ * must see /vault. PORT/HOST/INDEX_DB_PATH are hardcoded infrastructure.
9
+ */
10
+ const CONTAINER_ENV_OVERRIDES = [
11
+ "VAULT_PATH=/vault",
12
+ "PORT=8000",
13
+ "HOST=0.0.0.0",
14
+ "INDEX_DB_PATH=/data/index.db",
15
+ ];
16
+ /** Node one-liner matching the compose healthcheck — exits 0 on HTTP 200. */
17
+ 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))\"";
18
+ /**
19
+ * Builds the `docker run` args array. Pure function — no I/O — so it's
20
+ * testable without spawning processes.
21
+ */
22
+ export const buildDockerRunArgs = (params) => {
23
+ const { mode, envFilePath, port, vaultPath } = params;
24
+ const image = mode === "local" ? LOCAL_IMAGE : REMOTE_IMAGE;
25
+ const args = [
26
+ "run",
27
+ "-d",
28
+ "--name",
29
+ CONTAINER_NAME,
30
+ "--restart",
31
+ "unless-stopped",
32
+ "--env-file",
33
+ envFilePath,
34
+ ...CONTAINER_ENV_OVERRIDES.flatMap((override) => ["-e", override]),
35
+ "-p",
36
+ `${port}:8000`,
37
+ ];
38
+ if (mode === "local") {
39
+ if (!vaultPath) {
40
+ throw new Error("vaultPath is required for local mode");
41
+ }
42
+ args.push("-v", `${vaultPath}:/vault:rw`);
43
+ args.push("-v", "vault-cortex_mcp_data:/data");
44
+ args.push("--health-cmd", HEALTH_CMD);
45
+ args.push("--health-interval", "15s");
46
+ args.push("--health-timeout", "5s");
47
+ args.push("--health-retries", "3");
48
+ args.push("--health-start-period", "20s");
49
+ }
50
+ else {
51
+ args.push("--hostname", CONTAINER_NAME);
52
+ args.push("-v", "vault-cortex_vault_data:/vault");
53
+ args.push("-v", "vault-cortex_mcp_data:/data");
54
+ args.push("-v", "vault-cortex_obsidian_config:/home/obsidian/.config");
55
+ args.push("--health-cmd", HEALTH_CMD);
56
+ args.push("--health-interval", "15s");
57
+ args.push("--health-timeout", "5s");
58
+ args.push("--health-retries", "5");
59
+ args.push("--health-start-period", "60s");
60
+ args.push("--log-driver", "json-file");
61
+ args.push("--log-opt", "max-size=10m");
62
+ args.push("--log-opt", "max-file=3");
63
+ }
64
+ args.push(image);
65
+ return args;
66
+ };
4
67
  export const createDockerRunner = () => ({
5
- isComposeAvailable: () => spawnSync("docker", ["compose", "version"]).status === 0,
6
68
  isDaemonRunning: () => spawnSync("docker", ["info"], { timeout: 5_000 }).status === 0,
7
- composeUp: (cwd) => spawnSync("docker", ["compose", "up", "-d"], { cwd, stdio: "inherit" })
69
+ dockerRun: (params) => spawnSync("docker", buildDockerRunArgs(params), { stdio: "inherit" })
8
70
  .status === 0,
9
- runGetToken: () => spawnSync("docker", ["run", "--rm", "-it", "--entrypoint", "get-token", GET_TOKEN_IMAGE], {
10
- stdio: "inherit",
11
- }).status === 0,
71
+ pullImage: (image) => spawnSync("docker", ["pull", image], { stdio: "inherit" }).status === 0,
72
+ stopAndRemoveContainer: () => spawnSync("docker", ["rm", "-f", CONTAINER_NAME]).status === 0,
73
+ runGetToken: () => spawnSync("docker", ["run", "--rm", "-it", "--entrypoint", "get-token", REMOTE_IMAGE], { stdio: "inherit" }).status === 0,
12
74
  });
13
75
  /**
14
76
  * Polls the health endpoint until it responds OK or the timeout elapses.
15
- * The first `docker compose up` pulls a ~150MB image, so the default window
16
- * is generous.
77
+ * The first `docker run` pulls the image, so the default window is generous.
17
78
  *
18
79
  * Native Date.now() rather than the server's Luxon convention: the published
19
80
  * CLI deliberately keeps its dependency set to two packages, and this is an
package/dist/env.js CHANGED
@@ -1,11 +1,22 @@
1
- import { GET_TOKEN_IMAGE } from "./docker.js";
2
- // The optional blocks mirror the canonical deploy/<mode>/.env.example files.
3
- // cli/src/templates.test.ts asserts every required `${VAR:?}` in the compose
4
- // templates has a matching line here, so a new required var breaks CI until
5
- // these builders learn it.
1
+ import { REMOTE_IMAGE } from "./docker.js";
2
+ // Optional env blocks are synced from deploy/<mode>/.env.example by
3
+ // npm run sync:cli-env-blocks. Edit the deploy/ files, then re-run the script.
4
+ // cli/src/templates.test.ts asserts the CLI optional block vars match the
5
+ // deploy/ .env.example optional vars, so a new var breaks CI until both
6
+ // surfaces carry it.
7
+ // ┌─────────────────────────────────────────────────────────────────────────┐
8
+ // │ GENERATED — do not edit between sync markers. │
9
+ // │ Source: deploy/local/.env.example → npm run sync:cli-env-blocks │
10
+ // │ The script replaces everything between :begin and :end on each run. │
11
+ // └─────────────────────────────────────────────────────────────────────────┘
12
+ // sync:local-optional:begin
6
13
  const LOCAL_OPTIONAL_BLOCK = `# Optional ──────────────────────────────────────────────────
7
14
  # To override a setting: uncomment it, set a value, then apply with
8
- # "docker compose up -d" (restart alone does not re-read this file).
15
+ # "npx vault-cortex upgrade" (restart alone does not re-read this file).
16
+
17
+ # Public URL for OAuth issuer URL in discovery metadata (default: http://localhost:8000).
18
+ # Override if you expose the server on a different URL (e.g. via a reverse proxy).
19
+ # PUBLIC_URL=http://localhost:8000
9
20
 
10
21
  # Your IANA timezone — affects daily note resolution and memory timestamps.
11
22
  # TZ=America/New_York
@@ -63,9 +74,16 @@ const LOCAL_OPTIONAL_BLOCK = `# Optional ─────────────
63
74
  # drive rather than inside WSL2, but harmless to enable for any Windows setup.
64
75
  # WINDOWS_MODE=true
65
76
  `;
77
+ // sync:local-optional:end
78
+ // ┌─────────────────────────────────────────────────────────────────────────┐
79
+ // │ GENERATED — do not edit between sync markers. │
80
+ // │ Source: deploy/remote/.env.example → npm run sync:cli-env-blocks │
81
+ // │ VAULT_PASSWORD is excluded (handled conditionally in buildRemoteEnv). │
82
+ // └─────────────────────────────────────────────────────────────────────────┘
83
+ // sync:remote-optional:begin
66
84
  const REMOTE_OPTIONAL_BLOCK = `# Optional ──────────────────────────────────────────────────
67
85
  # To override a setting: uncomment it, set a value, then apply with
68
- # "docker compose up -d" (restart alone does not re-read this file).
86
+ # "npx vault-cortex upgrade" (restart alone does not re-read this file).
69
87
 
70
88
  # Your IANA timezone — affects daily note resolution and memory timestamps.
71
89
  # TZ=America/New_York
@@ -130,11 +148,13 @@ const REMOTE_OPTIONAL_BLOCK = `# Optional ────────────
130
148
  # DEVICE_NAME=vault-cortex
131
149
 
132
150
  # Obsidian Sync conflict resolution: merge | conflict (default: merge).
151
+ # 'merge' integrates changes automatically; 'conflict' writes a separate conflict file.
133
152
  # CONFLICT_STRATEGY=merge
134
153
 
135
154
  # Sync direction: bidirectional | pull-only | push-only (default: bidirectional).
136
155
  # SYNC_MODE=bidirectional
137
156
  `;
157
+ // sync:remote-optional:end
138
158
  export const buildLocalEnv = (answers) => `# vault-cortex — local quickstart
139
159
  # Generated by \`npx vault-cortex init\`. Full option reference:
140
160
  # https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/.env.example
@@ -147,6 +167,10 @@ MCP_AUTH_TOKEN=${answers.mcpAuthToken}
147
167
  # Absolute path to your Obsidian vault on this machine.
148
168
  VAULT_PATH=${answers.vaultPath}
149
169
 
170
+ # Public URL for OAuth issuer URL in discovery metadata.
171
+ # Override if you expose the server on a different URL (e.g. via a reverse proxy).
172
+ PUBLIC_URL=http://localhost:8000
173
+
150
174
  ${LOCAL_OPTIONAL_BLOCK}`;
151
175
  export const buildRemoteEnv = (answers) => {
152
176
  const vaultPasswordLines = answers.vaultPassword === undefined
@@ -155,10 +179,10 @@ export const buildRemoteEnv = (answers) => {
155
179
  : `# Vault end-to-end encryption password.
156
180
  VAULT_PASSWORD=${answers.vaultPassword}`;
157
181
  const obsidianTokenComment = answers.obsidianAuthToken === ""
158
- ? `# Obsidian Sync auth token — FILL THIS IN before docker compose up.
182
+ ? `# Obsidian Sync auth token — FILL THIS IN before starting the server.
159
183
  # Generate once with:
160
184
  # docker run --rm -it --entrypoint get-token \\
161
- # ${GET_TOKEN_IMAGE}`
185
+ # ${REMOTE_IMAGE}`
162
186
  : `# Obsidian Sync auth token.`;
163
187
  return `# vault-cortex — remote quickstart (Obsidian Sync)
164
188
  # Generated by \`npx vault-cortex init\`. Full option reference:
package/dist/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { buildLocalEnv, buildRemoteEnv } from "./env.js";
3
3
  import { buildLocalConnectMessage, buildRemoteConnectMessage, } from "./messages.js";
4
- import { GET_TOKEN_IMAGE, pollHealth } from "./docker.js";
4
+ import { REMOTE_IMAGE, pollHealth } from "./docker.js";
5
5
  import { buildFilesToWrite, readEnvPort, writeFiles, } from "./scaffold.js";
6
6
  import { generateToken } from "./token.js";
7
7
  import { expandTilde, validateVaultPath } from "./vault.js";
@@ -20,11 +20,10 @@ const askMode = async (prompts) => {
20
20
  hint: "VPS + Obsidian Sync, access from anywhere",
21
21
  },
22
22
  ], "local");
23
- // The select only offers mode values; the guard narrows without a cast.
24
23
  return isMode(selected) ? selected : "local";
25
24
  };
26
25
  const GET_TOKEN_COMMAND = `docker run --rm -it --entrypoint get-token \\
27
- ${GET_TOKEN_IMAGE}`;
26
+ ${REMOTE_IMAGE}`;
28
27
  /**
29
28
  * Offers to run the vault-cortex image's get-token flow in this terminal.
30
29
  * Returns true only when it ran to completion (and so printed a token the
@@ -37,7 +36,8 @@ const offerGetTokenRun = async (prompts, docker) => {
37
36
  return false;
38
37
  prompts.log("Handing the terminal to get-token — it will ask for your Obsidian " +
39
38
  "account login and print a token at the end.");
40
- if (!docker.runGetToken()) {
39
+ const tokenGenerated = docker.runGetToken();
40
+ if (!tokenGenerated) {
41
41
  prompts.warn("get-token did not complete — you can run it later and edit .env.");
42
42
  return false;
43
43
  }
@@ -60,8 +60,6 @@ const askVaultPath = async (prompts) => {
60
60
  return askVaultPath(prompts);
61
61
  }
62
62
  if (validation.kind === "warn") {
63
- // Renders as: "<path> doesn't look like an Obsidian vault (no .obsidian
64
- // folder). Use it anyway? (Y/n)"
65
63
  const useAnyway = await prompts.confirm(`${validation.message} Use it anyway?`, true);
66
64
  if (!useAnyway)
67
65
  return askVaultPath(prompts);
@@ -140,45 +138,46 @@ const reportWrites = (params, prompts) => {
140
138
  }
141
139
  };
142
140
  /**
143
- * Offers to start the scaffolded stack, walking a gate ladder where each
144
- * failed gate degrades to instructions instead of an error: compose
145
- * installeddaemon runninguser consents compose up succeeds →
146
- * health check passes. Returns true only when the server is confirmed up;
147
- * the caller uses that to pick the right connect message.
141
+ * Offers to start the container, walking a gate ladder where each failed
142
+ * gate degrades to instructions instead of an error: daemon running → user
143
+ * consentsdocker run succeeds health check passes. Returns true only
144
+ * when the server is confirmed up.
148
145
  */
149
- const offerComposeUp = async (params, deps) => {
150
- const { targetDir, port } = params;
146
+ const offerDockerRun = async (params, deps) => {
147
+ const { targetDir, port, mode, vaultPath } = params;
151
148
  const { prompts, docker, fetchFn } = deps;
152
- if (!docker.isComposeAvailable()) {
153
- prompts.warn("Docker Compose not found — install Docker to start the server:\n" +
154
- "https://docs.docker.com/get-docker/");
155
- return false;
156
- }
157
149
  if (!docker.isDaemonRunning()) {
158
- prompts.warn("Docker is installed but not running — start Docker Desktop (or the\n" +
159
- "docker service on Linux), then run: docker compose up -d");
150
+ prompts.warn("Container runtime not running — start Docker Desktop, Colima,\n" +
151
+ "OrbStack, or another Docker-compatible runtime, then run:\n" +
152
+ ` npx vault-cortex upgrade --dir "${targetDir}"`);
160
153
  return false;
161
154
  }
162
- const startNow = await prompts.confirm("Start the server now? (docker compose up -d)", true);
155
+ const startNow = await prompts.confirm("Start the server now?", true);
163
156
  if (!startNow)
164
157
  return false;
165
- if (!docker.composeUp(targetDir)) {
166
- prompts.error("docker compose up failed — see output above.");
158
+ const containerStarted = docker.dockerRun({
159
+ mode,
160
+ envFilePath: join(targetDir, ".env"),
161
+ port,
162
+ vaultPath,
163
+ });
164
+ if (!containerStarted) {
165
+ prompts.error("docker run failed — see output above.");
167
166
  return false;
168
167
  }
169
168
  const spinner = prompts.spinner();
170
- spinner.start("Waiting for the server to come up (first run pulls a ~150MB image)");
169
+ spinner.start("Waiting for the server to come up (first run may take a moment)");
171
170
  const healthy = await pollHealth({ url: `http://127.0.0.1:${port}/healthz` }, fetchFn);
172
171
  if (!healthy) {
173
- spinner.stop("Server did not respond within 2 minutes — check: docker compose logs");
172
+ spinner.stop("Server did not respond within 2 minutes — check: docker logs vault-cortex");
174
173
  return false;
175
174
  }
176
175
  spinner.stop("Server is up — health check passed.");
177
176
  return true;
178
177
  };
179
178
  // Local flow: resolve vault path → resolve target dir → generate token →
180
- // write docker-compose.yml + .env → optionally start the stack → print
181
- // connect instructions. Returns a process exit code.
179
+ // write .env → optionally start the container → print connect instructions.
180
+ // Returns a process exit code.
182
181
  const runLocalInit = async (flags, deps) => {
183
182
  const { prompts } = deps;
184
183
  // Vault path comes from --vault-path when given and valid; interactive
@@ -188,7 +187,7 @@ const runLocalInit = async (flags, deps) => {
188
187
  ? undefined
189
188
  : validateVaultPath(flags.vaultPath);
190
189
  if (flags.yes) {
191
- if (vaultPathResult === undefined || vaultPathResult.kind === "error") {
190
+ if (!vaultPathResult || vaultPathResult.kind === "error") {
192
191
  prompts.error(vaultPathResult?.message ?? "--yes requires --vault-path.");
193
192
  return 1;
194
193
  }
@@ -200,7 +199,7 @@ const runLocalInit = async (flags, deps) => {
200
199
  }
201
200
  // A warn-level flag path (no .obsidian/) is accepted without the confirm a
202
201
  // prompted path gets — passing the flag is already an explicit choice.
203
- const vaultPath = vaultPathResult !== undefined && vaultPathResult.kind !== "error"
202
+ const vaultPath = vaultPathResult && vaultPathResult.kind !== "error"
204
203
  ? vaultPathResult.path
205
204
  : await askVaultPath(prompts);
206
205
  // expandTilde before resolve: resolve() treats a leading `~` as a literal
@@ -216,7 +215,7 @@ const runLocalInit = async (flags, deps) => {
216
215
  // Conflict policy: identical existing files are skipped silently;
217
216
  // differing ones prompt per file (default keep). --yes never overwrites —
218
217
  // any differing file becomes an exit-1 below, leaving it untouched.
219
- const files = buildFilesToWrite("local", buildLocalEnv({ mcpAuthToken: token, vaultPath }));
218
+ const files = buildFilesToWrite(buildLocalEnv({ mcpAuthToken: token, vaultPath }));
220
219
  const resolveConflict = flags.yes ? keepExisting : confirmOverwrite(prompts);
221
220
  const results = await writeFiles({ targetDir, files }, resolveConflict);
222
221
  reportWrites({ targetDir, results }, prompts);
@@ -226,8 +225,8 @@ const runLocalInit = async (flags, deps) => {
226
225
  return 1;
227
226
  }
228
227
  // When an existing .env was kept, this run's generated token was never
229
- // saved — the connect message must point at the token (and PORT) actually on disk,
230
- // or a pasted token fails auth with no hint why.
228
+ // saved — the connect message must point at the token (and PORT) actually
229
+ // on disk, or a pasted token fails auth with no hint why.
231
230
  const envResult = results.find((result) => result.name === ".env");
232
231
  const tokenWritten = envResult?.status === "created" || envResult?.status === "overwritten";
233
232
  if (tokenWritten)
@@ -236,15 +235,15 @@ const runLocalInit = async (flags, deps) => {
236
235
  // --yes is for scripts/CI, so it never starts Docker.
237
236
  const started = flags.yes
238
237
  ? false
239
- : await offerComposeUp({ targetDir, port }, deps);
238
+ : await offerDockerRun({ targetDir, port, mode: "local", vaultPath }, deps);
240
239
  prompts.print(buildLocalConnectMessage({ targetDir, token, started, port, tokenWritten }));
241
240
  return 0;
242
241
  };
243
242
  // Remote flow (VPS + Obsidian Sync): resolve target dir → PUBLIC_URL →
244
243
  // VAULT_NAME → Obsidian Sync token (optionally running get-token via
245
- // Docker) → optional E2E vault password → generate token → write the
246
- // single-service compose + .env → optionally start → print connect
247
- // instructions. Always interactive — the sync-token step can't be defaulted.
244
+ // Docker) → optional E2E vault password → generate token → write .env →
245
+ // optionally start → print connect instructions. Always interactive —
246
+ // the sync-token step can't be defaulted.
248
247
  const runRemoteInit = async (flags, deps) => {
249
248
  const { prompts, docker } = deps;
250
249
  // expandTilde before resolve: resolve() treats a leading `~` as a literal
@@ -263,7 +262,7 @@ const runRemoteInit = async (flags, deps) => {
263
262
  // A blank answer is allowed: the .env is written with an empty
264
263
  // OBSIDIAN_AUTH_TOKEN and a fill-this-in comment.
265
264
  prompts.note(GET_TOKEN_COMMAND, "Obsidian Sync token — generate once with");
266
- const getTokenRan = docker.isComposeAvailable() && docker.isDaemonRunning()
265
+ const getTokenRan = docker.isDaemonRunning()
267
266
  ? await offerGetTokenRun(prompts, docker)
268
267
  : false;
269
268
  // "printed above" is only true when get-token actually ran to completion.
@@ -283,24 +282,23 @@ const runRemoteInit = async (flags, deps) => {
283
282
  vaultName,
284
283
  vaultPassword,
285
284
  });
286
- const files = buildFilesToWrite("remote", envContent);
285
+ const files = buildFilesToWrite(envContent);
287
286
  const results = await writeFiles({ targetDir, files }, confirmOverwrite(prompts));
288
287
  reportWrites({ targetDir, results }, prompts);
289
288
  // Same kept-.env handling as the local flow: the server only reads config
290
289
  // from the .env on disk, so when an existing file was kept, this run's
291
290
  // generated token was never saved (printing it would fail auth) and PORT
292
- // may differ from the default — describe the server that will actually
293
- // run, not the one this run intended to configure.
291
+ // may differ from the default.
294
292
  const envResult = results.find((result) => result.name === ".env");
295
293
  const tokenWritten = envResult?.status === "created" || envResult?.status === "overwritten";
296
294
  if (tokenWritten)
297
295
  prompts.log("Generated MCP auth token (saved to .env).");
298
296
  const port = readEnvPort(join(targetDir, ".env"));
299
297
  // Without the sync token the container can't start (init-check-auth fails
300
- // and s6 stops it), so only offer compose up when it was provided.
298
+ // and s6 stops it), so only offer docker run when it was provided.
301
299
  const started = obsidianAuthToken === ""
302
300
  ? false
303
- : await offerComposeUp({ targetDir, port }, deps);
301
+ : await offerDockerRun({ targetDir, port, mode: "remote" }, deps);
304
302
  prompts.print(buildRemoteConnectMessage({
305
303
  targetDir,
306
304
  token,
@@ -326,9 +324,8 @@ export const runInit = async (flags, deps) => {
326
324
  return 1;
327
325
  }
328
326
  prompts.intro("vault-cortex init");
329
- // Mode resolution: explicit --mode wins (validated above, so the guard
330
- // narrows it); --yes implies local; otherwise ask, defaulting to local
331
- // it's the activation path.
327
+ // Mode resolution: explicit --mode wins; --yes implies local; otherwise
328
+ // ask, defaulting to local it's the simpler activation path.
332
329
  const mode = flags.mode !== undefined && isMode(flags.mode)
333
330
  ? flags.mode
334
331
  : flags.yes
package/dist/main.js CHANGED
@@ -2,6 +2,7 @@ import { createDockerRunner } from "./docker.js";
2
2
  import { runInit } from "./init.js";
3
3
  import { buildProgram } from "./program.js";
4
4
  import { createPrompts } from "./prompts.js";
5
+ import { runUpgrade } from "./upgrade.js";
5
6
  export const run = async (version) => {
6
7
  const program = buildProgram({
7
8
  version,
@@ -10,6 +11,11 @@ export const run = async (version) => {
10
11
  docker: createDockerRunner(),
11
12
  fetchFn: fetch,
12
13
  }),
14
+ runUpgrade: (flags) => runUpgrade(flags, {
15
+ prompts: createPrompts(),
16
+ docker: createDockerRunner(),
17
+ fetchFn: fetch,
18
+ }),
13
19
  });
14
20
  await program.parseAsync();
15
21
  };
package/dist/messages.js CHANGED
@@ -12,15 +12,15 @@ const bottomRule = () => paint("dim", `╰${"─".repeat(RULE_WIDTH - 2)}╯`);
12
12
  const sectionRule = (label) => paint("dim", `── ${label} ${"─".repeat(Math.max(0, RULE_WIDTH - label.length - 4))}`);
13
13
  // targetDir is quoted: these lines are meant to be copy-pasted into a
14
14
  // shell, and an unquoted path breaks on spaces or special characters.
15
- const composeUpCommand = (targetDir) => `cd "${targetDir}" && docker compose up -d`;
16
- const startServerLine = (targetDir) => `Start the server:\n ${composeUpCommand(targetDir)}`;
15
+ const upgradeCommand = (targetDir) => `npx vault-cortex upgrade --dir "${targetDir}"`;
16
+ const startServerLine = (targetDir) => `Start the server:\n ${upgradeCommand(targetDir)}`;
17
17
  /** Remote start line: running, blocked on the missing sync token, or ready to start. */
18
18
  const remoteStartLine = (params) => {
19
19
  const { targetDir, started, obsidianTokenMissing } = params;
20
20
  if (started)
21
21
  return "The server is running.";
22
22
  if (obsidianTokenMissing) {
23
- return `Fill in OBSIDIAN_AUTH_TOKEN in ${targetDir}/.env, then start the server:\n ${composeUpCommand(targetDir)}`;
23
+ return `Fill in OBSIDIAN_AUTH_TOKEN in ${targetDir}/.env, then start the server:\n ${upgradeCommand(targetDir)}`;
24
24
  }
25
25
  return startServerLine(targetDir);
26
26
  };
@@ -62,10 +62,8 @@ const curlGuidance = (mcpUrl) => `Clients without OAuth, scripts, and curl send
62
62
  curl -H "Authorization: Bearer <token>" ${mcpUrl}`;
63
63
  const smokeTest = (healthUrl) => `Smoke test:
64
64
  curl ${healthUrl}`;
65
- // Compose does not pull new images on `up` — without this hint users stay
66
- // on the image from init day forever while believing they track releases.
67
65
  const updateGuidance = (targetDir) => `Update to the latest release:
68
- cd "${targetDir}" && docker compose pull && docker compose up -d`;
66
+ ${upgradeCommand(targetDir)}`;
69
67
  /**
70
68
  * Local-mode "Connect" message. port comes from the .env on disk: a kept file
71
69
  * may override the default, so the message must describe the server that will
@@ -114,7 +112,7 @@ ${sectionRule("Settings")}
114
112
 
115
113
  Optional settings (timezone, memory folder, port, logging) are commented
116
114
  out in ${targetDir}/.env — uncomment, set a value, then apply with
117
- "docker compose up -d" (restart alone does not re-read .env).
115
+ "npx vault-cortex upgrade" (restart alone does not re-read .env).
118
116
 
119
117
  ${updateGuidance(targetDir)}
120
118
 
@@ -142,7 +140,8 @@ export const buildRemoteConnectMessage = (params) => {
142
140
  // know which case it is, so the http branch states it rather than asking.
143
141
  // Case-insensitive: askPublicUrl stores the scheme as typed, so an HTTPS://
144
142
  // input is valid and must still route to the https branch.
145
- const clientGuidance = publicUrl.toLowerCase().startsWith("https://")
143
+ const isHttps = publicUrl.toLowerCase().startsWith("https://");
144
+ const clientGuidance = isHttps
146
145
  ? `${connectGuidance(`${publicUrl}/mcp`)}
147
146
 
148
147
  Reachable over https from any MCP client — Claude Desktop, claude.ai (web
@@ -174,8 +173,8 @@ ${sectionRule("Settings")}
174
173
 
175
174
  Optional settings (timezone, memory folder, port, logging, sync
176
175
  behavior) are commented out in ${targetDir}/.env — uncomment, set a
177
- value, then apply with "docker compose up -d" (restart alone does not
178
- re-read .env).
176
+ value, then apply with "npx vault-cortex upgrade" (restart alone does
177
+ not re-read .env).
179
178
 
180
179
  ${updateGuidance(targetDir)}
181
180
 
package/dist/program.js CHANGED
@@ -7,7 +7,7 @@ export const buildProgram = (options) => {
7
7
  .version(options.version);
8
8
  program
9
9
  .command("init")
10
- .description("Scaffold docker-compose.yml + .env and optionally start the server")
10
+ .description("Scaffold .env and optionally start the server")
11
11
  .option("--mode <mode>", 'deployment mode: "local" (default) or "remote"')
12
12
  .option("--vault-path <path>", "absolute path to your Obsidian vault (local mode)")
13
13
  .option("--dir <path>", "directory to write config files into (default: ./vault-cortex)")
@@ -15,7 +15,13 @@ export const buildProgram = (options) => {
15
15
  .action(async (flags) => {
16
16
  process.exitCode = await options.runInit(flags);
17
17
  });
18
- // Bare `npx vault-cortex` shows help instead of a "missing command" error.
18
+ program
19
+ .command("upgrade")
20
+ .description("Pull the latest image, re-create the container, and verify health")
21
+ .option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
22
+ .action(async (flags) => {
23
+ process.exitCode = await options.runUpgrade(flags);
24
+ });
19
25
  program.action(() => {
20
26
  program.help();
21
27
  });
package/dist/scaffold.js CHANGED
@@ -1,18 +1,16 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- /** Default host port — matches the compose templates' `${PORT:-8000}`. */
3
+ /** Default host port matches the container's internal port. */
5
4
  export const DEFAULT_PORT = 8000;
6
5
  /** Matches an active (uncommented) PORT line in a .env file. */
7
6
  const ENV_PORT_LINE = /^PORT=(\d+)\s*$/m;
8
- /**
9
- * Reads the bundled docker-compose template for a mode. The templates are
10
- * verbatim copies of deploy/<mode>/docker-compose.yml, shipped inside the
11
- * npm package (kept in sync by cli/src/__tests__/templates.test.ts).
12
- */
13
- export const readComposeTemplate = (mode) => readFileSync(fileURLToPath(new URL(`../templates/${mode}/docker-compose.yml`, import.meta.url)), "utf8");
14
- export const buildFilesToWrite = (mode, envContent) => [
15
- { name: "docker-compose.yml", content: readComposeTemplate(mode) },
7
+ /** Matches an active (uncommented) VAULT_PATH line in a .env file. */
8
+ const ENV_VAULT_PATH_LINE = /^VAULT_PATH=(.+)\s*$/m;
9
+ /** Matches an active (uncommented) PUBLIC_URL line. */
10
+ const ENV_PUBLIC_URL_LINE = /^PUBLIC_URL=/m;
11
+ /** Matches an active (uncommented) OBSIDIAN_AUTH_TOKEN line. */
12
+ const OBSIDIAN_AUTH_TOKEN_LINE = /^OBSIDIAN_AUTH_TOKEN=/m;
13
+ export const buildFilesToWrite = (envContent) => [
16
14
  // .env holds the bearer token (and possibly a vault password) — owner-only.
17
15
  { name: ".env", content: envContent, mode: 0o600 },
18
16
  ];
@@ -26,7 +24,38 @@ export const readEnvPort = (envFilePath) => {
26
24
  if (!existsSync(envFilePath))
27
25
  return DEFAULT_PORT;
28
26
  const match = ENV_PORT_LINE.exec(readFileSync(envFilePath, "utf8"));
29
- return match === null ? DEFAULT_PORT : Number(match[1]);
27
+ return match ? Number(match[1]) : DEFAULT_PORT;
28
+ };
29
+ /**
30
+ * Reads the host vault path from a .env file. Returns undefined when the
31
+ * file is missing or has no uncommented VAULT_PATH line.
32
+ */
33
+ export const readEnvVaultPath = (envFilePath) => {
34
+ if (!existsSync(envFilePath))
35
+ return undefined;
36
+ const match = ENV_VAULT_PATH_LINE.exec(readFileSync(envFilePath, "utf8"));
37
+ return match?.[1].trim();
38
+ };
39
+ /**
40
+ * Returns true when the .env file has an active (uncommented) PUBLIC_URL line.
41
+ * Used by upgrade to detect .env files from the old compose-based CLI, where
42
+ * PUBLIC_URL was provided by docker-compose defaults rather than the .env.
43
+ */
44
+ export const hasEnvPublicUrl = (envFilePath) => {
45
+ if (!existsSync(envFilePath))
46
+ return false;
47
+ return ENV_PUBLIC_URL_LINE.test(readFileSync(envFilePath, "utf8"));
48
+ };
49
+ /**
50
+ * Detects the deployment mode from a .env file. Remote mode requires
51
+ * OBSIDIAN_AUTH_TOKEN (absent from local). Returns undefined when the
52
+ * .env file does not exist.
53
+ */
54
+ export const detectMode = (envFilePath) => {
55
+ if (!existsSync(envFilePath))
56
+ return undefined;
57
+ const content = readFileSync(envFilePath, "utf8");
58
+ return OBSIDIAN_AUTH_TOKEN_LINE.test(content) ? "remote" : "local";
30
59
  };
31
60
  /**
32
61
  * Writes the files into targetDir (created if missing). Existing files
@@ -0,0 +1,65 @@
1
+ import { join, resolve } from "node:path";
2
+ import { CONTAINER_NAME, LOCAL_IMAGE, REMOTE_IMAGE, pollHealth, } from "./docker.js";
3
+ import { detectMode, hasEnvPublicUrl, readEnvPort, readEnvVaultPath, } from "./scaffold.js";
4
+ import { expandTilde } from "./vault.js";
5
+ const DEFAULT_TARGET_DIR = "./vault-cortex";
6
+ export const runUpgrade = async (flags, deps) => {
7
+ const { prompts, docker, fetchFn } = deps;
8
+ prompts.intro("vault-cortex upgrade");
9
+ const targetDir = resolve(expandTilde(flags.dir ?? DEFAULT_TARGET_DIR));
10
+ const envFilePath = join(targetDir, ".env");
11
+ const mode = detectMode(envFilePath);
12
+ if (!mode) {
13
+ prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex init\` first.`);
14
+ return 1;
15
+ }
16
+ const port = readEnvPort(envFilePath);
17
+ const vaultPath = mode === "local" ? readEnvVaultPath(envFilePath) : undefined;
18
+ if (mode === "local" && !vaultPath) {
19
+ prompts.error(`VAULT_PATH is empty or missing in ${targetDir}/.env — cannot start the container.`);
20
+ return 1;
21
+ }
22
+ if (mode === "local" && !hasEnvPublicUrl(envFilePath)) {
23
+ prompts.error(`PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` +
24
+ `Add this line to your .env:\n PUBLIC_URL=http://localhost:${port}`);
25
+ return 1;
26
+ }
27
+ const image = mode === "local" ? LOCAL_IMAGE : REMOTE_IMAGE;
28
+ if (!docker.isDaemonRunning()) {
29
+ prompts.error("Container runtime not running — start Docker Desktop, Colima,\n" +
30
+ "OrbStack, or another Docker-compatible runtime.");
31
+ return 1;
32
+ }
33
+ const spinner = prompts.spinner();
34
+ spinner.start(`Pulling ${image}`);
35
+ const imagePulled = docker.pullImage(image);
36
+ if (!imagePulled) {
37
+ spinner.stop("Image pull failed — see output above.");
38
+ return 1;
39
+ }
40
+ spinner.stop("Image pulled.");
41
+ docker.stopAndRemoveContainer();
42
+ prompts.log("Starting container...");
43
+ const containerStarted = docker.dockerRun({
44
+ mode,
45
+ envFilePath,
46
+ port,
47
+ vaultPath,
48
+ });
49
+ if (!containerStarted) {
50
+ prompts.error("docker run failed — see output above.");
51
+ return 1;
52
+ }
53
+ spinner.start("Waiting for the server to come up");
54
+ const healthy = await pollHealth({
55
+ url: `http://127.0.0.1:${port}/healthz`,
56
+ timeoutMs: deps.healthTimeoutMs,
57
+ }, fetchFn);
58
+ if (!healthy) {
59
+ spinner.stop(`Server did not respond within 2 minutes — check: docker logs ${CONTAINER_NAME}`);
60
+ return 1;
61
+ }
62
+ spinner.stop("Server is up — health check passed.");
63
+ prompts.outro("Upgrade complete.");
64
+ return 0;
65
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vault-cortex",
3
- "version": "0.4.4",
3
+ "version": "0.4.6-beta.26",
4
4
  "description": "Set up a Vault Cortex MCP server for your Obsidian vault in one command: npx vault-cortex init",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,6 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
- "templates",
13
12
  "README.md"
14
13
  ],
15
14
  "engines": {
@@ -1,66 +0,0 @@
1
- # vault-cortex — local quickstart
2
- #
3
- # Run vault-cortex against a local Obsidian vault with just Docker.
4
- # No cloud, no Obsidian Sync, no AWS — just your vault folder and a bearer token.
5
- #
6
- # 1. cp .env.example .env (then fill in MCP_AUTH_TOKEN and VAULT_PATH)
7
- # 2. docker compose up
8
- # 3. Connect your MCP client to http://localhost:8000/mcp
9
- # (Claude Desktop's connector dialog requires https — register a
10
- # localhost server via the mcp-remote bridge; see the local README)
11
- #
12
- # Full docs: https://github.com/aliasunder/vault-cortex
13
-
14
- name: vault-cortex
15
-
16
- services:
17
- vault-cortex:
18
- image: ghcr.io/aliasunder/vault-cortex:latest
19
- container_name: vault-cortex
20
- restart: unless-stopped
21
- environment:
22
- PORT: "8000"
23
- HOST: "0.0.0.0"
24
- VAULT_PATH: /vault
25
- INDEX_DB_PATH: /data/index.db
26
- MCP_AUTH_TOKEN: "${MCP_AUTH_TOKEN:?Set MCP_AUTH_TOKEN in .env — see .env.example}"
27
- PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8000}
28
- EMBEDDING_ENABLED: ${EMBEDDING_ENABLED:-true}
29
- RERANK_MODE: ${RERANK_MODE:-blended}
30
- MEMORY_ENABLED: ${MEMORY_ENABLED:-true}
31
- MEMORY_DIR: ${MEMORY_DIR:-About Me}
32
- LOG_LEVEL: ${LOG_LEVEL:-info}
33
- LOG_DIR: ${LOG_DIR:-}
34
- LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
35
- TZ: ${TZ:-UTC}
36
- # Windows: set WINDOWS_MODE=true in .env when your vault is on a C: drive
37
- # (polling watcher + rename-based moves across the Docker Desktop/WSL2 bridge).
38
- WINDOWS_MODE: ${WINDOWS_MODE:-false}
39
- # Optional overrides. When unset, the server applies smart defaults
40
- # (<MEMORY_DIR> below is the resolved MEMORY_DIR value, default "About Me"):
41
- # PROTECTED_PATHS default: "<MEMORY_DIR>, Daily Notes" (blocked from vault_delete_note)
42
- # ORPHAN_EXCLUDE_FOLDERS default: "Daily Notes, Templates, <MEMORY_DIR>" (excluded from vault_find_orphans)
43
- # SERVICE_DOCUMENTATION_URL default: https://github.com/aliasunder/vault-cortex
44
- PROTECTED_PATHS: ${PROTECTED_PATHS:-}
45
- ORPHAN_EXCLUDE_FOLDERS: ${ORPHAN_EXCLUDE_FOLDERS:-}
46
- SERVICE_DOCUMENTATION_URL: ${SERVICE_DOCUMENTATION_URL:-}
47
- volumes:
48
- - "${VAULT_PATH:?Set VAULT_PATH to your Obsidian vault folder}:/vault:rw"
49
- - mcp_data:/data
50
- ports:
51
- - "${PORT:-8000}:8000"
52
- healthcheck:
53
- test:
54
- [
55
- "CMD",
56
- "node",
57
- "-e",
58
- "fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
59
- ]
60
- interval: 15s
61
- timeout: 5s
62
- retries: 3
63
- start_period: 20s
64
-
65
- volumes:
66
- mcp_data:
@@ -1,85 +0,0 @@
1
- # vault-cortex — remote quickstart (Obsidian Sync)
2
- #
3
- # Run vault-cortex on a VPS with Obsidian Sync for remote access from any device.
4
- # One container: the :remote image bundles Obsidian Sync and the MCP server
5
- # under s6-overlay supervision.
6
- #
7
- # 1. cp .env.example .env (then fill in required values)
8
- # 2. docker compose up -d
9
- # 3. Connect your MCP client to http://<your-server>:8000/mcp
10
- #
11
- # Compose is optional — the same container runs with plain `docker run`
12
- # (or podman/nerdctl); see the README for the one-liner.
13
- #
14
- # Full docs: https://github.com/aliasunder/vault-cortex
15
-
16
- name: vault-cortex
17
-
18
- services:
19
- vault-cortex:
20
- image: ghcr.io/aliasunder/vault-cortex:remote
21
- container_name: vault-cortex
22
- hostname: ${DEVICE_NAME:-vault-cortex}
23
- restart: unless-stopped
24
- environment:
25
- # --- Obsidian Sync (s6 init chain + sync service) ---
26
- OBSIDIAN_AUTH_TOKEN: "${OBSIDIAN_AUTH_TOKEN:?Set OBSIDIAN_AUTH_TOKEN — see .env.example}"
27
- VAULT_NAME: "${VAULT_NAME:?Set VAULT_NAME to your Obsidian vault name (case-sensitive)}"
28
- VAULT_PASSWORD: ${VAULT_PASSWORD:-}
29
- PUID: ${PUID:-1000}
30
- PGID: ${PGID:-1000}
31
- DEVICE_NAME: ${DEVICE_NAME:-vault-cortex}
32
- CONFLICT_STRATEGY: ${CONFLICT_STRATEGY:-merge}
33
- SYNC_MODE: ${SYNC_MODE:-bidirectional}
34
- # --- MCP server ---
35
- PORT: "8000"
36
- HOST: "0.0.0.0"
37
- VAULT_PATH: /vault
38
- INDEX_DB_PATH: /data/index.db
39
- MCP_AUTH_TOKEN: "${MCP_AUTH_TOKEN:?Set MCP_AUTH_TOKEN in .env — see .env.example}"
40
- PUBLIC_URL: "${PUBLIC_URL:?Set PUBLIC_URL to your server's public URL}"
41
- EMBEDDING_ENABLED: ${EMBEDDING_ENABLED:-true}
42
- RERANK_MODE: ${RERANK_MODE:-blended}
43
- MEMORY_ENABLED: ${MEMORY_ENABLED:-true}
44
- MEMORY_DIR: ${MEMORY_DIR:-About Me}
45
- LOG_LEVEL: ${LOG_LEVEL:-info}
46
- LOG_DIR: ${LOG_DIR:-/data/logs}
47
- LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
48
- TZ: ${TZ:-UTC}
49
- WINDOWS_MODE: ${WINDOWS_MODE:-false}
50
- # Optional overrides. When unset, the server applies smart defaults
51
- # (<MEMORY_DIR> below is the resolved MEMORY_DIR value, default "About Me"):
52
- # PROTECTED_PATHS default: "<MEMORY_DIR>, Daily Notes" (blocked from vault_delete_note)
53
- # ORPHAN_EXCLUDE_FOLDERS default: "Daily Notes, Templates, <MEMORY_DIR>" (excluded from vault_find_orphans)
54
- # SERVICE_DOCUMENTATION_URL default: https://github.com/aliasunder/vault-cortex
55
- PROTECTED_PATHS: ${PROTECTED_PATHS:-}
56
- ORPHAN_EXCLUDE_FOLDERS: ${ORPHAN_EXCLUDE_FOLDERS:-}
57
- SERVICE_DOCUMENTATION_URL: ${SERVICE_DOCUMENTATION_URL:-}
58
- volumes:
59
- - vault_data:/vault
60
- - mcp_data:/data
61
- - obsidian_config:/home/obsidian/.config
62
- ports:
63
- - "0.0.0.0:${PORT:-8000}:8000"
64
- # start_period covers the s6 init chain (Obsidian login + sync-setup)
65
- # that runs before the MCP server boots.
66
- healthcheck:
67
- test:
68
- [
69
- "CMD",
70
- "node",
71
- "-e",
72
- "fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
73
- ]
74
- interval: 15s
75
- timeout: 5s
76
- retries: 5
77
- start_period: 60s
78
- logging:
79
- driver: json-file
80
- options: { max-size: "10m", max-file: "3" }
81
-
82
- volumes:
83
- vault_data:
84
- mcp_data:
85
- obsidian_config: