import type { IExecuteFunctions, IDataObject, IHttpRequestOptions, } from 'n8n-workflow'; import { NodeApiError, NodeOperationError } from 'n8n-workflow'; export interface ObsidianCommandResult extends IDataObject { id: string; command: string[]; cwd: string; exit_code: number; stdout: string; stderr: string; duration_ms: number; timed_out: boolean; parsed_stdout?: IDataObject; } export interface ObsidianCredentials extends IDataObject { baseUrl: string; apiToken: string; defaultVault?: string; } export function compactArgs(args: Array): string[] { return args.filter((arg): arg is string => typeof arg === 'string' && arg.length > 0); } export function kv(name: string, value: string | number | boolean | undefined | null): string | undefined { if (value === undefined || value === null || value === '') return undefined; if (typeof value === 'boolean') return value ? name : undefined; return `${name}=${value}`; } export function withVault(args: string[], vault?: string): string[] { if (!vault || args[0]?.startsWith('vault=')) return args; return [`vault=${vault}`, ...args]; } export async function executeObsidianCommand( context: IExecuteFunctions, itemIndex: number, args: string[], options: { vault?: string; timeout?: number; stdin?: string } = {}, ): Promise { const credentials = await context.getCredentials('obsidianApi') as ObsidianCredentials; const baseUrl = String(credentials.baseUrl).replace(/\/$/, ''); const vault = options.vault || String(credentials.defaultVault || ''); const finalArgs = withVault(args, vault); const request: IHttpRequestOptions = { method: 'POST', url: `${baseUrl}/commands`, headers: { Authorization: `Bearer ${credentials.apiToken}`, 'Content-Type': 'application/json', }, body: { args: finalArgs, stdin: options.stdin || undefined, timeout: options.timeout, }, json: true, returnFullResponse: false, }; let response: unknown; try { response = await context.helpers.httpRequest(request) as unknown; } catch (error) { throw toCleanApiError(context, error, itemIndex); } const result = normalizeCommandResult(response); const parsed = maybeParseStdout(result); if (parsed) result.parsed_stdout = parsed; if (result.exit_code !== 0) { throw new NodeOperationError( context.getNode(), `Obsidian CLI command failed with exit code ${result.exit_code}: ${result.stderr || result.stdout}`, { itemIndex }, ); } return result; } 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; }