@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.
- package/README.md +105 -6
- package/dist/agentic/graph.d.ts +50 -0
- package/dist/agentic/graph.js +0 -2
- package/dist/agentic/nodes/architect.d.ts +5 -0
- package/dist/agentic/nodes/architect.js +8 -22
- package/dist/agentic/nodes/engineer.d.ts +15 -0
- package/dist/agentic/nodes/engineer.js +25 -4
- package/dist/agentic/nodes/qa.d.ts +1 -0
- package/dist/agentic/nodes/qa.js +280 -45
- package/dist/agentic/nodes/reviewer.d.ts +4 -0
- package/dist/agentic/nodes/reviewer.js +71 -13
- package/dist/agentic/nodes/supervisor.js +2 -3
- package/dist/agentic/state.d.ts +1 -0
- package/dist/agentic/state.js +4 -0
- package/dist/commands/create.js +37 -3
- package/dist/commands/doc.js +1 -1
- package/dist/commands/fixture.d.ts +12 -0
- package/dist/commands/fixture.js +258 -0
- package/dist/commands/test.d.ts +63 -4
- package/dist/commands/test.js +1179 -90
- package/dist/fixture-schema.json +162 -0
- package/dist/resources/node-definitions-fallback.json +185 -8
- package/dist/resources/node-test-hints.json +188 -0
- package/dist/resources/workflow-test-fixtures.json +42 -0
- package/dist/services/ai.service.d.ts +42 -0
- package/dist/services/ai.service.js +271 -21
- package/dist/services/node-definitions.service.d.ts +1 -0
- package/dist/services/node-definitions.service.js +4 -11
- package/dist/utils/config.js +2 -0
- package/dist/utils/fixtureManager.d.ts +28 -0
- package/dist/utils/fixtureManager.js +41 -0
- package/dist/utils/n8nClient.d.ts +27 -0
- package/dist/utils/n8nClient.js +169 -5
- package/dist/utils/spinner.d.ts +17 -0
- package/dist/utils/spinner.js +52 -0
- package/oclif.manifest.json +49 -1
- package/package.json +2 -2
package/dist/commands/doc.js
CHANGED
|
@@ -22,7 +22,7 @@ export default class Doc extends Command {
|
|
|
22
22
|
}),
|
|
23
23
|
};
|
|
24
24
|
async run() {
|
|
25
|
-
const { args
|
|
25
|
+
const { args } = await this.parse(Doc);
|
|
26
26
|
this.log(theme.brand());
|
|
27
27
|
this.log(theme.header('WORKFLOW DOCUMENTATION'));
|
|
28
28
|
// 1. Load Credentials & Client
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class Fixture extends Command {
|
|
3
|
+
static args: {
|
|
4
|
+
action: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
workflowId: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
6
|
+
};
|
|
7
|
+
static description: string;
|
|
8
|
+
static examples: string[];
|
|
9
|
+
run(): Promise<void>;
|
|
10
|
+
private initFixture;
|
|
11
|
+
private captureFixture;
|
|
12
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { Args, Command } from '@oclif/core';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
import { theme } from '../utils/theme.js';
|
|
7
|
+
import { N8nClient } from '../utils/n8nClient.js';
|
|
8
|
+
import { ConfigManager } from '../utils/config.js';
|
|
9
|
+
import { FixtureManager } from '../utils/fixtureManager.js';
|
|
10
|
+
export default class Fixture extends Command {
|
|
11
|
+
static args = {
|
|
12
|
+
action: Args.string({
|
|
13
|
+
description: 'Action to perform (init, capture)',
|
|
14
|
+
required: true,
|
|
15
|
+
options: ['init', 'capture'],
|
|
16
|
+
}),
|
|
17
|
+
workflowId: Args.string({
|
|
18
|
+
description: 'n8n workflow ID (optional — omit to browse and select)',
|
|
19
|
+
required: false,
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
static description = 'Manage n8m workflow fixtures for offline testing';
|
|
23
|
+
static examples = [
|
|
24
|
+
'<%= config.bin %> fixture init abc123 # scaffold an empty fixture template',
|
|
25
|
+
'<%= config.bin %> fixture capture # browse local files + n8n instance to pick a workflow',
|
|
26
|
+
'<%= config.bin %> fixture capture abc123 # pull latest real execution for a specific workflow ID',
|
|
27
|
+
];
|
|
28
|
+
async run() {
|
|
29
|
+
const { args } = await this.parse(Fixture);
|
|
30
|
+
if (args.action === 'init') {
|
|
31
|
+
await this.initFixture(args.workflowId);
|
|
32
|
+
}
|
|
33
|
+
else if (args.action === 'capture') {
|
|
34
|
+
await this.captureFixture(args.workflowId);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async initFixture(workflowId) {
|
|
38
|
+
if (!workflowId) {
|
|
39
|
+
this.log(theme.fail('Usage: n8m fixture init <workflowId>'));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const fixturesDir = path.join(process.cwd(), '.n8m', 'fixtures');
|
|
43
|
+
const fixturePath = path.join(fixturesDir, `${workflowId}.json`);
|
|
44
|
+
if (existsSync(fixturePath)) {
|
|
45
|
+
const { overwrite } = await inquirer.prompt([{
|
|
46
|
+
type: 'confirm',
|
|
47
|
+
name: 'overwrite',
|
|
48
|
+
message: `Fixture already exists at ${fixturePath}. Overwrite?`,
|
|
49
|
+
default: false,
|
|
50
|
+
}]);
|
|
51
|
+
if (!overwrite) {
|
|
52
|
+
this.log(theme.muted('Aborted.'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const schemaPath = '../../node_modules/n8m/dist/fixture-schema.json';
|
|
57
|
+
const capturedAt = new Date().toISOString();
|
|
58
|
+
const template = {
|
|
59
|
+
$schema: schemaPath,
|
|
60
|
+
version: '1.0',
|
|
61
|
+
capturedAt,
|
|
62
|
+
workflowId,
|
|
63
|
+
workflowName: 'My Workflow',
|
|
64
|
+
workflow: {
|
|
65
|
+
name: 'My Workflow',
|
|
66
|
+
nodes: [],
|
|
67
|
+
connections: {},
|
|
68
|
+
},
|
|
69
|
+
execution: {
|
|
70
|
+
status: 'success',
|
|
71
|
+
data: {
|
|
72
|
+
resultData: {
|
|
73
|
+
error: null,
|
|
74
|
+
runData: {
|
|
75
|
+
'Your Node Name': [
|
|
76
|
+
{ json: { key: 'value' } },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
await fs.mkdir(fixturesDir, { recursive: true });
|
|
84
|
+
await fs.writeFile(fixturePath, JSON.stringify(template, null, 2), 'utf-8');
|
|
85
|
+
this.log(theme.success(`Created ${fixturePath}`));
|
|
86
|
+
this.log('');
|
|
87
|
+
this.log(theme.muted(' Fill in each node\'s output under execution.data.resultData.runData.'));
|
|
88
|
+
this.log(theme.muted(' Keys must match exact node names in your workflow.'));
|
|
89
|
+
this.log('');
|
|
90
|
+
this.log(theme.muted(' To test with this fixture:'));
|
|
91
|
+
this.log(theme.muted(` n8m test --fixture ${fixturePath}`));
|
|
92
|
+
this.log(theme.muted(` Or n8m will auto-detect it when you run: n8m test (workflow ID: ${workflowId})`));
|
|
93
|
+
}
|
|
94
|
+
async captureFixture(workflowId) {
|
|
95
|
+
const config = await ConfigManager.load();
|
|
96
|
+
const n8nUrl = config.n8nUrl ?? process.env.N8N_API_URL;
|
|
97
|
+
const n8nKey = config.n8nKey ?? process.env.N8N_API_KEY;
|
|
98
|
+
if (!n8nUrl || !n8nKey) {
|
|
99
|
+
this.log(theme.fail('n8n instance not configured. Run: n8m config --n8n-url <url> --n8n-key <key>'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
|
|
103
|
+
// If no workflowId provided, show interactive picker (local + remote)
|
|
104
|
+
let resolvedId = workflowId;
|
|
105
|
+
let resolvedName;
|
|
106
|
+
if (!resolvedId) {
|
|
107
|
+
this.log(theme.info('Searching for local and remote workflows...'));
|
|
108
|
+
const localChoices = [];
|
|
109
|
+
const workflowsDir = path.join(process.cwd(), 'workflows');
|
|
110
|
+
const searchDirs = [workflowsDir, process.cwd()];
|
|
111
|
+
for (const dir of searchDirs) {
|
|
112
|
+
if (existsSync(dir)) {
|
|
113
|
+
const files = await fs.readdir(dir);
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
if (file.endsWith('.json')) {
|
|
116
|
+
try {
|
|
117
|
+
const raw = await fs.readFile(path.join(dir, file), 'utf-8');
|
|
118
|
+
const parsed = JSON.parse(raw);
|
|
119
|
+
if (parsed.id) {
|
|
120
|
+
localChoices.push({
|
|
121
|
+
name: `${theme.value('[LOCAL]')} ${parsed.name ?? file} (${parsed.id})`,
|
|
122
|
+
value: { type: 'local', id: parsed.id, name: parsed.name ?? file },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// skip unparseable files
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
let remoteChoices = [];
|
|
134
|
+
try {
|
|
135
|
+
const remoteWorkflows = await client.getWorkflows();
|
|
136
|
+
remoteChoices = remoteWorkflows
|
|
137
|
+
.filter((w) => !w.name.startsWith('[TEST'))
|
|
138
|
+
.map((w) => ({
|
|
139
|
+
name: `${theme.info('[n8n]')} ${w.name} (${w.id})${w.active ? ' [Active]' : ''}`,
|
|
140
|
+
value: { type: 'remote', id: w.id, name: w.name },
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
this.log(theme.warn(`Could not fetch remote workflows: ${e.message}`));
|
|
145
|
+
}
|
|
146
|
+
const choices = [
|
|
147
|
+
...(localChoices.length > 0 ? [new inquirer.Separator('--- Local Files ---'), ...localChoices] : []),
|
|
148
|
+
...(remoteChoices.length > 0 ? [new inquirer.Separator('--- n8n Instance ---'), ...remoteChoices] : []),
|
|
149
|
+
];
|
|
150
|
+
if (choices.length === 0) {
|
|
151
|
+
this.log(theme.fail('No workflows found locally or on your n8n instance.'));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const { selection } = await inquirer.prompt([{
|
|
155
|
+
type: 'select',
|
|
156
|
+
name: 'selection',
|
|
157
|
+
message: 'Select a workflow to capture:',
|
|
158
|
+
choices,
|
|
159
|
+
pageSize: 15,
|
|
160
|
+
}]);
|
|
161
|
+
resolvedId = selection.id;
|
|
162
|
+
resolvedName = selection.name;
|
|
163
|
+
}
|
|
164
|
+
if (!resolvedId)
|
|
165
|
+
return;
|
|
166
|
+
this.log(theme.agent(`Fetching workflow ${resolvedId} from n8n...`));
|
|
167
|
+
let workflow;
|
|
168
|
+
try {
|
|
169
|
+
workflow = await client.getWorkflow(resolvedId);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
this.log(theme.fail(`Could not fetch workflow: ${e.message}`));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
resolvedName = resolvedName ?? workflow.name ?? resolvedId;
|
|
176
|
+
this.log(theme.agent(`Fetching executions for workflow ${resolvedId}...`));
|
|
177
|
+
let executions;
|
|
178
|
+
try {
|
|
179
|
+
executions = await client.getWorkflowExecutions(resolvedId);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
this.log(theme.fail(`Could not fetch executions: ${e.message}`));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!executions?.length) {
|
|
186
|
+
this.log(theme.warn('No executions found for this workflow. Run it in n8n first, then capture.'));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const executionChoices = executions.map((ex) => {
|
|
190
|
+
const date = ex.startedAt ? new Date(ex.startedAt).toLocaleString() : 'unknown time';
|
|
191
|
+
const rawStatus = ex.status ?? (ex.finished === true ? 'success' : ex.finished === false ? 'running' : undefined);
|
|
192
|
+
const statusLabel = rawStatus === 'success' ? theme.success(rawStatus)
|
|
193
|
+
: rawStatus ? theme.fail(rawStatus)
|
|
194
|
+
: theme.muted('unknown');
|
|
195
|
+
return {
|
|
196
|
+
name: `#${ex.id} ${statusLabel} ${theme.muted(date)}`,
|
|
197
|
+
value: ex,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
const { selectedExecution } = await inquirer.prompt([{
|
|
201
|
+
type: 'select',
|
|
202
|
+
name: 'selectedExecution',
|
|
203
|
+
message: 'Select an execution to capture:',
|
|
204
|
+
choices: executionChoices,
|
|
205
|
+
pageSize: 15,
|
|
206
|
+
}]);
|
|
207
|
+
const latest = selectedExecution;
|
|
208
|
+
this.log(theme.muted(` Selected execution ${latest.id} (${latest.status}, ${latest.startedAt})`));
|
|
209
|
+
let fullExec;
|
|
210
|
+
try {
|
|
211
|
+
fullExec = await client.getExecution(latest.id);
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
this.log(theme.fail(`Could not fetch execution data: ${e.message}`));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const fixtureManager = new FixtureManager();
|
|
218
|
+
const fixturePath = path.join(process.cwd(), '.n8m', 'fixtures', `${resolvedId}.json`);
|
|
219
|
+
if (existsSync(fixturePath)) {
|
|
220
|
+
const { overwrite } = await inquirer.prompt([{
|
|
221
|
+
type: 'confirm',
|
|
222
|
+
name: 'overwrite',
|
|
223
|
+
message: `Fixture already exists for workflow ${resolvedId}. Overwrite?`,
|
|
224
|
+
default: true,
|
|
225
|
+
}]);
|
|
226
|
+
if (!overwrite) {
|
|
227
|
+
this.log(theme.muted('Aborted.'));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await fixtureManager.save({
|
|
232
|
+
version: '1.0',
|
|
233
|
+
capturedAt: new Date().toISOString(),
|
|
234
|
+
workflowId: resolvedId,
|
|
235
|
+
workflowName: resolvedName ?? resolvedId,
|
|
236
|
+
workflow,
|
|
237
|
+
execution: {
|
|
238
|
+
id: fullExec.id,
|
|
239
|
+
status: fullExec.status,
|
|
240
|
+
startedAt: fullExec.startedAt,
|
|
241
|
+
data: {
|
|
242
|
+
resultData: {
|
|
243
|
+
error: fullExec.data?.resultData?.error ?? null,
|
|
244
|
+
runData: fullExec.data?.resultData?.runData ?? {},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const nodeCount = Object.keys(fullExec.data?.resultData?.runData ?? {}).length;
|
|
250
|
+
this.log(theme.success(`Fixture saved to .n8m/fixtures/${resolvedId}.json`));
|
|
251
|
+
this.log(theme.muted(` Workflow: ${resolvedName}`));
|
|
252
|
+
this.log(theme.muted(` Execution: ${fullExec.status} · ${nodeCount} node(s) captured`));
|
|
253
|
+
this.log('');
|
|
254
|
+
this.log(theme.muted(' To test with this fixture:'));
|
|
255
|
+
this.log(theme.muted(` n8m test --fixture .n8m/fixtures/${resolvedId}.json`));
|
|
256
|
+
this.log(theme.muted(` Or just run: n8m test (auto-detected by workflow ID)`));
|
|
257
|
+
}
|
|
258
|
+
}
|
package/dist/commands/test.d.ts
CHANGED
|
@@ -10,19 +10,78 @@ export default class Test extends Command {
|
|
|
10
10
|
'no-brand': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
11
|
'validate-only': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
12
|
'ai-scenarios': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
fixture: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
14
|
};
|
|
14
15
|
run(): Promise<void>;
|
|
15
16
|
/**
|
|
16
17
|
* Extract clean error message from n8n API responses
|
|
17
18
|
*/
|
|
18
19
|
private cleanErrorMsg;
|
|
19
|
-
/**
|
|
20
|
-
* Normalize error messages to catch "similar" errors (masking IDs/numbers)
|
|
21
|
-
*/
|
|
22
|
-
private normalizeError;
|
|
23
20
|
private sanitizeWorkflow;
|
|
24
21
|
private stripShim;
|
|
25
22
|
private saveWorkflows;
|
|
26
23
|
private handlePostTestActions;
|
|
24
|
+
/**
|
|
25
|
+
* Test a workflow that already exists on the n8n instance, using its real credentials
|
|
26
|
+
* and configured triggers — no ephemeral copy, no credential stripping, no shim injection.
|
|
27
|
+
*/
|
|
28
|
+
private testRemoteWorkflowDirectly;
|
|
29
|
+
/**
|
|
30
|
+
* Scan a workflow's expressions to find all field names accessed via $json.body.FIELD.
|
|
31
|
+
* These become required keys in the test POST payload because n8n wraps the body
|
|
32
|
+
* automatically — a downstream expression $json.body.content needs {"content": ...} in the POST.
|
|
33
|
+
*/
|
|
34
|
+
private extractRequiredBodyFields;
|
|
35
|
+
/**
|
|
36
|
+
* Load a pre-defined test payload fixture for a specific workflow.
|
|
37
|
+
* Checks (in order): ./workflow-test-fixtures.json, ./workflows/test-fixtures.json,
|
|
38
|
+
* and the bundled src/resources/workflow-test-fixtures.json.
|
|
39
|
+
* Returns null if no matching fixture is found.
|
|
40
|
+
*/
|
|
41
|
+
private loadWorkflowFixture;
|
|
42
|
+
/**
|
|
43
|
+
* Load node-test-hints.json and build a context string describing what
|
|
44
|
+
* data format each node type in the workflow expects. Used to inform
|
|
45
|
+
* generateMockData so the AI sends correctly-shaped values (e.g. Block Kit
|
|
46
|
+
* JSON for a Slack blocksUi parameter instead of a plain string).
|
|
47
|
+
*/
|
|
48
|
+
private extractNodeTypeHints;
|
|
49
|
+
/**
|
|
50
|
+
* Find nodes that feed binary data into upload-type nodes.
|
|
51
|
+
* Returns node names whose outputs should be pinned with a test binary
|
|
52
|
+
* so downstream file-upload steps receive real content instead of empty buffers.
|
|
53
|
+
*/
|
|
54
|
+
private findBinarySourceNodes;
|
|
55
|
+
/**
|
|
56
|
+
* Detect webhook body fields that are used as image/file URLs by HTTP Request
|
|
57
|
+
* nodes that sit immediately upstream of binary-upload nodes.
|
|
58
|
+
*
|
|
59
|
+
* When n8n fetches a real image URL (supplied via the webhook payload) it gets
|
|
60
|
+
* actual binary bytes, which then flow into the upload step. Returning these
|
|
61
|
+
* field names lets the prompt instruct the AI to use a real hosted image URL
|
|
62
|
+
* (e.g. placehold.co) instead of a placeholder string — so the upload step
|
|
63
|
+
* receives real binary data without needing pinData API support.
|
|
64
|
+
*/
|
|
65
|
+
private findBinaryUrlFields;
|
|
66
|
+
/**
|
|
67
|
+
* Deep-scan all node parameter values and strip control characters
|
|
68
|
+
* (U+0000–U+001F, U+007F). Returns the sanitized nodes array and a flag
|
|
69
|
+
* indicating whether any changes were made.
|
|
70
|
+
*
|
|
71
|
+
* Control chars in node params (e.g. a literal newline inside a Slack
|
|
72
|
+
* blocksUi JSON string) are workflow configuration bugs — they cause n8n to
|
|
73
|
+
* throw "could not be parsed" at execution time regardless of the test payload.
|
|
74
|
+
*/
|
|
75
|
+
private sanitizeWorkflowNodeParams;
|
|
76
|
+
/**
|
|
77
|
+
* Strip control characters (U+0000–U+001F, except tab/LF/CR) from all
|
|
78
|
+
* string values in a generated mock payload. AI-generated Block Kit JSON
|
|
79
|
+
* and other rich-text fields sometimes contain raw control chars that cause
|
|
80
|
+
* n8n's parameter parser to throw "Bad control character in string literal".
|
|
81
|
+
*/
|
|
82
|
+
private sanitizeMockPayload;
|
|
27
83
|
private deployWorkflows;
|
|
84
|
+
private offerSaveFixture;
|
|
85
|
+
private testWithFixture;
|
|
86
|
+
private findPredecessorNode;
|
|
28
87
|
}
|