Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions cli/src/__tests__/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.")
})
})
71 changes: 57 additions & 14 deletions cli/src/__tests__/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -199,21 +220,43 @@ 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.")
expect(message).not.toContain("Start the server:")
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,
})

Expand All @@ -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,
})

Expand Down Expand Up @@ -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
Expand Down
38 changes: 23 additions & 15 deletions cli/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
buildLocalConnectMessage,
buildRemoteConnectMessage,
startCommand,
type StartStatus,
} from "./messages.js"
import {
healthPollTimeoutMs,
Expand Down Expand Up @@ -239,13 +240,14 @@ 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 },
deps: InitDeps,
): Promise<boolean> => {
): Promise<StartStatus> => {
const { targetDir, port, mode, vaultPath } = params
const { prompts, docker, fetchFn } = deps
const daemonStatus = docker.daemonStatus()
Expand All @@ -258,10 +260,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"),
Expand All @@ -270,7 +272,7 @@ const offerDockerRun = async (
})
if (!containerStarted) {
prompts.error("docker run failed — see output above.")
return false
return "not-started"
}

const spinner = prompts.spinner()
Expand All @@ -284,10 +286,10 @@ const offerDockerRun = async (
)
if (!healthy) {
spinner.stop(healthTimeoutMessage(mode, timeoutMs))
return false
return "starting"
Comment thread
aliasunder marked this conversation as resolved.
}
spinner.stop("Server is up — health check passed.")
return true
return "running"
}

// Local flow: resolve vault path → resolve target dir → generate token →
Expand Down Expand Up @@ -396,11 +398,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
}
Expand Down Expand Up @@ -509,13 +517,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,
Expand All @@ -526,7 +534,7 @@ const runRemoteInit = async (
targetDir,
token,
publicUrl: effectivePublicUrl,
started,
startStatus,
obsidianTokenMissing: obsidianAuthToken === "",
tokenWritten,
}),
Expand Down
Loading
Loading