@pagelines/n8n-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Local version control for n8n workflows
3
+ * Stores workflow snapshots in ~/.n8n-mcp/versions/
4
+ */
5
+
6
+ import { promises as fs } from 'fs';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import type { N8nWorkflow } from './types.js';
10
+
11
+ export interface WorkflowVersion {
12
+ id: string;
13
+ workflowId: string;
14
+ workflowName: string;
15
+ timestamp: string;
16
+ reason: string;
17
+ nodeCount: number;
18
+ hash: string;
19
+ }
20
+
21
+ export interface VersionConfig {
22
+ enabled: boolean;
23
+ maxVersions: number;
24
+ storageDir: string;
25
+ }
26
+
27
+ const DEFAULT_CONFIG: VersionConfig = {
28
+ enabled: true,
29
+ maxVersions: 20,
30
+ storageDir: path.join(os.homedir(), '.n8n-mcp', 'versions'),
31
+ };
32
+
33
+ let config: VersionConfig = { ...DEFAULT_CONFIG };
34
+
35
+ /**
36
+ * Initialize version control with custom config
37
+ */
38
+ export function initVersionControl(customConfig: Partial<VersionConfig> = {}): void {
39
+ config = { ...DEFAULT_CONFIG, ...customConfig };
40
+ }
41
+
42
+ /**
43
+ * Get the storage directory for a workflow
44
+ */
45
+ function getWorkflowDir(workflowId: string): string {
46
+ return path.join(config.storageDir, workflowId);
47
+ }
48
+
49
+ /**
50
+ * Generate a simple hash for workflow content
51
+ */
52
+ function hashWorkflow(workflow: N8nWorkflow): string {
53
+ const content = JSON.stringify({
54
+ nodes: workflow.nodes,
55
+ connections: workflow.connections,
56
+ settings: workflow.settings,
57
+ });
58
+ // Simple hash - good enough for comparison
59
+ let hash = 0;
60
+ for (let i = 0; i < content.length; i++) {
61
+ const char = content.charCodeAt(i);
62
+ hash = ((hash << 5) - hash) + char;
63
+ hash = hash & hash; // Convert to 32bit integer
64
+ }
65
+ return Math.abs(hash).toString(16).padStart(8, '0');
66
+ }
67
+
68
+ /**
69
+ * Save a workflow version
70
+ */
71
+ export async function saveVersion(
72
+ workflow: N8nWorkflow,
73
+ reason: string = 'manual'
74
+ ): Promise<WorkflowVersion | null> {
75
+ if (!config.enabled) return null;
76
+
77
+ const workflowDir = getWorkflowDir(workflow.id);
78
+ await fs.mkdir(workflowDir, { recursive: true });
79
+
80
+ const timestamp = new Date().toISOString();
81
+ const hash = hashWorkflow(workflow);
82
+
83
+ // Check if this exact version already exists (avoid duplicates)
84
+ const existing = await listVersions(workflow.id);
85
+ if (existing.length > 0 && existing[0].hash === hash) {
86
+ return null; // No changes, skip
87
+ }
88
+
89
+ const versionId = `${timestamp.replace(/[:.]/g, '-')}_${hash.slice(0, 6)}`;
90
+ const versionFile = path.join(workflowDir, `${versionId}.json`);
91
+
92
+ const versionMeta: WorkflowVersion = {
93
+ id: versionId,
94
+ workflowId: workflow.id,
95
+ workflowName: workflow.name,
96
+ timestamp,
97
+ reason,
98
+ nodeCount: workflow.nodes.length,
99
+ hash,
100
+ };
101
+
102
+ const versionData = {
103
+ meta: versionMeta,
104
+ workflow,
105
+ };
106
+
107
+ await fs.writeFile(versionFile, JSON.stringify(versionData, null, 2));
108
+
109
+ // Prune old versions
110
+ await pruneVersions(workflow.id);
111
+
112
+ return versionMeta;
113
+ }
114
+
115
+ /**
116
+ * List all versions for a workflow
117
+ */
118
+ export async function listVersions(workflowId: string): Promise<WorkflowVersion[]> {
119
+ const workflowDir = getWorkflowDir(workflowId);
120
+
121
+ try {
122
+ const files = await fs.readdir(workflowDir);
123
+ const versions: WorkflowVersion[] = [];
124
+
125
+ for (const file of files) {
126
+ if (!file.endsWith('.json')) continue;
127
+
128
+ const filePath = path.join(workflowDir, file);
129
+ const content = await fs.readFile(filePath, 'utf-8');
130
+ const data = JSON.parse(content);
131
+
132
+ if (data.meta) {
133
+ versions.push(data.meta);
134
+ }
135
+ }
136
+
137
+ // Sort by timestamp descending (newest first)
138
+ return versions.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
139
+ } catch (error) {
140
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
141
+ return [];
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Get a specific version's full workflow data
149
+ */
150
+ export async function getVersion(
151
+ workflowId: string,
152
+ versionId: string
153
+ ): Promise<{ meta: WorkflowVersion; workflow: N8nWorkflow } | null> {
154
+ const workflowDir = getWorkflowDir(workflowId);
155
+ const versionFile = path.join(workflowDir, `${versionId}.json`);
156
+
157
+ try {
158
+ const content = await fs.readFile(versionFile, 'utf-8');
159
+ return JSON.parse(content);
160
+ } catch (error) {
161
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
162
+ return null;
163
+ }
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Get the most recent version
170
+ */
171
+ export async function getLatestVersion(
172
+ workflowId: string
173
+ ): Promise<{ meta: WorkflowVersion; workflow: N8nWorkflow } | null> {
174
+ const versions = await listVersions(workflowId);
175
+ if (versions.length === 0) return null;
176
+ return getVersion(workflowId, versions[0].id);
177
+ }
178
+
179
+ /**
180
+ * Prune old versions beyond maxVersions
181
+ */
182
+ async function pruneVersions(workflowId: string): Promise<number> {
183
+ const versions = await listVersions(workflowId);
184
+
185
+ if (versions.length <= config.maxVersions) {
186
+ return 0;
187
+ }
188
+
189
+ const toDelete = versions.slice(config.maxVersions);
190
+ const workflowDir = getWorkflowDir(workflowId);
191
+
192
+ for (const version of toDelete) {
193
+ const versionFile = path.join(workflowDir, `${version.id}.json`);
194
+ await fs.unlink(versionFile);
195
+ }
196
+
197
+ return toDelete.length;
198
+ }
199
+
200
+ /**
201
+ * Compare two workflow versions
202
+ */
203
+ export interface VersionDiff {
204
+ nodesAdded: string[];
205
+ nodesRemoved: string[];
206
+ nodesModified: string[];
207
+ connectionsChanged: boolean;
208
+ settingsChanged: boolean;
209
+ summary: string;
210
+ }
211
+
212
+ export function diffWorkflows(
213
+ oldWorkflow: N8nWorkflow,
214
+ newWorkflow: N8nWorkflow
215
+ ): VersionDiff {
216
+ const oldNodes = new Map(oldWorkflow.nodes.map((n) => [n.name, n]));
217
+ const newNodes = new Map(newWorkflow.nodes.map((n) => [n.name, n]));
218
+
219
+ const nodesAdded: string[] = [];
220
+ const nodesRemoved: string[] = [];
221
+ const nodesModified: string[] = [];
222
+
223
+ // Find added and modified nodes
224
+ for (const [name, node] of newNodes) {
225
+ if (!oldNodes.has(name)) {
226
+ nodesAdded.push(name);
227
+ } else {
228
+ const oldNode = oldNodes.get(name)!;
229
+ if (JSON.stringify(oldNode.parameters) !== JSON.stringify(node.parameters)) {
230
+ nodesModified.push(name);
231
+ }
232
+ }
233
+ }
234
+
235
+ // Find removed nodes
236
+ for (const name of oldNodes.keys()) {
237
+ if (!newNodes.has(name)) {
238
+ nodesRemoved.push(name);
239
+ }
240
+ }
241
+
242
+ const connectionsChanged =
243
+ JSON.stringify(oldWorkflow.connections) !== JSON.stringify(newWorkflow.connections);
244
+
245
+ const settingsChanged =
246
+ JSON.stringify(oldWorkflow.settings) !== JSON.stringify(newWorkflow.settings);
247
+
248
+ // Generate summary
249
+ const parts: string[] = [];
250
+ if (nodesAdded.length) parts.push(`+${nodesAdded.length} nodes`);
251
+ if (nodesRemoved.length) parts.push(`-${nodesRemoved.length} nodes`);
252
+ if (nodesModified.length) parts.push(`~${nodesModified.length} modified`);
253
+ if (connectionsChanged) parts.push('connections changed');
254
+ if (settingsChanged) parts.push('settings changed');
255
+
256
+ return {
257
+ nodesAdded,
258
+ nodesRemoved,
259
+ nodesModified,
260
+ connectionsChanged,
261
+ settingsChanged,
262
+ summary: parts.length ? parts.join(', ') : 'no changes',
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Delete all versions for a workflow
268
+ */
269
+ export async function deleteAllVersions(workflowId: string): Promise<number> {
270
+ const versions = await listVersions(workflowId);
271
+ const workflowDir = getWorkflowDir(workflowId);
272
+
273
+ for (const version of versions) {
274
+ const versionFile = path.join(workflowDir, `${version.id}.json`);
275
+ await fs.unlink(versionFile);
276
+ }
277
+
278
+ // Remove the directory if empty
279
+ try {
280
+ await fs.rmdir(workflowDir);
281
+ } catch {
282
+ // Ignore if not empty
283
+ }
284
+
285
+ return versions.length;
286
+ }
287
+
288
+ /**
289
+ * Get version control status/stats
290
+ */
291
+ export async function getVersionStats(): Promise<{
292
+ enabled: boolean;
293
+ storageDir: string;
294
+ maxVersions: number;
295
+ workflowCount: number;
296
+ totalVersions: number;
297
+ }> {
298
+ let workflowCount = 0;
299
+ let totalVersions = 0;
300
+
301
+ try {
302
+ const workflows = await fs.readdir(config.storageDir);
303
+ workflowCount = workflows.length;
304
+
305
+ for (const workflowId of workflows) {
306
+ const versions = await listVersions(workflowId);
307
+ totalVersions += versions.length;
308
+ }
309
+ } catch {
310
+ // Storage dir doesn't exist yet
311
+ }
312
+
313
+ return {
314
+ enabled: config.enabled,
315
+ storageDir: config.storageDir,
316
+ maxVersions: config.maxVersions,
317
+ workflowCount,
318
+ totalVersions,
319
+ };
320
+ }