✨ feat: add test suite, CI/CD pipeline, setup wizard, and upstream feature ports (#6)
Establish comprehensive quality infrastructure for a project that previously had zero tests, enabling confident refactoring and community contributions with automated guardrails. Port upstream enhancements for font normalization, image element support, and arrow binding preservation. 🏗️ Testing infrastructure: - Unit tests for SQLite persistence layer and element validation helpers - Integration tests for REST API, WebSocket broadcast, and arrow binding - E2E tests with Playwright for canvas rendering and real-time sync - Vitest + Playwright configuration with proper isolation 👷 CI/CD pipeline: - Auto-versioning from conventional commits on push to main - Auto-publish to NPM and Docker Hub on GitHub release - Matrix testing across Node 18/20/22 with pinned dependencies - Docker health check with diagnostic logging on failure - Preserve rollup status checks for branch protection gates 📦 Developer experience: - Interactive setup wizard for first-time configuration - Canvas clear confirmation and scene description tools - Frontend helpers extracted for testability 🔧 Upstream feature ports: - Font family normalization (string names to numeric IDs) - Image element support with file management API - Arrow binding preservation through server round-trips - Vite config fix for font subsetting worker chunk names - Idempotent database initialization for standalone Docker mode 🐛 Docker fixes: - Set EXCALIDRAW_DB_PATH in both Dockerfiles to writable /app/data/ - Make initDb() idempotent and closeDb() reset-safe for test isolation 🎯 Provides the safety net needed for rapid iteration — every PR is validated across 120 test cases before merge, and releases are fully automated from commit to published package. Co-authored-by: sanjibdevnathlabs <devnath.sanjib@gmail.com>
This commit is contained in:
co-authored by
sanjibdevnathlabs
parent
4c50472ee4
commit
63209f9d5a
@@ -44,6 +44,8 @@ function generateId(): string {
|
||||
}
|
||||
|
||||
export function initDb(dbPath?: string): void {
|
||||
if (db) return; // Already initialized
|
||||
|
||||
const resolvedPath = dbPath
|
||||
|| process.env.EXCALIDRAW_DB_PATH
|
||||
|| path.join(os.homedir(), '.excalidraw-mcp', 'excalidraw.db');
|
||||
@@ -129,6 +131,11 @@ function runMigrations(): void {
|
||||
UNIQUE(project_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_project ON elements(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_type ON elements(project_id, type);
|
||||
CREATE INDEX IF NOT EXISTS idx_elements_deleted ON elements(project_id, is_deleted);
|
||||
@@ -501,9 +508,21 @@ export function bulkReplaceElements(elements: ServerElement[], projectId?: strin
|
||||
return tx();
|
||||
}
|
||||
|
||||
// ── Settings ──
|
||||
|
||||
export function getSetting(key: string): string | undefined {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
export function setSetting(key: string, value: string): void {
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = undefined as any;
|
||||
logger.info('SQLite database closed');
|
||||
}
|
||||
}
|
||||
|
||||
+225
-41
@@ -25,7 +25,9 @@ import {
|
||||
EXCALIDRAW_ELEMENT_TYPES,
|
||||
ServerElement,
|
||||
ExcalidrawElementType,
|
||||
validateElement
|
||||
validateElement,
|
||||
normalizeFontFamily,
|
||||
files as globalFiles
|
||||
} from './types.js';
|
||||
import fetch from 'node-fetch';
|
||||
import { startCanvasServer, stopCanvasServer } from './server.js';
|
||||
@@ -63,6 +65,10 @@ const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
|
||||
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
|
||||
const ENABLE_CANVAS_SYNC = true;
|
||||
|
||||
// One-time tokens for clear_canvas confirmation (token → expiry timestamp)
|
||||
const pendingClearTokens = new Map<string, { expiresAt: number; elementCount: number }>();
|
||||
const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes
|
||||
|
||||
// API Response types
|
||||
interface ApiResponse {
|
||||
success: boolean;
|
||||
@@ -247,7 +253,7 @@ const ElementSchema = z.object({
|
||||
opacity: z.number().optional(),
|
||||
text: z.string().optional(),
|
||||
fontSize: z.number().optional(),
|
||||
fontFamily: z.string().optional(),
|
||||
fontFamily: z.union([z.string(), z.number()]).optional(),
|
||||
groupIds: z.array(z.string()).optional(),
|
||||
locked: z.boolean().optional(),
|
||||
strokeStyle: z.string().optional(),
|
||||
@@ -258,6 +264,9 @@ const ElementSchema = z.object({
|
||||
endElementId: z.string().optional(),
|
||||
endArrowhead: z.string().optional(),
|
||||
startArrowhead: z.string().optional(),
|
||||
fileId: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
scale: z.tuple([z.number(), z.number()]).optional(),
|
||||
});
|
||||
|
||||
const ElementIdSchema = z.object({
|
||||
@@ -409,7 +418,7 @@ const tools: Tool[] = [
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' },
|
||||
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont (hand-drawn), 2=Helvetica (sans-serif), 3=Cascadia (monospace), 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
|
||||
startElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow start to. Arrow auto-routes to element edge.' },
|
||||
endElementId: { type: 'string', description: 'For arrows: ID of the element to bind the arrow end to. Arrow auto-routes to element edge.' },
|
||||
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
|
||||
@@ -640,7 +649,7 @@ const tools: Tool[] = [
|
||||
opacity: { type: 'number' },
|
||||
text: { type: 'string' },
|
||||
fontSize: { type: 'number' },
|
||||
fontFamily: { type: 'string' },
|
||||
fontFamily: { type: ['string', 'number'], description: 'Font family: 1=Excalifont, 2=Helvetica, 3=Cascadia, 4=Comic Shanns, 5=Liberation Sans, 6=Nunito, 7=Lilita One. Accepts name strings too.' },
|
||||
startElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow start to' },
|
||||
endElementId: { type: 'string', description: 'For arrows: ID of element to bind arrow end to' },
|
||||
endArrowhead: { type: 'string', description: 'Arrowhead style at end: arrow, bar, dot, triangle, or null' },
|
||||
@@ -666,10 +675,15 @@ const tools: Tool[] = [
|
||||
},
|
||||
{
|
||||
name: 'clear_canvas',
|
||||
description: 'Clear all elements from the canvas',
|
||||
description: 'DESTRUCTIVE: Permanently deletes ALL elements from the canvas. Two-step process: (1) Call WITHOUT clearToken to get a preview of what will be deleted — present this to the user and ask for confirmation. (2) Call WITH the returned clearToken to execute the clear. Prefer placing new diagrams alongside existing ones (call describe_scene first).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
properties: {
|
||||
clearToken: {
|
||||
type: 'string',
|
||||
description: 'One-time token returned by the preview step. Pass this to confirm and execute the clear. Omit on first call to get the preview.'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -973,11 +987,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
const { startElementId, endElementId, id: customId, ...elementProps } = params;
|
||||
const id = customId || generateId();
|
||||
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...elementProps,
|
||||
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
|
||||
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
|
||||
// Convert binding IDs to Excalidraw's start/end format
|
||||
...(startElementId ? { start: { id: startElementId } } : {}),
|
||||
...(endElementId ? { end: { id: endElementId } } : {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -1020,10 +1035,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
if (!id) throw new Error('Element ID is required');
|
||||
|
||||
// Build update payload with timestamp and version increment
|
||||
const normalizedFont = normalizeFontFamily(updates.fontFamily);
|
||||
const updatePayload: Partial<ServerElement> & { id: string } = {
|
||||
id,
|
||||
...updates,
|
||||
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
|
||||
points: rawPoints ? normalizePoints(rawPoints) : undefined,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
@@ -1468,11 +1484,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
for (const elementData of params.elements) {
|
||||
const { startElementId, endElementId, id: customId, ...elementProps } = elementData;
|
||||
const id = customId || generateId();
|
||||
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...elementProps,
|
||||
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
|
||||
points: elementProps.points ? normalizePoints(elementProps.points) : undefined,
|
||||
// Convert binding IDs to Excalidraw's start/end format
|
||||
...(startElementId ? { start: { id: startElementId } } : {}),
|
||||
...(endElementId ? { end: { id: endElementId } } : {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -1530,23 +1547,99 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
}
|
||||
|
||||
case 'clear_canvas': {
|
||||
logger.info('Clearing canvas via MCP');
|
||||
const clearParams = z.object({ clearToken: z.string().optional() }).parse(args);
|
||||
|
||||
const response = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
|
||||
if (!clearParams.clearToken) {
|
||||
// Step 1: Preview — show what will be deleted and return a one-time token
|
||||
const previewResp = await fetch(`${EXPRESS_SERVER_URL}/api/elements`, {
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
if (!previewResp.ok) throw new Error('Failed to fetch elements for preview');
|
||||
const previewData = await previewResp.json() as ApiResponse;
|
||||
const elements = previewData.elements || [];
|
||||
const count = elements.length;
|
||||
|
||||
if (count === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'The canvas is already empty. Nothing to clear.' }]
|
||||
};
|
||||
}
|
||||
|
||||
// Build a summary of what exists
|
||||
const typeCounts: Record<string, number> = {};
|
||||
for (const el of elements) {
|
||||
typeCounts[el.type] = (typeCounts[el.type] || 0) + 1;
|
||||
}
|
||||
const typesSummary = Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ');
|
||||
|
||||
// Generate one-time token
|
||||
const token = `clr_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
pendingClearTokens.set(token, { expiresAt: Date.now() + CLEAR_TOKEN_TTL_MS, elementCount: count });
|
||||
|
||||
// Prune expired tokens
|
||||
for (const [k, v] of pendingClearTokens) {
|
||||
if (v.expiresAt < Date.now()) pendingClearTokens.delete(k);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
`⚠️ CLEAR CANVAS — confirmation required`,
|
||||
``,
|
||||
`This will permanently delete **${count} element${count !== 1 ? 's' : ''}**: ${typesSummary}`,
|
||||
``,
|
||||
`Ask the user: "Do you want me to clear all ${count} elements from the canvas?"`,
|
||||
``,
|
||||
`If the user confirms, call clear_canvas again with clearToken: "${token}"`,
|
||||
`If the user declines, do NOT call clear_canvas again.`,
|
||||
].join('\n')
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Execute clear with a valid token
|
||||
const tokenData = pendingClearTokens.get(clearParams.clearToken);
|
||||
if (!tokenData) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Clear canvas rejected: invalid or expired clearToken. Call clear_canvas without clearToken first to get a fresh preview and token.'
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
if (tokenData.expiresAt < Date.now()) {
|
||||
pendingClearTokens.delete(clearParams.clearToken);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Clear canvas rejected: clearToken has expired. Call clear_canvas without clearToken to get a new one.'
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Token valid — consume it and clear
|
||||
pendingClearTokens.delete(clearParams.clearToken);
|
||||
|
||||
logger.info('Clearing canvas via MCP (user confirmed via token)');
|
||||
|
||||
const clearResponse = await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, {
|
||||
method: 'DELETE',
|
||||
headers: canvasHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to clear canvas: ${response.status} ${response.statusText}`);
|
||||
if (!clearResponse.ok) {
|
||||
throw new Error(`Failed to clear canvas: ${clearResponse.status} ${clearResponse.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as ApiResponse;
|
||||
const clearData = await clearResponse.json() as ApiResponse;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Canvas cleared.\n\n${JSON.stringify(data, null, 2)}`
|
||||
text: `Canvas cleared.\n\n${JSON.stringify(clearData, null, 2)}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -1568,6 +1661,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
const data = await response.json() as ApiResponse;
|
||||
const sceneElements = data.elements || [];
|
||||
|
||||
// Collect files from the in-memory store
|
||||
const exportFiles: Record<string, any> = {};
|
||||
for (const [id, file] of globalFiles) {
|
||||
exportFiles[id] = file;
|
||||
}
|
||||
|
||||
const excalidrawScene = {
|
||||
type: 'excalidraw',
|
||||
version: 2,
|
||||
@@ -1576,7 +1675,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
appState: {
|
||||
viewBackgroundColor: '#ffffff',
|
||||
gridSize: null
|
||||
}
|
||||
},
|
||||
files: exportFiles
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(excalidrawScene, null, 2);
|
||||
@@ -1587,7 +1687,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Scene exported to ${safePath} (${sceneElements.length} elements)`
|
||||
text: `Scene exported to ${safePath} (${sceneElements.length} elements, ${Object.keys(exportFiles).length} files)`
|
||||
}]
|
||||
};
|
||||
}
|
||||
@@ -1629,6 +1729,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
throw new Error('No elements found in the import data');
|
||||
}
|
||||
|
||||
// Import files if present
|
||||
const importedFiles = sceneData.files;
|
||||
if (importedFiles && typeof importedFiles === 'object') {
|
||||
for (const [id, fileData] of Object.entries(importedFiles)) {
|
||||
const file = fileData as any;
|
||||
globalFiles.set(id, {
|
||||
id,
|
||||
mimeType: file.mimeType || 'image/png',
|
||||
dataURL: file.dataURL,
|
||||
created: file.created || Date.now(),
|
||||
});
|
||||
}
|
||||
// Push files to canvas server
|
||||
try {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/files`, {
|
||||
method: 'POST',
|
||||
headers: canvasHeaders(),
|
||||
body: JSON.stringify({ files: importedFiles })
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (params.mode === 'replace') {
|
||||
await fetch(`${EXPRESS_SERVER_URL}/api/elements/clear`, { method: 'DELETE', headers: canvasHeaders() });
|
||||
}
|
||||
@@ -1814,7 +1936,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
|
||||
if (allElements.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.' }]
|
||||
content: [{ type: 'text', text: 'The canvas is empty. No elements to describe.\n\nSuggested placement for a new diagram: x=0, y=0' }]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1824,7 +1946,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
typeCounts[el.type] = (typeCounts[el.type] || 0) + 1;
|
||||
}
|
||||
|
||||
// Bounding box
|
||||
// Overall bounding box
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of allElements) {
|
||||
minX = Math.min(minX, el.x);
|
||||
@@ -1833,6 +1955,58 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
maxY = Math.max(maxY, el.y + (el.height || 0));
|
||||
}
|
||||
|
||||
// ── Diagram zone detection ──
|
||||
// Build a map of groupId → elements
|
||||
const groupMap: Record<string, ServerElement[]> = {};
|
||||
const ungroupedElements: ServerElement[] = [];
|
||||
for (const el of allElements) {
|
||||
if (el.groupIds && el.groupIds.length > 0) {
|
||||
for (const gid of el.groupIds) {
|
||||
if (!groupMap[gid]) groupMap[gid] = [];
|
||||
groupMap[gid]!.push(el);
|
||||
}
|
||||
} else {
|
||||
ungroupedElements.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
interface DiagramZone {
|
||||
groupId: string;
|
||||
label: string | null;
|
||||
bbox: { minX: number; minY: number; maxX: number; maxY: number };
|
||||
elementCount: number;
|
||||
}
|
||||
|
||||
const zones: DiagramZone[] = [];
|
||||
for (const [gid, elements] of Object.entries(groupMap)) {
|
||||
let zMinX = Infinity, zMinY = Infinity, zMaxX = -Infinity, zMaxY = -Infinity;
|
||||
let label: string | null = null;
|
||||
|
||||
for (const el of elements) {
|
||||
zMinX = Math.min(zMinX, el.x);
|
||||
zMinY = Math.min(zMinY, el.y);
|
||||
zMaxX = Math.max(zMaxX, el.x + (el.width || 0));
|
||||
zMaxY = Math.max(zMaxY, el.y + (el.height || 0));
|
||||
|
||||
// Use the first text element or label as the zone name
|
||||
if (!label) {
|
||||
if (el.type === 'text' && el.text) label = el.text;
|
||||
else if (el.label?.text) label = el.label.text;
|
||||
}
|
||||
}
|
||||
|
||||
zones.push({
|
||||
groupId: gid,
|
||||
label,
|
||||
bbox: { minX: zMinX, minY: zMinY, maxX: zMaxX, maxY: zMaxY },
|
||||
elementCount: elements.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Suggest next placement: 300px to the right of the overall bounding box
|
||||
const suggestedX = Math.round(maxX + 300);
|
||||
const suggestedY = Math.round(minY);
|
||||
|
||||
// Build element descriptions sorted top-to-bottom, left-to-right
|
||||
const sorted = [...allElements].sort((a, b) => {
|
||||
const rowDiff = Math.floor(a.y / 50) - Math.floor(b.y / 50);
|
||||
@@ -1879,7 +2053,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
lines.push(`## Canvas Description`);
|
||||
lines.push(`Total elements: ${allElements.length}`);
|
||||
lines.push(`Types: ${Object.entries(typeCounts).map(([t, c]) => `${t}(${c})`).join(', ')}`);
|
||||
lines.push(`Bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`);
|
||||
lines.push(`Canvas bounding box: (${Math.round(minX)}, ${Math.round(minY)}) to (${Math.round(maxX)}, ${Math.round(maxY)}) = ${Math.round(maxX - minX)}x${Math.round(maxY - minY)}`);
|
||||
|
||||
// Diagram zones section
|
||||
if (zones.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('### Diagram Zones (grouped):');
|
||||
for (const zone of zones) {
|
||||
const w = Math.round(zone.bbox.maxX - zone.bbox.minX);
|
||||
const h = Math.round(zone.bbox.maxY - zone.bbox.minY);
|
||||
const name = zone.label ? `"${zone.label}"` : '(unnamed)';
|
||||
lines.push(` Group ${zone.groupId}: ${name} | bbox (${Math.round(zone.bbox.minX)}, ${Math.round(zone.bbox.minY)}) to (${Math.round(zone.bbox.maxX)}, ${Math.round(zone.bbox.maxY)}) = ${w}x${h} | ${zone.elementCount} elements`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ungroupedElements.length > 0 && zones.length > 0) {
|
||||
lines.push(` + ${ungroupedElements.length} ungrouped elements`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`### Suggested placement for new diagram: x=${suggestedX}, y=${suggestedY}`);
|
||||
|
||||
lines.push('');
|
||||
lines.push('### Elements (top-to-bottom, left-to-right):');
|
||||
lines.push(...elementDescs);
|
||||
@@ -1890,23 +2084,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
lines.push(...connectionDescs);
|
||||
}
|
||||
|
||||
// Groups
|
||||
const groupedElements = allElements.filter(el => el.groupIds && el.groupIds.length > 0);
|
||||
if (groupedElements.length > 0) {
|
||||
const groupMap: Record<string, string[]> = {};
|
||||
for (const el of groupedElements) {
|
||||
for (const gid of (el.groupIds || [])) {
|
||||
if (!groupMap[gid]) groupMap[gid] = [];
|
||||
groupMap[gid]!.push(el.id);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('### Groups:');
|
||||
for (const [gid, ids] of Object.entries(groupMap)) {
|
||||
lines.push(` Group ${gid}: [${ids.join(', ')}]`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: lines.join('\n') }]
|
||||
};
|
||||
@@ -2533,10 +2710,17 @@ if (process.env.DEBUG === 'true') {
|
||||
|
||||
// Start the server if this file is run directly
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
runServer().catch(error => {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
if (process.argv[2] === 'setup') {
|
||||
import('./setup.js').then(m => m.runSetup()).catch(error => {
|
||||
process.stderr.write(`Setup failed: ${(error as Error).message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
runServer().catch(error => {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default runServer;
|
||||
+137
-10
@@ -18,10 +18,13 @@ import {
|
||||
BatchCreatedMessage,
|
||||
SyncStatusMessage,
|
||||
InitialElementsMessage,
|
||||
Snapshot
|
||||
Snapshot,
|
||||
normalizeFontFamily,
|
||||
ExcalidrawFile,
|
||||
files
|
||||
} from './types.js';
|
||||
import * as store from './db.js';
|
||||
import { listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
|
||||
import { initDb, listTenants as dbListTenants, getActiveTenant as dbGetActiveTenant, setActiveTenant as dbSetActiveTenant, getDefaultProjectForTenant } from './db.js';
|
||||
import { z } from 'zod';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
@@ -86,6 +89,15 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
elements: store.getAllElements()
|
||||
};
|
||||
ws.send(JSON.stringify(initialMessage));
|
||||
|
||||
// Send any stored files (image data)
|
||||
if (files.size > 0) {
|
||||
const allFiles: Record<string, ExcalidrawFile> = {};
|
||||
for (const [id, file] of files) {
|
||||
allFiles[id] = file;
|
||||
}
|
||||
ws.send(JSON.stringify({ type: 'files_added', files: allFiles }));
|
||||
}
|
||||
|
||||
// Send sync status to new client
|
||||
const syncMessage: SyncStatusMessage = {
|
||||
@@ -108,7 +120,7 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
// Schema validation
|
||||
const CreateElementSchema = z.object({
|
||||
id: z.string().optional(), // Allow passing ID for MCP sync
|
||||
id: z.string().optional(),
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES) as [ExcalidrawElementType, ...ExcalidrawElementType[]]),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
@@ -121,11 +133,12 @@ const CreateElementSchema = z.object({
|
||||
roughness: z.number().optional(),
|
||||
opacity: z.number().optional(),
|
||||
text: z.string().optional(),
|
||||
originalText: z.string().optional(),
|
||||
label: z.object({
|
||||
text: z.string()
|
||||
}).optional(),
|
||||
fontSize: z.number().optional(),
|
||||
fontFamily: z.string().optional(),
|
||||
fontFamily: z.union([z.string(), z.number()]).optional(),
|
||||
groupIds: z.array(z.string()).optional(),
|
||||
locked: z.boolean().optional(),
|
||||
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
|
||||
@@ -136,7 +149,14 @@ const CreateElementSchema = z.object({
|
||||
end: z.object({ id: z.string() }).optional(),
|
||||
startArrowhead: z.string().nullable().optional(),
|
||||
endArrowhead: z.string().nullable().optional(),
|
||||
startBinding: z.any().nullable().optional(),
|
||||
endBinding: z.any().nullable().optional(),
|
||||
boundElements: z.any().nullable().optional(),
|
||||
elbowed: z.boolean().optional(),
|
||||
// Image element properties
|
||||
fileId: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
scale: z.tuple([z.number(), z.number()]).optional(),
|
||||
});
|
||||
|
||||
const UpdateElementSchema = z.object({
|
||||
@@ -153,11 +173,12 @@ const UpdateElementSchema = z.object({
|
||||
roughness: z.number().optional(),
|
||||
opacity: z.number().optional(),
|
||||
text: z.string().optional(),
|
||||
originalText: z.string().optional(),
|
||||
label: z.object({
|
||||
text: z.string()
|
||||
}).optional(),
|
||||
fontSize: z.number().optional(),
|
||||
fontFamily: z.string().optional(),
|
||||
fontFamily: z.union([z.string(), z.number()]).optional(),
|
||||
groupIds: z.array(z.string()).optional(),
|
||||
locked: z.boolean().optional(),
|
||||
roundness: z.object({ type: z.number(), value: z.number().optional() }).nullable().optional(),
|
||||
@@ -170,7 +191,13 @@ const UpdateElementSchema = z.object({
|
||||
end: z.object({ id: z.string() }).optional(),
|
||||
startArrowhead: z.string().nullable().optional(),
|
||||
endArrowhead: z.string().nullable().optional(),
|
||||
startBinding: z.any().nullable().optional(),
|
||||
endBinding: z.any().nullable().optional(),
|
||||
boundElements: z.any().nullable().optional(),
|
||||
elbowed: z.boolean().optional(),
|
||||
fileId: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
scale: z.tuple([z.number(), z.number()]).optional(),
|
||||
});
|
||||
|
||||
// API Routes
|
||||
@@ -202,9 +229,11 @@ app.post('/api/elements', (req: Request, res: Response) => {
|
||||
logger.info('Creating element via API', { type: params.type });
|
||||
|
||||
const id = params.id || generateId();
|
||||
const normalizedFont = normalizeFontFamily(params.fontFamily);
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...params,
|
||||
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1
|
||||
@@ -253,9 +282,11 @@ app.put('/api/elements/:id', (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedFont = normalizeFontFamily(updates.fontFamily);
|
||||
const updatedElement: ServerElement = {
|
||||
...existingElement,
|
||||
...updates,
|
||||
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: (existingElement.version || 0) + 1
|
||||
};
|
||||
@@ -526,11 +557,9 @@ function resolveArrowBindings(batchElements: ServerElement[], projectId?: string
|
||||
el.y = finalStart.y;
|
||||
el.points = [[0, 0], [finalEnd.x - finalStart.x, finalEnd.y - finalStart.y]];
|
||||
|
||||
// Remove start/end refs (they were used for computation only)
|
||||
delete (el as any).start;
|
||||
delete (el as any).end;
|
||||
|
||||
// Set binding metadata for Excalidraw
|
||||
// Keep start/end refs on the element — the frontend's
|
||||
// convertToExcalidrawElements uses them to compute proper bindings
|
||||
// (focus, gap, fixedPoint). Also set basic binding metadata for export.
|
||||
if (startEl) {
|
||||
(el as any).startBinding = {
|
||||
elementId: startEl.id,
|
||||
@@ -566,9 +595,11 @@ app.post('/api/elements/batch', (req: Request, res: Response) => {
|
||||
elementsToCreate.forEach(elementData => {
|
||||
const params = CreateElementSchema.parse(elementData);
|
||||
const id = params.id || generateId();
|
||||
const normalizedFont = normalizeFontFamily(params.fontFamily);
|
||||
const element: ServerElement = {
|
||||
id,
|
||||
...params,
|
||||
...(normalizedFont !== undefined ? { fontFamily: normalizedFont } : {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
version: 1
|
||||
@@ -714,6 +745,70 @@ app.post('/api/elements/sync', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Files API (image element data) ──
|
||||
|
||||
// Get all files
|
||||
app.get('/api/files', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const allFiles: Record<string, ExcalidrawFile> = {};
|
||||
for (const [id, file] of files) {
|
||||
allFiles[id] = file;
|
||||
}
|
||||
res.json({ success: true, files: allFiles });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching files:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Add files (image data)
|
||||
app.post('/api/files', (req: Request, res: Response) => {
|
||||
try {
|
||||
const incoming = req.body.files;
|
||||
if (!incoming || typeof incoming !== 'object') {
|
||||
return res.status(400).json({ success: false, error: 'files object is required' });
|
||||
}
|
||||
|
||||
const addedIds: string[] = [];
|
||||
for (const [id, fileData] of Object.entries(incoming)) {
|
||||
const file = fileData as ExcalidrawFile;
|
||||
files.set(id, {
|
||||
id,
|
||||
mimeType: file.mimeType || 'image/png',
|
||||
dataURL: file.dataURL,
|
||||
created: file.created || Date.now(),
|
||||
});
|
||||
addedIds.push(id);
|
||||
}
|
||||
|
||||
broadcast({
|
||||
type: 'files_added',
|
||||
files: incoming
|
||||
});
|
||||
|
||||
res.json({ success: true, addedIds, count: addedIds.length });
|
||||
} catch (error) {
|
||||
logger.error('Error adding files:', error);
|
||||
res.status(400).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a file
|
||||
app.delete('/api/files/:id', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!files.has(id!)) {
|
||||
return res.status(404).json({ success: false, error: `File ${id} not found` });
|
||||
}
|
||||
files.delete(id!);
|
||||
broadcast({ type: 'file_deleted', fileId: id });
|
||||
res.json({ success: true, message: `File ${id} deleted` });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting file:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Image export: request (MCP -> Express -> WebSocket -> Frontend)
|
||||
interface PendingExport {
|
||||
resolve: (data: { format: string; data: string }) => void;
|
||||
@@ -1052,6 +1147,34 @@ app.put('/api/tenant/active', (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Settings API ──
|
||||
|
||||
app.get('/api/settings/:key', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const value = store.getSetting(key!);
|
||||
res.json({ success: true, key, value: value ?? null });
|
||||
} catch (error) {
|
||||
logger.error('Error reading setting:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/settings/:key', (req: Request, res: Response) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const { value } = req.body;
|
||||
if (value === undefined || value === null) {
|
||||
return res.status(400).json({ success: false, error: 'value is required' });
|
||||
}
|
||||
store.setSetting(key!, String(value));
|
||||
res.json({ success: true, key, value: String(value) });
|
||||
} catch (error) {
|
||||
logger.error('Error writing setting:', error);
|
||||
res.status(500).json({ success: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req: Request, res: Response) => {
|
||||
const projId = resolveTenantProject(req);
|
||||
@@ -1099,6 +1222,10 @@ export function isCanvasServerOwned(): boolean {
|
||||
}
|
||||
|
||||
export async function startCanvasServer(): Promise<void> {
|
||||
// Ensure the database is initialized when running standalone (e.g. Docker: `node dist/server.js`).
|
||||
// When launched via index.ts (MCP entry point), initDb() is a no-op on the second call.
|
||||
initDb();
|
||||
|
||||
// Pre-flight: check if an existing healthy canvas server is already on this port.
|
||||
// We do this BEFORE calling httpServer.listen() because Node's listen() can emit
|
||||
// EADDRINUSE as an uncaught exception that bypasses our error handler.
|
||||
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Interactive setup wizard for mcp-excalidraw-local.
|
||||
* Runs via: npx @sanjibdevnath/mcp-excalidraw-local setup
|
||||
*
|
||||
* Uses only Node.js built-ins — no third-party dependencies.
|
||||
* Every phase is optional and skippable.
|
||||
*/
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
const BOLD = '\x1b[1m';
|
||||
const DIM = '\x1b[2m';
|
||||
const GREEN = '\x1b[32m';
|
||||
const RED = '\x1b[31m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const CYAN = '\x1b[36m';
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
function ok(msg: string) { process.stdout.write(` ${GREEN}✔${RESET} ${msg}\n`); }
|
||||
function fail(msg: string) { process.stdout.write(` ${RED}✘${RESET} ${msg}\n`); }
|
||||
function warn(msg: string) { process.stdout.write(` ${YELLOW}⚠${RESET} ${msg}\n`); }
|
||||
function info(msg: string) { process.stdout.write(` ${msg}\n`); }
|
||||
function heading(phase: string, title: string) {
|
||||
process.stdout.write(`\n ${BOLD}[${phase}] ${title}${RESET}\n`);
|
||||
}
|
||||
|
||||
function ask(rl: readline.Interface, prompt: string): Promise<string> {
|
||||
return new Promise(resolve => rl.question(` ${prompt}`, resolve));
|
||||
}
|
||||
|
||||
async function confirm(rl: readline.Interface, prompt: string, defaultYes = true): Promise<boolean> {
|
||||
const hint = defaultYes ? '[Y/n]' : '[y/N]';
|
||||
const answer = (await ask(rl, `${prompt} ${hint}: `)).trim().toLowerCase();
|
||||
if (answer === '') return defaultYes;
|
||||
return answer === 'y' || answer === 'yes';
|
||||
}
|
||||
|
||||
// ── Agent Definitions ────────────────────────────────────────
|
||||
|
||||
interface AgentDef {
|
||||
name: string;
|
||||
detectPaths: string[];
|
||||
skillBasePaths: { global: string; local: string };
|
||||
mcpConfigType: 'json-file' | 'cli-command';
|
||||
mcpConfigPath?: string;
|
||||
mcpCliCommand?: string;
|
||||
}
|
||||
|
||||
function getAgents(): AgentDef[] {
|
||||
const home = os.homedir();
|
||||
return [
|
||||
{
|
||||
name: 'Cursor',
|
||||
detectPaths: [path.join(home, '.cursor')],
|
||||
skillBasePaths: {
|
||||
global: path.join(home, '.cursor', 'skills'),
|
||||
local: path.join(process.cwd(), '.cursor', 'skills'),
|
||||
},
|
||||
mcpConfigType: 'json-file',
|
||||
mcpConfigPath: path.join(home, '.cursor', 'mcp.json'),
|
||||
},
|
||||
{
|
||||
name: 'Claude Code',
|
||||
detectPaths: [path.join(home, '.claude')],
|
||||
skillBasePaths: {
|
||||
global: path.join(home, '.claude', 'skills'),
|
||||
local: path.join(process.cwd(), '.claude', 'skills'),
|
||||
},
|
||||
mcpConfigType: 'cli-command',
|
||||
mcpCliCommand: 'claude mcp add excalidraw-canvas --scope user -e CANVAS_PORT=3000 -- npx -y @sanjibdevnath/mcp-excalidraw-local',
|
||||
},
|
||||
{
|
||||
name: 'Codex CLI',
|
||||
detectPaths: [path.join(home, '.codex')],
|
||||
skillBasePaths: {
|
||||
global: path.join(home, '.codex', 'skills'),
|
||||
local: path.join(process.cwd(), '.codex', 'skills'),
|
||||
},
|
||||
mcpConfigType: 'json-file',
|
||||
mcpConfigPath: path.join(home, '.codex', 'mcp.json'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function detectInstalledAgents(): AgentDef[] {
|
||||
return getAgents().filter(a => a.detectPaths.some(p => fs.existsSync(p)));
|
||||
}
|
||||
|
||||
// ── Phase 1: Environment Check ──────────────────────────────
|
||||
|
||||
async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
||||
heading('1/3', 'Environment');
|
||||
let allOk = true;
|
||||
|
||||
// Node.js version
|
||||
const nodeVersion = process.version;
|
||||
const major = parseInt(nodeVersion.slice(1).split('.')[0] ?? '0', 10);
|
||||
if (major >= 18) {
|
||||
ok(`Node.js ${nodeVersion} ${'.' .repeat(Math.max(0, 24 - nodeVersion.length))} OK`);
|
||||
} else {
|
||||
fail(`Node.js ${nodeVersion} — requires >= 18.0.0`);
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
// better-sqlite3 bindings
|
||||
try {
|
||||
execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') });
|
||||
ok('better-sqlite3 bindings ........... OK');
|
||||
} catch {
|
||||
fail('better-sqlite3 bindings ........... FAILED');
|
||||
const doFix = await confirm(rl, 'Native module needs rebuild. Fix now?');
|
||||
if (doFix) {
|
||||
try {
|
||||
info(`${DIM}Running npm rebuild better-sqlite3...${RESET}`);
|
||||
execSync('npm rebuild better-sqlite3', {
|
||||
stdio: 'inherit',
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
});
|
||||
// Verify
|
||||
execSync('node -e "require(\'better-sqlite3\')"', { stdio: 'pipe', cwd: path.resolve(__dirname, '..') });
|
||||
ok('Rebuild successful');
|
||||
} catch {
|
||||
fail('Rebuild failed. Try manually:');
|
||||
info(` cd ${path.resolve(__dirname, '..')}`);
|
||||
info(' npm rebuild better-sqlite3');
|
||||
info('');
|
||||
info('Prerequisites:');
|
||||
if (process.platform === 'darwin') {
|
||||
info(' xcode-select --install');
|
||||
} else if (process.platform === 'linux') {
|
||||
info(' sudo apt install build-essential python3');
|
||||
} else {
|
||||
info(' npm install --global windows-build-tools');
|
||||
}
|
||||
allOk = false;
|
||||
}
|
||||
} else {
|
||||
info('Manual fix: npm rebuild better-sqlite3');
|
||||
allOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Frontend build
|
||||
const frontendIndex = path.resolve(__dirname, '..', 'dist', 'frontend', 'index.html');
|
||||
if (fs.existsSync(frontendIndex)) {
|
||||
ok('Frontend build .................... OK');
|
||||
} else {
|
||||
warn('Frontend build .................... NOT FOUND');
|
||||
info(`Expected: ${frontendIndex}`);
|
||||
info('Run: npm run build');
|
||||
}
|
||||
|
||||
return allOk;
|
||||
}
|
||||
|
||||
// ── Phase 2: Skill Installation ─────────────────────────────
|
||||
|
||||
async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
|
||||
heading('2/3', 'Agent Skill');
|
||||
|
||||
const wantSkill = await confirm(rl, 'Install the Excalidraw agent skill?');
|
||||
if (!wantSkill) {
|
||||
info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually if needed)${RESET}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const agents = detectInstalledAgents();
|
||||
if (agents.length === 0) {
|
||||
warn('No supported agents detected (Cursor, Claude Code, Codex CLI).');
|
||||
info(`${DIM}Skill folder: skills/excalidraw-skill/ (copy manually when ready)${RESET}`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write('\n Detected agents:\n');
|
||||
agents.forEach((a, i) => {
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${a.name}\n`);
|
||||
});
|
||||
|
||||
const selection = await ask(rl, "Which agents? (comma-separated, 'all', or 'skip'): ");
|
||||
const trimmed = selection.trim().toLowerCase();
|
||||
|
||||
if (trimmed === 'skip' || trimmed === '') return;
|
||||
|
||||
let selectedAgents: AgentDef[];
|
||||
if (trimmed === 'all') {
|
||||
selectedAgents = agents;
|
||||
} else {
|
||||
const indices = trimmed.split(',').map(s => parseInt(s.trim(), 10) - 1);
|
||||
selectedAgents = indices
|
||||
.filter(i => i >= 0 && i < agents.length)
|
||||
.map(i => agents[i]!);
|
||||
}
|
||||
|
||||
if (selectedAgents.length === 0) {
|
||||
warn('No valid agents selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const skillSource = path.resolve(__dirname, '..', 'skills', 'excalidraw-skill');
|
||||
if (!fs.existsSync(skillSource)) {
|
||||
fail(`Skill source not found at ${skillSource}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const agent of selectedAgents) {
|
||||
const scopeAnswer = await ask(rl, `\n ${agent.name} — scope? [G]lobal / [l]ocal: `);
|
||||
const scope = scopeAnswer.trim().toLowerCase() === 'l' ? 'local' : 'global';
|
||||
const destBase = agent.skillBasePaths[scope];
|
||||
const destDir = path.join(destBase, 'excalidraw-skill');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
copyDirSync(skillSource, destDir);
|
||||
ok(`Installed to ${destDir}`);
|
||||
} catch (err) {
|
||||
fail(`Failed to install to ${destDir}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirSync(src: string, dest: string): void {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyDirSync(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: MCP Configuration ──────────────────────────────
|
||||
|
||||
async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
|
||||
heading('3/3', 'MCP Configuration');
|
||||
|
||||
const wantConfig = await confirm(rl, 'Add MCP server to agent configs automatically?');
|
||||
if (!wantConfig) {
|
||||
printManualConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
const agents = detectInstalledAgents();
|
||||
if (agents.length === 0) {
|
||||
warn('No supported agents detected.');
|
||||
printManualConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) {
|
||||
const doIt = await confirm(rl, `${agent.name} — add to ${agent.mcpConfigPath}?`);
|
||||
if (!doIt) {
|
||||
info(`${DIM}Skipped. Add manually later.${RESET}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
mergeJsonConfig(agent.mcpConfigPath);
|
||||
ok(`Added 'excalidraw-canvas' to ${agent.mcpConfigPath}`);
|
||||
} catch (err) {
|
||||
fail(`Failed: ${(err as Error).message}`);
|
||||
info('Add manually:');
|
||||
printManualConfig();
|
||||
}
|
||||
} else if (agent.mcpConfigType === 'cli-command' && agent.mcpCliCommand) {
|
||||
const doIt = await confirm(rl, `${agent.name} — register via CLI?`);
|
||||
if (!doIt) {
|
||||
info(`${DIM}Skipped.${RESET}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(agent.mcpCliCommand, { stdio: 'inherit' });
|
||||
ok(`Registered 'excalidraw-canvas' via ${agent.name} CLI`);
|
||||
} catch (err) {
|
||||
fail(`CLI registration failed: ${(err as Error).message}`);
|
||||
info('Register manually:');
|
||||
info(` ${agent.mcpCliCommand}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeJsonConfig(configPath: string): void {
|
||||
const mcpEntry = {
|
||||
command: 'npx',
|
||||
args: ['-y', '@sanjibdevnath/mcp-excalidraw-local'],
|
||||
env: { CANVAS_PORT: '3000' },
|
||||
};
|
||||
|
||||
let existing: any = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
existing = JSON.parse(raw);
|
||||
}
|
||||
|
||||
if (!existing.mcpServers) {
|
||||
existing.mcpServers = {};
|
||||
}
|
||||
|
||||
if (existing.mcpServers['excalidraw-canvas']) {
|
||||
process.stdout.write(` ${YELLOW}Entry 'excalidraw-canvas' already exists — overwriting.${RESET}\n`);
|
||||
}
|
||||
|
||||
existing.mcpServers['excalidraw-canvas'] = mcpEntry;
|
||||
|
||||
const dir = path.dirname(configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
function printManualConfig(): void {
|
||||
process.stdout.write(`
|
||||
Manual config (JSON):
|
||||
${DIM}{
|
||||
"mcpServers": {
|
||||
"excalidraw-canvas": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@sanjibdevnath/mcp-excalidraw-local"],
|
||||
"env": { "CANVAS_PORT": "3000" }
|
||||
}
|
||||
}
|
||||
}${RESET}
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────
|
||||
|
||||
export async function runSetup(): Promise<void> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
process.stdout.write(`\n ${BOLD}Excalidraw MCP — Setup${RESET}\n`);
|
||||
|
||||
try {
|
||||
await phaseEnvironment(rl);
|
||||
await phaseSkillInstall(rl);
|
||||
await phaseMcpConfig(rl);
|
||||
|
||||
process.stdout.write(`\n ${GREEN}${BOLD}Done!${RESET} Open ${CYAN}http://localhost:3000${RESET} to verify the canvas.\n\n`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
+53
-3
@@ -106,7 +106,7 @@ export interface ExcalidrawBinding {
|
||||
fixedPoint?: readonly [number, number] | null;
|
||||
}
|
||||
|
||||
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw';
|
||||
export type ExcalidrawElementType = 'rectangle' | 'ellipse' | 'diamond' | 'arrow' | 'text' | 'line' | 'freedraw' | 'image';
|
||||
|
||||
// Excalidraw element types
|
||||
export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
|
||||
@@ -116,7 +116,8 @@ export const EXCALIDRAW_ELEMENT_TYPES: Record<string, ExcalidrawElementType> = {
|
||||
ARROW: 'arrow',
|
||||
TEXT: 'text',
|
||||
FREEDRAW: 'freedraw',
|
||||
LINE: 'line'
|
||||
LINE: 'line',
|
||||
IMAGE: 'image'
|
||||
} as const;
|
||||
|
||||
// Server-side element with metadata
|
||||
@@ -136,9 +137,16 @@ export interface ServerElement extends Omit<ExcalidrawElementBase, 'id'> {
|
||||
text: string;
|
||||
};
|
||||
points?: any;
|
||||
originalText?: string;
|
||||
// Arrow element binding: connect arrows to shapes by element ID
|
||||
start?: { id: string };
|
||||
end?: { id: string };
|
||||
startBinding?: ExcalidrawBinding | null;
|
||||
endBinding?: ExcalidrawBinding | null;
|
||||
// Image element properties
|
||||
fileId?: string;
|
||||
status?: string;
|
||||
scale?: [number, number];
|
||||
}
|
||||
|
||||
// API Response types
|
||||
@@ -183,7 +191,9 @@ export type WebSocketMessageType =
|
||||
| 'canvas_cleared'
|
||||
| 'export_image_request'
|
||||
| 'set_viewport'
|
||||
| 'tenant_switched';
|
||||
| 'tenant_switched'
|
||||
| 'files_added'
|
||||
| 'file_deleted';
|
||||
|
||||
export interface InitialElementsMessage extends WebSocketMessage {
|
||||
type: 'initial_elements';
|
||||
@@ -289,6 +299,46 @@ export interface Snapshot {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Excalidraw file (image) data — stored in-memory alongside element data
|
||||
export interface ExcalidrawFile {
|
||||
mimeType: string;
|
||||
id: string;
|
||||
dataURL: string;
|
||||
created: number;
|
||||
lastRetrieved?: number;
|
||||
}
|
||||
|
||||
// In-memory file storage (image files are too large for SQLite row storage)
|
||||
export const files = new Map<string, ExcalidrawFile>();
|
||||
|
||||
// Font family normalization: Excalidraw expects numeric IDs, but agents
|
||||
// often send string names. Map common names to their numeric equivalents.
|
||||
const FONT_FAMILY_MAP: Record<string, number> = {
|
||||
'virgil': 1,
|
||||
'hand-drawn': 1,
|
||||
'excalifont': 1,
|
||||
'helvetica': 2,
|
||||
'arial': 2,
|
||||
'sans-serif': 2,
|
||||
'cascadia': 3,
|
||||
'monospace': 3,
|
||||
'courier': 3,
|
||||
'comic shanns': 4,
|
||||
'comic sans': 4,
|
||||
'liberation sans': 5,
|
||||
'nunito': 6,
|
||||
'lilita one': 7,
|
||||
};
|
||||
|
||||
export function normalizeFontFamily(value: string | number | undefined): number | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === 'number') return value;
|
||||
const mapped = FONT_FAMILY_MAP[value.toLowerCase().trim()];
|
||||
if (mapped !== undefined) return mapped;
|
||||
const parsed = parseInt(value, 10);
|
||||
return isNaN(parsed) ? 1 : parsed;
|
||||
}
|
||||
|
||||
// Storage is now handled by src/db.ts (SQLite).
|
||||
// The Map exports below are kept only for backward compatibility with
|
||||
// standalone server.ts usage; they are NOT used when the DB is active.
|
||||
|
||||
Reference in New Issue
Block a user