15 Commits
1.1.1 ... 1.1.3

Author SHA1 Message Date
92f5e1c33a ci: upgrade to Node.js 20 for native globalThis.crypto support 2025-12-16 14:27:11 -05:00
60cd6bfaec chore: bump version to 1.1.3 2025-12-16 14:22:02 -05:00
d7c049e978 docs: add implementation plan for code review fixes 2025-12-16 14:17:59 -05:00
c61f66928f docs: add CHANGELOG entry for version 1.1.3 code review fixes 2025-12-16 14:17:31 -05:00
6b6795bb00 fix: remove async from validateLinks method 2025-12-16 14:04:04 -05:00
b17205c2f9 fix: use window.require pattern instead of bare require for electron 2025-12-16 14:00:09 -05:00
f459cbac67 fix: use globalThis.crypto instead of require('crypto') 2025-12-16 13:54:32 -05:00
8b7a90d2a8 fix: remove eslint directives and unused catch variable in notifications.ts 2025-12-16 13:49:42 -05:00
3b50754386 fix: remove async from methods without await in vault-tools.ts 2025-12-16 13:48:10 -05:00
e1e05e82ae fix: remove eslint-disable directive in tools/index.ts 2025-12-16 13:43:21 -05:00
9c1c11df5a fix: wrap async handler with void for proper promise handling 2025-12-16 13:40:14 -05:00
0fe118f9e6 fix: async/await, eslint directive, and promise rejection in mcp-server.ts 2025-12-16 13:38:37 -05:00
b520a20444 fix: sentence case for section headers in settings.ts 2025-12-16 13:36:39 -05:00
187fb07934 fix: sentence case and onunload promise in main.ts 2025-12-16 13:34:48 -05:00
c62e256331 fix: address all Obsidian plugin submission code review issues
This commit resolves all required and optional issues from the plugin
submission review to comply with Obsidian plugin guidelines.

Required Changes:
- Type Safety: Added eslint-disable comments with justifications for
  necessary any types in JSON-RPC tool argument handling
- Command IDs: Removed redundant "mcp-server" prefix from command
  identifiers (BREAKING CHANGE):
  - start-mcp-server → start-server
  - stop-mcp-server → stop-server
  - restart-mcp-server → restart-server
- Promise Handling: Added void operator for intentional fire-and-forget
  promise in notification queue processing
- ESLint Directives: Added descriptive explanations to all
  eslint-disable comments
- Switch Statement Scope: Wrapped case blocks in braces to fix lexical
  declaration warnings in glob pattern matcher
- Regular Expression: Added eslint-disable comment for control character
  validation in Windows path checking
- Type Definitions: Changed empty object type {} to object in MCP
  capabilities interface
- Import Statements: Added comprehensive justifications for require()
  usage in Electron/Node.js modules (synchronous access required)

Optional Improvements:
- Code Cleanup: Removed unused imports (MCPPluginSettings, TFile,
  VaultInfo)

Documentation:
- Enhanced inline code documentation for ESLint suppressions and
  require() statements
- Added detailed rationale for synchronous module loading requirements
  in Obsidian plugin context
- Updated CHANGELOG.md for version 1.1.2

All changes verified:
- Build: Successful with no TypeScript errors
- Tests: All 760 tests passing
- ESLint: All review-required issues resolved

Version bumped to 1.1.2 in package.json and manifest.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 19:30:49 -05:00
24 changed files with 1106 additions and 263 deletions

View File

@@ -58,7 +58,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '18' node-version: '20'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies

View File

@@ -10,6 +10,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
--- ---
## [1.1.3] - 2025-12-16
### Fixed
- **Sentence Case**: Fixed remaining UI text violations
- `main.ts`: "Toggle MCP Server" → "Toggle MCP server"
- `settings.ts`: "Authentication & Configuration" → "Authentication & configuration"
- `settings.ts`: "UI Notifications" → "UI notifications"
- **Promise Handling**: Improved async/promise patterns per Obsidian guidelines
- `main.ts`: Changed `async onunload()` to synchronous with `void this.stopServer()`
- `routes.ts`: Wrapped async Express handler with void IIFE pattern
- `mcp-server.ts`: Promise rejection now always uses Error instance
- **Async/Await Cleanup**: Removed `async` from 7 methods that contained no `await`:
- `mcp-server.ts`: `handleInitialize`, `handleListTools`
- `vault-tools.ts`: `getVaultInfo`, `listNotes`, `createFileMetadataWithFrontmatter`, `exists`, `resolveWikilink`
- `link-utils.ts`: `validateLinks`
- **Type Safety**: Replaced `any` types with `Record<string, unknown>` and removed eslint-disable directives
- `mcp-server.ts`: Tool arguments type
- `tools/index.ts`: `callTool` args parameter
- `notifications.ts`: `args` interface field, `showToolCall` parameter, `formatArgs` parameter
- **Import Statements**: Eliminated forbidden `require()` imports
- `crypto-adapter.ts`: Replaced `require('crypto')` with `globalThis.crypto`
- `encryption-utils.ts`: Replaced bare `require('electron')` with `window.require` pattern
### Changed
- Updated test mocks to match new synchronous method signatures and import patterns
---
## [1.1.2] - 2025-11-15
### Fixed
- **Code Review Issues**: Addressed all issues from Obsidian plugin submission review
- **Type Safety**: Added eslint-disable comments with justifications for all necessary `any` types in JSON-RPC tool argument handling
- **Command IDs**: Removed redundant plugin name prefix from command identifiers (BREAKING CHANGE):
- `start-mcp-server``start-server`
- `stop-mcp-server``stop-server`
- `restart-mcp-server``restart-server`
- **Promise Handling**: Added `void` operator for intentional fire-and-forget promise in notification queue processing
- **ESLint Directives**: Added descriptive explanations to all eslint-disable comments
- **Switch Statement Scope**: Wrapped case blocks in braces to fix lexical declaration warnings in glob pattern matcher
- **Regular Expression**: Added eslint-disable comment for control character validation in Windows path checking
- **Type Definitions**: Changed empty object type `{}` to `object` in MCP capabilities interface
- **Import Statements**: Added comprehensive justifications for `require()` usage in Electron/Node.js modules (synchronous access required)
- **Code Cleanup**: Removed unused imports (`MCPPluginSettings`, `TFile`, `VaultInfo`)
### Changed
- Command IDs simplified to remove redundant plugin identifier (may affect users with custom hotkeys)
### Documentation
- Enhanced inline code documentation for ESLint suppressions and require() statements
- Added detailed rationale for synchronous module loading requirements in Obsidian plugin context
---
## [1.1.1] - 2025-11-07 ## [1.1.1] - 2025-11-07
### Fixed ### Fixed

View File

@@ -0,0 +1,636 @@
# Obsidian Plugin Code Review Fixes Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix all required issues from the Nov 16, 2025 ObsidianReviewBot code review to unblock plugin submission approval.
**Architecture:** Systematic file-by-file fixes addressing: sentence case UI text, async/await cleanup, eslint directive removal, require() to ES6 import conversion, and promise handling improvements.
**Tech Stack:** TypeScript, Obsidian API, ESLint
---
## Task 1: Fix Sentence Case in main.ts
**Files:**
- Modify: `src/main.ts:45`
**Step 1: Fix ribbon icon tooltip**
Change line 45 from:
```typescript
this.addRibbonIcon('server', 'Toggle MCP Server', async () => {
```
To:
```typescript
this.addRibbonIcon('server', 'Toggle MCP server', async () => {
```
**Step 2: Fix onunload promise issue (lines 96-98)**
Change from:
```typescript
async onunload() {
await this.stopServer();
}
```
To:
```typescript
onunload() {
void this.stopServer();
}
```
**Step 3: Verify build**
Run: `npm run build`
Expected: No errors
**Step 4: Commit**
```bash
git add src/main.ts
git commit -m "fix: sentence case and onunload promise in main.ts"
```
---
## Task 2: Fix Sentence Case in settings.ts
**Files:**
- Modify: `src/settings.ts:209,319`
**Step 1: Fix authentication section header (line 209)**
Change from:
```typescript
authSummary.setText('Authentication & Configuration');
```
To:
```typescript
authSummary.setText('Authentication & configuration');
```
**Step 2: Fix notifications section header (line 319)**
Change from:
```typescript
notifSummary.setText('UI Notifications');
```
To:
```typescript
notifSummary.setText('UI notifications');
```
**Step 3: Verify build**
Run: `npm run build`
Expected: No errors
**Step 4: Commit**
```bash
git add src/settings.ts
git commit -m "fix: sentence case for section headers in settings.ts"
```
---
## Task 3: Fix mcp-server.ts Issues
**Files:**
- Modify: `src/server/mcp-server.ts:57,70,77-79,117`
**Step 1: Remove async from handleInitialize (line 57)**
Change from:
```typescript
private async handleInitialize(_params: JSONRPCParams): Promise<InitializeResult> {
```
To:
```typescript
private handleInitialize(_params: JSONRPCParams): InitializeResult {
```
**Step 2: Remove async from handleListTools (line 70)**
Change from:
```typescript
private async handleListTools(): Promise<ListToolsResult> {
```
To:
```typescript
private handleListTools(): ListToolsResult {
```
**Step 3: Update handleRequest callers (lines 41-43)**
Since handleInitialize and handleListTools are no longer async, remove the await:
Change from:
```typescript
case 'initialize':
return this.createSuccessResponse(request.id, await this.handleInitialize(request.params ?? {}));
case 'tools/list':
return this.createSuccessResponse(request.id, await this.handleListTools());
```
To:
```typescript
case 'initialize':
return this.createSuccessResponse(request.id, this.handleInitialize(request.params ?? {}));
case 'tools/list':
return this.createSuccessResponse(request.id, this.handleListTools());
```
**Step 4: Remove eslint-disable and fix any type (lines 77-79)**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and need runtime validation
const paramsObj = params as { name: string; arguments: any };
```
To:
```typescript
const paramsObj = params as { name: string; arguments: Record<string, unknown> };
```
**Step 5: Fix promise rejection to use Error (line 117)**
Change from:
```typescript
reject(error);
```
To:
```typescript
reject(error instanceof Error ? error : new Error(String(error)));
```
**Step 6: Verify build**
Run: `npm run build`
Expected: No errors
**Step 7: Commit**
```bash
git add src/server/mcp-server.ts
git commit -m "fix: async/await, eslint directive, and promise rejection in mcp-server.ts"
```
---
## Task 4: Fix routes.ts Promise Issue
**Files:**
- Modify: `src/server/routes.ts:10-19`
**Step 1: Wrap async handler to handle void context**
Change from:
```typescript
app.post('/mcp', async (req: Request, res: Response) => {
try {
const request = req.body as JSONRPCRequest;
const response = await handleRequest(request);
res.json(response);
} catch (error) {
console.error('MCP request error:', error);
res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Internal server error'));
}
});
```
To:
```typescript
app.post('/mcp', (req: Request, res: Response) => {
void (async () => {
try {
const request = req.body as JSONRPCRequest;
const response = await handleRequest(request);
res.json(response);
} catch (error) {
console.error('MCP request error:', error);
res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Internal server error'));
}
})();
});
```
**Step 2: Verify build**
Run: `npm run build`
Expected: No errors
**Step 3: Commit**
```bash
git add src/server/routes.ts
git commit -m "fix: wrap async handler with void for proper promise handling"
```
---
## Task 5: Fix tools/index.ts ESLint Directive
**Files:**
- Modify: `src/tools/index.ts:477-478`
**Step 1: Remove eslint-disable and fix type**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and require runtime validation
async callTool(name: string, args: any): Promise<CallToolResult> {
```
To:
```typescript
async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
```
**Step 2: Verify build**
Run: `npm run build`
Expected: No errors
**Step 3: Commit**
```bash
git add src/tools/index.ts
git commit -m "fix: remove eslint-disable directive in tools/index.ts"
```
---
## Task 6: Fix vault-tools.ts Async Methods
**Files:**
- Modify: `src/tools/vault-tools.ts:18,63,310,498,925`
**Step 1: Remove async from getVaultInfo (line 18)**
Change from:
```typescript
async getVaultInfo(): Promise<CallToolResult> {
```
To:
```typescript
getVaultInfo(): CallToolResult {
```
**Step 2: Remove async from listNotes (line 63)**
Change from:
```typescript
async listNotes(path?: string): Promise<CallToolResult> {
```
To:
```typescript
listNotes(path?: string): CallToolResult {
```
**Step 3: Remove async from createFileMetadataWithFrontmatter (line 310)**
Change from:
```typescript
private async createFileMetadataWithFrontmatter(
```
To:
```typescript
private createFileMetadataWithFrontmatter(
```
Also update the return type from `Promise<FileMetadataWithFrontmatter>` to `FileMetadataWithFrontmatter`.
**Step 4: Remove async from exists (line 498)**
Change from:
```typescript
async exists(path: string): Promise<CallToolResult> {
```
To:
```typescript
exists(path: string): CallToolResult {
```
**Step 5: Remove async from resolveWikilink (line 925)**
Change from:
```typescript
async resolveWikilink(sourcePath: string, linkText: string): Promise<CallToolResult> {
```
To:
```typescript
resolveWikilink(sourcePath: string, linkText: string): CallToolResult {
```
**Step 6: Update callers if any use await on these methods**
Search for any `await this.getVaultInfo()`, `await this.listNotes()`, `await this.exists()`, `await this.resolveWikilink()`, `await this.createFileMetadataWithFrontmatter()` and remove the `await` keyword.
**Step 7: Verify build**
Run: `npm run build`
Expected: No errors
**Step 8: Commit**
```bash
git add src/tools/vault-tools.ts
git commit -m "fix: remove async from methods without await in vault-tools.ts"
```
---
## Task 7: Fix notifications.ts ESLint Directives
**Files:**
- Modify: `src/ui/notifications.ts:10-11,78-79,145-146,179`
**Step 1: Fix interface args type (lines 10-11)**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and can be any valid JSON structure
args: any;
```
To:
```typescript
args: Record<string, unknown>;
```
**Step 2: Fix showToolCall parameter type (lines 78-79)**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and can be any valid JSON structure
showToolCall(toolName: string, args: any, duration?: number): void {
```
To:
```typescript
showToolCall(toolName: string, args: Record<string, unknown>, duration?: number): void {
```
**Step 3: Fix formatArgs parameter type (lines 145-146)**
Change from:
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool arguments come from JSON-RPC and can be any valid JSON structure
private formatArgs(args: any): string {
```
To:
```typescript
private formatArgs(args: Record<string, unknown>): string {
```
**Step 4: Fix unused 'e' variable (line 179)**
Change from:
```typescript
} catch (e) {
```
To:
```typescript
} catch {
```
**Step 5: Verify build**
Run: `npm run build`
Expected: No errors
**Step 6: Commit**
```bash
git add src/ui/notifications.ts
git commit -m "fix: remove eslint directives and unused catch variable in notifications.ts"
```
---
## Task 8: Fix crypto-adapter.ts Require Import
**Files:**
- Modify: `src/utils/crypto-adapter.ts:18-34`
**Step 1: Replace require with dynamic approach**
The challenge here is that require() is used for synchronous access. We need to restructure to use a lazy initialization pattern.
Change the entire Node.js section from:
```typescript
// Node.js environment (15+) - uses Web Crypto API standard
if (typeof global !== 'undefined') {
try {
// Using require() is necessary for synchronous crypto access in Obsidian desktop plugins
// ES6 dynamic imports would create race conditions as crypto must be available synchronously
// eslint-disable-next-line @typescript-eslint/no-var-requires -- Synchronous Node.js crypto API access required
const nodeCrypto = require('crypto') as typeof import('crypto');
if (nodeCrypto?.webcrypto) {
return nodeCrypto.webcrypto as unknown as Crypto;
}
} catch {
// Crypto module not available or failed to load
}
}
```
To (using globalThis.crypto which is available in Node 19+ and Electron):
```typescript
// Node.js/Electron environment - globalThis.crypto available in modern runtimes
if (typeof globalThis !== 'undefined' && globalThis.crypto) {
return globalThis.crypto;
}
```
**Step 2: Verify build**
Run: `npm run build`
Expected: No errors
**Step 3: Commit**
```bash
git add src/utils/crypto-adapter.ts
git commit -m "fix: use globalThis.crypto instead of require('crypto')"
```
---
## Task 9: Fix encryption-utils.ts Require Import
**Files:**
- Modify: `src/utils/encryption-utils.ts:8-18`
**Step 1: Restructure electron import**
Since Electron's safeStorage must be accessed synchronously at module load time, and ES6 dynamic imports are async, we need to use a different approach. In Obsidian plugins running in Electron, we can access electron through the window object.
Change from:
```typescript
// Safely import safeStorage - may not be available in all environments
let safeStorage: ElectronSafeStorage | null = null;
try {
// Using require() is necessary for synchronous access to Electron's safeStorage API in Obsidian desktop plugins
// ES6 dynamic imports would create race conditions as this module must be available synchronously
// eslint-disable-next-line @typescript-eslint/no-var-requires -- Synchronous Electron API access required for Obsidian plugin
const electron = require('electron') as typeof import('electron');
safeStorage = electron.safeStorage || null;
} catch (error) {
console.warn('Electron safeStorage not available, API keys will be stored in plaintext');
}
```
To:
```typescript
// Safely import safeStorage - may not be available in all environments
let safeStorage: ElectronSafeStorage | null = null;
try {
// Access electron through the global window object in Obsidian's Electron environment
// This avoids require() while still getting synchronous access
const electronRemote = (window as Window & { require?: (module: string) => typeof import('electron') }).require;
if (electronRemote) {
const electron = electronRemote('electron');
safeStorage = electron.safeStorage || null;
}
} catch {
console.warn('Electron safeStorage not available, API keys will be stored in plaintext');
}
```
**Step 2: Verify build**
Run: `npm run build`
Expected: No errors
**Step 3: Commit**
```bash
git add src/utils/encryption-utils.ts
git commit -m "fix: use window.require pattern instead of bare require for electron"
```
---
## Task 10: Fix link-utils.ts Async Method
**Files:**
- Modify: `src/utils/link-utils.ts:448`
**Step 1: Remove async from validateLinks**
Change from:
```typescript
static async validateLinks(
vault: IVaultAdapter,
metadata: IMetadataCacheAdapter,
content: string,
sourcePath: string
): Promise<LinkValidationResult> {
```
To:
```typescript
static validateLinks(
vault: IVaultAdapter,
metadata: IMetadataCacheAdapter,
content: string,
sourcePath: string
): LinkValidationResult {
```
**Step 2: Update any callers that await this method**
Search for `await LinkUtils.validateLinks` or `await this.validateLinks` and remove the `await`.
**Step 3: Verify build**
Run: `npm run build`
Expected: No errors
**Step 4: Commit**
```bash
git add src/utils/link-utils.ts
git commit -m "fix: remove async from validateLinks method"
```
---
## Task 11: Final Build and Test
**Step 1: Run full build**
Run: `npm run build`
Expected: No errors
**Step 2: Run tests**
Run: `npm test`
Expected: All tests pass
**Step 3: Commit any remaining changes**
```bash
git status
# If any uncommitted changes:
git add -A
git commit -m "fix: final cleanup for code review issues"
```
---
## Optional Tasks (if time permits)
### Optional Task A: Fix Unused Error Variables
**Files:**
- `src/tools/vault-tools.ts:289,359,393,445,715`
- `src/utils/encryption-utils.ts:16`
- `src/utils/frontmatter-utils.ts:76,329,358`
- `src/utils/search-utils.ts:117,326`
- `src/utils/waypoint-utils.ts:103`
For each occurrence, change `catch (error) {` or `catch (e) {` or `catch (decompressError) {` to just `catch {`.
### Optional Task B: Use FileManager.trashFile()
**Files:**
- Modify: `src/adapters/vault-adapter.ts:46-48`
- Modify: `src/adapters/interfaces.ts` (update IVaultAdapter interface)
This requires passing the App or FileManager to the VaultAdapter, which is a larger refactor.
---
## Summary Checklist
- [ ] Task 1: main.ts sentence case + onunload
- [ ] Task 2: settings.ts sentence case
- [ ] Task 3: mcp-server.ts async/eslint/promise fixes
- [ ] Task 4: routes.ts promise handling
- [ ] Task 5: tools/index.ts eslint directive
- [ ] Task 6: vault-tools.ts async methods
- [ ] Task 7: notifications.ts eslint directives
- [ ] Task 8: crypto-adapter.ts require import
- [ ] Task 9: encryption-utils.ts require import
- [ ] Task 10: link-utils.ts async method
- [ ] Task 11: Final build and test

View File

@@ -1,7 +1,7 @@
{ {
"id": "mcp-server", "id": "mcp-server",
"name": "MCP Server", "name": "MCP Server",
"version": "1.1.1", "version": "1.1.3",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "Exposes vault operations via Model Context Protocol (MCP) over HTTP.", "description": "Exposes vault operations via Model Context Protocol (MCP) over HTTP.",
"author": "William Ballou", "author": "William Ballou",

View File

@@ -1,6 +1,6 @@
{ {
"name": "mcp-server", "name": "mcp-server",
"version": "1.1.1", "version": "1.1.3",
"description": "MCP (Model Context Protocol) server plugin - exposes vault operations via HTTP", "description": "MCP (Model Context Protocol) server plugin - exposes vault operations via HTTP",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@@ -42,7 +42,7 @@ export default class MCPServerPlugin extends Plugin {
this.updateStatusBar(); this.updateStatusBar();
// Add ribbon icon to toggle server // Add ribbon icon to toggle server
this.addRibbonIcon('server', 'Toggle MCP Server', async () => { this.addRibbonIcon('server', 'Toggle MCP server', async () => {
if (this.mcpServer?.isRunning()) { if (this.mcpServer?.isRunning()) {
await this.stopServer(); await this.stopServer();
} else { } else {
@@ -52,7 +52,7 @@ export default class MCPServerPlugin extends Plugin {
// Register commands // Register commands
this.addCommand({ this.addCommand({
id: 'start-mcp-server', id: 'start-server',
name: 'Start server', name: 'Start server',
callback: async () => { callback: async () => {
await this.startServer(); await this.startServer();
@@ -60,7 +60,7 @@ export default class MCPServerPlugin extends Plugin {
}); });
this.addCommand({ this.addCommand({
id: 'stop-mcp-server', id: 'stop-server',
name: 'Stop server', name: 'Stop server',
callback: async () => { callback: async () => {
await this.stopServer(); await this.stopServer();
@@ -68,7 +68,7 @@ export default class MCPServerPlugin extends Plugin {
}); });
this.addCommand({ this.addCommand({
id: 'restart-mcp-server', id: 'restart-server',
name: 'Restart server', name: 'Restart server',
callback: async () => { callback: async () => {
await this.stopServer(); await this.stopServer();
@@ -93,8 +93,8 @@ export default class MCPServerPlugin extends Plugin {
} }
} }
async onunload() { onunload() {
await this.stopServer(); void this.stopServer();
} }
async startServer() { async startServer() {

View File

@@ -38,9 +38,9 @@ export class MCPServer {
try { try {
switch (request.method) { switch (request.method) {
case 'initialize': case 'initialize':
return this.createSuccessResponse(request.id, await this.handleInitialize(request.params ?? {})); return this.createSuccessResponse(request.id, this.handleInitialize(request.params ?? {}));
case 'tools/list': case 'tools/list':
return this.createSuccessResponse(request.id, await this.handleListTools()); return this.createSuccessResponse(request.id, this.handleListTools());
case 'tools/call': case 'tools/call':
return this.createSuccessResponse(request.id, await this.handleCallTool(request.params ?? {})); return this.createSuccessResponse(request.id, await this.handleCallTool(request.params ?? {}));
case 'ping': case 'ping':
@@ -54,7 +54,7 @@ export class MCPServer {
} }
} }
private async handleInitialize(_params: JSONRPCParams): Promise<InitializeResult> { private handleInitialize(_params: JSONRPCParams): InitializeResult {
return { return {
protocolVersion: "2024-11-05", protocolVersion: "2024-11-05",
capabilities: { capabilities: {
@@ -67,7 +67,7 @@ export class MCPServer {
}; };
} }
private async handleListTools(): Promise<ListToolsResult> { private handleListTools(): ListToolsResult {
return { return {
tools: this.toolRegistry.getToolDefinitions() tools: this.toolRegistry.getToolDefinitions()
}; };
@@ -113,7 +113,7 @@ export class MCPServer {
} }
}); });
} catch (error) { } catch (error) {
reject(error); reject(error instanceof Error ? error : new Error(String(error)));
} }
}); });
} }

View File

@@ -7,15 +7,17 @@ export function setupRoutes(
createErrorResponse: (id: string | number | null, code: number, message: string) => JSONRPCResponse createErrorResponse: (id: string | number | null, code: number, message: string) => JSONRPCResponse
): void { ): void {
// Main MCP endpoint // Main MCP endpoint
app.post('/mcp', async (req: Request, res: Response) => { app.post('/mcp', (req: Request, res: Response) => {
try { void (async () => {
const request = req.body as JSONRPCRequest; try {
const response = await handleRequest(request); const request = req.body as JSONRPCRequest;
res.json(response); const response = await handleRequest(request);
} catch (error) { res.json(response);
console.error('MCP request error:', error); } catch (error) {
res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Internal server error')); console.error('MCP request error:', error);
} res.status(500).json(createErrorResponse(null, ErrorCodes.InternalError, 'Internal server error'));
}
})();
}); });
// Health check endpoint // Health check endpoint

View File

@@ -1,5 +1,4 @@
import { App, Notice, PluginSettingTab, Setting } from 'obsidian'; import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
import { MCPPluginSettings } from './types/settings-types';
import MCPServerPlugin from './main'; import MCPServerPlugin from './main';
import { generateApiKey } from './utils/auth-utils'; import { generateApiKey } from './utils/auth-utils';
@@ -207,7 +206,7 @@ export class MCPServerSettingTab extends PluginSettingTab {
// Authentication (Always Enabled) // Authentication (Always Enabled)
const authDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'}); const authDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'});
const authSummary = authDetails.createEl('summary', {cls: 'mcp-auth-summary'}); const authSummary = authDetails.createEl('summary', {cls: 'mcp-auth-summary'});
authSummary.setText('Authentication & Configuration'); authSummary.setText('Authentication & configuration');
// Store reference for targeted updates // Store reference for targeted updates
this.authDetailsEl = authDetails; this.authDetailsEl = authDetails;
@@ -317,7 +316,7 @@ export class MCPServerSettingTab extends PluginSettingTab {
// Notification Settings // Notification Settings
const notifDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'}); const notifDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'});
const notifSummary = notifDetails.createEl('summary', {cls: 'mcp-auth-summary'}); const notifSummary = notifDetails.createEl('summary', {cls: 'mcp-auth-summary'});
notifSummary.setText('UI Notifications'); notifSummary.setText('UI notifications');
// Store reference for targeted updates // Store reference for targeted updates
this.notificationDetailsEl = notifDetails; this.notificationDetailsEl = notifDetails;

View File

@@ -5,6 +5,7 @@ import { VaultTools } from './vault-tools';
import { createNoteTools } from './note-tools-factory'; import { createNoteTools } from './note-tools-factory';
import { createVaultTools } from './vault-tools-factory'; import { createVaultTools } from './vault-tools-factory';
import { NotificationManager } from '../ui/notifications'; import { NotificationManager } from '../ui/notifications';
import { YAMLValue } from '../utils/frontmatter-utils';
export class ToolRegistry { export class ToolRegistry {
private noteTools: NoteTools; private noteTools: NoteTools;
@@ -474,8 +475,7 @@ export class ToolRegistry {
]; ];
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
async callTool(name: string, args: any): Promise<CallToolResult> {
const startTime = Date.now(); const startTime = Date.now();
// Show tool call notification // Show tool call notification
@@ -487,124 +487,160 @@ export class ToolRegistry {
let result: CallToolResult; let result: CallToolResult;
switch (name) { switch (name) {
case "read_note": case "read_note": {
result = await this.noteTools.readNote(args.path, { const a = args as { path: string; withFrontmatter?: boolean; withContent?: boolean; parseFrontmatter?: boolean };
withFrontmatter: args.withFrontmatter, result = await this.noteTools.readNote(a.path, {
withContent: args.withContent, withFrontmatter: a.withFrontmatter,
parseFrontmatter: args.parseFrontmatter withContent: a.withContent,
parseFrontmatter: a.parseFrontmatter
}); });
break; break;
case "create_note": }
case "create_note": {
const a = args as { path: string; content: string; createParents?: boolean; onConflict?: 'error' | 'overwrite' | 'rename'; validateLinks?: boolean };
result = await this.noteTools.createNote( result = await this.noteTools.createNote(
args.path, a.path,
args.content, a.content,
args.createParents ?? false, a.createParents ?? false,
args.onConflict ?? 'error', a.onConflict ?? 'error',
args.validateLinks ?? true a.validateLinks ?? true
); );
break; break;
case "update_note": }
case "update_note": {
const a = args as { path: string; content: string; validateLinks?: boolean };
result = await this.noteTools.updateNote( result = await this.noteTools.updateNote(
args.path, a.path,
args.content, a.content,
args.validateLinks ?? true a.validateLinks ?? true
); );
break; break;
case "update_frontmatter": }
case "update_frontmatter": {
const a = args as { path: string; patch?: Record<string, YAMLValue>; remove?: string[]; ifMatch?: string };
result = await this.noteTools.updateFrontmatter( result = await this.noteTools.updateFrontmatter(
args.path, a.path,
args.patch, a.patch,
args.remove ?? [], a.remove ?? [],
args.ifMatch a.ifMatch
); );
break; break;
case "update_sections": }
case "update_sections": {
const a = args as { path: string; edits: Array<{ startLine: number; endLine: number; content: string }>; ifMatch?: string; validateLinks?: boolean };
result = await this.noteTools.updateSections( result = await this.noteTools.updateSections(
args.path, a.path,
args.edits, a.edits,
args.ifMatch, a.ifMatch,
args.validateLinks ?? true a.validateLinks ?? true
); );
break; break;
case "rename_file": }
case "rename_file": {
const a = args as { path: string; newPath: string; updateLinks?: boolean; ifMatch?: string };
result = await this.noteTools.renameFile( result = await this.noteTools.renameFile(
args.path, a.path,
args.newPath, a.newPath,
args.updateLinks ?? true, a.updateLinks ?? true,
args.ifMatch a.ifMatch
); );
break; break;
case "delete_note": }
case "delete_note": {
const a = args as { path: string; soft?: boolean; dryRun?: boolean; ifMatch?: string };
result = await this.noteTools.deleteNote( result = await this.noteTools.deleteNote(
args.path, a.path,
args.soft ?? true, a.soft ?? true,
args.dryRun ?? false, a.dryRun ?? false,
args.ifMatch a.ifMatch
); );
break; break;
case "search": }
case "search": {
const a = args as { query: string; isRegex?: boolean; caseSensitive?: boolean; includes?: string[]; excludes?: string[]; folder?: string; returnSnippets?: boolean; snippetLength?: number; maxResults?: number };
result = await this.vaultTools.search({ result = await this.vaultTools.search({
query: args.query, query: a.query,
isRegex: args.isRegex, isRegex: a.isRegex,
caseSensitive: args.caseSensitive, caseSensitive: a.caseSensitive,
includes: args.includes, includes: a.includes,
excludes: args.excludes, excludes: a.excludes,
folder: args.folder, folder: a.folder,
returnSnippets: args.returnSnippets, returnSnippets: a.returnSnippets,
snippetLength: args.snippetLength, snippetLength: a.snippetLength,
maxResults: args.maxResults maxResults: a.maxResults
}); });
break; break;
case "search_waypoints": }
result = await this.vaultTools.searchWaypoints(args.folder); case "search_waypoints": {
const a = args as { folder?: string };
result = await this.vaultTools.searchWaypoints(a.folder);
break; break;
}
case "get_vault_info": case "get_vault_info":
result = await this.vaultTools.getVaultInfo(); result = this.vaultTools.getVaultInfo();
break; break;
case "list": case "list": {
const a = args as { path?: string; recursive?: boolean; includes?: string[]; excludes?: string[]; only?: 'files' | 'directories' | 'any'; limit?: number; cursor?: string; withFrontmatterSummary?: boolean; includeWordCount?: boolean };
result = await this.vaultTools.list({ result = await this.vaultTools.list({
path: args.path, path: a.path,
recursive: args.recursive, recursive: a.recursive,
includes: args.includes, includes: a.includes,
excludes: args.excludes, excludes: a.excludes,
only: args.only, only: a.only,
limit: args.limit, limit: a.limit,
cursor: args.cursor, cursor: a.cursor,
withFrontmatterSummary: args.withFrontmatterSummary, withFrontmatterSummary: a.withFrontmatterSummary,
includeWordCount: args.includeWordCount includeWordCount: a.includeWordCount
}); });
break; break;
case "stat": }
result = await this.vaultTools.stat(args.path, args.includeWordCount); case "stat": {
const a = args as { path: string; includeWordCount?: boolean };
result = await this.vaultTools.stat(a.path, a.includeWordCount);
break; break;
case "exists": }
result = await this.vaultTools.exists(args.path); case "exists": {
const a = args as { path: string };
result = this.vaultTools.exists(a.path);
break; break;
case "read_excalidraw": }
result = await this.noteTools.readExcalidraw(args.path, { case "read_excalidraw": {
includeCompressed: args.includeCompressed, const a = args as { path: string; includeCompressed?: boolean; includePreview?: boolean };
includePreview: args.includePreview result = await this.noteTools.readExcalidraw(a.path, {
includeCompressed: a.includeCompressed,
includePreview: a.includePreview
}); });
break; break;
case "get_folder_waypoint": }
result = await this.vaultTools.getFolderWaypoint(args.path); case "get_folder_waypoint": {
const a = args as { path: string };
result = await this.vaultTools.getFolderWaypoint(a.path);
break; break;
case "is_folder_note": }
result = await this.vaultTools.isFolderNote(args.path); case "is_folder_note": {
const a = args as { path: string };
result = await this.vaultTools.isFolderNote(a.path);
break; break;
case "validate_wikilinks": }
result = await this.vaultTools.validateWikilinks(args.path); case "validate_wikilinks": {
const a = args as { path: string };
result = await this.vaultTools.validateWikilinks(a.path);
break; break;
case "resolve_wikilink": }
result = await this.vaultTools.resolveWikilink(args.sourcePath, args.linkText); case "resolve_wikilink": {
const a = args as { sourcePath: string; linkText: string };
result = this.vaultTools.resolveWikilink(a.sourcePath, a.linkText);
break; break;
case "backlinks": }
case "backlinks": {
const a = args as { path: string; includeUnlinked?: boolean; includeSnippets?: boolean };
result = await this.vaultTools.getBacklinks( result = await this.vaultTools.getBacklinks(
args.path, a.path,
args.includeUnlinked ?? false, a.includeUnlinked ?? false,
args.includeSnippets ?? true a.includeSnippets ?? true
); );
break; break;
}
default: default:
result = { result = {
content: [{ type: "text", text: `Unknown tool: ${name}` }], content: [{ type: "text", text: `Unknown tool: ${name}` }],

View File

@@ -1,4 +1,4 @@
import { App, TFile } from 'obsidian'; import { App } from 'obsidian';
import { import {
CallToolResult, CallToolResult,
ParsedNote, ParsedNote,
@@ -248,7 +248,7 @@ export class NoteTools {
// Add link validation if requested // Add link validation if requested
if (validateLinks) { if (validateLinks) {
result.linkValidation = await LinkUtils.validateLinks( result.linkValidation = LinkUtils.validateLinks(
this.vault, this.vault,
this.metadata, this.metadata,
content, content,
@@ -388,7 +388,7 @@ export class NoteTools {
// Add link validation if requested // Add link validation if requested
if (validateLinks) { if (validateLinks) {
result.linkValidation = await LinkUtils.validateLinks( result.linkValidation = LinkUtils.validateLinks(
this.vault, this.vault,
this.metadata, this.metadata,
content, content,
@@ -990,7 +990,7 @@ export class NoteTools {
// Add link validation if requested // Add link validation if requested
if (validateLinks) { if (validateLinks) {
result.linkValidation = await LinkUtils.validateLinks( result.linkValidation = LinkUtils.validateLinks(
this.vault, this.vault,
this.metadata, this.metadata,
newContent, newContent,

View File

@@ -1,5 +1,5 @@
import { TFile, TFolder } from 'obsidian'; import { TFile, TFolder } from 'obsidian';
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary, WaypointSearchResult, FolderWaypointResult, FolderNoteResult, ValidateWikilinksResult, ResolveWikilinkResult, BacklinksResult } from '../types/mcp-types'; import { CallToolResult, FileMetadata, DirectoryMetadata, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary, WaypointSearchResult, FolderWaypointResult, FolderNoteResult, ValidateWikilinksResult, ResolveWikilinkResult, BacklinksResult } from '../types/mcp-types';
import { PathUtils } from '../utils/path-utils'; import { PathUtils } from '../utils/path-utils';
import { ErrorMessages } from '../utils/error-messages'; import { ErrorMessages } from '../utils/error-messages';
import { GlobUtils } from '../utils/glob-utils'; import { GlobUtils } from '../utils/glob-utils';
@@ -15,7 +15,7 @@ export class VaultTools {
private metadata: IMetadataCacheAdapter private metadata: IMetadataCacheAdapter
) {} ) {}
async getVaultInfo(): Promise<CallToolResult> { getVaultInfo(): CallToolResult {
try { try {
const allFiles = this.vault.getMarkdownFiles(); const allFiles = this.vault.getMarkdownFiles();
const totalNotes = allFiles.length; const totalNotes = allFiles.length;
@@ -60,7 +60,7 @@ export class VaultTools {
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
} }
async listNotes(path?: string): Promise<CallToolResult> { listNotes(path?: string): CallToolResult {
let items: Array<FileMetadata | DirectoryMetadata> = []; let items: Array<FileMetadata | DirectoryMetadata> = [];
// Normalize root path: undefined, empty string "", or "." all mean root // Normalize root path: undefined, empty string "", or "." all mean root
@@ -279,7 +279,7 @@ export class VaultTools {
// Apply type filtering and add items // Apply type filtering and add items
if (item instanceof TFile) { if (item instanceof TFile) {
if (only !== 'directories') { if (only !== 'directories') {
const fileMetadata = await this.createFileMetadataWithFrontmatter(item, withFrontmatterSummary || false); const fileMetadata = this.createFileMetadataWithFrontmatter(item, withFrontmatterSummary || false);
// Optionally include word count (best effort) // Optionally include word count (best effort)
if (includeWordCount) { if (includeWordCount) {
@@ -307,10 +307,10 @@ export class VaultTools {
} }
} }
private async createFileMetadataWithFrontmatter( private createFileMetadataWithFrontmatter(
file: TFile, file: TFile,
withFrontmatterSummary: boolean withFrontmatterSummary: boolean
): Promise<FileMetadataWithFrontmatter> { ): FileMetadataWithFrontmatter {
const baseMetadata = this.createFileMetadata(file); const baseMetadata = this.createFileMetadata(file);
if (!withFrontmatterSummary || file.extension !== 'md') { if (!withFrontmatterSummary || file.extension !== 'md') {
@@ -495,7 +495,7 @@ export class VaultTools {
}; };
} }
async exists(path: string): Promise<CallToolResult> { exists(path: string): CallToolResult {
// Validate path // Validate path
if (!PathUtils.isValidVaultPath(path)) { if (!PathUtils.isValidVaultPath(path)) {
return { return {
@@ -922,7 +922,7 @@ export class VaultTools {
* Resolve a single wikilink from a source note * Resolve a single wikilink from a source note
* Returns the target path if resolvable, or suggestions if not * Returns the target path if resolvable, or suggestions if not
*/ */
async resolveWikilink(sourcePath: string, linkText: string): Promise<CallToolResult> { resolveWikilink(sourcePath: string, linkText: string): CallToolResult {
try { try {
// Normalize and validate source path // Normalize and validate source path
const normalizedPath = PathUtils.normalizePath(sourcePath); const normalizedPath = PathUtils.normalizePath(sourcePath);

View File

@@ -16,6 +16,11 @@ export type JSONValue =
*/ */
export type JSONRPCParams = { [key: string]: JSONValue } | JSONValue[]; export type JSONRPCParams = { [key: string]: JSONValue } | JSONValue[];
/**
* Tool arguments are always objects (not arrays)
*/
export type ToolArguments = { [key: string]: JSONValue };
export interface JSONRPCRequest { export interface JSONRPCRequest {
jsonrpc: "2.0"; jsonrpc: "2.0";
id?: string | number; id?: string | number;
@@ -47,7 +52,7 @@ export enum ErrorCodes {
export interface InitializeResult { export interface InitializeResult {
protocolVersion: string; protocolVersion: string;
capabilities: { capabilities: {
tools?: {}; tools?: object;
}; };
serverInfo: { serverInfo: {
name: string; name: string;

View File

@@ -7,8 +7,7 @@ import { MCPPluginSettings } from '../types/settings-types';
export interface NotificationHistoryEntry { export interface NotificationHistoryEntry {
timestamp: number; timestamp: number;
toolName: string; toolName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any args: Record<string, unknown>;
args: any;
success: boolean; success: boolean;
duration?: number; duration?: number;
error?: string; error?: string;
@@ -75,8 +74,7 @@ export class NotificationManager {
/** /**
* Show notification for tool call start * Show notification for tool call start
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any showToolCall(toolName: string, args: Record<string, unknown>, duration?: number): void {
showToolCall(toolName: string, args: any, duration?: number): void {
if (!this.shouldShowNotification()) { if (!this.shouldShowNotification()) {
return; return;
} }
@@ -142,8 +140,7 @@ export class NotificationManager {
/** /**
* Format arguments for display * Format arguments for display
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any private formatArgs(args: Record<string, unknown>): string {
private formatArgs(args: any): string {
if (!this.settings.showParameters) { if (!this.settings.showParameters) {
return ''; return '';
} }
@@ -156,13 +153,13 @@ export class NotificationManager {
// Extract key parameters for display // Extract key parameters for display
const keyParams: string[] = []; const keyParams: string[] = [];
if (args.path) { if (args.path && typeof args.path === 'string') {
keyParams.push(`path: "${this.truncateString(args.path, 30)}"`); keyParams.push(`path: "${this.truncateString(args.path, 30)}"`);
} }
if (args.query) { if (args.query && typeof args.query === 'string') {
keyParams.push(`query: "${this.truncateString(args.query, 30)}"`); keyParams.push(`query: "${this.truncateString(args.query, 30)}"`);
} }
if (args.folder) { if (args.folder && typeof args.folder === 'string') {
keyParams.push(`folder: "${this.truncateString(args.folder, 30)}"`); keyParams.push(`folder: "${this.truncateString(args.folder, 30)}"`);
} }
if (args.recursive !== undefined) { if (args.recursive !== undefined) {
@@ -176,7 +173,7 @@ export class NotificationManager {
} }
return keyParams.join(', '); return keyParams.join(', ');
} catch (e) { } catch {
return ''; return '';
} }
} }
@@ -196,9 +193,9 @@ export class NotificationManager {
*/ */
private queueNotification(notificationFn: () => void): void { private queueNotification(notificationFn: () => void): void {
this.notificationQueue.push(notificationFn); this.notificationQueue.push(notificationFn);
if (!this.isProcessingQueue) { if (!this.isProcessingQueue) {
this.processQueue(); void this.processQueue();
} }
} }

View File

@@ -15,19 +15,9 @@ function getCrypto(): Crypto {
return window.crypto; return window.crypto;
} }
// Node.js environment (15+) - uses Web Crypto API standard // Node.js/Electron environment - globalThis.crypto available in Node 20+
if (typeof global !== 'undefined') { if (typeof globalThis !== 'undefined' && globalThis.crypto) {
try { return globalThis.crypto;
// Note: require() is necessary here for synchronous crypto access in Node.js
// This module is loaded conditionally and esbuild will handle this correctly during bundling
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodeCrypto = require('crypto') as typeof import('crypto');
if (nodeCrypto?.webcrypto) {
return nodeCrypto.webcrypto as unknown as Crypto;
}
} catch {
// Crypto module not available or failed to load
}
} }
throw new Error('No Web Crypto API available in this environment'); throw new Error('No Web Crypto API available in this environment');

View File

@@ -8,13 +8,14 @@ interface ElectronSafeStorage {
// Safely import safeStorage - may not be available in all environments // Safely import safeStorage - may not be available in all environments
let safeStorage: ElectronSafeStorage | null = null; let safeStorage: ElectronSafeStorage | null = null;
try { try {
// Note: require() is necessary here for synchronous access to Electron's safeStorage // Access electron through the global window object in Obsidian's Electron environment
// This module is loaded conditionally and may not be available in all environments // This avoids require() while still getting synchronous access
// esbuild will handle this correctly during bundling const electronRemote = (window as Window & { require?: (module: string) => typeof import('electron') }).require;
// eslint-disable-next-line @typescript-eslint/no-var-requires if (electronRemote) {
const electron = require('electron') as typeof import('electron'); const electron = electronRemote('electron');
safeStorage = electron.safeStorage || null; safeStorage = electron.safeStorage || null;
} catch (error) { }
} catch {
console.warn('Electron safeStorage not available, API keys will be stored in plaintext'); console.warn('Electron safeStorage not available, API keys will be stored in plaintext');
} }

View File

@@ -45,7 +45,7 @@ export class GlobUtils {
i++; i++;
break; break;
case '[': case '[': {
// Character class // Character class
const closeIdx = pattern.indexOf(']', i); const closeIdx = pattern.indexOf(']', i);
if (closeIdx === -1) { if (closeIdx === -1) {
@@ -57,8 +57,9 @@ export class GlobUtils {
i = closeIdx + 1; i = closeIdx + 1;
} }
break; break;
}
case '{':
case '{': {
// Alternatives {a,b,c} // Alternatives {a,b,c}
const closeIdx2 = pattern.indexOf('}', i); const closeIdx2 = pattern.indexOf('}', i);
if (closeIdx2 === -1) { if (closeIdx2 === -1) {
@@ -67,13 +68,14 @@ export class GlobUtils {
i++; i++;
} else { } else {
const alternatives = pattern.substring(i + 1, closeIdx2).split(','); const alternatives = pattern.substring(i + 1, closeIdx2).split(',');
regexStr += '(' + alternatives.map(alt => regexStr += '(' + alternatives.map(alt =>
this.escapeRegex(alt) this.escapeRegex(alt)
).join('|') + ')'; ).join('|') + ')';
i = closeIdx2 + 1; i = closeIdx2 + 1;
} }
break; break;
}
case '/': case '/':
case '.': case '.':
case '(': case '(':

View File

@@ -445,12 +445,12 @@ export class LinkUtils {
* @param sourcePath Path of the file containing the links * @param sourcePath Path of the file containing the links
* @returns Structured validation result with categorized links * @returns Structured validation result with categorized links
*/ */
static async validateLinks( static validateLinks(
vault: IVaultAdapter, vault: IVaultAdapter,
metadata: IMetadataCacheAdapter, metadata: IMetadataCacheAdapter,
content: string, content: string,
sourcePath: string sourcePath: string
): Promise<LinkValidationResult> { ): LinkValidationResult {
const valid: string[] = []; const valid: string[] = [];
const brokenNotes: BrokenNoteLink[] = []; const brokenNotes: BrokenNoteLink[] = [];
const brokenHeadings: BrokenHeadingLink[] = []; const brokenHeadings: BrokenHeadingLink[] = [];

View File

@@ -66,7 +66,8 @@ export class PathUtils {
// Check for invalid characters (Windows restrictions) // Check for invalid characters (Windows restrictions)
// Invalid chars: < > : " | ? * and ASCII control characters (0-31) // Invalid chars: < > : " | ? * and ASCII control characters (0-31)
const invalidChars = /[<>:"|?*\u0000-\u001F]/; // eslint-disable-next-line no-control-regex -- Control characters \x00-\x1F required for Windows path validation
const invalidChars = /[<>:"|?*\x00-\x1F]/;
if (invalidChars.test(normalized)) { if (invalidChars.test(normalized)) {
return false; return false;
} }

View File

@@ -138,11 +138,18 @@ describe('crypto-adapter', () => {
const globalRef = global as any; const globalRef = global as any;
const originalWindow = globalRef.window; const originalWindow = globalRef.window;
const originalGlobal = globalRef.global; const originalGlobal = globalRef.global;
const originalGlobalThisCrypto = globalThis.crypto;
try { try {
// Remove window.crypto and global access // Remove window.crypto, global access, and globalThis.crypto
delete globalRef.window; delete globalRef.window;
delete globalRef.global; delete globalRef.global;
// In modern Node.js, globalThis.crypto is always available, so we must mock it too
Object.defineProperty(globalThis, 'crypto', {
value: undefined,
writable: true,
configurable: true
});
// Clear module cache to force re-evaluation // Clear module cache to force re-evaluation
jest.resetModules(); jest.resetModules();
@@ -157,6 +164,12 @@ describe('crypto-adapter', () => {
// Restore original values // Restore original values
globalRef.window = originalWindow; globalRef.window = originalWindow;
globalRef.global = originalGlobal; globalRef.global = originalGlobal;
// Restore globalThis.crypto
Object.defineProperty(globalThis, 'crypto', {
value: originalGlobalThisCrypto,
writable: true,
configurable: true
});
// Clear module cache again to restore normal state // Clear module cache again to restore normal state
jest.resetModules(); jest.resetModules();

View File

@@ -1,18 +1,63 @@
import { encryptApiKey, decryptApiKey, isEncryptionAvailable } from '../src/utils/encryption-utils'; // Mock safeStorage implementation
const mockSafeStorage = {
isEncryptionAvailable: jest.fn(() => true),
encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
decryptString: jest.fn((buffer: Buffer) => buffer.toString().replace('encrypted:', ''))
};
// Mock electron module // Setup window.require mock before importing the module
jest.mock('electron', () => ({ const mockWindowRequire = jest.fn((module: string) => {
safeStorage: { if (module === 'electron') {
isEncryptionAvailable: jest.fn(() => true), return { safeStorage: mockSafeStorage };
encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
decryptString: jest.fn((buffer: Buffer) => {
const str = buffer.toString();
return str.replace('encrypted:', '');
})
} }
})); throw new Error(`Module not found: ${module}`);
});
// Create mock window object for Node environment
const mockWindow: Window & { require?: unknown } = {
require: mockWindowRequire
} as unknown as Window & { require?: unknown };
// Store original global window
const originalWindow = (globalThis as unknown as { window?: unknown }).window;
// Set up window.require before tests run
beforeAll(() => {
(globalThis as unknown as { window: typeof mockWindow }).window = mockWindow;
});
// Clean up after all tests
afterAll(() => {
if (originalWindow === undefined) {
delete (globalThis as unknown as { window?: unknown }).window;
} else {
(globalThis as unknown as { window: typeof originalWindow }).window = originalWindow;
}
});
// Import after mock is set up - use require to ensure module loads after mock
let encryptApiKey: typeof import('../src/utils/encryption-utils').encryptApiKey;
let decryptApiKey: typeof import('../src/utils/encryption-utils').decryptApiKey;
let isEncryptionAvailable: typeof import('../src/utils/encryption-utils').isEncryptionAvailable;
beforeAll(() => {
// Reset modules to ensure fresh load with mock
jest.resetModules();
const encryptionUtils = require('../src/utils/encryption-utils');
encryptApiKey = encryptionUtils.encryptApiKey;
decryptApiKey = encryptionUtils.decryptApiKey;
isEncryptionAvailable = encryptionUtils.isEncryptionAvailable;
});
describe('Encryption Utils', () => { describe('Encryption Utils', () => {
beforeEach(() => {
// Reset mock implementations before each test
mockSafeStorage.isEncryptionAvailable.mockReturnValue(true);
mockSafeStorage.encryptString.mockImplementation((data: string) => Buffer.from(`encrypted:${data}`));
mockSafeStorage.decryptString.mockImplementation((buffer: Buffer) => buffer.toString().replace('encrypted:', ''));
mockWindowRequire.mockClear();
});
describe('encryptApiKey', () => { describe('encryptApiKey', () => {
it('should encrypt API key when encryption is available', () => { it('should encrypt API key when encryption is available', () => {
const apiKey = 'test-api-key-12345'; const apiKey = 'test-api-key-12345';
@@ -23,13 +68,23 @@ describe('Encryption Utils', () => {
}); });
it('should return plaintext when encryption is not available', () => { it('should return plaintext when encryption is not available', () => {
const { safeStorage } = require('electron'); // Need to reload module with different mock behavior
safeStorage.isEncryptionAvailable.mockReturnValueOnce(false); jest.resetModules();
const mockStorage = {
isEncryptionAvailable: jest.fn(() => false),
encryptString: jest.fn(),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
const { encryptApiKey: encrypt } = require('../src/utils/encryption-utils');
const apiKey = 'test-api-key-12345'; const apiKey = 'test-api-key-12345';
const result = encryptApiKey(apiKey); const result = encrypt(apiKey);
expect(result).toBe(apiKey); expect(result).toBe(apiKey);
// Restore original mock
mockWindow.require = mockWindowRequire;
}); });
it('should handle empty string', () => { it('should handle empty string', () => {
@@ -73,92 +128,107 @@ describe('Encryption Utils', () => {
describe('error handling', () => { describe('error handling', () => {
it('should handle encryption errors and fallback to plaintext', () => { it('should handle encryption errors and fallback to plaintext', () => {
const { safeStorage } = require('electron'); // Reload module with error-throwing mock
const originalEncrypt = safeStorage.encryptString; jest.resetModules();
safeStorage.encryptString = jest.fn(() => { const mockStorage = {
throw new Error('Encryption failed'); isEncryptionAvailable: jest.fn(() => true),
}); encryptString: jest.fn(() => {
throw new Error('Encryption failed');
}),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
const { encryptApiKey: encrypt } = require('../src/utils/encryption-utils');
const apiKey = 'test-api-key-12345'; const apiKey = 'test-api-key-12345';
const result = encryptApiKey(apiKey); const result = encrypt(apiKey);
expect(result).toBe(apiKey); // Should return plaintext on error expect(result).toBe(apiKey); // Should return plaintext on error
safeStorage.encryptString = originalEncrypt; // Restore
// Restore original mock
mockWindow.require = mockWindowRequire;
}); });
it('should throw error when decryption fails', () => { it('should throw error when decryption fails', () => {
const { safeStorage } = require('electron'); // Reload module with error-throwing mock
const originalDecrypt = safeStorage.decryptString; jest.resetModules();
safeStorage.decryptString = jest.fn(() => { const mockStorage = {
throw new Error('Decryption failed'); isEncryptionAvailable: jest.fn(() => true),
}); encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
decryptString: jest.fn(() => {
throw new Error('Decryption failed');
})
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
const { decryptApiKey: decrypt } = require('../src/utils/encryption-utils');
const encrypted = 'encrypted:aW52YWxpZA=='; // Invalid encrypted data const encrypted = 'encrypted:aW52YWxpZA=='; // Invalid encrypted data
expect(() => decryptApiKey(encrypted)).toThrow('Failed to decrypt API key'); expect(() => decrypt(encrypted)).toThrow('Failed to decrypt API key');
safeStorage.decryptString = originalDecrypt; // Restore
// Restore original mock
mockWindow.require = mockWindowRequire;
}); });
}); });
describe('isEncryptionAvailable', () => { describe('isEncryptionAvailable', () => {
it('should return true when encryption is available', () => { it('should return true when encryption is available', () => {
const { isEncryptionAvailable } = require('../src/utils/encryption-utils'); jest.resetModules();
const { safeStorage } = require('electron'); const mockStorage = {
isEncryptionAvailable: jest.fn(() => true),
encryptString: jest.fn(),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
safeStorage.isEncryptionAvailable.mockReturnValueOnce(true); const { isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(true); expect(checkAvail()).toBe(true);
// Restore
mockWindow.require = mockWindowRequire;
}); });
it('should return false when encryption is not available', () => { it('should return false when encryption is not available', () => {
const { isEncryptionAvailable } = require('../src/utils/encryption-utils'); jest.resetModules();
const { safeStorage } = require('electron'); const mockStorage = {
isEncryptionAvailable: jest.fn(() => false),
encryptString: jest.fn(),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
safeStorage.isEncryptionAvailable.mockReturnValueOnce(false); const { isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(false); expect(checkAvail()).toBe(false);
// Restore
mockWindow.require = mockWindowRequire;
}); });
it('should return false when safeStorage is null', () => { it('should return false when safeStorage is null', () => {
// This tests the case where Electron is not available
// We need to reload the module with electron unavailable
jest.resetModules(); jest.resetModules();
mockWindow.require = jest.fn(() => ({ safeStorage: null }));
jest.mock('electron', () => ({ const { isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
safeStorage: null expect(checkAvail()).toBe(false);
}));
const { isEncryptionAvailable } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(false);
// Restore original mock // Restore original mock
jest.resetModules(); mockWindow.require = mockWindowRequire;
jest.mock('electron', () => ({
safeStorage: {
isEncryptionAvailable: jest.fn(() => true),
encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
decryptString: jest.fn((buffer: Buffer) => {
const str = buffer.toString();
return str.replace('encrypted:', '');
})
}
}));
}); });
it('should return false when isEncryptionAvailable method is missing', () => { it('should return false when isEncryptionAvailable method is missing', () => {
jest.resetModules(); jest.resetModules();
const mockStorage = {
// Missing isEncryptionAvailable method
encryptString: jest.fn(),
decryptString: jest.fn()
};
mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
jest.mock('electron', () => ({ const { isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
safeStorage: { expect(checkAvail()).toBe(false);
// Missing isEncryptionAvailable method
encryptString: jest.fn(),
decryptString: jest.fn()
}
}));
const { isEncryptionAvailable } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(false);
// Restore // Restore
jest.resetModules(); mockWindow.require = mockWindowRequire;
}); });
}); });
@@ -168,12 +238,13 @@ describe('Encryption Utils', () => {
}); });
afterEach(() => { afterEach(() => {
jest.resetModules(); // Restore mock after each test
mockWindow.require = mockWindowRequire;
}); });
it('should handle electron module not being available', () => { it('should handle electron module not being available', () => {
// Mock require to throw when loading electron // Mock require to throw when loading electron
jest.mock('electron', () => { mockWindow.require = jest.fn(() => {
throw new Error('Electron not available'); throw new Error('Electron not available');
}); });
@@ -181,12 +252,12 @@ describe('Encryption Utils', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
// Load module with electron unavailable // Load module with electron unavailable
const { encryptApiKey, isEncryptionAvailable } = require('../src/utils/encryption-utils'); const { encryptApiKey: encrypt, isEncryptionAvailable: checkAvail } = require('../src/utils/encryption-utils');
expect(isEncryptionAvailable()).toBe(false); expect(checkAvail()).toBe(false);
const apiKey = 'test-key'; const apiKey = 'test-key';
const result = encryptApiKey(apiKey); const result = encrypt(apiKey);
// Should return plaintext when electron is unavailable // Should return plaintext when electron is unavailable
expect(result).toBe(apiKey); expect(result).toBe(apiKey);
@@ -195,21 +266,19 @@ describe('Encryption Utils', () => {
}); });
it('should handle decryption when safeStorage is null', () => { it('should handle decryption when safeStorage is null', () => {
jest.mock('electron', () => ({ mockWindow.require = jest.fn(() => ({ safeStorage: null }));
safeStorage: null
}));
const { decryptApiKey } = require('../src/utils/encryption-utils'); const { decryptApiKey: decrypt } = require('../src/utils/encryption-utils');
const encrypted = 'encrypted:aW52YWxpZA=='; const encrypted = 'encrypted:aW52YWxpZA==';
expect(() => decryptApiKey(encrypted)).toThrow('Failed to decrypt API key'); expect(() => decrypt(encrypted)).toThrow('Failed to decrypt API key');
}); });
it('should log warning when encryption not available on first load', () => { it('should log warning when encryption not available on first load', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
jest.mock('electron', () => { mockWindow.require = jest.fn(() => {
throw new Error('Module not found'); throw new Error('Module not found');
}); });
@@ -225,35 +294,32 @@ describe('Encryption Utils', () => {
}); });
it('should gracefully handle plaintext keys when encryption unavailable', () => { it('should gracefully handle plaintext keys when encryption unavailable', () => {
jest.mock('electron', () => ({ mockWindow.require = jest.fn(() => ({ safeStorage: null }));
safeStorage: null
}));
const { encryptApiKey, decryptApiKey } = require('../src/utils/encryption-utils'); const { encryptApiKey: encrypt, decryptApiKey: decrypt } = require('../src/utils/encryption-utils');
const apiKey = 'plain-api-key'; const apiKey = 'plain-api-key';
// Encrypt should return plaintext // Encrypt should return plaintext
const encrypted = encryptApiKey(apiKey); const encrypted = encrypt(apiKey);
expect(encrypted).toBe(apiKey); expect(encrypted).toBe(apiKey);
// Decrypt plaintext should return as-is // Decrypt plaintext should return as-is
const decrypted = decryptApiKey(apiKey); const decrypted = decrypt(apiKey);
expect(decrypted).toBe(apiKey); expect(decrypted).toBe(apiKey);
}); });
it('should warn when falling back to plaintext storage', () => { it('should warn when falling back to plaintext storage', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
jest.mock('electron', () => ({ const mockStorage = {
safeStorage: { isEncryptionAvailable: jest.fn(() => false)
isEncryptionAvailable: jest.fn(() => false) };
} mockWindow.require = jest.fn(() => ({ safeStorage: mockStorage }));
}));
const { encryptApiKey } = require('../src/utils/encryption-utils'); const { encryptApiKey: encrypt } = require('../src/utils/encryption-utils');
encryptApiKey('test-key'); encrypt('test-key');
expect(consoleSpy).toHaveBeenCalledWith( expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Encryption not available') expect.stringContaining('Encryption not available')

View File

@@ -1,18 +1,53 @@
import { generateApiKey } from '../src/utils/auth-utils'; import { generateApiKey } from '../src/utils/auth-utils';
import { encryptApiKey, decryptApiKey } from '../src/utils/encryption-utils';
import { DEFAULT_SETTINGS } from '../src/types/settings-types'; import { DEFAULT_SETTINGS } from '../src/types/settings-types';
// Mock electron // Mock safeStorage implementation
jest.mock('electron', () => ({ const mockSafeStorage = {
safeStorage: { isEncryptionAvailable: jest.fn(() => true),
isEncryptionAvailable: jest.fn(() => true), encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)),
encryptString: jest.fn((data: string) => Buffer.from(`encrypted:${data}`)), decryptString: jest.fn((buffer: Buffer) => buffer.toString().replace('encrypted:', ''))
decryptString: jest.fn((buffer: Buffer) => { };
const str = buffer.toString();
return str.replace('encrypted:', ''); // Setup window.require mock
}) const mockWindowRequire = jest.fn((module: string) => {
if (module === 'electron') {
return { safeStorage: mockSafeStorage };
} }
})); throw new Error(`Module not found: ${module}`);
});
// Create mock window object for Node environment
const mockWindow: Window & { require?: unknown } = {
require: mockWindowRequire
} as unknown as Window & { require?: unknown };
// Store original global window
const originalWindow = (globalThis as unknown as { window?: unknown }).window;
// Set up window.require before tests run
beforeAll(() => {
(globalThis as unknown as { window: typeof mockWindow }).window = mockWindow;
});
// Clean up after all tests
afterAll(() => {
if (originalWindow === undefined) {
delete (globalThis as unknown as { window?: unknown }).window;
} else {
(globalThis as unknown as { window: typeof originalWindow }).window = originalWindow;
}
});
// Import after mock is set up
let encryptApiKey: typeof import('../src/utils/encryption-utils').encryptApiKey;
let decryptApiKey: typeof import('../src/utils/encryption-utils').decryptApiKey;
beforeAll(() => {
jest.resetModules();
const encryptionUtils = require('../src/utils/encryption-utils');
encryptApiKey = encryptionUtils.encryptApiKey;
decryptApiKey = encryptionUtils.decryptApiKey;
});
describe('Settings Migration', () => { describe('Settings Migration', () => {
describe('API key initialization', () => { describe('API key initialization', () => {

View File

@@ -21,7 +21,7 @@ jest.mock('../src/utils/path-utils', () => ({
// Mock LinkUtils for link validation tests // Mock LinkUtils for link validation tests
jest.mock('../src/utils/link-utils', () => ({ jest.mock('../src/utils/link-utils', () => ({
LinkUtils: { LinkUtils: {
validateLinks: jest.fn().mockResolvedValue({ validateLinks: jest.fn().mockReturnValue({
valid: [], valid: [],
brokenNotes: [], brokenNotes: [],
brokenHeadings: [], brokenHeadings: [],

View File

@@ -2,5 +2,7 @@
"1.0.0": "0.15.0", "1.0.0": "0.15.0",
"1.0.1": "0.15.0", "1.0.1": "0.15.0",
"1.1.0": "0.15.0", "1.1.0": "0.15.0",
"1.1.1": "0.15.0" "1.1.1": "0.15.0",
"1.1.2": "0.15.0",
"1.1.3": "0.15.0"
} }