vault-cortex 0.9.0 → 0.10.1-beta.48
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 +39 -17
- package/dist/configure.js +14 -5
- package/dist/docker.js +50 -12
- package/dist/env.js +49 -19
- package/dist/get-sync-token.js +8 -5
- package/dist/init.js +72 -29
- package/dist/lifecycle.js +87 -16
- package/dist/main.js +6 -1
- package/dist/messages.js +72 -15
- package/dist/optional-settings.js +53 -3
- package/dist/program.js +7 -0
- package/dist/scaffold.js +26 -2
- package/package.json +10 -5
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ and data files — see the
|
|
|
15
15
|
The server runs as a Docker container; this CLI scaffolds the config and
|
|
16
16
|
manages the container so you don't have to.
|
|
17
17
|
|
|
18
|
-

|
|
18
|
+

|
|
19
19
|
|
|
20
20
|
## Commands
|
|
21
21
|
|
|
@@ -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
|
|
@@ -32,7 +34,7 @@ manages the container so you don't have to.
|
|
|
32
34
|
- [`get-sync-token`](#get-sync-token) — generate an Obsidian Sync auth token
|
|
33
35
|
for remote setups
|
|
34
36
|
|
|
35
|
-
Run `npx vault-cortex <command> --help` for all flags.
|
|
37
|
+
Run `npx vault-cortex@latest <command> --help` for all flags.
|
|
36
38
|
|
|
37
39
|
## init
|
|
38
40
|
|
|
@@ -47,18 +49,19 @@ What it does:
|
|
|
47
49
|
- **Remote** — a VPS with [Obsidian Sync](https://obsidian.md/sync),
|
|
48
50
|
reachable from any device
|
|
49
51
|
2. Offers the most common optional settings — memory layer and folder,
|
|
50
|
-
file tools, semantic search, port,
|
|
51
|
-
remote) — press enter to keep the
|
|
52
|
-
|
|
53
|
-
[`configure`](#configure) instead)
|
|
52
|
+
daily notes folder and format, file tools, semantic search, port,
|
|
53
|
+
timezone (plus sync direction for remote) — press enter to keep the
|
|
54
|
+
defaults, or pick the ones you want to 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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
|
@@ -82,11 +85,13 @@ Change optional settings on an existing setup:
|
|
|
82
85
|
npx vault-cortex@latest configure
|
|
83
86
|
```
|
|
84
87
|
|
|
85
|
-
Shows the same settings chooser as [`init`](#init)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
live in `.env` too: edit the value there, then
|
|
88
|
+
Shows the same settings chooser as [`init`](#init), pre-filled with your
|
|
89
|
+
current values, saves your picks to `.env`, and offers to restart the
|
|
90
|
+
container so they take effect.
|
|
91
|
+
|
|
92
|
+
Settings not in the chooser live in `.env` too: edit the value there, then
|
|
93
|
+
run [`restart`](#restart). That's also how you clear a daily notes setting
|
|
94
|
+
back to your vault's own configuration — comment out or delete its line.
|
|
90
95
|
|
|
91
96
|
Use `--dir <path>` if your config isn't in `./vault-cortex`.
|
|
92
97
|
|
|
@@ -115,6 +120,22 @@ include Compose files you can use directly. If you set up with Compose, stick
|
|
|
115
120
|
with Compose for updates too (`docker compose pull && docker compose up -d`)
|
|
116
121
|
— the CLI and Compose manage the container independently.
|
|
117
122
|
|
|
123
|
+
## start
|
|
124
|
+
|
|
125
|
+
Start the server with your saved settings and verify health:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npx vault-cortex@latest start
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
This is the command the CLI's own guidance points to whenever the server
|
|
132
|
+
isn't running — after [`down`](#down), or an `init` where the server wasn't
|
|
133
|
+
started. It's the same cycle as [`restart`](#restart) under the name you'd
|
|
134
|
+
look for: any existing container is replaced, and Docker pulls the server
|
|
135
|
+
image automatically on a first start.
|
|
136
|
+
|
|
137
|
+
Use `--dir <path>` if your config isn't in `./vault-cortex`.
|
|
138
|
+
|
|
118
139
|
## restart
|
|
119
140
|
|
|
120
141
|
Re-create the container from your `.env` and verify health:
|
|
@@ -125,7 +146,7 @@ npx vault-cortex@latest restart
|
|
|
125
146
|
|
|
126
147
|
Use it after editing `.env` — settings are only read when the container is
|
|
127
148
|
created, so a plain `docker restart` won't pick them up, but this will. Unlike
|
|
128
|
-
[`upgrade`](#upgrade), it never
|
|
149
|
+
[`upgrade`](#upgrade), it never updates the server image: you get the same
|
|
129
150
|
version back, with your current settings applied.
|
|
130
151
|
|
|
131
152
|
Use `--dir <path>` if your config isn't in `./vault-cortex`.
|
|
@@ -159,7 +180,7 @@ npx vault-cortex@latest down
|
|
|
159
180
|
|
|
160
181
|
Safe by design: your vault, search index, and `.env` settings all live
|
|
161
182
|
outside the container, so nothing is lost. Start again any time with
|
|
162
|
-
[`
|
|
183
|
+
[`start`](#start). Running `down` when nothing is running is fine — it
|
|
163
184
|
just tells you there's nothing to stop.
|
|
164
185
|
|
|
165
186
|
Use `--dir <path>` if your config isn't in `./vault-cortex`.
|
|
@@ -190,7 +211,8 @@ is available.
|
|
|
190
211
|
- Node.js >= 20.12 (only for this CLI — the server itself runs in Docker)
|
|
191
212
|
- [Docker](https://docs.docker.com/get-docker/) or a Docker-compatible
|
|
192
213
|
runtime (e.g. OrbStack, Colima, Podman) to run the server — the CLI
|
|
193
|
-
manages the container through the `docker` command
|
|
214
|
+
manages the container through the `docker` command (on Linux, see
|
|
215
|
+
[Docker Engine](https://docs.docker.com/engine/install/))
|
|
194
216
|
|
|
195
217
|
## Docs
|
|
196
218
|
|
package/dist/configure.js
CHANGED
|
@@ -20,7 +20,10 @@ export const runConfigure = async (flags, deps) => {
|
|
|
20
20
|
const envContent = readFileSync(envFilePath, "utf8");
|
|
21
21
|
const pickedOverrides = await askOptionalSettings({ mode, envContent }, prompts);
|
|
22
22
|
if (Object.keys(pickedOverrides).length === 0) {
|
|
23
|
-
|
|
23
|
+
// Covers both empty-overrides paths: nothing picked in the chooser, and
|
|
24
|
+
// picked-but-kept (an optionalText prompt left blank logs its own
|
|
25
|
+
// "Kept the current value" line, which this must not contradict).
|
|
26
|
+
prompts.log("No changes to apply.");
|
|
24
27
|
prompts.outro("Done.");
|
|
25
28
|
return 0;
|
|
26
29
|
}
|
|
@@ -34,9 +37,15 @@ export const runConfigure = async (flags, deps) => {
|
|
|
34
37
|
if (pickedOverrides.PORT && !overrides.PUBLIC_URL && currentPublicUrl) {
|
|
35
38
|
prompts.warn(`PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.`);
|
|
36
39
|
}
|
|
37
|
-
const restartHint = `Apply the new settings with: npx vault-cortex restart --dir "${targetDir}"`;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
const restartHint = `Apply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"`;
|
|
41
|
+
const daemonStatus = docker.daemonStatus();
|
|
42
|
+
if (daemonStatus !== "running") {
|
|
43
|
+
// Settings are already saved — the runtime state only affects the restart
|
|
44
|
+
// offer, so both non-running states degrade to the hint; restart itself
|
|
45
|
+
// prints the full install/start guidance when run.
|
|
46
|
+
prompts.warn(daemonStatus === "not-installed"
|
|
47
|
+
? `No container runtime found — settings saved.\n${restartHint}`
|
|
48
|
+
: `Container runtime not running — settings saved.\n${restartHint}`);
|
|
40
49
|
prompts.outro("Done.");
|
|
41
50
|
return 0;
|
|
42
51
|
}
|
|
@@ -52,7 +61,7 @@ export const runConfigure = async (flags, deps) => {
|
|
|
52
61
|
if (!deployment) {
|
|
53
62
|
// The edit already succeeded — don't let the failed restart read as a
|
|
54
63
|
// 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}"`);
|
|
64
|
+
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
65
|
return 1;
|
|
57
66
|
}
|
|
58
67
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
211
|
+
const attemptTimeoutMs = Math.min(PROBE_TIMEOUT_MS, deadline - Date.now());
|
|
212
|
+
if (await probeHealth({ url, timeoutMs: attemptTimeoutMs }, fetchFn)) {
|
|
179
213
|
return true;
|
|
180
|
-
|
|
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
|
|
14
|
-
# value here (uncommenting it first if needed) and apply with
|
|
15
|
-
# "npx vault-cortex restart" (plain docker restart does not
|
|
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).
|
|
@@ -61,12 +61,21 @@ FILE_TOOLS_ENABLED=true
|
|
|
61
61
|
# Memory folder name in your vault (default: About Me).
|
|
62
62
|
MEMORY_DIR=About Me
|
|
63
63
|
|
|
64
|
-
#
|
|
65
|
-
#
|
|
64
|
+
# Daily notes folder and filename format (default: read from the
|
|
65
|
+
# vault's .obsidian/daily-notes.json, falling back to "Daily Notes" and
|
|
66
|
+
# YYYY-MM-DD). Folder is any vault-relative path (Journal, Planner/Daily);
|
|
67
|
+
# format takes the same tokens as Obsidian's date format setting.
|
|
68
|
+
# DAILY_NOTES_FOLDER=Journal
|
|
69
|
+
# DAILY_NOTES_FORMAT=YYYY-MM-DD
|
|
70
|
+
|
|
71
|
+
# Comma-separated folders protected from deletion (default: MEMORY_DIR plus
|
|
72
|
+
# the daily notes folder — DAILY_NOTES_FOLDER when set, otherwise "Daily Notes").
|
|
73
|
+
# A custom folder set only in daily-notes.json is not auto-protected.
|
|
66
74
|
# PROTECTED_PATHS=About Me,Daily Notes
|
|
67
75
|
|
|
68
|
-
# Comma-separated folders excluded from orphan detection
|
|
69
|
-
#
|
|
76
|
+
# Comma-separated folders excluded from orphan detection (default: the daily
|
|
77
|
+
# notes folder — DAILY_NOTES_FOLDER when set, otherwise "Daily Notes" — plus
|
|
78
|
+
# Templates and MEMORY_DIR).
|
|
70
79
|
# ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me
|
|
71
80
|
|
|
72
81
|
# URL shown in OAuth discovery metadata
|
|
@@ -101,10 +110,10 @@ WINDOWS_MODE=false
|
|
|
101
110
|
// └─────────────────────────────────────────────────────────────────────────┘
|
|
102
111
|
// sync:remote-optional:begin
|
|
103
112
|
const REMOTE_OPTIONAL_BLOCK = `# Optional ──────────────────────────────────────────────────
|
|
104
|
-
# To change a setting: run "npx vault-cortex configure", or edit
|
|
105
|
-
# value here (uncommenting it first if needed) and apply with
|
|
106
|
-
# "npx vault-cortex restart" (plain docker restart does not
|
|
107
|
-
# this file).
|
|
113
|
+
# To change a setting: run "npx vault-cortex@latest configure", or edit
|
|
114
|
+
# its value here (uncommenting it first if needed) and apply with
|
|
115
|
+
# "npx vault-cortex@latest restart" (plain docker restart does not
|
|
116
|
+
# re-read this file).
|
|
108
117
|
|
|
109
118
|
# Your IANA timezone — affects daily note resolution and memory timestamps.
|
|
110
119
|
# TZ=America/New_York
|
|
@@ -154,12 +163,22 @@ FILE_TOOLS_ENABLED=true
|
|
|
154
163
|
# Memory folder name in your vault (default: About Me).
|
|
155
164
|
MEMORY_DIR=About Me
|
|
156
165
|
|
|
157
|
-
#
|
|
158
|
-
#
|
|
166
|
+
# Daily notes folder and filename format (default: read from the
|
|
167
|
+
# vault's .obsidian/daily-notes.json, synced to the server via SYNC_CONFIGS
|
|
168
|
+
# below; falls back to "Daily Notes" and YYYY-MM-DD). Folder is any
|
|
169
|
+
# vault-relative path (Journal, Planner/Daily); format takes the same tokens
|
|
170
|
+
# as Obsidian's date format setting.
|
|
171
|
+
# DAILY_NOTES_FOLDER=Journal
|
|
172
|
+
# DAILY_NOTES_FORMAT=YYYY-MM-DD
|
|
173
|
+
|
|
174
|
+
# Comma-separated folders protected from deletion (default: MEMORY_DIR plus
|
|
175
|
+
# the daily notes folder — DAILY_NOTES_FOLDER when set, otherwise "Daily Notes").
|
|
176
|
+
# A custom folder set only in daily-notes.json is not auto-protected.
|
|
159
177
|
# PROTECTED_PATHS=About Me,Daily Notes
|
|
160
178
|
|
|
161
|
-
# Comma-separated folders excluded from orphan detection
|
|
162
|
-
#
|
|
179
|
+
# Comma-separated folders excluded from orphan detection (default: the daily
|
|
180
|
+
# notes folder — DAILY_NOTES_FOLDER when set, otherwise "Daily Notes" — plus
|
|
181
|
+
# Templates and MEMORY_DIR).
|
|
163
182
|
# ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me
|
|
164
183
|
|
|
165
184
|
# URL shown in OAuth discovery metadata
|
|
@@ -192,10 +211,21 @@ CONFLICT_STRATEGY=merge
|
|
|
192
211
|
|
|
193
212
|
# Sync direction: bidirectional | pull-only | push-only (default: bidirectional).
|
|
194
213
|
SYNC_MODE=bidirectional
|
|
214
|
+
|
|
215
|
+
# Obsidian settings categories to sync into the server's .obsidian/ folder
|
|
216
|
+
# (default: the two the server reads — daily notes settings and community
|
|
217
|
+
# plugin settings such as the Tasks plugin's format; "none" disables).
|
|
218
|
+
# A category only syncs after your desktop pushes it: Obsidian Settings →
|
|
219
|
+
# Sync → "Vault configuration sync" (per device). Some community plugins
|
|
220
|
+
# keep API keys in their settings — server tools never read .obsidian/,
|
|
221
|
+
# but synced settings do live on the server volume. Values: app,
|
|
222
|
+
# appearance, appearance-data, hotkey, core-plugin, core-plugin-data,
|
|
223
|
+
# community-plugin, community-plugin-data — comma-separated.
|
|
224
|
+
SYNC_CONFIGS=core-plugin-data,community-plugin-data
|
|
195
225
|
`;
|
|
196
226
|
// sync:remote-optional:end
|
|
197
227
|
export const buildLocalEnv = (answers) => `# vault-cortex — local quickstart
|
|
198
|
-
# Generated by \`npx vault-cortex init\`. Full option reference:
|
|
228
|
+
# Generated by \`npx vault-cortex@latest init\`. Full option reference:
|
|
199
229
|
# https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/.env.example
|
|
200
230
|
|
|
201
231
|
# Required ──────────────────────────────────────────────────
|
|
@@ -216,10 +246,10 @@ VAULT_PASSWORD=${answers.vaultPassword}`;
|
|
|
216
246
|
const obsidianTokenComment = answers.obsidianAuthToken === ""
|
|
217
247
|
? `# Obsidian Sync auth token — FILL THIS IN before starting the server.
|
|
218
248
|
# Generate once with:
|
|
219
|
-
# npx vault-cortex get-sync-token`
|
|
249
|
+
# npx vault-cortex@latest get-sync-token`
|
|
220
250
|
: `# Obsidian Sync auth token.`;
|
|
221
251
|
return `# vault-cortex — remote quickstart (Obsidian Sync)
|
|
222
|
-
# Generated by \`npx vault-cortex init\`. Full option reference:
|
|
252
|
+
# Generated by \`npx vault-cortex@latest init\`. Full option reference:
|
|
223
253
|
# https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/.env.example
|
|
224
254
|
|
|
225
255
|
# Required ──────────────────────────────────────────────────
|
package/dist/get-sync-token.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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)
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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),
|
|
284
|
-
//
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
const daemonStatus = docker.daemonStatus();
|
|
53
|
+
if (daemonStatus === "running")
|
|
51
54
|
return true;
|
|
52
|
-
prompts.error(
|
|
53
|
-
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
-
|
|
197
|
+
const runRecreateFromEnv = async (flags, deps, labels) => {
|
|
153
198
|
const { prompts, docker, fetchFn } = deps;
|
|
154
|
-
prompts.intro(
|
|
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(
|
|
164
|
-
prompts.outro(
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
${
|
|
108
|
-
|
|
109
|
-
${smokeTest(`${baseUrl}/healthz`)}
|
|
165
|
+
${nonOauthBlocks}
|
|
110
166
|
|
|
111
167
|
${sectionRule("Settings")}
|
|
112
168
|
|
|
113
|
-
Adjust optional settings (memory layer and folder,
|
|
114
|
-
semantic search, port, timezone):
|
|
115
|
-
npx vault-cortex configure --dir "${targetDir}"
|
|
169
|
+
Adjust optional settings (memory layer and folder, daily notes
|
|
170
|
+
folder and format, file tools, semantic search, port, timezone):
|
|
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,17 @@ ${sectionRule("Non-OAuth")}
|
|
|
171
227
|
|
|
172
228
|
${curlGuidance(`${publicUrl}/mcp`)}
|
|
173
229
|
|
|
174
|
-
${
|
|
230
|
+
${remoteHealthCheckBlock(`${publicUrl}/healthz`, started)}
|
|
175
231
|
|
|
176
232
|
${sectionRule("Settings")}
|
|
177
233
|
|
|
178
|
-
Adjust optional settings (memory layer and folder,
|
|
179
|
-
semantic search, port, timezone,
|
|
180
|
-
|
|
234
|
+
Adjust optional settings (memory layer and folder, daily notes
|
|
235
|
+
folder and format, file tools, semantic search, port, timezone,
|
|
236
|
+
sync direction):
|
|
237
|
+
npx vault-cortex@latest configure --dir "${targetDir}"
|
|
181
238
|
|
|
182
239
|
Or edit ${targetDir}/.env directly — change a value (uncommenting it
|
|
183
|
-
first if needed), then apply with "npx vault-cortex restart" (plain
|
|
240
|
+
first if needed), then apply with "npx vault-cortex@latest restart" (plain
|
|
184
241
|
docker restart does not re-read .env).
|
|
185
242
|
|
|
186
243
|
${updateGuidance(targetDir)}
|
|
@@ -17,6 +17,20 @@ const OPTIONAL_SETTINGS = [
|
|
|
17
17
|
defaultValue: "About Me",
|
|
18
18
|
requiresToggle: "MEMORY_ENABLED",
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
kind: "optionalText",
|
|
22
|
+
name: "DAILY_NOTES_FOLDER",
|
|
23
|
+
label: "Daily notes folder",
|
|
24
|
+
question: "Vault folder for daily notes:",
|
|
25
|
+
placeholder: "blank = use your vault's daily notes settings",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
kind: "optionalText",
|
|
29
|
+
name: "DAILY_NOTES_FORMAT",
|
|
30
|
+
label: "Daily notes format",
|
|
31
|
+
question: "Filename date format for daily notes (e.g. YYYY-MM-DD):",
|
|
32
|
+
placeholder: "blank = use your vault's daily notes settings",
|
|
33
|
+
},
|
|
20
34
|
{
|
|
21
35
|
kind: "toggle",
|
|
22
36
|
name: "FILE_TOOLS_ENABLED",
|
|
@@ -176,7 +190,34 @@ const askFolder = async (params, prompts) => {
|
|
|
176
190
|
prompts.error("The folder name can't be empty.");
|
|
177
191
|
return askFolder(params, prompts);
|
|
178
192
|
};
|
|
179
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Text prompt for a setting whose absence is meaningful — the server falls
|
|
195
|
+
* back to the vault's own config when the var is unset. Blank-when-unset
|
|
196
|
+
* writes nothing; blank-when-set keeps the current value. Returns undefined
|
|
197
|
+
* on skip or no-op so the caller never rewrites for nothing. No removal
|
|
198
|
+
* path — clearing is a manual .env edit.
|
|
199
|
+
*/
|
|
200
|
+
const askOptionalText = async (params, prompts) => {
|
|
201
|
+
const { question, placeholder, currentValue } = params;
|
|
202
|
+
const answer = (await prompts.text(question, {
|
|
203
|
+
defaultValue: currentValue,
|
|
204
|
+
placeholder: currentValue === undefined
|
|
205
|
+
? placeholder
|
|
206
|
+
: "blank = keep the current value",
|
|
207
|
+
})).trim();
|
|
208
|
+
if (answer !== "" && answer !== currentValue)
|
|
209
|
+
return answer;
|
|
210
|
+
if (currentValue === undefined) {
|
|
211
|
+
prompts.log("Left unset — the server reads this setting from your vault's own config.");
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
prompts.log(`Kept the current value (${currentValue}).`);
|
|
215
|
+
return undefined;
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Routes a picked setting to its kind's prompt and returns the .env value —
|
|
219
|
+
* or undefined when an optionalText prompt was left blank (nothing to write).
|
|
220
|
+
*/
|
|
180
221
|
const askSettingValue = async (params, prompts) => {
|
|
181
222
|
const { setting, currentValue } = params;
|
|
182
223
|
switch (setting.kind) {
|
|
@@ -194,6 +235,12 @@ const askSettingValue = async (params, prompts) => {
|
|
|
194
235
|
currentValue,
|
|
195
236
|
defaultValue: setting.defaultValue,
|
|
196
237
|
}, prompts);
|
|
238
|
+
case "optionalText":
|
|
239
|
+
return askOptionalText({
|
|
240
|
+
question: setting.question,
|
|
241
|
+
placeholder: setting.placeholder,
|
|
242
|
+
currentValue,
|
|
243
|
+
}, prompts);
|
|
197
244
|
case "choice":
|
|
198
245
|
return prompts.select(setting.question, setting.choices, currentValue ?? setting.defaultValue);
|
|
199
246
|
}
|
|
@@ -222,12 +269,15 @@ export const askOptionalSettings = async (params, prompts) => {
|
|
|
222
269
|
});
|
|
223
270
|
const pickedNames = await prompts.multiselect("Any optional settings to change? (press enter to skip)", chooserOptions);
|
|
224
271
|
// Sequential prompting: answers are gathered one at a time in the curated
|
|
225
|
-
// order, so the record builds up inside an honest loop.
|
|
272
|
+
// order, so the record builds up inside an honest loop. An undefined answer
|
|
273
|
+
// (an optionalText prompt left blank) writes nothing.
|
|
226
274
|
const overrides = {};
|
|
227
275
|
for (const setting of offeredSettings) {
|
|
228
276
|
if (!pickedNames.includes(setting.name))
|
|
229
277
|
continue;
|
|
230
|
-
|
|
278
|
+
const value = await askSettingValue({ setting, currentValue: readOptionalValue(envContent, setting.name) }, prompts);
|
|
279
|
+
if (value !== undefined)
|
|
280
|
+
overrides[setting.name] = value;
|
|
231
281
|
}
|
|
232
282
|
return overrides;
|
|
233
283
|
};
|
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
|
-
*
|
|
42
|
-
*
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vault-cortex",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Set up a Vault Cortex MCP server for your Obsidian vault in one command: npx vault-cortex init",
|
|
3
|
+
"version": "0.10.1-beta.48",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -28,18 +28,23 @@
|
|
|
28
28
|
"mcp-server",
|
|
29
29
|
"model-context-protocol",
|
|
30
30
|
"obsidian",
|
|
31
|
-
"obsidian-mcp",
|
|
31
|
+
"obsidian-mcp-server",
|
|
32
32
|
"obsidian-vault",
|
|
33
|
+
"obsidian-sync",
|
|
34
|
+
"ai-agents",
|
|
33
35
|
"ai-memory",
|
|
34
|
-
"memory",
|
|
36
|
+
"ai-memory-system",
|
|
35
37
|
"knowledge-base",
|
|
36
38
|
"note-taking",
|
|
37
|
-
"
|
|
39
|
+
"pkm",
|
|
40
|
+
"personal-knowledge-management",
|
|
38
41
|
"hybrid-search",
|
|
39
42
|
"task-management",
|
|
40
43
|
"semantic-search",
|
|
41
44
|
"attachments",
|
|
42
45
|
"pdf",
|
|
46
|
+
"self-hosted",
|
|
47
|
+
"docker",
|
|
43
48
|
"claude",
|
|
44
49
|
"cli"
|
|
45
50
|
],
|