diff --git a/app/command_target.py b/app/command_target.py new file mode 100644 index 0000000..fa0bbcc --- /dev/null +++ b/app/command_target.py @@ -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)) diff --git a/app/daily_notes.py b/app/daily_notes.py new file mode 100644 index 0000000..7405865 --- /dev/null +++ b/app/daily_notes.py @@ -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 diff --git a/app/main.py b/app/main.py index 0c6ba3c..2d5298d 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,8 @@ from starlette.routing import Route from app import auth from app.auth import require_auth from app.config import CLI_BIN, DEFAULT_TIMEOUT, HOST, MAX_TIMEOUT, PORT, VAULT_PATH -from app.policy import authorize_command, vault_for_path +from app.policy import authorize_command +from app.vaults import vault_for_path from app.runner import execute_command def error(status_code:int, detail:str) -> JSONResponse: diff --git a/app/path_policy.py b/app/path_policy.py new file mode 100644 index 0000000..b2fce7a --- /dev/null +++ b/app/path_policy.py @@ -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 diff --git a/app/policy.py b/app/policy.py index a646787..57e6846 100644 --- a/app/policy.py +++ b/app/policy.py @@ -1,10 +1,12 @@ from dataclasses import dataclass -from datetime import datetime from pathlib import Path 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) class TokenPolicy: @@ -23,16 +25,6 @@ class TokenPolicy: def allows_all_commands(self) -> bool: 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 = { "append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff", "search", "search:context", "files", "folders", @@ -51,132 +43,6 @@ def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]: return tuple(value) raise ValueError("JWT claims vaults, paths and commands must be strings or string arrays") -def vault_for_path(path:Path) -> str|None: - resolved = path.resolve() - best_name:str|None = None - best_len = -1 - - for name, vault_path in REGISTERED_VAULTS.items(): - root = Path(vault_path).resolve() - try: - resolved.relative_to(root) - except ValueError: - continue - - root_len = len(str(root)) - if root_len > best_len: - best_name = name - best_len = root_len - - return best_name - -def normalize_policy_path(value:str) -> str: - normalized = value.strip().replace("\\", "/").lstrip("/") - while "//" in normalized: - normalized = normalized.replace("//", "/") - return normalized - -def is_path_allowed(path:str, allowed_patterns:tuple[str, ...]) -> bool: - normalized = normalize_policy_path(path) - - for pattern in allowed_patterns: - pattern_normalized = normalize_policy_path(pattern) - if pattern_normalized == "*": - return True - if pattern_normalized.endswith("/**"): - prefix = pattern_normalized[:-3].rstrip("/") - if normalized == prefix or normalized.startswith(prefix + "/"): - return True - elif pattern_normalized.endswith("/"): - if normalized.startswith(pattern_normalized): - return True - elif normalized == pattern_normalized or normalized.startswith(pattern_normalized.rstrip("/") + "/"): - return True - - return False - -def parse_kv_arg(arg:str) -> tuple[str, str]|None: - if "=" not in arg: - return None - key, value = arg.split("=", 1) - if not key: - return None - return key, value - -def 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: if policy is None: return diff --git a/app/property_commands.py b/app/property_commands.py new file mode 100644 index 0000000..46aa9af --- /dev/null +++ b/app/property_commands.py @@ -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 diff --git a/app/runner.py b/app/runner.py index 67ec1f4..6d76998 100644 --- a/app/runner.py +++ b/app/runner.py @@ -4,12 +4,20 @@ from pathlib import Path from starlette.responses import JSONResponse from app.config import CLI_BIN +from app.property_commands import prepare_property_args def error(status_code:int, detail:str) -> JSONResponse: return JSONResponse({"detail": detail}, status_code=status_code) async def execute_command(args:list[str], cwd:Path, stdin:str|None, timeout:float) -> JSONResponse: command_id = str(uuid.uuid4()) + 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] started = time.perf_counter() diff --git a/app/vaults.py b/app/vaults.py new file mode 100644 index 0000000..5ee8478 --- /dev/null +++ b/app/vaults.py @@ -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() diff --git a/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/ObsidianApi.node.ts b/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/ObsidianApi.node.ts index 8c12b64..0eb7d9a 100644 --- a/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/ObsidianApi.node.ts +++ b/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/ObsidianApi.node.ts @@ -4,294 +4,14 @@ import type { INodeType, INodeTypeDescription, } 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 { - description: INodeTypeDescription = { - displayName: 'Obsidian API', - name: 'obsidianApi', - icon: 'file:obsidian.svg', - group: ['transform'], - version: 1, - subtitle: '={{$parameter["operation"]}}', - description: 'Run official Obsidian CLI commands through the Obsidian API Docker service', - defaults: { - name: 'Obsidian API', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'obsidianApi', - required: true, - }, - ], - properties: [ - { - displayName: '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=.', - }, - { - displayName: 'Verbose', - name: 'verbose', - type: 'boolean', - default: true, - description: 'Only used by the List Vaults operation', - }, - ], - }, - ], - }; + description: INodeTypeDescription = nodeDescription; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); @@ -304,98 +24,10 @@ export class ObsidianApi implements INodeType { const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {}) as Record; const vault = String(additionalFields.vault || ''); const timeout = Number(additionalFields.timeout ?? 60); - - 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 args = buildArgs(this, resource, operation, itemIndex, additionalFields); const result = await executeObsidianCommand(this, itemIndex, args, { vault, timeout }); - // Always keep n8n output minimal/clean. - // - 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 } }); + returnData.push(outputForResult(result, resource, operation, itemIndex)); } catch (error) { if (this.continueOnFail()) { const message = error instanceof Error ? error.message : String(error); @@ -409,100 +41,3 @@ export class ObsidianApi implements INodeType { 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[] { - 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; -} - diff --git a/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/argBuilders.ts b/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/argBuilders.ts new file mode 100644 index 0000000..10d9056 --- /dev/null +++ b/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/argBuilders.ts @@ -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[] { + 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[] { + 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; +} diff --git a/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/description.ts b/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/description.ts new file mode 100644 index 0000000..06a0a0e --- /dev/null +++ b/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/description.ts @@ -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=.', + }, + { + displayName: 'Verbose', + name: 'verbose', + type: 'boolean', + default: true, + description: 'Only used by the List Vaults operation', + }, + ], + }, + ], +}; diff --git a/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/output.ts b/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/output.ts new file mode 100644 index 0000000..5b547b8 --- /dev/null +++ b/packages/n8n-nodes-obsidian-api/nodes/ObsidianApi/output.ts @@ -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 } }; +} diff --git a/packages/n8n-nodes-obsidian-api/package-lock.json b/packages/n8n-nodes-obsidian-api/package-lock.json index 3c6cb9e..628c55e 100644 --- a/packages/n8n-nodes-obsidian-api/package-lock.json +++ b/packages/n8n-nodes-obsidian-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "n8n-nodes-obsidian-cli-api", - "version": "0.2.6", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "n8n-nodes-obsidian-cli-api", - "version": "0.2.6", + "version": "0.2.7", "license": "MIT", "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/n8n-nodes-obsidian-api/package.json b/packages/n8n-nodes-obsidian-api/package.json index 216b2f4..6317bdc 100644 --- a/packages/n8n-nodes-obsidian-api/package.json +++ b/packages/n8n-nodes-obsidian-api/package.json @@ -1,6 +1,6 @@ { "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.", "license": "MIT", "homepage": "https://git.yiprawr.dev/Docker/obsidian-api", diff --git a/packages/n8n-nodes-obsidian-api/shared/ObsidianApiClient.ts b/packages/n8n-nodes-obsidian-api/shared/ObsidianApiClient.ts index 01da8bd..f672637 100644 --- a/packages/n8n-nodes-obsidian-api/shared/ObsidianApiClient.ts +++ b/packages/n8n-nodes-obsidian-api/shared/ObsidianApiClient.ts @@ -3,7 +3,10 @@ import type { IDataObject, IHttpRequestOptions, } 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 { id: string; @@ -87,140 +90,3 @@ export async function executeObsidianCommand( 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): 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: \t - 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 = {}; - 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 { - return typeof value === 'object' && value !== null; -} diff --git a/packages/n8n-nodes-obsidian-api/shared/errors.ts b/packages/n8n-nodes-obsidian-api/shared/errors.ts new file mode 100644 index 0000000..01ac482 --- /dev/null +++ b/packages/n8n-nodes-obsidian-api/shared/errors.ts @@ -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): 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 { + return typeof value === 'object' && value !== null; +} diff --git a/packages/n8n-nodes-obsidian-api/shared/response.ts b/packages/n8n-nodes-obsidian-api/shared/response.ts new file mode 100644 index 0000000..5832cfe --- /dev/null +++ b/packages/n8n-nodes-obsidian-api/shared/response.ts @@ -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 = {}; + 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 { + return typeof value === 'object' && value !== null; +} diff --git a/pyproject.toml b/pyproject.toml index 4510df5..67e9c00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] 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." requires-python = ">=3.14" dependencies = [ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c720444 --- /dev/null +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index fd003e3..0000000 --- a/tests/test_api.py +++ /dev/null @@ -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 '" - -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/") diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..d3e4467 --- /dev/null +++ b/tests/test_auth.py @@ -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 '" + +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"] diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..8ad4977 --- /dev/null +++ b/tests/test_commands.py @@ -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 diff --git a/tests/test_daily.py b/tests/test_daily.py new file mode 100644 index 0000000..4b797f1 --- /dev/null +++ b/tests/test_daily.py @@ -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/") diff --git a/tests/test_property_commands.py b/tests/test_property_commands.py new file mode 100644 index 0000000..3b292b8 --- /dev/null +++ b/tests/test_property_commands.py @@ -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") diff --git a/uv.lock b/uv.lock index 00e5ead..0ce25fb 100644 --- a/uv.lock +++ b/uv.lock @@ -123,7 +123,7 @@ wheels = [ [[package]] name = "obsidian-api" -version = "1.0.4" +version = "1.0.6" source = { virtual = "." } dependencies = [ { name = "starlette" },