feat(properties): prepare property commands before execution
Build and Push Docker Container / build-and-push (push) Successful in 4m28s
Build and Push Docker Container / build-and-push (push) Successful in 4m28s
- Create missing note files before running property:set. - Default date-only datetime values to midnight. - Keep property names out of path authorization checks. - Split policy, vault, daily note, and command target helpers. - Split n8n node args, description, output, and API response helpers. - Split API tests by auth, command, daily note, and property behavior. - Bump API and n8n node versions.
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandTarget:
|
||||||
|
command:str|None
|
||||||
|
vault:str|None
|
||||||
|
paths:tuple[str, ...]
|
||||||
|
|
||||||
|
DEFAULT_PATH_KEYS = {"path", "file", "folder", "to"}
|
||||||
|
PATH_KEYS_BY_COMMAND = {
|
||||||
|
"rename": DEFAULT_PATH_KEYS | {"name"},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 path_keys_for_command(command:str|None) -> set[str]:
|
||||||
|
return PATH_KEYS_BY_COMMAND.get(command or "", DEFAULT_PATH_KEYS)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
path_keys = path_keys_for_command(command)
|
||||||
|
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))
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
|
||||||
|
def moment_to_strftime(format_string:str) -> str:
|
||||||
|
replacements = [
|
||||||
|
("YYYY", "%Y"),
|
||||||
|
("YY", "%y"),
|
||||||
|
("MMMM", "%B"),
|
||||||
|
("MMM", "%b"),
|
||||||
|
("MM", "%m"),
|
||||||
|
("DDDD", "%j"),
|
||||||
|
("DD", "%d"),
|
||||||
|
("dddd", "%A"),
|
||||||
|
("ddd", "%a"),
|
||||||
|
]
|
||||||
|
converted = format_string or "YYYY-MM-DD"
|
||||||
|
for moment_token, strftime_token in replacements:
|
||||||
|
converted = converted.replace(moment_token, strftime_token)
|
||||||
|
return converted
|
||||||
|
|
||||||
|
def daily_note_path_for_vault(vault_root:Path, now:datetime|None = None) -> str:
|
||||||
|
settings_path = vault_root / ".obsidian" / "daily-notes.json"
|
||||||
|
folder = ""
|
||||||
|
format_string = "YYYY-MM-DD"
|
||||||
|
|
||||||
|
if settings_path.exists() and settings_path.is_file():
|
||||||
|
try:
|
||||||
|
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(settings, dict):
|
||||||
|
folder_value = settings.get("folder")
|
||||||
|
format_value = settings.get("format")
|
||||||
|
if isinstance(folder_value, str):
|
||||||
|
folder = folder_value.strip().strip("/")
|
||||||
|
if isinstance(format_value, str) and format_value.strip():
|
||||||
|
format_string = format_value.strip()
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
note_name = (now or datetime.now()).strftime(moment_to_strftime(format_string))
|
||||||
|
if not note_name.endswith(".md"):
|
||||||
|
note_name += ".md"
|
||||||
|
return f"{folder}/{note_name}" if folder else note_name
|
||||||
+2
-1
@@ -10,7 +10,8 @@ from starlette.routing import Route
|
|||||||
from app import auth
|
from app import auth
|
||||||
from app.auth import require_auth
|
from app.auth import require_auth
|
||||||
from app.config import CLI_BIN, DEFAULT_TIMEOUT, HOST, MAX_TIMEOUT, PORT, VAULT_PATH
|
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.policy import authorize_command
|
||||||
|
from app.vaults import vault_for_path
|
||||||
from app.runner import execute_command
|
from app.runner import execute_command
|
||||||
|
|
||||||
def error(status_code:int, detail:str) -> JSONResponse:
|
def error(status_code:int, detail:str) -> JSONResponse:
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
+5
-139
@@ -1,10 +1,12 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import json
|
|
||||||
|
|
||||||
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
|
from app.command_target import CommandTarget, extract_command_target, parse_kv_arg
|
||||||
|
from app.config import DEFAULT_VAULT_NAME
|
||||||
|
from app.daily_notes import daily_note_path_for_vault
|
||||||
|
from app.path_policy import is_path_allowed, normalize_policy_path
|
||||||
|
from app.vaults import vault_for_path, vault_root_for_target
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TokenPolicy:
|
class TokenPolicy:
|
||||||
@@ -23,16 +25,6 @@ class TokenPolicy:
|
|||||||
def allows_all_commands(self) -> bool:
|
def allows_all_commands(self) -> bool:
|
||||||
return "*" in self.commands
|
return "*" in self.commands
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CommandTarget:
|
|
||||||
command:str|None
|
|
||||||
vault:str|None
|
|
||||||
paths:tuple[str, ...]
|
|
||||||
|
|
||||||
DEFAULT_PATH_KEYS = {"path", "file", "folder", "to"}
|
|
||||||
PATH_KEYS_BY_COMMAND = {
|
|
||||||
"rename": DEFAULT_PATH_KEYS | {"name"},
|
|
||||||
}
|
|
||||||
PATH_REQUIRED_WHEN_RESTRICTED = {
|
PATH_REQUIRED_WHEN_RESTRICTED = {
|
||||||
"append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff",
|
"append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff",
|
||||||
"search", "search:context", "files", "folders",
|
"search", "search:context", "files", "folders",
|
||||||
@@ -51,132 +43,6 @@ def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]:
|
|||||||
return tuple(value)
|
return tuple(value)
|
||||||
raise ValueError("JWT claims vaults, paths and commands must be strings or string arrays")
|
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 path_keys_for_command(command:str|None) -> set[str]:
|
|
||||||
return PATH_KEYS_BY_COMMAND.get(command or "", DEFAULT_PATH_KEYS)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
path_keys = path_keys_for_command(command)
|
|
||||||
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 moment_to_strftime(format_string:str) -> str:
|
|
||||||
replacements = [
|
|
||||||
("YYYY", "%Y"),
|
|
||||||
("YY", "%y"),
|
|
||||||
("MMMM", "%B"),
|
|
||||||
("MMM", "%b"),
|
|
||||||
("MM", "%m"),
|
|
||||||
("DDDD", "%j"),
|
|
||||||
("DD", "%d"),
|
|
||||||
("dddd", "%A"),
|
|
||||||
("ddd", "%a"),
|
|
||||||
]
|
|
||||||
converted = format_string or "YYYY-MM-DD"
|
|
||||||
for moment_token, strftime_token in replacements:
|
|
||||||
converted = converted.replace(moment_token, strftime_token)
|
|
||||||
return converted
|
|
||||||
|
|
||||||
def daily_note_path_for_vault(vault_root:Path, now:datetime|None = None) -> str:
|
|
||||||
settings_path = vault_root / ".obsidian" / "daily-notes.json"
|
|
||||||
folder = ""
|
|
||||||
format_string = "YYYY-MM-DD"
|
|
||||||
|
|
||||||
if settings_path.exists() and settings_path.is_file():
|
|
||||||
try:
|
|
||||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
||||||
if isinstance(settings, dict):
|
|
||||||
folder_value = settings.get("folder")
|
|
||||||
format_value = settings.get("format")
|
|
||||||
if isinstance(folder_value, str):
|
|
||||||
folder = folder_value.strip().strip("/")
|
|
||||||
if isinstance(format_value, str) and format_value.strip():
|
|
||||||
format_string = format_value.strip()
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
note_name = (now or datetime.now()).strftime(moment_to_strftime(format_string))
|
|
||||||
if not note_name.endswith(".md"):
|
|
||||||
note_name += ".md"
|
|
||||||
return f"{folder}/{note_name}" if folder else note_name
|
|
||||||
|
|
||||||
def vault_root_for_target(target:CommandTarget, cwd:Path) -> Path:
|
|
||||||
if target.vault and target.vault in REGISTERED_VAULTS:
|
|
||||||
return Path(REGISTERED_VAULTS[target.vault]).resolve()
|
|
||||||
|
|
||||||
vault_name = vault_for_path(cwd) or DEFAULT_VAULT_NAME
|
|
||||||
return Path(REGISTERED_VAULTS.get(vault_name, cwd)).resolve()
|
|
||||||
|
|
||||||
def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None:
|
def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None:
|
||||||
if policy is None:
|
if policy is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.command_target import extract_command_target, parse_kv_arg
|
||||||
|
from app.vaults import vault_root_for_target
|
||||||
|
|
||||||
|
DATE_ONLY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T?$")
|
||||||
|
|
||||||
|
def arg_value(args:list[str], name:str) -> str|None:
|
||||||
|
for arg in args:
|
||||||
|
parsed = parse_kv_arg(arg)
|
||||||
|
if parsed and parsed[0] == name and parsed[1]:
|
||||||
|
return parsed[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def normalize_property_datetime_args(args:list[str]) -> list[str]:
|
||||||
|
target = extract_command_target(args)
|
||||||
|
if target.command != "property:set" or arg_value(args, "type") != "datetime":
|
||||||
|
return args
|
||||||
|
|
||||||
|
value = arg_value(args, "value")
|
||||||
|
if not value or not DATE_ONLY_RE.match(value):
|
||||||
|
return args
|
||||||
|
|
||||||
|
normalized_value = f"{value.rstrip('T')}T00:00:00"
|
||||||
|
return [f"value={normalized_value}" if parse_kv_arg(arg) == ("value", value) else arg for arg in args]
|
||||||
|
|
||||||
|
def vault_note_path(vault_root:Path, path:str) -> Path:
|
||||||
|
note_path = (vault_root / path).resolve()
|
||||||
|
try:
|
||||||
|
note_path.relative_to(vault_root.resolve())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise PermissionError(f"path escapes vault: {path}") from exc
|
||||||
|
return note_path
|
||||||
|
|
||||||
|
def create_missing_property_file(args:list[str], cwd:Path) -> None:
|
||||||
|
target = extract_command_target(args)
|
||||||
|
if target.command != "property:set":
|
||||||
|
return
|
||||||
|
|
||||||
|
path = arg_value(args, "path")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
|
||||||
|
note_path = vault_note_path(vault_root_for_target(target, cwd), path)
|
||||||
|
if note_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
note_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
note_path.write_text("", encoding="utf-8")
|
||||||
|
|
||||||
|
def prepare_property_args(args:list[str], cwd:Path) -> list[str]:
|
||||||
|
args = normalize_property_datetime_args(args)
|
||||||
|
create_missing_property_file(args, cwd)
|
||||||
|
return args
|
||||||
@@ -4,12 +4,20 @@ from pathlib import Path
|
|||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from app.config import CLI_BIN
|
from app.config import CLI_BIN
|
||||||
|
from app.property_commands import prepare_property_args
|
||||||
|
|
||||||
def error(status_code:int, detail:str) -> JSONResponse:
|
def error(status_code:int, detail:str) -> JSONResponse:
|
||||||
return JSONResponse({"detail": detail}, status_code=status_code)
|
return JSONResponse({"detail": detail}, status_code=status_code)
|
||||||
|
|
||||||
async def execute_command(args:list[str], cwd:Path, stdin:str|None, timeout:float) -> JSONResponse:
|
async def execute_command(args:list[str], cwd:Path, stdin:str|None, timeout:float) -> JSONResponse:
|
||||||
command_id = str(uuid.uuid4())
|
command_id = str(uuid.uuid4())
|
||||||
|
try:
|
||||||
|
args = prepare_property_args(args, cwd)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return error(403, str(exc))
|
||||||
|
except OSError as exc:
|
||||||
|
return error(500, f"failed to prepare property command: {exc}")
|
||||||
|
|
||||||
command = [CLI_BIN, *args]
|
command = [CLI_BIN, *args]
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
|
||||||
|
from app.command_target import CommandTarget
|
||||||
|
|
||||||
|
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 vault_root_for_target(target:CommandTarget, cwd:Path) -> Path:
|
||||||
|
if target.vault and target.vault in REGISTERED_VAULTS:
|
||||||
|
return Path(REGISTERED_VAULTS[target.vault]).resolve()
|
||||||
|
|
||||||
|
vault_name = vault_for_path(cwd) or DEFAULT_VAULT_NAME
|
||||||
|
return Path(REGISTERED_VAULTS.get(vault_name, cwd)).resolve()
|
||||||
@@ -4,294 +4,14 @@ import type {
|
|||||||
INodeType,
|
INodeType,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeOperationError } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import { compactArgs, executeObsidianCommand, kv } from '../../shared/ObsidianApiClient';
|
import { executeObsidianCommand } from '../../shared/ObsidianApiClient';
|
||||||
|
import { buildArgs } from './argBuilders';
|
||||||
|
import { nodeDescription } from './description';
|
||||||
|
import { outputForResult } from './output';
|
||||||
|
|
||||||
export class ObsidianApi implements INodeType {
|
export class ObsidianApi implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = nodeDescription;
|
||||||
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: 'Resource',
|
|
||||||
name: 'resource',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
options: [
|
|
||||||
{ name: 'Daily Note', value: 'daily' },
|
|
||||||
{ name: 'Note', value: 'note' },
|
|
||||||
{ name: 'Property', value: 'property' },
|
|
||||||
{ 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,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Property operations
|
|
||||||
{
|
|
||||||
displayName: 'Operation',
|
|
||||||
name: 'operation',
|
|
||||||
type: 'options',
|
|
||||||
noDataExpression: true,
|
|
||||||
displayOptions: { show: { resource: ['property'] } },
|
|
||||||
options: [
|
|
||||||
{ name: 'List', value: 'properties' },
|
|
||||||
{ name: 'Read', value: 'propertyRead' },
|
|
||||||
{ name: 'Remove', value: 'propertyRemove' },
|
|
||||||
{ name: 'Set', value: 'propertySet' },
|
|
||||||
],
|
|
||||||
default: 'propertySet',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Path',
|
|
||||||
name: 'propertyPath',
|
|
||||||
type: 'string',
|
|
||||||
displayOptions: { show: { resource: ['property'], operation: ['propertyRead', 'propertyRemove', 'propertySet'] } },
|
|
||||||
default: '',
|
|
||||||
placeholder: 'Inbox/Note.md',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Name',
|
|
||||||
name: 'propertyName',
|
|
||||||
type: 'string',
|
|
||||||
displayOptions: { show: { resource: ['property'], operation: ['propertyRead', 'propertyRemove', 'propertySet', 'properties'] } },
|
|
||||||
default: '',
|
|
||||||
placeholder: 'tags',
|
|
||||||
description: 'Frontmatter/property name, e.g. tags, date, description',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Value',
|
|
||||||
name: 'propertyValue',
|
|
||||||
type: 'string',
|
|
||||||
displayOptions: { show: { resource: ['property'], operation: ['propertySet'] } },
|
|
||||||
default: '',
|
|
||||||
placeholder: 'kontext, feedback-browser-cli-tests',
|
|
||||||
description: 'Value passed to the official CLI property:set command',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Type',
|
|
||||||
name: 'propertyType',
|
|
||||||
type: 'options',
|
|
||||||
displayOptions: { show: { resource: ['property'], operation: ['propertySet'] } },
|
|
||||||
options: [
|
|
||||||
{ name: 'Text', value: 'text' },
|
|
||||||
{ name: 'List', value: 'list' },
|
|
||||||
{ name: 'Number', value: 'number' },
|
|
||||||
{ name: 'Checkbox', value: 'checkbox' },
|
|
||||||
{ name: 'Date', value: 'date' },
|
|
||||||
{ name: 'Date Time', value: 'datetime' },
|
|
||||||
],
|
|
||||||
default: 'text',
|
|
||||||
description: 'Obsidian property type. Use List for tags and Date for date.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Format',
|
|
||||||
name: 'propertiesFormat',
|
|
||||||
type: 'options',
|
|
||||||
displayOptions: { show: { resource: ['property'], operation: ['properties'] } },
|
|
||||||
options: [
|
|
||||||
{ name: 'YAML', value: 'yaml' },
|
|
||||||
{ name: 'JSON', value: 'json' },
|
|
||||||
{ name: 'TSV', value: 'tsv' },
|
|
||||||
],
|
|
||||||
default: 'yaml',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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: 'Additional Fields',
|
|
||||||
name: 'additionalFields',
|
|
||||||
type: 'collection',
|
|
||||||
placeholder: 'Add Field',
|
|
||||||
default: {},
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
displayName: 'Timeout Seconds',
|
|
||||||
name: 'timeout',
|
|
||||||
type: 'number',
|
|
||||||
default: 60,
|
|
||||||
description: 'Command timeout in seconds',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Vault',
|
|
||||||
name: 'vault',
|
|
||||||
type: 'string',
|
|
||||||
default: '',
|
|
||||||
placeholder: 'work',
|
|
||||||
description: 'Optional vault name/path. Overrides the credential default vault. Sent as vault=<value>.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
displayName: 'Verbose',
|
|
||||||
name: 'verbose',
|
|
||||||
type: 'boolean',
|
|
||||||
default: true,
|
|
||||||
description: 'Only used by the List Vaults operation',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||||
const items = this.getInputData();
|
const items = this.getInputData();
|
||||||
@@ -304,98 +24,10 @@ export class ObsidianApi implements INodeType {
|
|||||||
const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as Record<string, unknown>;
|
const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as Record<string, unknown>;
|
||||||
const vault = String(additionalFields.vault || '');
|
const vault = String(additionalFields.vault || '');
|
||||||
const timeout = Number(additionalFields.timeout ?? 60);
|
const timeout = Number(additionalFields.timeout ?? 60);
|
||||||
|
const args = buildArgs(this, resource, operation, itemIndex, additionalFields);
|
||||||
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 'property': {
|
|
||||||
args = buildPropertyArgs(this, operation, itemIndex);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'vault': {
|
|
||||||
args = buildVaultArgs(this, operation, itemIndex, additionalFields);
|
|
||||||
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 });
|
const result = await executeObsidianCommand(this, itemIndex, args, { vault, timeout });
|
||||||
|
|
||||||
// Always keep n8n output minimal/clean.
|
returnData.push(outputForResult(result, resource, operation, itemIndex));
|
||||||
// - Prefer parsed stdout (e.g. vault/vaults)
|
|
||||||
// - Otherwise keep stdout as-is
|
|
||||||
// Build a stable, minimal output shape for n8n.
|
|
||||||
// We try to expose the single most useful field name per operation.
|
|
||||||
const stdout = result.stdout ?? '';
|
|
||||||
|
|
||||||
// Daily notes
|
|
||||||
if (resource === 'daily' && operation === 'dailyPath') {
|
|
||||||
const out: any = { path: stdout };
|
|
||||||
if (result.stderr) out.stderr = result.stderr;
|
|
||||||
if (result.timed_out) out.timed_out = result.timed_out;
|
|
||||||
returnData.push({ json: out, pairedItem: { item: itemIndex } });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (resource === 'daily' && operation === 'dailyRead') {
|
|
||||||
const out: any = { content: stdout };
|
|
||||||
if (result.stderr) out.stderr = result.stderr;
|
|
||||||
if (result.timed_out) out.timed_out = result.timed_out;
|
|
||||||
returnData.push({ json: out, pairedItem: { item: itemIndex } });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notes
|
|
||||||
if (resource === 'note' && operation === 'read') {
|
|
||||||
const out: any = { content: stdout };
|
|
||||||
if (result.stderr) out.stderr = result.stderr;
|
|
||||||
if (result.timed_out) out.timed_out = result.timed_out;
|
|
||||||
returnData.push({ json: out, pairedItem: { item: itemIndex } });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Properties: keep CLI output because read/list return useful data.
|
|
||||||
if (resource === 'property' && (operation === 'propertyRead' || operation === 'properties')) {
|
|
||||||
const out: any = { content: stdout };
|
|
||||||
if (result.stderr) out.stderr = result.stderr;
|
|
||||||
if (result.timed_out) out.timed_out = result.timed_out;
|
|
||||||
returnData.push({ json: out, pairedItem: { item: itemIndex } });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// vault and vaults are parsed as structured JSON
|
|
||||||
const parsed = (result as any).parsed_stdout;
|
|
||||||
if (parsed) {
|
|
||||||
const out: any = parsed;
|
|
||||||
if (result.timed_out) out.timed_out = result.timed_out;
|
|
||||||
if (result.stderr) out.stderr = result.stderr;
|
|
||||||
returnData.push({ json: out, pairedItem: { item: itemIndex } });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything else (writes, deletes, rename, list files, etc.):
|
|
||||||
// keep output clean/stable and only confirm success.
|
|
||||||
const out: any = { ok: true };
|
|
||||||
if (result.stderr) out.stderr = result.stderr;
|
|
||||||
if (result.timed_out) out.timed_out = result.timed_out;
|
|
||||||
|
|
||||||
returnData.push({ json: out, pairedItem: { item: itemIndex } });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.continueOnFail()) {
|
if (this.continueOnFail()) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -409,100 +41,3 @@ export class ObsidianApi implements INodeType {
|
|||||||
return [returnData];
|
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 buildPropertyArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
|
|
||||||
const path = context.getNodeParameter('propertyPath', itemIndex, '') as string;
|
|
||||||
const name = context.getNodeParameter('propertyName', itemIndex, '') as string;
|
|
||||||
|
|
||||||
switch (operation) {
|
|
||||||
case 'properties':
|
|
||||||
return compactArgs([
|
|
||||||
'properties',
|
|
||||||
kv('name', name),
|
|
||||||
kv('format', context.getNodeParameter('propertiesFormat', itemIndex) as string),
|
|
||||||
]);
|
|
||||||
case 'propertyRead':
|
|
||||||
return compactArgs(['property:read', kv('path', path), kv('name', name)]);
|
|
||||||
case 'propertyRemove':
|
|
||||||
return compactArgs(['property:remove', kv('path', path), kv('name', name)]);
|
|
||||||
case 'propertySet':
|
|
||||||
return compactArgs([
|
|
||||||
'property:set',
|
|
||||||
kv('path', path),
|
|
||||||
kv('name', name),
|
|
||||||
kv('value', context.getNodeParameter('propertyValue', itemIndex) as string),
|
|
||||||
kv('type', context.getNodeParameter('propertyType', itemIndex) as string),
|
|
||||||
]);
|
|
||||||
default:
|
|
||||||
throw new NodeOperationError(context.getNode(), `Unsupported property operation: ${operation}`, { itemIndex });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildVaultArgs(context: IExecuteFunctions, operation: string, itemIndex: number, additionalFields: Record<string, unknown>): string[] {
|
|
||||||
switch (operation) {
|
|
||||||
case 'vaultInfo':
|
|
||||||
return ['vault'];
|
|
||||||
case 'vaults': {
|
|
||||||
const verbose = additionalFields.verbose === undefined ? true : Boolean(additionalFields.verbose);
|
|
||||||
return compactArgs(['vaults', kv('verbose', verbose)]);
|
|
||||||
}
|
|
||||||
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,119 @@
|
|||||||
|
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { compactArgs, kv } from '../../shared/ObsidianApiClient';
|
||||||
|
|
||||||
|
export function buildArgs(context: IExecuteFunctions, resource: string, operation: string, itemIndex: number, additionalFields: Record<string, unknown>): string[] {
|
||||||
|
switch (resource) {
|
||||||
|
case 'note':
|
||||||
|
return buildNoteArgs(context, operation, itemIndex);
|
||||||
|
case 'daily':
|
||||||
|
return buildDailyArgs(context, operation, itemIndex);
|
||||||
|
case 'search':
|
||||||
|
return buildSearchArgs(context, operation, itemIndex);
|
||||||
|
case 'property':
|
||||||
|
return buildPropertyArgs(context, operation, itemIndex);
|
||||||
|
case 'vault':
|
||||||
|
return buildVaultArgs(context, operation, itemIndex, additionalFields);
|
||||||
|
case 'raw':
|
||||||
|
return parseRawArgs(context, itemIndex);
|
||||||
|
default:
|
||||||
|
throw new NodeOperationError(context.getNode(), `Unsupported resource: ${resource}`, { itemIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 buildPropertyArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
|
||||||
|
const path = context.getNodeParameter('propertyPath', itemIndex, '') as string;
|
||||||
|
const name = context.getNodeParameter('propertyName', itemIndex, '') as string;
|
||||||
|
|
||||||
|
switch (operation) {
|
||||||
|
case 'properties':
|
||||||
|
return compactArgs([
|
||||||
|
'properties',
|
||||||
|
kv('name', name),
|
||||||
|
kv('format', context.getNodeParameter('propertiesFormat', itemIndex) as string),
|
||||||
|
]);
|
||||||
|
case 'propertyRead':
|
||||||
|
return compactArgs(['property:read', kv('path', path), kv('name', name)]);
|
||||||
|
case 'propertyRemove':
|
||||||
|
return compactArgs(['property:remove', kv('path', path), kv('name', name)]);
|
||||||
|
case 'propertySet':
|
||||||
|
return compactArgs([
|
||||||
|
'property:set',
|
||||||
|
kv('path', path),
|
||||||
|
kv('name', name),
|
||||||
|
kv('value', context.getNodeParameter('propertyValue', itemIndex) as string),
|
||||||
|
kv('type', context.getNodeParameter('propertyType', itemIndex) as string),
|
||||||
|
]);
|
||||||
|
default:
|
||||||
|
throw new NodeOperationError(context.getNode(), `Unsupported property operation: ${operation}`, { itemIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVaultArgs(context: IExecuteFunctions, operation: string, itemIndex: number, additionalFields: Record<string, unknown>): string[] {
|
||||||
|
switch (operation) {
|
||||||
|
case 'vaultInfo':
|
||||||
|
return ['vault'];
|
||||||
|
case 'vaults': {
|
||||||
|
const verbose = additionalFields.verbose === undefined ? true : Boolean(additionalFields.verbose);
|
||||||
|
return compactArgs(['vaults', kv('verbose', verbose)]);
|
||||||
|
}
|
||||||
|
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,273 @@
|
|||||||
|
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export const nodeDescription: 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: 'Resource',
|
||||||
|
name: 'resource',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
options: [
|
||||||
|
{ name: 'Daily Note', value: 'daily' },
|
||||||
|
{ name: 'Note', value: 'note' },
|
||||||
|
{ name: 'Property', value: 'property' },
|
||||||
|
{ name: 'Raw Command', value: 'raw' },
|
||||||
|
{ name: 'Search', value: 'search' },
|
||||||
|
{ name: 'Vault', value: 'vault' },
|
||||||
|
],
|
||||||
|
default: 'note',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Operation',
|
||||||
|
name: 'operation',
|
||||||
|
type: 'options',
|
||||||
|
noDataExpression: true,
|
||||||
|
displayOptions: { show: { resource: ['property'] } },
|
||||||
|
options: [
|
||||||
|
{ name: 'List', value: 'properties' },
|
||||||
|
{ name: 'Read', value: 'propertyRead' },
|
||||||
|
{ name: 'Remove', value: 'propertyRemove' },
|
||||||
|
{ name: 'Set', value: 'propertySet' },
|
||||||
|
],
|
||||||
|
default: 'propertySet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Path',
|
||||||
|
name: 'propertyPath',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: { show: { resource: ['property'], operation: ['propertyRead', 'propertyRemove', 'propertySet'] } },
|
||||||
|
default: '',
|
||||||
|
placeholder: 'Inbox/Note.md',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Name',
|
||||||
|
name: 'propertyName',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: { show: { resource: ['property'], operation: ['propertyRead', 'propertyRemove', 'propertySet', 'properties'] } },
|
||||||
|
default: '',
|
||||||
|
placeholder: 'tags',
|
||||||
|
description: 'Frontmatter/property name, e.g. tags, date, description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Value',
|
||||||
|
name: 'propertyValue',
|
||||||
|
type: 'string',
|
||||||
|
displayOptions: { show: { resource: ['property'], operation: ['propertySet'] } },
|
||||||
|
default: '',
|
||||||
|
placeholder: 'kontext, feedback-browser-cli-tests',
|
||||||
|
description: 'Value passed to the official CLI property:set command',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Type',
|
||||||
|
name: 'propertyType',
|
||||||
|
type: 'options',
|
||||||
|
displayOptions: { show: { resource: ['property'], operation: ['propertySet'] } },
|
||||||
|
options: [
|
||||||
|
{ name: 'Text', value: 'text' },
|
||||||
|
{ name: 'List', value: 'list' },
|
||||||
|
{ name: 'Number', value: 'number' },
|
||||||
|
{ name: 'Checkbox', value: 'checkbox' },
|
||||||
|
{ name: 'Date', value: 'date' },
|
||||||
|
{ name: 'Date Time', value: 'datetime' },
|
||||||
|
],
|
||||||
|
default: 'text',
|
||||||
|
description: 'Obsidian property type. Use List for tags and Date for date.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Format',
|
||||||
|
name: 'propertiesFormat',
|
||||||
|
type: 'options',
|
||||||
|
displayOptions: { show: { resource: ['property'], operation: ['properties'] } },
|
||||||
|
options: [
|
||||||
|
{ name: 'YAML', value: 'yaml' },
|
||||||
|
{ name: 'JSON', value: 'json' },
|
||||||
|
{ name: 'TSV', value: 'tsv' },
|
||||||
|
],
|
||||||
|
default: 'yaml',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: '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: 'Additional Fields',
|
||||||
|
name: 'additionalFields',
|
||||||
|
type: 'collection',
|
||||||
|
placeholder: 'Add Field',
|
||||||
|
default: {},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
displayName: 'Timeout Seconds',
|
||||||
|
name: 'timeout',
|
||||||
|
type: 'number',
|
||||||
|
default: 60,
|
||||||
|
description: 'Command timeout in seconds',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Vault',
|
||||||
|
name: 'vault',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
placeholder: 'work',
|
||||||
|
description: 'Optional vault name/path. Overrides the credential default vault. Sent as vault=<value>.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Verbose',
|
||||||
|
name: 'verbose',
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
description: 'Only used by the List Vaults operation',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { ObsidianCommandResult } from '../../shared/ObsidianApiClient';
|
||||||
|
|
||||||
|
export function outputForResult(result: ObsidianCommandResult, resource: string, operation: string, itemIndex: number): INodeExecutionData {
|
||||||
|
const stdout = result.stdout ?? '';
|
||||||
|
|
||||||
|
if (resource === 'daily' && operation === 'dailyPath') {
|
||||||
|
return paired({ path: stdout }, result, itemIndex);
|
||||||
|
}
|
||||||
|
if (resource === 'daily' && operation === 'dailyRead') {
|
||||||
|
return paired({ content: stdout }, result, itemIndex);
|
||||||
|
}
|
||||||
|
if (resource === 'note' && operation === 'read') {
|
||||||
|
return paired({ content: stdout }, result, itemIndex);
|
||||||
|
}
|
||||||
|
if (resource === 'property' && (operation === 'propertyRead' || operation === 'properties')) {
|
||||||
|
return paired({ content: stdout }, result, itemIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = result.parsed_stdout;
|
||||||
|
if (parsed) return paired(parsed, result, itemIndex);
|
||||||
|
|
||||||
|
return paired({ ok: true }, result, itemIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paired(json: IDataObject, result: ObsidianCommandResult, itemIndex: number): INodeExecutionData {
|
||||||
|
const out: IDataObject = { ...json };
|
||||||
|
if (result.stderr) out.stderr = result.stderr;
|
||||||
|
if (result.timed_out) out.timed_out = result.timed_out;
|
||||||
|
return { json: out, pairedItem: { item: itemIndex } };
|
||||||
|
}
|
||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-nodes-obsidian-cli-api",
|
"name": "n8n-nodes-obsidian-cli-api",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-nodes-obsidian-cli-api",
|
"name": "n8n-nodes-obsidian-cli-api",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-nodes-obsidian-cli-api",
|
"name": "n8n-nodes-obsidian-cli-api",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.",
|
"description": "n8n community nodes for the Obsidian API Docker service backed by the official Obsidian CLI.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://git.yiprawr.dev/Docker/obsidian-api",
|
"homepage": "https://git.yiprawr.dev/Docker/obsidian-api",
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type {
|
|||||||
IDataObject,
|
IDataObject,
|
||||||
IHttpRequestOptions,
|
IHttpRequestOptions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { toCleanApiError } from './errors';
|
||||||
|
import { maybeParseStdout, normalizeCommandResult } from './response';
|
||||||
|
|
||||||
export interface ObsidianCommandResult extends IDataObject {
|
export interface ObsidianCommandResult extends IDataObject {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,140 +90,3 @@ export async function executeObsidianCommand(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toCleanApiError(
|
|
||||||
context: IExecuteFunctions,
|
|
||||||
error: unknown,
|
|
||||||
itemIndex: number,
|
|
||||||
): Error {
|
|
||||||
const err = isObject(error) ? error : {};
|
|
||||||
const statusCode = Number(
|
|
||||||
err.statusCode ?? err.status ?? (isObject(err.response) ? err.response.statusCode : undefined) ?? 0,
|
|
||||||
);
|
|
||||||
const body = extractErrorBody(err);
|
|
||||||
const detail = extractErrorMessage(body) || (typeof err.message === 'string' ? err.message : '');
|
|
||||||
|
|
||||||
const statusText = statusCode ? `HTTP ${statusCode}` : 'Request failed';
|
|
||||||
const message = detail
|
|
||||||
? `Obsidian API error (${statusText}): ${detail}`
|
|
||||||
: `Obsidian API error (${statusText})`;
|
|
||||||
|
|
||||||
// Build a plain-object error so n8n never tries to serialize the raw HTTP
|
|
||||||
// response (it contains circular fields like res.socket._httpMessage).
|
|
||||||
return new NodeApiError(
|
|
||||||
context.getNode(),
|
|
||||||
{ message, ...(statusCode ? { statusCode } : {}), ...(detail ? { detail } : {}) },
|
|
||||||
{ message, description: detail || undefined, httpCode: statusCode ? String(statusCode) : undefined, itemIndex },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractErrorBody(err: Record<string, unknown>): unknown {
|
|
||||||
if ('body' in err && err.body !== undefined) return err.body;
|
|
||||||
const response = err.response;
|
|
||||||
if (isObject(response)) {
|
|
||||||
if ('body' in response && response.body !== undefined) return response.body;
|
|
||||||
if ('data' in response && response.data !== undefined) return response.data;
|
|
||||||
}
|
|
||||||
if ('error' in err && err.error !== undefined) return err.error;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractErrorMessage(body: unknown): string {
|
|
||||||
let data: unknown = body;
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
const trimmed = data.trim();
|
|
||||||
if (!trimmed) return '';
|
|
||||||
try {
|
|
||||||
data = JSON.parse(trimmed);
|
|
||||||
} catch {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isObject(data)) {
|
|
||||||
const candidate = data.detail ?? data.message ?? data.error;
|
|
||||||
if (typeof candidate === 'string' && candidate) return candidate;
|
|
||||||
if (isObject(candidate) && typeof candidate.message === 'string') return candidate.message;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeParseStdout(result: ObsidianCommandResult): IDataObject | undefined {
|
|
||||||
const cmd = result.command;
|
|
||||||
const hasVault = cmd.includes('vault');
|
|
||||||
const hasVaults = cmd.includes('vaults');
|
|
||||||
|
|
||||||
if (!hasVault && !hasVaults) return undefined;
|
|
||||||
|
|
||||||
const lines = (result.stdout || '')
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (!lines.length) return undefined;
|
|
||||||
|
|
||||||
// vaults output is typically a 2-col tabular format: <name>\t<containerPath>
|
|
||||||
if (hasVaults) {
|
|
||||||
const vaults: Array<{ name: string; path: string }> = [];
|
|
||||||
for (const line of lines) {
|
|
||||||
const parts = line.split('\t');
|
|
||||||
if (parts.length < 2) continue;
|
|
||||||
const name = parts[0].trim();
|
|
||||||
const path = parts.slice(1).join('\t').trim();
|
|
||||||
if (!name || !path) continue;
|
|
||||||
vaults.push({ name, path });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: if parsing failed, return raw lines
|
|
||||||
if (!vaults.length) return { vaults: lines };
|
|
||||||
return { vaults };
|
|
||||||
}
|
|
||||||
|
|
||||||
// vault output is often key\tvalue pairs
|
|
||||||
const kv: Record<string, string | number> = {};
|
|
||||||
for (const line of lines) {
|
|
||||||
const parts = line.split('\t');
|
|
||||||
if (parts.length < 2) continue;
|
|
||||||
const key = parts[0].trim();
|
|
||||||
const rawValue = parts.slice(1).join('\t').trim();
|
|
||||||
if (!key || !rawValue) continue;
|
|
||||||
|
|
||||||
if (/^-?\d+$/.test(rawValue)) kv[key] = Number(rawValue);
|
|
||||||
else kv[key] = rawValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(kv).length ? kv : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCommandResult(response: unknown): ObsidianCommandResult {
|
|
||||||
const data = extractResponseBody(response);
|
|
||||||
const result = typeof data === 'string' ? JSON.parse(data) : data;
|
|
||||||
|
|
||||||
if (!isObject(result)) {
|
|
||||||
throw new Error('Obsidian API returned a non-object response');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(result.id ?? ''),
|
|
||||||
command: Array.isArray(result.command) ? result.command.map(String) : [],
|
|
||||||
cwd: String(result.cwd ?? ''),
|
|
||||||
exit_code: Number(result.exit_code ?? 0),
|
|
||||||
stdout: String(result.stdout ?? ''),
|
|
||||||
stderr: String(result.stderr ?? ''),
|
|
||||||
duration_ms: Number(result.duration_ms ?? 0),
|
|
||||||
timed_out: Boolean(result.timed_out),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractResponseBody(response: unknown): unknown {
|
|
||||||
if (!isObject(response)) return response;
|
|
||||||
|
|
||||||
// Some n8n versions/helpers can return a full response object. Never pass that
|
|
||||||
// through to node output because it contains circular fields like res.socket.
|
|
||||||
if ('body' in response) return response.body;
|
|
||||||
if ('data' in response) return response.data;
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||||
|
import { NodeApiError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export function toCleanApiError(
|
||||||
|
context: IExecuteFunctions,
|
||||||
|
error: unknown,
|
||||||
|
itemIndex: number,
|
||||||
|
): Error {
|
||||||
|
const err = isRecord(error) ? error : {};
|
||||||
|
const statusCode = Number(
|
||||||
|
err.statusCode ?? err.status ?? (isRecord(err.response) ? err.response.statusCode : undefined) ?? 0,
|
||||||
|
);
|
||||||
|
const body = extractErrorBody(err);
|
||||||
|
const detail = extractErrorMessage(body) || (typeof err.message === 'string' ? err.message : '');
|
||||||
|
|
||||||
|
const statusText = statusCode ? `HTTP ${statusCode}` : 'Request failed';
|
||||||
|
const message = detail
|
||||||
|
? `Obsidian API error (${statusText}): ${detail}`
|
||||||
|
: `Obsidian API error (${statusText})`;
|
||||||
|
|
||||||
|
return new NodeApiError(
|
||||||
|
context.getNode(),
|
||||||
|
{ message, ...(statusCode ? { statusCode } : {}), ...(detail ? { detail } : {}) },
|
||||||
|
{ message, description: detail || undefined, httpCode: statusCode ? String(statusCode) : undefined, itemIndex },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorBody(err: Record<string, unknown>): unknown {
|
||||||
|
if ('body' in err && err.body !== undefined) return err.body;
|
||||||
|
const response = err.response;
|
||||||
|
if (isRecord(response)) {
|
||||||
|
if ('body' in response && response.body !== undefined) return response.body;
|
||||||
|
if ('data' in response && response.data !== undefined) return response.data;
|
||||||
|
}
|
||||||
|
if ('error' in err && err.error !== undefined) return err.error;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(body: unknown): string {
|
||||||
|
let data: unknown = body;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
const trimmed = data.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
try {
|
||||||
|
data = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRecord(data)) {
|
||||||
|
const candidate = data.detail ?? data.message ?? data.error;
|
||||||
|
if (typeof candidate === 'string' && candidate) return candidate;
|
||||||
|
if (isRecord(candidate) && typeof candidate.message === 'string') return candidate.message;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { ObsidianCommandResult } from './ObsidianApiClient';
|
||||||
|
|
||||||
|
export function normalizeCommandResult(response: unknown): ObsidianCommandResult {
|
||||||
|
const data = extractResponseBody(response);
|
||||||
|
const result = typeof data === 'string' ? JSON.parse(data) : data;
|
||||||
|
|
||||||
|
if (!isRecord(result)) {
|
||||||
|
throw new Error('Obsidian API returned a non-object response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(result.id ?? ''),
|
||||||
|
command: Array.isArray(result.command) ? result.command.map(String) : [],
|
||||||
|
cwd: String(result.cwd ?? ''),
|
||||||
|
exit_code: Number(result.exit_code ?? 0),
|
||||||
|
stdout: String(result.stdout ?? ''),
|
||||||
|
stderr: String(result.stderr ?? ''),
|
||||||
|
duration_ms: Number(result.duration_ms ?? 0),
|
||||||
|
timed_out: Boolean(result.timed_out),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maybeParseStdout(result: ObsidianCommandResult): IDataObject | undefined {
|
||||||
|
const cmd = result.command;
|
||||||
|
const hasVault = cmd.includes('vault');
|
||||||
|
const hasVaults = cmd.includes('vaults');
|
||||||
|
|
||||||
|
if (!hasVault && !hasVaults) return undefined;
|
||||||
|
|
||||||
|
const lines = (result.stdout || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!lines.length) return undefined;
|
||||||
|
if (hasVaults) return parseVaultsOutput(lines);
|
||||||
|
return parseVaultOutput(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVaultsOutput(lines: string[]): IDataObject {
|
||||||
|
const vaults: Array<{ name: string; path: string }> = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const parts = line.split('\t');
|
||||||
|
if (parts.length < 2) continue;
|
||||||
|
const name = parts[0].trim();
|
||||||
|
const path = parts.slice(1).join('\t').trim();
|
||||||
|
if (!name || !path) continue;
|
||||||
|
vaults.push({ name, path });
|
||||||
|
}
|
||||||
|
return vaults.length ? { vaults } : { vaults: lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVaultOutput(lines: string[]): IDataObject | undefined {
|
||||||
|
const kv: Record<string, string | number> = {};
|
||||||
|
for (const line of lines) {
|
||||||
|
const parts = line.split('\t');
|
||||||
|
if (parts.length < 2) continue;
|
||||||
|
const key = parts[0].trim();
|
||||||
|
const rawValue = parts.slice(1).join('\t').trim();
|
||||||
|
if (!key || !rawValue) continue;
|
||||||
|
kv[key] = /^-?\d+$/.test(rawValue) ? Number(rawValue) : rawValue;
|
||||||
|
}
|
||||||
|
return Object.keys(kv).length ? kv : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractResponseBody(response: unknown): unknown {
|
||||||
|
if (!isRecord(response)) return response;
|
||||||
|
if ('body' in response) return response.body;
|
||||||
|
if ('data' in response) return response.data;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "obsidian-api"
|
name = "obsidian-api"
|
||||||
version = "1.0.4"
|
version = "1.0.6"
|
||||||
description = "HTTP API wrapper for running Obsidian CLI commands from n8n, SDKs, or agent harnesses."
|
description = "HTTP API wrapper for running Obsidian CLI commands from n8n, SDKs, or agent harnesses."
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
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 use_work_vault(monkeypatch, vault) -> None:
|
||||||
|
monkeypatch.setattr("app.vaults.REGISTERED_VAULTS", {"work": str(vault)})
|
||||||
|
monkeypatch.setattr("app.vaults.DEFAULT_VAULT_NAME", "work")
|
||||||
|
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
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
|
|
||||||
assert response.json()["detail"] == "missing Authorization header; expected 'Bearer <jwt>'"
|
|
||||||
|
|
||||||
def test_invalid_token_reports_reason(monkeypatch):
|
|
||||||
monkeypatch.setattr("app.auth.JWT_SECRET", "secret")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/commands",
|
|
||||||
headers={"Authorization": "Bearer not-a-jwt"},
|
|
||||||
json={"args": ["--version"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json()["detail"].startswith("invalid token:")
|
|
||||||
|
|
||||||
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"].startswith("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"].startswith("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"].startswith("token is not allowed to access path: Private/Secret.md")
|
|
||||||
|
|
||||||
def test_property_set_name_is_not_treated_as_path(monkeypatch):
|
|
||||||
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["*"], "paths": ["00. Journal/"]})
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/commands",
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
json={"args": ["property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["command"] == ["python", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]
|
|
||||||
|
|
||||||
def test_rename_name_is_treated_as_path(monkeypatch):
|
|
||||||
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["rename"], "vaults": ["*"], "paths": ["00. Journal/"]})
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/commands",
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
json={"args": ["rename", "path=00. Journal/Old.md", "name=Private/New.md"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json()["detail"].startswith("token is not allowed to access path: Private/New.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"]
|
|
||||||
|
|
||||||
def test_path_restricted_token_allows_daily_command_from_obsidian_config(monkeypatch, tmp_path):
|
|
||||||
vault = tmp_path / "vault"
|
|
||||||
config = vault / ".obsidian"
|
|
||||||
config.mkdir(parents=True)
|
|
||||||
(config / "daily-notes.json").write_text(json.dumps({"folder": "00. Journal", "format": "YYYY-MM-DD"}))
|
|
||||||
|
|
||||||
monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)})
|
|
||||||
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
|
|
||||||
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/commands",
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["command"] == ["python", "vault=work", "daily:append", "content=ok"]
|
|
||||||
|
|
||||||
def test_path_restricted_token_allows_vault_info_command(monkeypatch):
|
|
||||||
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append", "vault", "vaults"], "vaults": ["*"], "paths": ["00. Journal/"]})
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/commands",
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
json={"args": ["vault"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["command"] == ["python", "vault"]
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/commands",
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
json={"args": ["vaults"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["command"] == ["python", "vaults"]
|
|
||||||
|
|
||||||
def test_path_restricted_token_denies_daily_command_outside_obsidian_config(monkeypatch, tmp_path):
|
|
||||||
vault = tmp_path / "vault"
|
|
||||||
config = vault / ".obsidian"
|
|
||||||
config.mkdir(parents=True)
|
|
||||||
(config / "daily-notes.json").write_text(json.dumps({"folder": "Journal", "format": "YYYY-MM-DD"}))
|
|
||||||
|
|
||||||
monkeypatch.setattr("app.policy.REGISTERED_VAULTS", {"work": str(vault)})
|
|
||||||
monkeypatch.setattr("app.policy.DEFAULT_VAULT_NAME", "work")
|
|
||||||
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/commands",
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json()["detail"].startswith("token is not allowed to access path: Journal/")
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import app.auth as authmod
|
||||||
|
from conftest import client, jwt, use_jwt_auth
|
||||||
|
|
||||||
|
def test_command_requires_auth():
|
||||||
|
response = client.post("/commands", json={"args": ["--version"]})
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["detail"] == "missing Authorization header; expected 'Bearer <jwt>'"
|
||||||
|
|
||||||
|
def test_invalid_token_reports_reason(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.auth.JWT_SECRET", "secret")
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": "Bearer not-a-jwt"},
|
||||||
|
json={"args": ["--version"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["detail"].startswith("invalid token:")
|
||||||
|
|
||||||
|
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"].startswith("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"].startswith("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"].startswith("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"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["command"] == ["python", "read", "path=Inbox/Allowed.md"]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from conftest import client, jwt
|
||||||
|
|
||||||
|
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_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
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from conftest import client, use_jwt_auth, use_work_vault
|
||||||
|
|
||||||
|
def test_path_restricted_token_allows_daily_command_from_obsidian_config(monkeypatch, tmp_path):
|
||||||
|
vault = tmp_path / "vault"
|
||||||
|
config = vault / ".obsidian"
|
||||||
|
config.mkdir(parents=True)
|
||||||
|
(config / "daily-notes.json").write_text(json.dumps({"folder": "00. Journal", "format": "YYYY-MM-DD"}))
|
||||||
|
|
||||||
|
use_work_vault(monkeypatch, vault)
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["command"] == ["python", "vault=work", "daily:append", "content=ok"]
|
||||||
|
|
||||||
|
def test_path_restricted_token_allows_vault_info_command(monkeypatch):
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append", "vault", "vaults"], "vaults": ["*"], "paths": ["00. Journal/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["vault"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["command"] == ["python", "vault"]
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["vaults"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["command"] == ["python", "vaults"]
|
||||||
|
|
||||||
|
def test_path_restricted_token_denies_daily_command_outside_obsidian_config(monkeypatch, tmp_path):
|
||||||
|
vault = tmp_path / "vault"
|
||||||
|
config = vault / ".obsidian"
|
||||||
|
config.mkdir(parents=True)
|
||||||
|
(config / "daily-notes.json").write_text(json.dumps({"folder": "Journal", "format": "YYYY-MM-DD"}))
|
||||||
|
|
||||||
|
use_work_vault(monkeypatch, vault)
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "daily", "commands": ["daily:append"], "vaults": ["work"], "paths": ["00. Journal/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["vault=work", "daily:append", "content=ok"], "cwd": str(vault)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"].startswith("token is not allowed to access path: Journal/")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from conftest import client, use_jwt_auth, use_work_vault
|
||||||
|
|
||||||
|
def test_property_set_name_is_not_treated_as_path(monkeypatch, tmp_path):
|
||||||
|
vault = tmp_path / "vault"
|
||||||
|
vault.mkdir()
|
||||||
|
use_work_vault(monkeypatch, vault)
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["work"], "paths": ["00. Journal/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["vault=work", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"], "cwd": str(vault)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["command"] == ["python", "vault=work", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]
|
||||||
|
|
||||||
|
def test_property_set_creates_missing_file(monkeypatch, tmp_path):
|
||||||
|
vault = tmp_path / "vault"
|
||||||
|
vault.mkdir()
|
||||||
|
use_work_vault(monkeypatch, vault)
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["work"], "paths": ["00. Journal/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["vault=work", "property:set", "path=00. Journal/New.md", "name=from", "value=Daniel", "type=text"], "cwd": str(vault)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (vault / "00. Journal" / "New.md").exists()
|
||||||
|
|
||||||
|
def test_property_set_datetime_defaults_missing_time(monkeypatch, tmp_path):
|
||||||
|
vault = tmp_path / "vault"
|
||||||
|
vault.mkdir()
|
||||||
|
use_work_vault(monkeypatch, vault)
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["work"], "paths": ["00. Journal/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["vault=work", "property:set", "path=00. Journal/New.md", "name=from", "value=2026-06-24T", "type=datetime"], "cwd": str(vault)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["command"] == ["python", "vault=work", "property:set", "path=00. Journal/New.md", "name=from", "value=2026-06-24T00:00:00", "type=datetime"]
|
||||||
|
|
||||||
|
def test_rename_name_is_treated_as_path(monkeypatch):
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["rename"], "vaults": ["*"], "paths": ["00. Journal/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["rename", "path=00. Journal/Old.md", "name=Private/New.md"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"].startswith("token is not allowed to access path: Private/New.md")
|
||||||
@@ -123,7 +123,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsidian-api"
|
name = "obsidian-api"
|
||||||
version = "1.0.4"
|
version = "1.0.6"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
|
|||||||
Reference in New Issue
Block a user