From 2a661e3f3684a0025bd565eeaa6e479f260e1890 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:59:24 -0400 Subject: [PATCH 01/23] feat(cli): replace Docker-based get-sync-token with native Obsidian API call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `get-sync-token` command previously required Docker Desktop (2+ GB) to run a throwaway container for a single API call. Replace the Docker path with a direct `fetch` to `api.obsidian.md/user/signin` — the same endpoint the `obsidian-headless` CLI uses internally. The new flow prompts for email, password, and MFA code (when 2FA is enabled) via clack prompts, with a 30s timeout, spinner feedback, and per-failure-mode error messages (wrong password, timeout, rate limit, server error, non-JSON response, missing token field). - Remove `runObsidianLogin` from `DockerRunner` type and all stubs - Remove `buildObsidianLoginArgs` and its tests - `init --mode remote` always offers token generation (no Docker gate) - Update deploy guides: npx path works without Docker - Update CLI README and program description Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/README.md | 14 +- cli/src/__tests__/command-stubs.ts | 2 - cli/src/__tests__/docker.test.ts | 66 ---- cli/src/__tests__/get-sync-token.test.ts | 409 ++++++++++++----------- cli/src/__tests__/init.test.ts | 66 ++-- cli/src/docker.ts | 63 +--- cli/src/get-sync-token.ts | 243 +++++++------- cli/src/init.ts | 27 +- cli/src/main.ts | 2 +- cli/src/messages.ts | 2 +- cli/src/program.ts | 2 +- deploy/railway/README.md | 2 +- deploy/remote/README.md | 9 +- deploy/render/README.md | 2 +- 14 files changed, 403 insertions(+), 506 deletions(-) diff --git a/cli/README.md b/cli/README.md index 36e77ef46..d301860e9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -61,7 +61,8 @@ 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. +[`get-sync-token`](#get-sync-token) for you — sign in to your Obsidian +account right from the terminal. Flags: @@ -194,17 +195,16 @@ 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. No Docker +required. 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..277cd76bc 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -1,254 +1,281 @@ -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", + name = "Test User", + email = "test@example.com", +): typeof fetch => + (async () => + new Response(JSON.stringify({ token, name, email }), { + 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 /** - * 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." - -/** - * Creates a DockerRunner whose runObsidianLogin writes a fake - * auth_token file into the config mount path, simulating the - * containerized login writing the token file. - */ -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 fetchMfaRequired = ( + token = "mfa-sync-token", + name = "MFA User", + email = "mfa@example.com", +): 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, name, email }), { + 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", + "Jane", + "user@example.com", + ), + }) expect(token).toBe("abc123-sync-token") + expect(scripted.spinnerMessages).toEqual([ + "start: Signing in to Obsidian...", + "stop: Signed in as Jane (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", "MFA User", "mfa@example.com"), + }) - 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 User (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 on wrong password", async () => { + const scripted = createScriptedPrompts([ + "user@example.com", + "wrong-password", + ]) - const token = captureObsidianToken( - { - docker: dockerWithToken(""), - prompts: scripted.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( - "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: Invalid email or password", ) }) - it("returns undefined and warns when the login succeeds but writes no token file", () => { - const scripted = createScriptedPrompts() - const dockerSucceedsButNoFile: DockerRunner = { - ...dockerDaemonOnly, - runObsidianLogin: () => true, - } + it("returns undefined on HTTP error", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) - const token = captureObsidianToken( - { - docker: dockerSucceedsButNoFile, - prompts: scripted.prompts, - }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchHttpError(500), + }) 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", - ) + expect(scripted.warnings[0]).toBe("Could not sign in: HTTP Error 500") }) - 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("returns undefined with a clear message on HTTP 429", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) - const token = captureObsidianToken( - { - docker: dockerThrows, - prompts: scripted.prompts, - }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchHttpError(429), + }) 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: HTTP Error 429") }) - 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 network error", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) - captureObsidianToken( - { docker: dockerTracker, prompts }, - TOKEN_DESTINATION_MESSAGE, - ) + const token = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchNetworkError("fetch failed"), + }) - expect(tempDirs).toHaveLength(1) - expect(existsSync(tempDirs[0])).toBe(false) + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe("Could not sign in: fetch failed") }) - it("logs the handoff message before running docker", () => { - const scripted = createScriptedPrompts() + it("returns undefined with a timeout message when the request times out", 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: fetchTimeout(), + }) - 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( + "Request timed out — check your internet connection and try again.", ) }) -}) -describe("runGetSyncToken subcommand", () => { - it("prints the token to stdout when --dir is not set", async () => { - const scripted = createScriptedPrompts() + it("returns undefined on non-JSON response", 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: fetchNonJson(), + }) - 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.", + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe( + "Could not sign in: Unexpected response from Obsidian API (not JSON)", ) - 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 missing the token field", 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: fetchMalformedSuccess(), + }) - 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.", + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe( + "Could not sign in: Unexpected response from Obsidian API (no token)", ) }) +}) - // 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", + "User", + "user@example.com", + ), + }, ) - 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,19 +288,17 @@ 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", ) @@ -285,11 +310,14 @@ describe("runGetSyncToken subcommand", () => { 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 +332,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..890d6da0e 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,6 +228,7 @@ describe("remote connect message https routing", () => { const scripted = createScriptedPrompts([ publicUrl, "MyVault", + false, // don't generate the token now (declined auto-capture) "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings @@ -286,6 +287,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", + false, // don't generate the token now (declined auto-capture) "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings @@ -318,6 +320,7 @@ describe("remote connect message https routing", () => { const scripted = createScriptedPrompts([ "https://vault.example.com/", // trailing slash — trimmed, not rejected "MyVault", + false, // don't generate the token now (declined auto-capture) "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings @@ -346,6 +349,7 @@ describe("remote connect message https routing", () => { const scripted = createScriptedPrompts([ "https://vault.example.com", "MyVault", + false, // don't generate the token now (declined auto-capture) "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings @@ -562,12 +566,13 @@ describe("runInit remote flow", () => { ) }) - 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) + "sync-token-xyz", // paste fallback — obsidian sync token false, // no end-to-end encryption [], // no optional settings ]) @@ -580,12 +585,13 @@ 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). No + // "Start the server now?" (the install warning replaces the start offer). 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):", + "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)", @@ -691,35 +697,32 @@ describe("runInit remote flow", () => { "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):", ) @@ -731,12 +734,13 @@ 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) + "", // blank token — fill in later false, // no encryption [], // no optional settings ]) @@ -763,7 +767,8 @@ describe("runInit remote flow", () => { 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) + "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings ]) @@ -782,6 +787,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):", + "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)", @@ -1050,10 +1056,17 @@ 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 () => { 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 + "user@example.com", // email + "bad-password", // password "", // paste fallback — blank token, fill in later false, // no encryption [], // no optional settings @@ -1063,7 +1076,7 @@ describe("runInit sync-token auto-capture fallback", () => { { prompts: scripted.prompts, docker: dockerDaemonOnly, - fetchFn: fetchNever, + fetchFn: fetchSigninError, }, ) @@ -1071,7 +1084,7 @@ describe("runInit sync-token auto-capture fallback", () => { "Paste the Obsidian Sync token (leave blank to fill in .env later):", ) expect(scripted.warnings[0]).toContain( - "The Obsidian login did not complete", + "Could not sign in: Invalid email or password", ) }) }) @@ -1250,6 +1263,7 @@ describe("runInit guided optional settings", () => { const scripted = createScriptedPrompts([ "https://vault.example.com", "MyVault", + false, // don't generate the token now (declined auto-capture) "", // blank sync token — fill in .env later false, // no encryption ["SYNC_MODE"], 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/get-sync-token.ts b/cli/src/get-sync-token.ts index c694c5f54..00c99418a 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,148 @@ 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 = "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. + * 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 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 +const callSigninApi = async ( + params: { email: string; password: string; mfa: string }, + fetchFn: typeof fetch, +): Promise<{ token: string; name: string; email: string }> => { + 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}`) } -} -/** - * 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 + let body: unknown try { - return docker.runObsidianLogin(configMountPath) - } catch (error) { - prompts.warn(`Docker run failed — ${describeError(error)}`) - return false + body = await response.json() + } catch { + throw new Error("Unexpected response from Obsidian API (not JSON)") } -} -/** - * 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". - */ -const readCapturedTokenFile = (configMountPath: string): string | undefined => { - const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") - try { - if (!existsSync(tokenPath)) return undefined - const token = readFileSync(tokenPath, "utf8").trim() - return token || undefined - } catch { - return undefined + const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + + if (!isRecord(body)) { + throw new Error("Unexpected response from Obsidian API (not JSON)") + } + + if ("error" in body && typeof body.error === "string") { + throw new ObsidianApiError(body.error) + } + + const token = + "token" in body && typeof body.token === "string" ? body.token : undefined + if (!token) { + throw new Error("Unexpected response from Obsidian API (no token)") } + + const name = "name" in body && typeof body.name === "string" ? body.name : "" + const email = + "email" in body && typeof body.email === "string" ? body.email : "" + + return { token, name, email } } -/** - * 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. - */ -const removeTempMountDir = ( - configMountPath: string, - prompts: Prompts, -): void => { - try { - rmSync(configMountPath, { recursive: true, force: true }) - } catch (error) { - prompts.warn( - `Could not remove temp directory ${configMountPath} — ${describeError(error)}`, - ) +class ObsidianApiError extends Error { + constructor(message: string) { + super(message) + this.name = "ObsidianApiError" } } /** - * 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", - ) - return undefined + const result = await callSigninApi({ email, password, mfa: "" }, fetchFn) + spinner.stop(`Signed in as ${result.name} (${result.email}).`) + return result.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. + if ( + error instanceof ObsidianApiError && + error.message.includes("2FA code") && + !error.message.includes("2FA code is incorrect") + ) { + spinner.stop("Two-factor authentication required.") + const mfaCode = await prompts.text("2FA code:") + + spinner.start("Verifying...") + try { + const result = await callSigninApi( + { email, password, mfa: mfaCode }, + fetchFn, + ) + spinner.stop(`Signed in as ${result.name} (${result.email}).`) + return result.token + } catch (retryError) { + spinner.stop("Sign-in failed.") + prompts.warn( + `Could not sign in: ${describeError(retryError)}\n` + + " Check your 2FA code and try again.", + ) + return undefined + } } - const token = readCapturedTokenFile(configMountPath) - if (!token) { + + spinner.stop("Sign-in failed.") + + if (error instanceof Error && error.name === "TimeoutError") { 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", + "Request timed out — check your internet connection and try again.", ) return undefined } - return token - } finally { - removeTempMountDir(configMountPath, prompts) + + prompts.warn(`Could not sign in: ${describeError(error)}`) + return undefined } } /** - * 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 +159,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`) diff --git a/cli/src/init.ts b/cli/src/init.ts index ab38a2b97..84912c4e2 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -74,20 +74,17 @@ 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 a paste prompt). */ const offerSyncTokenCapture = async ( prompts: Prompts, - docker: DockerRunner, + fetchFn: typeof fetch, ): Promise => { 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 }) } /** @@ -422,7 +419,7 @@ 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 "~". @@ -442,15 +439,9 @@ 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 + // Sign in to Obsidian and capture the Sync token directly via the API. + // Falls back to a paste prompt when the user declines or capture fails. + const capturedToken = await offerSyncTokenCapture(prompts, fetchFn) // 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. 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/deploy/railway/README.md b/deploy/railway/README.md index 8f0632218..2e9daf4bc 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -61,7 +61,7 @@ your computer — once, before you click the button. 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). +without Docker. ## Deploy diff --git a/deploy/remote/README.md b/deploy/remote/README.md index d57219c9f..51703d315 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 — no Docker needed: ```bash npx vault-cortex@latest get-sync-token diff --git a/deploy/render/README.md b/deploy/render/README.md index c07b9d438..2e5280be8 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -58,7 +58,7 @@ your computer — once, before you click the button. 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). +without Docker. ## Deploy From d939c87fdb358b40f49d0045d9ba7031d19efda5 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:49:27 -0400 Subject: [PATCH 02/23] fix(review): update stale Docker references in comments AGENTS.md structure tree and init.ts flow comment still described the get-sync-token mechanism as Docker-based ("via volume mount", "via Docker") after the PR replaced it with a direct Obsidian API call. Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 2 +- cli/src/init.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/src/init.ts b/cli/src/init.ts index 84912c4e2..cc8e41fb3 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -411,10 +411,10 @@ 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, From d92091638012c4f36caa6c88e99e6d4b8ddbe2d8 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:01:26 -0400 Subject: [PATCH 03/23] test: add MFA retry failure coverage and tighten assertion Cover the inner catch in captureObsidianToken's MFA retry path (previously untested). Fix a toContain on a deterministic warning in init.test.ts to use toBe. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/get-sync-token.test.ts | 51 ++++++++++++++++++++++++ cli/src/__tests__/init.test.ts | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 277cd76bc..47ba5a5e4 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -84,6 +84,27 @@ const fetchMfaRequired = ( }) as typeof fetch } +/** + * 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 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 on successful sign-in", async () => { const scripted = createScriptedPrompts([ @@ -147,6 +168,36 @@ describe("captureObsidianToken", () => { ) }) + 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 = 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( + "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 on wrong password", async () => { const scripted = createScriptedPrompts([ "user@example.com", diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 890d6da0e..d47ccbeaa 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -1083,7 +1083,7 @@ describe("runInit sync-token auto-capture fallback", () => { expect(scripted.asked).toContain( "Paste the Obsidian Sync token (leave blank to fill in .env later):", ) - expect(scripted.warnings[0]).toContain( + expect(scripted.warnings[0]).toBe( "Could not sign in: Invalid email or password", ) }) From 7ce7b17168f1e3cccabd37d6fe4357d190a45aa5 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:10:01 -0400 Subject: [PATCH 04/23] fix(cli): classify retry errors in MFA flow instead of blaming the 2FA code Timeouts and network errors during the MFA retry now get their own messages instead of the misleading "Check your 2FA code" hint. The hint is reserved for ObsidianApiError responses where the code was actually rejected. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/get-sync-token.test.ts | 58 ++++++++++++++++++++++++ cli/src/get-sync-token.ts | 13 +++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 47ba5a5e4..9e3a0d7dd 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -198,6 +198,64 @@ describe("captureObsidianToken", () => { ]) }) + 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 = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchMfaThenTimeout, + }) + + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe( + "Request timed out — check your internet connection and try again.", + ) + }) + + 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 = await captureObsidianToken({ + prompts: scripted.prompts, + fetchFn: fetchMfaThenNetworkError, + }) + + expect(token).toBeUndefined() + expect(scripted.warnings[0]).toBe("Could not sign in: fetch failed") + }) + it("returns undefined on wrong password", async () => { const scripted = createScriptedPrompts([ "user@example.com", diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index 00c99418a..97032a2e0 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -128,9 +128,18 @@ export const captureObsidianToken = async ( return result.token } catch (retryError) { spinner.stop("Sign-in failed.") + if (retryError instanceof Error && retryError.name === "TimeoutError") { + prompts.warn( + "Request timed out — check your internet connection and try again.", + ) + return undefined + } + const retryHint = + retryError instanceof ObsidianApiError + ? "\n Check your 2FA code and try again." + : "" prompts.warn( - `Could not sign in: ${describeError(retryError)}\n` + - " Check your 2FA code and try again.", + `Could not sign in: ${describeError(retryError)}${retryHint}`, ) return undefined } From d9b07b60e52117f0d1be4ff4a9dd60472eb8df40 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:20:22 -0400 Subject: [PATCH 05/23] fix(cli): tighten retry error hint + non-object JSON guard; lead guides with npx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate the 2FA retry hint on error.message containing "2FA code" — account-level errors (rate limiting, lockout) no longer get the misleading code-check hint. - Reject JSON arrays from the signin response (isRecord excludes arrays). - Restructure Railway and Render token sections: npx leads, Docker in a collapsible fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/get-sync-token.test.ts | 22 ++++++++++++++++++++ cli/src/get-sync-token.ts | 5 +++-- deploy/railway/README.md | 26 ++++++++++++++---------- deploy/render/README.md | 26 ++++++++++++++---------- 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 9e3a0d7dd..1a50f0140 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -59,6 +59,14 @@ const fetchMalformedSuccess = (): typeof fetch => 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 + /** * Builds a mock fetch that requires MFA: first call returns the "2FA code" * error, second call with a valid MFA code succeeds. @@ -337,6 +345,20 @@ describe("captureObsidianToken", () => { ) }) + it("returns undefined when the response is valid JSON but not an object", async () => { + const scripted = createScriptedPrompts(["user@example.com", "password"]) + + 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 JSON)", + ) + }) + it("returns undefined when the response is missing the token field", async () => { const scripted = createScriptedPrompts(["user@example.com", "password"]) diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index 97032a2e0..196712766 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -54,7 +54,7 @@ const callSigninApi = async ( } const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null + typeof value === "object" && value !== null && !Array.isArray(value) if (!isRecord(body)) { throw new Error("Unexpected response from Obsidian API (not JSON)") @@ -135,7 +135,8 @@ export const captureObsidianToken = async ( return undefined } const retryHint = - retryError instanceof ObsidianApiError + retryError instanceof ObsidianApiError && + retryError.message.includes("2FA code") ? "\n Check your 2FA code and try again." : "" prompts.warn( diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 2e9daf4bc..be9731e7d 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -44,24 +44,28 @@ 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. -3. **Paste this line and press Enter:** +2. **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. No Docker needed. -4. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`. +3. **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 -without Docker. +
+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/render/README.md b/deploy/render/README.md index 2e5280be8..2f18cb660 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -41,24 +41,28 @@ 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. -3. **Paste this line and press Enter:** +2. **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. No Docker needed. -4. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`. +3. **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 -without Docker. +
+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 From 33b0ee5b0e5a4e8a57f55a6a95b2cf1e82263c97 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:22:25 -0400 Subject: [PATCH 06/23] docs: add Node.js version check step before npx in one-click guides Non-technical users following the one-click deploy guides may not have Node.js installed. A `node -v` step with install link (nodejs.org) and a pointer to the Docker fallback guides them before they hit a "command not found" from npx. Co-Authored-By: Claude Opus 4.6 (1M context) --- deploy/railway/README.md | 10 ++++++++-- deploy/render/README.md | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/deploy/railway/README.md b/deploy/railway/README.md index be9731e7d..68812836b 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -44,7 +44,13 @@ 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. **Paste this line and press Enter:** +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/) + — or skip to the Docker fallback below. +3. **Paste this line and press Enter:** ```bash npx vault-cortex@latest get-sync-token @@ -53,7 +59,7 @@ your computer — once, before you click the button. It asks for your Obsidian account email, password, and two-factor code (if you use one), then prints the token. No Docker needed. -3. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`. +4. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`.
Don't have Node.js? diff --git a/deploy/render/README.md b/deploy/render/README.md index 2f18cb660..db0d77a3b 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -41,7 +41,13 @@ 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. **Paste this line and press Enter:** +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/) + — or skip to the Docker fallback below. +3. **Paste this line and press Enter:** ```bash npx vault-cortex@latest get-sync-token @@ -50,7 +56,7 @@ your computer — once, before you click the button. It asks for your Obsidian account email, password, and two-factor code (if you use one), then prints the token. No Docker needed. -3. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`. +4. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`.
Don't have Node.js? From 23189b350f6c918fa59d0e976a5ef76c8a49cb59 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:23:28 -0400 Subject: [PATCH 07/23] docs: remove confusing Docker fallback reference from Node.js check step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "skip to the Docker fallback below" read as pointing to the npx command on the next line. The Docker fallback is in a collapsible further down — the reference was misleading. Co-Authored-By: Claude Opus 4.6 (1M context) --- deploy/railway/README.md | 3 +-- deploy/render/README.md | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 68812836b..46735c477 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -48,8 +48,7 @@ your computer — once, before you click the button. ```bash node -v ``` - If Node.js is missing, install it from [nodejs.org](https://nodejs.org/) - — or skip to the Docker fallback below. + If Node.js is missing, install it from [nodejs.org](https://nodejs.org/). 3. **Paste this line and press Enter:** ```bash diff --git a/deploy/render/README.md b/deploy/render/README.md index db0d77a3b..ed2838a50 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -45,8 +45,7 @@ your computer — once, before you click the button. ```bash node -v ``` - If Node.js is missing, install it from [nodejs.org](https://nodejs.org/) - — or skip to the Docker fallback below. + If Node.js is missing, install it from [nodejs.org](https://nodejs.org/). 3. **Paste this line and press Enter:** ```bash From 99803e073dff30f16348ec5ae6c961f850af00cb Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:54:18 -0400 Subject: [PATCH 08/23] feat(cli): remove paste prompt, add orientation text for token capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user declines "Generate the token now?" or capture fails, the token is left blank and a log message points them at get-sync-token --dir. The paste prompt was confusing — a user who declined capture almost certainly doesn't have a token to paste. Both the get-sync-token subcommand and the init flow now explain what the token is for before prompting for credentials. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/init.test.ts | 157 ++++++++++++++++++++------------- cli/src/get-sync-token.ts | 4 + cli/src/init.ts | 24 ++--- 3 files changed, 111 insertions(+), 74 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index d47ccbeaa..71450bf16 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -229,7 +229,6 @@ describe("remote connect message https routing", () => { publicUrl, "MyVault", false, // don't generate the token now (declined auto-capture) - "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings ]) @@ -288,7 +287,6 @@ describe("remote connect message https routing", () => { "https://vault.example.com", // base origin — accepted on re-prompt "MyVault", false, // don't generate the token now (declined auto-capture) - "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings ]) @@ -321,7 +319,6 @@ describe("remote connect message https routing", () => { "https://vault.example.com/", // trailing slash — trimmed, not rejected "MyVault", false, // don't generate the token now (declined auto-capture) - "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings ]) @@ -350,7 +347,6 @@ describe("remote connect message https routing", () => { "https://vault.example.com", "MyVault", false, // don't generate the token now (declined auto-capture) - "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings ]) @@ -526,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 }, @@ -551,18 +545,17 @@ 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:\n" + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) }) @@ -572,7 +565,6 @@ describe("runInit remote flow", () => { "https://vault.example.com", // public URL "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 ]) @@ -585,36 +577,45 @@ describe("runInit remote flow", () => { }, ) - // Token generation is always offered (uses the API, not Docker). 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 — and since + // Docker is not installed, the Docker-not-installed warning fires too. 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):", "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)", ]) - 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 @@ -629,17 +630,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 () => { @@ -647,17 +643,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 @@ -667,7 +675,7 @@ describe("runInit remote flow", () => { { prompts: scripted.prompts, docker: dockerReady, - fetchFn: fetchPublicUrlDown, + fetchFn: fetchPublicUrlDownWithSignin, }, ) @@ -675,7 +683,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 (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)", @@ -691,7 +702,7 @@ 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", @@ -723,9 +734,6 @@ describe("runInit remote flow", () => { ) expect(exitCode).toBe(0) - 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"). @@ -740,7 +748,6 @@ describe("runInit remote flow", () => { "http://203.0.113.10:8000", "MyVault", false, // don't generate the token now (declined auto-capture) - "", // blank token — fill in later false, // no encryption [], // no optional settings ]) @@ -768,7 +775,6 @@ describe("runInit remote flow", () => { "https://vault.example.com", // public URL "MyVault", // vault name false, // don't generate the token now (declined auto-capture) - "", // blank sync token — fill in .env later false, // no encryption [], // no optional settings ]) @@ -788,7 +794,6 @@ 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)", ]) @@ -981,11 +986,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 }, @@ -1019,15 +1022,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 @@ -1044,7 +1059,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", ]) @@ -1054,7 +1071,7 @@ 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" }), { @@ -1067,7 +1084,6 @@ describe("runInit sync-token auto-capture fallback", () => { true, // try to generate the token "user@example.com", // email "bad-password", // password - "", // paste fallback — blank token, fill in later false, // no encryption [], // no optional settings ]) @@ -1080,12 +1096,16 @@ describe("runInit sync-token auto-capture fallback", () => { }, ) - 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(readFileSync(join(targetDir, ".env"), "utf8")).toMatch( + /^OBSIDIAN_AUTH_TOKEN=$/m, + ) + expect(scripted.logs).toContain( + "No token yet — run this later to add it:\n" + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, + ) }) }) @@ -1264,7 +1284,6 @@ describe("runInit guided optional settings", () => { "https://vault.example.com", "MyVault", false, // don't generate the token now (declined auto-capture) - "", // blank sync token — fill in .env later false, // no encryption ["SYNC_MODE"], "pull-only", @@ -1329,15 +1348,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 @@ -1348,13 +1379,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/get-sync-token.ts b/cli/src/get-sync-token.ts index 196712766..71bd9d3dd 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -94,6 +94,10 @@ export const captureObsidianToken = async ( ): Promise => { const { prompts, fetchFn } = deps + prompts.log( + "Sign in to your Obsidian account to generate the token your server\n" + + "needs to sync your vault.", + ) const email = await prompts.text("Obsidian account email:", { placeholder: "you@example.com", }) diff --git a/cli/src/init.ts b/cli/src/init.ts index 7f4c09286..749ecb766 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -83,6 +83,10 @@ const offerSyncTokenCapture = async ( prompts: Prompts, 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({ prompts, fetchFn }) @@ -443,18 +447,16 @@ const runRemoteInit = async ( const vaultName = await askVaultName(prompts) // Sign in to Obsidian and capture the Sync token directly via the API. - // Falls back to a paste prompt when the user declines or capture fails. - const capturedToken = await offerSyncTokenCapture(prompts, fetchFn) - // 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. + // When the user declines or capture fails, the token is left blank in .env + // and get-sync-token can fill it in later. const obsidianAuthToken = - capturedToken ?? - ( - await prompts.password( - "Paste the Obsidian Sync token (leave blank to fill in .env later):", - ) - ).trim() + (await offerSyncTokenCapture(prompts, fetchFn)) ?? "" + if (!obsidianAuthToken) { + prompts.log( + "No token yet — run this later to add it:\n" + + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, + ) + } const usesEncryption = await prompts.confirm( "Does your vault use end-to-end encryption?", From 1ae3ec69c4f9addedea1ed785ab328ebe78d1d70 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:55:30 -0400 Subject: [PATCH 09/23] docs: remove "No Docker needed" from token capture steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker hasn't been mentioned at that point in the guide — the phrase answers a question nobody asked. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/README.md | 5 ++--- deploy/railway/README.md | 2 +- deploy/remote/README.md | 2 +- deploy/render/README.md | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cli/README.md b/cli/README.md index d301860e9..a9baabe7c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -196,9 +196,8 @@ npx vault-cortex@latest get-sync-token ``` The command prompts for your Obsidian account email, password, and MFA code -(if enabled), signs in via the Obsidian API, and prints the token. No Docker -required. Use `--dir ` to write the token straight into an existing -`.env` instead: +(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 diff --git a/deploy/railway/README.md b/deploy/railway/README.md index 46735c477..8790eb596 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -56,7 +56,7 @@ your computer — once, before you click the button. ``` It asks for your Obsidian account email, password, and two-factor code - (if you use one), then prints the token. No Docker needed. + (if you use one), then prints the token. 4. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`. diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 51703d315..d4eabc8dd 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -69,7 +69,7 @@ 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 signs in to your -Obsidian account and captures the token — no Docker needed: +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 ed2838a50..f0aee47e8 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -53,7 +53,7 @@ your computer — once, before you click the button. ``` It asks for your Obsidian account email, password, and two-factor code - (if you use one), then prints the token. No Docker needed. + (if you use one), then prints the token. 4. **Copy the token.** The deploy form asks for it as `OBSIDIAN_AUTH_TOKEN`. From 343b05cf156ace6904cf955e781ffc6dcd3749fa Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:01:39 -0400 Subject: [PATCH 10/23] docs(cli): simplify init's get-sync-token reference State the outcome (generates your token as part of the flow) instead of explaining the mechanism inline. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/README.md b/cli/README.md index a9baabe7c..14fdd2b70 100644 --- a/cli/README.md +++ b/cli/README.md @@ -60,9 +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 — sign in to your Obsidian -account right from the terminal. +without asking. During a remote setup, init offers to generate your +[Obsidian Sync token](#get-sync-token) as part of the flow. Flags: From bbdbbc6041dd0155dc8b3f30917cc4a127d377e2 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:14:38 -0400 Subject: [PATCH 11/23] refactor(cli): rename isRecord to isJsonObject for clarity Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/get-sync-token.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index 71bd9d3dd..e566903c2 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -53,10 +53,10 @@ const callSigninApi = async ( throw new Error("Unexpected response from Obsidian API (not JSON)") } - const isRecord = (value: unknown): value is Record => + const isJsonObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) - if (!isRecord(body)) { + if (!isJsonObject(body)) { throw new Error("Unexpected response from Obsidian API (not JSON)") } From a15e4dafb33ec369f6bbbc0d807aa901cb3d0cc7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:16:22 -0400 Subject: [PATCH 12/23] refactor(cli): improve get-sync-token readability - Move ObsidianApiError and isJsonObject to module scope so definitions precede usage when reading top-to-bottom. - Extract warnSigninError to deduplicate the timeout check and error formatting between the initial signin and MFA retry catch blocks. - Flatten the MFA branch: early-return for non-MFA errors so the MFA flow reads linearly. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/get-sync-token.ts | 96 +++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index e566903c2..a19848a06 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -19,6 +19,16 @@ const SIGNIN_TIMEOUT_MS = 30_000 const describeError = (error: unknown): string => error instanceof Error ? error.message : String(error) +class ObsidianApiError extends Error { + constructor(message: string) { + super(message) + this.name = "ObsidianApiError" + } +} + +const isJsonObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + /** * Calls the Obsidian Sync signin API. Returns the parsed JSON on success, * or throws on HTTP/network errors. The API returns { error: string } for @@ -53,9 +63,6 @@ const callSigninApi = async ( throw new Error("Unexpected response from Obsidian API (not JSON)") } - const isJsonObject = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value) - if (!isJsonObject(body)) { throw new Error("Unexpected response from Obsidian API (not JSON)") } @@ -77,11 +84,30 @@ const callSigninApi = async ( return { token, name, email } } -class ObsidianApiError extends Error { - constructor(message: string) { - super(message) - this.name = "ObsidianApiError" +/** + * 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 warnSigninError = ( + error: unknown, + prompts: Prompts, + isMfaRetry: boolean, +): void => { + if (error instanceof Error && error.name === "TimeoutError") { + prompts.warn( + "Request timed out — check your internet connection and try again.", + ) + return } + + const mfaHint = + isMfaRetry && + error instanceof ObsidianApiError && + error.message.includes("2FA code") + ? "\n Check your 2FA code and try again." + : "" + + prompts.warn(`Could not sign in: ${describeError(error)}${mfaHint}`) } /** @@ -114,53 +140,33 @@ export const captureObsidianToken = async ( // 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. - if ( + const needsMfa = error instanceof ObsidianApiError && error.message.includes("2FA code") && !error.message.includes("2FA code is incorrect") - ) { - spinner.stop("Two-factor authentication required.") - const mfaCode = await prompts.text("2FA code:") - - spinner.start("Verifying...") - try { - const result = await callSigninApi( - { email, password, mfa: mfaCode }, - fetchFn, - ) - spinner.stop(`Signed in as ${result.name} (${result.email}).`) - return result.token - } catch (retryError) { - spinner.stop("Sign-in failed.") - if (retryError instanceof Error && retryError.name === "TimeoutError") { - prompts.warn( - "Request timed out — check your internet connection and try again.", - ) - return undefined - } - const retryHint = - retryError instanceof ObsidianApiError && - retryError.message.includes("2FA code") - ? "\n Check your 2FA code and try again." - : "" - prompts.warn( - `Could not sign in: ${describeError(retryError)}${retryHint}`, - ) - return undefined - } + + if (!needsMfa) { + spinner.stop("Sign-in failed.") + warnSigninError(error, prompts, false) + return undefined } - spinner.stop("Sign-in failed.") + spinner.stop("Two-factor authentication required.") + const mfaCode = await prompts.text("2FA code:") - if (error instanceof Error && error.name === "TimeoutError") { - prompts.warn( - "Request timed out — check your internet connection and try again.", + spinner.start("Verifying...") + try { + const result = await callSigninApi( + { email, password, mfa: mfaCode }, + fetchFn, ) + spinner.stop(`Signed in as ${result.name} (${result.email}).`) + return result.token + } catch (retryError) { + spinner.stop("Sign-in failed.") + warnSigninError(retryError, prompts, true) return undefined } - - prompts.warn(`Could not sign in: ${describeError(error)}`) - return undefined } } From d7f499bd652e35a647fa43e816c8c94efce3ee50 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:20:00 -0400 Subject: [PATCH 13/23] refactor(cli): flatten callSigninApi response parsing Early throws for each rejection case, const body from a single .json().catch(), and one return object. No mutable let, no branching field extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/get-sync-token.ts | 36 ++++++++++-------------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index a19848a06..7728edb46 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -52,36 +52,20 @@ const callSigninApi = async ( signal: AbortSignal.timeout(SIGNIN_TIMEOUT_MS), }) - if (!response.ok) { - throw new Error(`HTTP Error ${response.status}`) - } + if (!response.ok) throw new Error(`HTTP Error ${response.status}`) - let body: unknown - try { - body = await response.json() - } catch { + const body = await response.json().catch(() => null) + if (!isJsonObject(body)) throw new Error("Unexpected response from Obsidian API (not JSON)") - } - - if (!isJsonObject(body)) { - throw new Error("Unexpected response from Obsidian API (not JSON)") - } - - if ("error" in body && typeof body.error === "string") { - throw new ObsidianApiError(body.error) - } - - const token = - "token" in body && typeof body.token === "string" ? body.token : undefined - if (!token) { + if (typeof body.error === "string") throw new ObsidianApiError(body.error) + if (typeof body.token !== "string" || !body.token) throw new Error("Unexpected response from Obsidian API (no token)") - } - - const name = "name" in body && typeof body.name === "string" ? body.name : "" - const email = - "email" in body && typeof body.email === "string" ? body.email : "" - return { token, name, email } + return { + token: body.token, + name: typeof body.name === "string" ? body.name : "", + email: typeof body.email === "string" ? body.email : "", + } } /** From 4c94e6844afb15ba66564dffcb82183803d83800 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:42:22 -0400 Subject: [PATCH 14/23] refactor(cli): wrap response parsing in try/catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse, validate, and extract in one try block — the catch re-throws ObsidianApiError (auth failures) and wraps everything else in a single "Unexpected response" error with the original cause. Surfaces the real JSON parse error to the user. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/get-sync-token.test.ts | 8 +++---- cli/src/get-sync-token.ts | 29 +++++++++++++++--------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 1a50f0140..4304bea16 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -340,8 +340,8 @@ describe("captureObsidianToken", () => { }) expect(token).toBeUndefined() - expect(scripted.warnings[0]).toBe( - "Could not sign in: Unexpected response from Obsidian API (not JSON)", + expect(scripted.warnings[0]).toMatch( + /^Could not sign in: Unexpected response from Obsidian API \(/, ) }) @@ -355,7 +355,7 @@ describe("captureObsidianToken", () => { expect(token).toBeUndefined() expect(scripted.warnings[0]).toBe( - "Could not sign in: Unexpected response from Obsidian API (not JSON)", + "Could not sign in: Unexpected response from Obsidian API (not a JSON object)", ) }) @@ -369,7 +369,7 @@ describe("captureObsidianToken", () => { expect(token).toBeUndefined() expect(scripted.warnings[0]).toBe( - "Could not sign in: Unexpected response from Obsidian API (no token)", + "Could not sign in: Unexpected response from Obsidian API (no token field)", ) }) }) diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index 7728edb46..cca4b9942 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -54,17 +54,24 @@ const callSigninApi = async ( if (!response.ok) throw new Error(`HTTP Error ${response.status}`) - const body = await response.json().catch(() => null) - if (!isJsonObject(body)) - throw new Error("Unexpected response from Obsidian API (not JSON)") - if (typeof body.error === "string") throw new ObsidianApiError(body.error) - if (typeof body.token !== "string" || !body.token) - throw new Error("Unexpected response from Obsidian API (no token)") - - return { - token: body.token, - name: typeof body.name === "string" ? body.name : "", - email: typeof body.email === "string" ? body.email : "", + try { + 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 { + token: body.token, + name: typeof body.name === "string" ? body.name : "", + email: typeof body.email === "string" ? body.email : "", + } + } catch (error) { + if (error instanceof ObsidianApiError) throw error + throw new Error( + `Unexpected response from Obsidian API (${describeError(error)})`, + { cause: error }, + ) } } From c81c0a05e4c2e14bfc34baf962bb37192a046bdf Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:09:32 -0400 Subject: [PATCH 15/23] refactor(cli): simplify callSigninApi to return token string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caller already has the email from the prompt — no need to extract name and email from the API response. callSigninApi returns the token string directly, and the spinner shows the user's own email. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/get-sync-token.test.ts | 34 +++++++----------------- cli/src/get-sync-token.ts | 20 ++++++-------- 2 files changed, 17 insertions(+), 37 deletions(-) diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 4304bea16..35121c9cb 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -7,13 +7,9 @@ import { captureObsidianToken, runGetSyncToken } from "../get-sync-token.js" import { createScriptedPrompts } from "./command-stubs.js" /** Builds a mock fetch that returns a successful signin response. */ -const fetchSigninSuccess = ( - token = "test-sync-token", - name = "Test User", - email = "test@example.com", -): typeof fetch => +const fetchSigninSuccess = (token = "test-sync-token"): typeof fetch => (async () => - new Response(JSON.stringify({ token, name, email }), { + new Response(JSON.stringify({ token }), { status: 200, headers: { "Content-Type": "application/json" }, })) as typeof fetch @@ -71,11 +67,7 @@ const fetchJsonNonObject = (): typeof fetch => * Builds a mock fetch that requires MFA: first call returns the "2FA code" * error, second call with a valid MFA code succeeds. */ -const fetchMfaRequired = ( - token = "mfa-sync-token", - name = "MFA User", - email = "mfa@example.com", -): typeof fetch => { +const fetchMfaRequired = (token = "mfa-sync-token"): typeof fetch => { let callCount = 0 return (async () => { callCount += 1 @@ -85,7 +77,7 @@ const fetchMfaRequired = ( { status: 200, headers: { "Content-Type": "application/json" } }, ) } - return new Response(JSON.stringify({ token, name, email }), { + return new Response(JSON.stringify({ token }), { status: 200, headers: { "Content-Type": "application/json" }, }) @@ -122,17 +114,13 @@ describe("captureObsidianToken", () => { const token = await captureObsidianToken({ prompts: scripted.prompts, - fetchFn: fetchSigninSuccess( - "abc123-sync-token", - "Jane", - "user@example.com", - ), + fetchFn: fetchSigninSuccess("abc123-sync-token"), }) expect(token).toBe("abc123-sync-token") expect(scripted.spinnerMessages).toEqual([ "start: Signing in to Obsidian...", - "stop: Signed in as Jane (user@example.com).", + "stop: Signed in as user@example.com.", ]) }) @@ -145,7 +133,7 @@ describe("captureObsidianToken", () => { const token = await captureObsidianToken({ prompts: scripted.prompts, - fetchFn: fetchMfaRequired("mfa-token", "MFA User", "mfa@example.com"), + fetchFn: fetchMfaRequired("mfa-token"), }) expect(token).toBe("mfa-token") @@ -158,7 +146,7 @@ describe("captureObsidianToken", () => { "start: Signing in to Obsidian...", "stop: Two-factor authentication required.", "start: Verifying...", - "stop: Signed in as MFA User (mfa@example.com).", + "stop: Signed in as mfa@example.com.", ]) }) @@ -382,11 +370,7 @@ describe("runGetSyncToken subcommand", () => { {}, { prompts: scripted.prompts, - fetchFn: fetchSigninSuccess( - "my-sync-token", - "User", - "user@example.com", - ), + fetchFn: fetchSigninSuccess("my-sync-token"), }, ) diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index cca4b9942..9f8ce5e92 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -37,7 +37,7 @@ const isJsonObject = (value: unknown): value is Record => const callSigninApi = async ( params: { email: string; password: string; mfa: string }, fetchFn: typeof fetch, -): Promise<{ token: string; name: string; email: string }> => { +): Promise => { const response = await fetchFn(OBSIDIAN_SIGNIN_URL, { method: "POST", headers: { @@ -61,11 +61,7 @@ const callSigninApi = async ( if (typeof body.token !== "string" || !body.token) throw new Error("no token field") - return { - token: body.token, - name: typeof body.name === "string" ? body.name : "", - email: typeof body.email === "string" ? body.email : "", - } + return body.token } catch (error) { if (error instanceof ObsidianApiError) throw error throw new Error( @@ -124,9 +120,9 @@ export const captureObsidianToken = async ( spinner.start("Signing in to Obsidian...") try { - const result = await callSigninApi({ email, password, mfa: "" }, fetchFn) - spinner.stop(`Signed in as ${result.name} (${result.email}).`) - return result.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 @@ -147,12 +143,12 @@ export const captureObsidianToken = async ( spinner.start("Verifying...") try { - const result = await callSigninApi( + const token = await callSigninApi( { email, password, mfa: mfaCode }, fetchFn, ) - spinner.stop(`Signed in as ${result.name} (${result.email}).`) - return result.token + spinner.stop(`Signed in as ${email}.`) + return token } catch (retryError) { spinner.stop("Sign-in failed.") warnSigninError(retryError, prompts, true) From 2c4ba102fdcc9cd006cd717e1cc24c9027e8c018 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:12:47 -0400 Subject: [PATCH 16/23] fix(cli): preserve existing token on re-init; simplify API return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - On re-init, read OBSIDIAN_AUTH_TOKEN from the on-disk .env when capture is declined — a re-init over a working deployment no longer suppresses the start offer or shows "fill in your token" guidance. - callSigninApi returns the token string directly — the caller already has the email from the prompt, so name/email from the API response were unused overhead. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/init.test.ts | 2 +- cli/src/get-sync-token.ts | 8 +++----- cli/src/init.ts | 11 +++++++---- cli/src/scaffold.ts | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 71450bf16..87dcfa06a 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -686,7 +686,7 @@ describe("runInit remote flow", () => { // 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 (user@example.com).", + "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)", diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index 9f8ce5e92..a3b9d1df2 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -87,12 +87,10 @@ const warnSigninError = ( return } + const isMfaError = + error instanceof ObsidianApiError && error.message.includes("2FA code") const mfaHint = - isMfaRetry && - error instanceof ObsidianApiError && - error.message.includes("2FA code") - ? "\n Check your 2FA code and try again." - : "" + isMfaRetry && isMfaError ? "\n Check your 2FA code and try again." : "" prompts.warn(`Could not sign in: ${describeError(error)}${mfaHint}`) } diff --git a/cli/src/init.ts b/cli/src/init.ts index 749ecb766..e2df115b3 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, @@ -447,10 +448,12 @@ const runRemoteInit = async ( const vaultName = await askVaultName(prompts) // Sign in to Obsidian and capture the Sync token directly via the API. - // When the user declines or capture fails, the token is left blank in .env - // and get-sync-token can fill it in later. - const obsidianAuthToken = - (await offerSyncTokenCapture(prompts, fetchFn)) ?? "" + // 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 obsidianAuthToken = capturedToken ?? existingEnvToken ?? "" if (!obsidianAuthToken) { prompts.log( "No token yet — run this later to add it:\n" + 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 From 3674881a581db4fe897796f815232fd0ab9c05dc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:24:21 -0400 Subject: [PATCH 17/23] refactor(cli): use undefined for missing token instead of empty string The empty string sentinel checked via `=== ""` in two places was a boolean wearing a string costume. Use undefined for "no token" in the flow logic; pass `?? ""` only at the buildRemoteEnv boundary where the .env template needs the literal empty value. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/env.ts | 13 ++++++------- cli/src/init.ts | 15 +++++++-------- 2 files changed, 13 insertions(+), 15 deletions(-) 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/init.ts b/cli/src/init.ts index e2df115b3..6d7101b8c 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -453,8 +453,8 @@ const runRemoteInit = async ( // 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 obsidianAuthToken = capturedToken ?? existingEnvToken ?? "" - if (!obsidianAuthToken) { + const hasExistingToken = Boolean(capturedToken ?? existingEnvToken) + if (!hasExistingToken) { prompts.log( "No token yet — run this later to add it:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, @@ -479,7 +479,7 @@ const runRemoteInit = async ( const defaultEnvContent = buildRemoteEnv({ mcpAuthToken: token, publicUrl, - obsidianAuthToken, + obsidianAuthToken: capturedToken ?? existingEnvToken, vaultName, vaultPassword, }) @@ -516,10 +516,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") { @@ -534,7 +533,7 @@ const runRemoteInit = async ( token, publicUrl: effectivePublicUrl, startStatus, - obsidianTokenMissing: obsidianAuthToken === "", + obsidianTokenMissing: !hasExistingToken, tokenWritten, }), ) From 4e7ba9e44e8c2eb860a9225da0a5a44879fb9ed3 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:29:16 -0400 Subject: [PATCH 18/23] fix(cli): update PTY test for removed paste prompt + type obsidianAuthToken as optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The init remote PTY test still expected the paste prompt and a "Start the server now?" offer (which is suppressed when the token is blank). Updated to match the new flow: decline capture → no paste → token left blank → no start offer. Also changed RemoteEnvAnswers.obsidianAuthToken from string to string | undefined so the empty-string fallback lives at the template boundary, not in the caller. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/integration/cli-pty.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index 7cc8c2621..f6161c2a6 100644 --- a/cli/src/__tests__/integration/cli-pty.test.ts +++ b/cli/src/__tests__/integration/cli-pty.test.ts @@ -175,18 +175,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 +192,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) }) }) From 10282b7cb23c5ec17df31d5b699cb8b169cb98bc Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:40:07 -0400 Subject: [PATCH 19/23] fix(cli): add missing tests, fix comment, make signin URL configurable - Add readEnvObsidianToken unit tests (5 cases: missing file, valid token, empty value, no line, whitespace trimming) - Add init test for declined capture with existing on-disk token (re-init preserves token and offers start) - Fix misleading comment about Docker-not-installed warning - Update "No token yet" message to mention .env - Make OBSIDIAN_SIGNIN_URL configurable via env var for PTY testing Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/init.test.ts | 41 +++++++++++++++++++++++++--- cli/src/__tests__/scaffold.test.ts | 43 ++++++++++++++++++++++++++++++ cli/src/get-sync-token.ts | 3 ++- cli/src/init.ts | 2 +- 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 87dcfa06a..62036e374 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -554,7 +554,7 @@ describe("runInit remote flow", () => { expect(envContent).toContain("VAULT_NAME=MyVault\n") expect(envContent).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) expect(scripted.logs).toContain( - "No token yet — run this later to add it:\n" + + "No token yet — run this later to add it to your .env:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) }) @@ -578,8 +578,7 @@ describe("runInit remote flow", () => { ) // Token generation is always offered (uses the API, not Docker). With a - // blank token (capture declined), no start offer is shown — and since - // Docker is not installed, the Docker-not-installed warning fires too. + // 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):", @@ -768,6 +767,40 @@ 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([ @@ -1103,7 +1136,7 @@ describe("runInit sync-token auto-capture fallback", () => { /^OBSIDIAN_AUTH_TOKEN=$/m, ) expect(scripted.logs).toContain( - "No token yet — run this later to add it:\n" + + "No token yet — run this later to add it to your .env:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) }) 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/get-sync-token.ts b/cli/src/get-sync-token.ts index a3b9d1df2..b3c6d4866 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -13,7 +13,8 @@ export type GetSyncTokenDeps = { fetchFn: typeof fetch } -const OBSIDIAN_SIGNIN_URL = "https://api.obsidian.md/user/signin" +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 => diff --git a/cli/src/init.ts b/cli/src/init.ts index 6d7101b8c..dfba2bcbd 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -456,7 +456,7 @@ const runRemoteInit = async ( const hasExistingToken = Boolean(capturedToken ?? existingEnvToken) if (!hasExistingToken) { prompts.log( - "No token yet — run this later to add it:\n" + + "No token yet — run this later to add it to your .env:\n" + ` npx vault-cortex@latest get-sync-token --dir "${targetDir}"`, ) } From 071e2c1919a411d8e1d4389de20b5bb31862eb03 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:57:50 -0400 Subject: [PATCH 20/23] test(cli): add PTY happy-path tests for get-sync-token Local HTTP server fixture mimics the Obsidian signin API; two scenarios: - prints token to stdout (no --dir) - writes token to .env (--dir, remote-mode .env seeded with empty OBSIDIAN_AUTH_TOKEN) Uses the OBSIDIAN_SIGNIN_URL env var seam added in the previous commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/integration/cli-pty.test.ts | 95 ++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index f6161c2a6..7f421c79d 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" @@ -234,6 +235,98 @@ 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("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() From f2c5bc56f4e3c4e42b688a7723e7478573a2581e Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:14:51 -0400 Subject: [PATCH 21/23] fix(cli): correct stale paste-prompt reference in offerSyncTokenCapture comment Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/init.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/init.ts b/cli/src/init.ts index dfba2bcbd..ce5f64861 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -78,7 +78,8 @@ const askMode = async (prompts: Prompts): Promise => { /** * 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 a paste prompt). + * 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, From acf2e46def587b489e4ba3209a56db8a30b4e042 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:20:48 -0400 Subject: [PATCH 22/23] feat(cli): show start command after get-sync-token --dir writes token After writing the token to .env, print: Start the server: npx vault-cortex start --dir "" Closes the dead-end in the user journey: init points to get-sync-token when the user declines capture, but get-sync-token gave no next step. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/get-sync-token.test.ts | 3 +++ cli/src/__tests__/integration/cli-pty.test.ts | 1 + cli/src/get-sync-token.ts | 3 +++ 3 files changed, 7 insertions(+) diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 35121c9cb..22b5394cb 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -420,6 +420,9 @@ describe("runGetSyncToken subcommand", () => { expect(scripted.logs).toContain( `Token written to ${join(targetDir, ".env")}`, ) + expect(scripted.logs).toContain( + `Start the server:\n npx vault-cortex start --dir "${targetDir}"`, + ) }) it("exits 1 when --dir .env has no OBSIDIAN_AUTH_TOKEN line", async () => { diff --git a/cli/src/__tests__/integration/cli-pty.test.ts b/cli/src/__tests__/integration/cli-pty.test.ts index 7f421c79d..5b3a9134c 100644 --- a/cli/src/__tests__/integration/cli-pty.test.ts +++ b/cli/src/__tests__/integration/cli-pty.test.ts @@ -320,6 +320,7 @@ describe("get-sync-token", () => { 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") diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index b3c6d4866..72c02dde3 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -195,6 +195,9 @@ export const runGetSyncToken = async ( return 1 } prompts.log(`Token written to ${envFilePath}`) + prompts.log( + `Start the server:\n npx vault-cortex start --dir "${flags.dir}"`, + ) prompts.outro("Done.") return 0 } From c90e598432c5c413e2ece663ed40924c365e10ee Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:35:51 -0400 Subject: [PATCH 23/23] fix(cli): remove redundant orientation text and merge start hint into one log The offerSyncTokenCapture wrapper already explains what the token is for; captureObsidianToken repeated the same message after the user said yes. Also merges the "Token written" and "Start the server" logs into one block. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/__tests__/get-sync-token.test.ts | 9 ++++----- cli/src/get-sync-token.ts | 8 ++------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 22b5394cb..2c4944489 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -417,12 +417,11 @@ describe("runGetSyncToken subcommand", () => { 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")}`, - ) - expect(scripted.logs).toContain( - `Start the server:\n npx vault-cortex start --dir "${targetDir}"`, + 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 () => { diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index 72c02dde3..05dcc49f8 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -106,10 +106,6 @@ export const captureObsidianToken = async ( ): Promise => { const { prompts, fetchFn } = deps - prompts.log( - "Sign in to your Obsidian account to generate the token your server\n" + - "needs to sync your vault.", - ) const email = await prompts.text("Obsidian account email:", { placeholder: "you@example.com", }) @@ -194,9 +190,9 @@ export const runGetSyncToken = async ( ) return 1 } - prompts.log(`Token written to ${envFilePath}`) prompts.log( - `Start the server:\n npx vault-cortex start --dir "${flags.dir}"`, + `Token written to ${envFilePath}\n\n` + + `Start the server:\n npx vault-cortex start --dir "${flags.dir}"`, ) prompts.outro("Done.") return 0