From e484adb22b9d7902412edeeeeaa492b37dc3a059 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:08:25 -0400 Subject: [PATCH 01/57] feat(cli): give each install its own container and volumes with INSTANCE_NAME Every CLI command now reads INSTANCE_NAME from the install's .env and acts only on the container and volumes that name gives it. Unset or empty means vault-cortex, so existing installs keep today's names and data. - docker run, rm, inspect and logs, the remote hostname, the volume prefixes, printed commands and connector names all use the name. - An invalid name stops every command before it touches Docker. - init notices a name already in use on the machine (its container, or any of its volumes) and asks for a new name and port, or confirms reuse for the same vault; --yes exits unless --instance-name settles it. - New init flags --instance-name and --port. - A re-run of init keeps the install's name, port and URL (and the remote device name) instead of resetting them to the defaults. - PORT is read from the last active line, as docker --env-file does. Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/command-stubs.ts | 51 ++- cli/src/__tests__/configure.test.ts | 21 + cli/src/__tests__/docker.test.ts | 131 +++++- cli/src/__tests__/init.test.ts | 404 +++++++++++++++++- cli/src/__tests__/instance-identity.test.ts | 45 ++ cli/src/__tests__/integration/cli-pty.test.ts | 61 ++- cli/src/__tests__/integration/fixtures/docker | 22 +- cli/src/__tests__/lifecycle.test.ts | 149 ++++++- cli/src/__tests__/messages.test.ts | 35 ++ cli/src/__tests__/scaffold.test.ts | 65 +++ cli/src/__tests__/upgrade.test.ts | 22 + cli/src/docker.ts | 98 +++-- cli/src/init.ts | 70 ++- cli/src/instance-identity.ts | 207 +++++++++ cli/src/lifecycle.ts | 48 ++- cli/src/messages.ts | 43 +- cli/src/optional-settings.ts | 7 +- cli/src/program.ts | 5 + cli/src/scaffold.ts | 61 ++- 19 files changed, 1430 insertions(+), 115 deletions(-) create mode 100644 cli/src/__tests__/instance-identity.test.ts create mode 100644 cli/src/instance-identity.ts diff --git a/cli/src/__tests__/command-stubs.ts b/cli/src/__tests__/command-stubs.ts index f51369655..5a83e14c6 100644 --- a/cli/src/__tests__/command-stubs.ts +++ b/cli/src/__tests__/command-stubs.ts @@ -1,4 +1,4 @@ -import type { DockerRunner } from "../docker.js" +import type { DockerLogsParams, DockerRunner, DockerRunParams } from "../docker.js" import type { Prompts, SelectOption } from "../prompts.js" /** @@ -168,9 +168,57 @@ export const dockerReady: DockerRunner = { pullImage: () => true, stopAndRemoveContainer: () => true, containerExists: () => true, + volumeExists: () => true, streamLogs: async () => 0, } +/** + * Daemon up on a host with no vault-cortex install yet: no container and no + * volume under any name, so init never finds a name in use. + */ +export const dockerReadyEmptyHost: DockerRunner = { + ...dockerReady, + containerExists: () => false, + volumeExists: () => false, +} + +/** + * A ready daemon whose container exists, recording the name every inspect, + * remove, run and logs call targets, in call order, as `:`. + */ +export const createRecordingDocker = (): { + docker: DockerRunner + calls: string[] + runParams: DockerRunParams[] + logsParams: DockerLogsParams[] +} => { + const calls: string[] = [] + const runParams: DockerRunParams[] = [] + const logsParams: DockerLogsParams[] = [] + const docker: DockerRunner = { + ...dockerReady, + containerExists: (instanceName) => { + calls.push(`inspect:${instanceName}`) + return true + }, + stopAndRemoveContainer: (instanceName) => { + calls.push(`rm:${instanceName}`) + return true + }, + dockerRun: (params) => { + calls.push(`run:${params.instanceName}`) + runParams.push(params) + return true + }, + streamLogs: async (params) => { + calls.push(`logs:${params.instanceName}`) + logsParams.push(params) + return 0 + }, + } + return { docker, calls, runParams, logsParams } +} + /** Daemon installed but not running — every operation fails. */ export const dockerDown: DockerRunner = { daemonStatus: () => "not-running", @@ -178,6 +226,7 @@ export const dockerDown: DockerRunner = { pullImage: () => false, stopAndRemoveContainer: () => false, containerExists: () => false, + volumeExists: () => false, streamLogs: async () => 1, } diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index c503e50ee..0c79f2530 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -163,6 +163,26 @@ describe("runConfigure with picked settings", () => { expect(scripted.outros).toEqual(["Done."]) }) + it("re-creates the container a custom INSTANCE_NAME names when the restart is accepted", async () => { + const targetDir = makeTempTargetDir() + const envFilePath = join(targetDir, ".env") + writeFileSync(envFilePath, `${LOCAL_ENV_CONTENT}INSTANCE_NAME=work\n`) + const { docker, dockerRunCalls } = createRecordingDocker() + const scripted = createScriptedPrompts([ + ["MEMORY_ENABLED"], + false, // disable the memory layer + true, // restart now + ]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(0) + expect(dockerRunCalls.map((params) => params.instanceName)).toEqual(["work"]) + }) + it("re-creates the container on the new PORT when the restart is accepted", async () => { const targetDir = makeTempTargetDir() const envFilePath = writeLocalEnv(targetDir) @@ -189,6 +209,7 @@ describe("runConfigure with picked settings", () => { expect(dockerRunCalls).toEqual([ { mode: "local", + instanceName: "vault-cortex", envFilePath, port: 9100, vaultPath: "/home/user/MyVault", diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index 57ecb2f92..b490937ad 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -4,7 +4,6 @@ import { buildDockerLogsArgs, buildDockerRunArgs, classifyDaemonStatus, - CONTAINER_NAME, healthPollTimeoutMs, healthTimeoutMessage, LOCAL_IMAGE, @@ -20,6 +19,7 @@ describe("buildDockerRunArgs", () => { it("produces the correct args for local mode", () => { const args = buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/home/user/vault-cortex/.env", port: 8000, vaultPath: "/home/user/MyVault", @@ -29,7 +29,7 @@ describe("buildDockerRunArgs", () => { "run", "-d", "--name", - CONTAINER_NAME, + "vault-cortex", "--restart", "unless-stopped", "--env-file", @@ -65,6 +65,7 @@ describe("buildDockerRunArgs", () => { it("produces the correct args for remote mode", () => { const args = buildDockerRunArgs({ mode: "remote", + instanceName: "vault-cortex", envFilePath: "/opt/vault-cortex/.env", port: 8000, }) @@ -73,7 +74,7 @@ describe("buildDockerRunArgs", () => { "run", "-d", "--name", - CONTAINER_NAME, + "vault-cortex", "--restart", "unless-stopped", "--env-file", @@ -89,7 +90,7 @@ describe("buildDockerRunArgs", () => { "-p", "8000:8000", "--hostname", - CONTAINER_NAME, + "vault-cortex", "-v", "vault-cortex_vault_data:/vault", "-v", @@ -119,6 +120,7 @@ describe("buildDockerRunArgs", () => { it("uses a custom port for the host-side mapping", () => { const args = buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 9000, vaultPath: "/vault", @@ -132,6 +134,7 @@ describe("buildDockerRunArgs", () => { it("overrides VAULT_PATH so the host path does not leak into the container", () => { const args = buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, vaultPath: "/Users/me/My Vault", @@ -146,6 +149,7 @@ describe("buildDockerRunArgs", () => { expect(() => buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, }), @@ -155,6 +159,7 @@ describe("buildDockerRunArgs", () => { it("uses compose-prefixed volume names for backward compatibility", () => { const localArgs = buildDockerRunArgs({ mode: "local", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, vaultPath: "/vault", @@ -163,6 +168,7 @@ describe("buildDockerRunArgs", () => { const remoteArgs = buildDockerRunArgs({ mode: "remote", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, }) @@ -171,9 +177,74 @@ describe("buildDockerRunArgs", () => { expect(remoteArgs).toContain("vault-cortex_obsidian_config:/home/obsidian/.config") }) + it("names the container and every volume after a custom instance in local mode", () => { + const args = buildDockerRunArgs({ + mode: "local", + instanceName: "work", + envFilePath: "/home/user/work/.env", + port: 8001, + vaultPath: "/home/user/WorkVault", + }) + + expect(args).toEqual([ + "run", + "-d", + "--name", + "work", + "--restart", + "unless-stopped", + "--env-file", + "/home/user/work/.env", + "-e", + "VAULT_PATH=/vault", + "-e", + "PORT=8000", + "-e", + "HOST=0.0.0.0", + "-e", + "INDEX_DB_PATH=/data/index.db", + "-p", + "8001:8000", + "-v", + "/home/user/WorkVault:/vault:rw", + "-v", + "work_mcp_data:/data", + "--health-cmd", + expect.stringContaining("healthz"), + "--health-interval", + "15s", + "--health-timeout", + "5s", + "--health-retries", + "3", + "--health-start-period", + "20s", + LOCAL_IMAGE, + ]) + }) + + it("names the container, hostname and every volume after a custom instance in remote mode", () => { + const args = buildDockerRunArgs({ + mode: "remote", + instanceName: "work", + envFilePath: "/opt/work/.env", + port: 8001, + }) + + const volumeArgs = args.filter((_arg, index) => args[index - 1] === "-v") + expect(args.slice(0, 4)).toEqual(["run", "-d", "--name", "work"]) + expect(args[args.indexOf("--hostname") + 1]).toBe("work") + expect(volumeArgs).toEqual([ + "work_vault_data:/vault", + "work_mcp_data:/data", + "work_obsidian_config:/home/obsidian/.config", + ]) + }) + it("includes remote-specific log rotation and longer healthcheck timings", () => { const args = buildDockerRunArgs({ mode: "remote", + instanceName: "vault-cortex", envFilePath: "/tmp/.env", port: 8000, }) @@ -202,18 +273,28 @@ describe("healthPollTimeoutMs", () => { describe("healthTimeoutMessage", () => { it("renders the local budget as 2 minutes", () => { - expect(healthTimeoutMessage("local", 120_000)).toBe( - "Server did not respond within 2 minutes — check: docker logs vault-cortex", - ) + expect( + healthTimeoutMessage({ mode: "local", timeoutMs: 120_000, instanceName: "vault-cortex" }), + ).toBe("Server did not respond within 2 minutes — check: docker logs vault-cortex") }) it("adds the first-sync hint to the remote 4-minute message", () => { - expect(healthTimeoutMessage("remote", 240_000)).toBe( + expect( + healthTimeoutMessage({ mode: "remote", timeoutMs: 240_000, instanceName: "vault-cortex" }), + ).toBe( "Server did not respond within 4 minutes — check: docker logs vault-cortex (a long first sync may still be running — the container keeps starting in the background)", ) }) }) +describe("healthTimeoutMessage with a custom instance", () => { + it("points the logs command at the instance's container", () => { + expect(healthTimeoutMessage({ mode: "local", timeoutMs: 120_000, instanceName: "work" })).toBe( + "Server did not respond within 2 minutes — check: docker logs work", + ) + }) +}) + describe("pollHealth", () => { it("returns true as soon as the endpoint responds ok", async () => { const fetchStub = async (): Promise => okResponse @@ -268,29 +349,39 @@ describe("pollHealth", () => { describe("buildDockerLogsArgs", () => { it("targets the container with no flags by default", () => { - expect(buildDockerLogsArgs({ follow: false })).toEqual(["logs", CONTAINER_NAME]) + expect(buildDockerLogsArgs({ instanceName: "vault-cortex", follow: false })).toEqual([ + "logs", + "vault-cortex", + ]) }) it("adds --follow when requested", () => { - expect(buildDockerLogsArgs({ follow: true })).toEqual(["logs", "--follow", CONTAINER_NAME]) + expect(buildDockerLogsArgs({ instanceName: "vault-cortex", follow: true })).toEqual([ + "logs", + "--follow", + "vault-cortex", + ]) }) it("adds --since with its value when provided", () => { - expect(buildDockerLogsArgs({ follow: false, since: "10m" })).toEqual([ - "logs", - "--since", - "10m", - CONTAINER_NAME, - ]) + expect( + buildDockerLogsArgs({ instanceName: "vault-cortex", follow: false, since: "10m" }), + ).toEqual(["logs", "--since", "10m", "vault-cortex"]) }) it("combines --follow and --since in flag order", () => { - expect(buildDockerLogsArgs({ follow: true, since: "2h" })).toEqual([ + expect( + buildDockerLogsArgs({ instanceName: "vault-cortex", follow: true, since: "2h" }), + ).toEqual(["logs", "--follow", "--since", "2h", "vault-cortex"]) + }) +}) + +describe("buildDockerLogsArgs with a custom instance", () => { + it("targets the instance's container", () => { + expect(buildDockerLogsArgs({ instanceName: "work", follow: true })).toEqual([ "logs", "--follow", - "--since", - "2h", - CONTAINER_NAME, + "work", ]) }) }) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 924598fe1..186a297b5 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -4,8 +4,9 @@ import { join } from "node:path" import { describe, expect, it, onTestFinished, vi } from "vitest" import { runInit, validatePublicUrl } from "../init.js" -import { pollHealth } from "../docker.js" +import { pollHealth, type DockerRunner } from "../docker.js" import { buildDockerNotInstalledMessage } from "../messages.js" +import { readEnvInstanceName } from "../scaffold.js" vi.mock("../docker.js", { spy: true }) import { @@ -13,7 +14,7 @@ import { dockerDaemonOnly, dockerDown, dockerNotInstalled, - dockerReady, + dockerReadyEmptyHost, fetchNever, } from "./command-stubs.js" @@ -605,7 +606,7 @@ describe("runInit remote flow", () => { { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchRecorder, }, ) @@ -654,7 +655,7 @@ describe("runInit remote flow", () => { { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchPublicUrlDownWithSignin, }, ) @@ -938,7 +939,7 @@ describe("runInit with a kept existing .env", () => { {}, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchRecorder, }, ) @@ -1048,7 +1049,7 @@ describe("runInit remote with a kept existing .env", () => { { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchRecorder, }, ) @@ -1321,7 +1322,7 @@ describe("runInit health-timeout returns starting status", () => { {}, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchNever, }, ) @@ -1367,7 +1368,7 @@ describe("runInit health-timeout returns starting status", () => { { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerReady, + docker: dockerReadyEmptyHost, fetchFn: fetchWithSignin, }, ) @@ -1493,3 +1494,390 @@ describe("validatePublicUrl", () => { }) }) }) + +// ── Instance names ──────────────────────────────────────────────────────────── + +/** A running daemon on a host where these container and volume names already exist. */ +const dockerWithExisting = (existingNames: ReadonlyArray): DockerRunner => { + return { + ...dockerDaemonOnly, + containerExists: (instanceName) => existingNames.includes(instanceName), + volumeExists: (volumeName) => existingNames.includes(volumeName), + } +} + +const readEnvLines = (targetDir: string): string[] => { + return readFileSync(join(targetDir, ".env"), "utf8").split("\n") +} + +const IN_USE_QUESTION = + 'An install named "vault-cortex" already exists on this machine (its container or data volumes are here). What should this install do?' +const NAME_QUESTION = "Name for this install (lowercase letters, digits, - and _):" +const PORT_QUESTION = "Host port for the server:" + +describe("runInit when the instance name is already in use", () => { + it("gives a local install its own name and port, with PUBLIC_URL on that port", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "work", + "8001", + [], // no optional settings + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).toEqual([ + IN_USE_QUESTION, + NAME_QUESTION, + PORT_QUESTION, + "Any optional settings to change? (press enter to skip)", + "Start the server now?", + ]) + const envLines = readEnvLines(targetDir) + expect(envLines).toContain("INSTANCE_NAME=work") + expect(envLines).toContain("PORT=8001") + expect(envLines).toContain("PUBLIC_URL=http://localhost:8001") + // The chooser reads the identity, so a picked Host port starts from 8001. + const portOption = scripted.multiselectCalls[0].options.find( + (option) => option.value === "PORT", + ) + expect(portOption?.hint).toBe("PORT · currently 8001") + expect(scripted.prints[0]).toContain( + "claude mcp add --scope user --transport http work http://localhost:8001/mcp\n", + ) + }) + + it("gives a remote install its own name, port and device name before asking the URL", async () => { + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "work", + "8001", + "https://work.example.com", // public URL + "Work", // vault name + false, // don't generate the token now + false, // no encryption + [], // no optional settings + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked.slice(0, 4)).toEqual([ + IN_USE_QUESTION, + NAME_QUESTION, + PORT_QUESTION, + "Public base URL clients will use to reach this server (no /mcp — it's added for you):", + ]) + const envLines = readEnvLines(targetDir) + expect(envLines).toContain("INSTANCE_NAME=work") + expect(envLines).toContain("PORT=8001") + expect(envLines).toContain("DEVICE_NAME=work") + }) + + it("writes nothing extra when the user reuses the existing data", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readEnvInstanceName(join(targetDir, ".env"))).toEqual({ + kind: "ok", + instanceName: "vault-cortex", + }) + expect(readEnvLines(targetDir)).toContain("PORT=8000") + }) + + it("counts a remote install as in use when only its vault volume is left", async () => { + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "reuse", + "https://vault.example.com", + "MyVault", + false, + false, + [], + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex_vault_data"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked[0]).toBe(IN_USE_QUESTION) + }) + + it("suggests the folder name and re-asks for an invalid or in-use name", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "Work Vault", // invalid + "out-taken", // in use + "work", + "8001", + [], + false, + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "out-taken"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls[0]).toEqual({ + message: NAME_QUESTION, + defaultValue: "out", + placeholder: "out", + }) + expect(scripted.errors).toEqual([ + '"Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.', + '"out-taken" is also in use on this machine — pick another name.', + ]) + expect(readEnvLines(targetDir)).toContain("INSTANCE_NAME=work") + }) + + it("skips the check when the Docker daemon is not running", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([[]]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerWithExisting(["vault-cortex"]), daemonStatus: () => "not-running" }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).toEqual(["Any optional settings to change? (press enter to skip)"]) + }) +}) + +describe("runInit --yes when the instance name is already in use", () => { + it("exits 1 without --instance-name and writes nothing", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { yes: true, vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(existsSync(targetDir)).toBe(false) + expect(scripted.errors).toEqual([ + 'An install named "vault-cortex" already exists on this machine (its container or data volumes are here).\n' + + "Re-run with --instance-name (and --port if vault-cortex's port is taken) to give this install its own name,\n" + + "or with --instance-name vault-cortex to reuse that install's data for the same vault.", + ]) + }) + + it("gives the install the free name from --instance-name, with --port and PUBLIC_URL on it", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { yes: true, vaultPath: vaultDir, dir: targetDir, instanceName: "work", port: "8001" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.logs).not.toContain('Reusing the data of the existing "work" install.') + const envLines = readEnvLines(targetDir) + expect(envLines).toContain("INSTANCE_NAME=work") + expect(envLines).toContain("PORT=8001") + expect(envLines).toContain("PUBLIC_URL=http://localhost:8001") + }) + + it.each([ + { label: "the default name", instanceName: "vault-cortex" }, + { label: "a custom name", instanceName: "work" }, + ])("reuses an occupied $label named by --instance-name", async ({ instanceName }) => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { yes: true, vaultPath: vaultDir, dir: targetDir, instanceName }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([instanceName]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.logs).toContain(`Reusing the data of the existing "${instanceName}" install.`) + expect(readEnvInstanceName(join(targetDir, ".env"))).toEqual({ kind: "ok", instanceName }) + }) + + it.each([ + { + label: "an invalid --instance-name", + flags: { instanceName: "Work" }, + expectedError: + '--instance-name: "Work" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.', + }, + { + label: "an invalid --port", + flags: { port: "80a" }, + expectedError: "--port must be a whole number between 1 and 65535.", + }, + ])("exits 1 on $label", async ({ flags, expectedError }) => { + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { yes: true, vaultPath: makeVault(), dir: makeTargetDir(), ...flags }, + { prompts: scripted.prompts, docker: dockerReadyEmptyHost, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([expectedError]) + }) +}) + +describe("runInit re-run keeps the instance identity", () => { + const writeExistingLocalEnv = (targetDir: string, vaultDir: string): void => { + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\nPUBLIC_URL=http://localhost:8001\n`, + ) + } + + it("keeps the name, port and URL of a local install when the new file overwrites it", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + writeExistingLocalEnv(targetDir, vaultDir) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + [], // no optional settings + true, // overwrite the existing file + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).not.toContain(IN_USE_QUESTION) + const envLines = readEnvLines(targetDir) + expect(envLines).toContain("INSTANCE_NAME=work") + expect(envLines).toContain("PORT=8001") + expect(envLines).toContain("PUBLIC_URL=http://localhost:8001") + expect(envLines).not.toContain("MCP_AUTH_TOKEN=old") + }) + + it("prints commands for the kept install's name when the existing file is kept", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + writeExistingLocalEnv(targetDir, vaultDir) + const scripted = createScriptedPrompts([true, [], false, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readEnvLines(targetDir)).toContain("MCP_AUTH_TOKEN=old") + expect(scripted.prints[0]).toContain( + "claude mcp add --scope user --transport http work http://localhost:8001/mcp\n", + ) + }) + + it("keeps the name, port and device name of a remote install", async () => { + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=old\nOBSIDIAN_AUTH_TOKEN=sync-token\nVAULT_NAME=Work\nPUBLIC_URL=https://work.example.com\nINSTANCE_NAME=work\nPORT=8001\nDEVICE_NAME=work-sync\n", + ) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + "https://work.example.com", + "Work", + false, // don't generate a new token + false, // no encryption + [], // no optional settings + true, // overwrite the existing file + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).not.toContain(IN_USE_QUESTION) + const envLines = readEnvLines(targetDir) + expect(envLines).toContain("INSTANCE_NAME=work") + expect(envLines).toContain("PORT=8001") + expect(envLines).toContain("DEVICE_NAME=work-sync") + }) +}) diff --git a/cli/src/__tests__/instance-identity.test.ts b/cli/src/__tests__/instance-identity.test.ts new file mode 100644 index 000000000..f16ddd8fc --- /dev/null +++ b/cli/src/__tests__/instance-identity.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" + +import { applyInstanceIdentity } from "../instance-identity.js" + +const GENERATED_LOCAL_ENV = + "MCP_AUTH_TOKEN=new\nVAULT_PATH=/vault\nPUBLIC_URL=http://localhost:8000\nPORT=8000\nINSTANCE_NAME=vault-cortex\n" + +describe("applyInstanceIdentity", () => { + it("writes carried values verbatim, keeping a custom PUBLIC_URL beside a carried port", () => { + const envContent = applyInstanceIdentity(GENERATED_LOCAL_ENV, { + carried: { + INSTANCE_NAME: "work", + PORT: "8001", + PUBLIC_URL: "https://work.example.com", + }, + chosen: {}, + }) + + expect(envContent).toBe( + "MCP_AUTH_TOKEN=new\nVAULT_PATH=/vault\nPUBLIC_URL=https://work.example.com\nPORT=8001\nINSTANCE_NAME=work\n", + ) + }) + + it("moves the generated localhost PUBLIC_URL to a chosen port", () => { + const envContent = applyInstanceIdentity(GENERATED_LOCAL_ENV, { + carried: {}, + chosen: { INSTANCE_NAME: "work", PORT: "8001" }, + }) + + expect(envContent).toBe( + "MCP_AUTH_TOKEN=new\nVAULT_PATH=/vault\nPUBLIC_URL=http://localhost:8001\nPORT=8001\nINSTANCE_NAME=work\n", + ) + }) + + it("lets a chosen value win over a carried one", () => { + const envContent = applyInstanceIdentity(GENERATED_LOCAL_ENV, { + carried: { INSTANCE_NAME: "old-name", PORT: "8001", PUBLIC_URL: "http://localhost:8001" }, + chosen: { PORT: "8002" }, + }) + + expect(envContent).toBe( + "MCP_AUTH_TOKEN=new\nVAULT_PATH=/vault\nPUBLIC_URL=http://localhost:8002\nPORT=8002\nINSTANCE_NAME=old-name\n", + ) + }) +}) diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index fda99a7ae..dc402233e 100644 --- a/cli/src/__tests__/integration/cli-pty.test.ts +++ b/cli/src/__tests__/integration/cli-pty.test.ts @@ -62,6 +62,53 @@ describe("init local", () => { expect(envContent).toMatch(/^MCP_AUTH_TOKEN=\S+$/m) }) + it("gives a second install its own name and port when the default name is in use", async () => { + const { vaultDir, configDir } = createPtyWorkDir() + + const prompts: PtyPrompt[] = [ + { match: "How do you want to run", send: "\r", label: "mode → local" }, + { + match: "Path to your Obsidian vault", + send: `${vaultDir}\r`, + label: "vault path", + }, + { + match: "Where should I put the config", + send: `${configDir}\r`, + label: "config dir", + }, + { + match: 'An install named "vault-cortex" already exists', + send: "\r", + label: "name in use → own name", + }, + { match: "Name for this install", send: "work\r", label: "name → work" }, + { match: "Host port", send: "8001\r", label: "port → 8001" }, + { + match: "Any optional settings", + send: "\r", + label: "optional settings → skip", + }, + { match: "Start the server now", send: "n\r", label: "start → no" }, + ] + + const result = await drivePty({ + args: ["init"], + workDir: vaultDir, + prompts, + env: { DOCKER_SHIM_PRESENT: "vault-cortex vault-cortex_mcp_data" }, + }) + + expect(result.exitCode).toBe(0) + expect(result.promptsAnswered).toBe(result.totalPrompts) + expect(result.transcript).toContain("--transport http work http://localhost:8001/mcp") + + const envLines = readFileSync(join(configDir, ".env"), "utf8").split("\n") + expect(envLines).toContain("INSTANCE_NAME=work") + expect(envLines).toContain("PORT=8001") + expect(envLines).toContain("PUBLIC_URL=http://localhost:8001") + }) + it("accepts start and passes health check", async () => { const { vaultDir, configDir } = createPtyWorkDir() const pidFile = join(configDir, "health-server.pid") @@ -337,7 +384,11 @@ describe("non-interactive commands", () => { args: ["upgrade", "--dir", configDir], workDir: vaultDir, prompts: [], - env: { DOCKER_SHIM_PID_FILE: pidFile, DOCKER_SHIM_HEALTH_PORT: "8000" }, + env: { + DOCKER_SHIM_PID_FILE: pidFile, + DOCKER_SHIM_HEALTH_PORT: "8000", + DOCKER_SHIM_PRESENT: "vault-cortex", + }, }) expect(result.exitCode).toBe(0) @@ -352,7 +403,6 @@ describe("non-interactive commands", () => { args: ["down", "--dir", configDir], workDir: vaultDir, prompts: [], - env: { DOCKER_SHIM_NO_CONTAINER: "1" }, }) expect(result.exitCode).toBe(0) @@ -367,6 +417,7 @@ describe("non-interactive commands", () => { args: ["down", "--dir", configDir], workDir: vaultDir, prompts: [], + env: { DOCKER_SHIM_PRESENT: "vault-cortex" }, }) expect(result.exitCode).toBe(0) @@ -383,7 +434,11 @@ describe("non-interactive commands", () => { args: ["restart", "--dir", configDir], workDir: vaultDir, prompts: [], - env: { DOCKER_SHIM_PID_FILE: pidFile, DOCKER_SHIM_HEALTH_PORT: "8000" }, + env: { + DOCKER_SHIM_PID_FILE: pidFile, + DOCKER_SHIM_HEALTH_PORT: "8000", + DOCKER_SHIM_PRESENT: "vault-cortex", + }, }) expect(result.exitCode).toBe(0) diff --git a/cli/src/__tests__/integration/fixtures/docker b/cli/src/__tests__/integration/fixtures/docker index 8c0662ae3..8cc4f787e 100755 --- a/cli/src/__tests__/integration/fixtures/docker +++ b/cli/src/__tests__/integration/fixtures/docker @@ -2,13 +2,23 @@ # Fake docker binary for CLI PTY integration tests. # Shadows the real docker when its directory is prepended to PATH. # Behavior is controllable via environment variables: -# DOCKER_SHIM_NO_CONTAINER=1 — `container inspect` exits 1 (no container) +# DOCKER_SHIM_PRESENT — space-separated container and volume names that +# exist; `container inspect` and `volume inspect` +# exit 1 for any other name (default: none exist) # DOCKER_SHIM_RUN_FAIL=1 — `run` exits 1 # DOCKER_SHIM_HEALTH_PORT — port for the background health server (default 8000) # DOCKER_SHIM_PID_FILE — where to write the health server PID set -euo pipefail +# Exits 0 when the name is in DOCKER_SHIM_PRESENT. +is_present() { + case " ${DOCKER_SHIM_PRESENT:-} " in + *" $1 "*) return 0 ;; + *) return 1 ;; + esac +} + case "${1:-}" in info) echo '{"ServerVersion":"27.0.0","OSType":"linux"}' @@ -36,12 +46,20 @@ case "${1:-}" in container) # docker container inspect — check if container exists - if [ "${DOCKER_SHIM_NO_CONTAINER:-}" = "1" ]; then + if ! is_present "${3:-}"; then exit 1 fi echo '[{"Id":"abc123","State":{"Status":"running"}}]' ;; + volume) + # docker volume inspect — check if volume exists + if ! is_present "${3:-}"; then + exit 1 + fi + echo '[{"Name":"'"${3:-}"'"}]' + ;; + stop) PID_FILE="${DOCKER_SHIM_PID_FILE:-/tmp/docker-shim-health.pid}" if [ -f "$PID_FILE" ]; then diff --git a/cli/src/__tests__/lifecycle.test.ts b/cli/src/__tests__/lifecycle.test.ts index 9d1f45b16..8a77a98d9 100644 --- a/cli/src/__tests__/lifecycle.test.ts +++ b/cli/src/__tests__/lifecycle.test.ts @@ -7,6 +7,7 @@ import type { DockerLogsParams, DockerRunner } from "../docker.js" import { runDown, runLogs, runRestart, runStart } from "../lifecycle.js" import { buildDockerNotInstalledMessage } from "../messages.js" import { + createRecordingDocker, createScriptedPrompts, dockerDown, dockerNotInstalled, @@ -265,7 +266,7 @@ describe("runLogs", () => { ) expect(exitCode).toBe(0) - expect(streamCalls).toEqual([{ follow: true, since: "10m" }]) + expect(streamCalls).toEqual([{ instanceName: "vault-cortex", follow: true, since: "10m" }]) }) it("defaults to a non-following stream with no since bound", async () => { @@ -287,7 +288,7 @@ describe("runLogs", () => { ) expect(exitCode).toBe(0) - expect(streamCalls).toEqual([{ follow: false, since: undefined }]) + expect(streamCalls).toEqual([{ instanceName: "vault-cortex", follow: false, since: undefined }]) }) it("passes the stream's exit code through as its own", async () => { @@ -471,6 +472,7 @@ describe("runRestart", () => { [ { mode: "local", + instanceName: "vault-cortex", envFilePath: join(targetDir, ".env"), port: 8000, vaultPath: "/home/user/MyVault", @@ -523,6 +525,7 @@ describe("runRestart", () => { [ { mode: "remote", + instanceName: "vault-cortex", envFilePath: join(targetDir, ".env"), port: 8000, vaultPath: undefined, @@ -728,3 +731,145 @@ describe("runRestart", () => { expect(fetchedUrls).toEqual(["http://127.0.0.1:9000/healthz"]) }) }) + +describe("commands on an install with a custom INSTANCE_NAME", () => { + const writeNamedLocalEnv = (targetDir: string): void => { + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/WorkVault\nPORT=8001\nPUBLIC_URL=http://localhost:8001\nINSTANCE_NAME=work\n", + ) + } + + it("down removes only the instance's own container", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runDown( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker }, + ) + + expect(exitCode).toBe(0) + expect(recording.calls).toEqual(["inspect:work", "rm:work"]) + }) + + it("down names the instance when its container is absent", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const scripted = createScriptedPrompts() + + const exitCode = await runDown( + { dir: targetDir }, + { prompts: scripted.prompts, docker: { ...dockerReady, containerExists: () => false } }, + ) + + expect(exitCode).toBe(0) + expect(scripted.logs).toEqual(["No work container found — nothing to stop."]) + }) + + it("down names the instance in the remove hint when removal fails", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const scripted = createScriptedPrompts() + + const exitCode = await runDown( + { dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReady, stopAndRemoveContainer: () => false }, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual(["Could not remove the container — check: docker rm -f work"]) + }) + + it("logs streams the instance's own container", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runLogs( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker }, + ) + + expect(exitCode).toBe(0) + expect(recording.calls).toEqual(["inspect:work", "logs:work"]) + expect(recording.logsParams).toEqual([ + { instanceName: "work", follow: false, since: undefined }, + ]) + }) + + it.each([ + { label: "start", run: runStart }, + { label: "restart", run: runRestart }, + ])("$label re-creates the instance's own container", async ({ run }) => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await run( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(0) + expect(recording.calls).toEqual(["inspect:work", "rm:work", "run:work"]) + expect(recording.runParams).toEqual([ + { + mode: "local", + instanceName: "work", + envFilePath: join(targetDir, ".env"), + port: 8001, + vaultPath: "/home/user/WorkVault", + }, + ]) + }) + + it("restart names the instance in the logs hint when the health check times out", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const scripted = createScriptedPrompts() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: dockerReady, fetchFn: fetchNever, healthTimeoutMs: 0 }, + ) + + expect(exitCode).toBe(1) + expect(scripted.spinnerMessages).toEqual([ + "start: Waiting for the server to come up", + "stop: Server did not respond within 0 minutes — check: docker logs work", + ]) + }) + + it.each([ + { label: "down", run: runDown }, + { label: "logs", run: runLogs }, + { label: "start", run: runStart }, + ])("$label stops on an invalid INSTANCE_NAME before any docker call", async ({ run }) => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/WorkVault\nPUBLIC_URL=http://localhost:8000\nINSTANCE_NAME=Work Vault\n", + ) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await run( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(1) + expect(recording.calls).toEqual([]) + expect(scripted.errors).toEqual([ + `INSTANCE_NAME "Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit. Fix it in ${targetDir}/.env.`, + ]) + }) +}) diff --git a/cli/src/__tests__/messages.test.ts b/cli/src/__tests__/messages.test.ts index de3ec4990..be56046d6 100644 --- a/cli/src/__tests__/messages.test.ts +++ b/cli/src/__tests__/messages.test.ts @@ -24,6 +24,7 @@ const expectedSectionRule = (label: string): string => const localDefaults = { targetDir: "/home/user/vault-cortex", + instanceName: "vault-cortex", token: "abc123deadbeef", startStatus: "not-started" as const, port: 8000, @@ -32,6 +33,7 @@ const localDefaults = { const remoteDefaults = { targetDir: "/home/user/vault-cortex", + instanceName: "vault-cortex", token: "abc123deadbeef", publicUrl: "https://vault.example.com", startStatus: "not-started" as const, @@ -183,6 +185,24 @@ describe("buildLocalConnectMessage", () => { expect(message).toContain('curl -H "Authorization: Bearer "') expect(message).not.toMatch(/\n\n\n/) }) + + it("names a custom instance in the logs command and both connector entries", () => { + const message = buildLocalConnectMessage({ + ...localDefaults, + instanceName: "work", + port: 8001, + startStatus: "starting", + }) + + expect(message).toContain("check progress:\n docker logs work\n") + expect(message).toContain( + "claude mcp add --scope user --transport http work http://localhost:8001/mcp\n", + ) + expect(message).toContain(' "work": {\n') + expect(message).not.toContain("docker logs vault-cortex") + expect(message).not.toContain("transport http vault-cortex") + expect(message).not.toContain('"vault-cortex": {') + }) }) // ── buildRemoteConnectMessage ────────────────────────────────────────────────── @@ -237,6 +257,21 @@ describe("buildRemoteConnectMessage", () => { expect(message).not.toContain("npx vault-cortex@latest start") }) + it("names a custom instance in the logs command and the connector entry", () => { + const message = buildRemoteConnectMessage({ + ...remoteDefaults, + instanceName: "work", + startStatus: "starting", + }) + + expect(message).toContain("check progress:\n docker logs work\n") + expect(message).toContain( + "claude mcp add --scope user --transport http work https://vault.example.com/mcp\n", + ) + expect(message).not.toContain("docker logs vault-cortex") + expect(message).not.toContain("transport http vault-cortex") + }) + it("shows the health check block when startStatus is starting", () => { const message = buildRemoteConnectMessage({ ...remoteDefaults, diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index e83f8a0d3..a4598e62c 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -7,6 +7,7 @@ import { buildFilesToWrite, detectMode, patchEnvObsidianToken, + readEnvInstanceName, readEnvObsidianToken, readEnvPort, readEnvPublicUrl, @@ -145,6 +146,70 @@ describe("readEnvPort", () => { expect(readEnvPort(envPath)).toBe(9000) }) + + it("takes the last active PORT line, as docker --env-file does", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, "PORT=8000\nMCP_AUTH_TOKEN=abc\nPORT=8001\n") + + expect(readEnvPort(envPath)).toBe(8001) + }) +}) + +describe("readEnvInstanceName", () => { + const writeEnv = (content: string): string => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync(envPath, content) + return envPath + } + + it("returns the default name when no .env exists", () => { + const missingPath = join(tmpdir(), "vault-cli-no-such-env", ".env") + + expect(readEnvInstanceName(missingPath)).toEqual({ kind: "ok", instanceName: "vault-cortex" }) + }) + + it("returns the default name when the line is missing, commented or empty", () => { + const withoutLine = writeEnv("MCP_AUTH_TOKEN=abc\n") + const commentedLine = writeEnv("# INSTANCE_NAME=work\n") + const emptyLine = writeEnv("INSTANCE_NAME=\n") + + expect(readEnvInstanceName(withoutLine)).toEqual({ kind: "ok", instanceName: "vault-cortex" }) + expect(readEnvInstanceName(commentedLine)).toEqual({ kind: "ok", instanceName: "vault-cortex" }) + expect(readEnvInstanceName(emptyLine)).toEqual({ kind: "ok", instanceName: "vault-cortex" }) + }) + + it("returns a valid name, with surrounding quotes dropped", () => { + expect(readEnvInstanceName(writeEnv("INSTANCE_NAME=work_2\n"))).toEqual({ + kind: "ok", + instanceName: "work_2", + }) + expect(readEnvInstanceName(writeEnv('INSTANCE_NAME="work-vault"\n'))).toEqual({ + kind: "ok", + instanceName: "work-vault", + }) + }) + + it("takes the last active line, as docker --env-file does", () => { + const envPath = writeEnv("INSTANCE_NAME=vault-cortex\nPORT=8001\nINSTANCE_NAME=work\n") + + expect(readEnvInstanceName(envPath)).toEqual({ kind: "ok", instanceName: "work" }) + }) + + it.each([ + { label: "a one-character name", value: "a" }, + { label: "uppercase letters", value: "Work" }, + { label: "a space", value: "my vault" }, + { label: "a leading hyphen", value: "-work" }, + { label: "a dot", value: "work.vault" }, + ])("rejects $label with the rule in the message", ({ value }) => { + const envPath = writeEnv(`INSTANCE_NAME=${value}\n`) + + expect(readEnvInstanceName(envPath)).toEqual({ + kind: "invalid", + message: `INSTANCE_NAME "${value}" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.`, + }) + }) }) describe("readEnvVaultPath", () => { diff --git a/cli/src/__tests__/upgrade.test.ts b/cli/src/__tests__/upgrade.test.ts index 7e5e14b80..3aa40cd33 100644 --- a/cli/src/__tests__/upgrade.test.ts +++ b/cli/src/__tests__/upgrade.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest" import type { DockerRunner } from "../docker.js" import { runUpgrade } from "../upgrade.js" import { + createRecordingDocker, createScriptedPrompts, dockerDown, dockerReady, @@ -93,6 +94,7 @@ describe("runUpgrade", () => { [ { mode: "local", + instanceName: "vault-cortex", envFilePath: join(targetDir, ".env"), port: 8000, vaultPath: "/home/user/MyVault", @@ -101,6 +103,25 @@ describe("runUpgrade", () => { ]) }) + it("re-creates the container a custom INSTANCE_NAME names", async () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-upgrade-")) + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nOBSIDIAN_AUTH_TOKEN=sync-token\nVAULT_NAME=Work\nPUBLIC_URL=https://work.example.com\nINSTANCE_NAME=work\n", + ) + const recording = createRecordingDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runUpgrade( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(0) + expect(recording.calls).toEqual(["inspect:work", "rm:work", "run:work"]) + expect(recording.runParams.map((params) => params.instanceName)).toEqual(["work"]) + }) + it("detects remote mode and runs without a vault path", async () => { const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-upgrade-")) writeRemoteEnv(targetDir) @@ -124,6 +145,7 @@ describe("runUpgrade", () => { [ { mode: "remote", + instanceName: "vault-cortex", envFilePath: join(targetDir, ".env"), port: 8000, vaultPath: undefined, diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 25b2478c1..8a255c3c9 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -4,10 +4,11 @@ import type { Mode } from "./scaffold.js" export const LOCAL_IMAGE = "ghcr.io/aliasunder/vault-cortex:latest" export const REMOTE_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote" -export const CONTAINER_NAME = "vault-cortex" export type DockerRunParams = { mode: Mode + /** The install's INSTANCE_NAME: the container name and the volume prefix. */ + instanceName: string envFilePath: string port: number /** Host vault path — local mode only (bind mount). */ @@ -15,12 +16,43 @@ export type DockerRunParams = { } export type DockerLogsParams = { + instanceName: string /** Stream new output until interrupted (`docker logs --follow`). */ follow: boolean /** Passed through to `docker logs --since` (e.g. "10m", "2h", or a timestamp). */ since?: string } +/** + * Named volumes per mode, mounted in this order. The volume names are + * `_`, the names Compose gives a project's volumes, so a + * CLI install and a Compose install with the same name share their data. + */ +const VOLUME_MOUNTS: Readonly>> = + { + local: [{ volume: "mcp_data", mountPath: "/data" }], + remote: [ + { volume: "vault_data", mountPath: "/vault" }, + { volume: "mcp_data", mountPath: "/data" }, + { volume: "obsidian_config", mountPath: "/home/obsidian/.config" }, + ], + } + +const volumeName = (instanceName: string, volume: string): string => `${instanceName}_${volume}` + +/** The named volumes an install of this mode and name mounts. */ +export const instanceVolumeNames = (params: { mode: Mode; instanceName: string }): string[] => { + const { mode, instanceName } = params + return VOLUME_MOUNTS[mode].map(({ volume }) => volumeName(instanceName, volume)) +} + +const volumeMountArgs = (mode: Mode, instanceName: string): string[] => { + return VOLUME_MOUNTS[mode].flatMap(({ volume, mountPath }) => [ + "-v", + `${volumeName(instanceName, volume)}:${mountPath}`, + ]) +} + /** * Container-runtime reachability: "running" (daemon answered), "not-running" * (binary exists but the daemon didn't answer), or "not-installed" (no docker @@ -36,10 +68,12 @@ export type DockerRunner = { dockerRun: (params: DockerRunParams) => boolean /** Pulls the latest image from the registry. */ pullImage: (image: string) => boolean - /** Stops and removes the vault-cortex container (idempotent). */ - stopAndRemoveContainer: () => boolean - /** True when a vault-cortex container exists, running or stopped. */ - containerExists: () => boolean + /** Stops and removes the named container (idempotent). */ + stopAndRemoveContainer: (instanceName: string) => boolean + /** True when a container with this name exists, running or stopped. */ + containerExists: (instanceName: string) => boolean + /** True when a named volume exists. */ + volumeExists: (volumeName: string) => boolean /** * Streams `docker logs` to the terminal; resolves with the docker * process's exit code once the stream ends. @@ -68,14 +102,14 @@ const HEALTH_CMD = * testable without spawning processes. */ export const buildDockerRunArgs = (params: DockerRunParams): string[] => { - const { mode, envFilePath, port, vaultPath } = params + const { mode, instanceName, envFilePath, port, vaultPath } = params const image = mode === "local" ? LOCAL_IMAGE : REMOTE_IMAGE const args = [ "run", "-d", "--name", - CONTAINER_NAME, + instanceName, "--restart", "unless-stopped", "--env-file", @@ -90,17 +124,15 @@ export const buildDockerRunArgs = (params: DockerRunParams): string[] => { throw new Error("vaultPath is required for local mode") } args.push("-v", `${vaultPath}:/vault:rw`) - args.push("-v", "vault-cortex_mcp_data:/data") + args.push(...volumeMountArgs(mode, instanceName)) args.push("--health-cmd", HEALTH_CMD) args.push("--health-interval", "15s") args.push("--health-timeout", "5s") args.push("--health-retries", "3") args.push("--health-start-period", "20s") } else { - args.push("--hostname", CONTAINER_NAME) - args.push("-v", "vault-cortex_vault_data:/vault") - args.push("-v", "vault-cortex_mcp_data:/data") - args.push("-v", "vault-cortex_obsidian_config:/home/obsidian/.config") + args.push("--hostname", instanceName) + args.push(...volumeMountArgs(mode, instanceName)) args.push("--health-cmd", HEALTH_CMD) args.push("--health-interval", "15s") args.push("--health-timeout", "5s") @@ -123,12 +155,12 @@ export const buildDockerRunArgs = (params: DockerRunParams): string[] => { * testable without spawning processes. */ export const buildDockerLogsArgs = (params: DockerLogsParams): string[] => { - const { follow, since } = params + const { instanceName, follow, since } = params return [ "logs", ...(follow ? ["--follow"] : []), ...(since ? ["--since", since] : []), - CONTAINER_NAME, + instanceName, ] } @@ -155,21 +187,31 @@ export const createDockerRunner = (): DockerRunner => ({ // which lands as a raw hex line between the wizard's prompts. stderr stays // inherited — image-pull progress and error output print live, which the // "see output above" failure messages rely on. - dockerRun: (params) => - spawnSync("docker", buildDockerRunArgs(params), { - stdio: ["ignore", "ignore", "inherit"], - }).status === 0, + dockerRun: (params) => { + return ( + spawnSync("docker", buildDockerRunArgs(params), { + stdio: ["ignore", "ignore", "inherit"], + }).status === 0 + ) + }, pullImage: (image) => spawnSync("docker", ["pull", image], { stdio: "inherit" }).status === 0, - stopAndRemoveContainer: () => spawnSync("docker", ["rm", "-f", CONTAINER_NAME]).status === 0, + stopAndRemoveContainer: (instanceName) => { + return spawnSync("docker", ["rm", "-f", instanceName]).status === 0 + }, // `docker rm -f` on a missing container exits 1 on engines < 23 and 0 on // >= 23, so stopAndRemoveContainer's status can't distinguish "already // gone" from "failed" — callers needing idempotent messaging probe // existence first. Output stays piped (discarded): this is a boolean probe. - containerExists: () => spawnSync("docker", ["container", "inspect", CONTAINER_NAME]).status === 0, + containerExists: (instanceName) => { + return spawnSync("docker", ["container", "inspect", instanceName]).status === 0 + }, + volumeExists: (volumeName) => { + return spawnSync("docker", ["volume", "inspect", volumeName]).status === 0 + }, // Async spawn, not spawnSync: --follow streams until interrupted, and the // exit code must be observable after the stream closes. - streamLogs: (params) => - new Promise((resolveExitCode) => { + streamLogs: (params) => { + return new Promise((resolveExitCode) => { const child = spawn("docker", buildDockerLogsArgs(params), { stdio: ["ignore", "inherit", "inherit"], }) @@ -188,7 +230,8 @@ export const createDockerRunner = (): DockerRunner => ({ // A null code means the child died to a signal — report the shell // convention for ctrl-C (128 + SIGINT = 130). child.once("close", (code) => resolveExitCode(code ?? 130)) - }), + }) + }, }) /** Default bound on a single health request (shared by probe and poll). */ @@ -233,8 +276,13 @@ export const healthPollTimeoutMs = (mode: Mode): number => { * adds a first-sync hint: the container's init chain retries the first * sync with no upper time bound, so an expired poll does not mean the * server failed — it may flip healthy after the CLI stops waiting. */ -export const healthTimeoutMessage = (mode: Mode, timeoutMs: number): string => { - const baseMessage = `Server did not respond within ${timeoutMs / 60_000} minutes — check: docker logs ${CONTAINER_NAME}` +export const healthTimeoutMessage = (params: { + mode: Mode + timeoutMs: number + instanceName: string +}): string => { + const { mode, timeoutMs, instanceName } = params + const baseMessage = `Server did not respond within ${timeoutMs / 60_000} minutes — check: docker logs ${instanceName}` if (mode === "remote") { return `${baseMessage} (a long first sync may still be running — the container keeps starting in the background)` diff --git a/cli/src/init.ts b/cli/src/init.ts index 8ae37dc9d..0cc99b7ca 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -17,6 +17,7 @@ import { pollHealth, type DockerRunner, } from "./docker.js" +import { applyInstanceIdentity, resolveInstanceIdentity } from "./instance-identity.js" import { reportPublicUrlProbe } from "./lifecycle.js" import { applyOptionalSettings, @@ -25,6 +26,7 @@ import { } from "./optional-settings.js" import { buildFilesToWrite, + readEnvInstanceName, readEnvObsidianToken, readEnvPort, readEnvPublicUrl, @@ -41,6 +43,10 @@ export type InitFlags = { mode?: string vaultPath?: string dir?: string + /** INSTANCE_NAME for this install, skipping the name-in-use prompt. */ + instanceName?: string + /** Host PORT for this install. */ + port?: string yes?: boolean } @@ -269,6 +275,19 @@ const reportWrites = ( } } +/** + * Reads the instance name from the .env on disk, which may be a kept file + * whose INSTANCE_NAME differs from this run's. Reports an invalid value and + * returns undefined, so init never starts or prints commands for a bad name. + */ +const readWrittenInstanceName = (targetDir: string, prompts: Prompts): string | undefined => { + const instanceNameRead = readEnvInstanceName(join(targetDir, ".env")) + + if (instanceNameRead.kind === "ok") return instanceNameRead.instanceName + prompts.error(`${instanceNameRead.message} Fix it in ${targetDir}/.env.`) + return undefined +} + /** * Offers to start the container, walking a gate ladder where each failed * gate degrades to instructions instead of an error: daemon running → user @@ -277,10 +296,10 @@ const reportWrites = ( * but the health check timed out, or "not-started" when a gate failed. */ const offerDockerRun = async ( - params: { targetDir: string; port: number; mode: Mode; vaultPath?: string }, + params: { targetDir: string; instanceName: string; port: number; mode: Mode; vaultPath?: string }, deps: InitDeps, ): Promise => { - const { targetDir, port, mode, vaultPath } = params + const { targetDir, instanceName, port, mode, vaultPath } = params const { prompts, docker, fetchFn } = deps const daemonStatus = docker.daemonStatus() @@ -302,6 +321,7 @@ const offerDockerRun = async ( stripEnvQuotedValues(envFilePath) const containerStarted = docker.dockerRun({ mode, + instanceName, envFilePath, port, vaultPath, @@ -318,7 +338,7 @@ const offerDockerRun = async ( const healthy = await pollHealth({ url: `http://127.0.0.1:${port}/healthz`, timeoutMs }, fetchFn) if (!healthy) { - spinner.stop(healthTimeoutMessage(mode, timeoutMs)) + spinner.stop(healthTimeoutMessage({ mode, timeoutMs, instanceName })) return "starting" } spinner.stop("Server is up — health check passed.") @@ -378,6 +398,10 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = if (!continueReinit) return 0 } + const identityResult = await resolveInstanceIdentity({ mode: "local", targetDir, flags }, deps) + + if (identityResult.kind === "exit") return 1 + const token = generateToken() // Guided optional settings: the chooser reads current values from the @@ -387,7 +411,10 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = // user asked for a full re-run — the answers land in the regenerated file // when they overwrite at the conflict prompt (keeping it discards them, // which the write report states). In-place edits stay configure's job. - const defaultEnvContent = buildLocalEnv({ mcpAuthToken: token, vaultPath }) + const defaultEnvContent = applyInstanceIdentity( + buildLocalEnv({ mcpAuthToken: token, vaultPath }), + identityResult.identity, + ) const optionalOverrides = flags.yes ? {} : await askOptionalSettings({ mode: "local", envContent: defaultEnvContent }, prompts) @@ -421,14 +448,18 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") const port = readEnvPort(join(targetDir, ".env")) + const instanceName = readWrittenInstanceName(targetDir, prompts) + + if (!instanceName) return 1 // --yes is for scripts/CI, so it never starts Docker. const startStatus: StartStatus = flags.yes ? "not-started" - : await offerDockerRun({ targetDir, port, mode: "local", vaultPath }, deps) + : await offerDockerRun({ targetDir, instanceName, port, mode: "local", vaultPath }, deps) prompts.print( buildLocalConnectMessage({ targetDir, + instanceName, token, startStatus, port, @@ -462,6 +493,12 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise if (!continueReinit) return 0 + // Before the public URL prompt, so a second install's new port is known when + // its URL is typed. + const identityResult = await resolveInstanceIdentity({ mode: "remote", targetDir, flags }, deps) + + if (identityResult.kind === "exit") return 1 + const publicUrl = await askPublicUrl(prompts) const vaultName = await askVaultName(prompts) @@ -492,13 +529,16 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise // .env passed the re-init guard — a consented re-run gets the full setup, // chooser included (see the local flow's comment for the overwrite/keep // semantics). - const defaultEnvContent = buildRemoteEnv({ - mcpAuthToken: token, - publicUrl, - obsidianAuthToken: capturedToken ?? existingEnvToken, - vaultName, - vaultPassword, - }) + const defaultEnvContent = applyInstanceIdentity( + buildRemoteEnv({ + mcpAuthToken: token, + publicUrl, + obsidianAuthToken: capturedToken ?? existingEnvToken, + vaultName, + vaultPassword, + }), + identityResult.identity, + ) const optionalOverrides = await askOptionalSettings( { mode: "remote", envContent: defaultEnvContent }, prompts, @@ -525,12 +565,15 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise // server only reads the file. The prompted value is the fallback for a kept // legacy .env that predates PUBLIC_URL. const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl + const instanceName = readWrittenInstanceName(targetDir, prompts) + + if (!instanceName) return 1 // Without the sync token the container can't start (init-check-auth fails // and s6 stops it), so only offer docker run when it was provided. const startStatus: StartStatus = !hasExistingToken ? "not-started" - : await offerDockerRun({ targetDir, port, mode: "remote" }, deps) + : await offerDockerRun({ targetDir, instanceName, port, mode: "remote" }, deps) // The container check above hit localhost on this machine; the public URL // is the ingress path clients actually use — probe it too, informationally. @@ -543,6 +586,7 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise prompts.print( buildRemoteConnectMessage({ targetDir, + instanceName, token, publicUrl: effectivePublicUrl, startStatus, diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts new file mode 100644 index 000000000..4248786d2 --- /dev/null +++ b/cli/src/instance-identity.ts @@ -0,0 +1,207 @@ +import { existsSync, readFileSync } from "node:fs" +import { basename, join } from "node:path" + +import { instanceVolumeNames, type DockerRunner } from "./docker.js" +import { + applyOptionalSettings, + askPort, + derivePublicUrlOverride, + isValidPort, + readOptionalValue, +} from "./optional-settings.js" +import { DEFAULT_INSTANCE_NAME, validateInstanceName, type Mode } from "./scaffold.js" +import type { Prompts } from "./prompts.js" + +/** The .env values that tell one install on a machine apart from another. */ +export type InstanceIdentity = { + /** Copied verbatim from the folder's existing .env when init runs again over it. */ + carried: Record + /** Chosen in this run, by flag or prompt. A local PUBLIC_URL follows a chosen PORT. */ + chosen: Record +} + +export type InstanceIdentityFlags = { + instanceName?: string + port?: string + yes?: boolean +} + +type InstanceIdentityResult = { kind: "ok"; identity: InstanceIdentity } | { kind: "exit" } + +/** + * Identity lines an existing install keeps through a re-run of init. A local + * PUBLIC_URL is the OAuth issuer and follows its port; a remote one is asked + * again in the same run, so it is not carried. + */ +const CARRIED_SETTINGS: Readonly>> = { + local: ["INSTANCE_NAME", "PORT", "PUBLIC_URL"], + remote: ["INSTANCE_NAME", "PORT", "DEVICE_NAME"], +} + +const readCarriedIdentity = (envFilePath: string, mode: Mode): Record => { + const envContent = readFileSync(envFilePath, "utf8") + const carriedEntries = CARRIED_SETTINGS[mode].flatMap((name) => { + const currentValue = readOptionalValue(envContent, name) + return currentValue ? [[name, currentValue] as const] : [] + }) + return Object.fromEntries(carriedEntries) +} + +/** + * Whether another install already uses this name on the machine: its + * container exists, or one of its volumes does. A leftover volume counts, + * because a new install with that name would mount the old data. + */ +const isInstanceNameInUse = ( + params: { mode: Mode; instanceName: string }, + docker: DockerRunner, +): boolean => { + if (docker.containerExists(params.instanceName)) return true + return instanceVolumeNames(params).some((volumeName) => docker.volumeExists(volumeName)) +} + +/** Re-prompts until the answer is a valid name no other install on the machine uses. */ +const askFreeInstanceName = async ( + params: { mode: Mode; suggestedName: string | undefined }, + deps: { prompts: Prompts; docker: DockerRunner }, +): Promise => { + const { mode, suggestedName } = params + const { prompts, docker } = deps + const answer = ( + await prompts.text("Name for this install (lowercase letters, digits, - and _):", { + defaultValue: suggestedName, + placeholder: suggestedName ?? "work", + }) + ).trim() + const invalidReason = validateInstanceName(answer) + + if (invalidReason) { + prompts.error(invalidReason) + return askFreeInstanceName(params, deps) + } + if (isInstanceNameInUse({ mode, instanceName: answer }, docker)) { + prompts.error(`"${answer}" is also in use on this machine — pick another name.`) + return askFreeInstanceName(params, deps) + } + return answer +} + +/** + * The target folder's name, offered as the new install's name when it is a + * valid name nobody uses yet (a `--dir ./work` install suggests `work`). + */ +const suggestInstanceName = ( + params: { mode: Mode; targetDir: string }, + docker: DockerRunner, +): string | undefined => { + const folderName = basename(params.targetDir).toLowerCase() + + if (validateInstanceName(folderName)) return undefined + if (isInstanceNameInUse({ mode: params.mode, instanceName: folderName }, docker)) return undefined + return folderName +} + +const inUseOnYesMessage = (instanceName: string): string => { + return ( + `An install named "${instanceName}" already exists on this machine (its container or data volumes are here).\n` + + `Re-run with --instance-name (and --port if ${instanceName}'s port is taken) to give this install its own name,\n` + + `or with --instance-name ${instanceName} to reuse that install's data for the same vault.` + ) +} + +/** + * Decides this install's name and port before init writes .env: + * - a re-run over an existing .env keeps its name, port and URL (`carried`); + * - --instance-name and --port set them directly; + * - a new install whose name is already in use on the machine asks for its own + * name and port, or confirms reuse of the existing data for the same vault. + * --yes cannot ask, so it exits unless --instance-name settles it. + * The check needs a running Docker daemon; without one it is skipped. + */ +export const resolveInstanceIdentity = async ( + params: { mode: Mode; targetDir: string; flags: InstanceIdentityFlags }, + deps: { prompts: Prompts; docker: DockerRunner }, +): Promise => { + const { mode, targetDir, flags } = params + const { prompts, docker } = deps + + const flagNameError = flags.instanceName && validateInstanceName(flags.instanceName) + + if (flagNameError) { + prompts.error(`--instance-name: ${flagNameError}`) + return { kind: "exit" } + } + if (flags.port && !isValidPort(flags.port)) { + prompts.error("--port must be a whole number between 1 and 65535.") + return { kind: "exit" } + } + const flagOverrides = { + ...(flags.instanceName ? { INSTANCE_NAME: flags.instanceName } : {}), + ...(flags.port ? { PORT: flags.port } : {}), + } + + const existingEnvPath = join(targetDir, ".env") + + if (existsSync(existingEnvPath)) { + const carried = readCarriedIdentity(existingEnvPath, mode) + return { kind: "ok", identity: { carried, chosen: flagOverrides } } + } + + const requestedName = flags.instanceName ?? DEFAULT_INSTANCE_NAME + const nameCheckPossible = docker.daemonStatus() === "running" + + if (!nameCheckPossible || !isInstanceNameInUse({ mode, instanceName: requestedName }, docker)) { + return { kind: "ok", identity: { carried: {}, chosen: flagOverrides } } + } + if (flags.instanceName) { + prompts.log(`Reusing the data of the existing "${requestedName}" install.`) + return { kind: "ok", identity: { carried: {}, chosen: flagOverrides } } + } + if (flags.yes) { + prompts.error(inUseOnYesMessage(requestedName)) + return { kind: "exit" } + } + + const choice = await prompts.select( + `An install named "${requestedName}" already exists on this machine (its container or data volumes are here). What should this install do?`, + [ + { + value: "own-name", + label: "Give this install its own name", + hint: "a second vault, with its own container, data and port", + }, + { + value: "reuse", + label: "Reuse the existing data — this is the same vault set up again", + hint: `replaces the "${requestedName}" container`, + }, + ], + "own-name", + ) + + if (choice === "reuse") { + return { kind: "ok", identity: { carried: {}, chosen: flagOverrides } } + } + const suggestedName = suggestInstanceName({ mode, targetDir }, docker) + const instanceName = await askFreeInstanceName({ mode, suggestedName }, deps) + const port = await askPort(flags.port, prompts) + // The device name labels this install's changes in Obsidian Sync's history, + // so a second remote install is told apart from the first there too. + const chosen = { + INSTANCE_NAME: instanceName, + PORT: port, + ...(mode === "remote" ? { DEVICE_NAME: instanceName } : {}), + } + return { kind: "ok", identity: { carried: {}, chosen } } +} + +/** + * Writes the identity into freshly generated .env content, before the + * optional-settings chooser reads it, so the chooser shows the identity's port + * as current. Carried values land verbatim; chosen ones go through + * derivePublicUrlOverride, so a local PUBLIC_URL follows a new port. + */ +export const applyInstanceIdentity = (envContent: string, identity: InstanceIdentity): string => { + const withCarried = applyOptionalSettings(envContent, identity.carried) + return applyOptionalSettings(withCarried, derivePublicUrlOverride(withCarried, identity.chosen)) +} diff --git a/cli/src/lifecycle.ts b/cli/src/lifecycle.ts index 203794441..f2e194175 100644 --- a/cli/src/lifecycle.ts +++ b/cli/src/lifecycle.ts @@ -1,7 +1,6 @@ import { join, resolve } from "node:path" import { - CONTAINER_NAME, healthPollTimeoutMs, healthTimeoutMessage, pollHealth, @@ -12,6 +11,7 @@ import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage } from "./ import { detectMode, hasEnvPublicUrl, + readEnvInstanceName, readEnvPort, readEnvPublicUrl, readEnvVaultPath, @@ -56,6 +56,8 @@ export type RestartDeps = { /** An init'd deployment on disk, reconstructed from its .env file. */ export type Deployment = { mode: Mode + /** The .env's INSTANCE_NAME: the container this deployment owns and its volume prefix. */ + instanceName: string targetDir: string envFilePath: string port: number @@ -68,14 +70,15 @@ export type Deployment = { const DEFAULT_TARGET_DIR = "./vault-cortex" /** - * Light gate for commands that operate on an existing deployment without - * starting a container: resolves --dir and confirms an init'd .env exists. + * Light gate for commands that use an existing deployment without starting a container: + * - resolves --dir and confirms an init'd .env exists; + * - reads INSTANCE_NAME, so an invalid name stops every command before it touches Docker. * Full start-readiness validation lives in resolveDeployment. */ export const requireInitializedDir = ( dirFlag: string | undefined, prompts: Prompts, -): { targetDir: string; envFilePath: string; mode: Mode } | undefined => { +): { targetDir: string; envFilePath: string; mode: Mode; instanceName: string } | undefined => { const targetDir = resolve(expandTilde(dirFlag ?? DEFAULT_TARGET_DIR)) const envFilePath = join(targetDir, ".env") @@ -85,7 +88,13 @@ export const requireInitializedDir = ( prompts.error(`No .env found in ${targetDir} — run \`npx vault-cortex@latest init\` first.`) return undefined } - return { targetDir, envFilePath, mode } + const instanceNameRead = readEnvInstanceName(envFilePath) + + if (instanceNameRead.kind === "invalid") { + prompts.error(`${instanceNameRead.message} Fix it in ${targetDir}/.env.`) + return undefined + } + return { targetDir, envFilePath, mode, instanceName: instanceNameRead.instanceName } } /** @@ -101,7 +110,7 @@ export const resolveDeployment = ( const initialized = requireInitializedDir(dirFlag, prompts) if (!initialized) return undefined - const { targetDir, envFilePath, mode } = initialized + const { targetDir, envFilePath, mode, instanceName } = initialized const port = readEnvPort(envFilePath) const vaultPath = mode === "local" ? readEnvVaultPath(envFilePath) : undefined @@ -121,7 +130,7 @@ export const resolveDeployment = ( } const publicUrl = readEnvPublicUrl(envFilePath) - return { mode, targetDir, envFilePath, port, vaultPath, publicUrl } + return { mode, instanceName, targetDir, envFilePath, port, vaultPath, publicUrl } } /** @@ -197,14 +206,15 @@ export const recreateContainer = async ( ): Promise => { const { deployment, healthTimeoutMs } = params const { prompts, docker, fetchFn } = deps + const { instanceName } = deployment // `docker rm -f` on a missing container exits non-zero on engines < 23, // so the bare return value can't distinguish "nothing to remove" from a // real failure — only treat removal as failed when a container exists. // Proceeding after a genuine failure would surface as a confusing // "container name already in use" from docker run. - if (docker.containerExists() && !docker.stopAndRemoveContainer()) { - prompts.error(`Could not remove the existing container — check: docker rm -f ${CONTAINER_NAME}`) + if (docker.containerExists(instanceName) && !docker.stopAndRemoveContainer(instanceName)) { + prompts.error(`Could not remove the existing container — check: docker rm -f ${instanceName}`) return 1 } @@ -212,6 +222,7 @@ export const recreateContainer = async ( prompts.log("Starting container...") const containerStarted = docker.dockerRun({ mode: deployment.mode, + instanceName, envFilePath: deployment.envFilePath, port: deployment.port, vaultPath: deployment.vaultPath, @@ -234,7 +245,7 @@ export const recreateContainer = async ( ) if (!healthy) { - spinner.stop(healthTimeoutMessage(deployment.mode, timeoutMs)) + spinner.stop(healthTimeoutMessage({ mode: deployment.mode, timeoutMs, instanceName })) return 1 } spinner.stop("Server is up — health check passed.") @@ -263,15 +274,16 @@ export const runDown = async (flags: DownFlags, deps: DownDeps): Promise if (!initialized) return 1 if (!ensureDaemonRunning(docker, prompts)) return 1 + const { instanceName } = initialized - if (!docker.containerExists()) { - prompts.log("No vault-cortex container found — nothing to stop.") + if (!docker.containerExists(instanceName)) { + prompts.log(`No ${instanceName} container found — nothing to stop.`) prompts.outro("Done.") return 0 } - if (!docker.stopAndRemoveContainer()) { - prompts.error(`Could not remove the container — check: docker rm -f ${CONTAINER_NAME}`) + if (!docker.stopAndRemoveContainer(instanceName)) { + prompts.error(`Could not remove the container — check: docker rm -f ${instanceName}`) return 1 } @@ -283,7 +295,7 @@ export const runDown = async (flags: DownFlags, deps: DownDeps): Promise } /** - * Streams `docker logs` for the vault-cortex container. The stream's exit + * Streams `docker logs` for the deployment's container. The stream's exit * code passes through as the command's exit code; no outro follows the raw * docker output. */ @@ -296,15 +308,17 @@ export const runLogs = async (flags: LogsFlags, deps: LogsDeps): Promise if (!initialized) return 1 if (!ensureDaemonRunning(docker, prompts)) return 1 + const { instanceName } = initialized - if (!docker.containerExists()) { + if (!docker.containerExists(instanceName)) { prompts.error( - `No vault-cortex container — start it with: npx vault-cortex@latest start --dir "${initialized.targetDir}"`, + `No ${instanceName} container — start it with: npx vault-cortex@latest start --dir "${initialized.targetDir}"`, ) return 1 } return await docker.streamLogs({ + instanceName, follow: Boolean(flags.follow), since: flags.since, }) diff --git a/cli/src/messages.ts b/cli/src/messages.ts index a66eadd7b..1ed3cbcce 100644 --- a/cli/src/messages.ts +++ b/cli/src/messages.ts @@ -1,7 +1,5 @@ import { styleText } from "node:util" -import { CONTAINER_NAME } from "./docker.js" - export type StartStatus = "running" | "starting" | "not-started" // Connect instructions are printed as plain text (not a clack note box) so the @@ -84,19 +82,20 @@ export const startCommand = (targetDir: string): string => const startServerLine = (targetDir: string): string => `Start the server:\n ${startCommand(targetDir)}` -const startingInBackgroundLine = (): string => - `The server is starting in the background — check progress:\n docker logs ${CONTAINER_NAME}` +const startingInBackgroundLine = (instanceName: string): string => + `The server is starting in the background — check progress:\n docker logs ${instanceName}` /** Remote start line: running, starting, blocked on the missing sync token, or ready to start. */ const remoteStartLine = (params: { targetDir: string + instanceName: string startStatus: StartStatus obsidianTokenMissing: boolean }): string => { - const { targetDir, startStatus, obsidianTokenMissing } = params + const { targetDir, instanceName, startStatus, obsidianTokenMissing } = params if (startStatus === "running") return "The server is running." - if (startStatus === "starting") return startingInBackgroundLine() + if (startStatus === "starting") return startingInBackgroundLine(instanceName) if (obsidianTokenMissing) { return `Fill in OBSIDIAN_AUTH_TOKEN in ${targetDir}/.env, then start the server:\n ${startCommand(targetDir)}` } @@ -138,11 +137,12 @@ const connectUrlBlock = (mcpUrl: string, tokenLine: string): string => // a CLI (`claude mcp add`, `opencode mcp add`), a connector dialog, or a // config file — then approve the consent page. Claude Code is the worked // example. The per-mode caveats (which clients need https, the localhost -// bridge) are appended by the builders. -const connectGuidance = (mcpUrl: string): string => +// bridge) are appended by the builders. The connector is named after the +// instance, so a second instance gets its own entry instead of replacing the first. +const connectGuidance = (mcpUrl: string, instanceName: string): string => `Add the URL above as a remote MCP server (leave Client ID/Secret empty), then approve the consent page with the token. For example, Claude Code: - 1. claude mcp add --scope user --transport http vault-cortex ${mcpUrl} + 1. claude mcp add --scope user --transport http ${instanceName} ${mcpUrl} 2. approve the browser consent page with the token above 3. done — the client holds auto-refreshing access tokens; the token never sits in client config` @@ -181,17 +181,18 @@ const updateGuidance = (targetDir: string): string => */ export const buildLocalConnectMessage = (params: { targetDir: string + instanceName: string token: string startStatus: StartStatus port: number tokenWritten: boolean }): string => { - const { targetDir, token, startStatus, port, tokenWritten } = params + const { targetDir, instanceName, token, startStatus, port, tokenWritten } = params const baseUrl = `http://localhost:${port}` const nonRunningLine = - startStatus === "starting" ? startingInBackgroundLine() : startServerLine(targetDir) + startStatus === "starting" ? startingInBackgroundLine(instanceName) : startServerLine(targetDir) const startLine = startStatus === "running" ? "The server is running." : nonRunningLine const tokenLine = tokenBlock({ targetDir, token, tokenWritten }) @@ -220,13 +221,13 @@ ${sectionRule("MCP client")} ${connectUrlBlock(`${baseUrl}/mcp`, tokenLine)} -${connectGuidance(`${baseUrl}/mcp`)} +${connectGuidance(`${baseUrl}/mcp`, instanceName)} Other clients (opencode, Cursor, …) take the URL above too. claude.ai (web) can't reach localhost — connect from a client on this machine. Claude Desktop only accepts https URLs in its connector dialog, so bridge it with mcp-remote: - "vault-cortex": { + "${instanceName}": { "command": "npx", "args": ["-y", "mcp-remote", "${baseUrl}/mcp", "--header", "Authorization: Bearer "] @@ -262,16 +263,26 @@ ${bottomRule()}` */ export const buildRemoteConnectMessage = (params: { targetDir: string + instanceName: string token: string publicUrl: string startStatus: StartStatus obsidianTokenMissing: boolean tokenWritten: boolean }): string => { - const { targetDir, token, publicUrl, startStatus, obsidianTokenMissing, tokenWritten } = params + const { + targetDir, + instanceName, + token, + publicUrl, + startStatus, + obsidianTokenMissing, + tokenWritten, + } = params const startLine = remoteStartLine({ targetDir, + instanceName, startStatus, obsidianTokenMissing, }) @@ -286,11 +297,11 @@ export const buildRemoteConnectMessage = (params: { // input is valid and must still route to the https branch. const isHttps = publicUrl.toLowerCase().startsWith("https://") const clientGuidance = isHttps - ? `${connectGuidance(`${publicUrl}/mcp`)} + ? `${connectGuidance(`${publicUrl}/mcp`, instanceName)} Reachable over https from any MCP client — Claude Desktop, claude.ai (web and mobile), opencode, Cursor — from any device.` - : `${connectGuidance(`${publicUrl}/mcp`)} + : `${connectGuidance(`${publicUrl}/mcp`, instanceName)} Other clients (opencode, Cursor, …) work over http too. claude.ai and Claude Desktop only accept https URLs — set up HTTPS for those clients diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 308d77832..293000eb6 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -244,7 +244,7 @@ const isEnabledToggleValue = (value: string | undefined): boolean => * it accepts "1e4"/"0x1F40"/"+9000", which readEnvPort's /^PORT=(\d+)/ * would later fail to read back — silently falling to the default port. */ -const isValidPort = (value: string): boolean => { +export const isValidPort = (value: string): boolean => { if (!/^\d+$/.test(value)) return false const port = Number(value) return port >= 1 && port <= 65535 @@ -264,7 +264,10 @@ const isValidTimezone = (value: string): boolean => { } /** Re-prompts until the answer is a valid port number. */ -const askPort = async (currentValue: string | undefined, prompts: Prompts): Promise => { +export const askPort = async ( + currentValue: string | undefined, + prompts: Prompts, +): Promise => { const answer = ( await prompts.text("Host port for the server:", { defaultValue: currentValue ?? String(DEFAULT_PORT), diff --git a/cli/src/program.ts b/cli/src/program.ts index 84eb34e98..a968a8a23 100644 --- a/cli/src/program.ts +++ b/cli/src/program.ts @@ -34,6 +34,11 @@ export const buildProgram = (options: ProgramOptions): Command => { .option("--mode ", 'deployment mode: "local" (default) or "remote"') .option("--vault-path ", "absolute path to your Obsidian vault (local mode)") .option("--dir ", "directory to write config files into (default: ./vault-cortex)") + .option( + "--instance-name ", + "name for this install's container and data (default: vault-cortex); give each vault on a machine its own", + ) + .option("--port ", "host port for this install (default: 8000)") .option("--yes", "non-interactive local setup with defaults; requires --vault-path") .action(async (flags: InitFlags) => { process.exitCode = await options.runInit(flags) diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index 231af59a1..938b1c4dd 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -19,8 +19,26 @@ export type FileWriteResult = { /** Default host port — matches the container's internal port. */ export const DEFAULT_PORT = 8000 -/** Matches an active (uncommented) PORT line, with optional surrounding quotes. */ -const ENV_PORT_LINE = /^PORT=["']?(\d+)["']?\s*$/m +/** + * Used when .env sets no INSTANCE_NAME: the container name and volume prefix + * every install had before the setting existed, so those installs keep their data. + */ +export const DEFAULT_INSTANCE_NAME = "vault-cortex" + +/** + * Matches every active (uncommented) PORT line, with optional surrounding + * quotes. Global, because `docker run --env-file` lets the last duplicate win. + */ +const ENV_PORT_LINES = /^PORT=["']?(\d+)["']?\s*$/gm + +/** Matches every active (uncommented) INSTANCE_NAME line, capturing its raw value. */ +const ENV_INSTANCE_NAME_LINES = /^INSTANCE_NAME=(.*)$/gm + +/** + * Compose's project-name rule plus Docker's two-character minimum for container + * names, so the container, the Compose project and the volume prefix are one string. + */ +const INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]+$/ /** Matches an active (uncommented) VAULT_PATH line in a .env file. */ const ENV_VAULT_PATH_LINE = /^VAULT_PATH=(.+)\s*$/m @@ -55,13 +73,44 @@ export const buildFilesToWrite = (envContent: string): FileToWrite[] => [ /** * Reads the host port from the .env that is actually on disk — which may be * a pre-existing file this run chose to keep, with a PORT override the - * generated default doesn't have. Falls back to DEFAULT_PORT when the file - * or an uncommented PORT line is absent. + * generated default doesn't have. The last active line wins, as it does for + * `docker run --env-file`. Falls back to DEFAULT_PORT when the file or an + * uncommented PORT line is absent. */ export const readEnvPort = (envFilePath: string): number => { if (!existsSync(envFilePath)) return DEFAULT_PORT - const match = ENV_PORT_LINE.exec(readFileSync(envFilePath, "utf8")) - return match ? Number(match[1]) : DEFAULT_PORT + const portLines = [...readFileSync(envFilePath, "utf8").matchAll(ENV_PORT_LINES)] + const lastPortLine = portLines.at(-1) + return lastPortLine ? Number(lastPortLine[1]) : DEFAULT_PORT +} + +/** Returns why a candidate instance name is invalid, or undefined when it is valid. */ +export const validateInstanceName = (candidateName: string): string | undefined => { + if (INSTANCE_NAME_PATTERN.test(candidateName)) return undefined + return `"${candidateName}" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.` +} + +export type InstanceNameRead = + { kind: "ok"; instanceName: string } | { kind: "invalid"; message: string } + +/** + * Reads INSTANCE_NAME from a .env file: + * - the last active line wins, as it does for `docker run --env-file`; + * - a missing file, line or empty value means DEFAULT_INSTANCE_NAME; + * - an invalid value is reported, never corrected, because the name decides + * which volumes hold the install's data. + */ +export const readEnvInstanceName = (envFilePath: string): InstanceNameRead => { + if (!existsSync(envFilePath)) return { kind: "ok", instanceName: DEFAULT_INSTANCE_NAME } + const instanceNameLines = [...readFileSync(envFilePath, "utf8").matchAll(ENV_INSTANCE_NAME_LINES)] + const rawValue = instanceNameLines.at(-1)?.[1].trim() + const instanceName = rawValue ? stripSurroundingQuotes(rawValue) : "" + + if (!instanceName) return { kind: "ok", instanceName: DEFAULT_INSTANCE_NAME } + const invalidReason = validateInstanceName(instanceName) + + if (invalidReason) return { kind: "invalid", message: `INSTANCE_NAME ${invalidReason}` } + return { kind: "ok", instanceName } } /** From 21f8924b7e5469fc4cd092470a18a10d48c941b2 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:15:25 -0400 Subject: [PATCH 02/57] feat(remote): add a read-only check for another vault's Sync setup check-sync-vault decides, before ob sync-setup writes anything, whether a boot would sync a vault other than VAULT_NAME: 1. resolve VAULT_NAME to a vault ID the way ob sync-setup does (ID first, then a unique name, across owned and shared vaults); 2. stop on a sync config.json the engine would silently ignore (unreadable, unparseable, or not an object with vaultId and vaultPath); 3. compare the engine's current pick (ob sync-config --json) with that ID: no setup continues, the requested vault continues (warning about any dormant setups beside it), any other vault or an unreadable setup stops. It never changes the config tree or the vault, and prints its decision, so it can also run by hand inside a container. The refusal names both vaults and the fixes for the install's layout. The remote-boot ob stub now keeps setups in the real client's layout and answers sync-list-remote, sync-list-local and sync-config --json the way the engine does. Co-Authored-By: Claude Opus 5.5 --- .../etc/s6-overlay/scripts/check-sync-vault | 199 +++++++++ scripts/lint-shell.ts | 3 +- src/__tests__/docker/fixtures/ob | 85 +++- .../__tests__/check-sync-vault.test.ts | 422 ++++++++++++++++++ 4 files changed, 702 insertions(+), 7 deletions(-) create mode 100644 rootfs/etc/s6-overlay/scripts/check-sync-vault create mode 100644 src/vault-mcp/__tests__/check-sync-vault.test.ts diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault new file mode 100644 index 000000000..47e6f5d77 --- /dev/null +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -0,0 +1,199 @@ +#!/command/with-contenv sh +# shellcheck shell=sh # ShellCheck cannot read the with-contenv shebang +# Stops a boot that would sync a vault other than VAULT_NAME. +# +# Each vault's Sync setup is /obsidian-headless/sync//config.json. +# Every ob command uses the first setup, by folder name, whose vaultPath is the +# vault folder, whatever VAULT_NAME says. So a volume holding another vault's +# setup (two installs sharing volumes, or VAULT_NAME changed on one install) +# would sync that vault instead, or merge the two through Obsidian Sync. +# +# This check only reads, and runs before ob sync-setup writes anything: +# 1. resolve VAULT_NAME to a vault ID the way ob sync-setup does; +# 2. stop on a config.json the engine would silently ignore; +# 3. compare the vault the engine would sync now with that ID. +# Exit 0 lets the boot continue; exit 1 stops it. It can also run by hand +# inside a running container, which prints the same decision: +# docker exec -i -w /vault sh -s < check-sync-vault +set -u + +if [ -z "${VAULT_NAME:-}" ]; then + echo "[obsidian-sync] ERROR: VAULT_NAME is not set." >&2 + exit 1 +fi + +VAULT_PATH="${VAULT_PATH:-/vault}" +SYNC_STATE_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/obsidian-headless/sync" +GUIDE_URL="https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup" + +# ob compares paths against its own working directory, so every call runs +# inside the vault folder, the way init-setup-vault runs ob sync-setup. +if ! cd "$VAULT_PATH"; then + echo "[obsidian-sync] ERROR: Failed to change directory to VAULT_PATH='$VAULT_PATH'." >&2 + exit 1 +fi + +run_ob() { + s6-setuidgid obsidian ob "$@" +} + +# --------------------------------------------------------------------------- +# 1. The requested vault +# --------------------------------------------------------------------------- + +if ! REMOTE_VAULTS=$(run_ob sync-list-remote --json); then + echo "[obsidian-sync] ERROR: Could not list the vaults in your Obsidian account." >&2 + echo "[obsidian-sync] Check OBSIDIAN_AUTH_TOKEN and the network, then restart." >&2 + exit 1 +fi + +# Prints "" for the vault VAULT_NAME names, matched the way ob +# sync-setup matches it: an exact vault ID first, otherwise exactly one vault +# with that name, across owned and shared vaults. Prints nothing when no vault +# or several match; ob sync-setup then reports that itself and writes nothing. +REQUESTED_VAULT=$(printf '%s' "$REMOTE_VAULTS" | VAULT_NAME="$VAULT_NAME" node --no-warnings -e ' + const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const allVaults = [...(listed.vaults ?? []), ...(listed.shared ?? [])] + const requested = process.env.VAULT_NAME + const byId = allVaults.find((vault) => vault.id === requested) + const byName = allVaults.filter((vault) => vault.name === requested) + const match = byId ?? (byName.length === 1 ? byName[0] : undefined) + if (match) console.log(match.id + "\t" + match.name) +') +if [ -z "$REQUESTED_VAULT" ]; then + echo "[obsidian-sync] Vault check: '$VAULT_NAME' does not name exactly one vault — ob sync-setup reports it." + exit 0 +fi +REQUESTED_ID=$(printf '%s' "$REQUESTED_VAULT" | cut -f1) + +# Prints the current name of a vault ID from the remote list, or nothing when +# the vault is no longer in the account. +remote_vault_name() { + printf '%s' "$REMOTE_VAULTS" | VAULT_ID="$1" node --no-warnings -e ' + const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const allVaults = [...(listed.vaults ?? []), ...(listed.shared ?? [])] + const match = allVaults.find((vault) => vault.id === process.env.VAULT_ID) + if (match) console.log(match.name) + ' +} + +# --------------------------------------------------------------------------- +# 2. Setups the engine would silently ignore +# --------------------------------------------------------------------------- + +# The engine skips a config.json it cannot read or parse, so a stale vault's +# setup could hide there and its notes sync into this vault. Prints one line +# per such file; a sync folder that can't be listed is a non-zero exit. +invalid_sync_configs() { + [ -d "$SYNC_STATE_ROOT" ] || return 0 + node --no-warnings -e ' + const { existsSync, readdirSync, readFileSync } = require("node:fs") + const { join } = require("node:path") + const syncRoot = process.argv[1] + for (const entry of readdirSync(syncRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const configPath = join(syncRoot, entry.name, "config.json") + if (!existsSync(configPath)) continue + try { + const config = JSON.parse(readFileSync(configPath, "utf8")) + const isConfig = + config !== null && + typeof config === "object" && + typeof config.vaultId === "string" && + config.vaultId !== "" && + typeof config.vaultPath === "string" + if (!isConfig) console.log(configPath) + } catch { + console.log(configPath) + } + } + ' "$SYNC_STATE_ROOT" +} + +if ! INVALID_CONFIGS=$(invalid_sync_configs); then + echo "[obsidian-sync] ERROR: Could not read the Sync setups under ${SYNC_STATE_ROOT}." >&2 + echo "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for." >&2 + exit 1 +fi +if [ -n "$INVALID_CONFIGS" ]; then + echo "[obsidian-sync] ERROR: A Sync setup file here is not a valid setup, and Obsidian Sync would ignore it:" >&2 + printf '%s\n' "$INVALID_CONFIGS" | sed 's/^/[obsidian-sync] /' >&2 + echo "[obsidian-sync] Refusing to sync, because it may belong to another vault whose notes are in this folder." >&2 + echo "[obsidian-sync] See: $GUIDE_URL" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# 3. The vault the engine would sync now +# --------------------------------------------------------------------------- + +PICKED_CONFIG=$(run_ob sync-config --json 2>/dev/null) +PICK_EXIT=$? + +# Exit 3: no setup for this folder yet (a first boot, or setups only for other +# paths), so ob sync-setup creates the first one. +if [ "$PICK_EXIT" -eq 3 ]; then + echo "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet." + exit 0 +fi +if [ "$PICK_EXIT" -ne 0 ]; then + echo "[obsidian-sync] ERROR: The Sync client could not read its own setup (ob sync-config exited $PICK_EXIT)." >&2 + echo "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for." >&2 + echo "[obsidian-sync] See: $GUIDE_URL" >&2 + exit 1 +fi + +# Prints "" of the setup the engine picked. +PICKED_VAULT=$(printf '%s' "$PICKED_CONFIG" | node --no-warnings -e ' + const config = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + console.log(config.vaultId + "\t" + (config.vaultName ?? "")) +') +PICKED_ID=$(printf '%s' "$PICKED_VAULT" | cut -f1) +PICKED_STORED_NAME=$(printf '%s' "$PICKED_VAULT" | cut -f2) + +if [ "$PICKED_ID" = "$REQUESTED_ID" ]; then + # A second setup for another vault is dormant while the engine picks this + # one, so the boot continues; removing it keeps it from being picked later. + OTHER_IDS=$(run_ob sync-list-local --json 2>/dev/null | REQUESTED_ID="$REQUESTED_ID" node --no-warnings -e ' + const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const otherIds = listed.vaults.map((vault) => vault.id).filter((id) => id !== process.env.REQUESTED_ID) + if (otherIds.length > 0) console.log(otherIds.join(" ")) + ') + if [ -n "$OTHER_IDS" ]; then + echo "[obsidian-sync] WARNING: This install also holds Sync setups for other vaults: $OTHER_IDS" >&2 + echo "[obsidian-sync] They are not used now. Remove each one's folder under ${SYNC_STATE_ROOT} so it can't be picked later." >&2 + fi + echo "[obsidian-sync] Vault check: continue — set up for '$VAULT_NAME' ($REQUESTED_ID)." + exit 0 +fi + +# --------------------------------------------------------------------------- +# Refuse +# --------------------------------------------------------------------------- + +PICKED_CURRENT_NAME=$(remote_vault_name "$PICKED_ID") +if [ -n "${STORAGE_ROOT:-}" ]; then + RESET_STEP="remove ${STORAGE_ROOT}/vault and ${STORAGE_ROOT}/config together" + SEPARATE_STEP="run it as a separate service" +else + RESET_STEP="remove this install's vault_data and obsidian_config volumes together" + SEPARATE_STEP="run it as a separate install with its own INSTANCE_NAME" +fi + +echo "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME." >&2 +if [ -n "$PICKED_CURRENT_NAME" ]; then + echo "[obsidian-sync] VAULT_NAME is '$VAULT_NAME' ($REQUESTED_ID), but its Sync setup is for '$PICKED_CURRENT_NAME' ($PICKED_ID)." >&2 +else + echo "[obsidian-sync] VAULT_NAME is '$VAULT_NAME' ($REQUESTED_ID), but its Sync setup is for '$PICKED_STORED_NAME' ($PICKED_ID), which is no longer in your account." >&2 +fi +echo "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:" >&2 +echo "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart." >&2 +echo "[obsidian-sync] - To sync '$VAULT_NAME' as well, $SEPARATE_STEP." >&2 +if [ -z "$PICKED_CURRENT_NAME" ]; then + echo "[obsidian-sync] - If you re-created this same vault in Obsidian and the notes here are its notes," >&2 + echo "[obsidian-sync] remove only the old setup folder: ${SYNC_STATE_ROOT}/${PICKED_ID}" >&2 +fi +echo "[obsidian-sync] - To switch this install to '$VAULT_NAME' on purpose, $RESET_STEP." >&2 +echo "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one." >&2 +echo "[obsidian-sync] See: $GUIDE_URL" >&2 +exit 1 diff --git a/scripts/lint-shell.ts b/scripts/lint-shell.ts index 8fa8e6630..d03e09d24 100644 --- a/scripts/lint-shell.ts +++ b/scripts/lint-shell.ts @@ -29,7 +29,8 @@ import { fileURLToPath } from "node:url" const SHELL_SCRIPT_GLOBS = [ ".claude/hooks/*.sh", ".github/scripts/*.sh", - // The s6 init scripts, plus print-derived-env, which init-derive-env runs + // The s6 init scripts, plus the two they run: print-derived-env + // (init-derive-env) and check-sync-vault (init-setup-vault) "rootfs/etc/s6-overlay/scripts/*", "rootfs/etc/s6-overlay/s6-rc.d/*/run", "rootfs/etc/s6-overlay/s6-rc.d/*/finish", diff --git a/src/__tests__/docker/fixtures/ob b/src/__tests__/docker/fixtures/ob index f0e331994..227258a35 100755 --- a/src/__tests__/docker/fixtures/ob +++ b/src/__tests__/docker/fixtures/ob @@ -33,10 +33,16 @@ # that this device has never downloaded (a server # row with no local row), as a filter or excluded # folder leaves behind +# OB_STUB_VAULT_ID — the ID of the account's vault named VAULT_NAME +# (default ci-vault-id) # -# `sync-config --json` prints the vault ID and the stored sync mode. -# `sync-setup` resets the mode to two-way, and `sync-config --mode` rejects -# an unknown value, as the real client does. +# Vault setups follow the real client's layout: `sync-setup` writes +# sync//config.json, and `sync-config --json` prints the first +# setup, by folder name, whose vaultPath is the working folder, or exits 3 +# when there is none. `sync-list-local --json` lists every setup, and +# `sync-list-remote --json` lists one vault, VAULT_NAME. `sync-setup` resets +# the mode to two-way, and `sync-config --mode` rejects an unknown value, as +# the real client does. # # Test-only verbs (not real ob commands) that stand in for what continuous # sync does to the record while the container runs: @@ -48,8 +54,37 @@ CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" CALL_LOG="$CONFIG_HOME/ob-calls.log" printf '%s\n' "$*" >> "$CALL_LOG" -STATE_DIR="$CONFIG_HOME/obsidian-headless/sync/ci-vault-id" +VAULT_ID="${OB_STUB_VAULT_ID:-ci-vault-id}" +SYNC_ROOT="$CONFIG_HOME/obsidian-headless/sync" +STATE_DIR="$SYNC_ROOT/$VAULT_ID" MODE_FILE="$CONFIG_HOME/ob-stub-sync-mode" +export SYNC_ROOT + +# Prints the setup the real client would use for the working folder: the +# first sync//config.json, in name order, whose vaultPath resolves to +# the working folder, with the stored sync mode. Exits 3 when none matches. +print_picked_setup() { + MODE="$1" node --no-warnings -e ' + const { existsSync, readdirSync, readFileSync } = require("node:fs") + const { join, resolve } = require("node:path") + const syncRoot = process.env.SYNC_ROOT + const folders = existsSync(syncRoot) ? readdirSync(syncRoot).sort() : [] + for (const folder of folders) { + const configPath = join(syncRoot, folder, "config.json") + if (!existsSync(configPath)) continue + let config + try { + config = JSON.parse(readFileSync(configPath, "utf8")) + } catch { + continue + } + if (!config || resolve(config.vaultPath) !== process.cwd()) continue + console.log(JSON.stringify({ ...config, syncMode: process.env.MODE })) + process.exit(0) + } + process.exit(3) + ' +} # The real client's table layout for the three tables init-first-sync reads, # exported so the node snippets below can read it from the environment. @@ -110,8 +145,46 @@ case "${1:-}" in fi exit 0 ;; + sync-list-remote) + VAULT_ID="$VAULT_ID" node --no-warnings -e ' + const vault = { id: process.env.VAULT_ID, name: process.env.VAULT_NAME, region: "us" } + console.log(JSON.stringify({ vaults: [vault], shared: [] })) + ' + exit 0 + ;; + sync-list-local) + node --no-warnings -e ' + const { existsSync, readdirSync, readFileSync } = require("node:fs") + const { join } = require("node:path") + const syncRoot = process.env.SYNC_ROOT + const folders = existsSync(syncRoot) ? readdirSync(syncRoot).sort() : [] + const vaults = folders.flatMap((folder) => { + const configPath = join(syncRoot, folder, "config.json") + if (!existsSync(configPath)) return [] + try { + const config = JSON.parse(readFileSync(configPath, "utf8")) + return config ? [{ id: folder, path: config.vaultPath, host: "stub" }] : [] + } catch { + return [] + } + }) + console.log(JSON.stringify({ vaults })) + ' + exit 0 + ;; sync-setup) rm -f "$MODE_FILE" + # The real client writes only config.json here; state.db appears once a + # sync runs. vaultPath is the physical working folder, as path.resolve(".") gives. + mkdir -p "$STATE_DIR" + VAULT_ID="$VAULT_ID" VAULT_FOLDER="$(pwd -P)" node --no-warnings -e ' + const config = { + vaultId: process.env.VAULT_ID, + vaultName: process.env.VAULT_NAME, + vaultPath: process.env.VAULT_FOLDER, + } + require("node:fs").writeFileSync(process.argv[1], JSON.stringify(config, null, 2)) + ' "$STATE_DIR/config.json" if [ "${OB_STUB_UNDOWNLOADED_FILE:-}" = "1" ]; then record_undownloaded_file fi @@ -123,8 +196,8 @@ case "${1:-}" in if [ -f "$MODE_FILE" ]; then mode=$(cat "$MODE_FILE") fi - printf '{"vaultId":"ci-vault-id","syncMode":"%s"}\n' "$mode" - exit 0 + print_picked_setup "$mode" + exit $? fi if [ "${2:-}" = "--mode" ]; then case "${3:-}" in diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts new file mode 100644 index 000000000..1572c3d45 --- /dev/null +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -0,0 +1,422 @@ +import { spawnSync } from "node:child_process" +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +import { describe, expect, it, onTestFinished } from "vitest" + +/** + * Behavioral spec for the remote image's vault check + * (rootfs/etc/s6-overlay/scripts/check-sync-vault), which stops a boot that + * would sync a vault other than VAULT_NAME. The real script runs under `sh` + * with stub `ob` and `s6-setuidgid` executables on PATH. The stub replays + * the pinned engine's outputs below; the config tree the script validates + * itself is real files in a temp directory. + */ + +const SCRIPT_PATH = resolve(__dirname, "../../../rootfs/etc/s6-overlay/scripts/check-sync-vault") + +const GUIDE_URL = + "https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup" + +// Engine vectors: captured from obsidian-headless 0.0.14 inside the :remote +// image with hand-written config trees and no token. +// `ob sync-config --json` with no setup for the working folder (none at all, +// or only for another path) prints nothing and exits 3. +const NO_SETUP_EXIT = 3 +// A setup without vaultPath that sorts first makes `ob sync-config` throw. +const UNREADABLE_SETUP_EXIT = 1 + +/** `ob sync-config --json` output for a picked setup, in the engine's field order. */ +const pickedConfig = (vaultId: string, vaultName: string): string => { + return JSON.stringify( + { + vaultId, + vaultName, + vaultPath: "/vault", + syncMode: "bidirectional", + deviceName: "vault-cortex", + configDir: ".obsidian", + fileTypes: ["image", "audio", "pdf", "video"], + configs: [], + excludedFolders: [], + }, + null, + 2, + ) +} + +/** `ob sync-list-local --json` output: one row per setup, raw stored path. */ +const localSetups = (vaultIds: ReadonlyArray): string => { + return JSON.stringify({ + vaults: vaultIds.map((id) => ({ id, path: "/vault", host: "sync-1.obsidian.md" })), + }) +} + +/** `ob sync-list-remote --json` output for the account's owned vaults. */ +const remoteVaults = (vaults: ReadonlyArray<{ id: string; name: string }>): string => { + return JSON.stringify({ + vaults: vaults.map((vault) => ({ ...vault, region: "us" })), + shared: [], + }) +} + +/** Stub `ob`: logs each call and replays the output and exit code the test sets per verb. */ +const OB_STUB = `#!/bin/sh +echo "$*" >> "$OB_CALL_LOG" +case "$1" in + sync-list-remote) printf '%s' "$OB_REMOTE_JSON"; exit "$OB_REMOTE_EXIT" ;; + sync-config) printf '%s' "$OB_CONFIG_JSON"; exit "$OB_CONFIG_EXIT" ;; + sync-list-local) printf '%s' "$OB_LOCAL_JSON"; exit 0 ;; + *) exit 0 ;; +esac +` + +/** Stub `s6-setuidgid`: drops the user argument and runs the command. */ +const SETUIDGID_STUB = `#!/bin/sh +shift +exec "$@" +` + +type CheckOptions = { + vaultName: string + remoteJson: string + remoteExit?: number + configJson?: string + configExit: number + localJson?: string + /** config.json files to create under the sync folder, keyed by folder name. */ + setupFiles?: Readonly> + /** Folders to create under the sync folder with no config.json in them. */ + foldersWithoutSetup?: ReadonlyArray + storageRoot?: string +} + +type CheckRun = { + status: number | null + stdout: string + stderr: string + obCalls: string[] + syncRoot: string + /** Every file under the sync folder with its content, read after the run. */ + setupTreeAfter: Record +} + +const readSetupTree = (syncRoot: string): Record => { + const tree: Record = {} + for (const folderName of readdirSync(syncRoot)) { + for (const fileName of readdirSync(join(syncRoot, folderName))) { + tree[`${folderName}/${fileName}`] = readFileSync(join(syncRoot, folderName, fileName), "utf8") + } + } + return tree +} + +const runCheck = (options: CheckOptions): CheckRun => { + const tempDir = mkdtempSync(join(tmpdir(), "check-sync-vault-")) + onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })) + const stubBinDir = join(tempDir, "bin") + const vaultPath = join(tempDir, "vault") + const homeDir = join(tempDir, "home") + const syncRoot = join(homeDir, ".config", "obsidian-headless", "sync") + mkdirSync(stubBinDir) + mkdirSync(vaultPath) + mkdirSync(syncRoot, { recursive: true }) + writeFileSync(join(stubBinDir, "ob"), OB_STUB, { mode: 0o755 }) + writeFileSync(join(stubBinDir, "s6-setuidgid"), SETUIDGID_STUB, { mode: 0o755 }) + + for (const [folderName, content] of Object.entries(options.setupFiles ?? {})) { + mkdirSync(join(syncRoot, folderName)) + writeFileSync(join(syncRoot, folderName, "config.json"), content) + } + for (const folderName of options.foldersWithoutSetup ?? []) { + mkdirSync(join(syncRoot, folderName)) + } + + const callLogPath = join(tempDir, "ob-calls.log") + writeFileSync(callLogPath, "") + + const result = spawnSync("sh", [SCRIPT_PATH], { + encoding: "utf8", + env: { + PATH: `${stubBinDir}:${process.env.PATH ?? ""}`, + HOME: homeDir, + VAULT_PATH: vaultPath, + VAULT_NAME: options.vaultName, + OB_CALL_LOG: callLogPath, + OB_REMOTE_JSON: options.remoteJson, + OB_REMOTE_EXIT: String(options.remoteExit ?? 0), + OB_CONFIG_JSON: options.configJson ?? "", + OB_CONFIG_EXIT: String(options.configExit), + OB_LOCAL_JSON: options.localJson ?? localSetups([]), + ...(options.storageRoot ? { STORAGE_ROOT: options.storageRoot } : {}), + }, + }) + + const obCalls = readFileSync(callLogPath, "utf8") + .split("\n") + .filter((loggedCall) => loggedCall !== "") + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + obCalls, + syncRoot, + setupTreeAfter: readSetupTree(syncRoot), + } +} + +const VAULT_A = { id: "aaa111", name: "Personal" } +const VAULT_B = { id: "bbb222", name: "Work" } +const setupFile = (vault: { id: string; name: string }): string => { + return JSON.stringify({ vaultId: vault.id, vaultName: vault.name, vaultPath: "/vault" }) +} + +describe("check-sync-vault", () => { + it("continues on a first boot, when no setup exists for the vault folder", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configExit: NO_SETUP_EXIT, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe( + "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet.\n", + ) + expect(run.stderr).toBe("") + expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) + }) + + it("continues on a reboot, when the engine already syncs the requested vault", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_B.id, VAULT_B.name), + configExit: 0, + localJson: localSetups([VAULT_B.id]), + setupFiles: { [VAULT_B.id]: setupFile(VAULT_B) }, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("[obsidian-sync] Vault check: continue — set up for 'Work' (bbb222).\n") + expect(run.stderr).toBe("") + }) + + it("matches VAULT_NAME as a vault ID before matching it as a name", () => { + const vaultNamedLikeAnId = { id: "ccc333", name: "bbb222" } + const run = runCheck({ + vaultName: "bbb222", + remoteJson: remoteVaults([VAULT_B, vaultNamedLikeAnId]), + configJson: pickedConfig(VAULT_B.id, VAULT_B.name), + configExit: 0, + localJson: localSetups([VAULT_B.id]), + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe( + "[obsidian-sync] Vault check: continue — set up for 'bbb222' (bbb222).\n", + ) + }) + + it("refuses a second vault on a volume set up for another vault still in the account", () => { + const setupFiles = { [VAULT_A.id]: setupFile(VAULT_A) } + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + setupFiles, + }) + + expect(run.status).toBe(1) + expect(run.stdout).toBe("") + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Personal' (aaa111).", + "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:", + "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart.", + "[obsidian-sync] - To sync 'Work' as well, run it as a separate install with its own INSTANCE_NAME.", + "[obsidian-sync] - To switch this install to 'Work' on purpose, remove this install's vault_data and obsidian_config volumes together.", + "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), + ) + // The check only reads: the existing setup is byte-for-byte unchanged. + expect(run.setupTreeAfter).toEqual({ [`${VAULT_A.id}/config.json`]: setupFiles[VAULT_A.id] }) + expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) + }) + + it("names a setup whose vault is gone from the account and offers removing only its folder", () => { + const deletedVault = { id: "ddd444", name: "Old Work" } + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configJson: pickedConfig(deletedVault.id, deletedVault.name), + configExit: 0, + setupFiles: { [deletedVault.id]: setupFile(deletedVault) }, + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Old Work' (ddd444), which is no longer in your account.", + "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:", + "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart.", + "[obsidian-sync] - To sync 'Work' as well, run it as a separate install with its own INSTANCE_NAME.", + "[obsidian-sync] - If you re-created this same vault in Obsidian and the notes here are its notes,", + `[obsidian-sync] remove only the old setup folder: ${run.syncRoot}/ddd444`, + "[obsidian-sync] - To switch this install to 'Work' on purpose, remove this install's vault_data and obsidian_config volumes together.", + "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), + ) + }) + + it("words the fixes for the single-volume hosted layout under STORAGE_ROOT", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + storageRoot: "/persist", + }) + + expect(run.status).toBe(1) + const stderrLines = run.stderr.split("\n") + expect(stderrLines).toContain( + "[obsidian-sync] - To sync 'Work' as well, run it as a separate service.", + ) + expect(stderrLines).toContain( + "[obsidian-sync] - To switch this install to 'Work' on purpose, remove /persist/vault and /persist/config together.", + ) + }) + + it("continues with a warning when a dormant setup for another vault sits beside the picked one", () => { + const run = runCheck({ + vaultName: "Personal", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + localJson: localSetups([VAULT_A.id, VAULT_B.id]), + }) + + expect(run.status).toBe(0) + expect(run.stderr).toBe( + "[obsidian-sync] WARNING: This install also holds Sync setups for other vaults: bbb222\n" + + `[obsidian-sync] They are not used now. Remove each one's folder under ${run.syncRoot} so it can't be picked later.\n`, + ) + expect(run.stdout).toBe( + "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).\n", + ) + }) + + it("stops when the account's vault list can't be read", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: "", + remoteExit: 1, + configExit: NO_SETUP_EXIT, + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + "[obsidian-sync] ERROR: Could not list the vaults in your Obsidian account.\n" + + "[obsidian-sync] Check OBSIDIAN_AUTH_TOKEN and the network, then restart.\n", + ) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + }) + + it.each([ + { label: "no vault", vaults: [VAULT_A] }, + { label: "several vaults", vaults: [{ ...VAULT_A, name: "Work" }, VAULT_B] }, + ])("leaves the error to ob sync-setup when VAULT_NAME matches $label", ({ vaults }) => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults(vaults), + configExit: NO_SETUP_EXIT, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe( + "[obsidian-sync] Vault check: 'Work' does not name exactly one vault — ob sync-setup reports it.\n", + ) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + }) + + it.each([ + { label: "an unparseable file", content: "{not json" }, + { label: "null", content: "null" }, + { label: "false", content: "false" }, + { label: "an empty string", content: '""' }, + { label: "an object without vaultId", content: '{"vaultPath":"/vault"}' }, + { label: "an object without vaultPath", content: '{"vaultId":"eee555"}' }, + ])("stops on a setup file holding $label, before asking the engine", ({ content }) => { + const setupFiles = { [VAULT_B.id]: setupFile(VAULT_B), eee555: content } + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configJson: pickedConfig(VAULT_B.id, VAULT_B.name), + configExit: 0, + setupFiles, + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: A Sync setup file here is not a valid setup, and Obsidian Sync would ignore it:", + `[obsidian-sync] ${run.syncRoot}/eee555/config.json`, + "[obsidian-sync] Refusing to sync, because it may belong to another vault whose notes are in this folder.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), + ) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + expect(run.setupTreeAfter).toEqual({ + [`${VAULT_B.id}/config.json`]: setupFiles[VAULT_B.id], + "eee555/config.json": content, + }) + }) + + it("ignores a setup folder without config.json, as the engine does", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configExit: NO_SETUP_EXIT, + foldersWithoutSetup: ["leftover"], + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe( + "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet.\n", + ) + expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) + }) + + it("stops when the engine can't read its own setup", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configExit: UNREADABLE_SETUP_EXIT, + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + "[obsidian-sync] ERROR: The Sync client could not read its own setup (ob sync-config exited 1).\n" + + "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for.\n" + + `[obsidian-sync] See: ${GUIDE_URL}\n`, + ) + }) + + it("stops when VAULT_NAME is not set", () => { + const run = runCheck({ vaultName: "", remoteJson: remoteVaults([]), configExit: NO_SETUP_EXIT }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe("[obsidian-sync] ERROR: VAULT_NAME is not set.\n") + expect(run.obCalls).toEqual([]) + }) +}) From aebff960637032122889ac832b3df37c190957e7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:19:15 -0400 Subject: [PATCH 03/57] feat(deploy): name the local Compose project, container and volume after INSTANCE_NAME The local quickstart's project is ${INSTANCE_NAME:-vault-cortex}, and its container follows the project name (-p or COMPOSE_PROJECT_NAME rename it too), falling back to INSTANCE_NAME where older Compose leaves COMPOSE_PROJECT_NAME empty. Unset or set to vault-cortex, the rendered config is identical to before. .env.example documents INSTANCE_NAME, and the CLI's generated .env carries it. The drift test now skips Compose's own COMPOSE_PROJECT_NAME and compares active optional lines as well as commented ones. Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/templates.test.ts | 22 +++++++++++++++++++--- cli/src/env.ts | 11 +++++++++++ deploy/local/.env.example | 11 +++++++++++ deploy/local/docker-compose.yml | 7 +++++-- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/cli/src/__tests__/templates.test.ts b/cli/src/__tests__/templates.test.ts index ba51f8673..a3136b75b 100644 --- a/cli/src/__tests__/templates.test.ts +++ b/cli/src/__tests__/templates.test.ts @@ -45,11 +45,25 @@ const optionalSection = (content: string): string => { return parts.length > 1 ? parts[1] : "" } -/** Extracts commented var names from the optional section only. */ +/** Matches env var assignments in the optional section, commented or active. */ +const OPTIONAL_VAR_LINE = /^(?:# )?([A-Z_]+)=/gm + +/** + * Extracts var names from the optional section only. Active lines count too: + * a setting written as `INSTANCE_NAME=vault-cortex` must reach the CLI's + * generated .env just like a commented one. + */ const optionalVarNames = (content: string): string[] => [ - ...new Set([...optionalSection(content).matchAll(COMMENTED_VAR_LINE)].map((match) => match[1])), + ...new Set([...optionalSection(content).matchAll(OPTIONAL_VAR_LINE)].map((match) => match[1])), ] +/** + * Variables Compose sets itself, so a compose file can read them without the + * user documenting them: the container name follows the project name through + * COMPOSE_PROJECT_NAME. + */ +const COMPOSE_PROVIDED_VARS = new Set(["COMPOSE_PROJECT_NAME"]) + /** Extracts all var names from a .env.example file (required + optional). */ const allEnvExampleVarNames = (envExampleContent: string): string[] => { const required = [...envExampleContent.matchAll(UNCOMMENTED_VAR_LINE)].map((match) => match[1]) @@ -285,7 +299,9 @@ describe("env var consistency across deploy surfaces", () => { const composeVars = new Set(interpolatedVars(readRepoFile(`deploy/${mode}/docker-compose.yml`))) const exampleVars = new Set(allEnvExampleVarNames(readRepoFile(`deploy/${mode}/.env.example`))) - const undocumented = [...composeVars].filter((varName) => !exampleVars.has(varName)).sort() + const undocumented = [...composeVars] + .filter((varName) => !exampleVars.has(varName) && !COMPOSE_PROVIDED_VARS.has(varName)) + .sort() expect(undocumented).toEqual([]) }) diff --git a/cli/src/env.ts b/cli/src/env.ts index c9dad9eeb..75a193372 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -127,6 +127,17 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork +# Name of this install's container, Compose project and search-index volume +# (_mcp_data). To run more than one vault on this machine, give each +# install its own INSTANCE_NAME and its own PORT. +# Valid values: two or more lowercase letters, digits, hyphens or underscores, +# starting with a letter or digit. +# Default: vault-cortex +# Set it before the first start. Changing it later starts the install on a new, +# empty index volume while the old one stays, so stop the old container first +# (docker compose down, or npx vault-cortex down). +INSTANCE_NAME=vault-cortex + # Host port to expose (default: 8000). PORT=8000 diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 14abdb262..5eb639f12 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -117,6 +117,17 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork +# Name of this install's container, Compose project and search-index volume +# (_mcp_data). To run more than one vault on this machine, give each +# install its own INSTANCE_NAME and its own PORT. +# Valid values: two or more lowercase letters, digits, hyphens or underscores, +# starting with a letter or digit. +# Default: vault-cortex +# Set it before the first start. Changing it later starts the install on a new, +# empty index volume while the old one stays, so stop the old container first +# (docker compose down, or npx vault-cortex down). +INSTANCE_NAME=vault-cortex + # Host port to expose (default: 8000). PORT=8000 diff --git a/deploy/local/docker-compose.yml b/deploy/local/docker-compose.yml index 5d7020a8d..845817fc2 100644 --- a/deploy/local/docker-compose.yml +++ b/deploy/local/docker-compose.yml @@ -11,12 +11,15 @@ # # Full docs: https://github.com/aliasunder/vault-cortex -name: vault-cortex +name: ${INSTANCE_NAME:-vault-cortex} services: vault-cortex: image: ghcr.io/aliasunder/vault-cortex:latest - container_name: vault-cortex + # Follows the project name, so -p and COMPOSE_PROJECT_NAME rename the + # container too. Older Compose versions leave COMPOSE_PROJECT_NAME empty + # here, and the INSTANCE_NAME fallback covers them. + container_name: ${COMPOSE_PROJECT_NAME:-${INSTANCE_NAME:-vault-cortex}} restart: unless-stopped environment: PORT: "8000" From a356c0c59e68b80cff09dad2243723c0fa381492 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:22:46 -0400 Subject: [PATCH 04/57] docs: describe INSTANCE_NAME and the other-vault check outside the deploy guides - cli/README: the --instance-name and --port flags, the name-in-use prompt, and how a second vault gets its own install. - llms-install: a second vault's folder, INSTANCE_NAME and port. - Render and Railway: what to do when a boot refuses another vault's Sync setup, and which reset keeps the vaults apart. - ARCHITECTURE: the vault check in the init chain. - AGENTS: the engine outputs check-sync-vault depends on, and its test file. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 23 ++++++++++++++++++----- ARCHITECTURE.md | 7 +++++-- cli/README.md | 19 ++++++++++++++++--- deploy/railway/README.md | 19 ++++++++++++++++++- deploy/render/README.md | 19 ++++++++++++++++++- llms-install.md | 2 ++ 6 files changed, 77 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3df874e02..0a18c16ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -953,9 +953,10 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal): `src/vault-mcp/__tests__/` (`init-check-auth.test.ts`, `init-obsidian-login.test.ts`, `init-first-sync.test.ts`, `init-setup-user.test.ts`, `init-setup-vault.test.ts`, - `print-derived-env.test.ts`, which covers the derivation that - `init-derive-env` publishes), and the hook's and release scripts' - tests in `src/__tests__/` (`install-deps.test.ts`, + `check-sync-vault.test.ts`, which covers the vault check + `init-setup-vault` runs, and `print-derived-env.test.ts`, which covers + the derivation that `init-derive-env` publishes), and the hook's and + release scripts' tests in `src/__tests__/` (`install-deps.test.ts`, `generate-notes.test.ts`, `update-changelog.test.ts`). These script tests run the real script under its own shell (`sh` for the init scripts, `bash` for the others) with stub binaries on `PATH` or, for @@ -1194,10 +1195,22 @@ of `obsidian-headless/package.json` as a potential regression and re-verify each contract against the new source before merging: - Verbs and flags the scripts call: `login`, `sync-config` (including - `--mode` and `--json`), `sync`, `sync --continuous`, and - `sync-setup --vault --device-name`. `sync-config --json` prints `vaultId` + `--mode` and `--json`), `sync`, `sync --continuous`, + `sync-setup --vault --device-name`, `sync-list-remote --json` and + `sync-list-local --json`. `sync-config --json` prints `vaultId` and `syncMode` (`bidirectional` when unset), and an unknown `--mode` value exits non-zero, which `init-setup-vault` treats as fatal. +- Each vault's setup is `obsidian-headless/sync//config.json`, and + every command uses the first setup, in folder-name order, whose + `path.resolve(vaultPath)` is the working folder. `check-sync-vault` relies + on these outputs, captured as vectors in its test file: + - `sync-config --json` exits 3 when no setup matches the folder and 1 when + a setup without `vaultPath` sorts first, and it skips a setup file it + can't read or parse; + - `sync-list-local --json` lists `{ id, path, host }` per setup; + - `sync-list-remote --json` lists owned and shared vaults with `id` and + `name`, and needs the token; + - `sync-setup` resolves `--vault` by ID first, then by a unique name. - `sync-setup` rewrites the whole config on every boot without the filter fields or `syncMode`. `sync-config --file-types` / `--excluded-folders` / `--configs` then queue only the recorded server files that the new value diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 12ae7df46..fe8b01775 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -927,8 +927,11 @@ graph LR `init-check-auth` (looks for the Sync token in the env var, then in the Sync client's own credential file on the config volume; with neither, the boot switches to [setup mode](#setup-mode) instead of stopping) → - `init-obsidian-login` (`ob login`) → `init-setup-vault` (`ob sync-setup` - with `--device-name`, plus optional sync-config; fails fast when + `init-obsidian-login` (`ob login`) → `init-setup-vault` (first + `check-sync-vault`, which stops the boot before anything is written when + the volume's Sync setup is for a different vault than `VAULT_NAME`; then + `ob sync-setup` with `--device-name`, a check that the client now uses + `VAULT_NAME`'s vault, and optional sync-config; fails fast when `VAULT_NAME` is missing or `SYNC_MODE` fails to apply) → `init-first-sync` (one-shot `ob sync` run to _completion_, with retries; its four steps, failure branches included, are listed below). Any diff --git a/cli/README.md b/cli/README.md index edae3cb23..b9e700fa7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -59,17 +59,30 @@ What it does: Re-running init where a setup already exists asks first — declining leaves everything unchanged and points you at [`configure`](#configure), the right -tool for changing settings in place. Existing files are never overwritten -without asking. During a remote setup, init offers to generate your +tool for changing settings in place. A re-run keeps the setup's instance +name and port. Existing files are never overwritten without asking. During a +remote setup, init offers to generate your [Obsidian Sync token](#get-sync-token) as part of the flow. +To run a second vault on the same machine, run init again with its own +`--dir`. When the default name `vault-cortex` is already in use, init asks +you to give the new install its own name and port. That name (`INSTANCE_NAME` +in its `.env`) names the install's container and data, and every command run +with that `--dir` acts only on it. Each install gets its own connector in +your MCP client. + Flags: - `--mode local|remote` — skip the mode prompt - `--vault-path ` — absolute path to your vault (local mode) - `--dir ` — directory to write config files into (default `./vault-cortex`) -- `--yes` — non-interactive local setup with defaults; requires `--vault-path` +- `--instance-name ` — name for this install's container and data + (default `vault-cortex`); lowercase letters, digits, `-` and `_`. Naming an + install that already exists reuses its data, for the same vault set up again +- `--port ` — host port for this install (default `8000`) +- `--yes` — non-interactive local setup with defaults; requires `--vault-path`, + and `--instance-name` when the name is already in use Non-interactive example: diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 60d9f9e58..a7eb76acf 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -347,7 +347,24 @@ again, like a first deploy. **A redeploy took as long as the first deploy and downloaded the whole vault again.** The volume was replaced, or `/persist/config` was removed, so the container started over with a fresh Obsidian Sync connection. Nothing needs -cleaning up. +cleaning up, unless `VAULT_NAME` now names a different vault than before: see +the next entry. + +**`This install is set up to sync a different vault than VAULT_NAME.`** The +container refused to start because the volume holds the Obsidian Sync setup +for another vault, usually after `VAULT_NAME` was changed. Starting would +merge the two vaults' notes. Nothing was changed, and the log line after it +names both vaults. Then do one of these: + +- If `VAULT_NAME` is a mistake, set it back under **Variables** and apply the + staged change. +- To serve the other vault as well, deploy it as a separate Railway service + with its own volume, and leave this one on its original vault. +- To switch this service to the new vault, delete the volume + (**Settings → Volumes**), add a new one at `/persist`, then **Redeploy**. + The container downloads the new vault like a first deploy, and the old + vault stays in Obsidian Sync. Removing only `/persist/config` would upload + the old vault's notes into the new one. **`The vault is empty but this device has previously synced.`** The container refused to start because the vault directory on the volume is diff --git a/deploy/render/README.md b/deploy/render/README.md index cfdef84bf..c4eefab3b 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -320,7 +320,24 @@ for the first deploy and remove it once the vault has synced. **A redeploy took as long as the first deploy and downloaded the whole vault again.** The disk was replaced, or `/persist/config` was removed, so the container started over with a fresh Obsidian Sync connection. Nothing needs -cleaning up. +cleaning up, unless `VAULT_NAME` now names a different vault than before: see +the next entry. + +**`This install is set up to sync a different vault than VAULT_NAME.`** The +container refused to start because the disk holds the Obsidian Sync setup +for another vault, usually after `VAULT_NAME` was changed. Starting would +merge the two vaults' notes. Nothing was changed, and the log line after it +names both vaults. Then do one of these: + +- If `VAULT_NAME` is a mistake, set it back under **Environment** and save. +- To serve the other vault as well, deploy the Blueprint again for it, as a + separate service with its own disk, and leave this one on its original + vault. +- To switch to the new vault, deploy the Blueprint again for it, then delete + this service (see [Restart, stop, delete](#restart-stop-delete)). The new + service downloads the new vault like a first deploy, and the old vault + stays in Obsidian Sync. Removing only `/persist/config` would upload the + old vault's notes into the new one. **`The vault is empty but this device has previously synced.`** The container refused to start because the vault directory on the disk is empty diff --git a/llms-install.md b/llms-install.md index 32ca46056..4678207d4 100644 --- a/llms-install.md +++ b/llms-install.md @@ -163,6 +163,8 @@ docker compose down -v # stop and delete the index (rebuilds on next sta docker compose pull && docker compose up -d --force-recreate # update to the latest image ``` +To serve a second vault from the same machine, set it up in a separate folder with its own `.env` that sets `INSTANCE_NAME` (for example `INSTANCE_NAME=work`) and a free `PORT`, then register it in the client under its own name. `INSTANCE_NAME` names that install's container and index volume, so the commands in this guide use that name in place of `vault-cortex` (`docker exec work …`). + ## More - Full configuration (memory folder, protected paths, timezone, logging): [README → Configuration](./README.md#configuration) From f459ac293ceef69e81122b82c24f52682caeecd8 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:25:50 -0400 Subject: [PATCH 05/57] feat(deploy): name the remote Compose project, container and volumes after INSTANCE_NAME Unset, or set to vault-cortex, the remote compose file renders exactly as before, so existing installs keep their volumes. The CLI's remote .env block is resynced. Co-Authored-By: Claude Opus 5.5 --- cli/src/env.ts | 13 +++++++++++++ deploy/remote/.env.example | 13 +++++++++++++ deploy/remote/docker-compose.yml | 7 +++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/cli/src/env.ts b/cli/src/env.ts index 75a193372..fcb9d6d1f 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -291,6 +291,19 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork +# Name of this install's container, Compose project and data volumes +# (_vault_data, _mcp_data and _obsidian_config). To run more +# than one vault on this server, give each install its own INSTANCE_NAME, PORT +# and DEVICE_NAME. +# Valid values: two or more lowercase letters, digits, hyphens or underscores, +# starting with a letter or digit. +# Default: vault-cortex +# Set it before the first start. Changing it later starts the install on new, +# empty volumes, so the vault downloads again as a new Obsidian Sync device +# while the old volumes stay. Stop the old container first +# (docker compose down, or npx vault-cortex down). +INSTANCE_NAME=vault-cortex + # Host port to expose (default: 8000). PORT=8000 diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index be4a767c2..d386d5a73 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -123,6 +123,19 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork +# Name of this install's container, Compose project and data volumes +# (_vault_data, _mcp_data and _obsidian_config). To run more +# than one vault on this server, give each install its own INSTANCE_NAME, PORT +# and DEVICE_NAME. +# Valid values: two or more lowercase letters, digits, hyphens or underscores, +# starting with a letter or digit. +# Default: vault-cortex +# Set it before the first start. Changing it later starts the install on new, +# empty volumes, so the vault downloads again as a new Obsidian Sync device +# while the old volumes stay. Stop the old container first +# (docker compose down, or npx vault-cortex down). +INSTANCE_NAME=vault-cortex + # Host port to expose (default: 8000). PORT=8000 diff --git a/deploy/remote/docker-compose.yml b/deploy/remote/docker-compose.yml index aa81c49e1..54745985c 100644 --- a/deploy/remote/docker-compose.yml +++ b/deploy/remote/docker-compose.yml @@ -13,12 +13,15 @@ # # Full docs: https://github.com/aliasunder/vault-cortex -name: vault-cortex +name: ${INSTANCE_NAME:-vault-cortex} services: vault-cortex: image: ghcr.io/aliasunder/vault-cortex:remote - container_name: vault-cortex + # Follows the project name, so -p and COMPOSE_PROJECT_NAME rename the + # container too. Older Compose versions leave COMPOSE_PROJECT_NAME empty + # here, and the INSTANCE_NAME fallback covers them. + container_name: ${COMPOSE_PROJECT_NAME:-${INSTANCE_NAME:-vault-cortex}} hostname: ${DEVICE_NAME:-vault-cortex} restart: unless-stopped environment: From 374897ba6f33769009e9cdf9846cfe612a634909 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:40:03 -0400 Subject: [PATCH 06/57] feat(remote): stop the boot before syncing another vault's Sync setup init-setup-vault now runs check-sync-vault before ob sync-setup writes anything, so a volume set up for a different vault than VAULT_NAME stops the boot instead of merging the two vaults. After ob sync-setup it checks again that the Sync client uses VAULT_NAME's vault, before any sync-config call writes to its setup. check-sync-vault prints the vault ID it resolved on stdout for that second check and sends its messages to stderr. A remote-boot scenario seeds another vault's setup and confirms the boot stops before sync-setup, leaving the setup unchanged. Co-Authored-By: Claude Opus 5.5 --- .../etc/s6-overlay/scripts/check-sync-vault | 15 +- rootfs/etc/s6-overlay/scripts/init-first-sync | 12 +- .../etc/s6-overlay/scripts/init-setup-vault | 39 +++++ src/__tests__/docker/docker-harness.ts | 88 +++++++++- .../docker/remote-image-boot.test.ts | 141 +++++++++++++--- .../__tests__/check-sync-vault.test.ts | 26 +-- .../__tests__/init-setup-vault.test.ts | 159 +++++++++++++++--- 7 files changed, 396 insertions(+), 84 deletions(-) diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index 47e6f5d77..e02937d40 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -12,8 +12,11 @@ # 1. resolve VAULT_NAME to a vault ID the way ob sync-setup does; # 2. stop on a config.json the engine would silently ignore; # 3. compare the vault the engine would sync now with that ID. -# Exit 0 lets the boot continue; exit 1 stops it. It can also run by hand -# inside a running container, which prints the same decision: +# Exit 0 lets the boot continue and prints the vault ID from step 1 on stdout +# (nothing when VAULT_NAME names no single vault), which init-setup-vault +# checks again after ob sync-setup. Exit 1 stops the boot. Every message goes +# to stderr. It can also run by hand inside a running container, which prints +# the same decision: # docker exec -i -w /vault sh -s < check-sync-vault set -u @@ -61,7 +64,7 @@ REQUESTED_VAULT=$(printf '%s' "$REMOTE_VAULTS" | VAULT_NAME="$VAULT_NAME" node - if (match) console.log(match.id + "\t" + match.name) ') if [ -z "$REQUESTED_VAULT" ]; then - echo "[obsidian-sync] Vault check: '$VAULT_NAME' does not name exactly one vault — ob sync-setup reports it." + echo "[obsidian-sync] Vault check: '$VAULT_NAME' does not name exactly one vault — ob sync-setup reports it." >&2 exit 0 fi REQUESTED_ID=$(printf '%s' "$REQUESTED_VAULT" | cut -f1) @@ -133,7 +136,8 @@ PICK_EXIT=$? # Exit 3: no setup for this folder yet (a first boot, or setups only for other # paths), so ob sync-setup creates the first one. if [ "$PICK_EXIT" -eq 3 ]; then - echo "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet." + echo "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet." >&2 + printf '%s\n' "$REQUESTED_ID" exit 0 fi if [ "$PICK_EXIT" -ne 0 ]; then @@ -163,7 +167,8 @@ if [ "$PICKED_ID" = "$REQUESTED_ID" ]; then echo "[obsidian-sync] WARNING: This install also holds Sync setups for other vaults: $OTHER_IDS" >&2 echo "[obsidian-sync] They are not used now. Remove each one's folder under ${SYNC_STATE_ROOT} so it can't be picked later." >&2 fi - echo "[obsidian-sync] Vault check: continue — set up for '$VAULT_NAME' ($REQUESTED_ID)." + echo "[obsidian-sync] Vault check: continue — set up for '$VAULT_NAME' ($REQUESTED_ID)." >&2 + printf '%s\n' "$REQUESTED_ID" exit 0 fi diff --git a/rootfs/etc/s6-overlay/scripts/init-first-sync b/rootfs/etc/s6-overlay/scripts/init-first-sync index fa438255f..835f3858b 100644 --- a/rootfs/etc/s6-overlay/scripts/init-first-sync +++ b/rootfs/etc/s6-overlay/scripts/init-first-sync @@ -66,11 +66,13 @@ fi # # The guard sums local_files across every store instead of resolving # . The image serves one vault, so a second store exists only -# after the device was registered against a different remote vault -# (one deleted and re-created, or a manual sync-setup). Counting that -# store too keeps the guard conservative; the cost is a stopped -# container until the device is re-registered. The queue step below reads -# only the active store, because it writes to the store the client syncs from. +# after the device was registered against a different remote vault (one +# deleted and re-created, or a manual sync-setup). init-setup-vault has +# already stopped any boot whose client would sync that other vault, so +# here the second store is unused. Counting it too keeps the guard +# conservative; the cost is a stopped container until the device is +# re-registered. The queue step below reads only the active store, because +# it writes to the store the client syncs from. SYNC_STATE_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/obsidian-headless/sync" # Prints the number of files the sync state records as present locally; diff --git a/rootfs/etc/s6-overlay/scripts/init-setup-vault b/rootfs/etc/s6-overlay/scripts/init-setup-vault index 1a3b4cf57..4e6697e1c 100644 --- a/rootfs/etc/s6-overlay/scripts/init-setup-vault +++ b/rootfs/etc/s6-overlay/scripts/init-setup-vault @@ -9,6 +9,10 @@ if [ "${SETUP_MODE:-}" = 1 ]; then exit 0 fi +# Resolved before the cd into VAULT_PATH, so a relative script path still +# finds check-sync-vault beside this script. +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) + VAULT_PATH="${VAULT_PATH:-/vault}" mkdir -p "$VAULT_PATH" if ! cd "$VAULT_PATH"; then @@ -52,6 +56,16 @@ fi if [ -n "$CONFIG_DIR_NAME" ]; then set -- "$@" --config-dir "$CONFIG_DIR_NAME" fi + +# check-sync-vault stops the boot before ob sync-setup writes anything when +# the volume holds another vault's Sync setup. It prints the vault ID that +# VAULT_NAME names, for the check after ob sync-setup. It runs under sh +# because this script has already loaded the environment its with-contenv +# line would load. +if ! REQUESTED_VAULT_ID=$(sh "$SCRIPT_DIR/check-sync-vault"); then + exit 1 +fi + if ! run_sync_setup "$@"; then echo "[obsidian-sync] ERROR: ob sync-setup failed." >&2 echo "[obsidian-sync] Check OBSIDIAN_AUTH_TOKEN and VAULT_NAME are correct." >&2 @@ -61,6 +75,31 @@ if ! run_sync_setup "$@"; then exit 1 fi +# ob sync-setup has just written VAULT_NAME's setup, so the Sync client must +# now use it for this folder. Every sync-config call below writes to the setup +# the client uses, so any other answer stops the boot before those writes. +sync_client_uses_vault() { + picked_config=$(s6-setuidgid obsidian ob sync-config --json) || return 1 + printf '%s' "$picked_config" | VAULT_ID="$1" node --no-warnings -e ' + const configText = require("node:fs").readFileSync(0, "utf8") + const parseConfig = () => { + try { + return JSON.parse(configText) + } catch { + process.exit(1) + } + } + process.exit(parseConfig()?.vaultId === process.env.VAULT_ID ? 0 : 1) + ' +} + +if [ -z "$REQUESTED_VAULT_ID" ] || ! sync_client_uses_vault "$REQUESTED_VAULT_ID"; then + echo "[obsidian-sync] ERROR: After ob sync-setup, the Sync client does not use the setup for '$VAULT_NAME' in this folder." >&2 + echo "[obsidian-sync] Refusing to sync a vault other than VAULT_NAME." >&2 + echo "[obsidian-sync] See: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup" >&2 + exit 1 +fi + # --------------------------------------------------------------------------- # Apply optional sync config # --------------------------------------------------------------------------- diff --git a/src/__tests__/docker/docker-harness.ts b/src/__tests__/docker/docker-harness.ts index 2ff32bea1..287b49414 100644 --- a/src/__tests__/docker/docker-harness.ts +++ b/src/__tests__/docker/docker-harness.ts @@ -69,11 +69,14 @@ export const docker = async (argv: string[]): Promise => { type ExecFileError = Error & { code: number; stdout: string; stderr: string } -const isExecFileError = (error: unknown): error is ExecFileError => - error instanceof Error && - typeof Reflect.get(error, "code") === "number" && - typeof Reflect.get(error, "stdout") === "string" && - typeof Reflect.get(error, "stderr") === "string" +const isExecFileError = (error: unknown): error is ExecFileError => { + return ( + error instanceof Error && + typeof Reflect.get(error, "code") === "number" && + typeof Reflect.get(error, "stdout") === "string" && + typeof Reflect.get(error, "stderr") === "string" + ) +} /** Run `docker ` and throw with the captured stderr when it fails. */ export const dockerOrThrow = async (argv: string[]): Promise => { @@ -301,6 +304,76 @@ export const seedSyncToken = async ({ ]) } +/** Seed a named config volume with one vault's Sync setup + * (`obsidian-headless/sync//config.json`) before any container + * mounts it — how a scenario fakes a volume set up for another vault. Runs + * `node` from the image under test with the entrypoint bypassed. */ +export const seedSyncSetup = async ({ + image, + volume, + mountPath, + vaultId, + configJson, +}: { + image: string + volume: string + /** Where the volume is mounted for the seeding run, which is also the + * Sync client's config home. */ + mountPath: string + vaultId: string + configJson: string +}): Promise => { + await dockerOrThrow([ + "run", + "--rm", + "--pull=never", + "--entrypoint", + "node", + "-v", + `${volume}:${mountPath}`, + image, + "--no-warnings", + "-e", + ` + const { mkdirSync, writeFileSync } = require("node:fs") + const { join } = require("node:path") + const setupDir = join(process.argv[1], "obsidian-headless", "sync", process.argv[2]) + mkdirSync(setupDir, { recursive: true }) + writeFileSync(join(setupDir, "config.json"), process.argv[3]) + `, + mountPath, + vaultId, + configJson, + ]) +} + +/** Raw bytes of a file on a named volume, read by a throwaway container from + * the image under test — how a scenario reads what a boot that stopped + * itself left on its volume. */ +export const readVolumeFile = ({ + image, + volume, + mountPath, + path, +}: { + image: string + volume: string + mountPath: string + path: string +}): Promise => { + return dockerOrThrow([ + "run", + "--rm", + "--pull=never", + "--entrypoint", + "cat", + "-v", + `${volume}:${mountPath}`, + image, + path, + ]) +} + /** Port the in-container stand-in for api.obsidian.md listens on; the * setup-mode scenarios point OBSIDIAN_API_URL at it. */ export const FAKE_OBSIDIAN_API_PORT = 9797 @@ -506,8 +579,9 @@ export const waitForStopped = async ({ /** Run a command inside the container and report its outcome — non-zero * exits are data here (`test -e` on an absent path), not errors. */ -export const execInContainer = (name: string, argv: string[]): Promise => - docker(["exec", name, ...argv]) +export const execInContainer = (name: string, argv: string[]): Promise => { + return docker(["exec", name, ...argv]) +} /** Raw bytes of a file inside the container — no trimming, so a stray * trailing newline in a `container_environment` value fails an exact diff --git a/src/__tests__/docker/remote-image-boot.test.ts b/src/__tests__/docker/remote-image-boot.test.ts index b304093d8..bd11b3709 100644 --- a/src/__tests__/docker/remote-image-boot.test.ts +++ b/src/__tests__/docker/remote-image-boot.test.ts @@ -35,8 +35,10 @@ import { pathExistsInContainer, publishedPort, readContainerFile, + readVolumeFile, runContainer, runSyncEngineOracle, + seedSyncSetup, seedSyncState, seedSyncToken, startContainer, @@ -93,30 +95,56 @@ const BASE_ENV = { EMBEDDING_ENABLED: "false", } -/** Every `ob` call that a single boot makes, in order, for BASE_ENV. The stub - * logs each call's arguments as "$*": +/** The `ob` calls check-sync-vault makes before `sync-setup`: the account's + * vault list, then the setup the client uses for the vault folder now. */ +const VAULT_CHECK_CALLS = ["sync-list-remote --json", "sync-config --json"] + +/** The `ob` calls from `sync-setup` to the first sync's settings read, for + * BASE_ENV. The stub logs each call's arguments as "$*": + * - The first `sync-config --json` is init-setup-vault confirming that the + * client uses the setup `sync-setup` just wrote. * - A `sync-config` flag whose variable is unset is skipped. * - The folder and file-type filters always run, so an emptied variable * clears the setting. The empty argument shows as a trailing space. * - `--configs` always runs, because SYNC_CONFIGS has a built-in default. - * - `sync-config --json` is init-first-sync reading the settings back. */ -const EXPECTED_BOOT_SEQUENCE = [ - "login", + * - The last `sync-config --json` is init-first-sync reading the settings back. */ +const SETUP_CALLS = [ "sync-setup --vault ci-vault --device-name ci-device", + "sync-config --json", "sync-config --device-name ci-device", "sync-config --excluded-folders ", "sync-config --file-types ", "sync-config --configs core-plugin-data,community-plugin-data,app", "sync-config --json", +] + +/** Every `ob` call that a first boot makes, in order: no setup exists yet. */ +const FIRST_BOOT_SEQUENCE = [ + "login", + ...VAULT_CHECK_CALLS, + ...SETUP_CALLS, + "sync", + "sync --continuous", +] + +/** A later boot finds this vault's setup, so the check also lists the local + * setups to warn about any for other vaults. */ +const REBOOT_SEQUENCE = [ + "login", + ...VAULT_CHECK_CALLS, + "sync-list-local --json", + ...SETUP_CALLS, "sync", "sync --continuous", ] -const callLogOf = (boots: number): string => - Array.from({ length: boots }, () => EXPECTED_BOOT_SEQUENCE) +/** The call log the stub writes across the given boots, one call per line. */ +const callLogOf = (...boots: ReadonlyArray>): string => { + return boots .flat() .map((call) => `${call}\n`) .join("") +} /** Test-owned copy of what the stub's one-shot sync writes. */ const REMOTE_BOOT_NOTE = `--- @@ -130,8 +158,9 @@ Delivered by the stubbed first sync. Links to [[Sync Log]]. const OWNERSHIP_LINE = "Ownership IDs changed" -const uniqueName = (scenario: string): string => - `remote-boot-${scenario}-${randomBytes(4).toString("hex")}` +const uniqueName = (scenario: string): string => { + return `remote-boot-${scenario}-${randomBytes(4).toString("hex")}` +} const bootedStartedAt = async (name: string): Promise => { const result = await docker(["inspect", "-f", "{{.State.StartedAt}}", name]) @@ -226,12 +255,12 @@ describe("remote image boot — three-volume layout (anonymous /vault, /data, /h expect(appliedIds).toBe("1000:1000\n") }) - it("invokes the Sync client in the documented order: login, sync-setup, sync-config ×5, sync, sync --continuous", async () => { + it("invokes the Sync client in the documented order, from login to sync --continuous", async () => { const callLog = await readContainerFile({ name, path: "/home/obsidian/.config/ob-calls.log", }) - expect(callLog).toBe(callLogOf(1)) + expect(callLog).toBe(callLogOf(FIRST_BOOT_SEQUENCE)) }) it("records the two synced notes in the sync state under /home/obsidian/.config", async () => { @@ -279,8 +308,15 @@ describe("remote image boot — three-volume layout (anonymous /vault, /data, /h const searchResponse: unknown = JSON.parse(textContent(result)) // `modified` is the boot-time mtime and `score` is an RRF float — every // other field of the response is fixed by the fixture, so pin those. - const isSearchResponse = (value: unknown): value is { results: Record[] } => - typeof value === "object" && value !== null && Array.isArray(Reflect.get(value, "results")) + const isSearchResponse = ( + value: unknown, + ): value is { results: Record[] } => { + return ( + typeof value === "object" && + value !== null && + Array.isArray(Reflect.get(value, "results")) + ) + } if (!isSearchResponse(searchResponse)) { throw new Error(`unexpected vault_search response: ${textContent(result)}`) @@ -479,7 +515,7 @@ describe("remote image boot — single-volume layout (STORAGE_ROOT=/persist)", ( it("runs the full Sync sequence again, appending to the first boot's log", async () => { expect(await readContainerFile({ name, path: "/persist/config/ob-calls.log" })).toBe( - callLogOf(2), + callLogOf(FIRST_BOOT_SEQUENCE, REBOOT_SEQUENCE), ) }) @@ -580,21 +616,15 @@ describe("remote image boot — first sync keeps failing (OB_STUB_SYNC_FAIL=1)", path: "/home/obsidian/.config/ob-calls.log", }) expect(callLog).toBe( - [ + callLogOf([ "login", - "sync-setup --vault ci-vault --device-name ci-device", - "sync-config --device-name ci-device", - "sync-config --excluded-folders ", - "sync-config --file-types ", - "sync-config --configs core-plugin-data,community-plugin-data,app", - "sync-config --json", + ...VAULT_CHECK_CALLS, + ...SETUP_CALLS, "sync", "sync", "sync", "sync --continuous", - ] - .map((call) => `${call}\n`) - .join(""), + ]), ) }) @@ -957,6 +987,60 @@ describe("remote image boot — safety checks in the init chain stop the contain expect(logs).not.toContain("First sync (attempt") }) + it("refuses to sync another vault's Sync setup before ob sync-setup writes anything", async () => { + // A config volume left set up for another vault: the Sync client would + // sync that vault into /vault, whatever VAULT_NAME says. The stub's + // account lists only VAULT_NAME's vault, so the other one reads as gone. + const configVolume = `${uniqueName("other-vault-config")}-volume` + const configHome = "/home/obsidian/.config" + const otherVaultSetup = JSON.stringify({ + vaultId: "other-vault-id", + vaultName: "Other Vault", + vaultPath: "/vault", + }) + await seedSyncSetup({ + image: IMAGE, + volume: configVolume, + mountPath: configHome, + vaultId: "other-vault-id", + configJson: otherVaultSetup, + }) + const logs = await logsAfterGuardStops({ + scenario: "other-vault-setup", + env: BASE_ENV, + volumes: [`${configVolume}:${configHome}`], + }) + expect(logs).toContain( + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + ) + expect(logs).toContain( + "[obsidian-sync] VAULT_NAME is 'ci-vault' (ci-vault-id), but its Sync setup is for 'Other Vault' (other-vault-id), which is no longer in your account.", + ) + expect(logs).toContain( + "s6-rc: warning: unable to start service init-setup-vault: command exited 1", + ) + expect(logs).not.toContain("First sync (attempt") + + // The volume shows what ran: the check's reads only, no sync-setup, and + // the other vault's setup unchanged. + const callLog = await readVolumeFile({ + image: IMAGE, + volume: configVolume, + mountPath: configHome, + path: `${configHome}/ob-calls.log`, + }) + const setupAfter = await readVolumeFile({ + image: IMAGE, + volume: configVolume, + mountPath: configHome, + path: `${configHome}/obsidian-headless/sync/other-vault-id/config.json`, + }) + expect({ callLog, setupAfter }).toEqual({ + callLog: callLogOf(["login", ...VAULT_CHECK_CALLS]), + setupAfter: otherVaultSetup, + }) + }) + it("refuses to sync an empty vault when the device's sync state records files", async () => { // A kept config volume (sync state recording files) with a wiped vault // volume would have the sync engine push every recorded file as a @@ -1182,12 +1266,13 @@ const setupModeEnv = (): Record => { } } -const postSetupForm = (port: number, fields: Record): Promise => - fetch(`http://127.0.0.1:${port}/setup`, { +const postSetupForm = (port: number, fields: Record): Promise => { + return fetch(`http://127.0.0.1:${port}/setup`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(fields), }) +} describe("remote image boot — setup mode (no Sync token anywhere)", () => { const name = uniqueName("setup-mode") @@ -1335,7 +1420,7 @@ describe("remote image boot — setup mode (no Sync token anywhere)", () => { expect({ token, mode: mode.stdout.trim(), callLog }).toEqual({ token: FAKE_OBSIDIAN_ACCOUNT.token, mode: "600", - callLog: callLogOf(1), + callLog: callLogOf(FIRST_BOOT_SEQUENCE), }) expect(logs).toContain("[obsidian-sync] Auth token found on the volume.") }) @@ -1479,7 +1564,7 @@ describe("remote image boot — env var token wins over a token file on the volu const logs = await containerLogs(name) expect({ setupModePublished, callLog }).toEqual({ setupModePublished: false, - callLog: callLogOf(1), + callLog: callLogOf(FIRST_BOOT_SEQUENCE), }) expect(logs).toContain("[obsidian-sync] Auth token present.") }) diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts index 1572c3d45..37fbb6bc1 100644 --- a/src/vault-mcp/__tests__/check-sync-vault.test.ts +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -181,10 +181,11 @@ describe("check-sync-vault", () => { }) expect(run.status).toBe(0) - expect(run.stdout).toBe( + // stdout carries only the requested vault's ID, for init-setup-vault. + expect(run.stdout).toBe("bbb222\n") + expect(run.stderr).toBe( "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet.\n", ) - expect(run.stderr).toBe("") expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) }) @@ -199,8 +200,8 @@ describe("check-sync-vault", () => { }) expect(run.status).toBe(0) - expect(run.stdout).toBe("[obsidian-sync] Vault check: continue — set up for 'Work' (bbb222).\n") - expect(run.stderr).toBe("") + expect(run.stdout).toBe("bbb222\n") + expect(run.stderr).toBe("[obsidian-sync] Vault check: continue — set up for 'Work' (bbb222).\n") }) it("matches VAULT_NAME as a vault ID before matching it as a name", () => { @@ -214,7 +215,8 @@ describe("check-sync-vault", () => { }) expect(run.status).toBe(0) - expect(run.stdout).toBe( + expect(run.stdout).toBe("bbb222\n") + expect(run.stderr).toBe( "[obsidian-sync] Vault check: continue — set up for 'bbb222' (bbb222).\n", ) }) @@ -308,11 +310,10 @@ describe("check-sync-vault", () => { expect(run.status).toBe(0) expect(run.stderr).toBe( "[obsidian-sync] WARNING: This install also holds Sync setups for other vaults: bbb222\n" + - `[obsidian-sync] They are not used now. Remove each one's folder under ${run.syncRoot} so it can't be picked later.\n`, - ) - expect(run.stdout).toBe( - "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).\n", + `[obsidian-sync] They are not used now. Remove each one's folder under ${run.syncRoot} so it can't be picked later.\n` + + "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).\n", ) + expect(run.stdout).toBe("aaa111\n") }) it("stops when the account's vault list can't be read", () => { @@ -342,7 +343,8 @@ describe("check-sync-vault", () => { }) expect(run.status).toBe(0) - expect(run.stdout).toBe( + expect(run.stdout).toBe("") + expect(run.stderr).toBe( "[obsidian-sync] Vault check: 'Work' does not name exactly one vault — ob sync-setup reports it.\n", ) expect(run.obCalls).toEqual(["sync-list-remote --json"]) @@ -391,9 +393,7 @@ describe("check-sync-vault", () => { }) expect(run.status).toBe(0) - expect(run.stdout).toBe( - "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet.\n", - ) + expect(run.stdout).toBe("bbb222\n") expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) }) diff --git a/src/vault-mcp/__tests__/init-setup-vault.test.ts b/src/vault-mcp/__tests__/init-setup-vault.test.ts index 12b5f8989..09f2f2ff9 100644 --- a/src/vault-mcp/__tests__/init-setup-vault.test.ts +++ b/src/vault-mcp/__tests__/init-setup-vault.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process" -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs" +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync, symlinkSync } from "node:fs" import { tmpdir } from "node:os" import { join, resolve } from "node:path" @@ -12,18 +12,27 @@ import { describe, expect, it, onTestFinished } from "vitest" * name reaches the first-sync stage unconfigured — so every branch is * exercised here by running the real script under `sh` with stub `ob` and * `s6-setuidgid` executables on PATH. + * + * The script runs check-sync-vault from its own folder, so each run links the + * real script into a temp folder beside a stub check. The check's own + * branches are covered in check-sync-vault.test.ts; these tests cover when + * it runs and what happens on each of its answers. */ const SCRIPT_PATH = resolve(__dirname, "../../../rootfs/etc/s6-overlay/scripts/init-setup-vault") /** Stub `ob`: logs each invocation; `sync-setup` exits with the code the * test configures, `sync-config` fails when its flag matches - * `OB_SYNC_CONFIG_FAIL_FLAG`, every other invocation succeeds. */ + * `OB_SYNC_CONFIG_FAIL_FLAG`, `sync-config --json` prints a setup for + * `OB_PICKED_VAULT_ID`, and every other invocation succeeds. */ const OB_STUB = `#!/bin/sh echo "$*" >> "$OB_CALL_LOG" case "$1" in sync-setup) exit "$OB_SYNC_SETUP_EXIT" ;; - sync-config) [ "$2" = "$OB_SYNC_CONFIG_FAIL_FLAG" ] && exit 1; exit 0 ;; + sync-config) + [ "$2" = "$OB_SYNC_CONFIG_FAIL_FLAG" ] && exit 1 + [ "$2" = --json ] && printf '{"vaultId":"%s","syncMode":"bidirectional"}' "$OB_PICKED_VAULT_ID" + exit 0 ;; *) exit 0 ;; esac ` @@ -34,6 +43,22 @@ shift exec "$@" ` +/** Stub check-sync-vault: logs its run in the same call log, then refuses or + * prints the vault ID the test configures. */ +const VAULT_CHECK_STUB = `#!/bin/sh +echo "check-sync-vault" >> "$OB_CALL_LOG" +if [ "$VAULT_CHECK_REFUSES" = 1 ]; then + echo "[obsidian-sync] ERROR: stub vault check refused." >&2 + exit 1 +fi +printf '%s\\n' "$VAULT_CHECK_VAULT_ID" +` + +const REQUESTED_VAULT_ID = "aaa111" + +const VAULT_CHECK_CALL = "check-sync-vault" +/** The check after ob sync-setup that the Sync client uses the requested vault. */ +const PICKED_VAULT_CALL = "sync-config --json" const DEFAULT_SYNC_CONFIGS_CALL = "sync-config --configs core-plugin-data,community-plugin-data,app" /** The folder and attachment filters are applied on every boot, empty meaning @@ -42,12 +67,18 @@ const DEFAULT_SYNC_CONFIGS_CALL = "sync-config --configs core-plugin-data,commun const CLEAR_EXCLUDED_FOLDERS_CALL = "sync-config --excluded-folders " const CLEAR_FILE_TYPES_CALL = "sync-config --file-types " +const PICKED_VAULT_ERROR = + "[obsidian-sync] ERROR: After ob sync-setup, the Sync client does not use the setup for 'MyVault' in this folder.\n" + + "[obsidian-sync] Refusing to sync a vault other than VAULT_NAME.\n" + + "[obsidian-sync] See: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup\n" + type SetupRun = { status: number | null stdout: string stderr: string - /** Every `ob` invocation, as the script issued it (subcommand + args). */ - obCalls: string[] + /** Every `ob` invocation (subcommand + args) and vault check run, in the + * order the script issued them. */ + calls: string[] vaultPath: string } @@ -66,25 +97,37 @@ type SetupRunOptions = { syncSetupFails?: boolean /** The `ob sync-config` flag whose invocation fails (e.g. `--file-types`). */ syncConfigFailsFor?: string + /** When true, the vault check refuses. */ + vaultCheckRefuses?: boolean + /** The vault ID the vault check prints (default: the requested vault's; + * empty when VAULT_NAME names no single vault). */ + vaultCheckVaultId?: string + /** The vault ID `ob sync-config --json` reports after `ob sync-setup` + * (default: the requested vault's). */ + pickedVaultId?: string } const runSetupScript = (options: SetupRunOptions): SetupRun => { const tempDir = mkdtempSync(join(tmpdir(), "init-setup-vault-")) onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })) const stubBinDir = join(tempDir, "bin") + const scriptsDir = join(tempDir, "scripts") const vaultPath = join(tempDir, "vault") mkdirSync(stubBinDir) + mkdirSync(scriptsDir) mkdirSync(vaultPath) writeFileSync(join(stubBinDir, "ob"), OB_STUB, { mode: 0o755 }) writeFileSync(join(stubBinDir, "s6-setuidgid"), SETUIDGID_STUB, { mode: 0o755, }) + symlinkSync(SCRIPT_PATH, join(scriptsDir, "init-setup-vault")) + writeFileSync(join(scriptsDir, "check-sync-vault"), VAULT_CHECK_STUB) - const callLogPath = join(tempDir, "ob-calls.log") + const callLogPath = join(tempDir, "calls.log") writeFileSync(callLogPath, "") - const result = spawnSync("sh", [SCRIPT_PATH], { + const result = spawnSync("sh", [join(scriptsDir, "init-setup-vault")], { encoding: "utf8", env: { PATH: `${stubBinDir}:${process.env.PATH ?? ""}`, @@ -93,6 +136,9 @@ const runSetupScript = (options: SetupRunOptions): SetupRun => { OB_CALL_LOG: callLogPath, OB_SYNC_SETUP_EXIT: String(options.syncSetupFails ? 1 : 0), OB_SYNC_CONFIG_FAIL_FLAG: options.syncConfigFailsFor ?? "", + OB_PICKED_VAULT_ID: options.pickedVaultId ?? REQUESTED_VAULT_ID, + VAULT_CHECK_REFUSES: options.vaultCheckRefuses ? "1" : "0", + VAULT_CHECK_VAULT_ID: options.vaultCheckVaultId ?? REQUESTED_VAULT_ID, ...(options.setupMode ? { SETUP_MODE: "1" } : {}), ...(options.vaultName === undefined ? {} : { VAULT_NAME: options.vaultName }), ...(options.vaultPassword === undefined ? {} : { VAULT_PASSWORD: options.vaultPassword }), @@ -107,14 +153,14 @@ const runSetupScript = (options: SetupRunOptions): SetupRun => { }, }) - const obCalls = readFileSync(callLogPath, "utf8") + const calls = readFileSync(callLogPath, "utf8") .split("\n") .filter((loggedCall) => loggedCall !== "") return { status: result.status, stdout: result.stdout, stderr: result.stderr, - obCalls, + calls, vaultPath, } } @@ -128,7 +174,7 @@ describe("init-setup-vault script", () => { expect(run.status).toBe(0) expect(run.stdout).toBe("[obsidian-sync] Setup mode — skipping vault setup.\n") expect(run.stderr).toBe("") - expect(run.obCalls).toEqual([]) + expect(run.calls).toEqual([]) }) it("refuses to start when VAULT_NAME is unset", () => { @@ -140,32 +186,69 @@ describe("init-setup-vault script", () => { "[obsidian-sync] ERROR: VAULT_NAME is not set.\n" + "[obsidian-sync] Set VAULT_NAME to your exact Obsidian vault name (case-sensitive) in .env (or -e VAULT_NAME=...) and restart.\n", ) - expect(run.obCalls).toEqual([]) + expect(run.calls).toEqual([]) }) it("refuses to start when VAULT_NAME is empty", () => { const run = runSetupScript({ vaultName: "" }) expect(run.status).toBe(1) - expect(run.obCalls).toEqual([]) + expect(run.calls).toEqual([]) }) it("configures sync for the named vault", () => { const run = runSetupScript({ vaultName: "MyVault" }) expect(run.status).toBe(0) + // The vault ID the check prints is captured, so it never reaches the log. expect(run.stdout).toBe( `[obsidian-sync] Configuring sync for vault: 'MyVault' → ${run.vaultPath}\n`, ) expect(run.stderr).toBe("") - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL, ]) }) + it("stops before ob sync-setup when the vault check refuses", () => { + const run = runSetupScript({ vaultName: "MyVault", vaultCheckRefuses: true }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe("[obsidian-sync] ERROR: stub vault check refused.\n") + expect(run.calls).toEqual([VAULT_CHECK_CALL]) + }) + + it("stops before any sync-config write when the Sync client uses another vault after ob sync-setup", () => { + const run = runSetupScript({ vaultName: "MyVault", pickedVaultId: "bbb222" }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe(PICKED_VAULT_ERROR) + expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault", PICKED_VAULT_CALL]) + }) + + it("stops before any sync-config write when ob sync-config --json fails after ob sync-setup", () => { + const run = runSetupScript({ vaultName: "MyVault", syncConfigFailsFor: "--json" }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe(PICKED_VAULT_ERROR) + expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault", PICKED_VAULT_CALL]) + }) + + it("stops after ob sync-setup when the vault check named no vault", () => { + // ob sync-setup fails when VAULT_NAME names no single vault; if it ever + // succeeds anyway, the setup it wrote can't be checked, so the boot stops. + const run = runSetupScript({ vaultName: "MyVault", vaultCheckVaultId: "" }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe(PICKED_VAULT_ERROR) + expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault"]) + }) + it("exits 1 with the credential hint when ob sync-setup fails", () => { const run = runSetupScript({ vaultName: "MyVault", syncSetupFails: true }) @@ -175,7 +258,7 @@ describe("init-setup-vault script", () => { "[obsidian-sync] Check OBSIDIAN_AUTH_TOKEN and VAULT_NAME are correct.\n" + "[obsidian-sync] If your vault uses end-to-end encryption, set VAULT_PASSWORD.\n", ) - expect(run.obCalls).toEqual(["sync-setup --vault MyVault"]) + expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault"]) }) it("registers the first device under DEVICE_NAME", () => { @@ -184,8 +267,10 @@ describe("init-setup-vault script", () => { deviceName: "vault cortex box", }) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault --device-name vault cortex box", + PICKED_VAULT_CALL, "sync-config --device-name vault cortex box", CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, @@ -205,8 +290,10 @@ describe("init-setup-vault script", () => { "[obsidian-sync] WARNING: ob sync-config --file-types 'image,pdf' failed — continuing without it.\n", ) // The call after the failed one still runs. - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, "sync-config --file-types image,pdf", DEFAULT_SYNC_CONFIGS_CALL, @@ -218,8 +305,10 @@ describe("init-setup-vault script", () => { expect(run.status).toBe(0) expect(run.stderr).toBe("") - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --mode pull-only", @@ -241,8 +330,10 @@ describe("init-setup-vault script", () => { "[obsidian-sync] Set SYNC_MODE to bidirectional, pull-only, or mirror-remote and restart.\n", ) // Nothing after the failed mode call runs. - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --mode pull-onyl", @@ -256,8 +347,10 @@ describe("init-setup-vault script", () => { syncFileTypes: "image,pdf", }) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, "sync-config --excluded-folders Daily Notes,Private", "sync-config --file-types image,pdf", DEFAULT_SYNC_CONFIGS_CALL, @@ -271,8 +364,10 @@ describe("init-setup-vault script", () => { syncFileTypes: "", }) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL, @@ -287,8 +382,10 @@ describe("init-setup-vault script", () => { expect(run.status).toBe(0) expect(run.stderr).toBe("") - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL, @@ -299,8 +396,10 @@ describe("init-setup-vault script", () => { const run = runSetupScript({ vaultName: "MyVault", syncConfigs: "app,hotkey" }) expect(run.status).toBe(0) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --configs app,hotkey", @@ -310,8 +409,10 @@ describe("init-setup-vault script", () => { it("clears config-category sync when SYNC_CONFIGS is none", () => { const run = runSetupScript({ vaultName: "MyVault", syncConfigs: "none" }) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --configs ", @@ -329,8 +430,10 @@ describe("init-setup-vault script", () => { expect(run.stderr).toBe( "[obsidian-sync] WARNING: ob sync-config --configs '' failed — continuing without it.\n", ) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, "sync-config --configs ", @@ -344,8 +447,10 @@ describe("init-setup-vault script", () => { }) expect(run.status).toBe(0) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault --password s3cret", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL, @@ -359,8 +464,10 @@ describe("init-setup-vault script", () => { }) expect(run.status).toBe(0) - expect(run.obCalls).toEqual([ + expect(run.calls).toEqual([ + VAULT_CHECK_CALL, "sync-setup --vault MyVault --config-dir .obsidian-custom", + PICKED_VAULT_CALL, CLEAR_EXCLUDED_FOLDERS_CALL, CLEAR_FILE_TYPES_CALL, DEFAULT_SYNC_CONFIGS_CALL, From 30886d6a55be15fb0b62054dfe65f8c4d561bae0 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:40:16 -0400 Subject: [PATCH 07/57] docs: add "Run more than one vault" to the local and remote guides Both guides cover a second install by the CLI and by Compose (and docker run on the remote guide): its own INSTANCE_NAME, port, connector and update commands. The remote guide adds an INSTANCE_NAME settings row and explains the boot that stops on another vault's Sync setup, with a fix for each cause. DEPLOY.md notes the reference deployment keeps its explicit volume names. Co-Authored-By: Claude Opus 5.5 --- DEPLOY.md | 2 + cli/src/env.ts | 2 +- deploy/local/.env.example | 2 +- deploy/local/README.md | 59 +++++++++++++++++-- deploy/remote/README.md | 119 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 174 insertions(+), 10 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index fffa40992..d208cb41f 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -99,6 +99,8 @@ That runs, in order: On startup, Compose runs one `vault-cortex` container (the `:remote` image). Inside it, s6-overlay runs the init chain (Obsidian login → vault setup → first sync to completion) and then supervises two processes in order: the sync process (mirrors your vault) → the MCP server. Both drop to the same UID (1000, adjustable via PUID/PGID) so they share the `/vault` volume. See [ARCHITECTURE.md § Container Startup](./ARCHITECTURE.md#container-startup) for the full diagram. +The repo-root `docker-compose.yml` names its volumes explicitly (`vault-cortex_vault_data`, `vault-cortex_mcp_index_data`, `vault-cortex_obsidian_config`) and does not read `INSTANCE_NAME`, so this deployment's data stays on those volumes whatever `.env` sets. + ## Verify ```bash diff --git a/cli/src/env.ts b/cli/src/env.ts index fcb9d6d1f..59fea998a 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -129,7 +129,7 @@ MEMORY_DIR=About Me # Name of this install's container, Compose project and search-index volume # (_mcp_data). To run more than one vault on this machine, give each -# install its own INSTANCE_NAME and its own PORT. +# install its own INSTANCE_NAME and PORT, with PUBLIC_URL on that port. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 5eb639f12..79797d9c6 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -119,7 +119,7 @@ MEMORY_DIR=About Me # Name of this install's container, Compose project and search-index volume # (_mcp_data). To run more than one vault on this machine, give each -# install its own INSTANCE_NAME and its own PORT. +# install its own INSTANCE_NAME and PORT, with PUBLIC_URL on that port. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex diff --git a/deploy/local/README.md b/deploy/local/README.md index b4fbe69af..9915efc72 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -3,7 +3,7 @@ Run Vault Cortex on your machine against a local Obsidian vault. No cloud, no Obsidian Sync — just Docker and a folder of `.md` files. -**Contents** — [Prerequisites](#prerequisites) · [Setup](#setup) · [Connect](#connect-your-mcp-client) · [Verify](#verify) · [Monitoring](#monitoring) · [Updating](#updating) · [Restart](#restart) · [Stop](#stop) · [Windows](#windows-docker-desktop) · [Memory](#memory) · [File Tools](#file-tools) · [Read-only](#read-only-mode) · [Deleting notes](#deleting-notes) · [Config](#configuration) · [Troubleshooting](#troubleshooting) +**Contents** — [Prerequisites](#prerequisites) · [Setup](#setup) · [Connect](#connect-your-mcp-client) · [Verify](#verify) · [Monitoring](#monitoring) · [Updating](#updating) · [Restart](#restart) · [Stop](#stop) · [More than one vault](#run-more-than-one-vault) · [Windows](#windows-docker-desktop) · [Memory](#memory) · [File Tools](#file-tools) · [Read-only](#read-only-mode) · [Deleting notes](#deleting-notes) · [Config](#configuration) · [Troubleshooting](#troubleshooting) ## Prerequisites @@ -199,8 +199,8 @@ The server runs these startup tasks on every boot: or deleted while the server started. Restarting the container re-runs this flow (useful when testing bootstrap -behavior). The command is the same for both setup methods, -since both name the container `vault-cortex`: +behavior). The command is the same for both setup methods, since both name +the container after `INSTANCE_NAME` (`vault-cortex` unless you set it): ```bash docker restart vault-cortex @@ -237,6 +237,55 @@ docker stop vault-cortex reference). Otherwise resume with `docker compose up -d` or `docker start vault-cortex`. +## Run more than one vault + +Each vault gets its own install, with its own folder, `.env`, container, +search index, port and connector. Give every install after the first its own +`INSTANCE_NAME` before its first start. That name labels the install's +container, Compose project and index volume (`_mcp_data`); the first +install keeps the default, `vault-cortex`. + +**Set up with the CLI?** Run `init` again into a new folder: + +```bash +npx vault-cortex@latest init --dir ./vault-cortex-work +``` + +When `vault-cortex` is already in use, `init` asks for the new install's +name and port, for example `work` and `8001`. For a scripted setup, add +`--yes --vault-path --instance-name work --port 8001`. Then pass the +same `--dir` to every command for that install: + +```bash +npx vault-cortex@latest upgrade --dir ./vault-cortex-work +npx vault-cortex@latest logs --dir ./vault-cortex-work +``` + +**Set up with Docker Compose?** Copy this folder, and set these in the +copy's `.env`: + +```dotenv +INSTANCE_NAME=work +PORT=8001 +PUBLIC_URL=http://localhost:8001 +VAULT_PATH=/Users/you/Documents/WorkVault +``` + +Run each install's `docker compose` commands from its own folder. + +For each install: + +- **Connect it as its own server** in your MCP client, under its own name and + port. For Claude Code: + `claude mcp add --scope user --transport http work http://localhost:8001/mcp`. +- **Update and restart it on its own:** `upgrade --dir` or `restart --dir` + with the CLI, or `docker compose pull && docker compose up -d` in its folder + with Compose. +- **Use its name in `docker` commands.** The commands in this guide that name + the container (`docker logs vault-cortex`, `docker restart vault-cortex`, + `docker stop vault-cortex`) take the install's `INSTANCE_NAME` instead, for + example `docker logs work`. + ## Windows (Docker Desktop) Vault Cortex runs in a Linux container, so on Windows it reaches your vault through @@ -353,7 +402,9 @@ usual cause. container from a different management method is still running. The CLI (`npx vault-cortex@latest upgrade`) and Docker Compose (`docker compose up -d`) manage the container independently — stop the existing one first with -`docker rm -f vault-cortex`, then retry with your preferred method. +`docker rm -f vault-cortex`, then retry with your preferred method. Setting +up a second vault? Give it its own name instead — see +[Run more than one vault](#run-more-than-one-vault). ## Building from source diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 2cfb91999..4bf13b831 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -15,7 +15,7 @@ configuration. Both produce an identical container — restart policy, log rotation, and health check included — and Podman or any OCI-compatible container runtime works in place of Docker. -**Contents** — [Prerequisites](#prerequisites) · [Setup](#setup) · [HTTPS access](#https-access) · [Sign in](#sign-in-to-obsidian-sync) · [Connect](#connect-your-mcp-client) · [Verify](#verify) · [Monitoring](#monitoring) · [Updating](#updating) · [Restart](#restart) · [Stop](#stop) · [Memory](#memory) · [File Tools](#file-tools) · [Read-only](#read-only-mode) · [Deleting notes](#deleting-notes) · [Daily Notes](#daily-notes) · [Config](#configuration) · [Hardening](#hardening-recommended) · [Troubleshooting](#troubleshooting) +**Contents** — [Prerequisites](#prerequisites) · [Setup](#setup) · [HTTPS access](#https-access) · [Sign in](#sign-in-to-obsidian-sync) · [Connect](#connect-your-mcp-client) · [Verify](#verify) · [Monitoring](#monitoring) · [Updating](#updating) · [Restart](#restart) · [Stop](#stop) · [More than one vault](#run-more-than-one-vault) · [Memory](#memory) · [File Tools](#file-tools) · [Read-only](#read-only-mode) · [Deleting notes](#deleting-notes) · [Daily Notes](#daily-notes) · [Config](#configuration) · [Hardening](#hardening-recommended) · [Troubleshooting](#troubleshooting) ## Prerequisites @@ -493,6 +493,109 @@ docker stop vault-cortex reference). Otherwise resume with `docker compose up -d` or `docker start vault-cortex`. +## Run more than one vault + +Each vault gets its own install, with its own folder, `.env`, container, +volumes, port, Obsidian Sync device and connector. Give every install after +the first its own `INSTANCE_NAME` before its first start. That name labels +the install's container, Compose project and volumes (`_vault_data`, +`_mcp_data` and `_obsidian_config`); the first install keeps the +default, `vault-cortex`. A second vault must never use the first install's +volumes: the container refuses to start rather than merge the two vaults +(see [Another vault's Sync setup](#another-vaults-sync-setup)). + +**Set up with the CLI?** Run `init` again into a new folder: + +```bash +npx vault-cortex@latest init --mode remote --dir ./vault-cortex-work +``` + +When `vault-cortex` is already in use, `init` asks for the new install's +name and port, for example `work` and `8001`, and names its Sync device after +it. Then pass the same `--dir` to every command for that install: + +```bash +npx vault-cortex@latest upgrade --dir ./vault-cortex-work +npx vault-cortex@latest logs --dir ./vault-cortex-work +``` + +**Set up with Docker Compose?** Copy this folder, and set these in the +copy's `.env`: + +```dotenv +INSTANCE_NAME=work +PORT=8001 +DEVICE_NAME=work +VAULT_NAME=Work +PUBLIC_URL=https://work.example.com +``` + +Run each install's `docker compose` commands from its own folder. + +**Set up with `docker run`?** In the command from [Setup](#setup), replace +`vault-cortex` with the new name in `--name`, `--hostname` and the three +volume names, and the host port in `-p` (`-p 8001:8000`). Set its own +`DEVICE_NAME` in its `.env`. + +For each install: + +- **Give it its own address.** Route a separate host name to its port in your + [HTTPS access](#https-access) setup, and use that as its `PUBLIC_URL`. +- **Connect it as its own server** in your MCP client, under its own name. + For Claude Code: + `claude mcp add --scope user --transport http work /mcp`. +- **Update and restart it on its own:** `upgrade --dir` or `restart --dir` + with the CLI, `docker compose pull && docker compose up -d` in its folder + with Compose, or the `docker run` steps under [Updating](#updating) with its + name. +- **Use its name in `docker` commands.** The commands in this guide that name + the container or its volumes (`docker logs vault-cortex`, + `docker restart vault-cortex`, `docker rm -f vault-cortex`, + `vault-cortex_vault_data`) take the install's name instead, for example + `docker logs work`. + +### Another vault's Sync setup + +When the container's volumes hold the Obsidian Sync setup for a different +vault than `VAULT_NAME`, the container stops before it syncs or changes +anything, because starting would merge the two vaults' notes through +Obsidian Sync. `docker logs` then shows: + +```text +[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME. +[obsidian-sync] VAULT_NAME is 'Work' (), but its Sync setup is for 'Personal' (). +``` + +It usually means `VAULT_NAME` was changed, or a second install is using the +first one's volumes. Fix it one of these ways: + +- **`VAULT_NAME` is a mistake:** set it back and re-create the container. The + **Changed `.env`?** note under [Restart](#restart) gives the command for + each setup method. +- **You want to sync both vaults:** give the second vault its own install, as + described above. On Render or Railway, deploy it as a separate service. +- **You want this install to sync the new vault instead:** delete all of its + volumes together (the **Stop and delete all volumes** commands under + [Stop](#stop)), then start it again. The container downloads the new vault + like a first start, and the old vault stays in Obsidian Sync. Deleting only + `obsidian_config` would upload the old vault's notes into the new one. On + Render or Railway, follow the entry in that platform's guide. +- **You re-created the same vault in Obsidian**, the log says the old one "is + no longer in your account", and the notes on the server are that vault's + notes: remove only the old setup folder the log names, then start the + container: + + ```bash + docker run --rm -v vault-cortex_obsidian_config:/home/obsidian/.config \ + --entrypoint rm ghcr.io/aliasunder/vault-cortex:remote \ + -r /home/obsidian/.config/obsidian-headless/sync/ + ``` + +A **WARNING** that the install "also holds Sync setups for other vaults" +means the container started, because it uses the right vault, but one of +those setups could be used on a later start. Remove each one's folder with +the same command, using the vault ID from the warning. + ## Memory The memory layer is enabled by default. Set `MEMORY_ENABLED=false` in your @@ -605,6 +708,7 @@ These optional settings are worth knowing about: | `READONLY_MODE` | `false` | Set `true` to hide every tool that changes the vault and skip memory folder auto-creation — read and search only | | `DISABLED_TOOLS` | none hidden | Hide individual tools by name, comma-separated; names match the [README tools table](https://github.com/aliasunder/vault-cortex#tools) | | `SYNC_CONFIGS` | daily notes, plugins, and app | Obsidian settings categories synced to the server (see [Daily notes](#daily-notes)); `none` disables | +| `INSTANCE_NAME` | `vault-cortex` | Names this install's container, Compose project and volumes. Set it before the first start to run another vault on the same server (see [Run more than one vault](#run-more-than-one-vault)) | | `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log | | `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | | `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | @@ -697,16 +801,23 @@ container, because `docker start` keeps the old settings. The **Changed `.env`?** note under [Restart](#restart) gives the command for each setup method. +**"This install is set up to sync a different vault than VAULT_NAME" in +`docker logs`, and the container stops.** The container's volumes hold the +Obsidian Sync setup for another vault, and starting would merge the two. +[Another vault's Sync setup](#another-vaults-sync-setup) gives the fixes. + **"container name vault-cortex already in use" on start or upgrade.** A container from a different management method is still running. The CLI (`npx vault-cortex@latest upgrade`) and Docker Compose (`docker compose up -d`) manage the container independently — stop the existing one first with -`docker rm -f vault-cortex`, then retry with your preferred method. +`docker rm -f vault-cortex`, then retry with your preferred method. Setting +up a second vault? Give it its own name instead — see +[Run more than one vault](#run-more-than-one-vault). **Vault re-syncs from scratch (or the search index is empty) after switching between the CLI, Compose, and `docker run`.** Nothing is lost — your data is -still in the old volumes. All three methods use the same named volumes, so -data normally carries over — but only when the names match exactly, and a +still in the old volumes. All three methods use the same named volumes for +the same `INSTANCE_NAME`, so data normally carries over — but only when the names match exactly, and a mismatch produces **no error**. Docker silently creates fresh, empty volumes and the container starts with a clean slate: Obsidian Sync registers a new device and re-syncs the vault, and the search index rebuilds. From 24877a8e54435decdeeff0efe79cf14813577218 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:43:41 -0400 Subject: [PATCH 08/57] test: expect ShellCheck to cover check-sync-vault Co-Authored-By: Claude Opus 5.5 --- scripts/__tests__/lint-shell.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/__tests__/lint-shell.test.ts b/scripts/__tests__/lint-shell.test.ts index 65f524662..c82883b62 100644 --- a/scripts/__tests__/lint-shell.test.ts +++ b/scripts/__tests__/lint-shell.test.ts @@ -24,6 +24,7 @@ const ALL_SHELL_SCRIPTS = [ "rootfs/etc/s6-overlay/s6-rc.d/svc-obsidian-sync/run", "rootfs/etc/s6-overlay/s6-rc.d/svc-vault-mcp/finish", "rootfs/etc/s6-overlay/s6-rc.d/svc-vault-mcp/run", + "rootfs/etc/s6-overlay/scripts/check-sync-vault", "rootfs/etc/s6-overlay/scripts/init-check-auth", "rootfs/etc/s6-overlay/scripts/init-derive-env", "rootfs/etc/s6-overlay/scripts/init-first-sync", From 707ba03bb9dd8a69b44233a3fc17f7e26655a143 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:52:24 -0400 Subject: [PATCH 09/57] fix(remote): give check-sync-vault's by-hand command the s6 PATH and environment docker exec leaves /command off PATH, so the documented command could not find s6-setuidgid; with-contenv also loads the paths the init chain derives. Co-Authored-By: Claude Opus 5.5 --- rootfs/etc/s6-overlay/scripts/check-sync-vault | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index e02937d40..05cd4e67c 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -16,8 +16,10 @@ # (nothing when VAULT_NAME names no single vault), which init-setup-vault # checks again after ob sync-setup. Exit 1 stops the boot. Every message goes # to stderr. It can also run by hand inside a running container, which prints -# the same decision: -# docker exec -i -w /vault sh -s < check-sync-vault +# the same decision. docker exec leaves s6's /command off PATH, and +# with-contenv loads the environment the init chain published: +# docker exec -i -w /vault -e PATH=/command:/usr/bin:/bin \ +# /command/with-contenv sh -s < check-sync-vault set -u if [ -z "${VAULT_NAME:-}" ]; then From b37f13c80dcc13da13d20e78dbb64088c451bf66 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:10:17 -0400 Subject: [PATCH 10/57] fix(review): cover PUBLIC_URL and single-volume layouts in the second-vault docs, and list the Sync vault check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - llms-install.md: a second install also sets PUBLIC_URL on its port, and only the docker exec commands take the instance name (the Compose service stays vault-cortex) - deploy/remote/README.md: the docker run and re-created-vault steps cover the single-volume (STORAGE_ROOT) layout the guide offers - SECURITY.md: list the new Sync vault check beside the Sync-state guard Ship-Check: pr-review · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- SECURITY.md | 6 ++++++ deploy/remote/README.md | 7 +++++-- llms-install.md | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index fd7bbcd0a..ae3b6c626 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -160,6 +160,12 @@ mechanism-level detail. synced `.obsidian/` settings), the container refuses to start, preventing the sync engine from interpreting the empty vault as mass local deletions +- Sync vault check (remote image): before `ob sync-setup` writes anything, + the init chain asks the Sync client which vault it would sync into the + vault folder. When that is a vault other than `VAULT_NAME`, or a Sync + setup file there is not a valid setup, the container refuses to start + rather than merge two vaults through Obsidian Sync. It checks the Sync + setup, not which vault the files in the folder came from - Trash sweep identity gate: the retention sweep deletes a recorded `.trash/` file only while it still has the identity recorded when the server trashed it and has not changed since, so a different note that diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 4bf13b831..669c87473 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -533,8 +533,8 @@ PUBLIC_URL=https://work.example.com Run each install's `docker compose` commands from its own folder. **Set up with `docker run`?** In the command from [Setup](#setup), replace -`vault-cortex` with the new name in `--name`, `--hostname` and the three -volume names, and the host port in `-p` (`-p 8001:8000`). Set its own +`vault-cortex` with the new name in `--name`, `--hostname` and the volume +names, and the host port in `-p` (`-p 8001:8000`). Set its own `DEVICE_NAME` in its `.env`. For each install: @@ -591,6 +591,9 @@ first one's volumes. Fix it one of these ways: -r /home/obsidian/.config/obsidian-headless/sync/ ``` + With a single volume (`STORAGE_ROOT`), mount `vault-cortex_storage:/persist` + instead, and remove the folder the log names under `/persist/config`. + A **WARNING** that the install "also holds Sync setups for other vaults" means the container started, because it uses the right vault, but one of those setups could be used on a later start. Remove each one's folder with diff --git a/llms-install.md b/llms-install.md index 4678207d4..393851977 100644 --- a/llms-install.md +++ b/llms-install.md @@ -163,7 +163,7 @@ docker compose down -v # stop and delete the index (rebuilds on next sta docker compose pull && docker compose up -d --force-recreate # update to the latest image ``` -To serve a second vault from the same machine, set it up in a separate folder with its own `.env` that sets `INSTANCE_NAME` (for example `INSTANCE_NAME=work`) and a free `PORT`, then register it in the client under its own name. `INSTANCE_NAME` names that install's container and index volume, so the commands in this guide use that name in place of `vault-cortex` (`docker exec work …`). +To serve a second vault from the same machine, set it up in a separate folder with its own `.env` that sets `INSTANCE_NAME` (for example `INSTANCE_NAME=work`), a free `PORT`, and `PUBLIC_URL` on that port (for example `http://localhost:8001`), then register it in the client under its own name and URL. `INSTANCE_NAME` names that install's container and index volume, so the `docker exec` commands in this guide use that name in place of `vault-cortex` (`docker exec work …`) and its URLs use its `PORT`. The `docker compose` commands keep the service name `vault-cortex` and run from that install's folder. ## More From b52eecba627b99d574fa598191308ab69e8fcb48 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:12:37 -0400 Subject: [PATCH 11/57] fix(cli): give a remote install the device name of a free --instance-name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive "own name" answer already sets DEVICE_NAME to the new name; the flag path kept the template's vault-cortex, so two remote installs showed the same device label in Obsidian Sync's history. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 30 ++++++++++++++++++++++++++++++ cli/src/instance-identity.ts | 8 +++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 186a297b5..188162978 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1593,6 +1593,36 @@ describe("runInit when the instance name is already in use", () => { expect(envLines).toContain("DEVICE_NAME=work") }) + it("gives a remote install the device name of a free --instance-name", async () => { + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "https://work.example.com", // public URL + "Work", // vault name + false, // don't generate the token now + false, // no encryption + [], // no optional settings + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir, instanceName: "work", port: "8001" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked[0]).toBe( + "Public base URL clients will use to reach this server (no /mcp — it's added for you):", + ) + const envLines = readEnvLines(targetDir) + expect(envLines).toContain("INSTANCE_NAME=work") + expect(envLines).toContain("PORT=8001") + expect(envLines).toContain("DEVICE_NAME=work") + expect(envLines).not.toContain("DEVICE_NAME=vault-cortex") + }) + it("writes nothing extra when the user reuses the existing data", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 4248786d2..83a03cccb 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -151,7 +151,13 @@ export const resolveInstanceIdentity = async ( const nameCheckPossible = docker.daemonStatus() === "running" if (!nameCheckPossible || !isInstanceNameInUse({ mode, instanceName: requestedName }, docker)) { - return { kind: "ok", identity: { carried: {}, chosen: flagOverrides } } + // A free name from --instance-name is a new install, so a remote one gets + // the matching device name, as the interactive "own name" answer gives it. + const chosen = { + ...flagOverrides, + ...(mode === "remote" && flags.instanceName ? { DEVICE_NAME: flags.instanceName } : {}), + } + return { kind: "ok", identity: { carried: {}, chosen } } } if (flags.instanceName) { prompts.log(`Reusing the data of the existing "${requestedName}" install.`) From 0f91875293b294ea50818b888adaaf2ac598a98b Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:29:29 -0400 Subject: [PATCH 12/57] style: clarify instance identity code, Sync vault check comments and second-vault docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename validateInstanceName to describeInvalidInstanceName, and give init's instance-name, token and env-content locals names that match what they hold; one helper states the DEVICE_NAME rule for a new remote install. - Positive in-use boolean and explicit flag guard in resolveInstanceIdentity; shared askTargetDir prompt with a named intermediate in both init flows; rule widths computed from their label strings. - Comments state why re-runs skip the in-use check, why the CLI fixes the remote hostname, what STORAGE_ROOT is, and why a failed local listing only skips the warning. - Deploy guides: say where to run the second init, what counts as in use, which folder and token a second Compose install starts from, how each HTTPS option routes a second port, and what a stopped container's invalid-setup error means; drop a cross-reference to a refusal that cannot fire after /persist/config is removed. Ship-Check: code-quality · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 18 ++- SECURITY.md | 4 +- cli/README.md | 7 +- cli/src/docker.ts | 10 +- cli/src/env.ts | 21 ++-- cli/src/init.ts | 68 ++++++------ cli/src/instance-identity.ts | 51 ++++++--- cli/src/messages.ts | 16 ++- cli/src/scaffold.ts | 7 +- deploy/local/.env.example | 8 +- deploy/local/README.md | 28 +++-- deploy/railway/README.md | 14 ++- deploy/remote/.env.example | 13 ++- deploy/remote/README.md | 105 +++++++++++------- deploy/render/README.md | 10 +- llms-install.md | 8 +- .../etc/s6-overlay/scripts/check-sync-vault | 15 ++- rootfs/etc/s6-overlay/scripts/init-first-sync | 1 + 18 files changed, 245 insertions(+), 159 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fe8b01775..94c997de2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -927,16 +927,22 @@ graph LR `init-check-auth` (looks for the Sync token in the env var, then in the Sync client's own credential file on the config volume; with neither, the boot switches to [setup mode](#setup-mode) instead of stopping) → - `init-obsidian-login` (`ob login`) → `init-setup-vault` (first - `check-sync-vault`, which stops the boot before anything is written when - the volume's Sync setup is for a different vault than `VAULT_NAME`; then - `ob sync-setup` with `--device-name`, a check that the client now uses - `VAULT_NAME`'s vault, and optional sync-config; fails fast when - `VAULT_NAME` is missing or `SYNC_MODE` fails to apply) → `init-first-sync` + `init-obsidian-login` (`ob login`) → `init-setup-vault` (sets up the + vault's Sync in the steps listed after this chain) → `init-first-sync` (one-shot `ob sync` run to _completion_, with retries; its four steps, failure branches included, are listed below). Any fatal init failure stops the container (`S6_BEHAVIOUR_IF_STAGE2_FAILS=2`) — the restart policy owns retry. + + `init-setup-vault` fails fast when `VAULT_NAME` is missing or `SYNC_MODE` + fails to apply. Its steps, in order: + + 1. `check-sync-vault` stops the boot, before anything is written, when the + volume's Sync setup is for a different vault than `VAULT_NAME`. + 2. `ob sync-setup` with `--device-name`, then a check that the Sync client + now uses `VAULT_NAME`'s vault. + 3. Optional sync-config settings. + 2. **`svc-obsidian-sync`** — bidirectional Obsidian Sync (`ob sync --continuous`). Stores sync state in the config volume at `/home/obsidian/.config` (persists across restarts for incremental sync — diff --git a/SECURITY.md b/SECURITY.md index ae3b6c626..ca407eb2f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -165,7 +165,9 @@ mechanism-level detail. vault folder. When that is a vault other than `VAULT_NAME`, or a Sync setup file there is not a valid setup, the container refuses to start rather than merge two vaults through Obsidian Sync. It checks the Sync - setup, not which vault the files in the folder came from + setup, not which vault the files in the folder came from, so notes left in + the folder after their vault's setup was removed still sync into + `VAULT_NAME`'s vault - Trash sweep identity gate: the retention sweep deletes a recorded `.trash/` file only while it still has the identity recorded when the server trashed it and has not changed since, so a different note that diff --git a/cli/README.md b/cli/README.md index b9e700fa7..08b693c40 100644 --- a/cli/README.md +++ b/cli/README.md @@ -65,8 +65,8 @@ remote setup, init offers to generate your [Obsidian Sync token](#get-sync-token) as part of the flow. To run a second vault on the same machine, run init again with its own -`--dir`. When the default name `vault-cortex` is already in use, init asks -you to give the new install its own name and port. That name (`INSTANCE_NAME` +`--dir`. When the machine already has a `vault-cortex` container or data +volume, init asks you to give the new install its own name and port. That name (`INSTANCE_NAME` in its `.env`) names the install's container and data, and every command run with that `--dir` acts only on it. Each install gets its own connector in your MCP client. @@ -79,7 +79,8 @@ Flags: `./vault-cortex`) - `--instance-name ` — name for this install's container and data (default `vault-cortex`); lowercase letters, digits, `-` and `_`. Naming an - install that already exists reuses its data, for the same vault set up again + install that already exists reuses its data, so use it only for the same + vault set up again - `--port ` — host port for this install (default `8000`) - `--yes` — non-interactive local setup with defaults; requires `--vault-path`, and `--instance-name` when the name is already in use diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 8a255c3c9..ee059c1eb 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -131,6 +131,8 @@ export const buildDockerRunArgs = (params: DockerRunParams): string[] => { args.push("--health-retries", "3") args.push("--health-start-period", "20s") } else { + // The init chain names the Sync device after the hostname when the .env + // sets no DEVICE_NAME; a fixed name keeps it from being a container ID. args.push("--hostname", instanceName) args.push(...volumeMountArgs(mode, instanceName)) args.push("--health-cmd", HEALTH_CMD) @@ -198,10 +200,10 @@ export const createDockerRunner = (): DockerRunner => ({ stopAndRemoveContainer: (instanceName) => { return spawnSync("docker", ["rm", "-f", instanceName]).status === 0 }, - // `docker rm -f` on a missing container exits 1 on engines < 23 and 0 on - // >= 23, so stopAndRemoveContainer's status can't distinguish "already - // gone" from "failed" — callers needing idempotent messaging probe - // existence first. Output stays piped (discarded): this is a boolean probe. + // Callers that must tell "already gone" from "failed" probe with this + // before stopAndRemoveContainer: `docker rm -f` on a missing container + // exits 1 on engines < 23 and 0 on >= 23, so its status can't tell them + // apart. Output stays piped (discarded): this is a boolean probe. containerExists: (instanceName) => { return spawnSync("docker", ["container", "inspect", instanceName]).status === 0 }, diff --git a/cli/src/env.ts b/cli/src/env.ts index 59fea998a..5e24cb50c 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -30,6 +30,7 @@ const LOCAL_OPTIONAL_BLOCK = `# Optional ───────────── # Public URL for OAuth issuer and access-token binding (default: http://localhost:8000). # Override if you expose the server on a different URL (e.g. via a reverse proxy). +# When you change PORT, change the port here too, or MCP clients can't sign in. PUBLIC_URL=http://localhost:8000 # Largest file vault_read_file will read, in bytes (default: 52428800 = 50 MiB). @@ -133,9 +134,10 @@ MEMORY_DIR=About Me # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex -# Set it before the first start. Changing it later starts the install on a new, -# empty index volume while the old one stays, so stop the old container first -# (docker compose down, or npx vault-cortex down). +# Set it before the first start. To change it later, stop the container first +# (docker compose down, or npx vault-cortex down), then change it: the install +# starts on a new, empty index volume, and the old one stays until you remove +# it (docker volume rm _mcp_data). INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). @@ -293,15 +295,15 @@ MEMORY_DIR=About Me # Name of this install's container, Compose project and data volumes # (_vault_data, _mcp_data and _obsidian_config). To run more -# than one vault on this server, give each install its own INSTANCE_NAME, PORT -# and DEVICE_NAME. +# than one vault on this server, give each install its own INSTANCE_NAME, PORT, +# PUBLIC_URL and DEVICE_NAME. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex -# Set it before the first start. Changing it later starts the install on new, -# empty volumes, so the vault downloads again as a new Obsidian Sync device -# while the old volumes stay. Stop the old container first -# (docker compose down, or npx vault-cortex down). +# Set it before the first start. To change it later, stop the container first +# (docker compose down, or npx vault-cortex down), then change it: the install +# starts on new, empty volumes and downloads the vault again as a new Obsidian +# Sync device, while the old volumes stay until you remove them. INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). @@ -348,6 +350,7 @@ PUID=1000 PGID=1000 # Device name the container reports to Obsidian Sync — labels its changes in the sync log. +# Give each install on a server its own, to tell their changes apart. DEVICE_NAME=vault-cortex # Obsidian Sync conflict resolution: merge | conflict (default: merge). diff --git a/cli/src/init.ts b/cli/src/init.ts index 0cc99b7ca..0bb194acb 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -197,6 +197,13 @@ export const validatePublicUrl = (input: string): PublicUrlValidation => { return { kind: "ok", url: trimmed.replace(/\/+$/, "") } } +const askTargetDir = async (prompts: Prompts): Promise => { + return await prompts.text("Where should I put the config files?", { + defaultValue: DEFAULT_TARGET_DIR, + placeholder: DEFAULT_TARGET_DIR, + }) +} + /** Re-prompts until the answer is a valid base http(s) URL (no /mcp path). */ const askPublicUrl = async (prompts: Prompts): Promise => { const answer = await prompts.text( @@ -280,7 +287,7 @@ const reportWrites = ( * whose INSTANCE_NAME differs from this run's. Reports an invalid value and * returns undefined, so init never starts or prints commands for a bad name. */ -const readWrittenInstanceName = (targetDir: string, prompts: Prompts): string | undefined => { +const readInstanceNameFromDisk = (targetDir: string, prompts: Prompts): string | undefined => { const instanceNameRead = readEnvInstanceName(join(targetDir, ".env")) if (instanceNameRead.kind === "ok") return instanceNameRead.instanceName @@ -359,6 +366,8 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = if (flags.yes) { if (!vaultPathResult || vaultPathResult.kind === "error") { + // runInit rejects --yes without --vault-path before calling this, so the + // fallback message is unreachable; it covers vaultPathResult's undefined type. prompts.error(vaultPathResult?.message ?? "--yes requires --vault-path.") return 1 } @@ -376,19 +385,11 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = ? vaultPathResult.path : await askVaultPath(prompts) + const requestedTargetDir = + flags.dir ?? (flags.yes ? DEFAULT_TARGET_DIR : await askTargetDir(prompts)) // expandTilde before resolve: resolve() treats a leading `~` as a literal // path segment, so a quoted "~/path" would create a directory named "~". - const targetDir = resolve( - expandTilde( - flags.dir ?? - (flags.yes - ? DEFAULT_TARGET_DIR - : await prompts.text("Where should I put the config files?", { - defaultValue: DEFAULT_TARGET_DIR, - placeholder: DEFAULT_TARGET_DIR, - })), - ), - ) + const targetDir = resolve(expandTilde(requestedTargetDir)) // --yes skips the guard: it's non-interactive by contract, and its own // conflict policy (refuse to overwrite, exit 1) already protects the dir. @@ -411,16 +412,16 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = // user asked for a full re-run — the answers land in the regenerated file // when they overwrite at the conflict prompt (keeping it discards them, // which the write report states). In-place edits stay configure's job. - const defaultEnvContent = applyInstanceIdentity( + const generatedEnvContent = applyInstanceIdentity( buildLocalEnv({ mcpAuthToken: token, vaultPath }), identityResult.identity, ) const optionalOverrides = flags.yes ? {} - : await askOptionalSettings({ mode: "local", envContent: defaultEnvContent }, prompts) + : await askOptionalSettings({ mode: "local", envContent: generatedEnvContent }, prompts) const envContent = applyOptionalSettings( - defaultEnvContent, - derivePublicUrlOverride(defaultEnvContent, optionalOverrides), + generatedEnvContent, + derivePublicUrlOverride(generatedEnvContent, optionalOverrides), ) // Conflict policy: identical existing files are skipped silently; @@ -448,7 +449,7 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") const port = readEnvPort(join(targetDir, ".env")) - const instanceName = readWrittenInstanceName(targetDir, prompts) + const instanceName = readInstanceNameFromDisk(targetDir, prompts) if (!instanceName) return 1 @@ -477,24 +478,17 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise => { const { prompts, fetchFn } = deps + const requestedTargetDir = flags.dir ?? (await askTargetDir(prompts)) // expandTilde before resolve: resolve() treats a leading `~` as a literal // path segment, so a quoted "~/path" would create a directory named "~". - const targetDir = resolve( - expandTilde( - flags.dir ?? - (await prompts.text("Where should I put the config files?", { - defaultValue: DEFAULT_TARGET_DIR, - placeholder: DEFAULT_TARGET_DIR, - })), - ), - ) + const targetDir = resolve(expandTilde(requestedTargetDir)) const continueReinit = await confirmReinitOverExistingEnv(targetDir, prompts) if (!continueReinit) return 0 - // Before the public URL prompt, so a second install's new port is known when - // its URL is typed. + // Before the public URL prompt, so a second install's port is chosen before + // the user types the URL that reaches it. const identityResult = await resolveInstanceIdentity({ mode: "remote", targetDir, flags }, deps) if (identityResult.kind === "exit") return 1 @@ -508,9 +502,9 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise // the "run get-sync-token later" guidance when neither source has a token. const capturedToken = await offerSyncTokenCapture(prompts, fetchFn) const existingEnvToken = readEnvObsidianToken(join(targetDir, ".env")) - const hasExistingToken = Boolean(capturedToken ?? existingEnvToken) + const hasSyncToken = Boolean(capturedToken ?? existingEnvToken) - if (!hasExistingToken) { + if (!hasSyncToken) { prompts.log( "No token yet — run this later to add it to your .env:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, @@ -529,7 +523,7 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise // .env passed the re-init guard — a consented re-run gets the full setup, // chooser included (see the local flow's comment for the overwrite/keep // semantics). - const defaultEnvContent = applyInstanceIdentity( + const generatedEnvContent = applyInstanceIdentity( buildRemoteEnv({ mcpAuthToken: token, publicUrl, @@ -540,12 +534,12 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise identityResult.identity, ) const optionalOverrides = await askOptionalSettings( - { mode: "remote", envContent: defaultEnvContent }, + { mode: "remote", envContent: generatedEnvContent }, prompts, ) const envContent = applyOptionalSettings( - defaultEnvContent, - derivePublicUrlOverride(defaultEnvContent, optionalOverrides), + generatedEnvContent, + derivePublicUrlOverride(generatedEnvContent, optionalOverrides), ) const files = buildFilesToWrite(envContent) const results = await writeFiles({ targetDir, files }, confirmOverwrite(prompts)) @@ -565,13 +559,13 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise // server only reads the file. The prompted value is the fallback for a kept // legacy .env that predates PUBLIC_URL. const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl - const instanceName = readWrittenInstanceName(targetDir, prompts) + const instanceName = readInstanceNameFromDisk(targetDir, prompts) if (!instanceName) return 1 // Without the sync token the container can't start (init-check-auth fails // and s6 stops it), so only offer docker run when it was provided. - const startStatus: StartStatus = !hasExistingToken + const startStatus: StartStatus = !hasSyncToken ? "not-started" : await offerDockerRun({ targetDir, instanceName, port, mode: "remote" }, deps) @@ -590,7 +584,7 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise token, publicUrl: effectivePublicUrl, startStatus, - obsidianTokenMissing: !hasExistingToken, + obsidianTokenMissing: !hasSyncToken, tokenWritten, }), ) diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 83a03cccb..421c10098 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -9,14 +9,17 @@ import { isValidPort, readOptionalValue, } from "./optional-settings.js" -import { DEFAULT_INSTANCE_NAME, validateInstanceName, type Mode } from "./scaffold.js" +import { DEFAULT_INSTANCE_NAME, describeInvalidInstanceName, type Mode } from "./scaffold.js" import type { Prompts } from "./prompts.js" /** The .env values that tell one install on a machine apart from another. */ export type InstanceIdentity = { /** Copied verbatim from the folder's existing .env when init runs again over it. */ carried: Record - /** Chosen in this run, by flag or prompt. A local PUBLIC_URL follows a chosen PORT. */ + /** + * Chosen in this run, by flag or prompt. A `http://localhost:` + * PUBLIC_URL follows a chosen PORT. + */ chosen: Record } @@ -60,6 +63,8 @@ const isInstanceNameInUse = ( return instanceVolumeNames(params).some((volumeName) => docker.volumeExists(volumeName)) } +const EXAMPLE_INSTANCE_NAME = "work" + /** Re-prompts until the answer is a valid name no other install on the machine uses. */ const askFreeInstanceName = async ( params: { mode: Mode; suggestedName: string | undefined }, @@ -70,10 +75,10 @@ const askFreeInstanceName = async ( const answer = ( await prompts.text("Name for this install (lowercase letters, digits, - and _):", { defaultValue: suggestedName, - placeholder: suggestedName ?? "work", + placeholder: suggestedName ?? EXAMPLE_INSTANCE_NAME, }) ).trim() - const invalidReason = validateInstanceName(answer) + const invalidReason = describeInvalidInstanceName(answer) if (invalidReason) { prompts.error(invalidReason) @@ -96,12 +101,20 @@ const suggestInstanceName = ( ): string | undefined => { const folderName = basename(params.targetDir).toLowerCase() - if (validateInstanceName(folderName)) return undefined + if (describeInvalidInstanceName(folderName)) return undefined if (isInstanceNameInUse({ mode: params.mode, instanceName: folderName }, docker)) return undefined return folderName } -const inUseOnYesMessage = (instanceName: string): string => { +/** + * A new remote install's Sync device takes the install's name, so Obsidian + * Sync's history tells its changes apart from the first install's. + */ +const buildDeviceNameSetting = (mode: Mode, instanceName: string): Record => { + return mode === "remote" ? { DEVICE_NAME: instanceName } : {} +} + +const nameInUseWithYesFlagMessage = (instanceName: string): string => { return ( `An install named "${instanceName}" already exists on this machine (its container or data volumes are here).\n` + `Re-run with --instance-name (and --port if ${instanceName}'s port is taken) to give this install its own name,\n` + @@ -125,7 +138,9 @@ export const resolveInstanceIdentity = async ( const { mode, targetDir, flags } = params const { prompts, docker } = deps - const flagNameError = flags.instanceName && validateInstanceName(flags.instanceName) + const flagNameError = flags.instanceName + ? describeInvalidInstanceName(flags.instanceName) + : undefined if (flagNameError) { prompts.error(`--instance-name: ${flagNameError}`) @@ -142,20 +157,23 @@ export const resolveInstanceIdentity = async ( const existingEnvPath = join(targetDir, ".env") + // An existing .env makes this folder an install already, and its own + // container and volumes would always count as in use, so a re-run skips the check. if (existsSync(existingEnvPath)) { const carried = readCarriedIdentity(existingEnvPath, mode) return { kind: "ok", identity: { carried, chosen: flagOverrides } } } const requestedName = flags.instanceName ?? DEFAULT_INSTANCE_NAME - const nameCheckPossible = docker.daemonStatus() === "running" + const requestedNameInUse = + docker.daemonStatus() === "running" && + isInstanceNameInUse({ mode, instanceName: requestedName }, docker) - if (!nameCheckPossible || !isInstanceNameInUse({ mode, instanceName: requestedName }, docker)) { - // A free name from --instance-name is a new install, so a remote one gets - // the matching device name, as the interactive "own name" answer gives it. + // A name not in use, or one Docker could not check, starts a new install. + if (!requestedNameInUse) { const chosen = { ...flagOverrides, - ...(mode === "remote" && flags.instanceName ? { DEVICE_NAME: flags.instanceName } : {}), + ...(flags.instanceName ? buildDeviceNameSetting(mode, flags.instanceName) : {}), } return { kind: "ok", identity: { carried: {}, chosen } } } @@ -164,7 +182,7 @@ export const resolveInstanceIdentity = async ( return { kind: "ok", identity: { carried: {}, chosen: flagOverrides } } } if (flags.yes) { - prompts.error(inUseOnYesMessage(requestedName)) + prompts.error(nameInUseWithYesFlagMessage(requestedName)) return { kind: "exit" } } @@ -191,12 +209,10 @@ export const resolveInstanceIdentity = async ( const suggestedName = suggestInstanceName({ mode, targetDir }, docker) const instanceName = await askFreeInstanceName({ mode, suggestedName }, deps) const port = await askPort(flags.port, prompts) - // The device name labels this install's changes in Obsidian Sync's history, - // so a second remote install is told apart from the first there too. const chosen = { INSTANCE_NAME: instanceName, PORT: port, - ...(mode === "remote" ? { DEVICE_NAME: instanceName } : {}), + ...buildDeviceNameSetting(mode, instanceName), } return { kind: "ok", identity: { carried: {}, chosen } } } @@ -205,7 +221,8 @@ export const resolveInstanceIdentity = async ( * Writes the identity into freshly generated .env content, before the * optional-settings chooser reads it, so the chooser shows the identity's port * as current. Carried values land verbatim; chosen ones go through - * derivePublicUrlOverride, so a local PUBLIC_URL follows a new port. + * derivePublicUrlOverride, so a `http://localhost:` PUBLIC_URL follows a + * new port. */ export const applyInstanceIdentity = (envContent: string, identity: InstanceIdentity): string => { const withCarried = applyOptionalSettings(envContent, identity.carried) diff --git a/cli/src/messages.ts b/cli/src/messages.ts index 1ed3cbcce..b76025bf2 100644 --- a/cli/src/messages.ts +++ b/cli/src/messages.ts @@ -19,13 +19,19 @@ const paint = (style: TextStyle, text: string): string => const RULE_WIDTH = 56 -const topRule = (label: string): string => - paint("dim", `╭── ${label} ${"─".repeat(Math.max(0, RULE_WIDTH - label.length - 6))}╮`) +const topRule = (label: string): string => { + const labelPart = `╭── ${label} ` + const fillWidth = Math.max(0, RULE_WIDTH - labelPart.length - "╮".length) + return paint("dim", `${labelPart}${"─".repeat(fillWidth)}╮`) +} -const bottomRule = (): string => paint("dim", `╰${"─".repeat(RULE_WIDTH - 2)}╯`) +const bottomRule = (): string => paint("dim", `╰${"─".repeat(RULE_WIDTH - "╰╯".length)}╯`) -const sectionRule = (label: string): string => - paint("dim", `── ${label} ${"─".repeat(Math.max(0, RULE_WIDTH - label.length - 4))}`) +const sectionRule = (label: string): string => { + const labelPart = `── ${label} ` + const fillWidth = Math.max(0, RULE_WIDTH - labelPart.length) + return paint("dim", `${labelPart}${"─".repeat(fillWidth)}`) +} /** * Daemon-stopped guidance shared by every command that needs the container diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index 938b1c4dd..cedd39589 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -85,7 +85,7 @@ export const readEnvPort = (envFilePath: string): number => { } /** Returns why a candidate instance name is invalid, or undefined when it is valid. */ -export const validateInstanceName = (candidateName: string): string | undefined => { +export const describeInvalidInstanceName = (candidateName: string): string | undefined => { if (INSTANCE_NAME_PATTERN.test(candidateName)) return undefined return `"${candidateName}" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.` } @@ -107,7 +107,7 @@ export const readEnvInstanceName = (envFilePath: string): InstanceNameRead => { const instanceName = rawValue ? stripSurroundingQuotes(rawValue) : "" if (!instanceName) return { kind: "ok", instanceName: DEFAULT_INSTANCE_NAME } - const invalidReason = validateInstanceName(instanceName) + const invalidReason = describeInvalidInstanceName(instanceName) if (invalidReason) return { kind: "invalid", message: `INSTANCE_NAME ${invalidReason}` } return { kind: "ok", instanceName } @@ -190,7 +190,8 @@ export const patchEnvObsidianToken = (envFilePath: string, token: string): boole /** * Reads the OBSIDIAN_AUTH_TOKEN value from an existing .env file. Returns * undefined when the file is missing, has no active line, or the value is - * empty — an empty `OBSIDIAN_AUTH_TOKEN=` line is not a valid token. + * empty — an empty `OBSIDIAN_AUTH_TOKEN=` line is not a valid token. Quotes + * stay in the value, because init writes it back into the regenerated .env. */ export const readEnvObsidianToken = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 79797d9c6..687c1d2a6 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -20,6 +20,7 @@ VAULT_PATH= # Public URL for OAuth issuer and access-token binding (default: http://localhost:8000). # Override if you expose the server on a different URL (e.g. via a reverse proxy). +# When you change PORT, change the port here too, or MCP clients can't sign in. PUBLIC_URL=http://localhost:8000 # Largest file vault_read_file will read, in bytes (default: 52428800 = 50 MiB). @@ -123,9 +124,10 @@ MEMORY_DIR=About Me # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex -# Set it before the first start. Changing it later starts the install on a new, -# empty index volume while the old one stays, so stop the old container first -# (docker compose down, or npx vault-cortex down). +# Set it before the first start. To change it later, stop the container first +# (docker compose down, or npx vault-cortex down), then change it: the install +# starts on a new, empty index volume, and the old one stays until you remove +# it (docker volume rm _mcp_data). INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). diff --git a/deploy/local/README.md b/deploy/local/README.md index 9915efc72..ec08b3ddc 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -241,28 +241,31 @@ reference). Otherwise resume with `docker compose up -d` or Each vault gets its own install, with its own folder, `.env`, container, search index, port and connector. Give every install after the first its own -`INSTANCE_NAME` before its first start. That name labels the install's -container, Compose project and index volume (`_mcp_data`); the first -install keeps the default, `vault-cortex`. +`INSTANCE_NAME` before its first start. Docker uses that name for the +install's container and for the volume that holds its search index +(`_mcp_data`); the first install keeps the default, `vault-cortex`. -**Set up with the CLI?** Run `init` again into a new folder: +**Set up with the CLI?** From the folder you ran `init` in the first time, +run it again with a new `--dir`: ```bash npx vault-cortex@latest init --dir ./vault-cortex-work ``` -When `vault-cortex` is already in use, `init` asks for the new install's -name and port, for example `work` and `8001`. For a scripted setup, add -`--yes --vault-path --instance-name work --port 8001`. Then pass the -same `--dir` to every command for that install: +When this machine already has a `vault-cortex` container or search index +volume, `init` asks for the new install's name and port, for example `work` +and `8001`, and points its `PUBLIC_URL` at that port. For a scripted setup, +add `--yes --vault-path --instance-name work --port 8001`. Then pass +the same `--dir` to every command for that install: ```bash npx vault-cortex@latest upgrade --dir ./vault-cortex-work npx vault-cortex@latest logs --dir ./vault-cortex-work ``` -**Set up with Docker Compose?** Copy this folder, and set these in the -copy's `.env`: +**Set up with Docker Compose?** Set up a new folder as in **Manual setup** +under [Setup](#setup), with its own `MCP_AUTH_TOKEN`, and also set these in +its `.env`: ```dotenv INSTANCE_NAME=work @@ -271,7 +274,8 @@ PUBLIC_URL=http://localhost:8001 VAULT_PATH=/Users/you/Documents/WorkVault ``` -Run each install's `docker compose` commands from its own folder. +`PUBLIC_URL` must use the new port, or MCP clients can't sign in. Run each +install's `docker compose` commands from its own folder. For each install: @@ -387,7 +391,7 @@ longer exists and `/authorize` rejects it. Clear the client's stored authorization for this server and reconnect so it registers fresh: - **Claude Code:** `claude mcp remove `, then - `claude mcp add --scope user --transport http http://localhost:8000/mcp`. + `claude mcp add --scope user --transport http http://localhost:/mcp`. - **Claude Desktop / mcp-remote:** delete `~/.mcp-auth` and restart the client. - **Other clients:** remove and re-add the server. diff --git a/deploy/railway/README.md b/deploy/railway/README.md index a7eb76acf..def0ce100 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -347,8 +347,7 @@ again, like a first deploy. **A redeploy took as long as the first deploy and downloaded the whole vault again.** The volume was replaced, or `/persist/config` was removed, so the container started over with a fresh Obsidian Sync connection. Nothing needs -cleaning up, unless `VAULT_NAME` now names a different vault than before: see -the next entry. +cleaning up. **`This install is set up to sync a different vault than VAULT_NAME.`** The container refused to start because the volume holds the Obsidian Sync setup @@ -359,12 +358,17 @@ names both vaults. Then do one of these: - If `VAULT_NAME` is a mistake, set it back under **Variables** and apply the staged change. - To serve the other vault as well, deploy it as a separate Railway service - with its own volume, and leave this one on its original vault. + with its own volume, and leave this one on its original vault. The new + service has its own URL and `MCP_AUTH_TOKEN`, so connect it as its own + server in your MCP client. Give it its own `DEVICE_NAME` to tell the two + apart in Obsidian Sync's history. - To switch this service to the new vault, delete the volume (**Settings → Volumes**), add a new one at `/persist`, then **Redeploy**. The container downloads the new vault like a first deploy, and the old - vault stays in Obsidian Sync. Removing only `/persist/config` would upload - the old vault's notes into the new one. + vault stays in Obsidian Sync. The new volume has no record of your + signed-in MCP clients, so remove and re-add the server in each client. + Removing only `/persist/config` would upload the old vault's notes into the + new one. **`The vault is empty but this device has previously synced.`** The container refused to start because the vault directory on the volume is diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index d386d5a73..549df50a3 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -125,15 +125,15 @@ MEMORY_DIR=About Me # Name of this install's container, Compose project and data volumes # (_vault_data, _mcp_data and _obsidian_config). To run more -# than one vault on this server, give each install its own INSTANCE_NAME, PORT -# and DEVICE_NAME. +# than one vault on this server, give each install its own INSTANCE_NAME, PORT, +# PUBLIC_URL and DEVICE_NAME. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex -# Set it before the first start. Changing it later starts the install on new, -# empty volumes, so the vault downloads again as a new Obsidian Sync device -# while the old volumes stay. Stop the old container first -# (docker compose down, or npx vault-cortex down). +# Set it before the first start. To change it later, stop the container first +# (docker compose down, or npx vault-cortex down), then change it: the install +# starts on new, empty volumes and downloads the vault again as a new Obsidian +# Sync device, while the old volumes stay until you remove them. INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). @@ -180,6 +180,7 @@ PUID=1000 PGID=1000 # Device name the container reports to Obsidian Sync — labels its changes in the sync log. +# Give each install on a server its own, to tell their changes apart. DEVICE_NAME=vault-cortex # Obsidian Sync conflict resolution: merge | conflict (default: merge). diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 669c87473..d8de64ac2 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -443,8 +443,8 @@ starts. The server then runs these startup tasks: or deleted while the server started. Restarting the container re-runs this flow (useful when testing bootstrap -behavior). The command is the same for every setup -method: +behavior). The command is the same for every setup method; an install with +its own `INSTANCE_NAME` uses that name in place of `vault-cortex`: ```bash # Sync and the MCP server both restart; the startup steps re-run cleanly: @@ -497,30 +497,35 @@ reference). Otherwise resume with `docker compose up -d` or Each vault gets its own install, with its own folder, `.env`, container, volumes, port, Obsidian Sync device and connector. Give every install after -the first its own `INSTANCE_NAME` before its first start. That name labels -the install's container, Compose project and volumes (`_vault_data`, +the first its own `INSTANCE_NAME` before its first start. Docker uses that +name for the install's container and volumes (`_vault_data`, `_mcp_data` and `_obsidian_config`); the first install keeps the -default, `vault-cortex`. A second vault must never use the first install's -volumes: the container refuses to start rather than merge the two vaults -(see [Another vault's Sync setup](#another-vaults-sync-setup)). +default, `vault-cortex`. Give each install its own `DEVICE_NAME` too, so +Obsidian Sync's history tells their changes apart. A second vault must never +use the first install's volumes: the container refuses to start rather than +merge the two vaults (see +[Another vault's Sync setup](#another-vaults-sync-setup)). -**Set up with the CLI?** Run `init` again into a new folder: +**Set up with the CLI?** From the folder you ran `init` in the first time, +run it again with a new `--dir`: ```bash npx vault-cortex@latest init --mode remote --dir ./vault-cortex-work ``` -When `vault-cortex` is already in use, `init` asks for the new install's -name and port, for example `work` and `8001`, and names its Sync device after -it. Then pass the same `--dir` to every command for that install: +When this server already has a `vault-cortex` container or volumes, `init` +asks for the new install's name and port, for example `work` and `8001`, and +sets its `DEVICE_NAME` to that name. Then pass the same `--dir` to every +command for that install: ```bash npx vault-cortex@latest upgrade --dir ./vault-cortex-work npx vault-cortex@latest logs --dir ./vault-cortex-work ``` -**Set up with Docker Compose?** Copy this folder, and set these in the -copy's `.env`: +**Set up with Docker Compose?** Set up a new folder as in **Manual setup** +under [Setup](#setup), with its own `MCP_AUTH_TOKEN`, and also set these in +its `.env`: ```dotenv INSTANCE_NAME=work @@ -532,15 +537,24 @@ PUBLIC_URL=https://work.example.com Run each install's `docker compose` commands from its own folder. -**Set up with `docker run`?** In the command from [Setup](#setup), replace -`vault-cortex` with the new name in `--name`, `--hostname` and the volume -names, and the host port in `-p` (`-p 8001:8000`). Set its own -`DEVICE_NAME` in its `.env`. +**Set up with `docker run`?** Set up a new folder with its own `.env` as +above, but leave `PORT` at `8000`, because the `docker run` command does not +override it. Then run the command from [Setup](#setup) with `vault-cortex` +replaced by the new name in `--name`, `--hostname` and the volume names, and +with the new host port in `-p` (`-p 8001:8000`). For each install: -- **Give it its own address.** Route a separate host name to its port in your - [HTTPS access](#https-access) setup, and use that as its `PUBLIC_URL`. +- **Give it its own address.** In your [HTTPS access](#https-access) setup, + route a separate address to its port, and use that address as its + `PUBLIC_URL`: + - Cloudflare Tunnel: add a public hostname that points at + `http://localhost:8001`. + - API Gateway: create another HTTP API that proxies to + `http://:8001/{proxy+}`. + - Reverse proxy: add a site block for another domain with + `reverse_proxy localhost:8001`. + - Direct access: `http://:8001`. - **Connect it as its own server** in your MCP client, under its own name. For Claude Code: `claude mcp add --scope user --transport http work /mcp`. @@ -556,10 +570,12 @@ For each install: ### Another vault's Sync setup -When the container's volumes hold the Obsidian Sync setup for a different -vault than `VAULT_NAME`, the container stops before it syncs or changes -anything, because starting would merge the two vaults' notes through -Obsidian Sync. `docker logs` then shows: +The container keeps an Obsidian Sync setup in its `obsidian_config` volume +(the `config` folder with a single volume): the record of which vault in your +account it syncs. When that setup is for a different vault than `VAULT_NAME`, +the container stops before it syncs or changes anything, because starting +would merge the two vaults' notes through Obsidian Sync. `docker logs` then +shows: ```text [obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME. @@ -572,18 +588,21 @@ first one's volumes. Fix it one of these ways: - **`VAULT_NAME` is a mistake:** set it back and re-create the container. The **Changed `.env`?** note under [Restart](#restart) gives the command for each setup method. -- **You want to sync both vaults:** give the second vault its own install, as - described above. On Render or Railway, deploy it as a separate service. +- **You want to sync both vaults:** set this install's `VAULT_NAME` back, and + give the second vault its own install, as described above. On Render or + Railway, deploy it as a separate service. - **You want this install to sync the new vault instead:** delete all of its volumes together (the **Stop and delete all volumes** commands under [Stop](#stop)), then start it again. The container downloads the new vault - like a first start, and the old vault stays in Obsidian Sync. Deleting only - `obsidian_config` would upload the old vault's notes into the new one. On - Render or Railway, follow the entry in that platform's guide. -- **You re-created the same vault in Obsidian**, the log says the old one "is - no longer in your account", and the notes on the server are that vault's - notes: remove only the old setup folder the log names, then start the - container: + like a first start, and the old vault stays in Obsidian Sync. Deleting the + volumes also deletes the server's record of signed-in clients, so remove + and re-add the server in each MCP client. Deleting only `obsidian_config` + would upload the old vault's notes into the new one. On Render or Railway, + follow the entry in that platform's guide. +- **You re-created the same vault in Obsidian**, the second log line ends + with "which is no longer in your account", and the notes on the server are + that vault's notes: remove only the old setup folder the log names, then + start the container: ```bash docker run --rm -v vault-cortex_obsidian_config:/home/obsidian/.config \ @@ -592,12 +611,22 @@ first one's volumes. Fix it one of these ways: ``` With a single volume (`STORAGE_ROOT`), mount `vault-cortex_storage:/persist` - instead, and remove the folder the log names under `/persist/config`. + instead, and remove the full path the log names, which starts with + `/persist/config`. A **WARNING** that the install "also holds Sync setups for other vaults" -means the container started, because it uses the right vault, but one of -those setups could be used on a later start. Remove each one's folder with -the same command, using the vault ID from the warning. +means the container started, because it uses the right vault, but the Sync +client would use one of those setups if this vault's setup were removed. +Remove each one's folder with the same command, using the vault ID from the +warning. + +An **ERROR** that "a Sync setup file here is not a valid setup" means the +Sync client can't read the file the log names and would ignore it. That file +may be another vault's setup, whose notes are in this install's vault. If the +notes on the server are `VAULT_NAME`'s notes, remove the folder that holds +the file with the command above, using that folder's name in place of +``. Otherwise delete all of the install's volumes, as in the fix +for syncing the new vault instead. ## Memory @@ -712,7 +741,7 @@ These optional settings are worth knowing about: | `DISABLED_TOOLS` | none hidden | Hide individual tools by name, comma-separated; names match the [README tools table](https://github.com/aliasunder/vault-cortex#tools) | | `SYNC_CONFIGS` | daily notes, plugins, and app | Obsidian settings categories synced to the server (see [Daily notes](#daily-notes)); `none` disables | | `INSTANCE_NAME` | `vault-cortex` | Names this install's container, Compose project and volumes. Set it before the first start to run another vault on the same server (see [Run more than one vault](#run-more-than-one-vault)) | -| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log | +| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log; give each install on a server its own (see [Run more than one vault](#run-more-than-one-vault)) | | `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | | `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | | `SYNC_EXCLUDED_FOLDERS` | _(empty)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Empty excludes nothing. | @@ -732,7 +761,7 @@ The setup above is authenticated — every request requires your token or an OAuth session. These optional measures add defense-in-depth: - **Close port 8000** — once a tunnel or reverse proxy handles HTTPS, close - direct access to port 8000 wherever you manage your server's firewall + direct access to port 8000, and to each other install's `PORT`, wherever you manage your server's firewall (provider panel, AWS security groups, infrastructure-as-code). All traffic then flows through the encrypted path. diff --git a/deploy/render/README.md b/deploy/render/README.md index c4eefab3b..c220d9536 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -320,8 +320,7 @@ for the first deploy and remove it once the vault has synced. **A redeploy took as long as the first deploy and downloaded the whole vault again.** The disk was replaced, or `/persist/config` was removed, so the container started over with a fresh Obsidian Sync connection. Nothing needs -cleaning up, unless `VAULT_NAME` now names a different vault than before: see -the next entry. +cleaning up. **`This install is set up to sync a different vault than VAULT_NAME.`** The container refused to start because the disk holds the Obsidian Sync setup @@ -332,11 +331,14 @@ names both vaults. Then do one of these: - If `VAULT_NAME` is a mistake, set it back under **Environment** and save. - To serve the other vault as well, deploy the Blueprint again for it, as a separate service with its own disk, and leave this one on its original - vault. + vault. The new service has its own URL and `MCP_AUTH_TOKEN`, so connect it + as its own server in your MCP client. Give it its own `DEVICE_NAME` to tell + the two apart in Obsidian Sync's history. - To switch to the new vault, deploy the Blueprint again for it, then delete this service (see [Restart, stop, delete](#restart-stop-delete)). The new service downloads the new vault like a first deploy, and the old vault - stays in Obsidian Sync. Removing only `/persist/config` would upload the + stays in Obsidian Sync. Connect your MCP client to the new service's URL + with its `MCP_AUTH_TOKEN`. Removing only `/persist/config` would upload the old vault's notes into the new one. **`The vault is empty but this device has previously synced.`** The diff --git a/llms-install.md b/llms-install.md index 393851977..248803260 100644 --- a/llms-install.md +++ b/llms-install.md @@ -163,7 +163,13 @@ docker compose down -v # stop and delete the index (rebuilds on next sta docker compose pull && docker compose up -d --force-recreate # update to the latest image ``` -To serve a second vault from the same machine, set it up in a separate folder with its own `.env` that sets `INSTANCE_NAME` (for example `INSTANCE_NAME=work`), a free `PORT`, and `PUBLIC_URL` on that port (for example `http://localhost:8001`), then register it in the client under its own name and URL. `INSTANCE_NAME` names that install's container and index volume, so the `docker exec` commands in this guide use that name in place of `vault-cortex` (`docker exec work …`) and its URLs use its `PORT`. The `docker compose` commands keep the service name `vault-cortex` and run from that install's folder. +To serve a second vault from the same machine: + +1. Repeat Steps 1–2 in a separate folder, with a new `MCP_AUTH_TOKEN` and that vault's `VAULT_PATH`. +2. In that folder's `.env`, also set `INSTANCE_NAME` (for example `INSTANCE_NAME=work`), a free `PORT`, and `PUBLIC_URL` on that port (for example `http://localhost:8001`). +3. Register it in the client (Step 4) under its own name and URL. + +`INSTANCE_NAME` names that install's container and index volume, so the `docker exec` commands in this guide use that name in place of `vault-cortex` (`docker exec work …`), and its URLs use its `PORT`. The `docker compose` commands keep the service name `vault-cortex` and run from that install's folder. ## More diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index 05cd4e67c..b0155bc50 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -3,8 +3,8 @@ # Stops a boot that would sync a vault other than VAULT_NAME. # # Each vault's Sync setup is /obsidian-headless/sync//config.json. -# Every ob command uses the first setup, by folder name, whose vaultPath is the -# vault folder, whatever VAULT_NAME says. So a volume holding another vault's +# Every ob command uses the first setup, in folder-name order, whose vaultPath +# is the vault folder, whatever VAULT_NAME says. So a volume holding another vault's # setup (two installs sharing volumes, or VAULT_NAME changed on one install) # would sync that vault instead, or merge the two through Obsidian Sync. # @@ -86,9 +86,10 @@ remote_vault_name() { # 2. Setups the engine would silently ignore # --------------------------------------------------------------------------- -# The engine skips a config.json it cannot read or parse, so a stale vault's -# setup could hide there and its notes sync into this vault. Prints one line -# per such file; a sync folder that can't be listed is a non-zero exit. +# The engine skips a config.json it cannot read or parse. If that file is +# another vault's setup, this folder holds that vault's notes, and ob +# sync-setup would upload them into VAULT_NAME's vault. Prints one line per +# such file; a sync folder that can't be listed is a non-zero exit. invalid_sync_configs() { [ -d "$SYNC_STATE_ROOT" ] || return 0 node --no-warnings -e ' @@ -160,6 +161,8 @@ PICKED_STORED_NAME=$(printf '%s' "$PICKED_VAULT" | cut -f2) if [ "$PICKED_ID" = "$REQUESTED_ID" ]; then # A second setup for another vault is dormant while the engine picks this # one, so the boot continues; removing it keeps it from being picked later. + # The listing only feeds this warning, so a failed listing skips the + # warning instead of stopping a boot the checks above already passed. OTHER_IDS=$(run_ob sync-list-local --json 2>/dev/null | REQUESTED_ID="$REQUESTED_ID" node --no-warnings -e ' const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) const otherIds = listed.vaults.map((vault) => vault.id).filter((id) => id !== process.env.REQUESTED_ID) @@ -179,6 +182,8 @@ fi # --------------------------------------------------------------------------- PICKED_CURRENT_NAME=$(remote_vault_name "$PICKED_ID") +# STORAGE_ROOT is set on single-volume hosts (Render, Railway), where the +# vault and the Sync config are folders on one volume instead of two volumes. if [ -n "${STORAGE_ROOT:-}" ]; then RESET_STEP="remove ${STORAGE_ROOT}/vault and ${STORAGE_ROOT}/config together" SEPARATE_STEP="run it as a separate service" diff --git a/rootfs/etc/s6-overlay/scripts/init-first-sync b/rootfs/etc/s6-overlay/scripts/init-first-sync index 835f3858b..7eea797cf 100644 --- a/rootfs/etc/s6-overlay/scripts/init-first-sync +++ b/rootfs/etc/s6-overlay/scripts/init-first-sync @@ -83,6 +83,7 @@ SYNC_STATE_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/obsidian-headless/sync" # compares it as an integer, so the only output is the bare number. known_sync_files() { set -- "$SYNC_STATE_ROOT"/*/state.db + # sh leaves a glob that matches nothing as its literal text. [ -e "$1" ] || { echo 0; return 0; } node --no-warnings -e ' const { DatabaseSync } = require("node:sqlite") From 6c01f4513df959eab2596f11ecdda7c5c86f84ae Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:33:38 -0400 Subject: [PATCH 13/57] fix: name an unreadable Sync setup answer, reject an empty --instance-name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check-sync-vault: when ob sync-config exits 0 but prints no vault ID, refuse with the could-not-read-its-setup error instead of a different-vault refusal with blank names. - init: an empty --instance-name is an invalid name, not the default name without the in-use check; the re-asked name's error says "already in use". - Render and Railway: the redeploy entry says not to remove /persist/config on its own to switch vaults. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 8 +++++- cli/src/instance-identity.ts | 9 ++++--- deploy/railway/README.md | 3 ++- deploy/render/README.md | 3 ++- .../etc/s6-overlay/scripts/check-sync-vault | 25 +++++++++++++------ .../__tests__/check-sync-vault.test.ts | 19 ++++++++++++++ 6 files changed, 53 insertions(+), 14 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 188162978..79b82514d 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1699,7 +1699,7 @@ describe("runInit when the instance name is already in use", () => { }) expect(scripted.errors).toEqual([ '"Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.', - '"out-taken" is also in use on this machine — pick another name.', + '"out-taken" is already in use on this machine — pick another name.', ]) expect(readEnvLines(targetDir)).toContain("INSTANCE_NAME=work") }) @@ -1798,6 +1798,12 @@ describe("runInit --yes when the instance name is already in use", () => { expectedError: '--instance-name: "Work" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.', }, + { + label: "an empty --instance-name", + flags: { instanceName: "" }, + expectedError: + '--instance-name: "" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.', + }, { label: "an invalid --port", flags: { port: "80a" }, diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 421c10098..fd882f253 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -85,7 +85,7 @@ const askFreeInstanceName = async ( return askFreeInstanceName(params, deps) } if (isInstanceNameInUse({ mode, instanceName: answer }, docker)) { - prompts.error(`"${answer}" is also in use on this machine — pick another name.`) + prompts.error(`"${answer}" is already in use on this machine — pick another name.`) return askFreeInstanceName(params, deps) } return answer @@ -138,9 +138,10 @@ export const resolveInstanceIdentity = async ( const { mode, targetDir, flags } = params const { prompts, docker } = deps - const flagNameError = flags.instanceName - ? describeInvalidInstanceName(flags.instanceName) - : undefined + // Compared with undefined so an empty --instance-name is rejected instead of + // falling back to the default name without the in-use check. + const flagNameError = + flags.instanceName === undefined ? undefined : describeInvalidInstanceName(flags.instanceName) if (flagNameError) { prompts.error(`--instance-name: ${flagNameError}`) diff --git a/deploy/railway/README.md b/deploy/railway/README.md index def0ce100..632720002 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -347,7 +347,8 @@ again, like a first deploy. **A redeploy took as long as the first deploy and downloaded the whole vault again.** The volume was replaced, or `/persist/config` was removed, so the container started over with a fresh Obsidian Sync connection. Nothing needs -cleaning up. +cleaning up. To switch to another vault, don't remove `/persist/config` on +its own: follow the last step of the next entry. **`This install is set up to sync a different vault than VAULT_NAME.`** The container refused to start because the volume holds the Obsidian Sync setup diff --git a/deploy/render/README.md b/deploy/render/README.md index c220d9536..bbe28d371 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -320,7 +320,8 @@ for the first deploy and remove it once the vault has synced. **A redeploy took as long as the first deploy and downloaded the whole vault again.** The disk was replaced, or `/persist/config` was removed, so the container started over with a fresh Obsidian Sync connection. Nothing needs -cleaning up. +cleaning up. To switch to another vault, don't remove `/persist/config` on +its own: follow the last step of the next entry. **`This install is set up to sync a different vault than VAULT_NAME.`** The container refused to start because the disk holds the Obsidian Sync setup diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index b0155bc50..be5ca9d2f 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -133,6 +133,13 @@ fi # 3. The vault the engine would sync now # --------------------------------------------------------------------------- +refuse_unreadable_setup() { + echo "[obsidian-sync] ERROR: The Sync client could not read its own setup ($1)." >&2 + echo "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for." >&2 + echo "[obsidian-sync] See: $GUIDE_URL" >&2 + exit 1 +} + PICKED_CONFIG=$(run_ob sync-config --json 2>/dev/null) PICK_EXIT=$? @@ -144,20 +151,24 @@ if [ "$PICK_EXIT" -eq 3 ]; then exit 0 fi if [ "$PICK_EXIT" -ne 0 ]; then - echo "[obsidian-sync] ERROR: The Sync client could not read its own setup (ob sync-config exited $PICK_EXIT)." >&2 - echo "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for." >&2 - echo "[obsidian-sync] See: $GUIDE_URL" >&2 - exit 1 + refuse_unreadable_setup "ob sync-config exited $PICK_EXIT" fi -# Prints "" of the setup the engine picked. +# Prints "" of the setup the engine picked, or nothing +# when its output holds no vault ID. PICKED_VAULT=$(printf '%s' "$PICKED_CONFIG" | node --no-warnings -e ' const config = JSON.parse(require("node:fs").readFileSync(0, "utf8")) - console.log(config.vaultId + "\t" + (config.vaultName ?? "")) -') + if (typeof config.vaultId === "string" && config.vaultId) { + console.log(config.vaultId + "\t" + (config.vaultName ?? "")) + } +' 2>/dev/null) PICKED_ID=$(printf '%s' "$PICKED_VAULT" | cut -f1) PICKED_STORED_NAME=$(printf '%s' "$PICKED_VAULT" | cut -f2) +if [ -z "$PICKED_ID" ]; then + refuse_unreadable_setup "ob sync-config printed no vault ID" +fi + if [ "$PICKED_ID" = "$REQUESTED_ID" ]; then # A second setup for another vault is dormant while the engine picks this # one, so the boot continues; removing it keeps it from being picked later. diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts index 37fbb6bc1..a399782f3 100644 --- a/src/vault-mcp/__tests__/check-sync-vault.test.ts +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -412,6 +412,25 @@ describe("check-sync-vault", () => { ) }) + it.each([ + { label: "output that is not JSON", configJson: "Fetching vaults..." }, + { label: "JSON without a vault ID", configJson: '{"vaultName":"Work"}' }, + ])("stops when the engine's setup answer is $label", ({ configJson }) => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configJson, + configExit: 0, + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + "[obsidian-sync] ERROR: The Sync client could not read its own setup (ob sync-config printed no vault ID).\n" + + "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for.\n" + + `[obsidian-sync] See: ${GUIDE_URL}\n`, + ) + }) + it("stops when VAULT_NAME is not set", () => { const run = runCheck({ vaultName: "", remoteJson: remoteVaults([]), configExit: NO_SETUP_EXIT }) From c26d41f54c86bc1080b1b80638d8918128e80468 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:52:11 -0400 Subject: [PATCH 14/57] test(remote): cover the vault check's remaining branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-sync-vault: shared vaults, an empty vaultId, a missing or unlistable sync folder, a VAULT_PATH that can't be entered, every ob call running in the vault folder, and a failed sync-list-local continuing without the warning. The single-volume wording test now pins the whole message. init-setup-vault: a relative script path still finds the check, and a non-JSON sync-config --json answer after sync-setup stops the boot. Ship-Check: test-audit · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/check-sync-vault.test.ts | 204 ++++++++++++++++-- .../__tests__/init-setup-vault.test.ts | 39 +++- 2 files changed, 219 insertions(+), 24 deletions(-) diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts index a399782f3..0dc94be97 100644 --- a/src/vault-mcp/__tests__/check-sync-vault.test.ts +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -1,10 +1,20 @@ import { spawnSync } from "node:child_process" -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" import { tmpdir } from "node:os" import { join, resolve } from "node:path" import { describe, expect, it, onTestFinished } from "vitest" +import { runsAsRoot } from "../../__tests__/runs-as-root.js" + /** * Behavioral spec for the remote image's vault check * (rootfs/etc/s6-overlay/scripts/check-sync-vault), which stops a boot that @@ -53,21 +63,28 @@ const localSetups = (vaultIds: ReadonlyArray): string => { }) } -/** `ob sync-list-remote --json` output for the account's owned vaults. */ -const remoteVaults = (vaults: ReadonlyArray<{ id: string; name: string }>): string => { +type RemoteVault = { id: string; name: string } + +/** `ob sync-list-remote --json` output for the account's owned and shared vaults. */ +const remoteVaults = ( + vaults: ReadonlyArray, + sharedVaults: ReadonlyArray = [], +): string => { return JSON.stringify({ vaults: vaults.map((vault) => ({ ...vault, region: "us" })), - shared: [], + shared: sharedVaults.map((vault) => ({ ...vault, region: "us" })), }) } -/** Stub `ob`: logs each call and replays the output and exit code the test sets per verb. */ +/** Stub `ob`: logs each call and the folder it ran in, and replays the output + * and exit code the test sets per verb. */ const OB_STUB = `#!/bin/sh echo "$*" >> "$OB_CALL_LOG" +pwd >> "$OB_CWD_LOG" case "$1" in sync-list-remote) printf '%s' "$OB_REMOTE_JSON"; exit "$OB_REMOTE_EXIT" ;; sync-config) printf '%s' "$OB_CONFIG_JSON"; exit "$OB_CONFIG_EXIT" ;; - sync-list-local) printf '%s' "$OB_LOCAL_JSON"; exit 0 ;; + sync-list-local) printf '%s' "$OB_LOCAL_JSON"; exit "$OB_LOCAL_EXIT" ;; *) exit 0 ;; esac ` @@ -85,6 +102,13 @@ type CheckOptions = { configJson?: string configExit: number localJson?: string + localExit?: number + /** When true, VAULT_PATH names a folder that does not exist. */ + vaultPathMissing?: boolean + /** When true, the sync folder exists but can't be listed (mode 000). */ + syncRootUnreadable?: boolean + /** When true, the Sync client has not created its sync folder yet. */ + syncRootMissing?: boolean /** config.json files to create under the sync folder, keyed by folder name. */ setupFiles?: Readonly> /** Folders to create under the sync folder with no config.json in them. */ @@ -97,6 +121,9 @@ type CheckRun = { stdout: string stderr: string obCalls: string[] + /** The working folder of each `ob` call, in call order. */ + obCwds: string[] + vaultPath: string syncRoot: string /** Every file under the sync folder with its content, read after the run. */ setupTreeAfter: Record @@ -114,14 +141,18 @@ const readSetupTree = (syncRoot: string): Record => { const runCheck = (options: CheckOptions): CheckRun => { const tempDir = mkdtempSync(join(tmpdir(), "check-sync-vault-")) - onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })) const stubBinDir = join(tempDir, "bin") const vaultPath = join(tempDir, "vault") const homeDir = join(tempDir, "home") const syncRoot = join(homeDir, ".config", "obsidian-headless", "sync") + onTestFinished(() => { + // A mode-000 sync folder blocks the removal, so its mode comes back first. + if (options.syncRootUnreadable) chmodSync(syncRoot, 0o755) + rmSync(tempDir, { recursive: true, force: true }) + }) mkdirSync(stubBinDir) - mkdirSync(vaultPath) - mkdirSync(syncRoot, { recursive: true }) + if (!options.vaultPathMissing) mkdirSync(vaultPath) + if (!options.syncRootMissing) mkdirSync(syncRoot, { recursive: true }) writeFileSync(join(stubBinDir, "ob"), OB_STUB, { mode: 0o755 }) writeFileSync(join(stubBinDir, "s6-setuidgid"), SETUIDGID_STUB, { mode: 0o755 }) @@ -134,7 +165,11 @@ const runCheck = (options: CheckOptions): CheckRun => { } const callLogPath = join(tempDir, "ob-calls.log") + const cwdLogPath = join(tempDir, "ob-cwds.log") writeFileSync(callLogPath, "") + writeFileSync(cwdLogPath, "") + + if (options.syncRootUnreadable) chmodSync(syncRoot, 0o000) const result = spawnSync("sh", [SCRIPT_PATH], { encoding: "utf8", @@ -149,20 +184,30 @@ const runCheck = (options: CheckOptions): CheckRun => { OB_CONFIG_JSON: options.configJson ?? "", OB_CONFIG_EXIT: String(options.configExit), OB_LOCAL_JSON: options.localJson ?? localSetups([]), + OB_LOCAL_EXIT: String(options.localExit ?? 0), + OB_CWD_LOG: cwdLogPath, ...(options.storageRoot ? { STORAGE_ROOT: options.storageRoot } : {}), }, }) - const obCalls = readFileSync(callLogPath, "utf8") - .split("\n") - .filter((loggedCall) => loggedCall !== "") + // Readable again, so setupTreeAfter can show the run wrote nothing. + if (options.syncRootUnreadable) chmodSync(syncRoot, 0o755) + + const readLogLines = (logPath: string): string[] => { + return readFileSync(logPath, "utf8") + .split("\n") + .filter((loggedLine) => loggedLine !== "") + } + return { status: result.status, stdout: result.stdout, stderr: result.stderr, - obCalls, + obCalls: readLogLines(callLogPath), + obCwds: readLogLines(cwdLogPath), + vaultPath, syncRoot, - setupTreeAfter: readSetupTree(syncRoot), + setupTreeAfter: options.syncRootMissing ? {} : readSetupTree(syncRoot), } } @@ -189,6 +234,21 @@ describe("check-sync-vault", () => { expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) }) + it("continues on a first boot, before the Sync client has created its sync folder", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configExit: NO_SETUP_EXIT, + syncRootMissing: true, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("bbb222\n") + expect(run.stderr).toBe( + "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet.\n", + ) + }) + it("continues on a reboot, when the engine already syncs the requested vault", () => { const run = runCheck({ vaultName: "Work", @@ -289,15 +349,80 @@ describe("check-sync-vault", () => { }) expect(run.status).toBe(1) - const stderrLines = run.stderr.split("\n") - expect(stderrLines).toContain( - "[obsidian-sync] - To sync 'Work' as well, run it as a separate service.", + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Personal' (aaa111).", + "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:", + "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart.", + "[obsidian-sync] - To sync 'Work' as well, run it as a separate service.", + "[obsidian-sync] - To switch this install to 'Work' on purpose, remove /persist/vault and /persist/config together.", + "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), ) - expect(stderrLines).toContain( - "[obsidian-sync] - To switch this install to 'Work' on purpose, remove /persist/vault and /persist/config together.", + }) + + it("names a picked vault that is shared with the account by its current name", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B], [VAULT_A]), + configJson: pickedConfig(VAULT_A.id, "Old name"), + configExit: 0, + }) + + expect(run.status).toBe(1) + expect(run.stderr.split("\n")[1]).toBe( + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Personal' (aaa111).", ) }) + it("matches VAULT_NAME against vaults shared with the account", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_A], [VAULT_B]), + configExit: NO_SETUP_EXIT, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("bbb222\n") + }) + + it("runs every ob call inside the vault folder", () => { + const run = runCheck({ + vaultName: "Personal", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + localJson: localSetups([VAULT_A.id]), + }) + + expect(run.status).toBe(0) + expect(run.obCalls).toEqual([ + "sync-list-remote --json", + "sync-config --json", + "sync-list-local --json", + ]) + expect(run.obCwds).toEqual([run.vaultPath, run.vaultPath, run.vaultPath]) + }) + + it("stops before any ob call when VAULT_PATH can't be entered", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configExit: NO_SETUP_EXIT, + vaultPathMissing: true, + }) + + expect(run.status).toBe(1) + // The shell prints its own cd error first, worded differently per shell. + expect(run.stderr.trimEnd().split("\n").at(-1)).toBe( + `[obsidian-sync] ERROR: Failed to change directory to VAULT_PATH='${run.vaultPath}'.`, + ) + expect(run.obCalls).toEqual([]) + }) + it("continues with a warning when a dormant setup for another vault sits beside the picked one", () => { const run = runCheck({ vaultName: "Personal", @@ -316,6 +441,26 @@ describe("check-sync-vault", () => { expect(run.stdout).toBe("aaa111\n") }) + it("continues without the warning when the local setups can't be listed", () => { + const run = runCheck({ + vaultName: "Personal", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + localJson: "", + localExit: 1, + }) + + expect(run.status).toBe(0) + expect(run.stdout).toBe("aaa111\n") + // Only the outcome is pinned: node's parse error for the empty listing + // reaches stderr before this line. + expect(run.stderr.trimEnd().split("\n").at(-1)).toBe( + "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).", + ) + expect(run.stderr).not.toContain("WARNING") + }) + it("stops when the account's vault list can't be read", () => { const run = runCheck({ vaultName: "Work", @@ -356,6 +501,7 @@ describe("check-sync-vault", () => { { label: "false", content: "false" }, { label: "an empty string", content: '""' }, { label: "an object without vaultId", content: '{"vaultPath":"/vault"}' }, + { label: "an empty vaultId", content: '{"vaultId":"","vaultPath":"/vault"}' }, { label: "an object without vaultPath", content: '{"vaultId":"eee555"}' }, ])("stops on a setup file holding $label, before asking the engine", ({ content }) => { const setupFiles = { [VAULT_B.id]: setupFile(VAULT_B), eee555: content } @@ -397,6 +543,26 @@ describe("check-sync-vault", () => { expect(run.obCalls).toEqual(["sync-list-remote --json", "sync-config --json"]) }) + it.skipIf(runsAsRoot)( + "stops when the sync folder can't be listed, before asking the engine", + () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configExit: NO_SETUP_EXIT, + syncRootUnreadable: true, + }) + + expect(run.status).toBe(1) + // node's own error for the failed listing comes first. + expect(run.stderr.trimEnd().split("\n").slice(-2)).toEqual([ + `[obsidian-sync] ERROR: Could not read the Sync setups under ${run.syncRoot}.`, + "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for.", + ]) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + }, + ) + it("stops when the engine can't read its own setup", () => { const run = runCheck({ vaultName: "Work", diff --git a/src/vault-mcp/__tests__/init-setup-vault.test.ts b/src/vault-mcp/__tests__/init-setup-vault.test.ts index 09f2f2ff9..4c45fa3b4 100644 --- a/src/vault-mcp/__tests__/init-setup-vault.test.ts +++ b/src/vault-mcp/__tests__/init-setup-vault.test.ts @@ -23,15 +23,15 @@ const SCRIPT_PATH = resolve(__dirname, "../../../rootfs/etc/s6-overlay/scripts/i /** Stub `ob`: logs each invocation; `sync-setup` exits with the code the * test configures, `sync-config` fails when its flag matches - * `OB_SYNC_CONFIG_FAIL_FLAG`, `sync-config --json` prints a setup for - * `OB_PICKED_VAULT_ID`, and every other invocation succeeds. */ + * `OB_SYNC_CONFIG_FAIL_FLAG`, `sync-config --json` prints + * `OB_PICKED_CONFIG`, and every other invocation succeeds. */ const OB_STUB = `#!/bin/sh echo "$*" >> "$OB_CALL_LOG" case "$1" in sync-setup) exit "$OB_SYNC_SETUP_EXIT" ;; sync-config) [ "$2" = "$OB_SYNC_CONFIG_FAIL_FLAG" ] && exit 1 - [ "$2" = --json ] && printf '{"vaultId":"%s","syncMode":"bidirectional"}' "$OB_PICKED_VAULT_ID" + [ "$2" = --json ] && printf '%s' "$OB_PICKED_CONFIG" exit 0 ;; *) exit 0 ;; esac @@ -105,6 +105,10 @@ type SetupRunOptions = { /** The vault ID `ob sync-config --json` reports after `ob sync-setup` * (default: the requested vault's). */ pickedVaultId?: string + /** Raw `ob sync-config --json` output, replacing the setup for pickedVaultId. */ + pickedConfigOutput?: string + /** When true, the script runs by a path relative to its own folder. */ + relativeScriptPath?: boolean } const runSetupScript = (options: SetupRunOptions): SetupRun => { @@ -127,8 +131,17 @@ const runSetupScript = (options: SetupRunOptions): SetupRun => { const callLogPath = join(tempDir, "calls.log") writeFileSync(callLogPath, "") - const result = spawnSync("sh", [join(scriptsDir, "init-setup-vault")], { + const pickedConfig = JSON.stringify({ + vaultId: options.pickedVaultId ?? REQUESTED_VAULT_ID, + syncMode: "bidirectional", + }) + const scriptArgument = options.relativeScriptPath + ? "./init-setup-vault" + : join(scriptsDir, "init-setup-vault") + + const result = spawnSync("sh", [scriptArgument], { encoding: "utf8", + cwd: scriptsDir, env: { PATH: `${stubBinDir}:${process.env.PATH ?? ""}`, HOME: join(tempDir, "home"), @@ -136,7 +149,7 @@ const runSetupScript = (options: SetupRunOptions): SetupRun => { OB_CALL_LOG: callLogPath, OB_SYNC_SETUP_EXIT: String(options.syncSetupFails ? 1 : 0), OB_SYNC_CONFIG_FAIL_FLAG: options.syncConfigFailsFor ?? "", - OB_PICKED_VAULT_ID: options.pickedVaultId ?? REQUESTED_VAULT_ID, + OB_PICKED_CONFIG: options.pickedConfigOutput ?? pickedConfig, VAULT_CHECK_REFUSES: options.vaultCheckRefuses ? "1" : "0", VAULT_CHECK_VAULT_ID: options.vaultCheckVaultId ?? REQUESTED_VAULT_ID, ...(options.setupMode ? { SETUP_MODE: "1" } : {}), @@ -239,6 +252,22 @@ describe("init-setup-vault script", () => { expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault", PICKED_VAULT_CALL]) }) + it("stops before any sync-config write when ob sync-config --json prints no JSON after ob sync-setup", () => { + const run = runSetupScript({ vaultName: "MyVault", pickedConfigOutput: "Fetching vaults..." }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe(PICKED_VAULT_ERROR) + expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault", PICKED_VAULT_CALL]) + }) + + it("finds the vault check beside itself when started by a relative path", () => { + const run = runSetupScript({ vaultName: "MyVault", relativeScriptPath: true }) + + expect(run.status).toBe(0) + expect(run.stderr).toBe("") + expect(run.calls.slice(0, 2)).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault"]) + }) + it("stops after ob sync-setup when the vault check named no vault", () => { // ob sync-setup fails when VAULT_NAME names no single vault; if it ever // succeeds anyway, the setup it wrote can't be checked, so the boot stops. From 22f6dbe3582755748c5692dc882927f514913921 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:55:46 -0400 Subject: [PATCH 15/57] test(cli): pin instance identity exactly and cover its untested paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Identity lines in a written .env are compared as the full ordered list, so a duplicate or leftover default line fails the test. - The kept-file test runs with --instance-name/--port that differ from the kept .env, so it shows the printed commands come from the file on disk. - A remote re-run answers a new public URL, which must land: a remote PUBLIC_URL is asked again, not carried. - New: an invalid INSTANCE_NAME in a kept .env stops init before Docker; no name is suggested when the folder's name is in use; logs and restart name the instance in their messages; --instance-name/--port reach runInit; the compose templates' project, container and volume names match the CLI's. - The remote custom-instance run args and the configure re-create are asserted whole; the health command is a test-owned value; readEnvInstanceName cases are labeled tables. Ship-Check: test-audit · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/configure.test.ts | 10 +- cli/src/__tests__/docker.test.ts | 55 +++++- cli/src/__tests__/init.test.ts | 163 ++++++++++++++---- cli/src/__tests__/integration/cli-pty.test.ts | 17 +- cli/src/__tests__/lifecycle.test.ts | 40 ++++- cli/src/__tests__/program.test.ts | 24 ++- cli/src/__tests__/scaffold.test.ts | 42 +++-- cli/src/__tests__/templates.test.ts | 34 +++- 8 files changed, 316 insertions(+), 69 deletions(-) diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index 0c79f2530..778b4b49b 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -180,7 +180,15 @@ describe("runConfigure with picked settings", () => { ) expect(exitCode).toBe(0) - expect(dockerRunCalls.map((params) => params.instanceName)).toEqual(["work"]) + expect(dockerRunCalls).toEqual([ + { + mode: "local", + instanceName: "work", + envFilePath, + port: 8000, + vaultPath: "/home/user/MyVault", + }, + ]) }) it("re-creates the container on the new PORT when the restart is accepted", async () => { diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index b490937ad..baaf6f064 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -15,6 +15,10 @@ import { const okResponse = new Response(null, { status: 200 }) const failResponse = new Response(null, { status: 500 }) +/** The container health check: an in-container fetch of /healthz on the container's own port. */ +const HEALTH_CMD = + "node -e \"fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" + describe("buildDockerRunArgs", () => { it("produces the correct args for local mode", () => { const args = buildDockerRunArgs({ @@ -49,7 +53,7 @@ describe("buildDockerRunArgs", () => { "-v", "vault-cortex_mcp_data:/data", "--health-cmd", - expect.stringContaining("healthz"), + HEALTH_CMD, "--health-interval", "15s", "--health-timeout", @@ -98,7 +102,7 @@ describe("buildDockerRunArgs", () => { "-v", "vault-cortex_obsidian_config:/home/obsidian/.config", "--health-cmd", - expect.stringContaining("healthz"), + HEALTH_CMD, "--health-interval", "15s", "--health-timeout", @@ -210,7 +214,7 @@ describe("buildDockerRunArgs", () => { "-v", "work_mcp_data:/data", "--health-cmd", - expect.stringContaining("healthz"), + HEALTH_CMD, "--health-interval", "15s", "--health-timeout", @@ -231,13 +235,50 @@ describe("buildDockerRunArgs", () => { port: 8001, }) - const volumeArgs = args.filter((_arg, index) => args[index - 1] === "-v") - expect(args.slice(0, 4)).toEqual(["run", "-d", "--name", "work"]) - expect(args[args.indexOf("--hostname") + 1]).toBe("work") - expect(volumeArgs).toEqual([ + expect(args).toEqual([ + "run", + "-d", + "--name", + "work", + "--restart", + "unless-stopped", + "--env-file", + "/opt/work/.env", + "-e", + "VAULT_PATH=/vault", + "-e", + "PORT=8000", + "-e", + "HOST=0.0.0.0", + "-e", + "INDEX_DB_PATH=/data/index.db", + "-p", + "8001:8000", + "--hostname", + "work", + "-v", "work_vault_data:/vault", + "-v", "work_mcp_data:/data", + "-v", "work_obsidian_config:/home/obsidian/.config", + "--health-cmd", + HEALTH_CMD, + "--health-interval", + "15s", + "--health-timeout", + "5s", + "--health-retries", + "5", + "--health-start-period", + "180s", + "--log-driver", + "json-file", + "--log-opt", + "max-size=10m", + "--log-opt", + "max-file=3", + REMOTE_IMAGE, ]) }) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 79b82514d..b3eb754a0 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1510,6 +1510,19 @@ const readEnvLines = (targetDir: string): string[] => { return readFileSync(join(targetDir, ".env"), "utf8").split("\n") } +const IDENTITY_KEYS = ["PUBLIC_URL", "INSTANCE_NAME", "PORT", "DEVICE_NAME"] + +/** + * Every active identity line in the written .env, in file order. The file also + * holds a random token, so these lines stand in for the whole file; listing + * them all catches a duplicate or leftover default line. + */ +const readIdentityLines = (targetDir: string): string[] => { + return readEnvLines(targetDir).filter((line) => { + return IDENTITY_KEYS.some((key) => line.startsWith(`${key}=`)) + }) +} + const IN_USE_QUESTION = 'An install named "vault-cortex" already exists on this machine (its container or data volumes are here). What should this install do?' const NAME_QUESTION = "Name for this install (lowercase letters, digits, - and _):" @@ -1544,10 +1557,11 @@ describe("runInit when the instance name is already in use", () => { "Any optional settings to change? (press enter to skip)", "Start the server now?", ]) - const envLines = readEnvLines(targetDir) - expect(envLines).toContain("INSTANCE_NAME=work") - expect(envLines).toContain("PORT=8001") - expect(envLines).toContain("PUBLIC_URL=http://localhost:8001") + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) // The chooser reads the identity, so a picked Host port starts from 8001. const portOption = scripted.multiselectCalls[0].options.find( (option) => option.value === "PORT", @@ -1587,10 +1601,12 @@ describe("runInit when the instance name is already in use", () => { PORT_QUESTION, "Public base URL clients will use to reach this server (no /mcp — it's added for you):", ]) - const envLines = readEnvLines(targetDir) - expect(envLines).toContain("INSTANCE_NAME=work") - expect(envLines).toContain("PORT=8001") - expect(envLines).toContain("DEVICE_NAME=work") + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://work.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + "DEVICE_NAME=work", + ]) }) it("gives a remote install the device name of a free --instance-name", async () => { @@ -1616,11 +1632,12 @@ describe("runInit when the instance name is already in use", () => { expect(scripted.asked[0]).toBe( "Public base URL clients will use to reach this server (no /mcp — it's added for you):", ) - const envLines = readEnvLines(targetDir) - expect(envLines).toContain("INSTANCE_NAME=work") - expect(envLines).toContain("PORT=8001") - expect(envLines).toContain("DEVICE_NAME=work") - expect(envLines).not.toContain("DEVICE_NAME=vault-cortex") + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://work.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + "DEVICE_NAME=work", + ]) }) it("writes nothing extra when the user reuses the existing data", async () => { @@ -1638,11 +1655,16 @@ describe("runInit when the instance name is already in use", () => { ) expect(exitCode).toBe(0) - expect(readEnvInstanceName(join(targetDir, ".env"))).toEqual({ - kind: "ok", - instanceName: "vault-cortex", - }) - expect(readEnvLines(targetDir)).toContain("PORT=8000") + expect(scripted.asked).toEqual([ + IN_USE_QUESTION, + "Any optional settings to change? (press enter to skip)", + "Start the server now?", + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8000", + "INSTANCE_NAME=vault-cortex", + "PORT=8000", + ]) }) it("counts a remote install as in use when only its vault volume is left", async () => { @@ -1701,7 +1723,33 @@ describe("runInit when the instance name is already in use", () => { '"Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit.', '"out-taken" is already in use on this machine — pick another name.', ]) - expect(readEnvLines(targetDir)).toContain("INSTANCE_NAME=work") + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("suggests no name when the folder's name is the one in use", async () => { + const vaultDir = makeVault() + const targetDir = join(makeTargetDir(), "vault-cortex") + const scripted = createScriptedPrompts(["own-name", "work", "8001", [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls[0]).toEqual({ + message: NAME_QUESTION, + defaultValue: undefined, + placeholder: "work", + }) }) it("skips the check when the Docker daemon is not running", async () => { @@ -1763,10 +1811,11 @@ describe("runInit --yes when the instance name is already in use", () => { expect(exitCode).toBe(0) expect(scripted.logs).not.toContain('Reusing the data of the existing "work" install.') - const envLines = readEnvLines(targetDir) - expect(envLines).toContain("INSTANCE_NAME=work") - expect(envLines).toContain("PORT=8001") - expect(envLines).toContain("PUBLIC_URL=http://localhost:8001") + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) }) it.each([ @@ -1853,21 +1902,29 @@ describe("runInit re-run keeps the instance identity", () => { expect(exitCode).toBe(0) expect(scripted.asked).not.toContain(IN_USE_QUESTION) - const envLines = readEnvLines(targetDir) - expect(envLines).toContain("INSTANCE_NAME=work") - expect(envLines).toContain("PORT=8001") - expect(envLines).toContain("PUBLIC_URL=http://localhost:8001") - expect(envLines).not.toContain("MCP_AUTH_TOKEN=old") + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + expect(readEnvLines(targetDir)).not.toContain("MCP_AUTH_TOKEN=old") }) - it("prints commands for the kept install's name when the existing file is kept", async () => { + it("prints commands for the kept file's name and port, not this run's flags", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() writeExistingLocalEnv(targetDir, vaultDir) - const scripted = createScriptedPrompts([true, [], false, false]) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + [], // no optional settings + false, // keep the existing file + false, // don't start now + ]) + // The flags make this run's generated .env differ from the kept one, so + // the printed commands show which file they were read from. const exitCode = await runInit( - { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "other", port: "8002" }, { prompts: scripted.prompts, docker: dockerWithExisting(["vault-cortex", "work"]), @@ -1891,7 +1948,7 @@ describe("runInit re-run keeps the instance identity", () => { ) const scripted = createScriptedPrompts([ true, // re-run setup anyway - "https://work.example.com", + "https://new.example.com", // a remote PUBLIC_URL is asked again, not carried "Work", false, // don't generate a new token false, // no encryption @@ -1911,9 +1968,43 @@ describe("runInit re-run keeps the instance identity", () => { expect(exitCode).toBe(0) expect(scripted.asked).not.toContain(IN_USE_QUESTION) - const envLines = readEnvLines(targetDir) - expect(envLines).toContain("INSTANCE_NAME=work") - expect(envLines).toContain("PORT=8001") - expect(envLines).toContain("DEVICE_NAME=work-sync") + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://new.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + "DEVICE_NAME=work-sync", + ]) + }) + + it("stops before starting anything when the kept file's INSTANCE_NAME is invalid", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=Work Vault\n`, + ) + const dockerRun = vi.fn(() => true) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + [], // no optional settings + false, // keep the existing file + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReadyEmptyHost, dockerRun }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `INSTANCE_NAME "Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit. Fix it in ${targetDir}/.env.`, + ]) + expect(scripted.prints).toEqual([]) + expect(dockerRun).not.toHaveBeenCalled() }) }) diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index dc402233e..b0381d9a6 100644 --- a/cli/src/__tests__/integration/cli-pty.test.ts +++ b/cli/src/__tests__/integration/cli-pty.test.ts @@ -19,6 +19,9 @@ import { // Down arrow in terminal escape sequences const DOWN = "\x1b[B" +/** Matches an active .env line that sets a local install's name, port or URL. */ +const LOCAL_IDENTITY_LINE = /^(PUBLIC_URL|INSTANCE_NAME|PORT)=/ + describe("init local", () => { it("completes the happy path (decline start)", async () => { const { vaultDir, configDir } = createPtyWorkDir() @@ -103,10 +106,16 @@ describe("init local", () => { expect(result.promptsAnswered).toBe(result.totalPrompts) expect(result.transcript).toContain("--transport http work http://localhost:8001/mcp") - const envLines = readFileSync(join(configDir, ".env"), "utf8").split("\n") - expect(envLines).toContain("INSTANCE_NAME=work") - expect(envLines).toContain("PORT=8001") - expect(envLines).toContain("PUBLIC_URL=http://localhost:8001") + // The .env also holds a random token, so every active identity line + // stands in for the whole file. + const identityLines = readFileSync(join(configDir, ".env"), "utf8") + .split("\n") + .filter((line) => LOCAL_IDENTITY_LINE.test(line)) + expect(identityLines).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) }) it("accepts start and passes health check", async () => { diff --git a/cli/src/__tests__/lifecycle.test.ts b/cli/src/__tests__/lifecycle.test.ts index 8a77a98d9..6e875812c 100644 --- a/cli/src/__tests__/lifecycle.test.ts +++ b/cli/src/__tests__/lifecycle.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { describe, expect, it, onTestFinished } from "vitest" +import { describe, expect, it, onTestFinished, vi } from "vitest" import type { DockerLogsParams, DockerRunner } from "../docker.js" import { runDown, runLogs, runRestart, runStart } from "../lifecycle.js" @@ -804,6 +804,44 @@ describe("commands on an install with a custom INSTANCE_NAME", () => { ]) }) + it("logs names the instance and its start command when its container is absent", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const scripted = createScriptedPrompts() + + const exitCode = await runLogs( + { dir: targetDir }, + { prompts: scripted.prompts, docker: { ...dockerReady, containerExists: () => false } }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `No work container — start it with: npx vault-cortex@latest start --dir "${targetDir}"`, + ]) + }) + + it("restart names the instance in the remove hint when removal fails", async () => { + const targetDir = makeTempTargetDir("vault-cli-named-") + writeNamedLocalEnv(targetDir) + const dockerRun = vi.fn(() => true) + const scripted = createScriptedPrompts() + + const exitCode = await runRestart( + { dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReady, stopAndRemoveContainer: () => false, dockerRun }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + "Could not remove the existing container — check: docker rm -f work", + ]) + expect(dockerRun).not.toHaveBeenCalled() + }) + it.each([ { label: "start", run: runStart }, { label: "restart", run: runRestart }, diff --git a/cli/src/__tests__/program.test.ts b/cli/src/__tests__/program.test.ts index 7ed552b17..11694e0e7 100644 --- a/cli/src/__tests__/program.test.ts +++ b/cli/src/__tests__/program.test.ts @@ -73,14 +73,34 @@ describe("buildProgram init", () => { const { program, initCalls } = buildCapturingProgram() await program.parseAsync( - ["init", "--mode", "remote", "--vault-path", "/vaults/Mine", "--dir", "./out", "--yes"], + [ + "init", + "--mode", + "remote", + "--vault-path", + "/vaults/Mine", + "--dir", + "./out", + "--instance-name", + "work", + "--port", + "8001", + "--yes", + ], { from: "user", }, ) expect(initCalls).toEqual([ - { mode: "remote", vaultPath: "/vaults/Mine", dir: "./out", yes: true }, + { + mode: "remote", + vaultPath: "/vaults/Mine", + dir: "./out", + instanceName: "work", + port: "8001", + yes: true, + }, ]) }) diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index a4598e62c..d7379efc0 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -169,27 +169,35 @@ describe("readEnvInstanceName", () => { expect(readEnvInstanceName(missingPath)).toEqual({ kind: "ok", instanceName: "vault-cortex" }) }) - it("returns the default name when the line is missing, commented or empty", () => { - const withoutLine = writeEnv("MCP_AUTH_TOKEN=abc\n") - const commentedLine = writeEnv("# INSTANCE_NAME=work\n") - const emptyLine = writeEnv("INSTANCE_NAME=\n") - - expect(readEnvInstanceName(withoutLine)).toEqual({ kind: "ok", instanceName: "vault-cortex" }) - expect(readEnvInstanceName(commentedLine)).toEqual({ kind: "ok", instanceName: "vault-cortex" }) - expect(readEnvInstanceName(emptyLine)).toEqual({ kind: "ok", instanceName: "vault-cortex" }) - }) - - it("returns a valid name, with surrounding quotes dropped", () => { - expect(readEnvInstanceName(writeEnv("INSTANCE_NAME=work_2\n"))).toEqual({ - kind: "ok", - instanceName: "work_2", - }) - expect(readEnvInstanceName(writeEnv('INSTANCE_NAME="work-vault"\n'))).toEqual({ + it.each([ + { label: "no INSTANCE_NAME line", content: "MCP_AUTH_TOKEN=abc\n" }, + { label: "a commented-out line", content: "# INSTANCE_NAME=work\n" }, + { label: "an empty value", content: "INSTANCE_NAME=\n" }, + { label: "an empty quoted value", content: 'INSTANCE_NAME=""\n' }, + ])("returns the default name for $label", ({ content }) => { + expect(readEnvInstanceName(writeEnv(content))).toEqual({ kind: "ok", - instanceName: "work-vault", + instanceName: "vault-cortex", }) }) + it.each([ + { label: "a plain name", content: "INSTANCE_NAME=work_2\n", expected: "work_2" }, + { + label: "a double-quoted name", + content: 'INSTANCE_NAME="work-vault"\n', + expected: "work-vault", + }, + { + label: "a single-quoted name", + content: "INSTANCE_NAME='work-vault'\n", + expected: "work-vault", + }, + { label: "a name with trailing spaces", content: "INSTANCE_NAME=work \n", expected: "work" }, + ])("returns $label as written, without quotes or spaces", ({ content, expected }) => { + expect(readEnvInstanceName(writeEnv(content))).toEqual({ kind: "ok", instanceName: expected }) + }) + it("takes the last active line, as docker --env-file does", () => { const envPath = writeEnv("INSTANCE_NAME=vault-cortex\nPORT=8001\nINSTANCE_NAME=work\n") diff --git a/cli/src/__tests__/templates.test.ts b/cli/src/__tests__/templates.test.ts index a3136b75b..5bbe04375 100644 --- a/cli/src/__tests__/templates.test.ts +++ b/cli/src/__tests__/templates.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest" import { parse as parseYaml } from "yaml" import { loadConfig } from "../../../src/vault-mcp/config.js" -import { REMOTE_IMAGE } from "../docker.js" +import { instanceVolumeNames, REMOTE_IMAGE } from "../docker.js" import { buildLocalEnv, buildRemoteEnv } from "../env.js" const readRepoFile = (repoRelativePath: string): string => @@ -153,6 +153,38 @@ describe("image constants", () => { }) }) +/** The parts of a compose file that name an install's project, container and volumes. */ +type ComposeNaming = { + name: string + services: { "vault-cortex": { container_name: string } } + volumes: Record +} + +describe("instance naming in the compose templates", () => { + it.each([{ mode: "local" as const }, { mode: "remote" as const }])( + "$mode names the project and container after INSTANCE_NAME and declares the volumes the CLI mounts", + ({ mode }) => { + const composeFile: ComposeNaming = parseYaml( + readRepoFile(`deploy/${mode}/docker-compose.yml`), + ) + // Compose prefixes each volume with the project name, so one INSTANCE_NAME + // gives a CLI install and a Compose install the same data. + const composeVolumeNames = Object.keys(composeFile.volumes).map((volume) => `work_${volume}`) + const cliVolumeNames = instanceVolumeNames({ mode, instanceName: "work" }) + + expect({ + name: composeFile.name, + containerName: composeFile.services["vault-cortex"].container_name, + volumeNames: composeVolumeNames.sort(), + }).toEqual({ + name: "${INSTANCE_NAME:-vault-cortex}", + containerName: "${COMPOSE_PROJECT_NAME:-${INSTANCE_NAME:-vault-cortex}}", + volumeNames: cliVolumeNames.sort(), + }) + }, + ) +}) + describe("hosted platform templates", () => { describe("render.yaml", () => { it("runs the image with the single-volume boot variables fixed", () => { From d8b5a6744227c5ae190903c8d2a099a1a479c9af Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:57:59 -0400 Subject: [PATCH 16/57] fix(cli): let init's start replace an existing container with the same name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker run refuses a name an existing container holds, so after "Reuse the existing data" (or a re-run in the same folder) init's "Start the server now?" failed with "docker run failed". A yes now stops and replaces that container the way start does, and the question says so first; a failed removal stops before docker run. check-sync-vault: a failed or unparseable sync-list-local no longer puts a node stack trace in the boot log; it only skips the warning. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 46 ++++++++++++++++++- cli/src/init.ts | 15 +++++- .../etc/s6-overlay/scripts/check-sync-vault | 2 +- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index b3eb754a0..37ded8a35 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -10,12 +10,14 @@ import { readEnvInstanceName } from "../scaffold.js" vi.mock("../docker.js", { spy: true }) import { + createRecordingDocker, createScriptedPrompts, dockerDaemonOnly, dockerDown, dockerNotInstalled, dockerReadyEmptyHost, fetchNever, + fetchOk, } from "./command-stubs.js" const makeVault = (): string => { @@ -1527,6 +1529,8 @@ const IN_USE_QUESTION = 'An install named "vault-cortex" already exists on this machine (its container or data volumes are here). What should this install do?' const NAME_QUESTION = "Name for this install (lowercase letters, digits, - and _):" const PORT_QUESTION = "Host port for the server:" +const REPLACE_START_QUESTION = + 'Start the server now? This stops and replaces the existing "vault-cortex" container. Its data volumes are kept.' describe("runInit when the instance name is already in use", () => { it("gives a local install its own name and port, with PUBLIC_URL on that port", async () => { @@ -1658,7 +1662,7 @@ describe("runInit when the instance name is already in use", () => { expect(scripted.asked).toEqual([ IN_USE_QUESTION, "Any optional settings to change? (press enter to skip)", - "Start the server now?", + REPLACE_START_QUESTION, ]) expect(readIdentityLines(targetDir)).toEqual([ "PUBLIC_URL=http://localhost:8000", @@ -1667,6 +1671,46 @@ describe("runInit when the instance name is already in use", () => { ]) }) + it("replaces the existing container when the user reuses its data and starts now", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", [], true]) + const recording = createRecordingDocker() + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchOk }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked.at(-1)).toBe(REPLACE_START_QUESTION) + expect(recording.calls).toEqual([ + "inspect:vault-cortex", + "inspect:vault-cortex", + "rm:vault-cortex", + "run:vault-cortex", + ]) + }) + + it("starts nothing when the existing container can't be removed", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", [], true]) + const recording = createRecordingDocker() + const docker: DockerRunner = { ...recording.docker, stopAndRemoveContainer: () => false } + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([ + "Could not remove the existing container — check: docker rm -f vault-cortex", + ]) + expect(recording.calls).toEqual(["inspect:vault-cortex", "inspect:vault-cortex"]) + }) + it("counts a remote install as in use when only its vault volume is left", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ diff --git a/cli/src/init.ts b/cli/src/init.ts index 0bb194acb..f11e26920 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -298,7 +298,8 @@ const readInstanceNameFromDisk = (targetDir: string, prompts: Prompts): string | /** * Offers to start the container, walking a gate ladder where each failed * gate degrades to instructions instead of an error: daemon running → user - * consents → docker run succeeds → health check passes. Returns "running" + * consents → any container with this name is removed → docker run succeeds + * → health check passes. Returns "running" * when the server is confirmed up, "starting" when the container launched * but the health check timed out, or "not-started" when a gate failed. */ @@ -321,9 +322,19 @@ const offerDockerRun = async ( ) return "not-started" } - const startNow = await prompts.confirm("Start the server now?", true) + // docker run refuses a name an existing container already holds, so a yes + // replaces that container the way `start` does, and the question says so. + const containerExists = docker.containerExists(instanceName) + const startQuestion = containerExists + ? `Start the server now? This stops and replaces the existing "${instanceName}" container. Its data volumes are kept.` + : "Start the server now?" + const startNow = await prompts.confirm(startQuestion, true) if (!startNow) return "not-started" + if (containerExists && !docker.stopAndRemoveContainer(instanceName)) { + prompts.error(`Could not remove the existing container — check: docker rm -f ${instanceName}`) + return "not-started" + } const envFilePath = join(targetDir, ".env") stripEnvQuotedValues(envFilePath) const containerStarted = docker.dockerRun({ diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index be5ca9d2f..a0e0a1954 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -178,7 +178,7 @@ if [ "$PICKED_ID" = "$REQUESTED_ID" ]; then const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) const otherIds = listed.vaults.map((vault) => vault.id).filter((id) => id !== process.env.REQUESTED_ID) if (otherIds.length > 0) console.log(otherIds.join(" ")) - ') + ' 2>/dev/null) if [ -n "$OTHER_IDS" ]; then echo "[obsidian-sync] WARNING: This install also holds Sync setups for other vaults: $OTHER_IDS" >&2 echo "[obsidian-sync] They are not used now. Remove each one's folder under ${SYNC_STATE_ROOT} so it can't be picked later." >&2 From b2abed09b957bef878efeb09c8241e3f6e40a17d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:13:13 -0400 Subject: [PATCH 17/57] fix: match containers by exact name and fail closed on an unreadable vault list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - containerExists compares the name docker inspect prints, because Docker falls back to container-ID prefixes: an all-hex instance name such as "2025" could match another container, which start, restart, upgrade, configure, init and down would then remove. The PTY fake docker prints the inspected name. - check-sync-vault stops the boot when the account's vault list can't be parsed, instead of treating it as a name that matches no vault and letting ob sync-setup write beside another vault's setup. - init rejects an empty --port, as it already rejects an empty --instance-name. - llms-install.md sets INSTANCE_NAME before the second install's first start; the guides and CLI reference say init checks names in use only while Docker is running. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 13 +++++---- cli/src/__tests__/docker.test.ts | 16 ++++++++++ cli/src/__tests__/init.test.ts | 5 ++++ cli/src/__tests__/integration/fixtures/docker | 8 +++-- cli/src/docker.ts | 29 +++++++++++++++---- cli/src/instance-identity.ts | 10 ++++--- deploy/local/README.md | 5 ++-- deploy/remote/README.md | 5 ++-- llms-install.md | 5 ++-- .../etc/s6-overlay/scripts/check-sync-vault | 10 +++++-- .../__tests__/check-sync-vault.test.ts | 19 ++++++++++++ 11 files changed, 99 insertions(+), 26 deletions(-) diff --git a/cli/README.md b/cli/README.md index 08b693c40..71c72617e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -64,12 +64,13 @@ name and port. Existing files are never overwritten without asking. During a remote setup, init offers to generate your [Obsidian Sync token](#get-sync-token) as part of the flow. -To run a second vault on the same machine, run init again with its own -`--dir`. When the machine already has a `vault-cortex` container or data -volume, init asks you to give the new install its own name and port. That name (`INSTANCE_NAME` -in its `.env`) names the install's container and data, and every command run -with that `--dir` acts only on it. Each install gets its own connector in -your MCP client. +To run a second vault on the same machine, start Docker, so init can check +which names are in use, then run init again with its own `--dir`. When the +machine already has a `vault-cortex` container or data volume, init asks you +to give the new install its own name and port. That name +(`INSTANCE_NAME` in its `.env`) names the install's container and data, and +every command run with that `--dir` acts only on it. Each install gets its +own connector in your MCP client. Flags: diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index baaf6f064..7e9ef3232 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -6,6 +6,7 @@ import { classifyDaemonStatus, healthPollTimeoutMs, healthTimeoutMessage, + isContainerNamed, LOCAL_IMAGE, pollHealth, probeHealth, @@ -455,6 +456,21 @@ describe("classifyDaemonStatus", () => { }) }) +describe("isContainerNamed", () => { + it.each([ + { label: "Docker's slash-prefixed name", inspectedName: "/2025\n" }, + { label: "Podman's bare name", inspectedName: "2025\n" }, + ])("matches $label", ({ inspectedName }) => { + expect(isContainerNamed({ inspectedName, instanceName: "2025" })).toBe(true) + }) + + it("rejects a container that inspect found by an ID prefix", () => { + expect( + isContainerNamed({ inspectedName: "/remote-boot-relative-index-db\n", instanceName: "2025" }), + ).toBe(false) + }) +}) + describe("probeHealth", () => { it("returns true when the endpoint responds ok", async () => { const probeResult = await probeHealth( diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 37ded8a35..96e146ce1 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1902,6 +1902,11 @@ describe("runInit --yes when the instance name is already in use", () => { flags: { port: "80a" }, expectedError: "--port must be a whole number between 1 and 65535.", }, + { + label: "an empty --port", + flags: { port: "" }, + expectedError: "--port must be a whole number between 1 and 65535.", + }, ])("exits 1 on $label", async ({ flags, expectedError }) => { const scripted = createScriptedPrompts([]) diff --git a/cli/src/__tests__/integration/fixtures/docker b/cli/src/__tests__/integration/fixtures/docker index 8cc4f787e..b2288e327 100755 --- a/cli/src/__tests__/integration/fixtures/docker +++ b/cli/src/__tests__/integration/fixtures/docker @@ -45,11 +45,13 @@ case "${1:-}" in ;; container) - # docker container inspect — check if container exists - if ! is_present "${3:-}"; then + # docker container inspect --format '{{.Name}}' — prints the name + # with Docker's leading slash when the container exists + container_name="${!#}" + if ! is_present "$container_name"; then exit 1 fi - echo '[{"Id":"abc123","State":{"Status":"running"}}]' + echo "/$container_name" ;; volume) diff --git a/cli/src/docker.ts b/cli/src/docker.ts index ee059c1eb..2ee9b5061 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -183,6 +183,18 @@ export const classifyDaemonStatus = (spawnResult: { return spawnErrorCode === "ENOENT" ? "not-installed" : "not-running" } +/** + * Whether `docker container inspect --format {{.Name}}` printed this name. + * Docker prints it with a leading slash; Podman and nerdctl print it bare. + */ +export const isContainerNamed = (params: { + inspectedName: string + instanceName: string +}): boolean => { + const { inspectedName, instanceName } = params + return inspectedName.trim().replace(/^\//, "") === instanceName +} + export const createDockerRunner = (): DockerRunner => ({ daemonStatus: () => classifyDaemonStatus(spawnSync("docker", ["info"], { timeout: 5_000 })), // stdout is discarded: `docker run -d` prints only the container ID there, @@ -200,12 +212,19 @@ export const createDockerRunner = (): DockerRunner => ({ stopAndRemoveContainer: (instanceName) => { return spawnSync("docker", ["rm", "-f", instanceName]).status === 0 }, - // Callers that must tell "already gone" from "failed" probe with this - // before stopAndRemoveContainer: `docker rm -f` on a missing container - // exits 1 on engines < 23 and 0 on >= 23, so its status can't tell them - // apart. Output stays piped (discarded): this is a boolean probe. + // - Callers probe with this before stopAndRemoveContainer: `docker rm -f` on + // a missing container exits 1 on engines < 23 and 0 on >= 23. + // - The printed name is compared because Docker falls back to container-ID + // prefixes, so an all-hex name ("2025") could match another container. containerExists: (instanceName) => { - return spawnSync("docker", ["container", "inspect", instanceName]).status === 0 + const inspectResult = spawnSync( + "docker", + ["container", "inspect", "--format", "{{.Name}}", instanceName], + { encoding: "utf8" }, + ) + + if (inspectResult.status !== 0) return false + return isContainerNamed({ inspectedName: inspectResult.stdout, instanceName }) }, volumeExists: (volumeName) => { return spawnSync("docker", ["volume", "inspect", volumeName]).status === 0 diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index fd882f253..343c30e58 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -124,7 +124,8 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { /** * Decides this install's name and port before init writes .env: - * - a re-run over an existing .env keeps its name, port and URL (`carried`); + * - a re-run over an existing .env keeps its name and port, plus a local + * PUBLIC_URL or a remote DEVICE_NAME (`carried`); * - --instance-name and --port set them directly; * - a new install whose name is already in use on the machine asks for its own * name and port, or confirms reuse of the existing data for the same vault. @@ -138,8 +139,9 @@ export const resolveInstanceIdentity = async ( const { mode, targetDir, flags } = params const { prompts, docker } = deps - // Compared with undefined so an empty --instance-name is rejected instead of - // falling back to the default name without the in-use check. + // Compared with undefined so an empty flag is an error: an empty + // --instance-name would skip the default name's in-use check, and an empty + // --port would silently become the default port. const flagNameError = flags.instanceName === undefined ? undefined : describeInvalidInstanceName(flags.instanceName) @@ -147,7 +149,7 @@ export const resolveInstanceIdentity = async ( prompts.error(`--instance-name: ${flagNameError}`) return { kind: "exit" } } - if (flags.port && !isValidPort(flags.port)) { + if (flags.port !== undefined && !isValidPort(flags.port)) { prompts.error("--port must be a whole number between 1 and 65535.") return { kind: "exit" } } diff --git a/deploy/local/README.md b/deploy/local/README.md index ec08b3ddc..2baff2eed 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -245,8 +245,9 @@ search index, port and connector. Give every install after the first its own install's container and for the volume that holds its search index (`_mcp_data`); the first install keeps the default, `vault-cortex`. -**Set up with the CLI?** From the folder you ran `init` in the first time, -run it again with a new `--dir`: +**Set up with the CLI?** Start Docker, so `init` can check which names are +in use. Then, from the folder you ran `init` in the first time, run it again +with a new `--dir`: ```bash npx vault-cortex@latest init --dir ./vault-cortex-work diff --git a/deploy/remote/README.md b/deploy/remote/README.md index d8de64ac2..41dace0ee 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -506,8 +506,9 @@ use the first install's volumes: the container refuses to start rather than merge the two vaults (see [Another vault's Sync setup](#another-vaults-sync-setup)). -**Set up with the CLI?** From the folder you ran `init` in the first time, -run it again with a new `--dir`: +**Set up with the CLI?** Start Docker, so `init` can check which names are +in use. Then, from the folder you ran `init` in the first time, run it again +with a new `--dir`: ```bash npx vault-cortex@latest init --mode remote --dir ./vault-cortex-work diff --git a/llms-install.md b/llms-install.md index 248803260..6d49828dc 100644 --- a/llms-install.md +++ b/llms-install.md @@ -165,9 +165,10 @@ docker compose pull && docker compose up -d --force-recreate # update to the l To serve a second vault from the same machine: -1. Repeat Steps 1–2 in a separate folder, with a new `MCP_AUTH_TOKEN` and that vault's `VAULT_PATH`. +1. In a separate folder, repeat Step 1 and Step 2's download and `.env` edit, with a new `MCP_AUTH_TOKEN` and that vault's `VAULT_PATH`. Don't start it yet: started under the default name, it would replace the first install's container. 2. In that folder's `.env`, also set `INSTANCE_NAME` (for example `INSTANCE_NAME=work`), a free `PORT`, and `PUBLIC_URL` on that port (for example `http://localhost:8001`). -3. Register it in the client (Step 4) under its own name and URL. +3. Start it from that folder with Step 2's `docker compose pull` and `docker compose up -d`. +4. Register it in the client (Step 4) under its own name and URL. `INSTANCE_NAME` names that install's container and index volume, so the `docker exec` commands in this guide use that name in place of `vault-cortex` (`docker exec work …`), and its URLs use its `PORT`. The `docker compose` commands keep the service name `vault-cortex` and run from that install's folder. diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index a0e0a1954..55d32e282 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -56,7 +56,7 @@ fi # sync-setup matches it: an exact vault ID first, otherwise exactly one vault # with that name, across owned and shared vaults. Prints nothing when no vault # or several match; ob sync-setup then reports that itself and writes nothing. -REQUESTED_VAULT=$(printf '%s' "$REMOTE_VAULTS" | VAULT_NAME="$VAULT_NAME" node --no-warnings -e ' +if ! REQUESTED_VAULT=$(printf '%s' "$REMOTE_VAULTS" | VAULT_NAME="$VAULT_NAME" node --no-warnings -e ' const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) const allVaults = [...(listed.vaults ?? []), ...(listed.shared ?? [])] const requested = process.env.VAULT_NAME @@ -64,7 +64,13 @@ REQUESTED_VAULT=$(printf '%s' "$REMOTE_VAULTS" | VAULT_NAME="$VAULT_NAME" node - const byName = allVaults.filter((vault) => vault.name === requested) const match = byId ?? (byName.length === 1 ? byName[0] : undefined) if (match) console.log(match.id + "\t" + match.name) -') +'); then + # ob sync-setup reads the list itself and could still set up VAULT_NAME's + # vault beside another vault's setup for this folder. + echo "[obsidian-sync] ERROR: Could not read the list of vaults in your Obsidian account." >&2 + echo "[obsidian-sync] Refusing to sync without knowing which vault VAULT_NAME names." >&2 + exit 1 +fi if [ -z "$REQUESTED_VAULT" ]; then echo "[obsidian-sync] Vault check: '$VAULT_NAME' does not name exactly one vault — ob sync-setup reports it." >&2 exit 0 diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts index 0dc94be97..940c721c3 100644 --- a/src/vault-mcp/__tests__/check-sync-vault.test.ts +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -477,6 +477,25 @@ describe("check-sync-vault", () => { expect(run.obCalls).toEqual(["sync-list-remote --json"]) }) + it("stops when the account's vault list is not JSON", () => { + const run = runCheck({ + vaultName: "Work", + remoteJson: "Fetching vaults...", + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + setupFiles: { [VAULT_A.id]: setupFile(VAULT_A) }, + }) + + expect(run.status).toBe(1) + expect(run.stdout).toBe("") + // node's own parse error comes first. + expect(run.stderr.trimEnd().split("\n").slice(-2)).toEqual([ + "[obsidian-sync] ERROR: Could not read the list of vaults in your Obsidian account.", + "[obsidian-sync] Refusing to sync without knowing which vault VAULT_NAME names.", + ]) + expect(run.obCalls).toEqual(["sync-list-remote --json"]) + }) + it.each([ { label: "no vault", vaults: [VAULT_A] }, { label: "several vaults", vaults: [{ ...VAULT_A, name: "Work" }, VAULT_B] }, From eddbb8a9aa8b01d75c1d6d4ddd373b8f09b7d203 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:16:11 -0400 Subject: [PATCH 18/57] fix(cli): offer the next port to a second install instead of 8000 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "own name" branch runs only when another install already uses the name, and that install most likely holds port 8000, so pressing enter at the port question led to a port conflict. The question's placeholder now also shows the value enter accepts. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 6 ++++++ cli/src/instance-identity.ts | 11 +++++++++-- cli/src/optional-settings.ts | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 96e146ce1..0e88a7441 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1561,6 +1561,12 @@ describe("runInit when the instance name is already in use", () => { "Any optional settings to change? (press enter to skip)", "Start the server now?", ]) + // The install using the default name most likely holds port 8000. + expect(scripted.textCalls[1]).toEqual({ + message: PORT_QUESTION, + defaultValue: "8001", + placeholder: "8001", + }) expect(readIdentityLines(targetDir)).toEqual([ "PUBLIC_URL=http://localhost:8001", "INSTANCE_NAME=work", diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 343c30e58..33f1355f8 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -9,7 +9,12 @@ import { isValidPort, readOptionalValue, } from "./optional-settings.js" -import { DEFAULT_INSTANCE_NAME, describeInvalidInstanceName, type Mode } from "./scaffold.js" +import { + DEFAULT_INSTANCE_NAME, + DEFAULT_PORT, + describeInvalidInstanceName, + type Mode, +} from "./scaffold.js" import type { Prompts } from "./prompts.js" /** The .env values that tell one install on a machine apart from another. */ @@ -211,7 +216,9 @@ export const resolveInstanceIdentity = async ( } const suggestedName = suggestInstanceName({ mode, targetDir }, docker) const instanceName = await askFreeInstanceName({ mode, suggestedName }, deps) - const port = await askPort(flags.port, prompts) + // The install already using the name most likely holds the default port, so + // the next port is offered instead. + const port = await askPort(flags.port ?? String(DEFAULT_PORT + 1), prompts) const chosen = { INSTANCE_NAME: instanceName, PORT: port, diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 293000eb6..b83e764ab 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -271,7 +271,7 @@ export const askPort = async ( const answer = ( await prompts.text("Host port for the server:", { defaultValue: currentValue ?? String(DEFAULT_PORT), - placeholder: String(DEFAULT_PORT), + placeholder: currentValue ?? String(DEFAULT_PORT), }) ).trim() From 5e852a5a84f178ded7073de45fc28c4f6730f684 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:21:24 -0400 Subject: [PATCH 19/57] fix(cli): read an empty PORT line as the default port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty PORT= line leaves the default host port in effect (readEnvPort falls back to it), but the host-port prompt offered an empty default and no placeholder, so pressing enter was rejected, and a PUBLIC_URL on the default port stopped following a port change. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/optional-settings.test.ts | 24 +++++++++++++++++++++ cli/src/optional-settings.ts | 9 +++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 37ee0a0d2..413764a7b 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -103,6 +103,16 @@ describe("derivePublicUrlOverride", () => { }) }) + it("reads an empty PORT line as the default port", () => { + const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=\n", { + PORT: "9000", + }) + expect(derived).toEqual({ + PORT: "9000", + PUBLIC_URL: "http://localhost:9000", + }) + }) + it("never touches a custom PUBLIC_URL", () => { const derived = derivePublicUrlOverride("PUBLIC_URL=https://vault.example.com\nPORT=8000\n", { PORT: "9000", @@ -398,6 +408,20 @@ describe("askOptionalSettings per-setting prompts", () => { expect(overrides).toEqual({ PORT: "8000" }) }) + it("offers the default port when the PORT line is empty", async () => { + const scripted = createScriptedPrompts([["PORT"], ""]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "PORT=\n" }, + scripted.prompts, + ) + + expect(scripted.textCalls).toEqual([ + { message: "Host port for the server:", defaultValue: "8000", placeholder: "8000" }, + ]) + expect(overrides).toEqual({ PORT: "8000" }) + }) + it("seeds the SYNC_MODE select with the current .env value", async () => { const scripted = createScriptedPrompts([["SYNC_MODE"], "bidirectional"]) diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index b83e764ab..5475e6366 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -223,7 +223,8 @@ export const derivePublicUrlOverride = ( const newPort = overrides.PORT if (!newPort) return overrides - const currentPort = readOptionalValue(envContent, "PORT") ?? String(DEFAULT_PORT) + // An empty PORT= line leaves the default port in effect, as readEnvPort reads it. + const currentPort = readOptionalValue(envContent, "PORT") || String(DEFAULT_PORT) const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL") if (currentPublicUrl !== `http://localhost:${currentPort}`) return overrides @@ -268,10 +269,12 @@ export const askPort = async ( currentValue: string | undefined, prompts: Prompts, ): Promise => { + // An empty PORT= line leaves the default port in effect, as readEnvPort reads it. + const suggestedPort = currentValue || String(DEFAULT_PORT) const answer = ( await prompts.text("Host port for the server:", { - defaultValue: currentValue ?? String(DEFAULT_PORT), - placeholder: currentValue ?? String(DEFAULT_PORT), + defaultValue: suggestedPort, + placeholder: suggestedPort, }) ).trim() From 79b4d4e656d08a2b0fbe349ca50fc18ba758fda0 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:27:33 -0400 Subject: [PATCH 20/57] fix: name an unreadable Sync setup after sync-setup, and follow a carried port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init-setup-vault: after ob sync-setup, a failed or vault-less ob sync-config answer now says the Sync client could not read its setup instead of claiming it uses another vault; an empty vault ID from the check gets its own message. - check-sync-vault: a failed setup listing prints a warning instead of silently dropping the dormant-setup warning. - init: on a re-run, a PORT carried from a .env without PUBLIC_URL moves the generated localhost PUBLIC_URL with it; a carried PUBLIC_URL is kept as it is. - The remote guide covers the could-not-read error its link points to. - init-setup-vault.test.ts copies the script instead of symlinking it. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 59 +++++++++++++++++++ cli/src/instance-identity.ts | 10 ++-- deploy/remote/README.md | 5 ++ .../etc/s6-overlay/scripts/check-sync-vault | 16 +++-- .../etc/s6-overlay/scripts/init-setup-vault | 44 +++++++++----- .../__tests__/check-sync-vault.test.ts | 17 +++--- .../__tests__/init-setup-vault.test.ts | 41 ++++++++----- 7 files changed, 145 insertions(+), 47 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 0e88a7441..fcef20031 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1965,6 +1965,65 @@ describe("runInit re-run keeps the instance identity", () => { expect(readEnvLines(targetDir)).not.toContain("MCP_AUTH_TOKEN=old") }) + it("points a generated PUBLIC_URL at the carried port when the old file has none", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\n`, + ) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + [], // no optional settings + true, // overwrite the existing file + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("keeps a custom PUBLIC_URL carried beside a changed port", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\nPUBLIC_URL=https://vault.example.com\n`, + ) + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://vault.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + it("prints commands for the kept file's name and port, not this run's flags", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 33f1355f8..7604efd52 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -230,11 +230,13 @@ export const resolveInstanceIdentity = async ( /** * Writes the identity into freshly generated .env content, before the * optional-settings chooser reads it, so the chooser shows the identity's port - * as current. Carried values land verbatim; chosen ones go through - * derivePublicUrlOverride, so a `http://localhost:` PUBLIC_URL follows a - * new port. + * as current. A `http://localhost:` PUBLIC_URL follows a carried or a + * chosen port; a carried PUBLIC_URL is the user's own and lands verbatim. */ export const applyInstanceIdentity = (envContent: string, identity: InstanceIdentity): string => { - const withCarried = applyOptionalSettings(envContent, identity.carried) + const carriedSettings = identity.carried.PUBLIC_URL + ? identity.carried + : derivePublicUrlOverride(envContent, identity.carried) + const withCarried = applyOptionalSettings(envContent, carriedSettings) return applyOptionalSettings(withCarried, derivePublicUrlOverride(withCarried, identity.chosen)) } diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 41dace0ee..c518c6ddb 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -629,6 +629,11 @@ the file with the command above, using that folder's name in place of ``. Otherwise delete all of the install's volumes, as in the fix for syncing the new vault instead. +An **ERROR** that "the Sync client could not read its own setup" means the +Sync client's answer about which vault it syncs failed or named no vault, so +the container stopped without changing anything. Restart it. If the error +comes back, the Sync client's own message in the lines above it says why. + ## Memory The memory layer is enabled by default. Set `MEMORY_ENABLED=false` in your diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index 55d32e282..793ee0088 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -178,13 +178,17 @@ fi if [ "$PICKED_ID" = "$REQUESTED_ID" ]; then # A second setup for another vault is dormant while the engine picks this # one, so the boot continues; removing it keeps it from being picked later. - # The listing only feeds this warning, so a failed listing skips the + # The listing only feeds this warning, so a failed listing is reported as a # warning instead of stopping a boot the checks above already passed. - OTHER_IDS=$(run_ob sync-list-local --json 2>/dev/null | REQUESTED_ID="$REQUESTED_ID" node --no-warnings -e ' - const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) - const otherIds = listed.vaults.map((vault) => vault.id).filter((id) => id !== process.env.REQUESTED_ID) - if (otherIds.length > 0) console.log(otherIds.join(" ")) - ' 2>/dev/null) + if ! LOCAL_SETUPS=$(run_ob sync-list-local --json 2>/dev/null) || + ! OTHER_IDS=$(printf '%s' "$LOCAL_SETUPS" | REQUESTED_ID="$REQUESTED_ID" node --no-warnings -e ' + const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + const otherIds = listed.vaults.map((vault) => vault.id).filter((id) => id !== process.env.REQUESTED_ID) + if (otherIds.length > 0) console.log(otherIds.join(" ")) + ' 2>/dev/null); then + echo "[obsidian-sync] WARNING: Could not list this install's Sync setups, so a setup for another vault would not be reported here." >&2 + OTHER_IDS="" + fi if [ -n "$OTHER_IDS" ]; then echo "[obsidian-sync] WARNING: This install also holds Sync setups for other vaults: $OTHER_IDS" >&2 echo "[obsidian-sync] They are not used now. Remove each one's folder under ${SYNC_STATE_ROOT} so it can't be picked later." >&2 diff --git a/rootfs/etc/s6-overlay/scripts/init-setup-vault b/rootfs/etc/s6-overlay/scripts/init-setup-vault index 4e6697e1c..b7db99c68 100644 --- a/rootfs/etc/s6-overlay/scripts/init-setup-vault +++ b/rootfs/etc/s6-overlay/scripts/init-setup-vault @@ -75,28 +75,42 @@ if ! run_sync_setup "$@"; then exit 1 fi +SYNC_SETUP_GUIDE_URL="https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup" + +# The check prints no vault ID when VAULT_NAME named no single vault. ob +# sync-setup then fails, so getting here means the vault changed mid-boot. +if [ -z "$REQUESTED_VAULT_ID" ]; then + echo "[obsidian-sync] ERROR: VAULT_NAME '$VAULT_NAME' named no single vault when the vault check ran, so this boot can't confirm which setup ob sync-setup used." >&2 + echo "[obsidian-sync] Restart to run the check again." >&2 + exit 1 +fi + # ob sync-setup has just written VAULT_NAME's setup, so the Sync client must # now use it for this folder. Every sync-config call below writes to the setup # the client uses, so any other answer stops the boot before those writes. -sync_client_uses_vault() { - picked_config=$(s6-setuidgid obsidian ob sync-config --json) || return 1 - printf '%s' "$picked_config" | VAULT_ID="$1" node --no-warnings -e ' - const configText = require("node:fs").readFileSync(0, "utf8") - const parseConfig = () => { - try { - return JSON.parse(configText) - } catch { - process.exit(1) - } - } - process.exit(parseConfig()?.vaultId === process.env.VAULT_ID ? 0 : 1) - ' +# +# Prints the vault ID the Sync client now uses for this folder, or nothing +# when ob sync-config fails or prints no vault ID. It always returns 0, +# because set -e would end the script silently on a failed assignment. +picked_vault_id() { + picked_config=$(s6-setuidgid obsidian ob sync-config --json) || return 0 + printf '%s' "$picked_config" | node --no-warnings -e ' + const config = JSON.parse(require("node:fs").readFileSync(0, "utf8")) + if (typeof config.vaultId === "string") console.log(config.vaultId) + ' 2>/dev/null || return 0 } +PICKED_VAULT_ID=$(picked_vault_id) -if [ -z "$REQUESTED_VAULT_ID" ] || ! sync_client_uses_vault "$REQUESTED_VAULT_ID"; then +if [ -z "$PICKED_VAULT_ID" ]; then + echo "[obsidian-sync] ERROR: After ob sync-setup, the Sync client could not read its own setup." >&2 + echo "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for." >&2 + echo "[obsidian-sync] See: $SYNC_SETUP_GUIDE_URL" >&2 + exit 1 +fi +if [ "$PICKED_VAULT_ID" != "$REQUESTED_VAULT_ID" ]; then echo "[obsidian-sync] ERROR: After ob sync-setup, the Sync client does not use the setup for '$VAULT_NAME' in this folder." >&2 echo "[obsidian-sync] Refusing to sync a vault other than VAULT_NAME." >&2 - echo "[obsidian-sync] See: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup" >&2 + echo "[obsidian-sync] See: $SYNC_SETUP_GUIDE_URL" >&2 exit 1 fi diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts index 940c721c3..902c3003f 100644 --- a/src/vault-mcp/__tests__/check-sync-vault.test.ts +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -441,24 +441,25 @@ describe("check-sync-vault", () => { expect(run.stdout).toBe("aaa111\n") }) - it("continues without the warning when the local setups can't be listed", () => { + it.each([ + { label: "the listing fails", localJson: "", localExit: 1 }, + { label: "the listing is not JSON", localJson: "Fetching vaults...", localExit: 0 }, + ])("continues with a listing warning when $label", ({ localJson, localExit }) => { const run = runCheck({ vaultName: "Personal", remoteJson: remoteVaults([VAULT_A, VAULT_B]), configJson: pickedConfig(VAULT_A.id, VAULT_A.name), configExit: 0, - localJson: "", - localExit: 1, + localJson, + localExit, }) expect(run.status).toBe(0) expect(run.stdout).toBe("aaa111\n") - // Only the outcome is pinned: node's parse error for the empty listing - // reaches stderr before this line. - expect(run.stderr.trimEnd().split("\n").at(-1)).toBe( - "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).", + expect(run.stderr).toBe( + "[obsidian-sync] WARNING: Could not list this install's Sync setups, so a setup for another vault would not be reported here.\n" + + "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).\n", ) - expect(run.stderr).not.toContain("WARNING") }) it("stops when the account's vault list can't be read", () => { diff --git a/src/vault-mcp/__tests__/init-setup-vault.test.ts b/src/vault-mcp/__tests__/init-setup-vault.test.ts index 4c45fa3b4..dbe8ae12d 100644 --- a/src/vault-mcp/__tests__/init-setup-vault.test.ts +++ b/src/vault-mcp/__tests__/init-setup-vault.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process" -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync, symlinkSync } from "node:fs" +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import { join, resolve } from "node:path" @@ -72,6 +72,11 @@ const PICKED_VAULT_ERROR = "[obsidian-sync] Refusing to sync a vault other than VAULT_NAME.\n" + "[obsidian-sync] See: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup\n" +const UNREADABLE_SETUP_ERROR = + "[obsidian-sync] ERROR: After ob sync-setup, the Sync client could not read its own setup.\n" + + "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for.\n" + + "[obsidian-sync] See: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md#another-vaults-sync-setup\n" + type SetupRun = { status: number | null stdout: string @@ -125,7 +130,7 @@ const runSetupScript = (options: SetupRunOptions): SetupRun => { writeFileSync(join(stubBinDir, "s6-setuidgid"), SETUIDGID_STUB, { mode: 0o755, }) - symlinkSync(SCRIPT_PATH, join(scriptsDir, "init-setup-vault")) + copyFileSync(SCRIPT_PATH, join(scriptsDir, "init-setup-vault")) writeFileSync(join(scriptsDir, "check-sync-vault"), VAULT_CHECK_STUB) const callLogPath = join(tempDir, "calls.log") @@ -244,19 +249,24 @@ describe("init-setup-vault script", () => { expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault", PICKED_VAULT_CALL]) }) - it("stops before any sync-config write when ob sync-config --json fails after ob sync-setup", () => { - const run = runSetupScript({ vaultName: "MyVault", syncConfigFailsFor: "--json" }) - - expect(run.status).toBe(1) - expect(run.stderr).toBe(PICKED_VAULT_ERROR) - expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault", PICKED_VAULT_CALL]) - }) - - it("stops before any sync-config write when ob sync-config --json prints no JSON after ob sync-setup", () => { - const run = runSetupScript({ vaultName: "MyVault", pickedConfigOutput: "Fetching vaults..." }) + it.each<{ label: string; options: SetupRunOptions }>([ + { + label: "ob sync-config --json fails", + options: { vaultName: "MyVault", syncConfigFailsFor: "--json" }, + }, + { + label: "ob sync-config --json prints no JSON", + options: { vaultName: "MyVault", pickedConfigOutput: "Fetching vaults..." }, + }, + { + label: "ob sync-config --json prints no vault ID", + options: { vaultName: "MyVault", pickedConfigOutput: '{"syncMode":"bidirectional"}' }, + }, + ])("stops before any sync-config write when $label after ob sync-setup", ({ options }) => { + const run = runSetupScript(options) expect(run.status).toBe(1) - expect(run.stderr).toBe(PICKED_VAULT_ERROR) + expect(run.stderr).toBe(UNREADABLE_SETUP_ERROR) expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault", PICKED_VAULT_CALL]) }) @@ -274,7 +284,10 @@ describe("init-setup-vault script", () => { const run = runSetupScript({ vaultName: "MyVault", vaultCheckVaultId: "" }) expect(run.status).toBe(1) - expect(run.stderr).toBe(PICKED_VAULT_ERROR) + expect(run.stderr).toBe( + "[obsidian-sync] ERROR: VAULT_NAME 'MyVault' named no single vault when the vault check ran, so this boot can't confirm which setup ob sync-setup used.\n" + + "[obsidian-sync] Restart to run the check again.\n", + ) expect(run.calls).toEqual([VAULT_CHECK_CALL, "sync-setup --vault MyVault"]) }) From db3e7c5422fff5f41e643696d71ba73ffa666896 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:38:01 -0400 Subject: [PATCH 21/57] fix: show the Sync client's reason when the vault check can't read its setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault check hides ob sync-config's stderr so a first boot's exit 3 stays quiet, which also dropped the reason for any other failure. The remote guide tells users that reason is in the lines above the error. On a failure other than exit 3, the check now runs the read-only call once more with its stderr shown before refusing. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../etc/s6-overlay/scripts/check-sync-vault | 3 +++ .../__tests__/check-sync-vault.test.ts | 22 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index 793ee0088..8c77b86d4 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -157,6 +157,9 @@ if [ "$PICK_EXIT" -eq 3 ]; then exit 0 fi if [ "$PICK_EXIT" -ne 0 ]; then + # The call above drops ob's message so a first boot's exit 3 stays quiet. + # Running this read-only call again puts the failure's reason in the log. + run_ob sync-config --json >/dev/null refuse_unreadable_setup "ob sync-config exited $PICK_EXIT" fi diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts index 902c3003f..18139da99 100644 --- a/src/vault-mcp/__tests__/check-sync-vault.test.ts +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -32,7 +32,7 @@ const GUIDE_URL = // Engine vectors: captured from obsidian-headless 0.0.14 inside the :remote // image with hand-written config trees and no token. // `ob sync-config --json` with no setup for the working folder (none at all, -// or only for another path) prints nothing and exits 3. +// or only for another path) prints nothing on stdout and exits 3. const NO_SETUP_EXIT = 3 // A setup without vaultPath that sorts first makes `ob sync-config` throw. const UNREADABLE_SETUP_EXIT = 1 @@ -83,7 +83,7 @@ echo "$*" >> "$OB_CALL_LOG" pwd >> "$OB_CWD_LOG" case "$1" in sync-list-remote) printf '%s' "$OB_REMOTE_JSON"; exit "$OB_REMOTE_EXIT" ;; - sync-config) printf '%s' "$OB_CONFIG_JSON"; exit "$OB_CONFIG_EXIT" ;; + sync-config) printf '%s' "$OB_CONFIG_JSON"; printf '%s' "$OB_CONFIG_STDERR" >&2; exit "$OB_CONFIG_EXIT" ;; sync-list-local) printf '%s' "$OB_LOCAL_JSON"; exit "$OB_LOCAL_EXIT" ;; *) exit 0 ;; esac @@ -101,6 +101,8 @@ type CheckOptions = { remoteExit?: number configJson?: string configExit: number + /** What `ob sync-config` prints on stderr, as the engine prints its reason there. */ + configStderr?: string localJson?: string localExit?: number /** When true, VAULT_PATH names a folder that does not exist. */ @@ -183,6 +185,7 @@ const runCheck = (options: CheckOptions): CheckRun => { OB_REMOTE_EXIT: String(options.remoteExit ?? 0), OB_CONFIG_JSON: options.configJson ?? "", OB_CONFIG_EXIT: String(options.configExit), + OB_CONFIG_STDERR: options.configStderr ?? "", OB_LOCAL_JSON: options.localJson ?? localSetups([]), OB_LOCAL_EXIT: String(options.localExit ?? 0), OB_CWD_LOG: cwdLogPath, @@ -223,11 +226,13 @@ describe("check-sync-vault", () => { vaultName: "Work", remoteJson: remoteVaults([VAULT_A, VAULT_B]), configExit: NO_SETUP_EXIT, + configStderr: "No sync configuration found for /vault\nRun 'ob sync-setup' first.\n", }) expect(run.status).toBe(0) // stdout carries only the requested vault's ID, for init-setup-vault. expect(run.stdout).toBe("bbb222\n") + // The engine's own first-boot message stays out of the log. expect(run.stderr).toBe( "[obsidian-sync] Vault check: continue — no Sync setup for this folder yet.\n", ) @@ -583,19 +588,28 @@ describe("check-sync-vault", () => { }, ) - it("stops when the engine can't read its own setup", () => { + it("stops with the engine's own reason when it can't read its own setup", () => { const run = runCheck({ vaultName: "Work", remoteJson: remoteVaults([VAULT_B]), configExit: UNREADABLE_SETUP_EXIT, + configStderr: + 'Config update failed: TypeError: The "path" argument must be of type string.\n', }) expect(run.status).toBe(1) expect(run.stderr).toBe( - "[obsidian-sync] ERROR: The Sync client could not read its own setup (ob sync-config exited 1).\n" + + 'Config update failed: TypeError: The "path" argument must be of type string.\n' + + "[obsidian-sync] ERROR: The Sync client could not read its own setup (ob sync-config exited 1).\n" + "[obsidian-sync] Refusing to sync without knowing which vault this install is set up for.\n" + `[obsidian-sync] See: ${GUIDE_URL}\n`, ) + // The second, read-only call is the one whose message reaches the log. + expect(run.obCalls).toEqual([ + "sync-list-remote --json", + "sync-config --json", + "sync-config --json", + ]) }) it.each([ From a1c358a40cb00966aedc7906b71af73c73ff9e4f Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:38:54 -0400 Subject: [PATCH 22/57] test: say the init-setup-vault harness copies the script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/vault-mcp/__tests__/init-setup-vault.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vault-mcp/__tests__/init-setup-vault.test.ts b/src/vault-mcp/__tests__/init-setup-vault.test.ts index dbe8ae12d..1d19f101a 100644 --- a/src/vault-mcp/__tests__/init-setup-vault.test.ts +++ b/src/vault-mcp/__tests__/init-setup-vault.test.ts @@ -13,7 +13,7 @@ import { describe, expect, it, onTestFinished } from "vitest" * exercised here by running the real script under `sh` with stub `ob` and * `s6-setuidgid` executables on PATH. * - * The script runs check-sync-vault from its own folder, so each run links the + * The script runs check-sync-vault from its own folder, so each run copies the * real script into a temp folder beside a stub check. The check's own * branches are covered in check-sync-vault.test.ts; these tests cover when * it runs and what happens on each of its answers. From d45c1e49ac2c34adbcb84cffe8ac7dd83205a91d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:47:37 -0400 Subject: [PATCH 23/57] fix(cli): read .env values without their quotes, and name a reused remote install's device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readOptionalValue drops one pair of surrounding quotes, as stripEnvQuotedValues does before docker run, so a quoted PORT moves a localhost PUBLIC_URL on a re-run instead of landing inside it, and the port prompt no longer rejects its own suggested value. - A remote install reusing a name from --instance-name gets that name as DEVICE_NAME, as a new name does. - check-sync-vault.test.ts covers the gone-vault refusal under STORAGE_ROOT. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 56 +++++++++++++++++++ cli/src/__tests__/optional-settings.test.ts | 11 ++++ cli/src/instance-identity.ts | 14 +++-- cli/src/optional-settings.ts | 13 +++-- cli/src/scaffold.ts | 2 +- .../__tests__/check-sync-vault.test.ts | 29 ++++++++++ 6 files changed, 113 insertions(+), 12 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index fcef20031..1a6aeedd5 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1890,6 +1890,35 @@ describe("runInit --yes when the instance name is already in use", () => { expect(readEnvInstanceName(join(targetDir, ".env"))).toEqual({ kind: "ok", instanceName }) }) + it("gives a remote install reusing a name from --instance-name that name as its device name", async () => { + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "https://work.example.com", // public URL + "Work", // vault name + false, // don't generate the token now + false, // no encryption + [], // no optional settings + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir, instanceName: "work", port: "8001" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.logs).toContain('Reusing the data of the existing "work" install.') + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://work.example.com", + "INSTANCE_NAME=work", + "PORT=8001", + "DEVICE_NAME=work", + ]) + }) + it.each([ { label: "an invalid --instance-name", @@ -1997,6 +2026,33 @@ describe("runInit re-run keeps the instance identity", () => { ]) }) + it("points a generated PUBLIC_URL at a quoted carried port without its quotes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT="8001"\n`, + ) + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + it("keeps a custom PUBLIC_URL carried beside a changed port", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 413764a7b..91a29449e 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -24,6 +24,17 @@ describe("readOptionalValue", () => { it("returns undefined for a missing line", () => { expect(readOptionalValue("PORT=9000\n", "TZ")).toBeUndefined() }) + + it.each([ + { label: "double quotes", envContent: 'PORT="9000"\n' }, + { label: "single quotes", envContent: "PORT='9000'\n" }, + ])("drops one pair of surrounding $label, as the server reads the value", ({ envContent }) => { + expect(readOptionalValue(envContent, "PORT")).toBe("9000") + }) + + it("returns an empty string for a line with no value", () => { + expect(readOptionalValue("PORT=\n", "PORT")).toBe("") + }) }) describe("applyOptionalSettings", () => { diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 7604efd52..9dcaea79d 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -176,18 +176,20 @@ export const resolveInstanceIdentity = async ( const requestedNameInUse = docker.daemonStatus() === "running" && isInstanceNameInUse({ mode, instanceName: requestedName }, docker) + // A new folder's .env starts from the template's device name, so a name + // given by --instance-name sets it too, whether the name is new or reused. + const flagSettings = { + ...flagOverrides, + ...(flags.instanceName ? buildDeviceNameSetting(mode, flags.instanceName) : {}), + } // A name not in use, or one Docker could not check, starts a new install. if (!requestedNameInUse) { - const chosen = { - ...flagOverrides, - ...(flags.instanceName ? buildDeviceNameSetting(mode, flags.instanceName) : {}), - } - return { kind: "ok", identity: { carried: {}, chosen } } + return { kind: "ok", identity: { carried: {}, chosen: flagSettings } } } if (flags.instanceName) { prompts.log(`Reusing the data of the existing "${requestedName}" install.`) - return { kind: "ok", identity: { carried: {}, chosen: flagOverrides } } + return { kind: "ok", identity: { carried: {}, chosen: flagSettings } } } if (flags.yes) { prompts.error(nameInUseWithYesFlagMessage(requestedName)) diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 5475e6366..624dc3c53 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -1,4 +1,4 @@ -import { DEFAULT_PORT, type Mode } from "./scaffold.js" +import { DEFAULT_PORT, stripSurroundingQuotes, type Mode } from "./scaffold.js" import type { Prompts, SelectOption } from "./prompts.js" type OptionalSettingBase = { @@ -172,13 +172,16 @@ const commentedLinePattern = (name: string): RegExp => new RegExp(`^# ${name}=.* * Reads a var's current value from .env content. A commented-out or missing * line returns undefined — both mean "the server uses its built-in default", * which is exactly what the prompts need to distinguish. Duplicate lines - * report the LAST value, matching docker --env-file precedence — hints, - * prompt seeding, and the PUBLIC_URL derivation must reason from the value - * that actually takes effect. + * report the LAST value, matching docker --env-file precedence, and lose one + * pair of surrounding quotes, as stripEnvQuotedValues removes them before + * docker run. Hints, prompt seeding, and the PUBLIC_URL derivation must + * reason from the value that actually takes effect. */ export const readOptionalValue = (envContent: string, name: string): string | undefined => { const matches = [...envContent.matchAll(new RegExp(`^${name}=(.*)$`, "gm"))] - return matches.at(-1)?.[1].trim() + const lastMatch = matches.at(-1) + + return lastMatch && stripSurroundingQuotes(lastMatch[1].trim()) } /** diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index cedd39589..af9130e01 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -60,7 +60,7 @@ const QUOTED_ENV_VALUE = /^([A-Za-z_][A-Za-z0-9_]*=)(["'])(.*)\2(\s*)$/gm * `'bar'` → `bar`, `unquoted` → `unquoted`. Only strips when the * opening and closing quote characters match. */ -const stripSurroundingQuotes = (value: string): string => { +export const stripSurroundingQuotes = (value: string): string => { const quoteMatch = /^(["'])(.*)\1$/.exec(value) return quoteMatch ? quoteMatch[2] : value } diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts index 18139da99..dd36e5410 100644 --- a/src/vault-mcp/__tests__/check-sync-vault.test.ts +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -369,6 +369,35 @@ describe("check-sync-vault", () => { ) }) + it("words the gone-vault fixes for the single-volume hosted layout under STORAGE_ROOT", () => { + const deletedVault = { id: "ddd444", name: "Old Work" } + const run = runCheck({ + vaultName: "Work", + remoteJson: remoteVaults([VAULT_B]), + configJson: pickedConfig(deletedVault.id, deletedVault.name), + configExit: 0, + setupFiles: { [deletedVault.id]: setupFile(deletedVault) }, + storageRoot: "/persist", + }) + + expect(run.status).toBe(1) + expect(run.stderr).toBe( + [ + "[obsidian-sync] ERROR: This install is set up to sync a different vault than VAULT_NAME.", + "[obsidian-sync] VAULT_NAME is 'Work' (bbb222), but its Sync setup is for 'Old Work' (ddd444), which is no longer in your account.", + "[obsidian-sync] Syncing now would merge the two vaults' notes. Nothing was changed. To fix it:", + "[obsidian-sync] - If VAULT_NAME is a mistake, set it back and restart.", + "[obsidian-sync] - To sync 'Work' as well, run it as a separate service.", + "[obsidian-sync] - If you re-created this same vault in Obsidian and the notes here are its notes,", + `[obsidian-sync] remove only the old setup folder: ${run.syncRoot}/ddd444`, + "[obsidian-sync] - To switch this install to 'Work' on purpose, remove /persist/vault and /persist/config together.", + "[obsidian-sync] Removing only the Sync config would upload the old vault's notes into the new one.", + `[obsidian-sync] See: ${GUIDE_URL}`, + "", + ].join("\n"), + ) + }) + it("names a picked vault that is shared with the account by its current name", () => { const run = runCheck({ vaultName: "Work", From e965052187a233dd3e4948e15a6e0bbe39afb44a Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:55:07 -0400 Subject: [PATCH 24/57] fix(cli): read an empty memory or daily-notes folder as unset in the settings prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The memory folder prompt offers the default folder for an empty MEMORY_DIR value, as the server reads it, instead of rejecting its own empty suggestion. A quoted empty value now reads as empty too. - The daily notes prompts treat an empty value as unset, matching the chooser's "not set" hint, instead of keeping an empty "current value". - Doc comments say carried identity values and readOptionalValue's result lose their surrounding quotes. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/optional-settings.test.ts | 39 +++++++++++++++++++++ cli/src/instance-identity.ts | 5 ++- cli/src/optional-settings.ts | 16 +++++---- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 91a29449e..5630c7339 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -399,6 +399,21 @@ describe("askOptionalSettings per-setting prompts", () => { expect(overrides).toEqual({ MEMORY_DIR: "Memory Bank" }) }) + it.each([ + { label: "an empty line", envContent: "MEMORY_DIR=\n" }, + { label: "empty quotes", envContent: 'MEMORY_DIR=""\n' }, + ])( + "offers the default memory folder when MEMORY_DIR is $label, as the server reads it", + async ({ envContent }) => { + const scripted = createScriptedPrompts([["MEMORY_DIR"], ""]) + + const overrides = await askOptionalSettings({ mode: "local", envContent }, scripted.prompts) + + expect(scripted.errors).toEqual([]) + expect(overrides).toEqual({ MEMORY_DIR: "About Me" }) + }, + ) + it("re-prompts on an empty memory folder until a name is given", async () => { // No current value and no typed answer: the stub returns the prompt's // defaultValue ("About Me"), so a genuinely empty submission needs the @@ -480,6 +495,30 @@ describe("askOptionalSettings per-setting prompts", () => { ]) }) + it.each([ + { label: "an empty line", envContent: "DAILY_NOTES_FOLDER=\n" }, + { label: "empty quotes", envContent: 'DAILY_NOTES_FOLDER=""\n' }, + ])( + "treats a daily notes folder set to $label as unset, as the server reads it", + async ({ envContent }) => { + const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) + + const overrides = await askOptionalSettings({ mode: "local", envContent }, scripted.prompts) + + expect(scripted.textCalls).toEqual([ + { + message: "Vault folder for daily notes:", + defaultValue: "", + placeholder: "blank = use your vault's daily notes settings", + }, + ]) + expect(overrides).toEqual({}) + expect(scripted.logs).toEqual([ + "Left unset — the server reads this setting from your vault's own config.", + ]) + }, + ) + it("keeps a set daily notes folder on a blank submit without recording a no-op", async () => { // The prompt resolves an empty submit to its defaultValue (the current // value), so blank never destroys an existing setting — and the unchanged diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 9dcaea79d..ff3d55997 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -19,7 +19,10 @@ import type { Prompts } from "./prompts.js" /** The .env values that tell one install on a machine apart from another. */ export type InstanceIdentity = { - /** Copied verbatim from the folder's existing .env when init runs again over it. */ + /** + * Read from the folder's existing .env, without surrounding quotes, when + * init runs again over it. + */ carried: Record /** * Chosen in this run, by flag or prompt. A `http://localhost:` diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 624dc3c53..1c7a33284 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -172,10 +172,10 @@ const commentedLinePattern = (name: string): RegExp => new RegExp(`^# ${name}=.* * Reads a var's current value from .env content. A commented-out or missing * line returns undefined — both mean "the server uses its built-in default", * which is exactly what the prompts need to distinguish. Duplicate lines - * report the LAST value, matching docker --env-file precedence, and lose one - * pair of surrounding quotes, as stripEnvQuotedValues removes them before - * docker run. Hints, prompt seeding, and the PUBLIC_URL derivation must - * reason from the value that actually takes effect. + * report the LAST value, matching docker --env-file precedence. The value + * loses one pair of surrounding quotes, as stripEnvQuotedValues removes them + * before docker run. Hints, prompt seeding, and the PUBLIC_URL derivation + * must reason from the value that actually takes effect. */ export const readOptionalValue = (envContent: string, name: string): string | undefined => { const matches = [...envContent.matchAll(new RegExp(`^${name}=(.*)$`, "gm"))] @@ -314,7 +314,8 @@ const askFolder = async ( const { question, currentValue, defaultValue } = params const answer = ( await prompts.text(question, { - defaultValue: currentValue ?? defaultValue, + // An empty .env value leaves the server's default folder in effect. + defaultValue: currentValue || defaultValue, placeholder: defaultValue, }) ).trim() @@ -353,12 +354,13 @@ const askOptionalText = async ( const answer = ( await prompts.text(question, { defaultValue: currentValue, - placeholder: currentValue === undefined ? placeholder : "blank = keep the current value", + // The server reads an empty .env value as unset. + placeholder: currentValue ? "blank = keep the current value" : placeholder, }) ).trim() if (answer !== "" && answer !== currentValue) return answer - if (currentValue === undefined) { + if (!currentValue) { prompts.log("Left unset — the server reads this setting from your vault's own config.") return undefined } From 7532507b31b41f7e3451256a7be244c09d60d49d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:02:36 -0400 Subject: [PATCH 25/57] fix(cli): refuse a re-run of init that renames the install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A re-run over an existing .env skipped the in-use check, so a different --instance-name could leave the old container running and point the folder at another install's volumes. The re-run now exits with the manual rename steps; the same name is still accepted. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 7 +++-- cli/src/__tests__/init.test.ts | 57 +++++++++++++++++++++++++++++++--- cli/src/instance-identity.ts | 20 +++++++++++- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/cli/README.md b/cli/README.md index 71c72617e..1ca7204f1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -60,9 +60,10 @@ What it does: Re-running init where a setup already exists asks first — declining leaves everything unchanged and points you at [`configure`](#configure), the right tool for changing settings in place. A re-run keeps the setup's instance -name and port. Existing files are never overwritten without asking. During a -remote setup, init offers to generate your -[Obsidian Sync token](#get-sync-token) as part of the flow. +name and port, and stops if `--instance-name` names a different install. +Existing files are never overwritten without asking. During a remote setup, +init offers to generate your [Obsidian Sync token](#get-sync-token) as part +of the flow. To run a second vault on the same machine, start Docker, so init can check which names are in use, then run init again with its own `--dir`. When the diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 1a6aeedd5..6f18649b6 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -2080,7 +2080,56 @@ describe("runInit re-run keeps the instance identity", () => { ]) }) - it("prints commands for the kept file's name and port, not this run's flags", async () => { + it("refuses a re-run whose --instance-name renames the install, and keeps its file", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + writeExistingLocalEnv(targetDir, vaultDir) + const existingEnv = readFileSync(join(targetDir, ".env"), "utf8") + const scripted = createScriptedPrompts([true]) // re-run setup anyway + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "other" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `--instance-name: the install in ${targetDir} is named "work", and init keeps that name.\n` + + `To rename it, stop it first (npx vault-cortex down --dir ${targetDir}), then change INSTANCE_NAME in its .env:\n` + + "it starts on new, empty volumes under the new name.", + ]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + }) + + it("accepts a re-run whose --instance-name repeats the install's name", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + writeExistingLocalEnv(targetDir, vaultDir) + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "work" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("prints commands for the kept file's port, not this run's --port", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() writeExistingLocalEnv(targetDir, vaultDir) @@ -2091,10 +2140,10 @@ describe("runInit re-run keeps the instance identity", () => { false, // don't start now ]) - // The flags make this run's generated .env differ from the kept one, so - // the printed commands show which file they were read from. + // --port makes this run's generated .env differ from the kept one, so the + // printed commands show which file they were read from. const exitCode = await runInit( - { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "other", port: "8002" }, + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "8002" }, { prompts: scripted.prompts, docker: dockerWithExisting(["vault-cortex", "work"]), diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index ff3d55997..7a7e15cc5 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -122,6 +122,15 @@ const buildDeviceNameSetting = (mode: Mode, instanceName: string): Record { + const { carriedName, targetDir } = params + return ( + `--instance-name: the install in ${targetDir} is named "${carriedName}", and init keeps that name.\n` + + `To rename it, stop it first (npx vault-cortex down --dir ${targetDir}), then change INSTANCE_NAME in its .env:\n` + + `it starts on new, empty volumes under the new name.` + ) +} + const nameInUseWithYesFlagMessage = (instanceName: string): string => { return ( `An install named "${instanceName}" already exists on this machine (its container or data volumes are here).\n` + @@ -133,7 +142,8 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { /** * Decides this install's name and port before init writes .env: * - a re-run over an existing .env keeps its name and port, plus a local - * PUBLIC_URL or a remote DEVICE_NAME (`carried`); + * PUBLIC_URL or a remote DEVICE_NAME (`carried`), and exits when + * --instance-name names a different install; * - --instance-name and --port set them directly; * - a new install whose name is already in use on the machine asks for its own * name and port, or confirms reuse of the existing data for the same vault. @@ -172,6 +182,14 @@ export const resolveInstanceIdentity = async ( // container and volumes would always count as in use, so a re-run skips the check. if (existsSync(existingEnvPath)) { const carried = readCarriedIdentity(existingEnvPath, mode) + const carriedName = carried.INSTANCE_NAME || DEFAULT_INSTANCE_NAME + + // Renaming here would leave the old container running and could point + // the folder at another install's volumes, so the name stays. + if (flags.instanceName && flags.instanceName !== carriedName) { + prompts.error(renameOnRerunMessage({ carriedName, targetDir })) + return { kind: "exit" } + } return { kind: "ok", identity: { carried, chosen: flagOverrides } } } From 72c0a76e2f3ce378400b6c74bc87f04ec8d895f5 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:07:19 -0400 Subject: [PATCH 26/57] fix(cli): quote the rename hint's folder and say the new name must be free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refused-rename message now prints a pasteable `npx vault-cortex@latest down --dir ""`, like every other command hint, and asks for a name no other install uses, since a hand-edited name skips init's in-use check. The README states that --instance-name is refused for any different name, and that a re-run's port can still be changed with --port or the optional settings. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 3 ++- cli/src/__tests__/init.test.ts | 4 ++-- cli/src/instance-identity.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cli/README.md b/cli/README.md index 1ca7204f1..b9492a34d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -60,7 +60,8 @@ What it does: Re-running init where a setup already exists asks first — declining leaves everything unchanged and points you at [`configure`](#configure), the right tool for changing settings in place. A re-run keeps the setup's instance -name and port, and stops if `--instance-name` names a different install. +name, and stops if `--instance-name` gives a different one. It keeps the +port too, unless you change it with `--port` or in the optional settings. Existing files are never overwritten without asking. During a remote setup, init offers to generate your [Obsidian Sync token](#get-sync-token) as part of the flow. diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 6f18649b6..1f0b4a5d1 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -2099,8 +2099,8 @@ describe("runInit re-run keeps the instance identity", () => { expect(exitCode).toBe(1) expect(scripted.errors).toEqual([ `--instance-name: the install in ${targetDir} is named "work", and init keeps that name.\n` + - `To rename it, stop it first (npx vault-cortex down --dir ${targetDir}), then change INSTANCE_NAME in its .env:\n` + - "it starts on new, empty volumes under the new name.", + `To rename it, stop it first (npx vault-cortex@latest down --dir "${targetDir}"), then change INSTANCE_NAME in its .env\n` + + "to a name no other install uses: it starts on new, empty volumes under the new name.", ]) expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) }) diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 7a7e15cc5..c08034a41 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -126,8 +126,8 @@ const renameOnRerunMessage = (params: { carriedName: string; targetDir: string } const { carriedName, targetDir } = params return ( `--instance-name: the install in ${targetDir} is named "${carriedName}", and init keeps that name.\n` + - `To rename it, stop it first (npx vault-cortex down --dir ${targetDir}), then change INSTANCE_NAME in its .env:\n` + - `it starts on new, empty volumes under the new name.` + `To rename it, stop it first (npx vault-cortex@latest down --dir "${targetDir}"), then change INSTANCE_NAME in its .env\n` + + `to a name no other install uses: it starts on new, empty volumes under the new name.` ) } @@ -143,7 +143,7 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { * Decides this install's name and port before init writes .env: * - a re-run over an existing .env keeps its name and port, plus a local * PUBLIC_URL or a remote DEVICE_NAME (`carried`), and exits when - * --instance-name names a different install; + * --instance-name gives a different name; * - --instance-name and --port set them directly; * - a new install whose name is already in use on the machine asks for its own * name and port, or confirms reuse of the existing data for the same vault. From 524035c2d0a4a8e805d12e0e859472f514b67294 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:08:45 -0400 Subject: [PATCH 27/57] fix(cli): report an invalid kept INSTANCE_NAME on a re-run, and say a new name must be free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A re-run whose --instance-name differs from an invalid INSTANCE_NAME in the existing .env now gets the invalid-name error the other commands give, instead of rename steps whose down would refuse that name. - Both .env.example files (and the CLI's synced blocks) say to change INSTANCE_NAME to a name no other install uses. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 25 +++++++++++++++++++++++++ cli/src/env.ts | 13 +++++++------ cli/src/instance-identity.ts | 9 ++++++++- deploy/local/.env.example | 6 +++--- deploy/remote/.env.example | 7 ++++--- 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 1f0b4a5d1..4cba2f885 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -2105,6 +2105,31 @@ describe("runInit re-run keeps the instance identity", () => { expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) }) + it("reports an invalid kept name instead of the rename steps when --instance-name differs", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=Work Vault\n`, + ) + const scripted = createScriptedPrompts([true]) // re-run setup anyway + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "work" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `"Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit. Fix it in ${targetDir}/.env.`, + ]) + }) + it("accepts a re-run whose --instance-name repeats the install's name", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() diff --git a/cli/src/env.ts b/cli/src/env.ts index 5e24cb50c..bf4ea3169 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -135,9 +135,9 @@ MEMORY_DIR=About Me # starting with a letter or digit. # Default: vault-cortex # Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex down), then change it: the install -# starts on a new, empty index volume, and the old one stays until you remove -# it (docker volume rm _mcp_data). +# (docker compose down, or npx vault-cortex down), then change it to a name no +# other install uses: the install starts on a new, empty index volume, and the +# old one stays until you remove it (docker volume rm _mcp_data). INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). @@ -301,9 +301,10 @@ MEMORY_DIR=About Me # starting with a letter or digit. # Default: vault-cortex # Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex down), then change it: the install -# starts on new, empty volumes and downloads the vault again as a new Obsidian -# Sync device, while the old volumes stay until you remove them. +# (docker compose down, or npx vault-cortex down), then change it to a name no +# other install uses: the install starts on new, empty volumes and downloads +# the vault again as a new Obsidian Sync device, while the old volumes stay +# until you remove them. INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index c08034a41..b8aa403c5 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -187,7 +187,14 @@ export const resolveInstanceIdentity = async ( // Renaming here would leave the old container running and could point // the folder at another install's volumes, so the name stays. if (flags.instanceName && flags.instanceName !== carriedName) { - prompts.error(renameOnRerunMessage({ carriedName, targetDir })) + // An invalid kept name gets the error every other command gives it, + // since the rename steps start with a down that would refuse it. + const carriedNameError = describeInvalidInstanceName(carriedName) + prompts.error( + carriedNameError + ? `${carriedNameError} Fix it in ${targetDir}/.env.` + : renameOnRerunMessage({ carriedName, targetDir }), + ) return { kind: "exit" } } return { kind: "ok", identity: { carried, chosen: flagOverrides } } diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 687c1d2a6..52f625365 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -125,9 +125,9 @@ MEMORY_DIR=About Me # starting with a letter or digit. # Default: vault-cortex # Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex down), then change it: the install -# starts on a new, empty index volume, and the old one stays until you remove -# it (docker volume rm _mcp_data). +# (docker compose down, or npx vault-cortex down), then change it to a name no +# other install uses: the install starts on a new, empty index volume, and the +# old one stays until you remove it (docker volume rm _mcp_data). INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index 549df50a3..e7e8832c6 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -131,9 +131,10 @@ MEMORY_DIR=About Me # starting with a letter or digit. # Default: vault-cortex # Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex down), then change it: the install -# starts on new, empty volumes and downloads the vault again as a new Obsidian -# Sync device, while the old volumes stay until you remove them. +# (docker compose down, or npx vault-cortex down), then change it to a name no +# other install uses: the install starts on new, empty volumes and downloads +# the vault again as a new Obsidian Sync device, while the old volumes stay +# until you remove them. INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). From bbe16b1e3b42d00e40d61384e224bba4e0f099a0 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:14:28 -0400 Subject: [PATCH 28/57] fix(cli): stop an init re-run as soon as the kept INSTANCE_NAME is invalid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The re-run now reports an invalid kept name before asking anything more, with or without --instance-name. Before, a re-run without the flag asked every remaining question and could overwrite .env, then failed on the same name. - The error carries the INSTANCE_NAME prefix that down, start, logs and the end of init already use. - The INSTANCE_NAME comments in both .env.example files (and the synced CLI blocks) say npx vault-cortex@latest down, like every other doc. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 18 +++++++----------- cli/src/env.ts | 15 ++++++++------- cli/src/instance-identity.ts | 20 ++++++++++---------- deploy/local/.env.example | 7 ++++--- deploy/remote/.env.example | 8 ++++---- 5 files changed, 33 insertions(+), 35 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 4cba2f885..6e78e6c1d 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -2126,7 +2126,7 @@ describe("runInit re-run keeps the instance identity", () => { expect(exitCode).toBe(1) expect(scripted.errors).toEqual([ - `"Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit. Fix it in ${targetDir}/.env.`, + `INSTANCE_NAME "Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit. Fix it in ${targetDir}/.env.`, ]) }) @@ -2220,20 +2220,14 @@ describe("runInit re-run keeps the instance identity", () => { ]) }) - it("stops before starting anything when the kept file's INSTANCE_NAME is invalid", async () => { + it("stops before asking anything more when the kept file's INSTANCE_NAME is invalid", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) - writeFileSync( - join(targetDir, ".env"), - `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=Work Vault\n`, - ) + const existingEnv = `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=Work Vault\n` + writeFileSync(join(targetDir, ".env"), existingEnv) const dockerRun = vi.fn(() => true) - const scripted = createScriptedPrompts([ - true, // re-run setup anyway - [], // no optional settings - false, // keep the existing file - ]) + const scripted = createScriptedPrompts([true]) // re-run setup anyway const exitCode = await runInit( { mode: "local", vaultPath: vaultDir, dir: targetDir }, @@ -2248,6 +2242,8 @@ describe("runInit re-run keeps the instance identity", () => { expect(scripted.errors).toEqual([ `INSTANCE_NAME "Work Vault" is not a valid instance name — use at least two lowercase letters, digits, hyphens or underscores, starting with a letter or digit. Fix it in ${targetDir}/.env.`, ]) + expect(scripted.asked).toEqual(["Re-run setup for this directory anyway?"]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) expect(scripted.prints).toEqual([]) expect(dockerRun).not.toHaveBeenCalled() }) diff --git a/cli/src/env.ts b/cli/src/env.ts index bf4ea3169..d9aec1120 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -135,9 +135,10 @@ MEMORY_DIR=About Me # starting with a letter or digit. # Default: vault-cortex # Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex down), then change it to a name no -# other install uses: the install starts on a new, empty index volume, and the -# old one stays until you remove it (docker volume rm _mcp_data). +# (docker compose down, or npx vault-cortex@latest down), then change it to a +# name no other install uses: the install starts on a new, empty index volume, +# and the old one stays until you remove it +# (docker volume rm _mcp_data). INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). @@ -301,10 +302,10 @@ MEMORY_DIR=About Me # starting with a letter or digit. # Default: vault-cortex # Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex down), then change it to a name no -# other install uses: the install starts on new, empty volumes and downloads -# the vault again as a new Obsidian Sync device, while the old volumes stay -# until you remove them. +# (docker compose down, or npx vault-cortex@latest down), then change it to a +# name no other install uses: the install starts on new, empty volumes and +# downloads the vault again as a new Obsidian Sync device, while the old +# volumes stay until you remove them. INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index b8aa403c5..8fa53dc1e 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -142,8 +142,8 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { /** * Decides this install's name and port before init writes .env: * - a re-run over an existing .env keeps its name and port, plus a local - * PUBLIC_URL or a remote DEVICE_NAME (`carried`), and exits when - * --instance-name gives a different name; + * PUBLIC_URL or a remote DEVICE_NAME (`carried`), and exits when that + * name is invalid or --instance-name gives a different one; * - --instance-name and --port set them directly; * - a new install whose name is already in use on the machine asks for its own * name and port, or confirms reuse of the existing data for the same vault. @@ -183,18 +183,18 @@ export const resolveInstanceIdentity = async ( if (existsSync(existingEnvPath)) { const carried = readCarriedIdentity(existingEnvPath, mode) const carriedName = carried.INSTANCE_NAME || DEFAULT_INSTANCE_NAME + const carriedNameError = describeInvalidInstanceName(carriedName) + // An invalid kept name gets the error every other command gives it before + // init asks anything more, since the re-run would carry it into the new .env. + if (carriedNameError) { + prompts.error(`INSTANCE_NAME ${carriedNameError} Fix it in ${targetDir}/.env.`) + return { kind: "exit" } + } // Renaming here would leave the old container running and could point // the folder at another install's volumes, so the name stays. if (flags.instanceName && flags.instanceName !== carriedName) { - // An invalid kept name gets the error every other command gives it, - // since the rename steps start with a down that would refuse it. - const carriedNameError = describeInvalidInstanceName(carriedName) - prompts.error( - carriedNameError - ? `${carriedNameError} Fix it in ${targetDir}/.env.` - : renameOnRerunMessage({ carriedName, targetDir }), - ) + prompts.error(renameOnRerunMessage({ carriedName, targetDir })) return { kind: "exit" } } return { kind: "ok", identity: { carried, chosen: flagOverrides } } diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 52f625365..67a04ed79 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -125,9 +125,10 @@ MEMORY_DIR=About Me # starting with a letter or digit. # Default: vault-cortex # Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex down), then change it to a name no -# other install uses: the install starts on a new, empty index volume, and the -# old one stays until you remove it (docker volume rm _mcp_data). +# (docker compose down, or npx vault-cortex@latest down), then change it to a +# name no other install uses: the install starts on a new, empty index volume, +# and the old one stays until you remove it +# (docker volume rm _mcp_data). INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index e7e8832c6..d0ed58597 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -131,10 +131,10 @@ MEMORY_DIR=About Me # starting with a letter or digit. # Default: vault-cortex # Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex down), then change it to a name no -# other install uses: the install starts on new, empty volumes and downloads -# the vault again as a new Obsidian Sync device, while the old volumes stay -# until you remove them. +# (docker compose down, or npx vault-cortex@latest down), then change it to a +# name no other install uses: the install starts on new, empty volumes and +# downloads the vault again as a new Obsidian Sync device, while the old +# volumes stay until you remove them. INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). From 0117ff5cf406e746e28c15fcd12e29d8ba152f68 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:58:41 -0400 Subject: [PATCH 29/57] fix(cli): warn when init can't check names, ask a reused install's port, and read .env like Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init warns when Docker isn't running, because it can't check whether another install already uses the name. - Reusing an install's data (by the in-use choice, or by --instance-name without --yes or --port) asks for its port instead of resetting it to 8000. - init gives configure's warning when a chosen port change leaves a custom PUBLIC_URL as it was (shared describeStrandedPublicUrl). - readEnvVaultPath, readEnvPublicUrl and readEnvObsidianToken take the last matching line, as docker --env-file and the PORT and INSTANCE_NAME readers do. - configure.test.ts uses the shared createRecordingDocker stub. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/configure.test.ts | 33 ++++------ cli/src/__tests__/init.test.ts | 93 ++++++++++++++++++++++++++--- cli/src/__tests__/scaffold.test.ts | 32 ++++++++++ cli/src/configure.ts | 16 +++-- cli/src/init.ts | 26 +++++--- cli/src/instance-identity.ts | 35 +++++++++-- cli/src/optional-settings.ts | 17 ++++++ cli/src/scaffold.ts | 21 ++++--- 8 files changed, 213 insertions(+), 60 deletions(-) diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index 778b4b49b..59d573bd1 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -4,8 +4,9 @@ import { join } from "node:path" import { describe, expect, it, onTestFinished } from "vitest" import { runConfigure } from "../configure.js" -import type { DockerRunParams, DockerRunner } from "../docker.js" +import type { DockerRunner } from "../docker.js" import { + createRecordingDocker, createScriptedPrompts, dockerDown, dockerNotInstalled, @@ -14,20 +15,6 @@ import { fetchOk, } from "./command-stubs.js" -/** A ready daemon that records dockerRun calls for start-parameter asserts. */ -const createRecordingDocker = () => { - const dockerRunCalls: DockerRunParams[] = [] - const docker: DockerRunner = { - ...dockerReady, - containerExists: () => false, - dockerRun: (params) => { - dockerRunCalls.push(params) - return true - }, - } - return { docker, dockerRunCalls } -} - // Creates a temp target dir and registers its removal at creation time, so // the suite self-cleans even when an assertion throws mid-test. const makeTempTargetDir = (): string => { @@ -139,7 +126,7 @@ describe("runConfigure with picked settings", () => { it("saves the changes and prints the restart hint when the restart is declined", async () => { const targetDir = makeTempTargetDir() const envFilePath = writeLocalEnv(targetDir) - const { docker, dockerRunCalls } = createRecordingDocker() + const { docker, runParams } = createRecordingDocker() const scripted = createScriptedPrompts([ ["MEMORY_ENABLED"], false, // disable the memory layer @@ -155,7 +142,7 @@ describe("runConfigure with picked settings", () => { expect(readFileSync(envFilePath, "utf8")).toBe( LOCAL_ENV_CONTENT.replace("MEMORY_ENABLED=true", "MEMORY_ENABLED=false"), ) - expect(dockerRunCalls).toEqual([]) + expect(runParams).toEqual([]) expect(scripted.logs).toEqual([ `Updated MEMORY_ENABLED in ${targetDir}/.env.`, `Apply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"`, @@ -167,7 +154,7 @@ describe("runConfigure with picked settings", () => { const targetDir = makeTempTargetDir() const envFilePath = join(targetDir, ".env") writeFileSync(envFilePath, `${LOCAL_ENV_CONTENT}INSTANCE_NAME=work\n`) - const { docker, dockerRunCalls } = createRecordingDocker() + const { docker, runParams } = createRecordingDocker() const scripted = createScriptedPrompts([ ["MEMORY_ENABLED"], false, // disable the memory layer @@ -180,7 +167,7 @@ describe("runConfigure with picked settings", () => { ) expect(exitCode).toBe(0) - expect(dockerRunCalls).toEqual([ + expect(runParams).toEqual([ { mode: "local", instanceName: "work", @@ -194,7 +181,7 @@ describe("runConfigure with picked settings", () => { it("re-creates the container on the new PORT when the restart is accepted", async () => { const targetDir = makeTempTargetDir() const envFilePath = writeLocalEnv(targetDir) - const { docker, dockerRunCalls } = createRecordingDocker() + const { docker, runParams } = createRecordingDocker() const fetchedUrls: string[] = [] const fetchRecorder: typeof fetch = async (url) => { fetchedUrls.push(String(url)) @@ -214,7 +201,7 @@ describe("runConfigure with picked settings", () => { expect(exitCode).toBe(0) // The restart resolves the deployment from the .env written moments // before, so the new port drives both the port mapping and health URL. - expect(dockerRunCalls).toEqual([ + expect(runParams).toEqual([ { mode: "local", instanceName: "vault-cortex", @@ -345,7 +332,7 @@ describe("runConfigure with picked settings", () => { envFilePath, "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/MyVault\nMEMORY_ENABLED=true\n", ) - const { docker, dockerRunCalls } = createRecordingDocker() + const { docker, runParams } = createRecordingDocker() const scripted = createScriptedPrompts([ ["MEMORY_ENABLED"], false, // disable the memory layer @@ -362,7 +349,7 @@ describe("runConfigure with picked settings", () => { `PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` + `Add this line to your .env:\n PUBLIC_URL=http://localhost:8000`, ]) - expect(dockerRunCalls).toEqual([]) + expect(runParams).toEqual([]) // The settings edit itself succeeded before the restart failed — and the // user is told so, with the way to apply once the .env issue is fixed. expect(readFileSync(envFilePath, "utf8")).toBe( diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 6e78e6c1d..577f4be92 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, onTestFinished, vi } from "vitest" import { runInit, validatePublicUrl } from "../init.js" import { pollHealth, type DockerRunner } from "../docker.js" -import { buildDockerNotInstalledMessage } from "../messages.js" +import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage } from "../messages.js" import { readEnvInstanceName } from "../scaffold.js" vi.mock("../docker.js", { spy: true }) @@ -447,8 +447,12 @@ describe("runInit interactive local flow", () => { expect(exitCode).toBe(0) expect(scripted.asked).not.toContain("Start the server now?") - expect(scripted.warnings).toHaveLength(1) - expect(scripted.warnings[0]).toContain("Container runtime not running") + expect(scripted.warnings).toEqual([ + nameCheckSkippedWarning("vault-cortex"), + buildDaemonNotRunningMessage( + `, then run:\n npx vault-cortex@latest start --dir "${targetDir}"`, + ), + ]) }) // Message content per platform is pinned test-owned in messages.test.ts — @@ -474,6 +478,7 @@ describe("runInit interactive local flow", () => { expect(exitCode).toBe(0) expect(scripted.asked).not.toContain("Start the server now?") expect(scripted.warnings).toEqual([ + nameCheckSkippedWarning("vault-cortex"), buildDockerNotInstalledMessage({ nextStep: `\nThen start the server with:\n npx vault-cortex@latest start --dir "${targetDir}"`, }), @@ -1529,6 +1534,12 @@ const IN_USE_QUESTION = 'An install named "vault-cortex" already exists on this machine (its container or data volumes are here). What should this install do?' const NAME_QUESTION = "Name for this install (lowercase letters, digits, - and _):" const PORT_QUESTION = "Host port for the server:" +const nameCheckSkippedWarning = (instanceName: string): string => { + return ( + `Docker isn't running, so init can't check whether another install on this machine already uses the name "${instanceName}".\n` + + "If one does, give this install its own INSTANCE_NAME and PORT in its .env before you start it, or run init again with Docker running." + ) +} const REPLACE_START_QUESTION = 'Start the server now? This stops and replaces the existing "vault-cortex" container. Its data volumes are kept.' @@ -1650,10 +1661,13 @@ describe("runInit when the instance name is already in use", () => { ]) }) - it("writes nothing extra when the user reuses the existing data", async () => { + it.each([ + { label: "the default port", port: "8000" }, + { label: "another port", port: "8001" }, + ])("asks the port of the install whose data is reused, and writes $label", async ({ port }) => { const vaultDir = makeVault() const targetDir = makeTargetDir() - const scripted = createScriptedPrompts(["reuse", [], false]) + const scripted = createScriptedPrompts(["reuse", port, [], false]) const exitCode = await runInit( { mode: "local", vaultPath: vaultDir, dir: targetDir }, @@ -1667,20 +1681,44 @@ describe("runInit when the instance name is already in use", () => { expect(exitCode).toBe(0) expect(scripted.asked).toEqual([ IN_USE_QUESTION, + PORT_QUESTION, "Any optional settings to change? (press enter to skip)", REPLACE_START_QUESTION, ]) expect(readIdentityLines(targetDir)).toEqual([ - "PUBLIC_URL=http://localhost:8000", + `PUBLIC_URL=http://localhost:${port}`, "INSTANCE_NAME=vault-cortex", - "PORT=8000", + `PORT=${port}`, + ]) + }) + + it("asks the port when --instance-name reuses an install without --yes or --port", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["8002", [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, instanceName: "work" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked[0]).toBe(PORT_QUESTION) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8002", + "INSTANCE_NAME=work", + "PORT=8002", ]) }) it("replaces the existing container when the user reuses its data and starts now", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() - const scripted = createScriptedPrompts(["reuse", [], true]) + const scripted = createScriptedPrompts(["reuse", "8000", [], true]) const recording = createRecordingDocker() const exitCode = await runInit( @@ -1701,7 +1739,7 @@ describe("runInit when the instance name is already in use", () => { it("starts nothing when the existing container can't be removed", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() - const scripted = createScriptedPrompts(["reuse", [], true]) + const scripted = createScriptedPrompts(["reuse", "8000", [], true]) const recording = createRecordingDocker() const docker: DockerRunner = { ...recording.docker, stopAndRemoveContainer: () => false } @@ -1721,6 +1759,7 @@ describe("runInit when the instance name is already in use", () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ "reuse", + "8000", "https://vault.example.com", "MyVault", false, @@ -2053,6 +2092,42 @@ describe("runInit re-run keeps the instance identity", () => { ]) }) + it("warns when a re-run's chosen port leaves a custom PUBLIC_URL as it was", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\nPUBLIC_URL=https://vault.example.com\n`, + ) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + ["PORT"], // change the host port + "9001", + true, // overwrite the existing file + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting(["vault-cortex", "work"]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://vault.example.com", + "INSTANCE_NAME=work", + "PORT=9001", + ]) + expect(scripted.warnings).toEqual([ + "PORT changed — make sure PUBLIC_URL (https://vault.example.com) still reaches the server.", + ]) + }) + it("keeps a custom PUBLIC_URL carried beside a changed port", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index d7379efc0..293ac19eb 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -266,6 +266,17 @@ describe("readEnvVaultPath", () => { expect(readEnvVaultPath(envPath)).toBe("/Users/me/My Vault") }) + + it("takes the last active line, as docker --env-file does", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync( + envPath, + "VAULT_PATH=/Users/me/OldVault\nMCP_AUTH_TOKEN=abc\nVAULT_PATH=/Users/me/NewVault\n", + ) + + expect(readEnvVaultPath(envPath)).toBe("/Users/me/NewVault") + }) }) describe("readEnvPublicUrl", () => { @@ -277,6 +288,17 @@ describe("readEnvPublicUrl", () => { expect(readEnvPublicUrl(envPath)).toBe("https://vault.example.com") }) + it("takes the last active line, as docker --env-file does", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync( + envPath, + "PUBLIC_URL=https://old.example.com\nMCP_AUTH_TOKEN=abc\nPUBLIC_URL=https://new.example.com\n", + ) + + expect(readEnvPublicUrl(envPath)).toBe("https://new.example.com") + }) + it("strips trailing slashes so consumers can append paths cleanly", () => { // A hand-edited `https://host/` must not become `https://host//mcp` in // the connect message — mirror askPublicUrl's prompt-side normalization. @@ -512,6 +534,16 @@ describe("readEnvObsidianToken", () => { expect(readEnvObsidianToken(envPath)).toBeUndefined() }) + it("takes the last line with a value, as docker --env-file does", () => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync( + envPath, + "OBSIDIAN_AUTH_TOKEN=old-token\nVAULT_NAME=Test\nOBSIDIAN_AUTH_TOKEN=new-token\n", + ) + + expect(readEnvObsidianToken(envPath)).toBe("new-token") + }) + it("returns undefined when the file has no OBSIDIAN_AUTH_TOKEN line", () => { const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") writeFileSync(envPath, "MCP_AUTH_TOKEN=abc\nVAULT_NAME=Test\n") diff --git a/cli/src/configure.ts b/cli/src/configure.ts index 5be4da217..e20cbb310 100644 --- a/cli/src/configure.ts +++ b/cli/src/configure.ts @@ -5,7 +5,7 @@ import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, - readOptionalValue, + describeStrandedPublicUrl, } from "./optional-settings.js" import type { DockerRunner } from "./docker.js" import type { Prompts } from "./prompts.js" @@ -58,15 +58,13 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): writeFileSync(envFilePath, applyOptionalSettings(envContent, overrides)) prompts.log(`Updated ${changedNames.join(", ")} in ${targetDir}/.env.`) - // A custom PUBLIC_URL is never rewritten (see derivePublicUrlOverride), but - // a port change can still strand it — surface the consequence non-blocking. - const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL") + const publicUrlWarning = describeStrandedPublicUrl({ + envContent, + pickedOverrides, + appliedOverrides: overrides, + }) - if (pickedOverrides.PORT && !overrides.PUBLIC_URL && currentPublicUrl) { - prompts.warn( - `PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.`, - ) - } + if (publicUrlWarning) prompts.warn(publicUrlWarning) const restartHint = `Apply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"` const daemonStatus = docker.daemonStatus() diff --git a/cli/src/init.ts b/cli/src/init.ts index f11e26920..c53a6652f 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -23,6 +23,7 @@ import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, + describeStrandedPublicUrl, } from "./optional-settings.js" import { buildFilesToWrite, @@ -430,10 +431,13 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = const optionalOverrides = flags.yes ? {} : await askOptionalSettings({ mode: "local", envContent: generatedEnvContent }, prompts) - const envContent = applyOptionalSettings( - generatedEnvContent, - derivePublicUrlOverride(generatedEnvContent, optionalOverrides), - ) + const appliedOverrides = derivePublicUrlOverride(generatedEnvContent, optionalOverrides) + const envContent = applyOptionalSettings(generatedEnvContent, appliedOverrides) + const publicUrlWarning = describeStrandedPublicUrl({ + envContent: generatedEnvContent, + pickedOverrides: optionalOverrides, + appliedOverrides, + }) // Conflict policy: identical existing files are skipped silently; // differing ones prompt per file (default keep). --yes never overwrites — @@ -459,6 +463,8 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = const tokenWritten = envResult?.status === "created" || envResult?.status === "overwritten" if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") + // Only a written .env carries the picked port, so a kept one needs no warning. + if (tokenWritten && publicUrlWarning) prompts.warn(publicUrlWarning) const port = readEnvPort(join(targetDir, ".env")) const instanceName = readInstanceNameFromDisk(targetDir, prompts) @@ -548,10 +554,13 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise { mode: "remote", envContent: generatedEnvContent }, prompts, ) - const envContent = applyOptionalSettings( - generatedEnvContent, - derivePublicUrlOverride(generatedEnvContent, optionalOverrides), - ) + const appliedOverrides = derivePublicUrlOverride(generatedEnvContent, optionalOverrides) + const envContent = applyOptionalSettings(generatedEnvContent, appliedOverrides) + const publicUrlWarning = describeStrandedPublicUrl({ + envContent: generatedEnvContent, + pickedOverrides: optionalOverrides, + appliedOverrides, + }) const files = buildFilesToWrite(envContent) const results = await writeFiles({ targetDir, files }, confirmOverwrite(prompts)) reportWrites({ targetDir, results }, prompts) @@ -564,6 +573,7 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise const tokenWritten = envResult?.status === "created" || envResult?.status === "overwritten" if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") + if (tokenWritten && publicUrlWarning) prompts.warn(publicUrlWarning) const port = readEnvPort(join(targetDir, ".env")) // Like PORT above, PUBLIC_URL comes from the .env actually on disk — a kept // existing file may hold a different URL than this run's prompt, and the diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 8fa53dc1e..f60e9ee15 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -131,6 +131,27 @@ const renameOnRerunMessage = (params: { carriedName: string; targetDir: string } ) } +const nameCheckSkippedMessage = (instanceName: string): string => { + return ( + `Docker isn't running, so init can't check whether another install on this machine already uses the name "${instanceName}".\n` + + `If one does, give this install its own INSTANCE_NAME and PORT in its .env before you start it, or run init again with Docker running.` + ) +} + +/** + * A reused install's .env is in another folder, so its port is asked for + * rather than assumed to be the default; --yes cannot ask and keeps --port + * or the default. + */ +const askReusedPort = async ( + flags: InstanceIdentityFlags, + prompts: Prompts, +): Promise> => { + if (flags.port) return { PORT: flags.port } + if (flags.yes) return {} + return { PORT: await askPort(undefined, prompts) } +} + const nameInUseWithYesFlagMessage = (instanceName: string): string => { return ( `An install named "${instanceName}" already exists on this machine (its container or data volumes are here).\n` + @@ -201,9 +222,13 @@ export const resolveInstanceIdentity = async ( } const requestedName = flags.instanceName ?? DEFAULT_INSTANCE_NAME + const daemonRunning = docker.daemonStatus() === "running" + + // Without Docker the check can't see another install's container or + // volumes, and a shared name would only surface when start replaces them. + if (!daemonRunning) prompts.warn(nameCheckSkippedMessage(requestedName)) const requestedNameInUse = - docker.daemonStatus() === "running" && - isInstanceNameInUse({ mode, instanceName: requestedName }, docker) + daemonRunning && isInstanceNameInUse({ mode, instanceName: requestedName }, docker) // A new folder's .env starts from the template's device name, so a name // given by --instance-name sets it too, whether the name is new or reused. const flagSettings = { @@ -217,7 +242,8 @@ export const resolveInstanceIdentity = async ( } if (flags.instanceName) { prompts.log(`Reusing the data of the existing "${requestedName}" install.`) - return { kind: "ok", identity: { carried: {}, chosen: flagSettings } } + const reusedPort = await askReusedPort(flags, prompts) + return { kind: "ok", identity: { carried: {}, chosen: { ...flagSettings, ...reusedPort } } } } if (flags.yes) { prompts.error(nameInUseWithYesFlagMessage(requestedName)) @@ -242,7 +268,8 @@ export const resolveInstanceIdentity = async ( ) if (choice === "reuse") { - return { kind: "ok", identity: { carried: {}, chosen: flagOverrides } } + const reusedPort = await askReusedPort(flags, prompts) + return { kind: "ok", identity: { carried: {}, chosen: { ...flagOverrides, ...reusedPort } } } } const suggestedName = suggestInstanceName({ mode, targetDir }, docker) const instanceName = await askFreeInstanceName({ mode, suggestedName }, deps) diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 1c7a33284..102866b54 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -234,6 +234,23 @@ export const derivePublicUrlOverride = ( return { ...overrides, PUBLIC_URL: `http://localhost:${newPort}` } } +/** + * Warns about a picked PORT change that left PUBLIC_URL as it was: + * derivePublicUrlOverride never rewrites a custom URL, so it may still name + * the old port. Returns undefined when there is nothing to warn about. + */ +export const describeStrandedPublicUrl = (params: { + envContent: string + pickedOverrides: Record + appliedOverrides: Record +}): string | undefined => { + const { envContent, pickedOverrides, appliedOverrides } = params + const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL") + + if (!pickedOverrides.PORT || appliedOverrides.PUBLIC_URL || !currentPublicUrl) return undefined + return `PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.` +} + /** * True unless the .env value is an explicit "off" spelling — env-var's asBool * accepts 0/1 alongside true/false. An absent or unrecognized value falls to diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index af9130e01..a033bf3ee 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -40,14 +40,21 @@ const ENV_INSTANCE_NAME_LINES = /^INSTANCE_NAME=(.*)$/gm */ const INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]+$/ -/** Matches an active (uncommented) VAULT_PATH line in a .env file. */ -const ENV_VAULT_PATH_LINE = /^VAULT_PATH=(.+)\s*$/m +/** + * Matches every active (uncommented) VAULT_PATH line. Global, like the + * PUBLIC_URL and token patterns below, because `docker run --env-file` lets + * the last duplicate win. + */ +const ENV_VAULT_PATH_LINES = /^VAULT_PATH=(.+)\s*$/gm /** Matches an active (uncommented) PUBLIC_URL line. */ const ENV_PUBLIC_URL_LINE = /^PUBLIC_URL=/m -/** Matches an active (uncommented) PUBLIC_URL line, capturing a non-empty value. */ -const ENV_PUBLIC_URL_VALUE_LINE = /^PUBLIC_URL=(.+)\s*$/m +/** Matches every active (uncommented) PUBLIC_URL line, capturing a non-empty value. */ +const ENV_PUBLIC_URL_VALUE_LINES = /^PUBLIC_URL=(.+)\s*$/gm + +/** Matches every active (uncommented) OBSIDIAN_AUTH_TOKEN line with a value. */ +const ENV_OBSIDIAN_TOKEN_LINES = /^OBSIDIAN_AUTH_TOKEN=(.+)$/gm /** Matches an active (uncommented) OBSIDIAN_AUTH_TOKEN line. */ const OBSIDIAN_AUTH_TOKEN_LINE = /^OBSIDIAN_AUTH_TOKEN=/m @@ -119,7 +126,7 @@ export const readEnvInstanceName = (envFilePath: string): InstanceNameRead => { */ export const readEnvVaultPath = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined - const match = ENV_VAULT_PATH_LINE.exec(readFileSync(envFilePath, "utf8")) + const match = [...readFileSync(envFilePath, "utf8").matchAll(ENV_VAULT_PATH_LINES)].at(-1) const rawValue = match?.[1].trim() return rawValue ? stripSurroundingQuotes(rawValue) : undefined } @@ -145,7 +152,7 @@ export const hasEnvPublicUrl = (envFilePath: string): boolean => { */ export const readEnvPublicUrl = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined - const match = ENV_PUBLIC_URL_VALUE_LINE.exec(readFileSync(envFilePath, "utf8")) + const match = [...readFileSync(envFilePath, "utf8").matchAll(ENV_PUBLIC_URL_VALUE_LINES)].at(-1) // A whitespace-only line matches the regex and trims to "" — normalize to // undefined so the non-empty contract holds ("" is never a legitimate URL). const rawValue = match?.[1].trim() @@ -195,7 +202,7 @@ export const patchEnvObsidianToken = (envFilePath: string, token: string): boole */ export const readEnvObsidianToken = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined - const match = /^OBSIDIAN_AUTH_TOKEN=(.+)$/m.exec(readFileSync(envFilePath, "utf8")) + const match = [...readFileSync(envFilePath, "utf8").matchAll(ENV_OBSIDIAN_TOKEN_LINES)].at(-1) return match?.[1].trim() || undefined } From c9c7ed75644aabaf7d8f978263c0ccd0fe49c3ed Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:05:50 -0400 Subject: [PATCH 30/57] fix(cli): patch every Sync token line, drop a remedy init can't keep, and warn only on a real PORT change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get-sync-token --dir now replaces every active OBSIDIAN_AUTH_TOKEN line. docker --env-file and readEnvObsidianToken both take the last line, so patching only the first left the old token in effect. - The Docker-down name warning no longer says to run init again: once the run writes .env, a re-run keeps the name and skips the in-use check. - The stranded-PUBLIC_URL warning no longer says "PORT changed" when the picked port equals the current one. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 2 +- cli/src/__tests__/optional-settings.test.ts | 33 +++++++++++++++++++++ cli/src/__tests__/scaffold.test.ts | 17 +++++++++++ cli/src/instance-identity.ts | 6 ++-- cli/src/optional-settings.ts | 6 +++- cli/src/scaffold.ts | 14 ++++----- 6 files changed, 67 insertions(+), 11 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 577f4be92..78227a173 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1537,7 +1537,7 @@ const PORT_QUESTION = "Host port for the server:" const nameCheckSkippedWarning = (instanceName: string): string => { return ( `Docker isn't running, so init can't check whether another install on this machine already uses the name "${instanceName}".\n` + - "If one does, give this install its own INSTANCE_NAME and PORT in its .env before you start it, or run init again with Docker running." + "If one does, give this install its own INSTANCE_NAME and PORT in its .env before you start it." ) } const REPLACE_START_QUESTION = diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 5630c7339..31b8b9278 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -4,6 +4,7 @@ import { applyOptionalSettings, askOptionalSettings, derivePublicUrlOverride, + describeStrandedPublicUrl, readOptionalValue, } from "../optional-settings.js" import { createScriptedPrompts } from "./command-stubs.js" @@ -144,6 +145,38 @@ describe("derivePublicUrlOverride", () => { }) }) +describe("describeStrandedPublicUrl", () => { + const CUSTOM_URL_ENV = "PUBLIC_URL=https://vault.example.com\nPORT=8000\n" + + it("warns when a new PORT leaves a custom PUBLIC_URL as it was", () => { + const warning = describeStrandedPublicUrl({ + envContent: CUSTOM_URL_ENV, + pickedOverrides: { PORT: "9000" }, + appliedOverrides: { PORT: "9000" }, + }) + + expect(warning).toBe( + "PORT changed — make sure PUBLIC_URL (https://vault.example.com) still reaches the server.", + ) + }) + + it.each([ + { label: "the current port", envContent: CUSTOM_URL_ENV }, + { + label: "the default port behind an empty PORT line", + envContent: "PUBLIC_URL=https://vault.example.com\nPORT=\n", + }, + ])("stays quiet when the picked PORT is $label", ({ envContent }) => { + const warning = describeStrandedPublicUrl({ + envContent, + pickedOverrides: { PORT: "8000" }, + appliedOverrides: { PORT: "8000" }, + }) + + expect(warning).toBeUndefined() + }) +}) + describe("askOptionalSettings chooser", () => { it("offers the local settings without SYNC_MODE in local mode", async () => { const scripted = createScriptedPrompts([[]]) diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index 293ac19eb..193ecf5f2 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -459,6 +459,23 @@ describe("patchEnvObsidianToken", () => { ) }) + it("replaces every active token line, so the last one docker reads is the new token", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) + const envPath = join(targetDir, ".env") + writeFileSync( + envPath, + "OBSIDIAN_AUTH_TOKEN=old-token\nVAULT_NAME=MyVault\nOBSIDIAN_AUTH_TOKEN=stale-token\n", + ) + + const result = patchEnvObsidianToken(envPath, "new-token") + + expect(result).toBe(true) + expect(readFileSync(envPath, "utf8")).toBe( + "OBSIDIAN_AUTH_TOKEN=new-token\nVAULT_NAME=MyVault\nOBSIDIAN_AUTH_TOKEN=new-token\n", + ) + expect(readEnvObsidianToken(envPath)).toBe("new-token") + }) + it("replaces an empty token value", () => { const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) const envPath = join(targetDir, ".env") diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index f60e9ee15..3d775cc69 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -131,10 +131,12 @@ const renameOnRerunMessage = (params: { carriedName: string; targetDir: string } ) } +// The message offers no "run init again" fix: once this run writes .env, a +// re-run keeps its name and skips the check. const nameCheckSkippedMessage = (instanceName: string): string => { return ( `Docker isn't running, so init can't check whether another install on this machine already uses the name "${instanceName}".\n` + - `If one does, give this install its own INSTANCE_NAME and PORT in its .env before you start it, or run init again with Docker running.` + `If one does, give this install its own INSTANCE_NAME and PORT in its .env before you start it.` ) } @@ -169,7 +171,7 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { * - a new install whose name is already in use on the machine asks for its own * name and port, or confirms reuse of the existing data for the same vault. * --yes cannot ask, so it exits unless --instance-name settles it. - * The check needs a running Docker daemon; without one it is skipped. + * The check needs a running Docker daemon; without one it is skipped with a warning. */ export const resolveInstanceIdentity = async ( params: { mode: Mode; targetDir: string; flags: InstanceIdentityFlags }, diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 102866b54..e970108fe 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -247,7 +247,11 @@ export const describeStrandedPublicUrl = (params: { const { envContent, pickedOverrides, appliedOverrides } = params const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL") - if (!pickedOverrides.PORT || appliedOverrides.PUBLIC_URL || !currentPublicUrl) return undefined + // Picking PORT and accepting its pre-filled current value changes nothing. + const currentPort = readOptionalValue(envContent, "PORT") || String(DEFAULT_PORT) + const portChanged = Boolean(pickedOverrides.PORT) && pickedOverrides.PORT !== currentPort + + if (!portChanged || appliedOverrides.PUBLIC_URL || !currentPublicUrl) return undefined return `PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.` } diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index a033bf3ee..33bac8f6d 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -176,20 +176,20 @@ export const detectMode = (envFilePath: string): Mode | undefined => { } /** - * Patches the OBSIDIAN_AUTH_TOKEN value in an existing .env file. - * Returns true when the patch succeeded, false when the file is missing - * or has no active OBSIDIAN_AUTH_TOKEN line (e.g. a local-mode .env). + * Patches every active OBSIDIAN_AUTH_TOKEN line in an existing .env, since + * `docker run --env-file` lets the last duplicate win. Returns false when the + * file is missing or has no active line (e.g. a local-mode .env). */ export const patchEnvObsidianToken = (envFilePath: string, token: string): boolean => { if (!existsSync(envFilePath)) return false const content = readFileSync(envFilePath, "utf8") - /** Matches the full OBSIDIAN_AUTH_TOKEN line for replacement. */ - const fullTokenLine = /^OBSIDIAN_AUTH_TOKEN=.*$/m + /** Matches every full OBSIDIAN_AUTH_TOKEN line for replacement. */ + const everyTokenLine = /^OBSIDIAN_AUTH_TOKEN=.*$/gm - if (!fullTokenLine.test(content)) return false + if (!OBSIDIAN_AUTH_TOKEN_LINE.test(content)) return false // Function replacement avoids $ pattern interpretation ($&, $', etc.) // that String.prototype.replace applies to string replacements. - const patched = content.replace(fullTokenLine, () => `OBSIDIAN_AUTH_TOKEN=${token}`) + const patched = content.replace(everyTokenLine, () => `OBSIDIAN_AUTH_TOKEN=${token}`) writeFileSync(envFilePath, patched) return true } From d65c7d9a9829d1abb6327903495aa0d24db9890c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:07:39 -0400 Subject: [PATCH 31/57] fix: let an empty last .env line win, mention --port for a scripted reuse, and correct the setup-error guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readEnvVaultPath, readEnvPublicUrl and readEnvObsidianToken read the last active line even when it is empty, as docker --env-file does, and treat the empty value as unset. - The --yes name-in-use message tells a scripted reuse to pass --port when the reused install isn't on 8000. - The remote guide says the could-not-read-setup error stops the container before it syncs any notes, which holds for both of its messages. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 2 +- cli/src/__tests__/scaffold.test.ts | 30 +++++++++++++++++++++++++++++- cli/src/instance-identity.ts | 2 +- cli/src/scaffold.ts | 16 ++++++---------- deploy/remote/README.md | 2 +- 5 files changed, 38 insertions(+), 14 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 78227a173..b1f175874 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1880,7 +1880,7 @@ describe("runInit --yes when the instance name is already in use", () => { expect(scripted.errors).toEqual([ 'An install named "vault-cortex" already exists on this machine (its container or data volumes are here).\n' + "Re-run with --instance-name (and --port if vault-cortex's port is taken) to give this install its own name,\n" + - "or with --instance-name vault-cortex to reuse that install's data for the same vault.", + "or with --instance-name vault-cortex (and --port unless that install uses 8000) to reuse its data for the same vault.", ]) }) diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index 193ecf5f2..098c8ee31 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -551,7 +551,7 @@ describe("readEnvObsidianToken", () => { expect(readEnvObsidianToken(envPath)).toBeUndefined() }) - it("takes the last line with a value, as docker --env-file does", () => { + it("takes the last active line, as docker --env-file does", () => { const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") writeFileSync( envPath, @@ -655,3 +655,31 @@ describe("stripEnvQuotedValues", () => { expect(readFileSync(envPath, "utf8")).toBe("VAULT_NAME=My 'Cool' Vault\n") }) }) + +describe("value readers with an empty last line", () => { + it.each([ + { + label: "VAULT_PATH", + envContent: "VAULT_PATH=/Users/me/OldVault\nVAULT_PATH=\n", + readValue: readEnvVaultPath, + }, + { + label: "PUBLIC_URL", + envContent: "PUBLIC_URL=https://old.example.com\nPUBLIC_URL=\n", + readValue: readEnvPublicUrl, + }, + { + label: "OBSIDIAN_AUTH_TOKEN", + envContent: "OBSIDIAN_AUTH_TOKEN=old-token\nOBSIDIAN_AUTH_TOKEN=\n", + readValue: readEnvObsidianToken, + }, + ])( + "read an empty last $label line as unset, as docker --env-file does", + ({ envContent, readValue }) => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync(envPath, envContent) + + expect(readValue(envPath)).toBeUndefined() + }, + ) +}) diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 3d775cc69..a9c49a1b4 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -158,7 +158,7 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { return ( `An install named "${instanceName}" already exists on this machine (its container or data volumes are here).\n` + `Re-run with --instance-name (and --port if ${instanceName}'s port is taken) to give this install its own name,\n` + - `or with --instance-name ${instanceName} to reuse that install's data for the same vault.` + `or with --instance-name ${instanceName} (and --port unless that install uses ${DEFAULT_PORT}) to reuse its data for the same vault.` ) } diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index 33bac8f6d..ef0e87ea8 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -41,21 +41,17 @@ const ENV_INSTANCE_NAME_LINES = /^INSTANCE_NAME=(.*)$/gm const INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]+$/ /** - * Matches every active (uncommented) VAULT_PATH line. Global, like the - * PUBLIC_URL and token patterns below, because `docker run --env-file` lets - * the last duplicate win. + * Match every active (uncommented) line of their variable, empty values + * included: `docker run --env-file` lets the last duplicate win, even an + * empty one, and the readers below treat an empty value as unset. */ -const ENV_VAULT_PATH_LINES = /^VAULT_PATH=(.+)\s*$/gm +const ENV_VAULT_PATH_LINES = /^VAULT_PATH=(.*)$/gm +const ENV_PUBLIC_URL_VALUE_LINES = /^PUBLIC_URL=(.*)$/gm +const ENV_OBSIDIAN_TOKEN_LINES = /^OBSIDIAN_AUTH_TOKEN=(.*)$/gm /** Matches an active (uncommented) PUBLIC_URL line. */ const ENV_PUBLIC_URL_LINE = /^PUBLIC_URL=/m -/** Matches every active (uncommented) PUBLIC_URL line, capturing a non-empty value. */ -const ENV_PUBLIC_URL_VALUE_LINES = /^PUBLIC_URL=(.+)\s*$/gm - -/** Matches every active (uncommented) OBSIDIAN_AUTH_TOKEN line with a value. */ -const ENV_OBSIDIAN_TOKEN_LINES = /^OBSIDIAN_AUTH_TOKEN=(.+)$/gm - /** Matches an active (uncommented) OBSIDIAN_AUTH_TOKEN line. */ const OBSIDIAN_AUTH_TOKEN_LINE = /^OBSIDIAN_AUTH_TOKEN=/m diff --git a/deploy/remote/README.md b/deploy/remote/README.md index c518c6ddb..6c7ddaa92 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -631,7 +631,7 @@ for syncing the new vault instead. An **ERROR** that "the Sync client could not read its own setup" means the Sync client's answer about which vault it syncs failed or named no vault, so -the container stopped without changing anything. Restart it. If the error +the container stopped before syncing any notes. Restart it. If the error comes back, the Sync client's own message in the lines above it says why. ## Memory From 2657d9a7d6a7321bbd75edb086905c3421d2568e Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:15:24 -0400 Subject: [PATCH 32/57] fix(cli): read an empty last PORT line as the default port, and a quoted empty token or vault path as unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readEnvPort skipped an empty or non-numeric last PORT line and used an earlier line's port, while configure's port prompt and the PUBLIC_URL warning read that line as the default port. Accepting the pre-filled port then moved the server without the PUBLIC_URL warning. It now reads the last active line like the other .env readers. readEnvObsidianToken read OBSIDIAN_AUTH_TOKEN="" as a token, though the CLI strips the quotes before docker run; init then skipped the get-sync-token guidance. readEnvVaultPath returned "" for VAULT_PATH="" instead of undefined. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/scaffold.test.ts | 30 +++++++++++++++++++++++++++++ cli/src/optional-settings.ts | 6 +++--- cli/src/scaffold.ts | 31 +++++++++++++++++++----------- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index 098c8ee31..86c257af2 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -154,6 +154,14 @@ describe("readEnvPort", () => { expect(readEnvPort(envPath)).toBe(8001) }) + + it("reads an empty last PORT line as the default port, not an earlier line's port", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, "PORT=9000\nMCP_AUTH_TOKEN=abc\nPORT=\n") + + expect(readEnvPort(envPath)).toBe(8000) + }) }) describe("readEnvInstanceName", () => { @@ -267,6 +275,14 @@ describe("readEnvVaultPath", () => { expect(readEnvVaultPath(envPath)).toBe("/Users/me/My Vault") }) + it("returns undefined when the value is an empty pair of quotes", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, 'MCP_AUTH_TOKEN=abc\nVAULT_PATH=""\n') + + expect(readEnvVaultPath(envPath)).toBeUndefined() + }) + it("takes the last active line, as docker --env-file does", () => { const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) const envPath = join(targetDir, ".env") @@ -551,6 +567,20 @@ describe("readEnvObsidianToken", () => { expect(readEnvObsidianToken(envPath)).toBeUndefined() }) + it("returns undefined when the value is an empty pair of quotes", () => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync(envPath, 'OBSIDIAN_AUTH_TOKEN=old-token\nOBSIDIAN_AUTH_TOKEN=""\n') + + expect(readEnvObsidianToken(envPath)).toBeUndefined() + }) + + it("keeps the quotes around a quoted token", () => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync(envPath, 'OBSIDIAN_AUTH_TOKEN="my-token"\n') + + expect(readEnvObsidianToken(envPath)).toBe('"my-token"') + }) + it("takes the last active line, as docker --env-file does", () => { const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") writeFileSync( diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index e970108fe..19f0abf0e 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -265,9 +265,9 @@ const isEnabledToggleValue = (value: string | undefined): boolean => !["false", "0"].includes((value ?? "").toLowerCase()) /** - * Plain digits in the TCP port range. Number() coercion is not enough: - * it accepts "1e4"/"0x1F40"/"+9000", which readEnvPort's /^PORT=(\d+)/ - * would later fail to read back — silently falling to the default port. + * Plain digits in the TCP port range. Number() coercion is not enough: it + * accepts "1e4"/"0x1F40"/"+9000", and readEnvPort reads any value that is not + * plain digits as the default port. */ export const isValidPort = (value: string): boolean => { if (!/^\d+$/.test(value)) return false diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index ef0e87ea8..7b9a191f1 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -26,10 +26,10 @@ export const DEFAULT_PORT = 8000 export const DEFAULT_INSTANCE_NAME = "vault-cortex" /** - * Matches every active (uncommented) PORT line, with optional surrounding - * quotes. Global, because `docker run --env-file` lets the last duplicate win. + * Matches every active (uncommented) PORT line, capturing its raw value. + * Global, because `docker run --env-file` lets the last duplicate win. */ -const ENV_PORT_LINES = /^PORT=["']?(\d+)["']?\s*$/gm +const ENV_PORT_LINES = /^PORT=(.*)$/gm /** Matches every active (uncommented) INSTANCE_NAME line, capturing its raw value. */ const ENV_INSTANCE_NAME_LINES = /^INSTANCE_NAME=(.*)$/gm @@ -78,13 +78,16 @@ export const buildFilesToWrite = (envContent: string): FileToWrite[] => [ * a pre-existing file this run chose to keep, with a PORT override the * generated default doesn't have. The last active line wins, as it does for * `docker run --env-file`. Falls back to DEFAULT_PORT when the file or an - * uncommented PORT line is absent. + * uncommented PORT line is absent, or the last line's value is not plain digits + * (an empty `PORT=` included). */ export const readEnvPort = (envFilePath: string): number => { if (!existsSync(envFilePath)) return DEFAULT_PORT const portLines = [...readFileSync(envFilePath, "utf8").matchAll(ENV_PORT_LINES)] - const lastPortLine = portLines.at(-1) - return lastPortLine ? Number(lastPortLine[1]) : DEFAULT_PORT + const rawValue = portLines.at(-1)?.[1].trim() + const portValue = rawValue ? stripSurroundingQuotes(rawValue) : "" + + return /^\d+$/.test(portValue) ? Number(portValue) : DEFAULT_PORT } /** Returns why a candidate instance name is invalid, or undefined when it is valid. */ @@ -118,13 +121,15 @@ export const readEnvInstanceName = (envFilePath: string): InstanceNameRead => { /** * Reads the host vault path from a .env file. Returns undefined when the - * file is missing or has no uncommented VAULT_PATH line. + * file is missing, has no uncommented VAULT_PATH line, or the last one is empty. */ export const readEnvVaultPath = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined const match = [...readFileSync(envFilePath, "utf8").matchAll(ENV_VAULT_PATH_LINES)].at(-1) const rawValue = match?.[1].trim() - return rawValue ? stripSurroundingQuotes(rawValue) : undefined + const vaultPath = rawValue ? stripSurroundingQuotes(rawValue) : "" + + return vaultPath || undefined } /** @@ -193,13 +198,17 @@ export const patchEnvObsidianToken = (envFilePath: string, token: string): boole /** * Reads the OBSIDIAN_AUTH_TOKEN value from an existing .env file. Returns * undefined when the file is missing, has no active line, or the value is - * empty — an empty `OBSIDIAN_AUTH_TOKEN=` line is not a valid token. Quotes - * stay in the value, because init writes it back into the regenerated .env. + * empty — `OBSIDIAN_AUTH_TOKEN=""` included, since the CLI strips quotes + * before docker run. Quotes stay in a returned value, because init writes it + * back into the regenerated .env. */ export const readEnvObsidianToken = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined const match = [...readFileSync(envFilePath, "utf8").matchAll(ENV_OBSIDIAN_TOKEN_LINES)].at(-1) - return match?.[1].trim() || undefined + const rawValue = match?.[1].trim() + const unquotedValue = rawValue ? stripSurroundingQuotes(rawValue).trim() : "" + + return unquotedValue ? rawValue : undefined } /** From 042a82da462d4a39304386d9d00f8fe12f911a96 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:18:05 -0400 Subject: [PATCH 33/57] fix(cli): stop start on an empty PUBLIC_URL line, and correct the setup-error guide and a stale comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start, restart and upgrade read PUBLIC_URL's last active line, as docker --env-file does, and stop on an empty value for a local install; before, an empty line passed the check and the server failed to boot, which showed only as a health-check timeout. - The remote guide no longer promises a Sync client message above the could-not-read-setup error: one branch of that error prints none. - init's comment says a container started without a Sync token opens its setup page. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/configure.test.ts | 4 ++-- cli/src/__tests__/lifecycle.test.ts | 24 ++++++++++++++++++++++-- cli/src/__tests__/scaffold.test.ts | 2 -- cli/src/init.ts | 5 +++-- cli/src/lifecycle.ts | 11 +++++------ cli/src/scaffold.ts | 22 +++------------------- deploy/remote/README.md | 3 ++- 7 files changed, 37 insertions(+), 34 deletions(-) diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index 59d573bd1..bd8294a47 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -346,8 +346,8 @@ describe("runConfigure with picked settings", () => { expect(exitCode).toBe(1) expect(scripted.errors).toEqual([ - `PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` + - `Add this line to your .env:\n PUBLIC_URL=http://localhost:8000`, + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + `Set this line in your .env:\n PUBLIC_URL=http://localhost:8000`, ]) expect(runParams).toEqual([]) // The settings edit itself succeeded before the restart failed — and the diff --git a/cli/src/__tests__/lifecycle.test.ts b/cli/src/__tests__/lifecycle.test.ts index 6e875812c..705341526 100644 --- a/cli/src/__tests__/lifecycle.test.ts +++ b/cli/src/__tests__/lifecycle.test.ts @@ -393,6 +393,26 @@ describe("runRestart", () => { ]) }) + it("exits 1 when local .env has an empty PUBLIC_URL line", async () => { + const targetDir = makeTempTargetDir("vault-cli-restart-") + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/MyVault\nPUBLIC_URL=\n", + ) + const scripted = createScriptedPrompts() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: dockerReady, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + "Set this line in your .env:\n PUBLIC_URL=http://localhost:8000", + ]) + }) + it("exits 1 when local .env has no PUBLIC_URL", async () => { const targetDir = makeTempTargetDir("vault-cli-restart-") writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/MyVault\n") @@ -405,8 +425,8 @@ describe("runRestart", () => { expect(exitCode).toBe(1) expect(scripted.errors).toEqual([ - `PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` + - "Add this line to your .env:\n PUBLIC_URL=http://localhost:8000", + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + "Set this line in your .env:\n PUBLIC_URL=http://localhost:8000", ]) }) diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index 86c257af2..c8efe5eb0 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -348,8 +348,6 @@ describe("readEnvPublicUrl", () => { }) it("returns undefined for an empty PUBLIC_URL= line", () => { - // Contrast with hasEnvPublicUrl, which deliberately matches the empty - // line for old-compose detection — the value reader must not. const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) const envPath = join(targetDir, ".env") writeFileSync(envPath, "MCP_AUTH_TOKEN=abc\nPUBLIC_URL=\n") diff --git a/cli/src/init.ts b/cli/src/init.ts index c53a6652f..2005d90e7 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -584,8 +584,9 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise if (!instanceName) return 1 - // Without the sync token the container can't start (init-check-auth fails - // and s6 stops it), so only offer docker run when it was provided. + // Without the sync token the container only opens its setup page and syncs + // nothing, so docker run is offered only with a token; otherwise the + // closing message says how to get one. const startStatus: StartStatus = !hasSyncToken ? "not-started" : await offerDockerRun({ targetDir, instanceName, port, mode: "remote" }, deps) diff --git a/cli/src/lifecycle.ts b/cli/src/lifecycle.ts index f2e194175..693731dc0 100644 --- a/cli/src/lifecycle.ts +++ b/cli/src/lifecycle.ts @@ -10,7 +10,6 @@ import { import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage } from "./messages.js" import { detectMode, - hasEnvPublicUrl, readEnvInstanceName, readEnvPort, readEnvPublicUrl, @@ -121,15 +120,15 @@ export const resolveDeployment = ( ) return undefined } - if (mode === "local" && !hasEnvPublicUrl(envFilePath)) { + const publicUrl = readEnvPublicUrl(envFilePath) + + if (mode === "local" && !publicUrl) { prompts.error( - `PUBLIC_URL not found in ${targetDir}/.env — the server requires it.\n` + - `Add this line to your .env:\n PUBLIC_URL=http://localhost:${port}`, + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + `Set this line in your .env:\n PUBLIC_URL=http://localhost:${port}`, ) return undefined } - - const publicUrl = readEnvPublicUrl(envFilePath) return { mode, instanceName, targetDir, envFilePath, port, vaultPath, publicUrl } } diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index 7b9a191f1..403f3560c 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -49,9 +49,6 @@ const ENV_VAULT_PATH_LINES = /^VAULT_PATH=(.*)$/gm const ENV_PUBLIC_URL_VALUE_LINES = /^PUBLIC_URL=(.*)$/gm const ENV_OBSIDIAN_TOKEN_LINES = /^OBSIDIAN_AUTH_TOKEN=(.*)$/gm -/** Matches an active (uncommented) PUBLIC_URL line. */ -const ENV_PUBLIC_URL_LINE = /^PUBLIC_URL=/m - /** Matches an active (uncommented) OBSIDIAN_AUTH_TOKEN line. */ const OBSIDIAN_AUTH_TOKEN_LINE = /^OBSIDIAN_AUTH_TOKEN=/m @@ -132,24 +129,11 @@ export const readEnvVaultPath = (envFilePath: string): string | undefined => { return vaultPath || undefined } -/** - * Returns true when the .env file has an active (uncommented) PUBLIC_URL line. - * It's the FALSE result that carries the signal: the old compose-based CLI's - * generated .env never held a PUBLIC_URL line (the compose file's environment - * defaults supplied it), so a local .env without one predates the docker-run - * migration — callers use the negation to ask the user to add the line - * instead of starting a server missing a required variable. - */ -export const hasEnvPublicUrl = (envFilePath: string): boolean => { - if (!existsSync(envFilePath)) return false - return ENV_PUBLIC_URL_LINE.test(readFileSync(envFilePath, "utf8")) -} - /** * Reads the public URL value from a .env file. Returns undefined when the - * file is missing or has no uncommented, non-empty PUBLIC_URL line — - * deliberately stricter than hasEnvPublicUrl, whose job is old-compose - * detection and so matches an empty `PUBLIC_URL=` line too. + * file is missing or its last active PUBLIC_URL line is empty or absent, + * which is how start spots a .env the server would refuse (including one + * from the old compose-based CLI, which never wrote the line). */ export const readEnvPublicUrl = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 6c7ddaa92..2c6d1309f 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -632,7 +632,8 @@ for syncing the new vault instead. An **ERROR** that "the Sync client could not read its own setup" means the Sync client's answer about which vault it syncs failed or named no vault, so the container stopped before syncing any notes. Restart it. If the error -comes back, the Sync client's own message in the lines above it says why. +comes back and the lines just above it hold a message from the Sync client, +that message says why. ## Memory From 291326765f8b7609fec40b8ee775fb4a8e939017 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:24:36 -0400 Subject: [PATCH 34/57] fix(cli): treat a quoted whitespace-only PUBLIC_URL as empty, and correct the no-token start comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readEnvPublicUrl now trims inside the quotes, so `PUBLIC_URL=" "` stops start with the empty-or-missing error instead of passing the gate and timing out on a server that refuses the blank value. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/scaffold.test.ts | 16 ++++++++++++++++ cli/src/init.ts | 2 +- cli/src/scaffold.ts | 6 +++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index c8efe5eb0..12ebc1053 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -380,6 +380,22 @@ describe("readEnvPublicUrl", () => { expect(readEnvPublicUrl(envPath)).toBe("https://vault.example.com") }) + + it("returns undefined when the quotes hold only whitespace", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, 'MCP_AUTH_TOKEN=abc\nPUBLIC_URL=" "\n') + + expect(readEnvPublicUrl(envPath)).toBeUndefined() + }) + + it("trims whitespace inside the quotes before stripping trailing slashes", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, 'MCP_AUTH_TOKEN=abc\nPUBLIC_URL=" https://vault.example.com/ "\n') + + expect(readEnvPublicUrl(envPath)).toBe("https://vault.example.com") + }) }) describe("detectMode", () => { diff --git a/cli/src/init.ts b/cli/src/init.ts index 2005d90e7..0b003c59d 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -586,7 +586,7 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise // Without the sync token the container only opens its setup page and syncs // nothing, so docker run is offered only with a token; otherwise the - // closing message says how to get one. + // closing message says to fill it in before starting. const startStatus: StartStatus = !hasSyncToken ? "not-started" : await offerDockerRun({ targetDir, instanceName, port, mode: "remote" }, deps) diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index 403f3560c..e0bc644d5 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -138,10 +138,10 @@ export const readEnvVaultPath = (envFilePath: string): string | undefined => { export const readEnvPublicUrl = (envFilePath: string): string | undefined => { if (!existsSync(envFilePath)) return undefined const match = [...readFileSync(envFilePath, "utf8").matchAll(ENV_PUBLIC_URL_VALUE_LINES)].at(-1) - // A whitespace-only line matches the regex and trims to "" — normalize to - // undefined so the non-empty contract holds ("" is never a legitimate URL). + // A whitespace-only value, quoted or not, counts as empty, as it does in + // the server's check of the required variable. const rawValue = match?.[1].trim() - const unquotedValue = rawValue ? stripSurroundingQuotes(rawValue) : undefined + const unquotedValue = rawValue ? stripSurroundingQuotes(rawValue).trim() : undefined // Strip trailing slashes (mirroring askPublicUrl's prompt-side // normalization): consumers append paths to this base, and a hand-edited // `https://host/` would otherwise print broken `//mcp` connect URLs. From 75c0a916778e3e14649280cca7f9032c9819239b Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:39:37 -0400 Subject: [PATCH 35/57] fix(cli): refuse an invalid PORT, check a remote PUBLIC_URL, and let init start without a Sync token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readEnvPort reports a PORT that isn't a whole number from 1 to 65535 instead of using 8000; start, restart, upgrade and init stop on it, and an init re-run stops on a kept invalid PORT unless --port replaces it. Before, the CLI mapped 8000 while a derived PUBLIC_URL could carry the bad value. - start, restart and upgrade refuse an empty or missing PUBLIC_URL for a remote install too, suggesting the server's public address. - Remote init offers to start the server without a Sync token: the container keeps a login saved on its volume, or serves its sign-in page, and the closing message says how to sign in there (over HTTPS). The generated .env comment no longer says the token must be filled in before starting. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 4 +- cli/src/__tests__/env.test.ts | 11 +- cli/src/__tests__/init.test.ts | 112 +++++++++++++++++- cli/src/__tests__/integration/cli-pty.test.ts | 5 +- cli/src/__tests__/lifecycle.test.ts | 44 +++++++ cli/src/__tests__/messages.test.ts | 43 ++++++- cli/src/__tests__/scaffold.test.ts | 32 ++++- cli/src/env.ts | 4 +- cli/src/init.ts | 48 ++++---- cli/src/instance-identity.ts | 16 ++- cli/src/lifecycle.ts | 42 +++---- cli/src/messages.ts | 36 ++++-- cli/src/optional-settings.ts | 13 +- cli/src/scaffold.ts | 38 ++++-- 14 files changed, 351 insertions(+), 97 deletions(-) diff --git a/cli/README.md b/cli/README.md index b9492a34d..3f1228064 100644 --- a/cli/README.md +++ b/cli/README.md @@ -64,7 +64,9 @@ name, and stops if `--instance-name` gives a different one. It keeps the port too, unless you change it with `--port` or in the optional settings. Existing files are never overwritten without asking. During a remote setup, init offers to generate your [Obsidian Sync token](#get-sync-token) as part -of the flow. +of the flow. If you skip it, init still offers to start the server, which +then shows a sign-in page at your `PUBLIC_URL`; set up HTTPS first, since the +page sends your Obsidian password to the server. To run a second vault on the same machine, start Docker, so init can check which names are in use, then run init again with its own `--dir`. When the diff --git a/cli/src/__tests__/env.test.ts b/cli/src/__tests__/env.test.ts index 3ea58853e..10a08cce7 100644 --- a/cli/src/__tests__/env.test.ts +++ b/cli/src/__tests__/env.test.ts @@ -91,12 +91,15 @@ describe("buildRemoteEnv", () => { expect(lines).toContain("VAULT_PASSWORD=hunter2") }) - it("writes an empty OBSIDIAN_AUTH_TOKEN line with a fill-this-in warning when the token was skipped", () => { + it("writes an empty OBSIDIAN_AUTH_TOKEN line under the sign-in options when the token was skipped", () => { const env = buildRemoteEnv({ ...baseAnswers, obsidianAuthToken: "" }) - expect(env).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) - expect(env).toContain("FILL THIS IN") - expect(env).toContain("npx vault-cortex@latest get-sync-token") + expect(env).toContain( + "# Obsidian Sync auth token. Leave empty to sign in on the server's sign-in\n" + + "# page at your PUBLIC_URL once it starts (over HTTPS), or generate one with:\n" + + "# npx vault-cortex@latest get-sync-token\n" + + "OBSIDIAN_AUTH_TOKEN=\n", + ) }) it("states defaulted sync settings as uncommented lines", () => { diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index b1f175874..e908f232e 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -520,6 +520,7 @@ describe("runInit remote flow", () => { false, // don't generate the token now (declined auto-capture) false, // no end-to-end encryption [], // no optional settings + false, // don't start the server ]) const exitCode = await runInit( { mode: "remote", dir: targetDir }, @@ -537,6 +538,7 @@ describe("runInit remote flow", () => { "Generate the token now?", "Does your vault use end-to-end encryption?", "Any optional settings to change? (press enter to skip)", + "Start the server now?", ]) expect(existsSync(join(targetDir, "docker-compose.yml"))).toBe(false) const envContent = readFileSync(join(targetDir, ".env"), "utf8") @@ -544,7 +546,7 @@ describe("runInit remote flow", () => { expect(envContent).toContain("VAULT_NAME=MyVault\n") expect(envContent).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) expect(scripted.logs).toContain( - "No token yet — run this later to add it to your .env:\n" + + "No Sync token in .env — sign in on the server's sign-in page once it starts, or add a token with:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) }) @@ -567,8 +569,8 @@ describe("runInit remote flow", () => { }, ) - // Token generation is always offered (uses the API, not Docker). With a - // blank token (capture declined), no start offer is shown. + // Token generation is always offered (uses the API, not Docker). Without + // Docker installed, no start offer is shown. expect(exitCode).toBe(0) expect(scripted.asked).toEqual([ "Public base URL clients will use to reach this server (no /mcp — it's added for you):", @@ -627,6 +629,48 @@ describe("runInit remote flow", () => { ]) }) + it("starts the server without a Sync token and points at its sign-in page", async () => { + const targetDir = makeTargetDir() + const fetchedUrls: string[] = [] + const fetchRecorder: typeof fetch = async (input) => { + fetchedUrls.push(String(input)) + return new Response(null, { status: 200 }) + } + const scripted = createScriptedPrompts([ + "https://vault.example.com", // public URL + "MyVault", // vault name + false, // don't generate the token now + false, // no end-to-end encryption + [], // no optional settings + true, // start the server now + ]) + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerReadyEmptyHost, + fetchFn: fetchRecorder, + }, + ) + + expect(exitCode).toBe(0) + // The health poll and the public URL probe only run after docker run. + expect(fetchedUrls).toEqual([ + "http://127.0.0.1:8000/healthz", + "https://vault.example.com/healthz", + ]) + const connectMessage = scripted.prints.find((message) => { + return message.includes("Connect your MCP client") + }) + + expect(connectMessage).toContain( + "The server is running.\n" + + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + "syncing. Open it and sign in with the auth token below:\n" + + " https://vault.example.com\n\n", + ) + }) + it("keeps a successful start at exit 0 when the public URL does not answer", async () => { const targetDir = makeTargetDir() const fetchedUrls: string[] = [] @@ -994,6 +1038,7 @@ describe("runInit remote encryption password", () => { true, // vault uses end-to-end encryption "hunter2", // password (masked prompt) [], // no optional settings + false, // don't start the server ]) const exitCode = await runInit( { mode: "remote", dir: targetDir }, @@ -1089,6 +1134,7 @@ describe("runInit sync-token auto-capture fallback", () => { "bad-password", // password false, // no encryption [], // no optional settings + false, // don't start the server ]) await runInit( { mode: "remote", dir: targetDir }, @@ -1102,7 +1148,7 @@ describe("runInit sync-token auto-capture fallback", () => { expect(scripted.warnings[0]).toBe("Could not sign in: Invalid email or password") expect(readFileSync(join(targetDir, ".env"), "utf8")).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) expect(scripted.logs).toContain( - "No token yet — run this later to add it to your .env:\n" + + "No Sync token in .env — sign in on the server's sign-in page once it starts, or add a token with:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) }) @@ -1604,6 +1650,7 @@ describe("runInit when the instance name is already in use", () => { false, // don't generate the token now false, // no encryption [], // no optional settings + false, // don't start the server ]) const exitCode = await runInit( @@ -1638,6 +1685,7 @@ describe("runInit when the instance name is already in use", () => { false, // don't generate the token now false, // no encryption [], // no optional settings + false, // don't start the server ]) const exitCode = await runInit( @@ -1765,6 +1813,7 @@ describe("runInit when the instance name is already in use", () => { false, false, [], + false, ]) const exitCode = await runInit( @@ -1937,6 +1986,7 @@ describe("runInit --yes when the instance name is already in use", () => { false, // don't generate the token now false, // no encryption [], // no optional settings + false, // don't start the server ]) const exitCode = await runInit( @@ -2205,6 +2255,60 @@ describe("runInit re-run keeps the instance identity", () => { ]) }) + it("stops a re-run whose kept PORT is not a port, before asking anything more", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const existingEnv = `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nPORT=80a\n` + writeFileSync(join(targetDir, ".env"), existingEnv) + const scripted = createScriptedPrompts([true]) // re-run setup anyway + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PORT "80a" is not a valid port — use a whole number between 1 and 65535. Fix it in ${targetDir}/.env.`, + ]) + expect(scripted.asked).toHaveLength(1) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + }) + + it("lets --port replace a kept PORT that is not a port, moving the localhost PUBLIC_URL with it", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nPORT=80a\nPUBLIC_URL=http://localhost:80a\n`, + ) + // re-run anyway, no optional settings, overwrite .env, don't start + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:9000", + "INSTANCE_NAME=vault-cortex", + "PORT=9000", + ]) + }) + it("accepts a re-run whose --instance-name repeats the install's name", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index b0381d9a6..9d9b4956c 100644 --- a/cli/src/__tests__/integration/cli-pty.test.ts +++ b/cli/src/__tests__/integration/cli-pty.test.ts @@ -238,6 +238,7 @@ describe("init remote", () => { send: "\r", label: "optional settings → skip", }, + { match: "Start the server now", send: "n\r", label: "start → no" }, ] const result = await drivePty({ @@ -249,7 +250,7 @@ describe("init remote", () => { expect(result.exitCode).toBe(0) expect(result.promptsAnswered).toBe(result.totalPrompts) expect(result.transcript).toContain("Done.") - expect(result.transcript).toContain("No token yet") + expect(result.transcript).toContain("No Sync token in .env") const envContent = readFileSync(join(configDir, ".env"), "utf8") expect(envContent).toContain("PUBLIC_URL=https://vault.example.com") @@ -550,6 +551,7 @@ describe("input validation re-prompts", () => { send: "\r", label: "optional settings → skip", }, + { match: "Start the server now", send: "n\r", label: "start → no" }, ] const result = await drivePty({ @@ -603,6 +605,7 @@ describe("input validation re-prompts", () => { send: "\r", label: "optional settings → skip", }, + { match: "Start the server now", send: "n\r", label: "start → no" }, ] const result = await drivePty({ diff --git a/cli/src/__tests__/lifecycle.test.ts b/cli/src/__tests__/lifecycle.test.ts index 705341526..82fd20829 100644 --- a/cli/src/__tests__/lifecycle.test.ts +++ b/cli/src/__tests__/lifecycle.test.ts @@ -430,6 +430,50 @@ describe("runRestart", () => { ]) }) + it("exits 1 when remote .env has an empty PUBLIC_URL line", async () => { + const targetDir = makeTempTargetDir("vault-cli-restart-") + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nOBSIDIAN_AUTH_TOKEN=sync-token\nVAULT_NAME=MyVault\nPUBLIC_URL=\n", + ) + const scripted = createScriptedPrompts() + const recording = createRecordingDocker() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + "Set it to the address your MCP clients reach this server at, for example:\n" + + " PUBLIC_URL=https://vault.example.com", + ]) + expect(recording.runParams).toEqual([]) + }) + + it("exits 1 when .env's PORT is not a port", async () => { + const targetDir = makeTempTargetDir("vault-cli-restart-") + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc123\nVAULT_PATH=/home/user/MyVault\nPORT=80a\nPUBLIC_URL=http://localhost:8000\n", + ) + const scripted = createScriptedPrompts() + const recording = createRecordingDocker() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PORT "80a" is not a valid port — use a whole number between 1 and 65535. Fix it in ${targetDir}/.env.`, + ]) + expect(recording.runParams).toEqual([]) + }) + it("exits 1 when Docker daemon is not running", async () => { const targetDir = makeTempTargetDir("vault-cli-restart-") writeLocalEnv(targetDir) diff --git a/cli/src/__tests__/messages.test.ts b/cli/src/__tests__/messages.test.ts index be56046d6..7179eb8cb 100644 --- a/cli/src/__tests__/messages.test.ts +++ b/cli/src/__tests__/messages.test.ts @@ -240,9 +240,9 @@ describe("buildRemoteConnectMessage", () => { startStatus: "running", }) - expect(message).toContain("The server is running.") + expect(message).toContain("The server is running.\n\n") expect(message).not.toContain("Start the server:") - expect(message).not.toContain("Fill in OBSIDIAN_AUTH_TOKEN") + expect(message).not.toContain("sign-in page") }) it("shows 'starting in the background' when startStatus is starting", () => { @@ -282,15 +282,50 @@ describe("buildRemoteConnectMessage", () => { expect(message).not.toContain("Smoke test:") }) - it("shows 'Fill in OBSIDIAN_AUTH_TOKEN' when obsidianTokenMissing and not started", () => { + it("follows the start command with the sign-in steps when .env has no Sync token", () => { const message = buildRemoteConnectMessage({ ...remoteDefaults, + publicUrl: "https://vault.example.com", startStatus: "not-started", obsidianTokenMissing: true, }) expect(message).toContain( - `Fill in OBSIDIAN_AUTH_TOKEN in ${remoteDefaults.targetDir}/.env, then start the server:\n npx vault-cortex@latest start --dir "${remoteDefaults.targetDir}"`, + `Start the server:\n npx vault-cortex@latest start --dir "${remoteDefaults.targetDir}"\n` + + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + "syncing. Open it and sign in with the auth token below:\n" + + " https://vault.example.com\n\n", + ) + }) + + it("follows the running line with the sign-in steps when .env has no Sync token", () => { + const message = buildRemoteConnectMessage({ + ...remoteDefaults, + publicUrl: "https://vault.example.com", + startStatus: "running", + obsidianTokenMissing: true, + }) + + expect(message).toContain( + "The server is running.\n" + + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + "syncing. Open it and sign in with the auth token below:\n" + + " https://vault.example.com\n\n", + ) + }) + + it("asks for HTTPS before the sign-in page when PUBLIC_URL is http", () => { + const message = buildRemoteConnectMessage({ + ...remoteDefaults, + publicUrl: "http://203.0.113.5:8000", + startStatus: "running", + obsidianTokenMissing: true, + }) + + expect(message).toContain( + "sign in with the auth token below:\n" + + " http://203.0.113.5:8000\n" + + "Set up HTTPS first: the page sends your Obsidian password to the server.\n\n", ) }) diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index 12ebc1053..6e1408de5 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -120,7 +120,7 @@ describe("readEnvPort", () => { it("returns the default 8000 when no .env exists", () => { const missingPath = join(tmpdir(), "vault-cli-no-such-env", ".env") - expect(readEnvPort(missingPath)).toBe(8000) + expect(readEnvPort(missingPath)).toEqual({ kind: "ok", port: 8000 }) }) it("returns the default 8000 when PORT is only present as a comment", () => { @@ -128,7 +128,7 @@ describe("readEnvPort", () => { const envPath = join(targetDir, ".env") writeFileSync(envPath, "MCP_AUTH_TOKEN=abc\n# PORT=9000\n") - expect(readEnvPort(envPath)).toBe(8000) + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 8000 }) }) it("returns an uncommented PORT override", () => { @@ -136,7 +136,7 @@ describe("readEnvPort", () => { const envPath = join(targetDir, ".env") writeFileSync(envPath, "MCP_AUTH_TOKEN=abc\nPORT=9000\n") - expect(readEnvPort(envPath)).toBe(9000) + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 9000 }) }) it("strips surrounding double quotes from the port value", () => { @@ -144,7 +144,7 @@ describe("readEnvPort", () => { const envPath = join(targetDir, ".env") writeFileSync(envPath, 'MCP_AUTH_TOKEN=abc\nPORT="9000"\n') - expect(readEnvPort(envPath)).toBe(9000) + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 9000 }) }) it("takes the last active PORT line, as docker --env-file does", () => { @@ -152,7 +152,7 @@ describe("readEnvPort", () => { const envPath = join(targetDir, ".env") writeFileSync(envPath, "PORT=8000\nMCP_AUTH_TOKEN=abc\nPORT=8001\n") - expect(readEnvPort(envPath)).toBe(8001) + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 8001 }) }) it("reads an empty last PORT line as the default port, not an earlier line's port", () => { @@ -160,8 +160,28 @@ describe("readEnvPort", () => { const envPath = join(targetDir, ".env") writeFileSync(envPath, "PORT=9000\nMCP_AUTH_TOKEN=abc\nPORT=\n") - expect(readEnvPort(envPath)).toBe(8000) + expect(readEnvPort(envPath)).toEqual({ kind: "ok", port: 8000 }) }) + + it.each([ + { label: "letters", portValue: "abc", reportedValue: "abc" }, + { label: "digits then letters", portValue: "80a", reportedValue: "80a" }, + { label: "zero", portValue: "0", reportedValue: "0" }, + { label: "a number above 65535", portValue: "70000", reportedValue: "70000" }, + { label: "a quoted value padded with spaces", portValue: '" 9000 "', reportedValue: " 9000 " }, + ])( + "reports $label as an invalid port instead of using the default", + ({ portValue, reportedValue }) => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, `PORT=9000\nPORT=${portValue}\n`) + + expect(readEnvPort(envPath)).toEqual({ + kind: "invalid", + message: `PORT "${reportedValue}" is not a valid port — use a whole number between 1 and 65535.`, + }) + }, + ) }) describe("readEnvInstanceName", () => { diff --git a/cli/src/env.ts b/cli/src/env.ts index d9aec1120..5c1c260b3 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -424,8 +424,8 @@ VAULT_PASSWORD=${answers.vaultPassword}` const obsidianTokenComment = answers.obsidianAuthToken ? `# Obsidian Sync auth token.` - : `# Obsidian Sync auth token — FILL THIS IN before starting the server. -# Generate once with: + : `# Obsidian Sync auth token. Leave empty to sign in on the server's sign-in +# page at your PUBLIC_URL once it starts (over HTTPS), or generate one with: # npx vault-cortex@latest get-sync-token` return `# vault-cortex — remote quickstart (Obsidian Sync) diff --git a/cli/src/init.ts b/cli/src/init.ts index 0b003c59d..22c19b40f 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -288,12 +288,23 @@ const reportWrites = ( * whose INSTANCE_NAME differs from this run's. Reports an invalid value and * returns undefined, so init never starts or prints commands for a bad name. */ -const readInstanceNameFromDisk = (targetDir: string, prompts: Prompts): string | undefined => { - const instanceNameRead = readEnvInstanceName(join(targetDir, ".env")) +const readInstanceFromDisk = ( + targetDir: string, + prompts: Prompts, +): { instanceName: string; port: number } | undefined => { + const envFilePath = join(targetDir, ".env") + const instanceNameRead = readEnvInstanceName(envFilePath) + const portRead = readEnvPort(envFilePath) - if (instanceNameRead.kind === "ok") return instanceNameRead.instanceName - prompts.error(`${instanceNameRead.message} Fix it in ${targetDir}/.env.`) - return undefined + if (instanceNameRead.kind === "invalid") { + prompts.error(`${instanceNameRead.message} Fix it in ${targetDir}/.env.`) + return undefined + } + if (portRead.kind === "invalid") { + prompts.error(`${portRead.message} Fix it in ${targetDir}/.env.`) + return undefined + } + return { instanceName: instanceNameRead.instanceName, port: portRead.port } } /** @@ -465,10 +476,10 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") // Only a written .env carries the picked port, so a kept one needs no warning. if (tokenWritten && publicUrlWarning) prompts.warn(publicUrlWarning) - const port = readEnvPort(join(targetDir, ".env")) - const instanceName = readInstanceNameFromDisk(targetDir, prompts) + const instanceOnDisk = readInstanceFromDisk(targetDir, prompts) - if (!instanceName) return 1 + if (!instanceOnDisk) return 1 + const { instanceName, port } = instanceOnDisk // --yes is for scripts/CI, so it never starts Docker. const startStatus: StartStatus = flags.yes @@ -516,14 +527,14 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise // Sign in to Obsidian and capture the Sync token directly via the API. // When the user declines or capture fails, fall back to any token already // in the on-disk .env (a re-init over an existing deployment). Only show - // the "run get-sync-token later" guidance when neither source has a token. + // the sign-in guidance when neither source has a token. const capturedToken = await offerSyncTokenCapture(prompts, fetchFn) const existingEnvToken = readEnvObsidianToken(join(targetDir, ".env")) const hasSyncToken = Boolean(capturedToken ?? existingEnvToken) if (!hasSyncToken) { prompts.log( - "No token yet — run this later to add it to your .env:\n" + + "No Sync token in .env — sign in on the server's sign-in page once it starts, or add a token with:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) } @@ -574,22 +585,19 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") if (tokenWritten && publicUrlWarning) prompts.warn(publicUrlWarning) - const port = readEnvPort(join(targetDir, ".env")) + const instanceOnDisk = readInstanceFromDisk(targetDir, prompts) + + if (!instanceOnDisk) return 1 + const { instanceName, port } = instanceOnDisk // Like PORT above, PUBLIC_URL comes from the .env actually on disk — a kept // existing file may hold a different URL than this run's prompt, and the // server only reads the file. The prompted value is the fallback for a kept // legacy .env that predates PUBLIC_URL. const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl - const instanceName = readInstanceNameFromDisk(targetDir, prompts) - if (!instanceName) return 1 - - // Without the sync token the container only opens its setup page and syncs - // nothing, so docker run is offered only with a token; otherwise the - // closing message says to fill it in before starting. - const startStatus: StartStatus = !hasSyncToken - ? "not-started" - : await offerDockerRun({ targetDir, instanceName, port, mode: "remote" }, deps) + // Started without a Sync token in .env, the container keeps a login saved + // on its volume, or serves its sign-in page; the closing message covers both. + const startStatus = await offerDockerRun({ targetDir, instanceName, port, mode: "remote" }, deps) // The container check above hit localhost on this machine; the public URL // is the ingress path clients actually use — probe it too, informationally. diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index a9c49a1b4..04b8fb751 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -6,13 +6,14 @@ import { applyOptionalSettings, askPort, derivePublicUrlOverride, - isValidPort, readOptionalValue, } from "./optional-settings.js" import { DEFAULT_INSTANCE_NAME, DEFAULT_PORT, + describeInvalidEnvPort, describeInvalidInstanceName, + isValidPort, type Mode, } from "./scaffold.js" import type { Prompts } from "./prompts.js" @@ -165,8 +166,9 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { /** * Decides this install's name and port before init writes .env: * - a re-run over an existing .env keeps its name and port, plus a local - * PUBLIC_URL or a remote DEVICE_NAME (`carried`), and exits when that - * name is invalid or --instance-name gives a different one; + * PUBLIC_URL or a remote DEVICE_NAME (`carried`), and exits when the name + * or port is invalid (--port replaces the port) or --instance-name gives a + * different name; * - --instance-name and --port set them directly; * - a new install whose name is already in use on the machine asks for its own * name and port, or confirms reuse of the existing data for the same vault. @@ -214,6 +216,14 @@ export const resolveInstanceIdentity = async ( prompts.error(`INSTANCE_NAME ${carriedNameError} Fix it in ${targetDir}/.env.`) return { kind: "exit" } } + // The same holds for a kept port, unless --port replaces it. + const carriedPortError = + carried.PORT && !isValidPort(carried.PORT) ? describeInvalidEnvPort(carried.PORT) : undefined + + if (carriedPortError && !flags.port) { + prompts.error(`${carriedPortError} Fix it in ${targetDir}/.env.`) + return { kind: "exit" } + } // Renaming here would leave the old container running and could point // the folder at another install's volumes, so the name stays. if (flags.instanceName && flags.instanceName !== carriedName) { diff --git a/cli/src/lifecycle.ts b/cli/src/lifecycle.ts index 693731dc0..4a5fbcef9 100644 --- a/cli/src/lifecycle.ts +++ b/cli/src/lifecycle.ts @@ -62,8 +62,7 @@ export type Deployment = { port: number /** Present only in local mode. */ vaultPath?: string - /** The .env's PUBLIC_URL, when set. */ - publicUrl?: string + publicUrl: string } const DEFAULT_TARGET_DIR = "./vault-cortex" @@ -96,10 +95,16 @@ export const requireInitializedDir = ( return { targetDir, envFilePath, mode, instanceName: instanceNameRead.instanceName } } +/** A local PUBLIC_URL is this machine's own address; a remote one is the user's domain. */ +const missingPublicUrlFix = (mode: Mode, port: number): string => { + if (mode === "local") return `Set this line in your .env:\n PUBLIC_URL=http://localhost:${port}` + return "Set it to the address your MCP clients reach this server at, for example:\n PUBLIC_URL=https://vault.example.com" +} + /** * Resolves --dir to the on-disk deployment and validates its .env for a - * container start: mode detection, VAULT_PATH (local), and PUBLIC_URL - * (local). Reports each failure via prompts.error and returns undefined. + * container start: mode detection, PORT, VAULT_PATH (local), and PUBLIC_URL. + * Reports each failure via prompts.error and returns undefined. * Shared by upgrade and restart so their precondition checks can't drift. */ export const resolveDeployment = ( @@ -111,7 +116,13 @@ export const resolveDeployment = ( if (!initialized) return undefined const { targetDir, envFilePath, mode, instanceName } = initialized - const port = readEnvPort(envFilePath) + const portRead = readEnvPort(envFilePath) + + if (portRead.kind === "invalid") { + prompts.error(`${portRead.message} Fix it in ${targetDir}/.env.`) + return undefined + } + const { port } = portRead const vaultPath = mode === "local" ? readEnvVaultPath(envFilePath) : undefined if (mode === "local" && !vaultPath) { @@ -122,10 +133,10 @@ export const resolveDeployment = ( } const publicUrl = readEnvPublicUrl(envFilePath) - if (mode === "local" && !publicUrl) { + if (!publicUrl) { prompts.error( `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + - `Set this line in your .env:\n PUBLIC_URL=http://localhost:${port}`, + missingPublicUrlFix(mode, port), ) return undefined } @@ -148,18 +159,6 @@ export const ensureDaemonRunning = (docker: DockerRunner, prompts: Prompts): boo return false } -/** - * Whether the post-start public-URL probe should run — and, when it should, - * proof that publicUrl is set. Local's PUBLIC_URL is the derived localhost - * URL, so probing it would only duplicate the health check the start cycle - * just ran. - */ -const shouldRunPostStartProbe = ( - deployment: Deployment, -): deployment is Deployment & { publicUrl: string } => { - return deployment.mode === "remote" && deployment.publicUrl !== undefined -} - /** * One-shot informational probe of the public /healthz after a confirmed * container start. Never a gate: a failure warns and the command still @@ -250,8 +249,9 @@ export const recreateContainer = async ( spinner.stop("Server is up — health check passed.") // Informational only — the container is confirmed healthy above, so the - // public-URL result never changes the exit code. - if (shouldRunPostStartProbe(deployment)) { + // public-URL result never changes the exit code. A local PUBLIC_URL is the + // localhost address the health check just reached, so only remote probes it. + if (deployment.mode === "remote") { await reportPublicUrlProbe(deployment.publicUrl, { prompts, fetchFn }) } return 0 diff --git a/cli/src/messages.ts b/cli/src/messages.ts index b76025bf2..6ee2d2d7c 100644 --- a/cli/src/messages.ts +++ b/cli/src/messages.ts @@ -91,21 +91,37 @@ const startServerLine = (targetDir: string): string => const startingInBackgroundLine = (instanceName: string): string => `The server is starting in the background — check progress:\n docker logs ${instanceName}` -/** Remote start line: running, starting, blocked on the missing sync token, or ready to start. */ +const isHttpsUrl = (url: string): boolean => url.toLowerCase().startsWith("https://") + +/** + * Without a Sync token in .env, a container that has never signed in serves + * its sign-in page at PUBLIC_URL; one that signed in there before keeps that + * login. The page asks for the MCP auth token and the Obsidian password. + */ +const syncSignInLines = (publicUrl: string): string => { + const signInLines = + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + `syncing. Open it and sign in with the auth token below:\n ${publicUrl}` + + if (isHttpsUrl(publicUrl)) return signInLines + return `${signInLines}\nSet up HTTPS first: the page sends your Obsidian password to the server.` +} + +/** Remote start line: running, starting, or ready to start, plus sign-in steps when .env has no Sync token. */ const remoteStartLine = (params: { targetDir: string instanceName: string + publicUrl: string startStatus: StartStatus obsidianTokenMissing: boolean }): string => { - const { targetDir, instanceName, startStatus, obsidianTokenMissing } = params + const { targetDir, instanceName, publicUrl, startStatus, obsidianTokenMissing } = params + const notRunningLine = + startStatus === "starting" ? startingInBackgroundLine(instanceName) : startServerLine(targetDir) + const statusLine = startStatus === "running" ? "The server is running." : notRunningLine - if (startStatus === "running") return "The server is running." - if (startStatus === "starting") return startingInBackgroundLine(instanceName) - if (obsidianTokenMissing) { - return `Fill in OBSIDIAN_AUTH_TOKEN in ${targetDir}/.env, then start the server:\n ${startCommand(targetDir)}` - } - return startServerLine(targetDir) + if (!obsidianTokenMissing) return statusLine + return `${statusLine}\n${syncSignInLines(publicUrl)}` } /** @@ -289,6 +305,7 @@ export const buildRemoteConnectMessage = (params: { const startLine = remoteStartLine({ targetDir, instanceName, + publicUrl, startStatus, obsidianTokenMissing, }) @@ -301,8 +318,7 @@ export const buildRemoteConnectMessage = (params: { // know which case it is, so the http branch states it rather than asking. // Case-insensitive: askPublicUrl stores the scheme as typed, so an HTTPS:// // input is valid and must still route to the https branch. - const isHttps = publicUrl.toLowerCase().startsWith("https://") - const clientGuidance = isHttps + const clientGuidance = isHttpsUrl(publicUrl) ? `${connectGuidance(`${publicUrl}/mcp`, instanceName)} Reachable over https from any MCP client — Claude Desktop, claude.ai (web diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 19f0abf0e..87b45ca15 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -1,4 +1,4 @@ -import { DEFAULT_PORT, stripSurroundingQuotes, type Mode } from "./scaffold.js" +import { DEFAULT_PORT, isValidPort, stripSurroundingQuotes, type Mode } from "./scaffold.js" import type { Prompts, SelectOption } from "./prompts.js" type OptionalSettingBase = { @@ -264,17 +264,6 @@ export const describeStrandedPublicUrl = (params: { const isEnabledToggleValue = (value: string | undefined): boolean => !["false", "0"].includes((value ?? "").toLowerCase()) -/** - * Plain digits in the TCP port range. Number() coercion is not enough: it - * accepts "1e4"/"0x1F40"/"+9000", and readEnvPort reads any value that is not - * plain digits as the default port. - */ -export const isValidPort = (value: string): boolean => { - if (!/^\d+$/.test(value)) return false - const port = Number(value) - return port >= 1 && port <= 65535 -} - /** * The engine's own IANA zone validation: Intl.DateTimeFormat throws a * RangeError for an unknown timeZone, so no hand-rolled zone list to drift. diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index e0bc644d5..f06fdf3d1 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -71,20 +71,40 @@ export const buildFilesToWrite = (envContent: string): FileToWrite[] => [ ] /** - * Reads the host port from the .env that is actually on disk — which may be - * a pre-existing file this run chose to keep, with a PORT override the - * generated default doesn't have. The last active line wins, as it does for - * `docker run --env-file`. Falls back to DEFAULT_PORT when the file or an - * uncommented PORT line is absent, or the last line's value is not plain digits - * (an empty `PORT=` included). + * Plain digits in the TCP port range. Number() coercion is not enough: it + * accepts "1e4", "0x1F40" and "+9000". */ -export const readEnvPort = (envFilePath: string): number => { - if (!existsSync(envFilePath)) return DEFAULT_PORT +export const isValidPort = (value: string): boolean => { + if (!/^\d+$/.test(value)) return false + const port = Number(value) + return port >= 1 && port <= 65535 +} + +/** The error for a .env PORT value that isn't a port; callers add where to fix it. */ +export const describeInvalidEnvPort = (portValue: string): string => { + return `PORT "${portValue}" is not a valid port — use a whole number between 1 and 65535.` +} + +export type PortRead = { kind: "ok"; port: number } | { kind: "invalid"; message: string } + +/** + * Reads the host port from a .env file: + * - the last active line wins, as it does for `docker run --env-file`; + * - a missing file, line or empty value means DEFAULT_PORT; + * - any other value that is not a port is reported, never corrected, because + * the CLI would map one port while PUBLIC_URL names another. + */ +export const readEnvPort = (envFilePath: string): PortRead => { + if (!existsSync(envFilePath)) return { kind: "ok", port: DEFAULT_PORT } const portLines = [...readFileSync(envFilePath, "utf8").matchAll(ENV_PORT_LINES)] const rawValue = portLines.at(-1)?.[1].trim() const portValue = rawValue ? stripSurroundingQuotes(rawValue) : "" - return /^\d+$/.test(portValue) ? Number(portValue) : DEFAULT_PORT + if (!portValue) return { kind: "ok", port: DEFAULT_PORT } + if (!isValidPort(portValue)) { + return { kind: "invalid", message: describeInvalidEnvPort(portValue) } + } + return { kind: "ok", port: Number(portValue) } } /** Returns why a candidate instance name is invalid, or undefined when it is valid. */ From 50399b476b62950263eae143482fc737dfc0e8c0 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:49:18 -0400 Subject: [PATCH 36/57] fix(cli): keep a localhost PUBLIC_URL with a replaced invalid PORT, and show sign-in steps for a kept .env without a Sync token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A PORT that isn't a port no longer strands a default-port localhost PUBLIC_URL when init --port or configure replaces it, and configure no longer pre-fills that value in the port prompt. - Remote init decides whether to print the sign-in steps from the .env on disk, so a token captured this run but not saved to a kept .env no longer hides them. - Comments listing the start checks now include PORT and every command that runs them. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 51 +++++++++++++++++++++ cli/src/__tests__/optional-settings.test.ts | 32 +++++++++++++ cli/src/configure.ts | 2 +- cli/src/init.ts | 9 ++-- cli/src/lifecycle.ts | 4 +- cli/src/optional-settings.ts | 16 +++++-- 6 files changed, 104 insertions(+), 10 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index e908f232e..c0bc4c1bc 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1116,6 +1116,57 @@ describe("runInit remote with a kept existing .env", () => { expect(scripted.prints[0]).toContain("https://persisted.example.com/mcp") expect(scripted.prints[0]).not.toContain("prompted.example.com") }) + + it("points at the sign-in page when the kept file has no Sync token, despite this run's capture", async () => { + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=existing\n" + + "OBSIDIAN_AUTH_TOKEN=\n" + + "VAULT_NAME=MyVault\n" + + "PUBLIC_URL=https://persisted.example.com\n", + ) + const fetchSigninSuccess: typeof fetch = async (input) => { + if (!String(input).includes("api.obsidian.md")) return new Response(null, { status: 200 }) + return new Response( + JSON.stringify({ token: "sync-token-xyz", name: "User", email: "user@example.com" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + const scripted = createScriptedPrompts([ + true, // existing deployment found — re-run setup anyway + "https://prompted.example.com", // public URL + "MyVault", // vault name + true, // generate the token now + "user@example.com", // email + "password", // password + false, // no end-to-end encryption + [], // no optional settings + false, // .env differs — keep the existing file + true, // start the server now + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerReadyEmptyHost, + fetchFn: fetchSigninSuccess, + }, + ) + + expect(exitCode).toBe(0) + expect(readFileSync(join(targetDir, ".env"), "utf8").split("\n")).toContain( + "OBSIDIAN_AUTH_TOKEN=", + ) + expect(scripted.prints[0]).toContain( + "The server is running.\n" + + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + + "syncing. Open it and sign in with the auth token below:\n" + + " https://persisted.example.com\n\n", + ) + }) }) describe("runInit sync-token auto-capture fallback", () => { diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 31b8b9278..6b10d12f3 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -125,6 +125,23 @@ describe("derivePublicUrlOverride", () => { }) }) + it("follows a PORT change from a value that isn't a port when PUBLIC_URL is on the default port", () => { + const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=80a0\n", { + PORT: "9000", + }) + expect(derived).toEqual({ + PORT: "9000", + PUBLIC_URL: "http://localhost:9000", + }) + }) + + it("leaves a default-port PUBLIC_URL beside a valid PORT on another port", () => { + const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=9100\n", { + PORT: "9200", + }) + expect(derived).toEqual({ PORT: "9200" }) + }) + it("never touches a custom PUBLIC_URL", () => { const derived = derivePublicUrlOverride("PUBLIC_URL=https://vault.example.com\nPORT=8000\n", { PORT: "9000", @@ -481,6 +498,21 @@ describe("askOptionalSettings per-setting prompts", () => { expect(overrides).toEqual({ PORT: "8000" }) }) + it("offers the default port when the PORT line is not a port", async () => { + const scripted = createScriptedPrompts([["PORT"], ""]) + + const overrides = await askOptionalSettings( + { mode: "local", envContent: "PORT=80a0\n" }, + scripted.prompts, + ) + + expect(scripted.textCalls).toEqual([ + { message: "Host port for the server:", defaultValue: "8000", placeholder: "8000" }, + ]) + expect(scripted.errors).toEqual([]) + expect(overrides).toEqual({ PORT: "8000" }) + }) + it("seeds the SYNC_MODE select with the current .env value", async () => { const scripted = createScriptedPrompts([["SYNC_MODE"], "bidirectional"]) diff --git a/cli/src/configure.ts b/cli/src/configure.ts index e20cbb310..2266c243c 100644 --- a/cli/src/configure.ts +++ b/cli/src/configure.ts @@ -35,7 +35,7 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): prompts.intro("vault-cortex configure") // Editing settings only needs an init'd .env — the full start validation - // (VAULT_PATH, PUBLIC_URL) runs later, only when a restart is requested. + // (PORT, VAULT_PATH, PUBLIC_URL) runs later, only when a restart is requested. const initialized = requireInitializedDir(flags.dir, prompts) if (!initialized) return 1 diff --git a/cli/src/init.ts b/cli/src/init.ts index 22c19b40f..6147a76a7 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -284,9 +284,9 @@ const reportWrites = ( } /** - * Reads the instance name from the .env on disk, which may be a kept file - * whose INSTANCE_NAME differs from this run's. Reports an invalid value and - * returns undefined, so init never starts or prints commands for a bad name. + * Reads the instance name and port from the .env on disk, which may be a kept + * file whose values differ from this run's. Reports an invalid value and + * returns undefined, so init never starts or prints commands for a bad name or port. */ const readInstanceFromDisk = ( targetDir: string, @@ -614,7 +614,8 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise token, publicUrl: effectivePublicUrl, startStatus, - obsidianTokenMissing: !hasSyncToken, + // From the file, like PUBLIC_URL: a token captured this run is not in a kept .env. + obsidianTokenMissing: !readEnvObsidianToken(join(targetDir, ".env")), tokenWritten, }), ) diff --git a/cli/src/lifecycle.ts b/cli/src/lifecycle.ts index 4a5fbcef9..1e24fd03f 100644 --- a/cli/src/lifecycle.ts +++ b/cli/src/lifecycle.ts @@ -105,7 +105,7 @@ const missingPublicUrlFix = (mode: Mode, port: number): string => { * Resolves --dir to the on-disk deployment and validates its .env for a * container start: mode detection, PORT, VAULT_PATH (local), and PUBLIC_URL. * Reports each failure via prompts.error and returns undefined. - * Shared by upgrade and restart so their precondition checks can't drift. + * Shared by start, restart, upgrade and configure so their precondition checks can't drift. */ export const resolveDeployment = ( dirFlag: string | undefined, @@ -268,7 +268,7 @@ export const runDown = async (flags: DownFlags, deps: DownDeps): Promise prompts.intro("vault-cortex down") // Teardown only needs to confirm this is an init'd directory — the full - // .env validation (VAULT_PATH, PUBLIC_URL) guards container starts. + // .env validation (PORT, VAULT_PATH, PUBLIC_URL) guards container starts. const initialized = requireInitializedDir(flags.dir, prompts) if (!initialized) return 1 diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 87b45ca15..a08526f28 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -229,8 +229,16 @@ export const derivePublicUrlOverride = ( // An empty PORT= line leaves the default port in effect, as readEnvPort reads it. const currentPort = readOptionalValue(envContent, "PORT") || String(DEFAULT_PORT) const currentPublicUrl = readOptionalValue(envContent, "PUBLIC_URL") + // A generated .env starts with PUBLIC_URL on the default port, so beside a + // PORT edited into a value that isn't a port, that URL follows too. + const followedPorts = isValidPort(currentPort) + ? [currentPort] + : [currentPort, String(DEFAULT_PORT)] + const publicUrlFollowsPort = followedPorts.some( + (followedPort) => currentPublicUrl === `http://localhost:${followedPort}`, + ) - if (currentPublicUrl !== `http://localhost:${currentPort}`) return overrides + if (!publicUrlFollowsPort) return overrides return { ...overrides, PUBLIC_URL: `http://localhost:${newPort}` } } @@ -282,8 +290,10 @@ export const askPort = async ( currentValue: string | undefined, prompts: Prompts, ): Promise => { - // An empty PORT= line leaves the default port in effect, as readEnvPort reads it. - const suggestedPort = currentValue || String(DEFAULT_PORT) + // An empty PORT= line leaves the default port in effect, as readEnvPort reads + // it; a value that isn't a port is never offered, since it would be rejected. + const suggestedPort = + currentValue && isValidPort(currentValue) ? currentValue : String(DEFAULT_PORT) const answer = ( await prompts.text("Host port for the server:", { defaultValue: suggestedPort, From 735024c553efe97aa478c0737a67145f4e620d68 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:57:21 -0400 Subject: [PATCH 37/57] fix(cli): check init's .env on disk the way start does, warn about a dropped Sync token, and warn when --port strands PUBLIC_URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - After writing .env, init runs start's own checks on the file on disk: a kept .env with no PUBLIC_URL, VAULT_PATH, or a valid PORT or INSTANCE_NAME stops init with start's error instead of starting a server that can't boot, and a local start mounts the kept file's vault folder, as start does. - Remote init warns when it kept an existing .env and so dropped the Sync token captured in this run, and prints the command that adds it. - A re-run whose --port leaves a custom PUBLIC_URL on the old port gets the same warning a port picked in the settings menu gives. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 121 +++++++++++++++++++++++++++++++-- cli/src/init.ts | 88 ++++++++++-------------- cli/src/instance-identity.ts | 20 ++++++ 3 files changed, 171 insertions(+), 58 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index c0bc4c1bc..d9218306b 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -951,7 +951,10 @@ describe("runInit with a kept existing .env", () => { const vaultDir = makeVault() const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=existing\n") + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${vaultDir}\nPUBLIC_URL=http://localhost:8000\n`, + ) const scripted = createScriptedPrompts(keepEnvAnswers(vaultDir, targetDir)) const exitCode = await runInit( @@ -975,7 +978,10 @@ describe("runInit with a kept existing .env", () => { const vaultDir = makeVault() const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=existing\nPORT=9000\n") + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${vaultDir}\nPORT=9000\nPUBLIC_URL=http://localhost:9000\n`, + ) const scripted = createScriptedPrompts([ ...keepEnvAnswers(vaultDir, targetDir), true, // start the server now @@ -999,6 +1005,55 @@ describe("runInit with a kept existing .env", () => { expect(fetchedUrls).toEqual(["http://127.0.0.1:9000/healthz"]) expect(scripted.prints[0]).toContain("http://localhost:9000/mcp") }) + + it("stops instead of starting a kept .env with no PUBLIC_URL, as start does", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync(join(targetDir, ".env"), `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${vaultDir}\n`) + const scripted = createScriptedPrompts(keepEnvAnswers(vaultDir, targetDir)) + const recording = createRecordingDocker() + + const exitCode = await runInit( + {}, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `PUBLIC_URL is empty or missing in ${targetDir}/.env — the server requires it.\n` + + "Set this line in your .env:\n PUBLIC_URL=http://localhost:8000", + ]) + expect(recording.runParams).toEqual([]) + expect(scripted.prints).toEqual([]) + }) + + it("mounts the kept file's vault folder, not this run's, as start does", async () => { + const promptedVaultDir = makeVault() + const keptVaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${keptVaultDir}\nPUBLIC_URL=http://localhost:8000\n`, + ) + const scripted = createScriptedPrompts([ + ...keepEnvAnswers(promptedVaultDir, targetDir), + true, // start the server now + ]) + const recording = createRecordingDocker() + const fetchHealthy: typeof fetch = async () => { + return new Response(null, { status: 200 }) + } + + const exitCode = await runInit( + {}, + { prompts: scripted.prompts, docker: recording.docker, fetchFn: fetchHealthy }, + ) + + expect(exitCode).toBe(0) + expect(recording.runParams.map((params) => params.vaultPath)).toEqual([keptVaultDir]) + }) }) describe("runInit --vault-path flag in interactive mode", () => { @@ -1117,8 +1172,10 @@ describe("runInit remote with a kept existing .env", () => { expect(scripted.prints[0]).not.toContain("prompted.example.com") }) - it("points at the sign-in page when the kept file has no Sync token, despite this run's capture", async () => { - const targetDir = makeTargetDir() + /** Captures a Sync token this run, then keeps an existing .env that has none, and starts. */ + const runInitCapturingTokenThenKeepingEnv = async ( + targetDir: string, + ): Promise<{ exitCode: number; scripted: ReturnType }> => { mkdirSync(targetDir, { recursive: true }) writeFileSync( join(targetDir, ".env"), @@ -1146,7 +1203,6 @@ describe("runInit remote with a kept existing .env", () => { false, // .env differs — keep the existing file true, // start the server now ]) - const exitCode = await runInit( { mode: "remote", dir: targetDir }, { @@ -1155,6 +1211,25 @@ describe("runInit remote with a kept existing .env", () => { fetchFn: fetchSigninSuccess, }, ) + return { exitCode, scripted } + } + + it("warns that this run's Sync token wasn't saved when the existing .env is kept", async () => { + const targetDir = makeTargetDir() + + const { exitCode, scripted } = await runInitCapturingTokenThenKeepingEnv(targetDir) + + expect(exitCode).toBe(0) + expect(scripted.warnings).toEqual([ + `The Sync token from this sign-in wasn't saved, because ${targetDir}/.env was kept. To add it, run:\n` + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, + ]) + }) + + it("points at the sign-in page when the kept file has no Sync token, despite this run's capture", async () => { + const targetDir = makeTargetDir() + + const { exitCode, scripted } = await runInitCapturingTokenThenKeepingEnv(targetDir) expect(exitCode).toBe(0) expect(readFileSync(join(targetDir, ".env"), "utf8").split("\n")).toContain( @@ -1302,7 +1377,8 @@ describe("runInit guided optional settings", () => { const vaultDir = makeVault() const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=existing\n") + const existingEnv = `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${vaultDir}\nPUBLIC_URL=http://localhost:8000\n` + writeFileSync(join(targetDir, ".env"), existingEnv) const scripted = createScriptedPrompts([ "local", vaultDir, @@ -1334,7 +1410,7 @@ describe("runInit guided optional settings", () => { ]) // Keeping at the conflict prompt still protects the file (the write // report states the discard); no configure-pointer log remains. - expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe("MCP_AUTH_TOKEN=existing\n") + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) }) it("applies chooser answers when a consented re-init overwrites the existing .env", async () => { @@ -2331,6 +2407,37 @@ describe("runInit re-run keeps the instance identity", () => { expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) }) + it("warns when --port moves a re-run off the port its custom PUBLIC_URL serves", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nPUBLIC_URL=https://vault.example.com\n`, + ) + // re-run anyway, no optional settings, overwrite .env, don't start + const scripted = createScriptedPrompts([true, [], true, false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithExisting([]), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.warnings).toEqual([ + "PORT changed — make sure PUBLIC_URL (https://vault.example.com) still reaches the server.", + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=https://vault.example.com", + "INSTANCE_NAME=vault-cortex", + "PORT=9000", + ]) + }) + it("lets --port replace a kept PORT that is not a port, moving the localhost PUBLIC_URL with it", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() diff --git a/cli/src/init.ts b/cli/src/init.ts index 6147a76a7..0f81d71b3 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -17,8 +17,12 @@ import { pollHealth, type DockerRunner, } from "./docker.js" -import { applyInstanceIdentity, resolveInstanceIdentity } from "./instance-identity.js" -import { reportPublicUrlProbe } from "./lifecycle.js" +import { + applyInstanceIdentity, + describeStrandedCarriedPublicUrl, + resolveInstanceIdentity, +} from "./instance-identity.js" +import { reportPublicUrlProbe, resolveDeployment } from "./lifecycle.js" import { applyOptionalSettings, askOptionalSettings, @@ -27,10 +31,7 @@ import { } from "./optional-settings.js" import { buildFilesToWrite, - readEnvInstanceName, readEnvObsidianToken, - readEnvPort, - readEnvPublicUrl, stripEnvQuotedValues, writeFiles, type FileWriteResult, @@ -283,30 +284,6 @@ const reportWrites = ( } } -/** - * Reads the instance name and port from the .env on disk, which may be a kept - * file whose values differ from this run's. Reports an invalid value and - * returns undefined, so init never starts or prints commands for a bad name or port. - */ -const readInstanceFromDisk = ( - targetDir: string, - prompts: Prompts, -): { instanceName: string; port: number } | undefined => { - const envFilePath = join(targetDir, ".env") - const instanceNameRead = readEnvInstanceName(envFilePath) - const portRead = readEnvPort(envFilePath) - - if (instanceNameRead.kind === "invalid") { - prompts.error(`${instanceNameRead.message} Fix it in ${targetDir}/.env.`) - return undefined - } - if (portRead.kind === "invalid") { - prompts.error(`${portRead.message} Fix it in ${targetDir}/.env.`) - return undefined - } - return { instanceName: instanceNameRead.instanceName, port: portRead.port } -} - /** * Offers to start the container, walking a gate ladder where each failed * gate degrades to instructions instead of an error: daemon running → user @@ -435,20 +412,19 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = // user asked for a full re-run — the answers land in the regenerated file // when they overwrite at the conflict prompt (keeping it discards them, // which the write report states). In-place edits stay configure's job. - const generatedEnvContent = applyInstanceIdentity( - buildLocalEnv({ mcpAuthToken: token, vaultPath }), - identityResult.identity, - ) + const templateEnvContent = buildLocalEnv({ mcpAuthToken: token, vaultPath }) + const generatedEnvContent = applyInstanceIdentity(templateEnvContent, identityResult.identity) const optionalOverrides = flags.yes ? {} : await askOptionalSettings({ mode: "local", envContent: generatedEnvContent }, prompts) const appliedOverrides = derivePublicUrlOverride(generatedEnvContent, optionalOverrides) const envContent = applyOptionalSettings(generatedEnvContent, appliedOverrides) - const publicUrlWarning = describeStrandedPublicUrl({ - envContent: generatedEnvContent, - pickedOverrides: optionalOverrides, - appliedOverrides, - }) + const publicUrlWarning = + describeStrandedPublicUrl({ + envContent: generatedEnvContent, + pickedOverrides: optionalOverrides, + appliedOverrides, + }) ?? describeStrandedCarriedPublicUrl(templateEnvContent, identityResult.identity) // Conflict policy: identical existing files are skipped silently; // differing ones prompt per file (default keep). --yes never overwrites — @@ -476,15 +452,22 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") // Only a written .env carries the picked port, so a kept one needs no warning. if (tokenWritten && publicUrlWarning) prompts.warn(publicUrlWarning) - const instanceOnDisk = readInstanceFromDisk(targetDir, prompts) + // The .env on disk decides what starts, with start's own checks: a kept + // file may hold another name, port, vault folder or PUBLIC_URL than this run's. + const deployment = resolveDeployment(targetDir, prompts) - if (!instanceOnDisk) return 1 - const { instanceName, port } = instanceOnDisk + if (!deployment) return 1 + const { instanceName, port } = deployment + // A kept remote .env has no VAULT_PATH, so this run's folder is mounted. + const mountedVaultPath = deployment.vaultPath ?? vaultPath // --yes is for scripts/CI, so it never starts Docker. const startStatus: StartStatus = flags.yes ? "not-started" - : await offerDockerRun({ targetDir, instanceName, port, mode: "local", vaultPath }, deps) + : await offerDockerRun( + { targetDir, instanceName, port, mode: "local", vaultPath: mountedVaultPath }, + deps, + ) prompts.print( buildLocalConnectMessage({ targetDir, @@ -585,15 +568,18 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise if (tokenWritten) prompts.log("Generated MCP auth token (saved to .env).") if (tokenWritten && publicUrlWarning) prompts.warn(publicUrlWarning) - const instanceOnDisk = readInstanceFromDisk(targetDir, prompts) - - if (!instanceOnDisk) return 1 - const { instanceName, port } = instanceOnDisk - // Like PORT above, PUBLIC_URL comes from the .env actually on disk — a kept - // existing file may hold a different URL than this run's prompt, and the - // server only reads the file. The prompted value is the fallback for a kept - // legacy .env that predates PUBLIC_URL. - const effectivePublicUrl = readEnvPublicUrl(join(targetDir, ".env")) ?? publicUrl + if (capturedToken && !tokenWritten) { + prompts.warn( + `The Sync token from this sign-in wasn't saved, because ${targetDir}/.env was kept. To add it, run:\n` + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, + ) + } + // Same on-disk checks as the local flow; the server reads only the file, + // so a kept file's PUBLIC_URL wins over this run's prompt. + const deployment = resolveDeployment(targetDir, prompts) + + if (!deployment) return 1 + const { instanceName, port, publicUrl: effectivePublicUrl } = deployment // Started without a Sync token in .env, the container keeps a login saved // on its volume, or serves its sign-in page; the closing message covers both. diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 04b8fb751..63c733489 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -6,6 +6,7 @@ import { applyOptionalSettings, askPort, derivePublicUrlOverride, + describeStrandedPublicUrl, readOptionalValue, } from "./optional-settings.js" import { @@ -309,3 +310,22 @@ export const applyInstanceIdentity = (envContent: string, identity: InstanceIden const withCarried = applyOptionalSettings(envContent, carriedSettings) return applyOptionalSettings(withCarried, derivePublicUrlOverride(withCarried, identity.chosen)) } + +/** + * Warns when --port moves a re-run off the port its carried PUBLIC_URL names: + * applyInstanceIdentity never rewrites a custom URL. Returns undefined + * when there is nothing to warn about. + */ +export const describeStrandedCarriedPublicUrl = ( + envContent: string, + identity: InstanceIdentity, +): string | undefined => { + if (!identity.carried.PUBLIC_URL) return undefined + const withCarried = applyOptionalSettings(envContent, identity.carried) + + return describeStrandedPublicUrl({ + envContent: withCarried, + pickedOverrides: identity.chosen, + appliedOverrides: derivePublicUrlOverride(withCarried, identity.chosen), + }) +} From 6d1adb3cd344e79dd4cb884bf3e33d989a382906 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:03:30 -0400 Subject: [PATCH 38/57] fix(cli): name init among resolveDeployment's callers and say when the --port PUBLIC_URL warning fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveDeployment's docstring lists init, which now runs start's .env checks. - describeStrandedCarriedPublicUrl's docstring states that any carried PUBLIC_URL that doesn't follow the new port warns, not only one that names a port. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/instance-identity.ts | 6 +++--- cli/src/lifecycle.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 63c733489..d31512ef6 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -312,9 +312,9 @@ export const applyInstanceIdentity = (envContent: string, identity: InstanceIden } /** - * Warns when --port moves a re-run off the port its carried PUBLIC_URL names: - * applyInstanceIdentity never rewrites a custom URL. Returns undefined - * when there is nothing to warn about. + * Warns when --port changes a re-run's port but its carried PUBLIC_URL stays: + * applyInstanceIdentity moves only a `http://localhost:` URL, never a + * custom one. Returns undefined when there is nothing to warn about. */ export const describeStrandedCarriedPublicUrl = ( envContent: string, diff --git a/cli/src/lifecycle.ts b/cli/src/lifecycle.ts index 1e24fd03f..1884326c0 100644 --- a/cli/src/lifecycle.ts +++ b/cli/src/lifecycle.ts @@ -105,7 +105,7 @@ const missingPublicUrlFix = (mode: Mode, port: number): string => { * Resolves --dir to the on-disk deployment and validates its .env for a * container start: mode detection, PORT, VAULT_PATH (local), and PUBLIC_URL. * Reports each failure via prompts.error and returns undefined. - * Shared by start, restart, upgrade and configure so their precondition checks can't drift. + * Shared by init, start, restart, upgrade and configure so their precondition checks can't drift. */ export const resolveDeployment = ( dirFlag: string | undefined, From 5261257ea5c4c8ced556c7843f1716f43de30038 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:05:13 -0400 Subject: [PATCH 39/57] fix(cli): skip the stranded-PUBLIC_URL warning when the localhost URL already names the new port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/optional-settings.test.ts | 10 ++++++++++ cli/src/optional-settings.ts | 3 +++ 2 files changed, 13 insertions(+) diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 6b10d12f3..a1129c5a4 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -192,6 +192,16 @@ describe("describeStrandedPublicUrl", () => { expect(warning).toBeUndefined() }) + + it("stays quiet when the localhost PUBLIC_URL already names the new port", () => { + const warning = describeStrandedPublicUrl({ + envContent: "PUBLIC_URL=http://localhost:9000\nPORT=8000\n", + pickedOverrides: { PORT: "9000" }, + appliedOverrides: { PORT: "9000" }, + }) + + expect(warning).toBeUndefined() + }) }) describe("askOptionalSettings chooser", () => { diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index a08526f28..d4734dcf1 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -258,8 +258,11 @@ export const describeStrandedPublicUrl = (params: { // Picking PORT and accepting its pre-filled current value changes nothing. const currentPort = readOptionalValue(envContent, "PORT") || String(DEFAULT_PORT) const portChanged = Boolean(pickedOverrides.PORT) && pickedOverrides.PORT !== currentPort + // A localhost URL that already names the new port still reaches the server. + const publicUrlNamesNewPort = currentPublicUrl === `http://localhost:${pickedOverrides.PORT}` if (!portChanged || appliedOverrides.PUBLIC_URL || !currentPublicUrl) return undefined + if (publicUrlNamesNewPort) return undefined return `PORT changed — make sure PUBLIC_URL (${currentPublicUrl}) still reaches the server.` } From 6297291634a52a05e858e89d5b8b8f4e5497a5cd Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:12:40 -0400 Subject: [PATCH 40/57] fix(cli): check a new install's port against the ports other containers publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init already refuses a name another install uses; it now does the same for the port. Docker lists every container's published host ports, stopped ones included: - the own-name prompt offers the first free port above 8000 and asks again for one that's taken, so a third install no longer gets 8001 - a taken --port or default port stops --yes and is asked again interactively - a reuse doesn't count the reused install's own container Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 10 ++- cli/src/__tests__/command-stubs.ts | 2 + cli/src/__tests__/docker.test.ts | 23 +++++ cli/src/__tests__/init.test.ts | 131 +++++++++++++++++++++++++++++ cli/src/docker.ts | 49 +++++++++++ cli/src/instance-identity.ts | 113 ++++++++++++++++++++++--- deploy/local/README.md | 4 +- deploy/remote/README.md | 4 +- 8 files changed, 314 insertions(+), 22 deletions(-) diff --git a/cli/README.md b/cli/README.md index 3f1228064..d636ca5fd 100644 --- a/cli/README.md +++ b/cli/README.md @@ -69,9 +69,10 @@ then shows a sign-in page at your `PUBLIC_URL`; set up HTTPS first, since the page sends your Obsidian password to the server. To run a second vault on the same machine, start Docker, so init can check -which names are in use, then run init again with its own `--dir`. When the -machine already has a `vault-cortex` container or data volume, init asks you -to give the new install its own name and port. That name +which names and ports are in use, then run init again with its own `--dir`. +When the machine already has a `vault-cortex` container or data volume, init +asks you to give the new install its own name and port, and offers the first +port no other container uses. That name (`INSTANCE_NAME` in its `.env`) names the install's container and data, and every command run with that `--dir` acts only on it. Each install gets its own connector in your MCP client. @@ -86,7 +87,8 @@ Flags: (default `vault-cortex`); lowercase letters, digits, `-` and `_`. Naming an install that already exists reuses its data, so use it only for the same vault set up again -- `--port ` — host port for this install (default `8000`) +- `--port ` — host port for this install (default `8000`); a port + another container already publishes is refused - `--yes` — non-interactive local setup with defaults; requires `--vault-path`, and `--instance-name` when the name is already in use diff --git a/cli/src/__tests__/command-stubs.ts b/cli/src/__tests__/command-stubs.ts index 5a83e14c6..eef202095 100644 --- a/cli/src/__tests__/command-stubs.ts +++ b/cli/src/__tests__/command-stubs.ts @@ -169,6 +169,7 @@ export const dockerReady: DockerRunner = { stopAndRemoveContainer: () => true, containerExists: () => true, volumeExists: () => true, + publishedHostPorts: () => new Map(), streamLogs: async () => 0, } @@ -227,6 +228,7 @@ export const dockerDown: DockerRunner = { stopAndRemoveContainer: () => false, containerExists: () => false, volumeExists: () => false, + publishedHostPorts: () => new Map(), streamLogs: async () => 1, } diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index 7e9ef3232..5fc7ba32f 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -8,6 +8,7 @@ import { healthTimeoutMessage, isContainerNamed, LOCAL_IMAGE, + parsePublishedHostPorts, pollHealth, probeHealth, REMOTE_IMAGE, @@ -456,6 +457,28 @@ describe("classifyDaemonStatus", () => { }) }) +describe("parsePublishedHostPorts", () => { + it("maps each published host port to its container, with or without a leading slash", () => { + const inspectOutput = [ + "/vault-cortex 8000", + "/work 8001 9443", + "podman-app 7000", + "/no-ports", + "/runtime-picked ", + "", + ].join("\n") + + expect(parsePublishedHostPorts(inspectOutput)).toEqual( + new Map([ + [8000, "vault-cortex"], + [8001, "work"], + [9443, "work"], + [7000, "podman-app"], + ]), + ) + }) +}) + describe("isContainerNamed", () => { it.each([ { label: "Docker's slash-prefixed name", inspectedName: "/2025\n" }, diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index d9218306b..d4d1258ec 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1686,6 +1686,17 @@ const dockerWithExisting = (existingNames: ReadonlyArray): DockerRunner } } +/** dockerWithExisting, plus host ports other containers publish. */ +const dockerWithPorts = (params: { + existingNames: ReadonlyArray + publishedPorts: ReadonlyArray +}): DockerRunner => { + return { + ...dockerWithExisting(params.existingNames), + publishedHostPorts: () => new Map(params.publishedPorts), + } +} + const readEnvLines = (targetDir: string): string[] => { return readFileSync(join(targetDir, ".env"), "utf8").split("\n") } @@ -1836,6 +1847,75 @@ describe("runInit when the instance name is already in use", () => { ]) }) + it("offers the first free port and asks again for one another container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "second", + "8001", // taken by the "work" container + "8003", + [], // no optional settings + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [ + [8000, "vault-cortex"], + [8001, "work"], + ], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls[1]).toEqual({ + message: PORT_QUESTION, + defaultValue: "8002", + placeholder: "8002", + }) + expect(scripted.errors).toEqual([ + 'Port 8001 is already used by the "work" container on this machine. Pick another port.', + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8003", + "INSTANCE_NAME=second", + "PORT=8003", + ]) + }) + + it("lets a reuse keep the port the reused install's own container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", "8001", [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [[8001, "vault-cortex"]], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=vault-cortex", + "PORT=8001", + ]) + }) + it.each([ { label: "the default port", port: "8000" }, { label: "another port", port: "8001" }, @@ -2036,6 +2116,57 @@ describe("runInit when the instance name is already in use", () => { }) }) +describe("runInit when another container publishes the new install's port", () => { + it("stops --yes on a --port another container publishes, before writing anything", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts() + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, yes: true, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ existingNames: [], publishedPorts: [[9000, "other-app"]] }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine. Re-run with --port and a free port.', + ]) + expect(existsSync(join(targetDir, ".env"))).toBe(false) + }) + + it("warns and asks for another port when the default one is taken", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["", [], false]) // accept the suggested port + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ existingNames: [], publishedPorts: [[8000, "other-app"]] }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.warnings).toEqual([ + 'Port 8000 is already used by the "other-app" container on this machine.', + ]) + expect(scripted.textCalls).toEqual([ + { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=vault-cortex", + "PORT=8001", + ]) + }) +}) + describe("runInit --yes when the instance name is already in use", () => { it("exits 1 without --instance-name and writes nothing", async () => { const vaultDir = makeVault() diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 2ee9b5061..6991dc686 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -74,6 +74,11 @@ export type DockerRunner = { containerExists: (instanceName: string) => boolean /** True when a named volume exists. */ volumeExists: (volumeName: string) => boolean + /** + * Host ports that existing containers publish, stopped ones included, each + * mapped to its container's name. Empty when the runtime can't list them. + */ + publishedHostPorts: () => ReadonlyMap /** * Streams `docker logs` to the terminal; resolves with the docker * process's exit code once the stream ends. @@ -195,6 +200,32 @@ export const isContainerNamed = (params: { return inspectedName.trim().replace(/^\//, "") === instanceName } +/** + * Go template that prints a container's name, then each host port it + * publishes: "/work 8001". PortBindings is the container's run setting, so a + * stopped container's ports print too. + */ +const PUBLISHED_PORTS_FORMAT = + "{{.Name}}{{range $containerPort, $bindings := .HostConfig.PortBindings}}{{range $bindings}} {{.HostPort}}{{end}}{{end}}" + +/** A published host port; an empty HostPort means the runtime picks one at start. */ +const HOST_PORT_PATTERN = /^\d+$/ + +/** Reads PUBLISHED_PORTS_FORMAT output into host port → container name. */ +export const parsePublishedHostPorts = (inspectOutput: string): ReadonlyMap => { + const portEntries = inspectOutput.split("\n").flatMap((line) => { + const [inspectedName, ...hostPorts] = line.trim().split(/\s+/) + + if (!inspectedName) return [] + // Docker prints the name with a leading slash; Podman and nerdctl print it bare. + const containerName = inspectedName.replace(/^\//, "") + return hostPorts + .filter((hostPort) => HOST_PORT_PATTERN.test(hostPort)) + .map((hostPort) => [Number(hostPort), containerName] as const) + }) + return new Map(portEntries) +} + export const createDockerRunner = (): DockerRunner => ({ daemonStatus: () => classifyDaemonStatus(spawnSync("docker", ["info"], { timeout: 5_000 })), // stdout is discarded: `docker run -d` prints only the container ID there, @@ -229,6 +260,24 @@ export const createDockerRunner = (): DockerRunner => ({ volumeExists: (volumeName) => { return spawnSync("docker", ["volume", "inspect", volumeName]).status === 0 }, + publishedHostPorts: () => { + const listResult = spawnSync("docker", ["container", "ls", "-aq"], { encoding: "utf8" }) + + if (listResult.status !== 0) return new Map() + const containerIds = listResult.stdout.split("\n").filter(Boolean) + + if (containerIds.length === 0) return new Map() + const inspectResult = spawnSync( + "docker", + ["container", "inspect", "--format", PUBLISHED_PORTS_FORMAT, ...containerIds], + { encoding: "utf8" }, + ) + + if (inspectResult.error) return new Map() + // Read whatever the exit status: a container removed after the listing + // makes inspect exit 1, but it still prints the others. + return parsePublishedHostPorts(inspectResult.stdout) + }, // Async spawn, not spawnSync: --follow streams until interrupted, and the // exit code must be observable after the stream closes. streamLogs: (params) => { diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index d31512ef6..ff8f54dc9 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -142,18 +142,82 @@ const nameCheckSkippedMessage = (instanceName: string): string => { ) } +type PortsInUse = ReadonlyMap + +type PortSettingResult = { kind: "ok"; settings: Record } | { kind: "exit" } + +const portInUseMessage = (port: string, containerName: string): string => { + return `Port ${port} is already used by the "${containerName}" container on this machine.` +} + +/** The first port above the default that no container publishes. */ +const suggestFreePort = (portsInUse: PortsInUse): string => { + const findFreePort = (candidatePort: number): number => { + return portsInUse.has(candidatePort) ? findFreePort(candidatePort + 1) : candidatePort + } + return String(findFreePort(DEFAULT_PORT + 1)) +} + +/** Re-prompts until the answer is a valid port no other container on the machine publishes. */ +const askFreePort = async ( + params: { suggestedPort: string; portsInUse: PortsInUse }, + prompts: Prompts, +): Promise => { + const answer = await askPort(params.suggestedPort, prompts) + const portUser = params.portsInUse.get(Number(answer)) + + if (!portUser) return answer + prompts.error(`${portInUseMessage(answer, portUser)} Pick another port.`) + return askFreePort(params, prompts) +} + +/** + * Checks the port a new install publishes, --port or the default, against the + * ports other containers publish: a taken port stops --yes, and is asked again + * otherwise. + */ +const checkRequestedPort = async ( + params: { flags: InstanceIdentityFlags; portsInUse: PortsInUse }, + prompts: Prompts, +): Promise => { + const { flags, portsInUse } = params + const requestedPort = flags.port ?? String(DEFAULT_PORT) + const portUser = portsInUse.get(Number(requestedPort)) + + if (!portUser) return { kind: "ok", settings: flags.port ? { PORT: flags.port } : {} } + const message = portInUseMessage(requestedPort, portUser) + + if (flags.yes) { + prompts.error(`${message} Re-run with --port and a free port.`) + return { kind: "exit" } + } + prompts.warn(message) + const port = await askFreePort( + { suggestedPort: suggestFreePort(portsInUse), portsInUse }, + prompts, + ) + return { kind: "ok", settings: { PORT: port } } +} + +/** Start replaces the reused install's container, so its own ports are free for this install. */ +const portsOfOtherContainers = (portsInUse: PortsInUse, containerName: string): PortsInUse => { + return new Map([...portsInUse].filter(([, portUser]) => portUser !== containerName)) +} + /** * A reused install's .env is in another folder, so its port is asked for * rather than assumed to be the default; --yes cannot ask and keeps --port * or the default. */ const askReusedPort = async ( - flags: InstanceIdentityFlags, + params: { flags: InstanceIdentityFlags; portsInUse: PortsInUse }, prompts: Prompts, -): Promise> => { - if (flags.port) return { PORT: flags.port } - if (flags.yes) return {} - return { PORT: await askPort(undefined, prompts) } +): Promise => { + const { flags, portsInUse } = params + + if (flags.port || flags.yes) return checkRequestedPort(params, prompts) + const port = await askFreePort({ suggestedPort: String(DEFAULT_PORT), portsInUse }, prompts) + return { kind: "ok", settings: { PORT: port } } } const nameInUseWithYesFlagMessage = (instanceName: string): string => { @@ -173,8 +237,9 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { * - --instance-name and --port set them directly; * - a new install whose name is already in use on the machine asks for its own * name and port, or confirms reuse of the existing data for the same vault. - * --yes cannot ask, so it exits unless --instance-name settles it. - * The check needs a running Docker daemon; without one it is skipped with a warning. + * --yes cannot ask, so it exits unless --instance-name settles it; + * - a new install's port must not be one another container publishes. + * The checks need a running Docker daemon; without one they are skipped with a warning. */ export const resolveInstanceIdentity = async ( params: { mode: Mode; targetDir: string; flags: InstanceIdentityFlags }, @@ -242,6 +307,7 @@ export const resolveInstanceIdentity = async ( if (!daemonRunning) prompts.warn(nameCheckSkippedMessage(requestedName)) const requestedNameInUse = daemonRunning && isInstanceNameInUse({ mode, instanceName: requestedName }, docker) + const portsInUse: PortsInUse = daemonRunning ? docker.publishedHostPorts() : new Map() // A new folder's .env starts from the template's device name, so a name // given by --instance-name sets it too, whether the name is new or reused. const flagSettings = { @@ -251,12 +317,24 @@ export const resolveInstanceIdentity = async ( // A name not in use, or one Docker could not check, starts a new install. if (!requestedNameInUse) { - return { kind: "ok", identity: { carried: {}, chosen: flagSettings } } + const portSetting = await checkRequestedPort({ flags, portsInUse }, prompts) + + if (portSetting.kind === "exit") return { kind: "exit" } + const chosen = { ...flagSettings, ...portSetting.settings } + return { kind: "ok", identity: { carried: {}, chosen } } } + const portsOutsideReusedInstall = portsOfOtherContainers(portsInUse, requestedName) + if (flags.instanceName) { prompts.log(`Reusing the data of the existing "${requestedName}" install.`) - const reusedPort = await askReusedPort(flags, prompts) - return { kind: "ok", identity: { carried: {}, chosen: { ...flagSettings, ...reusedPort } } } + const reusedPort = await askReusedPort( + { flags, portsInUse: portsOutsideReusedInstall }, + prompts, + ) + + if (reusedPort.kind === "exit") return { kind: "exit" } + const chosen = { ...flagSettings, ...reusedPort.settings } + return { kind: "ok", identity: { carried: {}, chosen } } } if (flags.yes) { prompts.error(nameInUseWithYesFlagMessage(requestedName)) @@ -281,14 +359,21 @@ export const resolveInstanceIdentity = async ( ) if (choice === "reuse") { - const reusedPort = await askReusedPort(flags, prompts) - return { kind: "ok", identity: { carried: {}, chosen: { ...flagOverrides, ...reusedPort } } } + const reusedPort = await askReusedPort( + { flags, portsInUse: portsOutsideReusedInstall }, + prompts, + ) + + if (reusedPort.kind === "exit") return { kind: "exit" } + const chosen = { ...flagOverrides, ...reusedPort.settings } + return { kind: "ok", identity: { carried: {}, chosen } } } const suggestedName = suggestInstanceName({ mode, targetDir }, docker) const instanceName = await askFreeInstanceName({ mode, suggestedName }, deps) // The install already using the name most likely holds the default port, so - // the next port is offered instead. - const port = await askPort(flags.port ?? String(DEFAULT_PORT + 1), prompts) + // the first free port above it is offered instead. + const suggestedPort = flags.port ?? suggestFreePort(portsInUse) + const port = await askFreePort({ suggestedPort, portsInUse }, prompts) const chosen = { INSTANCE_NAME: instanceName, PORT: port, diff --git a/deploy/local/README.md b/deploy/local/README.md index 2baff2eed..3306df7b9 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -245,8 +245,8 @@ search index, port and connector. Give every install after the first its own install's container and for the volume that holds its search index (`_mcp_data`); the first install keeps the default, `vault-cortex`. -**Set up with the CLI?** Start Docker, so `init` can check which names are -in use. Then, from the folder you ran `init` in the first time, run it again +**Set up with the CLI?** Start Docker, so `init` can check which names and +ports are in use. Then, from the folder you ran `init` in the first time, run it again with a new `--dir`: ```bash diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 2c6d1309f..d27f3cbf5 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -506,8 +506,8 @@ use the first install's volumes: the container refuses to start rather than merge the two vaults (see [Another vault's Sync setup](#another-vaults-sync-setup)). -**Set up with the CLI?** Start Docker, so `init` can check which names are -in use. Then, from the folder you ran `init` in the first time, run it again +**Set up with the CLI?** Start Docker, so `init` can check which names and +ports are in use. Then, from the folder you ran `init` in the first time, run it again with a new `--dir`: ```bash From 4133ec0a5f19d22e63a10f5c39d55b4e356355d7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:53:36 -0400 Subject: [PATCH 41/57] docs: point multi-vault setup at the settings reference and split the CLI init notes The Compose steps in the local and remote guides now name the settings a second install needs and link where each is described, instead of listing example values. The CLI README's init notes are split by subject: remote setups, re-running init, and running a second vault as numbered steps. Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 51 ++++++++++++++++++++++++++--------------- deploy/local/README.md | 17 ++++---------- deploy/remote/README.md | 16 ++++--------- 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/cli/README.md b/cli/README.md index d636ca5fd..6d0a4e701 100644 --- a/cli/README.md +++ b/cli/README.md @@ -57,25 +57,38 @@ What it does: 5. Prints your connection details — the MCP URL, your auth token, and how to connect your client -Re-running init where a setup already exists asks first — declining leaves -everything unchanged and points you at [`configure`](#configure), the right -tool for changing settings in place. A re-run keeps the setup's instance -name, and stops if `--instance-name` gives a different one. It keeps the -port too, unless you change it with `--port` or in the optional settings. -Existing files are never overwritten without asking. During a remote setup, -init offers to generate your [Obsidian Sync token](#get-sync-token) as part -of the flow. If you skip it, init still offers to start the server, which -then shows a sign-in page at your `PUBLIC_URL`; set up HTTPS first, since the -page sends your Obsidian password to the server. - -To run a second vault on the same machine, start Docker, so init can check -which names and ports are in use, then run init again with its own `--dir`. -When the machine already has a `vault-cortex` container or data volume, init -asks you to give the new install its own name and port, and offers the first -port no other container uses. That name -(`INSTANCE_NAME` in its `.env`) names the install's container and data, and -every command run with that `--dir` acts only on it. Each install gets its -own connector in your MCP client. +**Remote setups:** init offers to generate your +[Obsidian Sync token](#get-sync-token) during the flow. If you skip it, init +still offers to start the server, which then shows a sign-in page at your +`PUBLIC_URL`. Set up HTTPS first: the page sends your Obsidian password to the +server. + +**Re-running init** where a setup already exists: + +- Asks first. Declining leaves everything unchanged and points you to + [`configure`](#configure), the command for changing settings in place. +- Never overwrites existing files without asking. +- Keeps the install's name, and stops if `--instance-name` gives a different + one. +- Keeps the install's port, unless you change it with `--port` or in the + optional settings. + +**Running a second vault** on the same machine: + +1. Start Docker, so init can check which names and ports are in use. +2. Run init again with a new `--dir`: + + ```bash + npx vault-cortex@latest init --dir ./vault-cortex-work + ``` + +3. When the machine already has a `vault-cortex` container or data volume, + init asks for the new install's name and port, and offers the first port + no other container uses. The name is saved as `INSTANCE_NAME` in the + install's `.env` and names its container and data. +4. Pass that `--dir` to every command for the install, such as `upgrade` or + `logs`. Each command acts only on that install. +5. Add the install to your MCP client as its own connector. Flags: diff --git a/deploy/local/README.md b/deploy/local/README.md index 3306df7b9..645defd5f 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -265,18 +265,11 @@ npx vault-cortex@latest logs --dir ./vault-cortex-work ``` **Set up with Docker Compose?** Set up a new folder as in **Manual setup** -under [Setup](#setup), with its own `MCP_AUTH_TOKEN`, and also set these in -its `.env`: - -```dotenv -INSTANCE_NAME=work -PORT=8001 -PUBLIC_URL=http://localhost:8001 -VAULT_PATH=/Users/you/Documents/WorkVault -``` - -`PUBLIC_URL` must use the new port, or MCP clients can't sign in. Run each -install's `docker compose` commands from its own folder. +under [Setup](#setup), with its own `MCP_AUTH_TOKEN` and `VAULT_PATH`. In its +`.env`, also set a new `INSTANCE_NAME` and a free `PORT`, and point +`PUBLIC_URL` at that port, or MCP clients can't sign in. +[`.env.example`](./.env.example) describes each setting. Run each install's +`docker compose` commands from its own folder. For each install: diff --git a/deploy/remote/README.md b/deploy/remote/README.md index d27f3cbf5..be2e1236b 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -525,18 +525,10 @@ npx vault-cortex@latest logs --dir ./vault-cortex-work ``` **Set up with Docker Compose?** Set up a new folder as in **Manual setup** -under [Setup](#setup), with its own `MCP_AUTH_TOKEN`, and also set these in -its `.env`: - -```dotenv -INSTANCE_NAME=work -PORT=8001 -DEVICE_NAME=work -VAULT_NAME=Work -PUBLIC_URL=https://work.example.com -``` - -Run each install's `docker compose` commands from its own folder. +under [Setup](#setup), with its own `MCP_AUTH_TOKEN`, `VAULT_NAME` and +`PUBLIC_URL`. In its `.env`, also set a new `INSTANCE_NAME` and +`DEVICE_NAME` (see [Configuration](#configuration)) and a free `PORT`. Run +each install's `docker compose` commands from its own folder. **Set up with `docker run`?** Set up a new folder with its own `.env` as above, but leave `PORT` at `8000`, because the `docker run` command does not From 4e1eb1ee9826ac80d2b1de6ab1e3c88975d2482a Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:57:24 -0400 Subject: [PATCH 42/57] docs: point README readers who run more than one vault to the deploy guides Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index da50de2fb..eb5023b43 100644 --- a/README.md +++ b/README.md @@ -496,6 +496,8 @@ Every path runs the same image, `ghcr.io/aliasunder/vault-cortex` — `:latest` **Cost:** A remote setup needs a VPS or a hosted platform plan, plus $4 USD/mo for [Obsidian Sync](https://obsidian.md/sync). A 2 GiB instance handles semantic search fine for a typical vault; 4 GiB adds headroom for concurrent search and larger vaults. Skip semantic search entirely to go smaller still. Local-only is free. The [reference AWS deployment](./ARCHITECTURE.md#cost) runs ~$17–29 USD/mo all-in. +**More than one vault?** Each vault runs as its own install, with its own container, data and connector. On your own machine or server, give each install its own name: see **Run more than one vault** in the [local](./deploy/local/README.md#run-more-than-one-vault) and [remote](./deploy/remote/README.md#run-more-than-one-vault) guides. On Render or Railway, deploy a separate service for each vault. + ### One-click deploy Buttons and prerequisites are in [Quick Start → Remote](#remote-access-from-anywhere). Each guide walks through the deploy, where to find your URL and token, how to update, and how to delete: [`deploy/render/`](./deploy/render/) (from the [`render.yaml`](./render.yaml) Blueprint at the repo root) · [`deploy/railway/`](./deploy/railway/) (from a published template). From 7a3abce878cfe8bbbf22282663782d3b33b83f42 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:01:47 -0400 Subject: [PATCH 43/57] docs: spell out the full CLI commands for updating a second install Co-Authored-By: Claude Opus 5.5 --- deploy/local/README.md | 8 +++++--- deploy/remote/README.md | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/deploy/local/README.md b/deploy/local/README.md index 645defd5f..0b89d4cbf 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -276,9 +276,11 @@ For each install: - **Connect it as its own server** in your MCP client, under its own name and port. For Claude Code: `claude mcp add --scope user --transport http work http://localhost:8001/mcp`. -- **Update and restart it on its own:** `upgrade --dir` or `restart --dir` - with the CLI, or `docker compose pull && docker compose up -d` in its folder - with Compose. +- **Update and restart it on its own:** + - With the CLI: `npx vault-cortex@latest upgrade --dir ./vault-cortex-work` + to update, or `npx vault-cortex@latest restart --dir ./vault-cortex-work` + to restart. + - With Compose: `docker compose pull && docker compose up -d` in its folder. - **Use its name in `docker` commands.** The commands in this guide that name the container (`docker logs vault-cortex`, `docker restart vault-cortex`, `docker stop vault-cortex`) take the install's `INSTANCE_NAME` instead, for diff --git a/deploy/remote/README.md b/deploy/remote/README.md index be2e1236b..b87f7efb5 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -551,10 +551,12 @@ For each install: - **Connect it as its own server** in your MCP client, under its own name. For Claude Code: `claude mcp add --scope user --transport http work /mcp`. -- **Update and restart it on its own:** `upgrade --dir` or `restart --dir` - with the CLI, `docker compose pull && docker compose up -d` in its folder - with Compose, or the `docker run` steps under [Updating](#updating) with its - name. +- **Update and restart it on its own:** + - With the CLI: `npx vault-cortex@latest upgrade --dir ./vault-cortex-work` + to update, or `npx vault-cortex@latest restart --dir ./vault-cortex-work` + to restart. + - With Compose: `docker compose pull && docker compose up -d` in its folder. + - With `docker run`: the steps under [Updating](#updating), using its name. - **Use its name in `docker` commands.** The commands in this guide that name the container or its volumes (`docker logs vault-cortex`, `docker restart vault-cortex`, `docker rm -f vault-cortex`, From 1a3dc28465394fae73a2379079e28621c46d820e Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:05:16 -0400 Subject: [PATCH 44/57] docs: drop the advice to give each install its own DEVICE_NAME Each install syncs a different vault, so their changes never share a sync history and a shared device name has nothing to tell apart. The settings table row goes back to describing what the name labels. Co-Authored-By: Claude Opus 5.5 --- cli/src/env.ts | 5 ++--- cli/src/instance-identity.ts | 5 +---- deploy/railway/README.md | 3 +-- deploy/remote/.env.example | 5 ++--- deploy/remote/README.md | 16 +++++++--------- deploy/render/README.md | 3 +-- 6 files changed, 14 insertions(+), 23 deletions(-) diff --git a/cli/src/env.ts b/cli/src/env.ts index 5c1c260b3..d6bc9ef66 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -296,8 +296,8 @@ MEMORY_DIR=About Me # Name of this install's container, Compose project and data volumes # (_vault_data, _mcp_data and _obsidian_config). To run more -# than one vault on this server, give each install its own INSTANCE_NAME, PORT, -# PUBLIC_URL and DEVICE_NAME. +# than one vault on this server, give each install its own INSTANCE_NAME, PORT +# and PUBLIC_URL. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex @@ -352,7 +352,6 @@ PUID=1000 PGID=1000 # Device name the container reports to Obsidian Sync — labels its changes in the sync log. -# Give each install on a server its own, to tell their changes apart. DEVICE_NAME=vault-cortex # Obsidian Sync conflict resolution: merge | conflict (default: merge). diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index ff8f54dc9..1b297ddd9 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -116,10 +116,7 @@ const suggestInstanceName = ( return folderName } -/** - * A new remote install's Sync device takes the install's name, so Obsidian - * Sync's history tells its changes apart from the first install's. - */ +/** Labels a new remote install's changes in its vault's sync log with the install's name. */ const buildDeviceNameSetting = (mode: Mode, instanceName: string): Record => { return mode === "remote" ? { DEVICE_NAME: instanceName } : {} } diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 632720002..bfdac742e 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -361,8 +361,7 @@ names both vaults. Then do one of these: - To serve the other vault as well, deploy it as a separate Railway service with its own volume, and leave this one on its original vault. The new service has its own URL and `MCP_AUTH_TOKEN`, so connect it as its own - server in your MCP client. Give it its own `DEVICE_NAME` to tell the two - apart in Obsidian Sync's history. + server in your MCP client. - To switch this service to the new vault, delete the volume (**Settings → Volumes**), add a new one at `/persist`, then **Redeploy**. The container downloads the new vault like a first deploy, and the old diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index d0ed58597..55a6b1306 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -125,8 +125,8 @@ MEMORY_DIR=About Me # Name of this install's container, Compose project and data volumes # (_vault_data, _mcp_data and _obsidian_config). To run more -# than one vault on this server, give each install its own INSTANCE_NAME, PORT, -# PUBLIC_URL and DEVICE_NAME. +# than one vault on this server, give each install its own INSTANCE_NAME, PORT +# and PUBLIC_URL. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex @@ -181,7 +181,6 @@ PUID=1000 PGID=1000 # Device name the container reports to Obsidian Sync — labels its changes in the sync log. -# Give each install on a server its own, to tell their changes apart. DEVICE_NAME=vault-cortex # Obsidian Sync conflict resolution: merge | conflict (default: merge). diff --git a/deploy/remote/README.md b/deploy/remote/README.md index b87f7efb5..4eec1c25a 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -500,8 +500,7 @@ volumes, port, Obsidian Sync device and connector. Give every install after the first its own `INSTANCE_NAME` before its first start. Docker uses that name for the install's container and volumes (`_vault_data`, `_mcp_data` and `_obsidian_config`); the first install keeps the -default, `vault-cortex`. Give each install its own `DEVICE_NAME` too, so -Obsidian Sync's history tells their changes apart. A second vault must never +default, `vault-cortex`. A second vault must never use the first install's volumes: the container refuses to start rather than merge the two vaults (see [Another vault's Sync setup](#another-vaults-sync-setup)). @@ -515,9 +514,8 @@ npx vault-cortex@latest init --mode remote --dir ./vault-cortex-work ``` When this server already has a `vault-cortex` container or volumes, `init` -asks for the new install's name and port, for example `work` and `8001`, and -sets its `DEVICE_NAME` to that name. Then pass the same `--dir` to every -command for that install: +asks for the new install's name and port, for example `work` and `8001`. Then +pass the same `--dir` to every command for that install: ```bash npx vault-cortex@latest upgrade --dir ./vault-cortex-work @@ -526,9 +524,9 @@ npx vault-cortex@latest logs --dir ./vault-cortex-work **Set up with Docker Compose?** Set up a new folder as in **Manual setup** under [Setup](#setup), with its own `MCP_AUTH_TOKEN`, `VAULT_NAME` and -`PUBLIC_URL`. In its `.env`, also set a new `INSTANCE_NAME` and -`DEVICE_NAME` (see [Configuration](#configuration)) and a free `PORT`. Run -each install's `docker compose` commands from its own folder. +`PUBLIC_URL`. In its `.env`, also set a new `INSTANCE_NAME` (see +[Configuration](#configuration)) and a free `PORT`. Run each install's +`docker compose` commands from its own folder. **Set up with `docker run`?** Set up a new folder with its own `.env` as above, but leave `PORT` at `8000`, because the `docker run` command does not @@ -742,7 +740,7 @@ These optional settings are worth knowing about: | `DISABLED_TOOLS` | none hidden | Hide individual tools by name, comma-separated; names match the [README tools table](https://github.com/aliasunder/vault-cortex#tools) | | `SYNC_CONFIGS` | daily notes, plugins, and app | Obsidian settings categories synced to the server (see [Daily notes](#daily-notes)); `none` disables | | `INSTANCE_NAME` | `vault-cortex` | Names this install's container, Compose project and volumes. Set it before the first start to run another vault on the same server (see [Run more than one vault](#run-more-than-one-vault)) | -| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log; give each install on a server its own (see [Run more than one vault](#run-more-than-one-vault)) | +| `DEVICE_NAME` | `vault-cortex` | The device name that labels this container's changes in Obsidian's sync log | | `SYNC_MODE` | `bidirectional` | Sync direction: `bidirectional`, `pull-only` (edits made on the server stay on the server and are never uploaded), or `mirror-remote` (Obsidian Sync overwrites edits made on the server, so the server always matches your vault). An invalid value stops the container at boot. | | `CONFLICT_STRATEGY` | `merge` | Obsidian Sync conflict resolution: `merge` integrates changes automatically; `conflict` writes a separate conflict file. | | `SYNC_EXCLUDED_FOLDERS` | _(empty)_ | Folders to leave out of sync, comma-separated — the same list as Obsidian's Sync → Excluded folders. Empty excludes nothing. | diff --git a/deploy/render/README.md b/deploy/render/README.md index bbe28d371..36cca914d 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -333,8 +333,7 @@ names both vaults. Then do one of these: - To serve the other vault as well, deploy the Blueprint again for it, as a separate service with its own disk, and leave this one on its original vault. The new service has its own URL and `MCP_AUTH_TOKEN`, so connect it - as its own server in your MCP client. Give it its own `DEVICE_NAME` to tell - the two apart in Obsidian Sync's history. + as its own server in your MCP client. - To switch to the new vault, deploy the Blueprint again for it, then delete this service (see [Restart, stop, delete](#restart-stop-delete)). The new service downloads the new vault like a first deploy, and the old vault From c3958af9e8c83f04b6fd8b5db662715a2a54c4d4 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:08:26 -0400 Subject: [PATCH 45/57] docs: say that init suggests the first free port for a second install Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 2 +- deploy/local/README.md | 3 ++- deploy/remote/README.md | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cli/README.md b/cli/README.md index 6d0a4e701..7d274a4f4 100644 --- a/cli/README.md +++ b/cli/README.md @@ -83,7 +83,7 @@ server. ``` 3. When the machine already has a `vault-cortex` container or data volume, - init asks for the new install's name and port, and offers the first port + init asks for the new install's name and port, and suggests the first port no other container uses. The name is saved as `INSTANCE_NAME` in the install's `.env` and names its container and data. 4. Pass that `--dir` to every command for the install, such as `upgrade` or diff --git a/deploy/local/README.md b/deploy/local/README.md index 0b89d4cbf..68b86e389 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -255,7 +255,8 @@ npx vault-cortex@latest init --dir ./vault-cortex-work When this machine already has a `vault-cortex` container or search index volume, `init` asks for the new install's name and port, for example `work` -and `8001`, and points its `PUBLIC_URL` at that port. For a scripted setup, +and `8001`. It suggests the first port no other container uses, and points +the install's `PUBLIC_URL` at the port you pick. For a scripted setup, add `--yes --vault-path --instance-name work --port 8001`. Then pass the same `--dir` to every command for that install: diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 4eec1c25a..718cf2327 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -514,8 +514,9 @@ npx vault-cortex@latest init --mode remote --dir ./vault-cortex-work ``` When this server already has a `vault-cortex` container or volumes, `init` -asks for the new install's name and port, for example `work` and `8001`. Then -pass the same `--dir` to every command for that install: +asks for the new install's name and port, for example `work` and `8001`. It +suggests the first port no other container uses. Then pass the same `--dir` +to every command for that install: ```bash npx vault-cortex@latest upgrade --dir ./vault-cortex-work From d505f830e51b6b1b32e5ed4a718768f3819332e7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:22:17 -0400 Subject: [PATCH 46/57] docs: keep Compose commands out of the settings text the CLI copies into .env The optional settings text in each deploy .env.example is copied into the .env the CLI writes, where Compose wording points CLI users at a tool they never use. The shared text now names no setup method: settings apply when the container is re-created, and renaming an install starts with stopping its container. Co-Authored-By: Claude Opus 5.5 --- cli/src/env.ts | 30 +++++++++++++----------------- deploy/local/.env.example | 14 ++++++-------- deploy/remote/.env.example | 16 +++++++--------- 3 files changed, 26 insertions(+), 34 deletions(-) diff --git a/cli/src/env.ts b/cli/src/env.ts index d6bc9ef66..1d306432c 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -118,8 +118,7 @@ MEMORY_DIR=About Me # setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. -# To apply environment changes, run the CLI's restart command -# or recreate the Compose container. +# Environment changes apply when the container is re-created. # Obsidian settings apply on the next query; daily-notes.json is read # directly from your bind-mounted vault. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me @@ -128,16 +127,15 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork -# Name of this install's container, Compose project and search-index volume +# Name of this install's container and search-index volume # (_mcp_data). To run more than one vault on this machine, give each # install its own INSTANCE_NAME and PORT, with PUBLIC_URL on that port. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex -# Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex@latest down), then change it to a -# name no other install uses: the install starts on a new, empty index volume, -# and the old one stays until you remove it +# Set it before the first start. To change it later, stop the container first, +# then change it to a name no other install uses: the install starts on a new, +# empty index volume, and the old one stays until you remove it # (docker volume rm _mcp_data). INSTANCE_NAME=vault-cortex @@ -173,7 +171,7 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: none). # The container's own log (what \`docker logs\` shows) is always written, but # Docker discards it whenever the container is recreated — on image updates -# or compose changes. Set a path (e.g. /data/logs) to also write date-stamped +# or settings changes. Set a path (e.g. /data/logs) to also write date-stamped # log files there; the /data volume keeps them. LOG_DIR=none @@ -285,8 +283,7 @@ MEMORY_DIR=About Me # setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. -# To apply environment changes, run the CLI's restart command -# or recreate the Compose container. +# Environment changes apply when the container is re-created. # Obsidian settings apply on the next query once daily-notes.json reaches the server. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me @@ -294,18 +291,17 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork -# Name of this install's container, Compose project and data volumes +# Name of this install's container and data volumes # (_vault_data, _mcp_data and _obsidian_config). To run more # than one vault on this server, give each install its own INSTANCE_NAME, PORT # and PUBLIC_URL. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex -# Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex@latest down), then change it to a -# name no other install uses: the install starts on new, empty volumes and -# downloads the vault again as a new Obsidian Sync device, while the old -# volumes stay until you remove them. +# Set it before the first start. To change it later, stop the container first, +# then change it to a name no other install uses: the install starts on new, +# empty volumes and downloads the vault again as a new Obsidian Sync device, +# while the old volumes stay until you remove them. INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). @@ -340,7 +336,7 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: /data/logs). # The container's own log (what \`docker logs\` shows) is always written, but # Docker discards it whenever the container is recreated — on image updates -# or compose changes. Log files under LOG_DIR live on the data volume and +# or settings changes. Log files under LOG_DIR live on the data volume and # survive. Set to "none" to keep only the container log. LOG_DIR=/data/logs diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 67a04ed79..79bf0e3a6 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -108,8 +108,7 @@ MEMORY_DIR=About Me # setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. -# To apply environment changes, run the CLI's restart command -# or recreate the Compose container. +# Environment changes apply when the container is re-created. # Obsidian settings apply on the next query; daily-notes.json is read # directly from your bind-mounted vault. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me @@ -118,16 +117,15 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork -# Name of this install's container, Compose project and search-index volume +# Name of this install's container and search-index volume # (_mcp_data). To run more than one vault on this machine, give each # install its own INSTANCE_NAME and PORT, with PUBLIC_URL on that port. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex -# Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex@latest down), then change it to a -# name no other install uses: the install starts on a new, empty index volume, -# and the old one stays until you remove it +# Set it before the first start. To change it later, stop the container first, +# then change it to a name no other install uses: the install starts on a new, +# empty index volume, and the old one stays until you remove it # (docker volume rm _mcp_data). INSTANCE_NAME=vault-cortex @@ -163,7 +161,7 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: none). # The container's own log (what `docker logs` shows) is always written, but # Docker discards it whenever the container is recreated — on image updates -# or compose changes. Set a path (e.g. /data/logs) to also write date-stamped +# or settings changes. Set a path (e.g. /data/logs) to also write date-stamped # log files there; the /data volume keeps them. LOG_DIR=none diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index 55a6b1306..6b7d30e25 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -114,8 +114,7 @@ MEMORY_DIR=About Me # setting adds no daily folder, so those daily notes remain eligible. # When set, replaces the whole default — include every folder to keep excluded. # Set ORPHAN_EXCLUDE_FOLDERS=, to exclude nothing; an empty value uses the defaults. -# To apply environment changes, run the CLI's restart command -# or recreate the Compose container. +# Environment changes apply when the container is re-created. # Obsidian settings apply on the next query once daily-notes.json reaches the server. # ORPHAN_EXCLUDE_FOLDERS=Daily Notes,Templates,About Me @@ -123,18 +122,17 @@ MEMORY_DIR=About Me # (default: https://github.com/aliasunder/vault-cortex). # SERVICE_DOCUMENTATION_URL=https://github.com/youruser/your-fork -# Name of this install's container, Compose project and data volumes +# Name of this install's container and data volumes # (_vault_data, _mcp_data and _obsidian_config). To run more # than one vault on this server, give each install its own INSTANCE_NAME, PORT # and PUBLIC_URL. # Valid values: two or more lowercase letters, digits, hyphens or underscores, # starting with a letter or digit. # Default: vault-cortex -# Set it before the first start. To change it later, stop the container first -# (docker compose down, or npx vault-cortex@latest down), then change it to a -# name no other install uses: the install starts on new, empty volumes and -# downloads the vault again as a new Obsidian Sync device, while the old -# volumes stay until you remove them. +# Set it before the first start. To change it later, stop the container first, +# then change it to a name no other install uses: the install starts on new, +# empty volumes and downloads the vault again as a new Obsidian Sync device, +# while the old volumes stay until you remove them. INSTANCE_NAME=vault-cortex # Host port to expose (default: 8000). @@ -169,7 +167,7 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: /data/logs). # The container's own log (what `docker logs` shows) is always written, but # Docker discards it whenever the container is recreated — on image updates -# or compose changes. Log files under LOG_DIR live on the data volume and +# or settings changes. Log files under LOG_DIR live on the data volume and # survive. Set to "none" to keep only the container log. LOG_DIR=/data/logs From fe0912d8bf2ddf489c0e4c70094f645898776cbc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:48:52 -0400 Subject: [PATCH 47/57] fix(cli): refuse init over the other mode's install, and fix four review findings - init stops before asking anything when the folder's .env belongs to an install of the other mode, pointing at start or another --dir, so a re-run never writes or starts the other mode over it. - After a taken port is entered, the port prompt suggests the first free port, and a reuse whose default port another container holds suggests a free one instead of the taken default. - The sign-in lines name the page first, then say it asks for the auth token below, so the URL no longer reads as the token. - The boot warning about other vaults' Sync setups lists only setups stored for this vault folder, the only ones ob can pick. - The PTY tests' fake docker answers `container ls -aq` with the present names instead of failing. Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 7 +- cli/src/__tests__/init.test.ts | 133 +++++++++++++++++- cli/src/__tests__/integration/fixtures/docker | 11 +- cli/src/__tests__/messages.test.ts | 13 +- cli/src/init.ts | 30 ++++ cli/src/instance-identity.ts | 8 +- cli/src/messages.ts | 3 +- .../etc/s6-overlay/scripts/check-sync-vault | 7 +- .../__tests__/check-sync-vault.test.ts | 38 ++++- 9 files changed, 230 insertions(+), 20 deletions(-) diff --git a/cli/README.md b/cli/README.md index 7d274a4f4..f841893c4 100644 --- a/cli/README.md +++ b/cli/README.md @@ -65,8 +65,11 @@ server. **Re-running init** where a setup already exists: -- Asks first. Declining leaves everything unchanged and points you to - [`configure`](#configure), the command for changing settings in place. +- Stops when the folder holds an install of the other mode (a remote install + for a local setup, or the reverse), and points you to [`start`](#start). +- Otherwise asks before anything else. Declining leaves everything unchanged + and points you to [`configure`](#configure), the command for changing + settings in place. - Never overwrites existing files without asking. - Keeps the install's name, and stops if `--instance-name` gives a different one. diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index d4d1258ec..3c2e44671 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -666,8 +666,9 @@ describe("runInit remote flow", () => { expect(connectMessage).toContain( "The server is running.\n" + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + - "syncing. Open it and sign in with the auth token below:\n" + - " https://vault.example.com\n\n", + "syncing. Open the page:\n" + + " https://vault.example.com\n" + + "It asks for the auth token below and your Obsidian password.\n\n", ) }) @@ -867,7 +868,8 @@ describe("runInit re-init guard", () => { it("backs out of remote init before any mode-specific question when the dir already holds a deployment", async () => { const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) - writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=existing\n") + const existingEnv = "MCP_AUTH_TOKEN=existing\nOBSIDIAN_AUTH_TOKEN=\n" + writeFileSync(join(targetDir, ".env"), existingEnv) const scripted = createScriptedPrompts([ false, // existing deployment found — do not re-run setup (default) ]) @@ -896,7 +898,7 @@ describe("runInit re-init guard", () => { `Nothing changed. To adjust settings instead: npx vault-cortex@latest configure --dir "${targetDir}"`, ]) expect(scripted.outros).toEqual(["Done."]) - expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe("MCP_AUTH_TOKEN=existing\n") + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) }) it("backs out of local init after the dir prompt when it already holds a deployment", async () => { @@ -935,6 +937,63 @@ describe("runInit re-init guard", () => { expect(scripted.outros).toEqual(["Done."]) expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe("MCP_AUTH_TOKEN=existing\n") }) + + it("stops local init on a folder that holds a remote install, before asking anything", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const existingEnv = "MCP_AUTH_TOKEN=existing\nOBSIDIAN_AUTH_TOKEN=\nVAULT_NAME=Personal\n" + writeFileSync(join(targetDir, ".env"), existingEnv) + const dockerRun = vi.fn(() => true) + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReadyEmptyHost, dockerRun }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `${targetDir} holds a remote install, and this run sets up a local one.\n` + + `To start the existing install: npx vault-cortex@latest start --dir "${targetDir}"\n` + + "To set up a local install, run init again with another --dir.", + ]) + expect(scripted.asked).toEqual([]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + expect(dockerRun).not.toHaveBeenCalled() + }) + + it("stops remote init on a folder that holds a local install, before asking anything", async () => { + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const existingEnv = `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${makeVault()}\n` + writeFileSync(join(targetDir, ".env"), existingEnv) + const dockerRun = vi.fn(() => true) + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: { ...dockerReadyEmptyHost, dockerRun }, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + `${targetDir} holds a local install, and this run sets up a remote one.\n` + + `To start the existing install: npx vault-cortex@latest start --dir "${targetDir}"\n` + + "To set up a remote install, run init again with another --dir.", + ]) + expect(scripted.asked).toEqual([]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + expect(dockerRun).not.toHaveBeenCalled() + }) }) describe("runInit with a kept existing .env", () => { @@ -1238,8 +1297,9 @@ describe("runInit remote with a kept existing .env", () => { expect(scripted.prints[0]).toContain( "The server is running.\n" + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + - "syncing. Open it and sign in with the auth token below:\n" + - " https://persisted.example.com\n\n", + "syncing. Open the page:\n" + + " https://persisted.example.com\n" + + "It asks for the auth token below and your Obsidian password.\n\n", ) }) }) @@ -1890,6 +1950,67 @@ describe("runInit when the instance name is already in use", () => { ]) }) + it("suggests a free port after the answer is one another container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "own-name", + "second", + "9000", // the --port suggestion, taken by "other-app" + "8001", + [], // no optional settings + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [ + [8000, "vault-cortex"], + [9000, "other-app"], + ], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls.slice(1)).toEqual([ + { message: PORT_QUESTION, defaultValue: "9000", placeholder: "9000" }, + { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + ]) + expect(scripted.errors).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine. Pick another port.', + ]) + }) + + it("suggests the first free port for a reuse when another container publishes the default port", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts(["reuse", "8001", [], false]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [[8000, "other-app"]], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls).toEqual([ + { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + ]) + expect(scripted.errors).toEqual([]) + }) + it("lets a reuse keep the port the reused install's own container publishes", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() diff --git a/cli/src/__tests__/integration/fixtures/docker b/cli/src/__tests__/integration/fixtures/docker index b2288e327..b4ea52ad9 100755 --- a/cli/src/__tests__/integration/fixtures/docker +++ b/cli/src/__tests__/integration/fixtures/docker @@ -3,7 +3,8 @@ # Shadows the real docker when its directory is prepended to PATH. # Behavior is controllable via environment variables: # DOCKER_SHIM_PRESENT — space-separated container and volume names that -# exist; `container inspect` and `volume inspect` +# exist; `container ls` lists them, and +# `container inspect` and `volume inspect` # exit 1 for any other name (default: none exist) # DOCKER_SHIM_RUN_FAIL=1 — `run` exits 1 # DOCKER_SHIM_HEALTH_PORT — port for the background health server (default 8000) @@ -45,6 +46,14 @@ case "${1:-}" in ;; container) + # docker container ls -aq — one line per present name, standing in for the + # container IDs; the inspect below prints no ports for them + if [ "${2:-}" = "ls" ]; then + for present_name in ${DOCKER_SHIM_PRESENT:-}; do + echo "$present_name" + done + exit 0 + fi # docker container inspect --format '{{.Name}}' — prints the name # with Docker's leading slash when the container exists container_name="${!#}" diff --git a/cli/src/__tests__/messages.test.ts b/cli/src/__tests__/messages.test.ts index 7179eb8cb..4b19fa4aa 100644 --- a/cli/src/__tests__/messages.test.ts +++ b/cli/src/__tests__/messages.test.ts @@ -293,8 +293,9 @@ describe("buildRemoteConnectMessage", () => { expect(message).toContain( `Start the server:\n npx vault-cortex@latest start --dir "${remoteDefaults.targetDir}"\n` + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + - "syncing. Open it and sign in with the auth token below:\n" + - " https://vault.example.com\n\n", + "syncing. Open the page:\n" + + " https://vault.example.com\n" + + "It asks for the auth token below and your Obsidian password.\n\n", ) }) @@ -309,8 +310,9 @@ describe("buildRemoteConnectMessage", () => { expect(message).toContain( "The server is running.\n" + "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + - "syncing. Open it and sign in with the auth token below:\n" + - " https://vault.example.com\n\n", + "syncing. Open the page:\n" + + " https://vault.example.com\n" + + "It asks for the auth token below and your Obsidian password.\n\n", ) }) @@ -323,8 +325,9 @@ describe("buildRemoteConnectMessage", () => { }) expect(message).toContain( - "sign in with the auth token below:\n" + + "syncing. Open the page:\n" + " http://203.0.113.5:8000\n" + + "It asks for the auth token below and your Obsidian password.\n" + "Set up HTTPS first: the page sends your Obsidian password to the server.\n\n", ) }) diff --git a/cli/src/init.ts b/cli/src/init.ts index 0f81d71b3..f960bfb6b 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -31,6 +31,7 @@ import { } from "./optional-settings.js" import { buildFilesToWrite, + detectMode, readEnvObsidianToken, stripEnvQuotedValues, writeFiles, @@ -243,6 +244,23 @@ const confirmOverwrite = (name: string): Promise => prompts.confirm(`${name} already exists and differs — overwrite?`, false) +/** + * The refusal for a folder whose .env belongs to the other mode's install: + * start, restart and upgrade read the mode from that file, so a re-run must + * not write the other mode's settings over it or start the other image. + */ +const describeModeMismatch = (params: { targetDir: string; mode: Mode }): string | undefined => { + const { targetDir, mode } = params + const existingMode = detectMode(join(targetDir, ".env")) + + if (!existingMode || existingMode === mode) return undefined + return ( + `${targetDir} holds a ${existingMode} install, and this run sets up a ${mode} one.\n` + + `To start the existing install: ${startCommand(targetDir)}\n` + + `To set up a ${mode} install, run init again with another --dir.` + ) +} + /** * Re-init guard, fired the moment the target dir is known (prompt answer or * --dir flag): an existing .env there means a live deployment, so the flow @@ -390,6 +408,12 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = // expandTilde before resolve: resolve() treats a leading `~` as a literal // path segment, so a quoted "~/path" would create a directory named "~". const targetDir = resolve(expandTilde(requestedTargetDir)) + const modeMismatch = describeModeMismatch({ targetDir, mode: "local" }) + + if (modeMismatch) { + prompts.error(modeMismatch) + return 1 + } // --yes skips the guard: it's non-interactive by contract, and its own // conflict policy (refuse to overwrite, exit 1) already protects the dir. @@ -493,6 +517,12 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise // expandTilde before resolve: resolve() treats a leading `~` as a literal // path segment, so a quoted "~/path" would create a directory named "~". const targetDir = resolve(expandTilde(requestedTargetDir)) + const modeMismatch = describeModeMismatch({ targetDir, mode: "remote" }) + + if (modeMismatch) { + prompts.error(modeMismatch) + return 1 + } const continueReinit = await confirmReinitOverExistingEnv(targetDir, prompts) diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 1b297ddd9..bf7290829 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -165,7 +165,8 @@ const askFreePort = async ( if (!portUser) return answer prompts.error(`${portInUseMessage(answer, portUser)} Pick another port.`) - return askFreePort(params, prompts) + // A free suggestion, so pressing enter can't offer the taken port again. + return askFreePort({ ...params, suggestedPort: suggestFreePort(params.portsInUse) }, prompts) } /** @@ -213,7 +214,10 @@ const askReusedPort = async ( const { flags, portsInUse } = params if (flags.port || flags.yes) return checkRequestedPort(params, prompts) - const port = await askFreePort({ suggestedPort: String(DEFAULT_PORT), portsInUse }, prompts) + const suggestedPort = portsInUse.has(DEFAULT_PORT) + ? suggestFreePort(portsInUse) + : String(DEFAULT_PORT) + const port = await askFreePort({ suggestedPort, portsInUse }, prompts) return { kind: "ok", settings: { PORT: port } } } diff --git a/cli/src/messages.ts b/cli/src/messages.ts index 6ee2d2d7c..72d648c04 100644 --- a/cli/src/messages.ts +++ b/cli/src/messages.ts @@ -101,7 +101,8 @@ const isHttpsUrl = (url: string): boolean => url.toLowerCase().startsWith("https const syncSignInLines = (publicUrl: string): string => { const signInLines = "Until it's signed in to Obsidian Sync, it shows a sign-in page instead of\n" + - `syncing. Open it and sign in with the auth token below:\n ${publicUrl}` + `syncing. Open the page:\n ${publicUrl}\n` + + "It asks for the auth token below and your Obsidian password." if (isHttpsUrl(publicUrl)) return signInLines return `${signInLines}\nSet up HTTPS first: the page sends your Obsidian password to the server.` diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index 8c77b86d4..63d01fd0e 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -181,12 +181,17 @@ fi if [ "$PICKED_ID" = "$REQUESTED_ID" ]; then # A second setup for another vault is dormant while the engine picks this # one, so the boot continues; removing it keeps it from being picked later. + # Only setups whose path is this folder are listed, since ob never picks one + # stored for another folder. # The listing only feeds this warning, so a failed listing is reported as a # warning instead of stopping a boot the checks above already passed. if ! LOCAL_SETUPS=$(run_ob sync-list-local --json 2>/dev/null) || ! OTHER_IDS=$(printf '%s' "$LOCAL_SETUPS" | REQUESTED_ID="$REQUESTED_ID" node --no-warnings -e ' + const { resolve } = require("node:path") const listed = JSON.parse(require("node:fs").readFileSync(0, "utf8")) - const otherIds = listed.vaults.map((vault) => vault.id).filter((id) => id !== process.env.REQUESTED_ID) + const isOtherSetupForThisFolder = (vault) => + vault.id !== process.env.REQUESTED_ID && resolve(vault.path) === resolve(".") + const otherIds = listed.vaults.filter(isOtherSetupForThisFolder).map((vault) => vault.id) if (otherIds.length > 0) console.log(otherIds.join(" ")) ' 2>/dev/null); then echo "[obsidian-sync] WARNING: Could not list this install's Sync setups, so a setup for another vault would not be reported here." >&2 diff --git a/src/vault-mcp/__tests__/check-sync-vault.test.ts b/src/vault-mcp/__tests__/check-sync-vault.test.ts index dd36e5410..dd7adca13 100644 --- a/src/vault-mcp/__tests__/check-sync-vault.test.ts +++ b/src/vault-mcp/__tests__/check-sync-vault.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, readdirSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from "node:fs" @@ -56,10 +57,17 @@ const pickedConfig = (vaultId: string, vaultName: string): string => { ) } +/** + * Stands for the run's vault folder in a listing. runCheck swaps in the + * folder's real path, the form ob sync-setup stores, since the temp folder + * sits under a symlink on macOS. + */ +const RUN_VAULT_FOLDER = "" + /** `ob sync-list-local --json` output: one row per setup, raw stored path. */ const localSetups = (vaultIds: ReadonlyArray): string => { return JSON.stringify({ - vaults: vaultIds.map((id) => ({ id, path: "/vault", host: "sync-1.obsidian.md" })), + vaults: vaultIds.map((id) => ({ id, path: RUN_VAULT_FOLDER, host: "sync-1.obsidian.md" })), }) } @@ -172,6 +180,11 @@ const runCheck = (options: CheckOptions): CheckRun => { writeFileSync(cwdLogPath, "") if (options.syncRootUnreadable) chmodSync(syncRoot, 0o000) + const storedVaultPath = options.vaultPathMissing ? vaultPath : realpathSync(vaultPath) + const localJson = (options.localJson ?? localSetups([])).replaceAll( + RUN_VAULT_FOLDER, + storedVaultPath, + ) const result = spawnSync("sh", [SCRIPT_PATH], { encoding: "utf8", @@ -186,7 +199,7 @@ const runCheck = (options: CheckOptions): CheckRun => { OB_CONFIG_JSON: options.configJson ?? "", OB_CONFIG_EXIT: String(options.configExit), OB_CONFIG_STDERR: options.configStderr ?? "", - OB_LOCAL_JSON: options.localJson ?? localSetups([]), + OB_LOCAL_JSON: localJson, OB_LOCAL_EXIT: String(options.localExit ?? 0), OB_CWD_LOG: cwdLogPath, ...(options.storageRoot ? { STORAGE_ROOT: options.storageRoot } : {}), @@ -475,6 +488,27 @@ describe("check-sync-vault", () => { expect(run.stdout).toBe("aaa111\n") }) + it("leaves out another vault's setup stored for a different folder, which ob never picks here", () => { + const run = runCheck({ + vaultName: "Personal", + remoteJson: remoteVaults([VAULT_A, VAULT_B]), + configJson: pickedConfig(VAULT_A.id, VAULT_A.name), + configExit: 0, + localJson: JSON.stringify({ + vaults: [ + { id: VAULT_A.id, path: RUN_VAULT_FOLDER, host: "sync-1.obsidian.md" }, + { id: VAULT_B.id, path: "/old-vault", host: "sync-1.obsidian.md" }, + ], + }), + }) + + expect(run.status).toBe(0) + expect(run.stderr).toBe( + "[obsidian-sync] Vault check: continue — set up for 'Personal' (aaa111).\n", + ) + expect(run.stdout).toBe("aaa111\n") + }) + it.each([ { label: "the listing fails", localJson: "", localExit: 1 }, { label: "the listing is not JSON", localJson: "Fetching vaults...", localExit: 0 }, From b1e3cf83c2040416193401270706fb1e298963ee Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:09:04 -0400 Subject: [PATCH 48/57] test(cli): pin the remote daemon-down start gate instead of a removed blank-token skip Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 3c2e44671..7212286ba 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -773,10 +773,10 @@ describe("runInit remote flow", () => { expect(envContent.split("\n")).toContain("OBSIDIAN_AUTH_TOKEN=captured-token") }) - it("skips the start offer when the sync token was left blank", async () => { + it("warns and skips the start offer when the Docker daemon is down", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ - "http://203.0.113.10:8000", + "https://vault.example.com", "MyVault", false, // don't generate the token now (declined auto-capture) false, // no encryption @@ -794,7 +794,12 @@ describe("runInit remote flow", () => { expect(exitCode).toBe(0) expect(scripted.asked).not.toContain("Start the server now?") - expect(readFileSync(join(targetDir, ".env"), "utf8")).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) + expect(scripted.warnings).toEqual([ + nameCheckSkippedWarning("vault-cortex"), + buildDaemonNotRunningMessage( + `, then run:\n npx vault-cortex@latest start --dir "${targetDir}"`, + ), + ]) }) it("preserves existing token and offers start when capture is declined on re-init", async () => { From f0689321a9771d8df409cd97fd0044cf0880916d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:32:41 -0400 Subject: [PATCH 49/57] fix(cli): refuse a port another container publishes on init re-runs and in the settings list - A re-run's --port gets the same check as a new install's port, with the install's own container left out, so --yes stops and an interactive run asks again before .env is written. - The port picked in the optional-settings list, in init and configure, is refused when another container publishes it; the install's own port is accepted. The port-collision helpers move beside the port prompt they build on. Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/configure.test.ts | 35 +++++++++ cli/src/__tests__/init.test.ts | 106 ++++++++++++++++++++++++++++ cli/src/configure.ts | 12 +++- cli/src/init.ts | 11 ++- cli/src/instance-identity.ts | 82 ++++++++++----------- cli/src/optional-settings.ts | 58 ++++++++++++--- 6 files changed, 244 insertions(+), 60 deletions(-) diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index bd8294a47..a5c899bbd 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -223,6 +223,41 @@ describe("runConfigure with picked settings", () => { expect(scripted.outros).toEqual(["Configure complete."]) }) + it("asks again for a PORT another container publishes, and accepts this install's own", async () => { + const targetDir = makeTempTargetDir() + const envFilePath = writeLocalEnv(targetDir) + const docker: DockerRunner = { + ...createRecordingDocker().docker, + publishedHostPorts: () => { + return new Map([ + [8000, "vault-cortex"], + [9100, "other-app"], + ]) + }, + } + const scripted = createScriptedPrompts([ + ["PORT"], + "9100", // taken by "other-app" + "8000", // this install's own port + false, // decline the restart + ]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([ + 'Port 9100 is already used by the "other-app" container on this machine. Pick another port.', + ]) + expect(scripted.textCalls).toEqual([ + { message: "Host port for the server:", defaultValue: "8000", placeholder: "8000" }, + { message: "Host port for the server:", defaultValue: "8001", placeholder: "8001" }, + ]) + expect(readFileSync(envFilePath, "utf8")).toBe(LOCAL_ENV_CONTENT) + }) + it("appends SYNC_MODE on a remote .env that predates the setting", async () => { const targetDir = makeTempTargetDir() const envFilePath = join(targetDir, ".env") diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 7212286ba..24a3d97d3 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -2016,6 +2016,112 @@ describe("runInit when the instance name is already in use", () => { expect(scripted.errors).toEqual([]) }) + it("asks again when a re-run's --port is one another container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\n`, + ) + const scripted = createScriptedPrompts([ + true, // re-run setup + "8001", // the suggestion: the install's own port + [], // no optional settings + true, // overwrite .env + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, port: "9000" }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["work"], + publishedPorts: [ + [8001, "work"], + [9000, "other-app"], + ], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.warnings).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine.', + ]) + expect(scripted.textCalls).toEqual([ + { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("stops --yes on a re-run whose --port another container publishes, before writing", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const existingEnv = `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nINSTANCE_NAME=work\nPORT=8001\n` + writeFileSync(join(targetDir, ".env"), existingEnv) + const scripted = createScriptedPrompts([]) + + const exitCode = await runInit( + { vaultPath: vaultDir, dir: targetDir, port: "9000", yes: true }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ + existingNames: ["work"], + publishedPorts: [[9000, "other-app"]], + }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine. Re-run with --port and a free port.', + ]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) + }) + + it("asks again when the port picked in the optional settings is one another container publishes", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + ["PORT"], + "9000", // taken by "other-app" + "8001", + false, // don't start now + ]) + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ existingNames: [], publishedPorts: [[9000, "other-app"]] }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.textCalls).toEqual([ + { message: PORT_QUESTION, defaultValue: "8000", placeholder: "8000" }, + { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + ]) + expect(scripted.errors).toEqual([ + 'Port 9000 is already used by the "other-app" container on this machine. Pick another port.', + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=vault-cortex", + "PORT=8001", + ]) + }) + it("lets a reuse keep the port the reused install's own container publishes", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() diff --git a/cli/src/configure.ts b/cli/src/configure.ts index 2266c243c..82a2659a6 100644 --- a/cli/src/configure.ts +++ b/cli/src/configure.ts @@ -6,7 +6,9 @@ import { askOptionalSettings, derivePublicUrlOverride, describeStrandedPublicUrl, + portsOfOtherContainers, } from "./optional-settings.js" +import { readEnvInstanceName } from "./scaffold.js" import type { DockerRunner } from "./docker.js" import type { Prompts } from "./prompts.js" @@ -42,7 +44,14 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): const { targetDir, envFilePath, mode } = initialized const envContent = readFileSync(envFilePath, "utf8") - const pickedOverrides = await askOptionalSettings({ mode, envContent }, prompts) + const daemonStatus = docker.daemonStatus() + const instanceNameRead = readEnvInstanceName(envFilePath) + // An invalid name stops the restart later, so its ports go unchecked here. + const portsInUse = + daemonStatus === "running" && instanceNameRead.kind === "ok" + ? portsOfOtherContainers(docker.publishedHostPorts(), instanceNameRead.instanceName) + : new Map() + const pickedOverrides = await askOptionalSettings({ mode, envContent, portsInUse }, prompts) if (Object.keys(pickedOverrides).length === 0) { // Covers both empty-overrides paths: nothing picked in the chooser, and @@ -67,7 +76,6 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): if (publicUrlWarning) prompts.warn(publicUrlWarning) const restartHint = `Apply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"` - const daemonStatus = docker.daemonStatus() if (daemonStatus !== "running") { // Settings are already saved — the runtime state only affects the restart diff --git a/cli/src/init.ts b/cli/src/init.ts index f960bfb6b..277017f14 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -440,7 +440,14 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = const generatedEnvContent = applyInstanceIdentity(templateEnvContent, identityResult.identity) const optionalOverrides = flags.yes ? {} - : await askOptionalSettings({ mode: "local", envContent: generatedEnvContent }, prompts) + : await askOptionalSettings( + { + mode: "local", + envContent: generatedEnvContent, + portsInUse: identityResult.portsInUse, + }, + prompts, + ) const appliedOverrides = derivePublicUrlOverride(generatedEnvContent, optionalOverrides) const envContent = applyOptionalSettings(generatedEnvContent, appliedOverrides) const publicUrlWarning = @@ -575,7 +582,7 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise identityResult.identity, ) const optionalOverrides = await askOptionalSettings( - { mode: "remote", envContent: generatedEnvContent }, + { mode: "remote", envContent: generatedEnvContent, portsInUse: identityResult.portsInUse }, prompts, ) const appliedOverrides = derivePublicUrlOverride(generatedEnvContent, optionalOverrides) diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index bf7290829..60ff7d7a3 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -4,10 +4,14 @@ import { basename, join } from "node:path" import { instanceVolumeNames, type DockerRunner } from "./docker.js" import { applyOptionalSettings, - askPort, + askFreePort, derivePublicUrlOverride, describeStrandedPublicUrl, + portInUseMessage, + portsOfOtherContainers, readOptionalValue, + suggestFreePort, + type PortsInUse, } from "./optional-settings.js" import { DEFAULT_INSTANCE_NAME, @@ -39,7 +43,14 @@ export type InstanceIdentityFlags = { yes?: boolean } -type InstanceIdentityResult = { kind: "ok"; identity: InstanceIdentity } | { kind: "exit" } +type InstanceIdentityResult = + | { + kind: "ok" + identity: InstanceIdentity + /** Ports other containers publish, which the optional settings' port prompt refuses. */ + portsInUse: PortsInUse + } + | { kind: "exit" } /** * Identity lines an existing install keeps through a re-run of init. A local @@ -139,40 +150,12 @@ const nameCheckSkippedMessage = (instanceName: string): string => { ) } -type PortsInUse = ReadonlyMap - type PortSettingResult = { kind: "ok"; settings: Record } | { kind: "exit" } -const portInUseMessage = (port: string, containerName: string): string => { - return `Port ${port} is already used by the "${containerName}" container on this machine.` -} - -/** The first port above the default that no container publishes. */ -const suggestFreePort = (portsInUse: PortsInUse): string => { - const findFreePort = (candidatePort: number): number => { - return portsInUse.has(candidatePort) ? findFreePort(candidatePort + 1) : candidatePort - } - return String(findFreePort(DEFAULT_PORT + 1)) -} - -/** Re-prompts until the answer is a valid port no other container on the machine publishes. */ -const askFreePort = async ( - params: { suggestedPort: string; portsInUse: PortsInUse }, - prompts: Prompts, -): Promise => { - const answer = await askPort(params.suggestedPort, prompts) - const portUser = params.portsInUse.get(Number(answer)) - - if (!portUser) return answer - prompts.error(`${portInUseMessage(answer, portUser)} Pick another port.`) - // A free suggestion, so pressing enter can't offer the taken port again. - return askFreePort({ ...params, suggestedPort: suggestFreePort(params.portsInUse) }, prompts) -} - /** - * Checks the port a new install publishes, --port or the default, against the - * ports other containers publish: a taken port stops --yes, and is asked again - * otherwise. + * Checks the port the install will publish (--port, or the default) against + * the ports other containers publish: a taken port stops --yes, and is asked + * again otherwise. */ const checkRequestedPort = async ( params: { flags: InstanceIdentityFlags; portsInUse: PortsInUse }, @@ -197,11 +180,6 @@ const checkRequestedPort = async ( return { kind: "ok", settings: { PORT: port } } } -/** Start replaces the reused install's container, so its own ports are free for this install. */ -const portsOfOtherContainers = (portsInUse: PortsInUse, containerName: string): PortsInUse => { - return new Map([...portsInUse].filter(([, portUser]) => portUser !== containerName)) -} - /** * A reused install's .env is in another folder, so its port is asked for * rather than assumed to be the default; --yes cannot ask and keeps --port @@ -239,8 +217,10 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { * - a new install whose name is already in use on the machine asks for its own * name and port, or confirms reuse of the existing data for the same vault. * --yes cannot ask, so it exits unless --instance-name settles it; - * - a new install's port must not be one another container publishes. - * The checks need a running Docker daemon; without one they are skipped with a warning. + * - a new install's port, and a re-run's --port, must not be one another + * container publishes. + * The checks need a running Docker daemon; without one a new install's are + * skipped with a warning, and a re-run's --port is not checked. */ export const resolveInstanceIdentity = async ( params: { mode: Mode; targetDir: string; flags: InstanceIdentityFlags }, @@ -297,7 +277,19 @@ export const resolveInstanceIdentity = async ( prompts.error(renameOnRerunMessage({ carriedName, targetDir })) return { kind: "exit" } } - return { kind: "ok", identity: { carried, chosen: flagOverrides } } + const daemonRunning = docker.daemonStatus() === "running" + const portsInUse = portsOfOtherContainers( + daemonRunning ? docker.publishedHostPorts() : new Map(), + carriedName, + ) + // A kept port is the install's own; only a new --port can collide. + const portSetting: PortSettingResult = flags.port + ? await checkRequestedPort({ flags, portsInUse }, prompts) + : { kind: "ok", settings: {} } + + if (portSetting.kind === "exit") return { kind: "exit" } + const chosen = { ...flagOverrides, ...portSetting.settings } + return { kind: "ok", identity: { carried, chosen }, portsInUse } } const requestedName = flags.instanceName ?? DEFAULT_INSTANCE_NAME @@ -322,7 +314,7 @@ export const resolveInstanceIdentity = async ( if (portSetting.kind === "exit") return { kind: "exit" } const chosen = { ...flagSettings, ...portSetting.settings } - return { kind: "ok", identity: { carried: {}, chosen } } + return { kind: "ok", identity: { carried: {}, chosen }, portsInUse } } const portsOutsideReusedInstall = portsOfOtherContainers(portsInUse, requestedName) @@ -335,7 +327,7 @@ export const resolveInstanceIdentity = async ( if (reusedPort.kind === "exit") return { kind: "exit" } const chosen = { ...flagSettings, ...reusedPort.settings } - return { kind: "ok", identity: { carried: {}, chosen } } + return { kind: "ok", identity: { carried: {}, chosen }, portsInUse: portsOutsideReusedInstall } } if (flags.yes) { prompts.error(nameInUseWithYesFlagMessage(requestedName)) @@ -367,7 +359,7 @@ export const resolveInstanceIdentity = async ( if (reusedPort.kind === "exit") return { kind: "exit" } const chosen = { ...flagOverrides, ...reusedPort.settings } - return { kind: "ok", identity: { carried: {}, chosen } } + return { kind: "ok", identity: { carried: {}, chosen }, portsInUse: portsOutsideReusedInstall } } const suggestedName = suggestInstanceName({ mode, targetDir }, docker) const instanceName = await askFreeInstanceName({ mode, suggestedName }, deps) @@ -380,7 +372,7 @@ export const resolveInstanceIdentity = async ( PORT: port, ...buildDeviceNameSetting(mode, instanceName), } - return { kind: "ok", identity: { carried: {}, chosen } } + return { kind: "ok", identity: { carried: {}, chosen }, portsInUse } } /** diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index d4734dcf1..273eebe1c 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -289,10 +289,7 @@ const isValidTimezone = (value: string): boolean => { } /** Re-prompts until the answer is a valid port number. */ -export const askPort = async ( - currentValue: string | undefined, - prompts: Prompts, -): Promise => { +const askPort = async (currentValue: string | undefined, prompts: Prompts): Promise => { // An empty PORT= line leaves the default port in effect, as readEnvPort reads // it; a value that isn't a port is never offered, since it would be rejected. const suggestedPort = @@ -309,6 +306,43 @@ export const askPort = async ( return askPort(currentValue, prompts) } +/** Host ports that containers on this machine publish, mapped to each container's name. */ +export type PortsInUse = ReadonlyMap + +export const portInUseMessage = (port: string, containerName: string): string => { + return `Port ${port} is already used by the "${containerName}" container on this machine.` +} + +/** The first port above the default that no container publishes. */ +export const suggestFreePort = (portsInUse: PortsInUse): string => { + const findFreePort = (candidatePort: number): number => { + return portsInUse.has(candidatePort) ? findFreePort(candidatePort + 1) : candidatePort + } + return String(findFreePort(DEFAULT_PORT + 1)) +} + +/** Re-prompts until the answer is a valid port no other container on the machine publishes. */ +export const askFreePort = async ( + params: { suggestedPort: string | undefined; portsInUse: PortsInUse }, + prompts: Prompts, +): Promise => { + const answer = await askPort(params.suggestedPort, prompts) + const portUser = params.portsInUse.get(Number(answer)) + + if (!portUser) return answer + prompts.error(`${portInUseMessage(answer, portUser)} Pick another port.`) + // A free suggestion, so pressing enter can't offer the taken port again. + return askFreePort({ ...params, suggestedPort: suggestFreePort(params.portsInUse) }, prompts) +} + +/** Start and restart replace the install's own container, so only other containers' ports are taken. */ +export const portsOfOtherContainers = ( + portsInUse: PortsInUse, + containerName: string, +): PortsInUse => { + return new Map([...portsInUse].filter(([, portUser]) => portUser !== containerName)) +} + /** Re-prompts until the answer is a zone the runtime recognizes. */ const askTimezone = async (currentValue: string | undefined, prompts: Prompts): Promise => { const answer = ( @@ -396,10 +430,10 @@ const askOptionalText = async ( * or undefined when an optionalText prompt was left blank (nothing to write). */ const askSettingValue = async ( - params: { setting: OptionalSetting; currentValue: string | undefined }, + params: { setting: OptionalSetting; currentValue: string | undefined; portsInUse: PortsInUse }, prompts: Prompts, ): Promise => { - const { setting, currentValue } = params + const { setting, currentValue, portsInUse } = params switch (setting.kind) { case "toggle": { // An unset or empty var means the server default applies — seed the @@ -414,7 +448,7 @@ const askSettingValue = async ( return String(enabled) } case "port": - return askPort(currentValue, prompts) + return askFreePort({ suggestedPort: currentValue, portsInUse }, prompts) case "timezone": return askTimezone(currentValue, prompts) case "folder": @@ -455,13 +489,15 @@ const askSettingValue = async ( * The guided optional-settings flow shared by init and configure: one * multiselect chooser (enter with nothing picked = change nothing), then one * prompt per picked setting. Returns the chosen values keyed by var name — - * the caller owns applying them via applyOptionalSettings. + * the caller owns applying them via applyOptionalSettings. The port prompt + * refuses a port in portsInUse: the ports other containers publish, empty + * when Docker can't be asked. */ export const askOptionalSettings = async ( - params: { mode: Mode; envContent: string }, + params: { mode: Mode; envContent: string; portsInUse?: PortsInUse }, prompts: Prompts, ): Promise> => { - const { mode, envContent } = params + const { mode, envContent, portsInUse = new Map() } = params const offeredSettings = OPTIONAL_SETTINGS.filter( (setting) => !setting.remoteOnly || mode === "remote", ) @@ -493,7 +529,7 @@ export const askOptionalSettings = async ( for (const setting of offeredSettings) { if (!pickedNames.includes(setting.name)) continue const value = await askSettingValue( - { setting, currentValue: readOptionalValue(envContent, setting.name) }, + { setting, currentValue: readOptionalValue(envContent, setting.name), portsInUse }, prompts, ) From 60f54296b5ba3a8da55332c2649b28d2d552f39e Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:06:27 -0400 Subject: [PATCH 50/57] fix(cli): leave a container that runs another image in place start, restart, upgrade, configure's restart, down, and init's start offer now read the image of the container that holds the install's name. When it is not a Vault Cortex image they stop with a message naming the container and its image, instead of removing it. Also: - suggestFreePort starts at the default port, so init suggests 8000 when it is free, as the guides say. - init passes the deployment's vault path straight through; the fallback for a kept remote .env could not be reached once init refuses a mode mismatch. - derivePublicUrlOverride is now withPublicUrlOverride and takes named inputs. Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/command-stubs.ts | 14 ++++- cli/src/__tests__/configure.test.ts | 3 +- cli/src/__tests__/docker.test.ts | 46 ++++++++++++++ cli/src/__tests__/init.test.ts | 45 ++++++++++++-- cli/src/__tests__/integration/fixtures/docker | 16 ++++- cli/src/__tests__/lifecycle.test.ts | 62 ++++++++++++++++++- cli/src/__tests__/optional-settings.test.ts | 62 +++++++++++-------- cli/src/configure.ts | 4 +- cli/src/docker.ts | 49 +++++++++++++++ cli/src/init.ts | 33 +++++++--- cli/src/instance-identity.ts | 25 +++++--- cli/src/lifecycle.ts | 40 +++++++++++- cli/src/optional-settings.ts | 15 ++--- 13 files changed, 346 insertions(+), 68 deletions(-) diff --git a/cli/src/__tests__/command-stubs.ts b/cli/src/__tests__/command-stubs.ts index eef202095..cf74ad3d3 100644 --- a/cli/src/__tests__/command-stubs.ts +++ b/cli/src/__tests__/command-stubs.ts @@ -1,4 +1,9 @@ -import type { DockerLogsParams, DockerRunner, DockerRunParams } from "../docker.js" +import { + LOCAL_IMAGE, + type DockerLogsParams, + type DockerRunner, + type DockerRunParams, +} from "../docker.js" import type { Prompts, SelectOption } from "../prompts.js" /** @@ -168,6 +173,7 @@ export const dockerReady: DockerRunner = { pullImage: () => true, stopAndRemoveContainer: () => true, containerExists: () => true, + containerImage: () => LOCAL_IMAGE, volumeExists: () => true, publishedHostPorts: () => new Map(), streamLogs: async () => 0, @@ -180,6 +186,7 @@ export const dockerReady: DockerRunner = { export const dockerReadyEmptyHost: DockerRunner = { ...dockerReady, containerExists: () => false, + containerImage: () => undefined, volumeExists: () => false, } @@ -202,6 +209,10 @@ export const createRecordingDocker = (): { calls.push(`inspect:${instanceName}`) return true }, + containerImage: (instanceName) => { + calls.push(`inspect:${instanceName}`) + return LOCAL_IMAGE + }, stopAndRemoveContainer: (instanceName) => { calls.push(`rm:${instanceName}`) return true @@ -227,6 +238,7 @@ export const dockerDown: DockerRunner = { pullImage: () => false, stopAndRemoveContainer: () => false, containerExists: () => false, + containerImage: () => undefined, volumeExists: () => false, publishedHostPorts: () => new Map(), streamLogs: async () => 1, diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index a5c899bbd..7aa9bcf3a 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -253,7 +253,7 @@ describe("runConfigure with picked settings", () => { ]) expect(scripted.textCalls).toEqual([ { message: "Host port for the server:", defaultValue: "8000", placeholder: "8000" }, - { message: "Host port for the server:", defaultValue: "8001", placeholder: "8001" }, + { message: "Host port for the server:", defaultValue: "8000", placeholder: "8000" }, ]) expect(readFileSync(envFilePath, "utf8")).toBe(LOCAL_ENV_CONTENT) }) @@ -459,6 +459,7 @@ describe("runConfigure with picked settings", () => { const dockerRunFails: DockerRunner = { ...dockerReady, containerExists: () => false, + containerImage: () => undefined, dockerRun: () => false, } const scripted = createScriptedPrompts([ diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index 5fc7ba32f..fe3d3ee15 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -7,7 +7,9 @@ import { healthPollTimeoutMs, healthTimeoutMessage, isContainerNamed, + isVaultCortexImage, LOCAL_IMAGE, + parseContainerImage, parsePublishedHostPorts, pollHealth, probeHealth, @@ -494,6 +496,50 @@ describe("isContainerNamed", () => { }) }) +describe("parseContainerImage", () => { + it("reads the image of the container with the exact name", () => { + expect( + parseContainerImage({ + inspectOutput: "/work ghcr.io/aliasunder/vault-cortex:remote\n", + instanceName: "work", + }), + ).toBe("ghcr.io/aliasunder/vault-cortex:remote") + }) + + it("returns undefined for a container that inspect found by an ID prefix", () => { + expect( + parseContainerImage({ + inspectOutput: "/remote-boot-relative-index-db postgres:16\n", + instanceName: "2025", + }), + ).toBeUndefined() + }) +}) + +describe("isVaultCortexImage", () => { + it.each([ + { label: "the local tag on GHCR", image: "ghcr.io/aliasunder/vault-cortex:latest" }, + { label: "the remote tag on GHCR", image: "ghcr.io/aliasunder/vault-cortex:remote" }, + { label: "an untagged GHCR name", image: "ghcr.io/aliasunder/vault-cortex" }, + { label: "a Docker Hub tag", image: "aliasunder/vault-cortex:remote" }, + { label: "a fully named Docker Hub tag", image: "docker.io/aliasunder/vault-cortex:0.54.16" }, + { label: "a digest", image: "ghcr.io/aliasunder/vault-cortex@sha256:0123abcd" }, + ])("accepts $label", ({ image }) => { + expect(isVaultCortexImage(image)).toBe(true) + }) + + it.each([ + { label: "another image", image: "postgres:16" }, + { + label: "a repository that only starts with the name", + image: "aliasunder/vault-cortex-fork:1", + }, + { label: "another owner's image", image: "ghcr.io/someone/vault-cortex:latest" }, + ])("rejects $label", ({ image }) => { + expect(isVaultCortexImage(image)).toBe(false) + }) +}) + describe("probeHealth", () => { it("returns true when the endpoint responds ok", async () => { const probeResult = await probeHealth( diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 24a3d97d3..c21d8e15a 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { describe, expect, it, onTestFinished, vi } from "vitest" import { runInit, validatePublicUrl } from "../init.js" -import { pollHealth, type DockerRunner } from "../docker.js" +import { LOCAL_IMAGE, pollHealth, type DockerRunner } from "../docker.js" import { buildDaemonNotRunningMessage, buildDockerNotInstalledMessage } from "../messages.js" import { readEnvInstanceName } from "../scaffold.js" @@ -1747,6 +1747,9 @@ const dockerWithExisting = (existingNames: ReadonlyArray): DockerRunner return { ...dockerDaemonOnly, containerExists: (instanceName) => existingNames.includes(instanceName), + containerImage: (instanceName) => { + return existingNames.includes(instanceName) ? LOCAL_IMAGE : undefined + }, volumeExists: (volumeName) => existingNames.includes(volumeName), } } @@ -1808,7 +1811,10 @@ describe("runInit when the instance name is already in use", () => { { mode: "local", vaultPath: vaultDir, dir: targetDir }, { prompts: scripted.prompts, - docker: dockerWithExisting(["vault-cortex"]), + docker: dockerWithPorts({ + existingNames: ["vault-cortex"], + publishedPorts: [[8000, "vault-cortex"]], + }), fetchFn: fetchNever, }, ) @@ -1821,7 +1827,7 @@ describe("runInit when the instance name is already in use", () => { "Any optional settings to change? (press enter to skip)", "Start the server now?", ]) - // The install using the default name most likely holds port 8000. + // The install using the default name publishes port 8000. expect(scripted.textCalls[1]).toEqual({ message: PORT_QUESTION, defaultValue: "8001", @@ -2026,7 +2032,7 @@ describe("runInit when the instance name is already in use", () => { ) const scripted = createScriptedPrompts([ true, // re-run setup - "8001", // the suggestion: the install's own port + "8001", // the install's own port [], // no optional settings true, // overwrite .env false, // don't start now @@ -2052,7 +2058,7 @@ describe("runInit when the instance name is already in use", () => { 'Port 9000 is already used by the "other-app" container on this machine.', ]) expect(scripted.textCalls).toEqual([ - { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + { message: PORT_QUESTION, defaultValue: "8000", placeholder: "8000" }, ]) expect(readIdentityLines(targetDir)).toEqual([ "PUBLIC_URL=http://localhost:8001", @@ -2110,7 +2116,7 @@ describe("runInit when the instance name is already in use", () => { expect(exitCode).toBe(0) expect(scripted.textCalls).toEqual([ { message: PORT_QUESTION, defaultValue: "8000", placeholder: "8000" }, - { message: PORT_QUESTION, defaultValue: "8001", placeholder: "8001" }, + { message: PORT_QUESTION, defaultValue: "8000", placeholder: "8000" }, ]) expect(scripted.errors).toEqual([ 'Port 9000 is already used by the "other-app" container on this machine. Pick another port.', @@ -2242,6 +2248,33 @@ describe("runInit when the instance name is already in use", () => { expect(recording.calls).toEqual(["inspect:vault-cortex", "inspect:vault-cortex"]) }) + it("leaves a container that runs another image in place and does not offer to start", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + // No answer for a start question: asking one fails the test. + const scripted = createScriptedPrompts(["reuse", "8000", []]) + const recording = createRecordingDocker() + const docker: DockerRunner = { + ...recording.docker, + containerImage: (instanceName) => { + recording.calls.push(`inspect:${instanceName}`) + return "postgres:16" + }, + } + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([ + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so it was left in place.\n` + + `Give this install a name no other container uses: change INSTANCE_NAME in ${targetDir}/.env.`, + ]) + expect(recording.calls).toEqual(["inspect:vault-cortex", "inspect:vault-cortex"]) + }) + it("counts a remote install as in use when only its vault volume is left", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ diff --git a/cli/src/__tests__/integration/fixtures/docker b/cli/src/__tests__/integration/fixtures/docker index b4ea52ad9..f4ed14c5b 100755 --- a/cli/src/__tests__/integration/fixtures/docker +++ b/cli/src/__tests__/integration/fixtures/docker @@ -6,6 +6,8 @@ # exist; `container ls` lists them, and # `container inspect` and `volume inspect` # exit 1 for any other name (default: none exist) +# DOCKER_SHIM_IMAGE — the image `container inspect` reports for a +# present container (default: the local image) # DOCKER_SHIM_RUN_FAIL=1 — `run` exits 1 # DOCKER_SHIM_HEALTH_PORT — port for the background health server (default 8000) # DOCKER_SHIM_PID_FILE — where to write the health server PID @@ -54,13 +56,21 @@ case "${1:-}" in done exit 0 fi - # docker container inspect --format '{{.Name}}' — prints the name - # with Docker's leading slash when the container exists + # docker container inspect --format — prints the name with + # Docker's leading slash when the container exists, followed by its image + # when the format asks for it container_name="${!#}" if ! is_present "$container_name"; then exit 1 fi - echo "/$container_name" + case "${4:-}" in + *.Config.Image*) + echo "/$container_name ${DOCKER_SHIM_IMAGE:-ghcr.io/aliasunder/vault-cortex:latest}" + ;; + *) + echo "/$container_name" + ;; + esac ;; volume) diff --git a/cli/src/__tests__/lifecycle.test.ts b/cli/src/__tests__/lifecycle.test.ts index 82fd20829..3fbac8f87 100644 --- a/cli/src/__tests__/lifecycle.test.ts +++ b/cli/src/__tests__/lifecycle.test.ts @@ -96,6 +96,7 @@ describe("runDown", () => { const dockerNoContainer: DockerRunner = { ...dockerReady, containerExists: () => false, + containerImage: () => undefined, stopAndRemoveContainer: () => { removeCalls.push("called") return true @@ -727,6 +728,7 @@ describe("runRestart", () => { const dockerFirstStart: DockerRunner = { ...dockerReady, containerExists: () => false, + containerImage: () => undefined, stopAndRemoveContainer: () => { removeCalls.push("called") return false @@ -824,9 +826,15 @@ describe("commands on an install with a custom INSTANCE_NAME", () => { writeNamedLocalEnv(targetDir) const scripted = createScriptedPrompts() + const dockerNoContainer: DockerRunner = { + ...dockerReady, + containerExists: () => false, + containerImage: () => undefined, + } + const exitCode = await runDown( { dir: targetDir }, - { prompts: scripted.prompts, docker: { ...dockerReady, containerExists: () => false } }, + { prompts: scripted.prompts, docker: dockerNoContainer }, ) expect(exitCode).toBe(0) @@ -975,3 +983,55 @@ describe("commands on an install with a custom INSTANCE_NAME", () => { ]) }) }) + +describe("a container under the install's name that runs another image", () => { + const createForeignContainerDocker = (): { docker: DockerRunner; calls: string[] } => { + const recording = createRecordingDocker() + const docker: DockerRunner = { + ...recording.docker, + containerImage: (instanceName) => { + recording.calls.push(`inspect:${instanceName}`) + return "postgres:16" + }, + } + return { docker, calls: recording.calls } + } + + it("down leaves it in place and exits 1", async () => { + const targetDir = makeTempTargetDir("vault-cli-foreign-") + writeLocalEnv(targetDir) + const foreign = createForeignContainerDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runDown( + { dir: targetDir }, + { prompts: scripted.prompts, docker: foreign.docker }, + ) + + expect(exitCode).toBe(1) + expect(foreign.calls).toEqual(["inspect:vault-cortex"]) + expect(scripted.errors).toEqual([ + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so it was left in place.\n` + + `Give this install a name no other container uses: change INSTANCE_NAME in ${targetDir}/.env.`, + ]) + }) + + it("restart leaves it in place and starts nothing", async () => { + const targetDir = makeTempTargetDir("vault-cli-foreign-") + writeLocalEnv(targetDir) + const foreign = createForeignContainerDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runRestart( + { dir: targetDir }, + { prompts: scripted.prompts, docker: foreign.docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(foreign.calls).toEqual(["inspect:vault-cortex"]) + expect(scripted.errors).toEqual([ + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so it was left in place.\n` + + `Give this install a name no other container uses: change INSTANCE_NAME in ${targetDir}/.env.`, + ]) + }) +}) diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index a1129c5a4..2e8e84d07 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest" import { applyOptionalSettings, askOptionalSettings, - derivePublicUrlOverride, describeStrandedPublicUrl, readOptionalValue, + withPublicUrlOverride, } from "../optional-settings.js" import { createScriptedPrompts } from "./command-stubs.js" @@ -94,71 +94,81 @@ describe("applyOptionalSettings", () => { }) }) -describe("derivePublicUrlOverride", () => { +describe("withPublicUrlOverride", () => { it("follows a PORT change when PUBLIC_URL is the derived localhost form", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=8000\n", { - PORT: "9000", + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=8000\n", }) - expect(derived).toEqual({ + expect(overrides).toEqual({ PORT: "9000", PUBLIC_URL: "http://localhost:9000", }) }) - it("derives from the current PORT, not the default", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:9100\nPORT=9100\n", { - PORT: "9200", + it("follows the current PORT, not the default", () => { + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9200" }, + envContent: "PUBLIC_URL=http://localhost:9100\nPORT=9100\n", }) - expect(derived).toEqual({ + expect(overrides).toEqual({ PORT: "9200", PUBLIC_URL: "http://localhost:9200", }) }) it("reads an empty PORT line as the default port", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=\n", { - PORT: "9000", + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=\n", }) - expect(derived).toEqual({ + expect(overrides).toEqual({ PORT: "9000", PUBLIC_URL: "http://localhost:9000", }) }) it("follows a PORT change from a value that isn't a port when PUBLIC_URL is on the default port", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=80a0\n", { - PORT: "9000", + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=80a0\n", }) - expect(derived).toEqual({ + expect(overrides).toEqual({ PORT: "9000", PUBLIC_URL: "http://localhost:9000", }) }) it("leaves a default-port PUBLIC_URL beside a valid PORT on another port", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=9100\n", { - PORT: "9200", + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9200" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=9100\n", }) - expect(derived).toEqual({ PORT: "9200" }) + expect(overrides).toEqual({ PORT: "9200" }) }) it("never touches a custom PUBLIC_URL", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=https://vault.example.com\nPORT=8000\n", { - PORT: "9000", + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PUBLIC_URL=https://vault.example.com\nPORT=8000\n", }) - expect(derived).toEqual({ PORT: "9000" }) + expect(overrides).toEqual({ PORT: "9000" }) }) it("returns the overrides unchanged without a PORT override", () => { - const derived = derivePublicUrlOverride("PUBLIC_URL=http://localhost:8000\nPORT=8000\n", { - MEMORY_ENABLED: "false", + const overrides = withPublicUrlOverride({ + overrides: { MEMORY_ENABLED: "false" }, + envContent: "PUBLIC_URL=http://localhost:8000\nPORT=8000\n", }) - expect(derived).toEqual({ MEMORY_ENABLED: "false" }) + expect(overrides).toEqual({ MEMORY_ENABLED: "false" }) }) it("returns the overrides unchanged when PUBLIC_URL is absent", () => { - const derived = derivePublicUrlOverride("PORT=8000\n", { PORT: "9000" }) - expect(derived).toEqual({ PORT: "9000" }) + const overrides = withPublicUrlOverride({ + overrides: { PORT: "9000" }, + envContent: "PORT=8000\n", + }) + expect(overrides).toEqual({ PORT: "9000" }) }) }) diff --git a/cli/src/configure.ts b/cli/src/configure.ts index 82a2659a6..44e7d25af 100644 --- a/cli/src/configure.ts +++ b/cli/src/configure.ts @@ -4,9 +4,9 @@ import { recreateContainer, requireInitializedDir, resolveDeployment } from "./l import { applyOptionalSettings, askOptionalSettings, - derivePublicUrlOverride, describeStrandedPublicUrl, portsOfOtherContainers, + withPublicUrlOverride, } from "./optional-settings.js" import { readEnvInstanceName } from "./scaffold.js" import type { DockerRunner } from "./docker.js" @@ -62,7 +62,7 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): return 0 } - const overrides = derivePublicUrlOverride(envContent, pickedOverrides) + const overrides = withPublicUrlOverride({ overrides: pickedOverrides, envContent }) const changedNames = Object.keys(overrides) writeFileSync(envFilePath, applyOptionalSettings(envContent, overrides)) prompts.log(`Updated ${changedNames.join(", ")} in ${targetDir}/.env.`) diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 6991dc686..e32f8514f 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -5,6 +5,24 @@ import type { Mode } from "./scaffold.js" export const LOCAL_IMAGE = "ghcr.io/aliasunder/vault-cortex:latest" export const REMOTE_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote" +/** The image on GHCR and on its Docker Hub mirror, which Docker also reports without docker.io/. */ +const VAULT_CORTEX_IMAGE_REPOSITORIES = [ + "ghcr.io/aliasunder/vault-cortex", + "aliasunder/vault-cortex", + "docker.io/aliasunder/vault-cortex", +] + +/** True for any tag or digest of the Vault Cortex image. */ +export const isVaultCortexImage = (image: string): boolean => { + return VAULT_CORTEX_IMAGE_REPOSITORIES.some((repository) => { + return ( + image === repository || + image.startsWith(`${repository}:`) || + image.startsWith(`${repository}@`) + ) + }) +} + export type DockerRunParams = { mode: Mode /** The install's INSTANCE_NAME: the container name and the volume prefix. */ @@ -72,6 +90,11 @@ export type DockerRunner = { stopAndRemoveContainer: (instanceName: string) => boolean /** True when a container with this name exists, running or stopped. */ containerExists: (instanceName: string) => boolean + /** + * The image the container with this exact name was created from, or + * undefined when no container has the name. + */ + containerImage: (instanceName: string) => string | undefined /** True when a named volume exists. */ volumeExists: (volumeName: string) => boolean /** @@ -200,6 +223,22 @@ export const isContainerNamed = (params: { return inspectedName.trim().replace(/^\//, "") === instanceName } +/** Go template that prints a container's name and image: "/work ghcr.io/aliasunder/vault-cortex:latest". */ +const NAME_AND_IMAGE_FORMAT = "{{.Name}} {{.Config.Image}}" + +/** Reads NAME_AND_IMAGE_FORMAT output into the image, or undefined when it names another container. */ +export const parseContainerImage = (params: { + inspectOutput: string + instanceName: string +}): string | undefined => { + const [inspectedName = "", image] = params.inspectOutput.trim().split(" ") + + if (!image || !isContainerNamed({ inspectedName, instanceName: params.instanceName })) { + return undefined + } + return image +} + /** * Go template that prints a container's name, then each host port it * publishes: "/work 8001". PortBindings is the container's run setting, so a @@ -257,6 +296,16 @@ export const createDockerRunner = (): DockerRunner => ({ if (inspectResult.status !== 0) return false return isContainerNamed({ inspectedName: inspectResult.stdout, instanceName }) }, + containerImage: (instanceName) => { + const inspectResult = spawnSync( + "docker", + ["container", "inspect", "--format", NAME_AND_IMAGE_FORMAT, instanceName], + { encoding: "utf8" }, + ) + + if (inspectResult.status !== 0) return undefined + return parseContainerImage({ inspectOutput: inspectResult.stdout, instanceName }) + }, volumeExists: (volumeName) => { return spawnSync("docker", ["volume", "inspect", volumeName]).status === 0 }, diff --git a/cli/src/init.ts b/cli/src/init.ts index 277017f14..c68b813ef 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -14,6 +14,7 @@ import { import { healthPollTimeoutMs, healthTimeoutMessage, + isVaultCortexImage, pollHealth, type DockerRunner, } from "./docker.js" @@ -22,12 +23,12 @@ import { describeStrandedCarriedPublicUrl, resolveInstanceIdentity, } from "./instance-identity.js" -import { reportPublicUrlProbe, resolveDeployment } from "./lifecycle.js" +import { foreignContainerMessage, reportPublicUrlProbe, resolveDeployment } from "./lifecycle.js" import { applyOptionalSettings, askOptionalSettings, - derivePublicUrlOverride, describeStrandedPublicUrl, + withPublicUrlOverride, } from "./optional-settings.js" import { buildFilesToWrite, @@ -329,9 +330,21 @@ const offerDockerRun = async ( ) return "not-started" } + const existingImage = docker.containerImage(instanceName) + + if (existingImage && !isVaultCortexImage(existingImage)) { + prompts.error( + foreignContainerMessage({ + instanceName, + image: existingImage, + envFilePath: join(targetDir, ".env"), + }), + ) + return "not-started" + } // docker run refuses a name an existing container already holds, so a yes // replaces that container the way `start` does, and the question says so. - const containerExists = docker.containerExists(instanceName) + const containerExists = Boolean(existingImage) const startQuestion = containerExists ? `Start the server now? This stops and replaces the existing "${instanceName}" container. Its data volumes are kept.` : "Start the server now?" @@ -448,7 +461,10 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = }, prompts, ) - const appliedOverrides = derivePublicUrlOverride(generatedEnvContent, optionalOverrides) + const appliedOverrides = withPublicUrlOverride({ + overrides: optionalOverrides, + envContent: generatedEnvContent, + }) const envContent = applyOptionalSettings(generatedEnvContent, appliedOverrides) const publicUrlWarning = describeStrandedPublicUrl({ @@ -489,14 +505,12 @@ const runLocalInit = async (flags: InitFlags, deps: InitDeps): Promise = if (!deployment) return 1 const { instanceName, port } = deployment - // A kept remote .env has no VAULT_PATH, so this run's folder is mounted. - const mountedVaultPath = deployment.vaultPath ?? vaultPath // --yes is for scripts/CI, so it never starts Docker. const startStatus: StartStatus = flags.yes ? "not-started" : await offerDockerRun( - { targetDir, instanceName, port, mode: "local", vaultPath: mountedVaultPath }, + { targetDir, instanceName, port, mode: "local", vaultPath: deployment.vaultPath }, deps, ) prompts.print( @@ -585,7 +599,10 @@ const runRemoteInit = async (flags: InitFlags, deps: InitDeps): Promise { mode: "remote", envContent: generatedEnvContent, portsInUse: identityResult.portsInUse }, prompts, ) - const appliedOverrides = derivePublicUrlOverride(generatedEnvContent, optionalOverrides) + const appliedOverrides = withPublicUrlOverride({ + overrides: optionalOverrides, + envContent: generatedEnvContent, + }) const envContent = applyOptionalSettings(generatedEnvContent, appliedOverrides) const publicUrlWarning = describeStrandedPublicUrl({ envContent: generatedEnvContent, diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 60ff7d7a3..32a4a54c3 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -5,12 +5,12 @@ import { instanceVolumeNames, type DockerRunner } from "./docker.js" import { applyOptionalSettings, askFreePort, - derivePublicUrlOverride, describeStrandedPublicUrl, portInUseMessage, portsOfOtherContainers, readOptionalValue, suggestFreePort, + withPublicUrlOverride, type PortsInUse, } from "./optional-settings.js" import { @@ -192,10 +192,10 @@ const askReusedPort = async ( const { flags, portsInUse } = params if (flags.port || flags.yes) return checkRequestedPort(params, prompts) - const suggestedPort = portsInUse.has(DEFAULT_PORT) - ? suggestFreePort(portsInUse) - : String(DEFAULT_PORT) - const port = await askFreePort({ suggestedPort, portsInUse }, prompts) + const port = await askFreePort( + { suggestedPort: suggestFreePort(portsInUse), portsInUse }, + prompts, + ) return { kind: "ok", settings: { PORT: port } } } @@ -363,8 +363,6 @@ export const resolveInstanceIdentity = async ( } const suggestedName = suggestInstanceName({ mode, targetDir }, docker) const instanceName = await askFreeInstanceName({ mode, suggestedName }, deps) - // The install already using the name most likely holds the default port, so - // the first free port above it is offered instead. const suggestedPort = flags.port ?? suggestFreePort(portsInUse) const port = await askFreePort({ suggestedPort, portsInUse }, prompts) const chosen = { @@ -384,9 +382,13 @@ export const resolveInstanceIdentity = async ( export const applyInstanceIdentity = (envContent: string, identity: InstanceIdentity): string => { const carriedSettings = identity.carried.PUBLIC_URL ? identity.carried - : derivePublicUrlOverride(envContent, identity.carried) + : withPublicUrlOverride({ overrides: identity.carried, envContent }) const withCarried = applyOptionalSettings(envContent, carriedSettings) - return applyOptionalSettings(withCarried, derivePublicUrlOverride(withCarried, identity.chosen)) + const chosenSettings = withPublicUrlOverride({ + overrides: identity.chosen, + envContent: withCarried, + }) + return applyOptionalSettings(withCarried, chosenSettings) } /** @@ -404,6 +406,9 @@ export const describeStrandedCarriedPublicUrl = ( return describeStrandedPublicUrl({ envContent: withCarried, pickedOverrides: identity.chosen, - appliedOverrides: derivePublicUrlOverride(withCarried, identity.chosen), + appliedOverrides: withPublicUrlOverride({ + overrides: identity.chosen, + envContent: withCarried, + }), }) } diff --git a/cli/src/lifecycle.ts b/cli/src/lifecycle.ts index 1884326c0..9e4dec671 100644 --- a/cli/src/lifecycle.ts +++ b/cli/src/lifecycle.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path" import { healthPollTimeoutMs, healthTimeoutMessage, + isVaultCortexImage, pollHealth, probeHealth, type DockerRunner, @@ -193,6 +194,23 @@ export const reportPublicUrlProbe = async ( ) } +/** + * Refusal for a container that holds the install's name but runs another + * image: INSTANCE_NAME then names someone else's container, which a command + * must not stop or remove. + */ +export const foreignContainerMessage = (params: { + instanceName: string + image: string + envFilePath: string +}): string => { + const { instanceName, image, envFilePath } = params + return ( + `A container named "${instanceName}" runs ${image}, not Vault Cortex, so it was left in place.\n` + + `Give this install a name no other container uses: change INSTANCE_NAME in ${envFilePath}.` + ) +} + /** * Stop-and-remove → docker run → health poll, with the shared messaging. * Returns a process exit code. Callers own the daemon check and any @@ -205,13 +223,24 @@ export const recreateContainer = async ( const { deployment, healthTimeoutMs } = params const { prompts, docker, fetchFn } = deps const { instanceName } = deployment + const existingImage = docker.containerImage(instanceName) + if (existingImage && !isVaultCortexImage(existingImage)) { + prompts.error( + foreignContainerMessage({ + instanceName, + image: existingImage, + envFilePath: deployment.envFilePath, + }), + ) + return 1 + } // `docker rm -f` on a missing container exits non-zero on engines < 23, // so the bare return value can't distinguish "nothing to remove" from a // real failure — only treat removal as failed when a container exists. // Proceeding after a genuine failure would surface as a confusing // "container name already in use" from docker run. - if (docker.containerExists(instanceName) && !docker.stopAndRemoveContainer(instanceName)) { + if (existingImage && !docker.stopAndRemoveContainer(instanceName)) { prompts.error(`Could not remove the existing container — check: docker rm -f ${instanceName}`) return 1 } @@ -273,13 +302,18 @@ export const runDown = async (flags: DownFlags, deps: DownDeps): Promise if (!initialized) return 1 if (!ensureDaemonRunning(docker, prompts)) return 1 - const { instanceName } = initialized + const { instanceName, envFilePath } = initialized + const existingImage = docker.containerImage(instanceName) - if (!docker.containerExists(instanceName)) { + if (!existingImage) { prompts.log(`No ${instanceName} container found — nothing to stop.`) prompts.outro("Done.") return 0 } + if (!isVaultCortexImage(existingImage)) { + prompts.error(foreignContainerMessage({ instanceName, image: existingImage, envFilePath })) + return 1 + } if (!docker.stopAndRemoveContainer(instanceName)) { prompts.error(`Could not remove the container — check: docker rm -f ${instanceName}`) diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 273eebe1c..30a553673 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -219,10 +219,11 @@ export const applyOptionalSettings = ( * point at a port nothing listens on. A custom PUBLIC_URL (reverse proxy, * remote domain) is the user's own and is never touched. */ -export const derivePublicUrlOverride = ( - envContent: string, - overrides: Record, -): Record => { +export const withPublicUrlOverride = (params: { + overrides: Record + envContent: string +}): Record => { + const { overrides, envContent } = params const newPort = overrides.PORT if (!newPort) return overrides @@ -244,7 +245,7 @@ export const derivePublicUrlOverride = ( /** * Warns about a picked PORT change that left PUBLIC_URL as it was: - * derivePublicUrlOverride never rewrites a custom URL, so it may still name + * withPublicUrlOverride never rewrites a custom URL, so it may still name * the old port. Returns undefined when there is nothing to warn about. */ export const describeStrandedPublicUrl = (params: { @@ -313,12 +314,12 @@ export const portInUseMessage = (port: string, containerName: string): string => return `Port ${port} is already used by the "${containerName}" container on this machine.` } -/** The first port above the default that no container publishes. */ +/** The first port, counting up from the default, that no container publishes. */ export const suggestFreePort = (portsInUse: PortsInUse): string => { const findFreePort = (candidatePort: number): number => { return portsInUse.has(candidatePort) ? findFreePort(candidatePort + 1) : candidatePort } - return String(findFreePort(DEFAULT_PORT + 1)) + return String(findFreePort(DEFAULT_PORT)) } /** Re-prompts until the answer is a valid port no other container on the machine publishes. */ From aea2ef385d0afca1e77ba7e6d7fcc5e19be29766 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:31:35 -0400 Subject: [PATCH 51/57] fix(cli): treat the pre-rename vault-mcp image as Vault Cortex Installs the CLI started before v0.27.0 still run ghcr.io/aliasunder/vault-mcp, so the foreign-container check now accepts that image too, and start, restart, upgrade and down keep managing those installs. The fake docker in the PTY tests now answers `container inspect` for every name it is given, as docker does. Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/docker.test.ts | 1 + cli/src/__tests__/integration/fixtures/docker | 35 +++++++++++-------- cli/src/docker.ts | 7 +++- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index fe3d3ee15..22a40bf2e 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -524,6 +524,7 @@ describe("isVaultCortexImage", () => { { label: "a Docker Hub tag", image: "aliasunder/vault-cortex:remote" }, { label: "a fully named Docker Hub tag", image: "docker.io/aliasunder/vault-cortex:0.54.16" }, { label: "a digest", image: "ghcr.io/aliasunder/vault-cortex@sha256:0123abcd" }, + { label: "the image's name before the rename", image: "ghcr.io/aliasunder/vault-mcp:latest" }, ])("accepts $label", ({ image }) => { expect(isVaultCortexImage(image)).toBe(true) }) diff --git a/cli/src/__tests__/integration/fixtures/docker b/cli/src/__tests__/integration/fixtures/docker index f4ed14c5b..2311c66bb 100755 --- a/cli/src/__tests__/integration/fixtures/docker +++ b/cli/src/__tests__/integration/fixtures/docker @@ -56,21 +56,26 @@ case "${1:-}" in done exit 0 fi - # docker container inspect --format — prints the name with - # Docker's leading slash when the container exists, followed by its image - # when the format asks for it - container_name="${!#}" - if ! is_present "$container_name"; then - exit 1 - fi - case "${4:-}" in - *.Config.Image*) - echo "/$container_name ${DOCKER_SHIM_IMAGE:-ghcr.io/aliasunder/vault-cortex:latest}" - ;; - *) - echo "/$container_name" - ;; - esac + # docker container inspect --format ... — one line per present + # container: its name with Docker's leading slash, then its image when the + # format asks for it. Like docker, exits 1 when any name is missing, after + # printing the others. + inspect_status=0 + for container_name in "${@:5}"; do + if ! is_present "$container_name"; then + inspect_status=1 + continue + fi + case "${4:-}" in + *.Config.Image*) + echo "/$container_name ${DOCKER_SHIM_IMAGE:-ghcr.io/aliasunder/vault-cortex:latest}" + ;; + *) + echo "/$container_name" + ;; + esac + done + exit "$inspect_status" ;; volume) diff --git a/cli/src/docker.ts b/cli/src/docker.ts index e32f8514f..4ee5445a4 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -5,11 +5,16 @@ import type { Mode } from "./scaffold.js" export const LOCAL_IMAGE = "ghcr.io/aliasunder/vault-cortex:latest" export const REMOTE_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote" -/** The image on GHCR and on its Docker Hub mirror, which Docker also reports without docker.io/. */ +/** + * - The image on GHCR and on its Docker Hub mirror, which Docker also reports + * without docker.io/. + * - vault-mcp: the image's name before v0.27.0, which older CLI installs still run. + */ const VAULT_CORTEX_IMAGE_REPOSITORIES = [ "ghcr.io/aliasunder/vault-cortex", "aliasunder/vault-cortex", "docker.io/aliasunder/vault-cortex", + "ghcr.io/aliasunder/vault-mcp", ] /** True for any tag or digest of the Vault Cortex image. */ From 5d72a54bc08530ebbc1233f6a58b44feb38cf3b4 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:51:46 -0400 Subject: [PATCH 52/57] fix(cli): keep every container that publishes a host port The port check read Docker's published ports into a map keyed by host port, so when the install's own container and another container both declared a port, whichever was inspected last won. If that was the install's own container, the other container's claim disappeared and the port read as free. The ports now stay one entry per container, and the install's own container is left out before the map is built. Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/command-stubs.ts | 4 +-- cli/src/__tests__/configure.test.ts | 8 +++--- cli/src/__tests__/docker.test.ts | 25 +++++++++++------ cli/src/__tests__/init.test.ts | 4 ++- cli/src/__tests__/optional-settings.test.ts | 31 +++++++++++++++++++++ cli/src/docker.ts | 26 +++++++++-------- cli/src/instance-identity.ts | 8 ++++-- cli/src/optional-settings.ts | 17 +++++++++-- 8 files changed, 90 insertions(+), 33 deletions(-) diff --git a/cli/src/__tests__/command-stubs.ts b/cli/src/__tests__/command-stubs.ts index cf74ad3d3..fd6071746 100644 --- a/cli/src/__tests__/command-stubs.ts +++ b/cli/src/__tests__/command-stubs.ts @@ -175,7 +175,7 @@ export const dockerReady: DockerRunner = { containerExists: () => true, containerImage: () => LOCAL_IMAGE, volumeExists: () => true, - publishedHostPorts: () => new Map(), + publishedHostPorts: () => [], streamLogs: async () => 0, } @@ -240,7 +240,7 @@ export const dockerDown: DockerRunner = { containerExists: () => false, containerImage: () => undefined, volumeExists: () => false, - publishedHostPorts: () => new Map(), + publishedHostPorts: () => [], streamLogs: async () => 1, } diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index 7aa9bcf3a..ad43d7fca 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -229,10 +229,10 @@ describe("runConfigure with picked settings", () => { const docker: DockerRunner = { ...createRecordingDocker().docker, publishedHostPorts: () => { - return new Map([ - [8000, "vault-cortex"], - [9100, "other-app"], - ]) + return [ + { hostPort: 8000, containerName: "vault-cortex" }, + { hostPort: 9100, containerName: "other-app" }, + ] }, } const scripted = createScriptedPrompts([ diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index 22a40bf2e..e973dd949 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -460,7 +460,7 @@ describe("classifyDaemonStatus", () => { }) describe("parsePublishedHostPorts", () => { - it("maps each published host port to its container, with or without a leading slash", () => { + it("lists each published host port with its container, with or without a leading slash", () => { const inspectOutput = [ "/vault-cortex 8000", "/work 8001 9443", @@ -470,14 +470,21 @@ describe("parsePublishedHostPorts", () => { "", ].join("\n") - expect(parsePublishedHostPorts(inspectOutput)).toEqual( - new Map([ - [8000, "vault-cortex"], - [8001, "work"], - [9443, "work"], - [7000, "podman-app"], - ]), - ) + expect(parsePublishedHostPorts(inspectOutput)).toEqual([ + { hostPort: 8000, containerName: "vault-cortex" }, + { hostPort: 8001, containerName: "work" }, + { hostPort: 9443, containerName: "work" }, + { hostPort: 7000, containerName: "podman-app" }, + ]) + }) + + it("keeps every container that publishes the same host port", () => { + const inspectOutput = ["/vault-cortex 8000", "/stopped-test 8000"].join("\n") + + expect(parsePublishedHostPorts(inspectOutput)).toEqual([ + { hostPort: 8000, containerName: "vault-cortex" }, + { hostPort: 8000, containerName: "stopped-test" }, + ]) }) }) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index c21d8e15a..e2c9e31db 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1761,7 +1761,9 @@ const dockerWithPorts = (params: { }): DockerRunner => { return { ...dockerWithExisting(params.existingNames), - publishedHostPorts: () => new Map(params.publishedPorts), + publishedHostPorts: () => { + return params.publishedPorts.map(([hostPort, containerName]) => ({ hostPort, containerName })) + }, } } diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 2e8e84d07..30d5adf2f 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -4,6 +4,7 @@ import { applyOptionalSettings, askOptionalSettings, describeStrandedPublicUrl, + portsOfOtherContainers, readOptionalValue, withPublicUrlOverride, } from "../optional-settings.js" @@ -172,6 +173,36 @@ describe("withPublicUrlOverride", () => { }) }) +describe("portsOfOtherContainers", () => { + it("leaves out the install's own ports", () => { + const portsInUse = portsOfOtherContainers( + [ + { hostPort: 8000, containerName: "vault-cortex" }, + { hostPort: 9100, containerName: "other-app" }, + ], + "vault-cortex", + ) + + expect(portsInUse).toEqual(new Map([[9100, "other-app"]])) + }) + + it.each([ + { label: "after", containerNames: ["vault-cortex", "stopped-test"] }, + { label: "before", containerNames: ["stopped-test", "vault-cortex"] }, + ])( + "keeps a port another container also publishes, listed $label the install's own", + ({ containerNames }) => { + const publishedPorts = containerNames.map((containerName) => { + return { hostPort: 8000, containerName } + }) + + expect(portsOfOtherContainers(publishedPorts, "vault-cortex")).toEqual( + new Map([[8000, "stopped-test"]]), + ) + }, + ) +}) + describe("describeStrandedPublicUrl", () => { const CUSTOM_URL_ENV = "PUBLIC_URL=https://vault.example.com\nPORT=8000\n" diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 4ee5445a4..fd00c85b7 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -84,6 +84,8 @@ const volumeMountArgs = (mode: Mode, instanceName: string): string[] => { */ export type DaemonStatus = "running" | "not-running" | "not-installed" +export type PublishedPort = { hostPort: number; containerName: string } + export type DockerRunner = { /** Whether the container runtime is reachable, stopped, or absent. */ daemonStatus: () => DaemonStatus @@ -103,10 +105,10 @@ export type DockerRunner = { /** True when a named volume exists. */ volumeExists: (volumeName: string) => boolean /** - * Host ports that existing containers publish, stopped ones included, each - * mapped to its container's name. Empty when the runtime can't list them. + * Host ports that existing containers publish, stopped ones included: one + * entry per container and port. Empty when the runtime can't list them. */ - publishedHostPorts: () => ReadonlyMap + publishedHostPorts: () => ReadonlyArray /** * Streams `docker logs` to the terminal; resolves with the docker * process's exit code once the stream ends. @@ -255,9 +257,12 @@ const PUBLISHED_PORTS_FORMAT = /** A published host port; an empty HostPort means the runtime picks one at start. */ const HOST_PORT_PATTERN = /^\d+$/ -/** Reads PUBLISHED_PORTS_FORMAT output into host port → container name. */ -export const parsePublishedHostPorts = (inspectOutput: string): ReadonlyMap => { - const portEntries = inspectOutput.split("\n").flatMap((line) => { +/** + * Reads PUBLISHED_PORTS_FORMAT output into one entry per container and port, + * so a port two containers publish keeps both. + */ +export const parsePublishedHostPorts = (inspectOutput: string): ReadonlyArray => { + return inspectOutput.split("\n").flatMap((line) => { const [inspectedName, ...hostPorts] = line.trim().split(/\s+/) if (!inspectedName) return [] @@ -265,9 +270,8 @@ export const parsePublishedHostPorts = (inspectOutput: string): ReadonlyMap HOST_PORT_PATTERN.test(hostPort)) - .map((hostPort) => [Number(hostPort), containerName] as const) + .map((hostPort) => ({ hostPort: Number(hostPort), containerName })) }) - return new Map(portEntries) } export const createDockerRunner = (): DockerRunner => ({ @@ -317,17 +321,17 @@ export const createDockerRunner = (): DockerRunner => ({ publishedHostPorts: () => { const listResult = spawnSync("docker", ["container", "ls", "-aq"], { encoding: "utf8" }) - if (listResult.status !== 0) return new Map() + if (listResult.status !== 0) return [] const containerIds = listResult.stdout.split("\n").filter(Boolean) - if (containerIds.length === 0) return new Map() + if (containerIds.length === 0) return [] const inspectResult = spawnSync( "docker", ["container", "inspect", "--format", PUBLISHED_PORTS_FORMAT, ...containerIds], { encoding: "utf8" }, ) - if (inspectResult.error) return new Map() + if (inspectResult.error) return [] // Read whatever the exit status: a container removed after the listing // makes inspect exit 1, but it still prints the others. return parsePublishedHostPorts(inspectResult.stdout) diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 32a4a54c3..27d25fd50 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -5,6 +5,7 @@ import { instanceVolumeNames, type DockerRunner } from "./docker.js" import { applyOptionalSettings, askFreePort, + buildPortsInUse, describeStrandedPublicUrl, portInUseMessage, portsOfOtherContainers, @@ -279,7 +280,7 @@ export const resolveInstanceIdentity = async ( } const daemonRunning = docker.daemonStatus() === "running" const portsInUse = portsOfOtherContainers( - daemonRunning ? docker.publishedHostPorts() : new Map(), + daemonRunning ? docker.publishedHostPorts() : [], carriedName, ) // A kept port is the install's own; only a new --port can collide. @@ -300,7 +301,8 @@ export const resolveInstanceIdentity = async ( if (!daemonRunning) prompts.warn(nameCheckSkippedMessage(requestedName)) const requestedNameInUse = daemonRunning && isInstanceNameInUse({ mode, instanceName: requestedName }, docker) - const portsInUse: PortsInUse = daemonRunning ? docker.publishedHostPorts() : new Map() + const publishedPorts = daemonRunning ? docker.publishedHostPorts() : [] + const portsInUse = buildPortsInUse(publishedPorts) // A new folder's .env starts from the template's device name, so a name // given by --instance-name sets it too, whether the name is new or reused. const flagSettings = { @@ -316,7 +318,7 @@ export const resolveInstanceIdentity = async ( const chosen = { ...flagSettings, ...portSetting.settings } return { kind: "ok", identity: { carried: {}, chosen }, portsInUse } } - const portsOutsideReusedInstall = portsOfOtherContainers(portsInUse, requestedName) + const portsOutsideReusedInstall = portsOfOtherContainers(publishedPorts, requestedName) if (flags.instanceName) { prompts.log(`Reusing the data of the existing "${requestedName}" install.`) diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 30a553673..55c849895 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -1,4 +1,5 @@ import { DEFAULT_PORT, isValidPort, stripSurroundingQuotes, type Mode } from "./scaffold.js" +import type { PublishedPort } from "./docker.js" import type { Prompts, SelectOption } from "./prompts.js" type OptionalSettingBase = { @@ -336,12 +337,22 @@ export const askFreePort = async ( return askFreePort({ ...params, suggestedPort: suggestFreePort(params.portsInUse) }, prompts) } -/** Start and restart replace the install's own container, so only other containers' ports are taken. */ +export const buildPortsInUse = (publishedPorts: ReadonlyArray): PortsInUse => { + return new Map(publishedPorts.map(({ hostPort, containerName }) => [hostPort, containerName])) +} + +/** + * Start and restart replace the install's own container, so only other + * containers' ports are taken. A port another container also publishes stays taken. + */ export const portsOfOtherContainers = ( - portsInUse: PortsInUse, + publishedPorts: ReadonlyArray, containerName: string, ): PortsInUse => { - return new Map([...portsInUse].filter(([, portUser]) => portUser !== containerName)) + const otherContainersPorts = publishedPorts.filter((publishedPort) => { + return publishedPort.containerName !== containerName + }) + return buildPortsInUse(otherContainersPorts) } /** Re-prompts until the answer is a zone the runtime recognizes. */ From d3dbbe39144c40385c1c4b4c93bbe1931db42ac2 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:09:14 -0400 Subject: [PATCH 53/57] fix(cli): name a free port when --yes stops on a taken one The --yes error for a taken port told a user who had passed --port to re-run with --port. It now names the first free port instead, which fits whether the taken port came from --port or the default. configure also reuses the instance name requireInitializedDir has already checked, instead of reading INSTANCE_NAME a second time. Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/init.test.ts | 25 +++++++++++++++++++++++-- cli/src/configure.ts | 9 +++------ cli/src/instance-identity.ts | 4 +++- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index e2c9e31db..c81da2abf 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -2091,7 +2091,7 @@ describe("runInit when the instance name is already in use", () => { expect(exitCode).toBe(1) expect(scripted.errors).toEqual([ - 'Port 9000 is already used by the "other-app" container on this machine. Re-run with --port and a free port.', + 'Port 9000 is already used by the "other-app" container on this machine. Re-run with a free port, such as --port 8000.', ]) expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(existingEnv) }) @@ -2400,7 +2400,28 @@ describe("runInit when another container publishes the new install's port", () = expect(exitCode).toBe(1) expect(scripted.errors).toEqual([ - 'Port 9000 is already used by the "other-app" container on this machine. Re-run with --port and a free port.', + 'Port 9000 is already used by the "other-app" container on this machine. Re-run with a free port, such as --port 8000.', + ]) + expect(existsSync(join(targetDir, ".env"))).toBe(false) + }) + + it("stops --yes when the default port is taken, naming the first free one", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts() + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, yes: true }, + { + prompts: scripted.prompts, + docker: dockerWithPorts({ existingNames: [], publishedPorts: [[8000, "other-app"]] }), + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + 'Port 8000 is already used by the "other-app" container on this machine. Re-run with a free port, such as --port 8001.', ]) expect(existsSync(join(targetDir, ".env"))).toBe(false) }) diff --git a/cli/src/configure.ts b/cli/src/configure.ts index 44e7d25af..d0a28171d 100644 --- a/cli/src/configure.ts +++ b/cli/src/configure.ts @@ -8,7 +8,6 @@ import { portsOfOtherContainers, withPublicUrlOverride, } from "./optional-settings.js" -import { readEnvInstanceName } from "./scaffold.js" import type { DockerRunner } from "./docker.js" import type { Prompts } from "./prompts.js" @@ -41,15 +40,13 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): const initialized = requireInitializedDir(flags.dir, prompts) if (!initialized) return 1 - const { targetDir, envFilePath, mode } = initialized + const { targetDir, envFilePath, mode, instanceName } = initialized const envContent = readFileSync(envFilePath, "utf8") const daemonStatus = docker.daemonStatus() - const instanceNameRead = readEnvInstanceName(envFilePath) - // An invalid name stops the restart later, so its ports go unchecked here. const portsInUse = - daemonStatus === "running" && instanceNameRead.kind === "ok" - ? portsOfOtherContainers(docker.publishedHostPorts(), instanceNameRead.instanceName) + daemonStatus === "running" + ? portsOfOtherContainers(docker.publishedHostPorts(), instanceName) : new Map() const pickedOverrides = await askOptionalSettings({ mode, envContent, portsInUse }, prompts) diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 27d25fd50..874a50c05 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -170,7 +170,9 @@ const checkRequestedPort = async ( const message = portInUseMessage(requestedPort, portUser) if (flags.yes) { - prompts.error(`${message} Re-run with --port and a free port.`) + prompts.error( + `${message} Re-run with a free port, such as --port ${suggestFreePort(portsInUse)}.`, + ) return { kind: "exit" } } prompts.warn(message) From c86949816b505121051b796e0b54bf61650940fc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:30:34 -0400 Subject: [PATCH 54/57] fix(cli): check the image before logs streams a container logs streamed whatever container held the install's name, so it could show another app's output while down and restart refused that same container. It now refuses it with the same message. Every command now finds the install's container through containerImage, so containerExists is removed, along with its stale comment about guarding removals. Co-Authored-By: Claude Opus 5.5 --- cli/src/__tests__/command-stubs.ts | 7 ------- cli/src/__tests__/configure.test.ts | 1 - cli/src/__tests__/init.test.ts | 1 - cli/src/__tests__/lifecycle.test.ts | 26 +++++++++++++++++++++----- cli/src/docker.ts | 22 ++++------------------ cli/src/instance-identity.ts | 2 +- cli/src/lifecycle.ts | 9 +++++++-- 7 files changed, 33 insertions(+), 35 deletions(-) diff --git a/cli/src/__tests__/command-stubs.ts b/cli/src/__tests__/command-stubs.ts index fd6071746..297909888 100644 --- a/cli/src/__tests__/command-stubs.ts +++ b/cli/src/__tests__/command-stubs.ts @@ -172,7 +172,6 @@ export const dockerReady: DockerRunner = { dockerRun: () => true, pullImage: () => true, stopAndRemoveContainer: () => true, - containerExists: () => true, containerImage: () => LOCAL_IMAGE, volumeExists: () => true, publishedHostPorts: () => [], @@ -185,7 +184,6 @@ export const dockerReady: DockerRunner = { */ export const dockerReadyEmptyHost: DockerRunner = { ...dockerReady, - containerExists: () => false, containerImage: () => undefined, volumeExists: () => false, } @@ -205,10 +203,6 @@ export const createRecordingDocker = (): { const logsParams: DockerLogsParams[] = [] const docker: DockerRunner = { ...dockerReady, - containerExists: (instanceName) => { - calls.push(`inspect:${instanceName}`) - return true - }, containerImage: (instanceName) => { calls.push(`inspect:${instanceName}`) return LOCAL_IMAGE @@ -237,7 +231,6 @@ export const dockerDown: DockerRunner = { dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - containerExists: () => false, containerImage: () => undefined, volumeExists: () => false, publishedHostPorts: () => [], diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index ad43d7fca..6c5715325 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -458,7 +458,6 @@ describe("runConfigure with picked settings", () => { writeLocalEnv(targetDir) const dockerRunFails: DockerRunner = { ...dockerReady, - containerExists: () => false, containerImage: () => undefined, dockerRun: () => false, } diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index c81da2abf..8d29bb2bc 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1746,7 +1746,6 @@ describe("validatePublicUrl", () => { const dockerWithExisting = (existingNames: ReadonlyArray): DockerRunner => { return { ...dockerDaemonOnly, - containerExists: (instanceName) => existingNames.includes(instanceName), containerImage: (instanceName) => { return existingNames.includes(instanceName) ? LOCAL_IMAGE : undefined }, diff --git a/cli/src/__tests__/lifecycle.test.ts b/cli/src/__tests__/lifecycle.test.ts index 3fbac8f87..6ec6d4279 100644 --- a/cli/src/__tests__/lifecycle.test.ts +++ b/cli/src/__tests__/lifecycle.test.ts @@ -95,7 +95,6 @@ describe("runDown", () => { const removeCalls: string[] = [] const dockerNoContainer: DockerRunner = { ...dockerReady, - containerExists: () => false, containerImage: () => undefined, stopAndRemoveContainer: () => { removeCalls.push("called") @@ -228,7 +227,7 @@ describe("runLogs", () => { const streamCalls: DockerLogsParams[] = [] const dockerNoContainer: DockerRunner = { ...dockerReady, - containerExists: () => false, + containerImage: () => undefined, streamLogs: async (params) => { streamCalls.push(params) return 0 @@ -727,7 +726,6 @@ describe("runRestart", () => { const removeCalls: string[] = [] const dockerFirstStart: DockerRunner = { ...dockerReady, - containerExists: () => false, containerImage: () => undefined, stopAndRemoveContainer: () => { removeCalls.push("called") @@ -828,7 +826,6 @@ describe("commands on an install with a custom INSTANCE_NAME", () => { const dockerNoContainer: DockerRunner = { ...dockerReady, - containerExists: () => false, containerImage: () => undefined, } @@ -883,7 +880,7 @@ describe("commands on an install with a custom INSTANCE_NAME", () => { const exitCode = await runLogs( { dir: targetDir }, - { prompts: scripted.prompts, docker: { ...dockerReady, containerExists: () => false } }, + { prompts: scripted.prompts, docker: { ...dockerReady, containerImage: () => undefined } }, ) expect(exitCode).toBe(1) @@ -1016,6 +1013,25 @@ describe("a container under the install's name that runs another image", () => { ]) }) + it("logs refuses to stream it", async () => { + const targetDir = makeTempTargetDir("vault-cli-foreign-") + writeLocalEnv(targetDir) + const foreign = createForeignContainerDocker() + const scripted = createScriptedPrompts() + + const exitCode = await runLogs( + { dir: targetDir }, + { prompts: scripted.prompts, docker: foreign.docker }, + ) + + expect(exitCode).toBe(1) + expect(foreign.calls).toEqual(["inspect:vault-cortex"]) + expect(scripted.errors).toEqual([ + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so it was left in place.\n` + + `Give this install a name no other container uses: change INSTANCE_NAME in ${targetDir}/.env.`, + ]) + }) + it("restart leaves it in place and starts nothing", async () => { const targetDir = makeTempTargetDir("vault-cli-foreign-") writeLocalEnv(targetDir) diff --git a/cli/src/docker.ts b/cli/src/docker.ts index fd00c85b7..7cbae99c4 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -95,11 +95,9 @@ export type DockerRunner = { pullImage: (image: string) => boolean /** Stops and removes the named container (idempotent). */ stopAndRemoveContainer: (instanceName: string) => boolean - /** True when a container with this name exists, running or stopped. */ - containerExists: (instanceName: string) => boolean /** - * The image the container with this exact name was created from, or - * undefined when no container has the name. + * The image the container with this exact name was created from, running + * or stopped, or undefined when no container has the name. */ containerImage: (instanceName: string) => string | undefined /** True when a named volume exists. */ @@ -291,20 +289,8 @@ export const createDockerRunner = (): DockerRunner => ({ stopAndRemoveContainer: (instanceName) => { return spawnSync("docker", ["rm", "-f", instanceName]).status === 0 }, - // - Callers probe with this before stopAndRemoveContainer: `docker rm -f` on - // a missing container exits 1 on engines < 23 and 0 on >= 23. - // - The printed name is compared because Docker falls back to container-ID - // prefixes, so an all-hex name ("2025") could match another container. - containerExists: (instanceName) => { - const inspectResult = spawnSync( - "docker", - ["container", "inspect", "--format", "{{.Name}}", instanceName], - { encoding: "utf8" }, - ) - - if (inspectResult.status !== 0) return false - return isContainerNamed({ inspectedName: inspectResult.stdout, instanceName }) - }, + // The printed name is compared because Docker falls back to container-ID + // prefixes, so an all-hex name ("2025") could match another container. containerImage: (instanceName) => { const inspectResult = spawnSync( "docker", diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 874a50c05..0db43e367 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -81,7 +81,7 @@ const isInstanceNameInUse = ( params: { mode: Mode; instanceName: string }, docker: DockerRunner, ): boolean => { - if (docker.containerExists(params.instanceName)) return true + if (docker.containerImage(params.instanceName)) return true return instanceVolumeNames(params).some((volumeName) => docker.volumeExists(volumeName)) } diff --git a/cli/src/lifecycle.ts b/cli/src/lifecycle.ts index 9e4dec671..0d60641c6 100644 --- a/cli/src/lifecycle.ts +++ b/cli/src/lifecycle.ts @@ -341,14 +341,19 @@ export const runLogs = async (flags: LogsFlags, deps: LogsDeps): Promise if (!initialized) return 1 if (!ensureDaemonRunning(docker, prompts)) return 1 - const { instanceName } = initialized + const { instanceName, envFilePath } = initialized + const existingImage = docker.containerImage(instanceName) - if (!docker.containerExists(instanceName)) { + if (!existingImage) { prompts.error( `No ${instanceName} container — start it with: npx vault-cortex@latest start --dir "${initialized.targetDir}"`, ) return 1 } + if (!isVaultCortexImage(existingImage)) { + prompts.error(foreignContainerMessage({ instanceName, image: existingImage, envFilePath })) + return 1 + } return await docker.streamLogs({ instanceName, From c9518b81193fba602d6177c9a57b4c0b74655466 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:09:43 -0400 Subject: [PATCH 55/57] fix(cli): fixes from the pre-merge delta review Bug-check findings: - init no longer offers to reuse the data of another app's container that holds the requested name: --yes and --instance-name stop, and the prompt asks for a new name straight away. - upgrade refuses another app's container before downloading the image. - configure asks Docker again before offering the restart, since it may have stopped or started while the prompts were open. - A host-port range ("8000-8010") counts every port in it as taken. - The mode-mismatch refusal applies only to a .env init wrote (one with MCP_AUTH_TOKEN); another app's .env gets the usual re-run question. - The refusal names both remedies: rename the other container, which keeps the install's data, or change INSTANCE_NAME, which starts on new volumes. - The pre-rename vault-mcp image is no longer accepted: the CLI named those containers vault-mcp, so the INSTANCE_NAME lookup never reaches them. Code-quality findings: - One checkInstanceContainer helper replaces four copies of the image check. - askPort folds into askFreePort; portInUseMessage takes named inputs; askOptionalSettings requires portsInUse. - Stale comments and docs corrected (start's replace rule, init's re-run bullets, the second-vault step, the dormant-setup comment, re-created). Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 20 +- cli/src/__tests__/configure.test.ts | 31 +++- cli/src/__tests__/docker.test.ts | 9 +- cli/src/__tests__/init.test.ts | 105 ++++++++++- cli/src/__tests__/lifecycle.test.ts | 15 +- cli/src/__tests__/optional-settings.test.ts | 174 +++++++++++++----- cli/src/__tests__/upgrade.test.ts | 31 +++- cli/src/configure.ts | 19 +- cli/src/docker.ts | 34 ++-- cli/src/env.ts | 4 +- cli/src/init.ts | 45 ++--- cli/src/instance-identity.ts | 83 ++++++--- cli/src/lifecycle.ts | 79 ++++---- cli/src/optional-settings.ts | 67 +++---- cli/src/upgrade.ts | 14 +- deploy/local/.env.example | 2 +- deploy/remote/.env.example | 2 +- deploy/remote/README.md | 5 +- .../etc/s6-overlay/scripts/check-sync-vault | 12 +- 19 files changed, 521 insertions(+), 230 deletions(-) diff --git a/cli/README.md b/cli/README.md index f841893c4..5b8543820 100644 --- a/cli/README.md +++ b/cli/README.md @@ -67,9 +67,9 @@ server. - Stops when the folder holds an install of the other mode (a remote install for a local setup, or the reverse), and points you to [`start`](#start). -- Otherwise asks before anything else. Declining leaves everything unchanged - and points you to [`configure`](#configure), the command for changing - settings in place. +- Asks before changing anything. Declining leaves everything unchanged and + points you to [`configure`](#configure), the command for changing settings + in place. - Never overwrites existing files without asking. - Keeps the install's name, and stops if `--instance-name` gives a different one. @@ -85,10 +85,10 @@ server. npx vault-cortex@latest init --dir ./vault-cortex-work ``` -3. When the machine already has a `vault-cortex` container or data volume, - init asks for the new install's name and port, and suggests the first port - no other container uses. The name is saved as `INSTANCE_NAME` in the - install's `.env` and names its container and data. +3. Give the new install its own name and port when init asks. It asks when + the machine already has a `vault-cortex` container or data volume, and + suggests the first port no other container uses. The name is saved as + `INSTANCE_NAME` in the install's `.env` and names its container and data. 4. Pass that `--dir` to every command for the install, such as `upgrade` or `logs`. Each command acts only on that install. 5. Add the install to your MCP client as its own connector. @@ -168,8 +168,10 @@ npx vault-cortex@latest start This is the command the CLI's own guidance points to whenever the server isn't running — after [`down`](#down), or an `init` where the server wasn't started. It's the same cycle as [`restart`](#restart) under the name you'd -look for: any existing container is replaced, and Docker pulls the server -image automatically on a first start. +look for: the install's existing container is replaced, and Docker pulls the +server image automatically on a first start. A container of another app that +holds the install's name is left alone, and `start` stops with an error that +names it. Use `--dir ` if your config isn't in `./vault-cortex`. diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts index 6c5715325..f76d9f946 100644 --- a/cli/src/__tests__/configure.test.ts +++ b/cli/src/__tests__/configure.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { describe, expect, it, onTestFinished } from "vitest" +import { describe, expect, it, onTestFinished, vi } from "vitest" import { runConfigure } from "../configure.js" import type { DockerRunner } from "../docker.js" @@ -97,6 +97,35 @@ describe("runConfigure with picked settings", () => { ]) }) + it("asks Docker again before offering the restart, in case it stopped during the prompts", async () => { + const targetDir = makeTempTargetDir() + writeLocalEnv(targetDir) + const daemonStatus = vi + .fn() + .mockReturnValueOnce("running") + .mockReturnValue("not-running") + const scripted = createScriptedPrompts([ + ["MEMORY_ENABLED"], + false, // disable the memory layer + ]) + + const exitCode = await runConfigure( + { dir: targetDir }, + { prompts: scripted.prompts, docker: { ...dockerReady, daemonStatus }, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(daemonStatus).toHaveBeenCalledTimes(2) + expect(scripted.warnings).toEqual([ + `Container runtime not running — settings saved.\nApply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"`, + ]) + // The restart question is never asked. + expect(scripted.asked).toEqual([ + "Any optional settings to change? (press enter to skip)", + "Enable the memory layer (About Me/ folder + memory tools)?", + ]) + }) + it("saves the changes and names the missing runtime when Docker is not installed", async () => { const targetDir = makeTempTargetDir() const envFilePath = writeLocalEnv(targetDir) diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index e973dd949..584642484 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -478,6 +478,14 @@ describe("parsePublishedHostPorts", () => { ]) }) + it("counts every port of a published host-port range", () => { + expect(parsePublishedHostPorts("/ranged 18690-18692\n")).toEqual([ + { hostPort: 18690, containerName: "ranged" }, + { hostPort: 18691, containerName: "ranged" }, + { hostPort: 18692, containerName: "ranged" }, + ]) + }) + it("keeps every container that publishes the same host port", () => { const inspectOutput = ["/vault-cortex 8000", "/stopped-test 8000"].join("\n") @@ -531,7 +539,6 @@ describe("isVaultCortexImage", () => { { label: "a Docker Hub tag", image: "aliasunder/vault-cortex:remote" }, { label: "a fully named Docker Hub tag", image: "docker.io/aliasunder/vault-cortex:0.54.16" }, { label: "a digest", image: "ghcr.io/aliasunder/vault-cortex@sha256:0123abcd" }, - { label: "the image's name before the rename", image: "ghcr.io/aliasunder/vault-mcp:latest" }, ])("accepts $label", ({ image }) => { expect(isVaultCortexImage(image)).toBe(true) }) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 8d29bb2bc..48c8d56d2 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -943,7 +943,7 @@ describe("runInit re-init guard", () => { expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe("MCP_AUTH_TOKEN=existing\n") }) - it("stops local init on a folder that holds a remote install, before asking anything", async () => { + it("stops local init on a folder that holds a remote install, before asking about the existing install", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) @@ -972,7 +972,27 @@ describe("runInit re-init guard", () => { expect(dockerRun).not.toHaveBeenCalled() }) - it("stops remote init on a folder that holds a local install, before asking anything", async () => { + it("asks the usual re-run question for a .env that init did not write", async () => { + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + const otherAppEnv = "DATABASE_URL=postgres://localhost/app\n" + writeFileSync(join(targetDir, ".env"), otherAppEnv) + const scripted = createScriptedPrompts([ + false, // do not re-run setup + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { prompts: scripted.prompts, docker: dockerReadyEmptyHost, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.errors).toEqual([]) + expect(scripted.asked).toEqual(["Re-run setup for this directory anyway?"]) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe(otherAppEnv) + }) + + it("stops remote init on a folder that holds a local install, before asking about the existing install", async () => { const targetDir = makeTargetDir() mkdirSync(targetDir, { recursive: true }) const existingEnv = `MCP_AUTH_TOKEN=existing\nVAULT_PATH=${makeVault()}\n` @@ -2249,11 +2269,81 @@ describe("runInit when the instance name is already in use", () => { expect(recording.calls).toEqual(["inspect:vault-cortex", "inspect:vault-cortex"]) }) - it("leaves a container that runs another image in place and does not offer to start", async () => { + it("asks for a new name, without offering reuse, when another app's container holds the default name", async () => { const vaultDir = makeVault() const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "work", + "8001", + [], // no optional settings + false, // don't start now + ]) + const docker: DockerRunner = { + ...dockerWithExisting([]), + containerImage: (instanceName) => { + return instanceName === "vault-cortex" ? "postgres:16" : undefined + }, + } + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).toEqual([ + NAME_QUESTION, + PORT_QUESTION, + "Any optional settings to change? (press enter to skip)", + "Start the server now?", + ]) + expect(scripted.warnings).toEqual([ + 'A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this install needs another name.', + ]) + expect(readIdentityLines(targetDir)).toEqual([ + "PUBLIC_URL=http://localhost:8001", + "INSTANCE_NAME=work", + "PORT=8001", + ]) + }) + + it("stops --instance-name when another app's container holds that name", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts() + const docker: DockerRunner = { + ...dockerWithExisting([]), + containerImage: (instanceName) => { + return instanceName === "work" ? "postgres:16" : undefined + }, + } + + const exitCode = await runInit( + { mode: "local", vaultPath: vaultDir, dir: targetDir, yes: true, instanceName: "work" }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(scripted.errors).toEqual([ + 'A container named "work" runs postgres:16, not Vault Cortex, so this install needs another name. Re-run with --instance-name and a name no other container uses.', + ]) + expect(existsSync(join(targetDir, ".env"))).toBe(false) + }) + + it("leaves a re-run's container that runs another image in place and does not offer to start", async () => { + const vaultDir = makeVault() + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + `MCP_AUTH_TOKEN=old\nVAULT_PATH=${vaultDir}\nPUBLIC_URL=http://localhost:8001\nINSTANCE_NAME=work\nPORT=8001\n`, + ) // No answer for a start question: asking one fails the test. - const scripted = createScriptedPrompts(["reuse", "8000", []]) + const scripted = createScriptedPrompts([ + true, // re-run setup anyway + [], // no optional settings + true, // overwrite the existing file + ]) const recording = createRecordingDocker() const docker: DockerRunner = { ...recording.docker, @@ -2270,10 +2360,11 @@ describe("runInit when the instance name is already in use", () => { expect(exitCode).toBe(0) expect(scripted.errors).toEqual([ - `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so it was left in place.\n` + - `Give this install a name no other container uses: change INSTANCE_NAME in ${targetDir}/.env.`, + `A container named "work" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + "To keep this install's data, rename that container: docker rename work \n" + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, ]) - expect(recording.calls).toEqual(["inspect:vault-cortex", "inspect:vault-cortex"]) + expect(recording.calls).toEqual(["inspect:work"]) }) it("counts a remote install as in use when only its vault volume is left", async () => { diff --git a/cli/src/__tests__/lifecycle.test.ts b/cli/src/__tests__/lifecycle.test.ts index 6ec6d4279..004459644 100644 --- a/cli/src/__tests__/lifecycle.test.ts +++ b/cli/src/__tests__/lifecycle.test.ts @@ -1008,8 +1008,9 @@ describe("a container under the install's name that runs another image", () => { expect(exitCode).toBe(1) expect(foreign.calls).toEqual(["inspect:vault-cortex"]) expect(scripted.errors).toEqual([ - `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so it was left in place.\n` + - `Give this install a name no other container uses: change INSTANCE_NAME in ${targetDir}/.env.`, + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + `To keep this install's data, rename that container: docker rename vault-cortex \n` + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, ]) }) @@ -1027,8 +1028,9 @@ describe("a container under the install's name that runs another image", () => { expect(exitCode).toBe(1) expect(foreign.calls).toEqual(["inspect:vault-cortex"]) expect(scripted.errors).toEqual([ - `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so it was left in place.\n` + - `Give this install a name no other container uses: change INSTANCE_NAME in ${targetDir}/.env.`, + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + `To keep this install's data, rename that container: docker rename vault-cortex \n` + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, ]) }) @@ -1046,8 +1048,9 @@ describe("a container under the install's name that runs another image", () => { expect(exitCode).toBe(1) expect(foreign.calls).toEqual(["inspect:vault-cortex"]) expect(scripted.errors).toEqual([ - `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so it was left in place.\n` + - `Give this install a name no other container uses: change INSTANCE_NAME in ${targetDir}/.env.`, + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + `To keep this install's data, rename that container: docker rename vault-cortex \n` + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, ]) }) }) diff --git a/cli/src/__tests__/optional-settings.test.ts b/cli/src/__tests__/optional-settings.test.ts index 30d5adf2f..2be7e6c2c 100644 --- a/cli/src/__tests__/optional-settings.test.ts +++ b/cli/src/__tests__/optional-settings.test.ts @@ -249,7 +249,10 @@ describe("askOptionalSettings chooser", () => { it("offers the local settings without SYNC_MODE in local mode", async () => { const scripted = createScriptedPrompts([[]]) - await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.multiselectCalls[0].options.map((option) => option.value)).toEqual([ "MEMORY_ENABLED", @@ -267,7 +270,10 @@ describe("askOptionalSettings chooser", () => { it("adds SYNC_MODE to the chooser in remote mode", async () => { const scripted = createScriptedPrompts([[]]) - await askOptionalSettings({ mode: "remote", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "remote", envContent: "" }, + scripted.prompts, + ) expect(scripted.multiselectCalls[0].options.map((option) => option.value)).toEqual([ "MEMORY_ENABLED", @@ -287,7 +293,7 @@ describe("askOptionalSettings chooser", () => { const scripted = createScriptedPrompts([[]]) await askOptionalSettings( - { mode: "local", envContent: "MEMORY_ENABLED=false\nPORT=9000\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=false\nPORT=9000\n" }, scripted.prompts, ) @@ -309,6 +315,7 @@ describe("askOptionalSettings chooser", () => { await askOptionalSettings( { + portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=true\nMEMORY_DIR=About Me\n", }, @@ -324,7 +331,10 @@ describe("askOptionalSettings chooser", () => { it('adds the dependency note for the server-valid disabled spelling "0"', async () => { const scripted = createScriptedPrompts([[]]) - await askOptionalSettings({ mode: "local", envContent: "MEMORY_ENABLED=0\n" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=0\n" }, + scripted.prompts, + ) const memoryFolderOption = scripted.multiselectCalls[0].options.find( (option) => option.value === "MEMORY_DIR", @@ -337,7 +347,10 @@ describe("askOptionalSettings chooser", () => { it("returns no overrides and asks nothing further when nothing is picked", async () => { const scripted = createScriptedPrompts([[]]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(overrides).toEqual({}) expect(scripted.asked).toEqual(["Any optional settings to change? (press enter to skip)"]) @@ -349,7 +362,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["MEMORY_ENABLED"], true]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "MEMORY_ENABLED=false\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=false\n" }, scripted.prompts, ) @@ -367,7 +380,10 @@ describe("askOptionalSettings per-setting prompts", () => { // toggle) — the confirm must start on Yes, not fall to false. const scripted = createScriptedPrompts([["MEMORY_ENABLED"], true]) - await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.confirmCalls).toEqual([ { @@ -382,7 +398,10 @@ describe("askOptionalSettings per-setting prompts", () => { // the confirm at No, not inherit the enabled-unless-"false" heuristic. const scripted = createScriptedPrompts([["READONLY_MODE"], false]) - await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.confirmCalls).toEqual([ { @@ -397,7 +416,10 @@ describe("askOptionalSettings per-setting prompts", () => { // ${VAR:-default} and env-var's .default() — the CLI must match. const scripted = createScriptedPrompts([["READONLY_MODE"], false]) - await askOptionalSettings({ mode: "local", envContent: "READONLY_MODE=\n" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "READONLY_MODE=\n" }, + scripted.prompts, + ) expect(scripted.confirmCalls).toEqual([ { @@ -411,7 +433,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["READONLY_MODE"], true]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "READONLY_MODE=true\n" }, + { portsInUse: new Map(), mode: "local", envContent: "READONLY_MODE=true\n" }, scripted.prompts, ) @@ -427,7 +449,10 @@ describe("askOptionalSettings per-setting prompts", () => { it("offers the three Sync modes the client accepts, seeded with the default when the var is absent", async () => { const scripted = createScriptedPrompts([["SYNC_MODE"], "pull-only"]) - await askOptionalSettings({ mode: "remote", envContent: "" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "remote", envContent: "" }, + scripted.prompts, + ) // The values are what `ob sync-config --mode` accepts; a chooser entry the // client rejects would only surface at the image's first boot. @@ -461,7 +486,10 @@ describe("askOptionalSettings per-setting prompts", () => { // the confirm must not misrepresent MEMORY_ENABLED=0 as enabled. const scripted = createScriptedPrompts([["MEMORY_ENABLED"], false]) - await askOptionalSettings({ mode: "local", envContent: "MEMORY_ENABLED=0\n" }, scripted.prompts) + await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_ENABLED=0\n" }, + scripted.prompts, + ) expect(scripted.confirmCalls).toEqual([ { @@ -476,7 +504,10 @@ describe("askOptionalSettings per-setting prompts", () => { // silently fall back to the default port on every later read. const scripted = createScriptedPrompts([["PORT"], "1e4", "9000"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["PORT must be a whole number between 1 and 65535."]) expect(overrides).toEqual({ PORT: "9000" }) @@ -486,7 +517,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["MEMORY_DIR"], "Memory Bank"]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "MEMORY_DIR=About Me\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_DIR=About Me\n" }, scripted.prompts, ) @@ -508,7 +539,10 @@ describe("askOptionalSettings per-setting prompts", () => { async ({ envContent }) => { const scripted = createScriptedPrompts([["MEMORY_DIR"], ""]) - const overrides = await askOptionalSettings({ mode: "local", envContent }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent }, + scripted.prompts, + ) expect(scripted.errors).toEqual([]) expect(overrides).toEqual({ MEMORY_DIR: "About Me" }) @@ -521,7 +555,10 @@ describe("askOptionalSettings per-setting prompts", () => { // default suppressed — send whitespace, which trims to empty. const scripted = createScriptedPrompts([["MEMORY_DIR"], " ", "Notes"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["The folder name can't be empty."]) expect(overrides).toEqual({ MEMORY_DIR: "Notes" }) @@ -530,7 +567,10 @@ describe("askOptionalSettings per-setting prompts", () => { it("uses the default port on an empty submission", async () => { const scripted = createScriptedPrompts([["PORT"], ""]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(overrides).toEqual({ PORT: "8000" }) }) @@ -539,7 +579,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["PORT"], ""]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "PORT=\n" }, + { portsInUse: new Map(), mode: "local", envContent: "PORT=\n" }, scripted.prompts, ) @@ -553,7 +593,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["PORT"], ""]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "PORT=80a0\n" }, + { portsInUse: new Map(), mode: "local", envContent: "PORT=80a0\n" }, scripted.prompts, ) @@ -568,7 +608,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["SYNC_MODE"], "bidirectional"]) const overrides = await askOptionalSettings( - { mode: "remote", envContent: "SYNC_MODE=pull-only\n" }, + { portsInUse: new Map(), mode: "remote", envContent: "SYNC_MODE=pull-only\n" }, scripted.prompts, ) @@ -583,7 +623,10 @@ describe("askOptionalSettings per-setting prompts", () => { "Europe/London", // accepted ]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ '"Not/AZone" is not a recognized IANA timezone (e.g. America/New_York, Europe/London).', @@ -594,7 +637,10 @@ describe("askOptionalSettings per-setting prompts", () => { it("skips an unset daily notes folder left blank, writing nothing", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) // No pre-filled default when unset — the real default is the vault's // own config, so the prompt must not offer a concrete value to accept. @@ -619,7 +665,10 @@ describe("askOptionalSettings per-setting prompts", () => { async ({ envContent }) => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) - const overrides = await askOptionalSettings({ mode: "local", envContent }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent }, + scripted.prompts, + ) expect(scripted.textCalls).toEqual([ { @@ -643,7 +692,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], ""]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -661,7 +710,10 @@ describe("askOptionalSettings per-setting prompts", () => { it("collects a typed daily notes format, trimmed", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], " DD-MM-YYYY "]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.textCalls).toEqual([ { @@ -681,7 +733,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], " "]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -693,7 +745,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "Planner"]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -711,7 +763,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "Journal"]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -726,7 +778,10 @@ describe("askOptionalSettings per-setting prompts", () => { "YYYY/MM/DD", // format typed ]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY/MM/DD" }) }) @@ -735,7 +790,7 @@ describe("askOptionalSettings per-setting prompts", () => { const scripted = createScriptedPrompts([[]]) await askOptionalSettings( - { mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, + { portsInUse: new Map(), mode: "local", envContent: "DAILY_NOTES_FOLDER=Journal\n" }, scripted.prompts, ) @@ -758,7 +813,7 @@ describe("askFolder validation", () => { ]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "MEMORY_DIR=About Me\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_DIR=About Me\n" }, scripted.prompts, ) @@ -770,7 +825,7 @@ describe("askFolder validation", () => { const scripted = createScriptedPrompts([["MEMORY_DIR"], "/var/data", "My Notes"]) const overrides = await askOptionalSettings( - { mode: "local", envContent: "MEMORY_DIR=About Me\n" }, + { portsInUse: new Map(), mode: "local", envContent: "MEMORY_DIR=About Me\n" }, scripted.prompts, ) @@ -785,7 +840,10 @@ describe("DAILY_NOTES_FOLDER validate callback", () => { it("rejects path traversal", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "../etc", "Journal"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["Path traversal (..) is not allowed in folder names."]) expect(overrides).toEqual({ DAILY_NOTES_FOLDER: "Journal" }) @@ -794,7 +852,10 @@ describe("DAILY_NOTES_FOLDER validate callback", () => { it("rejects absolute paths", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "/var/notes", "Journal"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ "Absolute paths are not allowed — use a vault-relative folder name.", @@ -805,7 +866,10 @@ describe("DAILY_NOTES_FOLDER validate callback", () => { it("accepts / as the vault root", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FOLDER"], "/"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([]) expect(overrides).toEqual({ DAILY_NOTES_FOLDER: "/" }) @@ -816,7 +880,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects path traversal", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "../YYYY-MM-DD", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["Date format must not contain path traversal (..)."]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) @@ -825,7 +892,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects a leading path separator", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "/YYYY-MM-DD", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["Date format must not start with a path separator."]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) @@ -834,7 +904,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects a trailing path separator", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "YYYY-MM-DD/", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual(["Date format must not end with a path separator."]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD" }) @@ -843,7 +916,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects digits outside bracket escapes", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "2024-MM-DD", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.", @@ -854,7 +930,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects digits outside brackets even when brackets contain digits too", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "2024 [Day 2]", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.", @@ -865,7 +944,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("rejects trailing digits in a format string", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "YYYY-2024", "YYYY-MM-DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([ "Date format should use Moment tokens (YYYY, MM, DD), not digits — wrap literal text in [...] brackets.", @@ -876,7 +958,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("accepts digits inside bracket escapes", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "YYYY-MM-DD [Day 1]"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY-MM-DD [Day 1]" }) @@ -885,7 +970,10 @@ describe("DAILY_NOTES_FORMAT validate callback", () => { it("accepts a valid format without digits", async () => { const scripted = createScriptedPrompts([["DAILY_NOTES_FORMAT"], "YYYY/MM/DD"]) - const overrides = await askOptionalSettings({ mode: "local", envContent: "" }, scripted.prompts) + const overrides = await askOptionalSettings( + { portsInUse: new Map(), mode: "local", envContent: "" }, + scripted.prompts, + ) expect(scripted.errors).toEqual([]) expect(overrides).toEqual({ DAILY_NOTES_FORMAT: "YYYY/MM/DD" }) diff --git a/cli/src/__tests__/upgrade.test.ts b/cli/src/__tests__/upgrade.test.ts index 3aa40cd33..68d31886c 100644 --- a/cli/src/__tests__/upgrade.test.ts +++ b/cli/src/__tests__/upgrade.test.ts @@ -118,7 +118,8 @@ describe("runUpgrade", () => { ) expect(exitCode).toBe(0) - expect(recording.calls).toEqual(["inspect:work", "rm:work", "run:work"]) + // Inspected before the pull, then again by the re-create step. + expect(recording.calls).toEqual(["inspect:work", "inspect:work", "rm:work", "run:work"]) expect(recording.runParams.map((params) => params.instanceName)).toEqual(["work"]) }) @@ -216,6 +217,34 @@ describe("runUpgrade", () => { ]) }) + it("refuses a container of another app before downloading the image", async () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-upgrade-")) + writeLocalEnv(targetDir) + const pulledImages: string[] = [] + const docker: DockerRunner = { + ...dockerReady, + containerImage: () => "postgres:16", + pullImage: (image) => { + pulledImages.push(image) + return true + }, + } + const scripted = createScriptedPrompts() + + const exitCode = await runUpgrade( + { dir: targetDir }, + { prompts: scripted.prompts, docker, fetchFn: fetchNever }, + ) + + expect(exitCode).toBe(1) + expect(pulledImages).toEqual([]) + expect(scripted.errors).toEqual([ + `A container named "vault-cortex" runs postgres:16, not Vault Cortex, so this command leaves it alone.\n` + + "To keep this install's data, rename that container: docker rename vault-cortex \n" + + `Or change INSTANCE_NAME in ${targetDir}/.env: the install then starts on new, empty volumes.`, + ]) + }) + it("exits 1 when image pull fails", async () => { const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-upgrade-")) writeLocalEnv(targetDir) diff --git a/cli/src/configure.ts b/cli/src/configure.ts index d0a28171d..7f7e16f14 100644 --- a/cli/src/configure.ts +++ b/cli/src/configure.ts @@ -44,10 +44,10 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): const envContent = readFileSync(envFilePath, "utf8") const daemonStatus = docker.daemonStatus() - const portsInUse = - daemonStatus === "running" - ? portsOfOtherContainers(docker.publishedHostPorts(), instanceName) - : new Map() + const portsInUse = portsOfOtherContainers( + daemonStatus === "running" ? docker.publishedHostPorts() : [], + instanceName, + ) const pickedOverrides = await askOptionalSettings({ mode, envContent, portsInUse }, prompts) if (Object.keys(pickedOverrides).length === 0) { @@ -73,13 +73,14 @@ export const runConfigure = async (flags: ConfigureFlags, deps: ConfigureDeps): if (publicUrlWarning) prompts.warn(publicUrlWarning) const restartHint = `Apply the new settings with: npx vault-cortex@latest restart --dir "${targetDir}"` + // Asked again: Docker may have stopped or started while the prompts were open. + const restartDaemonStatus = docker.daemonStatus() - if (daemonStatus !== "running") { - // Settings are already saved — the runtime state only affects the restart - // offer, so both non-running states degrade to the hint; restart itself - // prints the full install/start guidance when run. + if (restartDaemonStatus !== "running") { + // Settings are already saved, so both non-running states degrade to the + // hint; restart itself prints the full install/start guidance when run. prompts.warn( - daemonStatus === "not-installed" + restartDaemonStatus === "not-installed" ? `No container runtime found — settings saved.\n${restartHint}` : `Container runtime not running — settings saved.\n${restartHint}`, ) diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 7cbae99c4..67dafff6e 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -5,16 +5,11 @@ import type { Mode } from "./scaffold.js" export const LOCAL_IMAGE = "ghcr.io/aliasunder/vault-cortex:latest" export const REMOTE_IMAGE = "ghcr.io/aliasunder/vault-cortex:remote" -/** - * - The image on GHCR and on its Docker Hub mirror, which Docker also reports - * without docker.io/. - * - vault-mcp: the image's name before v0.27.0, which older CLI installs still run. - */ +/** The image on GHCR and on its Docker Hub mirror, which Docker also reports without docker.io/. */ const VAULT_CORTEX_IMAGE_REPOSITORIES = [ "ghcr.io/aliasunder/vault-cortex", "aliasunder/vault-cortex", "docker.io/aliasunder/vault-cortex", - "ghcr.io/aliasunder/vault-mcp", ] /** True for any tag or digest of the Vault Cortex image. */ @@ -217,8 +212,8 @@ export const classifyDaemonStatus = (spawnResult: { } /** - * Whether `docker container inspect --format {{.Name}}` printed this name. - * Docker prints it with a leading slash; Podman and nerdctl print it bare. + * Whether the name `docker container inspect` printed is this name. Docker + * prints it with a leading slash; Podman and nerdctl print it bare. */ export const isContainerNamed = (params: { inspectedName: string @@ -236,7 +231,7 @@ export const parseContainerImage = (params: { inspectOutput: string instanceName: string }): string | undefined => { - const [inspectedName = "", image] = params.inspectOutput.trim().split(" ") + const [inspectedName, image] = params.inspectOutput.trim().split(/\s+/) if (!image || !isContainerNamed({ inspectedName, instanceName: params.instanceName })) { return undefined @@ -252,8 +247,21 @@ export const parseContainerImage = (params: { const PUBLISHED_PORTS_FORMAT = "{{.Name}}{{range $containerPort, $bindings := .HostConfig.PortBindings}}{{range $bindings}} {{.HostPort}}{{end}}{{end}}" -/** A published host port; an empty HostPort means the runtime picks one at start. */ -const HOST_PORT_PATTERN = /^\d+$/ +/** + * A published host port ("8000"), or a range ("8000-8010") the runtime picks one + * port from at start. An empty HostPort means the runtime picks any port. + */ +const HOST_PORT_PATTERN = /^(\d+)(?:-(\d+))?$/ + +/** Every port a HostPort value can claim; a range counts whole, since any of its ports may be the one in use. */ +const expandHostPort = (hostPort: string): number[] => { + const match = HOST_PORT_PATTERN.exec(hostPort) + + if (!match) return [] + const firstPort = Number(match[1]) + const lastPort = match[2] ? Number(match[2]) : firstPort + return Array.from({ length: lastPort - firstPort + 1 }, (_, offset) => firstPort + offset) +} /** * Reads PUBLISHED_PORTS_FORMAT output into one entry per container and port, @@ -266,9 +274,7 @@ export const parsePublishedHostPorts = (inspectOutput: string): ReadonlyArray HOST_PORT_PATTERN.test(hostPort)) - .map((hostPort) => ({ hostPort: Number(hostPort), containerName })) + return hostPorts.flatMap(expandHostPort).map((hostPort) => ({ hostPort, containerName })) }) } diff --git a/cli/src/env.ts b/cli/src/env.ts index 1d306432c..0e9496d1e 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -170,7 +170,7 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: none). # The container's own log (what \`docker logs\` shows) is always written, but -# Docker discards it whenever the container is recreated — on image updates +# Docker discards it whenever the container is re-created — on image updates # or settings changes. Set a path (e.g. /data/logs) to also write date-stamped # log files there; the /data volume keeps them. LOG_DIR=none @@ -335,7 +335,7 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: /data/logs). # The container's own log (what \`docker logs\` shows) is always written, but -# Docker discards it whenever the container is recreated — on image updates +# Docker discards it whenever the container is re-created — on image updates # or settings changes. Log files under LOG_DIR live on the data volume and # survive. Set to "none" to keep only the container log. LOG_DIR=/data/logs diff --git a/cli/src/init.ts b/cli/src/init.ts index c68b813ef..e31f8cc1b 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { join, resolve } from "node:path" import { buildLocalEnv, buildRemoteEnv } from "./env.js" @@ -14,7 +14,6 @@ import { import { healthPollTimeoutMs, healthTimeoutMessage, - isVaultCortexImage, pollHealth, type DockerRunner, } from "./docker.js" @@ -23,11 +22,12 @@ import { describeStrandedCarriedPublicUrl, resolveInstanceIdentity, } from "./instance-identity.js" -import { foreignContainerMessage, reportPublicUrlProbe, resolveDeployment } from "./lifecycle.js" +import { checkInstanceContainer, reportPublicUrlProbe, resolveDeployment } from "./lifecycle.js" import { applyOptionalSettings, askOptionalSettings, describeStrandedPublicUrl, + readOptionalValue, withPublicUrlOverride, } from "./optional-settings.js" import { @@ -252,9 +252,16 @@ const confirmOverwrite = */ const describeModeMismatch = (params: { targetDir: string; mode: Mode }): string | undefined => { const { targetDir, mode } = params - const existingMode = detectMode(join(targetDir, ".env")) + const envFilePath = join(targetDir, ".env") + const existingMode = detectMode(envFilePath) if (!existingMode || existingMode === mode) return undefined + // detectMode reads any .env without a Sync token as local, but only a file + // init wrote has MCP_AUTH_TOKEN; another app's .env gets the usual re-run question. + const writtenByInit = + readOptionalValue(readFileSync(envFilePath, "utf8"), "MCP_AUTH_TOKEN") !== undefined + + if (!writtenByInit) return undefined return ( `${targetDir} holds a ${existingMode} install, and this run sets up a ${mode} one.\n` + `To start the existing install: ${startCommand(targetDir)}\n` + @@ -305,11 +312,11 @@ const reportWrites = ( /** * Offers to start the container, walking a gate ladder where each failed - * gate degrades to instructions instead of an error: daemon running → user - * consents → any container with this name is removed → docker run succeeds - * → health check passes. Returns "running" - * when the server is confirmed up, "starting" when the container launched - * but the health check timed out, or "not-started" when a gate failed. + * gate degrades to instructions instead of an error: daemon running → no + * other image's container holds the name → user consents → any container with + * this name is removed → docker run succeeds → health check passes. Returns + * "running" when the server is confirmed up, "starting" when the container + * launched but the health check timed out, or "not-started" when a gate failed. */ const offerDockerRun = async ( params: { targetDir: string; instanceName: string; port: number; mode: Mode; vaultPath?: string }, @@ -330,21 +337,16 @@ const offerDockerRun = async ( ) return "not-started" } - const existingImage = docker.containerImage(instanceName) + const envFilePath = join(targetDir, ".env") + const instanceContainer = checkInstanceContainer( + { instanceName, envFilePath }, + { prompts, docker }, + ) - if (existingImage && !isVaultCortexImage(existingImage)) { - prompts.error( - foreignContainerMessage({ - instanceName, - image: existingImage, - envFilePath: join(targetDir, ".env"), - }), - ) - return "not-started" - } + if (instanceContainer === "foreign") return "not-started" // docker run refuses a name an existing container already holds, so a yes // replaces that container the way `start` does, and the question says so. - const containerExists = Boolean(existingImage) + const containerExists = instanceContainer === "own" const startQuestion = containerExists ? `Start the server now? This stops and replaces the existing "${instanceName}" container. Its data volumes are kept.` : "Start the server now?" @@ -355,7 +357,6 @@ const offerDockerRun = async ( prompts.error(`Could not remove the existing container — check: docker rm -f ${instanceName}`) return "not-started" } - const envFilePath = join(targetDir, ".env") stripEnvQuotedValues(envFilePath) const containerStarted = docker.dockerRun({ mode, diff --git a/cli/src/instance-identity.ts b/cli/src/instance-identity.ts index 0db43e367..8b91f5cdf 100644 --- a/cli/src/instance-identity.ts +++ b/cli/src/instance-identity.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from "node:fs" import { basename, join } from "node:path" -import { instanceVolumeNames, type DockerRunner } from "./docker.js" +import { instanceVolumeNames, isVaultCortexImage, type DockerRunner } from "./docker.js" import { applyOptionalSettings, askFreePort, @@ -72,19 +72,22 @@ const readCarriedIdentity = (envFilePath: string, mode: Mode): Record { - if (docker.containerImage(params.instanceName)) return true return instanceVolumeNames(params).some((volumeName) => docker.volumeExists(volumeName)) } +/** Whether another install already uses this name on the machine: its container or one of its volumes exists. */ +const isInstanceNameInUse = ( + params: { mode: Mode; instanceName: string }, + docker: DockerRunner, +): boolean => { + return Boolean(docker.containerImage(params.instanceName)) || hasInstanceVolumes(params, docker) +} + const EXAMPLE_INSTANCE_NAME = "work" /** Re-prompts until the answer is a valid name no other install on the machine uses. */ @@ -167,7 +170,7 @@ const checkRequestedPort = async ( const portUser = portsInUse.get(Number(requestedPort)) if (!portUser) return { kind: "ok", settings: flags.port ? { PORT: flags.port } : {} } - const message = portInUseMessage(requestedPort, portUser) + const message = portInUseMessage({ port: requestedPort, containerName: portUser }) if (flags.yes) { prompts.error( @@ -222,8 +225,8 @@ const nameInUseWithYesFlagMessage = (instanceName: string): string => { * --yes cannot ask, so it exits unless --instance-name settles it; * - a new install's port, and a re-run's --port, must not be one another * container publishes. - * The checks need a running Docker daemon; without one a new install's are - * skipped with a warning, and a re-run's --port is not checked. + * The checks need a running Docker daemon; without one a new install's checks + * are skipped with a warning, and a re-run's --port is not checked. */ export const resolveInstanceIdentity = async ( params: { mode: Mode; targetDir: string; flags: InstanceIdentityFlags }, @@ -254,7 +257,7 @@ export const resolveInstanceIdentity = async ( const existingEnvPath = join(targetDir, ".env") // An existing .env makes this folder an install already, and its own - // container and volumes would always count as in use, so a re-run skips the check. + // container and volumes would always count as in use, so a re-run skips the name check. if (existsSync(existingEnvPath)) { const carried = readCarriedIdentity(existingEnvPath, mode) const carriedName = carried.INSTANCE_NAME || DEFAULT_INSTANCE_NAME @@ -301,8 +304,11 @@ export const resolveInstanceIdentity = async ( // Without Docker the check can't see another install's container or // volumes, and a shared name would only surface when start replaces them. if (!daemonRunning) prompts.warn(nameCheckSkippedMessage(requestedName)) + const requestedNameImage = daemonRunning ? docker.containerImage(requestedName) : undefined const requestedNameInUse = - daemonRunning && isInstanceNameInUse({ mode, instanceName: requestedName }, docker) + daemonRunning && + (Boolean(requestedNameImage) || + hasInstanceVolumes({ mode, instanceName: requestedName }, docker)) const publishedPorts = daemonRunning ? docker.publishedHostPorts() : [] const portsInUse = buildPortsInUse(publishedPorts) // A new folder's .env starts from the template's device name, so a name @@ -321,7 +327,22 @@ export const resolveInstanceIdentity = async ( return { kind: "ok", identity: { carried: {}, chosen }, portsInUse } } const portsOutsideReusedInstall = portsOfOtherContainers(publishedPorts, requestedName) - + // Another app's container holds no install's data, so its name can't be + // reused: --yes and --instance-name stop, and the prompt asks for a new name. + const otherAppImage = + requestedNameImage && !isVaultCortexImage(requestedNameImage) ? requestedNameImage : undefined + + if (otherAppImage) { + const nameTakenMessage = `A container named "${requestedName}" runs ${otherAppImage}, not Vault Cortex, so this install needs another name.` + + if (flags.instanceName || flags.yes) { + prompts.error( + `${nameTakenMessage} Re-run with --instance-name and a name no other container uses.`, + ) + return { kind: "exit" } + } + prompts.warn(nameTakenMessage) + } if (flags.instanceName) { prompts.log(`Reusing the data of the existing "${requestedName}" install.`) const reusedPort = await askReusedPort( @@ -338,22 +359,24 @@ export const resolveInstanceIdentity = async ( return { kind: "exit" } } - const choice = await prompts.select( - `An install named "${requestedName}" already exists on this machine (its container or data volumes are here). What should this install do?`, - [ - { - value: "own-name", - label: "Give this install its own name", - hint: "a second vault, with its own container, data and port", - }, - { - value: "reuse", - label: "Reuse the existing data — this is the same vault set up again", - hint: `replaces the "${requestedName}" container`, - }, - ], - "own-name", - ) + const choice = otherAppImage + ? "own-name" + : await prompts.select( + `An install named "${requestedName}" already exists on this machine (its container or data volumes are here). What should this install do?`, + [ + { + value: "own-name", + label: "Give this install its own name", + hint: "a second vault, with its own container, data and port", + }, + { + value: "reuse", + label: "Reuse the existing data — this is the same vault set up again", + hint: `replaces the "${requestedName}" container`, + }, + ], + "own-name", + ) if (choice === "reuse") { const reusedPort = await askReusedPort( diff --git a/cli/src/lifecycle.ts b/cli/src/lifecycle.ts index 0d60641c6..917962a1e 100644 --- a/cli/src/lifecycle.ts +++ b/cli/src/lifecycle.ts @@ -195,20 +195,25 @@ export const reportPublicUrlProbe = async ( } /** - * Refusal for a container that holds the install's name but runs another - * image: INSTANCE_NAME then names someone else's container, which a command - * must not stop or remove. + * Looks up the container that holds the install's name. One running another + * image is someone else's, so no command may stop, remove or stream it: this + * reports the refusal, and callers stop on "foreign". */ -export const foreignContainerMessage = (params: { - instanceName: string - image: string - envFilePath: string -}): string => { - const { instanceName, image, envFilePath } = params - return ( - `A container named "${instanceName}" runs ${image}, not Vault Cortex, so it was left in place.\n` + - `Give this install a name no other container uses: change INSTANCE_NAME in ${envFilePath}.` +export const checkInstanceContainer = ( + params: { instanceName: string; envFilePath: string }, + deps: { prompts: Prompts; docker: DockerRunner }, +): "none" | "own" | "foreign" => { + const { instanceName, envFilePath } = params + const image = deps.docker.containerImage(instanceName) + + if (!image) return "none" + if (isVaultCortexImage(image)) return "own" + deps.prompts.error( + `A container named "${instanceName}" runs ${image}, not Vault Cortex, so this command leaves it alone.\n` + + `To keep this install's data, rename that container: docker rename ${instanceName} \n` + + `Or change INSTANCE_NAME in ${envFilePath}: the install then starts on new, empty volumes.`, ) + return "foreign" } /** @@ -223,24 +228,18 @@ export const recreateContainer = async ( const { deployment, healthTimeoutMs } = params const { prompts, docker, fetchFn } = deps const { instanceName } = deployment - const existingImage = docker.containerImage(instanceName) + const instanceContainer = checkInstanceContainer( + { instanceName, envFilePath: deployment.envFilePath }, + { prompts, docker }, + ) - if (existingImage && !isVaultCortexImage(existingImage)) { - prompts.error( - foreignContainerMessage({ - instanceName, - image: existingImage, - envFilePath: deployment.envFilePath, - }), - ) - return 1 - } + if (instanceContainer === "foreign") return 1 // `docker rm -f` on a missing container exits non-zero on engines < 23, // so the bare return value can't distinguish "nothing to remove" from a // real failure — only treat removal as failed when a container exists. // Proceeding after a genuine failure would surface as a confusing // "container name already in use" from docker run. - if (existingImage && !docker.stopAndRemoveContainer(instanceName)) { + if (instanceContainer === "own" && !docker.stopAndRemoveContainer(instanceName)) { prompts.error(`Could not remove the existing container — check: docker rm -f ${instanceName}`) return 1 } @@ -302,18 +301,18 @@ export const runDown = async (flags: DownFlags, deps: DownDeps): Promise if (!initialized) return 1 if (!ensureDaemonRunning(docker, prompts)) return 1 - const { instanceName, envFilePath } = initialized - const existingImage = docker.containerImage(instanceName) + const { instanceName, envFilePath, targetDir } = initialized + const instanceContainer = checkInstanceContainer( + { instanceName, envFilePath }, + { prompts, docker }, + ) - if (!existingImage) { + if (instanceContainer === "foreign") return 1 + if (instanceContainer === "none") { prompts.log(`No ${instanceName} container found — nothing to stop.`) prompts.outro("Done.") return 0 } - if (!isVaultCortexImage(existingImage)) { - prompts.error(foreignContainerMessage({ instanceName, image: existingImage, envFilePath })) - return 1 - } if (!docker.stopAndRemoveContainer(instanceName)) { prompts.error(`Could not remove the container — check: docker rm -f ${instanceName}`) @@ -323,7 +322,7 @@ export const runDown = async (flags: DownFlags, deps: DownDeps): Promise prompts.log( "Container stopped and removed. Your vault data, search index, and settings are untouched.", ) - prompts.outro(`Start again with: npx vault-cortex@latest start --dir "${initialized.targetDir}"`) + prompts.outro(`Start again with: npx vault-cortex@latest start --dir "${targetDir}"`) return 0 } @@ -341,19 +340,19 @@ export const runLogs = async (flags: LogsFlags, deps: LogsDeps): Promise if (!initialized) return 1 if (!ensureDaemonRunning(docker, prompts)) return 1 - const { instanceName, envFilePath } = initialized - const existingImage = docker.containerImage(instanceName) + const { instanceName, envFilePath, targetDir } = initialized + const instanceContainer = checkInstanceContainer( + { instanceName, envFilePath }, + { prompts, docker }, + ) - if (!existingImage) { + if (instanceContainer === "foreign") return 1 + if (instanceContainer === "none") { prompts.error( - `No ${instanceName} container — start it with: npx vault-cortex@latest start --dir "${initialized.targetDir}"`, + `No ${instanceName} container — start it with: npx vault-cortex@latest start --dir "${targetDir}"`, ) return 1 } - if (!isVaultCortexImage(existingImage)) { - prompts.error(foreignContainerMessage({ instanceName, image: existingImage, envFilePath })) - return 1 - } return await docker.streamLogs({ instanceName, diff --git a/cli/src/optional-settings.ts b/cli/src/optional-settings.ts index 55c849895..94ac488d8 100644 --- a/cli/src/optional-settings.ts +++ b/cli/src/optional-settings.ts @@ -290,29 +290,11 @@ const isValidTimezone = (value: string): boolean => { } } -/** Re-prompts until the answer is a valid port number. */ -const askPort = async (currentValue: string | undefined, prompts: Prompts): Promise => { - // An empty PORT= line leaves the default port in effect, as readEnvPort reads - // it; a value that isn't a port is never offered, since it would be rejected. - const suggestedPort = - currentValue && isValidPort(currentValue) ? currentValue : String(DEFAULT_PORT) - const answer = ( - await prompts.text("Host port for the server:", { - defaultValue: suggestedPort, - placeholder: suggestedPort, - }) - ).trim() - - if (isValidPort(answer)) return answer - prompts.error("PORT must be a whole number between 1 and 65535.") - return askPort(currentValue, prompts) -} - /** Host ports that containers on this machine publish, mapped to each container's name. */ export type PortsInUse = ReadonlyMap -export const portInUseMessage = (port: string, containerName: string): string => { - return `Port ${port} is already used by the "${containerName}" container on this machine.` +export const portInUseMessage = (params: { port: string; containerName: string }): string => { + return `Port ${params.port} is already used by the "${params.containerName}" container on this machine.` } /** The first port, counting up from the default, that no container publishes. */ @@ -328,13 +310,28 @@ export const askFreePort = async ( params: { suggestedPort: string | undefined; portsInUse: PortsInUse }, prompts: Prompts, ): Promise => { - const answer = await askPort(params.suggestedPort, prompts) - const portUser = params.portsInUse.get(Number(answer)) + const { suggestedPort, portsInUse } = params + // An empty PORT= line leaves the default port in effect, as readEnvPort reads + // it; a value that isn't a port is never offered, since it would be rejected. + const offeredPort = + suggestedPort && isValidPort(suggestedPort) ? suggestedPort : String(DEFAULT_PORT) + const answer = ( + await prompts.text("Host port for the server:", { + defaultValue: offeredPort, + placeholder: offeredPort, + }) + ).trim() + + if (!isValidPort(answer)) { + prompts.error("PORT must be a whole number between 1 and 65535.") + return askFreePort(params, prompts) + } + const portUser = portsInUse.get(Number(answer)) if (!portUser) return answer - prompts.error(`${portInUseMessage(answer, portUser)} Pick another port.`) - // A free suggestion, so pressing enter can't offer the taken port again. - return askFreePort({ ...params, suggestedPort: suggestFreePort(params.portsInUse) }, prompts) + prompts.error(`${portInUseMessage({ port: answer, containerName: portUser })} Pick another port.`) + // Suggest a free port, so pressing Enter can't pick the taken one again. + return askFreePort({ ...params, suggestedPort: suggestFreePort(portsInUse) }, prompts) } export const buildPortsInUse = (publishedPorts: ReadonlyArray): PortsInUse => { @@ -498,18 +495,22 @@ const askSettingValue = async ( } /** - * The guided optional-settings flow shared by init and configure: one - * multiselect chooser (enter with nothing picked = change nothing), then one - * prompt per picked setting. Returns the chosen values keyed by var name — - * the caller owns applying them via applyOptionalSettings. The port prompt - * refuses a port in portsInUse: the ports other containers publish, empty - * when Docker can't be asked. + * The guided optional-settings flow shared by init and configure: + * - one multiselect chooser (enter with nothing picked = change nothing), then + * one prompt per picked setting; + * - returns the chosen values keyed by var name, which the caller applies with + * applyOptionalSettings. */ export const askOptionalSettings = async ( - params: { mode: Mode; envContent: string; portsInUse?: PortsInUse }, + params: { + mode: Mode + envContent: string + /** Ports other containers publish, which the port prompt refuses; empty when Docker can't be asked. */ + portsInUse: PortsInUse + }, prompts: Prompts, ): Promise> => { - const { mode, envContent, portsInUse = new Map() } = params + const { mode, envContent, portsInUse } = params const offeredSettings = OPTIONAL_SETTINGS.filter( (setting) => !setting.remoteOnly || mode === "remote", ) diff --git a/cli/src/upgrade.ts b/cli/src/upgrade.ts index bafcf4677..c0fb9519f 100644 --- a/cli/src/upgrade.ts +++ b/cli/src/upgrade.ts @@ -1,5 +1,10 @@ import { LOCAL_IMAGE, REMOTE_IMAGE, type DockerRunner } from "./docker.js" -import { ensureDaemonRunning, recreateContainer, resolveDeployment } from "./lifecycle.js" +import { + checkInstanceContainer, + ensureDaemonRunning, + recreateContainer, + resolveDeployment, +} from "./lifecycle.js" import type { Prompts } from "./prompts.js" export type UpgradeFlags = { @@ -31,6 +36,13 @@ export const runUpgrade = async (flags: UpgradeFlags, deps: UpgradeDeps): Promis const image = deployment.mode === "local" ? LOCAL_IMAGE : REMOTE_IMAGE if (!ensureDaemonRunning(docker, prompts)) return 1 + // Checked before the download, which recreateContainer's own check follows. + const instanceContainer = checkInstanceContainer( + { instanceName: deployment.instanceName, envFilePath: deployment.envFilePath }, + { prompts, docker }, + ) + + if (instanceContainer === "foreign") return 1 const spinner = prompts.spinner() spinner.start(`Pulling ${image}`) diff --git a/deploy/local/.env.example b/deploy/local/.env.example index 79bf0e3a6..77ed95ac5 100644 --- a/deploy/local/.env.example +++ b/deploy/local/.env.example @@ -160,7 +160,7 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: none). # The container's own log (what `docker logs` shows) is always written, but -# Docker discards it whenever the container is recreated — on image updates +# Docker discards it whenever the container is re-created — on image updates # or settings changes. Set a path (e.g. /data/logs) to also write date-stamped # log files there; the /data volume keeps them. LOG_DIR=none diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index 6b7d30e25..24a18191a 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -166,7 +166,7 @@ LOG_LEVEL=info # Directory for persistent log files inside the container (default: /data/logs). # The container's own log (what `docker logs` shows) is always written, but -# Docker discards it whenever the container is recreated — on image updates +# Docker discards it whenever the container is re-created — on image updates # or settings changes. Log files under LOG_DIR live on the data volume and # survive. Set to "none" to keep only the container log. LOG_DIR=/data/logs diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 718cf2327..454ebbb32 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -500,9 +500,8 @@ volumes, port, Obsidian Sync device and connector. Give every install after the first its own `INSTANCE_NAME` before its first start. Docker uses that name for the install's container and volumes (`_vault_data`, `_mcp_data` and `_obsidian_config`); the first install keeps the -default, `vault-cortex`. A second vault must never -use the first install's volumes: the container refuses to start rather than -merge the two vaults (see +default, `vault-cortex`. A second vault must never use the first install's +volumes: the container refuses to start rather than merge the two vaults (see [Another vault's Sync setup](#another-vaults-sync-setup)). **Set up with the CLI?** Start Docker, so `init` can check which names and diff --git a/rootfs/etc/s6-overlay/scripts/check-sync-vault b/rootfs/etc/s6-overlay/scripts/check-sync-vault index 63d01fd0e..3e3fba682 100644 --- a/rootfs/etc/s6-overlay/scripts/check-sync-vault +++ b/rootfs/etc/s6-overlay/scripts/check-sync-vault @@ -179,12 +179,12 @@ if [ -z "$PICKED_ID" ]; then fi if [ "$PICKED_ID" = "$REQUESTED_ID" ]; then - # A second setup for another vault is dormant while the engine picks this - # one, so the boot continues; removing it keeps it from being picked later. - # Only setups whose path is this folder are listed, since ob never picks one - # stored for another folder. - # The listing only feeds this warning, so a failed listing is reported as a - # warning instead of stopping a boot the checks above already passed. + # - A second setup for another vault is dormant while the engine picks this + # one, so the boot continues; removing it keeps it from being picked later. + # - The warning reports only setups stored for this folder: ob never picks + # one stored for another folder. + # - The listing only feeds this warning, so a failed listing is reported as + # a warning instead of stopping a boot the checks above already passed. if ! LOCAL_SETUPS=$(run_ob sync-list-local --json 2>/dev/null) || ! OTHER_IDS=$(printf '%s' "$LOCAL_SETUPS" | REQUESTED_ID="$REQUESTED_ID" node --no-warnings -e ' const { resolve } = require("node:path") From a8035a4ebd4b852811e7ef8d0842115b8d90dfa0 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:40:57 -0400 Subject: [PATCH 56/57] docs: say why a docker run install keeps PORT at 8000 Co-Authored-By: Claude Opus 5.5 --- deploy/remote/README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 454ebbb32..aad9ddc4f 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -529,10 +529,11 @@ under [Setup](#setup), with its own `MCP_AUTH_TOKEN`, `VAULT_NAME` and `docker compose` commands from its own folder. **Set up with `docker run`?** Set up a new folder with its own `.env` as -above, but leave `PORT` at `8000`, because the `docker run` command does not -override it. Then run the command from [Setup](#setup) with `vault-cortex` -replaced by the new name in `--name`, `--hostname` and the volume names, and -with the new host port in `-p` (`-p 8001:8000`). +above, but keep `PORT` at `8000`. With `docker run`, `PORT` is the port the +server listens on inside the container, and the command's `-p` mapping and +health check both expect `8000`. Then run the command from [Setup](#setup) +with `vault-cortex` replaced by the new name in `--name`, `--hostname` and the +volume names, and with the new host port on the left of `-p` (`-p 8001:8000`). For each install: From d53c94773453ee6c68a05fa3cb1193372484f1aa Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:42:07 -0400 Subject: [PATCH 57/57] docs: label the docker run paragraph for a second vault like the Updating one Co-Authored-By: Claude Opus 5.5 --- deploy/remote/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/remote/README.md b/deploy/remote/README.md index aad9ddc4f..4adbf5298 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -528,7 +528,7 @@ under [Setup](#setup), with its own `MCP_AUTH_TOKEN`, `VAULT_NAME` and [Configuration](#configuration)) and a free `PORT`. Run each install's `docker compose` commands from its own folder. -**Set up with `docker run`?** Set up a new folder with its own `.env` as +**Set up with `docker run` (no Compose)?** Set up a new folder with its own `.env` as above, but keep `PORT` at `8000`. With `docker run`, `PORT` is the port the server listens on inside the container, and the command's `-p` mapping and health check both expect `8000`. Then run the command from [Setup](#setup)