Add open provider contracts to SDK
This commit is contained in:
parent
24d500a7b5
commit
0a0da4c1cb
30
README.md
30
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
104
src/types.ts
104
src/types.ts
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in New Issue