@pagelines/n8n-mcp 0.3.1 → 0.3.2
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 +10 -6
- package/dist/index.js +2 -71
- package/dist/n8n-client.d.ts +1 -2
- package/dist/n8n-client.js +0 -6
- package/dist/tools.js +0 -29
- package/dist/types.d.ts +1 -28
- package/dist/types.js +1 -1
- package/dist/validators.d.ts +1 -9
- package/dist/validators.js +0 -85
- package/package.json +2 -2
- package/.github/workflows/ci.yml +0 -38
- package/dist/n8n-client.test.d.ts +0 -1
- package/dist/n8n-client.test.js +0 -382
- package/dist/response-format.test.d.ts +0 -1
- package/dist/response-format.test.js +0 -291
- package/dist/validators.test.d.ts +0 -1
- package/dist/validators.test.js +0 -310
- package/docs/best-practices.md +0 -165
- package/docs/node-config.md +0 -205
- package/plans/ai-guidelines.md +0 -233
- package/plans/architecture.md +0 -220
- package/server.json +0 -66
- package/src/autofix.ts +0 -275
- package/src/expressions.ts +0 -254
- package/src/index.ts +0 -550
- package/src/n8n-client.test.ts +0 -467
- package/src/n8n-client.ts +0 -374
- package/src/response-format.test.ts +0 -355
- package/src/response-format.ts +0 -278
- package/src/tools.ts +0 -489
- package/src/types.ts +0 -183
- package/src/validators.test.ts +0 -374
- package/src/validators.ts +0 -394
- package/src/versions.ts +0 -320
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -8
package/src/validators.ts
DELETED
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Opinion-based workflow validation
|
|
3
|
-
* Enforces best practices from n8n-best-practices.md
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
N8nWorkflow,
|
|
8
|
-
N8nNode,
|
|
9
|
-
ValidationResult,
|
|
10
|
-
ValidationWarning,
|
|
11
|
-
NodeTypeValidationError,
|
|
12
|
-
} from './types.js';
|
|
13
|
-
|
|
14
|
-
export function validateWorkflow(workflow: N8nWorkflow): ValidationResult {
|
|
15
|
-
const warnings: ValidationWarning[] = [];
|
|
16
|
-
|
|
17
|
-
// Validate workflow name
|
|
18
|
-
validateSnakeCase(workflow.name, 'workflow', warnings);
|
|
19
|
-
|
|
20
|
-
// Validate each node
|
|
21
|
-
for (const node of workflow.nodes) {
|
|
22
|
-
validateNode(node, warnings);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Check for orphan nodes (no connections)
|
|
26
|
-
validateConnections(workflow, warnings);
|
|
27
|
-
|
|
28
|
-
return {
|
|
29
|
-
valid: warnings.filter((w) => w.severity === 'error').length === 0,
|
|
30
|
-
warnings,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function validateNode(node: N8nNode, warnings: ValidationWarning[]): void {
|
|
35
|
-
// Check node name is snake_case
|
|
36
|
-
validateSnakeCase(node.name, `node "${node.name}"`, warnings);
|
|
37
|
-
|
|
38
|
-
// Check for $json usage (should use explicit references)
|
|
39
|
-
checkForJsonUsage(node, warnings);
|
|
40
|
-
|
|
41
|
-
// Check for hardcoded IDs
|
|
42
|
-
checkForHardcodedIds(node, warnings);
|
|
43
|
-
|
|
44
|
-
// Check for hardcoded secrets
|
|
45
|
-
checkForHardcodedSecrets(node, warnings);
|
|
46
|
-
|
|
47
|
-
// Check for code node usage (should be last resort)
|
|
48
|
-
checkForCodeNodeUsage(node, warnings);
|
|
49
|
-
|
|
50
|
-
// Check for AI node structured output settings
|
|
51
|
-
checkForAIStructuredOutput(node, warnings);
|
|
52
|
-
|
|
53
|
-
// Check for in-memory storage (non-persistent)
|
|
54
|
-
checkForInMemoryStorage(node, warnings);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function validateSnakeCase(name: string, context: string, warnings: ValidationWarning[]): void {
|
|
58
|
-
// Allow spaces for readability, but check the underlying format
|
|
59
|
-
const normalized = name.toLowerCase().replace(/\s+/g, '_');
|
|
60
|
-
|
|
61
|
-
// Check if it matches snake_case pattern (allowing numbers)
|
|
62
|
-
const isSnakeCase = /^[a-z][a-z0-9_]*$/.test(normalized);
|
|
63
|
-
|
|
64
|
-
// Also allow names that are already snake_case
|
|
65
|
-
const isAlreadySnake = /^[a-z][a-z0-9_]*$/.test(name);
|
|
66
|
-
|
|
67
|
-
if (!isSnakeCase && !isAlreadySnake) {
|
|
68
|
-
warnings.push({
|
|
69
|
-
rule: 'snake_case',
|
|
70
|
-
message: `${context} should use snake_case naming: "${name}" → "${normalized}"`,
|
|
71
|
-
severity: 'warning',
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function checkForJsonUsage(node: N8nNode, warnings: ValidationWarning[]): void {
|
|
77
|
-
const params = JSON.stringify(node.parameters);
|
|
78
|
-
|
|
79
|
-
// Detect $json without explicit node reference
|
|
80
|
-
// Bad: $json.field, {{ $json.field }}
|
|
81
|
-
// Good: $('node_name').item.json.field
|
|
82
|
-
const badPatterns = [
|
|
83
|
-
/\$json\./g,
|
|
84
|
-
/\{\{\s*\$json\./g,
|
|
85
|
-
];
|
|
86
|
-
|
|
87
|
-
for (const pattern of badPatterns) {
|
|
88
|
-
if (pattern.test(params)) {
|
|
89
|
-
warnings.push({
|
|
90
|
-
node: node.name,
|
|
91
|
-
rule: 'explicit_reference',
|
|
92
|
-
message: `Node "${node.name}" uses $json - use explicit $('node_name').item.json.field instead`,
|
|
93
|
-
severity: 'warning',
|
|
94
|
-
});
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function checkForHardcodedIds(node: N8nNode, warnings: ValidationWarning[]): void {
|
|
101
|
-
const params = JSON.stringify(node.parameters);
|
|
102
|
-
|
|
103
|
-
// Detect patterns that look like hardcoded IDs
|
|
104
|
-
const idPatterns = [
|
|
105
|
-
// Discord IDs (17-19 digit numbers)
|
|
106
|
-
/["']\d{17,19}["']/g,
|
|
107
|
-
// UUIDs
|
|
108
|
-
/["'][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}["']/gi,
|
|
109
|
-
// MongoDB ObjectIds
|
|
110
|
-
/["'][0-9a-f]{24}["']/gi,
|
|
111
|
-
];
|
|
112
|
-
|
|
113
|
-
for (const pattern of idPatterns) {
|
|
114
|
-
if (pattern.test(params)) {
|
|
115
|
-
warnings.push({
|
|
116
|
-
node: node.name,
|
|
117
|
-
rule: 'no_hardcoded_ids',
|
|
118
|
-
message: `Node "${node.name}" may contain hardcoded IDs - consider using config nodes or environment variables`,
|
|
119
|
-
severity: 'info',
|
|
120
|
-
});
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function checkForHardcodedSecrets(node: N8nNode, warnings: ValidationWarning[]): void {
|
|
127
|
-
const params = JSON.stringify(node.parameters).toLowerCase();
|
|
128
|
-
|
|
129
|
-
// Check for common secret patterns
|
|
130
|
-
const secretPatterns = [
|
|
131
|
-
/api[_-]?key["']\s*:\s*["'][^"']+["']/i,
|
|
132
|
-
/secret["']\s*:\s*["'][^"']+["']/i,
|
|
133
|
-
/password["']\s*:\s*["'][^"']+["']/i,
|
|
134
|
-
/token["']\s*:\s*["'][a-z0-9]{20,}["']/i,
|
|
135
|
-
];
|
|
136
|
-
|
|
137
|
-
for (const pattern of secretPatterns) {
|
|
138
|
-
if (pattern.test(params)) {
|
|
139
|
-
warnings.push({
|
|
140
|
-
node: node.name,
|
|
141
|
-
rule: 'no_hardcoded_secrets',
|
|
142
|
-
message: `Node "${node.name}" may contain hardcoded secrets - consider using $env.VAR_NAME`,
|
|
143
|
-
severity: 'info',
|
|
144
|
-
});
|
|
145
|
-
break;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function checkForCodeNodeUsage(node: N8nNode, warnings: ValidationWarning[]): void {
|
|
151
|
-
// Detect code nodes - they should be last resort
|
|
152
|
-
const codeNodeTypes = [
|
|
153
|
-
'n8n-nodes-base.code',
|
|
154
|
-
'n8n-nodes-base.function',
|
|
155
|
-
'n8n-nodes-base.functionItem',
|
|
156
|
-
];
|
|
157
|
-
|
|
158
|
-
if (codeNodeTypes.some((t) => node.type.includes(t))) {
|
|
159
|
-
warnings.push({
|
|
160
|
-
node: node.name,
|
|
161
|
-
rule: 'code_node_usage',
|
|
162
|
-
message: `Node "${node.name}" is a code node - ensure built-in nodes can't achieve this`,
|
|
163
|
-
severity: 'info',
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function checkForAIStructuredOutput(node: N8nNode, warnings: ValidationWarning[]): void {
|
|
169
|
-
// Check AI/LLM nodes for structured output settings
|
|
170
|
-
const aiNodeTypes = [
|
|
171
|
-
'langchain.agent',
|
|
172
|
-
'langchain.chainLlm',
|
|
173
|
-
'langchain.lmChatOpenAi',
|
|
174
|
-
'langchain.lmChatAnthropic',
|
|
175
|
-
'langchain.lmChatGoogleGemini',
|
|
176
|
-
];
|
|
177
|
-
|
|
178
|
-
const isAINode = aiNodeTypes.some((t) => node.type.toLowerCase().includes(t.toLowerCase()));
|
|
179
|
-
if (!isAINode) return;
|
|
180
|
-
|
|
181
|
-
// Check for structured output settings
|
|
182
|
-
const params = node.parameters as Record<string, unknown>;
|
|
183
|
-
const hasPromptType = params.promptType === 'define';
|
|
184
|
-
const hasOutputParser = params.hasOutputParser === true;
|
|
185
|
-
|
|
186
|
-
// Only warn if it looks like they want structured output but missed settings
|
|
187
|
-
if (params.outputParser || params.schemaType) {
|
|
188
|
-
if (!hasPromptType || !hasOutputParser) {
|
|
189
|
-
warnings.push({
|
|
190
|
-
node: node.name,
|
|
191
|
-
rule: 'ai_structured_output',
|
|
192
|
-
message: `Node "${node.name}" may need promptType: "define" and hasOutputParser: true for reliable structured output`,
|
|
193
|
-
severity: 'warning',
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function checkForInMemoryStorage(node: N8nNode, warnings: ValidationWarning[]): void {
|
|
200
|
-
// Detect in-memory storage nodes that don't persist across restarts
|
|
201
|
-
const inMemoryTypes = [
|
|
202
|
-
'memoryBufferWindow',
|
|
203
|
-
'memoryVectorStore',
|
|
204
|
-
'vectorStoreInMemory',
|
|
205
|
-
];
|
|
206
|
-
|
|
207
|
-
const isInMemory = inMemoryTypes.some((t) => node.type.toLowerCase().includes(t.toLowerCase()));
|
|
208
|
-
if (isInMemory) {
|
|
209
|
-
warnings.push({
|
|
210
|
-
node: node.name,
|
|
211
|
-
rule: 'in_memory_storage',
|
|
212
|
-
message: `Node "${node.name}" uses in-memory storage - consider Postgres for production persistence`,
|
|
213
|
-
severity: 'warning',
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function validateConnections(workflow: N8nWorkflow, warnings: ValidationWarning[]): void {
|
|
219
|
-
const connectedNodes = new Set<string>();
|
|
220
|
-
|
|
221
|
-
// Collect all nodes that have connections (as source or target)
|
|
222
|
-
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
|
|
223
|
-
connectedNodes.add(sourceName);
|
|
224
|
-
for (const outputType of Object.values(outputs)) {
|
|
225
|
-
for (const connections of outputType) {
|
|
226
|
-
for (const conn of connections) {
|
|
227
|
-
connectedNodes.add(conn.node);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Check for orphan nodes (excluding trigger nodes which don't need incoming)
|
|
234
|
-
const triggerTypes = [
|
|
235
|
-
'n8n-nodes-base.webhook',
|
|
236
|
-
'n8n-nodes-base.scheduleTrigger',
|
|
237
|
-
'n8n-nodes-base.manualTrigger',
|
|
238
|
-
'n8n-nodes-base.emailTrigger',
|
|
239
|
-
'@n8n/n8n-nodes-langchain.chatTrigger',
|
|
240
|
-
];
|
|
241
|
-
|
|
242
|
-
for (const node of workflow.nodes) {
|
|
243
|
-
const isTrigger = triggerTypes.some((t) => node.type.includes(t.split('.')[1]));
|
|
244
|
-
if (!isTrigger && !connectedNodes.has(node.name)) {
|
|
245
|
-
warnings.push({
|
|
246
|
-
node: node.name,
|
|
247
|
-
rule: 'orphan_node',
|
|
248
|
-
message: `Node "${node.name}" has no connections - may be orphaned`,
|
|
249
|
-
severity: 'warning',
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Validate a partial update to ensure it won't cause issues
|
|
257
|
-
*/
|
|
258
|
-
export function validatePartialUpdate(
|
|
259
|
-
currentWorkflow: N8nWorkflow,
|
|
260
|
-
nodeName: string,
|
|
261
|
-
newParameters: Record<string, unknown>
|
|
262
|
-
): ValidationWarning[] {
|
|
263
|
-
const warnings: ValidationWarning[] = [];
|
|
264
|
-
|
|
265
|
-
const currentNode = currentWorkflow.nodes.find((n) => n.name === nodeName);
|
|
266
|
-
if (!currentNode) {
|
|
267
|
-
warnings.push({
|
|
268
|
-
node: nodeName,
|
|
269
|
-
rule: 'node_exists',
|
|
270
|
-
message: `Node "${nodeName}" not found in workflow`,
|
|
271
|
-
severity: 'error',
|
|
272
|
-
});
|
|
273
|
-
return warnings;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Check for parameter loss
|
|
277
|
-
const currentKeys = Object.keys(currentNode.parameters);
|
|
278
|
-
const newKeys = Object.keys(newParameters);
|
|
279
|
-
const missingKeys = currentKeys.filter((k) => !newKeys.includes(k));
|
|
280
|
-
|
|
281
|
-
if (missingKeys.length > 0) {
|
|
282
|
-
warnings.push({
|
|
283
|
-
node: nodeName,
|
|
284
|
-
rule: 'parameter_preservation',
|
|
285
|
-
message: `Update will remove parameters: ${missingKeys.join(', ')}. Include all existing parameters to preserve them.`,
|
|
286
|
-
severity: 'error',
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return warnings;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// ─────────────────────────────────────────────────────────────
|
|
294
|
-
// Node Type Validation
|
|
295
|
-
// ─────────────────────────────────────────────────────────────
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Validate that all node types in an array exist in the available types
|
|
299
|
-
* Returns errors for any invalid node types with suggestions
|
|
300
|
-
*/
|
|
301
|
-
export function validateNodeTypes(
|
|
302
|
-
nodes: Array<{ name: string; type: string }>,
|
|
303
|
-
availableTypes: Set<string>
|
|
304
|
-
): NodeTypeValidationError[] {
|
|
305
|
-
const errors: NodeTypeValidationError[] = [];
|
|
306
|
-
|
|
307
|
-
for (const node of nodes) {
|
|
308
|
-
if (!availableTypes.has(node.type)) {
|
|
309
|
-
errors.push({
|
|
310
|
-
nodeType: node.type,
|
|
311
|
-
nodeName: node.name,
|
|
312
|
-
message: `Invalid node type "${node.type}" for node "${node.name}"`,
|
|
313
|
-
suggestions: findSimilarTypes(node.type, availableTypes),
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return errors;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Find similar node types for suggestions (fuzzy matching)
|
|
323
|
-
* Returns up to 3 suggestions
|
|
324
|
-
*/
|
|
325
|
-
function findSimilarTypes(invalidType: string, availableTypes: Set<string>): string[] {
|
|
326
|
-
const suggestions: string[] = [];
|
|
327
|
-
const searchTerm = invalidType.toLowerCase();
|
|
328
|
-
|
|
329
|
-
// Extract the last part after the dot (e.g., "webhook" from "n8n-nodes-base.webhook")
|
|
330
|
-
const typeParts = searchTerm.split('.');
|
|
331
|
-
const shortName = typeParts[typeParts.length - 1];
|
|
332
|
-
|
|
333
|
-
for (const validType of availableTypes) {
|
|
334
|
-
const validLower = validType.toLowerCase();
|
|
335
|
-
const validShortName = validLower.split('.').pop() || '';
|
|
336
|
-
|
|
337
|
-
// Check for partial matches (substring)
|
|
338
|
-
if (
|
|
339
|
-
validLower.includes(shortName) ||
|
|
340
|
-
shortName.includes(validShortName) ||
|
|
341
|
-
validShortName.includes(shortName)
|
|
342
|
-
) {
|
|
343
|
-
suggestions.push(validType);
|
|
344
|
-
if (suggestions.length >= 3) break;
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Check for typos using Levenshtein distance
|
|
349
|
-
const distance = levenshteinDistance(shortName, validShortName);
|
|
350
|
-
const maxLen = Math.max(shortName.length, validShortName.length);
|
|
351
|
-
// Allow up to 2 character differences for short names, or 20% of length for longer ones
|
|
352
|
-
const threshold = Math.max(2, Math.floor(maxLen * 0.2));
|
|
353
|
-
if (distance <= threshold) {
|
|
354
|
-
suggestions.push(validType);
|
|
355
|
-
if (suggestions.length >= 3) break;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
return suggestions;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Calculate Levenshtein distance between two strings
|
|
364
|
-
*/
|
|
365
|
-
function levenshteinDistance(a: string, b: string): number {
|
|
366
|
-
if (a.length === 0) return b.length;
|
|
367
|
-
if (b.length === 0) return a.length;
|
|
368
|
-
|
|
369
|
-
const matrix: number[][] = [];
|
|
370
|
-
|
|
371
|
-
// Initialize first column
|
|
372
|
-
for (let i = 0; i <= a.length; i++) {
|
|
373
|
-
matrix[i] = [i];
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Initialize first row
|
|
377
|
-
for (let j = 0; j <= b.length; j++) {
|
|
378
|
-
matrix[0][j] = j;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Fill in the rest of the matrix
|
|
382
|
-
for (let i = 1; i <= a.length; i++) {
|
|
383
|
-
for (let j = 1; j <= b.length; j++) {
|
|
384
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
385
|
-
matrix[i][j] = Math.min(
|
|
386
|
-
matrix[i - 1][j] + 1, // deletion
|
|
387
|
-
matrix[i][j - 1] + 1, // insertion
|
|
388
|
-
matrix[i - 1][j - 1] + cost // substitution
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
return matrix[a.length][b.length];
|
|
394
|
-
}
|
package/src/versions.ts
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
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
|
-
}
|