From 86b22f573cd22f90c7881befedce6c96979e824d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:40:29 -0400 Subject: [PATCH 1/3] fix(cli): three-valued start status prevents destructive restart after health timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a health-poll timeout, the Connect box said "Start the server:" while the spinner had just said "the container keeps starting in the background." Following that instruction kills the still-syncing container and restarts from scratch. The root cause: offerDockerRun returned boolean, collapsing "never started" and "started but still coming up" into the same false. Widen the return to StartStatus ("running" | "starting" | "not-started") and thread it through both connect-message builders. The "starting" variant shows "starting in the background — check progress: docker logs" instead of the start command that would re-create the container. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/messages.test.ts | 71 ++++++++++++++++++++++++------ cli/src/init.ts | 33 ++++++++------ cli/src/messages.ts | 43 +++++++++++------- 3 files changed, 104 insertions(+), 43 deletions(-) diff --git a/cli/src/__tests__/messages.test.ts b/cli/src/__tests__/messages.test.ts index 1aaae57b3..4a52a497c 100644 --- a/cli/src/__tests__/messages.test.ts +++ b/cli/src/__tests__/messages.test.ts @@ -25,7 +25,7 @@ const expectedSectionRule = (label: string): string => const localDefaults = { targetDir: "/home/user/vault-cortex", token: "abc123deadbeef", - started: false, + startStatus: "not-started" as const, port: 8000, tokenWritten: true, } @@ -34,7 +34,7 @@ const remoteDefaults = { targetDir: "/home/user/vault-cortex", token: "abc123deadbeef", publicUrl: "https://vault.example.com", - started: false, + startStatus: "not-started" as const, obsidianTokenMissing: false, tokenWritten: true, } @@ -65,20 +65,20 @@ describe("buildLocalConnectMessage", () => { expect(message).toContain("http://localhost:9999/healthz") }) - it("shows 'The server is running.' when started is true", () => { + it("shows 'The server is running.' when startStatus is running", () => { const message = buildLocalConnectMessage({ ...localDefaults, - started: true, + startStatus: "running", }) expect(message).toContain("The server is running.") expect(message).not.toContain("Start the server:") }) - it("shows the start command when started is false", () => { + it("shows the start command when startStatus is not-started", () => { const message = buildLocalConnectMessage({ ...localDefaults, - started: false, + startStatus: "not-started", }) // Bound to the start line specifically — the update-guidance block also @@ -154,10 +154,31 @@ describe("buildLocalConnectMessage", () => { expect(message).toContain("curl http://localhost:8000/healthz") }) - it("omits the smoke test once the server is started", () => { + it("shows 'starting in the background' when startStatus is starting", () => { const message = buildLocalConnectMessage({ ...localDefaults, - started: true, + startStatus: "starting", + }) + + expect(message).toContain("starting in the background") + expect(message).toContain("docker logs vault-cortex") + expect(message).not.toContain("Start the server:") + expect(message).not.toContain("npx vault-cortex@latest start") + }) + + it("shows the smoke test when startStatus is starting", () => { + const message = buildLocalConnectMessage({ + ...localDefaults, + startStatus: "starting", + }) + + expect(message).toContain("Smoke test:") + }) + + it("omits the smoke test once the server is running", () => { + const message = buildLocalConnectMessage({ + ...localDefaults, + startStatus: "running", }) // The CLI just health-checked this exact URL. The curl auth guidance must @@ -199,10 +220,10 @@ describe("buildRemoteConnectMessage", () => { expect(message).toContain("https://my-vault.example.com/healthz") }) - it("shows 'The server is running.' when started is true", () => { + it("shows 'The server is running.' when startStatus is running", () => { const message = buildRemoteConnectMessage({ ...remoteDefaults, - started: true, + startStatus: "running", }) expect(message).toContain("The server is running.") @@ -210,10 +231,32 @@ describe("buildRemoteConnectMessage", () => { expect(message).not.toContain("Fill in OBSIDIAN_AUTH_TOKEN") }) + it("shows 'starting in the background' when startStatus is starting", () => { + const message = buildRemoteConnectMessage({ + ...remoteDefaults, + startStatus: "starting", + }) + + expect(message).toContain("starting in the background") + expect(message).toContain("docker logs vault-cortex") + expect(message).not.toContain("Start the server:") + expect(message).not.toContain("npx vault-cortex@latest start") + }) + + it("shows the health check block when startStatus is starting", () => { + const message = buildRemoteConnectMessage({ + ...remoteDefaults, + startStatus: "starting", + }) + + expect(message).toContain("Health check — works from any device") + expect(message).not.toContain("Smoke test:") + }) + it("shows 'Fill in OBSIDIAN_AUTH_TOKEN' when obsidianTokenMissing and not started", () => { const message = buildRemoteConnectMessage({ ...remoteDefaults, - started: false, + startStatus: "not-started", obsidianTokenMissing: true, }) @@ -225,7 +268,7 @@ describe("buildRemoteConnectMessage", () => { it("shows the start command when not started and obsidian token present", () => { const message = buildRemoteConnectMessage({ ...remoteDefaults, - started: false, + startStatus: "not-started", obsidianTokenMissing: false, }) @@ -323,10 +366,10 @@ describe("buildRemoteConnectMessage", () => { expect(message).not.toContain("works from any device") }) - it("rewords the health check as the any-device check once started", () => { + it("rewords the health check as the any-device check once running", () => { const message = buildRemoteConnectMessage({ ...remoteDefaults, - started: true, + startStatus: "running", }) // Unlike local, the command survives a confirmed start: the CLI verified diff --git a/cli/src/init.ts b/cli/src/init.ts index 7cbc96dda..9706f9105 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -9,6 +9,7 @@ import { buildLocalConnectMessage, buildRemoteConnectMessage, startCommand, + type StartStatus, } from "./messages.js" import { healthPollTimeoutMs, @@ -245,7 +246,7 @@ const reportWrites = ( const offerDockerRun = async ( params: { targetDir: string; port: number; mode: Mode; vaultPath?: string }, deps: InitDeps, -): Promise => { +): Promise => { const { targetDir, port, mode, vaultPath } = params const { prompts, docker, fetchFn } = deps const daemonStatus = docker.daemonStatus() @@ -258,10 +259,10 @@ const offerDockerRun = async ( }) : buildDaemonNotRunningMessage(`, then run:\n ${startHint}`), ) - return false + return "not-started" } const startNow = await prompts.confirm("Start the server now?", true) - if (!startNow) return false + if (!startNow) return "not-started" const containerStarted = docker.dockerRun({ mode, envFilePath: join(targetDir, ".env"), @@ -270,7 +271,7 @@ const offerDockerRun = async ( }) if (!containerStarted) { prompts.error("docker run failed — see output above.") - return false + return "not-started" } const spinner = prompts.spinner() @@ -284,10 +285,10 @@ const offerDockerRun = async ( ) if (!healthy) { spinner.stop(healthTimeoutMessage(mode, timeoutMs)) - return false + return "starting" } spinner.stop("Server is up — health check passed.") - return true + return "running" } // Local flow: resolve vault path → resolve target dir → generate token → @@ -396,11 +397,17 @@ const runLocalInit = async ( const port = readEnvPort(join(targetDir, ".env")) // --yes is for scripts/CI, so it never starts Docker. - const started = flags.yes - ? false + const startStatus: StartStatus = flags.yes + ? "not-started" : await offerDockerRun({ targetDir, port, mode: "local", vaultPath }, deps) prompts.print( - buildLocalConnectMessage({ targetDir, token, started, port, tokenWritten }), + buildLocalConnectMessage({ + targetDir, + token, + startStatus, + port, + tokenWritten, + }), ) return 0 } @@ -509,13 +516,13 @@ const runRemoteInit = async ( // 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 started = + const startStatus: StartStatus = obsidianAuthToken === "" - ? false + ? "not-started" : await offerDockerRun({ targetDir, 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. - if (started) { + if (startStatus === "running") { await reportPublicUrlProbe(effectivePublicUrl, { prompts, fetchFn: deps.fetchFn, @@ -526,7 +533,7 @@ const runRemoteInit = async ( targetDir, token, publicUrl: effectivePublicUrl, - started, + startStatus, obsidianTokenMissing: obsidianAuthToken === "", tokenWritten, }), diff --git a/cli/src/messages.ts b/cli/src/messages.ts index bff9c7700..8685b51ed 100644 --- a/cli/src/messages.ts +++ b/cli/src/messages.ts @@ -1,5 +1,9 @@ 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 // terminal soft-wraps long commands instead of hard-wrapping them behind a // "│ " border. A boxed command can't be copied without dragging in the border @@ -88,14 +92,18 @@ export const startCommand = (targetDir: string): string => const startServerLine = (targetDir: string): string => `Start the server:\n ${startCommand(targetDir)}` -/** Remote start line: running, blocked on the missing sync token, or ready to start. */ +const startingInBackgroundLine = (): string => + `The server is starting in the background — check progress:\n docker logs ${CONTAINER_NAME}` + +/** Remote start line: running, starting, blocked on the missing sync token, or ready to start. */ const remoteStartLine = (params: { targetDir: string - started: boolean + startStatus: StartStatus obsidianTokenMissing: boolean }): string => { - const { targetDir, started, obsidianTokenMissing } = params - if (started) return "The server is running." + const { targetDir, startStatus, obsidianTokenMissing } = params + if (startStatus === "running") return "The server is running." + if (startStatus === "starting") return startingInBackgroundLine() if (obsidianTokenMissing) { return `Fill in OBSIDIAN_AUTH_TOKEN in ${targetDir}/.env, then start the server:\n ${startCommand(targetDir)}` } @@ -162,9 +170,9 @@ const smokeTest = (healthUrl: string): string => */ const remoteHealthCheckBlock = ( healthUrl: string, - started: boolean, + startStatus: StartStatus, ): string => { - if (started) { + if (startStatus === "running" || startStatus === "starting") { return `Health check — works from any device that can reach the URL: curl ${healthUrl}` } @@ -183,17 +191,20 @@ const updateGuidance = (targetDir: string): string => export const buildLocalConnectMessage = (params: { targetDir: string token: string - started: boolean + startStatus: StartStatus port: number tokenWritten: boolean }): string => { - const { targetDir, token, started, port, tokenWritten } = params + const { targetDir, token, startStatus, port, tokenWritten } = params const baseUrl = `http://localhost:${port}` - const startLine = started - ? "The server is running." - : startServerLine(targetDir) + const startLine = + startStatus === "running" + ? "The server is running." + : startStatus === "starting" + ? startingInBackgroundLine() + : startServerLine(targetDir) const tokenLine = tokenBlock({ targetDir, token, tokenWritten }) @@ -202,7 +213,7 @@ export const buildLocalConnectMessage = (params: { // Assembled as a filtered list so the omission leaves no stray blank line. const nonOauthBlocks = [ curlGuidance(`${baseUrl}/mcp`), - started ? undefined : smokeTest(`${baseUrl}/healthz`), + startStatus === "running" ? undefined : smokeTest(`${baseUrl}/healthz`), ] .filter(Boolean) .join("\n\n") @@ -265,7 +276,7 @@ export const buildRemoteConnectMessage = (params: { targetDir: string token: string publicUrl: string - started: boolean + startStatus: StartStatus obsidianTokenMissing: boolean tokenWritten: boolean }): string => { @@ -273,14 +284,14 @@ export const buildRemoteConnectMessage = (params: { targetDir, token, publicUrl, - started, + startStatus, obsidianTokenMissing, tokenWritten, } = params const startLine = remoteStartLine({ targetDir, - started, + startStatus, obsidianTokenMissing, }) @@ -320,7 +331,7 @@ ${sectionRule("Non-OAuth")} ${curlGuidance(`${publicUrl}/mcp`)} -${remoteHealthCheckBlock(`${publicUrl}/healthz`, started)} +${remoteHealthCheckBlock(`${publicUrl}/healthz`, startStatus)} ${sectionRule("Settings")} From 4fa4875414148803d9c39d6206d1991de4402052 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:43:59 -0400 Subject: [PATCH 2/3] fix(review): update stale JSDoc comments for three-valued StartStatus Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/init.ts | 5 +++-- cli/src/messages.ts | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cli/src/init.ts b/cli/src/init.ts index 9706f9105..ab38a2b97 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -240,8 +240,9 @@ 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 → docker run succeeds → health check passes. Returns true only - * when the server is confirmed up. + * consents → 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; port: number; mode: Mode; vaultPath?: string }, diff --git a/cli/src/messages.ts b/cli/src/messages.ts index 8685b51ed..f0ada29ad 100644 --- a/cli/src/messages.ts +++ b/cli/src/messages.ts @@ -163,10 +163,11 @@ const smokeTest = (healthUrl: string): string => curl ${healthUrl}` /** - * Remote health-check block. Started: the CLI verified localhost on the VPS, - * but the public URL is a different check (ingress — DNS, TLS, proxy), so the - * command stays, reworded as the works-from-any-device check. Not started: - * the plain smoke test to run after starting. + * Remote health-check block. Running or starting: the CLI verified localhost + * on the VPS (or the container is still coming up), but the public URL is a + * different check (ingress — DNS, TLS, proxy), so the command stays, reworded + * as the works-from-any-device check. Not started: the plain smoke test to + * run after starting. */ const remoteHealthCheckBlock = ( healthUrl: string, From 47c1506f83730321d38875b28d3098b10bb8bbb8 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:56:21 -0400 Subject: [PATCH 3/3] test(cli): cover the health-timeout starting path through runInit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core behavior change — offerDockerRun returning "starting" when the health check times out — had no integration test through runInit. Add two tests: local mode verifies the connect message shows "starting in the background", remote mode verifies the public URL probe is skipped. Both mutation-verified. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/init.test.ts | 72 +++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 16bb9ef31..79af36f24 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -7,11 +7,13 @@ import { } 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 { runInit } from "../init.js" -import type { DockerRunner } from "../docker.js" +import { pollHealth, type DockerRunner } from "../docker.js" import { buildDockerNotInstalledMessage } from "../messages.js" + +vi.mock("../docker.js", { spy: true }) import { createScriptedPrompts, dockerDaemonOnly, @@ -1264,3 +1266,69 @@ describe("runInit guided optional settings", () => { ) }) }) + +describe("runInit health-timeout returns starting status", () => { + it("shows 'starting in the background' when the local health check times out", async () => { + vi.mocked(pollHealth).mockResolvedValueOnce(false) + const vaultDir = makeVault() + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "local", + vaultDir, + targetDir, + [], // no optional settings + true, // start the server now + ]) + + const exitCode = await runInit( + {}, + { + prompts: scripted.prompts, + docker: dockerReady, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + // The connect message must show the "starting" copy, not "Start the server:". + expect(scripted.prints[0]).toContain("starting in the background") + expect(scripted.prints[0]).not.toContain("Start the server:") + }) + + it("skips the public URL probe when the remote health check times out", async () => { + vi.mocked(pollHealth).mockResolvedValueOnce(false) + 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 (declined auto-capture) + "sync-token-xyz", // paste fallback + 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: dockerReady, + fetchFn: fetchRecorder, + }, + ) + + expect(exitCode).toBe(0) + // pollHealth was mocked — fetchRecorder was never called. The public URL + // probe only runs for "running", not "starting", so no URLs were fetched. + expect(fetchedUrls).toEqual([]) + // The connect message must show "starting", not the running or not-started copy. + expect(scripted.prints[0]).toContain("starting in the background") + expect(scripted.prints[0]).not.toContain("Start the server:") + expect(scripted.prints[0]).not.toContain("The server is running.") + }) +})