vault-cortex 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -4
- package/dist/configure.js +64 -0
- package/dist/docker.js +41 -1
- package/dist/env.js +8 -10
- package/dist/init.js +30 -2
- package/dist/lifecycle.js +166 -0
- package/dist/main.js +20 -0
- package/dist/messages.js +14 -7
- package/dist/optional-settings.js +233 -0
- package/dist/program.js +30 -0
- package/dist/prompts.js +3 -0
- package/dist/upgrade.js +14 -49
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,14 +15,20 @@ 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
|
|
28
|
+
- [`restart`](#restart) — re-create the container so your `.env` edits take
|
|
29
|
+
effect; no image pull
|
|
30
|
+
- [`logs`](#logs) — show the server's logs, live or after the fact
|
|
31
|
+
- [`down`](#down) — stop and remove the container; your data stays
|
|
26
32
|
- [`get-sync-token`](#get-sync-token) — generate an Obsidian Sync auth token
|
|
27
33
|
for remote setups
|
|
28
34
|
|
|
@@ -40,9 +46,14 @@ What it does:
|
|
|
40
46
|
- **Local** — Docker on this machine, your vault folder bind-mounted
|
|
41
47
|
- **Remote** — a VPS with [Obsidian Sync](https://obsidian.md/sync),
|
|
42
48
|
reachable from any device
|
|
43
|
-
2.
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
46
57
|
connect your client
|
|
47
58
|
|
|
48
59
|
Existing files are never overwritten without asking. During a remote setup,
|
|
@@ -63,6 +74,22 @@ Non-interactive example:
|
|
|
63
74
|
npx vault-cortex@latest init --yes --vault-path /path/to/YourVault
|
|
64
75
|
```
|
|
65
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
|
+
|
|
66
93
|
## upgrade
|
|
67
94
|
|
|
68
95
|
Pull the latest image, re-create the container, and verify health:
|
|
@@ -88,6 +115,55 @@ include Compose files you can use directly. If you set up with Compose, stick
|
|
|
88
115
|
with Compose for updates too (`docker compose pull && docker compose up -d`)
|
|
89
116
|
— the CLI and Compose manage the container independently.
|
|
90
117
|
|
|
118
|
+
## restart
|
|
119
|
+
|
|
120
|
+
Re-create the container from your `.env` and verify health:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx vault-cortex@latest restart
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Use it after editing `.env` — settings are only read when the container is
|
|
127
|
+
created, so a plain `docker restart` won't pick them up, but this will. Unlike
|
|
128
|
+
[`upgrade`](#upgrade), it never pulls a new image: you get the same server
|
|
129
|
+
version back, with your current settings applied.
|
|
130
|
+
|
|
131
|
+
Use `--dir <path>` if your config isn't in `./vault-cortex`.
|
|
132
|
+
|
|
133
|
+
## logs
|
|
134
|
+
|
|
135
|
+
Show the server's logs:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npx vault-cortex@latest logs
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Flags:
|
|
142
|
+
|
|
143
|
+
- `--follow` — keep streaming new output until you press ctrl-C
|
|
144
|
+
- `--since <time>` — only logs newer than this (e.g. `10m`, `2h`, or a
|
|
145
|
+
timestamp)
|
|
146
|
+
- `--dir <path>` — directory containing `.env` (default `./vault-cortex`)
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npx vault-cortex@latest logs --follow --since 10m
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## down
|
|
153
|
+
|
|
154
|
+
Stop and remove the container:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npx vault-cortex@latest down
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Safe by design: your vault, search index, and `.env` settings all live
|
|
161
|
+
outside the container, so nothing is lost. Start again any time with
|
|
162
|
+
[`restart`](#restart). Running `down` when nothing is running is fine — it
|
|
163
|
+
just tells you there's nothing to stop.
|
|
164
|
+
|
|
165
|
+
Use `--dir <path>` if your config isn't in `./vault-cortex`.
|
|
166
|
+
|
|
91
167
|
## get-sync-token
|
|
92
168
|
|
|
93
169
|
Generate an [Obsidian Sync](https://obsidian.md/sync) auth token — needed for
|
|
@@ -0,0 +1,64 @@
|
|
|
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 restart --dir "${targetDir}"`;
|
|
38
|
+
if (!docker.isDaemonRunning()) {
|
|
39
|
+
prompts.warn(`Container runtime not running — settings saved.\n${restartHint}`);
|
|
40
|
+
prompts.outro("Done.");
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
const restartNow = await prompts.confirm("Restart the container now to apply the new settings?", true);
|
|
44
|
+
if (!restartNow) {
|
|
45
|
+
prompts.log(restartHint);
|
|
46
|
+
prompts.outro("Done.");
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
// Resolve from disk after the write so the restart honors the new values
|
|
50
|
+
// (a changed PORT must drive the port mapping and health URL).
|
|
51
|
+
const deployment = resolveDeployment(flags.dir, prompts);
|
|
52
|
+
if (!deployment) {
|
|
53
|
+
// The edit already succeeded — don't let the failed restart read as a
|
|
54
|
+
// 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}"`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
|
|
59
|
+
if (exitCode !== 0)
|
|
60
|
+
return exitCode;
|
|
61
|
+
prompts.log("Applied the current .env settings.");
|
|
62
|
+
prompts.outro("Configure complete.");
|
|
63
|
+
return 0;
|
|
64
|
+
};
|
package/dist/docker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
export const LOCAL_IMAGE = "ghcr.io/aliasunder/vault-cortex:latest";
|
|
3
3
|
export const REMOTE_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote";
|
|
4
4
|
export const CONTAINER_NAME = "vault-cortex";
|
|
@@ -94,6 +94,19 @@ export const buildDockerRunArgs = (params) => {
|
|
|
94
94
|
args.push(image);
|
|
95
95
|
return args;
|
|
96
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* Builds the `docker logs` args array. Pure function — no I/O — so it's
|
|
99
|
+
* testable without spawning processes.
|
|
100
|
+
*/
|
|
101
|
+
export const buildDockerLogsArgs = (params) => {
|
|
102
|
+
const { follow, since } = params;
|
|
103
|
+
return [
|
|
104
|
+
"logs",
|
|
105
|
+
...(follow ? ["--follow"] : []),
|
|
106
|
+
...(since ? ["--since", since] : []),
|
|
107
|
+
CONTAINER_NAME,
|
|
108
|
+
];
|
|
109
|
+
};
|
|
97
110
|
export const createDockerRunner = () => ({
|
|
98
111
|
isDaemonRunning: () => spawnSync("docker", ["info"], { timeout: 5_000 }).status === 0,
|
|
99
112
|
// stdout is discarded: `docker run -d` prints only the container ID there,
|
|
@@ -108,6 +121,33 @@ export const createDockerRunner = () => ({
|
|
|
108
121
|
}).status === 0,
|
|
109
122
|
pullImage: (image) => spawnSync("docker", ["pull", image], { stdio: "inherit" }).status === 0,
|
|
110
123
|
stopAndRemoveContainer: () => spawnSync("docker", ["rm", "-f", CONTAINER_NAME]).status === 0,
|
|
124
|
+
// `docker rm -f` on a missing container exits 1 on engines < 23 and 0 on
|
|
125
|
+
// >= 23, so stopAndRemoveContainer's status can't distinguish "already
|
|
126
|
+
// gone" from "failed" — callers needing idempotent messaging probe
|
|
127
|
+
// existence first. Output stays piped (discarded): this is a boolean probe.
|
|
128
|
+
containerExists: () => spawnSync("docker", ["container", "inspect", CONTAINER_NAME]).status === 0,
|
|
129
|
+
// Async spawn, not spawnSync: --follow streams until interrupted, and the
|
|
130
|
+
// exit code must be observable after the stream closes.
|
|
131
|
+
streamLogs: (params) => new Promise((resolveExitCode) => {
|
|
132
|
+
const child = spawn("docker", buildDockerLogsArgs(params), {
|
|
133
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
134
|
+
});
|
|
135
|
+
// ctrl-C delivers SIGINT to the whole foreground process group. Node's
|
|
136
|
+
// default disposition would kill this process before the child's
|
|
137
|
+
// "close" event fires; this no-op keep-alive lets the docker child
|
|
138
|
+
// exit on its own SIGINT, the streams flush, and the exit code
|
|
139
|
+
// propagate. `once` self-removes, so later ctrl-Cs behave normally.
|
|
140
|
+
process.once("SIGINT", () => { });
|
|
141
|
+
child.once("error", (spawnError) => {
|
|
142
|
+
// Event handler, not a catch — but the same "never swallow" rule
|
|
143
|
+
// applies: without this line a spawn failure is a bare exit 1.
|
|
144
|
+
process.stderr.write(`vault-cortex: could not run docker logs — ${spawnError.message}\n`);
|
|
145
|
+
resolveExitCode(1);
|
|
146
|
+
});
|
|
147
|
+
// A null code means the child died to a signal — report the shell
|
|
148
|
+
// convention for ctrl-C (128 + SIGINT = 130).
|
|
149
|
+
child.once("close", (code) => resolveExitCode(code ?? 130));
|
|
150
|
+
}),
|
|
111
151
|
runObsidianLogin: (configMountPath) => spawnSync("docker", buildObsidianLoginArgs({
|
|
112
152
|
configMountPath,
|
|
113
153
|
uid: process.getuid?.(),
|
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
|
-
# re-read
|
|
13
|
+
# To change a setting: run "npx vault-cortex configure", or edit its
|
|
14
|
+
# value here (uncommenting it first if needed) and apply with
|
|
15
|
+
# "npx vault-cortex restart" (plain docker restart does not re-read
|
|
16
|
+
# this file).
|
|
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
|
-
# re-read
|
|
104
|
+
# To change a setting: run "npx vault-cortex configure", or edit its
|
|
105
|
+
# value here (uncommenting it first if needed) and apply with
|
|
106
|
+
# "npx vault-cortex restart" (plain docker restart does not re-read
|
|
107
|
+
# this file).
|
|
106
108
|
|
|
107
109
|
# Your IANA timezone — affects daily note resolution and memory timestamps.
|
|
108
110
|
# TZ=America/New_York
|
|
@@ -204,10 +206,6 @@ MCP_AUTH_TOKEN=${answers.mcpAuthToken}
|
|
|
204
206
|
# Absolute path to your Obsidian vault on this machine.
|
|
205
207
|
VAULT_PATH=${answers.vaultPath}
|
|
206
208
|
|
|
207
|
-
# Public URL for OAuth issuer URL in discovery metadata.
|
|
208
|
-
# Override if you expose the server on a different URL (e.g. via a reverse proxy).
|
|
209
|
-
PUBLIC_URL=http://localhost:8000
|
|
210
|
-
|
|
211
209
|
${LOCAL_OPTIONAL_BLOCK}`;
|
|
212
210
|
export const buildRemoteEnv = (answers) => {
|
|
213
211
|
const vaultPasswordLines = answers.vaultPassword === undefined
|
package/dist/init.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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
5
|
import { buildLocalConnectMessage, buildRemoteConnectMessage, } from "./messages.js";
|
|
5
6
|
import { pollHealth } from "./docker.js";
|
|
7
|
+
import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, } from "./optional-settings.js";
|
|
6
8
|
import { buildFilesToWrite, readEnvPort, writeFiles, } from "./scaffold.js";
|
|
7
9
|
import { generateToken } from "./token.js";
|
|
8
10
|
import { expandTilde, validateVaultPath } from "./vault.js";
|
|
@@ -203,10 +205,25 @@ const runLocalInit = async (flags, deps) => {
|
|
|
203
205
|
placeholder: DEFAULT_TARGET_DIR,
|
|
204
206
|
}))));
|
|
205
207
|
const token = generateToken();
|
|
208
|
+
// Guided optional settings: the chooser reads current values from the
|
|
209
|
+
// generated defaults; enter with nothing picked keeps them all. --yes
|
|
210
|
+
// skips the chooser (non-interactive by contract), and so does an existing
|
|
211
|
+
// .env — the conflict prompt defaults to keeping it, which would discard
|
|
212
|
+
// the answers; settings on an existing deployment are configure's job.
|
|
213
|
+
const defaultEnvContent = buildLocalEnv({ mcpAuthToken: token, vaultPath });
|
|
214
|
+
const envAlreadyExists = existsSync(join(targetDir, ".env"));
|
|
215
|
+
const offerSettingsChooser = !flags.yes && !envAlreadyExists;
|
|
216
|
+
if (!flags.yes && envAlreadyExists) {
|
|
217
|
+
prompts.log('Found an existing .env — settings prompts skipped. Adjust settings with "npx vault-cortex configure".');
|
|
218
|
+
}
|
|
219
|
+
const optionalOverrides = offerSettingsChooser
|
|
220
|
+
? await askOptionalSettings({ mode: "local", envContent: defaultEnvContent }, prompts)
|
|
221
|
+
: {};
|
|
222
|
+
const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
|
|
206
223
|
// Conflict policy: identical existing files are skipped silently;
|
|
207
224
|
// differing ones prompt per file (default keep). --yes never overwrites —
|
|
208
225
|
// any differing file becomes an exit-1 below, leaving it untouched.
|
|
209
|
-
const files = buildFilesToWrite(
|
|
226
|
+
const files = buildFilesToWrite(envContent);
|
|
210
227
|
const resolveConflict = flags.yes ? keepExisting : confirmOverwrite(prompts);
|
|
211
228
|
const results = await writeFiles({ targetDir, files }, resolveConflict);
|
|
212
229
|
reportWrites({ targetDir, results }, prompts);
|
|
@@ -262,13 +279,24 @@ const runRemoteInit = async (flags, deps) => {
|
|
|
262
279
|
? await prompts.password("Vault encryption password:")
|
|
263
280
|
: undefined;
|
|
264
281
|
const token = generateToken();
|
|
265
|
-
|
|
282
|
+
// Guided optional settings, mirroring the local flow — remote also offers
|
|
283
|
+
// SYNC_MODE. Remote init is always interactive (no --yes), so only an
|
|
284
|
+
// existing .env skips the chooser here.
|
|
285
|
+
const defaultEnvContent = buildRemoteEnv({
|
|
266
286
|
mcpAuthToken: token,
|
|
267
287
|
publicUrl,
|
|
268
288
|
obsidianAuthToken,
|
|
269
289
|
vaultName,
|
|
270
290
|
vaultPassword,
|
|
271
291
|
});
|
|
292
|
+
const envAlreadyExists = existsSync(join(targetDir, ".env"));
|
|
293
|
+
if (envAlreadyExists) {
|
|
294
|
+
prompts.log('Found an existing .env — settings prompts skipped. Adjust settings with "npx vault-cortex configure".');
|
|
295
|
+
}
|
|
296
|
+
const optionalOverrides = envAlreadyExists
|
|
297
|
+
? {}
|
|
298
|
+
: await askOptionalSettings({ mode: "remote", envContent: defaultEnvContent }, prompts);
|
|
299
|
+
const envContent = applyOptionalSettings(defaultEnvContent, derivePublicUrlOverride(defaultEnvContent, optionalOverrides));
|
|
272
300
|
const files = buildFilesToWrite(envContent);
|
|
273
301
|
const results = await writeFiles({ targetDir, files }, confirmOverwrite(prompts));
|
|
274
302
|
reportWrites({ targetDir, results }, prompts);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { CONTAINER_NAME, pollHealth } from "./docker.js";
|
|
3
|
+
import { detectMode, hasEnvPublicUrl, readEnvPort, readEnvVaultPath, } from "./scaffold.js";
|
|
4
|
+
import { expandTilde } from "./vault.js";
|
|
5
|
+
const DEFAULT_TARGET_DIR = "./vault-cortex";
|
|
6
|
+
/**
|
|
7
|
+
* Light gate for commands that operate on an existing deployment without
|
|
8
|
+
* starting a container: resolves --dir and confirms an init'd .env exists.
|
|
9
|
+
* Full start-readiness validation lives in resolveDeployment.
|
|
10
|
+
*/
|
|
11
|
+
export const requireInitializedDir = (dirFlag, prompts) => {
|
|
12
|
+
const targetDir = resolve(expandTilde(dirFlag ?? DEFAULT_TARGET_DIR));
|
|
13
|
+
const envFilePath = join(targetDir, ".env");
|
|
14
|
+
const mode = detectMode(envFilePath);
|
|
15
|
+
if (!mode) {
|
|
16
|
+
prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex init\` first.`);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return { targetDir, envFilePath, mode };
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Resolves --dir to the on-disk deployment and validates its .env for a
|
|
23
|
+
* container start: mode detection, VAULT_PATH (local), and PUBLIC_URL
|
|
24
|
+
* (local). Reports each failure via prompts.error and returns undefined.
|
|
25
|
+
* Shared by upgrade and restart so their precondition checks can't drift.
|
|
26
|
+
*/
|
|
27
|
+
export const resolveDeployment = (dirFlag, prompts) => {
|
|
28
|
+
const initialized = requireInitializedDir(dirFlag, prompts);
|
|
29
|
+
if (!initialized)
|
|
30
|
+
return undefined;
|
|
31
|
+
const { targetDir, envFilePath, mode } = initialized;
|
|
32
|
+
const port = readEnvPort(envFilePath);
|
|
33
|
+
const vaultPath = mode === "local" ? readEnvVaultPath(envFilePath) : undefined;
|
|
34
|
+
if (mode === "local" && !vaultPath) {
|
|
35
|
+
prompts.error(`VAULT_PATH is empty or missing in ${targetDir}/.env — cannot start the container.`);
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (mode === "local" && !hasEnvPublicUrl(envFilePath)) {
|
|
39
|
+
prompts.error(`PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` +
|
|
40
|
+
`Add this line to your .env:\n PUBLIC_URL=http://localhost:${port}`);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return { mode, targetDir, envFilePath, port, vaultPath };
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Verifies the container runtime is reachable, reporting the shared error
|
|
47
|
+
* message when it isn't. Callers early-return on false.
|
|
48
|
+
*/
|
|
49
|
+
export const ensureDaemonRunning = (docker, prompts) => {
|
|
50
|
+
if (docker.isDaemonRunning())
|
|
51
|
+
return true;
|
|
52
|
+
prompts.error("Container runtime not running — start Docker Desktop, Colima,\n" +
|
|
53
|
+
"OrbStack, or another Docker-compatible runtime.");
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Stop-and-remove → docker run → health poll, with the shared messaging.
|
|
58
|
+
* Returns a process exit code. Callers own the daemon check and any
|
|
59
|
+
* image-pull step.
|
|
60
|
+
*/
|
|
61
|
+
export const recreateContainer = async (params, deps) => {
|
|
62
|
+
const { deployment, healthTimeoutMs } = params;
|
|
63
|
+
const { prompts, docker, fetchFn } = deps;
|
|
64
|
+
// `docker rm -f` on a missing container exits non-zero on engines < 23,
|
|
65
|
+
// so the bare return value can't distinguish "nothing to remove" from a
|
|
66
|
+
// real failure — only treat removal as failed when a container exists.
|
|
67
|
+
// Proceeding after a genuine failure would surface as a confusing
|
|
68
|
+
// "container name already in use" from docker run.
|
|
69
|
+
if (docker.containerExists() && !docker.stopAndRemoveContainer()) {
|
|
70
|
+
prompts.error(`Could not remove the existing container — check: docker rm -f ${CONTAINER_NAME}`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
prompts.log("Starting container...");
|
|
74
|
+
const containerStarted = docker.dockerRun({
|
|
75
|
+
mode: deployment.mode,
|
|
76
|
+
envFilePath: deployment.envFilePath,
|
|
77
|
+
port: deployment.port,
|
|
78
|
+
vaultPath: deployment.vaultPath,
|
|
79
|
+
});
|
|
80
|
+
if (!containerStarted) {
|
|
81
|
+
prompts.error("docker run failed — see output above.");
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
const spinner = prompts.spinner();
|
|
85
|
+
spinner.start("Waiting for the server to come up");
|
|
86
|
+
const healthy = await pollHealth({
|
|
87
|
+
url: `http://127.0.0.1:${deployment.port}/healthz`,
|
|
88
|
+
timeoutMs: healthTimeoutMs,
|
|
89
|
+
}, fetchFn);
|
|
90
|
+
if (!healthy) {
|
|
91
|
+
spinner.stop(`Server did not respond within 2 minutes — check: docker logs ${CONTAINER_NAME}`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
spinner.stop("Server is up — health check passed.");
|
|
95
|
+
return 0;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Stops and removes the container. Vault data, the search index, and .env
|
|
99
|
+
* settings all live outside the container (bind mount, named volumes, host
|
|
100
|
+
* file), so this is always safe.
|
|
101
|
+
*/
|
|
102
|
+
export const runDown = async (flags, deps) => {
|
|
103
|
+
const { prompts, docker } = deps;
|
|
104
|
+
prompts.intro("vault-cortex down");
|
|
105
|
+
// Teardown only needs to confirm this is an init'd directory — the full
|
|
106
|
+
// .env validation (VAULT_PATH, PUBLIC_URL) guards container starts.
|
|
107
|
+
const initialized = requireInitializedDir(flags.dir, prompts);
|
|
108
|
+
if (!initialized)
|
|
109
|
+
return 1;
|
|
110
|
+
if (!ensureDaemonRunning(docker, prompts))
|
|
111
|
+
return 1;
|
|
112
|
+
if (!docker.containerExists()) {
|
|
113
|
+
prompts.log("No vault-cortex container found — nothing to stop.");
|
|
114
|
+
prompts.outro("Done.");
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
if (!docker.stopAndRemoveContainer()) {
|
|
118
|
+
prompts.error(`Could not remove the container — check: docker rm -f ${CONTAINER_NAME}`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
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}"`);
|
|
123
|
+
return 0;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Streams `docker logs` for the vault-cortex container. The stream's exit
|
|
127
|
+
* code passes through as the command's exit code; no outro follows the raw
|
|
128
|
+
* docker output.
|
|
129
|
+
*/
|
|
130
|
+
export const runLogs = async (flags, deps) => {
|
|
131
|
+
const { prompts, docker } = deps;
|
|
132
|
+
prompts.intro("vault-cortex logs");
|
|
133
|
+
const initialized = requireInitializedDir(flags.dir, prompts);
|
|
134
|
+
if (!initialized)
|
|
135
|
+
return 1;
|
|
136
|
+
if (!ensureDaemonRunning(docker, prompts))
|
|
137
|
+
return 1;
|
|
138
|
+
if (!docker.containerExists()) {
|
|
139
|
+
prompts.error("No vault-cortex container — start it with `npx vault-cortex restart`.");
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
return await docker.streamLogs({
|
|
143
|
+
follow: Boolean(flags.follow),
|
|
144
|
+
since: flags.since,
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Re-creates the container from the .env on disk and verifies health.
|
|
149
|
+
* Unlike `docker restart`, this applies .env edits (the env-file is only
|
|
150
|
+
* read at container creation); unlike upgrade, it never pulls an image.
|
|
151
|
+
*/
|
|
152
|
+
export const runRestart = async (flags, deps) => {
|
|
153
|
+
const { prompts, docker, fetchFn } = deps;
|
|
154
|
+
prompts.intro("vault-cortex restart");
|
|
155
|
+
const deployment = resolveDeployment(flags.dir, prompts);
|
|
156
|
+
if (!deployment)
|
|
157
|
+
return 1;
|
|
158
|
+
if (!ensureDaemonRunning(docker, prompts))
|
|
159
|
+
return 1;
|
|
160
|
+
const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
|
|
161
|
+
if (exitCode !== 0)
|
|
162
|
+
return exitCode;
|
|
163
|
+
prompts.log("Applied the current .env settings.");
|
|
164
|
+
prompts.outro("Restart complete.");
|
|
165
|
+
return 0;
|
|
166
|
+
};
|
package/dist/main.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { runDown, runLogs, runRestart } from "./lifecycle.js";
|
|
4
6
|
import { buildProgram } from "./program.js";
|
|
5
7
|
import { createPrompts } from "./prompts.js";
|
|
6
8
|
import { runUpgrade } from "./upgrade.js";
|
|
@@ -12,11 +14,29 @@ export const run = async (version) => {
|
|
|
12
14
|
docker: createDockerRunner(),
|
|
13
15
|
fetchFn: fetch,
|
|
14
16
|
}),
|
|
17
|
+
runConfigure: (flags) => runConfigure(flags, {
|
|
18
|
+
prompts: createPrompts(),
|
|
19
|
+
docker: createDockerRunner(),
|
|
20
|
+
fetchFn: fetch,
|
|
21
|
+
}),
|
|
15
22
|
runUpgrade: (flags) => runUpgrade(flags, {
|
|
16
23
|
prompts: createPrompts(),
|
|
17
24
|
docker: createDockerRunner(),
|
|
18
25
|
fetchFn: fetch,
|
|
19
26
|
}),
|
|
27
|
+
runRestart: (flags) => runRestart(flags, {
|
|
28
|
+
prompts: createPrompts(),
|
|
29
|
+
docker: createDockerRunner(),
|
|
30
|
+
fetchFn: fetch,
|
|
31
|
+
}),
|
|
32
|
+
runLogs: (flags) => runLogs(flags, {
|
|
33
|
+
prompts: createPrompts(),
|
|
34
|
+
docker: createDockerRunner(),
|
|
35
|
+
}),
|
|
36
|
+
runDown: (flags) => runDown(flags, {
|
|
37
|
+
prompts: createPrompts(),
|
|
38
|
+
docker: createDockerRunner(),
|
|
39
|
+
}),
|
|
20
40
|
runGetSyncToken: (flags) => runGetSyncToken(flags, {
|
|
21
41
|
prompts: createPrompts(),
|
|
22
42
|
docker: createDockerRunner(),
|
package/dist/messages.js
CHANGED
|
@@ -110,9 +110,13 @@ ${smokeTest(`${baseUrl}/healthz`)}
|
|
|
110
110
|
|
|
111
111
|
${sectionRule("Settings")}
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
Adjust optional settings (memory layer and folder, file tools,
|
|
114
|
+
semantic search, port, timezone):
|
|
115
|
+
npx vault-cortex configure --dir "${targetDir}"
|
|
116
|
+
|
|
117
|
+
Or edit ${targetDir}/.env directly — change a value (uncommenting it
|
|
118
|
+
first if needed), then apply with "npx vault-cortex restart" (plain
|
|
119
|
+
docker restart does not re-read .env).
|
|
116
120
|
|
|
117
121
|
${updateGuidance(targetDir)}
|
|
118
122
|
|
|
@@ -171,10 +175,13 @@ ${smokeTest(`${publicUrl}/healthz`)}
|
|
|
171
175
|
|
|
172
176
|
${sectionRule("Settings")}
|
|
173
177
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
Adjust optional settings (memory layer and folder, file tools,
|
|
179
|
+
semantic search, port, timezone, sync direction):
|
|
180
|
+
npx vault-cortex configure --dir "${targetDir}"
|
|
181
|
+
|
|
182
|
+
Or edit ${targetDir}/.env directly — change a value (uncommenting it
|
|
183
|
+
first if needed), then apply with "npx vault-cortex restart" (plain
|
|
184
|
+
docker restart does not re-read .env).
|
|
178
185
|
|
|
179
186
|
${updateGuidance(targetDir)}
|
|
180
187
|
|
|
@@ -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")
|
|
@@ -22,6 +29,29 @@ export const buildProgram = (options) => {
|
|
|
22
29
|
.action(async (flags) => {
|
|
23
30
|
process.exitCode = await options.runUpgrade(flags);
|
|
24
31
|
});
|
|
32
|
+
program
|
|
33
|
+
.command("restart")
|
|
34
|
+
.description("Re-create the container from .env and verify health (applies .env edits; no image pull)")
|
|
35
|
+
.option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
|
|
36
|
+
.action(async (flags) => {
|
|
37
|
+
process.exitCode = await options.runRestart(flags);
|
|
38
|
+
});
|
|
39
|
+
program
|
|
40
|
+
.command("logs")
|
|
41
|
+
.description("Show container logs")
|
|
42
|
+
.option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
|
|
43
|
+
.option("--follow", "stream new log output until interrupted (ctrl-C)")
|
|
44
|
+
.option("--since <time>", 'only logs newer than this (e.g. "10m", "2h", or a timestamp)')
|
|
45
|
+
.action(async (flags) => {
|
|
46
|
+
process.exitCode = await options.runLogs(flags);
|
|
47
|
+
});
|
|
48
|
+
program
|
|
49
|
+
.command("down")
|
|
50
|
+
.description("Stop and remove the container — vault data and settings are preserved")
|
|
51
|
+
.option("--dir <path>", "directory containing .env (default: ./vault-cortex)")
|
|
52
|
+
.action(async (flags) => {
|
|
53
|
+
process.exitCode = await options.runDown(flags);
|
|
54
|
+
});
|
|
25
55
|
program
|
|
26
56
|
.command("get-sync-token")
|
|
27
57
|
.description("Generate an Obsidian Sync auth token via Docker and print it or write it to .env")
|
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/upgrade.js
CHANGED
|
@@ -1,35 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { LOCAL_IMAGE, REMOTE_IMAGE } from "./docker.js";
|
|
2
|
+
import { ensureDaemonRunning, recreateContainer, resolveDeployment, } from "./lifecycle.js";
|
|
3
|
+
/**
|
|
4
|
+
* Pulls the latest image, re-creates the container, and verifies health.
|
|
5
|
+
* The only lifecycle command that contacts the registry — restart re-creates
|
|
6
|
+
* from the image already on disk.
|
|
7
|
+
*/
|
|
6
8
|
export const runUpgrade = async (flags, deps) => {
|
|
7
9
|
const { prompts, docker, fetchFn } = deps;
|
|
8
10
|
prompts.intro("vault-cortex upgrade");
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
const mode = detectMode(envFilePath);
|
|
12
|
-
if (!mode) {
|
|
13
|
-
prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex init\` first.`);
|
|
11
|
+
const deployment = resolveDeployment(flags.dir, prompts);
|
|
12
|
+
if (!deployment)
|
|
14
13
|
return 1;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const vaultPath = mode === "local" ? readEnvVaultPath(envFilePath) : undefined;
|
|
18
|
-
if (mode === "local" && !vaultPath) {
|
|
19
|
-
prompts.error(`VAULT_PATH is empty or missing in ${targetDir}/.env — cannot start the container.`);
|
|
20
|
-
return 1;
|
|
21
|
-
}
|
|
22
|
-
if (mode === "local" && !hasEnvPublicUrl(envFilePath)) {
|
|
23
|
-
prompts.error(`PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` +
|
|
24
|
-
`Add this line to your .env:\n PUBLIC_URL=http://localhost:${port}`);
|
|
25
|
-
return 1;
|
|
26
|
-
}
|
|
27
|
-
const image = mode === "local" ? LOCAL_IMAGE : REMOTE_IMAGE;
|
|
28
|
-
if (!docker.isDaemonRunning()) {
|
|
29
|
-
prompts.error("Container runtime not running — start Docker Desktop, Colima,\n" +
|
|
30
|
-
"OrbStack, or another Docker-compatible runtime.");
|
|
14
|
+
const image = deployment.mode === "local" ? LOCAL_IMAGE : REMOTE_IMAGE;
|
|
15
|
+
if (!ensureDaemonRunning(docker, prompts))
|
|
31
16
|
return 1;
|
|
32
|
-
}
|
|
33
17
|
const spinner = prompts.spinner();
|
|
34
18
|
spinner.start(`Pulling ${image}`);
|
|
35
19
|
const imagePulled = docker.pullImage(image);
|
|
@@ -38,28 +22,9 @@ export const runUpgrade = async (flags, deps) => {
|
|
|
38
22
|
return 1;
|
|
39
23
|
}
|
|
40
24
|
spinner.stop("Image pulled.");
|
|
41
|
-
docker
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
mode,
|
|
45
|
-
envFilePath,
|
|
46
|
-
port,
|
|
47
|
-
vaultPath,
|
|
48
|
-
});
|
|
49
|
-
if (!containerStarted) {
|
|
50
|
-
prompts.error("docker run failed — see output above.");
|
|
51
|
-
return 1;
|
|
52
|
-
}
|
|
53
|
-
spinner.start("Waiting for the server to come up");
|
|
54
|
-
const healthy = await pollHealth({
|
|
55
|
-
url: `http://127.0.0.1:${port}/healthz`,
|
|
56
|
-
timeoutMs: deps.healthTimeoutMs,
|
|
57
|
-
}, fetchFn);
|
|
58
|
-
if (!healthy) {
|
|
59
|
-
spinner.stop(`Server did not respond within 2 minutes — check: docker logs ${CONTAINER_NAME}`);
|
|
60
|
-
return 1;
|
|
61
|
-
}
|
|
62
|
-
spinner.stop("Server is up — health check passed.");
|
|
25
|
+
const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
|
|
26
|
+
if (exitCode !== 0)
|
|
27
|
+
return exitCode;
|
|
63
28
|
prompts.log("Your vault data, search index, and settings are preserved.");
|
|
64
29
|
prompts.outro("Upgrade complete.");
|
|
65
30
|
return 0;
|