diff --git a/AGENTS.md b/AGENTS.md index df23b940b..4eef52ff2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ cli/ # npx vault-cortex CLI (published as vaul docker.ts # Container management (docker run, health-check wait) upgrade.ts # Upgrade command (pull + re-create + health check) lifecycle.ts # Down/logs/restart commands + shared deployment resolution and re-create plumbing - get-sync-token.ts # Get-sync-token subcommand (Sync token auto-capture via volume mount) + get-sync-token.ts # Get-sync-token subcommand (Sync token capture via Obsidian API) env.ts # Environment file handling (.env generation) token.ts # Secure token generation (openssl rand) vault.ts # Vault path validation diff --git a/cli/README.md b/cli/README.md index 358d9eb2b..d340fcf70 100644 --- a/cli/README.md +++ b/cli/README.md @@ -60,8 +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. Existing files are never overwritten -without asking. During a remote setup, init offers to run -[`get-sync-token`](#get-sync-token) for you when Docker is available. +without asking. During a remote setup, init offers to generate your +[Obsidian Sync token](#get-sync-token) as part of the flow. Flags: @@ -194,17 +194,15 @@ remote setups — without leaving the CLI: npx vault-cortex@latest get-sync-token ``` -The command opens the Obsidian login inside Docker. Once you've signed in, it -captures your token and prints it — nothing to dig out of the login output. -Use `--dir ` to write the token straight into an existing `.env` -instead: +The command prompts for your Obsidian account email, password, and MFA code +(if enabled), signs in via the Obsidian API, and prints the token. Use +`--dir ` to write the token straight into an existing `.env` instead: ```bash npx vault-cortex@latest get-sync-token --dir ./vault-cortex ``` -During `init --mode remote`, this flow is offered automatically when Docker -is available. +During `init --mode remote`, this flow is offered automatically. ## Requirements diff --git a/cli/src/__tests__/command-stubs.ts b/cli/src/__tests__/command-stubs.ts index b27c0e051..512e3c780 100644 --- a/cli/src/__tests__/command-stubs.ts +++ b/cli/src/__tests__/command-stubs.ts @@ -174,7 +174,6 @@ export const dockerReady: DockerRunner = { stopAndRemoveContainer: () => true, containerExists: () => true, streamLogs: async () => 0, - runObsidianLogin: () => false, } /** Daemon installed but not running — every operation fails. */ @@ -185,7 +184,6 @@ export const dockerDown: DockerRunner = { stopAndRemoveContainer: () => false, containerExists: () => false, streamLogs: async () => 1, - runObsidianLogin: () => false, } /** Docker binary absent entirely — every operation fails. */ diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index c20b638d2..b584f9b6f 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest" import { buildDockerLogsArgs, buildDockerRunArgs, - buildObsidianLoginArgs, classifyDaemonStatus, CONTAINER_NAME, healthPollTimeoutMs, @@ -193,71 +192,6 @@ describe("buildDockerRunArgs", () => { }) }) -describe("buildObsidianLoginArgs", () => { - it("produces the correct args on macOS (no --user flag)", () => { - const args = buildObsidianLoginArgs({ - configMountPath: "/tmp/vault-cortex-sync-token-abc", - platform: "darwin", - uid: 501, - gid: 20, - }) - - expect(args).toEqual([ - "run", - "--rm", - "-it", - "--entrypoint", - "ob", - "-v", - "/tmp/vault-cortex-sync-token-abc:/home/obsidian/.config", - REMOTE_IMAGE, - "login", - ]) - }) - - it("includes --user uid:gid on Linux", () => { - const args = buildObsidianLoginArgs({ - configMountPath: "/tmp/vault-cortex-sync-token-abc", - platform: "linux", - uid: 1000, - gid: 1000, - }) - - expect(args).toEqual([ - "run", - "--rm", - "-it", - "--entrypoint", - "ob", - "-v", - "/tmp/vault-cortex-sync-token-abc:/home/obsidian/.config", - "--user", - "1000:1000", - REMOTE_IMAGE, - "login", - ]) - }) - - it("omits --user on Linux when uid/gid are not provided", () => { - const args = buildObsidianLoginArgs({ - configMountPath: "/tmp/test", - platform: "linux", - }) - - expect(args).toEqual([ - "run", - "--rm", - "-it", - "--entrypoint", - "ob", - "-v", - "/tmp/test:/home/obsidian/.config", - REMOTE_IMAGE, - "login", - ]) - }) -}) - describe("healthPollTimeoutMs", () => { it("gives remote mode a 4-minute budget for the first-sync gate", () => { expect(healthPollTimeoutMs("remote")).toBe(240_000) diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index e9f5f8387..2c4944489 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -1,254 +1,396 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - writeFileSync, -} from "node:fs" +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { describe, expect, it } from "vitest" import { captureObsidianToken, runGetSyncToken } from "../get-sync-token.js" -import type { DockerRunner } from "../docker.js" -import { buildDockerNotInstalledMessage } from "../messages.js" -import { - createScriptedPrompts, - dockerDaemonOnly, - dockerDown, - dockerNotInstalled, -} from "./command-stubs.js" +import { createScriptedPrompts } from "./command-stubs.js" + +/** Builds a mock fetch that returns a successful signin response. */ +const fetchSigninSuccess = (token = "test-sync-token"): typeof fetch => + (async () => + new Response(JSON.stringify({ token }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch + +/** Builds a mock fetch that returns an API error (200 with error field). */ +const fetchApiError = (errorMessage: string): typeof fetch => + (async () => + new Response(JSON.stringify({ error: errorMessage }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch + +/** Builds a mock fetch that returns an HTTP error status. */ +const fetchHttpError = (status: number): typeof fetch => + (async () => new Response(null, { status })) as typeof fetch + +/** Builds a mock fetch that throws a network error. */ +const fetchNetworkError = (message: string): typeof fetch => + (async () => { + throw new Error(message) + }) as typeof fetch + +/** Builds a mock fetch that throws a timeout error. */ +const fetchTimeout = (): typeof fetch => + (async () => { + const error = new DOMException("The operation was aborted", "TimeoutError") + throw error + }) as typeof fetch + +/** Builds a mock fetch that returns a non-JSON response. */ +const fetchNonJson = (): typeof fetch => + (async () => + new Response("Server Error", { + status: 200, + headers: { "Content-Type": "text/html" }, + })) as typeof fetch + +/** Builds a mock fetch that returns 200 but no token field. */ +const fetchMalformedSuccess = (): typeof fetch => + (async () => + new Response(JSON.stringify({ name: "User", email: "u@e.com" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch + +/** Builds a mock fetch that returns valid JSON that is not an object (e.g. array). */ +const fetchJsonNonObject = (): typeof fetch => + (async () => + new Response(JSON.stringify([1, 2, 3]), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch /** - * Destination sentence passed to captureObsidianToken in direct-call tests — - * production callers supply their own flow-specific sentence (stored in - * .env / printed / written to a path). + * Builds a mock fetch that requires MFA: first call returns the "2FA code" + * error, second call with a valid MFA code succeeds. */ -const TOKEN_DESTINATION_MESSAGE = "The token is captured automatically." +const fetchMfaRequired = (token = "mfa-sync-token"): typeof fetch => { + let callCount = 0 + return (async () => { + callCount += 1 + if (callCount === 1) { + return new Response( + JSON.stringify({ error: "Your account requires a 2FA code" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + return new Response(JSON.stringify({ token }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) as typeof fetch +} /** - * Creates a DockerRunner whose runObsidianLogin writes a fake - * auth_token file into the config mount path, simulating the - * containerized login writing the token file. + * Builds a mock fetch that requires MFA but the retry fails: first call + * returns the "2FA code" error, second call returns a wrong-code rejection. */ -const dockerWithToken = (token: string): DockerRunner => ({ - ...dockerDaemonOnly, - runObsidianLogin: (configMountPath) => { - const tokenDir = join(configMountPath, "obsidian-headless") - mkdirSync(tokenDir, { recursive: true }) - writeFileSync(join(tokenDir, "auth_token"), token) - return true - }, -}) +const fetchMfaRetryFail = (retryError: string): typeof fetch => { + let callCount = 0 + return (async () => { + callCount += 1 + if (callCount === 1) { + return new Response( + JSON.stringify({ error: "Your account requires a 2FA code" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + return new Response(JSON.stringify({ error: retryError }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) as typeof fetch +} describe("captureObsidianToken", () => { - it("returns the token when the login writes the auth_token file", () => { - const { prompts } = createScriptedPrompts() + it("returns the token on successful sign-in", async () => { + const scripted = createScriptedPrompts([ + "user@example.com", // email + "secret123", // password + ]) - const token = captureObsidianToken( - { - docker: dockerWithToken("abc123-sync-token"), - prompts, - }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchSigninSuccess("abc123-sync-token"), + }) expect(token).toBe("abc123-sync-token") + expect(scripted.spinnerMessages).toEqual([ + "start: Signing in to Obsidian...", + "stop: Signed in as user@example.com.", + ]) }) - it("trims whitespace from the token file", () => { - const { prompts } = createScriptedPrompts() + it("prompts for MFA when the API requires it and succeeds on retry", async () => { + const scripted = createScriptedPrompts([ + "mfa@example.com", // email + "password", // password + "123456", // MFA code + ]) - const token = captureObsidianToken( - { - docker: dockerWithToken(" token-with-whitespace \n"), - prompts, - }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchMfaRequired("mfa-token"), + }) - expect(token).toBe("token-with-whitespace") + expect(token).toBe("mfa-token") + expect(scripted.asked).toEqual([ + "Obsidian account email:", + "Password:", + "2FA code:", + ]) + expect(scripted.spinnerMessages).toEqual([ + "start: Signing in to Obsidian...", + "stop: Two-factor authentication required.", + "start: Verifying...", + "stop: Signed in as mfa@example.com.", + ]) }) - it("returns undefined when docker run fails", () => { - const scripted = createScriptedPrompts() + it("returns undefined when the MFA code is incorrect", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) - const token = captureObsidianToken( - { - docker: dockerDaemonOnly, - prompts: scripted.prompts, - }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchApiError("2FA code is incorrect"), + }) expect(token).toBeUndefined() expect(scripted.warnings[0]).toBe( - "The Obsidian login did not complete — you can run it later with:\n" + - " npx vault-cortex@latest get-sync-token", + "Could not sign in: 2FA code is incorrect", ) }) - it("returns undefined and warns when the token file is empty", () => { - const scripted = createScriptedPrompts() + it("returns undefined with retry guidance when MFA retry fails", async () => { + const scripted = createScriptedPrompts([ + "user@example.com", // email + "password", // password + "000000", // wrong MFA code + ]) - const token = captureObsidianToken( - { - docker: dockerWithToken(""), - prompts: scripted.prompts, - }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchMfaRetryFail("2FA code is incorrect"), + }) expect(token).toBeUndefined() + expect(scripted.asked).toEqual([ + "Obsidian account email:", + "Password:", + "2FA code:", + ]) expect(scripted.warnings[0]).toBe( - "The Obsidian login finished, but no token was captured — the " + - "token file was missing, empty, or unreadable. You can retry with:\n" + - " npx vault-cortex@latest get-sync-token", + "Could not sign in: 2FA code is incorrect\n" + + " Check your 2FA code and try again.", ) + expect(scripted.spinnerMessages).toEqual([ + "start: Signing in to Obsidian...", + "stop: Two-factor authentication required.", + "start: Verifying...", + "stop: Sign-in failed.", + ]) }) - it("returns undefined and warns when the login succeeds but writes no token file", () => { - const scripted = createScriptedPrompts() - const dockerSucceedsButNoFile: DockerRunner = { - ...dockerDaemonOnly, - runObsidianLogin: () => true, - } + it("shows a timeout message when the MFA retry times out", async () => { + let callCount = 0 + const fetchMfaThenTimeout: typeof fetch = (async () => { + callCount += 1 + if (callCount === 1) { + return new Response( + JSON.stringify({ error: "Your account requires a 2FA code" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + throw new DOMException("The operation was aborted", "TimeoutError") + }) as typeof fetch + + const scripted = createScriptedPrompts([ + "user@example.com", + "password", + "123456", + ]) - const token = captureObsidianToken( - { - docker: dockerSucceedsButNoFile, - prompts: scripted.prompts, - }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchMfaThenTimeout, + }) expect(token).toBeUndefined() expect(scripted.warnings[0]).toBe( - "The Obsidian login finished, but no token was captured — the " + - "token file was missing, empty, or unreadable. You can retry with:\n" + - " npx vault-cortex@latest get-sync-token", + "Request timed out — check your internet connection and try again.", ) }) - it("treats a docker runner throw as a failed run and returns undefined", () => { - const scripted = createScriptedPrompts() - const dockerThrows: DockerRunner = { - ...dockerDaemonOnly, - runObsidianLogin: () => { - throw new Error("spawn docker ENOENT") - }, - } + it("omits 2FA hint when the MFA retry fails with a network error", async () => { + let callCount = 0 + const fetchMfaThenNetworkError: typeof fetch = (async () => { + callCount += 1 + if (callCount === 1) { + return new Response( + JSON.stringify({ error: "Your account requires a 2FA code" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + throw new Error("fetch failed") + }) as typeof fetch + + const scripted = createScriptedPrompts([ + "user@example.com", + "password", + "123456", + ]) - const token = captureObsidianToken( - { - docker: dockerThrows, - prompts: scripted.prompts, - }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchMfaThenNetworkError, + }) expect(token).toBeUndefined() - expect(scripted.warnings).toEqual([ - "Docker run failed — spawn docker ENOENT", - "The Obsidian login did not complete — you can run it later with:\n" + - " npx vault-cortex@latest get-sync-token", - ]) + expect(scripted.warnings[0]).toBe("Could not sign in: fetch failed") }) - it("cleans up the temp directory even on failure", () => { - const { prompts } = createScriptedPrompts() - const tempDirs: string[] = [] - const dockerTracker: DockerRunner = { - ...dockerDaemonOnly, - runObsidianLogin: (configMountPath) => { - tempDirs.push(configMountPath) - return false - }, - } + it("returns undefined on wrong password", async () => { + const scripted = createScriptedPrompts([ + "user@example.com", + "wrong-password", + ]) - captureObsidianToken( - { docker: dockerTracker, prompts }, - TOKEN_DESTINATION_MESSAGE, + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchApiError("Invalid email or password"), + }) + + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe( + "Could not sign in: Invalid email or password", ) + }) - expect(tempDirs).toHaveLength(1) - expect(existsSync(tempDirs[0])).toBe(false) + it("returns undefined on HTTP error", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) + + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchHttpError(500), + }) + + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe("Could not sign in: HTTP Error 500") }) - it("logs the handoff message before running docker", () => { - const scripted = createScriptedPrompts() + it("returns undefined with a clear message on HTTP 429", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) - captureObsidianToken( - { - docker: dockerWithToken("token"), - prompts: scripted.prompts, - }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchHttpError(429), + }) - expect(scripted.logs[0]).toBe( - "Handing the terminal to the Obsidian login — it will ask for your " + - "account email, password, and MFA code. The token is captured automatically.", - ) + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe("Could not sign in: HTTP Error 429") }) -}) -describe("runGetSyncToken subcommand", () => { - it("prints the token to stdout when --dir is not set", async () => { - const scripted = createScriptedPrompts() + it("returns undefined on network error", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) - const exitCode = await runGetSyncToken( - {}, - { prompts: scripted.prompts, docker: dockerWithToken("my-sync-token") }, + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchNetworkError("fetch failed"), + }) + + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe("Could not sign in: fetch failed") + }) + + it("returns undefined with a timeout message when the request times out", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) + + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchTimeout(), + }) + + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe( + "Request timed out — check your internet connection and try again.", ) + }) - expect(exitCode).toBe(0) - expect(scripted.logs[0]).toBe( - "Handing the terminal to the Obsidian login — it will ask for your " + - "account email, password, and MFA code. The token is captured " + - "automatically and printed at the end.", + it("returns undefined on non-JSON response", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) + + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchNonJson(), + }) + + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toMatch( + /^Could not sign in: Unexpected response from Obsidian API \(/, ) - expect(scripted.logs).toContain("Your OBSIDIAN_AUTH_TOKEN:") - expect(scripted.prints).toEqual(["\n my-sync-token\n"]) }) - it("exits 1 when the docker daemon is not running", async () => { - const scripted = createScriptedPrompts() + it("returns undefined when the response is valid JSON but not an object", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) - const exitCode = await runGetSyncToken( - {}, - { prompts: scripted.prompts, docker: dockerDown }, + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchJsonNonObject(), + }) + + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe( + "Could not sign in: Unexpected response from Obsidian API (not a JSON object)", ) + }) - expect(exitCode).toBe(1) - expect(scripted.errors[0]).toBe( - "Container runtime not running — start Docker Desktop, Colima,\n" + - "OrbStack, or another Docker-compatible runtime and try again.", + it("returns undefined when the response is missing the token field", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) + + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchMalformedSuccess(), + }) + + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe( + "Could not sign in: Unexpected response from Obsidian API (no token field)", ) }) +}) - // Message content per platform is pinned test-owned in messages.test.ts — - // this asserts the not-installed state routes to the install guidance. - it("exits 1 with install guidance when no container runtime is installed", async () => { - const scripted = createScriptedPrompts() +describe("runGetSyncToken subcommand", () => { + it("prints the token to stdout when --dir is not set", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) const exitCode = await runGetSyncToken( {}, - { prompts: scripted.prompts, docker: dockerNotInstalled }, + { + prompts: scripted.prompts, + fetchFn: fetchSigninSuccess("my-sync-token"), + }, ) - expect(exitCode).toBe(1) - expect(scripted.errors).toEqual([ - buildDockerNotInstalledMessage({ nextStep: "\nThen try again." }), - ]) + expect(exitCode).toBe(0) + expect(scripted.logs).toContain("Your OBSIDIAN_AUTH_TOKEN:") + expect(scripted.prints).toEqual(["\n my-sync-token\n"]) }) it("exits 1 when token capture fails", async () => { - const scripted = createScriptedPrompts() + const scripted = createScriptedPrompts([ + "user@example.com", + "wrong-password", + ]) const exitCode = await runGetSyncToken( {}, - { prompts: scripted.prompts, docker: dockerDaemonOnly }, + { + prompts: scripted.prompts, + fetchFn: fetchApiError("Invalid email or password"), + }, ) expect(exitCode).toBe(1) @@ -261,35 +403,38 @@ describe("runGetSyncToken subcommand", () => { join(targetDir, ".env"), "MCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=old-token\nVAULT_NAME=MyVault\n", ) - const scripted = createScriptedPrompts() + const scripted = createScriptedPrompts(["user@example.com", "password"]) const exitCode = await runGetSyncToken( { dir: targetDir }, - { prompts: scripted.prompts, docker: dockerWithToken("new-sync-token") }, + { + prompts: scripted.prompts, + fetchFn: fetchSigninSuccess("new-sync-token"), + }, ) expect(exitCode).toBe(0) - expect(scripted.logs[0]).toBe( - "Handing the terminal to the Obsidian login — it will ask for your " + - "account email, password, and MFA code. The token is captured " + - `automatically and written to ${join(targetDir, ".env")}.`, - ) expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe( "MCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=new-sync-token\nVAULT_NAME=MyVault\n", ) - expect(scripted.logs).toContain( - `Token written to ${join(targetDir, ".env")}`, + const startHint = scripted.logs.find((log) => + log.includes("Token written to"), ) + expect(startHint).toContain(`Token written to ${join(targetDir, ".env")}`) + expect(startHint).toContain(`npx vault-cortex start --dir "${targetDir}"`) }) it("exits 1 when --dir .env has no OBSIDIAN_AUTH_TOKEN line", async () => { const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-sync-token-")) writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=abc\n") - const scripted = createScriptedPrompts() + const scripted = createScriptedPrompts(["user@example.com", "password"]) const exitCode = await runGetSyncToken( { dir: targetDir }, - { prompts: scripted.prompts, docker: dockerWithToken("new-sync-token") }, + { + prompts: scripted.prompts, + fetchFn: fetchSigninSuccess("new-sync-token"), + }, ) expect(exitCode).toBe(1) @@ -304,11 +449,14 @@ describe("runGetSyncToken subcommand", () => { mkdtempSync(join(tmpdir(), "vault-cli-sync-token-")), "nonexistent", ) - const scripted = createScriptedPrompts() + const scripted = createScriptedPrompts(["user@example.com", "password"]) const exitCode = await runGetSyncToken( { dir: targetDir }, - { prompts: scripted.prompts, docker: dockerWithToken("new-sync-token") }, + { + prompts: scripted.prompts, + fetchFn: fetchSigninSuccess("new-sync-token"), + }, ) expect(exitCode).toBe(1) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 66cbfb1df..62036e374 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -10,7 +10,7 @@ import { join } from "node:path" import { describe, expect, it, onTestFinished, vi } from "vitest" import { runInit } from "../init.js" -import { pollHealth, type DockerRunner } from "../docker.js" +import { pollHealth } from "../docker.js" import { buildDockerNotInstalledMessage } from "../messages.js" vi.mock("../docker.js", { spy: true }) @@ -228,7 +228,7 @@ describe("remote connect message https routing", () => { const scripted = createScriptedPrompts([ publicUrl, "MyVault", - "", // blank sync token — fill in .env later + false, // don't generate the token now (declined auto-capture) false, // no encryption [], // no optional settings ]) @@ -286,7 +286,7 @@ describe("remote connect message https routing", () => { "https://vault.example.com/mcp", // re-included the /mcp path — rejected "https://vault.example.com", // base origin — accepted on re-prompt "MyVault", - "", // blank sync token — fill in .env later + false, // don't generate the token now (declined auto-capture) false, // no encryption [], // no optional settings ]) @@ -318,7 +318,7 @@ describe("remote connect message https routing", () => { const scripted = createScriptedPrompts([ "https://vault.example.com/", // trailing slash — trimmed, not rejected "MyVault", - "", // blank sync token — fill in .env later + false, // don't generate the token now (declined auto-capture) false, // no encryption [], // no optional settings ]) @@ -346,7 +346,7 @@ describe("remote connect message https routing", () => { const scripted = createScriptedPrompts([ "https://vault.example.com", "MyVault", - "", // blank sync token — fill in .env later + false, // don't generate the token now (declined auto-capture) false, // no encryption [], // no optional settings ]) @@ -522,16 +522,14 @@ describe("runInit interactive local flow", () => { }) describe("runInit remote flow", () => { - it("asks the remote sequence with auto-capture declined and writes .env", async () => { + it("leaves OBSIDIAN_AUTH_TOKEN blank when auto-capture is declined", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ "https://vault.example.com/", // public URL (trailing slash trimmed) "MyVault", // vault name false, // don't generate the token now (declined auto-capture) - "sync-token-xyz", // paste fallback — obsidian sync token false, // no end-to-end encryption [], // no optional settings - false, // don't start the server ]) const exitCode = await runInit( { mode: "remote", dir: targetDir }, @@ -547,27 +545,26 @@ describe("runInit remote flow", () => { "Public base URL clients will use to reach this server (no /mcp — it's added for you):", "Exact name of your Obsidian vault (case-sensitive):", "Generate the token now?", - "Paste the Obsidian Sync token (leave blank to fill in .env later):", "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") expect(envContent).toContain("PUBLIC_URL=https://vault.example.com\n") expect(envContent).toContain("VAULT_NAME=MyVault\n") - expect(envContent).toContain("OBSIDIAN_AUTH_TOKEN=sync-token-xyz\n") - expect(scripted.prints[0]).toContain( - "Adjust optional settings (memory layer and folder, daily notes\nfolder and format, file tools, semantic search, port, timezone,\nsync direction):", + expect(envContent).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) + expect(scripted.logs).toContain( + "No token yet — run this later to add it to your .env:\n" + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) }) - it("skips the token auto-capture offer when Docker is not installed", async () => { + it("always offers token generation even without Docker", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ "https://vault.example.com", // public URL "MyVault", // vault name - "sync-token-xyz", // paste prompt directly — no auto-capture offer + false, // don't generate the token now (declined auto-capture) false, // no end-to-end encryption [], // no optional settings ]) @@ -580,35 +577,44 @@ describe("runInit remote flow", () => { }, ) - // No "Generate the token now?" (capture needs Docker) and no "Start the - // server now?" (the install warning replaces the start offer). + // Token generation is always offered (uses the API, not Docker). With a + // blank token (capture declined), 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):", "Exact name of your Obsidian vault (case-sensitive):", - "Paste the Obsidian Sync token (leave blank to fill in .env later):", + "Generate the token now?", "Does your vault use end-to-end encryption?", "Any optional settings to change? (press enter to skip)", ]) - expect(scripted.warnings).toEqual([ - buildDockerNotInstalledMessage({ - nextStep: `\nThen start the server with:\n npx vault-cortex@latest start --dir "${targetDir}"`, - }), - ]) + expect(scripted.asked).toContain("Generate the token now?") }) it("probes the public URL after a confirmed start and reports success", async () => { const targetDir = makeTargetDir() const fetchedUrls: string[] = [] const fetchRecorder: typeof fetch = async (input) => { - fetchedUrls.push(String(input)) + const url = String(input) + fetchedUrls.push(url) + // Signin API call succeeds; health/probe calls succeed + if (url.includes("api.obsidian.md")) { + return new Response( + JSON.stringify({ + token: "sync-token-xyz", + name: "User", + email: "user@example.com", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } 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 + true, // generate the token now + "user@example.com", // email + "password", // password false, // no end-to-end encryption [], // no optional settings true, // start the server now @@ -623,17 +629,12 @@ describe("runInit remote flow", () => { ) expect(exitCode).toBe(0) - // Order proves the probe ran after the container health poll. + // Signin first (auto-capture), then health poll, then public URL probe. expect(fetchedUrls).toEqual([ + "https://api.obsidian.md/user/signin", "http://127.0.0.1:8000/healthz", "https://vault.example.com/healthz", ]) - expect(scripted.spinnerMessages).toEqual([ - "start: Waiting for the server to come up (first run may take a moment)", - "stop: Server is up — health check passed.", - "start: Checking the public URL (https://vault.example.com/healthz)", - "stop: Public URL responds — https://vault.example.com/healthz answered from this machine.", - ]) }) it("keeps a successful start at exit 0 when the public URL does not answer", async () => { @@ -641,17 +642,29 @@ describe("runInit remote flow", () => { const fetchedUrls: string[] = [] // Localhost (the container check) answers; the public URL is unreachable // — the state every remote init is in before HTTPS/ingress is set up. - const fetchPublicUrlDown: typeof fetch = async (input) => { - const url = String(input) - fetchedUrls.push(url) - if (url.includes("127.0.0.1")) return new Response(null, { status: 200 }) + const fetchPublicUrlDownWithSignin: typeof fetch = async (input) => { + const requestUrl = String(input) + fetchedUrls.push(requestUrl) + if (requestUrl.includes("api.obsidian.md")) { + return new Response( + JSON.stringify({ + token: "sync-token-xyz", + name: "User", + email: "user@example.com", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + if (requestUrl.includes("127.0.0.1")) + return new Response(null, { status: 200 }) throw new Error("ECONNREFUSED") } 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 + true, // generate the token now + "user@example.com", // email + "password", // password false, // no end-to-end encryption [], // no optional settings true, // start the server now @@ -661,7 +674,7 @@ describe("runInit remote flow", () => { { prompts: scripted.prompts, docker: dockerReady, - fetchFn: fetchPublicUrlDown, + fetchFn: fetchPublicUrlDownWithSignin, }, ) @@ -669,7 +682,10 @@ describe("runInit remote flow", () => { // never a gate — and the connect message still reports the running server. expect(exitCode).toBe(0) expect(fetchedUrls).toContain("https://vault.example.com/healthz") + // Signin spinner from auto-capture, then container health, then URL probe. expect(scripted.spinnerMessages).toEqual([ + "start: Signing in to Obsidian...", + "stop: Signed in as user@example.com.", "start: Waiting for the server to come up (first run may take a moment)", "stop: Server is up — health check passed.", "start: Checking the public URL (https://vault.example.com/healthz)", @@ -685,44 +701,38 @@ describe("runInit remote flow", () => { expect(scripted.prints[0]).toContain("The server is running.") }) - it("skips paste prompt when auto-capture succeeds", async () => { + it("fills OBSIDIAN_AUTH_TOKEN from auto-capture when accepted", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ "https://vault.example.com", "MyVault", true, // generate the token now + "user@example.com", // email + "password", // password false, // no end-to-end encryption [], // no optional settings false, // don't start the server ]) - const dockerWithCapture: DockerRunner = { - ...dockerDaemonOnly, - runObsidianLogin: (configMountPath) => { - const tokenDir = join(configMountPath, "obsidian-headless") - mkdirSync(tokenDir, { recursive: true }) - writeFileSync(join(tokenDir, "auth_token"), "captured-token") - return true - }, - } + const fetchSigninSuccess: typeof fetch = async () => + new Response( + JSON.stringify({ + token: "captured-token", + name: "User", + email: "user@example.com", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) const exitCode = await runInit( { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerWithCapture, - fetchFn: fetchNever, + docker: dockerDaemonOnly, + fetchFn: fetchSigninSuccess, }, ) expect(exitCode).toBe(0) - expect(scripted.logs).toContain( - "Handing the terminal to the Obsidian login — it will ask for your " + - "account email, password, and MFA code. The token is captured " + - "automatically and stored in your .env — nothing to copy.", - ) - expect(scripted.asked).not.toContain( - "Paste the Obsidian Sync token (leave blank to fill in .env later):", - ) const envContent = readFileSync(join(targetDir, ".env"), "utf8") // Exact line match — a substring check would also pass for a commented // or prefixed entry (e.g. "# OBSIDIAN_AUTH_TOKEN=captured-token"). @@ -731,12 +741,12 @@ describe("runInit remote flow", () => { ) }) - it("skips the docker-run offer when the sync token was left blank", async () => { + it("skips the start offer when the sync token was left blank", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ "http://203.0.113.10:8000", "MyVault", - "", // blank token — fill in later (Docker unavailable, no capture offer) + false, // don't generate the token now (declined auto-capture) false, // no encryption [], // no optional settings ]) @@ -757,13 +767,47 @@ describe("runInit remote flow", () => { ) }) + it("preserves existing token and offers start when capture is declined on re-init", async () => { + const targetDir = makeTargetDir() + mkdirSync(targetDir, { recursive: true }) + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=old\nOBSIDIAN_AUTH_TOKEN=existing-token\nPUBLIC_URL=https://vault.example.com\nVAULT_NAME=MyVault\n", + ) + const scripted = createScriptedPrompts([ + true, // re-run setup + "https://vault.example.com", + "MyVault", + false, // don't generate the token now + false, // no encryption + [], // no optional settings + true, // overwrite .env (content differs due to new MCP_AUTH_TOKEN) + false, // don't start the server + ]) + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerDaemonOnly, + fetchFn: fetchNever, + }, + ) + + expect(exitCode).toBe(0) + expect(scripted.asked).toContain("Start the server now?") + expect(scripted.logs).not.toContain(expect.stringContaining("No token yet")) + const envContent = readFileSync(join(targetDir, ".env"), "utf8") + expect(envContent).toMatch(/^OBSIDIAN_AUTH_TOKEN=existing-token$/m) + }) + it("asks the config dir first, then the mode-specific inputs", async () => { const configDir = makeTargetDir() const scripted = createScriptedPrompts([ configDir, // config dir — prompted, not passed as a flag "https://vault.example.com", // public URL "MyVault", // vault name - "", // blank sync token (Docker down, no capture offer) + false, // don't generate the token now (declined auto-capture) false, // no encryption [], // no optional settings ]) @@ -782,7 +826,7 @@ describe("runInit remote flow", () => { "Where should I put the config files?", "Public base URL clients will use to reach this server (no /mcp — it's added for you):", "Exact name of your Obsidian vault (case-sensitive):", - "Paste the Obsidian Sync token (leave blank to fill in .env later):", + "Generate the token now?", "Does your vault use end-to-end encryption?", "Any optional settings to change? (press enter to skip)", ]) @@ -975,11 +1019,9 @@ describe("runInit remote encryption password", () => { "https://vault.example.com", "MyVault", false, // decline auto-capture - "sync-token-xyz", // paste fallback 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 }, @@ -1013,15 +1055,27 @@ describe("runInit remote with a kept existing .env", () => { ) const fetchedUrls: string[] = [] const fetchRecorder: typeof fetch = async (input) => { - fetchedUrls.push(String(input)) + const requestUrl = String(input) + fetchedUrls.push(requestUrl) + if (requestUrl.includes("api.obsidian.md")) { + return new Response( + JSON.stringify({ + token: "sync-token-xyz", + name: "User", + email: "user@example.com", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } return new Response(null, { status: 200 }) } const scripted = createScriptedPrompts([ true, // existing deployment found — re-run setup anyway "https://prompted.example.com", // public URL prompt — differs from disk "MyVault", // vault name - false, // don't generate the token now (declined auto-capture) - "sync-token-xyz", // paste fallback + true, // generate the token now + "user@example.com", // email + "password", // password false, // no end-to-end encryption [], // settings chooser — consented re-runs get the full setup false, // .env differs — keep the existing file @@ -1038,7 +1092,9 @@ describe("runInit remote with a kept existing .env", () => { ) expect(exitCode).toBe(0) + // The signin URL is first (auto-capture), then health + public URL probe. expect(fetchedUrls).toEqual([ + "https://api.obsidian.md/user/signin", "http://127.0.0.1:8000/healthz", "https://persisted.example.com/healthz", ]) @@ -1048,13 +1104,19 @@ describe("runInit remote with a kept existing .env", () => { }) describe("runInit sync-token auto-capture fallback", () => { - it("falls back to paste prompt when auto-capture fails", async () => { + it("logs get-sync-token guidance when capture fails", async () => { const targetDir = makeTargetDir() + const fetchSigninError: typeof fetch = async () => + new Response(JSON.stringify({ error: "Invalid email or password" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) const scripted = createScriptedPrompts([ "https://vault.example.com", "MyVault", true, // try to generate the token - "", // paste fallback — blank token, fill in later + "user@example.com", // email + "bad-password", // password false, // no encryption [], // no optional settings ]) @@ -1063,15 +1125,19 @@ describe("runInit sync-token auto-capture fallback", () => { { prompts: scripted.prompts, docker: dockerDaemonOnly, - fetchFn: fetchNever, + fetchFn: fetchSigninError, }, ) - expect(scripted.asked).toContain( - "Paste the Obsidian Sync token (leave blank to fill in .env later):", + expect(scripted.warnings[0]).toBe( + "Could not sign in: Invalid email or password", ) - expect(scripted.warnings[0]).toContain( - "The Obsidian login did not complete", + 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" + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) }) }) @@ -1250,7 +1316,7 @@ describe("runInit guided optional settings", () => { const scripted = createScriptedPrompts([ "https://vault.example.com", "MyVault", - "", // blank sync token — fill in .env later + false, // don't generate the token now (declined auto-capture) false, // no encryption ["SYNC_MODE"], "pull-only", @@ -1315,15 +1381,27 @@ describe("runInit health-timeout returns starting status", () => { vi.mocked(pollHealth).mockResolvedValueOnce(false) const targetDir = makeTargetDir() const fetchedUrls: string[] = [] - const fetchRecorder: typeof fetch = async (input) => { - fetchedUrls.push(String(input)) + const fetchWithSignin: typeof fetch = async (input) => { + const requestUrl = String(input) + if (requestUrl.includes("api.obsidian.md")) { + return new Response( + JSON.stringify({ + token: "sync-token-xyz", + name: "User", + email: "user@example.com", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + } + fetchedUrls.push(requestUrl) 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 + true, // generate the token now + "user@example.com", // email + "password", // password false, // no end-to-end encryption [], // no optional settings true, // start the server now @@ -1334,13 +1412,13 @@ describe("runInit health-timeout returns starting status", () => { { prompts: scripted.prompts, docker: dockerReady, - fetchFn: fetchRecorder, + fetchFn: fetchWithSignin, }, ) 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. + // pollHealth was mocked — the public URL probe only runs for "running", + // not "starting", so no non-signin 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") diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index 7cc8c2621..5b3a9134c 100644 --- a/cli/src/__tests__/integration/cli-pty.test.ts +++ b/cli/src/__tests__/integration/cli-pty.test.ts @@ -3,7 +3,8 @@ // by verifying actual terminal rendering, keystroke processing, and end-to-end // entry point wiring. -import { existsSync, readFileSync } from "node:fs" +import { createServer, type Server } from "node:http" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import { join } from "node:path" import { describe, expect, it, onTestFinished } from "vitest" @@ -175,18 +176,12 @@ describe("init remote", () => { send: "n\r", label: "auto-capture → no", }, - { - match: "Paste the Obsidian Sync token", - send: "fake-sync-token-abc123\r", - label: "paste token", - }, { match: "end-to-end encryption", send: "n\r", label: "E2E → no" }, { 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({ @@ -198,11 +193,12 @@ 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") const envContent = readFileSync(join(configDir, ".env"), "utf8") expect(envContent).toContain("PUBLIC_URL=https://vault.example.com") expect(envContent).toContain("VAULT_NAME=MyVault") - expect(envContent).toContain("OBSIDIAN_AUTH_TOKEN=fake-sync-token-abc123") + expect(envContent).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) }) }) @@ -239,6 +235,99 @@ describe("configure", () => { }) }) +/** + * Starts a local HTTP server that returns a successful Obsidian signin + * response. Used with the OBSIDIAN_SIGNIN_URL env var seam in get-sync-token. + */ +const startSigninServer = ( + token: string, +): Promise<{ url: string; server: Server }> => + new Promise((resolvePromise) => { + const server = createServer((req, res) => { + const chunks: Buffer[] = [] + req.on("data", (chunk: Buffer) => chunks.push(chunk)) + req.on("end", () => { + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ token })) + }) + }) + server.listen(0, "127.0.0.1", () => { + const addr = server.address() + const port = typeof addr === "object" && addr ? addr.port : 0 + resolvePromise({ url: `http://127.0.0.1:${port}`, server }) + }) + }) + +describe("get-sync-token", () => { + it("signs in and prints the token", async () => { + const { vaultDir } = createPtyWorkDir() + const { url, server } = await startSigninServer("pty-test-token") + onTestFinished(() => { + server.close() + }) + + const prompts: PtyPrompt[] = [ + { + match: "Obsidian account email", + send: "user@example.com\r", + label: "email", + }, + { match: "Password", send: "secret123\r", label: "password" }, + ] + + const result = await drivePty({ + args: ["get-sync-token"], + workDir: vaultDir, + prompts, + env: { OBSIDIAN_SIGNIN_URL: url }, + }) + + expect(result.exitCode).toBe(0) + expect(result.promptsAnswered).toBe(result.totalPrompts) + expect(result.transcript).toContain("pty-test-token") + expect(result.transcript).toContain("Done.") + }) + + it("writes the token to .env with --dir", async () => { + const { vaultDir, configDir } = createPtyWorkDir() + const { url, server } = await startSigninServer("pty-dir-token") + onTestFinished(() => { + server.close() + }) + + mkdirSync(configDir, { recursive: true }) + writeFileSync( + join(configDir, ".env"), + "MCP_AUTH_TOKEN=test-token\nOBSIDIAN_AUTH_TOKEN=\nVAULT_NAME=TestVault\nPUBLIC_URL=http://localhost:8000\n", + ) + + const prompts: PtyPrompt[] = [ + { + match: "Obsidian account email", + send: "user@example.com\r", + label: "email", + }, + { match: "Password", send: "secret123\r", label: "password" }, + ] + + const result = await drivePty({ + args: ["get-sync-token", "--dir", configDir], + workDir: vaultDir, + prompts, + env: { OBSIDIAN_SIGNIN_URL: url }, + }) + + expect(result.exitCode).toBe(0) + expect(result.promptsAnswered).toBe(result.totalPrompts) + expect(result.transcript).toContain("Token written to") + expect(result.transcript).toContain("npx vault-cortex start") + expect(result.transcript).toContain("Done.") + + const envContent = readFileSync(join(configDir, ".env"), "utf8") + expect(envContent).toContain("OBSIDIAN_AUTH_TOKEN=pty-dir-token") + }) +}) + describe("non-interactive commands", () => { it("upgrade pulls and starts", async () => { const { vaultDir, configDir } = createPtyWorkDir() diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index 2bfe3df05..bef50446d 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -13,6 +13,7 @@ import { buildFilesToWrite, detectMode, patchEnvObsidianToken, + readEnvObsidianToken, readEnvPort, readEnvPublicUrl, readEnvVaultPath, @@ -477,6 +478,48 @@ describe("patchEnvObsidianToken", () => { }) }) +describe("readEnvObsidianToken", () => { + it("returns undefined when the file does not exist", () => { + const missingPath = join(tmpdir(), "vault-cli-no-such-env", ".env") + + expect(readEnvObsidianToken(missingPath)).toBeUndefined() + }) + + it("returns the token value from an existing .env", () => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync( + envPath, + "MCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=my-token\nVAULT_NAME=Test\n", + ) + + expect(readEnvObsidianToken(envPath)).toBe("my-token") + }) + + it("returns undefined when the line exists but the value is empty", () => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync( + envPath, + "MCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=\nVAULT_NAME=Test\n", + ) + + expect(readEnvObsidianToken(envPath)).toBeUndefined() + }) + + 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") + + expect(readEnvObsidianToken(envPath)).toBeUndefined() + }) + + it("trims whitespace from the token value", () => { + const envPath = join(mkdtempSync(join(tmpdir(), "vault-cli-")), ".env") + writeFileSync(envPath, "OBSIDIAN_AUTH_TOKEN= spaced-token \n") + + expect(readEnvObsidianToken(envPath)).toBe("spaced-token") + }) +}) + describe("stripEnvQuotedValues", () => { it("returns false when the file does not exist", () => { const missingPath = join(tmpdir(), "vault-cli-no-such-env", ".env") diff --git a/cli/src/docker.ts b/cli/src/docker.ts index c4d3d2b74..9469c57c0 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -45,54 +45,6 @@ export type DockerRunner = { * process's exit code once the stream ends. */ streamLogs: (params: DockerLogsParams) => Promise - /** Runs the Obsidian login with a volume mount for token auto-capture. */ - runObsidianLogin: (configMountPath: string) => boolean -} - -export type ObsidianLoginArgParams = { - configMountPath: string - /** Defaults to process.platform. */ - platform?: NodeJS.Platform - /** Host UID for --user flag on Linux. */ - uid?: number - /** Host GID for --user flag on Linux. */ - gid?: number -} - -/** - * Builds the `docker run` args for the Obsidian login with a volume mount - * that captures the auth token file. Runs `ob login` directly instead of - * the image's get-sync-token script: the script's additions are locating and - * printing the token, and the mount makes both unnecessary — the CLI reads - * the token file itself, and not echoing a credential keeps it out of - * terminal scrollback. Pure function for testability. - * - * On Linux, includes `--user uid:gid` when uid/gid are provided — Node - * exposes process.getuid/getgid on every POSIX platform, so in practice the - * flag is always set there — keeping the token file host-user-owned. macOS - * Docker Desktop translates UIDs automatically, so no flag is needed. - */ -export const buildObsidianLoginArgs = ( - params: ObsidianLoginArgParams, -): string[] => { - const { configMountPath, platform = process.platform, uid, gid } = params - - const args = [ - "run", - "--rm", - "-it", - "--entrypoint", - "ob", - "-v", - `${configMountPath}:/home/obsidian/.config`, - ] - - if (platform === "linux" && uid !== undefined && gid !== undefined) { - args.push("--user", `${uid}:${gid}`) - } - - args.push(REMOTE_IMAGE, "login") - return args } /** @@ -205,10 +157,7 @@ export const createDockerRunner = (): DockerRunner => ({ // stdout is discarded: `docker run -d` prints only the container ID there, // 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. stdin is ignored on purpose: - // buildDockerRunArgs always runs detached (never -it), and the prompt - // library owns the terminal's stdin — interactive flows go through - // runObsidianLogin, which inherits all three streams. + // "see output above" failure messages rely on. dockerRun: (params) => spawnSync("docker", buildDockerRunArgs(params), { stdio: ["ignore", "ignore", "inherit"], @@ -248,16 +197,6 @@ export const createDockerRunner = (): DockerRunner => ({ // convention for ctrl-C (128 + SIGINT = 130). child.once("close", (code) => resolveExitCode(code ?? 130)) }), - runObsidianLogin: (configMountPath) => - spawnSync( - "docker", - buildObsidianLoginArgs({ - configMountPath, - uid: process.getuid?.(), - gid: process.getgid?.(), - }), - { stdio: "inherit" }, - ).status === 0, }) /** Default bound on a single health request (shared by probe and poll). */ diff --git a/cli/src/env.ts b/cli/src/env.ts index eb860a46f..e7fb17fec 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -6,8 +6,8 @@ export type LocalEnvAnswers = { export type RemoteEnvAnswers = { mcpAuthToken: string publicUrl: string - /** Empty string when the user chose to fill it in later. */ - obsidianAuthToken: string + /** Undefined when no token was captured or found on disk. */ + obsidianAuthToken?: string vaultName: string /** Only set when the vault uses end-to-end encryption. */ vaultPassword?: string @@ -349,12 +349,11 @@ export const buildRemoteEnv = (answers: RemoteEnvAnswers): string => { : `# Vault end-to-end encryption password. VAULT_PASSWORD=${answers.vaultPassword}` - const obsidianTokenComment = - answers.obsidianAuthToken === "" - ? `# Obsidian Sync auth token — FILL THIS IN before starting the server. + const obsidianTokenComment = answers.obsidianAuthToken + ? `# Obsidian Sync auth token.` + : `# Obsidian Sync auth token — FILL THIS IN before starting the server. # Generate once with: # npx vault-cortex@latest get-sync-token` - : `# Obsidian Sync auth token.` return `# vault-cortex — remote quickstart (Obsidian Sync) # Generated by \`npx vault-cortex@latest init\`. Full option reference: @@ -370,7 +369,7 @@ MCP_AUTH_TOKEN=${answers.mcpAuthToken} PUBLIC_URL=${answers.publicUrl} ${obsidianTokenComment} -OBSIDIAN_AUTH_TOKEN=${answers.obsidianAuthToken} +OBSIDIAN_AUTH_TOKEN=${answers.obsidianAuthToken ?? ""} # Exact name of your Obsidian vault (case-sensitive). VAULT_NAME=${answers.vaultName} diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index c694c5f54..05dcc49f8 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -1,12 +1,5 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" import { join, resolve } from "node:path" -import type { DockerRunner } from "./docker.js" -import { - buildDaemonNotRunningMessage, - buildDockerNotInstalledMessage, -} from "./messages.js" import type { Prompts } from "./prompts.js" import { patchEnvObsidianToken } from "./scaffold.js" import { expandTilde } from "./vault.js" @@ -17,134 +10,150 @@ export type GetSyncTokenFlags = { export type GetSyncTokenDeps = { prompts: Prompts - docker: DockerRunner + fetchFn: typeof fetch } -/** Message from an unknown throw — Error instances keep their message. */ +const OBSIDIAN_SIGNIN_URL = + process.env.OBSIDIAN_SIGNIN_URL ?? "https://api.obsidian.md/user/signin" +const SIGNIN_TIMEOUT_MS = 30_000 + const describeError = (error: unknown): string => error instanceof Error ? error.message : String(error) -/** - * Creates the temp dir the container's config mount writes into. - * Returns undefined (after warning) when creation fails. - */ -const makeTempMountDir = (prompts: Prompts): string | undefined => { - try { - return mkdtempSync(join(tmpdir(), "vault-cortex-sync-token-")) - } catch (error) { - prompts.warn( - `Could not create a temp directory for token capture — ${describeError(error)}`, - ) - return undefined +class ObsidianApiError extends Error { + constructor(message: string) { + super(message) + this.name = "ObsidianApiError" } } -/** - * Runs the interactive Obsidian login container. A throw from the Docker - * runner is reported and treated the same as a non-zero exit. - */ -const runLoginContainer = ( - configMountPath: string, - deps: GetSyncTokenDeps, -): boolean => { - const { docker, prompts } = deps - try { - return docker.runObsidianLogin(configMountPath) - } catch (error) { - prompts.warn(`Docker run failed — ${describeError(error)}`) - return false - } -} +const isJsonObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) /** - * Reads the captured token file from the config mount. Returns undefined - * when the file is missing, empty, or unreadable — the caller treats all - * three as "no token captured". + * Calls the Obsidian Sync signin API. Returns the parsed JSON on success, + * or throws on HTTP/network errors. The API returns { error: string } for + * auth failures (200 with an error field), and non-200 for server errors. */ -const readCapturedTokenFile = (configMountPath: string): string | undefined => { - const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") +const callSigninApi = async ( + params: { email: string; password: string; mfa: string }, + fetchFn: typeof fetch, +): Promise => { + const response = await fetchFn(OBSIDIAN_SIGNIN_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://obsidian.md", + }, + body: JSON.stringify({ + email: params.email, + password: params.password, + mfa: params.mfa, + }), + signal: AbortSignal.timeout(SIGNIN_TIMEOUT_MS), + }) + + if (!response.ok) throw new Error(`HTTP Error ${response.status}`) + try { - if (!existsSync(tokenPath)) return undefined - const token = readFileSync(tokenPath, "utf8").trim() - return token || undefined - } catch { - return undefined + const body = await response.json() + if (!isJsonObject(body)) throw new Error("not a JSON object") + if (typeof body.error === "string") throw new ObsidianApiError(body.error) + if (typeof body.token !== "string" || !body.token) + throw new Error("no token field") + + return body.token + } catch (error) { + if (error instanceof ObsidianApiError) throw error + throw new Error( + `Unexpected response from Obsidian API (${describeError(error)})`, + { cause: error }, + ) } } /** - * Best-effort removal of the temp mount dir. Failing to remove it (e.g. - * root-owned files left by the container) must not turn a successful - * capture into a failure, so it warns instead of throwing. + * Warns the user about a signin failure with a message tailored to the + * error type. Called by both the initial signin and MFA retry paths. */ -const removeTempMountDir = ( - configMountPath: string, +const warnSigninError = ( + error: unknown, prompts: Prompts, + isMfaRetry: boolean, ): void => { - try { - rmSync(configMountPath, { recursive: true, force: true }) - } catch (error) { + if (error instanceof Error && error.name === "TimeoutError") { prompts.warn( - `Could not remove temp directory ${configMountPath} — ${describeError(error)}`, + "Request timed out — check your internet connection and try again.", ) + return } + + const isMfaError = + error instanceof ObsidianApiError && error.message.includes("2FA code") + const mfaHint = + isMfaRetry && isMfaError ? "\n Check your 2FA code and try again." : "" + + prompts.warn(`Could not sign in: ${describeError(error)}${mfaHint}`) } /** - * Runs the Obsidian login (`ob login`) inside a Docker container with a - * volume mount that captures the auth token file. The interactive login - * (email, password, MFA) shows in the terminal, but the resulting token is - * read from the mounted config dir — never printed, so it stays out of - * terminal scrollback. - * - * tokenDestinationMessage finishes the handoff message by telling the user - * where the captured token ends up — the destination differs per flow - * (init stores it in the generated .env; the subcommand prints it, or - * writes it to an existing .env with --dir). - * - * Returns the token string on success, undefined on any failure — each - * fallible operation is wrapped individually by the helpers above, so no - * catch-all is needed here. The bare try/finally only scopes the temp dir - * (acquire → release); it has no catch and swallows nothing. + * Signs in to the user's Obsidian account via the Sync API and returns + * the auth token. Prompts for email, password, and MFA code (when 2FA + * is enabled). Returns the token on success, undefined on any failure. */ -export const captureObsidianToken = ( +export const captureObsidianToken = async ( deps: GetSyncTokenDeps, - tokenDestinationMessage: string, -): string | undefined => { - const { prompts } = deps - const configMountPath = makeTempMountDir(prompts) - if (!configMountPath) return undefined +): Promise => { + const { prompts, fetchFn } = deps + + const email = await prompts.text("Obsidian account email:", { + placeholder: "you@example.com", + }) + const password = await prompts.password("Password:") + + const spinner = prompts.spinner() + spinner.start("Signing in to Obsidian...") try { - prompts.log( - "Handing the terminal to the Obsidian login — it will ask for your " + - `account email, password, and MFA code. ${tokenDestinationMessage}`, - ) - const loginSucceeded = runLoginContainer(configMountPath, deps) - if (!loginSucceeded) { - prompts.warn( - "The Obsidian login did not complete — you can run it later with:\n" + - " npx vault-cortex@latest get-sync-token", - ) + const token = await callSigninApi({ email, password, mfa: "" }, fetchFn) + spinner.stop(`Signed in as ${email}.`) + return token + } catch (error) { + // MFA required: the API returns an error containing "2FA code" — prompt + // and retry. "2FA code is incorrect" is a wrong-code rejection, not a + // prompt-for-code signal. Mirrors the obsidian-headless v0.0.14 logic. + const needsMfa = + error instanceof ObsidianApiError && + error.message.includes("2FA code") && + !error.message.includes("2FA code is incorrect") + + if (!needsMfa) { + spinner.stop("Sign-in failed.") + warnSigninError(error, prompts, false) return undefined } - const token = readCapturedTokenFile(configMountPath) - if (!token) { - prompts.warn( - "The Obsidian login finished, but no token was captured — the " + - "token file was missing, empty, or unreadable. You can retry with:\n" + - " npx vault-cortex@latest get-sync-token", + + spinner.stop("Two-factor authentication required.") + const mfaCode = await prompts.text("2FA code:") + + spinner.start("Verifying...") + try { + const token = await callSigninApi( + { email, password, mfa: mfaCode }, + fetchFn, ) + spinner.stop(`Signed in as ${email}.`) + return token + } catch (retryError) { + spinner.stop("Sign-in failed.") + warnSigninError(retryError, prompts, true) return undefined } - return token - } finally { - removeTempMountDir(configMountPath, prompts) } } /** - * Subcommand entry: generate an Obsidian Sync token via Docker. + * Subcommand entry: generate an Obsidian Sync token via the Obsidian API. * Without --dir, prints the token to stdout. * With --dir, writes it directly to `/.env`. */ @@ -152,38 +161,20 @@ export const runGetSyncToken = async ( flags: GetSyncTokenFlags, deps: GetSyncTokenDeps, ): Promise => { - const { prompts, docker } = deps - - const daemonStatus = docker.daemonStatus() - if (daemonStatus !== "running") { - prompts.error( - daemonStatus === "not-installed" - ? buildDockerNotInstalledMessage({ nextStep: "\nThen try again." }) - : buildDaemonNotRunningMessage(" and try again."), - ) - return 1 - } + const { prompts } = deps prompts.intro("vault-cortex get-sync-token") - // Resolve the destination up front so the login handoff message can tell - // the user where the token will end up. - const envFilePath = flags.dir - ? join(resolve(expandTilde(flags.dir)), ".env") - : undefined - const tokenDestinationMessage = envFilePath - ? `The token is captured automatically and written to ${envFilePath}.` - : "The token is captured automatically and printed at the end." - - const token = captureObsidianToken( - { docker, prompts }, - tokenDestinationMessage, - ) + const token = await captureObsidianToken(deps) if (!token) { prompts.error("Could not capture the auth token.") return 1 } + const envFilePath = flags.dir + ? join(resolve(expandTilde(flags.dir)), ".env") + : undefined + if (!envFilePath) { prompts.log("Your OBSIDIAN_AUTH_TOKEN:") prompts.print(`\n ${token}\n`) @@ -199,7 +190,10 @@ export const runGetSyncToken = async ( ) return 1 } - prompts.log(`Token written to ${envFilePath}`) + prompts.log( + `Token written to ${envFilePath}\n\n` + + `Start the server:\n npx vault-cortex start --dir "${flags.dir}"`, + ) prompts.outro("Done.") return 0 } diff --git a/cli/src/init.ts b/cli/src/init.ts index e5052b2e2..ce5f64861 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -25,6 +25,7 @@ import { } from "./optional-settings.js" import { buildFilesToWrite, + readEnvObsidianToken, readEnvPort, readEnvPublicUrl, stripEnvQuotedValues, @@ -75,20 +76,22 @@ const askMode = async (prompts: Prompts): Promise => { } /** - * Offers to auto-capture the Obsidian Sync token via a Docker volume mount. - * Returns the captured token string, or undefined when the user declines or - * the capture fails (the caller falls back to a paste prompt). + * Offers to sign in to the Obsidian account and capture the Sync token. + * Returns the captured token string, or undefined when the user declines + * or the capture fails (the caller falls back to any token already in the + * on-disk .env, or shows get-sync-token guidance). */ const offerSyncTokenCapture = async ( prompts: Prompts, - docker: DockerRunner, + fetchFn: typeof fetch, ): Promise => { + prompts.log( + "Your server needs an Obsidian Sync token to access your vault.\n" + + "You can sign in to your Obsidian account now to generate one.", + ) const runNow = await prompts.confirm("Generate the token now?", true) if (!runNow) return undefined - return captureObsidianToken( - { docker, prompts }, - "The token is captured automatically and stored in your .env — nothing to copy.", - ) + return captureObsidianToken({ prompts, fetchFn }) } /** @@ -417,15 +420,15 @@ const runLocalInit = async ( } // Remote flow (VPS + Obsidian Sync): resolve target dir → PUBLIC_URL → -// VAULT_NAME → Obsidian Sync token (optionally running the Obsidian login via -// Docker) → optional E2E vault password → generate token → write .env → -// optionally start → print connect instructions. Always interactive — -// the sync-token step can't be defaulted. +// VAULT_NAME → Obsidian Sync token (sign in via the Obsidian API) → optional +// E2E vault password → generate token → write .env → optionally start → print +// connect instructions. Always interactive — the sync-token step can't be +// defaulted. const runRemoteInit = async ( flags: InitFlags, deps: InitDeps, ): Promise => { - const { prompts, docker } = deps + const { prompts, fetchFn } = deps // expandTilde before resolve: resolve() treats a leading `~` as a literal // path segment, so a quoted "~/path" would create a directory named "~". @@ -445,25 +448,19 @@ const runRemoteInit = async ( const publicUrl = await askPublicUrl(prompts) const vaultName = await askVaultName(prompts) - // Auto-capture the Obsidian Sync token via a Docker volume mount when - // the daemon is reachable. Falls back to a paste prompt when capture - // fails or the user declines. Both non-running states stay silent here — - // the paste fallback is fully functional without Docker, and the start - // offer surfaces the differentiated runtime guidance later in the flow. - const capturedToken = - docker.daemonStatus() === "running" - ? await offerSyncTokenCapture(prompts, docker) - : undefined - // Masked prompt: the sync token is a credential and must not echo into - // the terminal or scrollback. An empty submission still means "fill in - // .env later" — clack's password prompt accepts blank input. - const obsidianAuthToken = - capturedToken ?? - ( - await prompts.password( - "Paste the Obsidian Sync token (leave blank to fill in .env later):", - ) - ).trim() + // 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. + const capturedToken = await offerSyncTokenCapture(prompts, fetchFn) + const existingEnvToken = readEnvObsidianToken(join(targetDir, ".env")) + const hasExistingToken = Boolean(capturedToken ?? existingEnvToken) + if (!hasExistingToken) { + prompts.log( + "No token yet — run this later to add it to your .env:\n" + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, + ) + } const usesEncryption = await prompts.confirm( "Does your vault use end-to-end encryption?", @@ -483,7 +480,7 @@ const runRemoteInit = async ( const defaultEnvContent = buildRemoteEnv({ mcpAuthToken: token, publicUrl, - obsidianAuthToken, + obsidianAuthToken: capturedToken ?? existingEnvToken, vaultName, vaultPassword, }) @@ -520,10 +517,9 @@ 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 startStatus: StartStatus = - obsidianAuthToken === "" - ? "not-started" - : await offerDockerRun({ targetDir, port, mode: "remote" }, deps) + const startStatus: StartStatus = !hasExistingToken + ? "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 (startStatus === "running") { @@ -538,7 +534,7 @@ const runRemoteInit = async ( token, publicUrl: effectivePublicUrl, startStatus, - obsidianTokenMissing: obsidianAuthToken === "", + obsidianTokenMissing: !hasExistingToken, tokenWritten, }), ) diff --git a/cli/src/main.ts b/cli/src/main.ts index 4e5b1f48e..ae1576f69 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -53,7 +53,7 @@ export const run = async (version: string): Promise => { runGetSyncToken: (flags) => runGetSyncToken(flags, { prompts: createPrompts(), - docker: createDockerRunner(), + fetchFn: fetch, }), }) await program.parseAsync() diff --git a/cli/src/messages.ts b/cli/src/messages.ts index f0ada29ad..6811a6ee6 100644 --- a/cli/src/messages.ts +++ b/cli/src/messages.ts @@ -62,7 +62,7 @@ const dockerInstallLine = (platform: NodeJS.Platform): string => { /** * "No runtime at all" guidance — distinct from the daemon-stopped message so * the user isn't told to start something that isn't installed. platform is a - * defaulted param (mirroring buildObsidianLoginArgs) so each branch stays + * defaulted param so each branch stays * testable; `nextStep` is appended verbatim, as in * buildDaemonNotRunningMessage. */ diff --git a/cli/src/program.ts b/cli/src/program.ts index a7f2b8ea3..4645338b3 100644 --- a/cli/src/program.ts +++ b/cli/src/program.ts @@ -132,7 +132,7 @@ export const buildProgram = (options: ProgramOptions): Command => { program .command("get-sync-token") .description( - "Generate an Obsidian Sync auth token via Docker and print it or write it to .env", + "Sign in to your Obsidian account and print the Sync auth token, or write it to .env", ) .option( "--dir ", diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index e8dee1ba0..40ae4f890 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -151,6 +151,21 @@ export const patchEnvObsidianToken = ( return true } +/** + * 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. + */ +export const readEnvObsidianToken = ( + envFilePath: string, +): string | undefined => { + if (!existsSync(envFilePath)) return undefined + const match = /^OBSIDIAN_AUTH_TOKEN=(.+)$/m.exec( + readFileSync(envFilePath, "utf8"), + ) + return match?.[1].trim() || undefined +} + /** * Strips surrounding quotes from env values in the file. `docker run * --env-file` passes quotes literally (`VAULT_NAME="My Vault"` becomes diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 8f0632218..8790eb596 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -44,24 +44,33 @@ your computer — once, before you click the button. 1. **Open a terminal.** macOS: **Applications → Utilities → Terminal**. Windows: search the Start menu for **Terminal** (or **PowerShell**). -2. **Install and open Docker Desktop** if you don't have it: download it - from [docker.com](https://www.docker.com/products/docker-desktop/) and - run the installer. The login runs inside a throwaway container, so Docker - has to be running. +2. **Check that Node.js is installed** (version 20.12 or later): + ```bash + node -v + ``` + If Node.js is missing, install it from [nodejs.org](https://nodejs.org/). 3. **Paste this line and press Enter:** ```bash - docker run --rm -it --entrypoint get-sync-token ghcr.io/aliasunder/vault-cortex:remote + npx vault-cortex@latest get-sync-token ``` It asks for your Obsidian account email, password, and two-factor code - (if you use one), prints the token, and exits. Nothing is installed - permanently. + (if you use one), then prints the token. 4. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`. -Already have Node.js? `npx vault-cortex@latest get-sync-token` does the same -(Docker still has to be running). +
+Don't have Node.js? + +Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) +and run the login in a throwaway container instead: + +```bash +docker run --rm -it --entrypoint get-sync-token ghcr.io/aliasunder/vault-cortex:remote +``` + +
## Deploy diff --git a/deploy/remote/README.md b/deploy/remote/README.md index d57219c9f..d4eabc8dd 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -30,8 +30,9 @@ container runtime works in place of Docker. npx vault-cortex@latest init --mode remote ``` -The CLI walks through your public URL, Obsidian Sync token (it can run -[`get-sync-token`](../../cli/#get-sync-token) for you), and auth config, then +The CLI walks through your public URL, Obsidian Sync token (it can sign in to +your Obsidian account and [capture the token](../../cli/#get-sync-token) for +you), and auth config, then starts the server and prints the connection details for your MCP client ([CLI reference →](../../cli/)). @@ -67,8 +68,8 @@ Or clone the repo and `cd deploy/remote`. **3. Generate your Obsidian Sync auth token** (one-time): -If you have Node.js >= 20.12 on this machine, the CLI runs the login and -captures the token for you: +If you have Node.js >= 20.12 on this machine, the CLI signs in to your +Obsidian account and captures the token: ```bash npx vault-cortex@latest get-sync-token diff --git a/deploy/render/README.md b/deploy/render/README.md index c07b9d438..f0aee47e8 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -41,24 +41,33 @@ your computer — once, before you click the button. 1. **Open a terminal.** macOS: **Applications → Utilities → Terminal**. Windows: search the Start menu for **Terminal** (or **PowerShell**). -2. **Install and open Docker Desktop** if you don't have it: download it - from [docker.com](https://www.docker.com/products/docker-desktop/) and - run the installer. The login runs inside a throwaway container, so Docker - has to be running. +2. **Check that Node.js is installed** (version 20.12 or later): + ```bash + node -v + ``` + If Node.js is missing, install it from [nodejs.org](https://nodejs.org/). 3. **Paste this line and press Enter:** ```bash - docker run --rm -it --entrypoint get-sync-token ghcr.io/aliasunder/vault-cortex:remote + npx vault-cortex@latest get-sync-token ``` It asks for your Obsidian account email, password, and two-factor code - (if you use one), prints the token, and exits. Nothing is installed - permanently. + (if you use one), then prints the token. 4. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`. -Already have Node.js? `npx vault-cortex@latest get-sync-token` does the same -(Docker still has to be running). +
+Don't have Node.js? + +Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) +and run the login in a throwaway container instead: + +```bash +docker run --rm -it --entrypoint get-sync-token ghcr.io/aliasunder/vault-cortex:remote +``` + +
## Deploy