diff --git a/schemas/permissions.json b/schemas/permissions.json index 58e6aa3..699371d 100644 --- a/schemas/permissions.json +++ b/schemas/permissions.json @@ -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 }, diff --git a/src/plugin-api.test.ts b/src/plugin-api.test.ts index 9670859..9acc02a 100644 --- a/src/plugin-api.test.ts +++ b/src/plugin-api.test.ts @@ -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([]); }); diff --git a/src/plugin-api.ts b/src/plugin-api.ts index aabdff2..50bdf76 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -146,6 +146,7 @@ export interface VerstakPluginAPI { readText(relativePath: string): Promise; readBytes(relativePath: string): Promise; writeText(relativePath: string, content: string, options?: WriteTextOptions): Promise; + writeBytes(relativePath: string, dataBase64: string, options?: WriteTextOptions): Promise; createFolder(relativePath: string): Promise; move(fromRelativePath: string, toRelativePath: string, options?: MovePathOptions): Promise; trash(relativePath: string): Promise; diff --git a/src/test-utils.ts b/src/test-utils.ts index a7ba89b..39f2bdb 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -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}`);