@pagelines/n8n-mcp 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +44 -119
- package/dist/autofix.d.ts +28 -0
- package/dist/autofix.js +222 -0
- package/dist/expressions.d.ts +25 -0
- package/dist/expressions.js +209 -0
- package/dist/index.js +124 -1
- package/dist/tools.js +147 -4
- package/dist/validators.js +67 -0
- package/dist/validators.test.js +83 -0
- package/dist/versions.d.ts +71 -0
- package/dist/versions.js +239 -0
- package/docs/best-practices.md +160 -0
- package/docs/node-config.md +203 -0
- package/package.json +1 -1
- package/plans/ai-guidelines.md +233 -0
- package/plans/architecture.md +177 -0
- package/server.json +10 -2
- package/src/autofix.ts +275 -0
- package/src/expressions.ts +254 -0
- package/src/index.ts +169 -1
- package/src/tools.ts +155 -4
- package/src/validators.test.ts +97 -0
- package/src/validators.ts +77 -0
- package/src/versions.ts +320 -0
package/src/versions.ts
ADDED
|
@@ -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
|
+
}
|