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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
55 changes: 55 additions & 0 deletions cli/src/__tests__/docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"

import {
buildDockerRunArgs,
buildGetTokenArgs,
CONTAINER_NAME,
LOCAL_IMAGE,
pollHealth,
Expand Down Expand Up @@ -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<Response> => okResponse
Expand Down
5 changes: 1 addition & 4 deletions cli/src/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest"

import { REMOTE_IMAGE } from "../docker.js"
import { buildLocalEnv, buildRemoteEnv } from "../env.js"

describe("buildLocalEnv", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
267 changes: 267 additions & 0 deletions cli/src/__tests__/get-token.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading
Loading