vault-cortex 0.7.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 +82 -5
- package/dist/configure.js +70 -0
- package/dist/docker.js +91 -13
- package/dist/env.js +9 -11
- package/dist/get-sync-token.js +8 -5
- package/dist/init.js +59 -11
- package/dist/lifecycle.js +204 -0
- package/dist/main.js +20 -0
- package/dist/messages.js +71 -12
- package/dist/optional-settings.js +233 -0
- package/dist/program.js +30 -0
- package/dist/prompts.js +3 -0
- package/dist/scaffold.js +17 -0
- package/dist/upgrade.js +14 -49
- package/package.json +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
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";
|
|
5
|
+
import { expandTilde } from "./vault.js";
|
|
6
|
+
const DEFAULT_TARGET_DIR = "./vault-cortex";
|
|
7
|
+
/**
|
|
8
|
+
* Light gate for commands that operate on an existing deployment without
|
|
9
|
+
* starting a container: resolves --dir and confirms an init'd .env exists.
|
|
10
|
+
* Full start-readiness validation lives in resolveDeployment.
|
|
11
|
+
*/
|
|
12
|
+
export const requireInitializedDir = (dirFlag, prompts) => {
|
|
13
|
+
const targetDir = resolve(expandTilde(dirFlag ?? DEFAULT_TARGET_DIR));
|
|
14
|
+
const envFilePath = join(targetDir, ".env");
|
|
15
|
+
const mode = detectMode(envFilePath);
|
|
16
|
+
if (!mode) {
|
|
17
|
+
prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex@latest init\` first.`);
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return { targetDir, envFilePath, mode };
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Resolves --dir to the on-disk deployment and validates its .env for a
|
|
24
|
+
* container start: mode detection, VAULT_PATH (local), and PUBLIC_URL
|
|
25
|
+
* (local). Reports each failure via prompts.error and returns undefined.
|
|
26
|
+
* Shared by upgrade and restart so their precondition checks can't drift.
|
|
27
|
+
*/
|
|
28
|
+
export const resolveDeployment = (dirFlag, prompts) => {
|
|
29
|
+
const initialized = requireInitializedDir(dirFlag, prompts);
|
|
30
|
+
if (!initialized)
|
|
31
|
+
return undefined;
|
|
32
|
+
const { targetDir, envFilePath, mode } = initialized;
|
|
33
|
+
const port = readEnvPort(envFilePath);
|
|
34
|
+
const vaultPath = mode === "local" ? readEnvVaultPath(envFilePath) : undefined;
|
|
35
|
+
if (mode === "local" && !vaultPath) {
|
|
36
|
+
prompts.error(`VAULT_PATH is empty or missing in ${targetDir}/.env — cannot start the container.`);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
if (mode === "local" && !hasEnvPublicUrl(envFilePath)) {
|
|
40
|
+
prompts.error(`PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` +
|
|
41
|
+
`Add this line to your .env:\n PUBLIC_URL=http://localhost:${port}`);
|
|
42
|
+
return undefined;
|
|
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 };
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Verifies the container runtime is reachable, reporting the shared error
|
|
51
|
+
* message when it isn't. Callers early-return on false.
|
|
52
|
+
*/
|
|
53
|
+
export const ensureDaemonRunning = (docker, prompts) => {
|
|
54
|
+
const daemonStatus = docker.daemonStatus();
|
|
55
|
+
if (daemonStatus === "running")
|
|
56
|
+
return true;
|
|
57
|
+
prompts.error(daemonStatus === "not-installed"
|
|
58
|
+
? buildDockerNotInstalledMessage({ nextStep: "" })
|
|
59
|
+
: buildDaemonNotRunningMessage("."));
|
|
60
|
+
return false;
|
|
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
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Stop-and-remove → docker run → health poll, with the shared messaging.
|
|
91
|
+
* Returns a process exit code. Callers own the daemon check and any
|
|
92
|
+
* image-pull step.
|
|
93
|
+
*/
|
|
94
|
+
export const recreateContainer = async (params, deps) => {
|
|
95
|
+
const { deployment, healthTimeoutMs } = params;
|
|
96
|
+
const { prompts, docker, fetchFn } = deps;
|
|
97
|
+
// `docker rm -f` on a missing container exits non-zero on engines < 23,
|
|
98
|
+
// so the bare return value can't distinguish "nothing to remove" from a
|
|
99
|
+
// real failure — only treat removal as failed when a container exists.
|
|
100
|
+
// Proceeding after a genuine failure would surface as a confusing
|
|
101
|
+
// "container name already in use" from docker run.
|
|
102
|
+
if (docker.containerExists() && !docker.stopAndRemoveContainer()) {
|
|
103
|
+
prompts.error(`Could not remove the existing container — check: docker rm -f ${CONTAINER_NAME}`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
prompts.log("Starting container...");
|
|
107
|
+
const containerStarted = docker.dockerRun({
|
|
108
|
+
mode: deployment.mode,
|
|
109
|
+
envFilePath: deployment.envFilePath,
|
|
110
|
+
port: deployment.port,
|
|
111
|
+
vaultPath: deployment.vaultPath,
|
|
112
|
+
});
|
|
113
|
+
if (!containerStarted) {
|
|
114
|
+
prompts.error("docker run failed — see output above.");
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const spinner = prompts.spinner();
|
|
118
|
+
spinner.start("Waiting for the server to come up");
|
|
119
|
+
const healthy = await pollHealth({
|
|
120
|
+
url: `http://127.0.0.1:${deployment.port}/healthz`,
|
|
121
|
+
timeoutMs: healthTimeoutMs,
|
|
122
|
+
}, fetchFn);
|
|
123
|
+
if (!healthy) {
|
|
124
|
+
spinner.stop(`Server did not respond within 2 minutes — check: docker logs ${CONTAINER_NAME}`);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
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
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Stops and removes the container. Vault data, the search index, and .env
|
|
137
|
+
* settings all live outside the container (bind mount, named volumes, host
|
|
138
|
+
* file), so this is always safe.
|
|
139
|
+
*/
|
|
140
|
+
export const runDown = async (flags, deps) => {
|
|
141
|
+
const { prompts, docker } = deps;
|
|
142
|
+
prompts.intro("vault-cortex down");
|
|
143
|
+
// Teardown only needs to confirm this is an init'd directory — the full
|
|
144
|
+
// .env validation (VAULT_PATH, PUBLIC_URL) guards container starts.
|
|
145
|
+
const initialized = requireInitializedDir(flags.dir, prompts);
|
|
146
|
+
if (!initialized)
|
|
147
|
+
return 1;
|
|
148
|
+
if (!ensureDaemonRunning(docker, prompts))
|
|
149
|
+
return 1;
|
|
150
|
+
if (!docker.containerExists()) {
|
|
151
|
+
prompts.log("No vault-cortex container found — nothing to stop.");
|
|
152
|
+
prompts.outro("Done.");
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
if (!docker.stopAndRemoveContainer()) {
|
|
156
|
+
prompts.error(`Could not remove the container — check: docker rm -f ${CONTAINER_NAME}`);
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
prompts.log("Container stopped and removed. Your vault data, search index, and settings are untouched.");
|
|
160
|
+
prompts.outro(`Start again with: npx vault-cortex@latest restart --dir "${initialized.targetDir}"`);
|
|
161
|
+
return 0;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Streams `docker logs` for the vault-cortex container. The stream's exit
|
|
165
|
+
* code passes through as the command's exit code; no outro follows the raw
|
|
166
|
+
* docker output.
|
|
167
|
+
*/
|
|
168
|
+
export const runLogs = async (flags, deps) => {
|
|
169
|
+
const { prompts, docker } = deps;
|
|
170
|
+
prompts.intro("vault-cortex logs");
|
|
171
|
+
const initialized = requireInitializedDir(flags.dir, prompts);
|
|
172
|
+
if (!initialized)
|
|
173
|
+
return 1;
|
|
174
|
+
if (!ensureDaemonRunning(docker, prompts))
|
|
175
|
+
return 1;
|
|
176
|
+
if (!docker.containerExists()) {
|
|
177
|
+
prompts.error("No vault-cortex container — start it with `npx vault-cortex@latest restart`.");
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
return await docker.streamLogs({
|
|
181
|
+
follow: Boolean(flags.follow),
|
|
182
|
+
since: flags.since,
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Re-creates the container from the .env on disk and verifies health.
|
|
187
|
+
* Unlike `docker restart`, this applies .env edits (the env-file is only
|
|
188
|
+
* read at container creation); unlike upgrade, it never pulls an image.
|
|
189
|
+
*/
|
|
190
|
+
export const runRestart = async (flags, deps) => {
|
|
191
|
+
const { prompts, docker, fetchFn } = deps;
|
|
192
|
+
prompts.intro("vault-cortex restart");
|
|
193
|
+
const deployment = resolveDeployment(flags.dir, prompts);
|
|
194
|
+
if (!deployment)
|
|
195
|
+
return 1;
|
|
196
|
+
if (!ensureDaemonRunning(docker, prompts))
|
|
197
|
+
return 1;
|
|
198
|
+
const exitCode = await recreateContainer({ deployment, healthTimeoutMs: deps.healthTimeoutMs }, { prompts, docker, fetchFn });
|
|
199
|
+
if (exitCode !== 0)
|
|
200
|
+
return exitCode;
|
|
201
|
+
prompts.log("Applied the current .env settings.");
|
|
202
|
+
prompts.outro("Restart complete.");
|
|
203
|
+
return 0;
|
|
204
|
+
};
|
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
|
@@ -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,15 +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
|
-
|
|
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).
|
|
116
172
|
|
|
117
173
|
${updateGuidance(targetDir)}
|
|
118
174
|
|
|
@@ -167,14 +223,17 @@ ${sectionRule("Non-OAuth")}
|
|
|
167
223
|
|
|
168
224
|
${curlGuidance(`${publicUrl}/mcp`)}
|
|
169
225
|
|
|
170
|
-
${
|
|
226
|
+
${remoteHealthCheckBlock(`${publicUrl}/healthz`, started)}
|
|
171
227
|
|
|
172
228
|
${sectionRule("Settings")}
|
|
173
229
|
|
|
174
|
-
|
|
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
|
|
236
|
+
docker restart does not re-read .env).
|
|
178
237
|
|
|
179
238
|
${updateGuidance(targetDir)}
|
|
180
239
|
|
|
@@ -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,
|