Add open provider contracts to SDK

This commit is contained in:
mirivlad 2026-06-19 07:52:00 +08:00
parent 24d500a7b5
commit 0a0da4c1cb
20 changed files with 1030 additions and 432 deletions

View File

@ -1,3 +1,31 @@
# verstak-sdk
Verstak Plugin SDK — manifest schema, TypeScript SDK, RPC protocol, capability contracts, event schemas, test helpers, packaging tools
Verstak Plugin SDK — manifest schema, TypeScript SDK, RPC protocol, capability contracts, event schemas, test helpers, packaging tools
## Bundled Frontend API Contract
Verstak Desktop creates the real API with `createPluginAPI(pluginId)` and passes
it to bundled plugin components at mount time. The SDK exports TypeScript types
for that host-provided object:
- `settings.read/write/writeAll`
- `capabilities.list/get/has`
- `commands.register/execute`
- `events.publish/subscribe`
- `files.list/metadata/readText/writeText/createFolder/move/trash`
- `workbench.openResource/editResource`
- optional `dispose`
Files paths are canonical vault-relative slash paths. Backslashes, Windows/UNC
absolute paths, traversal, null bytes, `.verstak` variants, and symlink
read/write/move/trash operations are rejected by the host. Files read/write is
UTF-8 text-only in the current runtime.
Open/edit routing uses `OpenResourceRequest` with `kind: "vault-file"` and
contexts `generic-text`, `generic-markdown`, and `notes-markdown`. Plugins that
request routing declare `workbench.open`; editor/viewer plugins contribute
`contributes.openProviders`. A no-match route returns `status: "no-provider"`.
Bundled frontend plugins are trusted/cooperative and run in the desktop JS
context. Current permission checks are contract checks, not a security boundary;
real isolation belongs to a later sidecar/sandbox milestone.

2
dist/index.js vendored
View File

@ -1,6 +1,6 @@
// Verstak Plugin SDK — Public API
export * from './types';
export { VerstakPluginAPI, createPluginAPI } from './plugin-api';
export { createPluginAPI } from './plugin-api';
export { RPCServer, RPCClient } from './rpc';
export { createTestManifest, createTestPluginState, createMockPluginAPI, validateManifest, } from './test-utils';
//# sourceMappingURL=index.js.map

2
dist/index.js.map vendored
View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAElC,cAAc,SAAS,CAAC;AACxB,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,GACjB,MAAM,cAAc,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAElC,cAAc,SAAS,CAAC;AACxB,OAAO,EAAoB,eAAe,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,GACjB,MAAM,cAAc,CAAC"}

146
dist/plugin-api.d.ts vendored
View File

@ -1,79 +1,71 @@
import type { PluginSettings } from './types';
/**
* VerstakPluginAPI единственный способ для frontend плагина
* общаться с core платформы.
*
* Экземпляр API передаётся плагину при активации через глобальную
* переменную `window.__VERSTAK_PLUGIN_API__`.
*/
export declare class VerstakPluginAPI {
private pluginId;
private capabilities;
constructor(pluginId: string);
/**
* Инициализация API вызывается core после загрузки frontend bundle.
* @internal
*/
_init(capabilities: string[]): void;
/**
* Зарегистрировать view для отображения в UI Shell.
*/
registerView(id: string, component: unknown): void;
/**
* Зарегистрировать панель настроек плагина.
*/
registerSettingsPanel(id: string, title: string, component: unknown): void;
/**
* Зарегистрировать команду для command palette.
*/
registerCommand(id: string, title: string, handler: () => void, keybinding?: string): void;
/**
* Зарегистрировать действия для файлов.
*/
registerFileAction(id: string, label: string, handler: (filePath: string) => void, capability?: string): void;
/**
* Зарегистрировать действия для заметок.
*/
registerNoteAction(id: string, label: string, handler: (noteId: string) => void, capability?: string): void;
/**
* Зарегистрировать provider поиска.
*/
registerSearchProvider(id: string, label: string, handler: (query: string) => unknown[]): void;
/**
* Проверить, доступна ли capability.
*/
hasCapability(name: string): boolean;
/**
* Получить список всех доступных capabilities.
*/
getAvailableCapabilities(): string[];
/**
* Вызвать backend метод плагина через RPC.
*/
callBackend(method: string, args?: unknown[]): Promise<unknown>;
/**
* Прочитать настройки плагина.
*/
readSettings(): Promise<PluginSettings>;
/**
* Записать настройки плагина.
*/
writeSettings(settings: PluginSettings): Promise<void>;
/**
* Подписаться на событие event bus.
*/
subscribe(event: string, handler: (payload: unknown) => void): void;
/**
* Опубликовать событие в event bus.
*/
publish(event: string, payload: unknown): void;
private _postMessage;
private _rpcCall;
import type { CapabilityEntry, FileEntry, FileMetadata, MovePathOptions, OpenResourceRequest, OpenResourceResult, PluginSettings, TrashResult, WriteTextOptions } from './types';
export type PluginCommandArgs = Record<string, unknown>;
export type PluginCommandHandler = (args: PluginCommandArgs, declaration: PluginCommandDeclaration) => unknown | Promise<unknown>;
export type Unsubscribe = () => void;
export interface PluginCommandDeclaration {
status: 'declared';
pluginId: string;
commandId: string;
handler?: string;
args?: PluginCommandArgs;
}
/**
* Создать экземпляр VerstakPluginAPI.
* Core вызывает эту функцию после загрузки frontend bundle,
* передавая pluginId и список доступных capabilities.
*/
export declare function createPluginAPI(pluginId: string): VerstakPluginAPI;
export interface PluginCommandResult {
status: 'handled';
pluginId: string;
commandId: string;
result: unknown;
}
export interface PluginEvent<TPayload = Record<string, unknown>> {
name: string;
pluginId: string;
payload: TPayload;
timestamp: string;
}
export interface VerstakPluginAPI {
readonly pluginId: string;
settings: {
read(): Promise<PluginSettings>;
read<T = unknown>(key: string): Promise<T | undefined>;
write(key: string, value: unknown): Promise<PluginSettings>;
writeAll(settings: PluginSettings): Promise<void>;
};
capabilities: {
has(capability: string): Promise<boolean>;
get(capability: string): Promise<{
available: boolean;
name?: string;
pluginId?: string;
status?: string;
}>;
list(): Promise<CapabilityEntry[]>;
};
commands: {
register(commandId: string, handler: PluginCommandHandler): Promise<Unsubscribe>;
execute(commandId: string, args?: PluginCommandArgs): Promise<PluginCommandResult>;
};
events: {
publish(eventName: string, payload?: Record<string, unknown>): Promise<void>;
subscribe<TPayload = Record<string, unknown>>(eventName: string, handler: (event: PluginEvent<TPayload>) => void): Promise<Unsubscribe>;
};
files: {
/**
* Files API uses canonical vault-relative slash paths. Backslashes,
* Windows/UNC absolute paths, traversal, null bytes, `.verstak` variants,
* and symlink read/write/move/trash operations are rejected by the host.
*/
list(relativeDir?: string): Promise<FileEntry[]>;
metadata(relativePath: string): Promise<FileMetadata>;
readText(relativePath: string): Promise<string>;
writeText(relativePath: string, content: string, options?: WriteTextOptions): Promise<void>;
createFolder(relativePath: string): Promise<void>;
move(fromRelativePath: string, toRelativePath: string, options?: MovePathOptions): Promise<void>;
trash(relativePath: string): Promise<TrashResult>;
};
workbench: {
openResource(request: OpenResourceRequest): Promise<OpenResourceResult>;
editResource(request: OpenResourceRequest): Promise<OpenResourceResult>;
};
dispose?: () => void;
}
export declare function createPluginAPI(_pluginId: string): VerstakPluginAPI;
//# sourceMappingURL=plugin-api.d.ts.map

View File

@ -1 +1 @@
{"version":3,"file":"plugin-api.d.ts","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C;;;;;;GAMG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,YAAY,CAAqB;gBAE7B,QAAQ,EAAE,MAAM;IAI5B;;;OAGG;IACH,KAAK,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,IAAI;IAMnC;;OAEG;IACH,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI;IAIlD;;OAEG;IACH,qBAAqB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI;IAI1E;;OAEG;IACH,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,IAAI,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI;IAI1F;;OAEG;IACH,kBAAkB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI;IAI7G;;OAEG;IACH,kBAAkB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI;IAI3G;;OAEG;IACH,sBAAsB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,EAAE,GAAG,IAAI;IAM9F;;OAEG;IACH,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIpC;;OAEG;IACH,wBAAwB,IAAI,MAAM,EAAE;IAMpC;;OAEG;IACG,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,OAAO,EAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAMzE;;OAEG;IACG,YAAY,IAAI,OAAO,CAAC,cAAc,CAAC;IAK7C;;OAEG;IACG,aAAa,CAAC,QAAQ,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAM5D;;OAEG;IACH,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI;IAInE;;OAEG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI;IAM9C,OAAO,CAAC,YAAY;YAMN,QAAQ;CAiBvB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,gBAAgB,CAGlE"}
{"version":3,"file":"plugin-api.d.ts","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"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"}

147
dist/plugin-api.js vendored
View File

@ -1,141 +1,10 @@
// Verstak Plugin SDK — VerstakPluginAPI
// The official runtime API available to all plugins in the frontend context.
/**
* VerstakPluginAPI единственный способ для frontend плагина
* общаться с core платформы.
*
* Экземпляр API передаётся плагину при активации через глобальную
* переменную `window.__VERSTAK_PLUGIN_API__`.
*/
export class VerstakPluginAPI {
pluginId;
capabilities = new Set();
constructor(pluginId) {
this.pluginId = pluginId;
}
/**
* Инициализация API вызывается core после загрузки frontend bundle.
* @internal
*/
_init(capabilities) {
this.capabilities = new Set(capabilities);
}
// ─── View Registration ─────────────────────────────────────
/**
* Зарегистрировать view для отображения в UI Shell.
*/
registerView(id, component) {
this._postMessage('register.view', { id, component });
}
/**
* Зарегистрировать панель настроек плагина.
*/
registerSettingsPanel(id, title, component) {
this._postMessage('register.settingsPanel', { id, title, component });
}
/**
* Зарегистрировать команду для command palette.
*/
registerCommand(id, title, handler, keybinding) {
this._postMessage('register.command', { id, title, keybinding, handler: handler.toString() });
}
/**
* Зарегистрировать действия для файлов.
*/
registerFileAction(id, label, handler, capability) {
this._postMessage('register.fileAction', { id, label, handler: handler.toString(), capability });
}
/**
* Зарегистрировать действия для заметок.
*/
registerNoteAction(id, label, handler, capability) {
this._postMessage('register.noteAction', { id, label, handler: handler.toString(), capability });
}
/**
* Зарегистрировать provider поиска.
*/
registerSearchProvider(id, label, handler) {
this._postMessage('register.searchProvider', { id, label, handler: handler.toString() });
}
// ─── Capabilities ──────────────────────────────────────────
/**
* Проверить, доступна ли capability.
*/
hasCapability(name) {
return this.capabilities.has(name);
}
/**
* Получить список всех доступных capabilities.
*/
getAvailableCapabilities() {
return Array.from(this.capabilities);
}
// ─── Backend Communication ─────────────────────────────────
/**
* Вызвать backend метод плагина через RPC.
*/
async callBackend(method, args = []) {
return this._rpcCall(method, args);
}
// ─── Settings ──────────────────────────────────────────────
/**
* Прочитать настройки плагина.
*/
async readSettings() {
const result = await this._rpcCall('readSettings', []);
return result;
}
/**
* Записать настройки плагина.
*/
async writeSettings(settings) {
await this._rpcCall('writeSettings', [settings]);
}
// ─── Event Bus ─────────────────────────────────────────────
/**
* Подписаться на событие event bus.
*/
subscribe(event, handler) {
this._postMessage('subscribe', { event, handler: handler.toString() });
}
/**
* Опубликовать событие в event bus.
*/
publish(event, payload) {
this._postMessage('publish', { event, payload });
}
// ─── Internal ──────────────────────────────────────────────
_postMessage(type, data) {
window.dispatchEvent(new CustomEvent('verstak:plugin', {
detail: { pluginId: this.pluginId, type, data }
}));
}
async _rpcCall(method, args) {
return new Promise((resolve, reject) => {
const callId = `${this.pluginId}:${Date.now()}:${Math.random()}`;
const handler = (event) => {
if (event.detail.callId === callId) {
window.removeEventListener('verstak:rpc:response', handler);
if (event.detail.error) {
reject(new Error(event.detail.error));
}
else {
resolve(event.detail.result);
}
}
};
window.addEventListener('verstak:rpc:response', handler);
this._postMessage('rpc', { callId, method, args });
});
}
}
/**
* Создать экземпляр VerstakPluginAPI.
* Core вызывает эту функцию после загрузки frontend bundle,
* передавая pluginId и список доступных capabilities.
*/
export function createPluginAPI(pluginId) {
const api = new VerstakPluginAPI(pluginId);
return api;
// Verstak Plugin SDK — bundled frontend plugin API contract.
//
// The desktop host creates the real API with createPluginAPI(pluginId) inside
// VerstakPluginAPI.js and passes it to bundled plugin components at mount time.
// This SDK file intentionally exposes the TypeScript contract only; it is not
// a standalone security boundary or RPC client.
export function createPluginAPI(_pluginId) {
throw new Error('createPluginAPI is provided by Verstak Desktop at plugin runtime');
}
//# sourceMappingURL=plugin-api.js.map

View File

@ -1 +1 @@
{"version":3,"file":"plugin-api.js","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"plugin-api.js","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAC7D,EAAE;AACF,8EAA8E;AAC9E,gFAAgF;AAChF,8EAA8E;AAC9E,gDAAgD;AA+FhD,MAAM,UAAU,eAAe,CAAC,SAAiB;IAC/C,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;AACtF,CAAC"}

11
dist/test-utils.d.ts vendored
View File

@ -1,4 +1,5 @@
import type { PluginManifest, PluginState } from './types';
import type { VerstakPluginAPI } from './plugin-api';
/**
* Создать тестовый manifest для unit-тестов.
*/
@ -10,15 +11,7 @@ export declare function createTestPluginState(overrides?: Partial<PluginState>):
/**
* Создать заглушку VerstakPluginAPI для тестов.
*/
export declare function createMockPluginAPI(): {
registerView: ReturnType<typeof vi.fn>;
registerCommand: ReturnType<typeof vi.fn>;
registerSettingsPanel: ReturnType<typeof vi.fn>;
hasCapability: ReturnType<typeof vi.fn>;
callBackend: ReturnType<typeof vi.fn>;
subscribe: ReturnType<typeof vi.fn>;
publish: ReturnType<typeof vi.fn>;
};
export declare function createMockPluginAPI(pluginId?: string): VerstakPluginAPI;
/**
* Валидатор plugin manifest.
*/

View File

@ -1 +1 @@
{"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3D;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAetF;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CASnF;AAED;;GAEG;AACH,wBAAgB,mBAAmB,IAAI;IACrC,YAAY,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;IACvC,eAAe,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;IAC1C,qBAAqB,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;IAChD,aAAa,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;IACxC,WAAW,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;IACtC,SAAS,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;IACpC,OAAO,EAAE,UAAU,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;CACnC,CAUA;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAgCxF;AAGD,OAAO,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,EAAE,EAAE,CAAC"}
{"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAetF;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CASnF;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,SAAgB,GAAG,gBAAgB,CAsL9E;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,OAAO,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAgCxF;AAGD,OAAO,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,EAAE,EAAE,CAAC"}

203
dist/test-utils.js vendored
View File

@ -34,15 +34,202 @@ export function createTestPluginState(overrides) {
/**
* Создать заглушку VerstakPluginAPI для тестов.
*/
export function createMockPluginAPI() {
export function createMockPluginAPI(pluginId = 'test.plugin') {
const settings = {};
const commands = new Map();
const eventHandlers = new Map();
const files = new Map();
files.set('', { type: 'folder', modifiedAt: new Date().toISOString() });
function normalizePath(path, allowRoot = false) {
const raw = String(path || '');
if (raw.includes('\0'))
throw new Error('invalid-path: null-byte');
if (raw.includes('\\'))
throw new Error('invalid-path: backslash not allowed');
const normalized = raw.replace(/^\.\//, '');
const parts = normalized.split('/').filter(Boolean);
if (!allowRoot && parts.length === 0)
throw new Error('invalid-path: empty path');
if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized))
throw new Error('invalid-path: absolute path rejected');
if (parts.includes('..'))
throw new Error('invalid-path: path-traversal');
if (parts[0] && parts[0].toLowerCase() === '.verstak')
throw new Error('reserved-path: .verstak is internal');
return parts.join('/');
}
function parentPath(path) {
const idx = path.lastIndexOf('/');
return idx === -1 ? '' : path.slice(0, idx);
}
function baseName(path) {
const idx = path.lastIndexOf('/');
return idx === -1 ? path : path.slice(idx + 1);
}
function entry(path, node) {
const name = baseName(path);
const dot = name.lastIndexOf('.');
const extension = dot > 0 ? name.slice(dot + 1) : '';
return {
name,
relativePath: path,
type: node.type,
size: node.type === 'file' ? (node.content || '').length : 0,
modifiedAt: node.modifiedAt,
extension,
isHidden: name.startsWith('.'),
isReserved: false,
canRead: true,
canWrite: true,
};
}
return {
registerView: vi.fn(),
registerCommand: vi.fn(),
registerSettingsPanel: vi.fn(),
hasCapability: vi.fn().mockReturnValue(false),
callBackend: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn(),
publish: vi.fn(),
pluginId,
settings: {
read: vi.fn(async (key) => key ? settings[key] : { ...settings }),
write: vi.fn(async (key, value) => {
settings[key] = value;
return { ...settings };
}),
writeAll: vi.fn(async (nextSettings) => {
Object.keys(settings).forEach((key) => delete settings[key]);
Object.assign(settings, nextSettings);
}),
},
capabilities: {
has: vi.fn(async () => false),
get: vi.fn(async (name) => ({ available: false, name })),
list: vi.fn(async () => []),
},
commands: {
register: vi.fn(async (commandId, handler) => {
commands.set(commandId, handler);
return () => { commands.delete(commandId); };
}),
execute: vi.fn(async (commandId, args = {}) => {
const handler = commands.get(commandId);
if (!handler) {
throw new Error(`declared-but-unhandled: ${commandId}`);
}
return { status: 'handled', pluginId, commandId, result: await handler(args) };
}),
},
events: {
publish: vi.fn(async (eventName, payload = {}) => {
const event = { name: eventName, pluginId, payload, timestamp: new Date().toISOString() };
(eventHandlers.get(eventName) || []).slice().forEach((handler) => handler(event));
}),
subscribe: vi.fn(async (eventName, handler) => {
const handlers = eventHandlers.get(eventName) || [];
handlers.push(handler);
eventHandlers.set(eventName, handlers);
return () => {
eventHandlers.set(eventName, (eventHandlers.get(eventName) || []).filter((item) => item !== handler));
};
}),
},
files: {
list: vi.fn(async (relativeDir = '') => {
const dir = normalizePath(relativeDir, true);
const node = files.get(dir);
if (!node || node.type !== 'folder')
throw new Error(`not-found: ${dir}`);
const prefix = dir ? `${dir}/` : '';
return Array.from(files.entries())
.filter(([path]) => path !== dir && path.startsWith(prefix) && !path.slice(prefix.length).includes('/'))
.map(([path, node]) => entry(path, node));
}),
metadata: vi.fn(async (relativePath) => {
const path = normalizePath(relativePath);
const node = files.get(path);
if (!node)
throw new Error(`not-found: ${path}`);
return { ...entry(path, node), mimeHint: '', isText: node.type === 'file' };
}),
readText: vi.fn(async (relativePath) => {
const path = normalizePath(relativePath);
const node = files.get(path);
if (!node)
throw new Error(`not-found: ${path}`);
if (node.type !== 'file')
throw new Error(`not-regular-file: ${path}`);
return node.content || '';
}),
writeText: vi.fn(async (relativePath, content, options = {}) => {
const path = normalizePath(relativePath);
const node = files.get(path);
if (node && node.type !== 'file')
throw new Error(`not-regular-file: ${path}`);
if (node && !options.overwrite)
throw new Error(`conflict: ${path}`);
if (!node && !options.createIfMissing)
throw new Error(`not-found: ${path}`);
const parent = parentPath(path);
if (!files.get(parent) || files.get(parent)?.type !== 'folder')
throw new Error(`parent-not-found: ${parent}`);
files.set(path, { type: 'file', content, modifiedAt: new Date().toISOString() });
}),
createFolder: vi.fn(async (relativePath) => {
const path = normalizePath(relativePath);
if (files.has(path))
throw new Error(`conflict: ${path}`);
const parent = parentPath(path);
if (!files.get(parent) || files.get(parent)?.type !== 'folder')
throw new Error(`parent-not-found: ${parent}`);
files.set(path, { type: 'folder', modifiedAt: new Date().toISOString() });
}),
move: vi.fn(async (fromRelativePath, toRelativePath, options = {}) => {
const from = normalizePath(fromRelativePath);
const to = normalizePath(toRelativePath);
const node = files.get(from);
if (!node)
throw new Error(`not-found: ${from}`);
if (node.type === 'folder' && (to === from || to.startsWith(`${from}/`))) {
throw new Error(`move-into-self: ${from} -> ${to}`);
}
if (files.has(to) && !options.overwrite)
throw new Error(`conflict: ${to}`);
const parent = parentPath(to);
if (!files.get(parent) || files.get(parent)?.type !== 'folder')
throw new Error(`parent-not-found: ${parent}`);
const moving = Array.from(files.entries()).filter(([path]) => path === from || path.startsWith(`${from}/`));
moving.forEach(([path, movingNode]) => {
const suffix = path.slice(from.length);
files.set(`${to}${suffix}`, movingNode);
files.delete(path);
});
}),
trash: vi.fn(async (relativePath) => {
const path = normalizePath(relativePath);
if (!files.has(path))
throw new Error(`not-found: ${path}`);
files.delete(path);
const trashId = `mock-${Date.now()}`;
return {
originalPath: path,
trashPath: `.verstak/trash/files/${trashId}/${baseName(path)}`,
trashId,
deletedAt: new Date().toISOString(),
};
}),
},
workbench: {
openResource: vi.fn(async (request) => ({
status: 'opened',
providerId: request.context?.notesMode ? 'mock.notes-markdown-provider' : 'mock.open-provider',
providerPluginId: 'mock.editor',
providerComponent: 'MockEditor',
request: { ...request, mode: request.mode || 'view' },
})),
editResource: vi.fn(async (request) => ({
status: 'opened',
providerId: request.context?.notesMode ? 'mock.notes-markdown-provider' : 'mock.open-provider',
providerPluginId: 'mock.editor',
providerComponent: 'MockEditor',
request: { ...request, mode: 'edit' },
})),
},
dispose: vi.fn(),
};
}
/**

File diff suppressed because one or more lines are too long

88
dist/types.d.ts vendored
View File

@ -41,15 +41,59 @@ export interface SyncConfig {
export type CapabilityName = string;
export interface CapabilityEntry {
name: CapabilityName;
description: string;
description?: string;
pluginId: string;
status: 'stable' | 'draft' | 'deprecated';
}
export type Permission = 'vault.read' | 'vault.write' | 'vault.watch' | 'storage.namespace' | 'storage.migrations' | 'events.publish' | 'events.subscribe' | 'ui.register' | 'commands.register' | 'network.local' | 'network.remote' | 'process.spawn' | 'secrets.read' | 'secrets.write' | 'sync.participate';
export type Permission = 'vault.read' | 'vault.write' | 'vault.watch' | 'files.read' | 'files.write' | 'files.delete' | 'workbench.open' | 'storage.namespace' | 'storage.migrations' | 'events.publish' | 'events.subscribe' | 'ui.register' | 'commands.register' | 'network.local' | 'network.remote' | 'process.spawn' | 'secrets.read' | 'secrets.write' | 'sync.participate';
export interface PermissionEntry {
name: Permission;
description: string;
dangerous: boolean;
}
export type FileEntryType = 'file' | 'folder' | 'symlink' | 'unknown';
export interface FileEntry {
name: string;
relativePath: string;
type: FileEntryType;
size: number;
modifiedAt: string;
extension: string;
isHidden: boolean;
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
}
export interface FileMetadata {
relativePath: string;
type: FileEntryType;
size: number;
modifiedAt: string;
createdAt?: string;
extension: string;
mimeHint: string;
isText: boolean;
isHidden: boolean;
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
}
export interface WriteTextOptions {
/** Create the file when it is missing. Parent folder must already exist. */
createIfMissing?: boolean;
/** Replace an existing regular file. Existing folders/symlinks are rejected. */
overwrite?: boolean;
}
export interface MovePathOptions {
/** Replace an existing target path when the host supports it. */
overwrite?: boolean;
}
export interface TrashResult {
originalPath: string;
trashPath: string;
trashId: string;
deletedAt: string;
}
export interface ContributionPoints {
views?: ContributionView[];
commands?: ContributionCommand[];
@ -61,6 +105,7 @@ export interface ContributionPoints {
searchProviders?: ContributionSearchProvider[];
activityProviders?: ContributionActivityProvider[];
statusBarItems?: ContributionStatusBarItem[];
openProviders?: ContributionOpenProvider[];
}
export interface ContributionView {
id: string;
@ -119,6 +164,45 @@ export interface ContributionStatusBarItem {
position?: 'left' | 'right';
handler?: string;
}
export type OpenResourceKind = 'vault-file';
export type OpenResourceMode = 'view' | 'edit';
export type OpenResourceContextName = 'generic-text' | 'generic-markdown' | 'notes-markdown' | string;
export interface OpenProviderSupport {
kind: OpenResourceKind;
extensions?: string[];
mime?: string[];
contexts?: OpenResourceContextName[];
}
export interface ContributionOpenProvider {
id: string;
title: string;
priority?: number;
component: string;
supports: OpenProviderSupport[];
}
export interface OpenResourceContext {
sourcePluginId?: string;
sourceView?: 'files' | 'notes' | string;
isInsideNotesFolder?: boolean;
notesScopePath?: string;
notesMode?: boolean;
}
export interface OpenResourceRequest {
kind: OpenResourceKind;
path: string;
mode?: OpenResourceMode;
mime?: string;
extension?: string;
context?: OpenResourceContext;
}
export interface OpenResourceResult {
status: 'opened' | 'no-provider';
providerId?: string;
providerPluginId?: string;
providerComponent?: string;
request: OpenResourceRequest;
message?: string;
}
export type PluginStatus = 'discovered' | 'disabled' | 'loading' | 'loaded' | 'degraded' | 'failed' | 'incompatible' | 'missing-required-capability';
export interface PluginState {
id: string;

2
dist/types.d.ts.map vendored

File diff suppressed because one or more lines are too long

View File

@ -84,6 +84,10 @@
"vault.read",
"vault.write",
"vault.watch",
"files.read",
"files.write",
"files.delete",
"workbench.open",
"storage.namespace",
"storage.migrations",
"events.publish",
@ -227,6 +231,12 @@
"items": {
"$ref": "#/$defs/ContributionStatusBarItem"
}
},
"openProviders": {
"type": "array",
"items": {
"$ref": "#/$defs/ContributionOpenProvider"
}
}
}
},
@ -357,6 +367,45 @@
"handler": { "type": "string" }
},
"required": ["id", "label"]
},
"OpenProviderSupport": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["vault-file"]
},
"extensions": {
"type": "array",
"items": { "type": "string" }
},
"mime": {
"type": "array",
"items": { "type": "string" }
},
"contexts": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["kind"]
},
"ContributionOpenProvider": {
"type": "object",
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"priority": { "type": "integer" },
"component": { "type": "string" },
"supports": {
"type": "array",
"items": {
"$ref": "#/$defs/OpenProviderSupport"
},
"minItems": 1
}
},
"required": ["id", "title", "component", "supports"]
}
}
}

View File

@ -30,12 +30,16 @@
{ "name": "vault.read", "description": "Read vault files and metadata", "dangerous": false },
{ "name": "vault.write", "description": "Write vault files and metadata", "dangerous": true },
{ "name": "vault.watch", "description": "Watch vault file changes", "dangerous": false },
{ "name": "files.read", "description": "List files and read text files through the vault Files API", "dangerous": false },
{ "name": "files.write", "description": "Create folders, write text files, and move paths through the vault Files API", "dangerous": true },
{ "name": "files.delete", "description": "Trash vault files and folders through the vault Files API", "dangerous": true },
{ "name": "storage.namespace", "description": "Read/write plugin's own storage namespace", "dangerous": false },
{ "name": "storage.migrations", "description": "Run database migrations in plugin namespace", "dangerous": false },
{ "name": "events.publish", "description": "Publish events to the event bus", "dangerous": false },
{ "name": "events.subscribe", "description": "Subscribe to events on the event bus", "dangerous": false },
{ "name": "ui.register", "description": "Register UI components and contributions", "dangerous": false },
{ "name": "commands.register", "description": "Register command palette commands", "dangerous": false },
{ "name": "workbench.open", "description": "Request Workbench open/edit routing for vault resources", "dangerous": false },
{ "name": "network.local", "description": "Connect to localhost network services", "dangerous": false },
{ "name": "network.remote", "description": "Connect to remote network services", "dangerous": true },
{ "name": "process.spawn", "description": "Spawn external processes", "dangerous": true },

View File

@ -41,18 +41,9 @@ if [ ! -d "$ROOT/node_modules" ]; then
fi
fi
# Run vitest tests
if grep -q '"test"' "$ROOT/package.json" 2>/dev/null; then
OUTPUT=$(cd "$ROOT" && npm test 2>&1) || true
if echo "$OUTPUT" | grep -q "No test files found"; then
echo " vitest: no test files yet"
else
echo "$OUTPUT"
report "vitest" ${PIPESTATUS[0]}
fi
else
echo " no test script in package.json"
fi
# Run vitest tests. The SDK has contract tests; "no test files" is a failure.
(cd "$ROOT" && npm test)
report "vitest" $?
echo ""
if [ "$FAILED" -eq 0 ]; then

197
src/plugin-api.test.ts Normal file
View File

@ -0,0 +1,197 @@
import { describe, expect, test } from 'vitest';
import manifestSchema from '../schemas/manifest.json';
import type { OpenResourceRequest, PluginManifest } from './types';
import { createMockPluginAPI } from './test-utils';
describe('VerstakPluginAPI contract', () => {
test('mock API exposes the bundled runtime shape', async () => {
const api = createMockPluginAPI('verstak.platform-test');
expect(api.pluginId).toBe('verstak.platform-test');
expect(typeof api.settings.read).toBe('function');
expect(typeof api.settings.write).toBe('function');
expect(typeof api.capabilities.list).toBe('function');
expect(typeof api.commands.register).toBe('function');
expect(typeof api.commands.execute).toBe('function');
expect(typeof api.events.publish).toBe('function');
expect(typeof api.events.subscribe).toBe('function');
expect(typeof api.files.list).toBe('function');
expect(typeof api.files.metadata).toBe('function');
expect(typeof api.files.readText).toBe('function');
expect(typeof api.files.writeText).toBe('function');
expect(typeof api.files.createFolder).toBe('function');
expect(typeof api.files.move).toBe('function');
expect(typeof api.files.trash).toBe('function');
expect(typeof api.workbench.openResource).toBe('function');
expect(typeof api.workbench.editResource).toBe('function');
});
test('manifest schema accepts files permissions used by platform-test', () => {
const permissionEnum = ((manifestSchema as any).properties.permissions.items.enum || []) as string[];
expect(permissionEnum).toContain('files.read');
expect(permissionEnum).toContain('files.write');
expect(permissionEnum).toContain('files.delete');
expect(permissionEnum).toContain('workbench.open');
});
test('manifest types accept open provider contributions', () => {
const manifest: PluginManifest = {
schemaVersion: 1,
id: 'verstak.default-editor',
name: 'Default Editor',
version: '0.1.0',
apiVersion: '1',
provides: ['editor.text', 'editor.text.markdown'],
permissions: ['ui.register', 'files.read', 'files.write', 'workbench.open'],
contributes: {
openProviders: [
{
id: 'verstak.default-editor.markdown',
title: 'Default Markdown Editor',
priority: 100,
component: 'MarkdownEditor',
supports: [
{
kind: 'vault-file',
extensions: ['.md', '.markdown'],
contexts: ['generic-markdown', 'notes-markdown'],
},
{
kind: 'vault-file',
mime: ['text/plain'],
extensions: ['.txt', '.log'],
contexts: ['generic-text'],
},
],
},
],
},
};
expect(manifest.contributes?.openProviders?.[0].supports[0].contexts).toContain('notes-markdown');
expect(manifest.contributes?.openProviders?.[0].supports[1].contexts).toContain('generic-text');
});
test('OpenResourceRequest and no-provider result shape are typed', () => {
const request: OpenResourceRequest = {
kind: 'vault-file',
path: 'Docs/todo.txt',
mode: 'edit',
mime: 'text/plain',
extension: '.txt',
context: {
sourcePluginId: 'files.plugin',
sourceView: 'files',
},
};
const result = {
status: 'no-provider' as const,
request,
message: 'no open provider for resource',
};
expect(result.status).toBe('no-provider');
expect(result.request.context?.sourceView).toBe('files');
});
test('workbench mock routes open and edit resources', async () => {
const api = createMockPluginAPI('files.plugin');
const request: OpenResourceRequest = {
kind: 'vault-file',
path: 'Notes/Overview.md',
mode: 'view',
extension: '.md',
context: {
sourceView: 'notes',
isInsideNotesFolder: true,
notesMode: true,
},
};
await expect(api.workbench.openResource(request)).resolves.toEqual(expect.objectContaining({
status: 'opened',
providerId: expect.any(String),
request: expect.objectContaining({ path: 'Notes/Overview.md', mode: 'view' }),
}));
await expect(api.workbench.editResource({ ...request, mode: 'edit' })).resolves.toEqual(expect.objectContaining({
status: 'opened',
request: expect.objectContaining({ mode: 'edit' }),
}));
});
test('settings persist in the mock API namespace', async () => {
const api = createMockPluginAPI();
await api.settings.write('savedText', 'hello');
await expect(api.settings.read('savedText')).resolves.toBe('hello');
await expect(api.settings.read()).resolves.toEqual({ savedText: 'hello' });
});
test('commands register, execute, and unregister', async () => {
const api = createMockPluginAPI('cmd.plugin');
const unregister = await api.commands.register('cmd.plugin.echo', async (args) => args.value);
await expect(api.commands.execute('cmd.plugin.echo', { value: 'ok' })).resolves.toEqual({
status: 'handled',
pluginId: 'cmd.plugin',
commandId: 'cmd.plugin.echo',
result: 'ok',
});
unregister();
await expect(api.commands.execute('cmd.plugin.echo', {})).rejects.toThrow('declared-but-unhandled');
});
test('events publish to subscribers and unsubscribe cleanly', async () => {
const api = createMockPluginAPI('event.plugin');
const received: unknown[] = [];
const unsubscribe = await api.events.subscribe('event.plugin.echo', (event) => {
received.push(event.payload.message);
});
await api.events.publish('event.plugin.echo', { message: 'first' });
unsubscribe();
await api.events.publish('event.plugin.echo', { message: 'second' });
expect(received).toEqual(['first']);
});
test('files mock supports text write, read, list, move, and trash', async () => {
const api = createMockPluginAPI('files.plugin');
await api.files.createFolder('PlatformTest');
await api.files.writeText('PlatformTest/one.txt', 'hello', { createIfMissing: true });
await expect(api.files.readText('PlatformTest/one.txt')).resolves.toBe('hello');
await expect(api.files.list('PlatformTest')).resolves.toEqual([
expect.objectContaining({ relativePath: 'PlatformTest/one.txt', type: 'file' }),
]);
await api.files.move('PlatformTest/one.txt', 'PlatformTest/two.txt');
const trash = await api.files.trash('PlatformTest/two.txt');
expect(trash.originalPath).toBe('PlatformTest/two.txt');
expect(trash.trashId).toBeTruthy();
expect(trash.trashPath).toMatch(/^\.verstak\/trash\/files\/.+\/two\.txt$/);
});
test('files mock rejects non-canonical and reserved paths', async () => {
const api = createMockPluginAPI('files.plugin');
await expect(api.files.readText(String.raw`PlatformTest\one.txt`)).rejects.toThrow('backslash');
await expect(api.files.readText('//server/share')).rejects.toThrow('absolute');
await expect(api.files.readText('C:/Windows/system.ini')).rejects.toThrow('absolute');
await expect(api.files.readText('../secret')).rejects.toThrow('path-traversal');
await expect(api.files.readText('bad\0path')).rejects.toThrow('null-byte');
await expect(api.files.readText('.Verstak/vault.json')).rejects.toThrow('reserved-path');
});
test('files mock rejects moving a folder into itself', async () => {
const api = createMockPluginAPI('files.plugin');
await api.files.createFolder('Folder');
await expect(api.files.move('Folder', 'Folder/Child')).rejects.toThrow('move-into-self');
});
});

View File

@ -1,166 +1,103 @@
// Verstak Plugin SDK — VerstakPluginAPI
// The official runtime API available to all plugins in the frontend context.
// Verstak Plugin SDK — bundled frontend plugin API contract.
//
// The desktop host creates the real API with createPluginAPI(pluginId) inside
// VerstakPluginAPI.js and passes it to bundled plugin components at mount time.
// This SDK file intentionally exposes the TypeScript contract only; it is not
// a standalone security boundary or RPC client.
import type { PluginSettings } from './types';
import type {
CapabilityEntry,
FileEntry,
FileMetadata,
MovePathOptions,
OpenResourceRequest,
OpenResourceResult,
PluginSettings,
TrashResult,
WriteTextOptions,
} from './types';
/**
* VerstakPluginAPI единственный способ для frontend плагина
* общаться с core платформы.
*
* Экземпляр API передаётся плагину при активации через глобальную
* переменную `window.__VERSTAK_PLUGIN_API__`.
*/
export class VerstakPluginAPI {
private pluginId: string;
private capabilities = new Set<string>();
export type PluginCommandArgs = Record<string, unknown>;
export type PluginCommandHandler = (
args: PluginCommandArgs,
declaration: PluginCommandDeclaration
) => unknown | Promise<unknown>;
export type Unsubscribe = () => void;
constructor(pluginId: string) {
this.pluginId = pluginId;
}
/**
* Инициализация API вызывается core после загрузки frontend bundle.
* @internal
*/
_init(capabilities: string[]): void {
this.capabilities = new Set(capabilities);
}
// ─── View Registration ─────────────────────────────────────
/**
* Зарегистрировать view для отображения в UI Shell.
*/
registerView(id: string, component: unknown): void {
this._postMessage('register.view', { id, component });
}
/**
* Зарегистрировать панель настроек плагина.
*/
registerSettingsPanel(id: string, title: string, component: unknown): void {
this._postMessage('register.settingsPanel', { id, title, component });
}
/**
* Зарегистрировать команду для command palette.
*/
registerCommand(id: string, title: string, handler: () => void, keybinding?: string): void {
this._postMessage('register.command', { id, title, keybinding, handler: handler.toString() });
}
/**
* Зарегистрировать действия для файлов.
*/
registerFileAction(id: string, label: string, handler: (filePath: string) => void, capability?: string): void {
this._postMessage('register.fileAction', { id, label, handler: handler.toString(), capability });
}
/**
* Зарегистрировать действия для заметок.
*/
registerNoteAction(id: string, label: string, handler: (noteId: string) => void, capability?: string): void {
this._postMessage('register.noteAction', { id, label, handler: handler.toString(), capability });
}
/**
* Зарегистрировать provider поиска.
*/
registerSearchProvider(id: string, label: string, handler: (query: string) => unknown[]): void {
this._postMessage('register.searchProvider', { id, label, handler: handler.toString() });
}
// ─── Capabilities ──────────────────────────────────────────
/**
* Проверить, доступна ли capability.
*/
hasCapability(name: string): boolean {
return this.capabilities.has(name);
}
/**
* Получить список всех доступных capabilities.
*/
getAvailableCapabilities(): string[] {
return Array.from(this.capabilities);
}
// ─── Backend Communication ─────────────────────────────────
/**
* Вызвать backend метод плагина через RPC.
*/
async callBackend(method: string, args: unknown[] = []): Promise<unknown> {
return this._rpcCall(method, args);
}
// ─── Settings ──────────────────────────────────────────────
/**
* Прочитать настройки плагина.
*/
async readSettings(): Promise<PluginSettings> {
const result = await this._rpcCall('readSettings', []);
return result as PluginSettings;
}
/**
* Записать настройки плагина.
*/
async writeSettings(settings: PluginSettings): Promise<void> {
await this._rpcCall('writeSettings', [settings]);
}
// ─── Event Bus ─────────────────────────────────────────────
/**
* Подписаться на событие event bus.
*/
subscribe(event: string, handler: (payload: unknown) => void): void {
this._postMessage('subscribe', { event, handler: handler.toString() });
}
/**
* Опубликовать событие в event bus.
*/
publish(event: string, payload: unknown): void {
this._postMessage('publish', { event, payload });
}
// ─── Internal ──────────────────────────────────────────────
private _postMessage(type: string, data: Record<string, unknown>): void {
window.dispatchEvent(new CustomEvent('verstak:plugin', {
detail: { pluginId: this.pluginId, type, data }
}));
}
private async _rpcCall(method: string, args: unknown[]): Promise<unknown> {
return new Promise((resolve, reject) => {
const callId = `${this.pluginId}:${Date.now()}:${Math.random()}`;
const handler = (event: CustomEvent) => {
if (event.detail.callId === callId) {
window.removeEventListener('verstak:rpc:response', handler as EventListener);
if (event.detail.error) {
reject(new Error(event.detail.error));
} else {
resolve(event.detail.result);
}
}
};
window.addEventListener('verstak:rpc:response', handler as EventListener);
this._postMessage('rpc', { callId, method, args });
});
}
export interface PluginCommandDeclaration {
status: 'declared';
pluginId: string;
commandId: string;
handler?: string;
args?: PluginCommandArgs;
}
/**
* Создать экземпляр VerstakPluginAPI.
* Core вызывает эту функцию после загрузки frontend bundle,
* передавая pluginId и список доступных capabilities.
*/
export function createPluginAPI(pluginId: string): VerstakPluginAPI {
const api = new VerstakPluginAPI(pluginId);
return api;
export interface PluginCommandResult {
status: 'handled';
pluginId: string;
commandId: string;
result: unknown;
}
export interface PluginEvent<TPayload = Record<string, unknown>> {
name: string;
pluginId: string;
payload: TPayload;
timestamp: string;
}
export interface VerstakPluginAPI {
readonly pluginId: string;
settings: {
read(): Promise<PluginSettings>;
read<T = unknown>(key: string): Promise<T | undefined>;
write(key: string, value: unknown): Promise<PluginSettings>;
writeAll(settings: PluginSettings): Promise<void>;
};
capabilities: {
has(capability: string): Promise<boolean>;
get(capability: string): Promise<{ available: boolean; name?: string; pluginId?: string; status?: string }>;
list(): Promise<CapabilityEntry[]>;
};
commands: {
register(commandId: string, handler: PluginCommandHandler): Promise<Unsubscribe>;
execute(commandId: string, args?: PluginCommandArgs): Promise<PluginCommandResult>;
};
events: {
publish(eventName: string, payload?: Record<string, unknown>): Promise<void>;
subscribe<TPayload = Record<string, unknown>>(
eventName: string,
handler: (event: PluginEvent<TPayload>) => void
): Promise<Unsubscribe>;
};
files: {
/**
* Files API uses canonical vault-relative slash paths. Backslashes,
* Windows/UNC absolute paths, traversal, null bytes, `.verstak` variants,
* and symlink read/write/move/trash operations are rejected by the host.
*/
list(relativeDir?: string): Promise<FileEntry[]>;
metadata(relativePath: string): Promise<FileMetadata>;
readText(relativePath: string): Promise<string>;
writeText(relativePath: string, content: string, options?: WriteTextOptions): Promise<void>;
createFolder(relativePath: string): Promise<void>;
move(fromRelativePath: string, toRelativePath: string, options?: MovePathOptions): Promise<void>;
trash(relativePath: string): Promise<TrashResult>;
};
workbench: {
openResource(request: OpenResourceRequest): Promise<OpenResourceResult>;
editResource(request: OpenResourceRequest): Promise<OpenResourceResult>;
};
dispose?: () => void;
}
export function createPluginAPI(_pluginId: string): VerstakPluginAPI {
throw new Error('createPluginAPI is provided by Verstak Desktop at plugin runtime');
}

View File

@ -1,6 +1,7 @@
// Verstak Plugin SDK — Test Utilities
import type { PluginManifest, PluginState } from './types';
import type { VerstakPluginAPI } from './plugin-api';
/**
* Создать тестовый manifest для unit-тестов.
@ -39,23 +40,187 @@ export function createTestPluginState(overrides?: Partial<PluginState>): PluginS
/**
* Создать заглушку VerstakPluginAPI для тестов.
*/
export function createMockPluginAPI(): {
registerView: ReturnType<typeof vi.fn>;
registerCommand: ReturnType<typeof vi.fn>;
registerSettingsPanel: ReturnType<typeof vi.fn>;
hasCapability: ReturnType<typeof vi.fn>;
callBackend: ReturnType<typeof vi.fn>;
subscribe: ReturnType<typeof vi.fn>;
publish: ReturnType<typeof vi.fn>;
} {
export function createMockPluginAPI(pluginId = 'test.plugin'): VerstakPluginAPI {
const settings: Record<string, unknown> = {};
const commands = new Map<string, (args: Record<string, unknown>) => unknown>();
const eventHandlers = new Map<string, Array<(event: any) => void>>();
const files = new Map<string, { type: 'file' | 'folder'; content?: string; modifiedAt: string }>();
files.set('', { type: 'folder', modifiedAt: new Date().toISOString() });
function normalizePath(path: string, allowRoot = false): string {
const raw = String(path || '');
if (raw.includes('\0')) throw new Error('invalid-path: null-byte');
if (raw.includes('\\')) throw new Error('invalid-path: backslash not allowed');
const normalized = raw.replace(/^\.\//, '');
const parts = normalized.split('/').filter(Boolean);
if (!allowRoot && parts.length === 0) throw new Error('invalid-path: empty path');
if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) throw new Error('invalid-path: absolute path rejected');
if (parts.includes('..')) throw new Error('invalid-path: path-traversal');
if (parts[0] && parts[0].toLowerCase() === '.verstak') throw new Error('reserved-path: .verstak is internal');
return parts.join('/');
}
function parentPath(path: string): string {
const idx = path.lastIndexOf('/');
return idx === -1 ? '' : path.slice(0, idx);
}
function baseName(path: string): string {
const idx = path.lastIndexOf('/');
return idx === -1 ? path : path.slice(idx + 1);
}
function entry(path: string, node: { type: 'file' | 'folder'; content?: string; modifiedAt: string }) {
const name = baseName(path);
const dot = name.lastIndexOf('.');
const extension = dot > 0 ? name.slice(dot + 1) : '';
return {
name,
relativePath: path,
type: node.type,
size: node.type === 'file' ? (node.content || '').length : 0,
modifiedAt: node.modifiedAt,
extension,
isHidden: name.startsWith('.'),
isReserved: false,
canRead: true,
canWrite: true,
};
}
return {
registerView: vi.fn(),
registerCommand: vi.fn(),
registerSettingsPanel: vi.fn(),
hasCapability: vi.fn().mockReturnValue(false),
callBackend: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn(),
publish: vi.fn(),
pluginId,
settings: {
read: vi.fn(async (key?: string) => key ? settings[key] : { ...settings }) as VerstakPluginAPI['settings']['read'],
write: vi.fn(async (key: string, value: unknown) => {
settings[key] = value;
return { ...settings };
}),
writeAll: vi.fn(async (nextSettings: Record<string, unknown>) => {
Object.keys(settings).forEach((key) => delete settings[key]);
Object.assign(settings, nextSettings);
}),
},
capabilities: {
has: vi.fn(async () => false),
get: vi.fn(async (name: string) => ({ available: false, name })),
list: vi.fn(async () => []),
},
commands: {
register: vi.fn(async (commandId: string, handler: (args: Record<string, unknown>) => unknown) => {
commands.set(commandId, handler);
return () => { commands.delete(commandId); };
}),
execute: vi.fn(async (commandId: string, args: Record<string, unknown> = {}) => {
const handler = commands.get(commandId);
if (!handler) {
throw new Error(`declared-but-unhandled: ${commandId}`);
}
return { status: 'handled' as const, pluginId, commandId, result: await handler(args) };
}),
},
events: {
publish: vi.fn(async (eventName: string, payload: Record<string, unknown> = {}) => {
const event = { name: eventName, pluginId, payload, timestamp: new Date().toISOString() };
(eventHandlers.get(eventName) || []).slice().forEach((handler) => handler(event));
}),
subscribe: vi.fn(async (eventName: string, handler: (event: any) => void) => {
const handlers = eventHandlers.get(eventName) || [];
handlers.push(handler);
eventHandlers.set(eventName, handlers);
return () => {
eventHandlers.set(eventName, (eventHandlers.get(eventName) || []).filter((item) => item !== handler));
};
}),
},
files: {
list: vi.fn(async (relativeDir = '') => {
const dir = normalizePath(relativeDir, true);
const node = files.get(dir);
if (!node || node.type !== 'folder') throw new Error(`not-found: ${dir}`);
const prefix = dir ? `${dir}/` : '';
return Array.from(files.entries())
.filter(([path]) => path !== dir && path.startsWith(prefix) && !path.slice(prefix.length).includes('/'))
.map(([path, node]) => entry(path, node));
}),
metadata: vi.fn(async (relativePath: string) => {
const path = normalizePath(relativePath);
const node = files.get(path);
if (!node) throw new Error(`not-found: ${path}`);
return { ...entry(path, node), mimeHint: '', isText: node.type === 'file' };
}),
readText: vi.fn(async (relativePath: string) => {
const path = normalizePath(relativePath);
const node = files.get(path);
if (!node) throw new Error(`not-found: ${path}`);
if (node.type !== 'file') throw new Error(`not-regular-file: ${path}`);
return node.content || '';
}),
writeText: vi.fn(async (relativePath: string, content: string, options = {}) => {
const path = normalizePath(relativePath);
const node = files.get(path);
if (node && node.type !== 'file') throw new Error(`not-regular-file: ${path}`);
if (node && !options.overwrite) throw new Error(`conflict: ${path}`);
if (!node && !options.createIfMissing) throw new Error(`not-found: ${path}`);
const parent = parentPath(path);
if (!files.get(parent) || files.get(parent)?.type !== 'folder') throw new Error(`parent-not-found: ${parent}`);
files.set(path, { type: 'file', content, modifiedAt: new Date().toISOString() });
}),
createFolder: vi.fn(async (relativePath: string) => {
const path = normalizePath(relativePath);
if (files.has(path)) throw new Error(`conflict: ${path}`);
const parent = parentPath(path);
if (!files.get(parent) || files.get(parent)?.type !== 'folder') throw new Error(`parent-not-found: ${parent}`);
files.set(path, { type: 'folder', modifiedAt: new Date().toISOString() });
}),
move: vi.fn(async (fromRelativePath: string, toRelativePath: string, options = {}) => {
const from = normalizePath(fromRelativePath);
const to = normalizePath(toRelativePath);
const node = files.get(from);
if (!node) throw new Error(`not-found: ${from}`);
if (node.type === 'folder' && (to === from || to.startsWith(`${from}/`))) {
throw new Error(`move-into-self: ${from} -> ${to}`);
}
if (files.has(to) && !options.overwrite) throw new Error(`conflict: ${to}`);
const parent = parentPath(to);
if (!files.get(parent) || files.get(parent)?.type !== 'folder') throw new Error(`parent-not-found: ${parent}`);
const moving = Array.from(files.entries()).filter(([path]) => path === from || path.startsWith(`${from}/`));
moving.forEach(([path, movingNode]) => {
const suffix = path.slice(from.length);
files.set(`${to}${suffix}`, movingNode);
files.delete(path);
});
}),
trash: vi.fn(async (relativePath: string) => {
const path = normalizePath(relativePath);
if (!files.has(path)) throw new Error(`not-found: ${path}`);
files.delete(path);
const trashId = `mock-${Date.now()}`;
return {
originalPath: path,
trashPath: `.verstak/trash/files/${trashId}/${baseName(path)}`,
trashId,
deletedAt: new Date().toISOString(),
};
}),
},
workbench: {
openResource: vi.fn(async (request) => ({
status: 'opened' as const,
providerId: request.context?.notesMode ? 'mock.notes-markdown-provider' : 'mock.open-provider',
providerPluginId: 'mock.editor',
providerComponent: 'MockEditor',
request: { ...request, mode: request.mode || 'view' },
})),
editResource: vi.fn(async (request) => ({
status: 'opened' as const,
providerId: request.context?.notesMode ? 'mock.notes-markdown-provider' : 'mock.open-provider',
providerPluginId: 'mock.editor',
providerComponent: 'MockEditor',
request: { ...request, mode: 'edit' as const },
})),
},
dispose: vi.fn(),
};
}

View File

@ -55,7 +55,8 @@ export type CapabilityName = string;
export interface CapabilityEntry {
name: CapabilityName;
description: string;
description?: string;
pluginId: string;
status: 'stable' | 'draft' | 'deprecated';
}
@ -65,6 +66,10 @@ export type Permission =
| 'vault.read'
| 'vault.write'
| 'vault.watch'
| 'files.read'
| 'files.write'
| 'files.delete'
| 'workbench.open'
| 'storage.namespace'
| 'storage.migrations'
| 'events.publish'
@ -84,6 +89,57 @@ export interface PermissionEntry {
dangerous: boolean;
}
// ─── Files API ──────────────────────────────────────────────
export type FileEntryType = 'file' | 'folder' | 'symlink' | 'unknown';
export interface FileEntry {
name: string;
relativePath: string;
type: FileEntryType;
size: number;
modifiedAt: string;
extension: string;
isHidden: boolean;
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
}
export interface FileMetadata {
relativePath: string;
type: FileEntryType;
size: number;
modifiedAt: string;
createdAt?: string;
extension: string;
mimeHint: string;
isText: boolean;
isHidden: boolean;
isReserved: boolean;
canRead: boolean;
canWrite: boolean;
}
export interface WriteTextOptions {
/** Create the file when it is missing. Parent folder must already exist. */
createIfMissing?: boolean;
/** Replace an existing regular file. Existing folders/symlinks are rejected. */
overwrite?: boolean;
}
export interface MovePathOptions {
/** Replace an existing target path when the host supports it. */
overwrite?: boolean;
}
export interface TrashResult {
originalPath: string;
trashPath: string;
trashId: string;
deletedAt: string;
}
// ─── Contribution Points ─────────────────────────────────────
export interface ContributionPoints {
@ -97,6 +153,7 @@ export interface ContributionPoints {
searchProviders?: ContributionSearchProvider[];
activityProviders?: ContributionActivityProvider[];
statusBarItems?: ContributionStatusBarItem[];
openProviders?: ContributionOpenProvider[];
}
export interface ContributionView {
@ -165,6 +222,51 @@ export interface ContributionStatusBarItem {
handler?: string;
}
export type OpenResourceKind = 'vault-file';
export type OpenResourceMode = 'view' | 'edit';
export type OpenResourceContextName = 'generic-text' | 'generic-markdown' | 'notes-markdown' | string;
export interface OpenProviderSupport {
kind: OpenResourceKind;
extensions?: string[];
mime?: string[];
contexts?: OpenResourceContextName[];
}
export interface ContributionOpenProvider {
id: string;
title: string;
priority?: number;
component: string;
supports: OpenProviderSupport[];
}
export interface OpenResourceContext {
sourcePluginId?: string;
sourceView?: 'files' | 'notes' | string;
isInsideNotesFolder?: boolean;
notesScopePath?: string;
notesMode?: boolean;
}
export interface OpenResourceRequest {
kind: OpenResourceKind;
path: string;
mode?: OpenResourceMode;
mime?: string;
extension?: string;
context?: OpenResourceContext;
}
export interface OpenResourceResult {
status: 'opened' | 'no-provider';
providerId?: string;
providerPluginId?: string;
providerComponent?: string;
request: OpenResourceRequest;
message?: string;
}
// ─── Plugin State ────────────────────────────────────────────
export type PluginStatus =