feat: add Obsidian CLI API service
Build and Push Docker Container / build-and-push (push) Successful in 4m26s
Build and Push Docker Container / build-and-push (push) Successful in 4m26s
- Add Docker image for the official Obsidian desktop app and CLI. - Start Obsidian headlessly with Xvfb and DBus setup. - Expose a safe structured HTTP command API without shell execution. - Add JWT-based vault, path, and command authorization. - Support single-vault and multi-vault container mounts. - Add TypeScript SDK helpers for Obsidian CLI commands. - Add n8n community node package with Obsidian operations. - Add docs, compose config, tests, and production image workflow.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create an HS256 JWT for the Obsidian API.
|
||||
|
||||
Example:
|
||||
OBSIDIAN_JWT_SECRET=change-this-long-random-secret \
|
||||
python scripts/create_jwt.py \
|
||||
--sub n8n \
|
||||
--vault work \
|
||||
--path Inbox/n8n/ \
|
||||
--command create --command read --command append \
|
||||
--days 365
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse, hashlib, base64
|
||||
import json, hmac, time, os
|
||||
from typing import Any
|
||||
|
||||
def b64url(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode().rstrip("=")
|
||||
|
||||
def sign_jwt(claims: dict[str, Any], secret: bytes) -> str:
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
|
||||
payload_b64 = b64url(json.dumps(claims, separators=(",", ":")).encode())
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||
signature = hmac.new(secret, signing_input, hashlib.sha256).digest()
|
||||
return f"{header_b64}.{payload_b64}.{b64url(signature)}"
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Create an HS256 JWT for obsidian-api")
|
||||
parser.add_argument("--secret", help="JWT signing secret. Defaults to OBSIDIAN_JWT_SECRET env.")
|
||||
parser.add_argument("--sub", required=True, help="Subject/name for the token, e.g. n8n")
|
||||
parser.add_argument("--name", help="Display name claim")
|
||||
parser.add_argument("--vault", action="append", dest="vaults", default=[], help="Allowed vault. Repeatable. Default: *")
|
||||
parser.add_argument("--path", action="append", dest="paths", default=[], help="Allowed path/prefix. Repeatable. Default: *")
|
||||
parser.add_argument("--command", action="append", dest="commands", default=[], help="Allowed CLI command. Repeatable. Default: *")
|
||||
parser.add_argument("--days", type=int, default=365, help="Days until expiry")
|
||||
parser.add_argument("--claim", action="append", default=[], help="Extra claim as key=value. Repeatable.")
|
||||
args = parser.parse_args()
|
||||
|
||||
secret = args.secret or os.getenv("OBSIDIAN_JWT_SECRET", "")
|
||||
if not secret:
|
||||
raise SystemExit("Missing JWT secret. Set OBSIDIAN_JWT_SECRET or pass --secret.")
|
||||
|
||||
now = int(time.time())
|
||||
claims: dict[str, Any] = {
|
||||
"sub": args.sub,
|
||||
"iat": now,
|
||||
"exp": now + args.days * 24 * 60 * 60,
|
||||
"vaults": args.vaults or ["*"],
|
||||
"paths": args.paths or ["*"],
|
||||
"commands": args.commands or ["*"],
|
||||
}
|
||||
if args.name:
|
||||
claims["name"] = args.name
|
||||
|
||||
for item in args.claim:
|
||||
if "=" not in item:
|
||||
raise SystemExit(f"Invalid --claim {item!r}; expected key=value")
|
||||
key, value = item.split("=", 1)
|
||||
claims[key] = value
|
||||
|
||||
print(sign_jwt(claims, secret.encode()))
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
mkdir -p "$XDG_RUNTIME_DIR" /root/.config /root/.config/obsidian /root/.cache /root/.local/share /run/dbus
|
||||
chmod 700 "$XDG_RUNTIME_DIR"
|
||||
|
||||
# Electron apps are happier with a DBus session, even under Xvfb.
|
||||
if command -v dbus-launch >/dev/null 2>&1; then
|
||||
eval "$(dbus-launch --sh-syntax)"
|
||||
export DBUS_SESSION_BUS_ADDRESS DBUS_SESSION_BUS_PID
|
||||
fi
|
||||
|
||||
# Enable the official Obsidian CLI and register mounted vaults in global desktop-app settings.
|
||||
# This is the same CLI setting toggled by Settings > General > Advanced > Command line interface.
|
||||
DEFAULT_VAULT_PATH="$(python /app/scripts/render_config.py | tail -n 1)"
|
||||
|
||||
# Start a virtual X server for the official Obsidian desktop app.
|
||||
Xvfb "$DISPLAY" -screen 0 "${XVFB_SCREEN:-1280x1024x24}" -nolisten tcp >/tmp/xvfb.log 2>&1 &
|
||||
XVFB_PID=$!
|
||||
|
||||
cleanup() {
|
||||
if [ -n "${OBSIDIAN_PID:-}" ] && kill -0 "$OBSIDIAN_PID" 2>/dev/null; then
|
||||
kill "$OBSIDIAN_PID" 2>/dev/null || true
|
||||
fi
|
||||
kill "$XVFB_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup INT TERM EXIT
|
||||
|
||||
# Open the mounted vault using the official desktop app. The official CLI talks
|
||||
# to this running app through $XDG_RUNTIME_DIR/.obsidian-cli.sock.
|
||||
/opt/Obsidian/obsidian \
|
||||
--no-sandbox \
|
||||
--disable-gpu \
|
||||
--disable-software-rasterizer \
|
||||
--disable-dev-shm-usage \
|
||||
--disable-features=UseOzonePlatform \
|
||||
"${DEFAULT_VAULT_PATH}" \
|
||||
>/tmp/obsidian.log 2>&1 &
|
||||
OBSIDIAN_PID=$!
|
||||
|
||||
SOCKET="$XDG_RUNTIME_DIR/.obsidian-cli.sock"
|
||||
WAIT_SECONDS="${OBSIDIAN_STARTUP_TIMEOUT:-60}"
|
||||
STARTED=0
|
||||
while [ "$STARTED" -lt "$WAIT_SECONDS" ]; do
|
||||
if [ -S "$SOCKET" ]; then
|
||||
break
|
||||
fi
|
||||
STARTED=$((STARTED + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ ! -S "$SOCKET" ]; then
|
||||
echo "WARNING: Official Obsidian CLI socket was not created at $SOCKET after ${WAIT_SECONDS}s." >&2
|
||||
echo "The HTTP API will still start, but /commands may fail until Obsidian exposes the CLI socket." >&2
|
||||
echo "Obsidian log tail:" >&2
|
||||
tail -80 /tmp/obsidian.log >&2 || true
|
||||
fi
|
||||
|
||||
exec uv run python -m app.main
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the global Obsidian desktop config (~/.config/obsidian/obsidian.json).
|
||||
|
||||
Enables the official CLI and registers every mounted vault so the official
|
||||
`obsidian` CLI can target them with `vault=<name>`.
|
||||
|
||||
Vaults are read from the environment:
|
||||
|
||||
* ``OBSIDIAN_VAULTS`` (preferred, multi-vault):
|
||||
- JSON object: {"work": "/vaults/work", "personal": "/vaults/personal"}
|
||||
- or pairs: work=/vaults/work,personal=/vaults/personal
|
||||
* ``OBSIDIAN_VAULT_PATH`` (single-vault fallback): /vault
|
||||
|
||||
The first registered vault becomes the default (highest ``ts``), which is the
|
||||
vault used when a CLI command omits ``vault=<name>``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def _parse_vaults() -> "list[tuple[str, str]]":
|
||||
raw = os.environ.get("OBSIDIAN_VAULTS", "").strip()
|
||||
pairs: list[tuple[str, str]] = []
|
||||
|
||||
if raw:
|
||||
# Try JSON object first.
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, dict):
|
||||
return [(str(name), str(path)) for name, path in data.items()]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fall back to comma/newline separated name=path pairs.
|
||||
for chunk in raw.replace("\n", ",").split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
if "=" not in chunk:
|
||||
print(f"WARNING: ignoring malformed OBSIDIAN_VAULTS entry: {chunk!r}", file=sys.stderr)
|
||||
continue
|
||||
name, path = chunk.split("=", 1)
|
||||
pairs.append((name.strip(), path.strip()))
|
||||
|
||||
if not pairs:
|
||||
single = os.environ.get("OBSIDIAN_VAULT_PATH", "/vault").strip() or "/vault"
|
||||
name = Path(single).name or "vault"
|
||||
pairs.append((name, single))
|
||||
|
||||
return pairs
|
||||
|
||||
def main() -> int:
|
||||
pairs = _parse_vaults()
|
||||
|
||||
vaults: dict[str, dict] = {}
|
||||
# Descending timestamps so the first declared vault sorts as most-recent
|
||||
# (the desktop app treats the highest ts as the active/default vault).
|
||||
base_ts = 1893456000000
|
||||
for index, (name, path) in enumerate(pairs):
|
||||
vaults[name] = {
|
||||
"path": path,
|
||||
"ts": base_ts - index,
|
||||
"open": True,
|
||||
}
|
||||
|
||||
config = {"cli": True, "vaults": vaults}
|
||||
|
||||
config_path = Path(os.environ.get("OBSIDIAN_CONFIG_PATH", "/root/.config/obsidian/obsidian.json"))
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
names = ", ".join(f"{name} -> {data['path']}" for name, data in vaults.items())
|
||||
print(f"Registered {len(vaults)} vault(s): {names}")
|
||||
# First declared vault is the default for commands without vault=<name>.
|
||||
print(pairs[0][1]) # default vault path, consumed by the entrypoint
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user