3bda4002fe
Build and Push Docker Container / build-and-push (push) Successful in 4m29s
- Allow vault and vaults commands when a token has path restrictions - Treat vault/vaults as pathless metadata commands in policy checks - Add allowed commands, vaults and paths to 403 permission errors - Surface real JWT failure reason in 401 instead of generic Unauthorized - Report missing, malformed and empty Bearer tokens distinctly - Catch httpRequest failures in the n8n node to avoid circular JSON - Map API errors to clean NodeApiError with status code and detail - Support continueOnFail with per-item error output in the n8n node - Push a versioned image tag from pyproject in the CI workflow - Bump server to 1.0.2 and n8n node package to 0.2.1 - Add tests for vault info access and richer auth error messages
176 lines
5.4 KiB
TypeScript
176 lines
5.4 KiB
TypeScript
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;
|
|
}
|
|
|
|
export interface ObsidianCredentials extends IDataObject {
|
|
baseUrl: string;
|
|
apiToken: string;
|
|
defaultVault?: string;
|
|
}
|
|
|
|
export function compactArgs(args: Array<string | undefined | null | false>): string[] {
|
|
return args.filter((arg): arg is string => typeof arg === 'string' && arg.length > 0);
|
|
}
|
|
|
|
export function kv(name: string, value: string | number | boolean | undefined | null): string | undefined {
|
|
if (value === undefined || value === null || value === '') return undefined;
|
|
if (typeof value === 'boolean') return value ? name : undefined;
|
|
return `${name}=${value}`;
|
|
}
|
|
|
|
export function withVault(args: string[], vault?: string): string[] {
|
|
if (!vault || args[0]?.startsWith('vault=')) return args;
|
|
return [`vault=${vault}`, ...args];
|
|
}
|
|
|
|
export async function executeObsidianCommand(
|
|
context: IExecuteFunctions,
|
|
itemIndex: number,
|
|
args: string[],
|
|
options: { vault?: string; timeout?: number; stdin?: string } = {},
|
|
): Promise<ObsidianCommandResult> {
|
|
const credentials = await context.getCredentials('obsidianApi') as ObsidianCredentials;
|
|
const baseUrl = String(credentials.baseUrl).replace(/\/$/, '');
|
|
const vault = options.vault || String(credentials.defaultVault || '');
|
|
const finalArgs = withVault(args, vault);
|
|
|
|
const request: IHttpRequestOptions = {
|
|
method: 'POST',
|
|
url: `${baseUrl}/commands`,
|
|
headers: {
|
|
Authorization: `Bearer ${credentials.apiToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: {
|
|
args: finalArgs,
|
|
stdin: options.stdin || undefined,
|
|
timeout: options.timeout,
|
|
},
|
|
json: true,
|
|
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);
|
|
|
|
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<string, unknown>): unknown {
|
|
if ('body' in err && err.body !== undefined) return err.body;
|
|
const response = err.response;
|
|
if (isObject(response)) {
|
|
if ('body' in response && response.body !== undefined) return response.body;
|
|
if ('data' in response && response.data !== undefined) return response.data;
|
|
}
|
|
if ('error' in err && err.error !== undefined) return err.error;
|
|
return undefined;
|
|
}
|
|
|
|
function extractErrorMessage(body: unknown): string {
|
|
let data: unknown = body;
|
|
if (typeof data === 'string') {
|
|
const trimmed = data.trim();
|
|
if (!trimmed) return '';
|
|
try {
|
|
data = JSON.parse(trimmed);
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
}
|
|
if (isObject(data)) {
|
|
const candidate = data.detail ?? data.message ?? data.error;
|
|
if (typeof candidate === 'string' && candidate) return candidate;
|
|
if (isObject(candidate) && typeof candidate.message === 'string') return candidate.message;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function normalizeCommandResult(response: unknown): ObsidianCommandResult {
|
|
const data = extractResponseBody(response);
|
|
const result = typeof data === 'string' ? JSON.parse(data) : data;
|
|
|
|
if (!isObject(result)) {
|
|
throw new Error('Obsidian API returned a non-object response');
|
|
}
|
|
|
|
return {
|
|
id: String(result.id ?? ''),
|
|
command: Array.isArray(result.command) ? result.command.map(String) : [],
|
|
cwd: String(result.cwd ?? ''),
|
|
exit_code: Number(result.exit_code ?? 0),
|
|
stdout: String(result.stdout ?? ''),
|
|
stderr: String(result.stderr ?? ''),
|
|
duration_ms: Number(result.duration_ms ?? 0),
|
|
timed_out: Boolean(result.timed_out),
|
|
};
|
|
}
|
|
|
|
function extractResponseBody(response: unknown): unknown {
|
|
if (!isObject(response)) return response;
|
|
|
|
// Some n8n versions/helpers can return a full response object. Never pass that
|
|
// through to node output because it contains circular fields like res.socket.
|
|
if ('body' in response) return response.body;
|
|
if ('data' in response) return response.data;
|
|
|
|
return response;
|
|
}
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|