@lhi/n8m 0.2.0 → 0.2.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.
Files changed (37) hide show
  1. package/README.md +105 -6
  2. package/dist/agentic/graph.d.ts +50 -0
  3. package/dist/agentic/graph.js +0 -2
  4. package/dist/agentic/nodes/architect.d.ts +5 -0
  5. package/dist/agentic/nodes/architect.js +8 -22
  6. package/dist/agentic/nodes/engineer.d.ts +15 -0
  7. package/dist/agentic/nodes/engineer.js +25 -4
  8. package/dist/agentic/nodes/qa.d.ts +1 -0
  9. package/dist/agentic/nodes/qa.js +280 -45
  10. package/dist/agentic/nodes/reviewer.d.ts +4 -0
  11. package/dist/agentic/nodes/reviewer.js +71 -13
  12. package/dist/agentic/nodes/supervisor.js +2 -3
  13. package/dist/agentic/state.d.ts +1 -0
  14. package/dist/agentic/state.js +4 -0
  15. package/dist/commands/create.js +37 -3
  16. package/dist/commands/doc.js +1 -1
  17. package/dist/commands/fixture.d.ts +12 -0
  18. package/dist/commands/fixture.js +258 -0
  19. package/dist/commands/test.d.ts +63 -4
  20. package/dist/commands/test.js +1179 -90
  21. package/dist/fixture-schema.json +162 -0
  22. package/dist/resources/node-definitions-fallback.json +185 -8
  23. package/dist/resources/node-test-hints.json +188 -0
  24. package/dist/resources/workflow-test-fixtures.json +42 -0
  25. package/dist/services/ai.service.d.ts +42 -0
  26. package/dist/services/ai.service.js +271 -21
  27. package/dist/services/node-definitions.service.d.ts +1 -0
  28. package/dist/services/node-definitions.service.js +4 -11
  29. package/dist/utils/config.js +2 -0
  30. package/dist/utils/fixtureManager.d.ts +28 -0
  31. package/dist/utils/fixtureManager.js +41 -0
  32. package/dist/utils/n8nClient.d.ts +27 -0
  33. package/dist/utils/n8nClient.js +169 -5
  34. package/dist/utils/spinner.d.ts +17 -0
  35. package/dist/utils/spinner.js +52 -0
  36. package/oclif.manifest.json +49 -1
  37. package/package.json +2 -2
@@ -110,7 +110,7 @@ export class N8nClient {
110
110
  try {
111
111
  const url = new URL(`${this.apiUrl}/executions`);
112
112
  url.searchParams.set('workflowId', workflowId);
113
- url.searchParams.set('limit', '5');
113
+ url.searchParams.set('limit', '25');
114
114
  const response = await fetch(url.toString(), {
115
115
  headers: this.headers,
116
116
  method: 'GET',
@@ -178,8 +178,6 @@ export class N8nClient {
178
178
  name,
179
179
  ...workflowData,
180
180
  });
181
- // Debug logging for payload validation errors
182
- // console.log('DEBUG: createWorkflow payload keys:', Object.keys(payload));
183
181
  const response = await fetch(`${this.apiUrl}/workflows`, {
184
182
  body: JSON.stringify(payload),
185
183
  headers: this.headers,
@@ -190,7 +188,11 @@ export class N8nClient {
190
188
  return { id: result.id };
191
189
  }
192
190
  catch (error) {
193
- throw new Error(`Failed to create workflow: ${error.message}`);
191
+ const msg = error.message;
192
+ if (msg === 'fetch failed' || msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) {
193
+ throw new Error(`Cannot connect to n8n at ${this.apiUrl}. Ensure n8n is running and the URL is correct.`);
194
+ }
195
+ throw new Error(`Failed to create workflow: ${msg}`);
194
196
  }
195
197
  }
196
198
  /**
@@ -210,7 +212,6 @@ export class N8nClient {
210
212
  method: 'GET',
211
213
  });
212
214
  if (!response.ok) {
213
- console.warn(`[N8nClient] node-types request failed (${response.status}) — validation/shimming disabled`);
214
215
  return [];
215
216
  }
216
217
  const result = await response.json();
@@ -365,6 +366,60 @@ export class N8nClient {
365
366
  connections
366
367
  };
367
368
  }
369
+ /**
370
+ * Temporarily set pin data on a workflow so test executions receive
371
+ * synthetic binary data instead of running binary-generating nodes live.
372
+ * Pass pinData:{} to clear injected pins and restore the original state.
373
+ *
374
+ * The public /api/v1/ schema may reject pinData as an "additional property".
375
+ * In that case we fall back to the internal /rest/ API that n8n's own UI uses,
376
+ * which accepts pinData without schema restriction.
377
+ */
378
+ async setPinData(workflowId, workflowData, pinData) {
379
+ const settings = { ...(workflowData.settings || {}) };
380
+ delete settings.timezone; // n8n rejects many timezone values
381
+ const payload = {
382
+ name: workflowData.name,
383
+ nodes: workflowData.nodes,
384
+ connections: workflowData.connections,
385
+ settings,
386
+ pinData,
387
+ };
388
+ // Preserve versionId and staticData so the internal API doesn't reset them
389
+ if (workflowData.versionId)
390
+ payload.versionId = workflowData.versionId;
391
+ if (workflowData.staticData)
392
+ payload.staticData = workflowData.staticData;
393
+ // Attempt 1: public /api/v1/ endpoint
394
+ const pubResponse = await fetch(`${this.apiUrl}/workflows/${workflowId}`, {
395
+ body: JSON.stringify(payload),
396
+ headers: this.headers,
397
+ method: 'PUT',
398
+ });
399
+ if (pubResponse.ok)
400
+ return;
401
+ const pubErr = await pubResponse.text();
402
+ // Attempt 2: internal /rest/ endpoint (used by n8n UI; accepts pinData)
403
+ if (pubResponse.status === 400 && pubErr.includes('additional properties')) {
404
+ const restUrl = this.apiUrl.replace('/api/v1', '/rest');
405
+ const restResponse = await fetch(`${restUrl}/workflows/${workflowId}`, {
406
+ body: JSON.stringify(payload),
407
+ headers: this.headers,
408
+ method: 'PUT',
409
+ });
410
+ if (restResponse.ok)
411
+ return;
412
+ const restErr = await restResponse.text();
413
+ throw new Error(`set pin data failed: ${restResponse.status} - ${restErr}`);
414
+ }
415
+ if (pubResponse.status === 403) {
416
+ throw new Error(`set pin data failed (403 Forbidden). Ensure your API key has permission to update this workflow. n8n says: ${pubErr}`);
417
+ }
418
+ if (pubResponse.status === 401) {
419
+ throw new Error(`set pin data failed (401 Unauthorized). Run: n8m config --n8n-key <your-key>`);
420
+ }
421
+ throw new Error(`set pin data failed: ${pubResponse.status} - ${pubErr}`);
422
+ }
368
423
  /**
369
424
  * Get n8n instance deep link for a workflow
370
425
  */
@@ -372,4 +427,113 @@ export class N8nClient {
372
427
  const baseUrl = this.apiUrl.replace('/api/v1', '');
373
428
  return `${baseUrl}/workflow/${workflowId}`;
374
429
  }
430
+ /**
431
+ * Node types that never make external HTTP calls — pure logic, control flow,
432
+ * data transformation, or local execution. Everything else is a candidate
433
+ * for shimming during test runs.
434
+ */
435
+ static PASS_THROUGH_TYPES = new Set([
436
+ 'n8n-nodes-base.webhook',
437
+ 'n8n-nodes-base.manualTrigger',
438
+ 'n8n-nodes-base.scheduleTrigger',
439
+ 'n8n-nodes-base.intervalTrigger',
440
+ 'n8n-nodes-base.code',
441
+ 'n8n-nodes-base.function',
442
+ 'n8n-nodes-base.functionItem',
443
+ 'n8n-nodes-base.if',
444
+ 'n8n-nodes-base.switch',
445
+ 'n8n-nodes-base.set',
446
+ 'n8n-nodes-base.editFields',
447
+ 'n8n-nodes-base.merge',
448
+ 'n8n-nodes-base.noOp',
449
+ 'n8n-nodes-base.executeWorkflow',
450
+ 'n8n-nodes-base.executeWorkflowTrigger',
451
+ 'n8n-nodes-base.respondToWebhook',
452
+ 'n8n-nodes-base.wait',
453
+ 'n8n-nodes-base.splitInBatches',
454
+ 'n8n-nodes-base.aggregate',
455
+ 'n8n-nodes-base.splitOut',
456
+ 'n8n-nodes-base.itemLists',
457
+ 'n8n-nodes-base.filter',
458
+ 'n8n-nodes-base.sort',
459
+ 'n8n-nodes-base.limit',
460
+ 'n8n-nodes-base.removeDuplicates',
461
+ 'n8n-nodes-base.dateTime',
462
+ 'n8n-nodes-base.html',
463
+ 'n8n-nodes-base.htmlExtract',
464
+ 'n8n-nodes-base.xml',
465
+ 'n8n-nodes-base.markdown',
466
+ 'n8n-nodes-base.compression',
467
+ 'n8n-nodes-base.convertToFile',
468
+ 'n8n-nodes-base.extractFromFile',
469
+ 'n8n-nodes-base.crypto',
470
+ 'n8n-nodes-base.executeCommand',
471
+ 'n8n-nodes-base.stickyNote',
472
+ 'n8n-nodes-base.start',
473
+ ]);
474
+ /**
475
+ * Replace every node that makes external network calls with an inert Code shim
476
+ * returning plausible fake data. Node NAMES (and IDs) are preserved so
477
+ * connections stay valid. Used to ensure tests never hit real external services.
478
+ *
479
+ * Shimming criteria: the node has credentials configured OR is an HTTP Request node.
480
+ * Pure-logic nodes in PASS_THROUGH_TYPES are always left untouched.
481
+ */
482
+ static shimNetworkNodes(nodes) {
483
+ return nodes.map((node) => {
484
+ if (!node)
485
+ return node;
486
+ if (N8nClient.PASS_THROUGH_TYPES.has(node.type))
487
+ return node;
488
+ // LangChain sub-nodes (@n8n/ namespace) communicate via supplyData(), not main outputs.
489
+ // Replacing them with Code nodes breaks the supplyData protocol — skip shimming.
490
+ if (node.type?.startsWith('@n8n/'))
491
+ return node;
492
+ const hasCredentials = node.credentials && Object.keys(node.credentials).length > 0;
493
+ const isHttpRequest = node.type === 'n8n-nodes-base.httpRequest';
494
+ if (!hasCredentials && !isHttpRequest)
495
+ return node;
496
+ return {
497
+ id: node.id,
498
+ name: node.name,
499
+ type: 'n8n-nodes-base.code',
500
+ typeVersion: 2,
501
+ position: node.position,
502
+ parameters: {
503
+ mode: 'runOnceForAllItems',
504
+ jsCode: N8nClient.buildNetworkShimCode(node.type),
505
+ },
506
+ };
507
+ });
508
+ }
509
+ /** Generate the JS body for a Code shim that stands in for an external-calling node. */
510
+ static buildNetworkShimCode(nodeType) {
511
+ const t = nodeType.toLowerCase();
512
+ if (t.includes('httprequest')) {
513
+ return `// [n8m:shim] HTTP Request — no external call made during testing\nreturn [{ json: { status: 200, statusText: 'OK', body: '{"ok":true,"shimmed":true}', headers: { 'content-type': 'application/json' } } }];`;
514
+ }
515
+ if (t.includes('openai') || t.includes('anthropic') || t.includes('gemini') || t.includes('lmchat') || t.includes('languagemodel')) {
516
+ return `// [n8m:shim] AI node — no external call made during testing\nreturn [{ json: { message: { role: 'assistant', content: '[test shim: AI response]' }, finish_reason: 'stop', usage: { total_tokens: 0 } } }];`;
517
+ }
518
+ if (t.includes('slack')) {
519
+ return `// [n8m:shim] Slack — no external call made during testing\nreturn [{ json: { ok: true, ts: '1000000000.000001', channel: 'C00000000', message: { text: '[test shim]' } } }];`;
520
+ }
521
+ if (t.includes('gmail') || t.includes('emailsend') || t.includes('sendemail') || t.includes('imap')) {
522
+ return `// [n8m:shim] Email — no external call made during testing\nreturn [{ json: { id: 'shim-msg-id', threadId: 'shim-thread-id', labelIds: ['SENT'] } }];`;
523
+ }
524
+ if (t.includes('googledrive') || t.includes('googlesheets') || t.includes('googledocs')) {
525
+ return `// [n8m:shim] Google service — no external call made during testing\nreturn [{ json: { kind: 'drive#file', id: 'shim-id', name: 'shim', mimeType: 'application/json' } }];`;
526
+ }
527
+ if (t.includes('github') || t.includes('gitlab')) {
528
+ return `// [n8m:shim] Git service — no external call made during testing\nreturn [{ json: { id: 1, number: 1, title: '[test shim]', state: 'open', html_url: 'https://example.com' } }];`;
529
+ }
530
+ if (t.includes('telegram') || t.includes('discord') || t.includes('teams') || t.includes('mattermost')) {
531
+ return `// [n8m:shim] Messaging service — no external call made during testing\nreturn [{ json: { ok: true, result: { message_id: 1, text: '[test shim]' } } }];`;
532
+ }
533
+ if (t.includes('airtable') || t.includes('notion') || t.includes('jira') || t.includes('asana') || t.includes('trello')) {
534
+ return `// [n8m:shim] Project management service — no external call made during testing\nreturn [{ json: { id: 'shim-id', name: '[test shim]', status: 'ok' } }];`;
535
+ }
536
+ // Default: generic success response for any other credentialed service node
537
+ return `// [n8m:shim] External service — no external call made during testing\nreturn [{ json: { ok: true, shimmed: true, id: 'shim-id', result: '[test shim]' } }];`;
538
+ }
375
539
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Ref-counted terminal spinner.
3
+ *
4
+ * Multiple concurrent callers (e.g. two parallel Engineer nodes) share a
5
+ * single animated line. The animation stops only when every caller has
6
+ * called stop().
7
+ */
8
+ export declare class Spinner {
9
+ private static refCount;
10
+ private static intervalId;
11
+ private static frameIdx;
12
+ private static text;
13
+ static start(text: string): void;
14
+ static stop(): void;
15
+ /** Convenience: run fn() while the spinner is active. */
16
+ static wrap<T>(text: string, fn: () => Promise<T>): Promise<T>;
17
+ }
@@ -0,0 +1,52 @@
1
+ import chalk from 'chalk';
2
+ import spinners from 'cli-spinners';
3
+ const frames = spinners.dots.frames;
4
+ /**
5
+ * Ref-counted terminal spinner.
6
+ *
7
+ * Multiple concurrent callers (e.g. two parallel Engineer nodes) share a
8
+ * single animated line. The animation stops only when every caller has
9
+ * called stop().
10
+ */
11
+ export class Spinner {
12
+ static refCount = 0;
13
+ static intervalId = null;
14
+ static frameIdx = 0;
15
+ static text = '';
16
+ static start(text) {
17
+ if (!process.stdout.isTTY)
18
+ return;
19
+ // Update the label to the latest caller's message.
20
+ Spinner.text = text;
21
+ Spinner.refCount++;
22
+ if (Spinner.intervalId)
23
+ return; // already animating
24
+ Spinner.frameIdx = 0;
25
+ Spinner.intervalId = setInterval(() => {
26
+ const frame = chalk.hex('#A855F7')(frames[Spinner.frameIdx++ % frames.length]);
27
+ process.stdout.write(`\r${frame} ${chalk.dim(Spinner.text)}`);
28
+ }, 80);
29
+ }
30
+ static stop() {
31
+ if (!process.stdout.isTTY)
32
+ return;
33
+ Spinner.refCount = Math.max(0, Spinner.refCount - 1);
34
+ if (Spinner.refCount > 0)
35
+ return; // other callers still in flight
36
+ if (Spinner.intervalId) {
37
+ clearInterval(Spinner.intervalId);
38
+ Spinner.intervalId = null;
39
+ }
40
+ process.stdout.write('\r\x1b[K'); // erase the spinner line
41
+ }
42
+ /** Convenience: run fn() while the spinner is active. */
43
+ static async wrap(text, fn) {
44
+ Spinner.start(text);
45
+ try {
46
+ return await fn();
47
+ }
48
+ finally {
49
+ Spinner.stop();
50
+ }
51
+ }
52
+ }
@@ -199,6 +199,46 @@
199
199
  "doc.js"
200
200
  ]
201
201
  },
202
+ "fixture": {
203
+ "aliases": [],
204
+ "args": {
205
+ "action": {
206
+ "description": "Action to perform (init, capture)",
207
+ "name": "action",
208
+ "options": [
209
+ "init",
210
+ "capture"
211
+ ],
212
+ "required": true
213
+ },
214
+ "workflowId": {
215
+ "description": "n8n workflow ID (optional — omit to browse and select)",
216
+ "name": "workflowId",
217
+ "required": false
218
+ }
219
+ },
220
+ "description": "Manage n8m workflow fixtures for offline testing",
221
+ "examples": [
222
+ "<%= config.bin %> fixture init abc123 # scaffold an empty fixture template",
223
+ "<%= config.bin %> fixture capture # browse local files + n8n instance to pick a workflow",
224
+ "<%= config.bin %> fixture capture abc123 # pull latest real execution for a specific workflow ID"
225
+ ],
226
+ "flags": {},
227
+ "hasDynamicHelp": false,
228
+ "hiddenAliases": [],
229
+ "id": "fixture",
230
+ "pluginAlias": "@lhi/n8m",
231
+ "pluginName": "@lhi/n8m",
232
+ "pluginType": "core",
233
+ "strict": true,
234
+ "enableJsonFlag": false,
235
+ "isESM": true,
236
+ "relativePath": [
237
+ "dist",
238
+ "commands",
239
+ "fixture.js"
240
+ ]
241
+ },
202
242
  "mcp": {
203
243
  "aliases": [],
204
244
  "args": {},
@@ -373,6 +413,14 @@
373
413
  "name": "ai-scenarios",
374
414
  "allowNo": false,
375
415
  "type": "boolean"
416
+ },
417
+ "fixture": {
418
+ "char": "f",
419
+ "description": "Path to a fixture JSON file to use for offline testing",
420
+ "name": "fixture",
421
+ "hasDynamicHelp": false,
422
+ "multiple": false,
423
+ "type": "option"
376
424
  }
377
425
  },
378
426
  "hasDynamicHelp": false,
@@ -391,5 +439,5 @@
391
439
  ]
392
440
  }
393
441
  },
394
- "version": "0.2.0"
442
+ "version": "0.2.2"
395
443
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lhi/n8m",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Agentic n8n CLI wrapper - A Skill Bridge for n8n workflow automation",
5
5
  "author": "Lem Canady",
6
6
  "license": "MIT",
@@ -85,7 +85,7 @@
85
85
  }
86
86
  },
87
87
  "scripts": {
88
- "build": "shx rm -rf dist && tsc -b && shx mkdir -p dist/resources && shx cp src/resources/*.json dist/resources/",
88
+ "build": "shx rm -rf dist && tsc -b && shx mkdir -p dist/resources && shx cp src/resources/*.json dist/resources/ && shx cp src/fixture-schema.json dist/fixture-schema.json",
89
89
  "lint": "eslint .",
90
90
  "postpack": "shx rm -f oclif.manifest.json",
91
91
  "posttest": "npm run lint",