feat: add Obsidian CLI API service
Build and Push Docker Container / build-and-push (push) Successful in 4m26s

- Add Docker image for the official Obsidian desktop app and CLI.

- Start Obsidian headlessly with Xvfb and DBus setup.

- Expose a safe structured HTTP command API without shell execution.

- Add JWT-based vault, path, and command authorization.

- Support single-vault and multi-vault container mounts.

- Add TypeScript SDK helpers for Obsidian CLI commands.

- Add n8n community node package with Obsidian operations.

- Add docs, compose config, tests, and production image workflow.
This commit is contained in:
2026-06-24 09:11:13 +02:00
commit 902a3df8b5
34 changed files with 5153 additions and 0 deletions
@@ -0,0 +1,324 @@
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { compactArgs, executeObsidianCommand, kv } from '../../shared/ObsidianApiClient';
export class ObsidianApi implements INodeType {
description: INodeTypeDescription = {
displayName: 'Obsidian API',
name: 'obsidianApi',
icon: 'file:obsidian.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"]}}',
description: 'Run official Obsidian CLI commands through the Obsidian API Docker service',
defaults: {
name: 'Obsidian API',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'obsidianApi',
required: true,
},
],
properties: [
{
displayName: 'Vault',
name: 'vault',
type: 'string',
default: '',
placeholder: 'work',
description: 'Optional vault name/path. Overrides the credential default vault. Sent as vault=<value>.',
},
{
displayName: 'Resource',
name: 'resource',
type: 'options',
noDataExpression: true,
options: [
{ name: 'Daily Note', value: 'daily' },
{ name: 'Note', value: 'note' },
{ name: 'Raw Command', value: 'raw' },
{ name: 'Search', value: 'search' },
{ name: 'Vault', value: 'vault' },
],
default: 'note',
},
// Note operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['note'] } },
options: [
{ name: 'Append', value: 'append' },
{ name: 'Create', value: 'create' },
{ name: 'Delete', value: 'delete' },
{ name: 'List Files', value: 'files' },
{ name: 'Prepend', value: 'prepend' },
{ name: 'Read', value: 'read' },
{ name: 'Rename', value: 'rename' },
],
default: 'create',
},
{
displayName: 'Path',
name: 'path',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'delete', 'prepend', 'read', 'rename'] } },
default: '',
placeholder: 'Inbox/Note.md',
required: true,
},
{
displayName: 'Content',
name: 'content',
type: 'string',
typeOptions: { rows: 8 },
displayOptions: { show: { resource: ['note'], operation: ['append', 'create', 'prepend'] } },
default: '',
required: true,
},
{
displayName: 'Overwrite',
name: 'overwrite',
type: 'boolean',
displayOptions: { show: { resource: ['note'], operation: ['create'] } },
default: false,
},
{
displayName: 'New Name',
name: 'newName',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['rename'] } },
default: '',
required: true,
},
{
displayName: 'Folder',
name: 'folder',
type: 'string',
displayOptions: { show: { resource: ['note'], operation: ['files'] } },
default: '',
placeholder: 'Inbox',
},
// Daily note operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['daily'] } },
options: [
{ name: 'Append', value: 'dailyAppend' },
{ name: 'Path', value: 'dailyPath' },
{ name: 'Prepend', value: 'dailyPrepend' },
{ name: 'Read', value: 'dailyRead' },
],
default: 'dailyAppend',
},
{
displayName: 'Content',
name: 'dailyContent',
type: 'string',
typeOptions: { rows: 8 },
displayOptions: { show: { resource: ['daily'], operation: ['dailyAppend', 'dailyPrepend'] } },
default: '',
required: true,
},
// Search operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['search'] } },
options: [
{ name: 'Search', value: 'search' },
{ name: 'Search With Context', value: 'searchContext' },
],
default: 'search',
},
{
displayName: 'Query',
name: 'query',
type: 'string',
displayOptions: { show: { resource: ['search'] } },
default: '',
required: true,
},
{
displayName: 'Format',
name: 'format',
type: 'options',
displayOptions: { show: { resource: ['search'] } },
options: [
{ name: 'Text', value: 'text' },
{ name: 'JSON', value: 'json' },
],
default: 'text',
},
// Vault operations
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['vault'] } },
options: [
{ name: 'Info', value: 'vaultInfo' },
{ name: 'List Vaults', value: 'vaults' },
],
default: 'vaultInfo',
},
{
displayName: 'Verbose',
name: 'verbose',
type: 'boolean',
displayOptions: { show: { resource: ['vault'], operation: ['vaults'] } },
default: true,
},
// Raw command
{
displayName: 'Args JSON',
name: 'rawArgs',
type: 'json',
displayOptions: { show: { resource: ['raw'] } },
default: '["version"]',
description: 'Structured CLI args array. Example: ["create", "path=Inbox/Test.md", "content=Hello", "overwrite"]. Do not include shell strings.',
required: true,
},
{
displayName: 'Timeout Seconds',
name: 'timeout',
type: 'number',
default: 60,
description: 'Command timeout in seconds',
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const resource = this.getNodeParameter('resource', itemIndex) as string;
const operation = this.getNodeParameter('operation', itemIndex) as string;
const vault = this.getNodeParameter('vault', itemIndex, '') as string;
const timeout = this.getNodeParameter('timeout', itemIndex, 60) as number;
let args: string[];
switch (resource) {
case 'note': {
args = buildNoteArgs(this, operation, itemIndex);
break;
}
case 'daily': {
args = buildDailyArgs(this, operation, itemIndex);
break;
}
case 'search': {
args = buildSearchArgs(this, operation, itemIndex);
break;
}
case 'vault': {
args = buildVaultArgs(this, operation, itemIndex);
break;
}
case 'raw': {
args = parseRawArgs(this, itemIndex);
break;
}
default:
throw new NodeOperationError(this.getNode(), `Unsupported resource: ${resource}`, { itemIndex });
}
const result = await executeObsidianCommand(this, itemIndex, args, { vault, timeout });
returnData.push({ json: result, pairedItem: { item: itemIndex } });
}
return [returnData];
}
}
function buildNoteArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const path = context.getNodeParameter('path', itemIndex, '') as string;
switch (operation) {
case 'create':
return compactArgs(['create', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string), kv('overwrite', context.getNodeParameter('overwrite', itemIndex) as boolean)]);
case 'read':
return compactArgs(['read', kv('path', path)]);
case 'append':
return compactArgs(['append', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
case 'prepend':
return compactArgs(['prepend', kv('path', path), kv('content', context.getNodeParameter('content', itemIndex) as string)]);
case 'delete':
return compactArgs(['delete', kv('path', path)]);
case 'rename':
return compactArgs(['rename', kv('path', path), kv('name', context.getNodeParameter('newName', itemIndex) as string)]);
case 'files':
return compactArgs(['files', kv('folder', context.getNodeParameter('folder', itemIndex, '') as string)]);
default:
throw new NodeOperationError(context.getNode(), `Unsupported note operation: ${operation}`, { itemIndex });
}
}
function buildDailyArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
switch (operation) {
case 'dailyAppend':
return compactArgs(['daily:append', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
case 'dailyPrepend':
return compactArgs(['daily:prepend', kv('content', context.getNodeParameter('dailyContent', itemIndex) as string)]);
case 'dailyRead':
return ['daily:read'];
case 'dailyPath':
return ['daily:path'];
default:
throw new NodeOperationError(context.getNode(), `Unsupported daily operation: ${operation}`, { itemIndex });
}
}
function buildSearchArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
const command = operation === 'searchContext' ? 'search:context' : 'search';
return compactArgs([
command,
kv('query', context.getNodeParameter('query', itemIndex) as string),
kv('format', context.getNodeParameter('format', itemIndex) as string),
]);
}
function buildVaultArgs(context: IExecuteFunctions, operation: string, itemIndex: number): string[] {
switch (operation) {
case 'vaultInfo':
return ['vault'];
case 'vaults':
return compactArgs(['vaults', kv('verbose', context.getNodeParameter('verbose', itemIndex) as boolean)]);
default:
throw new NodeOperationError(context.getNode(), `Unsupported vault operation: ${operation}`, { itemIndex });
}
}
function parseRawArgs(context: IExecuteFunctions, itemIndex: number): string[] {
const raw = context.getNodeParameter('rawArgs', itemIndex) as string | string[];
const parsed = Array.isArray(raw) ? raw : JSON.parse(raw);
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) {
throw new NodeOperationError(context.getNode(), 'Args JSON must be an array of strings', { itemIndex });
}
return parsed;
}