vault-cortex 0.7.0 → 0.9.0

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
@@ -15,14 +15,20 @@ and data files — see the
15
15
  The server runs as a Docker container; this CLI scaffolds the config and
16
16
  manages the container so you don't have to.
17
17
 
18
- ![npx vault-cortex init — the interactive setup wizard picks a mode, finds your vault, generates the config, and starts the server](https://raw.githubusercontent.com/aliasunder/vault-cortex/main/assets/demo-cli-init.gif)
18
+ ![npx vault-cortex init — the interactive setup wizard picks a mode, finds your vault, offers the optional settings, generates the config, and starts the server](https://raw.githubusercontent.com/aliasunder/vault-cortex/main/assets/demo-cli-init.gif)
19
19
 
20
20
  ## Commands
21
21
 
22
22
  - [`init`](#init) — interactive setup: scaffold the config, generate the auth
23
23
  token, start the server
24
+ - [`configure`](#configure) — change optional settings interactively and
25
+ restart to apply them
24
26
  - [`upgrade`](#upgrade) — pull the latest image and re-create the container;
25
27
  your data stays
28
+ - [`restart`](#restart) — re-create the container so your `.env` edits take
29
+ effect; no image pull
30
+ - [`logs`](#logs) — show the server's logs, live or after the fact
31
+ - [`down`](#down) — stop and remove the container; your data stays
26
32
  - [`get-sync-token`](#get-sync-token) — generate an Obsidian Sync auth token
27
33
  for remote setups
28
34
 
@@ -40,9 +46,14 @@ What it does:
40
46
  - **Local** — Docker on this machine, your vault folder bind-mounted
41
47
  - **Remote** — a VPS with [Obsidian Sync](https://obsidian.md/sync),
42
48
  reachable from any device
43
- 2. Generates a `.env` file with a securely generated `MCP_AUTH_TOKEN`
44
- 3. Optionally starts the container and waits for the health check
45
- 4. Prints your connection details the MCP URL, your auth token, and how to
49
+ 2. Offers the most common optional settings memory layer and folder,
50
+ file tools, semantic search, port, timezone (plus sync direction for
51
+ remote) press enter to keep the defaults, or pick the ones you want to
52
+ change. Skipped when the target directory already has a `.env` (use
53
+ [`configure`](#configure) instead)
54
+ 3. Generates a `.env` file with a securely generated `MCP_AUTH_TOKEN`
55
+ 4. Optionally starts the container and waits for the health check
56
+ 5. Prints your connection details — the MCP URL, your auth token, and how to
46
57
  connect your client
47
58
 
48
59
  Existing files are never overwritten without asking. During a remote setup,
@@ -63,6 +74,22 @@ Non-interactive example:
63
74
  npx vault-cortex@latest init --yes --vault-path /path/to/YourVault
64
75
  ```
65
76
 
77
+ ## configure
78
+
79
+ Change optional settings on an existing setup:
80
+
81
+ ```bash
82
+ npx vault-cortex@latest configure
83
+ ```
84
+
85
+ Shows the same settings chooser as [`init`](#init) — memory layer and
86
+ folder, file tools, semantic search, port, timezone (plus sync direction
87
+ for remote) — pre-filled with your current values, saves your picks to
88
+ `.env`, and offers to restart the container so they take effect. Settings not in the chooser
89
+ live in `.env` too: edit the value there, then run [`restart`](#restart).
90
+
91
+ Use `--dir <path>` if your config isn't in `./vault-cortex`.
92
+
66
93
  ## upgrade
67
94
 
68
95
  Pull the latest image, re-create the container, and verify health:
@@ -88,6 +115,55 @@ include Compose files you can use directly. If you set up with Compose, stick
88
115
  with Compose for updates too (`docker compose pull && docker compose up -d`)
89
116
  — the CLI and Compose manage the container independently.
90
117
 
118
+ ## restart
119
+
120
+ Re-create the container from your `.env` and verify health:
121
+
122
+ ```bash
123
+ npx vault-cortex@latest restart
124
+ ```
125
+
126
+ Use it after editing `.env` — settings are only read when the container is
127
+ created, so a plain `docker restart` won't pick them up, but this will. Unlike
128
+ [`upgrade`](#upgrade), it never pulls a new image: you get the same server
129
+ version back, with your current settings applied.
130
+
131
+ Use `--dir <path>` if your config isn't in `./vault-cortex`.
132
+
133
+ ## logs
134
+
135
+ Show the server's logs:
136
+
137
+ ```bash
138
+ npx vault-cortex@latest logs
139
+ ```
140
+
141
+ Flags:
142
+
143
+ - `--follow` — keep streaming new output until you press ctrl-C
144
+ - `--since <time>` — only logs newer than this (e.g. `10m`, `2h`, or a
145
+ timestamp)
146
+ - `--dir <path>` — directory containing `.env` (default `./vault-cortex`)
147
+
148
+ ```bash
149
+ npx vault-cortex@latest logs --follow --since 10m
150
+ ```
151
+
152
+ ## down
153
+
154
+ Stop and remove the container:
155
+
156
+ ```bash
157
+ npx vault-cortex@latest down
158
+ ```
159
+
160
+ Safe by design: your vault, search index, and `.env` settings all live
161
+ outside the container, so nothing is lost. Start again any time with
162
+ [`restart`](#restart). Running `down` when nothing is running is fine — it
163
+ just tells you there's nothing to stop.
164
+
165
+ Use `--dir <path>` if your config isn't in `./vault-cortex`.
166
+
91
167
  ## get-sync-token
92
168
 
93
169
  Generate an [Obsidian Sync](https://obsidian.md/sync) auth token — needed for
@@ -0,0 +1,64 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { recreateContainer, requireInitializedDir, resolveDeployment, } from "./lifecycle.js";
3
+ import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, readOptionalValue, } from "./optional-settings.js";
4
+ /**
5
+ * Interactively changes optional settings in an existing deployment's .env,
6
+ * then offers to re-create the container so the new values take effect (the
7
+ * env-file is only read at container creation). The edit succeeds on its own:
8
+ * a stopped daemon or a declined restart still exits 0 with the settings
9
+ * saved and a restart hint printed.
10
+ */
11
+ export const runConfigure = async (flags, deps) => {
12
+ const { prompts, docker, fetchFn } = deps;
13
+ prompts.intro("vault-cortex configure");
14
+ // Editing settings only needs an init'd .env — the full start validation
15
+ // (VAULT_PATH, PUBLIC_URL) runs later, only when a restart is requested.
16
+ const initialized = requireInitializedDir(flags.dir, prompts);
17
+ if (!initialized)
18
+ return 1;
19
+ const { targetDir, envFilePath, mode } = initialized;
20
+ const envContent = readFileSync(envFilePath, "utf8");
21
+ const pickedOverrides = await askOptionalSettings({ mode, envContent }, prompts);
22
+ if (Object.keys(pickedOverrides).length === 0) {
23
+ prompts.log("No settings selected — nothing changed.");
24
+ prompts.outro("Done.");
25
+ return 0;
26
+ }
27
+ const overrides = derivePublicUrlOverride(envContent, pickedOverrides);
28
+ const changedNames = Object.keys(overrides);
29
+ writeFileSync(envFilePath, applyOptionalSettings(envContent, overrides));
30
+ prompts.log(`Updated ${changedNames.join(", ")} in ${targetDir}/.env.`);
31
+ // A custom PUBLIC_URL is never rewritten (see derivePublicUrlOverride), but
32
+ // a port change can still strand it — surface the consequence non-blocking.
33
+ const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL");
34
+ if (pickedOverrides.PORT && !overrides.PUBLIC_URL && currentPublicUrl) {
35
+ prompts.warn(`PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.`);
36
+ }
37
+ const restartHint = `Apply the new settings with: npx vault-cortex restart --dir "${targetDir}"`;
38
+ if (!docker.isDaemonRunning()) {
39
+ prompts.warn(`Container runtime not running — settings saved.\n${restartHint}`);
40
+ prompts.outro("Done.");
41
+ return 0;
42
+ }
43
+ const restartNow = await prompts.confirm("Restart the container now to apply the new settings?", true);
44
+ if (!restartNow) {
45
+ prompts.log(restartHint);
46
+ prompts.outro("Done.");
47
+ return 0;
48
+ }
49
+ // Resolve from disk after the write so the restart honors the new values
50
+ // (a changed PORT must drive the port mapping and health URL).
51
+ const deployment = resolveDeployment(flags.dir, prompts);
52
+ if (!deployment) {
53
+ // The edit already succeeded — don't let the failed restart read as a
54
+ // failed configure.
55
+ prompts.warn(`The restart did not run — your settings are saved. Fix the issue above, then apply them with: npx vault-cortex restart --dir "${targetDir}"`);
56
+ return 1;
57
+ }
58
+ const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
59
+ if (exitCode !== 0)
60
+ return exitCode;
61
+ prompts.log("Applied the current .env settings.");
62
+ prompts.outro("Configure complete.");
63
+ return 0;
64
+ };
package/dist/docker.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  export const LOCAL_IMAGE = "ghcr.io/aliasunder/vault-cortex:latest";
3
3
  export const REMOTE_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote";
4
4
  export const CONTAINER_NAME = "vault-cortex";
@@ -94,6 +94,19 @@ export const buildDockerRunArgs = (params) => {
94
94
  args.push(image);
95
95
  return args;
96
96
  };
97
+ /**
98
+ * Builds the `docker logs` args array. Pure function — no I/O — so it's
99
+ * testable without spawning processes.
100
+ */
101
+ export const buildDockerLogsArgs = (params) => {
102
+ const { follow, since } = params;
103
+ return [
104
+ "logs",
105
+ ...(follow ? ["--follow"] : []),
106
+ ...(since ? ["--since", since] : []),
107
+ CONTAINER_NAME,
108
+ ];
109
+ };
97
110
  export const createDockerRunner = () => ({
98
111
  isDaemonRunning: () => spawnSync("docker", ["info"], { timeout: 5_000 }).status === 0,
99
112
  // stdout is discarded: `docker run -d` prints only the container ID there,
@@ -108,6 +121,33 @@ export const createDockerRunner = () => ({
108
121
  }).status === 0,
109
122
  pullImage: (image) => spawnSync("docker", ["pull", image], { stdio: "inherit" }).status === 0,
110
123
  stopAndRemoveContainer: () => spawnSync("docker", ["rm", "-f", CONTAINER_NAME]).status === 0,
124
+ // `docker rm -f` on a missing container exits 1 on engines < 23 and 0 on
125
+ // >= 23, so stopAndRemoveContainer's status can't distinguish "already
126
+ // gone" from "failed" — callers needing idempotent messaging probe
127
+ // existence first. Output stays piped (discarded): this is a boolean probe.
128
+ containerExists: () => spawnSync("docker", ["container", "inspect", CONTAINER_NAME]).status === 0,
129
+ // Async spawn, not spawnSync: --follow streams until interrupted, and the
130
+ // exit code must be observable after the stream closes.
131
+ streamLogs: (params) => new Promise((resolveExitCode) => {
132
+ const child = spawn("docker", buildDockerLogsArgs(params), {
133
+ stdio: ["ignore", "inherit", "inherit"],
134
+ });
135
+ // ctrl-C delivers SIGINT to the whole foreground process group. Node's
136
+ // default disposition would kill this process before the child's
137
+ // "close" event fires; this no-op keep-alive lets the docker child
138
+ // exit on its own SIGINT, the streams flush, and the exit code
139
+ // propagate. `once` self-removes, so later ctrl-Cs behave normally.
140
+ process.once("SIGINT", () => { });
141
+ child.once("error", (spawnError) => {
142
+ // Event handler, not a catch — but the same "never swallow" rule
143
+ // applies: without this line a spawn failure is a bare exit 1.
144
+ process.stderr.write(`vault-cortex: could not run docker logs — ${spawnError.message}\n`);
145
+ resolveExitCode(1);
146
+ });
147
+ // A null code means the child died to a signal — report the shell
148
+ // convention for ctrl-C (128 + SIGINT = 130).
149
+ child.once("close", (code) => resolveExitCode(code ?? 130));
150
+ }),
111
151
  runObsidianLogin: (configMountPath) => spawnSync("docker", buildObsidianLoginArgs({
112
152
  configMountPath,
113
153
  uid: process.getuid?.(),
package/dist/env.js CHANGED
@@ -10,9 +10,10 @@
10
10
  // └─────────────────────────────────────────────────────────────────────────┘
11
11
  // sync:local-optional:begin
12
12
  const LOCAL_OPTIONAL_BLOCK = `# Optional ──────────────────────────────────────────────────
13
- # To change a setting: edit its value (uncommenting it first if needed),
14
- # then apply with "npx vault-cortex upgrade" (restart alone does not
15
- # re-read this file).
13
+ # To change a setting: run "npx vault-cortex configure", or edit its
14
+ # value here (uncommenting it first if needed) and apply with
15
+ # "npx vault-cortex restart" (plain docker restart does not re-read
16
+ # this file).
16
17
 
17
18
  # Public URL for OAuth issuer URL in discovery metadata (default: http://localhost:8000).
18
19
  # Override if you expose the server on a different URL (e.g. via a reverse proxy).
@@ -100,9 +101,10 @@ WINDOWS_MODE=false
100
101
  // └─────────────────────────────────────────────────────────────────────────┘
101
102
  // sync:remote-optional:begin
102
103
  const REMOTE_OPTIONAL_BLOCK = `# Optional ──────────────────────────────────────────────────
103
- # To change a setting: edit its value (uncommenting it first if needed),
104
- # then apply with "npx vault-cortex upgrade" (restart alone does not
105
- # re-read this file).
104
+ # To change a setting: run "npx vault-cortex configure", or edit its
105
+ # value here (uncommenting it first if needed) and apply with
106
+ # "npx vault-cortex restart" (plain docker restart does not re-read
107
+ # this file).
106
108
 
107
109
  # Your IANA timezone — affects daily note resolution and memory timestamps.
108
110
  # TZ=America/New_York
@@ -204,10 +206,6 @@ MCP_AUTH_TOKEN=${answers.mcpAuthToken}
204
206
  # Absolute path to your Obsidian vault on this machine.
205
207
  VAULT_PATH=${answers.vaultPath}
206
208
 
207
- # Public URL for OAuth issuer URL in discovery metadata.
208
- # Override if you expose the server on a different URL (e.g. via a reverse proxy).
209
- PUBLIC_URL=http://localhost:8000
210
-
211
209
  ${LOCAL_OPTIONAL_BLOCK}`;
212
210
  export const buildRemoteEnv = (answers) => {
213
211
  const vaultPasswordLines = answers.vaultPassword === undefined
package/dist/init.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { join, resolve } from "node:path";
2
3
  import { buildLocalEnv, buildRemoteEnv } from "./env.js";
3
4
  import { captureObsidianToken } from "./get-sync-token.js";
4
5
  import { buildLocalConnectMessage, buildRemoteConnectMessage, } from "./messages.js";
5
6
  import { pollHealth } from "./docker.js";
7
+ import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, } from "./optional-settings.js";
6
8
  import { buildFilesToWrite, readEnvPort, writeFiles, } from "./scaffold.js";
7
9
  import { generateToken } from "./token.js";
8
10
  import { expandTilde, validateVaultPath } from "./vault.js";
@@ -203,10 +205,25 @@ const runLocalInit = async (flags, deps) => {
203
205
  placeholder: DEFAULT_TARGET_DIR,
204
206
  }))));
205
207
  const token = generateToken();
208
+ // Guided optional settings: the chooser reads current values from the
209
+ // generated defaults; enter with nothing picked keeps them all. --yes
210
+ // skips the chooser (non-interactive by contract), and so does an existing
211
+ // .env — the conflict prompt defaults to keeping it, which would discard
212
+ // the answers; settings on an existing deployment are configure's job.
213
+ const defaultEnvContent = buildLocalEnv({ mcpAuthToken: token, vaultPath });
214
+ const envAlreadyExists = existsSync(join(targetDir, ".env"));
215
+ const offerSettingsChooser = !flags.yes && !envAlreadyExists;
216
+ if (!flags.yes && envAlreadyExists) {
217
+ prompts.log('Found an existing .env — settings prompts skipped. Adjust settings with "npx vault-cortex configure".');
218
+ }
219
+ const optionalOverrides = offerSettingsChooser
220
+ ? await askOptionalSettings({ mode: "local", envContent: defaultEnvContent }, prompts)
221
+ : {};
222
+ const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
206
223
  // Conflict policy: identical existing files are skipped silently;
207
224
  // differing ones prompt per file (default keep). --yes never overwrites —
208
225
  // any differing file becomes an exit-1 below, leaving it untouched.
209
- const files = buildFilesToWrite(buildLocalEnv({ mcpAuthToken: token, vaultPath }));
226
+ const files = buildFilesToWrite(envContent);
210
227
  const resolveConflict = flags.yes ? keepExisting : confirmOverwrite(prompts);
211
228
  const results = await writeFiles({ targetDir, files }, resolveConflict);
212
229
  reportWrites({ targetDir, results }, prompts);
@@ -262,13 +279,24 @@ const runRemoteInit = async (flags, deps) => {
262
279
  ? await prompts.password("Vault encryption password:")
263
280
  : undefined;
264
281
  const token = generateToken();
265
- const envContent = buildRemoteEnv({
282
+ // Guided optional settings, mirroring the local flow — remote also offers
283
+ // SYNC_MODE. Remote init is always interactive (no --yes), so only an
284
+ // existing .env skips the chooser here.
285
+ const defaultEnvContent = buildRemoteEnv({
266
286
  mcpAuthToken: token,
267
287
  publicUrl,
268
288
  obsidianAuthToken,
269
289
  vaultName,
270
290
  vaultPassword,
271
291
  });
292
+ const envAlreadyExists = existsSync(join(targetDir, ".env"));
293
+ if (envAlreadyExists) {
294
+ prompts.log('Found an existing .env — settings prompts skipped. Adjust settings with "npx vault-cortex configure".');
295
+ }
296
+ const optionalOverrides = envAlreadyExists
297
+ ? {}
298
+ : await askOptionalSettings({ mode: "remote", envContent: defaultEnvContent }, prompts);
299
+ const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
272
300
  const files = buildFilesToWrite(envContent);
273
301
  const results = await writeFiles({ targetDir, files }, confirmOverwrite(prompts));
274
302
  reportWrites({ targetDir, results }, prompts);
@@ -0,0 +1,166 @@
1
+ import { join, resolve } from "node:path";
2
+ import { CONTAINER_NAME, 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
+ /**
7
+ * Light gate for commands that operate on an existing deployment without
8
+ * starting a container: resolves --dir and confirms an init'd .env exists.
9
+ * Full start-readiness validation lives in resolveDeployment.
10
+ */
11
+ export const requireInitializedDir = (dirFlag, prompts) => {
12
+ const targetDir = resolve(expandTilde(dirFlag ?? DEFAULT_TARGET_DIR));
13
+ const envFilePath = join(targetDir, ".env");
14
+ const mode = detectMode(envFilePath);
15
+ if (!mode) {
16
+ prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex init\` first.`);
17
+ return undefined;
18
+ }
19
+ return { targetDir, envFilePath, mode };
20
+ };
21
+ /**
22
+ * Resolves --dir to the on-disk deployment and validates its .env for a
23
+ * container start: mode detection, VAULT_PATH (local), and PUBLIC_URL
24
+ * (local). Reports each failure via prompts.error and returns undefined.
25
+ * Shared by upgrade and restart so their precondition checks can't drift.
26
+ */
27
+ export const resolveDeployment = (dirFlag, prompts) => {
28
+ const initialized = requireInitializedDir(dirFlag, prompts);
29
+ if (!initialized)
30
+ return undefined;
31
+ const { targetDir, envFilePath, mode } = initialized;
32
+ const port = readEnvPort(envFilePath);
33
+ const vaultPath = mode === "local" ? readEnvVaultPath(envFilePath) : undefined;
34
+ if (mode === "local" && !vaultPath) {
35
+ prompts.error(`VAULT_PATH is empty or missing in ${targetDir}/.env — cannot start the container.`);
36
+ return undefined;
37
+ }
38
+ if (mode === "local" && !hasEnvPublicUrl(envFilePath)) {
39
+ prompts.error(`PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` +
40
+ `Add this line to your .env:\n PUBLIC_URL=http://localhost:${port}`);
41
+ return undefined;
42
+ }
43
+ return { mode, targetDir, envFilePath, port, vaultPath };
44
+ };
45
+ /**
46
+ * Verifies the container runtime is reachable, reporting the shared error
47
+ * message when it isn't. Callers early-return on false.
48
+ */
49
+ export const ensureDaemonRunning = (docker, prompts) => {
50
+ if (docker.isDaemonRunning())
51
+ return true;
52
+ prompts.error("Container runtime not running — start Docker Desktop, Colima,\n" +
53
+ "OrbStack, or another Docker-compatible runtime.");
54
+ return false;
55
+ };
56
+ /**
57
+ * Stop-and-remove → docker run → health poll, with the shared messaging.
58
+ * Returns a process exit code. Callers own the daemon check and any
59
+ * image-pull step.
60
+ */
61
+ export const recreateContainer = async (params, deps) => {
62
+ const { deployment, healthTimeoutMs } = params;
63
+ const { prompts, docker, fetchFn } = deps;
64
+ // `docker rm -f` on a missing container exits non-zero on engines < 23,
65
+ // so the bare return value can't distinguish "nothing to remove" from a
66
+ // real failure — only treat removal as failed when a container exists.
67
+ // Proceeding after a genuine failure would surface as a confusing
68
+ // "container name already in use" from docker run.
69
+ if (docker.containerExists() && !docker.stopAndRemoveContainer()) {
70
+ prompts.error(`Could not remove the existing container — check: docker rm -f ${CONTAINER_NAME}`);
71
+ return 1;
72
+ }
73
+ prompts.log("Starting container...");
74
+ const containerStarted = docker.dockerRun({
75
+ mode: deployment.mode,
76
+ envFilePath: deployment.envFilePath,
77
+ port: deployment.port,
78
+ vaultPath: deployment.vaultPath,
79
+ });
80
+ if (!containerStarted) {
81
+ prompts.error("docker run failed — see output above.");
82
+ return 1;
83
+ }
84
+ const spinner = prompts.spinner();
85
+ spinner.start("Waiting for the server to come up");
86
+ const healthy = await pollHealth({
87
+ url: `http://127.0.0.1:${deployment.port}/healthz`,
88
+ timeoutMs: healthTimeoutMs,
89
+ }, fetchFn);
90
+ if (!healthy) {
91
+ spinner.stop(`Server did not respond within 2 minutes — check: docker logs ${CONTAINER_NAME}`);
92
+ return 1;
93
+ }
94
+ spinner.stop("Server is up — health check passed.");
95
+ return 0;
96
+ };
97
+ /**
98
+ * Stops and removes the container. Vault data, the search index, and .env
99
+ * settings all live outside the container (bind mount, named volumes, host
100
+ * file), so this is always safe.
101
+ */
102
+ export const runDown = async (flags, deps) => {
103
+ const { prompts, docker } = deps;
104
+ prompts.intro("vault-cortex down");
105
+ // Teardown only needs to confirm this is an init'd directory — the full
106
+ // .env validation (VAULT_PATH, PUBLIC_URL) guards container starts.
107
+ const initialized = requireInitializedDir(flags.dir, prompts);
108
+ if (!initialized)
109
+ return 1;
110
+ if (!ensureDaemonRunning(docker, prompts))
111
+ return 1;
112
+ if (!docker.containerExists()) {
113
+ prompts.log("No vault-cortex container found — nothing to stop.");
114
+ prompts.outro("Done.");
115
+ return 0;
116
+ }
117
+ if (!docker.stopAndRemoveContainer()) {
118
+ prompts.error(`Could not remove the container — check: docker rm -f ${CONTAINER_NAME}`);
119
+ return 1;
120
+ }
121
+ prompts.log("Container stopped and removed. Your vault data, search index, and settings are untouched.");
122
+ prompts.outro(`Start again with: npx vault-cortex restart --dir "${initialized.targetDir}"`);
123
+ return 0;
124
+ };
125
+ /**
126
+ * Streams `docker logs` for the vault-cortex container. The stream's exit
127
+ * code passes through as the command's exit code; no outro follows the raw
128
+ * docker output.
129
+ */
130
+ export const runLogs = async (flags, deps) => {
131
+ const { prompts, docker } = deps;
132
+ prompts.intro("vault-cortex logs");
133
+ const initialized = requireInitializedDir(flags.dir, prompts);
134
+ if (!initialized)
135
+ return 1;
136
+ if (!ensureDaemonRunning(docker, prompts))
137
+ return 1;
138
+ if (!docker.containerExists()) {
139
+ prompts.error("No vault-cortex container — start it with `npx vault-cortex restart`.");
140
+ return 1;
141
+ }
142
+ return await docker.streamLogs({
143
+ follow: Boolean(flags.follow),
144
+ since: flags.since,
145
+ });
146
+ };
147
+ /**
148
+ * Re-creates the container from the .env on disk and verifies health.
149
+ * Unlike `docker restart`, this applies .env edits (the env-file is only
150
+ * read at container creation); unlike upgrade, it never pulls an image.
151
+ */
152
+ export const runRestart = async (flags, deps) => {
153
+ const { prompts, docker, fetchFn } = deps;
154
+ prompts.intro("vault-cortex restart");
155
+ const deployment = resolveDeployment(flags.dir, prompts);
156
+ if (!deployment)
157
+ return 1;
158
+ if (!ensureDaemonRunning(docker, prompts))
159
+ return 1;
160
+ const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
161
+ if (exitCode !== 0)
162
+ return exitCode;
163
+ prompts.log("Applied the current .env settings.");
164
+ prompts.outro("Restart complete.");
165
+ return 0;
166
+ };
package/dist/main.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { runConfigure } from "./configure.js";
1
2
  import { createDockerRunner } from "./docker.js";
2
3
  import { runGetSyncToken } from "./get-sync-token.js";
3
4
  import { runInit } from "./init.js";
5
+ import { runDown, runLogs, runRestart } from "./lifecycle.js";
4
6
  import { buildProgram } from "./program.js";
5
7
  import { createPrompts } from "./prompts.js";
6
8
  import { runUpgrade } from "./upgrade.js";
@@ -12,11 +14,29 @@ export const run = async (version) => {
12
14
  docker: createDockerRunner(),
13
15
  fetchFn: fetch,
14
16
  }),
17
+ runConfigure: (flags) => runConfigure(flags, {
18
+ prompts: createPrompts(),
19
+ docker: createDockerRunner(),
20
+ fetchFn: fetch,
21
+ }),
15
22
  runUpgrade: (flags) => runUpgrade(flags, {
16
23
  prompts: createPrompts(),
17
24
  docker: createDockerRunner(),
18
25
  fetchFn: fetch,
19
26
  }),
27
+ runRestart: (flags) => runRestart(flags, {
28
+ prompts: createPrompts(),
29
+ docker: createDockerRunner(),
30
+ fetchFn: fetch,
31
+ }),
32
+ runLogs: (flags) => runLogs(flags, {
33
+ prompts: createPrompts(),
34
+ docker: createDockerRunner(),
35
+ }),
36
+ runDown: (flags) => runDown(flags, {
37
+ prompts: createPrompts(),
38
+ docker: createDockerRunner(),
39
+ }),
20
40
  runGetSyncToken: (flags) => runGetSyncToken(flags, {
21
41
  prompts: createPrompts(),
22
42
  docker: createDockerRunner(),
package/dist/messages.js CHANGED
@@ -110,9 +110,13 @@ ${smokeTest(`${baseUrl}/healthz`)}
110
110
 
111
111
  ${sectionRule("Settings")}
112
112
 
113
- Optional settings (timezone, memory folder, port, logging) are commented
114
- out in ${targetDir}/.env — uncomment, set a value, then apply with
115
- "npx vault-cortex upgrade" (restart alone does not re-read .env).
113
+ Adjust optional settings (memory layer and folder, file tools,
114
+ semantic search, port, timezone):
115
+ npx vault-cortex configure --dir "${targetDir}"
116
+
117
+ Or edit ${targetDir}/.env directly — change a value (uncommenting it
118
+ first if needed), then apply with "npx vault-cortex restart" (plain
119
+ docker restart does not re-read .env).
116
120
 
117
121
  ${updateGuidance(targetDir)}
118
122
 
@@ -171,10 +175,13 @@ ${smokeTest(`${publicUrl}/healthz`)}
171
175
 
172
176
  ${sectionRule("Settings")}
173
177
 
174
- Optional settings (timezone, memory folder, port, logging, sync
175
- behavior) are commented out in ${targetDir}/.env — uncomment, set a
176
- value, then apply with "npx vault-cortex upgrade" (restart alone does
177
- not re-read .env).
178
+ Adjust optional settings (memory layer and folder, file tools,
179
+ semantic search, port, timezone, sync direction):
180
+ npx vault-cortex configure --dir "${targetDir}"
181
+
182
+ Or edit ${targetDir}/.env directly — change a value (uncommenting it
183
+ first if needed), then apply with "npx vault-cortex restart" (plain
184
+ docker restart does not re-read .env).
178
185
 
179
186
  ${updateGuidance(targetDir)}
180
187
 
@@ -0,0 +1,233 @@
1
+ import { DEFAULT_PORT } from "./scaffold.js";
2
+ // The curated prompt set — settings users most often want without reading
3
+ // .env comments. Everything else stays documented-only in the generated
4
+ // optional block, deliberately: every extra prompt costs init flow length.
5
+ const OPTIONAL_SETTINGS = [
6
+ {
7
+ kind: "toggle",
8
+ name: "MEMORY_ENABLED",
9
+ label: "Memory layer",
10
+ question: "Enable the memory layer (About Me/ folder + memory tools)?",
11
+ },
12
+ {
13
+ kind: "folder",
14
+ name: "MEMORY_DIR",
15
+ label: "Memory folder",
16
+ question: "Vault folder for the memory files:",
17
+ defaultValue: "About Me",
18
+ requiresToggle: "MEMORY_ENABLED",
19
+ },
20
+ {
21
+ kind: "toggle",
22
+ name: "FILE_TOOLS_ENABLED",
23
+ label: "File tools",
24
+ question: "Enable file tools (read images, PDFs, and other non-Markdown files)?",
25
+ },
26
+ {
27
+ kind: "toggle",
28
+ name: "EMBEDDING_ENABLED",
29
+ label: "Semantic search",
30
+ question: "Enable semantic search embeddings (richer search, slower first startup)?",
31
+ },
32
+ { kind: "port", name: "PORT", label: "Host port" },
33
+ { kind: "timezone", name: "TZ", label: "Timezone" },
34
+ {
35
+ kind: "choice",
36
+ name: "SYNC_MODE",
37
+ label: "Sync direction",
38
+ question: "Obsidian Sync direction:",
39
+ choices: [
40
+ {
41
+ value: "bidirectional",
42
+ label: "Bidirectional",
43
+ hint: "pull remote changes and push server-side edits",
44
+ },
45
+ {
46
+ value: "pull-only",
47
+ label: "Pull-only",
48
+ hint: "receive changes but never push",
49
+ },
50
+ {
51
+ value: "push-only",
52
+ label: "Push-only",
53
+ hint: "push changes but never pull",
54
+ },
55
+ ],
56
+ defaultValue: "bidirectional",
57
+ remoteOnly: true,
58
+ },
59
+ ];
60
+ /** Matches the full active (uncommented) assignment line for a var. */
61
+ const activeLinePattern = (name) => new RegExp(`^${name}=.*$`, "m");
62
+ /** Matches the full commented-out assignment line (`# VAR=...`) for a var. */
63
+ const commentedLinePattern = (name) => new RegExp(`^# ${name}=.*$`, "m");
64
+ /**
65
+ * Reads a var's current value from .env content. A commented-out or missing
66
+ * line returns undefined — both mean "the server uses its built-in default",
67
+ * which is exactly what the prompts need to distinguish. Duplicate lines
68
+ * report the LAST value, matching docker --env-file precedence — hints,
69
+ * prompt seeding, and the PUBLIC_URL derivation must reason from the value
70
+ * that actually takes effect.
71
+ */
72
+ export const readOptionalValue = (envContent, name) => {
73
+ const matches = [...envContent.matchAll(new RegExp(`^${name}=(.*)$`, "gm"))];
74
+ return matches.at(-1)?.[1].trim();
75
+ };
76
+ /**
77
+ * Applies chosen values to .env content as a pure text transform: every
78
+ * active line for the var is replaced (docker --env-file gives the last
79
+ * duplicate precedence, so a single-line replace could leave a stale
80
+ * duplicate winning), a commented-out line is uncommented and replaced, and
81
+ * a var with no line at all (a .env predating the setting) is appended —
82
+ * the chosen value must land in the file, never be silently dropped.
83
+ */
84
+ export const applyOptionalSettings = (envContent, overrides) => Object.entries(overrides).reduce((content, [name, value]) => {
85
+ // Fresh RegExp per use (the /g flag makes instances stateful via
86
+ // lastIndex); function replacements avoid $-pattern interpretation in
87
+ // values, same as patchEnvObsidianToken.
88
+ const everyActiveLine = new RegExp(`^${name}=.*$`, "gm");
89
+ if (activeLinePattern(name).test(content)) {
90
+ return content.replace(everyActiveLine, () => `${name}=${value}`);
91
+ }
92
+ if (commentedLinePattern(name).test(content)) {
93
+ return content.replace(commentedLinePattern(name), () => `${name}=${value}`);
94
+ }
95
+ return `${content.trimEnd()}\n\n${name}=${value}\n`;
96
+ }, envContent);
97
+ /**
98
+ * A PORT override moves the server, and the local quickstart derives
99
+ * PUBLIC_URL (the OAuth issuer) from that port. When the current PUBLIC_URL
100
+ * is exactly the derived http://localhost:<current port> form, it follows
101
+ * the new port — otherwise the advertised OAuth discovery endpoints would
102
+ * point at a port nothing listens on. A custom PUBLIC_URL (reverse proxy,
103
+ * remote domain) is the user's own and is never touched.
104
+ */
105
+ export const derivePublicUrlOverride = (envContent, overrides) => {
106
+ const newPort = overrides.PORT;
107
+ if (!newPort)
108
+ return overrides;
109
+ const currentPort = readOptionalValue(envContent, "PORT") ?? String(DEFAULT_PORT);
110
+ const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL");
111
+ if (currentPublicUrl !== `http://localhost:${currentPort}`)
112
+ return overrides;
113
+ return { ...overrides, PUBLIC_URL: `http://localhost:${newPort}` };
114
+ };
115
+ /**
116
+ * The .env spellings the server reads as "off" — env-var's asBool accepts
117
+ * 0/1 alongside true/false. An absent or unrecognized value falls to the
118
+ * server default, which is enabled for every curated toggle.
119
+ */
120
+ const isDisabledToggleValue = (value) => ["false", "0"].includes((value ?? "").toLowerCase());
121
+ /**
122
+ * Plain digits in the TCP port range. Number() coercion is not enough:
123
+ * it accepts "1e4"/"0x1F40"/"+9000", which readEnvPort's /^PORT=(\d+)/
124
+ * would later fail to read back — silently falling to the default port.
125
+ */
126
+ const isValidPort = (value) => {
127
+ if (!/^\d+$/.test(value))
128
+ return false;
129
+ const port = Number(value);
130
+ return port >= 1 && port <= 65535;
131
+ };
132
+ /**
133
+ * The engine's own IANA zone validation: Intl.DateTimeFormat throws a
134
+ * RangeError for an unknown timeZone, so no hand-rolled zone list to drift.
135
+ */
136
+ const isValidTimezone = (value) => {
137
+ try {
138
+ new Intl.DateTimeFormat("en-US", { timeZone: value });
139
+ return true;
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ };
145
+ /** Re-prompts until the answer is a valid port number. */
146
+ const askPort = async (currentValue, prompts) => {
147
+ const answer = (await prompts.text("Host port for the server:", {
148
+ defaultValue: currentValue ?? String(DEFAULT_PORT),
149
+ placeholder: String(DEFAULT_PORT),
150
+ })).trim();
151
+ if (isValidPort(answer))
152
+ return answer;
153
+ prompts.error("PORT must be a whole number between 1 and 65535.");
154
+ return askPort(currentValue, prompts);
155
+ };
156
+ /** Re-prompts until the answer is a zone the runtime recognizes. */
157
+ const askTimezone = async (currentValue, prompts) => {
158
+ const answer = (await prompts.text("Your IANA timezone:", {
159
+ defaultValue: currentValue,
160
+ placeholder: "America/New_York",
161
+ })).trim();
162
+ if (answer !== "" && isValidTimezone(answer))
163
+ return answer;
164
+ prompts.error(`"${answer}" is not a recognized IANA timezone (e.g. America/New_York, Europe/London).`);
165
+ return askTimezone(currentValue, prompts);
166
+ };
167
+ /** Re-prompts until the answer is a non-empty folder name. */
168
+ const askFolder = async (params, prompts) => {
169
+ const { question, currentValue, defaultValue } = params;
170
+ const answer = (await prompts.text(question, {
171
+ defaultValue: currentValue ?? defaultValue,
172
+ placeholder: defaultValue,
173
+ })).trim();
174
+ if (answer !== "")
175
+ return answer;
176
+ prompts.error("The folder name can't be empty.");
177
+ return askFolder(params, prompts);
178
+ };
179
+ /** Routes a picked setting to its kind's prompt and returns the .env value. */
180
+ const askSettingValue = async (params, prompts) => {
181
+ const { setting, currentValue } = params;
182
+ switch (setting.kind) {
183
+ case "toggle": {
184
+ const enabled = await prompts.confirm(setting.question, !isDisabledToggleValue(currentValue));
185
+ return String(enabled);
186
+ }
187
+ case "port":
188
+ return askPort(currentValue, prompts);
189
+ case "timezone":
190
+ return askTimezone(currentValue, prompts);
191
+ case "folder":
192
+ return askFolder({
193
+ question: setting.question,
194
+ currentValue,
195
+ defaultValue: setting.defaultValue,
196
+ }, prompts);
197
+ case "choice":
198
+ return prompts.select(setting.question, setting.choices, currentValue ?? setting.defaultValue);
199
+ }
200
+ };
201
+ /**
202
+ * The guided optional-settings flow shared by init and configure: one
203
+ * multiselect chooser (enter with nothing picked = change nothing), then one
204
+ * prompt per picked setting. Returns the chosen values keyed by var name —
205
+ * the caller owns applying them via applyOptionalSettings.
206
+ */
207
+ export const askOptionalSettings = async (params, prompts) => {
208
+ const { mode, envContent } = params;
209
+ const offeredSettings = OPTIONAL_SETTINGS.filter((setting) => !setting.remoteOnly || mode === "remote");
210
+ const chooserOptions = offeredSettings.map((setting) => {
211
+ const currentValue = readOptionalValue(envContent, setting.name);
212
+ const requiredToggle = OPTIONAL_SETTINGS.find((candidate) => candidate.name === setting.requiresToggle);
213
+ const dependencyNote = requiredToggle &&
214
+ isDisabledToggleValue(readOptionalValue(envContent, requiredToggle.name))
215
+ ? ` · not used while ${requiredToggle.label} is off`
216
+ : "";
217
+ return {
218
+ value: setting.name,
219
+ label: setting.label,
220
+ hint: `${setting.name} · currently ${currentValue || "not set"}${dependencyNote}`,
221
+ };
222
+ });
223
+ const pickedNames = await prompts.multiselect("Any optional settings to change? (press enter to skip)", chooserOptions);
224
+ // Sequential prompting: answers are gathered one at a time in the curated
225
+ // order, so the record builds up inside an honest loop.
226
+ const overrides = {};
227
+ for (const setting of offeredSettings) {
228
+ if (!pickedNames.includes(setting.name))
229
+ continue;
230
+ overrides[setting.name] = await askSettingValue({ setting, currentValue: readOptionalValue(envContent, setting.name) }, prompts);
231
+ }
232
+ return overrides;
233
+ };
package/dist/program.js CHANGED
@@ -15,6 +15,13 @@ export const buildProgram = (options) => {
15
15
  .action(async (flags) => {
16
16
  process.exitCode = await options.runInit(flags);
17
17
  });
18
+ program
19
+ .command("configure")
20
+ .description("Change optional settings interactively and restart to apply them")
21
+ .option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
22
+ .action(async (flags) => {
23
+ process.exitCode = await options.runConfigure(flags);
24
+ });
18
25
  program
19
26
  .command("upgrade")
20
27
  .description("Pull the latest image, re-create the container, and verify health")
@@ -22,6 +29,29 @@ export const buildProgram = (options) => {
22
29
  .action(async (flags) => {
23
30
  process.exitCode = await options.runUpgrade(flags);
24
31
  });
32
+ program
33
+ .command("restart")
34
+ .description("Re-create the container from .env and verify health (applies .env edits; no image pull)")
35
+ .option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
36
+ .action(async (flags) => {
37
+ process.exitCode = await options.runRestart(flags);
38
+ });
39
+ program
40
+ .command("logs")
41
+ .description("Show container logs")
42
+ .option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
43
+ .option("--follow", "stream new log output until interrupted (ctrl-C)")
44
+ .option("--since <time>", 'only logs newer than this (e.g. "10m", "2h", or a timestamp)')
45
+ .action(async (flags) => {
46
+ process.exitCode = await options.runLogs(flags);
47
+ });
48
+ program
49
+ .command("down")
50
+ .description("Stop and remove the container — vault data and settings are preserved")
51
+ .option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
52
+ .action(async (flags) => {
53
+ process.exitCode = await options.runDown(flags);
54
+ });
25
55
  program
26
56
  .command("get-sync-token")
27
57
  .description("Generate an Obsidian Sync auth token via Docker and print it or write it to .env")
package/dist/prompts.js CHANGED
@@ -28,6 +28,9 @@ export const createPrompts = () => ({
28
28
  warn: (message) => clack.log.warn(message),
29
29
  error: (message) => clack.log.error(message),
30
30
  select: async (message, options, initialValue) => exitOnCancel(await clack.select({ message, options, initialValue })),
31
+ // required: false makes an empty submission legal — the Prompts contract
32
+ // promises "no picks" resolves to [] instead of a re-prompt loop.
33
+ multiselect: async (message, options) => exitOnCancel(await clack.multiselect({ message, options, required: false })),
31
34
  text: async (message, options = {}) => exitOnCancel(await clack.text({
32
35
  message,
33
36
  placeholder: options.placeholder,
package/dist/upgrade.js CHANGED
@@ -1,35 +1,19 @@
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";
1
+ import { LOCAL_IMAGE, REMOTE_IMAGE } from "./docker.js";
2
+ import { ensureDaemonRunning, recreateContainer, resolveDeployment, } from "./lifecycle.js";
3
+ /**
4
+ * Pulls the latest image, re-creates the container, and verifies health.
5
+ * The only lifecycle command that contacts the registry — restart re-creates
6
+ * from the image already on disk.
7
+ */
6
8
  export const runUpgrade = async (flags, deps) => {
7
9
  const { prompts, docker, fetchFn } = deps;
8
10
  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.`);
11
+ const deployment = resolveDeployment(flags.dir, prompts);
12
+ if (!deployment)
14
13
  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.");
14
+ const image = deployment.mode === "local" ? LOCAL_IMAGE : REMOTE_IMAGE;
15
+ if (!ensureDaemonRunning(docker, prompts))
31
16
  return 1;
32
- }
33
17
  const spinner = prompts.spinner();
34
18
  spinner.start(`Pulling ${image}`);
35
19
  const imagePulled = docker.pullImage(image);
@@ -38,28 +22,9 @@ export const runUpgrade = async (flags, deps) => {
38
22
  return 1;
39
23
  }
40
24
  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.");
25
+ const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
26
+ if (exitCode !== 0)
27
+ return exitCode;
63
28
  prompts.log("Your vault data, search index, and settings are preserved.");
64
29
  prompts.outro("Upgrade complete.");
65
30
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vault-cortex",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
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",