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,13 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.env
|
||||||
|
vault
|
||||||
|
*.pyc
|
||||||
|
node_modules
|
||||||
|
sdk/typescript/node_modules
|
||||||
|
sdk/typescript/dist
|
||||||
|
packages/*/node_modules
|
||||||
|
packages/*/dist
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Copy to .env and adjust.
|
||||||
|
|
||||||
|
# JWT signing secret. Use the same value to create JWTs with scripts/create_jwt.py.
|
||||||
|
OBSIDIAN_JWT_SECRET=change-this-long-random-secret
|
||||||
|
|
||||||
|
# Host path to your default/single Obsidian vault.
|
||||||
|
# Examples:
|
||||||
|
# OBSIDIAN_VAULT_PATH=/home/daniel/Documents/Obsidian/MainVault
|
||||||
|
# OBSIDIAN_VAULT_PATH=/mnt/storage/ObsidianVault
|
||||||
|
OBSIDIAN_VAULT_PATH=./vault
|
||||||
|
|
||||||
|
# Optional: mount a parent folder containing additional vaults at /vaults.
|
||||||
|
# Then register all vaults by name using container paths.
|
||||||
|
# Example:
|
||||||
|
# OBSIDIAN_VAULTS_ROOT=/home/daniel/Documents/Obsidian
|
||||||
|
# OBSIDIAN_VAULTS=main=/vault,work=/vaults/Work,personal=/vaults/Personal
|
||||||
|
OBSIDIAN_VAULTS_ROOT=./vaults
|
||||||
|
OBSIDIAN_VAULTS=
|
||||||
|
|
||||||
|
# Host port for the HTTP API.
|
||||||
|
OBSIDIAN_API_PORT=8080
|
||||||
|
|
||||||
|
# Official Obsidian desktop/CLI version installed in the image.
|
||||||
|
OBSIDIAN_VERSION=1.12.7
|
||||||
|
|
||||||
|
# Official CLI binary inside the container.
|
||||||
|
OBSIDIAN_CLI_BIN=obsidian
|
||||||
|
|
||||||
|
# Seconds to wait for the desktop app to expose the official CLI socket.
|
||||||
|
OBSIDIAN_STARTUP_TIMEOUT=60
|
||||||
|
|
||||||
|
# Command timeout limits in seconds.
|
||||||
|
COMMAND_TIMEOUT=60
|
||||||
|
MAX_COMMAND_TIMEOUT=300
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Build and Push Docker Container
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: obsidian-api
|
||||||
|
OBSIDIAN_VERSION: "1.12.7"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Rewrite SSH submodule URLs to HTTPS for CI
|
||||||
|
run: |
|
||||||
|
git config --global url."https://x-token:${{ secrets.ACTION_ACCESS_TOKEN }}@git.yiprawr.dev/".insteadOf "git@git.yiprawr.dev:"
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
token: "${{ secrets.ACTION_ACCESS_TOKEN }}"
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: Login to Docker registry
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.DOCKER_REGISTRY_URL }}
|
||||||
|
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.ACTION_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
OBSIDIAN_VERSION=${{ env.OBSIDIAN_VERSION }}
|
||||||
|
tags: |
|
||||||
|
${{ vars.DOCKER_REGISTRY_URL }}/daniel156161/${{ env.IMAGE_NAME }}:latest
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
obsidian_api.egg-info/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
vault/
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
packages/*/node_modules/
|
||||||
|
packages/*/dist/
|
||||||
|
sdk/typescript/dist/
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
|
||||||
|
|
||||||
|
ARG OBSIDIAN_VERSION=1.12.7
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
PATH="/app/.venv/bin:/usr/local/bin:${PATH}" \
|
||||||
|
OBSIDIAN_CLI_BIN=obsidian \
|
||||||
|
OBSIDIAN_VAULT_PATH=/vault \
|
||||||
|
OBSIDIAN_API_HOST=0.0.0.0 \
|
||||||
|
OBSIDIAN_API_PORT=8080 \
|
||||||
|
DISPLAY=:99 \
|
||||||
|
XDG_RUNTIME_DIR=/tmp/runtime-obsidian \
|
||||||
|
ELECTRON_DISABLE_SECURITY_WARNINGS=true
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
dumb-init \
|
||||||
|
xvfb \
|
||||||
|
xauth \
|
||||||
|
dbus-x11 \
|
||||||
|
libasound2 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatspi2.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libcups2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libdrm2 \
|
||||||
|
libgbm1 \
|
||||||
|
libgtk-3-0 \
|
||||||
|
libnss3 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libx11-xcb1 \
|
||||||
|
libxcb-dri3-0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxrandr2 \
|
||||||
|
&& case "${TARGETARCH:-amd64}" in \
|
||||||
|
amd64) OBSIDIAN_DEB_ARCH=amd64 ;; \
|
||||||
|
arm64) OBSIDIAN_DEB_ARCH=arm64 ;; \
|
||||||
|
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||||
|
esac \
|
||||||
|
&& curl -fsSL -o /tmp/obsidian.deb "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian_${OBSIDIAN_VERSION}_${OBSIDIAN_DEB_ARCH}.deb" \
|
||||||
|
&& apt-get install -y --no-install-recommends /tmp/obsidian.deb \
|
||||||
|
&& ln -sf /opt/Obsidian/obsidian-cli /usr/local/bin/obsidian \
|
||||||
|
&& rm -rf /tmp/obsidian.deb /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN uv sync --no-dev
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
COPY sdk ./sdk
|
||||||
|
COPY scripts ./scripts
|
||||||
|
RUN chmod +x /app/scripts/entrypoint.sh /app/scripts/create_jwt.py
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["/app/scripts/entrypoint.sh"]
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Obsidian API
|
||||||
|
Docker service that exposes an HTTP API for the **official Obsidian CLI**.
|
||||||
|
|
||||||
|
It is meant for:
|
||||||
|
- n8n custom nodes, HTTP Request nodes, and Code nodes
|
||||||
|
- Prism / TypeScript agent harnesses
|
||||||
|
- local automation against a mounted Obsidian vault
|
||||||
|
|
||||||
|
## Important: official CLI architecture
|
||||||
|
This uses the real Obsidian CLI from `obsidian.md/help/cli`.
|
||||||
|
|
||||||
|
That CLI is not an npm package. It ships inside the Obsidian desktop app and requires the app to be running. Therefore this container runs:
|
||||||
|
- official Obsidian desktop app
|
||||||
|
- Xvfb virtual display
|
||||||
|
- official `/opt/Obsidian/obsidian-cli` exposed as `obsidian`
|
||||||
|
- this Starlette HTTP API
|
||||||
|
|
||||||
|
Because Electron is required, the image is Debian-based and larger than Alpine.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
The HTTP API does **not** accept shell commands and does **not** accept plain command strings.
|
||||||
|
|
||||||
|
Allowed:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"args": ["read", "path=Inbox/Note.md"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rejected:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "read path=Inbox/Note.md"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "shell",
|
||||||
|
"args": ["ls -la"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The service always runs the configured binary directly with `exec` semantics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
obsidian <args...>
|
||||||
|
```
|
||||||
|
|
||||||
|
No shell is involved.
|
||||||
|
|
||||||
|
### JWT access policies
|
||||||
|
Auth uses signed HS256 JWT bearer tokens. The token itself contains the policy claims.
|
||||||
|
|
||||||
|
Create a signing secret:
|
||||||
|
```bash
|
||||||
|
export OBSIDIAN_JWT_SECRET="$(openssl rand -base64 48)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a token with the same secret:
|
||||||
|
```bash
|
||||||
|
python scripts/create_jwt.py \
|
||||||
|
--sub n8n \
|
||||||
|
--vault work \
|
||||||
|
--path Inbox/n8n/ \
|
||||||
|
--path Automation.md \
|
||||||
|
--command create \
|
||||||
|
--command read \
|
||||||
|
--command append \
|
||||||
|
--command search \
|
||||||
|
--command search:context \
|
||||||
|
--days 365
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the same secret for the API:
|
||||||
|
```env
|
||||||
|
OBSIDIAN_JWT_SECRET=change-this-long-random-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
JWT claims:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "n8n",
|
||||||
|
"vaults": ["work"],
|
||||||
|
"paths": ["Inbox/n8n/", "Automation.md"],
|
||||||
|
"commands": ["create", "read", "append", "search", "search:context"],
|
||||||
|
"iat": 1760000000,
|
||||||
|
"exp": 1791536000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Policy claims:
|
||||||
|
- `vaults`: allowed Obsidian vault names, e.g. `["work"]`
|
||||||
|
- `paths`: allowed note/path prefixes, e.g. `["Inbox/n8n/", "Automation.md"]`
|
||||||
|
- `commands`: allowed official CLI commands, e.g. `["read", "create", "append"]`
|
||||||
|
- `exp`: token expiry timestamp
|
||||||
|
- `nbf`: optional not-before timestamp
|
||||||
|
- `iat`: optional issued-at timestamp
|
||||||
|
|
||||||
|
Use `["*"]` for unrestricted access in a dimension.
|
||||||
|
|
||||||
|
Example request:
|
||||||
|
```bash
|
||||||
|
JWT="$(python scripts/create_jwt.py --sub n8n --vault work --path Inbox/n8n/ --command create --days 365)"
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8080/commands \
|
||||||
|
-H "Authorization: Bearer $JWT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"args":["vault=work","create","path=Inbox/n8n/Event.md","content=Hello","overwrite"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Path restrictions are enforced before the Obsidian CLI process starts. If a token has path restrictions, commands that read/write notes must include an explicit `path=`, `file=`, `folder=`, `name=` or `to=` argument.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit:
|
||||||
|
```env
|
||||||
|
OBSIDIAN_JWT_SECRET=change-this-long-random-secret
|
||||||
|
OBSIDIAN_VAULT_PATH=/absolute/path/to/your/vault
|
||||||
|
OBSIDIAN_API_PORT=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple vaults
|
||||||
|
Single vault is the default:
|
||||||
|
```env
|
||||||
|
OBSIDIAN_VAULT_PATH=/home/daniel/Documents/Obsidian/Main
|
||||||
|
```
|
||||||
|
|
||||||
|
For more vaults, mount a parent folder and register container paths:
|
||||||
|
```env
|
||||||
|
OBSIDIAN_VAULT_PATH=/home/daniel/Documents/Obsidian/Main
|
||||||
|
OBSIDIAN_VAULTS_ROOT=/home/daniel/Documents/Obsidian
|
||||||
|
OBSIDIAN_VAULTS=main=/vault,work=/vaults/Work,personal=/vaults/Personal
|
||||||
|
```
|
||||||
|
|
||||||
|
Then target a vault with the official CLI prefix:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"args": ["vault=work", "read", "path=Inbox/Note.md"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If no `vault=<name>` prefix is provided, the first vault in `OBSIDIAN_VAULTS` is used as the default.
|
||||||
|
|
||||||
|
Start:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs:
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run official Obsidian CLI command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/commands \
|
||||||
|
-H "Authorization: Bearer change-me" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"args":["version"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a note:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/commands \
|
||||||
|
-H "Authorization: Bearer change-me" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"args":["create","path=Inbox/Hello.md","content=# Hello","overwrite"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Read a note:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/commands \
|
||||||
|
-H "Authorization: Bearer change-me" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"args":["read","path=Inbox/Hello.md"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Search:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/commands \
|
||||||
|
-H "Authorization: Bearer change-me" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"args":["search","query=Hello","format=json"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript SDK
|
||||||
|
|
||||||
|
SDK path:
|
||||||
|
```text
|
||||||
|
sdk/typescript
|
||||||
|
```
|
||||||
|
|
||||||
|
Install locally:
|
||||||
|
```bash
|
||||||
|
npm install ./sdk/typescript
|
||||||
|
```
|
||||||
|
|
||||||
|
Use in TypeScript / Node / Prism:
|
||||||
|
```ts
|
||||||
|
import { ObsidianCliClient } from '@obsidian-api/sdk';
|
||||||
|
|
||||||
|
const obsidian = new ObsidianCliClient({
|
||||||
|
baseUrl: 'http://localhost:8080',
|
||||||
|
token: 'change-me',
|
||||||
|
});
|
||||||
|
|
||||||
|
await obsidian.create({
|
||||||
|
path: 'Inbox/Hello.md',
|
||||||
|
content: '# Hello from TypeScript',
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const note = await obsidian.read({ path: 'Inbox/Hello.md' });
|
||||||
|
console.log(note.stdout);
|
||||||
|
|
||||||
|
const results = await obsidian.search('Hello', { format: 'json' });
|
||||||
|
console.log(JSON.parse(results.stdout));
|
||||||
|
|
||||||
|
// Multi-vault: scoped client
|
||||||
|
const work = obsidian.inVault('work');
|
||||||
|
await work.create({ path: 'Inbox/Work.md', content: 'Hello work', overwrite: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
n8n options:
|
||||||
|
- real community-node package: [`packages/n8n-nodes-obsidian-api`](packages/n8n-nodes-obsidian-api)
|
||||||
|
- Code node / HTTP examples: [`docs/n8n.md`](docs/n8n.md)
|
||||||
|
|
||||||
|
Raw escape hatch, still safe because it sends an args array:
|
||||||
|
```ts
|
||||||
|
await obsidian.runRaw('daily:append', 'content=- [ ] Buy groceries');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Official CLI command examples
|
||||||
|
The SDK covers the official command groups from `obsidian.md/help/cli`, including:
|
||||||
|
- General: `help`, `version`, `reload`, `restart`
|
||||||
|
- Files: `create`, `read`, `append`, `prepend`, `move`, `rename`, `delete`, `files`, `folders`
|
||||||
|
- Daily notes: `daily`, `daily:path`, `daily:read`, `daily:append`, `daily:prepend`
|
||||||
|
- Search: `search`, `search:context`, `search:open`
|
||||||
|
- Links: `backlinks`, `links`, `unresolved`, `orphans`, `deadends`
|
||||||
|
- Tags/tasks/properties/templates
|
||||||
|
- Plugins/themes/snippets
|
||||||
|
- Sync/Publish commands
|
||||||
|
- Workspace/tabs/recents
|
||||||
|
- Developer commands: `dev:*`, `eval`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
Python API tests:
|
||||||
|
```bash
|
||||||
|
uv run pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
TypeScript SDK typecheck:
|
||||||
|
```bash
|
||||||
|
cd sdk/typescript
|
||||||
|
npm install
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib, base64, hmac
|
||||||
|
from typing import Any
|
||||||
|
import json, time
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.config import JWT_SECRET
|
||||||
|
from app.policy import TokenPolicy, tuple_claim
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthContext:
|
||||||
|
policy:TokenPolicy|None
|
||||||
|
|
||||||
|
def error(status_code:int, detail:str) -> JSONResponse:
|
||||||
|
return JSONResponse({"detail": detail}, status_code=status_code)
|
||||||
|
|
||||||
|
def b64url_decode(value:str) -> bytes:
|
||||||
|
padding = "=" * (-len(value) % 4)
|
||||||
|
return base64.urlsafe_b64decode((value + padding).encode())
|
||||||
|
|
||||||
|
def decode_hs256_jwt(token:str, secret:bytes) -> dict[str, Any]:
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError("JWT must have three parts")
|
||||||
|
|
||||||
|
header_b64, payload_b64, signature_b64 = parts
|
||||||
|
try:
|
||||||
|
header = json.loads(b64url_decode(header_b64))
|
||||||
|
payload = json.loads(b64url_decode(payload_b64))
|
||||||
|
except (json.JSONDecodeError, ValueError) as exc:
|
||||||
|
raise ValueError("JWT header or payload is invalid") from exc
|
||||||
|
|
||||||
|
if not isinstance(header, dict) or not isinstance(payload, dict):
|
||||||
|
raise ValueError("JWT header and payload must be JSON objects")
|
||||||
|
if header.get("alg") != "HS256":
|
||||||
|
raise ValueError("only HS256 JWTs are supported")
|
||||||
|
if header.get("typ") not in (None, "JWT"):
|
||||||
|
raise ValueError("JWT typ must be JWT")
|
||||||
|
|
||||||
|
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||||
|
expected = hmac.new(secret, signing_input, hashlib.sha256).digest()
|
||||||
|
supplied = b64url_decode(signature_b64)
|
||||||
|
if not hmac.compare_digest(supplied, expected):
|
||||||
|
raise ValueError("JWT signature is invalid")
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if exp is not None and (not isinstance(exp, int | float) or now >= int(exp)):
|
||||||
|
raise ValueError("JWT has expired")
|
||||||
|
nbf = payload.get("nbf")
|
||||||
|
if nbf is not None and (not isinstance(nbf, int | float) or now < int(nbf)):
|
||||||
|
raise ValueError("JWT is not active yet")
|
||||||
|
iat = payload.get("iat")
|
||||||
|
if iat is not None and (not isinstance(iat, int | float) or int(iat) > now + 300):
|
||||||
|
raise ValueError("JWT iat is in the future")
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def policy_from_jwt_claims(token:str, claims:dict[str, Any]) -> TokenPolicy:
|
||||||
|
name = str(claims.get("name") or claims.get("sub") or "jwt")
|
||||||
|
return TokenPolicy(
|
||||||
|
token=token,
|
||||||
|
name=name,
|
||||||
|
vaults=tuple_claim(claims.get("vaults"), ("*",)),
|
||||||
|
paths=tuple_claim(claims.get("paths"), ("*",)),
|
||||||
|
commands=tuple_claim(claims.get("commands"), ("*",)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def require_auth(request:Request) -> AuthContext|JSONResponse:
|
||||||
|
if not JWT_SECRET:
|
||||||
|
return error(500, "OBSIDIAN_JWT_SECRET is required")
|
||||||
|
|
||||||
|
header = request.headers.get("authorization", "")
|
||||||
|
if not header.startswith("Bearer "):
|
||||||
|
return error(401, "Unauthorized")
|
||||||
|
|
||||||
|
supplied = header.removeprefix("Bearer ")
|
||||||
|
try:
|
||||||
|
claims = decode_hs256_jwt(supplied, JWT_SECRET.encode())
|
||||||
|
return AuthContext(policy=policy_from_jwt_claims(supplied, claims))
|
||||||
|
except ValueError:
|
||||||
|
return error(401, "Unauthorized")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import json, os
|
||||||
|
|
||||||
|
JWT_SECRET = os.getenv("OBSIDIAN_JWT_SECRET", "")
|
||||||
|
DEFAULT_TIMEOUT = float(os.getenv("COMMAND_TIMEOUT", "60"))
|
||||||
|
MAX_TIMEOUT = float(os.getenv("MAX_COMMAND_TIMEOUT", "300"))
|
||||||
|
CLI_BIN = os.getenv("OBSIDIAN_CLI_BIN", "obsidian")
|
||||||
|
VAULT_PATH = Path(os.getenv("OBSIDIAN_VAULT_PATH", "/vault")).resolve()
|
||||||
|
HOST = os.getenv("OBSIDIAN_API_HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.getenv("OBSIDIAN_API_PORT", "8080"))
|
||||||
|
|
||||||
|
def parse_registered_vaults() -> dict[str, str]:
|
||||||
|
raw = os.getenv("OBSIDIAN_VAULTS", "").strip()
|
||||||
|
vaults:dict[str, str] = {}
|
||||||
|
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return {str(name): str(path) for name, path in parsed.items()}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for chunk in raw.replace("\n", ",").split(","):
|
||||||
|
chunk = chunk.strip()
|
||||||
|
if not chunk or "=" not in chunk:
|
||||||
|
continue
|
||||||
|
name, path = chunk.split("=", 1)
|
||||||
|
vaults[name.strip()] = path.strip()
|
||||||
|
|
||||||
|
if not vaults:
|
||||||
|
vaults[VAULT_PATH.name or "vault"] = str(VAULT_PATH)
|
||||||
|
|
||||||
|
return vaults
|
||||||
|
|
||||||
|
REGISTERED_VAULTS = parse_registered_vaults()
|
||||||
|
DEFAULT_VAULT_NAME = next(iter(REGISTERED_VAULTS), VAULT_PATH.name or "vault")
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
import json
|
||||||
|
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from app import auth
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.config import CLI_BIN, DEFAULT_TIMEOUT, HOST, MAX_TIMEOUT, PORT, VAULT_PATH
|
||||||
|
from app.policy import authorize_command, vault_for_path
|
||||||
|
from app.runner import execute_command
|
||||||
|
|
||||||
|
def error(status_code:int, detail:str) -> JSONResponse:
|
||||||
|
return JSONResponse({"detail": detail}, status_code=status_code)
|
||||||
|
|
||||||
|
def parse_args(value:Any) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||||
|
raise ValueError("args must be a list of strings")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def parse_timeout(value:Any) -> float:
|
||||||
|
if value is None:
|
||||||
|
return DEFAULT_TIMEOUT
|
||||||
|
if not isinstance(value, int | float) or value <= 0:
|
||||||
|
raise ValueError("timeout must be a positive number")
|
||||||
|
return min(float(value), MAX_TIMEOUT)
|
||||||
|
|
||||||
|
def safe_cwd(cwd:str|None) -> Path:
|
||||||
|
path = Path(cwd).resolve() if cwd else VAULT_PATH
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
raise ValueError(f"cwd does not exist or is not a directory: {path}")
|
||||||
|
if vault_for_path(path) is None:
|
||||||
|
raise ValueError(f"cwd must be inside a registered vault: {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
async def health(request:Request) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"vault": str(VAULT_PATH),
|
||||||
|
"cli": CLI_BIN,
|
||||||
|
"auth": bool(auth.JWT_SECRET),
|
||||||
|
"jwt": bool(auth.JWT_SECRET),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_command(request:Request) -> JSONResponse:
|
||||||
|
auth_context = require_auth(request)
|
||||||
|
if isinstance(auth_context, JSONResponse):
|
||||||
|
return auth_context
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return error(400, "invalid JSON body")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "command" in payload:
|
||||||
|
raise ValueError("command strings are not allowed; use args as a list of strings")
|
||||||
|
if "mode" in payload:
|
||||||
|
raise ValueError("mode is not allowed; only the configured Obsidian CLI can be executed")
|
||||||
|
|
||||||
|
args = parse_args(payload.get("args"))
|
||||||
|
stdin = payload.get("stdin")
|
||||||
|
if stdin is not None and not isinstance(stdin, str):
|
||||||
|
raise ValueError("stdin must be a string")
|
||||||
|
|
||||||
|
cwd_value = payload.get("cwd")
|
||||||
|
if cwd_value is not None and not isinstance(cwd_value, str):
|
||||||
|
raise ValueError("cwd must be a string")
|
||||||
|
|
||||||
|
cwd = safe_cwd(cwd_value)
|
||||||
|
timeout = parse_timeout(payload.get("timeout"))
|
||||||
|
authorize_command(auth_context.policy, args, cwd)
|
||||||
|
except ValueError as exc:
|
||||||
|
return error(400, str(exc))
|
||||||
|
except PermissionError as exc:
|
||||||
|
return error(403, str(exc))
|
||||||
|
|
||||||
|
return await execute_command(args=args, cwd=cwd, stdin=stdin, timeout=timeout)
|
||||||
|
|
||||||
|
async def typescript_sdk_example(request:Request) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"example": """
|
||||||
|
import { ObsidianCliClient } from '@obsidian-api/sdk';
|
||||||
|
|
||||||
|
const obsidian = new ObsidianCliClient({
|
||||||
|
baseUrl: 'http://localhost:8080',
|
||||||
|
token: '<jwt>',
|
||||||
|
});
|
||||||
|
|
||||||
|
await obsidian.create({
|
||||||
|
path: 'Inbox/Hello.md',
|
||||||
|
content: '# Hello from n8n/Prism',
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const note = await obsidian.read({ path: 'Inbox/Hello.md' });
|
||||||
|
console.log(note.stdout);
|
||||||
|
""".strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Starlette(
|
||||||
|
debug=False,
|
||||||
|
routes=[
|
||||||
|
Route("/health", health, methods=["GET"]),
|
||||||
|
Route("/commands", run_command, methods=["POST"]),
|
||||||
|
Route("/sdk/typescript", typescript_sdk_example, methods=["GET"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("app.main:app", host=HOST, port=PORT)
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TokenPolicy:
|
||||||
|
token:str = ""
|
||||||
|
name:str = "jwt"
|
||||||
|
vaults:tuple[str, ...] = ("*",)
|
||||||
|
paths:tuple[str, ...] = ("*",)
|
||||||
|
commands:tuple[str, ...] = ("*",)
|
||||||
|
|
||||||
|
def allows_all_vaults(self) -> bool:
|
||||||
|
return "*" in self.vaults
|
||||||
|
|
||||||
|
def allows_all_paths(self) -> bool:
|
||||||
|
return "*" in self.paths
|
||||||
|
|
||||||
|
def allows_all_commands(self) -> bool:
|
||||||
|
return "*" in self.commands
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandTarget:
|
||||||
|
command:str|None
|
||||||
|
vault:str|None
|
||||||
|
paths:tuple[str, ...]
|
||||||
|
|
||||||
|
PATH_KEYS = {"path", "file", "folder", "name", "to"}
|
||||||
|
PATH_REQUIRED_WHEN_RESTRICTED = {
|
||||||
|
"append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff",
|
||||||
|
"search", "search:context", "files", "folders",
|
||||||
|
"property:set", "property:remove", "property:read", "properties",
|
||||||
|
"aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task",
|
||||||
|
}
|
||||||
|
PATHLESS_ALLOWED_WHEN_RESTRICTED = {"help", "version"}
|
||||||
|
|
||||||
|
def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, str):
|
||||||
|
return (value,)
|
||||||
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||||
|
return tuple(value)
|
||||||
|
raise ValueError("JWT claims vaults, paths and commands must be strings or string arrays")
|
||||||
|
|
||||||
|
def vault_for_path(path:Path) -> str|None:
|
||||||
|
resolved = path.resolve()
|
||||||
|
best_name:str|None = None
|
||||||
|
best_len = -1
|
||||||
|
|
||||||
|
for name, vault_path in REGISTERED_VAULTS.items():
|
||||||
|
root = Path(vault_path).resolve()
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
root_len = len(str(root))
|
||||||
|
if root_len > best_len:
|
||||||
|
best_name = name
|
||||||
|
best_len = root_len
|
||||||
|
|
||||||
|
return best_name
|
||||||
|
|
||||||
|
def normalize_policy_path(value:str) -> str:
|
||||||
|
normalized = value.strip().replace("\\", "/").lstrip("/")
|
||||||
|
while "//" in normalized:
|
||||||
|
normalized = normalized.replace("//", "/")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def is_path_allowed(path:str, allowed_patterns:tuple[str, ...]) -> bool:
|
||||||
|
normalized = normalize_policy_path(path)
|
||||||
|
|
||||||
|
for pattern in allowed_patterns:
|
||||||
|
pattern_normalized = normalize_policy_path(pattern)
|
||||||
|
if pattern_normalized == "*":
|
||||||
|
return True
|
||||||
|
if pattern_normalized.endswith("/**"):
|
||||||
|
prefix = pattern_normalized[:-3].rstrip("/")
|
||||||
|
if normalized == prefix or normalized.startswith(prefix + "/"):
|
||||||
|
return True
|
||||||
|
elif pattern_normalized.endswith("/"):
|
||||||
|
if normalized.startswith(pattern_normalized):
|
||||||
|
return True
|
||||||
|
elif normalized == pattern_normalized or normalized.startswith(pattern_normalized.rstrip("/") + "/"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def parse_kv_arg(arg:str) -> tuple[str, str]|None:
|
||||||
|
if "=" not in arg:
|
||||||
|
return None
|
||||||
|
key, value = arg.split("=", 1)
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
return key, value
|
||||||
|
|
||||||
|
def extract_command_target(args:list[str]) -> CommandTarget:
|
||||||
|
vault:str|None = None
|
||||||
|
command:str|None = None
|
||||||
|
paths:list[str] = []
|
||||||
|
|
||||||
|
for arg in args:
|
||||||
|
parsed = parse_kv_arg(arg)
|
||||||
|
if parsed and parsed[0] == "vault" and command is None:
|
||||||
|
vault = parsed[1]
|
||||||
|
continue
|
||||||
|
command = arg
|
||||||
|
break
|
||||||
|
|
||||||
|
for arg in args:
|
||||||
|
parsed = parse_kv_arg(arg)
|
||||||
|
if not parsed:
|
||||||
|
continue
|
||||||
|
key, value = parsed
|
||||||
|
if key in PATH_KEYS and value:
|
||||||
|
paths.append(value)
|
||||||
|
|
||||||
|
return CommandTarget(command=command, vault=vault, paths=tuple(paths))
|
||||||
|
|
||||||
|
def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None:
|
||||||
|
if policy is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
target = extract_command_target(args)
|
||||||
|
command = target.command or ""
|
||||||
|
|
||||||
|
if not policy.allows_all_commands() and command not in policy.commands:
|
||||||
|
raise PermissionError(f"token is not allowed to run command: {command}")
|
||||||
|
|
||||||
|
if not policy.allows_all_vaults():
|
||||||
|
vault = target.vault or vault_for_path(cwd) or DEFAULT_VAULT_NAME
|
||||||
|
if vault not in policy.vaults:
|
||||||
|
raise PermissionError(f"token is not allowed to access vault: {vault}")
|
||||||
|
|
||||||
|
if policy.allows_all_paths():
|
||||||
|
return
|
||||||
|
|
||||||
|
if not target.paths:
|
||||||
|
if command in PATHLESS_ALLOWED_WHEN_RESTRICTED:
|
||||||
|
return
|
||||||
|
if command in PATH_REQUIRED_WHEN_RESTRICTED:
|
||||||
|
raise PermissionError("token has path restrictions; command must include an allowed path/file/folder/name argument")
|
||||||
|
raise PermissionError(f"token has path restrictions; command is not allowed without an explicit path: {command}")
|
||||||
|
|
||||||
|
denied = [path for path in target.paths if not is_path_allowed(path, policy.paths)]
|
||||||
|
if denied:
|
||||||
|
raise PermissionError(f"token is not allowed to access path: {denied[0]}")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import asyncio, uuid, time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.config import CLI_BIN
|
||||||
|
|
||||||
|
def error(status_code:int, detail:str) -> JSONResponse:
|
||||||
|
return JSONResponse({"detail": detail}, status_code=status_code)
|
||||||
|
|
||||||
|
async def execute_command(args:list[str], cwd:Path, stdin:str|None, timeout:float) -> JSONResponse:
|
||||||
|
command_id = str(uuid.uuid4())
|
||||||
|
command = [CLI_BIN, *args]
|
||||||
|
started = time.perf_counter()
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
cwd=str(cwd),
|
||||||
|
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return error(500, f"configured CLI binary was not found: {CLI_BIN}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout_b, stderr_b = await asyncio.wait_for(
|
||||||
|
process.communicate(None if stdin is None else stdin.encode()),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
timed_out = False
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
stdout_b, stderr_b = await process.communicate()
|
||||||
|
timed_out = True
|
||||||
|
|
||||||
|
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
stdout = stdout_b.decode(errors="replace")
|
||||||
|
stderr = stderr_b.decode(errors="replace")
|
||||||
|
exit_code = process.returncode if process.returncode is not None else -1
|
||||||
|
|
||||||
|
if timed_out:
|
||||||
|
exit_code = 124
|
||||||
|
stderr = (stderr + f"\nCommand timed out after {timeout}s").strip()
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"id": command_id,
|
||||||
|
"command": command,
|
||||||
|
"cwd": str(cwd),
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"stdout": stdout,
|
||||||
|
"stderr": stderr,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"timed_out": timed_out,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
obsidian-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
OBSIDIAN_VERSION: ${OBSIDIAN_VERSION:-1.12.7}
|
||||||
|
image: obsidian-api:local
|
||||||
|
container_name: obsidian-api
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# JWT signing secret. Bearer tokens are HS256 JWTs with vault/path/command claims.
|
||||||
|
OBSIDIAN_JWT_SECRET: ${OBSIDIAN_JWT_SECRET:-}
|
||||||
|
OBSIDIAN_CLI_BIN: ${OBSIDIAN_CLI_BIN:-obsidian}
|
||||||
|
# Single-vault fallback. For multiple vaults, set OBSIDIAN_VAULTS below.
|
||||||
|
OBSIDIAN_VAULT_PATH: /vault
|
||||||
|
# Multi-vault syntax: name=/container/path,name2=/container/path2
|
||||||
|
# Example with the optional /vaults mount below: main=/vault,work=/vaults/work,personal=/vaults/personal
|
||||||
|
OBSIDIAN_VAULTS: ${OBSIDIAN_VAULTS:-}
|
||||||
|
OBSIDIAN_STARTUP_TIMEOUT: ${OBSIDIAN_STARTUP_TIMEOUT:-60}
|
||||||
|
COMMAND_TIMEOUT: ${COMMAND_TIMEOUT:-60}
|
||||||
|
MAX_COMMAND_TIMEOUT: ${MAX_COMMAND_TIMEOUT:-300}
|
||||||
|
volumes:
|
||||||
|
# Default/single vault mount.
|
||||||
|
- ${OBSIDIAN_VAULT_PATH:-./vault}:/vault
|
||||||
|
# Optional parent-folder mount for multiple vaults. Example:
|
||||||
|
# OBSIDIAN_VAULTS_ROOT=/home/daniel/Documents/Obsidian
|
||||||
|
# OBSIDIAN_VAULTS=main=/vault,work=/vaults/Work,personal=/vaults/Personal
|
||||||
|
- ${OBSIDIAN_VAULTS_ROOT:-./vaults}:/vaults
|
||||||
|
ports:
|
||||||
|
- "${OBSIDIAN_API_PORT:-8080}:8080"
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# n8n usage notes
|
||||||
|
Use the TypeScript SDK from an n8n **Code** node when n8n can import local or bundled npm packages. In self-hosted n8n this usually means installing the SDK where n8n can resolve it and allowing external modules, for example with `NODE_FUNCTION_ALLOW_EXTERNAL=@obsidian-api/sdk` or `NODE_FUNCTION_ALLOW_EXTERNAL=*`.
|
||||||
|
|
||||||
|
The SDK only sends structured `args` arrays to the HTTP API. It never sends shell command strings.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
Recommended n8n env vars:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OBSIDIAN_API_URL=http://obsidian-api:8080
|
||||||
|
OBSIDIAN_API_JWT=<jwt-from-scripts/create_jwt.py>
|
||||||
|
OBSIDIAN_VAULT=work
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended: use a restricted JWT for n8n. Create it with:
|
||||||
|
```bash
|
||||||
|
OBSIDIAN_JWT_SECRET=change-this-long-random-secret \
|
||||||
|
python scripts/create_jwt.py \
|
||||||
|
--sub n8n \
|
||||||
|
--vault work \
|
||||||
|
--path Inbox/n8n/ \
|
||||||
|
--path Automation.md \
|
||||||
|
--command create \
|
||||||
|
--command read \
|
||||||
|
--command append \
|
||||||
|
--command search \
|
||||||
|
--command search:context \
|
||||||
|
--days 365
|
||||||
|
```
|
||||||
|
|
||||||
|
The JWT contains claims like:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "n8n",
|
||||||
|
"vaults": ["work"],
|
||||||
|
"paths": ["Inbox/n8n/", "Automation.md"],
|
||||||
|
"commands": ["create", "read", "append", "search", "search:context"],
|
||||||
|
"exp": 1791536000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If n8n runs outside the Docker network, use the exposed host URL instead:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OBSIDIAN_API_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Code node
|
||||||
|
```ts
|
||||||
|
import { ObsidianCliClient } from '@obsidian-api/sdk';
|
||||||
|
|
||||||
|
const obsidian = new ObsidianCliClient({
|
||||||
|
baseUrl: process.env.OBSIDIAN_API_URL!,
|
||||||
|
token: process.env.OBSIDIAN_API_JWT!,
|
||||||
|
vault: process.env.OBSIDIAN_VAULT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = $json.title ?? 'n8n note';
|
||||||
|
const content = `# ${title}\n\nCreated from n8n.\n`;
|
||||||
|
|
||||||
|
const result = await obsidian.create({
|
||||||
|
path: `Inbox/${title}.md`,
|
||||||
|
content,
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [{ json: result }];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Append incoming webhook payload to a daily note
|
||||||
|
```ts
|
||||||
|
import { ObsidianCliClient } from '@obsidian-api/sdk';
|
||||||
|
|
||||||
|
const obsidian = new ObsidianCliClient({
|
||||||
|
baseUrl: process.env.OBSIDIAN_API_URL!,
|
||||||
|
token: process.env.OBSIDIAN_API_JWT!,
|
||||||
|
vault: process.env.OBSIDIAN_VAULT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = JSON.stringify($json, null, 2).replace(/```/g, '``\\`');
|
||||||
|
|
||||||
|
const result = await obsidian.dailyAppend(
|
||||||
|
`\n## n8n event\n\n\`\`\`json\n${payload}\n\`\`\`\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [{ json: result }];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search notes
|
||||||
|
```ts
|
||||||
|
import { ObsidianCliClient } from '@obsidian-api/sdk';
|
||||||
|
|
||||||
|
const obsidian = new ObsidianCliClient({
|
||||||
|
baseUrl: process.env.OBSIDIAN_API_URL!,
|
||||||
|
token: process.env.OBSIDIAN_API_JWT!,
|
||||||
|
vault: process.env.OBSIDIAN_VAULT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await obsidian.search($json.query, { format: 'json' });
|
||||||
|
return [{ json: { matches: JSON.parse(result.stdout) } }];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-vault usage
|
||||||
|
If the API container registers multiple vaults, use a scoped client per vault:
|
||||||
|
```ts
|
||||||
|
const base = new ObsidianCliClient({
|
||||||
|
baseUrl: process.env.OBSIDIAN_API_URL!,
|
||||||
|
token: process.env.OBSIDIAN_API_JWT!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const work = base.inVault('work');
|
||||||
|
const personal = base.inVault('personal');
|
||||||
|
|
||||||
|
await work.create({ path: 'Inbox/Work.md', content: 'Work note', overwrite: true });
|
||||||
|
await personal.create({ path: 'Inbox/Personal.md', content: 'Personal note', overwrite: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Without SDK
|
||||||
|
If package imports are not available in your n8n environment, call the HTTP API directly:
|
||||||
|
```ts
|
||||||
|
const response = await fetch(`${process.env.OBSIDIAN_API_URL}/commands`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${process.env.OBSIDIAN_API_JWT}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
args: ['vault=work', 'create', 'path=Inbox/n8n.md', 'content=Hello from n8n', 'overwrite'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return [{ json: await response.json() }];
|
||||||
|
```
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# n8n-nodes-obsidian-api
|
||||||
|
n8n community-node package for the Obsidian API Docker service.
|
||||||
|
|
||||||
|
It talks to the HTTP API, which then runs the **official Obsidian CLI** against mounted vaults. The node sends structured `args` arrays only. It does not send shell commands.
|
||||||
|
|
||||||
|
## Nodes
|
||||||
|
|
||||||
|
### Obsidian API
|
||||||
|
|
||||||
|
Resources:
|
||||||
|
- **Note**
|
||||||
|
- Create
|
||||||
|
- Read
|
||||||
|
- Append
|
||||||
|
- Prepend
|
||||||
|
- Delete
|
||||||
|
- Rename
|
||||||
|
- List Files
|
||||||
|
- **Daily Note**
|
||||||
|
- Append
|
||||||
|
- Prepend
|
||||||
|
- Read
|
||||||
|
- Path
|
||||||
|
- **Search**
|
||||||
|
- Search
|
||||||
|
- Search With Context
|
||||||
|
- **Vault**
|
||||||
|
- Info
|
||||||
|
- List Vaults
|
||||||
|
- **Raw Command**
|
||||||
|
- Safe args-array escape hatch for any official CLI command
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
Credential type: **Obsidian API**
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- **Base URL**: e.g. `http://obsidian-api:8080`
|
||||||
|
- **API Token**: JWT created with `scripts/create_jwt.py`
|
||||||
|
- **Default Vault**: optional, e.g. `work`
|
||||||
|
|
||||||
|
## Multi-vault
|
||||||
|
Set the `Vault` field on a node to override the credential default vault.
|
||||||
|
|
||||||
|
The node sends this as the official CLI prefix:
|
||||||
|
```text
|
||||||
|
vault=work
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw command example:
|
||||||
|
```json
|
||||||
|
["vault=work", "read", "path=Inbox/Note.md"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local install in self-hosted n8n
|
||||||
|
From this repo:
|
||||||
|
```bash
|
||||||
|
cd packages/n8n-nodes-obsidian-api
|
||||||
|
npm install --ignore-scripts
|
||||||
|
npm run build
|
||||||
|
npm pack
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install the generated `.tgz` in your n8n custom extensions setup.
|
||||||
|
|
||||||
|
For Docker-based n8n, one common setup is mounting this package into n8n's custom extensions directory and setting:
|
||||||
|
```env
|
||||||
|
N8N_CUSTOM_EXTENSIONS=/home/node/.n8n/custom
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact installation depends on your n8n deployment.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
```bash
|
||||||
|
npm install --ignore-scripts
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
`--ignore-scripts` avoids local native builds from n8n transitive dependencies on unsupported bleeding-edge Node versions. Use the Node version recommended by your n8n release for production builds.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type {
|
||||||
|
ICredentialType,
|
||||||
|
INodeProperties,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class ObsidianApi implements ICredentialType {
|
||||||
|
name = 'obsidianApi';
|
||||||
|
displayName = 'Obsidian API';
|
||||||
|
documentationUrl = 'https://git.yiprawr.dev/Docker/obsidian-api';
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Base URL',
|
||||||
|
name: 'baseUrl',
|
||||||
|
type: 'string',
|
||||||
|
default: 'http://obsidian-api:8080',
|
||||||
|
placeholder: 'http://obsidian-api:8080',
|
||||||
|
description: 'Base URL of the Obsidian API Docker service',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'API Token',
|
||||||
|
name: 'apiToken',
|
||||||
|
type: 'string',
|
||||||
|
typeOptions: {
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
default: '',
|
||||||
|
description: 'Bearer JWT created with scripts/create_jwt.py',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Default Vault',
|
||||||
|
name: 'defaultVault',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
placeholder: 'work',
|
||||||
|
description: 'Optional default vault name/path. Sent as vault=<value> before every command unless the node overrides it.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import type {
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
INodeType,
|
||||||
|
INodeTypeDescription,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { compactArgs, executeObsidianCommand, kv } from '../../shared/ObsidianApiClient';
|
||||||
|
|
||||||
|
export class ObsidianApi implements INodeType {
|
||||||
|
description: INodeTypeDescription = {
|
||||||
|
displayName: 'Obsidian API',
|
||||||
|
name: 'obsidianApi',
|
||||||
|
icon: 'file:obsidian.svg',
|
||||||
|
group: ['transform'],
|
||||||
|
version: 1,
|
||||||
|
subtitle: '={{$parameter["operation"]}}',
|
||||||
|
description: 'Run official Obsidian CLI commands through the Obsidian API Docker service',
|
||||||
|
defaults: {
|
||||||
|
name: 'Obsidian API',
|
||||||
|
},
|
||||||
|
inputs: ['main'],
|
||||||
|
outputs: ['main'],
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
name: 'obsidianApi',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
displayName: 'Vault',
|
||||||
|
name: 'vault',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
placeholder: 'work',
|
||||||
|
description: 'Optional vault name/path. Overrides the credential default vault. Sent as vault=<value>.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Resource',
|
||||||
|
name: 'resource',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
options: [
|
||||||
|
{ name: 'Daily Note', value: 'daily' },
|
||||||
|
{ name: 'Note', value: 'note' },
|
||||||
|
{ name: 'Raw Command', value: 'raw' },
|
||||||
|
{ name: 'Search', value: 'search' },
|
||||||
|
{ name: 'Vault', value: 'vault' },
|
||||||
|
],
|
||||||
|
default: 'note',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Note operations
|
||||||
|
{
|
||||||
|
displayName: 'Operation',
|
||||||
|
name: 'operation',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
displayOptions: { show: { resource: ['note'] } },
|
||||||
|
options: [
|
||||||
|
{ name: 'Append', value: 'append' },
|
||||||
|
{ name: 'Create', value: 'create' },
|
||||||
|
{ name: 'Delete', value: 'delete' },
|
||||||
|
{ name: 'List Files', value: 'files' },
|
||||||
|
{ name: 'Prepend', value: 'prepend' },
|
||||||
|
{ name: 'Read', value: 'read' },
|
||||||
|
{ name: 'Rename', value: 'rename' },
|
||||||
|
],
|
||||||
|
default: 'create',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Path',
|
||||||
|
name: 'path',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'delete', 'prepend', 'read', 'rename'] } },
|
||||||
|
default: '',
|
||||||
|
placeholder: 'Inbox/Note.md',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Content',
|
||||||
|
name: 'content',
|
||||||
|
type: 'string',
|
||||||
|
typeOptions: { rows: 8 },
|
||||||
|
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'prepend'] } },
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Overwrite',
|
||||||
|
name: 'overwrite',
|
||||||
|
type: 'boolean',
|
||||||
|
displayOptions: { show: { resource: ['note'], operation: ['create'] } },
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'New Name',
|
||||||
|
name: 'newName',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: { show: { resource: ['note'], operation: ['rename'] } },
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Folder',
|
||||||
|
name: 'folder',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: { show: { resource: ['note'], operation: ['files'] } },
|
||||||
|
default: '',
|
||||||
|
placeholder: 'Inbox',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Daily note operations
|
||||||
|
{
|
||||||
|
displayName: 'Operation',
|
||||||
|
name: 'operation',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
displayOptions: { show: { resource: ['daily'] } },
|
||||||
|
options: [
|
||||||
|
{ name: 'Append', value: 'dailyAppend' },
|
||||||
|
{ name: 'Path', value: 'dailyPath' },
|
||||||
|
{ name: 'Prepend', value: 'dailyPrepend' },
|
||||||
|
{ name: 'Read', value: 'dailyRead' },
|
||||||
|
],
|
||||||
|
default: 'dailyAppend',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Content',
|
||||||
|
name: 'dailyContent',
|
||||||
|
type: 'string',
|
||||||
|
typeOptions: { rows: 8 },
|
||||||
|
displayOptions: { show: { resource: ['daily'], operation: ['dailyAppend', 'dailyPrepend'] } },
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search operations
|
||||||
|
{
|
||||||
|
displayName: 'Operation',
|
||||||
|
name: 'operation',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
displayOptions: { show: { resource: ['search'] } },
|
||||||
|
options: [
|
||||||
|
{ name: 'Search', value: 'search' },
|
||||||
|
{ name: 'Search With Context', value: 'searchContext' },
|
||||||
|
],
|
||||||
|
default: 'search',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Query',
|
||||||
|
name: 'query',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: { show: { resource: ['search'] } },
|
||||||
|
default: '',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Format',
|
||||||
|
name: 'format',
|
||||||
|
type: 'options',
|
||||||
|
displayOptions: { show: { resource: ['search'] } },
|
||||||
|
options: [
|
||||||
|
{ name: 'Text', value: 'text' },
|
||||||
|
{ name: 'JSON', value: 'json' },
|
||||||
|
],
|
||||||
|
default: 'text',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vault operations
|
||||||
|
{
|
||||||
|
displayName: 'Operation',
|
||||||
|
name: 'operation',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
displayOptions: { show: { resource: ['vault'] } },
|
||||||
|
options: [
|
||||||
|
{ name: 'Info', value: 'vaultInfo' },
|
||||||
|
{ name: 'List Vaults', value: 'vaults' },
|
||||||
|
],
|
||||||
|
default: 'vaultInfo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Verbose',
|
||||||
|
name: 'verbose',
|
||||||
|
type: 'boolean',
|
||||||
|
displayOptions: { show: { resource: ['vault'], operation: ['vaults'] } },
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Raw command
|
||||||
|
{
|
||||||
|
displayName: 'Args JSON',
|
||||||
|
name: 'rawArgs',
|
||||||
|
type: 'json',
|
||||||
|
displayOptions: { show: { resource: ['raw'] } },
|
||||||
|
default: '["version"]',
|
||||||
|
description: 'Structured CLI args array. Example: ["create", "path=Inbox/Test.md", "content=Hello", "overwrite"]. Do not include shell strings.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Timeout Seconds',
|
||||||
|
name: 'timeout',
|
||||||
|
type: 'number',
|
||||||
|
default: 60,
|
||||||
|
description: 'Command timeout in seconds',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||||
|
const items = this.getInputData();
|
||||||
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||||
|
const resource = this.getNodeParameter('resource', itemIndex) as string;
|
||||||
|
const operation = this.getNodeParameter('operation', itemIndex) as string;
|
||||||
|
const vault = this.getNodeParameter('vault', itemIndex, '') as string;
|
||||||
|
const timeout = this.getNodeParameter('timeout', itemIndex, 60) as number;
|
||||||
|
|
||||||
|
let args: string[];
|
||||||
|
|
||||||
|
switch (resource) {
|
||||||
|
case 'note': {
|
||||||
|
args = buildNoteArgs(this, operation, itemIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'daily': {
|
||||||
|
args = buildDailyArgs(this, operation, itemIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'search': {
|
||||||
|
args = buildSearchArgs(this, operation, itemIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'vault': {
|
||||||
|
args = buildVaultArgs(this, operation, itemIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'raw': {
|
||||||
|
args = parseRawArgs(this, itemIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new NodeOperationError(this.getNode(), `Unsupported resource: ${resource}`, { itemIndex });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeObsidianCommand(this, itemIndex, args, { vault, timeout });
|
||||||
|
returnData.push({ json: result, pairedItem: { item: itemIndex } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [returnData];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNoteArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
|
||||||
|
const path = context.getNodeParameter('path', itemIndex, '') as string;
|
||||||
|
switch (operation) {
|
||||||
|
case 'create':
|
||||||
|
return compactArgs(['create', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string), kv('overwrite', context.getNodeParameter('overwrite', itemIndex) as boolean)]);
|
||||||
|
case 'read':
|
||||||
|
return compactArgs(['read', kv('path', path)]);
|
||||||
|
case 'append':
|
||||||
|
return compactArgs(['append', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
|
||||||
|
case 'prepend':
|
||||||
|
return compactArgs(['prepend', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
|
||||||
|
case 'delete':
|
||||||
|
return compactArgs(['delete', kv('path', path)]);
|
||||||
|
case 'rename':
|
||||||
|
return compactArgs(['rename', kv('path', path), kv('name', context.getNodeParameter('newName', itemIndex) as string)]);
|
||||||
|
case 'files':
|
||||||
|
return compactArgs(['files', kv('folder', context.getNodeParameter('folder', itemIndex, '') as string)]);
|
||||||
|
default:
|
||||||
|
throw new NodeOperationError(context.getNode(), `Unsupported note operation: ${operation}`, { itemIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDailyArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
|
||||||
|
switch (operation) {
|
||||||
|
case 'dailyAppend':
|
||||||
|
return compactArgs(['daily:append', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
|
||||||
|
case 'dailyPrepend':
|
||||||
|
return compactArgs(['daily:prepend', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
|
||||||
|
case 'dailyRead':
|
||||||
|
return ['daily:read'];
|
||||||
|
case 'dailyPath':
|
||||||
|
return ['daily:path'];
|
||||||
|
default:
|
||||||
|
throw new NodeOperationError(context.getNode(), `Unsupported daily operation: ${operation}`, { itemIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
|
||||||
|
const command = operation === 'searchContext' ? 'search:context' : 'search';
|
||||||
|
return compactArgs([
|
||||||
|
command,
|
||||||
|
kv('query', context.getNodeParameter('query', itemIndex) as string),
|
||||||
|
kv('format', context.getNodeParameter('format', itemIndex) as string),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVaultArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
|
||||||
|
switch (operation) {
|
||||||
|
case 'vaultInfo':
|
||||||
|
return ['vault'];
|
||||||
|
case 'vaults':
|
||||||
|
return compactArgs(['vaults', kv('verbose', context.getNodeParameter('verbose', itemIndex) as boolean)]);
|
||||||
|
default:
|
||||||
|
throw new NodeOperationError(context.getNode(), `Unsupported vault operation: ${operation}`, { itemIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawArgs(context: IExecuteFunctions, itemIndex: number): string[] {
|
||||||
|
const raw = context.getNodeParameter('rawArgs', itemIndex) as string | string[];
|
||||||
|
const parsed = Array.isArray(raw) ? raw : JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) {
|
||||||
|
throw new NodeOperationError(context.getNode(), 'Args JSON must be an array of strings', { itemIndex });
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<svg fill="none" height="100%" width="100%" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<radialGradient id="logo-bottom-left" cx="0" cy="0" gradientTransform="matrix(-59 -225 150 -39 161.4 470)" gradientUnits="userSpaceOnUse" r="1">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity=".4"/>
|
||||||
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="logo-top-right" cx="0" cy="0" gradientTransform="matrix(50 -379 280 37 360 374.2)" gradientUnits="userSpaceOnUse" r="1">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity=".6"/>
|
||||||
|
<stop offset="1" stop-color="#fff" stop-opacity=".1"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="logo-top-left" cx="0" cy="0" gradientTransform="matrix(69 -319 218 47 175.4 307)" gradientUnits="userSpaceOnUse" r="1">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity=".8"/>
|
||||||
|
<stop offset="1" stop-color="#fff" stop-opacity=".4"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="logo-bottom-right" cx="0" cy="0" gradientTransform="matrix(-96 -163 187 -111 335.3 512.2)" gradientUnits="userSpaceOnUse" r="1">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity=".3"/>
|
||||||
|
<stop offset="1" stop-opacity=".3"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="logo-top-edge" cx="0" cy="0" gradientTransform="matrix(-36 166 -112 -24 310 128.2)" gradientUnits="userSpaceOnUse" r="1">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#fff" stop-opacity=".2"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="logo-left-edge" cx="0" cy="0" gradientTransform="matrix(88 89 -190 187 111 220.2)" gradientUnits="userSpaceOnUse" r="1">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity=".2"/>
|
||||||
|
<stop offset="1" stop-color="#fff" stop-opacity=".4"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="logo-bottom-edge" cx="0" cy="0" gradientTransform="matrix(9 130 -276 20 215 284)" gradientUnits="userSpaceOnUse" r="1">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity=".2"/>
|
||||||
|
<stop offset="1" stop-color="#fff" stop-opacity=".3"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="logo-middle-edge" cx="0" cy="0" gradientTransform="matrix(-198 -104 327 -623 400 399.2)" gradientUnits="userSpaceOnUse" r="1">
|
||||||
|
<stop offset="0" stop-color="#fff" stop-opacity=".2"/>
|
||||||
|
<stop offset=".5" stop-color="#fff" stop-opacity=".2"/>
|
||||||
|
<stop offset="1" stop-color="#fff" stop-opacity=".3"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="clip">
|
||||||
|
<path d="M.2.2h512v512H.2z"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#clip)">
|
||||||
|
<path d="M382.3 475.6c-3.1 23.4-26 41.6-48.7 35.3-32.4-8.9-69.9-22.8-103.6-25.4l-51.7-4a34 34 0 0 1-22-10.2l-89-91.7a34 34 0 0 1-6.7-37.7s55-121 57.1-127.3c2-6.3 9.6-61.2 14-90.6 1.2-7.9 5-15 11-20.3L248 8.9a34.1 34.1 0 0 1 49.6 4.3L386 125.6a37 37 0 0 1 7.6 22.4c0 21.3 1.8 65 13.6 93.2 11.5 27.3 32.5 57 43.5 71.5a17.3 17.3 0 0 1 1.3 19.2 1494 1494 0 0 1-44.8 70.6c-15 22.3-21.9 49.9-25 73.1z" fill="#6c31e3"/>
|
||||||
|
<path d="M165.9 478.3c41.4-84 40.2-144.2 22.6-187-16.2-39.6-46.3-64.5-70-80-.6 2.3-1.3 4.4-2.2 6.5L60.6 342a34 34 0 0 0 6.6 37.7l89.1 91.7a34 34 0 0 0 9.6 7z" fill="url(#logo-bottom-left)"/>
|
||||||
|
<path d="M278.4 307.8c11.2 1.2 22.2 3.6 32.8 7.6 34 12.7 65 41.2 90.5 96.3 1.8-3.1 3.6-6.2 5.6-9.2a1536 1536 0 0 0 44.8-70.6 17 17 0 0 0-1.3-19.2c-11-14.6-32-44.2-43.5-71.5-11.8-28.2-13.5-72-13.6-93.2 0-8.1-2.6-16-7.6-22.4L297.6 13.2a34 34 0 0 0-1.5-1.7 96 96 0 0 1 2 54 198.3 198.3 0 0 1-17.6 41.3l-7.2 14.2a171 171 0 0 0-19.4 71c-1.2 29.4 4.8 66.4 24.5 115.8z" fill="url(#logo-top-right)"/>
|
||||||
|
<path d="M278.4 307.8c-19.7-49.4-25.8-86.4-24.5-115.9a171 171 0 0 1 19.4-71c2.3-4.8 4.8-9.5 7.2-14.1 7.1-13.9 14-27 17.6-41.4a96 96 0 0 0-2-54A34.1 34.1 0 0 0 248 9l-105.4 94.8a34.1 34.1 0 0 0-10.9 20.3l-12.8 85-.5 2.3c23.8 15.5 54 40.4 70.1 80a147 147 0 0 1 7.8 24.8c28-6.8 55.7-11 82.1-8.3z" fill="url(#logo-top-left)"/>
|
||||||
|
<path d="M333.6 511c22.7 6.2 45.6-12 48.7-35.4a187 187 0 0 1 19.4-63.9c-25.6-55-56.5-83.6-90.4-96.3-36-13.4-75.2-9-115 .7 8.9 40.4 3.6 93.3-30.4 162.2 4 1.8 8.1 3 12.5 3.3 0 0 24.4 2 53.6 4.1 29 2 72.4 17.1 101.6 25.2z" fill="url(#logo-bottom-right)"/>
|
||||||
|
<g clip-rule="evenodd" fill-rule="evenodd">
|
||||||
|
<path d="M254.1 190c-1.3 29.2 2.4 62.8 22.1 112.1l-6.2-.5c-17.7-51.5-21.5-78-20.2-107.6a174.7 174.7 0 0 1 20.4-72c2.4-4.9 8-14.1 10.5-18.8 7.1-13.7 11.9-21 16-33.6 5.7-17.5 4.5-25.9 3.8-34.1 4.6 29.9-12.7 56-25.7 82.4a177.1 177.1 0 0 0-20.7 72z" fill="url(#logo-top-edge)"/>
|
||||||
|
<path d="M194.3 293.4c2.4 5.4 4.6 9.8 6 16.5L195 311c-2.1-7.8-3.8-13.4-6.8-20-17.8-42-46.3-63.6-69.7-79.5 28.2 15.2 57.2 39 75.7 81.9z" fill="url(#logo-left-edge)"/>
|
||||||
|
<path d="M200.6 315.1c9.8 46-1.2 104.2-33.6 160.9 27.1-56.2 40.2-110.1 29.3-160z" fill="url(#logo-bottom-edge)"/>
|
||||||
|
<path d="M312.5 311c53.1 19.9 73.6 63.6 88.9 100-19-38.1-45.2-80.3-90.8-96-34.8-11.8-64.1-10.4-114.3 1l-1.1-5c53.2-12.1 81-13.5 117.3 0z" fill="url(#logo-middle-edge)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
+2304
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "n8n-nodes-obsidian-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://git.yiprawr.dev/Docker/obsidian-api",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+ssh://git@git.yiprawr.dev/Docker/obsidian-api.git",
|
||||||
|
"directory": "packages/n8n-nodes-obsidian-api"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"n8n-community-node-package",
|
||||||
|
"n8n",
|
||||||
|
"obsidian",
|
||||||
|
"obsidian-cli",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json && mkdir -p dist/nodes/ObsidianApi && cp nodes/ObsidianApi/obsidian.svg dist/nodes/ObsidianApi/obsidian.svg",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"lint": "eslint nodes credentials shared --ext .ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"nodes",
|
||||||
|
"credentials",
|
||||||
|
"shared"
|
||||||
|
],
|
||||||
|
"n8n": {
|
||||||
|
"n8nNodesApiVersion": 1,
|
||||||
|
"credentials": [
|
||||||
|
"dist/credentials/ObsidianApi.credentials.js"
|
||||||
|
],
|
||||||
|
"nodes": [
|
||||||
|
"dist/nodes/ObsidianApi/ObsidianApi.node.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"eslint": "^9.29.0",
|
||||||
|
"n8n-workflow": "^1.82.0",
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"n8n-workflow": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type {
|
||||||
|
IExecuteFunctions,
|
||||||
|
IDataObject,
|
||||||
|
IHttpRequestOptions,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export interface ObsidianCommandResult extends IDataObject {
|
||||||
|
id: string;
|
||||||
|
command: string[];
|
||||||
|
cwd: string;
|
||||||
|
exit_code: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
duration_ms: number;
|
||||||
|
timed_out: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObsidianCredentials extends IDataObject {
|
||||||
|
baseUrl: string;
|
||||||
|
apiToken: string;
|
||||||
|
defaultVault?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compactArgs(args: Array<string | undefined | null | false>): string[] {
|
||||||
|
return args.filter((arg): arg is string => typeof arg === 'string' && arg.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kv(name: string, value: string | number | boolean | undefined | null): string | undefined {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
if (typeof value === 'boolean') return value ? name : undefined;
|
||||||
|
return `${name}=${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withVault(args: string[], vault?: string): string[] {
|
||||||
|
if (!vault || args[0]?.startsWith('vault=')) return args;
|
||||||
|
return [`vault=${vault}`, ...args];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeObsidianCommand(
|
||||||
|
context: IExecuteFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
args: string[],
|
||||||
|
options: { vault?: string; timeout?: number; stdin?: string } = {},
|
||||||
|
): Promise<ObsidianCommandResult> {
|
||||||
|
const credentials = await context.getCredentials('obsidianApi') as ObsidianCredentials;
|
||||||
|
const baseUrl = String(credentials.baseUrl).replace(/\/$/, '');
|
||||||
|
const vault = options.vault || String(credentials.defaultVault || '');
|
||||||
|
const finalArgs = withVault(args, vault);
|
||||||
|
|
||||||
|
const request: IHttpRequestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
url: `${baseUrl}/commands`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${credentials.apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
args: finalArgs,
|
||||||
|
stdin: options.stdin || undefined,
|
||||||
|
timeout: options.timeout,
|
||||||
|
},
|
||||||
|
json: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await context.helpers.httpRequest(request) as ObsidianCommandResult;
|
||||||
|
|
||||||
|
if (result.exit_code !== 0) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
context.getNode(),
|
||||||
|
`Obsidian CLI command failed with exit code ${result.exit_code}: ${result.stderr || result.stdout}`,
|
||||||
|
{ itemIndex },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["credentials/**/*.ts", "nodes/**/*.ts", "shared/**/*.ts", "index.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[project]
|
||||||
|
name = "obsidian-api"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "HTTP API wrapper for running Obsidian CLI commands from n8n, SDKs, or agent harnesses."
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"starlette==0.50.0",
|
||||||
|
"uvicorn[standard]==0.38.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest==9.0.2",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# @obsidian-api/sdk
|
||||||
|
TypeScript SDK for this HTTP wrapper around the **official Obsidian CLI**.
|
||||||
|
|
||||||
|
The SDK is built for Node 18+, n8n Code nodes, Prism, and other TypeScript agent harnesses.
|
||||||
|
|
||||||
|
## Install locally
|
||||||
|
```bash
|
||||||
|
npm install /path/to/obsidian-api/sdk/typescript
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```ts
|
||||||
|
import { ObsidianCliClient } from '@obsidian-api/sdk';
|
||||||
|
|
||||||
|
const obsidian = new ObsidianCliClient({
|
||||||
|
baseUrl: 'http://localhost:8080',
|
||||||
|
token: 'change-me',
|
||||||
|
});
|
||||||
|
|
||||||
|
await obsidian.create({
|
||||||
|
path: 'Inbox/Hello.md',
|
||||||
|
content: '# Hello from n8n/Prism',
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const note = await obsidian.read({ path: 'Inbox/Hello.md' });
|
||||||
|
console.log(note.stdout);
|
||||||
|
|
||||||
|
const results = await obsidian.search('Hello', { format: 'json' });
|
||||||
|
console.log(JSON.parse(results.stdout));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple vaults
|
||||||
|
Use the official CLI `vault=<name>` prefix directly:
|
||||||
|
```ts
|
||||||
|
await obsidian.runInVault('work', 'create', 'path=Inbox/Task.md', 'content=Hello');
|
||||||
|
```
|
||||||
|
|
||||||
|
Or create a scoped client:
|
||||||
|
```ts
|
||||||
|
const workVault = obsidian.inVault('work');
|
||||||
|
await workVault.create({ path: 'Inbox/Task.md', content: 'Hello', overwrite: true });
|
||||||
|
await workVault.read({ path: 'Inbox/Task.md' });
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also set a default vault in the constructor:
|
||||||
|
```ts
|
||||||
|
const obsidian = new ObsidianCliClient({
|
||||||
|
baseUrl: 'http://localhost:8080',
|
||||||
|
token: 'change-me',
|
||||||
|
vault: 'work',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Raw command escape hatch
|
||||||
|
Still safe: sends an args array, not a shell command string.
|
||||||
|
```ts
|
||||||
|
await obsidian.runRaw('create', 'name=Scratch', 'content=Test', 'overwrite');
|
||||||
|
```
|
||||||
Generated
+33
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@obsidian-api/sdk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@obsidian-api/sdk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@obsidian-api/sdk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "TypeScript SDK for the official Obsidian CLI HTTP wrapper. Built for n8n, Prism, and other TS/Node agent harnesses.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": ["dist", "src"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
export type OutputFormat = "json" | "tsv" | "csv" | "text" | "yaml" | "md" | "paths" | "tree";
|
||||||
|
export type PaneType = "tab" | "split" | "window";
|
||||||
|
export type PluginFilter = "core" | "community";
|
||||||
|
export type PropertyType = "text" | "list" | "number" | "checkbox" | "date" | "datetime";
|
||||||
|
export type HistoryFilter = "local" | "sync";
|
||||||
|
export type LogLevel = "log" | "warn" | "error" | "info" | "debug";
|
||||||
|
|
||||||
|
export interface CommandResult {
|
||||||
|
id: string;
|
||||||
|
command: string[];
|
||||||
|
cwd: string;
|
||||||
|
exit_code: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
duration_ms: number;
|
||||||
|
timed_out: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObsidianApiError extends Error {
|
||||||
|
constructor(public statusCode: number, public detail: string) {
|
||||||
|
super(`HTTP ${statusCode}: ${detail}`);
|
||||||
|
this.name = "ObsidianApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObsidianCliClientOptions {
|
||||||
|
baseUrl: string;
|
||||||
|
token?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** Optional default vault name/path. Sent as the official CLI `vault=<name>` prefix. */
|
||||||
|
vault?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParamValue = string | number | boolean | undefined | null;
|
||||||
|
|
||||||
|
function kv(name: string, value: ParamValue): string | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
if (typeof value === "boolean") return value ? name : undefined;
|
||||||
|
return `${name}=${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function args(...items: Array<string | undefined>): string[] {
|
||||||
|
return items.filter((item): item is string => item !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObsidianCliClient {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
readonly token?: string;
|
||||||
|
readonly timeoutMs: number;
|
||||||
|
readonly defaultVault?: string;
|
||||||
|
|
||||||
|
constructor(options: ObsidianCliClientOptions) {
|
||||||
|
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
||||||
|
this.token = options.token;
|
||||||
|
this.timeoutMs = options.timeoutMs ?? 60_000;
|
||||||
|
this.defaultVault = options.vault;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a scoped client that automatically prefixes every command with
|
||||||
|
* `vault=<name>`, matching the official Obsidian CLI multi-vault syntax.
|
||||||
|
*/
|
||||||
|
inVault(vault: string): ObsidianCliClient {
|
||||||
|
return new ObsidianCliClient({
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
token: this.token,
|
||||||
|
timeoutMs: this.timeoutMs,
|
||||||
|
vault,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(commandArgs: string[], options: { stdin?: string; timeoutMs?: number } = {}): Promise<CommandResult> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs + 5_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/commands`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
args: this.defaultVault && !commandArgs[0]?.startsWith("vault=") ? [`vault=${this.defaultVault}`, ...commandArgs] : commandArgs,
|
||||||
|
stdin: options.stdin,
|
||||||
|
timeout: Math.ceil(timeoutMs / 1000),
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as CommandResult | { detail?: string };
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ObsidianApiError(response.status, "detail" in data && data.detail ? data.detail : response.statusText);
|
||||||
|
}
|
||||||
|
return data as CommandResult;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runRaw(...commandArgs: string[]): Promise<CommandResult> {
|
||||||
|
return this.run(commandArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runInVault(vault: string, command: string, ...commandArgs: string[]): Promise<CommandResult> {
|
||||||
|
return this.run([`vault=${vault}`, command, ...commandArgs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// General
|
||||||
|
help = (command?: string) => this.run(args("help", command));
|
||||||
|
version = () => this.run(["version"]);
|
||||||
|
reload = () => this.run(["reload"]);
|
||||||
|
restart = () => this.run(["restart"]);
|
||||||
|
|
||||||
|
// Bases
|
||||||
|
bases = () => this.run(["bases"]);
|
||||||
|
baseViews = () => this.run(["base:views"]);
|
||||||
|
baseCreate = (o: { file?: string; path?: string; view?: string; name?: string; content?: string; open?: boolean; newtab?: boolean } = {}) =>
|
||||||
|
this.run(args("base:create", kv("file", o.file), kv("path", o.path), kv("view", o.view), kv("name", o.name), kv("content", o.content), kv("open", o.open), kv("newtab", o.newtab)));
|
||||||
|
baseQuery = (o: { file?: string; path?: string; view?: string; format?: "json" | "csv" | "tsv" | "md" | "paths" } = {}) =>
|
||||||
|
this.run(args("base:query", kv("file", o.file), kv("path", o.path), kv("view", o.view), kv("format", o.format)));
|
||||||
|
|
||||||
|
// Bookmarks / command palette / hotkeys
|
||||||
|
bookmarks = (o: { total?: boolean; verbose?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("bookmarks", kv("total", o.total), kv("verbose", o.verbose), kv("format", o.format)));
|
||||||
|
bookmark = (o: { file?: string; subpath?: string; folder?: string; search?: string; url?: string; title?: string }) => this.run(args("bookmark", kv("file", o.file), kv("subpath", o.subpath), kv("folder", o.folder), kv("search", o.search), kv("url", o.url), kv("title", o.title)));
|
||||||
|
commands = (o: { filter?: string } = {}) => this.run(args("commands", kv("filter", o.filter)));
|
||||||
|
command = (id: string) => this.run(["command", kv("id", id)!]);
|
||||||
|
hotkeys = (o: { total?: boolean; verbose?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("hotkeys", kv("total", o.total), kv("verbose", o.verbose), kv("format", o.format)));
|
||||||
|
hotkey = (id: string, o: { verbose?: boolean } = {}) => this.run(args("hotkey", kv("id", id), kv("verbose", o.verbose)));
|
||||||
|
|
||||||
|
// Daily notes
|
||||||
|
daily = (o: { paneType?: PaneType } = {}) => this.run(args("daily", kv("paneType", o.paneType)));
|
||||||
|
dailyPath = () => this.run(["daily:path"]);
|
||||||
|
dailyRead = () => this.run(["daily:read"]);
|
||||||
|
dailyAppend = (content: string, o: { paneType?: PaneType; inline?: boolean; open?: boolean } = {}) => this.run(args("daily:append", kv("content", content), kv("paneType", o.paneType), kv("inline", o.inline), kv("open", o.open)));
|
||||||
|
dailyPrepend = (content: string, o: { paneType?: PaneType; inline?: boolean; open?: boolean } = {}) => this.run(args("daily:prepend", kv("content", content), kv("paneType", o.paneType), kv("inline", o.inline), kv("open", o.open)));
|
||||||
|
|
||||||
|
// File history
|
||||||
|
diff = (o: { file?: string; path?: string; from?: number; to?: number; filter?: HistoryFilter } = {}) => this.run(args("diff", kv("file", o.file), kv("path", o.path), kv("from", o.from), kv("to", o.to), kv("filter", o.filter)));
|
||||||
|
history = (o: { file?: string; path?: string } = {}) => this.run(args("history", kv("file", o.file), kv("path", o.path)));
|
||||||
|
historyList = () => this.run(["history:list"]);
|
||||||
|
historyRead = (o: { file?: string; path?: string; version?: number } = {}) => this.run(args("history:read", kv("file", o.file), kv("path", o.path), kv("version", o.version)));
|
||||||
|
historyRestore = (version: number, o: { file?: string; path?: string } = {}) => this.run(args("history:restore", kv("file", o.file), kv("path", o.path), kv("version", version)));
|
||||||
|
historyOpen = (o: { file?: string; path?: string } = {}) => this.run(args("history:open", kv("file", o.file), kv("path", o.path)));
|
||||||
|
|
||||||
|
// Files and folders
|
||||||
|
file = (o: { file?: string; path?: string } = {}) => this.run(args("file", kv("file", o.file), kv("path", o.path)));
|
||||||
|
files = (o: { folder?: string; ext?: string; total?: boolean } = {}) => this.run(args("files", kv("folder", o.folder), kv("ext", o.ext), kv("total", o.total)));
|
||||||
|
folder = (path: string, o: { info?: "files" | "folders" | "size" } = {}) => this.run(args("folder", kv("path", path), kv("info", o.info)));
|
||||||
|
folders = (o: { folder?: string; total?: boolean } = {}) => this.run(args("folders", kv("folder", o.folder), kv("total", o.total)));
|
||||||
|
open = (o: { file?: string; path?: string; newtab?: boolean } = {}) => this.run(args("open", kv("file", o.file), kv("path", o.path), kv("newtab", o.newtab)));
|
||||||
|
create = (o: { name?: string; path?: string; content?: string; template?: string; overwrite?: boolean; open?: boolean; newtab?: boolean } = {}) => this.run(args("create", kv("name", o.name), kv("path", o.path), kv("content", o.content), kv("template", o.template), kv("overwrite", o.overwrite), kv("open", o.open), kv("newtab", o.newtab)));
|
||||||
|
read = (o: { file?: string; path?: string } = {}) => this.run(args("read", kv("file", o.file), kv("path", o.path)));
|
||||||
|
append = (content: string, o: { file?: string; path?: string; inline?: boolean } = {}) => this.run(args("append", kv("file", o.file), kv("path", o.path), kv("content", content), kv("inline", o.inline)));
|
||||||
|
prepend = (content: string, o: { file?: string; path?: string; inline?: boolean } = {}) => this.run(args("prepend", kv("file", o.file), kv("path", o.path), kv("content", content), kv("inline", o.inline)));
|
||||||
|
move = (to: string, o: { file?: string; path?: string } = {}) => this.run(args("move", kv("file", o.file), kv("path", o.path), kv("to", to)));
|
||||||
|
rename = (name: string, o: { file?: string; path?: string } = {}) => this.run(args("rename", kv("file", o.file), kv("path", o.path), kv("name", name)));
|
||||||
|
delete = (o: { file?: string; path?: string; permanent?: boolean } = {}) => this.run(args("delete", kv("file", o.file), kv("path", o.path), kv("permanent", o.permanent)));
|
||||||
|
|
||||||
|
// Links / outline
|
||||||
|
backlinks = (o: { file?: string; path?: string; counts?: boolean; total?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("backlinks", kv("file", o.file), kv("path", o.path), kv("counts", o.counts), kv("total", o.total), kv("format", o.format)));
|
||||||
|
links = (o: { file?: string; path?: string; total?: boolean } = {}) => this.run(args("links", kv("file", o.file), kv("path", o.path), kv("total", o.total)));
|
||||||
|
unresolved = (o: { total?: boolean; counts?: boolean; verbose?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("unresolved", kv("total", o.total), kv("counts", o.counts), kv("verbose", o.verbose), kv("format", o.format)));
|
||||||
|
orphans = (o: { total?: boolean } = {}) => this.run(args("orphans", kv("total", o.total)));
|
||||||
|
deadends = (o: { total?: boolean } = {}) => this.run(args("deadends", kv("total", o.total)));
|
||||||
|
outline = (o: { file?: string; path?: string; format?: "tree" | "md" | "json"; total?: boolean } = {}) => this.run(args("outline", kv("file", o.file), kv("path", o.path), kv("format", o.format), kv("total", o.total)));
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
plugins = (o: { filter?: PluginFilter; versions?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("plugins", kv("filter", o.filter), kv("versions", o.versions), kv("format", o.format)));
|
||||||
|
pluginsEnabled = (o: { filter?: PluginFilter; versions?: boolean; format?: "json" | "tsv" | "csv" } = {}) => this.run(args("plugins:enabled", kv("filter", o.filter), kv("versions", o.versions), kv("format", o.format)));
|
||||||
|
pluginsRestrict = (o: { on?: boolean; off?: boolean } = {}) => this.run(args("plugins:restrict", kv("on", o.on), kv("off", o.off)));
|
||||||
|
plugin = (id: string) => this.run(["plugin", kv("id", id)!]);
|
||||||
|
pluginEnable = (id: string, o: { filter?: PluginFilter } = {}) => this.run(args("plugin:enable", kv("id", id), kv("filter", o.filter)));
|
||||||
|
pluginDisable = (id: string, o: { filter?: PluginFilter } = {}) => this.run(args("plugin:disable", kv("id", id), kv("filter", o.filter)));
|
||||||
|
pluginInstall = (id: string, o: { enable?: boolean } = {}) => this.run(args("plugin:install", kv("id", id), kv("enable", o.enable)));
|
||||||
|
pluginUninstall = (id: string) => this.run(["plugin:uninstall", kv("id", id)!]);
|
||||||
|
pluginReload = (id: string) => this.run(["plugin:reload", kv("id", id)!]);
|
||||||
|
|
||||||
|
// Properties
|
||||||
|
aliases = (o: { file?: string; path?: string; total?: boolean; verbose?: boolean; active?: boolean } = {}) => this.run(args("aliases", kv("file", o.file), kv("path", o.path), kv("total", o.total), kv("verbose", o.verbose), kv("active", o.active)));
|
||||||
|
properties = (o: { file?: string; path?: string; name?: string; sort?: "count"; format?: "yaml" | "json" | "tsv"; total?: boolean; counts?: boolean; active?: boolean } = {}) => this.run(args("properties", kv("file", o.file), kv("path", o.path), kv("name", o.name), kv("sort", o.sort), kv("format", o.format), kv("total", o.total), kv("counts", o.counts), kv("active", o.active)));
|
||||||
|
propertySet = (name: string, value: string, o: { type?: PropertyType; file?: string; path?: string } = {}) => this.run(args("property:set", kv("name", name), kv("value", value), kv("type", o.type), kv("file", o.file), kv("path", o.path)));
|
||||||
|
propertyRemove = (name: string, o: { file?: string; path?: string } = {}) => this.run(args("property:remove", kv("name", name), kv("file", o.file), kv("path", o.path)));
|
||||||
|
propertyRead = (name: string, o: { file?: string; path?: string } = {}) => this.run(args("property:read", kv("name", name), kv("file", o.file), kv("path", o.path)));
|
||||||
|
|
||||||
|
// Publish
|
||||||
|
publishSite = () => this.run(["publish:site"]);
|
||||||
|
publishList = (o: { total?: boolean } = {}) => this.run(args("publish:list", kv("total", o.total)));
|
||||||
|
publishStatus = (o: { total?: boolean; new?: boolean; changed?: boolean; deleted?: boolean } = {}) => this.run(args("publish:status", kv("total", o.total), kv("new", o.new), kv("changed", o.changed), kv("deleted", o.deleted)));
|
||||||
|
publishAdd = (o: { file?: string; path?: string; changed?: boolean } = {}) => this.run(args("publish:add", kv("file", o.file), kv("path", o.path), kv("changed", o.changed)));
|
||||||
|
publishRemove = (o: { file?: string; path?: string } = {}) => this.run(args("publish:remove", kv("file", o.file), kv("path", o.path)));
|
||||||
|
publishOpen = (o: { file?: string; path?: string } = {}) => this.run(args("publish:open", kv("file", o.file), kv("path", o.path)));
|
||||||
|
|
||||||
|
// Random / search
|
||||||
|
random = (o: { folder?: string; newtab?: boolean } = {}) => this.run(args("random", kv("folder", o.folder), kv("newtab", o.newtab)));
|
||||||
|
randomRead = (o: { folder?: string } = {}) => this.run(args("random:read", kv("folder", o.folder)));
|
||||||
|
search = (query: string, o: { path?: string; limit?: number; format?: "text" | "json"; total?: boolean; case?: boolean } = {}) => this.run(args("search", kv("query", query), kv("path", o.path), kv("limit", o.limit), kv("format", o.format), kv("total", o.total), kv("case", o.case)));
|
||||||
|
searchContext = (query: string, o: { path?: string; limit?: number; format?: "text" | "json"; case?: boolean } = {}) => this.run(args("search:context", kv("query", query), kv("path", o.path), kv("limit", o.limit), kv("format", o.format), kv("case", o.case)));
|
||||||
|
searchOpen = (o: { query?: string } = {}) => this.run(args("search:open", kv("query", o.query)));
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
sync = (o: { on?: boolean; off?: boolean } = {}) => this.run(args("sync", kv("on", o.on), kv("off", o.off)));
|
||||||
|
syncStatus = () => this.run(["sync:status"]);
|
||||||
|
syncHistory = (o: { file?: string; path?: string; total?: boolean } = {}) => this.run(args("sync:history", kv("file", o.file), kv("path", o.path), kv("total", o.total)));
|
||||||
|
syncRead = (version: number, o: { file?: string; path?: string } = {}) => this.run(args("sync:read", kv("file", o.file), kv("path", o.path), kv("version", version)));
|
||||||
|
syncRestore = (version: number, o: { file?: string; path?: string } = {}) => this.run(args("sync:restore", kv("file", o.file), kv("path", o.path), kv("version", version)));
|
||||||
|
syncOpen = (o: { file?: string; path?: string } = {}) => this.run(args("sync:open", kv("file", o.file), kv("path", o.path)));
|
||||||
|
syncDeleted = (o: { total?: boolean } = {}) => this.run(args("sync:deleted", kv("total", o.total)));
|
||||||
|
|
||||||
|
// Tags / tasks
|
||||||
|
tags = (o: { file?: string; path?: string; sort?: "count"; total?: boolean; counts?: boolean; format?: "json" | "tsv" | "csv"; active?: boolean } = {}) => this.run(args("tags", kv("file", o.file), kv("path", o.path), kv("sort", o.sort), kv("total", o.total), kv("counts", o.counts), kv("format", o.format), kv("active", o.active)));
|
||||||
|
tag = (name: string, o: { total?: boolean; verbose?: boolean } = {}) => this.run(args("tag", kv("name", name), kv("total", o.total), kv("verbose", o.verbose)));
|
||||||
|
tasks = (o: { file?: string; path?: string; status?: string; total?: boolean; done?: boolean; todo?: boolean; verbose?: boolean; format?: "json" | "tsv" | "csv"; active?: boolean; daily?: boolean } = {}) => this.run(args("tasks", kv("file", o.file), kv("path", o.path), kv("status", o.status), kv("total", o.total), kv("done", o.done), kv("todo", o.todo), kv("verbose", o.verbose), kv("format", o.format), kv("active", o.active), kv("daily", o.daily)));
|
||||||
|
task = (o: { ref?: string; file?: string; path?: string; line?: number; status?: string; toggle?: boolean; daily?: boolean; done?: boolean; todo?: boolean } = {}) => this.run(args("task", kv("ref", o.ref), kv("file", o.file), kv("path", o.path), kv("line", o.line), kv("status", o.status), kv("toggle", o.toggle), kv("daily", o.daily), kv("done", o.done), kv("todo", o.todo)));
|
||||||
|
|
||||||
|
// Templates / themes / snippets
|
||||||
|
templates = (o: { total?: boolean } = {}) => this.run(args("templates", kv("total", o.total)));
|
||||||
|
templateRead = (name: string, o: { title?: string; resolve?: boolean } = {}) => this.run(args("template:read", kv("name", name), kv("title", o.title), kv("resolve", o.resolve)));
|
||||||
|
templateInsert = (name: string) => this.run(["template:insert", kv("name", name)!]);
|
||||||
|
themes = (o: { versions?: boolean } = {}) => this.run(args("themes", kv("versions", o.versions)));
|
||||||
|
theme = (o: { name?: string } = {}) => this.run(args("theme", kv("name", o.name)));
|
||||||
|
themeSet = (name: string) => this.run(["theme:set", kv("name", name)!]);
|
||||||
|
themeInstall = (name: string, o: { enable?: boolean } = {}) => this.run(args("theme:install", kv("name", name), kv("enable", o.enable)));
|
||||||
|
themeUninstall = (name: string) => this.run(["theme:uninstall", kv("name", name)!]);
|
||||||
|
snippets = () => this.run(["snippets"]);
|
||||||
|
snippetsEnabled = () => this.run(["snippets:enabled"]);
|
||||||
|
snippetEnable = (name: string) => this.run(["snippet:enable", kv("name", name)!]);
|
||||||
|
snippetDisable = (name: string) => this.run(["snippet:disable", kv("name", name)!]);
|
||||||
|
|
||||||
|
// Unique / vault / web / wordcount / workspace
|
||||||
|
unique = (o: { name?: string; content?: string; paneType?: PaneType; open?: boolean } = {}) => this.run(args("unique", kv("name", o.name), kv("content", o.content), kv("paneType", o.paneType), kv("open", o.open)));
|
||||||
|
vault = (o: { info?: "name" | "path" | "files" | "folders" | "size" } = {}) => this.run(args("vault", kv("info", o.info)));
|
||||||
|
vaults = (o: { total?: boolean; verbose?: boolean } = {}) => this.run(args("vaults", kv("total", o.total), kv("verbose", o.verbose)));
|
||||||
|
vaultOpen = (name: string) => this.run(["vault:open", kv("name", name)!]);
|
||||||
|
web = (url: string, o: { newtab?: boolean } = {}) => this.run(args("web", kv("url", url), kv("newtab", o.newtab)));
|
||||||
|
wordcount = (o: { file?: string; path?: string; words?: boolean; characters?: boolean } = {}) => this.run(args("wordcount", kv("file", o.file), kv("path", o.path), kv("words", o.words), kv("characters", o.characters)));
|
||||||
|
workspace = (o: { ids?: boolean } = {}) => this.run(args("workspace", kv("ids", o.ids)));
|
||||||
|
workspaces = (o: { total?: boolean } = {}) => this.run(args("workspaces", kv("total", o.total)));
|
||||||
|
workspaceSave = (name: string) => this.run(["workspace:save", kv("name", name)!]);
|
||||||
|
workspaceLoad = (name: string) => this.run(["workspace:load", kv("name", name)!]);
|
||||||
|
workspaceDelete = (name: string) => this.run(["workspace:delete", kv("name", name)!]);
|
||||||
|
tabs = (o: { ids?: boolean } = {}) => this.run(args("tabs", kv("ids", o.ids)));
|
||||||
|
tabOpen = (o: { group?: string; file?: string; view?: string } = {}) => this.run(args("tab:open", kv("group", o.group), kv("file", o.file), kv("view", o.view)));
|
||||||
|
recents = (o: { total?: boolean } = {}) => this.run(args("recents", kv("total", o.total)));
|
||||||
|
|
||||||
|
// Developer commands
|
||||||
|
devtools = () => this.run(["devtools"]);
|
||||||
|
devDebug = (o: { on?: boolean; off?: boolean } = {}) => this.run(args("dev:debug", kv("on", o.on), kv("off", o.off)));
|
||||||
|
devCdp = (method: string, o: { params?: string } = {}) => this.run(args("dev:cdp", kv("method", method), kv("params", o.params)));
|
||||||
|
devErrors = (o: { clear?: boolean } = {}) => this.run(args("dev:errors", kv("clear", o.clear)));
|
||||||
|
devScreenshot = (o: { path?: string } = {}) => this.run(args("dev:screenshot", kv("path", o.path)));
|
||||||
|
devConsole = (o: { limit?: number; level?: LogLevel; clear?: boolean } = {}) => this.run(args("dev:console", kv("limit", o.limit), kv("level", o.level), kv("clear", o.clear)));
|
||||||
|
devCss = (selector: string, o: { prop?: string } = {}) => this.run(args("dev:css", kv("selector", selector), kv("prop", o.prop)));
|
||||||
|
devDom = (selector: string, o: { attr?: string; css?: string; total?: boolean; text?: boolean; inner?: boolean; all?: boolean } = {}) => this.run(args("dev:dom", kv("selector", selector), kv("attr", o.attr), kv("css", o.css), kv("total", o.total), kv("text", o.text), kv("inner", o.inner), kv("all", o.all)));
|
||||||
|
devMobile = (o: { on?: boolean; off?: boolean } = {}) => this.run(args("dev:mobile", kv("on", o.on), kv("off", o.off)));
|
||||||
|
eval = (code: string) => this.run(["eval", kv("code", code)!]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import hashlib, base64, json, hmac
|
||||||
|
import time, os
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
os.environ.setdefault("OBSIDIAN_JWT_SECRET", "test-jwt-secret")
|
||||||
|
os.environ.setdefault("OBSIDIAN_CLI_BIN", "python")
|
||||||
|
os.environ.setdefault("OBSIDIAN_VAULT_PATH", ".")
|
||||||
|
|
||||||
|
import app.auth as authmod # noqa: E402
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
def make_jwt(claims:dict, secret:bytes = b"test-jwt-secret") -> str:
|
||||||
|
header = {"alg": "HS256", "typ": "JWT"}
|
||||||
|
|
||||||
|
def enc(value:dict) -> str:
|
||||||
|
raw = json.dumps(value, separators=(",", ":")).encode()
|
||||||
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
signing_input = f"{enc(header)}.{enc(claims)}"
|
||||||
|
signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
|
||||||
|
return f"{signing_input}.{base64.urlsafe_b64encode(signature).decode().rstrip('=')}"
|
||||||
|
|
||||||
|
def jwt(claims:dict|None = None) -> str:
|
||||||
|
claims = claims or {}
|
||||||
|
claims.setdefault("sub", "tests")
|
||||||
|
claims.setdefault("vaults", ["*"])
|
||||||
|
claims.setdefault("paths", ["*"])
|
||||||
|
claims.setdefault("commands", ["*"])
|
||||||
|
claims.setdefault("exp", int(time.time()) + 3600)
|
||||||
|
return make_jwt(claims)
|
||||||
|
|
||||||
|
def use_jwt_auth(monkeypatch, claims:dict) -> str:
|
||||||
|
monkeypatch.setattr(authmod, "JWT_SECRET", "test-jwt-secret")
|
||||||
|
return jwt(claims)
|
||||||
|
|
||||||
|
def test_health():
|
||||||
|
response = client.get("/health")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert "vault" in body
|
||||||
|
assert body["cli"] == "python"
|
||||||
|
assert body["jwt"] is True
|
||||||
|
|
||||||
|
def test_command_requires_auth():
|
||||||
|
response = client.post("/commands", json={"args": ["--version"]})
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_command_runs_configured_cli_only():
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {jwt()}"},
|
||||||
|
json={"args": ["--version"], "timeout": 5},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["exit_code"] == 0
|
||||||
|
assert body["command"] == ["python", "--version"]
|
||||||
|
assert "Python" in body["stdout"]
|
||||||
|
assert body["timed_out"] is False
|
||||||
|
|
||||||
|
def test_plain_command_string_is_rejected():
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {jwt()}"},
|
||||||
|
json={"command": "--version"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "command strings are not allowed; use args as a list of strings"
|
||||||
|
|
||||||
|
def test_shell_mode_is_rejected():
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {jwt()}"},
|
||||||
|
json={"mode": "shell", "args": ["echo nope"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"] == "mode is not allowed; only the configured Obsidian CLI can be executed"
|
||||||
|
|
||||||
|
def test_command_timeout():
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {jwt()}"},
|
||||||
|
json={"args": ["-c", "import time; time.sleep(2)"], "timeout": 0.1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["exit_code"] == 124
|
||||||
|
assert body["timed_out"] is True
|
||||||
|
|
||||||
|
def test_jwt_claims_become_policy():
|
||||||
|
token = jwt({"sub": "file", "vaults": ["work"], "paths": ["Inbox/"], "commands": ["read"]})
|
||||||
|
claims = authmod.decode_hs256_jwt(token, b"test-jwt-secret")
|
||||||
|
policy = authmod.policy_from_jwt_claims(token, claims)
|
||||||
|
|
||||||
|
assert policy.token == token
|
||||||
|
assert policy.name == "file"
|
||||||
|
assert policy.vaults == ("work",)
|
||||||
|
assert policy.paths == ("Inbox/",)
|
||||||
|
assert policy.commands == ("read",)
|
||||||
|
|
||||||
|
def test_token_policy_restricts_command(monkeypatch):
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "limited", "commands": ["read"], "vaults": ["*"], "paths": ["*"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["create", "path=Inbox/Nope.md", "content=Nope"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"] == "token is not allowed to run command: create"
|
||||||
|
|
||||||
|
def test_token_policy_restricts_vault(monkeypatch):
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "work", "commands": ["*"], "vaults": ["work"], "paths": ["*"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["vault=personal", "read", "path=Inbox/Test.md"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"] == "token is not allowed to access vault: personal"
|
||||||
|
|
||||||
|
def test_token_policy_restricts_path(monkeypatch):
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["read", "path=Private/Secret.md"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"] == "token is not allowed to access path: Private/Secret.md"
|
||||||
|
|
||||||
|
def test_token_policy_allows_allowed_path(monkeypatch):
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["-c", "print('ok')"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["read", "path=Inbox/Allowed.md"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# The authorization layer allows this. The fake Python CLI then fails because
|
||||||
|
# "read" is not a Python flag, which is fine for this policy test.
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"]
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.14.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.6.17"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "obsidian-api"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "starlette", specifier = "==0.50.0" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = "==0.38.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
|
{ name = "pytest", specifier = "==9.0.2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "0.50.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.38.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user