vault-cortex 0.12.0 → 0.13.1
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 +7 -8
- package/dist/docker.js +1 -39
- package/dist/env.js +16 -13
- package/dist/get-sync-token.js +89 -101
- package/dist/init.js +33 -33
- package/dist/lifecycle.js +2 -1
- package/dist/main.js +1 -1
- package/dist/messages.js +5 -6
- package/dist/program.js +1 -1
- package/dist/scaffold.js +48 -5
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -60,8 +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
|
|
64
|
-
[
|
|
63
|
+
without asking. During a remote setup, init offers to generate your
|
|
64
|
+
[Obsidian Sync token](#get-sync-token) as part of the flow.
|
|
65
65
|
|
|
66
66
|
Flags:
|
|
67
67
|
|
|
@@ -194,17 +194,15 @@ remote setups — without leaving the CLI:
|
|
|
194
194
|
npx vault-cortex@latest get-sync-token
|
|
195
195
|
```
|
|
196
196
|
|
|
197
|
-
The command
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
instead:
|
|
197
|
+
The command prompts for your Obsidian account email, password, and MFA code
|
|
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:
|
|
201
200
|
|
|
202
201
|
```bash
|
|
203
202
|
npx vault-cortex@latest get-sync-token --dir ./vault-cortex
|
|
204
203
|
```
|
|
205
204
|
|
|
206
|
-
During `init --mode remote`, this flow is offered automatically
|
|
207
|
-
is available.
|
|
205
|
+
During `init --mode remote`, this flow is offered automatically.
|
|
208
206
|
|
|
209
207
|
## Requirements
|
|
210
208
|
|
|
@@ -218,4 +216,5 @@ is available.
|
|
|
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/docker.js
CHANGED
|
@@ -2,36 +2,6 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
export const LOCAL_IMAGE = "ghcr.io/aliasunder/vault-cortex:latest";
|
|
3
3
|
export const REMOTE_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote";
|
|
4
4
|
export const CONTAINER_NAME = "vault-cortex";
|
|
5
|
-
/**
|
|
6
|
-
* Builds the `docker run` args for the Obsidian login with a volume mount
|
|
7
|
-
* that captures the auth token file. Runs `ob login` directly instead of
|
|
8
|
-
* the image's get-sync-token script: the script's additions are locating and
|
|
9
|
-
* printing the token, and the mount makes both unnecessary — the CLI reads
|
|
10
|
-
* the token file itself, and not echoing a credential keeps it out of
|
|
11
|
-
* terminal scrollback. Pure function for testability.
|
|
12
|
-
*
|
|
13
|
-
* On Linux, includes `--user uid:gid` when uid/gid are provided — Node
|
|
14
|
-
* exposes process.getuid/getgid on every POSIX platform, so in practice the
|
|
15
|
-
* flag is always set there — keeping the token file host-user-owned. macOS
|
|
16
|
-
* Docker Desktop translates UIDs automatically, so no flag is needed.
|
|
17
|
-
*/
|
|
18
|
-
export const buildObsidianLoginArgs = (params) => {
|
|
19
|
-
const { configMountPath, platform = process.platform, uid, gid } = params;
|
|
20
|
-
const args = [
|
|
21
|
-
"run",
|
|
22
|
-
"--rm",
|
|
23
|
-
"-it",
|
|
24
|
-
"--entrypoint",
|
|
25
|
-
"ob",
|
|
26
|
-
"-v",
|
|
27
|
-
`${configMountPath}:/home/obsidian/.config`,
|
|
28
|
-
];
|
|
29
|
-
if (platform === "linux" && uid !== undefined && gid !== undefined) {
|
|
30
|
-
args.push("--user", `${uid}:${gid}`);
|
|
31
|
-
}
|
|
32
|
-
args.push(REMOTE_IMAGE, "login");
|
|
33
|
-
return args;
|
|
34
|
-
};
|
|
35
5
|
/**
|
|
36
6
|
* Container-internal env vars that must override the user's .env values.
|
|
37
7
|
* VAULT_PATH in .env is the host path (for the -v mount); the container
|
|
@@ -130,10 +100,7 @@ export const createDockerRunner = () => ({
|
|
|
130
100
|
// stdout is discarded: `docker run -d` prints only the container ID there,
|
|
131
101
|
// which lands as a raw hex line between the wizard's prompts. stderr stays
|
|
132
102
|
// inherited — image-pull progress and error output print live, which the
|
|
133
|
-
// "see output above" failure messages rely on.
|
|
134
|
-
// buildDockerRunArgs always runs detached (never -it), and the prompt
|
|
135
|
-
// library owns the terminal's stdin — interactive flows go through
|
|
136
|
-
// runObsidianLogin, which inherits all three streams.
|
|
103
|
+
// "see output above" failure messages rely on.
|
|
137
104
|
dockerRun: (params) => spawnSync("docker", buildDockerRunArgs(params), {
|
|
138
105
|
stdio: ["ignore", "ignore", "inherit"],
|
|
139
106
|
}).status === 0,
|
|
@@ -166,11 +133,6 @@ export const createDockerRunner = () => ({
|
|
|
166
133
|
// convention for ctrl-C (128 + SIGINT = 130).
|
|
167
134
|
child.once("close", (code) => resolveExitCode(code ?? 130));
|
|
168
135
|
}),
|
|
169
|
-
runObsidianLogin: (configMountPath) => spawnSync("docker", buildObsidianLoginArgs({
|
|
170
|
-
configMountPath,
|
|
171
|
-
uid: process.getuid?.(),
|
|
172
|
-
gid: process.getgid?.(),
|
|
173
|
-
}), { stdio: "inherit" }).status === 0,
|
|
174
136
|
});
|
|
175
137
|
/** Default bound on a single health request (shared by probe and poll). */
|
|
176
138
|
const PROBE_TIMEOUT_MS = 10_000;
|
package/dist/env.js
CHANGED
|
@@ -15,7 +15,7 @@ const LOCAL_OPTIONAL_BLOCK = `# Optional ─────────────
|
|
|
15
15
|
# "npx vault-cortex@latest restart" (plain docker restart does not
|
|
16
16
|
# re-read this file).
|
|
17
17
|
|
|
18
|
-
# Public URL for OAuth issuer
|
|
18
|
+
# Public URL for OAuth issuer and access-token binding (default: http://localhost:8000).
|
|
19
19
|
# Override if you expose the server on a different URL (e.g. via a reverse proxy).
|
|
20
20
|
PUBLIC_URL=http://localhost:8000
|
|
21
21
|
|
|
@@ -77,9 +77,10 @@ MEMORY_DIR=About Me
|
|
|
77
77
|
# DAILY_NOTES_FOLDER=Journal
|
|
78
78
|
# DAILY_NOTES_FORMAT=YYYY-MM-DD
|
|
79
79
|
|
|
80
|
-
# Comma-separated folders protected from deletion
|
|
81
|
-
# the daily notes folder
|
|
82
|
-
#
|
|
80
|
+
# Comma-separated folders protected from deletion and moves. Default: MEMORY_DIR plus
|
|
81
|
+
# the daily notes folder, read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json
|
|
82
|
+
# (default "Daily Notes"). When set, replaces the whole default — include the
|
|
83
|
+
# daily notes folder in your list if needed.
|
|
83
84
|
# PROTECTED_PATHS=About Me,Daily Notes
|
|
84
85
|
|
|
85
86
|
# Comma-separated folders excluded from orphan detection (default: the daily
|
|
@@ -215,9 +216,10 @@ MEMORY_DIR=About Me
|
|
|
215
216
|
# DAILY_NOTES_FOLDER=Journal
|
|
216
217
|
# DAILY_NOTES_FORMAT=YYYY-MM-DD
|
|
217
218
|
|
|
218
|
-
# Comma-separated folders protected from deletion
|
|
219
|
-
# the daily notes folder
|
|
220
|
-
#
|
|
219
|
+
# Comma-separated folders protected from deletion and moves. Default: MEMORY_DIR plus
|
|
220
|
+
# the daily notes folder, read from DAILY_NOTES_FOLDER or .obsidian/daily-notes.json
|
|
221
|
+
# (default "Daily Notes"). When set, replaces the whole default — include the
|
|
222
|
+
# daily notes folder in your list if needed.
|
|
221
223
|
# PROTECTED_PATHS=About Me,Daily Notes
|
|
222
224
|
|
|
223
225
|
# Comma-separated folders excluded from orphan detection (default: the daily
|
|
@@ -324,11 +326,11 @@ export const buildRemoteEnv = (answers) => {
|
|
|
324
326
|
# VAULT_PASSWORD=`
|
|
325
327
|
: `# Vault end-to-end encryption password.
|
|
326
328
|
VAULT_PASSWORD=${answers.vaultPassword}`;
|
|
327
|
-
const obsidianTokenComment = answers.obsidianAuthToken
|
|
328
|
-
? `# Obsidian Sync auth token
|
|
329
|
+
const obsidianTokenComment = answers.obsidianAuthToken
|
|
330
|
+
? `# Obsidian Sync auth token.`
|
|
331
|
+
: `# Obsidian Sync auth token — FILL THIS IN before starting the server.
|
|
329
332
|
# Generate once with:
|
|
330
|
-
# npx vault-cortex@latest get-sync-token
|
|
331
|
-
: `# Obsidian Sync auth token.`;
|
|
333
|
+
# npx vault-cortex@latest get-sync-token`;
|
|
332
334
|
return `# vault-cortex — remote quickstart (Obsidian Sync)
|
|
333
335
|
# Generated by \`npx vault-cortex@latest init\`. Full option reference:
|
|
334
336
|
# https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/.env.example
|
|
@@ -339,11 +341,12 @@ VAULT_PASSWORD=${answers.vaultPassword}`;
|
|
|
339
341
|
MCP_AUTH_TOKEN=${answers.mcpAuthToken}
|
|
340
342
|
|
|
341
343
|
# Public URL that MCP clients use to reach this server.
|
|
342
|
-
# Used as the OAuth issuer URL in discovery metadata
|
|
344
|
+
# Used as the OAuth issuer URL in discovery metadata and stamped on every
|
|
345
|
+
# access token; changing it invalidates connected clients' tokens.
|
|
343
346
|
PUBLIC_URL=${answers.publicUrl}
|
|
344
347
|
|
|
345
348
|
${obsidianTokenComment}
|
|
346
|
-
OBSIDIAN_AUTH_TOKEN=${answers.obsidianAuthToken}
|
|
349
|
+
OBSIDIAN_AUTH_TOKEN=${answers.obsidianAuthToken ?? ""}
|
|
347
350
|
|
|
348
351
|
# Exact name of your Obsidian vault (case-sensitive).
|
|
349
352
|
VAULT_NAME=${answers.vaultName}
|
package/dist/get-sync-token.js
CHANGED
|
@@ -1,140 +1,127 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
1
|
import { join, resolve } from "node:path";
|
|
4
|
-
import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage, } from "./messages.js";
|
|
5
2
|
import { patchEnvObsidianToken } from "./scaffold.js";
|
|
6
3
|
import { expandTilde } from "./vault.js";
|
|
7
|
-
|
|
4
|
+
const OBSIDIAN_SIGNIN_URL = process.env.OBSIDIAN_SIGNIN_URL ?? "https://api.obsidian.md/user/signin";
|
|
5
|
+
const SIGNIN_TIMEOUT_MS = 30_000;
|
|
8
6
|
const describeError = (error) => error instanceof Error ? error.message : String(error);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const makeTempMountDir = (prompts) => {
|
|
14
|
-
try {
|
|
15
|
-
return mkdtempSync(join(tmpdir(), "vault-cortex-sync-token-"));
|
|
7
|
+
class ObsidianApiError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ObsidianApiError";
|
|
16
11
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return undefined;
|
|
20
|
-
}
|
|
21
|
-
};
|
|
12
|
+
}
|
|
13
|
+
const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
14
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
15
|
+
* Calls the Obsidian Sync signin API. Returns the parsed JSON on success,
|
|
16
|
+
* or throws on HTTP/network errors. The API returns { error: string } for
|
|
17
|
+
* auth failures (200 with an error field), and non-200 for server errors.
|
|
25
18
|
*/
|
|
26
|
-
const
|
|
27
|
-
const
|
|
19
|
+
const callSigninApi = async (params, fetchFn) => {
|
|
20
|
+
const response = await fetchFn(OBSIDIAN_SIGNIN_URL, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
Origin: "https://obsidian.md",
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
email: params.email,
|
|
28
|
+
password: params.password,
|
|
29
|
+
mfa: params.mfa,
|
|
30
|
+
}),
|
|
31
|
+
signal: AbortSignal.timeout(SIGNIN_TIMEOUT_MS),
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
throw new Error(`HTTP Error ${response.status}`);
|
|
28
35
|
try {
|
|
29
|
-
|
|
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;
|
|
30
44
|
}
|
|
31
45
|
catch (error) {
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
if (error instanceof ObsidianApiError)
|
|
47
|
+
throw error;
|
|
48
|
+
throw new Error(`Unexpected response from Obsidian API (${describeError(error)})`, { cause: error });
|
|
34
49
|
}
|
|
35
50
|
};
|
|
36
51
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* three as "no token captured".
|
|
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.
|
|
40
54
|
*/
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return undefined;
|
|
46
|
-
const token = readFileSync(tokenPath, "utf8").trim();
|
|
47
|
-
return token || undefined;
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return undefined;
|
|
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;
|
|
51
59
|
}
|
|
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}`);
|
|
52
63
|
};
|
|
53
64
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
65
|
+
* Signs in to the user's Obsidian account via the Sync API and returns
|
|
66
|
+
* the auth token. Prompts for email, password, and MFA code (when 2FA
|
|
67
|
+
* is enabled). Returns the token on success, undefined on any failure.
|
|
57
68
|
*/
|
|
58
|
-
const
|
|
69
|
+
export const captureObsidianToken = async (deps) => {
|
|
70
|
+
const { prompts, fetchFn } = deps;
|
|
71
|
+
const email = await prompts.text("Obsidian account email:", {
|
|
72
|
+
placeholder: "you@example.com",
|
|
73
|
+
});
|
|
74
|
+
const password = await prompts.password("Password:");
|
|
75
|
+
const spinner = prompts.spinner();
|
|
76
|
+
spinner.start("Signing in to Obsidian...");
|
|
59
77
|
try {
|
|
60
|
-
|
|
78
|
+
const token = await callSigninApi({ email, password, mfa: "" }, fetchFn);
|
|
79
|
+
spinner.stop(`Signed in as ${email}.`);
|
|
80
|
+
return token;
|
|
61
81
|
}
|
|
62
82
|
catch (error) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
* tokenDestinationMessage finishes the handoff message by telling the user
|
|
74
|
-
* where the captured token ends up — the destination differs per flow
|
|
75
|
-
* (init stores it in the generated .env; the subcommand prints it, or
|
|
76
|
-
* writes it to an existing .env with --dir).
|
|
77
|
-
*
|
|
78
|
-
* Returns the token string on success, undefined on any failure — each
|
|
79
|
-
* fallible operation is wrapped individually by the helpers above, so no
|
|
80
|
-
* catch-all is needed here. The bare try/finally only scopes the temp dir
|
|
81
|
-
* (acquire → release); it has no catch and swallows nothing.
|
|
82
|
-
*/
|
|
83
|
-
export const captureObsidianToken = (deps, tokenDestinationMessage) => {
|
|
84
|
-
const { prompts } = deps;
|
|
85
|
-
const configMountPath = makeTempMountDir(prompts);
|
|
86
|
-
if (!configMountPath)
|
|
87
|
-
return undefined;
|
|
88
|
-
try {
|
|
89
|
-
prompts.log("Handing the terminal to the Obsidian login — it will ask for your " +
|
|
90
|
-
`account email, password, and MFA code. ${tokenDestinationMessage}`);
|
|
91
|
-
const loginSucceeded = runLoginContainer(configMountPath, deps);
|
|
92
|
-
if (!loginSucceeded) {
|
|
93
|
-
prompts.warn("The Obsidian login did not complete — you can run it later with:\n" +
|
|
94
|
-
" npx vault-cortex@latest get-sync-token");
|
|
83
|
+
// MFA required: the API returns an error containing "2FA code" — prompt
|
|
84
|
+
// and retry. "2FA code is incorrect" is a wrong-code rejection, not a
|
|
85
|
+
// prompt-for-code signal. Mirrors the obsidian-headless v0.0.14 logic.
|
|
86
|
+
const needsMfa = error instanceof ObsidianApiError &&
|
|
87
|
+
error.message.includes("2FA code") &&
|
|
88
|
+
!error.message.includes("2FA code is incorrect");
|
|
89
|
+
if (!needsMfa) {
|
|
90
|
+
spinner.stop("Sign-in failed.");
|
|
91
|
+
warnSigninError(error, prompts, false);
|
|
95
92
|
return undefined;
|
|
96
93
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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;
|
|
101
|
+
}
|
|
102
|
+
catch (retryError) {
|
|
103
|
+
spinner.stop("Sign-in failed.");
|
|
104
|
+
warnSigninError(retryError, prompts, true);
|
|
102
105
|
return undefined;
|
|
103
106
|
}
|
|
104
|
-
return token;
|
|
105
|
-
}
|
|
106
|
-
finally {
|
|
107
|
-
removeTempMountDir(configMountPath, prompts);
|
|
108
107
|
}
|
|
109
108
|
};
|
|
110
109
|
/**
|
|
111
|
-
* Subcommand entry: generate an Obsidian Sync token via
|
|
110
|
+
* Subcommand entry: generate an Obsidian Sync token via the Obsidian API.
|
|
112
111
|
* Without --dir, prints the token to stdout.
|
|
113
112
|
* With --dir, writes it directly to `<dir>/.env`.
|
|
114
113
|
*/
|
|
115
114
|
export const runGetSyncToken = async (flags, deps) => {
|
|
116
|
-
const { prompts
|
|
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."));
|
|
122
|
-
return 1;
|
|
123
|
-
}
|
|
115
|
+
const { prompts } = deps;
|
|
124
116
|
prompts.intro("vault-cortex get-sync-token");
|
|
125
|
-
|
|
126
|
-
// the user where the token will end up.
|
|
127
|
-
const envFilePath = flags.dir
|
|
128
|
-
? join(resolve(expandTilde(flags.dir)), ".env")
|
|
129
|
-
: undefined;
|
|
130
|
-
const tokenDestinationMessage = envFilePath
|
|
131
|
-
? `The token is captured automatically and written to ${envFilePath}.`
|
|
132
|
-
: "The token is captured automatically and printed at the end.";
|
|
133
|
-
const token = captureObsidianToken({ docker, prompts }, tokenDestinationMessage);
|
|
117
|
+
const token = await captureObsidianToken(deps);
|
|
134
118
|
if (!token) {
|
|
135
119
|
prompts.error("Could not capture the auth token.");
|
|
136
120
|
return 1;
|
|
137
121
|
}
|
|
122
|
+
const envFilePath = flags.dir
|
|
123
|
+
? join(resolve(expandTilde(flags.dir)), ".env")
|
|
124
|
+
: undefined;
|
|
138
125
|
if (!envFilePath) {
|
|
139
126
|
prompts.log("Your OBSIDIAN_AUTH_TOKEN:");
|
|
140
127
|
prompts.print(`\n ${token}\n`);
|
|
@@ -147,7 +134,8 @@ export const runGetSyncToken = async (flags, deps) => {
|
|
|
147
134
|
"OBSIDIAN_AUTH_TOKEN line. Run init first.");
|
|
148
135
|
return 1;
|
|
149
136
|
}
|
|
150
|
-
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}"`);
|
|
151
139
|
prompts.outro("Done.");
|
|
152
140
|
return 0;
|
|
153
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";
|
|
@@ -27,15 +27,18 @@ const askMode = async (prompts) => {
|
|
|
27
27
|
return isMode(selected) ? selected : "local";
|
|
28
28
|
};
|
|
29
29
|
/**
|
|
30
|
-
* Offers to
|
|
31
|
-
* Returns the captured token string, or undefined when the user declines
|
|
32
|
-
* the capture fails (the caller falls back to
|
|
30
|
+
* Offers to sign in to the Obsidian account and capture the Sync token.
|
|
31
|
+
* Returns the captured token string, or undefined when the user declines
|
|
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
|
-
const offerSyncTokenCapture = async (prompts,
|
|
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;
|
|
38
|
-
return captureObsidianToken({
|
|
41
|
+
return captureObsidianToken({ prompts, fetchFn });
|
|
39
42
|
};
|
|
40
43
|
/**
|
|
41
44
|
* Asks for the vault path, recursing to re-prompt until it gets a usable
|
|
@@ -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
|
|
184
|
+
envFilePath,
|
|
180
185
|
port,
|
|
181
186
|
vaultPath,
|
|
182
187
|
});
|
|
@@ -285,12 +290,12 @@ const runLocalInit = async (flags, deps) => {
|
|
|
285
290
|
return 0;
|
|
286
291
|
};
|
|
287
292
|
// Remote flow (VPS + Obsidian Sync): resolve target dir → PUBLIC_URL →
|
|
288
|
-
// VAULT_NAME → Obsidian Sync token (
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
293
|
+
// VAULT_NAME → Obsidian Sync token (sign in via the Obsidian API) → optional
|
|
294
|
+
// E2E vault password → generate token → write .env → optionally start → print
|
|
295
|
+
// connect instructions. Always interactive — the sync-token step can't be
|
|
296
|
+
// defaulted.
|
|
292
297
|
const runRemoteInit = async (flags, deps) => {
|
|
293
|
-
const { prompts,
|
|
298
|
+
const { prompts, fetchFn } = deps;
|
|
294
299
|
// expandTilde before resolve: resolve() treats a leading `~` as a literal
|
|
295
300
|
// path segment, so a quoted "~/path" would create a directory named "~".
|
|
296
301
|
const targetDir = resolve(expandTilde(flags.dir ??
|
|
@@ -303,19 +308,17 @@ const runRemoteInit = async (flags, deps) => {
|
|
|
303
308
|
return 0;
|
|
304
309
|
const publicUrl = await askPublicUrl(prompts);
|
|
305
310
|
const vaultName = await askVaultName(prompts);
|
|
306
|
-
//
|
|
307
|
-
// the
|
|
308
|
-
//
|
|
309
|
-
// the
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const obsidianAuthToken = capturedToken ??
|
|
318
|
-
(await prompts.password("Paste the Obsidian Sync token (leave blank to fill in .env later):")).trim();
|
|
311
|
+
// Sign in to Obsidian and capture the Sync token directly via the API.
|
|
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.
|
|
315
|
+
const capturedToken = await offerSyncTokenCapture(prompts, fetchFn);
|
|
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
|
+
}
|
|
319
322
|
const usesEncryption = await prompts.confirm("Does your vault use end-to-end encryption?", false);
|
|
320
323
|
const vaultPassword = usesEncryption
|
|
321
324
|
? await prompts.password("Vault encryption password:")
|
|
@@ -329,7 +332,7 @@ const runRemoteInit = async (flags, deps) => {
|
|
|
329
332
|
const defaultEnvContent = buildRemoteEnv({
|
|
330
333
|
mcpAuthToken: token,
|
|
331
334
|
publicUrl,
|
|
332
|
-
obsidianAuthToken,
|
|
335
|
+
obsidianAuthToken: capturedToken ?? existingEnvToken,
|
|
333
336
|
vaultName,
|
|
334
337
|
vaultPassword,
|
|
335
338
|
});
|
|
@@ -354,7 +357,7 @@ const runRemoteInit = async (flags, deps) => {
|
|
|
354
357
|
const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl;
|
|
355
358
|
// Without the sync token the container can't start (init-check-auth fails
|
|
356
359
|
// and s6 stops it), so only offer docker run when it was provided.
|
|
357
|
-
const startStatus =
|
|
360
|
+
const startStatus = !hasExistingToken
|
|
358
361
|
? "not-started"
|
|
359
362
|
: await offerDockerRun({ targetDir, port, mode: "remote" }, deps);
|
|
360
363
|
// The container check above hit localhost on this machine; the public URL
|
|
@@ -370,7 +373,7 @@ const runRemoteInit = async (flags, deps) => {
|
|
|
370
373
|
token,
|
|
371
374
|
publicUrl: effectivePublicUrl,
|
|
372
375
|
startStatus,
|
|
373
|
-
obsidianTokenMissing:
|
|
376
|
+
obsidianTokenMissing: !hasExistingToken,
|
|
374
377
|
tokenWritten,
|
|
375
378
|
}));
|
|
376
379
|
return 0;
|
|
@@ -392,11 +395,8 @@ export const runInit = async (flags, deps) => {
|
|
|
392
395
|
prompts.intro("vault-cortex init");
|
|
393
396
|
// Mode resolution: explicit --mode wins; --yes implies local; otherwise
|
|
394
397
|
// ask, defaulting to local — it's the simpler activation path.
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
: flags.yes
|
|
398
|
-
? "local"
|
|
399
|
-
: await askMode(prompts);
|
|
398
|
+
const flagMode = flags.mode && isMode(flags.mode) ? flags.mode : undefined;
|
|
399
|
+
const mode = flagMode ?? (flags.yes ? "local" : await askMode(prompts));
|
|
400
400
|
const exitCode = mode === "local"
|
|
401
401
|
? await runLocalInit(flags, deps)
|
|
402
402
|
: await runRemoteInit(flags, deps);
|
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/main.js
CHANGED
package/dist/messages.js
CHANGED
|
@@ -33,7 +33,7 @@ const dockerInstallLine = (platform) => {
|
|
|
33
33
|
/**
|
|
34
34
|
* "No runtime at all" guidance — distinct from the daemon-stopped message so
|
|
35
35
|
* the user isn't told to start something that isn't installed. platform is a
|
|
36
|
-
* defaulted param
|
|
36
|
+
* defaulted param so each branch stays
|
|
37
37
|
* testable; `nextStep` is appended verbatim, as in
|
|
38
38
|
* buildDaemonNotRunningMessage.
|
|
39
39
|
*/
|
|
@@ -126,11 +126,10 @@ const updateGuidance = (targetDir) => `Update to the latest release:
|
|
|
126
126
|
export const buildLocalConnectMessage = (params) => {
|
|
127
127
|
const { targetDir, token, startStatus, port, tokenWritten } = params;
|
|
128
128
|
const baseUrl = `http://localhost:${port}`;
|
|
129
|
-
const
|
|
130
|
-
?
|
|
131
|
-
:
|
|
132
|
-
|
|
133
|
-
: startServerLine(targetDir);
|
|
129
|
+
const nonRunningLine = startStatus === "starting"
|
|
130
|
+
? startingInBackgroundLine()
|
|
131
|
+
: startServerLine(targetDir);
|
|
132
|
+
const startLine = startStatus === "running" ? "The server is running." : nonRunningLine;
|
|
134
133
|
const tokenLine = tokenBlock({ targetDir, token, tokenWritten });
|
|
135
134
|
// Once the server is confirmed up, the smoke test is dropped — the CLI just
|
|
136
135
|
// verified this exact URL, so re-printing it reads as leftover homework.
|
package/dist/program.js
CHANGED
|
@@ -61,7 +61,7 @@ export const buildProgram = (options) => {
|
|
|
61
61
|
});
|
|
62
62
|
program
|
|
63
63
|
.command("get-sync-token")
|
|
64
|
-
.description("
|
|
64
|
+
.description("Sign in to your Obsidian account and print the Sync auth token, or write it to .env")
|
|
65
65
|
.option("--dir <path>", "directory containing .env to update with the token")
|
|
66
66
|
.action(async (flags) => {
|
|
67
67
|
process.exitCode = await options.runGetSyncToken(flags);
|
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
|
|
6
|
-
const ENV_PORT_LINE = /^PORT=(\d+)
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
3
|
+
"version": "0.13.1",
|
|
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
|
-
"
|
|
44
|
+
"one-click-deploy",
|
|
46
45
|
"self-hosted",
|
|
47
46
|
"docker",
|
|
48
47
|
"claude",
|