@lhi/n8m 0.1.2 → 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.
@@ -12,13 +12,35 @@ export class N8nClient {
12
12
  // Priority: Explicit Config > Environment > Defaults
13
13
  this.apiUrl = config?.apiUrl ?? process.env.N8N_API_URL ?? 'http://localhost:5678/api/v1';
14
14
  this.apiKey = config?.apiKey ?? process.env.N8N_API_KEY ?? '';
15
+ // Normalize: ensure the URL ends with /api/v1 (config may store bare base URL)
16
+ if (!this.apiUrl.includes('/api/v1')) {
17
+ this.apiUrl = this.apiUrl.replace(/\/?$/, '') + '/api/v1';
18
+ }
15
19
  // Constructor validation moved to method call time to allow lazy loading
16
- // from config file if not provided here.
20
+ // from config file if not provided here.
17
21
  this.headers = {
18
22
  'Content-Type': 'application/json',
19
23
  'X-N8N-API-KEY': this.apiKey,
20
24
  };
21
25
  }
26
+ /**
27
+ * Assert that an API response is OK, throwing an actionable error for 401/403.
28
+ */
29
+ async assertOk(response, operation) {
30
+ if (response.ok)
31
+ return;
32
+ const errorText = await response.text();
33
+ if (response.status === 403) {
34
+ throw new Error(`${operation} failed (403 Forbidden). Your n8n API key does not have permission to access this resource. ` +
35
+ `In n8n, go to Settings → n8n API and ensure the key belongs to the same owner/project as the workflow. ` +
36
+ `n8n says: ${errorText}`);
37
+ }
38
+ if (response.status === 401) {
39
+ throw new Error(`${operation} failed (401 Unauthorized). Your n8n API key is missing or invalid. ` +
40
+ `Run: n8m config --n8n-key <your-key>`);
41
+ }
42
+ throw new Error(`${operation} failed: ${response.status} - ${errorText}`);
43
+ }
22
44
  /**
23
45
  * Activate a workflow
24
46
  */
@@ -27,10 +49,7 @@ export class N8nClient {
27
49
  headers: this.headers,
28
50
  method: 'POST',
29
51
  });
30
- if (!response.ok) {
31
- const errorText = await response.text();
32
- throw new Error(`Failed to activate workflow: ${response.status} - ${errorText}`);
33
- }
52
+ await this.assertOk(response, 'activate workflow');
34
53
  }
35
54
  /**
36
55
  * Deactivate a workflow
@@ -40,10 +59,7 @@ export class N8nClient {
40
59
  headers: this.headers,
41
60
  method: 'POST',
42
61
  });
43
- if (!response.ok) {
44
- const errorText = await response.text();
45
- throw new Error(`Failed to deactivate workflow: ${response.status} - ${errorText}`);
46
- }
62
+ await this.assertOk(response, 'deactivate workflow');
47
63
  }
48
64
  /**
49
65
  * Execute a workflow and return the result
@@ -56,10 +72,7 @@ export class N8nClient {
56
72
  headers: this.headers,
57
73
  method: 'POST',
58
74
  });
59
- if (!response.ok) {
60
- const errorText = await response.text();
61
- throw new Error(`n8n Validation Error: ${response.status} - ${errorText}`);
62
- }
75
+ await this.assertOk(response, 'execute workflow');
63
76
  // If activation succeeds, we assume basic validation passed.
64
77
  const result = await response.json();
65
78
  return {
@@ -83,9 +96,7 @@ export class N8nClient {
83
96
  headers: this.headers,
84
97
  method: 'GET',
85
98
  });
86
- if (!response.ok) {
87
- throw new Error(`Failed to get execution: ${response.status}`);
88
- }
99
+ await this.assertOk(response, 'get execution');
89
100
  return response.json();
90
101
  }
91
102
  catch (error) {
@@ -104,9 +115,7 @@ export class N8nClient {
104
115
  headers: this.headers,
105
116
  method: 'GET',
106
117
  });
107
- if (!response.ok) {
108
- throw new Error(`Failed to get executions: ${response.status}`);
109
- }
118
+ await this.assertOk(response, 'get workflow executions');
110
119
  const result = await response.json();
111
120
  return result.data;
112
121
  }
@@ -119,15 +128,13 @@ export class N8nClient {
119
128
  */
120
129
  async updateWorkflow(workflowId, workflowData) {
121
130
  try {
131
+ const sanitized = this.sanitizeSettings(workflowData);
122
132
  const response = await fetch(`${this.apiUrl}/workflows/${workflowId}`, {
123
- body: JSON.stringify(workflowData),
133
+ body: JSON.stringify(sanitized),
124
134
  headers: this.headers,
125
135
  method: 'PUT',
126
136
  });
127
- if (!response.ok) {
128
- const errorText = await response.text();
129
- throw new Error(`Failed to update workflow: ${response.status} - ${errorText}`);
130
- }
137
+ await this.assertOk(response, 'update workflow');
131
138
  }
132
139
  catch (error) {
133
140
  throw new Error(`Failed to update workflow: ${error.message}`);
@@ -142,24 +149,35 @@ export class N8nClient {
142
149
  headers: this.headers,
143
150
  method: 'GET',
144
151
  });
145
- if (!response.ok) {
146
- throw new Error(`Failed to get workflow: ${response.status}`);
147
- }
152
+ await this.assertOk(response, 'get workflow');
148
153
  return response.json();
149
154
  }
150
155
  catch (error) {
151
156
  throw new Error(`Failed to fetch workflow: ${error.message}`);
152
157
  }
153
158
  }
159
+ /**
160
+ * Strip invalid timezone from workflow settings so n8n activation never 400s.
161
+ */
162
+ sanitizeSettings(data) {
163
+ if (!data.settings || typeof data.settings !== 'object')
164
+ return data;
165
+ const settings = { ...data.settings };
166
+ // Always remove timezone — n8n validates against its own list and AI-generated
167
+ // values (including seemingly valid ones like "UTC") cause activation 400s.
168
+ // n8n will use the instance-level default timezone instead.
169
+ delete settings.timezone;
170
+ return { ...data, settings };
171
+ }
154
172
  /**
155
173
  * Create a new workflow
156
174
  */
157
175
  async createWorkflow(name, workflowData) {
158
176
  try {
159
- const payload = {
177
+ const payload = this.sanitizeSettings({
160
178
  name,
161
179
  ...workflowData,
162
- };
180
+ });
163
181
  // Debug logging for payload validation errors
164
182
  // console.log('DEBUG: createWorkflow payload keys:', Object.keys(payload));
165
183
  const response = await fetch(`${this.apiUrl}/workflows`, {
@@ -167,10 +185,7 @@ export class N8nClient {
167
185
  headers: this.headers,
168
186
  method: 'POST',
169
187
  });
170
- if (!response.ok) {
171
- const errorText = await response.text();
172
- throw new Error(`Failed to create workflow: ${response.status} - ${errorText}`);
173
- }
188
+ await this.assertOk(response, 'create workflow');
174
189
  const result = await response.json();
175
190
  return { id: result.id };
176
191
  }
@@ -179,121 +194,45 @@ export class N8nClient {
179
194
  }
180
195
  }
181
196
  /**
182
- * Get all installed node types via Probe Workflow (Webhook)
183
- *
184
- * Strategy:
185
- * 1. Create a workflow with Webhook -> HTTP Request (Internal API)
186
- * 2. Activate it
187
- * 3. Call the webhook -> Returns the node types
188
- */
189
- /**
190
- * Get all installed node types via Probe Workflow (Webhook)
191
- * Returns full node type objects including parameters, not just names.
197
+ * Get all installed node types directly from the n8n REST API.
198
+ * Handles paginated responses and returns the full node type objects.
192
199
  */
193
200
  async getNodeTypes() {
194
- const probeId = `probe-${Math.random().toString(36).substring(7)}`;
195
- const probePath = `n8m-probe-${Math.random().toString(36).substring(7)}`;
196
- let workflowId = null;
197
201
  try {
198
- const internalApiUrl = this.apiUrl + '/node-types';
199
- const probeWorkflow = {
200
- name: `[n8m:system] Node Probe ${probeId}`,
201
- nodes: [
202
- {
203
- parameters: {
204
- httpMethod: "GET",
205
- path: probePath,
206
- responseMode: "lastNode",
207
- options: {}
208
- },
209
- id: "webhook",
210
- name: "ProbeWebhook",
211
- type: "n8n-nodes-base.webhook",
212
- typeVersion: 1,
213
- position: [400, 300],
214
- webhookId: probePath
215
- },
216
- {
217
- parameters: {
218
- url: internalApiUrl,
219
- method: "GET",
220
- authentication: "none",
221
- sendHeaders: true,
222
- headerParameters: {
223
- parameters: [
224
- {
225
- name: "X-N8N-API-KEY",
226
- value: this.apiKey
227
- }
228
- ]
229
- },
230
- options: {}
231
- },
232
- id: "http-request",
233
- name: "FetchNodes",
234
- type: "n8n-nodes-base.httpRequest",
235
- typeVersion: 4.1,
236
- position: [600, 300]
237
- }
238
- ],
239
- connections: {
240
- "ProbeWebhook": {
241
- main: [[{ node: "FetchNodes", type: "main", index: 0 }]]
242
- }
243
- },
244
- settings: {
245
- saveManualExecutions: false,
246
- callerPolicy: 'workflowsFromSameOwner'
202
+ let all = [];
203
+ let cursor = undefined;
204
+ do {
205
+ const url = new URL(`${this.apiUrl}/node-types`);
206
+ if (cursor)
207
+ url.searchParams.set('cursor', cursor);
208
+ const response = await fetch(url.toString(), {
209
+ headers: this.headers,
210
+ method: 'GET',
211
+ });
212
+ if (!response.ok) {
213
+ console.warn(`[N8nClient] node-types request failed (${response.status}) — validation/shimming disabled`);
214
+ return [];
247
215
  }
248
- };
249
- // Header Injection
250
- probeWorkflow.nodes[1].parameters.authentication = 'none';
251
- probeWorkflow.nodes[1].parameters.headerParameters = {
252
- parameters: [
253
- { name: 'X-N8N-API-KEY', value: this.apiKey }
254
- ]
255
- };
256
- // 1. Create
257
- const { id } = await this.createWorkflow(probeWorkflow.name, probeWorkflow);
258
- workflowId = id;
259
- // 2. Activate
260
- await this.activateWorkflow(id);
261
- // 3. Trigger
262
- const baseUrl = this.apiUrl.replace('/api/v1', '');
263
- const webhookUrl = `${baseUrl}/webhook/${probePath}`;
264
- console.log(`[N8nClient] Triggering probe at ${webhookUrl}...`);
265
- const response = await fetch(webhookUrl);
266
- if (!response.ok) {
267
- const errorText = await response.text();
268
- if (process.env.DEBUG) {
269
- console.warn(`[N8nClient] Probe webhook failed (Status ${response.status}): ${errorText}`);
216
+ const result = await response.json();
217
+ if (Array.isArray(result)) {
218
+ all = [...all, ...result];
219
+ cursor = undefined;
270
220
  }
271
- throw new Error(`Probe webhook failed: ${response.status}`);
272
- }
273
- const result = await response.json();
274
- // Return full objects
275
- if (Array.isArray(result)) {
276
- return result;
277
- }
278
- if (result.data && Array.isArray(result.data)) {
279
- return result.data;
280
- }
281
- return [];
221
+ else if (result.data && Array.isArray(result.data)) {
222
+ all = [...all, ...result.data];
223
+ cursor = result.nextCursor ?? undefined;
224
+ }
225
+ else {
226
+ // Unknown format — stop paging
227
+ break;
228
+ }
229
+ } while (cursor);
230
+ return all;
282
231
  }
283
232
  catch (error) {
284
- if (process.env.DEBUG) {
285
- console.warn(`[N8nClient] Probe failed: ${error.message}`);
286
- }
233
+ console.warn(`[N8nClient] Failed to fetch node types: ${error.message}`);
287
234
  return [];
288
235
  }
289
- finally {
290
- if (workflowId) {
291
- try {
292
- await this.deleteWorkflow(workflowId);
293
- }
294
- catch { /* intentionally empty */ }
295
- }
296
- }
297
236
  }
298
237
  /**
299
238
  * Get all workflows
@@ -312,9 +251,7 @@ export class N8nClient {
312
251
  headers: this.headers,
313
252
  method: 'GET',
314
253
  });
315
- if (!response.ok) {
316
- throw new Error(`Failed to get workflows: ${response.status}`);
317
- }
254
+ await this.assertOk(response, 'get workflows');
318
255
  const result = await response.json();
319
256
  allWorkflows = [...allWorkflows, ...result.data];
320
257
  cursor = result.nextCursor;
@@ -334,9 +271,7 @@ export class N8nClient {
334
271
  headers: this.headers,
335
272
  method: 'DELETE',
336
273
  });
337
- if (!response.ok) {
338
- throw new Error(`Failed to delete workflow: ${response.status}`);
339
- }
274
+ await this.assertOk(response, 'delete workflow');
340
275
  }
341
276
  catch (error) {
342
277
  throw new Error(`Failed to delete workflow: ${error.message}`);
@@ -72,7 +72,7 @@
72
72
  "required": false
73
73
  }
74
74
  },
75
- "description": "Generate n8n workflows from natural language using Gemini AI Agent",
75
+ "description": "Generate n8n workflows from natural language using an AI Agent",
76
76
  "examples": [
77
77
  "<%= config.bin %> <%= command.id %> \"Send a telegram alert when I receive an email\"",
78
78
  "echo \"Slack to Discord sync\" | <%= config.bin %> <%= command.id %>",
@@ -164,6 +164,64 @@
164
164
  "deploy.js"
165
165
  ]
166
166
  },
167
+ "doc": {
168
+ "aliases": [],
169
+ "args": {
170
+ "workflow": {
171
+ "description": "Path or Name of the workflow to document",
172
+ "name": "workflow",
173
+ "required": false
174
+ }
175
+ },
176
+ "description": "Generate visual and text documentation for n8n workflows",
177
+ "flags": {
178
+ "output": {
179
+ "char": "o",
180
+ "description": "Output directory for documentation (defaults to ./docs)",
181
+ "name": "output",
182
+ "hasDynamicHelp": false,
183
+ "multiple": false,
184
+ "type": "option"
185
+ }
186
+ },
187
+ "hasDynamicHelp": false,
188
+ "hiddenAliases": [],
189
+ "id": "doc",
190
+ "pluginAlias": "@lhi/n8m",
191
+ "pluginName": "@lhi/n8m",
192
+ "pluginType": "core",
193
+ "strict": true,
194
+ "enableJsonFlag": false,
195
+ "isESM": true,
196
+ "relativePath": [
197
+ "dist",
198
+ "commands",
199
+ "doc.js"
200
+ ]
201
+ },
202
+ "mcp": {
203
+ "aliases": [],
204
+ "args": {},
205
+ "description": "Launch the n8m MCP (Model Context Protocol) server",
206
+ "examples": [
207
+ "<%= config.bin %> <%= command.id %>"
208
+ ],
209
+ "flags": {},
210
+ "hasDynamicHelp": false,
211
+ "hiddenAliases": [],
212
+ "id": "mcp",
213
+ "pluginAlias": "@lhi/n8m",
214
+ "pluginName": "@lhi/n8m",
215
+ "pluginType": "core",
216
+ "strict": true,
217
+ "enableJsonFlag": false,
218
+ "isESM": true,
219
+ "relativePath": [
220
+ "dist",
221
+ "commands",
222
+ "mcp.js"
223
+ ]
224
+ },
167
225
  "modify": {
168
226
  "aliases": [],
169
227
  "args": {
@@ -178,7 +236,7 @@
178
236
  "required": false
179
237
  }
180
238
  },
181
- "description": "Modify existing n8n workflows using Gemini AI Agent",
239
+ "description": "Modify existing n8n workflows using an AI Agent",
182
240
  "flags": {
183
241
  "multiline": {
184
242
  "char": "m",
@@ -309,6 +367,12 @@
309
367
  "name": "validate-only",
310
368
  "allowNo": false,
311
369
  "type": "boolean"
370
+ },
371
+ "ai-scenarios": {
372
+ "description": "Generate 3 diverse AI test scenarios (happy path, edge case, error)",
373
+ "name": "ai-scenarios",
374
+ "allowNo": false,
375
+ "type": "boolean"
312
376
  }
313
377
  },
314
378
  "hasDynamicHelp": false,
@@ -327,5 +391,5 @@
327
391
  ]
328
392
  }
329
393
  },
330
- "version": "0.1.2"
394
+ "version": "0.2.0"
331
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lhi/n8m",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Agentic n8n CLI wrapper - A Skill Bridge for n8n workflow automation",
5
5
  "author": "Lem Canady",
6
6
  "license": "MIT",
@@ -18,10 +18,10 @@
18
18
  "oclif.manifest.json"
19
19
  ],
20
20
  "dependencies": {
21
- "openai": "^4.0.0",
22
21
  "@langchain/core": "^1.1.18",
23
22
  "@langchain/langgraph": "^1.1.2",
24
23
  "@langchain/langgraph-checkpoint-sqlite": "^1.0.0",
24
+ "@modelcontextprotocol/sdk": "^1.27.1",
25
25
  "@oclif/core": "^4",
26
26
  "@oclif/plugin-help": "^6",
27
27
  "@oclif/plugin-plugins": "^5",
@@ -37,6 +37,8 @@
37
37
  "ink-spinner": "^5.0.0",
38
38
  "ink-text-input": "^6.0.0",
39
39
  "inquirer": "^13.2.2",
40
+ "jsonrepair": "^3.13.2",
41
+ "openai": "^4.0.0",
40
42
  "react": "^19.2.4"
41
43
  },
42
44
  "devDependencies": {
@@ -83,13 +85,14 @@
83
85
  }
84
86
  },
85
87
  "scripts": {
86
- "build": "shx rm -rf dist && tsc -b",
88
+ "build": "shx rm -rf dist && tsc -b && shx mkdir -p dist/resources && shx cp src/resources/*.json dist/resources/",
87
89
  "lint": "eslint .",
88
90
  "postpack": "shx rm -f oclif.manifest.json",
89
91
  "posttest": "npm run lint",
90
92
  "prepack": "oclif manifest && oclif readme",
91
- "test": "mocha --forbid-only \"test/**/*.test.ts\"",
93
+ "test": "NODE_ENV=test mocha --forbid-only \"test/**/*.test.ts\"",
92
94
  "n8m": "./bin/run.js",
95
+ "start": "npm run build && ./bin/run.js",
93
96
  "dev": "tsc -b -w",
94
97
  "version": "oclif readme && git add README.md"
95
98
  }