feat: expose binary file writes in sdk

This commit is contained in:
mirivlad 2026-06-29 10:12:26 +08:00
parent 380f90c5ec
commit b11178d8fd
4 changed files with 47 additions and 6 deletions

View File

@ -30,8 +30,8 @@
{ "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.read", "description": "List files and read bounded text or byte payloads through the vault Files API", "dangerous": false },
{ "name": "files.write", "description": "Create folders, write bounded text or byte payloads, 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": "files.openExternal", "description": "Open vault files and folders in external OS applications", "dangerous": true },
{ "name": "storage.namespace", "description": "Read/write plugin's own storage namespace", "dangerous": false },

View File

@ -27,6 +27,7 @@ describe('VerstakPluginAPI contract', () => {
expect(typeof api.files.readText).toBe('function');
expect(typeof api.files.readBytes).toBe('function');
expect(typeof api.files.writeText).toBe('function');
expect(typeof api.files.writeBytes).toBe('function');
expect(typeof api.files.createFolder).toBe('function');
expect(typeof api.files.move).toBe('function');
expect(typeof api.files.trash).toBe('function');
@ -286,6 +287,7 @@ describe('VerstakPluginAPI contract', () => {
await api.files.createFolder('PlatformTest');
await api.files.writeText('PlatformTest/one.txt', 'hello', { createIfMissing: true });
await api.files.writeBytes('PlatformTest/image.bin', 'AQID', { createIfMissing: true });
await expect(api.files.readText('PlatformTest/one.txt')).resolves.toBe('hello');
await expect(api.files.readBytes('PlatformTest/one.txt')).resolves.toEqual({
relativePath: 'PlatformTest/one.txt',
@ -293,9 +295,16 @@ describe('VerstakPluginAPI contract', () => {
mimeHint: 'text/plain; charset=utf-8',
dataBase64: 'aGVsbG8=',
});
await expect(api.files.list('PlatformTest')).resolves.toEqual([
await expect(api.files.readBytes('PlatformTest/image.bin')).resolves.toEqual({
relativePath: 'PlatformTest/image.bin',
size: 3,
mimeHint: '',
dataBase64: 'AQID',
});
await expect(api.files.list('PlatformTest')).resolves.toEqual(expect.arrayContaining([
expect.objectContaining({ relativePath: 'PlatformTest/one.txt', type: 'file' }),
]);
expect.objectContaining({ relativePath: 'PlatformTest/image.bin', type: 'file' }),
]));
await expect(api.files.openExternal('PlatformTest/one.txt')).resolves.toBeUndefined();
await expect(api.files.showInFolder('PlatformTest/one.txt')).resolves.toBeUndefined();
await api.files.move('PlatformTest/one.txt', 'PlatformTest/two.txt');
@ -308,9 +317,10 @@ describe('VerstakPluginAPI contract', () => {
expect.objectContaining({ originalPath: 'PlatformTest/two.txt', trashId: trash.trashId }),
]);
await expect(api.files.restoreTrash(trash.trashId)).resolves.toBe('PlatformTest/two.txt');
await expect(api.files.list('PlatformTest')).resolves.toEqual([
await expect(api.files.list('PlatformTest')).resolves.toEqual(expect.arrayContaining([
expect.objectContaining({ relativePath: 'PlatformTest/two.txt', type: 'file' }),
]);
expect.objectContaining({ relativePath: 'PlatformTest/image.bin', type: 'file' }),
]));
await expect(api.files.listTrash()).resolves.toEqual([]);
});

View File

@ -146,6 +146,7 @@ export interface VerstakPluginAPI {
readText(relativePath: string): Promise<string>;
readBytes(relativePath: string): Promise<FileBytes>;
writeText(relativePath: string, content: string, options?: WriteTextOptions): Promise<void>;
writeBytes(relativePath: string, dataBase64: string, options?: WriteTextOptions): Promise<void>;
createFolder(relativePath: string): Promise<void>;
move(fromRelativePath: string, toRelativePath: string, options?: MovePathOptions): Promise<void>;
trash(relativePath: string): Promise<TrashResult>;

View File

@ -99,6 +99,26 @@ export function createMockPluginAPI(pluginId = 'test.plugin', options: MockPlugi
return result;
}
function stringFromBase64(value: string): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const clean = String(value || '').replace(/\s+/g, '');
if (clean.length % 4 === 1) throw new Error('invalid-base64');
let result = '';
for (let i = 0; i < clean.length; i += 4) {
const a = alphabet.indexOf(clean[i]);
const b = alphabet.indexOf(clean[i + 1]);
const c = clean[i + 2] === '=' ? -1 : alphabet.indexOf(clean[i + 2]);
const d = clean[i + 3] === '=' ? -1 : alphabet.indexOf(clean[i + 3]);
if (a < 0 || b < 0 || (clean[i + 2] !== '=' && c < 0) || (clean[i + 3] !== '=' && d < 0)) {
throw new Error('invalid-base64');
}
result += String.fromCharCode((a << 2) | (b >> 4));
if (c >= 0) result += String.fromCharCode(((b & 15) << 4) | (c >> 2));
if (d >= 0) result += String.fromCharCode(((c & 3) << 6) | d);
}
return result;
}
function entry(path: string, node: { type: 'file' | 'folder'; content?: string; modifiedAt: string }) {
const name = baseName(path);
const dot = name.lastIndexOf('.');
@ -249,6 +269,16 @@ export function createMockPluginAPI(pluginId = 'test.plugin', options: MockPlugi
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() });
}),
writeBytes: vi.fn(async (relativePath: string, dataBase64: 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: stringFromBase64(dataBase64), modifiedAt: new Date().toISOString() });
}),
createFolder: vi.fn(async (relativePath: string) => {
const path = normalizePath(relativePath);
if (files.has(path)) throw new Error(`conflict: ${path}`);