From 47961fbc7f22adb934cf8b02e6d6cb66cc1e2c79 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:16:27 -0400 Subject: [PATCH 01/18] feat(cli): add get-token subcommand with auto-capture via volume mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `npx vault-cortex get-token` as a standalone subcommand and rewire init's remote flow to auto-capture the Obsidian Sync token via a Docker volume mount, eliminating the manual paste step. The command creates a temp directory, mounts it into the container at /home/obsidian/.config, runs the interactive login, then reads the auth_token file from the mount — no more copy-paste. On Linux, passes --user uid:gid so the token file is host-user-owned. - `get-token` (no flags): prints the captured token to stdout - `get-token --dir ./vault-cortex`: writes the token directly to .env - `init --mode remote`: auto-captures when Docker is available, falls back to paste prompt when capture fails or the user declines Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 1 + cli/README.md | 19 ++ cli/src/__tests__/docker.test.ts | 55 ++++++ cli/src/__tests__/env.test.ts | 5 +- cli/src/__tests__/get-token.test.ts | 267 ++++++++++++++++++++++++++++ cli/src/__tests__/init.test.ts | 110 ++++++------ cli/src/__tests__/program.test.ts | 28 ++- cli/src/__tests__/scaffold.test.ts | 68 +++++++ cli/src/__tests__/upgrade.test.ts | 4 +- cli/src/docker.ts | 50 +++++- cli/src/env.ts | 5 +- cli/src/get-token.ts | 102 +++++++++++ cli/src/init.ts | 65 +++---- cli/src/main.ts | 6 + cli/src/program.ts | 15 ++ cli/src/scaffold.ts | 19 ++ deploy/remote/README.md | 9 + 17 files changed, 719 insertions(+), 109 deletions(-) create mode 100644 cli/src/__tests__/get-token.test.ts create mode 100644 cli/src/get-token.ts diff --git a/AGENTS.md b/AGENTS.md index 8c111c1e..a8a21824 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ cli/ # npx vault-cortex CLI (published as vaul scaffold.ts # File generation (.env) docker.ts # Container management (docker run, health-check wait) upgrade.ts # Upgrade command (pull + re-create + health check) + get-token.ts # Get-token subcommand (auto-capture via volume mount) env.ts # Environment file handling (.env generation) token.ts # Secure token generation (openssl rand) vault.ts # Vault path validation diff --git a/cli/README.md b/cli/README.md index 660c0c08..6b3b70f9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -28,6 +28,25 @@ container; this CLI scaffolds the config so you don't have to. Existing files are never overwritten without asking. +## Get Token + +Generate an Obsidian Sync auth token without leaving the CLI: + +```bash +npx vault-cortex get-token +``` + +The command runs the Obsidian login flow inside Docker and captures the +resulting token automatically — no manual paste needed. Use `--dir` to +write the token directly to an existing `.env`: + +```bash +npx vault-cortex get-token --dir ./vault-cortex +``` + +During `init --mode remote`, this flow runs automatically when Docker is +available. + ## Upgrade Pull the latest image, re-create the container, and verify health: diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index c4cbef15..4ee16ae9 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import { buildDockerRunArgs, + buildGetTokenArgs, CONTAINER_NAME, LOCAL_IMAGE, pollHealth, @@ -187,6 +188,60 @@ describe("buildDockerRunArgs", () => { }) }) +describe("buildGetTokenArgs", () => { + it("produces the correct args on macOS (no --user flag)", () => { + const args = buildGetTokenArgs({ + configMountPath: "/tmp/vault-cortex-get-token-abc", + platform: "darwin", + uid: 501, + gid: 20, + }) + + expect(args).toEqual([ + "run", + "--rm", + "-it", + "--entrypoint", + "get-token", + "-v", + "/tmp/vault-cortex-get-token-abc:/home/obsidian/.config", + REMOTE_IMAGE, + ]) + }) + + it("includes --user uid:gid on Linux", () => { + const args = buildGetTokenArgs({ + configMountPath: "/tmp/vault-cortex-get-token-abc", + platform: "linux", + uid: 1000, + gid: 1000, + }) + + expect(args).toEqual([ + "run", + "--rm", + "-it", + "--entrypoint", + "get-token", + "-v", + "/tmp/vault-cortex-get-token-abc:/home/obsidian/.config", + "--user", + "1000:1000", + REMOTE_IMAGE, + ]) + }) + + it("omits --user on Linux when uid/gid are not provided", () => { + const args = buildGetTokenArgs({ + configMountPath: "/tmp/test", + platform: "linux", + }) + + expect(args).not.toContain("--user") + expect(args).toContain(REMOTE_IMAGE) + }) +}) + describe("pollHealth", () => { it("returns true as soon as the endpoint responds ok", async () => { const fetchStub = async (): Promise => okResponse diff --git a/cli/src/__tests__/env.test.ts b/cli/src/__tests__/env.test.ts index 0d0295ed..44cdf0a3 100644 --- a/cli/src/__tests__/env.test.ts +++ b/cli/src/__tests__/env.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest" -import { REMOTE_IMAGE } from "../docker.js" import { buildLocalEnv, buildRemoteEnv } from "../env.js" describe("buildLocalEnv", () => { @@ -74,9 +73,7 @@ describe("buildRemoteEnv", () => { expect(env).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) expect(env).toContain("FILL THIS IN") - // The guidance must name the actual get-token image, not just the - // subcommand — "get-token" alone would pass with a stale image name. - expect(env).toContain(`get-token \\\n# ${REMOTE_IMAGE}`) + expect(env).toContain("npx vault-cortex get-token") }) it("links to the canonical .env.example and keeps optional sync settings commented out", () => { diff --git a/cli/src/__tests__/get-token.test.ts b/cli/src/__tests__/get-token.test.ts new file mode 100644 index 00000000..f2a188f1 --- /dev/null +++ b/cli/src/__tests__/get-token.test.ts @@ -0,0 +1,267 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, expect, it } from "vitest" + +import { captureObsidianToken, runGetToken } from "../get-token.js" +import type { DockerRunner } from "../docker.js" +import type { Prompts } from "../prompts.js" + +const createSilentPrompts = () => { + const errors: string[] = [] + const warnings: string[] = [] + const logs: string[] = [] + const prints: string[] = [] + let introMessage = "" + let outroMessage = "" + + const prompts: Prompts = { + intro: (message) => { + introMessage = message ?? "" + }, + outro: (message) => { + outroMessage = message ?? "" + }, + note: () => {}, + print: (message) => { + prints.push(message) + }, + log: (message) => { + logs.push(message) + }, + warn: (message) => { + warnings.push(message) + }, + error: (message) => { + errors.push(message) + }, + select: async () => "", + text: async () => "", + password: async () => "", + confirm: async () => false, + spinner: () => ({ start: () => {}, stop: () => {} }), + } + + return { prompts, errors, warnings, logs, prints, introMessage, outroMessage } +} + +/** + * Creates a DockerRunner whose runGetTokenWithMount writes a fake + * auth_token file into the config mount path, simulating the container's + * get-token behavior. + */ +const dockerWithToken = (token: string): DockerRunner => ({ + isDaemonRunning: () => true, + dockerRun: () => false, + pullImage: () => false, + stopAndRemoveContainer: () => false, + runGetTokenWithMount: (configMountPath) => { + const tokenDir = join(configMountPath, "obsidian-headless") + mkdirSync(tokenDir, { recursive: true }) + writeFileSync(join(tokenDir, "auth_token"), token) + return true + }, +}) + +const dockerFailsGetToken: DockerRunner = { + isDaemonRunning: () => true, + dockerRun: () => false, + pullImage: () => false, + stopAndRemoveContainer: () => false, + runGetTokenWithMount: () => false, +} + +const dockerDown: DockerRunner = { + isDaemonRunning: () => false, + dockerRun: () => false, + pullImage: () => false, + stopAndRemoveContainer: () => false, + runGetTokenWithMount: () => false, +} + +describe("captureObsidianToken", () => { + it("returns the token when get-token writes the auth_token file", () => { + const { prompts } = createSilentPrompts() + + const token = captureObsidianToken({ + docker: dockerWithToken("abc123-sync-token"), + prompts, + }) + + expect(token).toBe("abc123-sync-token") + }) + + it("trims whitespace from the token file", () => { + const { prompts } = createSilentPrompts() + + const token = captureObsidianToken({ + docker: dockerWithToken(" token-with-whitespace \n"), + prompts, + }) + + expect(token).toBe("token-with-whitespace") + }) + + it("returns undefined when docker run fails", () => { + const silent = createSilentPrompts() + + const token = captureObsidianToken({ + docker: dockerFailsGetToken, + prompts: silent.prompts, + }) + + expect(token).toBeUndefined() + expect(silent.warnings[0]).toContain("get-token did not complete") + }) + + it("returns undefined when the token file is empty", () => { + const { prompts } = createSilentPrompts() + + const token = captureObsidianToken({ + docker: dockerWithToken(""), + prompts, + }) + + expect(token).toBeUndefined() + }) + + it("returns undefined when get-token succeeds but writes no token file", () => { + const { prompts } = createSilentPrompts() + const dockerSucceedsButNoFile: DockerRunner = { + ...dockerDown, + isDaemonRunning: () => true, + runGetTokenWithMount: () => true, + } + + const token = captureObsidianToken({ + docker: dockerSucceedsButNoFile, + prompts, + }) + + expect(token).toBeUndefined() + }) + + it("cleans up the temp directory even on failure", () => { + const { prompts } = createSilentPrompts() + const tempDirs: string[] = [] + const dockerTracker: DockerRunner = { + ...dockerDown, + isDaemonRunning: () => true, + runGetTokenWithMount: (configMountPath) => { + tempDirs.push(configMountPath) + return false + }, + } + + captureObsidianToken({ docker: dockerTracker, prompts }) + + expect(tempDirs).toHaveLength(1) + expect(existsSync(tempDirs[0])).toBe(false) + }) + + it("logs the handoff message before running docker", () => { + const silent = createSilentPrompts() + + captureObsidianToken({ + docker: dockerWithToken("token"), + prompts: silent.prompts, + }) + + expect(silent.logs[0]).toContain("Handing the terminal to get-token") + }) +}) + +describe("runGetToken subcommand", () => { + it("prints the token to stdout when --dir is not set", async () => { + const silent = createSilentPrompts() + + const exitCode = await runGetToken( + {}, + { prompts: silent.prompts, docker: dockerWithToken("my-sync-token") }, + ) + + expect(exitCode).toBe(0) + expect(silent.logs).toContain("Your OBSIDIAN_AUTH_TOKEN:") + expect(silent.prints).toEqual(["\n my-sync-token\n"]) + }) + + it("exits 1 when the docker daemon is not running", async () => { + const silent = createSilentPrompts() + + const exitCode = await runGetToken( + {}, + { prompts: silent.prompts, docker: dockerDown }, + ) + + expect(exitCode).toBe(1) + expect(silent.errors[0]).toContain("Container runtime not running") + }) + + it("exits 1 when token capture fails", async () => { + const silent = createSilentPrompts() + + const exitCode = await runGetToken( + {}, + { prompts: silent.prompts, docker: dockerFailsGetToken }, + ) + + expect(exitCode).toBe(1) + expect(silent.errors[0]).toContain("Could not capture the auth token") + }) + + it("writes the token to .env when --dir is set", async () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-get-token-")) + writeFileSync( + join(targetDir, ".env"), + "MCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=old-token\nVAULT_NAME=MyVault\n", + ) + const silent = createSilentPrompts() + + const exitCode = await runGetToken( + { dir: targetDir }, + { prompts: silent.prompts, docker: dockerWithToken("new-sync-token") }, + ) + + expect(exitCode).toBe(0) + expect(readFileSync(join(targetDir, ".env"), "utf8")).toBe( + "MCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=new-sync-token\nVAULT_NAME=MyVault\n", + ) + expect(silent.logs).toContain(`Token written to ${join(targetDir, ".env")}`) + }) + + it("exits 1 when --dir .env has no OBSIDIAN_AUTH_TOKEN line", async () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-get-token-")) + writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=abc\n") + const silent = createSilentPrompts() + + const exitCode = await runGetToken( + { dir: targetDir }, + { prompts: silent.prompts, docker: dockerWithToken("new-sync-token") }, + ) + + expect(exitCode).toBe(1) + expect(silent.errors[0]).toContain("no OBSIDIAN_AUTH_TOKEN line") + }) + + it("exits 1 when --dir .env does not exist", async () => { + const targetDir = join( + mkdtempSync(join(tmpdir(), "vault-cli-get-token-")), + "nonexistent", + ) + const silent = createSilentPrompts() + + const exitCode = await runGetToken( + { dir: targetDir }, + { prompts: silent.prompts, docker: dockerWithToken("new-sync-token") }, + ) + + expect(exitCode).toBe(1) + expect(silent.errors[0]).toContain("the file is missing") + }) +}) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 0e4da32d..80d5d27a 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -91,7 +91,7 @@ const dockerUnavailable: DockerRunner = { dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetToken: () => false, + runGetTokenWithMount: () => false, } const fetchNever: typeof fetch = async () => { @@ -540,13 +540,13 @@ describe("runInit interactive local flow", () => { }) describe("runInit remote flow", () => { - it("asks the remote sequence and writes .env", async () => { + it("asks the remote sequence with auto-capture declined and writes .env", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ "https://vault.example.com/", // public URL (trailing slash trimmed) "MyVault", // vault name - false, // don't run get-token now (offered because daemon is running) - "sync-token-xyz", // obsidian sync token + false, // don't generate the token now (declined auto-capture) + "sync-token-xyz", // paste fallback — obsidian sync token false, // no end-to-end encryption false, // don't start the server ]) @@ -568,7 +568,7 @@ describe("runInit remote flow", () => { 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):", - "Run the get-token command now?", + "Generate the token now?", "Paste the Obsidian Sync token (leave blank to fill in .env later):", "Does your vault use end-to-end encryption?", "Start the server now?", @@ -581,12 +581,49 @@ describe("runInit remote flow", () => { expect(scripted.prints[0]).toContain("Optional settings (timezone, memory") }) - it("skips the compose-up offer when the sync token was left blank", async () => { + it("skips paste prompt when auto-capture succeeds", async () => { + const targetDir = makeTargetDir() + const scripted = createScriptedPrompts([ + "https://vault.example.com", + "MyVault", + true, // generate the token now + false, // no end-to-end encryption + false, // don't start the server + ]) + const dockerWithCapture: DockerRunner = { + ...dockerUnavailable, + isDaemonRunning: () => true, + runGetTokenWithMount: (configMountPath) => { + const tokenDir = join(configMountPath, "obsidian-headless") + mkdirSync(tokenDir, { recursive: true }) + writeFileSync(join(tokenDir, "auth_token"), "captured-token") + return true + }, + } + + const exitCode = await runInit( + { mode: "remote", dir: targetDir }, + { + prompts: scripted.prompts, + docker: dockerWithCapture, + fetchFn: fetchNever, + }, + ) + + 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") + expect(envContent).toContain("OBSIDIAN_AUTH_TOKEN=captured-token\n") + }) + + it("skips the docker-run 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 + "", // blank token — fill in later (Docker unavailable, no capture offer) false, // no encryption ]) @@ -660,7 +697,7 @@ describe("runInit with a kept existing .env", () => { dockerRun: () => true, pullImage: () => true, stopAndRemoveContainer: () => true, - runGetToken: () => false, + runGetTokenWithMount: () => false, } const fetchedUrls: string[] = [] const fetchRecorder: typeof fetch = async (url) => { @@ -713,26 +750,25 @@ describe("runInit remote encryption password", () => { const scripted = createScriptedPrompts([ "https://vault.example.com", "MyVault", - "sync-token-xyz", + false, // decline auto-capture + "sync-token-xyz", // paste fallback true, // vault uses end-to-end encryption "hunter2", // password (masked prompt) false, // don't start the server ]) - const dockerComposeReady: DockerRunner = { + const dockerReady: DockerRunner = { isDaemonRunning: () => true, dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetToken: () => false, + runGetTokenWithMount: () => false, } - // get-token confirm slots in after VAULT_NAME when Docker is usable. - scripted.remaining.splice(2, 0, false) const exitCode = await runInit( { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerComposeReady, + docker: dockerReady, fetchFn: fetchNever, }, ) @@ -745,61 +781,29 @@ describe("runInit remote encryption password", () => { }) }) -describe("runInit get-token paste prompt wording", () => { - it('says "printed above" only when get-token ran to completion', async () => { - const targetDir = makeTargetDir() - const scripted = createScriptedPrompts([ - "https://vault.example.com", - "MyVault", - true, // run get-token now - "sync-token-xyz", - false, // no encryption - false, // don't start the server - ]) - const dockerWithWorkingGetToken: DockerRunner = { - isDaemonRunning: () => true, - dockerRun: () => false, - pullImage: () => false, - stopAndRemoveContainer: () => false, - runGetToken: () => true, - } - - await runInit( - { mode: "remote", dir: targetDir }, - { - prompts: scripted.prompts, - docker: dockerWithWorkingGetToken, - fetchFn: fetchNever, - }, - ) - - expect(scripted.asked).toContain( - "Paste the Obsidian Sync token printed above (leave blank to fill in .env later):", - ) - }) - - it('omits "printed above" when get-token failed', async () => { +describe("runInit get-token auto-capture fallback", () => { + it("falls back to paste prompt when auto-capture fails", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ "https://vault.example.com", "MyVault", - true, // try to run get-token - "", // blank token — fill in later + true, // try to generate the token + "", // paste fallback — blank token, fill in later false, // no encryption ]) - const dockerWithFailingGetToken: DockerRunner = { + const dockerWithFailingCapture: DockerRunner = { isDaemonRunning: () => true, dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetToken: () => false, + runGetTokenWithMount: () => false, } await runInit( { mode: "remote", dir: targetDir }, { prompts: scripted.prompts, - docker: dockerWithFailingGetToken, + docker: dockerWithFailingCapture, fetchFn: fetchNever, }, ) diff --git a/cli/src/__tests__/program.test.ts b/cli/src/__tests__/program.test.ts index 8db3d1a6..b72d6c07 100644 --- a/cli/src/__tests__/program.test.ts +++ b/cli/src/__tests__/program.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest" import { buildProgram } from "../program.js" +import type { GetTokenFlags } from "../get-token.js" import type { InitFlags } from "../init.js" import type { UpgradeFlags } from "../upgrade.js" const buildCapturingProgram = () => { const initCalls: InitFlags[] = [] const upgradeCalls: UpgradeFlags[] = [] + const getTokenCalls: GetTokenFlags[] = [] const program = buildProgram({ version: "0.0.0-test", runInit: async (flags) => { @@ -17,12 +19,16 @@ const buildCapturingProgram = () => { upgradeCalls.push(flags) return 0 }, + runGetToken: async (flags) => { + getTokenCalls.push(flags) + return 0 + }, }) for (const command of [program, ...program.commands]) { command.exitOverride() command.configureOutput({ writeOut: () => {}, writeErr: () => {} }) } - return { program, initCalls, upgradeCalls } + return { program, initCalls, upgradeCalls, getTokenCalls } } describe("buildProgram init", () => { @@ -93,3 +99,23 @@ describe("buildProgram upgrade", () => { expect(upgradeCalls).toEqual([{}]) }) }) + +describe("buildProgram get-token", () => { + it("passes --dir through to runGetToken", async () => { + const { program, getTokenCalls } = buildCapturingProgram() + + await program.parseAsync(["get-token", "--dir", "/opt/vault-cortex"], { + from: "user", + }) + + expect(getTokenCalls).toEqual([{ dir: "/opt/vault-cortex" }]) + }) + + it("invokes get-token with no flags when none are given", async () => { + const { program, getTokenCalls } = buildCapturingProgram() + + await program.parseAsync(["get-token"], { from: "user" }) + + expect(getTokenCalls).toEqual([{}]) + }) +}) diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index a8fb7d27..7123ce66 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it } from "vitest" import { buildFilesToWrite, detectMode, + patchEnvObsidianToken, readEnvPort, readEnvVaultPath, writeFiles, @@ -270,3 +271,70 @@ describe("writeFiles permissions", () => { expect(fileMode).toBe(0o600) }) }) + +describe("patchEnvObsidianToken", () => { + it("replaces an existing token value", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) + const envPath = join(targetDir, ".env") + writeFileSync( + envPath, + "MCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=old-token\nVAULT_NAME=MyVault\n", + ) + + const result = patchEnvObsidianToken(envPath, "new-token") + + expect(result).toBe(true) + expect(readFileSync(envPath, "utf8")).toBe( + "MCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=new-token\nVAULT_NAME=MyVault\n", + ) + }) + + it("replaces an empty token value", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, "OBSIDIAN_AUTH_TOKEN=\n") + + const result = patchEnvObsidianToken(envPath, "filled-in") + + expect(result).toBe(true) + expect(readFileSync(envPath, "utf8")).toBe( + "OBSIDIAN_AUTH_TOKEN=filled-in\n", + ) + }) + + it("returns false when the file does not exist", () => { + const result = patchEnvObsidianToken( + join(tmpdir(), "vault-cli-no-such-file", ".env"), + "token", + ) + + expect(result).toBe(false) + }) + + it("returns false when the file has no OBSIDIAN_AUTH_TOKEN line", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, "MCP_AUTH_TOKEN=abc\nVAULT_PATH=/vault\n") + + const result = patchEnvObsidianToken(envPath, "token") + + expect(result).toBe(false) + expect(readFileSync(envPath, "utf8")).toBe( + "MCP_AUTH_TOKEN=abc\nVAULT_PATH=/vault\n", + ) + }) + + it("preserves surrounding content when patching", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) + const envPath = join(targetDir, ".env") + const original = + "# Comment\nMCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=old\nVAULT_NAME=Test\n# Footer\n" + writeFileSync(envPath, original) + + patchEnvObsidianToken(envPath, "new") + + expect(readFileSync(envPath, "utf8")).toBe( + "# Comment\nMCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=new\nVAULT_NAME=Test\n# Footer\n", + ) + }) +}) diff --git a/cli/src/__tests__/upgrade.test.ts b/cli/src/__tests__/upgrade.test.ts index d2610065..f1d870f4 100644 --- a/cli/src/__tests__/upgrade.test.ts +++ b/cli/src/__tests__/upgrade.test.ts @@ -46,7 +46,7 @@ const dockerReady: DockerRunner = { dockerRun: () => true, pullImage: () => true, stopAndRemoveContainer: () => true, - runGetToken: () => false, + runGetTokenWithMount: () => false, } const dockerDown: DockerRunner = { @@ -54,7 +54,7 @@ const dockerDown: DockerRunner = { dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetToken: () => false, + runGetTokenWithMount: () => false, } const fetchOk: typeof fetch = async () => ({ ok: true }) as Response diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 4baa924e..2eda19a2 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -23,8 +23,46 @@ export type DockerRunner = { pullImage: (image: string) => boolean /** Stops and removes the vault-cortex container (idempotent). */ stopAndRemoveContainer: () => boolean - /** Runs the vault-cortex get-token flow with inherited stdio. */ - runGetToken: () => boolean + /** Runs get-token with a volume mount for auto-capture. */ + runGetTokenWithMount: (configMountPath: string) => boolean +} + +export type GetTokenArgParams = { + 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 get-token with a volume mount that + * captures the auth token file. Pure function for testability. + * + * On Linux, includes `--user uid:gid` so the token file is host-user-owned + * (macOS Docker Desktop translates UIDs automatically). + */ +export const buildGetTokenArgs = (params: GetTokenArgParams): string[] => { + const { configMountPath, platform = process.platform, uid, gid } = params + + const args = [ + "run", + "--rm", + "-it", + "--entrypoint", + "get-token", + "-v", + `${configMountPath}:/home/obsidian/.config`, + ] + + if (platform === "linux" && uid !== undefined && gid !== undefined) { + args.push("--user", `${uid}:${gid}`) + } + + args.push(REMOTE_IMAGE) + return args } /** @@ -105,10 +143,14 @@ export const createDockerRunner = (): DockerRunner => ({ spawnSync("docker", ["pull", image], { stdio: "inherit" }).status === 0, stopAndRemoveContainer: () => spawnSync("docker", ["rm", "-f", CONTAINER_NAME]).status === 0, - runGetToken: () => + runGetTokenWithMount: (configMountPath) => spawnSync( "docker", - ["run", "--rm", "-it", "--entrypoint", "get-token", REMOTE_IMAGE], + buildGetTokenArgs({ + configMountPath, + uid: process.getuid?.(), + gid: process.getgid?.(), + }), { stdio: "inherit" }, ).status === 0, }) diff --git a/cli/src/env.ts b/cli/src/env.ts index 397ffe59..53d76ddf 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -1,5 +1,3 @@ -import { REMOTE_IMAGE } from "./docker.js" - export type LocalEnvAnswers = { mcpAuthToken: string vaultPath: string @@ -208,8 +206,7 @@ VAULT_PASSWORD=${answers.vaultPassword}` answers.obsidianAuthToken === "" ? `# Obsidian Sync auth token — FILL THIS IN before starting the server. # Generate once with: -# docker run --rm -it --entrypoint get-token \\ -# ${REMOTE_IMAGE}` +# npx vault-cortex get-token` : `# Obsidian Sync auth token.` return `# vault-cortex — remote quickstart (Obsidian Sync) diff --git a/cli/src/get-token.ts b/cli/src/get-token.ts new file mode 100644 index 00000000..f58503ff --- /dev/null +++ b/cli/src/get-token.ts @@ -0,0 +1,102 @@ +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 type { Prompts } from "./prompts.js" +import { patchEnvObsidianToken } from "./scaffold.js" +import { expandTilde } from "./vault.js" + +export type GetTokenFlags = { + dir?: string +} + +export type GetTokenDeps = { + prompts: Prompts + docker: DockerRunner +} + +/** + * Runs get-token inside a Docker container with a volume mount that + * captures the auth token file. The interactive login (email, password, + * MFA) still shows in the terminal — only the resulting token is + * captured automatically. + * + * Returns the token string on success, undefined on any failure. + */ +export const captureObsidianToken = (deps: { + docker: DockerRunner + prompts: Prompts +}): string | undefined => { + const { docker, prompts } = deps + const configMountPath = mkdtempSync(join(tmpdir(), "vault-cortex-get-token-")) + try { + prompts.log( + "Handing the terminal to get-token — it will ask for your Obsidian " + + "account login and print a token at the end.", + ) + const succeeded = docker.runGetTokenWithMount(configMountPath) + if (!succeeded) { + prompts.warn( + "get-token did not complete — you can run it later with:\n" + + " npx vault-cortex get-token", + ) + return undefined + } + const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") + if (!existsSync(tokenPath)) return undefined + const token = readFileSync(tokenPath, "utf8").trim() + return token || undefined + } finally { + rmSync(configMountPath, { recursive: true, force: true }) + } +} + +/** + * Subcommand entry: generate an Obsidian Sync token via Docker. + * Without --dir, prints the token to stdout. + * With --dir, writes it directly to `/.env`. + */ +export const runGetToken = async ( + flags: GetTokenFlags, + deps: GetTokenDeps, +): Promise => { + const { prompts, docker } = deps + + if (!docker.isDaemonRunning()) { + prompts.error( + "Container runtime not running — start Docker Desktop, Colima,\n" + + "OrbStack, or another Docker-compatible runtime and try again.", + ) + return 1 + } + + prompts.intro("vault-cortex get-token") + + const token = captureObsidianToken({ docker, prompts }) + if (!token) { + prompts.error("Could not capture the auth token.") + return 1 + } + + if (!flags.dir) { + prompts.log("Your OBSIDIAN_AUTH_TOKEN:") + prompts.print(`\n ${token}\n`) + prompts.outro("Done.") + return 0 + } + + const targetDir = resolve(expandTilde(flags.dir)) + const envFilePath = join(targetDir, ".env") + const patched = patchEnvObsidianToken(envFilePath, token) + if (!patched) { + prompts.error( + `Could not patch ${envFilePath} — the file is missing or has no ` + + "OBSIDIAN_AUTH_TOKEN line. Run init first.", + ) + return 1 + } + prompts.log(`Token written to ${envFilePath}`) + prompts.outro("Done.") + return 0 +} diff --git a/cli/src/init.ts b/cli/src/init.ts index 6328694e..117033c9 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -1,11 +1,12 @@ import { join, resolve } from "node:path" import { buildLocalEnv, buildRemoteEnv } from "./env.js" +import { captureObsidianToken } from "./get-token.js" import { buildLocalConnectMessage, buildRemoteConnectMessage, } from "./messages.js" -import { REMOTE_IMAGE, pollHealth, type DockerRunner } from "./docker.js" +import { pollHealth, type DockerRunner } from "./docker.js" import { buildFilesToWrite, readEnvPort, @@ -55,33 +56,18 @@ const askMode = async (prompts: Prompts): Promise => { return isMode(selected) ? selected : "local" } -const GET_TOKEN_COMMAND = `docker run --rm -it --entrypoint get-token \\ - ${REMOTE_IMAGE}` - /** - * Offers to run the vault-cortex image's get-token flow in this terminal. - * Returns true only when it ran to completion (and so printed a token the - * user can scroll up to). The handoff log exists because the clack UI gives - * way to raw docker output — image pull, then the tool's own login prompts. + * 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). */ -const offerGetTokenRun = async ( +const offerGetTokenCapture = async ( prompts: Prompts, docker: DockerRunner, -): Promise => { - const runNow = await prompts.confirm("Run the get-token command now?", true) - if (!runNow) return false - prompts.log( - "Handing the terminal to get-token — it will ask for your Obsidian " + - "account login and print a token at the end.", - ) - const tokenGenerated = docker.runGetToken() - if (!tokenGenerated) { - prompts.warn( - "get-token did not complete — you can run it later and edit .env.", - ) - return false - } - return true +): Promise => { + const runNow = await prompts.confirm("Generate the token now?", true) + if (!runNow) return undefined + return captureObsidianToken({ docker, prompts }) } /** @@ -369,23 +355,20 @@ const runRemoteInit = async ( const publicUrl = await askPublicUrl(prompts) const vaultName = await askVaultName(prompts) - // The Obsidian Sync token comes from an interactive docker run (the - // get-token entrypoint logs into Obsidian). We print the command, offer to - // run it when Docker is usable, then ask the user to paste the result — - // get-token writes to the terminal, so it can't be captured automatically. - // A blank answer is allowed: the .env is written with an empty - // OBSIDIAN_AUTH_TOKEN and a fill-this-in comment. - prompts.note(GET_TOKEN_COMMAND, "Obsidian Sync token — generate once with") - const getTokenRan = docker.isDaemonRunning() - ? await offerGetTokenRun(prompts, docker) - : false - // "printed above" is only true when get-token actually ran to completion. - const pastePrompt = getTokenRan - ? "Paste the Obsidian Sync token printed above (leave blank to fill in .env later):" - : "Paste the Obsidian Sync token (leave blank to fill in .env later):" - const obsidianAuthToken = ( - await prompts.text(pastePrompt, { defaultValue: "" }) - ).trim() + // 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. + const capturedToken = docker.isDaemonRunning() + ? await offerGetTokenCapture(prompts, docker) + : undefined + const obsidianAuthToken = + capturedToken ?? + ( + await prompts.text( + "Paste the Obsidian Sync token (leave blank to fill in .env later):", + { defaultValue: "" }, + ) + ).trim() const usesEncryption = await prompts.confirm( "Does your vault use end-to-end encryption?", diff --git a/cli/src/main.ts b/cli/src/main.ts index ee3dadb5..4b28a77c 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -1,4 +1,5 @@ import { createDockerRunner } from "./docker.js" +import { runGetToken } from "./get-token.js" import { runInit } from "./init.js" import { buildProgram } from "./program.js" import { createPrompts } from "./prompts.js" @@ -19,6 +20,11 @@ export const run = async (version: string): Promise => { docker: createDockerRunner(), fetchFn: fetch, }), + runGetToken: (flags) => + runGetToken(flags, { + prompts: createPrompts(), + docker: createDockerRunner(), + }), }) await program.parseAsync() } diff --git a/cli/src/program.ts b/cli/src/program.ts index 61b03131..a15d3fd5 100644 --- a/cli/src/program.ts +++ b/cli/src/program.ts @@ -1,5 +1,6 @@ import { Command } from "commander" +import type { GetTokenFlags } from "./get-token.js" import type { InitFlags } from "./init.js" import type { UpgradeFlags } from "./upgrade.js" @@ -7,6 +8,7 @@ export type ProgramOptions = { version: string runInit: (flags: InitFlags) => Promise runUpgrade: (flags: UpgradeFlags) => Promise + runGetToken: (flags: GetTokenFlags) => Promise } export const buildProgram = (options: ProgramOptions): Command => { @@ -52,6 +54,19 @@ export const buildProgram = (options: ProgramOptions): Command => { process.exitCode = await options.runUpgrade(flags) }) + program + .command("get-token") + .description( + "Generate an Obsidian Sync auth token via Docker and print it or write it to .env", + ) + .option( + "--dir ", + "directory containing .env to update with the token", + ) + .action(async (flags: GetTokenFlags) => { + process.exitCode = await options.runGetToken(flags) + }) + program.action(() => { program.help() }) diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index 74a8fa2e..871f7c8d 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -85,6 +85,25 @@ export const detectMode = (envFilePath: string): Mode | undefined => { return OBSIDIAN_AUTH_TOKEN_LINE.test(content) ? "remote" : "local" } +/** + * Patches the OBSIDIAN_AUTH_TOKEN value in an existing .env file. + * Returns true when the patch succeeded, false when the file is missing + * or has no active OBSIDIAN_AUTH_TOKEN line (e.g. a local-mode .env). + */ +export const patchEnvObsidianToken = ( + envFilePath: string, + token: string, +): boolean => { + if (!existsSync(envFilePath)) return false + const content = readFileSync(envFilePath, "utf8") + /** Matches the full OBSIDIAN_AUTH_TOKEN line for replacement. */ + const fullTokenLine = /^OBSIDIAN_AUTH_TOKEN=.*$/m + if (!fullTokenLine.test(content)) return false + const patched = content.replace(fullTokenLine, `OBSIDIAN_AUTH_TOKEN=${token}`) + writeFileSync(envFilePath, patched) + return true +} + /** * Writes the files into targetDir (created if missing). Existing files * are never overwritten silently: identical content is skipped, and differing diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 8b28c3e9..3f529846 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -41,6 +41,15 @@ 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 captures the token +automatically: + +```bash +npx vault-cortex get-token +``` + +Otherwise, run the Docker image directly: + ```bash docker run --rm -it --entrypoint get-token \ ghcr.io/aliasunder/vault-cortex:remote From 679a0de61be9c75781814eaaf977647648d58d88 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:29:42 -0400 Subject: [PATCH 02/18] fix(review): use function replacement in patchEnvObsidianToken to avoid $ pattern injection String.prototype.replace interprets $-patterns ($&, $', $$, etc.) in string replacement arguments. If an Obsidian Sync token contained a $ character, the .env write would silently corrupt it. A function replacement avoids special-pattern interpretation entirely. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/scaffold.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/src/scaffold.ts b/cli/src/scaffold.ts index 871f7c8d..80fb5373 100644 --- a/cli/src/scaffold.ts +++ b/cli/src/scaffold.ts @@ -99,7 +99,12 @@ export const patchEnvObsidianToken = ( /** Matches the full OBSIDIAN_AUTH_TOKEN line for replacement. */ const fullTokenLine = /^OBSIDIAN_AUTH_TOKEN=.*$/m if (!fullTokenLine.test(content)) return false - const patched = content.replace(fullTokenLine, `OBSIDIAN_AUTH_TOKEN=${token}`) + // Function replacement avoids $ pattern interpretation ($&, $', etc.) + // that String.prototype.replace applies to string replacements. + const patched = content.replace( + fullTokenLine, + () => `OBSIDIAN_AUTH_TOKEN=${token}`, + ) writeFileSync(envFilePath, patched) return true } From cf2e9a098418efedcfdecfdca0295b097b64af1a Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:35:15 -0400 Subject: [PATCH 03/18] style: use existing GetTokenDeps type instead of inline duplicate Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/get-token.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cli/src/get-token.ts b/cli/src/get-token.ts index f58503ff..8dd16290 100644 --- a/cli/src/get-token.ts +++ b/cli/src/get-token.ts @@ -24,10 +24,9 @@ export type GetTokenDeps = { * * Returns the token string on success, undefined on any failure. */ -export const captureObsidianToken = (deps: { - docker: DockerRunner - prompts: Prompts -}): string | undefined => { +export const captureObsidianToken = ( + deps: GetTokenDeps, +): string | undefined => { const { docker, prompts } = deps const configMountPath = mkdtempSync(join(tmpdir(), "vault-cortex-get-token-")) try { From 3d54288f8d4b887e17c26eb2c209d5e8def858bb Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:42:12 -0400 Subject: [PATCH 04/18] test: exact assertions on deterministic messages, $-pattern regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten three substring assertions (toContain) to exact matches (toBe) on hardcoded error/warning messages in get-token tests. Replace partial not.toContain/toContain with full toEqual on buildGetTokenArgs Linux-no-uid test. Add regression test proving patchEnvObsidianToken handles $-pattern tokens (e.g. $&, $$) literally — mutation-verified against the Phase 1 fix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/__tests__/docker.test.ts | 12 ++++++++++-- cli/src/__tests__/get-token.test.ts | 12 +++++++++--- cli/src/__tests__/scaffold.test.ts | 13 +++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index 4ee16ae9..f44d58d6 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -237,8 +237,16 @@ describe("buildGetTokenArgs", () => { platform: "linux", }) - expect(args).not.toContain("--user") - expect(args).toContain(REMOTE_IMAGE) + expect(args).toEqual([ + "run", + "--rm", + "-it", + "--entrypoint", + "get-token", + "-v", + "/tmp/test:/home/obsidian/.config", + REMOTE_IMAGE, + ]) }) }) diff --git a/cli/src/__tests__/get-token.test.ts b/cli/src/__tests__/get-token.test.ts index f2a188f1..229a7e52 100644 --- a/cli/src/__tests__/get-token.test.ts +++ b/cli/src/__tests__/get-token.test.ts @@ -117,7 +117,10 @@ describe("captureObsidianToken", () => { }) expect(token).toBeUndefined() - expect(silent.warnings[0]).toContain("get-token did not complete") + expect(silent.warnings[0]).toBe( + "get-token did not complete — you can run it later with:\n" + + " npx vault-cortex get-token", + ) }) it("returns undefined when the token file is empty", () => { @@ -200,7 +203,10 @@ describe("runGetToken subcommand", () => { ) expect(exitCode).toBe(1) - expect(silent.errors[0]).toContain("Container runtime not running") + expect(silent.errors[0]).toBe( + "Container runtime not running — start Docker Desktop, Colima,\n" + + "OrbStack, or another Docker-compatible runtime and try again.", + ) }) it("exits 1 when token capture fails", async () => { @@ -212,7 +218,7 @@ describe("runGetToken subcommand", () => { ) expect(exitCode).toBe(1) - expect(silent.errors[0]).toContain("Could not capture the auth token") + expect(silent.errors[0]).toBe("Could not capture the auth token.") }) it("writes the token to .env when --dir is set", async () => { diff --git a/cli/src/__tests__/scaffold.test.ts b/cli/src/__tests__/scaffold.test.ts index 7123ce66..6e727500 100644 --- a/cli/src/__tests__/scaffold.test.ts +++ b/cli/src/__tests__/scaffold.test.ts @@ -324,6 +324,19 @@ describe("patchEnvObsidianToken", () => { ) }) + it("writes tokens containing $ patterns literally (no regex interpolation)", () => { + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) + const envPath = join(targetDir, ".env") + writeFileSync(envPath, "OBSIDIAN_AUTH_TOKEN=old-token\n") + + const result = patchEnvObsidianToken(envPath, "abc$&def$$1$'end") + + expect(result).toBe(true) + expect(readFileSync(envPath, "utf8")).toBe( + "OBSIDIAN_AUTH_TOKEN=abc$&def$$1$'end\n", + ) + }) + it("preserves surrounding content when patching", () => { const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-patch-")) const envPath = join(targetDir, ".env") From 45d3460ab2b2dc0d625e75b0c29324466ac157a5 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:50:57 -0400 Subject: [PATCH 05/18] fix: correct cli README claim that get-token runs automatically during init The init flow prompts for confirmation before running get-token (offerGetTokenCapture calls prompts.confirm). "Runs automatically" implies no user interaction; "is offered automatically" matches the actual behavior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/README.md b/cli/README.md index 826ebe5f..19cdc7c5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -44,8 +44,8 @@ write the token directly to an existing `.env`: npx vault-cortex get-token --dir ./vault-cortex ``` -During `init --mode remote`, this flow runs automatically when Docker is -available. +During `init --mode remote`, this flow is offered automatically when Docker +is available. ## Upgrade From 16f6cb2eefc70c1816f056c0f036e4b5dc774422 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:57:22 -0400 Subject: [PATCH 06/18] =?UTF-8?q?fix(cli):=20address=20bot=20review=20find?= =?UTF-8?q?ings=20=E2=80=94=20catch=20capture=20errors,=20warn=20on=20miss?= =?UTF-8?q?ing=20token=20file,=20mask=20token=20paste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - captureObsidianToken: wrap in try/catch so any throw degrades to the paste fallback instead of aborting init (CodeRabbit) - warn specifically when get-token completes but the token file is missing or empty, instead of returning undefined silently (Sourcery) - note auto-capture in the handoff message so users know they don't need to copy the printed token (qodo) - clarify buildGetTokenArgs docstring: --user is set when uid/gid are provided, which is always the case on POSIX hosts (Sourcery) - init: use the masked password prompt for the sync-token paste so the credential doesn't echo into scrollback (CodeRabbit) - tests: exact assertions on the remaining loose toContain checks; regression tests for the new warn + throw paths Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/__tests__/get-token.test.ts | 59 ++++++++++++++++++++++++----- cli/src/__tests__/init.test.ts | 6 ++- cli/src/docker.ts | 6 ++- cli/src/get-token.ts | 54 ++++++++++++++++++-------- cli/src/init.ts | 6 ++- 5 files changed, 101 insertions(+), 30 deletions(-) diff --git a/cli/src/__tests__/get-token.test.ts b/cli/src/__tests__/get-token.test.ts index 229a7e52..46becfdc 100644 --- a/cli/src/__tests__/get-token.test.ts +++ b/cli/src/__tests__/get-token.test.ts @@ -123,19 +123,24 @@ describe("captureObsidianToken", () => { ) }) - it("returns undefined when the token file is empty", () => { - const { prompts } = createSilentPrompts() + it("returns undefined and warns when the token file is empty", () => { + const silent = createSilentPrompts() const token = captureObsidianToken({ docker: dockerWithToken(""), - prompts, + prompts: silent.prompts, }) expect(token).toBeUndefined() + expect(silent.warnings[0]).toBe( + "get-token completed but no token was captured — the token file " + + "was missing or empty. You can retry with:\n" + + " npx vault-cortex get-token", + ) }) - it("returns undefined when get-token succeeds but writes no token file", () => { - const { prompts } = createSilentPrompts() + it("returns undefined and warns when get-token succeeds but writes no token file", () => { + const silent = createSilentPrompts() const dockerSucceedsButNoFile: DockerRunner = { ...dockerDown, isDaemonRunning: () => true, @@ -144,10 +149,36 @@ describe("captureObsidianToken", () => { const token = captureObsidianToken({ docker: dockerSucceedsButNoFile, - prompts, + prompts: silent.prompts, + }) + + expect(token).toBeUndefined() + expect(silent.warnings[0]).toBe( + "get-token completed but no token was captured — the token file " + + "was missing or empty. You can retry with:\n" + + " npx vault-cortex get-token", + ) + }) + + it("returns undefined and warns when token capture throws", () => { + const silent = createSilentPrompts() + const dockerThrows: DockerRunner = { + ...dockerDown, + isDaemonRunning: () => true, + runGetTokenWithMount: () => { + throw new Error("spawn docker ENOENT") + }, + } + + const token = captureObsidianToken({ + docker: dockerThrows, + prompts: silent.prompts, }) expect(token).toBeUndefined() + expect(silent.warnings[0]).toBe( + "Token capture failed — spawn docker ENOENT", + ) }) it("cleans up the temp directory even on failure", () => { @@ -176,7 +207,11 @@ describe("captureObsidianToken", () => { prompts: silent.prompts, }) - expect(silent.logs[0]).toContain("Handing the terminal to get-token") + expect(silent.logs[0]).toBe( + "Handing the terminal to get-token — it will ask for your Obsidian " + + "account login and print a token at the end. The token is " + + "captured automatically, so there's no need to copy it.", + ) }) }) @@ -252,7 +287,10 @@ describe("runGetToken subcommand", () => { ) expect(exitCode).toBe(1) - expect(silent.errors[0]).toContain("no OBSIDIAN_AUTH_TOKEN line") + expect(silent.errors[0]).toBe( + `Could not patch ${join(targetDir, ".env")} — the file is missing ` + + "or has no OBSIDIAN_AUTH_TOKEN line. Run init first.", + ) }) it("exits 1 when --dir .env does not exist", async () => { @@ -268,6 +306,9 @@ describe("runGetToken subcommand", () => { ) expect(exitCode).toBe(1) - expect(silent.errors[0]).toContain("the file is missing") + expect(silent.errors[0]).toBe( + `Could not patch ${join(targetDir, ".env")} — the file is missing ` + + "or has no OBSIDIAN_AUTH_TOKEN line. Run init first.", + ) }) }) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 80d5d27a..7662f846 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -615,7 +615,11 @@ describe("runInit remote flow", () => { "Paste the Obsidian Sync token (leave blank to fill in .env later):", ) const envContent = readFileSync(join(targetDir, ".env"), "utf8") - expect(envContent).toContain("OBSIDIAN_AUTH_TOKEN=captured-token\n") + // Exact line match — a substring check would also pass for a commented + // or prefixed entry (e.g. "# OBSIDIAN_AUTH_TOKEN=captured-token"). + expect(envContent.split("\n")).toContain( + "OBSIDIAN_AUTH_TOKEN=captured-token", + ) }) it("skips the docker-run offer when the sync token was left blank", async () => { diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 2eda19a2..19e6e249 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -41,8 +41,10 @@ export type GetTokenArgParams = { * Builds the `docker run` args for get-token with a volume mount that * captures the auth token file. Pure function for testability. * - * On Linux, includes `--user uid:gid` so the token file is host-user-owned - * (macOS Docker Desktop translates UIDs automatically). + * 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 buildGetTokenArgs = (params: GetTokenArgParams): string[] => { const { configMountPath, platform = process.platform, uid, gid } = params diff --git a/cli/src/get-token.ts b/cli/src/get-token.ts index 8dd16290..eeaac186 100644 --- a/cli/src/get-token.ts +++ b/cli/src/get-token.ts @@ -28,26 +28,48 @@ export const captureObsidianToken = ( deps: GetTokenDeps, ): string | undefined => { const { docker, prompts } = deps - const configMountPath = mkdtempSync(join(tmpdir(), "vault-cortex-get-token-")) + // Outer try/catch upholds the "undefined on any failure" contract — a + // throw from temp-dir creation, Docker, or the file read must degrade to + // the paste fallback, not abort the whole init flow. try { - prompts.log( - "Handing the terminal to get-token — it will ask for your Obsidian " + - "account login and print a token at the end.", + const configMountPath = mkdtempSync( + join(tmpdir(), "vault-cortex-get-token-"), ) - const succeeded = docker.runGetTokenWithMount(configMountPath) - if (!succeeded) { - prompts.warn( - "get-token did not complete — you can run it later with:\n" + - " npx vault-cortex get-token", + try { + prompts.log( + "Handing the terminal to get-token — it will ask for your Obsidian " + + "account login and print a token at the end. The token is " + + "captured automatically, so there's no need to copy it.", ) - return undefined + const succeeded = docker.runGetTokenWithMount(configMountPath) + if (!succeeded) { + prompts.warn( + "get-token did not complete — you can run it later with:\n" + + " npx vault-cortex get-token", + ) + return undefined + } + const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") + const token = existsSync(tokenPath) + ? readFileSync(tokenPath, "utf8").trim() + : "" + if (!token) { + prompts.warn( + "get-token completed but no token was captured — the token file " + + "was missing or empty. You can retry with:\n" + + " npx vault-cortex get-token", + ) + return undefined + } + return token + } finally { + rmSync(configMountPath, { recursive: true, force: true }) } - const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") - if (!existsSync(tokenPath)) return undefined - const token = readFileSync(tokenPath, "utf8").trim() - return token || undefined - } finally { - rmSync(configMountPath, { recursive: true, force: true }) + } catch (error) { + prompts.warn( + `Token capture failed — ${error instanceof Error ? error.message : String(error)}`, + ) + return undefined } } diff --git a/cli/src/init.ts b/cli/src/init.ts index 117033c9..4a8f175d 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -361,12 +361,14 @@ const runRemoteInit = async ( const capturedToken = docker.isDaemonRunning() ? await offerGetTokenCapture(prompts, docker) : undefined + // Masked prompt: the sync token is a credential and must not echo into + // the terminal or scrollback. An empty submission still means "fill in + // .env later" — clack's password prompt accepts blank input. const obsidianAuthToken = capturedToken ?? ( - await prompts.text( + await prompts.password( "Paste the Obsidian Sync token (leave blank to fill in .env later):", - { defaultValue: "" }, ) ).trim() From 20f6c881c9c8e6b5baf09c4c578a568aee385f34 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:48:41 -0400 Subject: [PATCH 07/18] refactor(cli): flatten captureObsidianToken and stop echoing the captured token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the nested try/finally + outer try/catch with a single try/catch/finally; cleanup is now best-effort, so an rmSync failure (e.g. root-owned container files) warns instead of discarding a successfully captured token - Run 'ob login' directly instead of the image's get-token script — the script only exists to locate and print the token, and the volume mount makes both unnecessary; the credential no longer echoes into terminal scrollback, and this works with already-published images (the manual --entrypoint get-token flow is unchanged) - Update the handoff message and test expectations accordingly Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/__tests__/docker.test.ts | 9 ++-- cli/src/__tests__/get-token.test.ts | 6 +-- cli/src/docker.ts | 14 +++-- cli/src/get-token.ts | 80 ++++++++++++++++------------- 4 files changed, 61 insertions(+), 48 deletions(-) diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index f44d58d6..87b1b7b7 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -202,10 +202,11 @@ describe("buildGetTokenArgs", () => { "--rm", "-it", "--entrypoint", - "get-token", + "ob", "-v", "/tmp/vault-cortex-get-token-abc:/home/obsidian/.config", REMOTE_IMAGE, + "login", ]) }) @@ -222,12 +223,13 @@ describe("buildGetTokenArgs", () => { "--rm", "-it", "--entrypoint", - "get-token", + "ob", "-v", "/tmp/vault-cortex-get-token-abc:/home/obsidian/.config", "--user", "1000:1000", REMOTE_IMAGE, + "login", ]) }) @@ -242,10 +244,11 @@ describe("buildGetTokenArgs", () => { "--rm", "-it", "--entrypoint", - "get-token", + "ob", "-v", "/tmp/test:/home/obsidian/.config", REMOTE_IMAGE, + "login", ]) }) }) diff --git a/cli/src/__tests__/get-token.test.ts b/cli/src/__tests__/get-token.test.ts index 46becfdc..da184aa3 100644 --- a/cli/src/__tests__/get-token.test.ts +++ b/cli/src/__tests__/get-token.test.ts @@ -208,9 +208,9 @@ describe("captureObsidianToken", () => { }) expect(silent.logs[0]).toBe( - "Handing the terminal to get-token — it will ask for your Obsidian " + - "account login and print a token at the end. The token is " + - "captured automatically, so there's no need to copy it.", + "Handing the terminal to the Obsidian login — it will ask for your " + + "account email, password, and MFA code. The token is captured " + + "automatically, so there's nothing to copy.", ) }) }) diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 19e6e249..cbf60319 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -23,7 +23,7 @@ export type DockerRunner = { pullImage: (image: string) => boolean /** Stops and removes the vault-cortex container (idempotent). */ stopAndRemoveContainer: () => boolean - /** Runs get-token with a volume mount for auto-capture. */ + /** Runs the Obsidian login with a volume mount for token auto-capture. */ runGetTokenWithMount: (configMountPath: string) => boolean } @@ -38,8 +38,12 @@ export type GetTokenArgParams = { } /** - * Builds the `docker run` args for get-token with a volume mount that - * captures the auth token file. Pure function for testability. + * 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-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 @@ -54,7 +58,7 @@ export const buildGetTokenArgs = (params: GetTokenArgParams): string[] => { "--rm", "-it", "--entrypoint", - "get-token", + "ob", "-v", `${configMountPath}:/home/obsidian/.config`, ] @@ -63,7 +67,7 @@ export const buildGetTokenArgs = (params: GetTokenArgParams): string[] => { args.push("--user", `${uid}:${gid}`) } - args.push(REMOTE_IMAGE) + args.push(REMOTE_IMAGE, "login") return args } diff --git a/cli/src/get-token.ts b/cli/src/get-token.ts index eeaac186..c95bf0a4 100644 --- a/cli/src/get-token.ts +++ b/cli/src/get-token.ts @@ -17,10 +17,11 @@ export type GetTokenDeps = { } /** - * Runs get-token inside a Docker container with a volume mount that - * captures the auth token file. The interactive login (email, password, - * MFA) still shows in the terminal — only the resulting token is - * captured automatically. + * 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. * * Returns the token string on success, undefined on any failure. */ @@ -28,48 +29,53 @@ export const captureObsidianToken = ( deps: GetTokenDeps, ): string | undefined => { const { docker, prompts } = deps - // Outer try/catch upholds the "undefined on any failure" contract — a - // throw from temp-dir creation, Docker, or the file read must degrade to - // the paste fallback, not abort the whole init flow. + // let: assigned inside the try so the finally can clean up the temp dir on + // every path, while staying undefined when mkdtemp itself is what threw. + let configMountPath: string | undefined try { - const configMountPath = mkdtempSync( - join(tmpdir(), "vault-cortex-get-token-"), + configMountPath = mkdtempSync(join(tmpdir(), "vault-cortex-get-token-")) + prompts.log( + "Handing the terminal to the Obsidian login — it will ask for your " + + "account email, password, and MFA code. The token is captured " + + "automatically, so there's nothing to copy.", ) - try { - prompts.log( - "Handing the terminal to get-token — it will ask for your Obsidian " + - "account login and print a token at the end. The token is " + - "captured automatically, so there's no need to copy it.", + const succeeded = docker.runGetTokenWithMount(configMountPath) + if (!succeeded) { + prompts.warn( + "get-token did not complete — you can run it later with:\n" + + " npx vault-cortex get-token", ) - const succeeded = docker.runGetTokenWithMount(configMountPath) - if (!succeeded) { - prompts.warn( - "get-token did not complete — you can run it later with:\n" + - " npx vault-cortex get-token", - ) - return undefined - } - const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") - const token = existsSync(tokenPath) - ? readFileSync(tokenPath, "utf8").trim() - : "" - if (!token) { - prompts.warn( - "get-token completed but no token was captured — the token file " + - "was missing or empty. You can retry with:\n" + - " npx vault-cortex get-token", - ) - return undefined - } - return token - } finally { - rmSync(configMountPath, { recursive: true, force: true }) + return undefined + } + const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") + const token = existsSync(tokenPath) + ? readFileSync(tokenPath, "utf8").trim() + : "" + if (!token) { + prompts.warn( + "get-token completed but no token was captured — the token file " + + "was missing or empty. You can retry with:\n" + + " npx vault-cortex get-token", + ) + return undefined } + return token } catch (error) { prompts.warn( `Token capture failed — ${error instanceof Error ? error.message : String(error)}`, ) return undefined + } finally { + // Best-effort cleanup: failing to remove the temp dir (e.g. root-owned + // files left by the container) must not turn a successful capture into + // a failure, so it only warns. + if (configMountPath) { + try { + rmSync(configMountPath, { recursive: true, force: true }) + } catch { + prompts.warn(`Could not remove temp directory: ${configMountPath}`) + } + } } } From 5fe1a55cd97a697f8f1160fc3a5c7102df791b73 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:59:34 -0400 Subject: [PATCH 08/18] refactor(cli): replace catch-all in token capture with per-operation error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each fallible operation gets its own narrow wrapper with one explicit failure meaning: makeTempMountDir (warn + undefined), runLoginContainer (throw = failed run), readCapturedTokenFile (missing/empty/unreadable = no token), removeTempMountDir (best-effort, warn only). The main flow keeps a single bare try/finally to scope the temp dir — no catch left that can swallow unrelated errors. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/__tests__/get-token.test.ts | 14 ++-- cli/src/get-token.ts | 108 ++++++++++++++++++++-------- 2 files changed, 88 insertions(+), 34 deletions(-) diff --git a/cli/src/__tests__/get-token.test.ts b/cli/src/__tests__/get-token.test.ts index da184aa3..b82ab1c7 100644 --- a/cli/src/__tests__/get-token.test.ts +++ b/cli/src/__tests__/get-token.test.ts @@ -134,7 +134,7 @@ describe("captureObsidianToken", () => { expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( "get-token completed but no token was captured — the token file " + - "was missing or empty. You can retry with:\n" + + "was missing, empty, or unreadable. You can retry with:\n" + " npx vault-cortex get-token", ) }) @@ -155,12 +155,12 @@ describe("captureObsidianToken", () => { expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( "get-token completed but no token was captured — the token file " + - "was missing or empty. You can retry with:\n" + + "was missing, empty, or unreadable. You can retry with:\n" + " npx vault-cortex get-token", ) }) - it("returns undefined and warns when token capture throws", () => { + it("treats a docker runner throw as a failed run and returns undefined", () => { const silent = createSilentPrompts() const dockerThrows: DockerRunner = { ...dockerDown, @@ -176,9 +176,11 @@ describe("captureObsidianToken", () => { }) expect(token).toBeUndefined() - expect(silent.warnings[0]).toBe( - "Token capture failed — spawn docker ENOENT", - ) + expect(silent.warnings).toEqual([ + "Docker run failed — spawn docker ENOENT", + "get-token did not complete — you can run it later with:\n" + + " npx vault-cortex get-token", + ]) }) it("cleans up the temp directory even on failure", () => { diff --git a/cli/src/get-token.ts b/cli/src/get-token.ts index c95bf0a4..89dfa4d2 100644 --- a/cli/src/get-token.ts +++ b/cli/src/get-token.ts @@ -16,6 +16,74 @@ export type GetTokenDeps = { docker: DockerRunner } +/** Message from an unknown throw — Error instances keep their message. */ +const describeError = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +/** + * Creates the temp dir the container's config mount writes into. + * Returns undefined (after warning) when creation fails. + */ +const makeTempMountDir = (prompts: Prompts): string | undefined => { + try { + return mkdtempSync(join(tmpdir(), "vault-cortex-get-token-")) + } catch (error) { + prompts.warn( + `Could not create a temp directory for token capture — ${describeError(error)}`, + ) + return undefined + } +} + +/** + * 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: GetTokenDeps, +): boolean => { + const { docker, prompts } = deps + try { + return docker.runGetTokenWithMount(configMountPath) + } catch (error) { + prompts.warn(`Docker run failed — ${describeError(error)}`) + return false + } +} + +/** + * Reads the captured token file from the config mount, returning "" when + * the file is missing, empty, or unreadable — the caller treats all three + * as "no token captured". + */ +const readCapturedTokenFile = (configMountPath: string): string => { + const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") + try { + return existsSync(tokenPath) ? readFileSync(tokenPath, "utf8").trim() : "" + } catch { + return "" + } +} + +/** + * 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)}`, + ) + } +} + /** * Runs the Obsidian login (`ob login`) inside a Docker container with a * volume mount that captures the auth token file. The interactive login @@ -23,59 +91,43 @@ export type GetTokenDeps = { * read from the mounted config dir — never printed, so it stays out of * terminal scrollback. * - * Returns the token string on success, undefined on any failure. + * 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. */ export const captureObsidianToken = ( deps: GetTokenDeps, ): string | undefined => { - const { docker, prompts } = deps - // let: assigned inside the try so the finally can clean up the temp dir on - // every path, while staying undefined when mkdtemp itself is what threw. - let configMountPath: string | undefined + const { prompts } = deps + const configMountPath = makeTempMountDir(prompts) + if (!configMountPath) return undefined + try { - configMountPath = mkdtempSync(join(tmpdir(), "vault-cortex-get-token-")) prompts.log( "Handing the terminal to the Obsidian login — it will ask for your " + "account email, password, and MFA code. The token is captured " + "automatically, so there's nothing to copy.", ) - const succeeded = docker.runGetTokenWithMount(configMountPath) - if (!succeeded) { + if (!runLoginContainer(configMountPath, deps)) { prompts.warn( "get-token did not complete — you can run it later with:\n" + " npx vault-cortex get-token", ) return undefined } - const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") - const token = existsSync(tokenPath) - ? readFileSync(tokenPath, "utf8").trim() - : "" + const token = readCapturedTokenFile(configMountPath) if (!token) { prompts.warn( "get-token completed but no token was captured — the token file " + - "was missing or empty. You can retry with:\n" + + "was missing, empty, or unreadable. You can retry with:\n" + " npx vault-cortex get-token", ) return undefined } return token - } catch (error) { - prompts.warn( - `Token capture failed — ${error instanceof Error ? error.message : String(error)}`, - ) - return undefined } finally { - // Best-effort cleanup: failing to remove the temp dir (e.g. root-owned - // files left by the container) must not turn a successful capture into - // a failure, so it only warns. - if (configMountPath) { - try { - rmSync(configMountPath, { recursive: true, force: true }) - } catch { - prompts.warn(`Could not remove temp directory: ${configMountPath}`) - } - } + removeTempMountDir(configMountPath, prompts) } } From 494a44e75b4bc7edc048a337011292207c5548ab Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:02:05 -0400 Subject: [PATCH 09/18] refactor(cli): return undefined instead of empty-string sentinel from readCapturedTokenFile Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/get-token.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cli/src/get-token.ts b/cli/src/get-token.ts index 89dfa4d2..3d81e7a1 100644 --- a/cli/src/get-token.ts +++ b/cli/src/get-token.ts @@ -53,16 +53,18 @@ const runLoginContainer = ( } /** - * Reads the captured token file from the config mount, returning "" when - * the file is missing, empty, or unreadable — the caller treats all three - * as "no token captured". + * 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 => { +const readCapturedTokenFile = (configMountPath: string): string | undefined => { const tokenPath = join(configMountPath, "obsidian-headless", "auth_token") try { - return existsSync(tokenPath) ? readFileSync(tokenPath, "utf8").trim() : "" + if (!existsSync(tokenPath)) return undefined + const token = readFileSync(tokenPath, "utf8").trim() + return token || undefined } catch { - return "" + return undefined } } From 36c7e968baa35ae0c5113d840cd725ab3a192ab9 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:03:21 -0400 Subject: [PATCH 10/18] style(cli): name the login-container result for readability Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/get-token.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/get-token.ts b/cli/src/get-token.ts index 3d81e7a1..0bb6454c 100644 --- a/cli/src/get-token.ts +++ b/cli/src/get-token.ts @@ -111,7 +111,8 @@ export const captureObsidianToken = ( "account email, password, and MFA code. The token is captured " + "automatically, so there's nothing to copy.", ) - if (!runLoginContainer(configMountPath, deps)) { + const succeeded = runLoginContainer(configMountPath, deps) + if (!succeeded) { prompts.warn( "get-token did not complete — you can run it later with:\n" + " npx vault-cortex get-token", From 943861a311f3eddc270ec9cfaa8ded6325720fe4 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:03:53 -0400 Subject: [PATCH 11/18] style(cli): rename succeeded to loginSucceeded per naming conventions Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/get-token.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/get-token.ts b/cli/src/get-token.ts index 0bb6454c..07e66525 100644 --- a/cli/src/get-token.ts +++ b/cli/src/get-token.ts @@ -111,8 +111,8 @@ export const captureObsidianToken = ( "account email, password, and MFA code. The token is captured " + "automatically, so there's nothing to copy.", ) - const succeeded = runLoginContainer(configMountPath, deps) - if (!succeeded) { + const loginSucceeded = runLoginContainer(configMountPath, deps) + if (!loginSucceeded) { prompts.warn( "get-token did not complete — you can run it later with:\n" + " npx vault-cortex get-token", From 1824dd7553afa5868c31ec4ce974a44b5cfb89fa Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:34:19 -0400 Subject: [PATCH 12/18] refactor(cli): rename get-token subcommand to get-sync-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old name was inherited from the container's get-token script, which the capture flow no longer uses, and it was ambiguous — the CLI handles two tokens (MCP auth token from init, Obsidian Sync token from this command). The new name matches the docs' "Obsidian Sync token" vocabulary. Pre-release, so no compatibility concern. Also renames the internals that still described the old flow: runGetTokenWithMount -> runObsidianLogin, buildGetTokenArgs -> buildObsidianLoginArgs, and the get-token.ts module -> get-sync-token.ts. The container script keeps its name — the manual --entrypoint get-token flow is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- AGENTS.md | 2 +- cli/README.md | 6 +- cli/src/__tests__/docker.test.ts | 18 ++--- cli/src/__tests__/env.test.ts | 2 +- ...t-token.test.ts => get-sync-token.test.ts} | 66 +++++++++---------- cli/src/__tests__/init.test.ts | 16 +++-- cli/src/__tests__/program.test.ts | 28 ++++---- cli/src/__tests__/upgrade.test.ts | 4 +- cli/src/docker.ts | 12 ++-- cli/src/env.ts | 2 +- cli/src/{get-token.ts => get-sync-token.ts} | 28 ++++---- cli/src/init.ts | 8 +-- cli/src/main.ts | 6 +- cli/src/program.ts | 10 +-- deploy/remote/README.md | 2 +- 15 files changed, 107 insertions(+), 103 deletions(-) rename cli/src/__tests__/{get-token.test.ts => get-sync-token.test.ts} (81%) rename cli/src/{get-token.ts => get-sync-token.ts} (88%) diff --git a/AGENTS.md b/AGENTS.md index a8a21824..5b7da77b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ cli/ # npx vault-cortex CLI (published as vaul scaffold.ts # File generation (.env) docker.ts # Container management (docker run, health-check wait) upgrade.ts # Upgrade command (pull + re-create + health check) - get-token.ts # Get-token subcommand (auto-capture via volume mount) + get-sync-token.ts # Get-sync-token subcommand (Sync token auto-capture via volume mount) env.ts # Environment file handling (.env generation) token.ts # Secure token generation (openssl rand) vault.ts # Vault path validation diff --git a/cli/README.md b/cli/README.md index 19cdc7c5..fe72dd10 100644 --- a/cli/README.md +++ b/cli/README.md @@ -28,12 +28,12 @@ container; this CLI scaffolds the config so you don't have to. Existing files are never overwritten without asking. -## Get Token +## Get Sync Token Generate an Obsidian Sync auth token without leaving the CLI: ```bash -npx vault-cortex get-token +npx vault-cortex get-sync-token ``` The command runs the Obsidian login flow inside Docker and captures the @@ -41,7 +41,7 @@ resulting token automatically — no manual paste needed. Use `--dir` to write the token directly to an existing `.env`: ```bash -npx vault-cortex get-token --dir ./vault-cortex +npx vault-cortex get-sync-token --dir ./vault-cortex ``` During `init --mode remote`, this flow is offered automatically when Docker diff --git a/cli/src/__tests__/docker.test.ts b/cli/src/__tests__/docker.test.ts index 87b1b7b7..c2f6beba 100644 --- a/cli/src/__tests__/docker.test.ts +++ b/cli/src/__tests__/docker.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { buildDockerRunArgs, - buildGetTokenArgs, + buildObsidianLoginArgs, CONTAINER_NAME, LOCAL_IMAGE, pollHealth, @@ -188,10 +188,10 @@ describe("buildDockerRunArgs", () => { }) }) -describe("buildGetTokenArgs", () => { +describe("buildObsidianLoginArgs", () => { it("produces the correct args on macOS (no --user flag)", () => { - const args = buildGetTokenArgs({ - configMountPath: "/tmp/vault-cortex-get-token-abc", + const args = buildObsidianLoginArgs({ + configMountPath: "/tmp/vault-cortex-sync-token-abc", platform: "darwin", uid: 501, gid: 20, @@ -204,15 +204,15 @@ describe("buildGetTokenArgs", () => { "--entrypoint", "ob", "-v", - "/tmp/vault-cortex-get-token-abc:/home/obsidian/.config", + "/tmp/vault-cortex-sync-token-abc:/home/obsidian/.config", REMOTE_IMAGE, "login", ]) }) it("includes --user uid:gid on Linux", () => { - const args = buildGetTokenArgs({ - configMountPath: "/tmp/vault-cortex-get-token-abc", + const args = buildObsidianLoginArgs({ + configMountPath: "/tmp/vault-cortex-sync-token-abc", platform: "linux", uid: 1000, gid: 1000, @@ -225,7 +225,7 @@ describe("buildGetTokenArgs", () => { "--entrypoint", "ob", "-v", - "/tmp/vault-cortex-get-token-abc:/home/obsidian/.config", + "/tmp/vault-cortex-sync-token-abc:/home/obsidian/.config", "--user", "1000:1000", REMOTE_IMAGE, @@ -234,7 +234,7 @@ describe("buildGetTokenArgs", () => { }) it("omits --user on Linux when uid/gid are not provided", () => { - const args = buildGetTokenArgs({ + const args = buildObsidianLoginArgs({ configMountPath: "/tmp/test", platform: "linux", }) diff --git a/cli/src/__tests__/env.test.ts b/cli/src/__tests__/env.test.ts index 8483d9e9..44dbab09 100644 --- a/cli/src/__tests__/env.test.ts +++ b/cli/src/__tests__/env.test.ts @@ -80,7 +80,7 @@ describe("buildRemoteEnv", () => { expect(env).toMatch(/^OBSIDIAN_AUTH_TOKEN=$/m) expect(env).toContain("FILL THIS IN") - expect(env).toContain("npx vault-cortex get-token") + expect(env).toContain("npx vault-cortex get-sync-token") }) it("states defaulted sync settings as uncommented lines", () => { diff --git a/cli/src/__tests__/get-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts similarity index 81% rename from cli/src/__tests__/get-token.test.ts rename to cli/src/__tests__/get-sync-token.test.ts index b82ab1c7..005a6ea6 100644 --- a/cli/src/__tests__/get-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { describe, expect, it } from "vitest" -import { captureObsidianToken, runGetToken } from "../get-token.js" +import { captureObsidianToken, runGetSyncToken } from "../get-sync-token.js" import type { DockerRunner } from "../docker.js" import type { Prompts } from "../prompts.js" @@ -52,16 +52,16 @@ const createSilentPrompts = () => { } /** - * Creates a DockerRunner whose runGetTokenWithMount writes a fake - * auth_token file into the config mount path, simulating the container's - * get-token behavior. + * 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 => ({ isDaemonRunning: () => true, dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetTokenWithMount: (configMountPath) => { + runObsidianLogin: (configMountPath) => { const tokenDir = join(configMountPath, "obsidian-headless") mkdirSync(tokenDir, { recursive: true }) writeFileSync(join(tokenDir, "auth_token"), token) @@ -69,12 +69,12 @@ const dockerWithToken = (token: string): DockerRunner => ({ }, }) -const dockerFailsGetToken: DockerRunner = { +const dockerFailsLogin: DockerRunner = { isDaemonRunning: () => true, dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetTokenWithMount: () => false, + runObsidianLogin: () => false, } const dockerDown: DockerRunner = { @@ -82,11 +82,11 @@ const dockerDown: DockerRunner = { dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetTokenWithMount: () => false, + runObsidianLogin: () => false, } describe("captureObsidianToken", () => { - it("returns the token when get-token writes the auth_token file", () => { + it("returns the token when the login writes the auth_token file", () => { const { prompts } = createSilentPrompts() const token = captureObsidianToken({ @@ -112,14 +112,14 @@ describe("captureObsidianToken", () => { const silent = createSilentPrompts() const token = captureObsidianToken({ - docker: dockerFailsGetToken, + docker: dockerFailsLogin, prompts: silent.prompts, }) expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( - "get-token did not complete — you can run it later with:\n" + - " npx vault-cortex get-token", + "The Obsidian login did not complete — you can run it later with:\n" + + " npx vault-cortex get-sync-token", ) }) @@ -133,18 +133,18 @@ describe("captureObsidianToken", () => { expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( - "get-token completed but no token was captured — the token file " + + "The Obsidian login completed but no token was captured — the token file " + "was missing, empty, or unreadable. You can retry with:\n" + - " npx vault-cortex get-token", + " npx vault-cortex get-sync-token", ) }) - it("returns undefined and warns when get-token succeeds but writes no token file", () => { + it("returns undefined and warns when the login succeeds but writes no token file", () => { const silent = createSilentPrompts() const dockerSucceedsButNoFile: DockerRunner = { ...dockerDown, isDaemonRunning: () => true, - runGetTokenWithMount: () => true, + runObsidianLogin: () => true, } const token = captureObsidianToken({ @@ -154,9 +154,9 @@ describe("captureObsidianToken", () => { expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( - "get-token completed but no token was captured — the token file " + + "The Obsidian login completed but no token was captured — the token file " + "was missing, empty, or unreadable. You can retry with:\n" + - " npx vault-cortex get-token", + " npx vault-cortex get-sync-token", ) }) @@ -165,7 +165,7 @@ describe("captureObsidianToken", () => { const dockerThrows: DockerRunner = { ...dockerDown, isDaemonRunning: () => true, - runGetTokenWithMount: () => { + runObsidianLogin: () => { throw new Error("spawn docker ENOENT") }, } @@ -178,8 +178,8 @@ describe("captureObsidianToken", () => { expect(token).toBeUndefined() expect(silent.warnings).toEqual([ "Docker run failed — spawn docker ENOENT", - "get-token did not complete — you can run it later with:\n" + - " npx vault-cortex get-token", + "The Obsidian login did not complete — you can run it later with:\n" + + " npx vault-cortex get-sync-token", ]) }) @@ -189,7 +189,7 @@ describe("captureObsidianToken", () => { const dockerTracker: DockerRunner = { ...dockerDown, isDaemonRunning: () => true, - runGetTokenWithMount: (configMountPath) => { + runObsidianLogin: (configMountPath) => { tempDirs.push(configMountPath) return false }, @@ -217,11 +217,11 @@ describe("captureObsidianToken", () => { }) }) -describe("runGetToken subcommand", () => { +describe("runGetSyncToken subcommand", () => { it("prints the token to stdout when --dir is not set", async () => { const silent = createSilentPrompts() - const exitCode = await runGetToken( + const exitCode = await runGetSyncToken( {}, { prompts: silent.prompts, docker: dockerWithToken("my-sync-token") }, ) @@ -234,7 +234,7 @@ describe("runGetToken subcommand", () => { it("exits 1 when the docker daemon is not running", async () => { const silent = createSilentPrompts() - const exitCode = await runGetToken( + const exitCode = await runGetSyncToken( {}, { prompts: silent.prompts, docker: dockerDown }, ) @@ -249,9 +249,9 @@ describe("runGetToken subcommand", () => { it("exits 1 when token capture fails", async () => { const silent = createSilentPrompts() - const exitCode = await runGetToken( + const exitCode = await runGetSyncToken( {}, - { prompts: silent.prompts, docker: dockerFailsGetToken }, + { prompts: silent.prompts, docker: dockerFailsLogin }, ) expect(exitCode).toBe(1) @@ -259,14 +259,14 @@ describe("runGetToken subcommand", () => { }) it("writes the token to .env when --dir is set", async () => { - const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-get-token-")) + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-sync-token-")) writeFileSync( join(targetDir, ".env"), "MCP_AUTH_TOKEN=abc\nOBSIDIAN_AUTH_TOKEN=old-token\nVAULT_NAME=MyVault\n", ) const silent = createSilentPrompts() - const exitCode = await runGetToken( + const exitCode = await runGetSyncToken( { dir: targetDir }, { prompts: silent.prompts, docker: dockerWithToken("new-sync-token") }, ) @@ -279,11 +279,11 @@ describe("runGetToken subcommand", () => { }) it("exits 1 when --dir .env has no OBSIDIAN_AUTH_TOKEN line", async () => { - const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-get-token-")) + const targetDir = mkdtempSync(join(tmpdir(), "vault-cli-sync-token-")) writeFileSync(join(targetDir, ".env"), "MCP_AUTH_TOKEN=abc\n") const silent = createSilentPrompts() - const exitCode = await runGetToken( + const exitCode = await runGetSyncToken( { dir: targetDir }, { prompts: silent.prompts, docker: dockerWithToken("new-sync-token") }, ) @@ -297,12 +297,12 @@ describe("runGetToken subcommand", () => { it("exits 1 when --dir .env does not exist", async () => { const targetDir = join( - mkdtempSync(join(tmpdir(), "vault-cli-get-token-")), + mkdtempSync(join(tmpdir(), "vault-cli-sync-token-")), "nonexistent", ) const silent = createSilentPrompts() - const exitCode = await runGetToken( + const exitCode = await runGetSyncToken( { dir: targetDir }, { prompts: silent.prompts, docker: dockerWithToken("new-sync-token") }, ) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 7662f846..65b12f7d 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -91,7 +91,7 @@ const dockerUnavailable: DockerRunner = { dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetTokenWithMount: () => false, + runObsidianLogin: () => false, } const fetchNever: typeof fetch = async () => { @@ -593,7 +593,7 @@ describe("runInit remote flow", () => { const dockerWithCapture: DockerRunner = { ...dockerUnavailable, isDaemonRunning: () => true, - runGetTokenWithMount: (configMountPath) => { + runObsidianLogin: (configMountPath) => { const tokenDir = join(configMountPath, "obsidian-headless") mkdirSync(tokenDir, { recursive: true }) writeFileSync(join(tokenDir, "auth_token"), "captured-token") @@ -701,7 +701,7 @@ describe("runInit with a kept existing .env", () => { dockerRun: () => true, pullImage: () => true, stopAndRemoveContainer: () => true, - runGetTokenWithMount: () => false, + runObsidianLogin: () => false, } const fetchedUrls: string[] = [] const fetchRecorder: typeof fetch = async (url) => { @@ -765,7 +765,7 @@ describe("runInit remote encryption password", () => { dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetTokenWithMount: () => false, + runObsidianLogin: () => false, } const exitCode = await runInit( @@ -785,7 +785,7 @@ describe("runInit remote encryption password", () => { }) }) -describe("runInit get-token auto-capture fallback", () => { +describe("runInit sync-token auto-capture fallback", () => { it("falls back to paste prompt when auto-capture fails", async () => { const targetDir = makeTargetDir() const scripted = createScriptedPrompts([ @@ -800,7 +800,7 @@ describe("runInit get-token auto-capture fallback", () => { dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetTokenWithMount: () => false, + runObsidianLogin: () => false, } await runInit( @@ -815,6 +815,8 @@ describe("runInit get-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("get-token did not complete") + expect(scripted.warnings[0]).toContain( + "The Obsidian login did not complete", + ) }) }) diff --git a/cli/src/__tests__/program.test.ts b/cli/src/__tests__/program.test.ts index b72d6c07..3527ea6d 100644 --- a/cli/src/__tests__/program.test.ts +++ b/cli/src/__tests__/program.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest" import { buildProgram } from "../program.js" -import type { GetTokenFlags } from "../get-token.js" +import type { GetSyncTokenFlags } from "../get-sync-token.js" import type { InitFlags } from "../init.js" import type { UpgradeFlags } from "../upgrade.js" const buildCapturingProgram = () => { const initCalls: InitFlags[] = [] const upgradeCalls: UpgradeFlags[] = [] - const getTokenCalls: GetTokenFlags[] = [] + const getSyncTokenCalls: GetSyncTokenFlags[] = [] const program = buildProgram({ version: "0.0.0-test", runInit: async (flags) => { @@ -19,8 +19,8 @@ const buildCapturingProgram = () => { upgradeCalls.push(flags) return 0 }, - runGetToken: async (flags) => { - getTokenCalls.push(flags) + runGetSyncToken: async (flags) => { + getSyncTokenCalls.push(flags) return 0 }, }) @@ -28,7 +28,7 @@ const buildCapturingProgram = () => { command.exitOverride() command.configureOutput({ writeOut: () => {}, writeErr: () => {} }) } - return { program, initCalls, upgradeCalls, getTokenCalls } + return { program, initCalls, upgradeCalls, getSyncTokenCalls } } describe("buildProgram init", () => { @@ -100,22 +100,22 @@ describe("buildProgram upgrade", () => { }) }) -describe("buildProgram get-token", () => { - it("passes --dir through to runGetToken", async () => { - const { program, getTokenCalls } = buildCapturingProgram() +describe("buildProgram get-sync-token", () => { + it("passes --dir through to runGetSyncToken", async () => { + const { program, getSyncTokenCalls } = buildCapturingProgram() - await program.parseAsync(["get-token", "--dir", "/opt/vault-cortex"], { + await program.parseAsync(["get-sync-token", "--dir", "/opt/vault-cortex"], { from: "user", }) - expect(getTokenCalls).toEqual([{ dir: "/opt/vault-cortex" }]) + expect(getSyncTokenCalls).toEqual([{ dir: "/opt/vault-cortex" }]) }) - it("invokes get-token with no flags when none are given", async () => { - const { program, getTokenCalls } = buildCapturingProgram() + it("invokes get-sync-token with no flags when none are given", async () => { + const { program, getSyncTokenCalls } = buildCapturingProgram() - await program.parseAsync(["get-token"], { from: "user" }) + await program.parseAsync(["get-sync-token"], { from: "user" }) - expect(getTokenCalls).toEqual([{}]) + expect(getSyncTokenCalls).toEqual([{}]) }) }) diff --git a/cli/src/__tests__/upgrade.test.ts b/cli/src/__tests__/upgrade.test.ts index f1d870f4..9a9d38e4 100644 --- a/cli/src/__tests__/upgrade.test.ts +++ b/cli/src/__tests__/upgrade.test.ts @@ -46,7 +46,7 @@ const dockerReady: DockerRunner = { dockerRun: () => true, pullImage: () => true, stopAndRemoveContainer: () => true, - runGetTokenWithMount: () => false, + runObsidianLogin: () => false, } const dockerDown: DockerRunner = { @@ -54,7 +54,7 @@ const dockerDown: DockerRunner = { dockerRun: () => false, pullImage: () => false, stopAndRemoveContainer: () => false, - runGetTokenWithMount: () => false, + runObsidianLogin: () => false, } const fetchOk: typeof fetch = async () => ({ ok: true }) as Response diff --git a/cli/src/docker.ts b/cli/src/docker.ts index cbf60319..8e7f5870 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -24,10 +24,10 @@ export type DockerRunner = { /** Stops and removes the vault-cortex container (idempotent). */ stopAndRemoveContainer: () => boolean /** Runs the Obsidian login with a volume mount for token auto-capture. */ - runGetTokenWithMount: (configMountPath: string) => boolean + runObsidianLogin: (configMountPath: string) => boolean } -export type GetTokenArgParams = { +export type ObsidianLoginArgParams = { configMountPath: string /** Defaults to process.platform. */ platform?: NodeJS.Platform @@ -50,7 +50,9 @@ export type GetTokenArgParams = { * 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 buildGetTokenArgs = (params: GetTokenArgParams): string[] => { +export const buildObsidianLoginArgs = ( + params: ObsidianLoginArgParams, +): string[] => { const { configMountPath, platform = process.platform, uid, gid } = params const args = [ @@ -149,10 +151,10 @@ export const createDockerRunner = (): DockerRunner => ({ spawnSync("docker", ["pull", image], { stdio: "inherit" }).status === 0, stopAndRemoveContainer: () => spawnSync("docker", ["rm", "-f", CONTAINER_NAME]).status === 0, - runGetTokenWithMount: (configMountPath) => + runObsidianLogin: (configMountPath) => spawnSync( "docker", - buildGetTokenArgs({ + buildObsidianLoginArgs({ configMountPath, uid: process.getuid?.(), gid: process.getgid?.(), diff --git a/cli/src/env.ts b/cli/src/env.ts index 68932317..02896943 100644 --- a/cli/src/env.ts +++ b/cli/src/env.ts @@ -208,7 +208,7 @@ VAULT_PASSWORD=${answers.vaultPassword}` answers.obsidianAuthToken === "" ? `# Obsidian Sync auth token — FILL THIS IN before starting the server. # Generate once with: -# npx vault-cortex get-token` +# npx vault-cortex get-sync-token` : `# Obsidian Sync auth token.` return `# vault-cortex — remote quickstart (Obsidian Sync) diff --git a/cli/src/get-token.ts b/cli/src/get-sync-token.ts similarity index 88% rename from cli/src/get-token.ts rename to cli/src/get-sync-token.ts index 07e66525..88fcbd65 100644 --- a/cli/src/get-token.ts +++ b/cli/src/get-sync-token.ts @@ -7,11 +7,11 @@ import type { Prompts } from "./prompts.js" import { patchEnvObsidianToken } from "./scaffold.js" import { expandTilde } from "./vault.js" -export type GetTokenFlags = { +export type GetSyncTokenFlags = { dir?: string } -export type GetTokenDeps = { +export type GetSyncTokenDeps = { prompts: Prompts docker: DockerRunner } @@ -26,7 +26,7 @@ const describeError = (error: unknown): string => */ const makeTempMountDir = (prompts: Prompts): string | undefined => { try { - return mkdtempSync(join(tmpdir(), "vault-cortex-get-token-")) + return mkdtempSync(join(tmpdir(), "vault-cortex-sync-token-")) } catch (error) { prompts.warn( `Could not create a temp directory for token capture — ${describeError(error)}`, @@ -41,11 +41,11 @@ const makeTempMountDir = (prompts: Prompts): string | undefined => { */ const runLoginContainer = ( configMountPath: string, - deps: GetTokenDeps, + deps: GetSyncTokenDeps, ): boolean => { const { docker, prompts } = deps try { - return docker.runGetTokenWithMount(configMountPath) + return docker.runObsidianLogin(configMountPath) } catch (error) { prompts.warn(`Docker run failed — ${describeError(error)}`) return false @@ -99,7 +99,7 @@ const removeTempMountDir = ( * (acquire → release); it has no catch and swallows nothing. */ export const captureObsidianToken = ( - deps: GetTokenDeps, + deps: GetSyncTokenDeps, ): string | undefined => { const { prompts } = deps const configMountPath = makeTempMountDir(prompts) @@ -114,17 +114,17 @@ export const captureObsidianToken = ( const loginSucceeded = runLoginContainer(configMountPath, deps) if (!loginSucceeded) { prompts.warn( - "get-token did not complete — you can run it later with:\n" + - " npx vault-cortex get-token", + "The Obsidian login did not complete — you can run it later with:\n" + + " npx vault-cortex get-sync-token", ) return undefined } const token = readCapturedTokenFile(configMountPath) if (!token) { prompts.warn( - "get-token completed but no token was captured — the token file " + + "The Obsidian login completed but no token was captured — the token file " + "was missing, empty, or unreadable. You can retry with:\n" + - " npx vault-cortex get-token", + " npx vault-cortex get-sync-token", ) return undefined } @@ -139,9 +139,9 @@ export const captureObsidianToken = ( * Without --dir, prints the token to stdout. * With --dir, writes it directly to `/.env`. */ -export const runGetToken = async ( - flags: GetTokenFlags, - deps: GetTokenDeps, +export const runGetSyncToken = async ( + flags: GetSyncTokenFlags, + deps: GetSyncTokenDeps, ): Promise => { const { prompts, docker } = deps @@ -153,7 +153,7 @@ export const runGetToken = async ( return 1 } - prompts.intro("vault-cortex get-token") + prompts.intro("vault-cortex get-sync-token") const token = captureObsidianToken({ docker, prompts }) if (!token) { diff --git a/cli/src/init.ts b/cli/src/init.ts index 4a8f175d..1bac7c46 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -1,7 +1,7 @@ import { join, resolve } from "node:path" import { buildLocalEnv, buildRemoteEnv } from "./env.js" -import { captureObsidianToken } from "./get-token.js" +import { captureObsidianToken } from "./get-sync-token.js" import { buildLocalConnectMessage, buildRemoteConnectMessage, @@ -61,7 +61,7 @@ const askMode = async (prompts: Prompts): Promise => { * Returns the captured token string, or undefined when the user declines or * the capture fails (the caller falls back to a paste prompt). */ -const offerGetTokenCapture = async ( +const offerSyncTokenCapture = async ( prompts: Prompts, docker: DockerRunner, ): Promise => { @@ -330,7 +330,7 @@ const runLocalInit = async ( } // Remote flow (VPS + Obsidian Sync): resolve target dir → PUBLIC_URL → -// VAULT_NAME → Obsidian Sync token (optionally running get-token via +// 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. @@ -359,7 +359,7 @@ const runRemoteInit = async ( // the daemon is reachable. Falls back to a paste prompt when capture // fails or the user declines. const capturedToken = docker.isDaemonRunning() - ? await offerGetTokenCapture(prompts, docker) + ? await offerSyncTokenCapture(prompts, docker) : undefined // Masked prompt: the sync token is a credential and must not echo into // the terminal or scrollback. An empty submission still means "fill in diff --git a/cli/src/main.ts b/cli/src/main.ts index 4b28a77c..acc2b53a 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -1,5 +1,5 @@ import { createDockerRunner } from "./docker.js" -import { runGetToken } from "./get-token.js" +import { runGetSyncToken } from "./get-sync-token.js" import { runInit } from "./init.js" import { buildProgram } from "./program.js" import { createPrompts } from "./prompts.js" @@ -20,8 +20,8 @@ export const run = async (version: string): Promise => { docker: createDockerRunner(), fetchFn: fetch, }), - runGetToken: (flags) => - runGetToken(flags, { + runGetSyncToken: (flags) => + runGetSyncToken(flags, { prompts: createPrompts(), docker: createDockerRunner(), }), diff --git a/cli/src/program.ts b/cli/src/program.ts index a15d3fd5..0ffba15a 100644 --- a/cli/src/program.ts +++ b/cli/src/program.ts @@ -1,6 +1,6 @@ import { Command } from "commander" -import type { GetTokenFlags } from "./get-token.js" +import type { GetSyncTokenFlags } from "./get-sync-token.js" import type { InitFlags } from "./init.js" import type { UpgradeFlags } from "./upgrade.js" @@ -8,7 +8,7 @@ export type ProgramOptions = { version: string runInit: (flags: InitFlags) => Promise runUpgrade: (flags: UpgradeFlags) => Promise - runGetToken: (flags: GetTokenFlags) => Promise + runGetSyncToken: (flags: GetSyncTokenFlags) => Promise } export const buildProgram = (options: ProgramOptions): Command => { @@ -55,7 +55,7 @@ export const buildProgram = (options: ProgramOptions): Command => { }) program - .command("get-token") + .command("get-sync-token") .description( "Generate an Obsidian Sync auth token via Docker and print it or write it to .env", ) @@ -63,8 +63,8 @@ export const buildProgram = (options: ProgramOptions): Command => { "--dir ", "directory containing .env to update with the token", ) - .action(async (flags: GetTokenFlags) => { - process.exitCode = await options.runGetToken(flags) + .action(async (flags: GetSyncTokenFlags) => { + process.exitCode = await options.runGetSyncToken(flags) }) program.action(() => { diff --git a/deploy/remote/README.md b/deploy/remote/README.md index f70e5be4..88b25969 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -64,7 +64,7 @@ If you have Node.js >= 20.12 on this machine, the CLI captures the token automatically: ```bash -npx vault-cortex get-token +npx vault-cortex get-sync-token ``` Otherwise, run the Docker image directly: From cc166e9de138ba8c337c2544b876563a29e4c470 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:41:49 -0400 Subject: [PATCH 13/18] refactor(docker): rename and rewrite the rootfs token helper as get-sync-token The get-token script was the largest artifact inherited from the absorbed obsidian-headless-sync-docker fork (~98% upstream expression, and the upstream chain carries no license). Rewritten from scratch in our own words and renamed to match the CLI's get-sync-token subcommand: - canonical XDG token path checked first, find as the fallback - single -s guard covers missing path, missing file, and empty file - prints one copy-paste OBSIDIAN_AUTH_TOKEN= line instead of echo banners All references updated: Dockerfile (comments + chmod), init-check-auth hint, DEPLOY.md, ARCHITECTURE.md, AGENTS.md tree, root and deploy .env.example, deploy/remote/README.md, and the docker.ts docstring. Note: images published before this release only contain the old get-token entrypoint name. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- .env.example | 2 +- AGENTS.md | 2 +- ARCHITECTURE.md | 52 ++++++++-------- DEPLOY.md | 4 +- Dockerfile | 6 +- cli/src/docker.ts | 2 +- deploy/remote/.env.example | 2 +- deploy/remote/README.md | 2 +- rootfs/etc/s6-overlay/scripts/init-check-auth | 2 +- rootfs/usr/local/bin/get-sync-token | 46 ++++++++++++++ rootfs/usr/local/bin/get-token | 62 ------------------- 11 files changed, 83 insertions(+), 99 deletions(-) create mode 100755 rootfs/usr/local/bin/get-sync-token delete mode 100644 rootfs/usr/local/bin/get-token diff --git a/.env.example b/.env.example index 2b0f784e..921b21a3 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ PUBLIC_URL= # Obsidian Sync credentials # Generate OBSIDIAN_AUTH_TOKEN once with: -# docker run --rm -it --entrypoint get-token \ +# docker run --rm -it --entrypoint get-sync-token \ # ghcr.io/aliasunder/vault-cortex:remote OBSIDIAN_AUTH_TOKEN= VAULT_NAME= diff --git a/AGENTS.md b/AGENTS.md index 5b7da77b..05b8bf69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ obsidian-headless/ # Lockfile-pinned obsidian-headless for D package-lock.json # sha512 integrity hashes (supply-chain security) rootfs/ # Container filesystem overlay (remote target) etc/s6-overlay/ # init chain + svc-obsidian-sync + svc-vault-mcp - usr/local/bin/get-token # interactive Obsidian Sync token helper + usr/local/bin/get-sync-token # interactive Obsidian Sync token helper (manual flow) docker-compose.yml # Lightsail: single vault-cortex:remote service docker-compose.local.yml # Contributor dev: builds from source .env.example # template for Lightsail .env diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c4816d94..f3dc1015 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -731,29 +731,29 @@ Vault Cortex runs anywhere Docker does — the reference deployment uses Lightsa ## Key Decisions -| Decision | Rationale | -| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Lightsail over ECS | $12–24 vs ~$50+. Single-user server. | -| API Gateway over Caddy | Free HTTPS URL without a custom domain, SST native, and a Lambda authorizer for path-aware auth (OAuth endpoints pass through, `/mcp` validates). Tradeoff: 10-minute idle timeout on HTTP connections can cause `Connection closed` on first call after idle. | -| Obsidian Sync over git-based sync | Bidirectional real-time sync to all devices, automatic conflict resolution, no manual push/pull. Tradeoff: dependency on Obsidian's proprietary cloud service. | -| Single image over a separate sync container | The `:remote` target absorbs the [obsidian-headless-sync-docker fork](https://github.com/aliasunder/obsidian-headless-sync-docker)'s s6 scaffolding (`rootfs/`, including the `get-token` helper) and installs `obsidian-headless` from a sha512-pinned lockfile via `npm ci` — the fork's Alpine base can't host `onnxruntime-node` (musl), so building `FROM` it was never an option. One image means one repo, one CI, one version, and no Compose requirement for users (`docker run`/Podman/nerdctl all work). s6-overlay's static binaries run on glibc, and `DEVICE_NAME`-aware initial registration carries over from the fork. In the `:remote` target the two processes have shared fate through `/vault` — the MCP server without sync serves a stale vault; sync without the server serves nothing — so a single supervised container is the semantically honest packaging, not a convenience bundle. The `local` target has no sync process and stays single-process under tini. | -| OAuth 2.1 + static token | OAuth 2.1 (PKCE) for browser-capable clients — automatic token refresh, no secret in config after consent. Static bearer token for CLI tools and scripts where a browser flow isn't practical. Both validated at two independent layers (Lambda + Express) using the same HMAC key. | -| Custom JWT over JWT libraries | 50-line HS256 implementation vs 200KB+ library bundle. Lambda authorizer stays tiny. Constant-time comparison prevents timing attacks. Acceptable for a single-algorithm use case. | -| JWT over opaque tokens | Verifiable at Lambda edge without shared state. HS256 with MCP_AUTH_TOKEN. | -| 60-day sliding refresh | Active clients never re-auth; leaked tokens bounded. Standard OAuth practice. | -| Auto-snapshot (`addOn`) | Native Lightsail primitive over hand-rolled cron + S3. Daily, 7-day retention, captures full boot disk including SSH-installed state. | -| Pulumi `protect` + `retainOnDelete` | IaC seatbelt over `replaceOnChanges` gymnastics. Intentional replaces require explicit unprotect — the friction is the feature. | -| Debian slim over Alpine | `onnxruntime-node` (bundled by `@huggingface/transformers` for local embeddings) requires glibc. Alpine uses musl — no musl build exists. Hard architectural constraint, not a preference. | -| SQLite FTS5 | Zero services, embedded, personal scale. | -| sqlite-vec over pgvector/Pinecone | Vectors live alongside FTS5 in the same SQLite database — loaded as an extension into the same connection (`sqliteVec.load(db)`), not a separate datastore or service. No network hop, no second process, no API key. Keeps the "zero services, embedded, personal scale" principle established by FTS5. | -| chokidar | Node-native, same process as SQLite. Embedding hook for vector index updates. | -| Streamable HTTP | Current MCP spec (2025-11-25). SSE is deprecated. | -| 405 on `GET /mcp` (no standalone stream) | The server never sends server-initiated messages, so the optional GET-opened SSE stream would only ever sit idle until an upstream proxy timeout kills it — surfacing as gateway 5xx noise in monitoring. The Streamable HTTP spec explicitly allows servers that don't offer the stream to reject the GET with 405 (`Allow: POST, DELETE`). Clients fall back cleanly; POST responses still stream per request. | -| GHCR over ECR | GITHUB_TOKEN auth, no AWS IAM for images. | -| Express 5 over Fastify/Hono | Ecosystem maturity, middleware compatibility. Express 5's native async error handling eliminated wrapper boilerplate. MCP SDK reference implementation uses Express. | -| Atomic writes + per-file mutex | MCP handlers are concurrent — two tools could write the same file. Write-to-tmp-then-rename prevents partial writes; `link()` no-clobber (`atomicWriteFileExclusive`) closes the TOCTOU race on moves. Per-file mutex prevents conflicting operations: fail-fast for intent-based writes (patch/replace), serializing for read-inside-lock writes (memory append). Multi-file locking (`withExclusiveMultiFileLock`) covers moves, which must read and write the moved note plus every backlink source as one unit. (→ [Data Integrity](#data-integrity)) | -| Factory over class | Functional style. Closure holds db ref, no `this`. | -| `type` over `interface` | Uniform syntax — `type` handles unions, intersections, tuples, mapped types, and object shapes; `interface` only handles objects, so you'd need both anyway. No accidental declaration merging (interfaces with the same name silently merge — a library augmentation feature that's a footgun in application code). Negligible performance difference in practice. | -| Hybrid search over LightRAG | 30% of natural-language queries fail on FTS-only (vocabulary mismatch), but vector-only loses precision on exact terms and technical jargon where keyword matching excels. Hybrid keeps both strengths. LightRAG requires a ≥32B LLM for entity extraction — far too heavy for a VPS — and the vault's wikilinks already encode a hand-authored knowledge graph. [qmd](https://github.com/tobi/qmd) demonstrated how lightweight hybrid search can be: FTS5 + sqlite-vec + RRF in a single SQLite file, all application-layer code. vault-cortex applies the same patterns with lighter ONNX models ([bge-small-en-v1.5](https://huggingface.co/Xenova/bge-small-en-v1.5) 33M/~25MB vs [qmd](https://github.com/tobi/qmd)'s ~2GB GGUF stack). Opt-out via `EMBEDDING_ENABLED=false` — no model download, no vector tables — and graceful FTS-only fallback when vectors aren't available. | -| RRF fusion (k=60) | Merges FTS keyword and vector similarity ranked lists by rank position, not score — BM25 scores and cosine distances are on incomparable scales, so any score-based combination would need normalization. Top-rank bonuses (+0.05 rank 1, +0.02 ranks 2–3) reward results that either system placed highly. Validated at 8/9 on the vocabulary-mismatch evaluation, ~8ms added latency. Inspired by [qmd](https://github.com/tobi/qmd). | -| Position-aware blending over full reranker | RRF alone scored 8/9 — it bridged vocabulary gaps but couldn't resolve intent-heavy queries ("how I feel about AI tools") where both keyword and vector signals miss. A cross-encoder reranker fills that gap by scoring each (query, document) pair jointly, but pure reranker sort also scored 8/9 — it fixed the intent queries but over-prioritized topical relevance, demoting structurally correct results (TASKS.md) on task-oriented queries. Position-aware blending combines both: top RRF hits are protected (75% retrieval weight for ranks 1–3) while the reranker rescues demoted results at lower ranks (60% reranker weight for ranks 11+). The only approach that scored 9/9, 0 regressions, ~200ms added latency. Opt-out via `RERANK_MODE=none`. | +| Decision | Rationale | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Lightsail over ECS | $12–24 vs ~$50+. Single-user server. | +| API Gateway over Caddy | Free HTTPS URL without a custom domain, SST native, and a Lambda authorizer for path-aware auth (OAuth endpoints pass through, `/mcp` validates). Tradeoff: 10-minute idle timeout on HTTP connections can cause `Connection closed` on first call after idle. | +| Obsidian Sync over git-based sync | Bidirectional real-time sync to all devices, automatic conflict resolution, no manual push/pull. Tradeoff: dependency on Obsidian's proprietary cloud service. | +| Single image over a separate sync container | The `:remote` target absorbs the [obsidian-headless-sync-docker fork](https://github.com/aliasunder/obsidian-headless-sync-docker)'s s6 scaffolding (`rootfs/`, including the interactive Sync-token helper, since rewritten and renamed `get-sync-token`) and installs `obsidian-headless` from a sha512-pinned lockfile via `npm ci` — the fork's Alpine base can't host `onnxruntime-node` (musl), so building `FROM` it was never an option. One image means one repo, one CI, one version, and no Compose requirement for users (`docker run`/Podman/nerdctl all work). s6-overlay's static binaries run on glibc, and `DEVICE_NAME`-aware initial registration carries over from the fork. In the `:remote` target the two processes have shared fate through `/vault` — the MCP server without sync serves a stale vault; sync without the server serves nothing — so a single supervised container is the semantically honest packaging, not a convenience bundle. The `local` target has no sync process and stays single-process under tini. | +| OAuth 2.1 + static token | OAuth 2.1 (PKCE) for browser-capable clients — automatic token refresh, no secret in config after consent. Static bearer token for CLI tools and scripts where a browser flow isn't practical. Both validated at two independent layers (Lambda + Express) using the same HMAC key. | +| Custom JWT over JWT libraries | 50-line HS256 implementation vs 200KB+ library bundle. Lambda authorizer stays tiny. Constant-time comparison prevents timing attacks. Acceptable for a single-algorithm use case. | +| JWT over opaque tokens | Verifiable at Lambda edge without shared state. HS256 with MCP_AUTH_TOKEN. | +| 60-day sliding refresh | Active clients never re-auth; leaked tokens bounded. Standard OAuth practice. | +| Auto-snapshot (`addOn`) | Native Lightsail primitive over hand-rolled cron + S3. Daily, 7-day retention, captures full boot disk including SSH-installed state. | +| Pulumi `protect` + `retainOnDelete` | IaC seatbelt over `replaceOnChanges` gymnastics. Intentional replaces require explicit unprotect — the friction is the feature. | +| Debian slim over Alpine | `onnxruntime-node` (bundled by `@huggingface/transformers` for local embeddings) requires glibc. Alpine uses musl — no musl build exists. Hard architectural constraint, not a preference. | +| SQLite FTS5 | Zero services, embedded, personal scale. | +| sqlite-vec over pgvector/Pinecone | Vectors live alongside FTS5 in the same SQLite database — loaded as an extension into the same connection (`sqliteVec.load(db)`), not a separate datastore or service. No network hop, no second process, no API key. Keeps the "zero services, embedded, personal scale" principle established by FTS5. | +| chokidar | Node-native, same process as SQLite. Embedding hook for vector index updates. | +| Streamable HTTP | Current MCP spec (2025-11-25). SSE is deprecated. | +| 405 on `GET /mcp` (no standalone stream) | The server never sends server-initiated messages, so the optional GET-opened SSE stream would only ever sit idle until an upstream proxy timeout kills it — surfacing as gateway 5xx noise in monitoring. The Streamable HTTP spec explicitly allows servers that don't offer the stream to reject the GET with 405 (`Allow: POST, DELETE`). Clients fall back cleanly; POST responses still stream per request. | +| GHCR over ECR | GITHUB_TOKEN auth, no AWS IAM for images. | +| Express 5 over Fastify/Hono | Ecosystem maturity, middleware compatibility. Express 5's native async error handling eliminated wrapper boilerplate. MCP SDK reference implementation uses Express. | +| Atomic writes + per-file mutex | MCP handlers are concurrent — two tools could write the same file. Write-to-tmp-then-rename prevents partial writes; `link()` no-clobber (`atomicWriteFileExclusive`) closes the TOCTOU race on moves. Per-file mutex prevents conflicting operations: fail-fast for intent-based writes (patch/replace), serializing for read-inside-lock writes (memory append). Multi-file locking (`withExclusiveMultiFileLock`) covers moves, which must read and write the moved note plus every backlink source as one unit. (→ [Data Integrity](#data-integrity)) | +| Factory over class | Functional style. Closure holds db ref, no `this`. | +| `type` over `interface` | Uniform syntax — `type` handles unions, intersections, tuples, mapped types, and object shapes; `interface` only handles objects, so you'd need both anyway. No accidental declaration merging (interfaces with the same name silently merge — a library augmentation feature that's a footgun in application code). Negligible performance difference in practice. | +| Hybrid search over LightRAG | 30% of natural-language queries fail on FTS-only (vocabulary mismatch), but vector-only loses precision on exact terms and technical jargon where keyword matching excels. Hybrid keeps both strengths. LightRAG requires a ≥32B LLM for entity extraction — far too heavy for a VPS — and the vault's wikilinks already encode a hand-authored knowledge graph. [qmd](https://github.com/tobi/qmd) demonstrated how lightweight hybrid search can be: FTS5 + sqlite-vec + RRF in a single SQLite file, all application-layer code. vault-cortex applies the same patterns with lighter ONNX models ([bge-small-en-v1.5](https://huggingface.co/Xenova/bge-small-en-v1.5) 33M/~25MB vs [qmd](https://github.com/tobi/qmd)'s ~2GB GGUF stack). Opt-out via `EMBEDDING_ENABLED=false` — no model download, no vector tables — and graceful FTS-only fallback when vectors aren't available. | +| RRF fusion (k=60) | Merges FTS keyword and vector similarity ranked lists by rank position, not score — BM25 scores and cosine distances are on incomparable scales, so any score-based combination would need normalization. Top-rank bonuses (+0.05 rank 1, +0.02 ranks 2–3) reward results that either system placed highly. Validated at 8/9 on the vocabulary-mismatch evaluation, ~8ms added latency. Inspired by [qmd](https://github.com/tobi/qmd). | +| Position-aware blending over full reranker | RRF alone scored 8/9 — it bridged vocabulary gaps but couldn't resolve intent-heavy queries ("how I feel about AI tools") where both keyword and vector signals miss. A cross-encoder reranker fills that gap by scoring each (query, document) pair jointly, but pure reranker sort also scored 8/9 — it fixed the intent queries but over-prioritized topical relevance, demoting structurally correct results (TASKS.md) on task-oriented queries. Position-aware blending combines both: top RRF hits are protected (75% retrieval weight for ranks 1–3) while the reranker rescues demoted results at lower ranks (60% reranker weight for ranks 11+). The only approach that scored 9/9, 0 regressions, ~200ms added latency. Opt-out via `RERANK_MODE=none`. | diff --git a/DEPLOY.md b/DEPLOY.md index 41225fd4..be8de385 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -62,7 +62,7 @@ Then open `~/.config/vault-cortex/.env` and fill in the remaining values: The [`.env.example`](./.env.example) file also includes optional configuration for the embedding pipeline (`EMBEDDING_ENABLED`), the reranker (`RERANK_MODE`), the memory system (`MEMORY_ENABLED`, `MEMORY_DIR`, `PROTECTED_PATHS`, `ORPHAN_EXCLUDE_FOLDERS`), timezone (`TZ`), and OAuth metadata (`SERVICE_DOCUMENTATION_URL`). All have sensible defaults — see the [Configuration](./README.md#configuration) section in the README. ```bash -docker run --rm -it --entrypoint get-token ghcr.io/aliasunder/vault-cortex:remote +docker run --rm -it --entrypoint get-sync-token ghcr.io/aliasunder/vault-cortex:remote ``` **4. Authenticate to GHCR** (once per machine): @@ -280,7 +280,7 @@ To find your stage: `cat .sst/stage` (after your first deploy). | `GHCR_TOKEN` | Personal access token (classic) with `write:packages` + `read:packages`. Used by `docker login` both at build-push and on-instance pull. Persists across runs; rotate when stale. | | `DOCKERHUB_TOKEN` | Optional. Docker Hub access token with `Read & Write` repository permissions. Used by deploy (image push) and dockerhub-description (DOCKERHUB.md sync). Only needed when `DOCKERHUB_USERNAME` is set. | | `MCP_AUTH_TOKEN` | Same value as the SST secret of the same name. Written into the instance `.env` for the Express auth layer. | -| `OBSIDIAN_AUTH_TOKEN` | Output of `docker run --rm -it --entrypoint get-token ghcr.io/aliasunder/vault-cortex:remote`. | +| `OBSIDIAN_AUTH_TOKEN` | Output of `docker run --rm -it --entrypoint get-sync-token ghcr.io/aliasunder/vault-cortex:remote`. | | `VAULT_PASSWORD` | Optional. Only set if your vault uses end-to-end encryption. Empty value is fine and ships through to `.env` as `VAULT_PASSWORD=`. | | `SSH_PUBKEY` | Public key contents of your `~/.ssh/vault-cortex.pub` (literal, single line). Same key local dev and CI use — see [Prerequisites](#prerequisites). | | `SSH_PRIVATE_KEY` | Private half (`~/.ssh/vault-cortex`, full multi-line block including BEGIN/END markers). Loaded by `webfactory/ssh-agent` for SCP/SSH to the instance. | diff --git a/Dockerfile b/Dockerfile index 62b402e6..d6bef7a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,7 +74,7 @@ EXPOSE 8000 # --------------------------------------------------------------------------- # remote: s6-overlay supervises obsidian-sync + vault-mcp in one container. # Absorbed from aliasunder/obsidian-headless-sync-docker (rootfs/, including -# the get-token helper), adapted from Alpine to Debian. +# the interactive token helper, since rewritten as get-sync-token), adapted from Alpine to Debian. # --------------------------------------------------------------------------- FROM base AS remote @@ -140,9 +140,9 @@ RUN userdel -r node \ && chown -R obsidian:obsidian /vault /home/obsidian # s6 service definitions (init chain + svc-obsidian-sync + svc-vault-mcp) -# and the interactive get-token helper (rootfs/usr/local/bin/get-token). +# and the interactive get-sync-token helper (rootfs/usr/local/bin/get-sync-token). COPY rootfs/ / -RUN chmod +x /usr/local/bin/get-token \ +RUN chmod +x /usr/local/bin/get-sync-token \ && chmod +x /etc/s6-overlay/scripts/* \ && chmod +x /etc/s6-overlay/s6-rc.d/svc-obsidian-sync/run \ /etc/s6-overlay/s6-rc.d/svc-vault-mcp/run diff --git a/cli/src/docker.ts b/cli/src/docker.ts index 8e7f5870..1ac641b3 100644 --- a/cli/src/docker.ts +++ b/cli/src/docker.ts @@ -40,7 +40,7 @@ export type ObsidianLoginArgParams = { /** * 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-token script: the script's additions are locating and + * 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. diff --git a/deploy/remote/.env.example b/deploy/remote/.env.example index 15cc0e5a..008afb43 100644 --- a/deploy/remote/.env.example +++ b/deploy/remote/.env.example @@ -13,7 +13,7 @@ MCP_AUTH_TOKEN= PUBLIC_URL= # Obsidian Sync auth token. Generate once with: -# docker run --rm -it --entrypoint get-token \ +# docker run --rm -it --entrypoint get-sync-token \ # ghcr.io/aliasunder/vault-cortex:remote OBSIDIAN_AUTH_TOKEN= diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 88b25969..bd76395c 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -70,7 +70,7 @@ npx vault-cortex get-sync-token Otherwise, run the Docker image directly: ```bash -docker run --rm -it --entrypoint get-token \ +docker run --rm -it --entrypoint get-sync-token \ ghcr.io/aliasunder/vault-cortex:remote ``` diff --git a/rootfs/etc/s6-overlay/scripts/init-check-auth b/rootfs/etc/s6-overlay/scripts/init-check-auth index 7ac882fc..b0f5bdea 100644 --- a/rootfs/etc/s6-overlay/scripts/init-check-auth +++ b/rootfs/etc/s6-overlay/scripts/init-check-auth @@ -4,7 +4,7 @@ set -e if [ -z "$OBSIDIAN_AUTH_TOKEN" ]; then echo "[obsidian-sync] ERROR: OBSIDIAN_AUTH_TOKEN is not set." >&2 - echo "[obsidian-sync] Run: docker run --rm -it --entrypoint get-token " >&2 + echo "[obsidian-sync] Run: docker run --rm -it --entrypoint get-sync-token " >&2 exit 1 fi diff --git a/rootfs/usr/local/bin/get-sync-token b/rootfs/usr/local/bin/get-sync-token new file mode 100755 index 00000000..59dffa53 --- /dev/null +++ b/rootfs/usr/local/bin/get-sync-token @@ -0,0 +1,46 @@ +#!/bin/sh +# get-sync-token — interactive helper for the manual (no-CLI) flow: signs in +# to the user's Obsidian account and prints the Sync auth token for .env. +# +# Run with the s6 services bypassed: +# docker run --rm -it --entrypoint get-sync-token +# +# The npx CLI (`npx vault-cortex get-sync-token`) captures the token file +# through a volume mount instead and never runs this script. + +set -eu + +# The Dockerfile sets HOME=/home/obsidian; default it so the script also +# works outside the image (e.g. during development). +export HOME="${HOME:-/home/obsidian}" + +printf '%s\n' \ + "" \ + "Obsidian Sync token setup" \ + "Sign in with your Obsidian account — email, password, and MFA code if enabled." \ + "" + +ob login + +# obsidian-headless writes the token under XDG config; fall back to a +# filesystem search in case a future version relocates it. +token_file="${HOME}/.config/obsidian-headless/auth_token" +if [ ! -s "$token_file" ]; then + token_file="$(find "$HOME" -name auth_token -path '*obsidian-headless*' 2>/dev/null | head -n 1)" +fi + +# -s also rejects an empty path (the find found nothing) and an empty file. +if [ ! -s "${token_file}" ]; then + printf '%s\n' \ + "The login finished but no auth token file was found." \ + "Search for it manually with:" \ + " find ~ -name auth_token 2>/dev/null" >&2 + exit 1 +fi + +printf '%s\n' \ + "" \ + "Login complete. Copy this line into your .env:" \ + "" \ + " OBSIDIAN_AUTH_TOKEN=$(cat "$token_file")" \ + "" diff --git a/rootfs/usr/local/bin/get-token b/rootfs/usr/local/bin/get-token deleted file mode 100644 index a1fc5319..00000000 --- a/rootfs/usr/local/bin/get-token +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/sh -# Interactive helper: logs in to Obsidian and prints the resulting auth token. -# -# Usage (override entrypoint so s6-overlay services are not started): -# docker run --rm -it --entrypoint get-token - -set -e - -# HOME is set by the Dockerfile ENV; this fallback ensures the script works -# when run outside the container (e.g. during development). -export HOME="${HOME:-/home/obsidian}" - -echo "" -echo "=== obsidian-headless: Get Auth Token ===" -echo "" -echo "Log in to your Obsidian account." -echo "You will be prompted for your email, password, and MFA code (if enabled)." -echo "" - -ob login - -echo "" -echo "===========================================" - -# Locate the stored token file (fall back to a find if path differs) -TOKEN="" -CANDIDATES=" - ${HOME}/.config/obsidian-headless/auth_token - ${HOME}/.local/share/obsidian-headless/auth_token - ${HOME}/.obsidian-headless/auth_token -" - -for candidate in $CANDIDATES; do - if [ -f "$candidate" ]; then - TOKEN=$(cat "$candidate") - break - fi -done - -if [ -z "$TOKEN" ]; then - TOKEN_FILE=$(find "$HOME" -path "*obsidian-headless*" -name "auth_token" 2>/dev/null | head -1) - if [ -n "$TOKEN_FILE" ]; then - TOKEN=$(cat "$TOKEN_FILE") - fi -fi - -if [ -z "$TOKEN" ]; then - echo "Could not locate the auth token file automatically." >&2 - echo "Search manually with:" >&2 - echo " find ~ -path '*obsidian-headless*' 2>/dev/null" >&2 - exit 1 -fi - -echo "" -echo "Your OBSIDIAN_AUTH_TOKEN:" -echo "" -echo " $TOKEN" -echo "" -echo "Add this to your .env file:" -echo " OBSIDIAN_AUTH_TOKEN=$TOKEN" -echo "" -echo "===========================================" From 56971b42f2e04267d19d7ef61e7a205012116254 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:47:47 -0400 Subject: [PATCH 14/18] refactor(docker): rewrite the two inherited s6 init scripts in our own words init-check-auth and init-obsidian-login were the last byte-identical holdovers from the absorbed obsidian-headless-sync-docker fork (whose upstream chain carries no license). Rewritten with the same semantics: - init-check-auth: positive-first gate with early exit, grouped stderr block, and a fuller hint (generate with get-sync-token, add to .env) - init-obsidian-login: adds a stale-token hint on rejection and set -u With these plus the get-sync-token rewrite, no inherited upstream expression remains in rootfs/. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- rootfs/etc/s6-overlay/scripts/init-check-auth | 21 ++++++++++++------- .../s6-overlay/scripts/init-obsidian-login | 19 ++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/rootfs/etc/s6-overlay/scripts/init-check-auth b/rootfs/etc/s6-overlay/scripts/init-check-auth index b0f5bdea..a3d762df 100644 --- a/rootfs/etc/s6-overlay/scripts/init-check-auth +++ b/rootfs/etc/s6-overlay/scripts/init-check-auth @@ -1,11 +1,18 @@ #!/command/with-contenv sh -# Validate that OBSIDIAN_AUTH_TOKEN is set before proceeding. -set -e +# Init-chain gate: refuse to start when no Obsidian Sync auth token is +# configured — everything downstream (login, sync-setup) would fail with a +# far less obvious error. +set -eu -if [ -z "$OBSIDIAN_AUTH_TOKEN" ]; then - echo "[obsidian-sync] ERROR: OBSIDIAN_AUTH_TOKEN is not set." >&2 - echo "[obsidian-sync] Run: docker run --rm -it --entrypoint get-sync-token " >&2 - exit 1 +if [ -n "${OBSIDIAN_AUTH_TOKEN:-}" ]; then + echo "[obsidian-sync] Auth token present." + exit 0 fi -echo "[obsidian-sync] Auth token present." +{ + echo "[obsidian-sync] ERROR: OBSIDIAN_AUTH_TOKEN is empty or unset." + echo "[obsidian-sync] Generate one with:" + echo "[obsidian-sync] docker run --rm -it --entrypoint get-sync-token " + echo "[obsidian-sync] then add it to your .env and restart the container." +} >&2 +exit 1 diff --git a/rootfs/etc/s6-overlay/scripts/init-obsidian-login b/rootfs/etc/s6-overlay/scripts/init-obsidian-login index 0b0ccc05..b12483c7 100644 --- a/rootfs/etc/s6-overlay/scripts/init-obsidian-login +++ b/rootfs/etc/s6-overlay/scripts/init-obsidian-login @@ -1,11 +1,14 @@ #!/command/with-contenv sh -# Authenticate with Obsidian using the provided auth token. -set -e +# Init-chain step: log obsidian-headless in to the Obsidian account, using +# the OBSIDIAN_AUTH_TOKEN provided via the environment (validated by +# init-check-auth, which runs before this step). Runs as the obsidian user +# so the stored credentials are owned by the service account. +set -eu -echo "[obsidian-sync] Logging in to Obsidian..." -if ! s6-setuidgid obsidian ob login; then - echo "[obsidian-sync] ERROR: ob login failed." >&2 +echo "[obsidian-sync] Authenticating with Obsidian..." +s6-setuidgid obsidian ob login || { + echo "[obsidian-sync] ERROR: login was rejected — the auth token may be" >&2 + echo "[obsidian-sync] stale. Generate a fresh one with get-sync-token." >&2 exit 1 -fi - -echo "[obsidian-sync] Login successful." +} +echo "[obsidian-sync] Authenticated." From f027ba20fbf5a7fc8bca3abd1097e7e72c75d6dd Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:59:02 -0400 Subject: [PATCH 15/18] docs(deploy): reference the CLI for Sync-token generation in DEPLOY.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEPLOY.md readers run SST, so Node.js is already a prerequisite — the CLI is the natural primary path, with the docker run one-liner kept as the direct-image alternative. Matches deploy/remote/README.md's CLI-first pattern. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- DEPLOY.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/DEPLOY.md b/DEPLOY.md index be8de385..9497c909 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -61,6 +61,12 @@ Then open `~/.config/vault-cortex/.env` and fill in the remaining values: The [`.env.example`](./.env.example) file also includes optional configuration for the embedding pipeline (`EMBEDDING_ENABLED`), the reranker (`RERANK_MODE`), the memory system (`MEMORY_ENABLED`, `MEMORY_DIR`, `PROTECTED_PATHS`, `ORPHAN_EXCLUDE_FOLDERS`), timezone (`TZ`), and OAuth metadata (`SERVICE_DOCUMENTATION_URL`). All have sensible defaults — see the [Configuration](./README.md#configuration) section in the README. +```bash +npx vault-cortex get-sync-token +``` + +Or run the Docker image directly: + ```bash docker run --rm -it --entrypoint get-sync-token ghcr.io/aliasunder/vault-cortex:remote ``` @@ -280,7 +286,7 @@ To find your stage: `cat .sst/stage` (after your first deploy). | `GHCR_TOKEN` | Personal access token (classic) with `write:packages` + `read:packages`. Used by `docker login` both at build-push and on-instance pull. Persists across runs; rotate when stale. | | `DOCKERHUB_TOKEN` | Optional. Docker Hub access token with `Read & Write` repository permissions. Used by deploy (image push) and dockerhub-description (DOCKERHUB.md sync). Only needed when `DOCKERHUB_USERNAME` is set. | | `MCP_AUTH_TOKEN` | Same value as the SST secret of the same name. Written into the instance `.env` for the Express auth layer. | -| `OBSIDIAN_AUTH_TOKEN` | Output of `docker run --rm -it --entrypoint get-sync-token ghcr.io/aliasunder/vault-cortex:remote`. | +| `OBSIDIAN_AUTH_TOKEN` | Output of `npx vault-cortex get-sync-token` — see [One-time setup](#one-time-setup). | | `VAULT_PASSWORD` | Optional. Only set if your vault uses end-to-end encryption. Empty value is fine and ships through to `.env` as `VAULT_PASSWORD=`. | | `SSH_PUBKEY` | Public key contents of your `~/.ssh/vault-cortex.pub` (literal, single line). Same key local dev and CI use — see [Prerequisites](#prerequisites). | | `SSH_PRIVATE_KEY` | Private half (`~/.ssh/vault-cortex`, full multi-line block including BEGIN/END markers). Loaded by `webfactory/ssh-agent` for SCP/SSH to the instance. | From 755885d741d8135db38ee1dabaf41d9b6602f12a Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:09:38 -0400 Subject: [PATCH 16/18] docs(cli): reword token-capture copy in user-facing messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Captured automatically' described the implementation, not the user experience. The handoff message, failure warnings, and both READMEs now say what the user sees: sign in, and vault-cortex picks up the token itself — nothing to find or copy. Internal docstrings keep the capture vocabulary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/README.md | 7 ++++--- cli/src/__tests__/get-sync-token.test.ts | 11 ++++++----- cli/src/get-sync-token.ts | 11 ++++++----- deploy/remote/README.md | 4 ++-- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/cli/README.md b/cli/README.md index fe72dd10..15871ad5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -36,9 +36,10 @@ Generate an Obsidian Sync auth token without leaving the CLI: npx vault-cortex get-sync-token ``` -The command runs the Obsidian login flow inside Docker and captures the -resulting token automatically — no manual paste needed. Use `--dir` to -write the token directly to an existing `.env`: +The command opens the Obsidian login inside Docker. Once you've signed +in, it picks up 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: ```bash npx vault-cortex get-sync-token --dir ./vault-cortex diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 005a6ea6..17ec08c9 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -133,7 +133,7 @@ describe("captureObsidianToken", () => { expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( - "The Obsidian login completed but no token was captured — the token file " + + "The Obsidian login finished, but the token it should have saved " + "was missing, empty, or unreadable. You can retry with:\n" + " npx vault-cortex get-sync-token", ) @@ -154,7 +154,7 @@ describe("captureObsidianToken", () => { expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( - "The Obsidian login completed but no token was captured — the token file " + + "The Obsidian login finished, but the token it should have saved " + "was missing, empty, or unreadable. You can retry with:\n" + " npx vault-cortex get-sync-token", ) @@ -211,8 +211,9 @@ describe("captureObsidianToken", () => { expect(silent.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, so there's nothing to copy.", + "account email, password, and MFA code. Once you've signed in, " + + "vault-cortex picks up the token itself — you won't need to find " + + "or copy it.", ) }) }) @@ -255,7 +256,7 @@ describe("runGetSyncToken subcommand", () => { ) expect(exitCode).toBe(1) - expect(silent.errors[0]).toBe("Could not capture the auth token.") + expect(silent.errors[0]).toBe("Could not retrieve the auth token.") }) it("writes the token to .env when --dir is set", async () => { diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index 88fcbd65..76d72d77 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -29,7 +29,7 @@ const makeTempMountDir = (prompts: Prompts): string | undefined => { return mkdtempSync(join(tmpdir(), "vault-cortex-sync-token-")) } catch (error) { prompts.warn( - `Could not create a temp directory for token capture — ${describeError(error)}`, + `Could not create a temp directory for the login — ${describeError(error)}`, ) return undefined } @@ -108,8 +108,9 @@ export const captureObsidianToken = ( try { prompts.log( "Handing the terminal to the Obsidian login — it will ask for your " + - "account email, password, and MFA code. The token is captured " + - "automatically, so there's nothing to copy.", + "account email, password, and MFA code. Once you've signed in, " + + "vault-cortex picks up the token itself — you won't need to find " + + "or copy it.", ) const loginSucceeded = runLoginContainer(configMountPath, deps) if (!loginSucceeded) { @@ -122,7 +123,7 @@ export const captureObsidianToken = ( const token = readCapturedTokenFile(configMountPath) if (!token) { prompts.warn( - "The Obsidian login completed but no token was captured — the token file " + + "The Obsidian login finished, but the token it should have saved " + "was missing, empty, or unreadable. You can retry with:\n" + " npx vault-cortex get-sync-token", ) @@ -157,7 +158,7 @@ export const runGetSyncToken = async ( const token = captureObsidianToken({ docker, prompts }) if (!token) { - prompts.error("Could not capture the auth token.") + prompts.error("Could not retrieve the auth token.") return 1 } diff --git a/deploy/remote/README.md b/deploy/remote/README.md index bd76395c..bfd09536 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -60,8 +60,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 captures the token -automatically: +If you have Node.js >= 20.12 on this machine, the CLI runs the login and +picks up the token for you: ```bash npx vault-cortex get-sync-token From 9b08e1f79dfec5a9e6ae3e2835732d4c9bb7b7bd Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:15:24 -0400 Subject: [PATCH 17/18] feat(cli): state the token destination in the login handoff message 'Captured automatically' alone didn't tell the user what actually happens to the token. Each flow now supplies its destination to the shared handoff message: init says it is stored in your .env, the bare subcommand says it is printed at the end, and --dir names the .env file it writes to. Tests assert each flow's exact message. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/README.md | 2 +- cli/src/__tests__/get-sync-token.test.ts | 106 +++++++++++++++-------- cli/src/__tests__/init.test.ts | 5 ++ cli/src/get-sync-token.ts | 36 +++++--- cli/src/init.ts | 5 +- deploy/remote/README.md | 2 +- 6 files changed, 105 insertions(+), 51 deletions(-) diff --git a/cli/README.md b/cli/README.md index 15871ad5..d046fe49 100644 --- a/cli/README.md +++ b/cli/README.md @@ -37,7 +37,7 @@ npx vault-cortex get-sync-token ``` The command opens the Obsidian login inside Docker. Once you've signed -in, it picks up your token and prints it — nothing to dig out of the +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: diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 17ec08c9..86d651dd 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -89,10 +89,13 @@ describe("captureObsidianToken", () => { it("returns the token when the login writes the auth_token file", () => { const { prompts } = createSilentPrompts() - const token = captureObsidianToken({ - docker: dockerWithToken("abc123-sync-token"), - prompts, - }) + const token = captureObsidianToken( + { + docker: dockerWithToken("abc123-sync-token"), + prompts, + }, + "Token destination note.", + ) expect(token).toBe("abc123-sync-token") }) @@ -100,10 +103,13 @@ describe("captureObsidianToken", () => { it("trims whitespace from the token file", () => { const { prompts } = createSilentPrompts() - const token = captureObsidianToken({ - docker: dockerWithToken(" token-with-whitespace \n"), - prompts, - }) + const token = captureObsidianToken( + { + docker: dockerWithToken(" token-with-whitespace \n"), + prompts, + }, + "Token destination note.", + ) expect(token).toBe("token-with-whitespace") }) @@ -111,10 +117,13 @@ describe("captureObsidianToken", () => { it("returns undefined when docker run fails", () => { const silent = createSilentPrompts() - const token = captureObsidianToken({ - docker: dockerFailsLogin, - prompts: silent.prompts, - }) + const token = captureObsidianToken( + { + docker: dockerFailsLogin, + prompts: silent.prompts, + }, + "Token destination note.", + ) expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( @@ -126,15 +135,18 @@ describe("captureObsidianToken", () => { it("returns undefined and warns when the token file is empty", () => { const silent = createSilentPrompts() - const token = captureObsidianToken({ - docker: dockerWithToken(""), - prompts: silent.prompts, - }) + const token = captureObsidianToken( + { + docker: dockerWithToken(""), + prompts: silent.prompts, + }, + "Token destination note.", + ) expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( - "The Obsidian login finished, but the token it should have saved " + - "was missing, empty, or unreadable. You can retry with:\n" + + "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 get-sync-token", ) }) @@ -147,15 +159,18 @@ describe("captureObsidianToken", () => { runObsidianLogin: () => true, } - const token = captureObsidianToken({ - docker: dockerSucceedsButNoFile, - prompts: silent.prompts, - }) + const token = captureObsidianToken( + { + docker: dockerSucceedsButNoFile, + prompts: silent.prompts, + }, + "Token destination note.", + ) expect(token).toBeUndefined() expect(silent.warnings[0]).toBe( - "The Obsidian login finished, but the token it should have saved " + - "was missing, empty, or unreadable. You can retry with:\n" + + "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 get-sync-token", ) }) @@ -170,10 +185,13 @@ describe("captureObsidianToken", () => { }, } - const token = captureObsidianToken({ - docker: dockerThrows, - prompts: silent.prompts, - }) + const token = captureObsidianToken( + { + docker: dockerThrows, + prompts: silent.prompts, + }, + "Token destination note.", + ) expect(token).toBeUndefined() expect(silent.warnings).toEqual([ @@ -195,7 +213,10 @@ describe("captureObsidianToken", () => { }, } - captureObsidianToken({ docker: dockerTracker, prompts }) + captureObsidianToken( + { docker: dockerTracker, prompts }, + "Token destination note.", + ) expect(tempDirs).toHaveLength(1) expect(existsSync(tempDirs[0])).toBe(false) @@ -204,16 +225,17 @@ describe("captureObsidianToken", () => { it("logs the handoff message before running docker", () => { const silent = createSilentPrompts() - captureObsidianToken({ - docker: dockerWithToken("token"), - prompts: silent.prompts, - }) + captureObsidianToken( + { + docker: dockerWithToken("token"), + prompts: silent.prompts, + }, + "Token destination note.", + ) expect(silent.logs[0]).toBe( "Handing the terminal to the Obsidian login — it will ask for your " + - "account email, password, and MFA code. Once you've signed in, " + - "vault-cortex picks up the token itself — you won't need to find " + - "or copy it.", + "account email, password, and MFA code. Token destination note.", ) }) }) @@ -228,6 +250,11 @@ describe("runGetSyncToken subcommand", () => { ) expect(exitCode).toBe(0) + expect(silent.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(silent.logs).toContain("Your OBSIDIAN_AUTH_TOKEN:") expect(silent.prints).toEqual(["\n my-sync-token\n"]) }) @@ -256,7 +283,7 @@ describe("runGetSyncToken subcommand", () => { ) expect(exitCode).toBe(1) - expect(silent.errors[0]).toBe("Could not retrieve the auth token.") + expect(silent.errors[0]).toBe("Could not capture the auth token.") }) it("writes the token to .env when --dir is set", async () => { @@ -273,6 +300,11 @@ describe("runGetSyncToken subcommand", () => { ) expect(exitCode).toBe(0) + expect(silent.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", ) diff --git a/cli/src/__tests__/init.test.ts b/cli/src/__tests__/init.test.ts index 65b12f7d..a9f4dc91 100644 --- a/cli/src/__tests__/init.test.ts +++ b/cli/src/__tests__/init.test.ts @@ -611,6 +611,11 @@ describe("runInit remote flow", () => { ) 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):", ) diff --git a/cli/src/get-sync-token.ts b/cli/src/get-sync-token.ts index 76d72d77..2e1d00aa 100644 --- a/cli/src/get-sync-token.ts +++ b/cli/src/get-sync-token.ts @@ -29,7 +29,7 @@ const makeTempMountDir = (prompts: Prompts): string | undefined => { return mkdtempSync(join(tmpdir(), "vault-cortex-sync-token-")) } catch (error) { prompts.warn( - `Could not create a temp directory for the login — ${describeError(error)}`, + `Could not create a temp directory for token capture — ${describeError(error)}`, ) return undefined } @@ -93,6 +93,11 @@ const removeTempMountDir = ( * 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 @@ -100,6 +105,7 @@ const removeTempMountDir = ( */ export const captureObsidianToken = ( deps: GetSyncTokenDeps, + tokenDestinationMessage: string, ): string | undefined => { const { prompts } = deps const configMountPath = makeTempMountDir(prompts) @@ -108,9 +114,7 @@ export const captureObsidianToken = ( try { prompts.log( "Handing the terminal to the Obsidian login — it will ask for your " + - "account email, password, and MFA code. Once you've signed in, " + - "vault-cortex picks up the token itself — you won't need to find " + - "or copy it.", + `account email, password, and MFA code. ${tokenDestinationMessage}`, ) const loginSucceeded = runLoginContainer(configMountPath, deps) if (!loginSucceeded) { @@ -123,8 +127,8 @@ export const captureObsidianToken = ( const token = readCapturedTokenFile(configMountPath) if (!token) { prompts.warn( - "The Obsidian login finished, but the token it should have saved " + - "was missing, empty, or unreadable. You can retry with:\n" + + "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 get-sync-token", ) return undefined @@ -156,21 +160,31 @@ export const runGetSyncToken = async ( prompts.intro("vault-cortex get-sync-token") - const token = captureObsidianToken({ docker, prompts }) + // 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, + ) if (!token) { - prompts.error("Could not retrieve the auth token.") + prompts.error("Could not capture the auth token.") return 1 } - if (!flags.dir) { + if (!envFilePath) { prompts.log("Your OBSIDIAN_AUTH_TOKEN:") prompts.print(`\n ${token}\n`) prompts.outro("Done.") return 0 } - const targetDir = resolve(expandTilde(flags.dir)) - const envFilePath = join(targetDir, ".env") const patched = patchEnvObsidianToken(envFilePath, token) if (!patched) { prompts.error( diff --git a/cli/src/init.ts b/cli/src/init.ts index 1bac7c46..06d52a86 100644 --- a/cli/src/init.ts +++ b/cli/src/init.ts @@ -67,7 +67,10 @@ const offerSyncTokenCapture = async ( ): Promise => { const runNow = await prompts.confirm("Generate the token now?", true) if (!runNow) return undefined - return captureObsidianToken({ docker, prompts }) + return captureObsidianToken( + { docker, prompts }, + "The token is captured automatically and stored in your .env — nothing to copy.", + ) } /** diff --git a/deploy/remote/README.md b/deploy/remote/README.md index bfd09536..fe1e3ad4 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -61,7 +61,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 runs the login and -picks up the token for you: +captures the token for you: ```bash npx vault-cortex get-sync-token From 36a39b73a9831c9997154548ed38ffbd656623a9 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:17:07 -0400 Subject: [PATCH 18/18] test(cli): replace awkward token-destination fixture string with a realistic constant Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0117h7grQjdkAMGD38vCfZGk --- cli/src/__tests__/get-sync-token.test.ts | 25 +++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/cli/src/__tests__/get-sync-token.test.ts b/cli/src/__tests__/get-sync-token.test.ts index 86d651dd..d54ee8fd 100644 --- a/cli/src/__tests__/get-sync-token.test.ts +++ b/cli/src/__tests__/get-sync-token.test.ts @@ -13,6 +13,13 @@ import { captureObsidianToken, runGetSyncToken } from "../get-sync-token.js" import type { DockerRunner } from "../docker.js" import type { Prompts } from "../prompts.js" +/** + * 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). + */ +const TOKEN_DESTINATION_MESSAGE = "The token is captured automatically." + const createSilentPrompts = () => { const errors: string[] = [] const warnings: string[] = [] @@ -94,7 +101,7 @@ describe("captureObsidianToken", () => { docker: dockerWithToken("abc123-sync-token"), prompts, }, - "Token destination note.", + TOKEN_DESTINATION_MESSAGE, ) expect(token).toBe("abc123-sync-token") @@ -108,7 +115,7 @@ describe("captureObsidianToken", () => { docker: dockerWithToken(" token-with-whitespace \n"), prompts, }, - "Token destination note.", + TOKEN_DESTINATION_MESSAGE, ) expect(token).toBe("token-with-whitespace") @@ -122,7 +129,7 @@ describe("captureObsidianToken", () => { docker: dockerFailsLogin, prompts: silent.prompts, }, - "Token destination note.", + TOKEN_DESTINATION_MESSAGE, ) expect(token).toBeUndefined() @@ -140,7 +147,7 @@ describe("captureObsidianToken", () => { docker: dockerWithToken(""), prompts: silent.prompts, }, - "Token destination note.", + TOKEN_DESTINATION_MESSAGE, ) expect(token).toBeUndefined() @@ -164,7 +171,7 @@ describe("captureObsidianToken", () => { docker: dockerSucceedsButNoFile, prompts: silent.prompts, }, - "Token destination note.", + TOKEN_DESTINATION_MESSAGE, ) expect(token).toBeUndefined() @@ -190,7 +197,7 @@ describe("captureObsidianToken", () => { docker: dockerThrows, prompts: silent.prompts, }, - "Token destination note.", + TOKEN_DESTINATION_MESSAGE, ) expect(token).toBeUndefined() @@ -215,7 +222,7 @@ describe("captureObsidianToken", () => { captureObsidianToken( { docker: dockerTracker, prompts }, - "Token destination note.", + TOKEN_DESTINATION_MESSAGE, ) expect(tempDirs).toHaveLength(1) @@ -230,12 +237,12 @@ describe("captureObsidianToken", () => { docker: dockerWithToken("token"), prompts: silent.prompts, }, - "Token destination note.", + TOKEN_DESTINATION_MESSAGE, ) expect(silent.logs[0]).toBe( "Handing the terminal to the Obsidian login — it will ask for your " + - "account email, password, and MFA code. Token destination note.", + "account email, password, and MFA code. The token is captured automatically.", ) }) })