vault-cortex 0.8.0 → 0.9.0-beta.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -5
- package/dist/configure.js +70 -0
- package/dist/docker.js +50 -12
- package/dist/env.js +11 -9
- package/dist/get-sync-token.js +8 -5
- package/dist/init.js +59 -11
- package/dist/lifecycle.js +48 -10
- package/dist/main.js +6 -0
- package/dist/messages.js +70 -12
- package/dist/optional-settings.js +233 -0
- package/dist/program.js +7 -0
- package/dist/prompts.js +3 -0
- package/dist/scaffold.js +17 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,12 +15,14 @@ and data files — see the
|
|
|
15
15
|
The server runs as a Docker container; this CLI scaffolds the config and
|
|
16
16
|
manages the container so you don't have to.
|
|
17
17
|
|
|
18
|
-

|
|
18
|
+

|
|
19
19
|
|
|
20
20
|
## Commands
|
|
21
21
|
|
|
22
22
|
- [`init`](#init) — interactive setup: scaffold the config, generate the auth
|
|
23
23
|
token, start the server
|
|
24
|
+
- [`configure`](#configure) — change optional settings interactively and
|
|
25
|
+
restart to apply them
|
|
24
26
|
- [`upgrade`](#upgrade) — pull the latest image and re-create the container;
|
|
25
27
|
your data stays
|
|
26
28
|
- [`restart`](#restart) — re-create the container so your `.env` edits take
|
|
@@ -44,9 +46,14 @@ What it does:
|
|
|
44
46
|
- **Local** — Docker on this machine, your vault folder bind-mounted
|
|
45
47
|
- **Remote** — a VPS with [Obsidian Sync](https://obsidian.md/sync),
|
|
46
48
|
reachable from any device
|
|
47
|
-
2.
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
2. Offers the most common optional settings — memory layer and folder,
|
|
50
|
+
file tools, semantic search, port, timezone (plus sync direction for
|
|
51
|
+
remote) — press enter to keep the defaults, or pick the ones you want to
|
|
52
|
+
change. Skipped when the target directory already has a `.env` (use
|
|
53
|
+
[`configure`](#configure) instead)
|
|
54
|
+
3. Generates a `.env` file with a securely generated `MCP_AUTH_TOKEN`
|
|
55
|
+
4. Optionally starts the container and waits for the health check
|
|
56
|
+
5. Prints your connection details — the MCP URL, your auth token, and how to
|
|
50
57
|
connect your client
|
|
51
58
|
|
|
52
59
|
Existing files are never overwritten without asking. During a remote setup,
|
|
@@ -67,6 +74,22 @@ Non-interactive example:
|
|
|
67
74
|
npx vault-cortex@latest init --yes --vault-path /path/to/YourVault
|
|
68
75
|
```
|
|
69
76
|
|
|
77
|
+
## configure
|
|
78
|
+
|
|
79
|
+
Change optional settings on an existing setup:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx vault-cortex@latest configure
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Shows the same settings chooser as [`init`](#init) — memory layer and
|
|
86
|
+
folder, file tools, semantic search, port, timezone (plus sync direction
|
|
87
|
+
for remote) — pre-filled with your current values, saves your picks to
|
|
88
|
+
`.env`, and offers to restart the container so they take effect. Settings not in the chooser
|
|
89
|
+
live in `.env` too: edit the value there, then run [`restart`](#restart).
|
|
90
|
+
|
|
91
|
+
Use `--dir <path>` if your config isn't in `./vault-cortex`.
|
|
92
|
+
|
|
70
93
|
## upgrade
|
|
71
94
|
|
|
72
95
|
Pull the latest image, re-create the container, and verify health:
|
|
@@ -167,7 +190,8 @@ is available.
|
|
|
167
190
|
- Node.js >= 20.12 (only for this CLI — the server itself runs in Docker)
|
|
168
191
|
- [Docker](https://docs.docker.com/get-docker/) or a Docker-compatible
|
|
169
192
|
runtime (e.g. OrbStack, Colima, Podman) to run the server — the CLI
|
|
170
|
-
manages the container through the `docker` command
|
|
193
|
+
manages the container through the `docker` command (on Linux, see
|
|
194
|
+
[Docker Engine](https://docs.docker.com/engine/install/))
|
|
171
195
|
|
|
172
196
|
## Docs
|
|
173
197
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { recreateContainer, requireInitializedDir, resolveDeployment, } from "./lifecycle.js";
|
|
3
|
+
import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, readOptionalValue, } from "./optional-settings.js";
|
|
4
|
+
/**
|
|
5
|
+
* Interactively changes optional settings in an existing deployment's .env,
|
|
6
|
+
* then offers to re-create the container so the new values take effect (the
|
|
7
|
+
* env-file is only read at container creation). The edit succeeds on its own:
|
|
8
|
+
* a stopped daemon or a declined restart still exits 0 with the settings
|
|
9
|
+
* saved and a restart hint printed.
|
|
10
|
+
*/
|
|
11
|
+
export const runConfigure = async (flags, deps) => {
|
|
12
|
+
const { prompts, docker, fetchFn } = deps;
|
|
13
|
+
prompts.intro("vault-cortex configure");
|
|
14
|
+
// Editing settings only needs an init'd .env — the full start validation
|
|
15
|
+
// (VAULT_PATH, PUBLIC_URL) runs later, only when a restart is requested.
|
|
16
|
+
const initialized = requireInitializedDir(flags.dir, prompts);
|
|
17
|
+
if (!initialized)
|
|
18
|
+
return 1;
|
|
19
|
+
const { targetDir, envFilePath, mode } = initialized;
|
|
20
|
+
const envContent = readFileSync(envFilePath, "utf8");
|
|
21
|
+
const pickedOverrides = await askOptionalSettings({ mode, envContent }, prompts);
|
|
22
|
+
if (Object.keys(pickedOverrides).length === 0) {
|
|
23
|
+
prompts.log("No settings selected — nothing changed.");
|
|
24
|
+
prompts.outro("Done.");
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
const overrides = derivePublicUrlOverride(envContent, pickedOverrides);
|
|
28
|
+
const changedNames = Object.keys(overrides);
|
|
29
|
+
writeFileSync(envFilePath, applyOptionalSettings(envContent, overrides));
|
|
30
|
+
prompts.log(`Updated ${changedNames.join(", ")} in ${targetDir}/.env.`);
|
|
31
|
+
// A custom PUBLIC_URL is never rewritten (see derivePublicUrlOverride), but
|
|
32
|
+
// a port change can still strand it — surface the consequence non-blocking.
|
|
33
|
+
const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL");
|
|
34
|
+
if (pickedOverrides.PORT && !overrides.PUBLIC_URL && currentPublicUrl) {
|
|
35
|
+
prompts.warn(`PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.`);
|
|
36
|
+
}
|
|
37
|
+
const restartHint = `Apply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"`;
|
|
38
|
+
const daemonStatus = docker.daemonStatus();
|
|
39
|
+
if (daemonStatus !== "running") {
|
|
40
|
+
// Settings are already saved — the runtime state only affects the restart
|
|
41
|
+
// offer, so both non-running states degrade to the hint; restart itself
|
|
42
|
+
// prints the full install/start guidance when run.
|
|
43
|
+
prompts.warn(daemonStatus === "not-installed"
|
|
44
|
+
? `No container runtime found — settings saved.\n${restartHint}`
|
|
45
|
+
: `Container runtime not running — settings saved.\n${restartHint}`);
|
|
46
|
+
prompts.outro("Done.");
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const restartNow = await prompts.confirm("Restart the container now to apply the new settings?", true);
|
|
50
|
+
if (!restartNow) {
|
|
51
|
+
prompts.log(restartHint);
|
|
52
|
+
prompts.outro("Done.");
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
// Resolve from disk after the write so the restart honors the new values
|
|
56
|
+
// (a changed PORT must drive the port mapping and health URL).
|
|
57
|
+
const deployment = resolveDeployment(flags.dir, prompts);
|
|
58
|
+
if (!deployment) {
|
|
59
|
+
// The edit already succeeded — don't let the failed restart read as a
|
|
60
|
+
// failed configure.
|
|
61
|
+
prompts.warn(`The restart did not run — your settings are saved. Fix the issue above, then apply them with: npx vault-cortex@latest restart --dir "${targetDir}"`);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
|
|
65
|
+
if (exitCode !== 0)
|
|
66
|
+
return exitCode;
|
|
67
|
+
prompts.log("Applied the current .env settings.");
|
|
68
|
+
prompts.outro("Configure complete.");
|
|
69
|
+
return 0;
|
|
70
|
+
};
|
package/dist/docker.js
CHANGED
|
@@ -107,8 +107,23 @@ export const buildDockerLogsArgs = (params) => {
|
|
|
107
107
|
CONTAINER_NAME,
|
|
108
108
|
];
|
|
109
109
|
};
|
|
110
|
+
/**
|
|
111
|
+
* Classifies a `docker info` spawnSync result. ENOENT on the spawn itself
|
|
112
|
+
* means the `docker` binary is absent (not installed); any other failure —
|
|
113
|
+
* non-zero exit, timeout, signal kill — means the binary exists but the
|
|
114
|
+
* daemon isn't answering. `status` alone can't make that call: it is null
|
|
115
|
+
* for ENOENT *and* for timeouts, so the split keys on the error code.
|
|
116
|
+
*/
|
|
117
|
+
export const classifyDaemonStatus = (spawnResult) => {
|
|
118
|
+
if (spawnResult.status === 0)
|
|
119
|
+
return "running";
|
|
120
|
+
const spawnErrorCode = spawnResult.error && "code" in spawnResult.error
|
|
121
|
+
? spawnResult.error.code
|
|
122
|
+
: undefined;
|
|
123
|
+
return spawnErrorCode === "ENOENT" ? "not-installed" : "not-running";
|
|
124
|
+
};
|
|
110
125
|
export const createDockerRunner = () => ({
|
|
111
|
-
|
|
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,9 +10,10 @@
|
|
|
10
10
|
// └─────────────────────────────────────────────────────────────────────────┘
|
|
11
11
|
// sync:local-optional:begin
|
|
12
12
|
const LOCAL_OPTIONAL_BLOCK = `# Optional ──────────────────────────────────────────────────
|
|
13
|
-
# To change a setting:
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# To change a setting: run "npx vault-cortex@latest configure", or edit
|
|
14
|
+
# its value here (uncommenting it first if needed) and apply with
|
|
15
|
+
# "npx vault-cortex@latest restart" (plain docker restart does not
|
|
16
|
+
# re-read this file).
|
|
16
17
|
|
|
17
18
|
# Public URL for OAuth issuer URL in discovery metadata (default: http://localhost:8000).
|
|
18
19
|
# Override if you expose the server on a different URL (e.g. via a reverse proxy).
|
|
@@ -100,9 +101,10 @@ WINDOWS_MODE=false
|
|
|
100
101
|
// └─────────────────────────────────────────────────────────────────────────┘
|
|
101
102
|
// sync:remote-optional:begin
|
|
102
103
|
const REMOTE_OPTIONAL_BLOCK = `# Optional ──────────────────────────────────────────────────
|
|
103
|
-
# To change a setting:
|
|
104
|
-
#
|
|
105
|
-
#
|
|
104
|
+
# To change a setting: run "npx vault-cortex@latest configure", or edit
|
|
105
|
+
# its value here (uncommenting it first if needed) and apply with
|
|
106
|
+
# "npx vault-cortex@latest restart" (plain docker restart does not
|
|
107
|
+
# re-read this file).
|
|
106
108
|
|
|
107
109
|
# Your IANA timezone — affects daily note resolution and memory timestamps.
|
|
108
110
|
# TZ=America/New_York
|
|
@@ -193,7 +195,7 @@ SYNC_MODE=bidirectional
|
|
|
193
195
|
`;
|
|
194
196
|
// sync:remote-optional:end
|
|
195
197
|
export const buildLocalEnv = (answers) => `# vault-cortex — local quickstart
|
|
196
|
-
# Generated by \`npx vault-cortex init\`. Full option reference:
|
|
198
|
+
# Generated by \`npx vault-cortex@latest init\`. Full option reference:
|
|
197
199
|
# https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/.env.example
|
|
198
200
|
|
|
199
201
|
# Required ──────────────────────────────────────────────────
|
|
@@ -214,10 +216,10 @@ VAULT_PASSWORD=${answers.vaultPassword}`;
|
|
|
214
216
|
const obsidianTokenComment = answers.obsidianAuthToken === ""
|
|
215
217
|
? `# Obsidian Sync auth token — FILL THIS IN before starting the server.
|
|
216
218
|
# Generate once with:
|
|
217
|
-
# npx vault-cortex get-sync-token`
|
|
219
|
+
# npx vault-cortex@latest get-sync-token`
|
|
218
220
|
: `# Obsidian Sync auth token.`;
|
|
219
221
|
return `# vault-cortex — remote quickstart (Obsidian Sync)
|
|
220
|
-
# Generated by \`npx vault-cortex init\`. Full option reference:
|
|
222
|
+
# Generated by \`npx vault-cortex@latest init\`. Full option reference:
|
|
221
223
|
# https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/.env.example
|
|
222
224
|
|
|
223
225
|
# Required ──────────────────────────────────────────────────
|
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
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { join, resolve } from "node:path";
|
|
2
3
|
import { buildLocalEnv, buildRemoteEnv } from "./env.js";
|
|
3
4
|
import { captureObsidianToken } from "./get-sync-token.js";
|
|
4
|
-
import { buildLocalConnectMessage, buildRemoteConnectMessage, } from "./messages.js";
|
|
5
|
+
import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage, buildLocalConnectMessage, buildRemoteConnectMessage, } from "./messages.js";
|
|
5
6
|
import { pollHealth } from "./docker.js";
|
|
6
|
-
import {
|
|
7
|
+
import { reportPublicUrlProbe } from "./lifecycle.js";
|
|
8
|
+
import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, } from "./optional-settings.js";
|
|
9
|
+
import { buildFilesToWrite, readEnvPort, readEnvPublicUrl, writeFiles, } from "./scaffold.js";
|
|
7
10
|
import { generateToken } from "./token.js";
|
|
8
11
|
import { expandTilde, validateVaultPath } from "./vault.js";
|
|
9
12
|
const DEFAULT_TARGET_DIR = "./vault-cortex";
|
|
@@ -137,10 +140,14 @@ const reportWrites = (params, prompts) => {
|
|
|
137
140
|
const offerDockerRun = async (params, deps) => {
|
|
138
141
|
const { targetDir, port, mode, vaultPath } = params;
|
|
139
142
|
const { prompts, docker, fetchFn } = deps;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
const daemonStatus = docker.daemonStatus();
|
|
144
|
+
if (daemonStatus !== "running") {
|
|
145
|
+
const upgradeHint = `npx vault-cortex@latest upgrade --dir "${targetDir}"`;
|
|
146
|
+
prompts.warn(daemonStatus === "not-installed"
|
|
147
|
+
? buildDockerNotInstalledMessage({
|
|
148
|
+
nextStep: `\nThen start the server with:\n ${upgradeHint}`,
|
|
149
|
+
})
|
|
150
|
+
: buildDaemonNotRunningMessage(`, then run:\n ${upgradeHint}`));
|
|
144
151
|
return false;
|
|
145
152
|
}
|
|
146
153
|
const startNow = await prompts.confirm("Start the server now?", true);
|
|
@@ -203,10 +210,25 @@ const runLocalInit = async (flags, deps) => {
|
|
|
203
210
|
placeholder: DEFAULT_TARGET_DIR,
|
|
204
211
|
}))));
|
|
205
212
|
const token = generateToken();
|
|
213
|
+
// Guided optional settings: the chooser reads current values from the
|
|
214
|
+
// generated defaults; enter with nothing picked keeps them all. --yes
|
|
215
|
+
// skips the chooser (non-interactive by contract), and so does an existing
|
|
216
|
+
// .env — the conflict prompt defaults to keeping it, which would discard
|
|
217
|
+
// the answers; settings on an existing deployment are configure's job.
|
|
218
|
+
const defaultEnvContent = buildLocalEnv({ mcpAuthToken: token, vaultPath });
|
|
219
|
+
const envAlreadyExists = existsSync(join(targetDir, ".env"));
|
|
220
|
+
const offerSettingsChooser = !flags.yes && !envAlreadyExists;
|
|
221
|
+
if (!flags.yes && envAlreadyExists) {
|
|
222
|
+
prompts.log('Found an existing .env — settings prompts skipped. Adjust settings with "npx vault-cortex@latest configure".');
|
|
223
|
+
}
|
|
224
|
+
const optionalOverrides = offerSettingsChooser
|
|
225
|
+
? await askOptionalSettings({ mode: "local", envContent: defaultEnvContent }, prompts)
|
|
226
|
+
: {};
|
|
227
|
+
const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
|
|
206
228
|
// Conflict policy: identical existing files are skipped silently;
|
|
207
229
|
// differing ones prompt per file (default keep). --yes never overwrites —
|
|
208
230
|
// any differing file becomes an exit-1 below, leaving it untouched.
|
|
209
|
-
const files = buildFilesToWrite(
|
|
231
|
+
const files = buildFilesToWrite(envContent);
|
|
210
232
|
const resolveConflict = flags.yes ? keepExisting : confirmOverwrite(prompts);
|
|
211
233
|
const results = await writeFiles({ targetDir, files }, resolveConflict);
|
|
212
234
|
reportWrites({ targetDir, results }, prompts);
|
|
@@ -248,8 +270,10 @@ const runRemoteInit = async (flags, deps) => {
|
|
|
248
270
|
const vaultName = await askVaultName(prompts);
|
|
249
271
|
// Auto-capture the Obsidian Sync token via a Docker volume mount when
|
|
250
272
|
// the daemon is reachable. Falls back to a paste prompt when capture
|
|
251
|
-
// fails or the user declines.
|
|
252
|
-
|
|
273
|
+
// fails or the user declines. Both non-running states stay silent here —
|
|
274
|
+
// the paste fallback is fully functional without Docker, and the start
|
|
275
|
+
// offer surfaces the differentiated runtime guidance later in the flow.
|
|
276
|
+
const capturedToken = docker.daemonStatus() === "running"
|
|
253
277
|
? await offerSyncTokenCapture(prompts, docker)
|
|
254
278
|
: undefined;
|
|
255
279
|
// Masked prompt: the sync token is a credential and must not echo into
|
|
@@ -262,13 +286,24 @@ const runRemoteInit = async (flags, deps) => {
|
|
|
262
286
|
? await prompts.password("Vault encryption password:")
|
|
263
287
|
: undefined;
|
|
264
288
|
const token = generateToken();
|
|
265
|
-
|
|
289
|
+
// Guided optional settings, mirroring the local flow — remote also offers
|
|
290
|
+
// SYNC_MODE. Remote init is always interactive (no --yes), so only an
|
|
291
|
+
// existing .env skips the chooser here.
|
|
292
|
+
const defaultEnvContent = buildRemoteEnv({
|
|
266
293
|
mcpAuthToken: token,
|
|
267
294
|
publicUrl,
|
|
268
295
|
obsidianAuthToken,
|
|
269
296
|
vaultName,
|
|
270
297
|
vaultPassword,
|
|
271
298
|
});
|
|
299
|
+
const envAlreadyExists = existsSync(join(targetDir, ".env"));
|
|
300
|
+
if (envAlreadyExists) {
|
|
301
|
+
prompts.log('Found an existing .env — settings prompts skipped. Adjust settings with "npx vault-cortex@latest configure".');
|
|
302
|
+
}
|
|
303
|
+
const optionalOverrides = envAlreadyExists
|
|
304
|
+
? {}
|
|
305
|
+
: await askOptionalSettings({ mode: "remote", envContent: defaultEnvContent }, prompts);
|
|
306
|
+
const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
|
|
272
307
|
const files = buildFilesToWrite(envContent);
|
|
273
308
|
const results = await writeFiles({ targetDir, files }, confirmOverwrite(prompts));
|
|
274
309
|
reportWrites({ targetDir, results }, prompts);
|
|
@@ -281,15 +316,28 @@ const runRemoteInit = async (flags, deps) => {
|
|
|
281
316
|
if (tokenWritten)
|
|
282
317
|
prompts.log("Generated MCP auth token (saved to .env).");
|
|
283
318
|
const port = readEnvPort(join(targetDir, ".env"));
|
|
319
|
+
// Like PORT above, PUBLIC_URL comes from the .env actually on disk — a kept
|
|
320
|
+
// existing file may hold a different URL than this run's prompt, and the
|
|
321
|
+
// server only reads the file. The prompted value is the fallback for a kept
|
|
322
|
+
// legacy .env that predates PUBLIC_URL.
|
|
323
|
+
const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl;
|
|
284
324
|
// Without the sync token the container can't start (init-check-auth fails
|
|
285
325
|
// and s6 stops it), so only offer docker run when it was provided.
|
|
286
326
|
const started = obsidianAuthToken === ""
|
|
287
327
|
? false
|
|
288
328
|
: await offerDockerRun({ targetDir, port, mode: "remote" }, deps);
|
|
329
|
+
// The container check above hit localhost on this machine; the public URL
|
|
330
|
+
// is the ingress path clients actually use — probe it too, informationally.
|
|
331
|
+
if (started) {
|
|
332
|
+
await reportPublicUrlProbe(effectivePublicUrl, {
|
|
333
|
+
prompts,
|
|
334
|
+
fetchFn: deps.fetchFn,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
289
337
|
prompts.print(buildRemoteConnectMessage({
|
|
290
338
|
targetDir,
|
|
291
339
|
token,
|
|
292
|
-
publicUrl,
|
|
340
|
+
publicUrl: effectivePublicUrl,
|
|
293
341
|
started,
|
|
294
342
|
obsidianTokenMissing: obsidianAuthToken === "",
|
|
295
343
|
tokenWritten,
|
package/dist/lifecycle.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
-
import { CONTAINER_NAME, pollHealth } from "./docker.js";
|
|
3
|
-
import {
|
|
2
|
+
import { CONTAINER_NAME, pollHealth, probeHealth, } from "./docker.js";
|
|
3
|
+
import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage, } from "./messages.js";
|
|
4
|
+
import { detectMode, hasEnvPublicUrl, readEnvPort, readEnvPublicUrl, readEnvVaultPath, } from "./scaffold.js";
|
|
4
5
|
import { expandTilde } from "./vault.js";
|
|
5
6
|
const DEFAULT_TARGET_DIR = "./vault-cortex";
|
|
6
7
|
/**
|
|
@@ -8,12 +9,12 @@ const DEFAULT_TARGET_DIR = "./vault-cortex";
|
|
|
8
9
|
* starting a container: resolves --dir and confirms an init'd .env exists.
|
|
9
10
|
* Full start-readiness validation lives in resolveDeployment.
|
|
10
11
|
*/
|
|
11
|
-
const requireInitializedDir = (dirFlag, prompts) => {
|
|
12
|
+
export const requireInitializedDir = (dirFlag, prompts) => {
|
|
12
13
|
const targetDir = resolve(expandTilde(dirFlag ?? DEFAULT_TARGET_DIR));
|
|
13
14
|
const envFilePath = join(targetDir, ".env");
|
|
14
15
|
const mode = detectMode(envFilePath);
|
|
15
16
|
if (!mode) {
|
|
16
|
-
prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex init\` first.`);
|
|
17
|
+
prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex@latest init\` first.`);
|
|
17
18
|
return undefined;
|
|
18
19
|
}
|
|
19
20
|
return { targetDir, envFilePath, mode };
|
|
@@ -40,19 +41,51 @@ export const resolveDeployment = (dirFlag, prompts) => {
|
|
|
40
41
|
`Add this line to your .env:\n PUBLIC_URL=http://localhost:${port}`);
|
|
41
42
|
return undefined;
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
// Local's PUBLIC_URL is the derived localhost URL — probing it would
|
|
45
|
+
// duplicate the health check recreateContainer just ran, so remote-only.
|
|
46
|
+
const publicUrl = mode === "remote" ? readEnvPublicUrl(envFilePath) : undefined;
|
|
47
|
+
return { mode, targetDir, envFilePath, port, vaultPath, publicUrl };
|
|
44
48
|
};
|
|
45
49
|
/**
|
|
46
50
|
* Verifies the container runtime is reachable, reporting the shared error
|
|
47
51
|
* message when it isn't. Callers early-return on false.
|
|
48
52
|
*/
|
|
49
53
|
export const ensureDaemonRunning = (docker, prompts) => {
|
|
50
|
-
|
|
54
|
+
const daemonStatus = docker.daemonStatus();
|
|
55
|
+
if (daemonStatus === "running")
|
|
51
56
|
return true;
|
|
52
|
-
prompts.error(
|
|
53
|
-
|
|
57
|
+
prompts.error(daemonStatus === "not-installed"
|
|
58
|
+
? buildDockerNotInstalledMessage({ nextStep: "" })
|
|
59
|
+
: buildDaemonNotRunningMessage("."));
|
|
54
60
|
return false;
|
|
55
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* One-shot informational probe of the public /healthz after a confirmed
|
|
64
|
+
* container start. Never a gate: a failure warns and the command still
|
|
65
|
+
* succeeds — before HTTPS/ingress access is set up an unreachable public URL
|
|
66
|
+
* is the expected state, and this machine's result doesn't prove the same
|
|
67
|
+
* for other devices (a VPS may not reach its own public address). Returning
|
|
68
|
+
* void keeps the informational contract structural.
|
|
69
|
+
*/
|
|
70
|
+
export const reportPublicUrlProbe = async (publicUrl, deps) => {
|
|
71
|
+
const { prompts, fetchFn } = deps;
|
|
72
|
+
// A hand-edited .env value may carry a trailing slash; strip it so the
|
|
73
|
+
// probe URL is `${base}/healthz`, never `${base}//healthz`.
|
|
74
|
+
const healthUrl = `${publicUrl.replace(/\/+$/, "")}/healthz`;
|
|
75
|
+
const spinner = prompts.spinner();
|
|
76
|
+
spinner.start(`Checking the public URL (${healthUrl})`);
|
|
77
|
+
const publicUrlResponded = await probeHealth({ url: healthUrl }, fetchFn);
|
|
78
|
+
if (publicUrlResponded) {
|
|
79
|
+
spinner.stop(`Public URL responds — ${healthUrl} answered from this machine.`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
spinner.stop(`No answer from ${healthUrl} yet.`);
|
|
83
|
+
prompts.warn("The server is up, but its public URL didn't answer from this machine.\n" +
|
|
84
|
+
"That's expected until HTTPS (or direct port) access is set up — and\n" +
|
|
85
|
+
"some networks keep a server from reaching its own public address even\n" +
|
|
86
|
+
"when other devices can. Once access is set up, check from any device:\n" +
|
|
87
|
+
` curl ${healthUrl}`);
|
|
88
|
+
};
|
|
56
89
|
/**
|
|
57
90
|
* Stop-and-remove → docker run → health poll, with the shared messaging.
|
|
58
91
|
* Returns a process exit code. Callers own the daemon check and any
|
|
@@ -92,6 +125,11 @@ export const recreateContainer = async (params, deps) => {
|
|
|
92
125
|
return 1;
|
|
93
126
|
}
|
|
94
127
|
spinner.stop("Server is up — health check passed.");
|
|
128
|
+
// Informational only — the container is confirmed healthy above, so the
|
|
129
|
+
// public-URL result never changes the exit code.
|
|
130
|
+
if (deployment.mode === "remote" && deployment.publicUrl) {
|
|
131
|
+
await reportPublicUrlProbe(deployment.publicUrl, { prompts, fetchFn });
|
|
132
|
+
}
|
|
95
133
|
return 0;
|
|
96
134
|
};
|
|
97
135
|
/**
|
|
@@ -119,7 +157,7 @@ export const runDown = async (flags, deps) => {
|
|
|
119
157
|
return 1;
|
|
120
158
|
}
|
|
121
159
|
prompts.log("Container stopped and removed. Your vault data, search index, and settings are untouched.");
|
|
122
|
-
prompts.outro(`Start again with: npx vault-cortex restart --dir "${initialized.targetDir}"`);
|
|
160
|
+
prompts.outro(`Start again with: npx vault-cortex@latest restart --dir "${initialized.targetDir}"`);
|
|
123
161
|
return 0;
|
|
124
162
|
};
|
|
125
163
|
/**
|
|
@@ -136,7 +174,7 @@ export const runLogs = async (flags, deps) => {
|
|
|
136
174
|
if (!ensureDaemonRunning(docker, prompts))
|
|
137
175
|
return 1;
|
|
138
176
|
if (!docker.containerExists()) {
|
|
139
|
-
prompts.error("No vault-cortex container — start it with `npx vault-cortex restart`.");
|
|
177
|
+
prompts.error("No vault-cortex container — start it with `npx vault-cortex@latest restart`.");
|
|
140
178
|
return 1;
|
|
141
179
|
}
|
|
142
180
|
return await docker.streamLogs({
|
package/dist/main.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runConfigure } from "./configure.js";
|
|
1
2
|
import { createDockerRunner } from "./docker.js";
|
|
2
3
|
import { runGetSyncToken } from "./get-sync-token.js";
|
|
3
4
|
import { runInit } from "./init.js";
|
|
@@ -13,6 +14,11 @@ export const run = async (version) => {
|
|
|
13
14
|
docker: createDockerRunner(),
|
|
14
15
|
fetchFn: fetch,
|
|
15
16
|
}),
|
|
17
|
+
runConfigure: (flags) => runConfigure(flags, {
|
|
18
|
+
prompts: createPrompts(),
|
|
19
|
+
docker: createDockerRunner(),
|
|
20
|
+
fetchFn: fetch,
|
|
21
|
+
}),
|
|
16
22
|
runUpgrade: (flags) => runUpgrade(flags, {
|
|
17
23
|
prompts: createPrompts(),
|
|
18
24
|
docker: createDockerRunner(),
|
package/dist/messages.js
CHANGED
|
@@ -10,9 +10,41 @@ const RULE_WIDTH = 56;
|
|
|
10
10
|
const topRule = (label) => paint("dim", `╭── ${label} ${"─".repeat(Math.max(0, RULE_WIDTH - label.length - 6))}╮`);
|
|
11
11
|
const bottomRule = () => paint("dim", `╰${"─".repeat(RULE_WIDTH - 2)}╯`);
|
|
12
12
|
const sectionRule = (label) => paint("dim", `── ${label} ${"─".repeat(Math.max(0, RULE_WIDTH - label.length - 4))}`);
|
|
13
|
+
/**
|
|
14
|
+
* Daemon-stopped guidance shared by every command that needs the container
|
|
15
|
+
* runtime. `nextStep` finishes the message per command — appended verbatim
|
|
16
|
+
* (".", " and try again.", or a ", then run:" continuation).
|
|
17
|
+
*/
|
|
18
|
+
export const buildDaemonNotRunningMessage = (nextStep) => "Container runtime not running — start Docker Desktop, Colima,\n" +
|
|
19
|
+
`OrbStack, or another Docker-compatible runtime${nextStep}`;
|
|
20
|
+
/**
|
|
21
|
+
* Per-platform install pointer — a docs link only, no install method
|
|
22
|
+
* suggestions: the CLI doesn't install anything, so the official docs (which
|
|
23
|
+
* cover every method) are the hand-off. Peers are named in the message above
|
|
24
|
+
* this line.
|
|
25
|
+
*/
|
|
26
|
+
const dockerInstallLine = (platform) => {
|
|
27
|
+
if (platform === "darwin" || platform === "win32") {
|
|
28
|
+
return "Install Docker Desktop: https://docs.docker.com/get-docker/";
|
|
29
|
+
}
|
|
30
|
+
return "Install Docker Engine: https://docs.docker.com/engine/install/";
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* "No runtime at all" guidance — distinct from the daemon-stopped message so
|
|
34
|
+
* the user isn't told to start something that isn't installed. platform is a
|
|
35
|
+
* defaulted param (mirroring buildObsidianLoginArgs) so each branch stays
|
|
36
|
+
* testable; `nextStep` is appended verbatim, as in
|
|
37
|
+
* buildDaemonNotRunningMessage.
|
|
38
|
+
*/
|
|
39
|
+
export const buildDockerNotInstalledMessage = (params) => {
|
|
40
|
+
const { nextStep, platform = process.platform } = params;
|
|
41
|
+
return ("No container runtime found — the server runs in Docker, so you need\n" +
|
|
42
|
+
"Docker or a Docker-compatible runtime (OrbStack, Colima, Podman).\n" +
|
|
43
|
+
`${dockerInstallLine(platform)}${nextStep}`);
|
|
44
|
+
};
|
|
13
45
|
// targetDir is quoted: these lines are meant to be copy-pasted into a
|
|
14
46
|
// shell, and an unquoted path breaks on spaces or special characters.
|
|
15
|
-
const upgradeCommand = (targetDir) => `npx vault-cortex upgrade --dir "${targetDir}"`;
|
|
47
|
+
const upgradeCommand = (targetDir) => `npx vault-cortex@latest upgrade --dir "${targetDir}"`;
|
|
16
48
|
const startServerLine = (targetDir) => `Start the server:\n ${upgradeCommand(targetDir)}`;
|
|
17
49
|
/** Remote start line: running, blocked on the missing sync token, or ready to start. */
|
|
18
50
|
const remoteStartLine = (params) => {
|
|
@@ -62,6 +94,19 @@ const curlGuidance = (mcpUrl) => `Clients without OAuth, scripts, and curl send
|
|
|
62
94
|
curl -H "Authorization: Bearer <token>" ${mcpUrl}`;
|
|
63
95
|
const smokeTest = (healthUrl) => `Smoke test:
|
|
64
96
|
curl ${healthUrl}`;
|
|
97
|
+
/**
|
|
98
|
+
* Remote health-check block. Started: the CLI verified localhost on the VPS,
|
|
99
|
+
* but the public URL is a different check (ingress — DNS, TLS, proxy), so the
|
|
100
|
+
* command stays, reworded as the works-from-any-device check. Not started:
|
|
101
|
+
* the plain smoke test to run after starting.
|
|
102
|
+
*/
|
|
103
|
+
const remoteHealthCheckBlock = (healthUrl, started) => {
|
|
104
|
+
if (started) {
|
|
105
|
+
return `Health check — works from any device that can reach the URL:
|
|
106
|
+
curl ${healthUrl}`;
|
|
107
|
+
}
|
|
108
|
+
return smokeTest(healthUrl);
|
|
109
|
+
};
|
|
65
110
|
const updateGuidance = (targetDir) => `Update to the latest release:
|
|
66
111
|
${upgradeCommand(targetDir)}`;
|
|
67
112
|
/**
|
|
@@ -76,6 +121,15 @@ export const buildLocalConnectMessage = (params) => {
|
|
|
76
121
|
? "The server is running."
|
|
77
122
|
: startServerLine(targetDir);
|
|
78
123
|
const tokenLine = tokenBlock({ targetDir, token, tokenWritten });
|
|
124
|
+
// Once the server is confirmed up, the smoke test is dropped — the CLI just
|
|
125
|
+
// verified this exact URL, so re-printing it reads as leftover homework.
|
|
126
|
+
// Assembled as a filtered list so the omission leaves no stray blank line.
|
|
127
|
+
const nonOauthBlocks = [
|
|
128
|
+
curlGuidance(`${baseUrl}/mcp`),
|
|
129
|
+
started ? undefined : smokeTest(`${baseUrl}/healthz`),
|
|
130
|
+
]
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join("\n\n");
|
|
79
133
|
// Flush-left on purpose: this is printed as plain text (see paint), so
|
|
80
134
|
// leading whitespace would render as literal indentation. Local is always
|
|
81
135
|
// localhost http, so it shares the http guidance; its only divergences are
|
|
@@ -104,16 +158,17 @@ it with mcp-remote:
|
|
|
104
158
|
|
|
105
159
|
${sectionRule("Non-OAuth")}
|
|
106
160
|
|
|
107
|
-
${
|
|
108
|
-
|
|
109
|
-
${smokeTest(`${baseUrl}/healthz`)}
|
|
161
|
+
${nonOauthBlocks}
|
|
110
162
|
|
|
111
163
|
${sectionRule("Settings")}
|
|
112
164
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
165
|
+
Adjust optional settings (memory layer and folder, file tools,
|
|
166
|
+
semantic search, port, timezone):
|
|
167
|
+
npx vault-cortex@latest configure --dir "${targetDir}"
|
|
168
|
+
|
|
169
|
+
Or edit ${targetDir}/.env directly — change a value (uncommenting it
|
|
170
|
+
first if needed), then apply with "npx vault-cortex@latest restart" (plain
|
|
171
|
+
docker restart does not re-read .env).
|
|
117
172
|
|
|
118
173
|
${updateGuidance(targetDir)}
|
|
119
174
|
|
|
@@ -168,13 +223,16 @@ ${sectionRule("Non-OAuth")}
|
|
|
168
223
|
|
|
169
224
|
${curlGuidance(`${publicUrl}/mcp`)}
|
|
170
225
|
|
|
171
|
-
${
|
|
226
|
+
${remoteHealthCheckBlock(`${publicUrl}/healthz`, started)}
|
|
172
227
|
|
|
173
228
|
${sectionRule("Settings")}
|
|
174
229
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
230
|
+
Adjust optional settings (memory layer and folder, file tools,
|
|
231
|
+
semantic search, port, timezone, sync direction):
|
|
232
|
+
npx vault-cortex@latest configure --dir "${targetDir}"
|
|
233
|
+
|
|
234
|
+
Or edit ${targetDir}/.env directly — change a value (uncommenting it
|
|
235
|
+
first if needed), then apply with "npx vault-cortex@latest restart" (plain
|
|
178
236
|
docker restart does not re-read .env).
|
|
179
237
|
|
|
180
238
|
${updateGuidance(targetDir)}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { DEFAULT_PORT } from "./scaffold.js";
|
|
2
|
+
// The curated prompt set — settings users most often want without reading
|
|
3
|
+
// .env comments. Everything else stays documented-only in the generated
|
|
4
|
+
// optional block, deliberately: every extra prompt costs init flow length.
|
|
5
|
+
const OPTIONAL_SETTINGS = [
|
|
6
|
+
{
|
|
7
|
+
kind: "toggle",
|
|
8
|
+
name: "MEMORY_ENABLED",
|
|
9
|
+
label: "Memory layer",
|
|
10
|
+
question: "Enable the memory layer (About Me/ folder + memory tools)?",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
kind: "folder",
|
|
14
|
+
name: "MEMORY_DIR",
|
|
15
|
+
label: "Memory folder",
|
|
16
|
+
question: "Vault folder for the memory files:",
|
|
17
|
+
defaultValue: "About Me",
|
|
18
|
+
requiresToggle: "MEMORY_ENABLED",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
kind: "toggle",
|
|
22
|
+
name: "FILE_TOOLS_ENABLED",
|
|
23
|
+
label: "File tools",
|
|
24
|
+
question: "Enable file tools (read images, PDFs, and other non-Markdown files)?",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
kind: "toggle",
|
|
28
|
+
name: "EMBEDDING_ENABLED",
|
|
29
|
+
label: "Semantic search",
|
|
30
|
+
question: "Enable semantic search embeddings (richer search, slower first startup)?",
|
|
31
|
+
},
|
|
32
|
+
{ kind: "port", name: "PORT", label: "Host port" },
|
|
33
|
+
{ kind: "timezone", name: "TZ", label: "Timezone" },
|
|
34
|
+
{
|
|
35
|
+
kind: "choice",
|
|
36
|
+
name: "SYNC_MODE",
|
|
37
|
+
label: "Sync direction",
|
|
38
|
+
question: "Obsidian Sync direction:",
|
|
39
|
+
choices: [
|
|
40
|
+
{
|
|
41
|
+
value: "bidirectional",
|
|
42
|
+
label: "Bidirectional",
|
|
43
|
+
hint: "pull remote changes and push server-side edits",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
value: "pull-only",
|
|
47
|
+
label: "Pull-only",
|
|
48
|
+
hint: "receive changes but never push",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
value: "push-only",
|
|
52
|
+
label: "Push-only",
|
|
53
|
+
hint: "push changes but never pull",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
defaultValue: "bidirectional",
|
|
57
|
+
remoteOnly: true,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
/** Matches the full active (uncommented) assignment line for a var. */
|
|
61
|
+
const activeLinePattern = (name) => new RegExp(`^${name}=.*$`, "m");
|
|
62
|
+
/** Matches the full commented-out assignment line (`# VAR=...`) for a var. */
|
|
63
|
+
const commentedLinePattern = (name) => new RegExp(`^# ${name}=.*$`, "m");
|
|
64
|
+
/**
|
|
65
|
+
* Reads a var's current value from .env content. A commented-out or missing
|
|
66
|
+
* line returns undefined — both mean "the server uses its built-in default",
|
|
67
|
+
* which is exactly what the prompts need to distinguish. Duplicate lines
|
|
68
|
+
* report the LAST value, matching docker --env-file precedence — hints,
|
|
69
|
+
* prompt seeding, and the PUBLIC_URL derivation must reason from the value
|
|
70
|
+
* that actually takes effect.
|
|
71
|
+
*/
|
|
72
|
+
export const readOptionalValue = (envContent, name) => {
|
|
73
|
+
const matches = [...envContent.matchAll(new RegExp(`^${name}=(.*)$`, "gm"))];
|
|
74
|
+
return matches.at(-1)?.[1].trim();
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Applies chosen values to .env content as a pure text transform: every
|
|
78
|
+
* active line for the var is replaced (docker --env-file gives the last
|
|
79
|
+
* duplicate precedence, so a single-line replace could leave a stale
|
|
80
|
+
* duplicate winning), a commented-out line is uncommented and replaced, and
|
|
81
|
+
* a var with no line at all (a .env predating the setting) is appended —
|
|
82
|
+
* the chosen value must land in the file, never be silently dropped.
|
|
83
|
+
*/
|
|
84
|
+
export const applyOptionalSettings = (envContent, overrides) => Object.entries(overrides).reduce((content, [name, value]) => {
|
|
85
|
+
// Fresh RegExp per use (the /g flag makes instances stateful via
|
|
86
|
+
// lastIndex); function replacements avoid $-pattern interpretation in
|
|
87
|
+
// values, same as patchEnvObsidianToken.
|
|
88
|
+
const everyActiveLine = new RegExp(`^${name}=.*$`, "gm");
|
|
89
|
+
if (activeLinePattern(name).test(content)) {
|
|
90
|
+
return content.replace(everyActiveLine, () => `${name}=${value}`);
|
|
91
|
+
}
|
|
92
|
+
if (commentedLinePattern(name).test(content)) {
|
|
93
|
+
return content.replace(commentedLinePattern(name), () => `${name}=${value}`);
|
|
94
|
+
}
|
|
95
|
+
return `${content.trimEnd()}\n\n${name}=${value}\n`;
|
|
96
|
+
}, envContent);
|
|
97
|
+
/**
|
|
98
|
+
* A PORT override moves the server, and the local quickstart derives
|
|
99
|
+
* PUBLIC_URL (the OAuth issuer) from that port. When the current PUBLIC_URL
|
|
100
|
+
* is exactly the derived http://localhost:<current port> form, it follows
|
|
101
|
+
* the new port — otherwise the advertised OAuth discovery endpoints would
|
|
102
|
+
* point at a port nothing listens on. A custom PUBLIC_URL (reverse proxy,
|
|
103
|
+
* remote domain) is the user's own and is never touched.
|
|
104
|
+
*/
|
|
105
|
+
export const derivePublicUrlOverride = (envContent, overrides) => {
|
|
106
|
+
const newPort = overrides.PORT;
|
|
107
|
+
if (!newPort)
|
|
108
|
+
return overrides;
|
|
109
|
+
const currentPort = readOptionalValue(envContent, "PORT") ?? String(DEFAULT_PORT);
|
|
110
|
+
const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL");
|
|
111
|
+
if (currentPublicUrl !== `http://localhost:${currentPort}`)
|
|
112
|
+
return overrides;
|
|
113
|
+
return { ...overrides, PUBLIC_URL: `http://localhost:${newPort}` };
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* The .env spellings the server reads as "off" — env-var's asBool accepts
|
|
117
|
+
* 0/1 alongside true/false. An absent or unrecognized value falls to the
|
|
118
|
+
* server default, which is enabled for every curated toggle.
|
|
119
|
+
*/
|
|
120
|
+
const isDisabledToggleValue = (value) => ["false", "0"].includes((value ?? "").toLowerCase());
|
|
121
|
+
/**
|
|
122
|
+
* Plain digits in the TCP port range. Number() coercion is not enough:
|
|
123
|
+
* it accepts "1e4"/"0x1F40"/"+9000", which readEnvPort's /^PORT=(\d+)/
|
|
124
|
+
* would later fail to read back — silently falling to the default port.
|
|
125
|
+
*/
|
|
126
|
+
const isValidPort = (value) => {
|
|
127
|
+
if (!/^\d+$/.test(value))
|
|
128
|
+
return false;
|
|
129
|
+
const port = Number(value);
|
|
130
|
+
return port >= 1 && port <= 65535;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* The engine's own IANA zone validation: Intl.DateTimeFormat throws a
|
|
134
|
+
* RangeError for an unknown timeZone, so no hand-rolled zone list to drift.
|
|
135
|
+
*/
|
|
136
|
+
const isValidTimezone = (value) => {
|
|
137
|
+
try {
|
|
138
|
+
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
/** Re-prompts until the answer is a valid port number. */
|
|
146
|
+
const askPort = async (currentValue, prompts) => {
|
|
147
|
+
const answer = (await prompts.text("Host port for the server:", {
|
|
148
|
+
defaultValue: currentValue ?? String(DEFAULT_PORT),
|
|
149
|
+
placeholder: String(DEFAULT_PORT),
|
|
150
|
+
})).trim();
|
|
151
|
+
if (isValidPort(answer))
|
|
152
|
+
return answer;
|
|
153
|
+
prompts.error("PORT must be a whole number between 1 and 65535.");
|
|
154
|
+
return askPort(currentValue, prompts);
|
|
155
|
+
};
|
|
156
|
+
/** Re-prompts until the answer is a zone the runtime recognizes. */
|
|
157
|
+
const askTimezone = async (currentValue, prompts) => {
|
|
158
|
+
const answer = (await prompts.text("Your IANA timezone:", {
|
|
159
|
+
defaultValue: currentValue,
|
|
160
|
+
placeholder: "America/New_York",
|
|
161
|
+
})).trim();
|
|
162
|
+
if (answer !== "" && isValidTimezone(answer))
|
|
163
|
+
return answer;
|
|
164
|
+
prompts.error(`"${answer}" is not a recognized IANA timezone (e.g. America/New_York, Europe/London).`);
|
|
165
|
+
return askTimezone(currentValue, prompts);
|
|
166
|
+
};
|
|
167
|
+
/** Re-prompts until the answer is a non-empty folder name. */
|
|
168
|
+
const askFolder = async (params, prompts) => {
|
|
169
|
+
const { question, currentValue, defaultValue } = params;
|
|
170
|
+
const answer = (await prompts.text(question, {
|
|
171
|
+
defaultValue: currentValue ?? defaultValue,
|
|
172
|
+
placeholder: defaultValue,
|
|
173
|
+
})).trim();
|
|
174
|
+
if (answer !== "")
|
|
175
|
+
return answer;
|
|
176
|
+
prompts.error("The folder name can't be empty.");
|
|
177
|
+
return askFolder(params, prompts);
|
|
178
|
+
};
|
|
179
|
+
/** Routes a picked setting to its kind's prompt and returns the .env value. */
|
|
180
|
+
const askSettingValue = async (params, prompts) => {
|
|
181
|
+
const { setting, currentValue } = params;
|
|
182
|
+
switch (setting.kind) {
|
|
183
|
+
case "toggle": {
|
|
184
|
+
const enabled = await prompts.confirm(setting.question, !isDisabledToggleValue(currentValue));
|
|
185
|
+
return String(enabled);
|
|
186
|
+
}
|
|
187
|
+
case "port":
|
|
188
|
+
return askPort(currentValue, prompts);
|
|
189
|
+
case "timezone":
|
|
190
|
+
return askTimezone(currentValue, prompts);
|
|
191
|
+
case "folder":
|
|
192
|
+
return askFolder({
|
|
193
|
+
question: setting.question,
|
|
194
|
+
currentValue,
|
|
195
|
+
defaultValue: setting.defaultValue,
|
|
196
|
+
}, prompts);
|
|
197
|
+
case "choice":
|
|
198
|
+
return prompts.select(setting.question, setting.choices, currentValue ?? setting.defaultValue);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* The guided optional-settings flow shared by init and configure: one
|
|
203
|
+
* multiselect chooser (enter with nothing picked = change nothing), then one
|
|
204
|
+
* prompt per picked setting. Returns the chosen values keyed by var name —
|
|
205
|
+
* the caller owns applying them via applyOptionalSettings.
|
|
206
|
+
*/
|
|
207
|
+
export const askOptionalSettings = async (params, prompts) => {
|
|
208
|
+
const { mode, envContent } = params;
|
|
209
|
+
const offeredSettings = OPTIONAL_SETTINGS.filter((setting) => !setting.remoteOnly || mode === "remote");
|
|
210
|
+
const chooserOptions = offeredSettings.map((setting) => {
|
|
211
|
+
const currentValue = readOptionalValue(envContent, setting.name);
|
|
212
|
+
const requiredToggle = OPTIONAL_SETTINGS.find((candidate) => candidate.name === setting.requiresToggle);
|
|
213
|
+
const dependencyNote = requiredToggle &&
|
|
214
|
+
isDisabledToggleValue(readOptionalValue(envContent, requiredToggle.name))
|
|
215
|
+
? ` · not used while ${requiredToggle.label} is off`
|
|
216
|
+
: "";
|
|
217
|
+
return {
|
|
218
|
+
value: setting.name,
|
|
219
|
+
label: setting.label,
|
|
220
|
+
hint: `${setting.name} · currently ${currentValue || "not set"}${dependencyNote}`,
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
const pickedNames = await prompts.multiselect("Any optional settings to change? (press enter to skip)", chooserOptions);
|
|
224
|
+
// Sequential prompting: answers are gathered one at a time in the curated
|
|
225
|
+
// order, so the record builds up inside an honest loop.
|
|
226
|
+
const overrides = {};
|
|
227
|
+
for (const setting of offeredSettings) {
|
|
228
|
+
if (!pickedNames.includes(setting.name))
|
|
229
|
+
continue;
|
|
230
|
+
overrides[setting.name] = await askSettingValue({ setting, currentValue: readOptionalValue(envContent, setting.name) }, prompts);
|
|
231
|
+
}
|
|
232
|
+
return overrides;
|
|
233
|
+
};
|
package/dist/program.js
CHANGED
|
@@ -15,6 +15,13 @@ export const buildProgram = (options) => {
|
|
|
15
15
|
.action(async (flags) => {
|
|
16
16
|
process.exitCode = await options.runInit(flags);
|
|
17
17
|
});
|
|
18
|
+
program
|
|
19
|
+
.command("configure")
|
|
20
|
+
.description("Change optional settings interactively and restart to apply them")
|
|
21
|
+
.option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
|
|
22
|
+
.action(async (flags) => {
|
|
23
|
+
process.exitCode = await options.runConfigure(flags);
|
|
24
|
+
});
|
|
18
25
|
program
|
|
19
26
|
.command("upgrade")
|
|
20
27
|
.description("Pull the latest image, re-create the container, and verify health")
|
package/dist/prompts.js
CHANGED
|
@@ -28,6 +28,9 @@ export const createPrompts = () => ({
|
|
|
28
28
|
warn: (message) => clack.log.warn(message),
|
|
29
29
|
error: (message) => clack.log.error(message),
|
|
30
30
|
select: async (message, options, initialValue) => exitOnCancel(await clack.select({ message, options, initialValue })),
|
|
31
|
+
// required: false makes an empty submission legal — the Prompts contract
|
|
32
|
+
// promises "no picks" resolves to [] instead of a re-prompt loop.
|
|
33
|
+
multiselect: async (message, options) => exitOnCancel(await clack.multiselect({ message, options, required: false })),
|
|
31
34
|
text: async (message, options = {}) => exitOnCancel(await clack.text({
|
|
32
35
|
message,
|
|
33
36
|
placeholder: options.placeholder,
|
package/dist/scaffold.js
CHANGED
|
@@ -8,6 +8,8 @@ const ENV_PORT_LINE = /^PORT=(\d+)\s*$/m;
|
|
|
8
8
|
const ENV_VAULT_PATH_LINE = /^VAULT_PATH=(.+)\s*$/m;
|
|
9
9
|
/** Matches an active (uncommented) PUBLIC_URL line. */
|
|
10
10
|
const ENV_PUBLIC_URL_LINE = /^PUBLIC_URL=/m;
|
|
11
|
+
/** Matches an active (uncommented) PUBLIC_URL line, capturing a non-empty value. */
|
|
12
|
+
const ENV_PUBLIC_URL_VALUE_LINE = /^PUBLIC_URL=(.+)\s*$/m;
|
|
11
13
|
/** Matches an active (uncommented) OBSIDIAN_AUTH_TOKEN line. */
|
|
12
14
|
const OBSIDIAN_AUTH_TOKEN_LINE = /^OBSIDIAN_AUTH_TOKEN=/m;
|
|
13
15
|
export const buildFilesToWrite = (envContent) => [
|
|
@@ -46,6 +48,21 @@ export const hasEnvPublicUrl = (envFilePath) => {
|
|
|
46
48
|
return false;
|
|
47
49
|
return ENV_PUBLIC_URL_LINE.test(readFileSync(envFilePath, "utf8"));
|
|
48
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* Reads the public URL value from a .env file. Returns undefined when the
|
|
53
|
+
* file is missing or has no uncommented, non-empty PUBLIC_URL line —
|
|
54
|
+
* deliberately stricter than hasEnvPublicUrl, whose job is old-compose
|
|
55
|
+
* detection and so matches an empty `PUBLIC_URL=` line too.
|
|
56
|
+
*/
|
|
57
|
+
export const readEnvPublicUrl = (envFilePath) => {
|
|
58
|
+
if (!existsSync(envFilePath))
|
|
59
|
+
return undefined;
|
|
60
|
+
const match = ENV_PUBLIC_URL_VALUE_LINE.exec(readFileSync(envFilePath, "utf8"));
|
|
61
|
+
// A whitespace-only line matches the regex and trims to "" — normalize to
|
|
62
|
+
// undefined so the non-empty contract holds ("" is never a legitimate URL).
|
|
63
|
+
const publicUrlValue = match?.[1].trim();
|
|
64
|
+
return publicUrlValue || undefined;
|
|
65
|
+
};
|
|
49
66
|
/**
|
|
50
67
|
* Detects the deployment mode from a .env file. Remote mode requires
|
|
51
68
|
* OBSIDIAN_AUTH_TOKEN (absent from local). Returns undefined when the
|