vault-cortex 0.8.0 → 0.9.0-beta.43

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,12 +15,14 @@ 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
26
28
  - [`restart`](#restart) — re-create the container so your `.env` edits take
@@ -44,9 +46,14 @@ What it does:
44
46
  - **Local** — Docker on this machine, your vault folder bind-mounted
45
47
  - **Remote** — a VPS with [Obsidian Sync](https://obsidian.md/sync),
46
48
  reachable from any device
47
- 2. Generates a `.env` file with a securely generated `MCP_AUTH_TOKEN`
48
- 3. Optionally starts the container and waits for the health check
49
- 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
50
57
  connect your client
51
58
 
52
59
  Existing files are never overwritten without asking. During a remote setup,
@@ -67,6 +74,22 @@ Non-interactive example:
67
74
  npx vault-cortex@latest init --yes --vault-path /path/to/YourVault
68
75
  ```
69
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
+
70
93
  ## upgrade
71
94
 
72
95
  Pull the latest image, re-create the container, and verify health:
@@ -167,7 +190,8 @@ is available.
167
190
  - Node.js >= 20.12 (only for this CLI — the server itself runs in Docker)
168
191
  - [Docker](https://docs.docker.com/get-docker/) or a Docker-compatible
169
192
  runtime (e.g. OrbStack, Colima, Podman) to run the server — the CLI
170
- manages the container through the `docker` command
193
+ manages the container through the `docker` command (on Linux, see
194
+ [Docker Engine](https://docs.docker.com/engine/install/))
171
195
 
172
196
  ## Docs
173
197
 
@@ -0,0 +1,70 @@
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@latest restart --dir "${targetDir}"`;
38
+ const daemonStatus = docker.daemonStatus();
39
+ if (daemonStatus !== "running") {
40
+ // Settings are already saved — the runtime state only affects the restart
41
+ // offer, so both non-running states degrade to the hint; restart itself
42
+ // prints the full install/start guidance when run.
43
+ prompts.warn(daemonStatus === "not-installed"
44
+ ? `No container runtime found — settings saved.\n${restartHint}`
45
+ : `Container runtime not running — settings saved.\n${restartHint}`);
46
+ prompts.outro("Done.");
47
+ return 0;
48
+ }
49
+ const restartNow = await prompts.confirm("Restart the container now to apply the new settings?", true);
50
+ if (!restartNow) {
51
+ prompts.log(restartHint);
52
+ prompts.outro("Done.");
53
+ return 0;
54
+ }
55
+ // Resolve from disk after the write so the restart honors the new values
56
+ // (a changed PORT must drive the port mapping and health URL).
57
+ const deployment = resolveDeployment(flags.dir, prompts);
58
+ if (!deployment) {
59
+ // The edit already succeeded — don't let the failed restart read as a
60
+ // failed configure.
61
+ prompts.warn(`The restart did not run — your settings are saved. Fix the issue above, then apply them with: npx vault-cortex@latest restart --dir "${targetDir}"`);
62
+ return 1;
63
+ }
64
+ const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
65
+ if (exitCode !== 0)
66
+ return exitCode;
67
+ prompts.log("Applied the current .env settings.");
68
+ prompts.outro("Configure complete.");
69
+ return 0;
70
+ };
package/dist/docker.js CHANGED
@@ -107,8 +107,23 @@ export const buildDockerLogsArgs = (params) => {
107
107
  CONTAINER_NAME,
108
108
  ];
109
109
  };
110
+ /**
111
+ * Classifies a `docker info` spawnSync result. ENOENT on the spawn itself
112
+ * means the `docker` binary is absent (not installed); any other failure —
113
+ * non-zero exit, timeout, signal kill — means the binary exists but the
114
+ * daemon isn't answering. `status` alone can't make that call: it is null
115
+ * for ENOENT *and* for timeouts, so the split keys on the error code.
116
+ */
117
+ export const classifyDaemonStatus = (spawnResult) => {
118
+ if (spawnResult.status === 0)
119
+ return "running";
120
+ const spawnErrorCode = spawnResult.error && "code" in spawnResult.error
121
+ ? spawnResult.error.code
122
+ : undefined;
123
+ return spawnErrorCode === "ENOENT" ? "not-installed" : "not-running";
124
+ };
110
125
  export const createDockerRunner = () => ({
111
- isDaemonRunning: () => spawnSync("docker", ["info"], { timeout: 5_000 }).status === 0,
126
+ daemonStatus: () => classifyDaemonStatus(spawnSync("docker", ["info"], { timeout: 5_000 })),
112
127
  // stdout is discarded: `docker run -d` prints only the container ID there,
113
128
  // which lands as a raw hex line between the wizard's prompts. stderr stays
114
129
  // inherited — image-pull progress and error output print live, which the
@@ -154,6 +169,28 @@ export const createDockerRunner = () => ({
154
169
  gid: process.getgid?.(),
155
170
  }), { stdio: "inherit" }).status === 0,
156
171
  });
172
+ /** Default bound on a single health request (shared by probe and poll). */
173
+ const PROBE_TIMEOUT_MS = 10_000;
174
+ /**
175
+ * One-shot health probe: true on an HTTP 2xx, false on any error, non-2xx,
176
+ * or timeout — false IS the handled outcome for a boolean probe, so the
177
+ * catch maps rather than logs. Unlike the localhost poll target (which fails
178
+ * fast with ECONNREFUSED), a public URL behind a dropped firewall rule can
179
+ * black-hole the TCP handshake for minutes — the abort timeout bounds every
180
+ * caller.
181
+ */
182
+ export const probeHealth = async (params, fetchFn) => {
183
+ const { url, timeoutMs = PROBE_TIMEOUT_MS } = params;
184
+ try {
185
+ const response = await fetchFn(url, {
186
+ signal: AbortSignal.timeout(timeoutMs),
187
+ });
188
+ return response.ok;
189
+ }
190
+ catch {
191
+ return false;
192
+ }
193
+ };
157
194
  /**
158
195
  * Polls the health endpoint until it responds OK or the timeout elapses.
159
196
  * The first `docker run` pulls the image, so the default window is generous.
@@ -165,19 +202,20 @@ export const createDockerRunner = () => ({
165
202
  export const pollHealth = async (params, fetchFn) => {
166
203
  const { url, timeoutMs = 120_000, intervalMs = 2_000 } = params;
167
204
  const deadline = Date.now() + timeoutMs;
168
- const isHealthy = async () => {
169
- try {
170
- const response = await fetchFn(url);
171
- return response.ok;
172
- }
173
- catch {
174
- return false;
175
- }
176
- };
205
+ // Each attempt is bounded by the per-request cap AND the remaining budget
206
+ // (a bare remaining-budget bound would let one black-holed request consume
207
+ // the whole window with no retries), and the pause never sleeps past the
208
+ // deadline — so the loop can't overshoot timeoutMs and the caller's
209
+ // "did not respond within N minutes" message stays accurate.
177
210
  while (Date.now() < deadline) {
178
- if (await isHealthy())
211
+ const attemptTimeoutMs = Math.min(PROBE_TIMEOUT_MS, deadline - Date.now());
212
+ if (await probeHealth({ url, timeoutMs: attemptTimeoutMs }, fetchFn)) {
179
213
  return true;
180
- await new Promise((resolvePause) => setTimeout(resolvePause, intervalMs));
214
+ }
215
+ const pauseMs = Math.min(intervalMs, deadline - Date.now());
216
+ if (pauseMs > 0) {
217
+ await new Promise((resolvePause) => setTimeout(resolvePause, pauseMs));
218
+ }
181
219
  }
182
220
  return false;
183
221
  };
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 restart" (plain docker restart does
15
- # not re-read this file).
13
+ # To change a setting: run "npx vault-cortex@latest configure", or edit
14
+ # its value here (uncommenting it first if needed) and apply with
15
+ # "npx vault-cortex@latest restart" (plain docker restart does not
16
+ # re-read 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 restart" (plain docker restart does
105
- # not re-read this file).
104
+ # To change a setting: run "npx vault-cortex@latest configure", or edit
105
+ # its value here (uncommenting it first if needed) and apply with
106
+ # "npx vault-cortex@latest restart" (plain docker restart does not
107
+ # re-read this file).
106
108
 
107
109
  # Your IANA timezone — affects daily note resolution and memory timestamps.
108
110
  # TZ=America/New_York
@@ -193,7 +195,7 @@ SYNC_MODE=bidirectional
193
195
  `;
194
196
  // sync:remote-optional:end
195
197
  export const buildLocalEnv = (answers) => `# vault-cortex — local quickstart
196
- # Generated by \`npx vault-cortex init\`. Full option reference:
198
+ # Generated by \`npx vault-cortex@latest init\`. Full option reference:
197
199
  # https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/.env.example
198
200
 
199
201
  # Required ──────────────────────────────────────────────────
@@ -214,10 +216,10 @@ VAULT_PASSWORD=${answers.vaultPassword}`;
214
216
  const obsidianTokenComment = answers.obsidianAuthToken === ""
215
217
  ? `# Obsidian Sync auth token — FILL THIS IN before starting the server.
216
218
  # Generate once with:
217
- # npx vault-cortex get-sync-token`
219
+ # npx vault-cortex@latest get-sync-token`
218
220
  : `# Obsidian Sync auth token.`;
219
221
  return `# vault-cortex — remote quickstart (Obsidian Sync)
220
- # Generated by \`npx vault-cortex init\`. Full option reference:
222
+ # Generated by \`npx vault-cortex@latest init\`. Full option reference:
221
223
  # https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/.env.example
222
224
 
223
225
  # Required ──────────────────────────────────────────────────
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
+ import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage, } from "./messages.js";
4
5
  import { patchEnvObsidianToken } from "./scaffold.js";
5
6
  import { expandTilde } from "./vault.js";
6
7
  /** Message from an unknown throw — Error instances keep their message. */
@@ -90,14 +91,14 @@ export const captureObsidianToken = (deps, tokenDestinationMessage) => {
90
91
  const loginSucceeded = runLoginContainer(configMountPath, deps);
91
92
  if (!loginSucceeded) {
92
93
  prompts.warn("The Obsidian login did not complete — you can run it later with:\n" +
93
- " npx vault-cortex get-sync-token");
94
+ " npx vault-cortex@latest get-sync-token");
94
95
  return undefined;
95
96
  }
96
97
  const token = readCapturedTokenFile(configMountPath);
97
98
  if (!token) {
98
99
  prompts.warn("The Obsidian login finished, but no token was captured — the " +
99
100
  "token file was missing, empty, or unreadable. You can retry with:\n" +
100
- " npx vault-cortex get-sync-token");
101
+ " npx vault-cortex@latest get-sync-token");
101
102
  return undefined;
102
103
  }
103
104
  return token;
@@ -113,9 +114,11 @@ export const captureObsidianToken = (deps, tokenDestinationMessage) => {
113
114
  */
114
115
  export const runGetSyncToken = async (flags, deps) => {
115
116
  const { prompts, docker } = deps;
116
- if (!docker.isDaemonRunning()) {
117
- prompts.error("Container runtime not running — start Docker Desktop, Colima,\n" +
118
- "OrbStack, or another Docker-compatible runtime and try again.");
117
+ const daemonStatus = docker.daemonStatus();
118
+ if (daemonStatus !== "running") {
119
+ prompts.error(daemonStatus === "not-installed"
120
+ ? buildDockerNotInstalledMessage({ nextStep: "\nThen try again." })
121
+ : buildDaemonNotRunningMessage(" and try again."));
119
122
  return 1;
120
123
  }
121
124
  prompts.intro("vault-cortex get-sync-token");
package/dist/init.js CHANGED
@@ -1,9 +1,12 @@
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
- import { buildLocalConnectMessage, buildRemoteConnectMessage, } from "./messages.js";
5
+ import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage, buildLocalConnectMessage, buildRemoteConnectMessage, } from "./messages.js";
5
6
  import { pollHealth } from "./docker.js";
6
- import { buildFilesToWrite, readEnvPort, writeFiles, } from "./scaffold.js";
7
+ import { reportPublicUrlProbe } from "./lifecycle.js";
8
+ import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, } from "./optional-settings.js";
9
+ import { buildFilesToWrite, readEnvPort, readEnvPublicUrl, writeFiles, } from "./scaffold.js";
7
10
  import { generateToken } from "./token.js";
8
11
  import { expandTilde, validateVaultPath } from "./vault.js";
9
12
  const DEFAULT_TARGET_DIR = "./vault-cortex";
@@ -137,10 +140,14 @@ const reportWrites = (params, prompts) => {
137
140
  const offerDockerRun = async (params, deps) => {
138
141
  const { targetDir, port, mode, vaultPath } = params;
139
142
  const { prompts, docker, fetchFn } = deps;
140
- if (!docker.isDaemonRunning()) {
141
- prompts.warn("Container runtime not running — start Docker Desktop, Colima,\n" +
142
- "OrbStack, or another Docker-compatible runtime, then run:\n" +
143
- ` npx vault-cortex upgrade --dir "${targetDir}"`);
143
+ const daemonStatus = docker.daemonStatus();
144
+ if (daemonStatus !== "running") {
145
+ const upgradeHint = `npx vault-cortex@latest upgrade --dir "${targetDir}"`;
146
+ prompts.warn(daemonStatus === "not-installed"
147
+ ? buildDockerNotInstalledMessage({
148
+ nextStep: `\nThen start the server with:\n ${upgradeHint}`,
149
+ })
150
+ : buildDaemonNotRunningMessage(`, then run:\n ${upgradeHint}`));
144
151
  return false;
145
152
  }
146
153
  const startNow = await prompts.confirm("Start the server now?", true);
@@ -203,10 +210,25 @@ const runLocalInit = async (flags, deps) => {
203
210
  placeholder: DEFAULT_TARGET_DIR,
204
211
  }))));
205
212
  const token = generateToken();
213
+ // Guided optional settings: the chooser reads current values from the
214
+ // generated defaults; enter with nothing picked keeps them all. --yes
215
+ // skips the chooser (non-interactive by contract), and so does an existing
216
+ // .env — the conflict prompt defaults to keeping it, which would discard
217
+ // the answers; settings on an existing deployment are configure's job.
218
+ const defaultEnvContent = buildLocalEnv({ mcpAuthToken: token, vaultPath });
219
+ const envAlreadyExists = existsSync(join(targetDir, ".env"));
220
+ const offerSettingsChooser = !flags.yes && !envAlreadyExists;
221
+ if (!flags.yes && envAlreadyExists) {
222
+ prompts.log('Found an existing .env — settings prompts skipped. Adjust settings with "npx vault-cortex@latest configure".');
223
+ }
224
+ const optionalOverrides = offerSettingsChooser
225
+ ? await askOptionalSettings({ mode: "local", envContent: defaultEnvContent }, prompts)
226
+ : {};
227
+ const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
206
228
  // Conflict policy: identical existing files are skipped silently;
207
229
  // differing ones prompt per file (default keep). --yes never overwrites —
208
230
  // any differing file becomes an exit-1 below, leaving it untouched.
209
- const files = buildFilesToWrite(buildLocalEnv({ mcpAuthToken: token, vaultPath }));
231
+ const files = buildFilesToWrite(envContent);
210
232
  const resolveConflict = flags.yes ? keepExisting : confirmOverwrite(prompts);
211
233
  const results = await writeFiles({ targetDir, files }, resolveConflict);
212
234
  reportWrites({ targetDir, results }, prompts);
@@ -248,8 +270,10 @@ const runRemoteInit = async (flags, deps) => {
248
270
  const vaultName = await askVaultName(prompts);
249
271
  // Auto-capture the Obsidian Sync token via a Docker volume mount when
250
272
  // the daemon is reachable. Falls back to a paste prompt when capture
251
- // fails or the user declines.
252
- const capturedToken = docker.isDaemonRunning()
273
+ // fails or the user declines. Both non-running states stay silent here —
274
+ // the paste fallback is fully functional without Docker, and the start
275
+ // offer surfaces the differentiated runtime guidance later in the flow.
276
+ const capturedToken = docker.daemonStatus() === "running"
253
277
  ? await offerSyncTokenCapture(prompts, docker)
254
278
  : undefined;
255
279
  // Masked prompt: the sync token is a credential and must not echo into
@@ -262,13 +286,24 @@ const runRemoteInit = async (flags, deps) => {
262
286
  ? await prompts.password("Vault encryption password:")
263
287
  : undefined;
264
288
  const token = generateToken();
265
- const envContent = buildRemoteEnv({
289
+ // Guided optional settings, mirroring the local flow — remote also offers
290
+ // SYNC_MODE. Remote init is always interactive (no --yes), so only an
291
+ // existing .env skips the chooser here.
292
+ const defaultEnvContent = buildRemoteEnv({
266
293
  mcpAuthToken: token,
267
294
  publicUrl,
268
295
  obsidianAuthToken,
269
296
  vaultName,
270
297
  vaultPassword,
271
298
  });
299
+ const envAlreadyExists = existsSync(join(targetDir, ".env"));
300
+ if (envAlreadyExists) {
301
+ prompts.log('Found an existing .env — settings prompts skipped. Adjust settings with "npx vault-cortex@latest configure".');
302
+ }
303
+ const optionalOverrides = envAlreadyExists
304
+ ? {}
305
+ : await askOptionalSettings({ mode: "remote", envContent: defaultEnvContent }, prompts);
306
+ const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
272
307
  const files = buildFilesToWrite(envContent);
273
308
  const results = await writeFiles({ targetDir, files }, confirmOverwrite(prompts));
274
309
  reportWrites({ targetDir, results }, prompts);
@@ -281,15 +316,28 @@ const runRemoteInit = async (flags, deps) => {
281
316
  if (tokenWritten)
282
317
  prompts.log("Generated MCP auth token (saved to .env).");
283
318
  const port = readEnvPort(join(targetDir, ".env"));
319
+ // Like PORT above, PUBLIC_URL comes from the .env actually on disk — a kept
320
+ // existing file may hold a different URL than this run's prompt, and the
321
+ // server only reads the file. The prompted value is the fallback for a kept
322
+ // legacy .env that predates PUBLIC_URL.
323
+ const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl;
284
324
  // Without the sync token the container can't start (init-check-auth fails
285
325
  // and s6 stops it), so only offer docker run when it was provided.
286
326
  const started = obsidianAuthToken === ""
287
327
  ? false
288
328
  : await offerDockerRun({ targetDir, port, mode: "remote" }, deps);
329
+ // The container check above hit localhost on this machine; the public URL
330
+ // is the ingress path clients actually use — probe it too, informationally.
331
+ if (started) {
332
+ await reportPublicUrlProbe(effectivePublicUrl, {
333
+ prompts,
334
+ fetchFn: deps.fetchFn,
335
+ });
336
+ }
289
337
  prompts.print(buildRemoteConnectMessage({
290
338
  targetDir,
291
339
  token,
292
- publicUrl,
340
+ publicUrl: effectivePublicUrl,
293
341
  started,
294
342
  obsidianTokenMissing: obsidianAuthToken === "",
295
343
  tokenWritten,
package/dist/lifecycle.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { join, resolve } from "node:path";
2
- import { CONTAINER_NAME, pollHealth } from "./docker.js";
3
- import { detectMode, hasEnvPublicUrl, readEnvPort, readEnvVaultPath, } from "./scaffold.js";
2
+ import { CONTAINER_NAME, pollHealth, probeHealth, } from "./docker.js";
3
+ import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage, } from "./messages.js";
4
+ import { detectMode, hasEnvPublicUrl, readEnvPort, readEnvPublicUrl, readEnvVaultPath, } from "./scaffold.js";
4
5
  import { expandTilde } from "./vault.js";
5
6
  const DEFAULT_TARGET_DIR = "./vault-cortex";
6
7
  /**
@@ -8,12 +9,12 @@ const DEFAULT_TARGET_DIR = "./vault-cortex";
8
9
  * starting a container: resolves --dir and confirms an init'd .env exists.
9
10
  * Full start-readiness validation lives in resolveDeployment.
10
11
  */
11
- const requireInitializedDir = (dirFlag, prompts) => {
12
+ export const requireInitializedDir = (dirFlag, prompts) => {
12
13
  const targetDir = resolve(expandTilde(dirFlag ?? DEFAULT_TARGET_DIR));
13
14
  const envFilePath = join(targetDir, ".env");
14
15
  const mode = detectMode(envFilePath);
15
16
  if (!mode) {
16
- prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex init\` first.`);
17
+ prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex@latest init\` first.`);
17
18
  return undefined;
18
19
  }
19
20
  return { targetDir, envFilePath, mode };
@@ -40,19 +41,51 @@ export const resolveDeployment = (dirFlag, prompts) => {
40
41
  `Add this line to your .env:\n PUBLIC_URL=http://localhost:${port}`);
41
42
  return undefined;
42
43
  }
43
- return { mode, targetDir, envFilePath, port, vaultPath };
44
+ // Local's PUBLIC_URL is the derived localhost URL — probing it would
45
+ // duplicate the health check recreateContainer just ran, so remote-only.
46
+ const publicUrl = mode === "remote" ? readEnvPublicUrl(envFilePath) : undefined;
47
+ return { mode, targetDir, envFilePath, port, vaultPath, publicUrl };
44
48
  };
45
49
  /**
46
50
  * Verifies the container runtime is reachable, reporting the shared error
47
51
  * message when it isn't. Callers early-return on false.
48
52
  */
49
53
  export const ensureDaemonRunning = (docker, prompts) => {
50
- if (docker.isDaemonRunning())
54
+ const daemonStatus = docker.daemonStatus();
55
+ if (daemonStatus === "running")
51
56
  return true;
52
- prompts.error("Container runtime not running — start Docker Desktop, Colima,\n" +
53
- "OrbStack, or another Docker-compatible runtime.");
57
+ prompts.error(daemonStatus === "not-installed"
58
+ ? buildDockerNotInstalledMessage({ nextStep: "" })
59
+ : buildDaemonNotRunningMessage("."));
54
60
  return false;
55
61
  };
62
+ /**
63
+ * One-shot informational probe of the public /healthz after a confirmed
64
+ * container start. Never a gate: a failure warns and the command still
65
+ * succeeds — before HTTPS/ingress access is set up an unreachable public URL
66
+ * is the expected state, and this machine's result doesn't prove the same
67
+ * for other devices (a VPS may not reach its own public address). Returning
68
+ * void keeps the informational contract structural.
69
+ */
70
+ export const reportPublicUrlProbe = async (publicUrl, deps) => {
71
+ const { prompts, fetchFn } = deps;
72
+ // A hand-edited .env value may carry a trailing slash; strip it so the
73
+ // probe URL is `${base}/healthz`, never `${base}//healthz`.
74
+ const healthUrl = `${publicUrl.replace(/\/+$/, "")}/healthz`;
75
+ const spinner = prompts.spinner();
76
+ spinner.start(`Checking the public URL (${healthUrl})`);
77
+ const publicUrlResponded = await probeHealth({ url: healthUrl }, fetchFn);
78
+ if (publicUrlResponded) {
79
+ spinner.stop(`Public URL responds — ${healthUrl} answered from this machine.`);
80
+ return;
81
+ }
82
+ spinner.stop(`No answer from ${healthUrl} yet.`);
83
+ prompts.warn("The server is up, but its public URL didn't answer from this machine.\n" +
84
+ "That's expected until HTTPS (or direct port) access is set up — and\n" +
85
+ "some networks keep a server from reaching its own public address even\n" +
86
+ "when other devices can. Once access is set up, check from any device:\n" +
87
+ ` curl ${healthUrl}`);
88
+ };
56
89
  /**
57
90
  * Stop-and-remove → docker run → health poll, with the shared messaging.
58
91
  * Returns a process exit code. Callers own the daemon check and any
@@ -92,6 +125,11 @@ export const recreateContainer = async (params, deps) => {
92
125
  return 1;
93
126
  }
94
127
  spinner.stop("Server is up — health check passed.");
128
+ // Informational only — the container is confirmed healthy above, so the
129
+ // public-URL result never changes the exit code.
130
+ if (deployment.mode === "remote" && deployment.publicUrl) {
131
+ await reportPublicUrlProbe(deployment.publicUrl, { prompts, fetchFn });
132
+ }
95
133
  return 0;
96
134
  };
97
135
  /**
@@ -119,7 +157,7 @@ export const runDown = async (flags, deps) => {
119
157
  return 1;
120
158
  }
121
159
  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}"`);
160
+ prompts.outro(`Start again with: npx vault-cortex@latest restart --dir "${initialized.targetDir}"`);
123
161
  return 0;
124
162
  };
125
163
  /**
@@ -136,7 +174,7 @@ export const runLogs = async (flags, deps) => {
136
174
  if (!ensureDaemonRunning(docker, prompts))
137
175
  return 1;
138
176
  if (!docker.containerExists()) {
139
- prompts.error("No vault-cortex container — start it with `npx vault-cortex restart`.");
177
+ prompts.error("No vault-cortex container — start it with `npx vault-cortex@latest restart`.");
140
178
  return 1;
141
179
  }
142
180
  return await docker.streamLogs({
package/dist/main.js CHANGED
@@ -1,3 +1,4 @@
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";
@@ -13,6 +14,11 @@ export const run = async (version) => {
13
14
  docker: createDockerRunner(),
14
15
  fetchFn: fetch,
15
16
  }),
17
+ runConfigure: (flags) => runConfigure(flags, {
18
+ prompts: createPrompts(),
19
+ docker: createDockerRunner(),
20
+ fetchFn: fetch,
21
+ }),
16
22
  runUpgrade: (flags) => runUpgrade(flags, {
17
23
  prompts: createPrompts(),
18
24
  docker: createDockerRunner(),
package/dist/messages.js CHANGED
@@ -10,9 +10,41 @@ const RULE_WIDTH = 56;
10
10
  const topRule = (label) => paint("dim", `╭── ${label} ${"─".repeat(Math.max(0, RULE_WIDTH - label.length - 6))}╮`);
11
11
  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
+ /**
14
+ * Daemon-stopped guidance shared by every command that needs the container
15
+ * runtime. `nextStep` finishes the message per command — appended verbatim
16
+ * (".", " and try again.", or a ", then run:" continuation).
17
+ */
18
+ export const buildDaemonNotRunningMessage = (nextStep) => "Container runtime not running — start Docker Desktop, Colima,\n" +
19
+ `OrbStack, or another Docker-compatible runtime${nextStep}`;
20
+ /**
21
+ * Per-platform install pointer — a docs link only, no install method
22
+ * suggestions: the CLI doesn't install anything, so the official docs (which
23
+ * cover every method) are the hand-off. Peers are named in the message above
24
+ * this line.
25
+ */
26
+ const dockerInstallLine = (platform) => {
27
+ if (platform === "darwin" || platform === "win32") {
28
+ return "Install Docker Desktop: https://docs.docker.com/get-docker/";
29
+ }
30
+ return "Install Docker Engine: https://docs.docker.com/engine/install/";
31
+ };
32
+ /**
33
+ * "No runtime at all" guidance — distinct from the daemon-stopped message so
34
+ * the user isn't told to start something that isn't installed. platform is a
35
+ * defaulted param (mirroring buildObsidianLoginArgs) so each branch stays
36
+ * testable; `nextStep` is appended verbatim, as in
37
+ * buildDaemonNotRunningMessage.
38
+ */
39
+ export const buildDockerNotInstalledMessage = (params) => {
40
+ const { nextStep, platform = process.platform } = params;
41
+ return ("No container runtime found — the server runs in Docker, so you need\n" +
42
+ "Docker or a Docker-compatible runtime (OrbStack, Colima, Podman).\n" +
43
+ `${dockerInstallLine(platform)}${nextStep}`);
44
+ };
13
45
  // targetDir is quoted: these lines are meant to be copy-pasted into a
14
46
  // shell, and an unquoted path breaks on spaces or special characters.
15
- const upgradeCommand = (targetDir) => `npx vault-cortex upgrade --dir "${targetDir}"`;
47
+ const upgradeCommand = (targetDir) => `npx vault-cortex@latest upgrade --dir "${targetDir}"`;
16
48
  const startServerLine = (targetDir) => `Start the server:\n ${upgradeCommand(targetDir)}`;
17
49
  /** Remote start line: running, blocked on the missing sync token, or ready to start. */
18
50
  const remoteStartLine = (params) => {
@@ -62,6 +94,19 @@ const curlGuidance = (mcpUrl) => `Clients without OAuth, scripts, and curl send
62
94
  curl -H "Authorization: Bearer <token>" ${mcpUrl}`;
63
95
  const smokeTest = (healthUrl) => `Smoke test:
64
96
  curl ${healthUrl}`;
97
+ /**
98
+ * Remote health-check block. Started: the CLI verified localhost on the VPS,
99
+ * but the public URL is a different check (ingress — DNS, TLS, proxy), so the
100
+ * command stays, reworded as the works-from-any-device check. Not started:
101
+ * the plain smoke test to run after starting.
102
+ */
103
+ const remoteHealthCheckBlock = (healthUrl, started) => {
104
+ if (started) {
105
+ return `Health check — works from any device that can reach the URL:
106
+ curl ${healthUrl}`;
107
+ }
108
+ return smokeTest(healthUrl);
109
+ };
65
110
  const updateGuidance = (targetDir) => `Update to the latest release:
66
111
  ${upgradeCommand(targetDir)}`;
67
112
  /**
@@ -76,6 +121,15 @@ export const buildLocalConnectMessage = (params) => {
76
121
  ? "The server is running."
77
122
  : startServerLine(targetDir);
78
123
  const tokenLine = tokenBlock({ targetDir, token, tokenWritten });
124
+ // Once the server is confirmed up, the smoke test is dropped — the CLI just
125
+ // verified this exact URL, so re-printing it reads as leftover homework.
126
+ // Assembled as a filtered list so the omission leaves no stray blank line.
127
+ const nonOauthBlocks = [
128
+ curlGuidance(`${baseUrl}/mcp`),
129
+ started ? undefined : smokeTest(`${baseUrl}/healthz`),
130
+ ]
131
+ .filter(Boolean)
132
+ .join("\n\n");
79
133
  // Flush-left on purpose: this is printed as plain text (see paint), so
80
134
  // leading whitespace would render as literal indentation. Local is always
81
135
  // localhost http, so it shares the http guidance; its only divergences are
@@ -104,16 +158,17 @@ it with mcp-remote:
104
158
 
105
159
  ${sectionRule("Non-OAuth")}
106
160
 
107
- ${curlGuidance(`${baseUrl}/mcp`)}
108
-
109
- ${smokeTest(`${baseUrl}/healthz`)}
161
+ ${nonOauthBlocks}
110
162
 
111
163
  ${sectionRule("Settings")}
112
164
 
113
- Optional settings (timezone, memory folder, port, logging) live in
114
- ${targetDir}/.env edit a value (uncommenting it first if needed), then
115
- apply with "npx vault-cortex restart" (plain docker restart does not
116
- re-read .env).
165
+ Adjust optional settings (memory layer and folder, file tools,
166
+ semantic search, port, timezone):
167
+ npx vault-cortex@latest configure --dir "${targetDir}"
168
+
169
+ Or edit ${targetDir}/.env directly — change a value (uncommenting it
170
+ first if needed), then apply with "npx vault-cortex@latest restart" (plain
171
+ docker restart does not re-read .env).
117
172
 
118
173
  ${updateGuidance(targetDir)}
119
174
 
@@ -168,13 +223,16 @@ ${sectionRule("Non-OAuth")}
168
223
 
169
224
  ${curlGuidance(`${publicUrl}/mcp`)}
170
225
 
171
- ${smokeTest(`${publicUrl}/healthz`)}
226
+ ${remoteHealthCheckBlock(`${publicUrl}/healthz`, started)}
172
227
 
173
228
  ${sectionRule("Settings")}
174
229
 
175
- Optional settings (timezone, memory folder, port, logging, sync
176
- behavior) live in ${targetDir}/.env edit a value (uncommenting it
177
- first if needed), then apply with "npx vault-cortex restart" (plain
230
+ Adjust optional settings (memory layer and folder, file tools,
231
+ semantic search, port, timezone, sync direction):
232
+ npx vault-cortex@latest configure --dir "${targetDir}"
233
+
234
+ Or edit ${targetDir}/.env directly — change a value (uncommenting it
235
+ first if needed), then apply with "npx vault-cortex@latest restart" (plain
178
236
  docker restart does not re-read .env).
179
237
 
180
238
  ${updateGuidance(targetDir)}
@@ -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")
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/scaffold.js CHANGED
@@ -8,6 +8,8 @@ const ENV_PORT_LINE = /^PORT=(\d+)\s*$/m;
8
8
  const ENV_VAULT_PATH_LINE = /^VAULT_PATH=(.+)\s*$/m;
9
9
  /** Matches an active (uncommented) PUBLIC_URL line. */
10
10
  const ENV_PUBLIC_URL_LINE = /^PUBLIC_URL=/m;
11
+ /** Matches an active (uncommented) PUBLIC_URL line, capturing a non-empty value. */
12
+ const ENV_PUBLIC_URL_VALUE_LINE = /^PUBLIC_URL=(.+)\s*$/m;
11
13
  /** Matches an active (uncommented) OBSIDIAN_AUTH_TOKEN line. */
12
14
  const OBSIDIAN_AUTH_TOKEN_LINE = /^OBSIDIAN_AUTH_TOKEN=/m;
13
15
  export const buildFilesToWrite = (envContent) => [
@@ -46,6 +48,21 @@ export const hasEnvPublicUrl = (envFilePath) => {
46
48
  return false;
47
49
  return ENV_PUBLIC_URL_LINE.test(readFileSync(envFilePath, "utf8"));
48
50
  };
51
+ /**
52
+ * Reads the public URL value from a .env file. Returns undefined when the
53
+ * file is missing or has no uncommented, non-empty PUBLIC_URL line —
54
+ * deliberately stricter than hasEnvPublicUrl, whose job is old-compose
55
+ * detection and so matches an empty `PUBLIC_URL=` line too.
56
+ */
57
+ export const readEnvPublicUrl = (envFilePath) => {
58
+ if (!existsSync(envFilePath))
59
+ return undefined;
60
+ const match = ENV_PUBLIC_URL_VALUE_LINE.exec(readFileSync(envFilePath, "utf8"));
61
+ // A whitespace-only line matches the regex and trims to "" — normalize to
62
+ // undefined so the non-empty contract holds ("" is never a legitimate URL).
63
+ const publicUrlValue = match?.[1].trim();
64
+ return publicUrlValue || undefined;
65
+ };
49
66
  /**
50
67
  * Detects the deployment mode from a .env file. Remote mode requires
51
68
  * OBSIDIAN_AUTH_TOKEN (absent from local). Returns undefined when the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vault-cortex",
3
- "version": "0.8.0",
3
+ "version": "0.9.0-beta.43",
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",