opensidian 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +35 -0
- package/.github/ISSUE_TEMPLATE/question.md +23 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +45 -0
- package/.github/README.md +5 -0
- package/.github/workflows/cd.yml +44 -0
- package/.github/workflows/ci.yml +128 -0
- package/.github/workflows/qa.yml +45 -0
- package/.planning/PROJECT.md +96 -0
- package/.planning/REQUIREMENTS.md +66 -0
- package/.planning/ROADMAP.md +129 -0
- package/.planning/STATE.md +47 -0
- package/.planning/config.json +14 -0
- package/CONTRIBUTING.md +232 -0
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/dist/api/auth.d.ts +5 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +112 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/routes.d.ts +3 -0
- package/dist/api/routes.d.ts.map +1 -0
- package/dist/api/routes.js +119 -0
- package/dist/api/routes.js.map +1 -0
- package/dist/api/themes.d.ts +3 -0
- package/dist/api/themes.d.ts.map +1 -0
- package/dist/api/themes.js +48 -0
- package/dist/api/themes.js.map +1 -0
- package/dist/core/graph.d.ts +16 -0
- package/dist/core/graph.d.ts.map +1 -0
- package/dist/core/graph.js +115 -0
- package/dist/core/graph.js.map +1 -0
- package/dist/core/markdown.d.ts +21 -0
- package/dist/core/markdown.d.ts.map +1 -0
- package/dist/core/markdown.js +77 -0
- package/dist/core/markdown.js.map +1 -0
- package/dist/core/search.d.ts +34 -0
- package/dist/core/search.d.ts.map +1 -0
- package/dist/core/search.js +159 -0
- package/dist/core/search.js.map +1 -0
- package/dist/core/sync.d.ts +30 -0
- package/dist/core/sync.d.ts.map +1 -0
- package/dist/core/sync.js +121 -0
- package/dist/core/sync.js.map +1 -0
- package/dist/core/vault.d.ts +28 -0
- package/dist/core/vault.d.ts.map +1 -0
- package/dist/core/vault.js +235 -0
- package/dist/core/vault.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/cli.d.ts +3 -0
- package/dist/mcp/cli.d.ts.map +1 -0
- package/dist/mcp/cli.js +7 -0
- package/dist/mcp/cli.js.map +1 -0
- package/dist/mcp/server.d.ts +18 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +272 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/plugins/host.d.ts +23 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +104 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/sample-plugin.d.ts +10 -0
- package/dist/plugins/sample-plugin.d.ts.map +1 -0
- package/dist/plugins/sample-plugin.js +23 -0
- package/dist/plugins/sample-plugin.js.map +1 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +77 -0
- package/dist/server.js.map +1 -0
- package/dist/shared/types.d.ts +86 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/types.js.map +1 -0
- package/docker/Dockerfile +37 -0
- package/docker/docker-compose.yml +46 -0
- package/docs/ARCHITECTURE.md +321 -0
- package/futuras_implementacoes.md +0 -0
- package/package.json +65 -0
- package/scripts/fix-gitignore.ps1 +5 -0
- package/scripts/seed-notes.mjs +60 -0
- package/src/api/auth.ts +130 -0
- package/src/api/routes.ts +133 -0
- package/src/api/themes.ts +60 -0
- package/src/core/graph.ts +145 -0
- package/src/core/markdown.ts +92 -0
- package/src/core/search.ts +208 -0
- package/src/core/sync.ts +157 -0
- package/src/core/vault.ts +286 -0
- package/src/index.ts +37 -0
- package/src/mcp/cli.ts +7 -0
- package/src/mcp/server.ts +296 -0
- package/src/plugins/host.ts +120 -0
- package/src/plugins/sample-plugin.ts +29 -0
- package/src/server.ts +90 -0
- package/src/shared/types.ts +92 -0
- package/tests/api/routes.test.ts +167 -0
- package/tests/core/graph.test.ts +236 -0
- package/tests/core/markdown.test.ts +157 -0
- package/tests/core/search.test.ts +132 -0
- package/tests/core/sync.test.ts +62 -0
- package/tests/core/vault.test.ts +162 -0
- package/tests/mcp/server.test.ts +118 -0
- package/tests/plugins/host.test.ts +165 -0
- package/tests/plugins/sample-plugin.test.ts +35 -0
- package/tests/server.test.ts +76 -0
- package/tsconfig.json +27 -0
- package/vite.config.ts +27 -0
- package/vitest.config.ts +33 -0
- package/web/index.html +13 -0
- package/web/package.json +26 -0
- package/web/public/favicon.svg +4 -0
- package/web/src/App.tsx +63 -0
- package/web/src/api/auth.ts +65 -0
- package/web/src/api/client.ts +117 -0
- package/web/src/api/themes.ts +78 -0
- package/web/src/components/GraphView.tsx +139 -0
- package/web/src/components/Layout.tsx +74 -0
- package/web/src/components/LoginPage.tsx +52 -0
- package/web/src/components/NoteEditor.tsx +114 -0
- package/web/src/components/NoteList.tsx +95 -0
- package/web/src/components/RegisterPage.tsx +58 -0
- package/web/src/components/SearchBar.tsx +71 -0
- package/web/src/components/SearchPanel.tsx +152 -0
- package/web/src/components/ThemeEditor.tsx +129 -0
- package/web/src/components/ThemeSelector.tsx +41 -0
- package/web/src/components/VaultList.tsx +89 -0
- package/web/src/hooks/AuthContext.tsx +57 -0
- package/web/src/hooks/ThemeContext.tsx +77 -0
- package/web/src/hooks/useWebSocket.ts +34 -0
- package/web/src/main.tsx +10 -0
- package/web/src/styles/global.css +449 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +19 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { SyncService } from '../../src/core/sync';
|
|
3
|
+
|
|
4
|
+
describe('SyncService', () => {
|
|
5
|
+
let syncService: SyncService;
|
|
6
|
+
const testPort = 19999;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
syncService = new SyncService(testPort);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
syncService.stop();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('constructor', () => {
|
|
17
|
+
it('should create sync service with default port', () => {
|
|
18
|
+
const service = new SyncService();
|
|
19
|
+
expect(service).toBeDefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should create sync service with custom port', () => {
|
|
23
|
+
const service = new SyncService(19998);
|
|
24
|
+
expect(service).toBeDefined();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('start/stop', () => {
|
|
29
|
+
it('should start and stop without errors', () => {
|
|
30
|
+
expect(() => syncService.start()).not.toThrow();
|
|
31
|
+
expect(() => syncService.stop()).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should not start twice', () => {
|
|
35
|
+
syncService.start();
|
|
36
|
+
expect(() => syncService.start()).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('broadcastChange', () => {
|
|
41
|
+
it('should emit change event', () => {
|
|
42
|
+
syncService.start();
|
|
43
|
+
|
|
44
|
+
const handler = vi.fn();
|
|
45
|
+
syncService.on('change', handler);
|
|
46
|
+
|
|
47
|
+
syncService.broadcastChange({
|
|
48
|
+
type: 'note_created',
|
|
49
|
+
note: { path: 'test.md' },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(handler).toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('getConnectedClients', () => {
|
|
57
|
+
it('should return 0 when no clients connected', () => {
|
|
58
|
+
syncService.start();
|
|
59
|
+
expect(syncService.getConnectedClients()).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { VaultManager } from '../../src/core/vault';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
describe('VaultManager', () => {
|
|
7
|
+
let vaultManager: VaultManager;
|
|
8
|
+
const testVaultPath = '/tmp/test-vault';
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vaultManager = new VaultManager();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('listVaults', () => {
|
|
15
|
+
it('should return empty array when no vaults are open', () => {
|
|
16
|
+
const vaults = vaultManager.listVaults();
|
|
17
|
+
expect(vaults).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('createVault', () => {
|
|
22
|
+
it('should create a new vault', async () => {
|
|
23
|
+
const vault = vaultManager.createVault('Test Vault', testVaultPath);
|
|
24
|
+
|
|
25
|
+
expect(vault).toBeDefined();
|
|
26
|
+
expect(vault.name).toBe('Test Vault');
|
|
27
|
+
expect(vault.path).toBe(testVaultPath);
|
|
28
|
+
expect(vault.created).toBeDefined();
|
|
29
|
+
expect(vault.notes).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should add vault to list after creation', async () => {
|
|
33
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
34
|
+
const vaults = vaultManager.listVaults();
|
|
35
|
+
|
|
36
|
+
expect(vaults).toHaveLength(1);
|
|
37
|
+
expect(vaults[0].name).toBe('Test Vault');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('openVault', () => {
|
|
42
|
+
it('should return null for non-existent path', () => {
|
|
43
|
+
const vault = vaultManager.openVault('/non/existent/path');
|
|
44
|
+
expect(vault).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should open existing vault directory', async () => {
|
|
48
|
+
await fs.mkdir(testVaultPath, { recursive: true });
|
|
49
|
+
const vault = vaultManager.openVault(testVaultPath);
|
|
50
|
+
|
|
51
|
+
expect(vault).toBeDefined();
|
|
52
|
+
expect(vault?.path).toBe(testVaultPath);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('createNote', () => {
|
|
57
|
+
it('should create a new note in vault', async () => {
|
|
58
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
59
|
+
const note = await vaultManager.createNote(testVaultPath, 'test-note', '# Test Note\n\nContent here.');
|
|
60
|
+
|
|
61
|
+
expect(note).toBeDefined();
|
|
62
|
+
expect(note.filename).toBe('test-note');
|
|
63
|
+
expect(note.path).toBe('test-note.md');
|
|
64
|
+
expect(note.content).toContain('# Test Note');
|
|
65
|
+
expect(note.frontmatter).toBeDefined();
|
|
66
|
+
expect(note.links).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should extract links from note content', async () => {
|
|
70
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
71
|
+
const note = await vaultManager.createNote(
|
|
72
|
+
testVaultPath,
|
|
73
|
+
'linked-note',
|
|
74
|
+
'# Linked Note\n\nSee [[Another Note]] for more.'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(note.links).toContain('Another Note');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should extract frontmatter from note content', async () => {
|
|
81
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
82
|
+
const note = await vaultManager.createNote(
|
|
83
|
+
testVaultPath,
|
|
84
|
+
'frontmatter-note',
|
|
85
|
+
'---\ntitle: My Title\ntags: [test, sample]\n---\n\nContent here.'
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(note.frontmatter).toHaveProperty('title', 'My Title');
|
|
89
|
+
expect(note.frontmatter).toHaveProperty('tags', '[test, sample]');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('readNote', () => {
|
|
94
|
+
it('should return null for non-existent note', async () => {
|
|
95
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
96
|
+
const note = await vaultManager.readNote(testVaultPath, 'non-existent.md');
|
|
97
|
+
expect(note).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should read existing note', async () => {
|
|
101
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
102
|
+
await vaultManager.createNote(testVaultPath, 'existing-note', '# Existing Note\n\nContent.');
|
|
103
|
+
|
|
104
|
+
const note = await vaultManager.readNote(testVaultPath, 'existing-note.md');
|
|
105
|
+
|
|
106
|
+
expect(note).toBeDefined();
|
|
107
|
+
expect(note?.filename).toBe('existing-note');
|
|
108
|
+
expect(note?.content).toContain('# Existing Note');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('updateNote', () => {
|
|
113
|
+
it('should update existing note', async () => {
|
|
114
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
115
|
+
await vaultManager.createNote(testVaultPath, 'update-note', '# Original\n\nOriginal content.');
|
|
116
|
+
|
|
117
|
+
const updatedNote = await vaultManager.updateNote(
|
|
118
|
+
testVaultPath,
|
|
119
|
+
'update-note.md',
|
|
120
|
+
'# Updated\n\nUpdated content.'
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(updatedNote.filename).toBe('update-note');
|
|
124
|
+
expect(updatedNote.content).toContain('# Updated');
|
|
125
|
+
expect(updatedNote.content).toContain('Updated content.');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('deleteNote', () => {
|
|
130
|
+
it('should delete existing note', async () => {
|
|
131
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
132
|
+
await vaultManager.createNote(testVaultPath, 'delete-note', '# To Delete\n\nContent.');
|
|
133
|
+
|
|
134
|
+
await vaultManager.deleteNote(testVaultPath, 'delete-note.md');
|
|
135
|
+
|
|
136
|
+
const note = await vaultManager.readNote(testVaultPath, 'delete-note.md');
|
|
137
|
+
expect(note).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('searchNotes', () => {
|
|
142
|
+
it('should find notes matching query', async () => {
|
|
143
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
144
|
+
await vaultManager.createNote(testVaultPath, 'note-one', '# First Note\n\nContains search term.');
|
|
145
|
+
await vaultManager.createNote(testVaultPath, 'note-two', '# Second Note\n\nDifferent content.');
|
|
146
|
+
|
|
147
|
+
const results = await vaultManager.searchNotes(testVaultPath, 'search term');
|
|
148
|
+
|
|
149
|
+
expect(results).toHaveLength(1);
|
|
150
|
+
expect(results[0].filename).toBe('note-one');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should be case insensitive', async () => {
|
|
154
|
+
vaultManager.createVault('Test Vault', testVaultPath);
|
|
155
|
+
await vaultManager.createNote(testVaultPath, 'search-test', '# Search Test\n\nUPPERCASE content.');
|
|
156
|
+
|
|
157
|
+
const results = await vaultManager.searchNotes(testVaultPath, 'uppercase');
|
|
158
|
+
|
|
159
|
+
expect(results).toHaveLength(1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { handleToolCall, tools } from '../../src/mcp/server.js';
|
|
5
|
+
|
|
6
|
+
function parseToolResponse(result: Awaited<ReturnType<typeof handleToolCall>>) {
|
|
7
|
+
const first = result.content[0];
|
|
8
|
+
if (first?.type !== 'text') {
|
|
9
|
+
throw new Error('Unexpected MCP response');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return first.text;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('MCP server', () => {
|
|
16
|
+
it('exposes the expected tools', () => {
|
|
17
|
+
expect(tools.map((tool) => tool.name)).toEqual(
|
|
18
|
+
expect.arrayContaining([
|
|
19
|
+
'vault_list',
|
|
20
|
+
'vault_open',
|
|
21
|
+
'vault_create',
|
|
22
|
+
'note_create',
|
|
23
|
+
'note_read',
|
|
24
|
+
'note_update',
|
|
25
|
+
'note_delete',
|
|
26
|
+
'note_search',
|
|
27
|
+
'graph_get',
|
|
28
|
+
'graph_neighbors',
|
|
29
|
+
])
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('handles vault, note and graph operations', async () => {
|
|
34
|
+
const vaultPath = await fs.mkdtemp(path.join(os.tmpdir(), 'opensidian-mcp-'));
|
|
35
|
+
|
|
36
|
+
const createVault = await handleToolCall('vault_create', {
|
|
37
|
+
name: 'MCP Vault',
|
|
38
|
+
path: vaultPath,
|
|
39
|
+
});
|
|
40
|
+
expect(createVault.isError).toBeUndefined();
|
|
41
|
+
expect(JSON.parse(parseToolResponse(createVault))).toEqual(
|
|
42
|
+
expect.objectContaining({ name: 'MCP Vault', path: vaultPath })
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const openVault = await handleToolCall('vault_open', { path: vaultPath });
|
|
46
|
+
expect(JSON.parse(parseToolResponse(openVault))).toEqual(
|
|
47
|
+
expect.objectContaining({ path: vaultPath })
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
await handleToolCall('note_create', {
|
|
51
|
+
vaultPath,
|
|
52
|
+
filename: 'second',
|
|
53
|
+
content: '# Second',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const createNote = await handleToolCall('note_create', {
|
|
57
|
+
vaultPath,
|
|
58
|
+
filename: 'first',
|
|
59
|
+
content: '# First\n\nSee [[second]] for details.',
|
|
60
|
+
});
|
|
61
|
+
expect(JSON.parse(parseToolResponse(createNote))).toEqual(
|
|
62
|
+
expect.objectContaining({ filename: 'first' })
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const readNote = await handleToolCall('note_read', {
|
|
66
|
+
vaultPath,
|
|
67
|
+
path: 'first.md',
|
|
68
|
+
});
|
|
69
|
+
expect(JSON.parse(parseToolResponse(readNote))).toEqual(
|
|
70
|
+
expect.objectContaining({ filename: 'first' })
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const updateNote = await handleToolCall('note_update', {
|
|
74
|
+
vaultPath,
|
|
75
|
+
path: 'first.md',
|
|
76
|
+
content: '# Updated\n\nSee [[second]].',
|
|
77
|
+
});
|
|
78
|
+
expect(JSON.parse(parseToolResponse(updateNote))).toEqual(
|
|
79
|
+
expect.objectContaining({ filename: 'first' })
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const searchNotes = await handleToolCall('note_search', {
|
|
83
|
+
vaultPath,
|
|
84
|
+
query: 'updated',
|
|
85
|
+
});
|
|
86
|
+
expect(JSON.parse(parseToolResponse(searchNotes))).toHaveLength(1);
|
|
87
|
+
|
|
88
|
+
const graph = await handleToolCall('graph_get', { vaultPath });
|
|
89
|
+
expect(JSON.parse(parseToolResponse(graph))).toEqual(
|
|
90
|
+
expect.objectContaining({
|
|
91
|
+
metadata: expect.objectContaining({ totalNotes: expect.any(Number) }),
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const neighbors = await handleToolCall('graph_neighbors', {
|
|
96
|
+
vaultPath,
|
|
97
|
+
path: 'first.md',
|
|
98
|
+
});
|
|
99
|
+
expect(JSON.parse(parseToolResponse(neighbors))).toEqual(expect.any(Array));
|
|
100
|
+
|
|
101
|
+
const deleteNote = await handleToolCall('note_delete', {
|
|
102
|
+
vaultPath,
|
|
103
|
+
path: 'first.md',
|
|
104
|
+
});
|
|
105
|
+
expect(JSON.parse(parseToolResponse(deleteNote))).toEqual({ success: true });
|
|
106
|
+
|
|
107
|
+
await fs.rm(vaultPath, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns typed errors for invalid or unknown tools', async () => {
|
|
111
|
+
const invalidArgs = await handleToolCall('vault_open', {});
|
|
112
|
+
expect(invalidArgs.isError).toBe(true);
|
|
113
|
+
|
|
114
|
+
const unknownTool = await handleToolCall('not-a-tool', {});
|
|
115
|
+
expect(unknownTool.isError).toBe(true);
|
|
116
|
+
expect(parseToolResponse(unknownTool)).toContain('Unknown tool');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { PluginHost } from '../../src/plugins/host';
|
|
3
|
+
import { Plugin } from '../../src/shared/types';
|
|
4
|
+
|
|
5
|
+
describe('PluginHost', () => {
|
|
6
|
+
let host: PluginHost;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
host = new PluginHost();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('loadPlugin', () => {
|
|
13
|
+
it('should load a valid plugin', async () => {
|
|
14
|
+
const plugin: Plugin = {
|
|
15
|
+
name: 'test-plugin',
|
|
16
|
+
version: '1.0.0',
|
|
17
|
+
async onLoad() {},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
await host.loadPlugin(plugin);
|
|
21
|
+
|
|
22
|
+
const plugins = host.listPlugins();
|
|
23
|
+
expect(plugins).toContain('test-plugin');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should throw when loading duplicate plugin', async () => {
|
|
27
|
+
const plugin: Plugin = {
|
|
28
|
+
name: 'duplicate-plugin',
|
|
29
|
+
version: '1.0.0',
|
|
30
|
+
async onLoad() {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
await host.loadPlugin(plugin);
|
|
34
|
+
await expect(host.loadPlugin(plugin)).rejects.toThrow('already loaded');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('unloadPlugin', () => {
|
|
39
|
+
it('should unload existing plugin', async () => {
|
|
40
|
+
const plugin: Plugin = {
|
|
41
|
+
name: 'unload-test',
|
|
42
|
+
version: '1.0.0',
|
|
43
|
+
async onLoad() {},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
await host.loadPlugin(plugin);
|
|
47
|
+
await host.unloadPlugin('unload-test');
|
|
48
|
+
|
|
49
|
+
const plugins = host.listPlugins();
|
|
50
|
+
expect(plugins).not.toContain('unload-test');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should call onUnload if defined', async () => {
|
|
54
|
+
const onUnload = vi.fn();
|
|
55
|
+
const plugin: Plugin = {
|
|
56
|
+
name: 'unload-callback-test',
|
|
57
|
+
version: '1.0.0',
|
|
58
|
+
async onLoad() {},
|
|
59
|
+
async onUnload() { onUnload(); },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
await host.loadPlugin(plugin);
|
|
63
|
+
await host.unloadPlugin('unload-callback-test');
|
|
64
|
+
|
|
65
|
+
expect(onUnload).toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should throw when unloading non-existent plugin', async () => {
|
|
69
|
+
await expect(host.unloadPlugin('non-existent')).rejects.toThrow('not loaded');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('registerCommand', () => {
|
|
74
|
+
it('should register command from loaded plugin', async () => {
|
|
75
|
+
const plugin: Plugin = {
|
|
76
|
+
name: 'command-test',
|
|
77
|
+
version: '1.0.0',
|
|
78
|
+
async onLoad(context) {
|
|
79
|
+
context.registerCommand({
|
|
80
|
+
id: 'test:command',
|
|
81
|
+
label: 'Test Command',
|
|
82
|
+
execute: () => {},
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
await host.loadPlugin(plugin);
|
|
88
|
+
|
|
89
|
+
const command = host.getCommand('test:command');
|
|
90
|
+
expect(command).toBeDefined();
|
|
91
|
+
expect(command?.id).toBe('test:command');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should throw when registering duplicate command', async () => {
|
|
95
|
+
const plugin: Plugin = {
|
|
96
|
+
name: 'dup-command-test',
|
|
97
|
+
version: '1.0.0',
|
|
98
|
+
async onLoad(context) {
|
|
99
|
+
context.registerCommand({
|
|
100
|
+
id: 'test:dup',
|
|
101
|
+
label: 'Command 1',
|
|
102
|
+
execute: () => {},
|
|
103
|
+
});
|
|
104
|
+
context.registerCommand({
|
|
105
|
+
id: 'test:dup',
|
|
106
|
+
label: 'Command 2',
|
|
107
|
+
execute: () => {},
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
await expect(host.loadPlugin(plugin)).rejects.toThrow('already registered');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('registerHook', () => {
|
|
117
|
+
it('should register hook from loaded plugin', async () => {
|
|
118
|
+
const handler = vi.fn();
|
|
119
|
+
const plugin: Plugin = {
|
|
120
|
+
name: 'hook-test',
|
|
121
|
+
version: '1.0.0',
|
|
122
|
+
async onLoad(context) {
|
|
123
|
+
context.registerHook('test:hook', handler);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
await host.loadPlugin(plugin);
|
|
128
|
+
const results = await host.executeHook('test:hook', 'arg');
|
|
129
|
+
|
|
130
|
+
expect(handler).toHaveBeenCalledWith('arg');
|
|
131
|
+
expect(results).toContain(handler());
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('listCommands', () => {
|
|
136
|
+
it('should list all registered commands', async () => {
|
|
137
|
+
const plugin: Plugin = {
|
|
138
|
+
name: 'list-commands-test',
|
|
139
|
+
version: '1.0.0',
|
|
140
|
+
async onLoad(context) {
|
|
141
|
+
context.registerCommand({ id: 'cmd1', label: 'Cmd 1', execute: () => {} });
|
|
142
|
+
context.registerCommand({ id: 'cmd2', label: 'Cmd 2', execute: () => {} });
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
await host.loadPlugin(plugin);
|
|
147
|
+
const commands = host.listCommands();
|
|
148
|
+
|
|
149
|
+
expect(commands).toHaveLength(2);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('listPlugins', () => {
|
|
154
|
+
it('should list all loaded plugins', async () => {
|
|
155
|
+
await host.loadPlugin({ name: 'plugin1', version: '1.0.0', async onLoad() {} });
|
|
156
|
+
await host.loadPlugin({ name: 'plugin2', version: '1.0.0', async onLoad() {} });
|
|
157
|
+
|
|
158
|
+
const plugins = host.listPlugins();
|
|
159
|
+
|
|
160
|
+
expect(plugins).toContain('plugin1');
|
|
161
|
+
expect(plugins).toContain('plugin2');
|
|
162
|
+
expect(plugins).toHaveLength(2);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import samplePlugin from '../../src/plugins/sample-plugin.js';
|
|
2
|
+
import { PluginHost } from '../../src/plugins/host.js';
|
|
3
|
+
|
|
4
|
+
describe('SamplePlugin', () => {
|
|
5
|
+
it('registers command and hooks when loaded', async () => {
|
|
6
|
+
const host = new PluginHost();
|
|
7
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
8
|
+
|
|
9
|
+
await host.loadPlugin(samplePlugin);
|
|
10
|
+
|
|
11
|
+
const command = host.getCommand('sample:greet');
|
|
12
|
+
expect(command).toBeDefined();
|
|
13
|
+
|
|
14
|
+
await command?.execute();
|
|
15
|
+
const results = await host.executeHook('note:pre-save', {
|
|
16
|
+
path: 'note.md',
|
|
17
|
+
content: '# Hello',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(logSpy).toHaveBeenCalledWith('Hello from sample plugin!');
|
|
21
|
+
expect(results).toEqual([
|
|
22
|
+
{
|
|
23
|
+
path: 'note.md',
|
|
24
|
+
content: '# Hello',
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
await host.executeHook('note:post-save', { path: 'note.md' });
|
|
29
|
+
await host.unloadPlugin(samplePlugin.name);
|
|
30
|
+
|
|
31
|
+
expect(host.listPlugins()).not.toContain(samplePlugin.name);
|
|
32
|
+
|
|
33
|
+
logSpy.mockRestore();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import net from 'net';
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
import { OpenSidianServer } from '../src/server.js';
|
|
4
|
+
|
|
5
|
+
async function getFreePort(): Promise<number> {
|
|
6
|
+
return await new Promise<number>((resolve, reject) => {
|
|
7
|
+
const server = net.createServer();
|
|
8
|
+
server.listen(0, '127.0.0.1', () => {
|
|
9
|
+
const address = server.address();
|
|
10
|
+
if (!address || typeof address === 'string') {
|
|
11
|
+
reject(new Error('Unable to allocate port'));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { port } = address;
|
|
16
|
+
server.close((error) => {
|
|
17
|
+
if (error) {
|
|
18
|
+
reject(error);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
resolve(port);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function wait(ms: number): Promise<void> {
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('OpenSidianServer', () => {
|
|
32
|
+
it('starts, serves health, handles websocket messages and stops', async () => {
|
|
33
|
+
const port = await getFreePort();
|
|
34
|
+
const syncPort = await getFreePort();
|
|
35
|
+
const server = new OpenSidianServer({
|
|
36
|
+
server: { port, host: '127.0.0.1' },
|
|
37
|
+
sync: { enabled: true, port: syncPort },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
41
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
42
|
+
|
|
43
|
+
server.start();
|
|
44
|
+
await wait(100);
|
|
45
|
+
|
|
46
|
+
const healthResponse = await fetch(`http://127.0.0.1:${port}/health`);
|
|
47
|
+
expect(healthResponse.status).toBe(200);
|
|
48
|
+
expect(await healthResponse.json()).toEqual(
|
|
49
|
+
expect.objectContaining({ status: 'ok' })
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
53
|
+
await new Promise<void>((resolve, reject) => {
|
|
54
|
+
socket.once('open', () => resolve());
|
|
55
|
+
socket.once('error', (error) => reject(error));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
socket.send(JSON.stringify({ hello: 'world' }));
|
|
59
|
+
socket.send('not-json');
|
|
60
|
+
await wait(50);
|
|
61
|
+
|
|
62
|
+
expect(logSpy).toHaveBeenCalledWith('WebSocket message:', { hello: 'world' });
|
|
63
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
64
|
+
|
|
65
|
+
socket.close();
|
|
66
|
+
server.stop();
|
|
67
|
+
|
|
68
|
+
logSpy.mockRestore();
|
|
69
|
+
errorSpy.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('stops safely even when never started', () => {
|
|
73
|
+
const server = new OpenSidianServer();
|
|
74
|
+
expect(() => server.stop()).not.toThrow();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"declarationMap": true,
|
|
16
|
+
"sourceMap": true,
|
|
17
|
+
"noImplicitAny": true,
|
|
18
|
+
"strictNullChecks": true,
|
|
19
|
+
"strictFunctionTypes": true,
|
|
20
|
+
"noImplicitReturns": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
|
22
|
+
"noUnusedLocals": true,
|
|
23
|
+
"noUnusedParameters": true
|
|
24
|
+
},
|
|
25
|
+
"include": ["src/**/*"],
|
|
26
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
27
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
alias: {
|
|
7
|
+
'@': resolve(__dirname, './src'),
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
build: {
|
|
11
|
+
lib: {
|
|
12
|
+
entry: resolve(__dirname, 'src/index.ts'),
|
|
13
|
+
name: 'OpenSidian',
|
|
14
|
+
fileName: 'opensidian',
|
|
15
|
+
},
|
|
16
|
+
rollupOptions: {
|
|
17
|
+
external: ['express', 'ws', 'cors'],
|
|
18
|
+
output: {
|
|
19
|
+
globals: {
|
|
20
|
+
express: 'express',
|
|
21
|
+
ws: 'ws',
|
|
22
|
+
cors: 'cors',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|