vault-cortex 0.9.0 → 0.10.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
@@ -25,6 +25,8 @@ manages the container so you don't have to.
25
25
  restart to apply them
26
26
  - [`upgrade`](#upgrade) — pull the latest image and re-create the container;
27
27
  your data stays
28
+ - [`start`](#start) — start the server with your saved settings, e.g. after
29
+ `down`
28
30
  - [`restart`](#restart) — re-create the container so your `.env` edits take
29
31
  effect; no image pull
30
32
  - [`logs`](#logs) — show the server's logs, live or after the fact
@@ -49,16 +51,17 @@ What it does:
49
51
  2. Offers the most common optional settings — memory layer and folder,
50
52
  file tools, semantic search, port, timezone (plus sync direction for
51
53
  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
+ change
54
55
  3. Generates a `.env` file with a securely generated `MCP_AUTH_TOKEN`
55
56
  4. Optionally starts the container and waits for the health check
56
57
  5. Prints your connection details — the MCP URL, your auth token, and how to
57
58
  connect your client
58
59
 
59
- Existing files are never overwritten without asking. During a remote setup,
60
- init offers to run [`get-sync-token`](#get-sync-token) for you when Docker is
61
- available.
60
+ Re-running init where a setup already exists asks first declining leaves
61
+ everything unchanged and points you at [`configure`](#configure), the right
62
+ tool for changing settings in place. Existing files are never overwritten
63
+ without asking. During a remote setup, init offers to run
64
+ [`get-sync-token`](#get-sync-token) for you when Docker is available.
62
65
 
63
66
  Flags:
64
67
 
@@ -115,6 +118,22 @@ include Compose files you can use directly. If you set up with Compose, stick
115
118
  with Compose for updates too (`docker compose pull && docker compose up -d`)
116
119
  — the CLI and Compose manage the container independently.
117
120
 
121
+ ## start
122
+
123
+ Start the server with your saved settings and verify health:
124
+
125
+ ```bash
126
+ npx vault-cortex@latest start
127
+ ```
128
+
129
+ This is the command the CLI's own guidance points to whenever the server
130
+ isn't running — after [`down`](#down), or an `init` where the server wasn't
131
+ started. It's the same cycle as [`restart`](#restart) under the name you'd
132
+ look for: any existing container is replaced, and Docker pulls the server
133
+ image automatically on a first start.
134
+
135
+ Use `--dir <path>` if your config isn't in `./vault-cortex`.
136
+
118
137
  ## restart
119
138
 
120
139
  Re-create the container from your `.env` and verify health:
@@ -125,7 +144,7 @@ npx vault-cortex@latest restart
125
144
 
126
145
  Use it after editing `.env` — settings are only read when the container is
127
146
  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
147
+ [`upgrade`](#upgrade), it never updates the server image: you get the same
129
148
  version back, with your current settings applied.
130
149
 
131
150
  Use `--dir <path>` if your config isn't in `./vault-cortex`.
@@ -159,7 +178,7 @@ npx vault-cortex@latest down
159
178
 
160
179
  Safe by design: your vault, search index, and `.env` settings all live
161
180
  outside the container, so nothing is lost. Start again any time with
162
- [`restart`](#restart). Running `down` when nothing is running is fine — it
181
+ [`start`](#start). Running `down` when nothing is running is fine — it
163
182
  just tells you there's nothing to stop.
164
183
 
165
184
  Use `--dir <path>` if your config isn't in `./vault-cortex`.
@@ -190,7 +209,8 @@ is available.
190
209
  - Node.js >= 20.12 (only for this CLI — the server itself runs in Docker)
191
210
  - [Docker](https://docs.docker.com/get-docker/) or a Docker-compatible
192
211
  runtime (e.g. OrbStack, Colima, Podman) to run the server — the CLI
193
- manages the container through the `docker` command
212
+ manages the container through the `docker` command (on Linux, see
213
+ [Docker Engine](https://docs.docker.com/engine/install/))
194
214
 
195
215
  ## Docs
196
216
 
package/dist/configure.js CHANGED
@@ -34,9 +34,15 @@ export const runConfigure = async (flags, deps) => {
34
34
  if (pickedOverrides.PORT && !overrides.PUBLIC_URL && currentPublicUrl) {
35
35
  prompts.warn(`PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.`);
36
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}`);
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}`);
40
46
  prompts.outro("Done.");
41
47
  return 0;
42
48
  }
@@ -52,7 +58,7 @@ export const runConfigure = async (flags, deps) => {
52
58
  if (!deployment) {
53
59
  // The edit already succeeded — don't let the failed restart read as a
54
60
  // 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}"`);
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}"`);
56
62
  return 1;
57
63
  }
58
64
  const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
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,10 +10,10 @@
10
10
  // └─────────────────────────────────────────────────────────────────────────┘
11
11
  // sync:local-optional:begin
12
12
  const LOCAL_OPTIONAL_BLOCK = `# Optional ──────────────────────────────────────────────────
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).
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).
17
17
 
18
18
  # Public URL for OAuth issuer URL in discovery metadata (default: http://localhost:8000).
19
19
  # Override if you expose the server on a different URL (e.g. via a reverse proxy).
@@ -101,10 +101,10 @@ WINDOWS_MODE=false
101
101
  // └─────────────────────────────────────────────────────────────────────────┘
102
102
  // sync:remote-optional:begin
103
103
  const REMOTE_OPTIONAL_BLOCK = `# Optional ──────────────────────────────────────────────────
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).
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).
108
108
 
109
109
  # Your IANA timezone — affects daily note resolution and memory timestamps.
110
110
  # TZ=America/New_York
@@ -195,7 +195,7 @@ SYNC_MODE=bidirectional
195
195
  `;
196
196
  // sync:remote-optional:end
197
197
  export const buildLocalEnv = (answers) => `# vault-cortex — local quickstart
198
- # Generated by \`npx vault-cortex init\`. Full option reference:
198
+ # Generated by \`npx vault-cortex@latest init\`. Full option reference:
199
199
  # https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/.env.example
200
200
 
201
201
  # Required ──────────────────────────────────────────────────
@@ -216,10 +216,10 @@ VAULT_PASSWORD=${answers.vaultPassword}`;
216
216
  const obsidianTokenComment = answers.obsidianAuthToken === ""
217
217
  ? `# Obsidian Sync auth token — FILL THIS IN before starting the server.
218
218
  # Generate once with:
219
- # npx vault-cortex get-sync-token`
219
+ # npx vault-cortex@latest get-sync-token`
220
220
  : `# Obsidian Sync auth token.`;
221
221
  return `# vault-cortex — remote quickstart (Obsidian Sync)
222
- # Generated by \`npx vault-cortex init\`. Full option reference:
222
+ # Generated by \`npx vault-cortex@latest init\`. Full option reference:
223
223
  # https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/.env.example
224
224
 
225
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
@@ -2,10 +2,11 @@ import { existsSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { buildLocalEnv, buildRemoteEnv } from "./env.js";
4
4
  import { captureObsidianToken } from "./get-sync-token.js";
5
- import { buildLocalConnectMessage, buildRemoteConnectMessage, } from "./messages.js";
5
+ import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage, buildLocalConnectMessage, buildRemoteConnectMessage, startCommand, } from "./messages.js";
6
6
  import { pollHealth } from "./docker.js";
7
+ import { reportPublicUrlProbe } from "./lifecycle.js";
7
8
  import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, } from "./optional-settings.js";
8
- import { buildFilesToWrite, readEnvPort, writeFiles, } from "./scaffold.js";
9
+ import { buildFilesToWrite, readEnvPort, readEnvPublicUrl, writeFiles, } from "./scaffold.js";
9
10
  import { generateToken } from "./token.js";
10
11
  import { expandTilde, validateVaultPath } from "./vault.js";
11
12
  const DEFAULT_TARGET_DIR = "./vault-cortex";
@@ -118,6 +119,26 @@ const askVaultName = async (prompts) => {
118
119
  const keepExisting = async () => false;
119
120
  /** Interactive conflict policy: ask per differing file, defaulting to keep. */
120
121
  const confirmOverwrite = (prompts) => (name) => prompts.confirm(`${name} already exists and differs — overwrite?`, false);
122
+ /**
123
+ * Re-init guard, fired the moment the target dir is known (prompt answer or
124
+ * --dir flag): an existing .env there means a live deployment, so the flow
125
+ * checks intent before any further questions are spent — re-running init over
126
+ * a deployment is usually an accident, and settings changes belong to
127
+ * `configure`. Declining (the default) backs out with pointers; accepting
128
+ * continues, still protected by the per-file overwrite confirms at write time.
129
+ */
130
+ const confirmReinitOverExistingEnv = async (targetDir, prompts) => {
131
+ if (!existsSync(join(targetDir, ".env")))
132
+ return true;
133
+ prompts.log(`Found an existing deployment in ${targetDir}.`);
134
+ const reinitAnyway = await prompts.confirm("Re-run setup for this directory anyway?", false);
135
+ if (reinitAnyway)
136
+ return true;
137
+ // No outro here: declining still exits 0, and the runInit wrapper owns the
138
+ // closing outro (mirroring configure's declined-restart path).
139
+ prompts.log(`Nothing changed. To adjust settings instead: npx vault-cortex@latest configure --dir "${targetDir}"`);
140
+ return false;
141
+ };
121
142
  const reportWrites = (params, prompts) => {
122
143
  const { targetDir, results } = params;
123
144
  for (const result of results) {
@@ -139,10 +160,14 @@ const reportWrites = (params, prompts) => {
139
160
  const offerDockerRun = async (params, deps) => {
140
161
  const { targetDir, port, mode, vaultPath } = params;
141
162
  const { prompts, docker, fetchFn } = deps;
142
- if (!docker.isDaemonRunning()) {
143
- prompts.warn("Container runtime not running — start Docker Desktop, Colima,\n" +
144
- "OrbStack, or another Docker-compatible runtime, then run:\n" +
145
- ` npx vault-cortex upgrade --dir "${targetDir}"`);
163
+ const daemonStatus = docker.daemonStatus();
164
+ if (daemonStatus !== "running") {
165
+ const startHint = startCommand(targetDir);
166
+ prompts.warn(daemonStatus === "not-installed"
167
+ ? buildDockerNotInstalledMessage({
168
+ nextStep: `\nThen start the server with:\n ${startHint}`,
169
+ })
170
+ : buildDaemonNotRunningMessage(`, then run:\n ${startHint}`));
146
171
  return false;
147
172
  }
148
173
  const startNow = await prompts.confirm("Start the server now?", true);
@@ -204,21 +229,25 @@ const runLocalInit = async (flags, deps) => {
204
229
  defaultValue: DEFAULT_TARGET_DIR,
205
230
  placeholder: DEFAULT_TARGET_DIR,
206
231
  }))));
232
+ // --yes skips the guard: it's non-interactive by contract, and its own
233
+ // conflict policy (refuse to overwrite, exit 1) already protects the dir.
234
+ if (!flags.yes) {
235
+ const continueReinit = await confirmReinitOverExistingEnv(targetDir, prompts);
236
+ if (!continueReinit)
237
+ return 0;
238
+ }
207
239
  const token = generateToken();
208
240
  // Guided optional settings: the chooser reads current values from the
209
241
  // 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.
242
+ // skips the chooser (non-interactive by contract). An existing .env does
243
+ // NOT skip it: every interactive path here passed the re-init guard, so the
244
+ // user asked for a full re-run the answers land in the regenerated file
245
+ // when they overwrite at the conflict prompt (keeping it discards them,
246
+ // which the write report states). In-place edits stay configure's job.
213
247
  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
- : {};
248
+ const optionalOverrides = flags.yes
249
+ ? {}
250
+ : await askOptionalSettings({ mode: "local", envContent: defaultEnvContent }, prompts);
222
251
  const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
223
252
  // Conflict policy: identical existing files are skipped silently;
224
253
  // differing ones prompt per file (default keep). --yes never overwrites —
@@ -261,12 +290,17 @@ const runRemoteInit = async (flags, deps) => {
261
290
  defaultValue: DEFAULT_TARGET_DIR,
262
291
  placeholder: DEFAULT_TARGET_DIR,
263
292
  }))));
293
+ const continueReinit = await confirmReinitOverExistingEnv(targetDir, prompts);
294
+ if (!continueReinit)
295
+ return 0;
264
296
  const publicUrl = await askPublicUrl(prompts);
265
297
  const vaultName = await askVaultName(prompts);
266
298
  // Auto-capture the Obsidian Sync token via a Docker volume mount when
267
299
  // the daemon is reachable. Falls back to a paste prompt when capture
268
- // fails or the user declines.
269
- const capturedToken = docker.isDaemonRunning()
300
+ // fails or the user declines. Both non-running states stay silent here —
301
+ // the paste fallback is fully functional without Docker, and the start
302
+ // offer surfaces the differentiated runtime guidance later in the flow.
303
+ const capturedToken = docker.daemonStatus() === "running"
270
304
  ? await offerSyncTokenCapture(prompts, docker)
271
305
  : undefined;
272
306
  // Masked prompt: the sync token is a credential and must not echo into
@@ -280,8 +314,10 @@ const runRemoteInit = async (flags, deps) => {
280
314
  : undefined;
281
315
  const token = generateToken();
282
316
  // 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.
317
+ // SYNC_MODE. Remote init is always interactive (no --yes), and any existing
318
+ // .env passed the re-init guard — a consented re-run gets the full setup,
319
+ // chooser included (see the local flow's comment for the overwrite/keep
320
+ // semantics).
285
321
  const defaultEnvContent = buildRemoteEnv({
286
322
  mcpAuthToken: token,
287
323
  publicUrl,
@@ -289,13 +325,7 @@ const runRemoteInit = async (flags, deps) => {
289
325
  vaultName,
290
326
  vaultPassword,
291
327
  });
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);
328
+ const optionalOverrides = await askOptionalSettings({ mode: "remote", envContent: defaultEnvContent }, prompts);
299
329
  const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
300
330
  const files = buildFilesToWrite(envContent);
301
331
  const results = await writeFiles({ targetDir, files }, confirmOverwrite(prompts));
@@ -309,15 +339,28 @@ const runRemoteInit = async (flags, deps) => {
309
339
  if (tokenWritten)
310
340
  prompts.log("Generated MCP auth token (saved to .env).");
311
341
  const port = readEnvPort(join(targetDir, ".env"));
342
+ // Like PORT above, PUBLIC_URL comes from the .env actually on disk — a kept
343
+ // existing file may hold a different URL than this run's prompt, and the
344
+ // server only reads the file. The prompted value is the fallback for a kept
345
+ // legacy .env that predates PUBLIC_URL.
346
+ const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl;
312
347
  // Without the sync token the container can't start (init-check-auth fails
313
348
  // and s6 stops it), so only offer docker run when it was provided.
314
349
  const started = obsidianAuthToken === ""
315
350
  ? false
316
351
  : await offerDockerRun({ targetDir, port, mode: "remote" }, deps);
352
+ // The container check above hit localhost on this machine; the public URL
353
+ // is the ingress path clients actually use — probe it too, informationally.
354
+ if (started) {
355
+ await reportPublicUrlProbe(effectivePublicUrl, {
356
+ prompts,
357
+ fetchFn: deps.fetchFn,
358
+ });
359
+ }
317
360
  prompts.print(buildRemoteConnectMessage({
318
361
  targetDir,
319
362
  token,
320
- publicUrl,
363
+ publicUrl: effectivePublicUrl,
321
364
  started,
322
365
  obsidianTokenMissing: obsidianAuthToken === "",
323
366
  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
  /**
@@ -13,7 +14,7 @@ export const requireInitializedDir = (dirFlag, prompts) => {
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,58 @@ 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
+ const publicUrl = readEnvPublicUrl(envFilePath);
45
+ return { mode, targetDir, envFilePath, port, vaultPath, publicUrl };
44
46
  };
45
47
  /**
46
48
  * Verifies the container runtime is reachable, reporting the shared error
47
49
  * message when it isn't. Callers early-return on false.
48
50
  */
49
51
  export const ensureDaemonRunning = (docker, prompts) => {
50
- if (docker.isDaemonRunning())
52
+ const daemonStatus = docker.daemonStatus();
53
+ if (daemonStatus === "running")
51
54
  return true;
52
- prompts.error("Container runtime not running — start Docker Desktop, Colima,\n" +
53
- "OrbStack, or another Docker-compatible runtime.");
55
+ prompts.error(daemonStatus === "not-installed"
56
+ ? buildDockerNotInstalledMessage({ nextStep: "" })
57
+ : buildDaemonNotRunningMessage("."));
54
58
  return false;
55
59
  };
60
+ /**
61
+ * Whether the post-start public-URL probe should run — and, when it should,
62
+ * proof that publicUrl is set. Local's PUBLIC_URL is the derived localhost
63
+ * URL, so probing it would only duplicate the health check the start cycle
64
+ * just ran.
65
+ */
66
+ const shouldRunPostStartProbe = (deployment) => {
67
+ return deployment.mode === "remote" && deployment.publicUrl !== undefined;
68
+ };
69
+ /**
70
+ * One-shot informational probe of the public /healthz after a confirmed
71
+ * container start. Never a gate: a failure warns and the command still
72
+ * succeeds — before HTTPS/ingress access is set up an unreachable public URL
73
+ * is the expected state, and this machine's result doesn't prove the same
74
+ * for other devices (a VPS may not reach its own public address). Returning
75
+ * void keeps the informational contract structural.
76
+ */
77
+ export const reportPublicUrlProbe = async (publicUrl, deps) => {
78
+ const { prompts, fetchFn } = deps;
79
+ // A hand-edited .env value may carry a trailing slash; strip it so the
80
+ // probe URL is `${base}/healthz`, never `${base}//healthz`.
81
+ const healthUrl = `${publicUrl.replace(/\/+$/, "")}/healthz`;
82
+ const spinner = prompts.spinner();
83
+ spinner.start(`Checking the public URL (${healthUrl})`);
84
+ const publicUrlResponded = await probeHealth({ url: healthUrl }, fetchFn);
85
+ if (publicUrlResponded) {
86
+ spinner.stop(`Public URL responds — ${healthUrl} answered from this machine.`);
87
+ return;
88
+ }
89
+ spinner.stop(`No answer from ${healthUrl} yet.`);
90
+ prompts.warn("The server is up, but its public URL didn't answer from this machine.\n" +
91
+ "That's expected until HTTPS (or direct port) access is set up — and\n" +
92
+ "some networks keep a server from reaching its own public address even\n" +
93
+ "when other devices can. Once access is set up, check from any device:\n" +
94
+ ` curl ${healthUrl}`);
95
+ };
56
96
  /**
57
97
  * Stop-and-remove → docker run → health poll, with the shared messaging.
58
98
  * Returns a process exit code. Callers own the daemon check and any
@@ -92,6 +132,11 @@ export const recreateContainer = async (params, deps) => {
92
132
  return 1;
93
133
  }
94
134
  spinner.stop("Server is up — health check passed.");
135
+ // Informational only — the container is confirmed healthy above, so the
136
+ // public-URL result never changes the exit code.
137
+ if (shouldRunPostStartProbe(deployment)) {
138
+ await reportPublicUrlProbe(deployment.publicUrl, { prompts, fetchFn });
139
+ }
95
140
  return 0;
96
141
  };
97
142
  /**
@@ -119,7 +164,7 @@ export const runDown = async (flags, deps) => {
119
164
  return 1;
120
165
  }
121
166
  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}"`);
167
+ prompts.outro(`Start again with: npx vault-cortex@latest start --dir "${initialized.targetDir}"`);
123
168
  return 0;
124
169
  };
125
170
  /**
@@ -136,7 +181,7 @@ export const runLogs = async (flags, deps) => {
136
181
  if (!ensureDaemonRunning(docker, prompts))
137
182
  return 1;
138
183
  if (!docker.containerExists()) {
139
- prompts.error("No vault-cortex container — start it with `npx vault-cortex restart`.");
184
+ prompts.error(`No vault-cortex container — start it with: npx vault-cortex@latest start --dir "${initialized.targetDir}"`);
140
185
  return 1;
141
186
  }
142
187
  return await docker.streamLogs({
@@ -145,13 +190,13 @@ export const runLogs = async (flags, deps) => {
145
190
  });
146
191
  };
147
192
  /**
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.
193
+ * Shared start/restart cycle: re-create the container from the .env on disk
194
+ * and verify health. One implementation, two command names — the labels are
195
+ * the only divergence, phrased for the intent each name serves.
151
196
  */
152
- export const runRestart = async (flags, deps) => {
197
+ const runRecreateFromEnv = async (flags, deps, labels) => {
153
198
  const { prompts, docker, fetchFn } = deps;
154
- prompts.intro("vault-cortex restart");
199
+ prompts.intro(labels.introTitle);
155
200
  const deployment = resolveDeployment(flags.dir, prompts);
156
201
  if (!deployment)
157
202
  return 1;
@@ -160,7 +205,33 @@ export const runRestart = async (flags, deps) => {
160
205
  const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
161
206
  if (exitCode !== 0)
162
207
  return exitCode;
163
- prompts.log("Applied the current .env settings.");
164
- prompts.outro("Restart complete.");
208
+ prompts.log(labels.successLog);
209
+ prompts.outro(labels.outroMessage);
165
210
  return 0;
166
211
  };
212
+ /**
213
+ * Starts the server from the saved .env — the command name users reach for
214
+ * when nothing is running yet (after `down`, or an init that skipped the
215
+ * start offer). Same cycle as restart: if a container is already running it
216
+ * is safely replaced, and `docker run` pulls the image when it's missing.
217
+ */
218
+ export const runStart = async (flags, deps) => {
219
+ return runRecreateFromEnv(flags, deps, {
220
+ introTitle: "vault-cortex start",
221
+ successLog: "Started with the settings from .env.",
222
+ outroMessage: "Start complete.",
223
+ });
224
+ };
225
+ /**
226
+ * Re-creates the container from the .env on disk and verifies health.
227
+ * Unlike `docker restart`, this applies .env edits (the env-file is only
228
+ * read at container creation); unlike upgrade, it never replaces an image
229
+ * you already have (`docker run` still pulls when none exists locally).
230
+ */
231
+ export const runRestart = async (flags, deps) => {
232
+ return runRecreateFromEnv(flags, deps, {
233
+ introTitle: "vault-cortex restart",
234
+ successLog: "Applied the current .env settings.",
235
+ outroMessage: "Restart complete.",
236
+ });
237
+ };
package/dist/main.js CHANGED
@@ -2,7 +2,7 @@ import { runConfigure } from "./configure.js";
2
2
  import { createDockerRunner } from "./docker.js";
3
3
  import { runGetSyncToken } from "./get-sync-token.js";
4
4
  import { runInit } from "./init.js";
5
- import { runDown, runLogs, runRestart } from "./lifecycle.js";
5
+ import { runDown, runLogs, runRestart, runStart } from "./lifecycle.js";
6
6
  import { buildProgram } from "./program.js";
7
7
  import { createPrompts } from "./prompts.js";
8
8
  import { runUpgrade } from "./upgrade.js";
@@ -24,6 +24,11 @@ export const run = async (version) => {
24
24
  docker: createDockerRunner(),
25
25
  fetchFn: fetch,
26
26
  }),
27
+ runStart: (flags) => runStart(flags, {
28
+ prompts: createPrompts(),
29
+ docker: createDockerRunner(),
30
+ fetchFn: fetch,
31
+ }),
27
32
  runRestart: (flags) => runRestart(flags, {
28
33
  prompts: createPrompts(),
29
34
  docker: createDockerRunner(),
package/dist/messages.js CHANGED
@@ -10,17 +10,53 @@ 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}"`;
16
- const startServerLine = (targetDir) => `Start the server:\n ${upgradeCommand(targetDir)}`;
47
+ const upgradeCommand = (targetDir) => `npx vault-cortex@latest upgrade --dir "${targetDir}"`;
48
+ // Start guidance prints `start`, not `upgrade` telling a user who has never
49
+ // started anything to run "upgrade" reads as updating something they don't
50
+ // have. `start` runs the same re-create cycle and pulls the image on demand.
51
+ export const startCommand = (targetDir) => `npx vault-cortex@latest start --dir "${targetDir}"`;
52
+ const startServerLine = (targetDir) => `Start the server:\n ${startCommand(targetDir)}`;
17
53
  /** Remote start line: running, blocked on the missing sync token, or ready to start. */
18
54
  const remoteStartLine = (params) => {
19
55
  const { targetDir, started, obsidianTokenMissing } = params;
20
56
  if (started)
21
57
  return "The server is running.";
22
58
  if (obsidianTokenMissing) {
23
- return `Fill in OBSIDIAN_AUTH_TOKEN in ${targetDir}/.env, then start the server:\n ${upgradeCommand(targetDir)}`;
59
+ return `Fill in OBSIDIAN_AUTH_TOKEN in ${targetDir}/.env, then start the server:\n ${startCommand(targetDir)}`;
24
60
  }
25
61
  return startServerLine(targetDir);
26
62
  };
@@ -62,6 +98,19 @@ const curlGuidance = (mcpUrl) => `Clients without OAuth, scripts, and curl send
62
98
  curl -H "Authorization: Bearer <token>" ${mcpUrl}`;
63
99
  const smokeTest = (healthUrl) => `Smoke test:
64
100
  curl ${healthUrl}`;
101
+ /**
102
+ * Remote health-check block. Started: the CLI verified localhost on the VPS,
103
+ * but the public URL is a different check (ingress — DNS, TLS, proxy), so the
104
+ * command stays, reworded as the works-from-any-device check. Not started:
105
+ * the plain smoke test to run after starting.
106
+ */
107
+ const remoteHealthCheckBlock = (healthUrl, started) => {
108
+ if (started) {
109
+ return `Health check — works from any device that can reach the URL:
110
+ curl ${healthUrl}`;
111
+ }
112
+ return smokeTest(healthUrl);
113
+ };
65
114
  const updateGuidance = (targetDir) => `Update to the latest release:
66
115
  ${upgradeCommand(targetDir)}`;
67
116
  /**
@@ -76,6 +125,15 @@ export const buildLocalConnectMessage = (params) => {
76
125
  ? "The server is running."
77
126
  : startServerLine(targetDir);
78
127
  const tokenLine = tokenBlock({ targetDir, token, tokenWritten });
128
+ // Once the server is confirmed up, the smoke test is dropped — the CLI just
129
+ // verified this exact URL, so re-printing it reads as leftover homework.
130
+ // Assembled as a filtered list so the omission leaves no stray blank line.
131
+ const nonOauthBlocks = [
132
+ curlGuidance(`${baseUrl}/mcp`),
133
+ started ? undefined : smokeTest(`${baseUrl}/healthz`),
134
+ ]
135
+ .filter(Boolean)
136
+ .join("\n\n");
79
137
  // Flush-left on purpose: this is printed as plain text (see paint), so
80
138
  // leading whitespace would render as literal indentation. Local is always
81
139
  // localhost http, so it shares the http guidance; its only divergences are
@@ -104,18 +162,16 @@ it with mcp-remote:
104
162
 
105
163
  ${sectionRule("Non-OAuth")}
106
164
 
107
- ${curlGuidance(`${baseUrl}/mcp`)}
108
-
109
- ${smokeTest(`${baseUrl}/healthz`)}
165
+ ${nonOauthBlocks}
110
166
 
111
167
  ${sectionRule("Settings")}
112
168
 
113
169
  Adjust optional settings (memory layer and folder, file tools,
114
170
  semantic search, port, timezone):
115
- npx vault-cortex configure --dir "${targetDir}"
171
+ npx vault-cortex@latest configure --dir "${targetDir}"
116
172
 
117
173
  Or edit ${targetDir}/.env directly — change a value (uncommenting it
118
- first if needed), then apply with "npx vault-cortex restart" (plain
174
+ first if needed), then apply with "npx vault-cortex@latest restart" (plain
119
175
  docker restart does not re-read .env).
120
176
 
121
177
  ${updateGuidance(targetDir)}
@@ -171,16 +227,16 @@ ${sectionRule("Non-OAuth")}
171
227
 
172
228
  ${curlGuidance(`${publicUrl}/mcp`)}
173
229
 
174
- ${smokeTest(`${publicUrl}/healthz`)}
230
+ ${remoteHealthCheckBlock(`${publicUrl}/healthz`, started)}
175
231
 
176
232
  ${sectionRule("Settings")}
177
233
 
178
234
  Adjust optional settings (memory layer and folder, file tools,
179
235
  semantic search, port, timezone, sync direction):
180
- npx vault-cortex configure --dir "${targetDir}"
236
+ npx vault-cortex@latest configure --dir "${targetDir}"
181
237
 
182
238
  Or edit ${targetDir}/.env directly — change a value (uncommenting it
183
- first if needed), then apply with "npx vault-cortex restart" (plain
239
+ first if needed), then apply with "npx vault-cortex@latest restart" (plain
184
240
  docker restart does not re-read .env).
185
241
 
186
242
  ${updateGuidance(targetDir)}
package/dist/program.js CHANGED
@@ -29,6 +29,13 @@ export const buildProgram = (options) => {
29
29
  .action(async (flags) => {
30
30
  process.exitCode = await options.runUpgrade(flags);
31
31
  });
32
+ program
33
+ .command("start")
34
+ .description("Start the server with the saved settings and verify health (same cycle as restart)")
35
+ .option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
36
+ .action(async (flags) => {
37
+ process.exitCode = await options.runStart(flags);
38
+ });
32
39
  program
33
40
  .command("restart")
34
41
  .description("Re-create the container from .env and verify health (applies .env edits; no image pull)")
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) => [
@@ -38,14 +40,36 @@ export const readEnvVaultPath = (envFilePath) => {
38
40
  };
39
41
  /**
40
42
  * Returns true when the .env file has an active (uncommented) PUBLIC_URL line.
41
- * Used by upgrade to detect .env files from the old compose-based CLI, where
42
- * PUBLIC_URL was provided by docker-compose defaults rather than the .env.
43
+ * It's the FALSE result that carries the signal: the old compose-based CLI's
44
+ * generated .env never held a PUBLIC_URL line (the compose file's environment
45
+ * defaults supplied it), so a local .env without one predates the docker-run
46
+ * migration — callers use the negation to ask the user to add the line
47
+ * instead of starting a server missing a required variable.
43
48
  */
44
49
  export const hasEnvPublicUrl = (envFilePath) => {
45
50
  if (!existsSync(envFilePath))
46
51
  return false;
47
52
  return ENV_PUBLIC_URL_LINE.test(readFileSync(envFilePath, "utf8"));
48
53
  };
54
+ /**
55
+ * Reads the public URL value from a .env file. Returns undefined when the
56
+ * file is missing or has no uncommented, non-empty PUBLIC_URL line —
57
+ * deliberately stricter than hasEnvPublicUrl, whose job is old-compose
58
+ * detection and so matches an empty `PUBLIC_URL=` line too.
59
+ */
60
+ export const readEnvPublicUrl = (envFilePath) => {
61
+ if (!existsSync(envFilePath))
62
+ return undefined;
63
+ const match = ENV_PUBLIC_URL_VALUE_LINE.exec(readFileSync(envFilePath, "utf8"));
64
+ // A whitespace-only line matches the regex and trims to "" — normalize to
65
+ // undefined so the non-empty contract holds ("" is never a legitimate URL).
66
+ const publicUrlValue = match?.[1].trim();
67
+ // Strip trailing slashes (mirroring askPublicUrl's prompt-side
68
+ // normalization): consumers append paths to this base, and a hand-edited
69
+ // `https://host/` would otherwise print broken `//mcp` connect URLs.
70
+ const normalizedPublicUrl = publicUrlValue?.replace(/\/+$/, "");
71
+ return normalizedPublicUrl || undefined;
72
+ };
49
73
  /**
50
74
  * Detects the deployment mode from a .env file. Remote mode requires
51
75
  * 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.9.0",
3
+ "version": "0.10.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",