vault-cortex 0.12.0-beta.61 → 0.13.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
@@ -60,9 +60,8 @@ What it does:
60
60
  Re-running init where a setup already exists asks first — declining leaves
61
61
  everything unchanged and points you at [`configure`](#configure), the right
62
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 sign in to your Obsidian
65
- account right from the terminal.
63
+ without asking. During a remote setup, init offers to generate your
64
+ [Obsidian Sync token](#get-sync-token) as part of the flow.
66
65
 
67
66
  Flags:
68
67
 
@@ -196,9 +195,8 @@ npx vault-cortex@latest get-sync-token
196
195
  ```
197
196
 
198
197
  The command prompts for your Obsidian account email, password, and MFA code
199
- (if enabled), signs in via the Obsidian API, and prints the token. No Docker
200
- required. Use `--dir <path>` to write the token straight into an existing
201
- `.env` instead:
198
+ (if enabled), signs in via the Obsidian API, and prints the token. Use
199
+ `--dir <path>` to write the token straight into an existing `.env` instead:
202
200
 
203
201
  ```bash
204
202
  npx vault-cortex@latest get-sync-token --dir ./vault-cortex
@@ -218,4 +216,5 @@ During `init --mode remote`, this flow is offered automatically.
218
216
 
219
217
  - [Local quickstart](https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/README.md)
220
218
  - [Remote quickstart (VPS + Obsidian Sync)](https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md)
219
+ - [One-click deploy on Render or Railway](https://github.com/aliasunder/vault-cortex#one-click-deploy)
221
220
  - [Full project README](https://github.com/aliasunder/vault-cortex)
package/dist/env.js CHANGED
@@ -324,11 +324,11 @@ export const buildRemoteEnv = (answers) => {
324
324
  # VAULT_PASSWORD=`
325
325
  : `# Vault end-to-end encryption password.
326
326
  VAULT_PASSWORD=${answers.vaultPassword}`;
327
- const obsidianTokenComment = answers.obsidianAuthToken === ""
328
- ? `# Obsidian Sync auth token — FILL THIS IN before starting the server.
327
+ const obsidianTokenComment = answers.obsidianAuthToken
328
+ ? `# Obsidian Sync auth token.`
329
+ : `# Obsidian Sync auth token — FILL THIS IN before starting the server.
329
330
  # Generate once with:
330
- # npx vault-cortex@latest get-sync-token`
331
- : `# Obsidian Sync auth token.`;
331
+ # npx vault-cortex@latest get-sync-token`;
332
332
  return `# vault-cortex — remote quickstart (Obsidian Sync)
333
333
  # Generated by \`npx vault-cortex@latest init\`. Full option reference:
334
334
  # https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/.env.example
@@ -343,7 +343,7 @@ MCP_AUTH_TOKEN=${answers.mcpAuthToken}
343
343
  PUBLIC_URL=${answers.publicUrl}
344
344
 
345
345
  ${obsidianTokenComment}
346
- OBSIDIAN_AUTH_TOKEN=${answers.obsidianAuthToken}
346
+ OBSIDIAN_AUTH_TOKEN=${answers.obsidianAuthToken ?? ""}
347
347
 
348
348
  # Exact name of your Obsidian vault (case-sensitive).
349
349
  VAULT_NAME=${answers.vaultName}
@@ -1,9 +1,16 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { patchEnvObsidianToken } from "./scaffold.js";
3
3
  import { expandTilde } from "./vault.js";
4
- const OBSIDIAN_SIGNIN_URL = "https://api.obsidian.md/user/signin";
4
+ const OBSIDIAN_SIGNIN_URL = process.env.OBSIDIAN_SIGNIN_URL ?? "https://api.obsidian.md/user/signin";
5
5
  const SIGNIN_TIMEOUT_MS = 30_000;
6
6
  const describeError = (error) => error instanceof Error ? error.message : String(error);
7
+ class ObsidianApiError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "ObsidianApiError";
11
+ }
12
+ }
13
+ const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
7
14
  /**
8
15
  * Calls the Obsidian Sync signin API. Returns the parsed JSON on success,
9
16
  * or throws on HTTP/network errors. The API returns { error: string } for
@@ -23,37 +30,37 @@ const callSigninApi = async (params, fetchFn) => {
23
30
  }),
24
31
  signal: AbortSignal.timeout(SIGNIN_TIMEOUT_MS),
25
32
  });
26
- if (!response.ok) {
33
+ if (!response.ok)
27
34
  throw new Error(`HTTP Error ${response.status}`);
28
- }
29
- let body;
30
35
  try {
31
- body = await response.json();
32
- }
33
- catch {
34
- throw new Error("Unexpected response from Obsidian API (not JSON)");
35
- }
36
- const isRecord = (value) => typeof value === "object" && value !== null;
37
- if (!isRecord(body)) {
38
- throw new Error("Unexpected response from Obsidian API (not JSON)");
36
+ const body = await response.json();
37
+ if (!isJsonObject(body))
38
+ throw new Error("not a JSON object");
39
+ if (typeof body.error === "string")
40
+ throw new ObsidianApiError(body.error);
41
+ if (typeof body.token !== "string" || !body.token)
42
+ throw new Error("no token field");
43
+ return body.token;
39
44
  }
40
- if ("error" in body && typeof body.error === "string") {
41
- throw new ObsidianApiError(body.error);
42
- }
43
- const token = "token" in body && typeof body.token === "string" ? body.token : undefined;
44
- if (!token) {
45
- throw new Error("Unexpected response from Obsidian API (no token)");
45
+ catch (error) {
46
+ if (error instanceof ObsidianApiError)
47
+ throw error;
48
+ throw new Error(`Unexpected response from Obsidian API (${describeError(error)})`, { cause: error });
46
49
  }
47
- const name = "name" in body && typeof body.name === "string" ? body.name : "";
48
- const email = "email" in body && typeof body.email === "string" ? body.email : "";
49
- return { token, name, email };
50
50
  };
51
- class ObsidianApiError extends Error {
52
- constructor(message) {
53
- super(message);
54
- this.name = "ObsidianApiError";
51
+ /**
52
+ * Warns the user about a signin failure with a message tailored to the
53
+ * error type. Called by both the initial signin and MFA retry paths.
54
+ */
55
+ const warnSigninError = (error, prompts, isMfaRetry) => {
56
+ if (error instanceof Error && error.name === "TimeoutError") {
57
+ prompts.warn("Request timed out — check your internet connection and try again.");
58
+ return;
55
59
  }
56
- }
60
+ const isMfaError = error instanceof ObsidianApiError && error.message.includes("2FA code");
61
+ const mfaHint = isMfaRetry && isMfaError ? "\n Check your 2FA code and try again." : "";
62
+ prompts.warn(`Could not sign in: ${describeError(error)}${mfaHint}`);
63
+ };
57
64
  /**
58
65
  * Signs in to the user's Obsidian account via the Sync API and returns
59
66
  * the auth token. Prompts for email, password, and MFA code (when 2FA
@@ -68,45 +75,35 @@ export const captureObsidianToken = async (deps) => {
68
75
  const spinner = prompts.spinner();
69
76
  spinner.start("Signing in to Obsidian...");
70
77
  try {
71
- const result = await callSigninApi({ email, password, mfa: "" }, fetchFn);
72
- spinner.stop(`Signed in as ${result.name} (${result.email}).`);
73
- return result.token;
78
+ const token = await callSigninApi({ email, password, mfa: "" }, fetchFn);
79
+ spinner.stop(`Signed in as ${email}.`);
80
+ return token;
74
81
  }
75
82
  catch (error) {
76
83
  // MFA required: the API returns an error containing "2FA code" — prompt
77
84
  // and retry. "2FA code is incorrect" is a wrong-code rejection, not a
78
85
  // prompt-for-code signal. Mirrors the obsidian-headless v0.0.14 logic.
79
- if (error instanceof ObsidianApiError &&
86
+ const needsMfa = error instanceof ObsidianApiError &&
80
87
  error.message.includes("2FA code") &&
81
- !error.message.includes("2FA code is incorrect")) {
82
- spinner.stop("Two-factor authentication required.");
83
- const mfaCode = await prompts.text("2FA code:");
84
- spinner.start("Verifying...");
85
- try {
86
- const result = await callSigninApi({ email, password, mfa: mfaCode }, fetchFn);
87
- spinner.stop(`Signed in as ${result.name} (${result.email}).`);
88
- return result.token;
89
- }
90
- catch (retryError) {
91
- spinner.stop("Sign-in failed.");
92
- if (retryError instanceof Error && retryError.name === "TimeoutError") {
93
- prompts.warn("Request timed out — check your internet connection and try again.");
94
- return undefined;
95
- }
96
- const retryHint = retryError instanceof ObsidianApiError
97
- ? "\n Check your 2FA code and try again."
98
- : "";
99
- prompts.warn(`Could not sign in: ${describeError(retryError)}${retryHint}`);
100
- return undefined;
101
- }
88
+ !error.message.includes("2FA code is incorrect");
89
+ if (!needsMfa) {
90
+ spinner.stop("Sign-in failed.");
91
+ warnSigninError(error, prompts, false);
92
+ return undefined;
93
+ }
94
+ spinner.stop("Two-factor authentication required.");
95
+ const mfaCode = await prompts.text("2FA code:");
96
+ spinner.start("Verifying...");
97
+ try {
98
+ const token = await callSigninApi({ email, password, mfa: mfaCode }, fetchFn);
99
+ spinner.stop(`Signed in as ${email}.`);
100
+ return token;
102
101
  }
103
- spinner.stop("Sign-in failed.");
104
- if (error instanceof Error && error.name === "TimeoutError") {
105
- prompts.warn("Request timed out — check your internet connection and try again.");
102
+ catch (retryError) {
103
+ spinner.stop("Sign-in failed.");
104
+ warnSigninError(retryError, prompts, true);
106
105
  return undefined;
107
106
  }
108
- prompts.warn(`Could not sign in: ${describeError(error)}`);
109
- return undefined;
110
107
  }
111
108
  };
112
109
  /**
@@ -137,7 +134,8 @@ export const runGetSyncToken = async (flags, deps) => {
137
134
  "OBSIDIAN_AUTH_TOKEN line. Run init first.");
138
135
  return 1;
139
136
  }
140
- prompts.log(`Token written to ${envFilePath}`);
137
+ prompts.log(`Token written to ${envFilePath}\n\n` +
138
+ `Start the server:\n npx vault-cortex start --dir "${flags.dir}"`);
141
139
  prompts.outro("Done.");
142
140
  return 0;
143
141
  };
package/dist/init.js CHANGED
@@ -6,7 +6,7 @@ import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage, buildLoca
6
6
  import { healthPollTimeoutMs, healthTimeoutMessage, pollHealth, } from "./docker.js";
7
7
  import { reportPublicUrlProbe } from "./lifecycle.js";
8
8
  import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, } from "./optional-settings.js";
9
- import { buildFilesToWrite, readEnvPort, readEnvPublicUrl, writeFiles, } from "./scaffold.js";
9
+ import { buildFilesToWrite, readEnvObsidianToken, readEnvPort, readEnvPublicUrl, stripEnvQuotedValues, writeFiles, } from "./scaffold.js";
10
10
  import { generateToken } from "./token.js";
11
11
  import { expandTilde, validateVaultPath } from "./vault.js";
12
12
  const DEFAULT_TARGET_DIR = "./vault-cortex";
@@ -29,9 +29,12 @@ const askMode = async (prompts) => {
29
29
  /**
30
30
  * Offers to sign in to the Obsidian account and capture the Sync token.
31
31
  * Returns the captured token string, or undefined when the user declines
32
- * or the capture fails (the caller falls back to a paste prompt).
32
+ * or the capture fails (the caller falls back to any token already in the
33
+ * on-disk .env, or shows get-sync-token guidance).
33
34
  */
34
35
  const offerSyncTokenCapture = async (prompts, fetchFn) => {
36
+ prompts.log("Your server needs an Obsidian Sync token to access your vault.\n" +
37
+ "You can sign in to your Obsidian account now to generate one.");
35
38
  const runNow = await prompts.confirm("Generate the token now?", true);
36
39
  if (!runNow)
37
40
  return undefined;
@@ -174,9 +177,11 @@ const offerDockerRun = async (params, deps) => {
174
177
  const startNow = await prompts.confirm("Start the server now?", true);
175
178
  if (!startNow)
176
179
  return "not-started";
180
+ const envFilePath = join(targetDir, ".env");
181
+ stripEnvQuotedValues(envFilePath);
177
182
  const containerStarted = docker.dockerRun({
178
183
  mode,
179
- envFilePath: join(targetDir, ".env"),
184
+ envFilePath,
180
185
  port,
181
186
  vaultPath,
182
187
  });
@@ -304,13 +309,16 @@ const runRemoteInit = async (flags, deps) => {
304
309
  const publicUrl = await askPublicUrl(prompts);
305
310
  const vaultName = await askVaultName(prompts);
306
311
  // Sign in to Obsidian and capture the Sync token directly via the API.
307
- // Falls back to a paste prompt when the user declines or capture fails.
312
+ // When the user declines or capture fails, fall back to any token already
313
+ // in the on-disk .env (a re-init over an existing deployment). Only show
314
+ // the "run get-sync-token later" guidance when neither source has a token.
308
315
  const capturedToken = await offerSyncTokenCapture(prompts, fetchFn);
309
- // Masked prompt: the sync token is a credential and must not echo into
310
- // the terminal or scrollback. An empty submission still means "fill in
311
- // .env later" — clack's password prompt accepts blank input.
312
- const obsidianAuthToken = capturedToken ??
313
- (await prompts.password("Paste the Obsidian Sync token (leave blank to fill in .env later):")).trim();
316
+ const existingEnvToken = readEnvObsidianToken(join(targetDir, ".env"));
317
+ const hasExistingToken = Boolean(capturedToken ?? existingEnvToken);
318
+ if (!hasExistingToken) {
319
+ prompts.log("No token yet run this later to add it to your .env:\n" +
320
+ ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`);
321
+ }
314
322
  const usesEncryption = await prompts.confirm("Does your vault use end-to-end encryption?", false);
315
323
  const vaultPassword = usesEncryption
316
324
  ? await prompts.password("Vault encryption password:")
@@ -324,7 +332,7 @@ const runRemoteInit = async (flags, deps) => {
324
332
  const defaultEnvContent = buildRemoteEnv({
325
333
  mcpAuthToken: token,
326
334
  publicUrl,
327
- obsidianAuthToken,
335
+ obsidianAuthToken: capturedToken ?? existingEnvToken,
328
336
  vaultName,
329
337
  vaultPassword,
330
338
  });
@@ -349,7 +357,7 @@ const runRemoteInit = async (flags, deps) => {
349
357
  const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl;
350
358
  // Without the sync token the container can't start (init-check-auth fails
351
359
  // and s6 stops it), so only offer docker run when it was provided.
352
- const startStatus = obsidianAuthToken === ""
360
+ const startStatus = !hasExistingToken
353
361
  ? "not-started"
354
362
  : await offerDockerRun({ targetDir, port, mode: "remote" }, deps);
355
363
  // The container check above hit localhost on this machine; the public URL
@@ -365,7 +373,7 @@ const runRemoteInit = async (flags, deps) => {
365
373
  token,
366
374
  publicUrl: effectivePublicUrl,
367
375
  startStatus,
368
- obsidianTokenMissing: obsidianAuthToken === "",
376
+ obsidianTokenMissing: !hasExistingToken,
369
377
  tokenWritten,
370
378
  }));
371
379
  return 0;
package/dist/lifecycle.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join, resolve } from "node:path";
2
2
  import { CONTAINER_NAME, healthPollTimeoutMs, healthTimeoutMessage, pollHealth, probeHealth, } from "./docker.js";
3
3
  import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage, } from "./messages.js";
4
- import { detectMode, hasEnvPublicUrl, readEnvPort, readEnvPublicUrl, readEnvVaultPath, } from "./scaffold.js";
4
+ import { detectMode, hasEnvPublicUrl, readEnvPort, readEnvPublicUrl, readEnvVaultPath, stripEnvQuotedValues, } from "./scaffold.js";
5
5
  import { expandTilde } from "./vault.js";
6
6
  const DEFAULT_TARGET_DIR = "./vault-cortex";
7
7
  /**
@@ -110,6 +110,7 @@ export const recreateContainer = async (params, deps) => {
110
110
  prompts.error(`Could not remove the existing container — check: docker rm -f ${CONTAINER_NAME}`);
111
111
  return 1;
112
112
  }
113
+ stripEnvQuotedValues(deployment.envFilePath);
113
114
  prompts.log("Starting container...");
114
115
  const containerStarted = docker.dockerRun({
115
116
  mode: deployment.mode,
package/dist/scaffold.js CHANGED
@@ -2,8 +2,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "
2
2
  import { join } from "node:path";
3
3
  /** Default host port — matches the container's internal port. */
4
4
  export const DEFAULT_PORT = 8000;
5
- /** Matches an active (uncommented) PORT line in a .env file. */
6
- const ENV_PORT_LINE = /^PORT=(\d+)\s*$/m;
5
+ /** Matches an active (uncommented) PORT line, with optional surrounding quotes. */
6
+ const ENV_PORT_LINE = /^PORT=["']?(\d+)["']?\s*$/m;
7
7
  /** Matches an active (uncommented) VAULT_PATH line in a .env file. */
8
8
  const ENV_VAULT_PATH_LINE = /^VAULT_PATH=(.+)\s*$/m;
9
9
  /** Matches an active (uncommented) PUBLIC_URL line. */
@@ -12,6 +12,17 @@ const ENV_PUBLIC_URL_LINE = /^PUBLIC_URL=/m;
12
12
  const ENV_PUBLIC_URL_VALUE_LINE = /^PUBLIC_URL=(.+)\s*$/m;
13
13
  /** Matches an active (uncommented) OBSIDIAN_AUTH_TOKEN line. */
14
14
  const OBSIDIAN_AUTH_TOKEN_LINE = /^OBSIDIAN_AUTH_TOKEN=/m;
15
+ /** Matches an env line whose value is wrapped in matching quotes. */
16
+ const QUOTED_ENV_VALUE = /^([A-Za-z_][A-Za-z0-9_]*=)(["'])(.*)\2(\s*)$/gm;
17
+ /**
18
+ * Strips matching surrounding quotes from a value — `"foo"` → `foo`,
19
+ * `'bar'` → `bar`, `unquoted` → `unquoted`. Only strips when the
20
+ * opening and closing quote characters match.
21
+ */
22
+ const stripSurroundingQuotes = (value) => {
23
+ const quoteMatch = /^(["'])(.*)\1$/.exec(value);
24
+ return quoteMatch ? quoteMatch[2] : value;
25
+ };
15
26
  export const buildFilesToWrite = (envContent) => [
16
27
  // .env holds the bearer token (and possibly a vault password) — owner-only.
17
28
  { name: ".env", content: envContent, mode: 0o600 },
@@ -36,7 +47,8 @@ export const readEnvVaultPath = (envFilePath) => {
36
47
  if (!existsSync(envFilePath))
37
48
  return undefined;
38
49
  const match = ENV_VAULT_PATH_LINE.exec(readFileSync(envFilePath, "utf8"));
39
- return match?.[1].trim();
50
+ const rawValue = match?.[1].trim();
51
+ return rawValue ? stripSurroundingQuotes(rawValue) : undefined;
40
52
  };
41
53
  /**
42
54
  * Returns true when the .env file has an active (uncommented) PUBLIC_URL line.
@@ -63,11 +75,12 @@ export const readEnvPublicUrl = (envFilePath) => {
63
75
  const match = ENV_PUBLIC_URL_VALUE_LINE.exec(readFileSync(envFilePath, "utf8"));
64
76
  // A whitespace-only line matches the regex and trims to "" — normalize to
65
77
  // undefined so the non-empty contract holds ("" is never a legitimate URL).
66
- const publicUrlValue = match?.[1].trim();
78
+ const rawValue = match?.[1].trim();
79
+ const unquotedValue = rawValue ? stripSurroundingQuotes(rawValue) : undefined;
67
80
  // Strip trailing slashes (mirroring askPublicUrl's prompt-side
68
81
  // normalization): consumers append paths to this base, and a hand-edited
69
82
  // `https://host/` would otherwise print broken `//mcp` connect URLs.
70
- const normalizedPublicUrl = publicUrlValue?.replace(/\/+$/, "");
83
+ const normalizedPublicUrl = unquotedValue?.replace(/\/+$/, "");
71
84
  return normalizedPublicUrl || undefined;
72
85
  };
73
86
  /**
@@ -100,6 +113,36 @@ export const patchEnvObsidianToken = (envFilePath, token) => {
100
113
  writeFileSync(envFilePath, patched);
101
114
  return true;
102
115
  };
116
+ /**
117
+ * Reads the OBSIDIAN_AUTH_TOKEN value from an existing .env file. Returns
118
+ * undefined when the file is missing, has no active line, or the value is
119
+ * empty — an empty `OBSIDIAN_AUTH_TOKEN=` line is not a valid token.
120
+ */
121
+ export const readEnvObsidianToken = (envFilePath) => {
122
+ if (!existsSync(envFilePath))
123
+ return undefined;
124
+ const match = /^OBSIDIAN_AUTH_TOKEN=(.+)$/m.exec(readFileSync(envFilePath, "utf8"));
125
+ return match?.[1].trim() || undefined;
126
+ };
127
+ /**
128
+ * Strips surrounding quotes from env values in the file. `docker run
129
+ * --env-file` passes quotes literally (`VAULT_NAME="My Vault"` becomes
130
+ * the value `"My Vault"` with embedded quotes), while Compose strips
131
+ * them. Removing quotes makes the file work correctly for both paths.
132
+ * Returns true when the file was modified.
133
+ */
134
+ export const stripEnvQuotedValues = (envFilePath) => {
135
+ if (!existsSync(envFilePath))
136
+ return false;
137
+ const content = readFileSync(envFilePath, "utf8");
138
+ // Reset lastIndex — the /g flag makes the regex stateful.
139
+ QUOTED_ENV_VALUE.lastIndex = 0;
140
+ const sanitized = content.replace(QUOTED_ENV_VALUE, "$1$3$4");
141
+ if (sanitized === content)
142
+ return false;
143
+ writeFileSync(envFilePath, sanitized);
144
+ return true;
145
+ };
103
146
  /**
104
147
  * Writes the files into targetDir (created if missing). Existing files
105
148
  * are never overwritten silently: identical content is skipped, and differing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vault-cortex",
3
- "version": "0.12.0-beta.61",
3
+ "version": "0.13.0",
4
4
  "description": "Set up a Vault Cortex MCP server for your Obsidian vault in one command: npx vault-cortex@latest init",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,7 +32,6 @@
32
32
  "obsidian-vault",
33
33
  "obsidian-sync",
34
34
  "ai-agents",
35
- "ai-memory",
36
35
  "ai-memory-system",
37
36
  "knowledge-base",
38
37
  "note-taking",
@@ -42,7 +41,7 @@
42
41
  "task-management",
43
42
  "semantic-search",
44
43
  "attachments",
45
- "pdf",
44
+ "one-click-deploy",
46
45
  "self-hosted",
47
46
  "docker",
48
47
  "claude",